"Forgot your password?" on web and mobile sends a one-time link (1 hour) to the account's email; the website's reset page sets the new password, signs out every other session and signs in. The request responds the same whether or not the email has an account, and the email goes out in the background so timing doesn't reveal it either. Only a hash of the link token is stored. Sends through SMTP_* settings; without them, emails are logged (and captured in tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -97,6 +97,7 @@ function AppNavigator() {
|
||||
<Stack.Protected guard={!signedIn}>
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="register" options={{ title: 'Create account' }} />
|
||||
<Stack.Screen name="forgot-password" options={{ title: 'Reset password' }} />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
</SWRConfig>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Body, Button, Field, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { colors } from '@/lib/theme';
|
||||
|
||||
/** Sends the reset email. The link in it opens the website, where the new password is set. */
|
||||
export default function ForgotPasswordScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.forgotPassword(email.trim());
|
||||
setSentTo(email.trim());
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
if (sentTo) {
|
||||
return (
|
||||
<Screen>
|
||||
<Title>Check your email</Title>
|
||||
<Body>
|
||||
If {sentTo} has a musicshare account, we've sent it a link to choose a new password. It expires in an hour.
|
||||
</Body>
|
||||
<Body muted>Open the link, set your new password, then log in here with it. Nothing there? Check your spam folder.</Body>
|
||||
<Button variant="primary" title="Back to log in" onPress={() => router.back()} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||
<Screen>
|
||||
<Body muted>Enter the email you signed up with and we'll send you a link to choose a new password.</Body>
|
||||
<Field
|
||||
label="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
textContentType="emailAddress"
|
||||
autoFocus
|
||||
onSubmitEditing={submit}
|
||||
returnKeyType="send"
|
||||
/>
|
||||
{error && <Body style={{ color: colors.danger }}>{error}</Body>}
|
||||
<Button variant="primary" title="Send reset link" loading={busy} onPress={submit} />
|
||||
</Screen>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,11 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
||||
/>
|
||||
{error && <Body style={{ color: colors.danger }}>{error}</Body>}
|
||||
<Button variant="primary" title={mode === 'login' ? 'Log in' : 'Create account'} loading={busy} onPress={submit} />
|
||||
{mode === 'login' && (
|
||||
<Link href="/forgot-password" style={styles.link}>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
)}
|
||||
{mode === 'login' ? (
|
||||
<Link href="/register" style={styles.link}>
|
||||
New here? Create an account
|
||||
|
||||
Reference in New Issue
Block a user