feat(mobile): UserKey unlock screen (PIN entry + recovery tab + reset)

This commit is contained in:
byGalax
2026-05-16 17:09:50 +02:00
parent 50bfb5b137
commit 7ae1d5ba8c
+194
View File
@@ -0,0 +1,194 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../components/PinInput';
import { useAuth } from '../../lib/authContext';
import { loadOrUnlockUserKey, resetIdentity } from '../../lib/userIdentity';
import { colors } from '../../theme/colors';
type Mode = 'pin' | 'recovery';
export default function UnlockScreen() {
const router = useRouter();
const { userId, userKeyState, refreshUserKeyState } = useAuth();
const [mode, setMode] = useState<Mode>('pin');
const [pin, setPin] = useState('');
const [recovery, setRecovery] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const locked =
userKeyState.status === 'needs-unlock' && userKeyState.lockedUntil !== null;
const hasRecovery =
userKeyState.status === 'needs-unlock' ? userKeyState.hasRecovery : false;
async function attempt(value: string, isRecoveryCode: boolean) {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const out = await loadOrUnlockUserKey({ userId, pin: value, isRecoveryCode });
if (out.kind === 'unlocked') {
await refreshUserKeyState();
router.replace('/(app)/chats');
return;
}
if (out.kind === 'locked') {
setError('Konto bis ' + new Date(out.lockedUntil).toLocaleString('de-DE') + ' gesperrt.');
await refreshUserKeyState();
return;
}
setError('Kein Identitäts-Datensatz auf dem Server.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Entsperren fehlgeschlagen');
if (isRecoveryCode) setRecovery('');
else setPin('');
await refreshUserKeyState();
} finally {
setSubmitting(false);
}
}
async function handleReset() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
await resetIdentity({ userId, pin: '000000' });
await refreshUserKeyState();
router.replace('/(app)/setup');
} catch (err) {
setError(err instanceof Error ? err.message : 'Reset fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Netralax entsperren</Text>
<View style={styles.tabs}>
<Pressable
style={[styles.tab, mode === 'pin' && styles.tabActive]}
onPress={() => setMode('pin')}
>
<Text style={[styles.tabText, mode === 'pin' && styles.tabTextActive]}>PIN</Text>
</Pressable>
<Pressable
style={[styles.tab, mode === 'recovery' && styles.tabActive]}
disabled={!hasRecovery}
onPress={() => setMode('recovery')}
>
<Text style={[styles.tabText, mode === 'recovery' && styles.tabTextActive]}>
Recovery-Code
</Text>
</Pressable>
</View>
{mode === 'pin' ? (
<>
<PinInput
value={pin}
onChange={setPin}
ariaLabel="PIN eingeben"
autoFocus
disabled={locked || submitting}
/>
<Pressable
style={[styles.primary, (pin.length !== 6 || locked || submitting) && styles.disabled]}
disabled={pin.length !== 6 || locked || submitting}
onPress={() => attempt(pin, false)}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>Entsperren</Text>
)}
</Pressable>
</>
) : (
<>
<Text style={styles.subtitle}>Recovery-Code eingeben (mit oder ohne Bindestriche):</Text>
<Text style={styles.recoveryDisplay} selectable>
{recovery}
</Text>
<Pressable
style={[styles.primary, recovery.length === 0 && styles.disabled]}
disabled={recovery.length === 0 || submitting}
onPress={() => attempt(recovery, true)}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>Mit Recovery entsperren</Text>
)}
</Pressable>
</>
)}
{error && <Text style={styles.error}>{error}</Text>}
{locked && (
<Pressable style={styles.danger} onPress={handleReset}>
<Text style={styles.dangerText}>Identität zurücksetzen (alte Chats verloren)</Text>
</Pressable>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg,
paddingTop: 64,
paddingHorizontal: 24,
gap: 16,
},
title: { color: colors.text, fontSize: 22, fontWeight: '700' },
subtitle: { color: colors.textMuted, fontSize: 13 },
tabs: { flexDirection: 'row', gap: 8 },
tab: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderBottomWidth: 2,
borderBottomColor: colors.border,
},
tabActive: { borderBottomColor: colors.accent },
tabText: { color: colors.textMuted, fontWeight: '600' },
tabTextActive: { color: colors.text },
recoveryDisplay: {
color: colors.text,
fontFamily: 'Courier',
fontSize: 18,
letterSpacing: 1.5,
backgroundColor: colors.surface,
padding: 16,
borderRadius: 10,
},
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
primaryText: { color: colors.text, fontWeight: '700' },
disabled: { opacity: 0.5 },
danger: {
paddingVertical: 12,
alignItems: 'center',
borderColor: colors.danger,
borderWidth: 1,
borderRadius: 12,
},
dangerText: { color: colors.danger, fontWeight: '700' },
error: { color: colors.danger, fontSize: 13 },
});