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:
+2
-2
@@ -18,7 +18,7 @@ import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import { formatElapsedHoursMinutes } from "@/lib/time-clock";
|
||||
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
|
||||
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function DashboardScreen() {
|
||||
<View style={styles.runningDot} />
|
||||
<View style={styles.runningMeta}>
|
||||
<Text style={styles.runningTitle}>
|
||||
{running.description || "Timer running"}
|
||||
{resolveClockDescription(running.description)}
|
||||
</Text>
|
||||
<Text style={styles.runningSub}>
|
||||
{running.client?.name ?? "No client"}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import { validateLineItems } from "@/lib/form-validation";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
@@ -100,6 +101,8 @@ export default function InvoiceEditScreen() {
|
||||
const taxAmount = subtotal * (taxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
const currency = invoice?.currency ?? "USD";
|
||||
const lineItemsError = validateLineItems(items);
|
||||
const canSave = !lineItemsError;
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
@@ -170,6 +173,7 @@ export default function InvoiceEditScreen() {
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!canSave) return;
|
||||
setError(null);
|
||||
|
||||
const parsedItems: Array<{
|
||||
@@ -180,25 +184,11 @@ export default function InvoiceEditScreen() {
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
const hours = Number(item.hours);
|
||||
const rate = Number(item.rate);
|
||||
if (!item.description.trim()) {
|
||||
setError("Each line needs a description");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(hours) || hours < 0) {
|
||||
setError("Hours must be a valid number");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(rate) || rate < 0) {
|
||||
setError("Rate must be a valid number");
|
||||
return;
|
||||
}
|
||||
parsedItems.push({
|
||||
date: item.date,
|
||||
description: item.description.trim(),
|
||||
hours,
|
||||
rate,
|
||||
hours: Number(item.hours),
|
||||
rate: Number(item.rate),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,10 +264,16 @@ export default function InvoiceEditScreen() {
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
|
||||
<Button
|
||||
title="Save changes"
|
||||
loading={updateInvoice.isPending}
|
||||
disabled={!canSave}
|
||||
onPress={handleSave}
|
||||
/>
|
||||
{status !== "paid" ? (
|
||||
<Button
|
||||
title={status === "draft" ? "Send invoice" : "Resend invoice"}
|
||||
|
||||
+29
-34
@@ -23,6 +23,11 @@ import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
|
||||
import {
|
||||
isRequiredString,
|
||||
isValidTaxRate,
|
||||
validateLineItems,
|
||||
} from "@/lib/form-validation";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
@@ -104,6 +109,19 @@ export default function NewInvoiceScreen() {
|
||||
const taxAmount = subtotal * (parsedTaxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
const clientError = clientId ? undefined : "Select a client";
|
||||
const invoiceNumberError = isRequiredString(invoiceNumber)
|
||||
? undefined
|
||||
: "Invoice number is required";
|
||||
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
|
||||
const lineItemsError = validateLineItems(items);
|
||||
const canCreate =
|
||||
clientOptions.length > 0 &&
|
||||
!clientError &&
|
||||
!invoiceNumberError &&
|
||||
!taxError &&
|
||||
!lineItemsError;
|
||||
|
||||
if (clientsQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading…" />;
|
||||
}
|
||||
@@ -140,18 +158,9 @@ export default function NewInvoiceScreen() {
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
if (!canCreate) return;
|
||||
setError(null);
|
||||
|
||||
if (!clientId) {
|
||||
setError("Select a client");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!invoiceNumber.trim()) {
|
||||
setError("Invoice number is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedItems: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
@@ -160,41 +169,21 @@ export default function NewInvoiceScreen() {
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
const hours = Number(item.hours);
|
||||
const rate = Number(item.rate);
|
||||
if (!item.description.trim()) {
|
||||
setError("Each line needs a description");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(hours) || hours < 0) {
|
||||
setError("Hours must be a valid number");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(rate) || rate < 0) {
|
||||
setError("Rate must be a valid number");
|
||||
return;
|
||||
}
|
||||
parsedItems.push({
|
||||
date: item.date,
|
||||
description: item.description.trim(),
|
||||
hours,
|
||||
rate,
|
||||
hours: Number(item.hours),
|
||||
rate: Number(item.rate),
|
||||
});
|
||||
}
|
||||
|
||||
const tax = Number(taxRate);
|
||||
if (Number.isNaN(tax) || tax < 0 || tax > 100) {
|
||||
setError("Tax rate must be between 0 and 100");
|
||||
return;
|
||||
}
|
||||
|
||||
createInvoice.mutate({
|
||||
clientId,
|
||||
invoiceNumber: invoiceNumber.trim(),
|
||||
issueDate,
|
||||
dueDate,
|
||||
notes,
|
||||
taxRate: tax,
|
||||
taxRate: Number(taxRate),
|
||||
currency,
|
||||
items: parsedItems,
|
||||
status: "draft",
|
||||
@@ -232,6 +221,8 @@ export default function NewInvoiceScreen() {
|
||||
placeholder="Select client…"
|
||||
value={clientId}
|
||||
options={clientOptions}
|
||||
required
|
||||
error={clientError}
|
||||
onValueChange={setClientId}
|
||||
/>
|
||||
)}
|
||||
@@ -240,6 +231,8 @@ export default function NewInvoiceScreen() {
|
||||
value={invoiceNumber}
|
||||
onChangeText={setInvoiceNumber}
|
||||
autoCapitalize="characters"
|
||||
required
|
||||
error={invoiceNumberError}
|
||||
/>
|
||||
<DateTimeField
|
||||
label="Issue date"
|
||||
@@ -256,6 +249,7 @@ export default function NewInvoiceScreen() {
|
||||
value={taxRate}
|
||||
onChangeText={setTaxRate}
|
||||
keyboardType="decimal-pad"
|
||||
error={taxError}
|
||||
/>
|
||||
<Input
|
||||
label="Notes"
|
||||
@@ -298,12 +292,13 @@ export default function NewInvoiceScreen() {
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title="Create invoice"
|
||||
loading={createInvoice.isPending}
|
||||
disabled={clientOptions.length === 0}
|
||||
disabled={!canCreate}
|
||||
onPress={handleCreate}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
@@ -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
@@ -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 }]}>
|
||||
|
||||
@@ -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
@@ -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 }]}>
|
||||
|
||||
+1
-9
@@ -12,7 +12,7 @@ import {
|
||||
import { useFonts } from "expo-font";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import "react-native-reanimated";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
@@ -23,8 +23,6 @@ import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
|
||||
import { AuthProvider, useSession } from "@/contexts/AuthContext";
|
||||
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { TRPCProvider } from "@/lib/trpc";
|
||||
import { ensureWidgetBrandAssets } from "@/lib/widget-brand-assets";
|
||||
|
||||
export { ErrorBoundary } from "expo-router";
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -77,12 +75,6 @@ export default function RootLayout() {
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "ios") {
|
||||
void ensureWidgetBrandAssets();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user