feat(mobile): UserKey unlock screen (PIN entry + recovery tab + reset)
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user