From ac95a2bc39237bf266b93eadf2daa4b06ce363c3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:25:00 +0200 Subject: [PATCH] feat: QR code sign-in --- android/app/src/main/AndroidManifest.xml | 7 ++ app.config.js | 13 ++- package-lock.json | 21 ++++ package.json | 1 + src/components/QrScanModal.tsx | 124 +++++++++++++++++++++++ src/lib/oauth.ts | 84 +++++++++++++++ src/screens/EmailThreadScreen.tsx | 59 +++++++++-- src/screens/LoginScreen.tsx | 53 +++++++++- src/stores/auth-store.ts | 98 +++++++++++++----- 9 files changed, 423 insertions(+), 37 deletions(-) create mode 100644 src/components/QrScanModal.tsx diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9c42ac7f..30262e95 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + @@ -21,6 +22,12 @@ + + + + + + diff --git a/app.config.js b/app.config.js index b879604e..63f70d13 100644 --- a/app.config.js +++ b/app.config.js @@ -46,7 +46,18 @@ module.exports = { web: { favicon: './assets/favicon.png', }, - plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'], + plugins: [ + 'expo-secure-store', + '@react-native-community/datetimepicker', + 'expo-localization', + [ + 'expo-camera', + { + cameraPermission: + 'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.', + }, + ], + ], extra: { commit: COMMIT, }, diff --git a/package-lock.json b/package-lock.json index a5bbda06..ea3ee4a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@react-navigation/native-stack": "^7.14.10", "date-fns": "^4.1.0", "expo": "~54.0.33", + "expo-camera": "~17.0.10", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", "expo-image-picker": "~17.0.10", @@ -5211,6 +5212,26 @@ } } }, + "node_modules/expo-camera": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", + "integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", diff --git a/package.json b/package.json index 6d7b3505..8bf3971d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@react-navigation/native-stack": "^7.14.10", "date-fns": "^4.1.0", "expo": "~54.0.33", + "expo-camera": "~17.0.10", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", "expo-image-picker": "~17.0.10", diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx new file mode 100644 index 00000000..2f227406 --- /dev/null +++ b/src/components/QrScanModal.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera'; +import { X } from 'lucide-react-native'; +import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; +import { useColors } from '../theme/colors'; +import Button from './Button'; + +interface QrScanModalProps { + visible: boolean; + onClose: () => void; + // Fires once with the raw decoded QR string. The parent is responsible for + // closing the modal and interpreting the payload. + onScanned: (data: string) => void; +} + +export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const [permission, requestPermission] = useCameraPermissions(); + // Guards against the camera firing onBarcodeScanned dozens of times for the + // same code before the modal tears down. + const handled = React.useRef(false); + + React.useEffect(() => { + if (visible) handled.current = false; + }, [visible]); + + const handleBarcode = React.useCallback( + (result: BarcodeScanningResult) => { + if (handled.current) return; + handled.current = true; + onScanned(result.data); + }, + [onScanned], + ); + + return ( + + + {permission?.granted ? ( + + ) : null} + + + + + + + Scan QR code + + + + {permission?.granted ? ( + + + + Point your camera at the QR code shown on the webmail settings screen. + + + ) : ( + + + {permission && !permission.canAskAgain + ? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.' + : 'Bulwark Mail needs camera access to scan a sign-in QR code.'} + + + + )} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: '#000000' }, + overlay: { flex: 1, justifyContent: 'flex-start' }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm, + paddingBottom: spacing.md, + }, + closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + title: { ...typography.h3, color: '#ffffff' }, + frameWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg }, + frame: { + width: 240, + height: 240, + borderWidth: 3, + borderColor: '#ffffff', + borderRadius: radius.lg, + backgroundColor: 'transparent', + }, + hint: { + ...typography.caption, + color: '#ffffff', + textAlign: 'center', + paddingHorizontal: spacing.xxl, + }, + permissionWrap: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.lg, + paddingHorizontal: spacing.xxl, + backgroundColor: c.background, + }, + permissionText: { ...typography.body, color: c.text, textAlign: 'center' }, + }); +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 48d153b8..015027be 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -123,6 +123,90 @@ export async function runWebmailHandoff(webmailUrl: string): Promise { + const base = webmailUrl.replace(/\/+$/, ''); + let response: Response; + try { + response = await secureFetch(`${base}/api/auth/pair/redeem`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ pairing_code: code }), + }); + } catch { + throw new HandoffError('Could not reach the server to complete pairing'); + } + + if (!response.ok) { + // The code was unknown, already used, or expired (server returns 400) — + // or something else went wrong. Either way the user needs a fresh QR. + throw new HandoffError('Pairing code is invalid or has expired'); + } + + const data = (await response.json()) as { + server_url?: string; + access_token?: string; + refresh_token?: string; + expires_in?: number; + token_endpoint?: string; + client_id?: string; + }; + + if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) { + throw new HandoffError('Pairing response missing token material'); + } + + return { + flow: 'oauth', + serverUrl: data.server_url, + tokens: { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? undefined, + expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, + tokenEndpoint: data.token_endpoint, + clientId: data.client_id, + }, + }; +} + // OAuth refresh — exchanges the refresh token at the original token endpoint // for a new access token. Returns the updated bundle so the caller can // persist it. diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 266dba80..98fe2021 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -82,6 +82,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) { const [email, setEmail] = React.useState(null); const [loading, setLoading] = React.useState(true); + // True only while a Prev/Next/swipe transition is waiting on an + // not-yet-prefetched neighbour. Drives a lightweight overlay spinner so the + // brief gap shows feedback instead of a blank pane — without unmounting the + // (still-animating) slide pane the way the full-screen `loading` state would. + const [switching, setSwitching] = React.useState(false); const [error, setError] = React.useState(null); const [moreMenuOpen, setMoreMenuOpen] = React.useState(false); const [moveMenuOpen, setMoveMenuOpen] = React.useState(false); @@ -114,11 +119,21 @@ export default function EmailThreadScreen({ route, navigation }: Props) { animatingRef.current = true; const w = windowWidthRef.current; const out = direction === 'next' ? -w : w; - // Slide the current message the rest of the way out, swap content, then - // slide the new message in from the opposite edge. - Animated.timing(translateX, { - toValue: out, duration: 170, easing: Easing.out(Easing.cubic), useNativeDriver: true, - }).start(() => { + + // Make sure the target's detail is in the cache *before* we swap it into + // view. That way the load effect takes its no-spinner cached path and the + // full-screen `loading` state never fires — which is what unmounted the + // slide pane mid-animation and produced the blank-then-jump flash. + // Neighbours are usually prefetched, so this resolves immediately. + const ready: Promise = detailCache.has(target.id) + ? Promise.resolve() + : getEmailDetail(target.id, jmapAccountId) + .then((e) => { detailCache.set(target.id, e); }) + .catch(() => { /* fall back to the load effect's own fetch */ }); + + // Swap content + slide the new message in from the opposite edge. + const swapAndSlideIn = () => { + setSwitching(false); setActiveEmailId(target.id); setActiveSubject(target.subject ?? ''); scrollRef.current?.scrollTo({ y: 0, animated: false }); @@ -126,8 +141,22 @@ export default function EmailThreadScreen({ route, navigation }: Props) { Animated.timing(translateX, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true, }).start(() => { animatingRef.current = false; }); + }; + + // Slide the current message the rest of the way out, then swap once the + // target is ready. If it isn't cached yet, show the overlay spinner during + // the (usually brief) wait rather than leaving an empty pane on screen. + Animated.timing(translateX, { + toValue: out, duration: 170, easing: Easing.out(Easing.cubic), useNativeDriver: true, + }).start(() => { + if (detailCache.has(target.id)) { + swapAndSlideIn(); + } else { + setSwitching(true); + void ready.then(swapAndSlideIn); + } }); - }, [translateX]); + }, [translateX, detailCache, getEmailDetail, jmapAccountId]); const switchEmailRef = React.useRef(switchEmail); switchEmailRef.current = switchEmail; @@ -579,11 +608,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) { - {loading ? ( + {loading && !email ? ( - ) : error ? ( + ) : error && !email ? ( {error} @@ -657,6 +686,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) { + {/* Transient spinner while a switch waits on an un-prefetched + neighbour — sits above the (slid-out) pane, not in place of it. */} + {switching && ( + + + + )} + {/* Bottom action bar */} makeStyles(c), [c]); const login = useAuthStore((state) => state.login); const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail); + const loginViaPairing = useAuthStore((state) => state.loginViaPairing); const isLoading = useAuthStore((state) => state.isLoading); const error = useAuthStore((state) => state.error); const clearError = useAuthStore((state) => state.clearError); @@ -29,6 +32,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree const [email, setEmail] = React.useState(''); const [password, setPassword] = React.useState(''); const [showPassword, setShowPassword] = React.useState(false); + const [scannerVisible, setScannerVisible] = React.useState(false); const canSubmit = Boolean(serverUrl.trim() && email.trim() && password); const canHandoff = Boolean(serverUrl.trim()); @@ -65,6 +69,34 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree } }; + const handleScanned = async (data: string) => { + setScannerVisible(false); + const payload = parseQrLoginPayload(data); + if (!payload) { + Alert.alert('Unrecognized QR code', "That QR code isn't a Bulwark Mail sign-in code."); + return; + } + try { + const before = useAuthStore.getState().isAuthenticated; + if (payload.kind === 'connect') { + // Server-bootstrap QR: fill the field and run the normal browser + // handoff so the user still authenticates in the webmail. + setServerUrl(payload.webmailUrl); + await loginViaWebmail(payload.webmailUrl, { addAccount: isAddMode }); + } else { + // Cross-device pairing QR: redeem the one-time code for tokens, no + // browser round-trip needed. + await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode }); + } + const after = useAuthStore.getState().isAuthenticated; + if ((after && !before) || (isAddMode && after)) { + onLogin?.(); + } + } catch { + // Store state already contains the user-facing error. + } + }; + const updateField = React.useCallback( (setter: React.Dispatch>) => (value: string) => { if (useAuthStore.getState().error) { @@ -182,6 +214,15 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree > Sign in via webmail + @@ -192,6 +233,14 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree + + setScannerVisible(false)} + onScanned={(data) => { + void handleScanned(data); + }} + /> ); } diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 21129a39..11f3830f 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -7,7 +7,7 @@ import { useContactsStore } from './contacts-store'; import { useCalendarStore } from './calendar-store'; import { useFilterStore } from './filter-store'; import { generateAccountId } from '../lib/account-utils'; -import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth'; +import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, type HandoffResult } from '../lib/oauth'; import { teardownPushNotifications, teardownPushNotificationsForAccount, @@ -41,6 +41,7 @@ export interface AuthState { login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise; loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise; + loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise; logout: () => Promise; logoutAll: () => Promise; switchAccount: (accountId: string) => Promise; @@ -97,6 +98,43 @@ function refetchFeatureStores(): void { } } +// Shared tail of the OAuth sign-in flows (browser handoff and cross-device QR +// 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( + set: (partial: Partial) => void, + get: () => AuthState, + result: Extract, + opts?: { addAccount?: boolean }, +): Promise { + if (opts?.addAccount && get().isAuthenticated) { + jmapClient.reset(); + useContactsStore.getState().reset(); + useCalendarStore.getState().reset(); + } + + const { session, username, accountId } = await jmapClient.connectWithOAuth( + result.serverUrl, + result.tokens, + ); + + const accountStore = useAccountStore.getState(); + accountStore.addAccount({ + serverUrl: result.serverUrl.replace(/\/+$/, ''), + username, + displayName: username, + email: username, + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + }); + accountStore.setActiveAccount(accountId); + useEmailStore.getState().setActiveAccount(accountId); + + applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId); +} + function applyConnectedState( set: (partial: Partial) => void, session: JMAPSession, @@ -200,31 +238,7 @@ export const useAuthStore = create((set, get) => ({ // token bundle. Bootstrap the JMAP session with Bearer auth and let // ensure/forceRefreshToken keep it alive going forward. try { - if (opts?.addAccount && get().isAuthenticated) { - jmapClient.reset(); - useContactsStore.getState().reset(); - useCalendarStore.getState().reset(); - } - - const { session, username, accountId } = await jmapClient.connectWithOAuth( - result.serverUrl, - result.tokens, - ); - - const accountStore = useAccountStore.getState(); - accountStore.addAccount({ - serverUrl: result.serverUrl.replace(/\/+$/, ''), - username, - displayName: username, - email: username, - lastLoginAt: Date.now(), - isConnected: true, - hasError: false, - }); - accountStore.setActiveAccount(accountId); - useEmailStore.getState().setActiveAccount(accountId); - - applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId); + await completeOAuthHandoff(set, get, result, opts); } catch (err) { const message = err instanceof AuthenticationError @@ -237,6 +251,38 @@ export const useAuthStore = create((set, get) => ({ } }, + loginViaPairing: async (webmailUrl, code, opts) => { + set({ isLoading: true, error: null }); + let result; + try { + result = await redeemPairingCode(webmailUrl, code); + } catch (err) { + const message = err instanceof Error ? err.message : 'Pairing failed'; + set({ isLoading: false, error: message }); + throw err; + } + + // redeemPairingCode only ever yields the OAuth flow, but guard anyway so a + // future server change can't silently mis-route credentials here. + if (result.flow !== 'oauth') { + set({ isLoading: false, error: 'Unexpected pairing response' }); + throw new Error('Unexpected pairing response'); + } + + try { + await completeOAuthHandoff(set, get, result, opts); + } catch (err) { + const message = + err instanceof AuthenticationError + ? 'Authentication rejected by server' + : err instanceof Error + ? err.message + : 'Pairing sign-in failed'; + set({ isLoading: false, error: message }); + throw err; + } + }, + logout: async () => { const accountStore = useAccountStore.getState(); const currentId = get().activeAccountId;