feat(mobile): UserKey setup screen (PIN + optional recovery code)

This commit is contained in:
byGalax
2026-05-16 17:08:47 +02:00
parent 8ad212291a
commit 41359816f1
+195
View File
@@ -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',
},
});