mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9857fe1711 | ||
|
|
b4331a9bf9 | ||
|
|
c08e9d6fa8 | ||
|
|
470e7fc3ba | ||
|
|
b3bd19cc8c |
@@ -441,6 +441,7 @@ jobs:
|
||||
cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING"
|
||||
echo "aab=$AAB" >> "$GITHUB_OUTPUT"
|
||||
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -450,11 +451,11 @@ jobs:
|
||||
${{ steps.artifact.outputs.mapping }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set. Play only accepts API
|
||||
# uploads after the first bundle was uploaded by hand in the Console, and
|
||||
# releases of an app that was never published must be drafts; switch
|
||||
# `status` to `completed` after the first production release.
|
||||
- name: Upload to Google Play (internal testing, draft)
|
||||
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API
|
||||
# uploads after the first bundle was uploaded by hand in the Console).
|
||||
# Every release goes out to the internal and closed (beta) testers right
|
||||
# away; closed testing may wait for Google's review first.
|
||||
- name: Upload to Google Play (internal and closed testing)
|
||||
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
@@ -462,5 +463,6 @@ jobs:
|
||||
packageName: org.bulwarkmail.mobile
|
||||
releaseFiles: ${{ steps.artifact.outputs.aab }}
|
||||
mappingFile: ${{ steps.artifact.outputs.mapping }}
|
||||
track: internal
|
||||
status: draft
|
||||
releaseName: ${{ steps.artifact.outputs.version }}
|
||||
tracks: internal,beta
|
||||
status: completed
|
||||
|
||||
@@ -228,9 +228,11 @@ The `play-bundle` job builds `bundlePlayRelease` for arm64-v8a, armeabi-v7a and
|
||||
x86_64 (Play splits the bundle per ABI), checks the signer, the package name and
|
||||
the permissions, and keeps the AAB and its R8 mapping as a workflow artifact.
|
||||
With the `PLAY_SERVICE_ACCOUNT_JSON` secret set (vault: "Google Play – service
|
||||
account bulwark-google-play-api") it also uploads the bundle to the internal
|
||||
testing track as a draft. Play only accepts API uploads once a first bundle has
|
||||
been uploaded by hand in the Play Console.
|
||||
account bulwark-google-play-api") it also uploads the bundle and rolls it out
|
||||
on the internal and closed (beta) testing tracks, so testers get every release
|
||||
without a step in the Play Console; closed testing may wait for Google's
|
||||
review. Play only accepts API uploads once a first bundle has been uploaded by
|
||||
hand in the Play Console.
|
||||
|
||||
The `github` flavor stays on the old Firebase project until the app registers
|
||||
its new FCM token with the relay when the token changes; moving it before that
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.84",
|
||||
"version": "0.1.86",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.84",
|
||||
"version": "0.1.86",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.84",
|
||||
"version": "0.1.86",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock('expo-secure-store', () => ({
|
||||
}));
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { JMAPClient, AuthenticationError, RateLimitError, parseHoldLimit } from '../jmap-client';
|
||||
import { JMAPClient, AuthenticationError, InvalidTokenError, RateLimitError, parseHoldLimit } from '../jmap-client';
|
||||
import type { JMAPSession } from '../types';
|
||||
|
||||
const MOCK_SESSION: JMAPSession = {
|
||||
@@ -113,8 +113,10 @@ describe('JMAPClient', () => {
|
||||
});
|
||||
|
||||
describe('connectWithToken', () => {
|
||||
const TOKEN_SESSION = { ...MOCK_SESSION, username: 'user@example.com' };
|
||||
|
||||
it('should use Bearer auth', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
|
||||
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
|
||||
|
||||
await client.connectWithToken('https://mail.example.com', 'my-token');
|
||||
|
||||
@@ -128,6 +130,70 @@ describe('JMAPClient', () => {
|
||||
);
|
||||
expect(client.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('names the account after the session and remembers the token', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
|
||||
|
||||
const { username, accountId } = await client.connectWithToken('https://mail.example.com/', 'my-token');
|
||||
|
||||
expect(username).toBe('user@example.com');
|
||||
expect(accountId).toBe('user@example.com@mail.example.com');
|
||||
const [key, stored] = vi.mocked(SecureStore.setItemAsync).mock.calls.at(-1)!;
|
||||
expect(key).toContain('user_example.com');
|
||||
expect(JSON.parse(stored)).toMatchObject({
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'user@example.com',
|
||||
accessToken: 'my-token',
|
||||
});
|
||||
expect(client.usesPastedToken).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a token the server rejects', async () => {
|
||||
global.fetch = mockFetch([{ status: 401 }]) as any;
|
||||
|
||||
await expect(client.connectWithToken('https://mail.example.com', 'bad')).rejects.toThrow(InvalidTokenError);
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a session that does not name the account', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
|
||||
|
||||
await expect(client.connectWithToken('https://mail.example.com', 'my-token')).rejects.toThrow('did not name the account');
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a download host the server runs on another HTTPS origin', async () => {
|
||||
global.fetch = mockFetch([{
|
||||
status: 200,
|
||||
json: {
|
||||
...TOKEN_SESSION,
|
||||
apiUrl: 'https://api.fastmail.com/jmap/api/',
|
||||
downloadUrl: 'https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}',
|
||||
uploadUrl: 'https://api.fastmail.com/jmap/upload/{accountId}/',
|
||||
},
|
||||
}]) as any;
|
||||
|
||||
const { session } = await client.connectWithToken('https://api.fastmail.com', 'my-token');
|
||||
|
||||
expect(session.downloadUrl).toBe('https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}');
|
||||
expect(session.uploadUrl).toBe('https://api.fastmail.com/jmap/upload/{accountId}/');
|
||||
});
|
||||
|
||||
it('still moves self-reported internal hosts onto the server address', async () => {
|
||||
global.fetch = mockFetch([{
|
||||
status: 200,
|
||||
json: {
|
||||
...TOKEN_SESSION,
|
||||
apiUrl: 'http://localhost:8080/jmap/',
|
||||
downloadUrl: 'http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}',
|
||||
},
|
||||
}]) as any;
|
||||
|
||||
const { session } = await client.connectWithToken('https://mail.example.com', 'my-token');
|
||||
|
||||
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
|
||||
expect(session.downloadUrl).toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request', () => {
|
||||
|
||||
+57
-6
@@ -177,6 +177,12 @@ export class JMAPClient {
|
||||
return !!this.credentials?.accessToken;
|
||||
}
|
||||
|
||||
// A pasted access token, as opposed to an OAuth bundle (which always names
|
||||
// the token endpoint it renews at).
|
||||
get usesPastedToken(): boolean {
|
||||
return !!this.credentials?.accessToken && !this.credentials.tokenEndpoint;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.session !== null && this._accountId !== null;
|
||||
}
|
||||
@@ -294,15 +300,42 @@ export class JMAPClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
|
||||
/**
|
||||
* Access-token login (an API token the user pasted, e.g. from Fastmail).
|
||||
* The token is the whole credential: it names the account, so the username
|
||||
* comes from the session, and with no refresh token it lasts until the
|
||||
* provider expires or revokes it.
|
||||
*/
|
||||
async connectWithToken(
|
||||
serverUrl: string,
|
||||
accessToken: string,
|
||||
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
|
||||
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
try {
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
} catch (err) {
|
||||
this.credentials = null;
|
||||
if (err instanceof AuthenticationError || (err instanceof Error && /^Session discovery failed: 403\b/.test(err.message))) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
this.firstTouchGate.reset();
|
||||
|
||||
return this.session;
|
||||
const username = this.session.username;
|
||||
if (!username) {
|
||||
this.reset();
|
||||
throw new Error('The server did not name the account');
|
||||
}
|
||||
this.credentials.username = username;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
|
||||
return { session: this.session, username, accountId };
|
||||
}
|
||||
|
||||
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
||||
@@ -612,16 +645,26 @@ export class JMAPClient {
|
||||
// URL polyfill mutates inputs (appends trailing slashes, normalises
|
||||
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
|
||||
// before the caller has a chance to substitute values into them.
|
||||
//
|
||||
// A URL on another HTTPS origin than the server's own apiUrl is one it
|
||||
// deliberately hosts elsewhere (Fastmail serves downloads from a separate
|
||||
// domain) and is kept; everything else moves onto the origin we connected to.
|
||||
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
|
||||
const serverOrigin = extractOrigin(serverUrl);
|
||||
const reportedOrigin = session.apiUrl ? extractOrigin(session.apiUrl) : null;
|
||||
const rewrite = (url: string | undefined): string | undefined =>
|
||||
rewriteSessionUrl(url, serverOrigin);
|
||||
const rewriteHosted = (url: string | undefined): string | undefined => {
|
||||
const origin = url ? extractOrigin(url) : null;
|
||||
const hostedElsewhere = !!origin && !!reportedOrigin && origin !== reportedOrigin && /^https:/i.test(origin);
|
||||
return hostedElsewhere ? url : rewrite(url);
|
||||
};
|
||||
return {
|
||||
...session,
|
||||
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
|
||||
downloadUrl: rewrite(session.downloadUrl) ?? session.downloadUrl,
|
||||
uploadUrl: rewrite(session.uploadUrl) ?? session.uploadUrl,
|
||||
eventSourceUrl: rewrite(session.eventSourceUrl) ?? session.eventSourceUrl,
|
||||
downloadUrl: rewriteHosted(session.downloadUrl) ?? session.downloadUrl,
|
||||
uploadUrl: rewriteHosted(session.uploadUrl) ?? session.uploadUrl,
|
||||
eventSourceUrl: rewriteHosted(session.eventSourceUrl) ?? session.eventSourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1283,6 +1326,14 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// The server refused a pasted access token (wrong, expired or revoked).
|
||||
export class InvalidTokenError extends Error {
|
||||
constructor() {
|
||||
super('Invalid access token');
|
||||
this.name = 'InvalidTokenError';
|
||||
}
|
||||
}
|
||||
|
||||
// The server wants a second factor before it will hand out a session.
|
||||
export class TotpRequiredError extends AuthenticationError {
|
||||
constructor() {
|
||||
|
||||
@@ -4,11 +4,12 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
/**
|
||||
* A Modal with its own safe-area provider. A Modal is a separate native
|
||||
* window, and a SafeAreaView inside a slide-up Modal measured no insets: the
|
||||
* view starts off screen when the slide begins, so on iPhones with a notch or
|
||||
* Dynamic Island the header (and its close button) ended up under the status
|
||||
* bar, where taps don't reach it. Use this for full-screen modals with a
|
||||
* SafeAreaView header.
|
||||
* window, and a SafeAreaView inside a Modal whose content slides in (a
|
||||
* slide-up Modal, or a drawer or sheet the app animates itself) measured no
|
||||
* insets: the view starts off screen. On iPhones with a notch or Dynamic
|
||||
* Island headers (and their close buttons) ended up under the status bar,
|
||||
* where taps don't reach them, and bottom rows on the home indicator. Use
|
||||
* this for any Modal with a SafeAreaView inside.
|
||||
*/
|
||||
export function SafeAreaModal({ children, ...props }: ModalProps) {
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,7 @@ import type { RootStackParamList } from '../navigation/types';
|
||||
import type { Mailbox } from '../api/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { confirmSignOut } from '../lib/sign-out-confirm';
|
||||
import { SafeAreaModal } from './SafeAreaModal';
|
||||
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
|
||||
import {
|
||||
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
|
||||
@@ -1240,8 +1241,11 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
);
|
||||
}
|
||||
|
||||
// Its own safe-area provider: the panel's SafeAreaView starts off screen
|
||||
// (it slides in), and without one it measured no insets on iOS, so the
|
||||
// account header sat under the status bar.
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -1268,7 +1272,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
onClose={() => setPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// A SafeAreaView inside a slide-up react-native Modal measured no insets on
|
||||
// iOS: the Modal is its own window and its content starts off screen, so on
|
||||
// iPhones with a notch or Dynamic Island the header and its close button sat
|
||||
// under the status bar, out of reach (attachment preview, QR scanner, paste
|
||||
// sign-in link, filter and event editors). Such modals use SafeAreaModal,
|
||||
// which gives the window its own SafeAreaProvider.
|
||||
// A SafeAreaView inside a react-native Modal whose content starts off screen
|
||||
// measured no insets on iOS: the Modal is its own window, and the view was
|
||||
// measured before it slid in. On iPhones with a notch or Dynamic Island the
|
||||
// header and its close button sat under the status bar, out of reach
|
||||
// (attachment preview, QR scanner, paste sign-in link, filter and event
|
||||
// editors), the drawers' account headers did too, and the bottom sheets'
|
||||
// last rows sat on the home indicator. Such modals use SafeAreaModal, which
|
||||
// gives the window its own SafeAreaProvider. Slid in means animationType
|
||||
// "slide", or "none" with the app's own slide animation (drawers, sheets).
|
||||
|
||||
const SRC = path.resolve(__dirname, '../..');
|
||||
|
||||
@@ -19,6 +22,15 @@ function sourceFiles(dir: string): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** Plain <Modal> elements: the opening tag and what is between the tags. */
|
||||
function modals(source: string): { tag: string; body: string }[] {
|
||||
return modalTags(source).map((tag) => {
|
||||
const start = source.indexOf(tag) + tag.length;
|
||||
const end = source.indexOf('</Modal>', start);
|
||||
return { tag, body: source.slice(start, end < 0 ? undefined : end) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Opening tags of plain <Modal> elements, attributes included. */
|
||||
function modalTags(source: string): string[] {
|
||||
const tags: string[] = [];
|
||||
@@ -40,18 +52,32 @@ function modalTags(source: string): string[] {
|
||||
return tags;
|
||||
}
|
||||
|
||||
describe('slide-up modals with a SafeAreaView', () => {
|
||||
it('use SafeAreaModal so their header clears the status bar', () => {
|
||||
describe('modals that slide in with a SafeAreaView', () => {
|
||||
it('use SafeAreaModal so their content clears the status bar and home indicator', () => {
|
||||
const offenders = sourceFiles(SRC)
|
||||
.filter((file) => {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
if (!source.includes('SafeAreaView')) return false;
|
||||
return modalTags(source).some((tag) => /animationType="slide"/.test(tag));
|
||||
return modals(source).some(({ tag, body }) =>
|
||||
/animationType="slide"/.test(tag)
|
||||
|| (/animationType="none"/.test(tag) && body.includes('<SafeAreaView')));
|
||||
})
|
||||
.map((file) => path.relative(SRC, file));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
// The mail drawer builds its panel (with the SafeAreaView) outside the
|
||||
// modal's tags, so the check above can't see it there.
|
||||
it('include the side drawers', () => {
|
||||
const drawers = sourceFiles(SRC).filter((file) => /SidebarDrawer\.tsx$/.test(file));
|
||||
expect(drawers.length).toBeGreaterThanOrEqual(3);
|
||||
for (const file of drawers) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
expect(modalTags(source).filter((tag) => /animationType="none"/.test(tag)), path.relative(SRC, file)).toEqual([]);
|
||||
expect(source).toContain('<SafeAreaModal');
|
||||
}
|
||||
});
|
||||
|
||||
it('finds the tags it is looking for', () => {
|
||||
const tags = modalTags('<View><Modal visible={x} onRequestClose={() => close()} animationType="slide">\n</Modal></View>');
|
||||
expect(tags).toHaveLength(1);
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -24,6 +23,7 @@ import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
/** A calendar view listed at the top of the drawer. */
|
||||
export interface DrawerViewOption {
|
||||
@@ -157,7 +157,7 @@ export function CalendarSidebarDrawer({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -271,7 +271,7 @@ export function CalendarSidebarDrawer({
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -63,6 +62,7 @@ import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrenc
|
||||
import type { TranslateFn } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
|
||||
@@ -248,7 +248,7 @@ export function EventDetailSheet({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -464,7 +464,7 @@ export function EventDetailSheet({
|
||||
{/* The screen's own toast host sits under this Modal; this one shows
|
||||
the copy confirmation above the sheet. */}
|
||||
<ToastHost />
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from '../icons';
|
||||
@@ -19,6 +19,7 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import type { Locale } from 'date-fns';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
||||
@@ -188,7 +189,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -378,7 +379,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator, Alert,
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, TextInput, ActivityIndicator, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
// The webmail's refresh intervals and their labels.
|
||||
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
|
||||
@@ -135,7 +136,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -313,7 +314,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
<View style={{ height: insets.bottom }} />
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -45,6 +44,7 @@ import { formatCalendarDate } from '../../lib/date-format';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||
|
||||
@@ -235,7 +235,7 @@ export function TasksSheet({
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -480,7 +480,7 @@ export function TasksSheet({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
@@ -15,6 +15,7 @@ import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||
// linked into every build (matches the FilesScreen upload flow).
|
||||
@@ -164,7 +165,7 @@ export default function ContactImportSheet({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -304,7 +305,7 @@ export default function ContactImportSheet({
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, Modal, TextInput, FlatList,
|
||||
View, Text, StyleSheet, Pressable, TextInput, FlatList,
|
||||
Animated, Dimensions, Easing,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -18,6 +18,7 @@ import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -98,7 +99,7 @@ export default function ContactPickerSheet({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -179,7 +180,7 @@ export default function ContactPickerSheet({
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing, TextInput, Alert,
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Animated, Dimensions, Easing, TextInput, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -19,6 +19,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
@@ -162,7 +163,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -310,7 +311,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,9 @@ export function AccountSettings() {
|
||||
const username = account?.username || authUsername || undefined;
|
||||
const serverUrl = account?.serverUrl || authServerUrl || '';
|
||||
// Live client state, not a prop: OAuth/handoff accounts carry a bearer token.
|
||||
const authMode: 'oauth' | 'basic' = jmapClient.usesBearerAuth ? 'oauth' : 'basic';
|
||||
const authMode: 'oauth' | 'token' | 'basic' = jmapClient.usesPastedToken
|
||||
? 'token'
|
||||
: jmapClient.usesBearerAuth ? 'oauth' : 'basic';
|
||||
|
||||
const [quota, setQuota] = useState<MailQuota | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -128,7 +130,9 @@ export function AccountSettings() {
|
||||
<Text style={styles.value}>
|
||||
{authMode === 'oauth'
|
||||
? t('settings.account.auth_method_oauth', 'Single Sign-On (OAuth/OIDC)')
|
||||
: t('settings.account.auth_method_basic', 'Password')}
|
||||
: authMode === 'token'
|
||||
? t('settings.account.auth_method_token', 'Access token')
|
||||
: t('settings.account.auth_method_basic', 'Password')}
|
||||
</Text>
|
||||
</SettingItem>
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ describe('describeLoginError', () => {
|
||||
expect(copy.detail).toMatch(/app password/i);
|
||||
});
|
||||
|
||||
it('blames a refused access token on the token, not on a password', () => {
|
||||
for (const err of [named('InvalidTokenError', 'Invalid access token'), 'Invalid access token']) {
|
||||
const copy = describeLoginError(err);
|
||||
expect(copy.detail).toMatch(/access token/);
|
||||
expect(copy.detail).not.toMatch(/password/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('explains the auth store\'s "Session expired" notice', () => {
|
||||
expect(describeLoginError('Session expired').title).toBe('Your session has expired. Please sign in again.');
|
||||
expect(describeLoginError('Session expired for this account').title).toBe('Your session has expired. Please sign in again.');
|
||||
|
||||
@@ -151,6 +151,13 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'InvalidTokenError' || lower.includes('invalid access token')) {
|
||||
return {
|
||||
title: t('login.mobile.err_bad_title', "That didn't work"),
|
||||
detail: t('login.error.invalid_token', 'The server did not accept this access token. Check that it is complete and has not expired or been revoked.'),
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
|
||||
return {
|
||||
title: t('login.mobile.err_bad_title', "That didn't work"),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Linking, Alert, Image, Share,
|
||||
Animated, Dimensions, Easing, Modal,
|
||||
} from 'react-native';
|
||||
Animated, Dimensions, Easing, } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -35,6 +34,7 @@ import { useColors } from '../theme/colors';
|
||||
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../lib/use-layout';
|
||||
import { SafeAreaModal } from '../components/SafeAreaModal';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
|
||||
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
|
||||
@@ -814,7 +814,7 @@ function MoreActionsSheet({
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -846,7 +846,7 @@ function MoreActionsSheet({
|
||||
})}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function signInLinkConfirmation(
|
||||
*/
|
||||
export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: LoginScreenProps) {
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const loginWithToken = useAuthStore((state) => state.loginWithToken);
|
||||
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
|
||||
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
|
||||
const clearError = useAuthStore((state) => state.clearError);
|
||||
@@ -102,6 +103,10 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
// common no-2FA sign-in stays a two-field form.
|
||||
const [totp, setTotp] = React.useState('');
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
// Access-token (Bearer) sign-in in place of username and password, for
|
||||
// providers that hand out API tokens (Fastmail accepts nothing else).
|
||||
const [tokenMode, setTokenMode] = React.useState(false);
|
||||
const [accessToken, setAccessToken] = React.useState('');
|
||||
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
|
||||
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
|
||||
const [searching, setSearching] = React.useState(false);
|
||||
@@ -261,6 +266,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
setPassword(pending.password);
|
||||
setServerUrl(pending.serverUrl);
|
||||
setTotpRequired(true);
|
||||
setTokenMode(false);
|
||||
setBusy(null);
|
||||
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
||||
setHistory((prev) => [...prev, step]);
|
||||
@@ -340,6 +346,26 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
setNotice({ title: t('login.mobile.notice_need_server', 'We need a server address first') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (tokenMode) {
|
||||
if (!accessToken.trim()) {
|
||||
setNotice({ title: t('login.token_placeholder', 'Paste your API token') });
|
||||
return;
|
||||
}
|
||||
setNotice(null);
|
||||
setBusy('connecting');
|
||||
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||
try {
|
||||
await loginWithToken(target, accessToken, { addAccount: isAddMode });
|
||||
finishIfSignedIn(wasAuthenticated);
|
||||
} catch (err) {
|
||||
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.trim() || !password) {
|
||||
setNotice({ title: t('login.mobile.notice_need_credentials', 'Enter your email and password') });
|
||||
return;
|
||||
@@ -369,7 +395,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired, t]);
|
||||
}, [accessToken, email, finishIfSignedIn, isAddMode, login, loginWithToken, password, serverInput, serverUrl, tokenMode, totp, totpRequired, t]);
|
||||
|
||||
// A sign-in code, however it arrived: scanned, pasted, or a tapped
|
||||
// `bulwarkmail://` link.
|
||||
@@ -517,7 +543,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
<SigningInStep
|
||||
phase={busy}
|
||||
serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl}
|
||||
email={pairing ? null : email.trim() || null}
|
||||
email={pairing || tokenMode ? null : email.trim() || null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -655,6 +681,17 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
}}
|
||||
onSubmit={() => void handlePasswordSubmit()}
|
||||
notice={notice}
|
||||
tokenMode={tokenMode}
|
||||
token={accessToken}
|
||||
onChangeToken={(value) => {
|
||||
setNotice(null);
|
||||
setAccessToken(value);
|
||||
}}
|
||||
onToggleTokenMode={() => {
|
||||
setNotice(null);
|
||||
clearError();
|
||||
setTokenMode((v) => !v);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</LoginShell>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, type TextInput } from 'react-native';
|
||||
import { Eye, EyeOff } from '../../components/icons';
|
||||
import { Eye, EyeOff, KeyRound } from '../../components/icons';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Button, Input } from '../../components';
|
||||
@@ -19,6 +19,11 @@ interface PasswordStepProps {
|
||||
onChangeTotp?: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
notice?: { title: string; detail?: string } | null;
|
||||
/** Access-token sign-in in place of username and password. */
|
||||
tokenMode?: boolean;
|
||||
token?: string;
|
||||
onChangeToken?: (value: string) => void;
|
||||
onToggleTokenMode?: () => void;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
@@ -38,6 +43,10 @@ export default function PasswordStep({
|
||||
onChangeTotp,
|
||||
onSubmit,
|
||||
notice,
|
||||
tokenMode = false,
|
||||
token = '',
|
||||
onChangeToken,
|
||||
onToggleTokenMode,
|
||||
}: PasswordStepProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -52,74 +61,107 @@ export default function PasswordStep({
|
||||
<View style={styles.heading}>
|
||||
<Text style={styles.title}>{t('login.mobile.password_title', 'Sign in to {host}', { host: hostOf(serverUrl) })}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{t('login.mobile.password_subtitle', 'Your password is sent to this server only, and stored in the device keychain.')}
|
||||
{tokenMode
|
||||
? t('login.token_hint', "Create an API token in your mail provider's settings and paste it here.")
|
||||
: t('login.mobile.password_subtitle', 'Your password is sent to this server only, and stored in the device keychain.')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
label={t('login.mobile.username_label', 'Email or username')}
|
||||
placeholder={t('login.username_placeholder', 'user@example.com')}
|
||||
value={email}
|
||||
onChangeText={onChangeEmail}
|
||||
testID="login-username-input"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
textContentType="username"
|
||||
keyboardType="email-address"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('login.password_label', 'Password')}
|
||||
placeholder={t('login.password_placeholder', 'Enter your password')}
|
||||
value={password}
|
||||
onChangeText={onChangePassword}
|
||||
testID="login-password-input"
|
||||
autoFocus={Boolean(email)}
|
||||
secureTextEntry={!showPassword}
|
||||
autoComplete="current-password"
|
||||
textContentType="password"
|
||||
ref={passwordRef}
|
||||
returnKeyType={totpRequired ? 'next' : 'go'}
|
||||
blurOnSubmit={!totpRequired}
|
||||
onSubmitEditing={totpRequired ? () => totpRef.current?.focus() : onSubmit}
|
||||
rightIcon={
|
||||
<Pressable
|
||||
onPress={() => setShowPassword((v) => !v)}
|
||||
hitSlop={14}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
{totpRequired ? (
|
||||
{tokenMode ? (
|
||||
<Input
|
||||
label={t('login.totp_label', 'Two-factor code')}
|
||||
placeholder="123456"
|
||||
value={totp}
|
||||
ref={totpRef}
|
||||
onChangeText={(value) => onChangeTotp?.(value)}
|
||||
label={t('login.token_label', 'Access token')}
|
||||
placeholder={t('login.token_placeholder', 'Paste your API token')}
|
||||
value={token}
|
||||
onChangeText={(value) => onChangeToken?.(value)}
|
||||
testID="login-token-input"
|
||||
autoFocus
|
||||
keyboardType="number-pad"
|
||||
autoComplete="one-time-code"
|
||||
textContentType="oneTimeCode"
|
||||
maxLength={8}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{tokenMode ? null : (
|
||||
<>
|
||||
<Input
|
||||
label={t('login.mobile.username_label', 'Email or username')}
|
||||
placeholder={t('login.username_placeholder', 'user@example.com')}
|
||||
value={email}
|
||||
onChangeText={onChangeEmail}
|
||||
testID="login-username-input"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
textContentType="username"
|
||||
keyboardType="email-address"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('login.password_label', 'Password')}
|
||||
placeholder={t('login.password_placeholder', 'Enter your password')}
|
||||
value={password}
|
||||
onChangeText={onChangePassword}
|
||||
testID="login-password-input"
|
||||
autoFocus={Boolean(email)}
|
||||
secureTextEntry={!showPassword}
|
||||
autoComplete="current-password"
|
||||
textContentType="password"
|
||||
ref={passwordRef}
|
||||
returnKeyType={totpRequired ? 'next' : 'go'}
|
||||
blurOnSubmit={!totpRequired}
|
||||
onSubmitEditing={totpRequired ? () => totpRef.current?.focus() : onSubmit}
|
||||
rightIcon={
|
||||
<Pressable
|
||||
onPress={() => setShowPassword((v) => !v)}
|
||||
hitSlop={14}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
{totpRequired ? (
|
||||
<Input
|
||||
label={t('login.totp_label', 'Two-factor code')}
|
||||
placeholder="123456"
|
||||
value={totp}
|
||||
ref={totpRef}
|
||||
onChangeText={(value) => onChangeTotp?.(value)}
|
||||
autoFocus
|
||||
keyboardType="number-pad"
|
||||
autoComplete="one-time-code"
|
||||
textContentType="oneTimeCode"
|
||||
maxLength={8}
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
|
||||
|
||||
<Button variant="default" size="md" onPress={onSubmit}>
|
||||
{t('login.sign_in', 'Sign in')}
|
||||
</Button>
|
||||
|
||||
{onToggleTokenMode ? (
|
||||
<Pressable onPress={onToggleTokenMode} hitSlop={8} style={styles.toggle} accessibilityRole="button" testID="login-token-toggle">
|
||||
<KeyRound size={16} color={c.textLink} />
|
||||
<Text style={styles.link}>
|
||||
{tokenMode ? t('login.password_toggle', 'Sign in with a password') : t('login.token_toggle', 'Sign in with an access token')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -130,5 +172,7 @@ function makeStyles(c: ThemePalette) {
|
||||
heading: { gap: spacing.sm },
|
||||
title: { ...typography.h1, color: c.text },
|
||||
subtitle: { ...typography.body, color: c.textSecondary },
|
||||
toggle: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.xs, paddingVertical: spacing.xs },
|
||||
link: { ...typography.body, color: c.textLink },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Access-token sign-in: the pasted token is cleaned up before it is sent, the
|
||||
// account is named after the session, and a refused token leaves nothing
|
||||
// registered.
|
||||
|
||||
const { InvalidTokenError, SESSION, connectWithToken } = vi.hoisted(() => {
|
||||
class InvalidTokenError extends Error {
|
||||
constructor() {
|
||||
super('Invalid access token');
|
||||
this.name = 'InvalidTokenError';
|
||||
}
|
||||
}
|
||||
const SESSION = { apiUrl: 'https://api.fastmail.com/jmap/api/', username: 'ada@fastmail.com' };
|
||||
const connectWithToken = vi.fn(async (_serverUrl: string, _token: string) => ({
|
||||
session: SESSION,
|
||||
username: 'ada@fastmail.com',
|
||||
accountId: 'ada@fastmail.com@api.fastmail.com',
|
||||
}));
|
||||
return { InvalidTokenError, SESSION, connectWithToken };
|
||||
});
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
connectWithToken: (serverUrl: string, token: string) => connectWithToken(serverUrl, token),
|
||||
snapshot: vi.fn(() => ({ session: null, credentials: null, accountId: null })),
|
||||
restoreSnapshot: vi.fn(),
|
||||
onAuthFailure: vi.fn(() => () => undefined),
|
||||
onTokenRefresh: vi.fn(() => () => undefined),
|
||||
hasAccountCapability: vi.fn(() => false),
|
||||
getAccountName: () => undefined,
|
||||
getSharedMailAccounts: () => [],
|
||||
request: vi.fn(async () => ({ methodResponses: [] })),
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
currentSession: SESSION,
|
||||
username: 'ada@fastmail.com',
|
||||
serverUrl: 'https://api.fastmail.com',
|
||||
},
|
||||
AuthenticationError: class AuthenticationError extends Error {},
|
||||
NetworkError: class NetworkError extends Error {},
|
||||
InvalidTokenError,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/push-notifications', () => ({
|
||||
teardownPushNotifications: vi.fn(async () => undefined),
|
||||
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { useAccountStore } from '../account-store';
|
||||
|
||||
beforeEach(() => {
|
||||
connectWithToken.mockClear();
|
||||
useAccountStore.setState({ accounts: [], activeAccountId: null });
|
||||
useAuthStore.setState({ isAuthenticated: false, error: null, activeAccountId: null });
|
||||
});
|
||||
|
||||
describe('loginWithToken', () => {
|
||||
it('sends the bare token and registers the account the session names', async () => {
|
||||
await useAuthStore.getState().loginWithToken('https://api.fastmail.com/', ' Bearer fmu1-abc ');
|
||||
|
||||
expect(connectWithToken).toHaveBeenCalledWith('https://api.fastmail.com', 'fmu1-abc');
|
||||
const account = useAccountStore.getState().getActiveAccount();
|
||||
expect(account?.id).toBe('ada@fastmail.com@api.fastmail.com');
|
||||
expect(account?.serverUrl).toBe('https://api.fastmail.com');
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
isAuthenticated: true,
|
||||
username: 'ada@fastmail.com',
|
||||
activeAccountId: 'ada@fastmail.com@api.fastmail.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a token with spaces inside without asking the server', async () => {
|
||||
await expect(useAuthStore.getState().loginWithToken('https://api.fastmail.com', 'fmu1 abc'))
|
||||
.rejects.toThrow(InvalidTokenError);
|
||||
expect(connectWithToken).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().error).toBe('Invalid access token');
|
||||
});
|
||||
|
||||
it('registers nothing when the server refuses the token', async () => {
|
||||
connectWithToken.mockRejectedValueOnce(new InvalidTokenError());
|
||||
|
||||
await expect(useAuthStore.getState().loginWithToken('https://api.fastmail.com', 'fmu1-expired'))
|
||||
.rejects.toThrow(InvalidTokenError);
|
||||
expect(useAccountStore.getState().accounts).toHaveLength(0);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { jmapClient, AuthenticationError, NetworkError, type ClientSnapshot } from '../api/jmap-client';
|
||||
import { jmapClient, AuthenticationError, InvalidTokenError, NetworkError, type ClientSnapshot } from '../api/jmap-client';
|
||||
import type { JMAPSession } from '../api/types';
|
||||
import { fetchAccountDisplayName, isStalwartSupported } from '../api/account-security';
|
||||
import { useAccountStore } from './account-store';
|
||||
@@ -86,6 +86,8 @@ export interface AuthState {
|
||||
password: string,
|
||||
opts?: { addAccount?: boolean; totp?: string },
|
||||
) => Promise<void>;
|
||||
/** Access-token (Bearer) sign-in with a token the user pasted, e.g. a Fastmail API token. */
|
||||
loginWithToken: (serverUrl: string, token: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
/** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */
|
||||
loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
@@ -255,11 +257,24 @@ async function syncAccountDisplayName(accountId: string): Promise<void> {
|
||||
// pairing both end here). Bootstraps a JMAP session from the token bundle,
|
||||
// registers the account, and flips the store to connected. Throws on failure
|
||||
// so the caller can surface a flow-specific error.
|
||||
async function completeOAuthHandoff(
|
||||
function completeOAuthHandoff(
|
||||
set: (partial: Partial<AuthState>) => void,
|
||||
get: () => AuthState,
|
||||
result: Extract<HandoffResult, { flow: 'oauth' }>,
|
||||
opts?: { addAccount?: boolean },
|
||||
): Promise<void> {
|
||||
return completeBearerSignIn(set, get, result.serverUrl, () => jmapClient.connectWithOAuth(result.serverUrl, result.tokens), opts);
|
||||
}
|
||||
|
||||
// Connects with `connect` (an OAuth bundle or a pasted access token, where the
|
||||
// server names the account), registers the account, and flips the store to
|
||||
// connected.
|
||||
async function completeBearerSignIn(
|
||||
set: (partial: Partial<AuthState>) => void,
|
||||
get: () => AuthState,
|
||||
serverUrl: string,
|
||||
connect: () => Promise<{ session: JMAPSession; username: string; accountId: string }>,
|
||||
opts?: { addAccount?: boolean },
|
||||
): Promise<void> {
|
||||
// Adding an account must not destroy the live one: the singleton keeps the
|
||||
// previous connection until the new sign-in has actually succeeded, and a
|
||||
@@ -268,7 +283,7 @@ async function completeOAuthHandoff(
|
||||
|
||||
let connected: { session: JMAPSession; username: string; accountId: string };
|
||||
try {
|
||||
connected = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens);
|
||||
connected = await connect();
|
||||
} catch (err) {
|
||||
if (previous) jmapClient.restoreSnapshot(previous);
|
||||
throw err;
|
||||
@@ -279,7 +294,7 @@ async function completeOAuthHandoff(
|
||||
const wasRegistered = !!accountStore.getAccountById(accountId);
|
||||
try {
|
||||
accountStore.addAccount({
|
||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||
serverUrl: serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
@@ -300,7 +315,7 @@ async function completeOAuthHandoff(
|
||||
accountStore.setActiveAccount(accountId);
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
// Start on the folder list now rather than once the mail screen has
|
||||
// mounted; the screen joins this load.
|
||||
void useEmailStore.getState().fetchMailboxes();
|
||||
@@ -414,6 +429,21 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginWithToken: async (serverUrl, typedToken, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
// Pasted tokens often come with the scheme or surrounding whitespace.
|
||||
const token = typedToken.trim().replace(/^Bearer\s+/i, '');
|
||||
const base = serverUrl.replace(/\/+$/, '');
|
||||
try {
|
||||
if (!token || /\s/.test(token)) throw new InvalidTokenError();
|
||||
await completeBearerSignIn(set, get, base, () => jmapClient.connectWithToken(base, token), opts);
|
||||
set({ pendingTotpLogin: null });
|
||||
} catch (err) {
|
||||
set({ isLoading: false, error: err instanceof Error ? err.message : 'Connection failed' });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
loginViaWebmail: async (webmailUrl, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
refuseAddWhenFull(set, opts);
|
||||
|
||||
Reference in New Issue
Block a user