feat(mobile): UserKey setup screen (PIN + optional recovery code)
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { PinInput } from '../../components/PinInput';
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { setupNewUserIdentity } from '../../lib/userIdentity';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
type Step = 'pin' | 'confirm' | 'recovery' | 'done';
|
||||
|
||||
export default function SetupScreen() {
|
||||
const router = useRouter();
|
||||
const { userId, refreshUserKeyState } = useAuth();
|
||||
const [step, setStep] = useState<Step>('pin');
|
||||
const [pin, setPin] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
|
||||
const [withRecovery, setWithRecovery] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function runSetup(saveRecovery: boolean) {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const out = await setupNewUserIdentity({ userId, pin, withRecovery: saveRecovery });
|
||||
setRecoveryCode(out.recoveryCode);
|
||||
if (out.recoveryCode) {
|
||||
setStep('recovery');
|
||||
} else {
|
||||
setStep('done');
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Setup fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'pin') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>PIN festlegen</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Mit dieser 6-stelligen PIN entsperrst du Netralax auf jedem Gerät.
|
||||
</Text>
|
||||
<PinInput value={pin} onChange={setPin} ariaLabel="Neue PIN" autoFocus />
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
<Pressable
|
||||
style={[styles.primary, pin.length !== 6 && styles.disabled]}
|
||||
disabled={pin.length !== 6}
|
||||
onPress={() => setStep('confirm')}
|
||||
>
|
||||
<Text style={styles.primaryText}>Weiter</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'confirm') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>PIN bestätigen</Text>
|
||||
<Text style={styles.subtitle}>Bitte gib dieselbe PIN erneut ein.</Text>
|
||||
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" autoFocus />
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
<Pressable
|
||||
style={[styles.primary, confirm.length !== 6 && styles.disabled]}
|
||||
disabled={confirm.length !== 6 || submitting}
|
||||
onPress={() => {
|
||||
if (pin !== confirm) {
|
||||
setError('PIN stimmt nicht überein.');
|
||||
setConfirm('');
|
||||
return;
|
||||
}
|
||||
void runSetup(withRecovery);
|
||||
}}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.primaryText}>PIN speichern</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setWithRecovery((v) => !v)} style={styles.secondary}>
|
||||
<Text style={styles.secondaryText}>
|
||||
{withRecovery
|
||||
? 'Recovery-Code überspringen (riskant)'
|
||||
: 'Recovery-Code erzeugen (empfohlen)'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'recovery' && recoveryCode) {
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.title}>Recovery-Code</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Notiere dir diesen Code an einem sicheren Ort. Du brauchst ihn, wenn du deine PIN
|
||||
vergisst. Wir zeigen ihn dir nur EINMAL.
|
||||
</Text>
|
||||
<View style={styles.codeBox}>
|
||||
<Text style={styles.codeText} selectable>
|
||||
{recoveryCode}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.primary}
|
||||
onPress={async () => {
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.primaryText}>Habe ich gespeichert</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.secondary}
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
'Sicher?',
|
||||
'Ohne Recovery-Code verlierst du den Zugriff, wenn du die PIN vergisst.',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Weiter ohne',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryText}>Überspringen</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
paddingTop: 64,
|
||||
backgroundColor: colors.bg,
|
||||
gap: 16,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 24, fontWeight: '700' },
|
||||
subtitle: { color: colors.textMuted, fontSize: 14, lineHeight: 20 },
|
||||
error: { color: colors.danger, fontSize: 13 },
|
||||
primary: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
primaryText: { color: colors.text, fontWeight: '700' },
|
||||
disabled: { opacity: 0.5 },
|
||||
secondary: { paddingVertical: 12, alignItems: 'center' },
|
||||
secondaryText: { color: colors.accent, fontWeight: '600' },
|
||||
codeBox: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
codeText: {
|
||||
color: colors.text,
|
||||
fontSize: 20,
|
||||
fontFamily: 'Courier',
|
||||
letterSpacing: 1.5,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user