Fix Live Activity lock screen rendering and polish multi-account auth.

Flatten widget layouts and use system colors so banner and expanded regions render on vibrant lock screens; migrate auth sessions per account to prevent double sign-in; scope app lock PIN to accounts; default clock description to "Clock In"; add architecture docs and deferred form validation on auth screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 01:23:36 -04:00
parent e6ea3d7c5d
commit 32ffe782ea
35 changed files with 1659 additions and 442 deletions
+24 -1
View File
@@ -11,6 +11,7 @@ import {
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -18,6 +19,7 @@ import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { requestPasswordReset } from "@/lib/auth-api";
import { isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function ForgotPasswordScreen() {
const { colors } = useAppTheme();
@@ -25,8 +27,19 @@ export default function ForgotPasswordScreen() {
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
const { touch, visible, markSubmitted } = useFieldVisibility();
const emailValidationError = !email.trim()
? "Email is required"
: isValidEmail(email)
? undefined
: "Enter a valid email";
const canSubmit = isValidEmail(email) && serverReady;
async function handleSubmit() {
markSubmitted();
if (!canSubmit) return;
setError(null);
setMessage(null);
setLoading(true);
@@ -53,6 +66,8 @@ export default function ForgotPasswordScreen() {
<Text style={[styles.back, { color: colors.mutedForeground }]}> Back</Text>
</Pressable>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="md" />
@@ -70,7 +85,10 @@ export default function ForgotPasswordScreen() {
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
{error ? (
@@ -80,7 +98,12 @@ export default function ForgotPasswordScreen() {
<Text style={[styles.success, { color: colors.foreground }]}>{message}</Text>
) : null}
<Button title="Send reset link" loading={loading} onPress={handleSubmit} />
<Button
title="Send reset link"
loading={loading}
disabled={!canSubmit}
onPress={handleSubmit}
/>
<Button
title="Have a reset token?"
variant="ghost"
+52 -8
View File
@@ -1,4 +1,4 @@
import { Link, router } from "expo-router";
import { Link } from "expo-router";
import { useState } from "react";
import {
KeyboardAvoidingView,
@@ -10,6 +10,7 @@ import {
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -19,10 +20,12 @@ import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { registerAccount } from "@/lib/auth-api";
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage";
import { isRequiredString, isValidEmail, isValidPassword, useFieldVisibility } from "@/lib/form-validation";
export default function RegisterScreen() {
const authClient = useAuthClient();
const { apiUrl, registerAccount: saveAccount } = useAccounts();
const { apiUrl, activeAccountId, registerAccount: saveAccount } = useAccounts();
const { colors } = useAppTheme();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
@@ -30,8 +33,31 @@ export default function RegisterScreen() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
const { touch, visible, markSubmitted } = useFieldVisibility();
const firstNameError = isRequiredString(firstName) ? undefined : "First name is required";
const lastNameError = isRequiredString(lastName) ? undefined : "Last name is required";
const emailValidationError = isValidEmail(email)
? undefined
: email.trim()
? "Enter a valid email"
: "Email is required";
const passwordValidationError = isValidPassword(password)
? undefined
: password
? "Password must be at least 8 characters"
: "Password is required";
const canRegister =
isRequiredString(firstName) &&
isRequiredString(lastName) &&
isValidEmail(email) &&
isValidPassword(password) &&
serverReady;
async function handleRegister() {
markSubmitted();
if (!canRegister) return;
setError(null);
setLoading(true);
@@ -49,22 +75,22 @@ export default function RegisterScreen() {
});
if (signInError) {
router.replace("/(auth)/sign-in");
setError(signInError.message || "Account created but sign-in failed. Try signing in.");
return;
}
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
await saveAccount({
instanceUrl: apiUrl,
await finalizeAuthenticatedAccount({
apiUrl,
userId: user.id,
email: user.email,
name: user.name,
activeAccountId,
registerAccount: saveAccount,
});
}
router.replace("/(app)");
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
} finally {
@@ -83,6 +109,7 @@ export default function RegisterScreen() {
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
@@ -99,8 +126,11 @@ export default function RegisterScreen() {
label="First name"
value={firstName}
onChangeText={setFirstName}
onBlur={() => touch("firstName")}
autoComplete="given-name"
placeholder="Jane"
required
error={visible("firstName") ? firstNameError : undefined}
/>
</View>
<View style={styles.half}>
@@ -108,8 +138,11 @@ export default function RegisterScreen() {
label="Last name"
value={lastName}
onChangeText={setLastName}
onBlur={() => touch("lastName")}
autoComplete="family-name"
placeholder="Doe"
required
error={visible("lastName") ? lastNameError : undefined}
/>
</View>
</View>
@@ -121,7 +154,10 @@ export default function RegisterScreen() {
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
@@ -129,14 +165,22 @@ export default function RegisterScreen() {
autoComplete="new-password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="At least 8 characters"
required
error={visible("password") ? passwordValidationError : undefined}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button title="Create Account" loading={loading} onPress={handleRegister} />
<Button
title="Create Account"
loading={loading}
disabled={!canRegister}
onPress={handleRegister}
/>
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
+33 -11
View File
@@ -11,6 +11,7 @@ import {
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -20,6 +21,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { resetPassword } from "@/lib/auth-api";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { isRequiredString, isValidPassword } from "@/lib/form-validation";
export default function ResetPasswordScreen() {
const styles = useThemedStyles(createResetPasswordStyles);
@@ -30,6 +32,7 @@ export default function ResetPasswordScreen() {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
useEffect(() => {
if (typeof tokenParam === "string" && tokenParam.length > 0) {
@@ -37,19 +40,25 @@ export default function ResetPasswordScreen() {
}
}, [tokenParam]);
const tokenError = isRequiredString(token) ? undefined : "Reset token is required";
const passwordError = isValidPassword(password)
? undefined
: password
? "Password must be at least 8 characters"
: "Password is required";
const confirmError =
confirmPassword && password !== confirmPassword ? "Passwords do not match" : undefined;
const canSubmit =
serverReady &&
isRequiredString(token) &&
isValidPassword(password) &&
password === confirmPassword &&
confirmPassword.length > 0;
async function handleSubmit() {
if (!canSubmit) return;
setError(null);
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
setLoading(true);
try {
@@ -74,6 +83,8 @@ export default function ResetPasswordScreen() {
<Text style={styles.back}> Back</Text>
</Pressable>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<HeadingText style={styles.title}>Set new password</HeadingText>
@@ -101,6 +112,8 @@ export default function ResetPasswordScreen() {
value={token}
onChangeText={setToken}
placeholder="Paste token from email"
required
error={tokenError}
/>
<Input
label="New password"
@@ -108,6 +121,8 @@ export default function ResetPasswordScreen() {
value={password}
onChangeText={setPassword}
placeholder="At least 8 characters"
required
error={passwordError}
/>
<Input
label="Confirm password"
@@ -115,11 +130,18 @@ export default function ResetPasswordScreen() {
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Repeat password"
required
error={confirmError}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title="Update password" loading={loading} onPress={handleSubmit} />
<Button
title="Update password"
loading={loading}
disabled={!canSubmit}
onPress={handleSubmit}
/>
</View>
)}
</Card>
+58 -26
View File
@@ -10,6 +10,7 @@ import {
View,
} from "react-native";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo";
import { FullScreen } from "@/components/Screen";
import { Button } from "@/components/ui/Button";
@@ -19,46 +20,65 @@ import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage";
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function SignInScreen() {
const authClient = useAuthClient();
const { apiUrl, registerAccount } = useAccounts();
const { apiUrl, activeAccountId, registerAccount } = useAccounts();
const { colors } = useAppTheme();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
const { touch, visible, markSubmitted } = useFieldVisibility();
const emailValidationError = !email.trim()
? "Email is required"
: isValidEmail(email)
? undefined
: "Enter a valid email";
const passwordValidationError = password.trim() ? undefined : "Password is required";
const canSignIn = isValidEmail(email) && isRequiredString(password) && serverReady;
async function handleSignIn() {
markSubmitted();
if (!canSignIn) return;
setError(null);
setLoading(true);
const { error: signInError } = await authClient.signIn.email({ email: email.trim(), password });
if (signInError) {
setLoading(false);
const message = signInError.message ?? "";
if (message.toLowerCase().includes("internal") || message.includes("500")) {
setError("Server error — is the API running with Postgres? Check beenvoice dev + docker.");
} else {
setError(message || "Invalid email or password");
}
return;
}
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
await registerAccount({
instanceUrl: apiUrl,
userId: user.id,
email: user.email,
name: user.name,
try {
const { error: signInError } = await authClient.signIn.email({
email: email.trim(),
password,
});
}
setLoading(false);
router.replace("/(app)");
if (signInError) {
const message = signInError.message ?? "";
if (message.toLowerCase().includes("internal") || message.includes("500")) {
setError("Server error — is the API running with Postgres? Check beenvoice dev + docker.");
} else {
setError(message || "Invalid email or password");
}
return;
}
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
await finalizeAuthenticatedAccount({
apiUrl,
userId: user.id,
email: user.email,
name: user.name,
activeAccountId,
registerAccount,
});
}
} finally {
setLoading(false);
}
}
return (
@@ -72,6 +92,7 @@ export default function SignInScreen() {
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
@@ -89,7 +110,10 @@ export default function SignInScreen() {
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
@@ -97,7 +121,10 @@ export default function SignInScreen() {
autoComplete="password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
required
error={visible("password") ? passwordValidationError : undefined}
/>
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
@@ -110,7 +137,12 @@ export default function SignInScreen() {
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button title="Sign In" loading={loading} onPress={handleSignIn} />
<Button
title="Sign In"
loading={loading}
disabled={!canSignIn}
onPress={handleSignIn}
/>
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>