Email confirmation, change email/password, show/hide passwords
CI / check (push) Canceled after 0s

- Sign-up emails a confirmation link (24h); unconfirmed accounts see a
  banner on the web with "send it again", and status in Settings.
- Change email (needs the password): the new address gets a confirmation
  link and only replaces the old one once clicked; the old address gets a
  heads-up with a reset-password link. Pending changes can be cancelled.
- Change password (needs the current one): signs out other sessions.
- Password fields have Show/Hide on web and mobile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-06 13:05:26 -04:00
co-authored by Claude Opus 5.5
parent 45a7bd190a
commit 85e1643c29
25 changed files with 2077 additions and 19 deletions
+31 -2
View File
@@ -418,7 +418,22 @@ function JellyfinConnection({
}
function Account() {
const { me, signOut } = useSession();
const { me, setMe, signOut } = useSession();
const resend = async () => {
try {
await api.resendVerification();
Alert.alert('Sent', `We sent a new link to ${me?.email}.`);
} catch (err) {
showError(err);
}
};
const cancelChange = async () => {
try {
setMe(await api.cancelEmailChange());
} catch (err) {
showError(err);
}
};
const confirmDelete = () =>
Alert.alert('Delete your account?', 'This permanently removes your profile, follows and connected services.', [
{ text: 'Cancel', style: 'cancel' },
@@ -439,8 +454,22 @@ function Account() {
<Card>
<Heading>Account</Heading>
<Body muted style={styles.small}>
Signed in as @{me?.username} · {me?.plan === 'free' ? 'Free plan' : 'Plus'}
Signed in as @{me?.username}
</Body>
<Body style={styles.small}>
{me?.email} · {me?.emailVerified ? 'confirmed' : 'not confirmed'}
</Body>
{me?.pendingEmail ? (
<Body muted style={styles.small}>
Changing to {me.pendingEmail}: open the link we sent there.
</Body>
) : null}
<Row>
{me && !me.emailVerified && !me.pendingEmail && <Button small variant="primary" title="Resend confirmation" onPress={resend} />}
{me?.pendingEmail && <Button small title="Cancel email change" onPress={cancelChange} />}
<Button small title="Change email" onPress={() => router.push('/change-email')} />
<Button small title="Change password" onPress={() => router.push('/change-password')} />
</Row>
<Button small title="Blocked accounts" onPress={() => router.push('/blocked')} style={{ alignSelf: 'flex-start' }} />
<Row>
<Button small title="Log out" onPress={signOut} />
+2
View File
@@ -96,6 +96,8 @@ function AppNavigator() {
<Stack.Screen name="u/[username]/following" options={{ title: 'Following' }} />
<Stack.Screen name="report/[username]" options={{ title: 'Report', presentation: 'modal' }} />
<Stack.Screen name="blocked" options={{ title: 'Blocked accounts' }} />
<Stack.Screen name="change-password" options={{ title: 'Change password', presentation: 'modal' }} />
<Stack.Screen name="change-email" options={{ title: 'Change email', presentation: 'modal' }} />
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
</Stack.Protected>
<Stack.Protected guard={!signedIn}>
+46
View File
@@ -0,0 +1,46 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { Alert, KeyboardAvoidingView, Platform } from 'react-native';
import { Screen } from '@/components/Screen';
import { Body, Button, Field } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { useSession } from '@/lib/session';
export default function ChangeEmailScreen() {
const { setMe } = useSession();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
try {
setMe(await api.changeEmail({ email: email.trim(), password }));
Alert.alert('Check your email', `Open the link we sent to ${email.trim()} to finish the change.`);
router.back();
} catch (err) {
showError(err, "Couldn't change your email");
}
setBusy(false);
};
return (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<Screen>
<Field
label="New email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
textContentType="emailAddress"
/>
<Field label="Your password" value={password} onChangeText={setPassword} secureTextEntry autoComplete="current-password" textContentType="password" />
<Body muted>Your email changes once you open the confirmation link we send to the new address.</Body>
<Button variant="primary" title="Send confirmation link" loading={busy} disabled={!email.trim() || !password} onPress={submit} />
</Screen>
</KeyboardAvoidingView>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { Alert, KeyboardAvoidingView, Platform } from 'react-native';
import { Screen } from '@/components/Screen';
import { Body, Button, Field } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
export default function ChangePasswordScreen() {
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
try {
await api.changePassword({ currentPassword: current, newPassword: next });
Alert.alert('Password changed', 'Your other devices were signed out.');
router.back();
} catch (err) {
showError(err, "Couldn't change your password");
}
setBusy(false);
};
return (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<Screen>
<Field label="Current password" value={current} onChangeText={setCurrent} secureTextEntry autoComplete="current-password" textContentType="password" />
<Field label="New password" value={next} onChangeText={setNext} secureTextEntry autoComplete="new-password" textContentType="newPassword" />
<Body muted>At least 8 characters. This signs you out on your other devices.</Body>
<Button variant="primary" title="Change password" loading={busy} disabled={!current || next.length < 8} onPress={submit} />
</Screen>
</KeyboardAvoidingView>
);
}
+26 -9
View File
@@ -97,22 +97,37 @@ export function Button({
);
}
/** A text field. Password fields (secureTextEntry) get a Show/Hide button. */
export const Field = forwardRef<TextInput, TextInputProps & { label?: string }>(function Field(
{ label, style, onFocus, onBlur, ...props },
{ label, style, onFocus, onBlur, secureTextEntry, ...props },
ref,
) {
const [focused, setFocused] = useState(false);
const [revealed, setRevealed] = useState(false);
return (
<View style={{ gap: space.xs }}>
{label && <Text style={styles.label}>{label}</Text>}
<TextInput
ref={ref}
placeholderTextColor={colors.muted}
style={[styles.input, focused && styles.inputFocused, style]}
onFocus={(e) => (setFocused(true), onFocus?.(e))}
onBlur={(e) => (setFocused(false), onBlur?.(e))}
{...props}
/>
<View>
<TextInput
ref={ref}
placeholderTextColor={colors.muted}
style={[styles.input, focused && styles.inputFocused, secureTextEntry && { paddingRight: 64 }, style]}
onFocus={(e) => (setFocused(true), onFocus?.(e))}
onBlur={(e) => (setFocused(false), onBlur?.(e))}
secureTextEntry={secureTextEntry && !revealed}
{...props}
/>
{secureTextEntry && (
<Pressable
accessibilityRole="button"
accessibilityLabel={revealed ? 'Hide password' : 'Show password'}
onPress={() => setRevealed((v) => !v)}
hitSlop={8}
style={styles.reveal}>
<Text style={styles.revealText}>{revealed ? 'Hide' : 'Show'}</Text>
</Pressable>
)}
</View>
</View>
);
});
@@ -175,6 +190,8 @@ const styles = StyleSheet.create({
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
buttonTextSmall: { fontSize: 13 },
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
reveal: { position: 'absolute', right: 0, top: 0, bottom: 0, justifyContent: 'center', paddingHorizontal: space.md },
revealText: { color: colors.accent2, fontWeight: '600', fontSize: 14 },
input: {
minHeight: 48,
paddingHorizontal: space.md + 2,