feat: QR code sign-in

This commit is contained in:
Linus Rath
2026-06-05 19:25:00 +02:00
parent e75aefeb4f
commit ac95a2bc39
9 changed files with 423 additions and 37 deletions
+7
View File
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
@@ -21,6 +22,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
</activity>
<service android:name=".BulwarkMessagingService" android:exported="false">
<intent-filter>
+12 -1
View File
@@ -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,
},
+21
View File
@@ -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",
+1
View File
@@ -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",
+124
View File
@@ -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 (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
style={StyleSheet.absoluteFill}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarcode}
/>
) : null}
<SafeAreaView style={styles.overlay}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
<X size={24} color="#ffffff" />
</Pressable>
<Text style={styles.title}>Scan QR code</Text>
<View style={styles.closeButton} />
</View>
{permission?.granted ? (
<View style={styles.frameWrap}>
<View style={styles.frame} />
<Text style={styles.hint}>
Point your camera at the QR code shown on the webmail settings screen.
</Text>
</View>
) : (
<View style={styles.permissionWrap}>
<Text style={styles.permissionText}>
{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.'}
</Text>
<Button variant="default" size="md" onPress={() => void requestPermission()}>
Allow camera access
</Button>
</View>
)}
</SafeAreaView>
</View>
</Modal>
);
}
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' },
});
}
+84
View File
@@ -123,6 +123,90 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
}
// QR login payloads. A QR scanned on the login screen either bootstraps the
// server URL for the normal webmail handoff (`connect`), or carries a one-time
// cross-device pairing code minted by an already-signed-in webmail (`pair`).
// The payload never contains credentials — the `pair` code is redeemed for
// tokens over the network, once.
export type QrLoginPayload =
| { kind: 'connect'; webmailUrl: string }
| { kind: 'pair'; webmailUrl: string; code: string };
export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
const trimmed = raw.trim();
// Custom scheme: bulwarkmail://connect?server=... | bulwarkmail://pair?server=...&code=...
const match = /^bulwarkmail:\/\/(connect|pair)\?(.*)$/i.exec(trimmed);
if (match) {
const kind = match[1].toLowerCase();
const params = new URLSearchParams(match[2]);
const server = params.get('server');
if (!server || !/^https?:\/\//i.test(server)) return null;
if (kind === 'pair') {
const code = params.get('code');
if (!code) return null;
return { kind: 'pair', webmailUrl: server, code };
}
return { kind: 'connect', webmailUrl: server };
}
// A bare https URL is treated as a server-bootstrap target so admins can
// hand out a plain webmail URL QR without the custom-scheme wrapper.
if (/^https?:\/\//i.test(trimmed)) {
return { kind: 'connect', webmailUrl: trimmed };
}
return null;
}
// Cross-device pairing redemption. Posts the scanned code to the webmail that
// minted it and maps the token bundle into the same shape the in-browser OAuth
// handoff produces, so the caller can reuse the existing connectWithOAuth path.
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
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.
+51 -8
View File
@@ -82,6 +82,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const [email, setEmail] = React.useState<Email | null>(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<string | null>(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<unknown> = 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) {
</View>
</View>
{loading ? (
{loading && !email ? (
<View style={styles.centered}>
<ActivityIndicator color={c.primary} />
</View>
) : error ? (
) : error && !email ? (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
@@ -657,6 +686,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
</ScrollView>
</Animated.View>
{/* Transient spinner while a switch waits on an un-prefetched
neighbour — sits above the (slid-out) pane, not in place of it. */}
{switching && (
<View style={styles.switchOverlay} pointerEvents="none">
<ActivityIndicator color={c.primary} />
</View>
)}
{/* Bottom action bar */}
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
@@ -1065,6 +1102,12 @@ function makeStyles(c: ThemePalette) {
padding: spacing.lg,
},
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
switchOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: c.background,
},
slidePane: { flex: 1, backgroundColor: c.background },
scroll: { flex: 1, backgroundColor: c.background },
+51 -2
View File
@@ -1,11 +1,13 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { Eye, EyeOff, X } from 'lucide-react-native';
import { Eye, EyeOff, X, QrCode } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button, Input } from '../components';
import { QrScanModal } from '../components/QrScanModal';
import { parseQrLoginPayload } from '../lib/oauth';
import { useAuthStore } from '../stores/auth-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
@@ -21,6 +23,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const styles = React.useMemo(() => 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<React.SetStateAction<string>>) => (value: string) => {
if (useAuthStore.getState().error) {
@@ -182,6 +214,15 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
>
Sign in via webmail
</Button>
<Button
variant="ghost"
size="md"
onPress={() => setScannerVisible(true)}
disabled={isLoading}
icon={<QrCode size={18} color={c.text} />}
>
Scan QR code
</Button>
</View>
</View>
@@ -192,6 +233,14 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
</View>
</View>
</KeyboardAvoidingView>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => {
void handleScanned(data);
}}
/>
</SafeAreaView>
);
}
+72 -26
View File
@@ -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<void>;
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
logout: () => Promise<void>;
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
@@ -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<AuthState>) => void,
get: () => AuthState,
result: Extract<HandoffResult, { flow: 'oauth' }>,
opts?: { addAccount?: boolean },
): Promise<void> {
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<AuthState>) => void,
session: JMAPSession,
@@ -200,31 +238,7 @@ export const useAuthStore = create<AuthState>((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<AuthState>((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;