mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Any web page (one tap) or installed app (no tap) could fire a `bulwarkmail://pair` link at the app, which then silently added the sender's mailbox and switched to it, so the user might send or save mail into an attacker's account. A `connect` link likewise opened any server's sign-in page. - Links that arrive from outside the app now ask first, naming the webmail's host and whether the link adds an account. Cancel discards the link for good; a link delivered again while the question is open is dropped. Codes scanned or pasted inside the app still go straight through. - With the account registry full, a link shows the limit instead of opening "Add account". - A pairing link for a plain-http webmail says the connection would be unencrypted instead of "That code isn't a Bulwark sign-in code", or nothing at all for a tapped link.
184 lines
6.2 KiB
TypeScript
184 lines
6.2 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
Pressable,
|
|
StyleSheet,
|
|
Modal,
|
|
Platform,
|
|
KeyboardAvoidingView,
|
|
ScrollView,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import * as Clipboard from 'expo-clipboard';
|
|
import { ClipboardPaste, X } from 'lucide-react-native';
|
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
|
import { useColors } from '../theme/colors';
|
|
import Button from './Button';
|
|
import { useLocaleStore } from '../stores/locale-store';
|
|
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
|
|
import { describeLoginError } from '../lib/login-errors';
|
|
|
|
interface PasteSignInLinkModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
// Fires with a link that parsed; the parent closes the modal and signs in
|
|
// with it exactly as with a scanned code.
|
|
onSubmit: (payload: QrLoginPayload) => void;
|
|
}
|
|
|
|
/**
|
|
* For when the code can't be scanned (a phone showing the webmail itself, a
|
|
* broken camera): the same `bulwarkmail://` link, pasted as text.
|
|
*/
|
|
export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignInLinkModalProps) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
const t = useLocaleStore((st) => st.t);
|
|
const [text, setText] = React.useState('');
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [focused, setFocused] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
if (!visible) return;
|
|
setText('');
|
|
setError(null);
|
|
}, [visible]);
|
|
|
|
const paste = React.useCallback(async () => {
|
|
let value = '';
|
|
try {
|
|
value = await Clipboard.getStringAsync();
|
|
} catch {
|
|
value = '';
|
|
}
|
|
if (!value.trim()) {
|
|
setError(t('login.mobile.paste_clipboard_empty', 'Nothing to paste. Copy the link in the webmail first.'));
|
|
return;
|
|
}
|
|
setText(value.trim());
|
|
setError(null);
|
|
}, [t]);
|
|
|
|
const submit = React.useCallback(() => {
|
|
const payload = parsePastedSignInLink(text);
|
|
if (!payload) {
|
|
// A pairing link for a plain-http webmail: say why it won't be used.
|
|
const insecure = insecurePairingLinkError(text);
|
|
if (insecure) {
|
|
const copy = describeLoginError(insecure, { t });
|
|
setError(copy.detail ?? copy.title);
|
|
return;
|
|
}
|
|
setError(t('login.mobile.paste_invalid', "That isn't a Bulwark sign-in link"));
|
|
return;
|
|
}
|
|
onSubmit(payload);
|
|
}, [onSubmit, t, text]);
|
|
|
|
const title = t('login.mobile.paste_title', 'Paste sign-in link');
|
|
|
|
return (
|
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
|
<SafeAreaView style={styles.container}>
|
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
|
|
<View style={styles.header}>
|
|
<Pressable
|
|
onPress={onClose}
|
|
hitSlop={10}
|
|
style={styles.closeButton}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('common.close', 'Close')}
|
|
>
|
|
<X size={24} color={c.text} />
|
|
</Pressable>
|
|
<Text style={styles.title} accessibilityRole="header">{title}</Text>
|
|
<View style={styles.closeButton} />
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.flex}
|
|
contentContainerStyle={styles.content}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
<Text style={styles.hint}>
|
|
{t('login.mobile.paste_hint', 'In the webmail, open Settings → Security → Link Mobile App, copy the sign-in link and paste it here.')}
|
|
</Text>
|
|
|
|
<TextInput
|
|
value={text}
|
|
onChangeText={(value) => {
|
|
setText(value);
|
|
setError(null);
|
|
}}
|
|
placeholder="bulwarkmail://pair?server=…&code=…"
|
|
placeholderTextColor={c.textMuted}
|
|
accessibilityLabel={t('login.mobile.paste_label', 'Sign-in link')}
|
|
multiline
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
spellCheck={false}
|
|
textAlignVertical="top"
|
|
onFocus={() => setFocused(true)}
|
|
onBlur={() => setFocused(false)}
|
|
style={[styles.input, focused && styles.inputFocused, error ? styles.inputError : null]}
|
|
/>
|
|
|
|
{error ? (
|
|
<Text style={styles.error} accessibilityLiveRegion="polite">{error}</Text>
|
|
) : null}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="md"
|
|
onPress={() => void paste()}
|
|
icon={<ClipboardPaste size={16} color={c.text} />}
|
|
>
|
|
{t('login.mobile.paste_button', 'Paste')}
|
|
</Button>
|
|
|
|
<Button variant="default" size="md" onPress={submit} disabled={!text.trim()}>
|
|
{t('login.sign_in', 'Sign in')}
|
|
</Button>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
</SafeAreaView>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function makeStyles(c: ThemePalette) {
|
|
return StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: c.background },
|
|
flex: { flex: 1 },
|
|
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: c.text },
|
|
content: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xxxl, gap: spacing.lg },
|
|
hint: { ...typography.body, color: c.textSecondary },
|
|
input: {
|
|
...typography.body,
|
|
color: c.text,
|
|
minHeight: 96,
|
|
borderWidth: 1,
|
|
borderColor: c.border,
|
|
borderRadius: radius.sm,
|
|
backgroundColor: c.background,
|
|
paddingHorizontal: spacing.md,
|
|
paddingTop: spacing.sm,
|
|
paddingBottom: spacing.sm,
|
|
},
|
|
inputFocused: { borderColor: c.borderFocus, borderWidth: 2 },
|
|
inputError: { borderColor: c.error },
|
|
error: { ...typography.caption, color: c.error, marginTop: -spacing.sm },
|
|
});
|
|
}
|