Stabilize mobile auth session handling

This commit is contained in:
2026-06-29 17:58:50 -04:00
parent 477459edd4
commit 57e1aa658a
25 changed files with 1126 additions and 192 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app", "bundleIdentifier": "com.beenvoice.app",
"buildNumber": "13", "buildNumber": "17",
"icon": "./assets/beenvoice.icon", "icon": "./assets/beenvoice.icon",
"infoPlist": { "infoPlist": {
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,
+66 -4
View File
@@ -1,6 +1,7 @@
import { router } from "expo-router"; import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import {
Alert,
Pressable, Pressable,
RefreshControl, RefreshControl,
ScrollView, ScrollView,
@@ -15,6 +16,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface"; import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage"; import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView"; import { TabScrollView } from "@/components/TabScrollView";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
@@ -38,6 +40,12 @@ export default function EntitiesScreen() {
const clientsQuery = api.clients.getAll.useQuery(); const clientsQuery = api.clients.getAll.useQuery();
const businessesQuery = api.businesses.getAll.useQuery(); const businessesQuery = api.businesses.getAll.useQuery();
const deleteClient = api.clients.delete.useMutation({
onSuccess: () => void clientsQuery.refetch(),
});
const deleteBusiness = api.businesses.delete.useMutation({
onSuccess: () => void businessesQuery.refetch(),
});
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery; const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
const isLoading = const isLoading =
@@ -68,6 +76,20 @@ export default function EntitiesScreen() {
else void businessesQuery.refetch(); else void businessesQuery.refetch();
} }
function confirmDelete(id: string, name: string) {
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
if (tab === "clients") deleteClient.mutate({ id });
else deleteBusiness.mutate({ id });
},
},
]);
}
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage>
@@ -112,10 +134,29 @@ export default function EntitiesScreen() {
</View> </View>
) : ( ) : (
clients.map((client) => ( clients.map((client) => (
<Pressable <SwipeableRow
key={client.id} key={client.id}
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)} backgroundColor={colors.cardGlass}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline",
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => confirmDelete(client.id, client.name),
},
]}
> >
<Pressable onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}>
<GlassSurface style={styles.card}> <GlassSurface style={styles.card}>
<View style={styles.cardInner}> <View style={styles.cardInner}>
<Text style={styles.name}>{client.name}</Text> <Text style={styles.name}>{client.name}</Text>
@@ -131,6 +172,7 @@ export default function EntitiesScreen() {
</View> </View>
</GlassSurface> </GlassSurface>
</Pressable> </Pressable>
</SwipeableRow>
)) ))
) )
) : businesses.length === 0 ? ( ) : businesses.length === 0 ? (
@@ -142,10 +184,29 @@ export default function EntitiesScreen() {
</View> </View>
) : ( ) : (
businesses.map((business) => ( businesses.map((business) => (
<Pressable <SwipeableRow
key={business.id} key={business.id}
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)} backgroundColor={colors.cardGlass}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline",
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => confirmDelete(business.id, business.name),
},
]}
> >
<Pressable onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}>
<GlassSurface style={styles.card}> <GlassSurface style={styles.card}>
<View style={styles.cardInner}> <View style={styles.cardInner}>
<View style={styles.nameRow}> <View style={styles.nameRow}>
@@ -161,6 +222,7 @@ export default function EntitiesScreen() {
</View> </View>
</GlassSurface> </GlassSurface>
</Pressable> </Pressable>
</SwipeableRow>
)) ))
)} )}
</TabScrollView> </TabScrollView>
+37 -2
View File
@@ -20,6 +20,7 @@ import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus } from "@/lib/invoice-status"; import { getInvoiceStatus } from "@/lib/invoice-status";
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock"; import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed"; import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
export default function DashboardScreen() { export default function DashboardScreen() {
@@ -41,7 +42,7 @@ export default function DashboardScreen() {
<Screen> <Screen>
<View style={styles.errorBox}> <View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load dashboard</Text> <Text style={styles.errorTitle}>Could not load dashboard</Text>
<Text style={styles.errorText}>{statsQuery.error.message}</Text> <Text style={styles.errorText}>{formatTrpcErrorMessage(statsQuery.error)}</Text>
</View> </View>
</Screen> </Screen>
</AppBackground> </AppBackground>
@@ -62,7 +63,7 @@ export default function DashboardScreen() {
: "No change vs last month"; : "No change vs last month";
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1); const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
const sendReminderDue = stats.sendReminderDue ?? []; const sendReminderDue = stats.recentInvoices.filter((inv) => inv.status === "draft");
return ( return (
<AppBackground> <AppBackground>
@@ -146,8 +147,31 @@ export default function DashboardScreen() {
variant="secondary" variant="secondary"
onPress={() => router.push("/(app)/invoices")} onPress={() => router.push("/(app)/invoices")}
/> />
<Button
title="Reports"
variant="secondary"
onPress={() => router.push("/(app)/more/reports" as never)}
/>
</View> </View>
{stats.currentDraft ? (
<GlassSurface style={styles.alertGlass}>
<Pressable
style={styles.alertBanner}
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
>
<Text style={styles.alertTitle}>
Draft {stats.currentDraft.invoiceNumber}
</Text>
<Text style={styles.alertText}>
{stats.currentDraft.client?.name ?? "Client"} ·{" "}
{formatCurrency(stats.currentDraft.totalAmount)} ·{" "}
{stats.currentDraft.totalHours.toFixed(1)}h logged
</Text>
</Pressable>
</GlassSurface>
) : null}
<View style={styles.statsGrid}> <View style={styles.statsGrid}>
<View style={styles.statCell}> <View style={styles.statCell}>
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} /> <StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
@@ -190,6 +214,17 @@ export default function DashboardScreen() {
</View> </View>
</Card> </Card>
<Card title="Invoice status">
{(stats.statusChartData ?? []).map((item) => (
<View key={item.status} style={styles.invoiceRow}>
<Text style={styles.invoiceClient}>{item.name}</Text>
<Text style={styles.invoiceDate}>
{item.count} · {formatCurrency(item.value)}
</Text>
</View>
))}
</Card>
<Card title="Recent invoices"> <Card title="Recent invoices">
{stats.recentInvoices.length === 0 ? ( {stats.recentInvoices.length === 0 ? (
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text> <Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
+33 -6
View File
@@ -8,6 +8,7 @@ import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions"; import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { SwipeableRow } from "@/components/SwipeableRow";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
@@ -23,6 +24,7 @@ import { api } from "@/lib/trpc";
export default function InvoiceDetailScreen() { export default function InvoiceDetailScreen() {
const styles = useThemedStyles(createInvoiceDetailStyles); const styles = useThemedStyles(createInvoiceDetailStyles);
const { colors } = useAppTheme();
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils(); const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding(); const scrollPadding = useTabBarScrollPadding();
@@ -50,10 +52,9 @@ export default function InvoiceDetailScreen() {
onError: (err) => Alert.alert("Could not send reminder", err.message), onError: (err) => Alert.alert("Could not send reminder", err.message),
}); });
const invoice = invoiceQuery.data;
const previewInput = useMemo( const previewInput = useMemo(
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null), () => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
[invoice], [invoiceQuery.data],
); );
if (!id) { if (!id) {
@@ -78,6 +79,7 @@ export default function InvoiceDetailScreen() {
); );
} }
const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice); const status = getInvoiceStatus(invoice);
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0); const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100); const taxAmount = subtotal * (invoice.taxRate / 100);
@@ -209,8 +211,9 @@ export default function InvoiceDetailScreen() {
add lines manually. add lines manually.
</Text> </Text>
) : ( ) : (
invoice.items.map((item) => ( invoice.items.map((item) => {
<View key={item.id} style={styles.lineItem}> const line = (
<View style={styles.lineItem}>
<View style={styles.lineMeta}> <View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text> <Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}> <Text style={styles.lineSub}>
@@ -222,7 +225,31 @@ export default function InvoiceDetailScreen() {
{formatCurrency(item.amount, invoice.currency)} {formatCurrency(item.amount, invoice.currency)}
</Text> </Text>
</View> </View>
)) );
if (invoice.status !== "draft") {
return <View key={item.id}>{line}</View>;
}
return (
<SwipeableRow
key={item.id}
backgroundColor={colors.card}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
},
]}
>
{line}
</SwipeableRow>
);
})
)} )}
<InvoiceTotals <InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)} subtotal={formatCurrency(subtotal, invoice.currency)}
+10
View File
@@ -196,6 +196,15 @@ export default function InvoiceEditScreen() {
setItems((prev) => prev.filter((_, i) => i !== index)); setItems((prev) => prev.filter((_, i) => i !== index));
} }
function duplicateItem(index: number) {
setItems((prev) => {
const source = prev[index];
if (!source) return prev;
const copy = { ...source, id: undefined };
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
});
}
async function handleSave() { async function handleSave() {
if (!canSave) return; if (!canSave) return;
setError(null); setError(null);
@@ -325,6 +334,7 @@ export default function InvoiceEditScreen() {
isLast={index === items.length - 1} isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)} onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)} onRemove={() => removeItem(index)}
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
readOnly={!isDraft} readOnly={!isDraft}
/> />
))} ))}
+74 -6
View File
@@ -16,6 +16,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface"; import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { TabPage } from "@/components/TabPage"; import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView"; import { TabScrollView } from "@/components/TabScrollView";
@@ -25,6 +26,7 @@ import { formatCurrency, formatDate } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status"; import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [ const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
@@ -49,6 +51,14 @@ export default function InvoicesScreen() {
onError: (err) => Alert.alert("Update failed", err.message), onError: (err) => Alert.alert("Update failed", err.message),
}); });
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
utils.invoices.getAll.invalidate();
utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Delete failed", err.message),
});
if (invoicesQuery.isLoading) { if (invoicesQuery.isLoading) {
return <LoadingScreen message="Loading invoices…" />; return <LoadingScreen message="Loading invoices…" />;
} }
@@ -59,7 +69,7 @@ export default function InvoicesScreen() {
<TabPage> <TabPage>
<View style={styles.errorBox}> <View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load invoices</Text> <Text style={styles.errorTitle}>Could not load invoices</Text>
<Text style={styles.errorText}>{invoicesQuery.error.message}</Text> <Text style={styles.errorText}>{formatTrpcErrorMessage(invoicesQuery.error)}</Text>
</View> </View>
</TabPage> </TabPage>
</AppBackground> </AppBackground>
@@ -93,6 +103,17 @@ export default function InvoicesScreen() {
]); ]);
} }
function confirmDelete(invoiceId: string, label: string) {
Alert.alert("Delete invoice?", `Remove ${label}? This cannot be undone.`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteInvoice.mutate({ id: invoiceId }),
},
]);
}
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage>
@@ -134,9 +155,58 @@ export default function InvoicesScreen() {
) : ( ) : (
invoices.map((invoice) => { invoices.map((invoice) => {
const status = getInvoiceStatus(invoice); const status = getInvoiceStatus(invoice);
const label = `${invoice.invoicePrefix}${invoice.invoiceNumber}`;
const actions = [
{
key: "open",
label: "Open",
icon: "open-outline" as const,
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/invoices/${invoice.id}`),
},
...(status === "draft"
? [
{
key: "edit",
label: "Edit",
icon: "create-outline" as const,
color: "#fff",
backgroundColor: colors.mutedForeground,
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
},
{
key: "send",
label: "Send",
icon: "send-outline" as const,
color: "#fff",
backgroundColor: colors.success,
onPress: () => router.push(`/(app)/invoices/send/${invoice.id}`),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline" as const,
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => confirmDelete(invoice.id, label),
},
]
: [
{
key: "status",
label: "Status",
icon: "flag-outline" as const,
color: "#fff",
backgroundColor: colors.warning,
onPress: () => promptStatusChange(invoice.id, status),
},
]),
];
return ( return (
<SwipeableRow key={invoice.id} actions={actions} backgroundColor={colors.cardGlass}>
<Pressable <Pressable
key={invoice.id}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)} onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
onLongPress={() => promptStatusChange(invoice.id, status)} onLongPress={() => promptStatusChange(invoice.id, status)}
> >
@@ -144,10 +214,7 @@ export default function InvoicesScreen() {
<View style={styles.cardInner}> <View style={styles.cardInner}>
<View style={styles.cardTop}> <View style={styles.cardTop}>
<View style={styles.cardMeta}> <View style={styles.cardMeta}>
<Text style={styles.invoiceNumber}> <Text style={styles.invoiceNumber}>{label}</Text>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}> <Text style={styles.clientName}>
{invoice.client?.name ?? "Client"} {invoice.client?.name ?? "Client"}
</Text> </Text>
@@ -163,6 +230,7 @@ export default function InvoicesScreen() {
</View> </View>
</GlassSurface> </GlassSurface>
</Pressable> </Pressable>
</SwipeableRow>
); );
}) })
)} )}
+10
View File
@@ -203,6 +203,15 @@ export default function NewInvoiceScreen() {
setItems((prev) => prev.filter((_, i) => i !== index)); setItems((prev) => prev.filter((_, i) => i !== index));
} }
function duplicateItem(index: number) {
setItems((prev) => {
const source = prev[index];
if (!source) return prev;
const copy = { ...source, id: undefined };
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
});
}
function handleCreate() { function handleCreate() {
if (!canCreate) return; if (!canCreate) return;
setError(null); setError(null);
@@ -327,6 +336,7 @@ export default function NewInvoiceScreen() {
isLast={index === items.length - 1} isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)} onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)} onRemove={() => removeItem(index)}
onDuplicate={() => duplicateItem(index)}
/> />
))} ))}
+6 -5
View File
@@ -51,10 +51,10 @@ export default function InvoiceSendScreen() {
onError: (err) => Alert.alert("Could not send invoice", err.message), onError: (err) => Alert.alert("Could not send invoice", err.message),
}); });
const invoice = invoiceQuery.data;
const previewInput = useMemo( const previewInput = useMemo(
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null), () =>
[invoice], invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
[invoiceQuery.data],
); );
if (!id) { if (!id) {
@@ -65,10 +65,11 @@ export default function InvoiceSendScreen() {
return <LoadingScreen message="Loading invoice…" />; return <LoadingScreen message="Loading invoice…" />;
} }
if (!invoice) { if (!invoiceQuery.data) {
return <LoadingScreen message="Invoice not found" />; return <LoadingScreen message="Invoice not found" />;
} }
const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice); const status = getInvoiceStatus(invoice);
const clientEmail = invoice.client?.email?.trim() ?? ""; const clientEmail = invoice.client?.email?.trim() ?? "";
const businessName = invoice.business?.name ?? "Your business"; const businessName = invoice.business?.name ?? "Your business";
@@ -91,7 +92,7 @@ export default function InvoiceSendScreen() {
} }
sendInvoice.mutate({ sendInvoice.mutate({
invoiceId: invoice!.id, invoiceId: invoice.id,
customMessage: customMessage.trim() || undefined, customMessage: customMessage.trim() || undefined,
}); });
} }
+8 -3
View File
@@ -21,6 +21,7 @@ import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext"; import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account"; import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions"; import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
import { performAuthReset } from "@/lib/auth-session";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [ const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
@@ -89,16 +90,20 @@ export default function SettingsScreen() {
async (result) => { async (result) => {
await finishAccountRemoval({ await finishAccountRemoval({
result, result,
authClient,
clearActiveAccount, clearActiveAccount,
signOut: () => authClient.signOut(), activeAccountId,
}); });
}, },
); );
} }
async function handleSignOut() { async function handleSignOut() {
await authClient.signOut(); await performAuthReset({
await clearActiveAccount(); authClient,
clearActiveAccount,
activeAccountId,
});
router.replace("/(auth)/sign-in"); router.replace("/(auth)/sign-in");
} }
+6 -1
View File
@@ -81,11 +81,16 @@ export default function RegisterScreen() {
const session = await authClient.getSession(); const session = await authClient.getSession();
const user = session.data?.user; const user = session.data?.user;
if (user) { if (user) {
await completeSignInAfterAuth(authClient, { const completed = await completeSignInAfterAuth(authClient, {
apiUrl, apiUrl,
activeAccountId, activeAccountId,
registerAccount: saveAccount, registerAccount: saveAccount,
}); });
if (!completed) {
setError("Account created but session setup failed. Try signing in.");
}
} else {
setError("Account created. Sign in with your email and password.");
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Registration failed"); setError(err instanceof Error ? err.message : "Registration failed");
+3 -7
View File
@@ -19,6 +19,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { fetchAuthCapabilities } from "@/lib/auth-capabilities"; import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
import { signInWithAuthentik } from "@/lib/auth-oauth"; import { signInWithAuthentik } from "@/lib/auth-oauth";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in"; import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
import { formatAuthErrorMessage } from "@/lib/trpc-errors";
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation"; import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function SignInScreen() { export default function SignInScreen() {
@@ -81,12 +82,7 @@ export default function SignInScreen() {
}); });
if (signInError) { if (signInError) {
const message = signInError.message ?? ""; setError(formatAuthErrorMessage(signInError));
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; return;
} }
@@ -108,7 +104,7 @@ export default function SignInScreen() {
); );
if (oauthError) { if (oauthError) {
setError(oauthError.message ?? "Could not sign in with Authentik"); setError(formatAuthErrorMessage(oauthError));
return; return;
} }
+5 -2
View File
@@ -16,6 +16,7 @@ import { View } from "react-native";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import "react-native-reanimated"; import "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { BrandBackground } from "@/components/BrandBackground"; import { BrandBackground } from "@/components/BrandBackground";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
@@ -84,6 +85,7 @@ export default function RootLayout() {
} }
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider> <SafeAreaProvider>
<ThemeProvider> <ThemeProvider>
<ThemedChrome> <ThemedChrome>
@@ -95,17 +97,18 @@ export default function RootLayout() {
</ThemedChrome> </ThemedChrome>
</ThemeProvider> </ThemeProvider>
</SafeAreaProvider> </SafeAreaProvider>
</GestureHandlerRootView>
); );
} }
function RootNavigator() { function RootNavigator() {
const { data: session, isPending, error } = useSession(); const { data: session, isPending } = useSession();
if (isPending) { if (isPending) {
return <LoadingScreen message="Checking session…" />; return <LoadingScreen message="Checking session…" />;
} }
const isAuthenticated = Boolean(session?.user) && !error; const isAuthenticated = Boolean(session?.user);
return ( return (
<Stack <Stack
+2 -1
View File
@@ -92,8 +92,9 @@ export function AccountSwitcher() {
} }
await finishAccountRemoval({ await finishAccountRemoval({
result, result,
authClient,
clearActiveAccount, clearActiveAccount,
signOut: () => authClient.signOut(), activeAccountId,
}); });
}, },
); );
+29 -4
View File
@@ -1,12 +1,18 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native"; import { AppState, type AppStateStatus } from "react-native";
import { useSession } from "@/contexts/AuthContext"; import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { performAuthReset } from "@/lib/auth-session";
import { isRateLimitError } from "@/lib/trpc-errors";
/** Refetch auth session when the app returns to the foreground. */ /** Refetch auth session when the app returns to the foreground. */
export function SessionSync() { export function SessionSync() {
const { refetch } = useSession(); const authClient = useAuthClient();
const { activeAccountId, clearActiveAccount } = useAccounts();
const { data: session, refetch } = useSession();
const wasBackgrounded = useRef(false); const wasBackgrounded = useRef(false);
const resettingRef = useRef(false);
useEffect(() => { useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => { const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
@@ -17,11 +23,30 @@ export function SessionSync() {
if (nextState !== "active" || !wasBackgrounded.current) return; if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false; wasBackgrounded.current = false;
void refetch();
void (async () => {
await refetch();
const next = await authClient.getSession();
if (next.error && isRateLimitError(next.error)) return;
if (next.data?.user) return;
if (!session?.user || resettingRef.current) return;
resettingRef.current = true;
try {
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
refetchSession: refetch,
});
} finally {
resettingRef.current = false;
}
})();
}); });
return () => subscription.remove(); return () => subscription.remove();
}, [refetch]); }, [authClient, refetch, session?.user, activeAccountId, clearActiveAccount]);
return null; return null;
} }
+33 -1
View File
@@ -3,6 +3,7 @@ import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { CompactDateField } from "@/components/ui/CompactDateField"; import { CompactDateField } from "@/components/ui/CompactDateField";
import { CompactStepperInput } from "@/components/ui/CompactStepperInput"; import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
import { SwipeableRow } from "@/components/SwipeableRow";
import { fonts, radii, spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatShortDate } from "@/lib/format"; import { formatCurrency, formatShortDate } from "@/lib/format";
@@ -21,6 +22,7 @@ type LineItemEditorProps = {
currency: string; currency: string;
onChange: (patch: Partial<EditableLineItem>) => void; onChange: (patch: Partial<EditableLineItem>) => void;
onRemove: () => void; onRemove: () => void;
onDuplicate?: () => void;
readOnly?: boolean; readOnly?: boolean;
isLast?: boolean; isLast?: boolean;
}; };
@@ -38,6 +40,7 @@ export function LineItemEditor({
currency, currency,
onChange, onChange,
onRemove, onRemove,
onDuplicate,
readOnly = false, readOnly = false,
isLast = false, isLast = false,
}: LineItemEditorProps) { }: LineItemEditorProps) {
@@ -70,7 +73,7 @@ export function LineItemEditor({
); );
} }
return ( const content = (
<View <View
style={[ style={[
styles.editBlock, styles.editBlock,
@@ -156,6 +159,35 @@ export function LineItemEditor({
</View> </View>
</View> </View>
); );
const swipeActions = [
...(onDuplicate
? [
{
key: "duplicate",
label: "Copy",
icon: "copy-outline" as const,
color: "#fff",
backgroundColor: colors.primary,
onPress: onDuplicate,
},
]
: []),
{
key: "delete",
label: "Delete",
icon: "trash-outline" as const,
color: "#fff",
backgroundColor: colors.destructive,
onPress: onRemove,
},
];
return (
<SwipeableRow actions={swipeActions} backgroundColor={colors.card}>
{content}
</SwipeableRow>
);
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
+149 -41
View File
@@ -13,7 +13,9 @@ import { router } from "expo-router";
import { FilterChip } from "@/components/FilterChip"; import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface"; import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabScrollView } from "@/components/TabScrollView"; import { TabScrollView } from "@/components/TabScrollView";
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField"; import { DateTimeField } from "@/components/ui/DateTimeField";
@@ -98,6 +100,8 @@ export function TimeClockPanel({
const [agoMinutesText, setAgoMinutesText] = useState("60"); const [agoMinutesText, setAgoMinutesText] = useState("60");
const [optionsExpanded, setOptionsExpanded] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false);
const [clientsExpanded, setClientsExpanded] = useState(false); const [clientsExpanded, setClientsExpanded] = useState(false);
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]); const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null); const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
const [prefsLoaded, setPrefsLoaded] = useState(false); const [prefsLoaded, setPrefsLoaded] = useState(false);
@@ -140,6 +144,15 @@ export function TimeClockPanel({
}, },
}); });
const updateRunning = api.timeEntries.updateRunning.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.invoices.getBillable.invalidate(),
]);
},
});
const clockOut = api.timeEntries.clockOut.useMutation({ const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => { onSuccess: async (data) => {
await endTimeClockLiveActivity(); await endTimeClockLiveActivity();
@@ -184,8 +197,10 @@ export function TimeClockPanel({
if (!running) return; if (!running) return;
setClientId(running.clientId ?? ""); setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? ""); setInvoiceId(running.invoiceId ?? "");
setDescription(running.description ?? "");
setStopNote(""); setStopNote("");
setRateText(running.rate != null ? String(running.rate) : ""); setRateText(running.rate != null ? String(running.rate) : "");
setRunningStartedAt(new Date(running.startedAt));
}, [running]); }, [running]);
useEffect(() => { useEffect(() => {
@@ -269,8 +284,7 @@ export function TimeClockPanel({
}, [agoMinutes, startMode, startedAt]); }, [agoMinutes, startMode, startedAt]);
const clockInErrors = useMemo(() => { const clockInErrors = useMemo(() => {
const next: { clientId?: string; rate?: string; start?: string } = {}; const next: { rate?: string; start?: string } = {};
if (!clientId) next.clientId = "Choose a client to start";
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) { if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate"; next.rate = "Enter a valid hourly rate";
} }
@@ -278,7 +292,7 @@ export function TimeClockPanel({
next.start = "Enter how long ago you started"; next.start = "Enter how long ago you started";
} }
return next; return next;
}, [agoMinutes, clientId, rateText, startMode]); }, [agoMinutes, rateText, startMode]);
const canClockIn = Object.keys(clockInErrors).length === 0; const canClockIn = Object.keys(clockInErrors).length === 0;
@@ -307,6 +321,14 @@ export function TimeClockPanel({
function selectClient(nextClientId: string) { function selectClient(nextClientId: string) {
const client = clients.find((c) => c.id === nextClientId); const client = clients.find((c) => c.id === nextClientId);
if (running) {
updateRunning.mutate({
clientId: nextClientId,
invoiceId: "",
rate: client?.defaultHourlyRate ?? undefined,
});
return;
}
setClientId(nextClientId); setClientId(nextClientId);
setInvoiceId(""); setInvoiceId("");
setRateText(clientRateText(client)); setRateText(clientRateText(client));
@@ -316,6 +338,27 @@ export function TimeClockPanel({
void persistClientChoice(nextClientId); void persistClientChoice(nextClientId);
} }
function selectInvoice(nextInvoiceId: string) {
if (running) {
updateRunning.mutate({ invoiceId: nextInvoiceId || "" });
return;
}
setInvoiceId(nextInvoiceId);
}
function handleRunningDescriptionBlur() {
if (!running) return;
const next = resolveClockDescription(description);
if (next === (running.description ?? "")) return;
updateRunning.mutate({ description: next });
}
function handleRunningStartedAtChange(date: Date) {
setRunningStartedAt(date);
if (!running || date > new Date()) return;
updateRunning.mutate({ startedAt: date });
}
function selectStartMode(mode: StartMode) { function selectStartMode(mode: StartMode) {
setStartMode(mode); setStartMode(mode);
if (mode !== "now") setOptionsExpanded(true); if (mode !== "now") setOptionsExpanded(true);
@@ -363,7 +406,9 @@ export function TimeClockPanel({
rate: effectiveRate ?? undefined, rate: effectiveRate ?? undefined,
startedAt: backdated, startedAt: backdated,
}); });
if (clientId) {
await persistClientChoice(clientId); await persistClientChoice(clientId);
}
setStartMode("now"); setStartMode("now");
setStartedAt(new Date()); setStartedAt(new Date());
setAgoMinutes(60); setAgoMinutes(60);
@@ -445,7 +490,7 @@ export function TimeClockPanel({
</> </>
) : ( ) : (
<Text style={styles.idleHint}> <Text style={styles.idleHint}>
Choose a client and clock in. A draft invoice is created automatically if needed. Start the timer anytime add client, invoice, and details later.
</Text> </Text>
)} )}
</View> </View>
@@ -457,6 +502,62 @@ export function TimeClockPanel({
{running ? ( {running ? (
<View style={styles.formSection}> <View style={styles.formSection}>
<Input
label="What are you working on?"
value={description}
onChangeText={setDescription}
onBlur={handleRunningDescriptionBlur}
placeholder="What are you working on?"
returnKeyType="done"
/>
<DateTimeField
label="Started at"
value={runningStartedAt}
maximumDate={new Date()}
onChange={handleRunningStartedAtChange}
/>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Client</Text>
<View style={styles.chipWrap}>
<FilterChip
label="None"
active={!clientId}
onPress={() => selectClient("")}
/>
{clients.map((client) => (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => selectClient(client.id)}
/>
))}
</View>
</View>
{clientId ? (
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Entry only"
active={!invoiceId}
onPress={() => selectInvoice("")}
/>
{billableInvoices.map((invoice) => (
<FilterChip
key={invoice.id}
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
active={invoiceId === invoice.id}
onPress={() => selectInvoice(invoice.id)}
/>
))}
</View>
</View>
) : null}
<Input <Input
label="Note on stop (optional)" label="Note on stop (optional)"
value={stopNote} value={stopNote}
@@ -490,7 +591,7 @@ export function TimeClockPanel({
<Text style={styles.sectionLabel}>Client</Text> <Text style={styles.sectionLabel}>Client</Text>
{clients.length === 0 ? ( {clients.length === 0 ? (
<Text style={styles.emptyClients}> <Text style={styles.emptyClients}>
Add a client first to start tracking time. No clients yet you can still start the timer and assign a client later.
</Text> </Text>
) : ( ) : (
<> <>
@@ -511,20 +612,15 @@ export function TimeClockPanel({
) : null} ) : null}
</> </>
)} )}
{clockInErrors.clientId ? (
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
) : null}
</View> </View>
{clientId ? (
<View style={styles.setupSection}> <View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice</Text> <Text style={styles.sectionLabel}>Invoice (optional)</Text>
{!clientId ? (
<Text style={styles.emptyClients}>Pick a client to attach a draft invoice.</Text>
) : (
<View style={styles.chipWrap}> <View style={styles.chipWrap}>
<FilterChip <FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
label="Entry only"
active={!invoiceId}
onPress={() => setInvoiceId("")}
/>
{billableInvoices.map((invoice) => { {billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`; const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
return ( return (
@@ -537,10 +633,9 @@ export function TimeClockPanel({
); );
})} })}
</View> </View>
)}
</View> </View>
) : null}
{clientId ? (
<View style={styles.setupSection}> <View style={styles.setupSection}>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
@@ -647,12 +742,11 @@ export function TimeClockPanel({
</View> </View>
) : null} ) : null}
</View> </View>
) : null}
<Button <Button
title={clockIn.isPending ? "Starting…" : "Start timer"} title={clockIn.isPending ? "Starting…" : "Start timer"}
loading={clockIn.isPending} loading={clockIn.isPending}
disabled={!canClockIn || clients.length === 0} disabled={!canClockIn}
showArrow={!clockIn.isPending} showArrow={!clockIn.isPending}
onPress={handleClockIn} onPress={handleClockIn}
/> />
@@ -667,8 +761,35 @@ export function TimeClockPanel({
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}` ? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null; : null;
const row = ( return (
<> <SwipeableRow
key={entry.id}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => setEditEntryId(entry.id),
},
{
key: "invoice",
label: "Invoice",
icon: "document-text-outline",
color: "#fff",
backgroundColor: colors.mutedForeground,
onPress: () => {
if (entry.invoice?.id) {
router.push(`/(app)/invoices/${entry.invoice.id}`);
} else {
setEditEntryId(entry.id);
}
},
},
]}
>
<View style={styles.entryRow}>
<View style={styles.entryMeta}> <View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text> <Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
<Text style={styles.entrySub}> <Text style={styles.entrySub}>
@@ -677,31 +798,18 @@ export function TimeClockPanel({
</Text> </Text>
</View> </View>
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text> <Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
</>
);
if (!entry.invoice) {
return (
<View key={entry.id} style={styles.entryRow}>
{row}
</View> </View>
); </SwipeableRow>
}
return (
<Pressable
key={entry.id}
accessibilityRole="button"
accessibilityLabel={`View invoice ${invoiceLabel}`}
onPress={() => router.push(`/(app)/invoices/${entry.invoice!.id}`)}
style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
>
{row}
</Pressable>
); );
})} })}
</Card> </Card>
) : null} ) : null}
<TimeEntryEditSheet
entryId={editEntryId}
visible={editEntryId != null}
onClose={() => setEditEntryId(null)}
/>
</TabScrollView> </TabScrollView>
); );
} }
@@ -0,0 +1,267 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { FilterChip } from "@/components/FilterChip";
import { Button } from "@/components/ui/Button";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type TimeEntryEditSheetProps = {
entryId: string | null;
visible: boolean;
onClose: () => void;
};
export function TimeEntryEditSheet({ entryId, visible, onClose }: TimeEntryEditSheetProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles);
const utils = api.useUtils();
const entryQuery = api.timeEntries.getById.useQuery(
{ id: entryId ?? "" },
{ enabled: visible && Boolean(entryId) },
);
const clientsQuery = api.clients.getAll.useQuery(undefined, { enabled: visible });
const [description, setDescription] = useState("");
const [clientId, setClientId] = useState("");
const [invoiceId, setInvoiceId] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const [endedAt, setEndedAt] = useState(() => new Date());
const billableQuery = api.invoices.getBillable.useQuery(
clientId ? { clientId } : undefined,
{ enabled: visible && Boolean(clientId) },
);
useEffect(() => {
const entry = entryQuery.data;
if (!entry) return;
setDescription(entry.description ?? "");
setClientId(entry.clientId ?? "");
setInvoiceId(entry.invoiceId ?? "");
setRateText(entry.rate != null ? String(entry.rate) : "");
setStartedAt(new Date(entry.startedAt));
setEndedAt(entry.endedAt ? new Date(entry.endedAt) : new Date());
}, [entryQuery.data]);
const hoursPreview = useMemo(() => {
if (endedAt <= startedAt) return null;
return Math.max(0, (endedAt.getTime() - startedAt.getTime()) / 3_600_000);
}, [endedAt, startedAt]);
const rate = parseNonNegativeNumber(rateText);
const updateEntry = api.timeEntries.update.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.timeEntries.getById.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (err) => Alert.alert("Could not save", err.message),
});
const deleteEntry = api.timeEntries.delete.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (err) => Alert.alert("Could not delete", err.message),
});
function handleSave() {
if (!entryId) return;
if (endedAt <= startedAt) {
Alert.alert("Invalid times", "End time must be after start time.");
return;
}
updateEntry.mutate({
id: entryId,
description,
clientId: clientId || "",
invoiceId: invoiceId || "",
rate: rate ?? undefined,
startedAt,
endedAt,
hours: hoursPreview ?? undefined,
});
}
function confirmDelete() {
if (!entryId) return;
Alert.alert("Delete time entry?", "This removes the entry and any linked invoice line.", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteEntry.mutate({ id: entryId }),
},
]);
}
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.foreground }]}>Edit time entry</Text>
<Pressable onPress={onClose} hitSlop={8}>
<Text style={[styles.close, { color: colors.mutedForeground }]}>Close</Text>
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{entryQuery.isLoading ? (
<Text style={{ color: colors.mutedForeground }}>Loading</Text>
) : (
<>
<Input label="Description" value={description} onChangeText={setDescription} />
<Text style={[styles.label, { color: colors.foreground }]}>Client</Text>
<View style={styles.chipWrap}>
<FilterChip
label="None"
active={!clientId}
onPress={() => {
setClientId("");
setInvoiceId("");
}}
/>
{(clientsQuery.data ?? []).map((client) => (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => {
setClientId(client.id);
setInvoiceId("");
}}
/>
))}
</View>
{clientId ? (
<>
<Text style={[styles.label, { color: colors.foreground }]}>Invoice</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Not on invoice"
active={!invoiceId}
onPress={() => setInvoiceId("")}
/>
{(billableQuery.data ?? []).map((invoice) => (
<FilterChip
key={invoice.id}
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
active={invoiceId === invoice.id}
onPress={() => setInvoiceId(invoice.id)}
/>
))}
</View>
</>
) : null}
<Input
label="Hourly rate"
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
/>
<DateTimeField
label="Started"
value={startedAt}
maximumDate={endedAt}
onChange={setStartedAt}
/>
<DateTimeField label="Ended" value={endedAt} minimumDate={startedAt} onChange={setEndedAt} />
{hoursPreview != null ? (
<Text style={[styles.preview, { color: colors.mutedForeground }]}>
{hoursPreview.toFixed(2)}h
{rate != null && rate > 0
? ` · ${formatCurrency(hoursPreview * rate)}`
: ""}
</Text>
) : null}
<Button title="Save changes" loading={updateEntry.isPending} onPress={handleSave} />
<Button
title="Delete entry"
variant="danger"
loading={deleteEntry.isPending}
onPress={confirmDelete}
/>
</>
)}
</ScrollView>
</View>
</Modal>
);
}
const createStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 18,
},
close: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
body: {
padding: spacing.lg,
gap: spacing.md,
},
label: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
chipWrap: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.sm,
},
preview: {
fontFamily: fonts.body,
fontSize: 14,
},
});
+13 -4
View File
@@ -1,24 +1,33 @@
import { router } from "expo-router"; import { router } from "expo-router";
import { Alert } from "react-native"; import { Alert } from "react-native";
import type { createAuthClient } from "better-auth/react";
import type { RemoveAccountResult } from "@/contexts/AccountsContext"; import type { RemoveAccountResult } from "@/contexts/AccountsContext";
import { performAuthReset } from "@/lib/auth-session";
type AuthClient = ReturnType<typeof createAuthClient>;
type FinishAccountRemovalInput = { type FinishAccountRemovalInput = {
result: RemoveAccountResult; result: RemoveAccountResult;
authClient: AuthClient;
clearActiveAccount: () => Promise<void>; clearActiveAccount: () => Promise<void>;
signOut: () => Promise<unknown>; activeAccountId: string | null;
}; };
/** Navigate to sign-in when the last saved account was removed. */ /** Navigate to sign-in when the last saved account was removed. */
export async function finishAccountRemoval({ export async function finishAccountRemoval({
result, result,
authClient,
clearActiveAccount, clearActiveAccount,
signOut, activeAccountId,
}: FinishAccountRemovalInput): Promise<void> { }: FinishAccountRemovalInput): Promise<void> {
if (result.remainingCount > 0) return; if (result.remainingCount > 0) return;
await signOut(); await performAuthReset({
await clearActiveAccount(); authClient,
clearActiveAccount,
activeAccountId,
});
router.replace("/(auth)/sign-in"); router.replace("/(auth)/sign-in");
} }
+4 -10
View File
@@ -1,11 +1,5 @@
import { getApiUrl } from "@/lib/config"; import { getApiUrl } from "@/lib/config";
import { readHttpErrorMessage } from "@/lib/trpc-errors";
type ApiError = { error?: string; message?: string };
async function parseError(res: Response) {
const data = (await res.json().catch(() => ({}))) as ApiError;
return data.error ?? data.message ?? "Something went wrong";
}
export async function registerAccount(input: { export async function registerAccount(input: {
firstName: string; firstName: string;
@@ -20,7 +14,7 @@ export async function registerAccount(input: {
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(await parseError(res)); throw new Error(await readHttpErrorMessage(res));
} }
} }
@@ -32,7 +26,7 @@ export async function requestPasswordReset(email: string) {
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(await parseError(res)); throw new Error(await readHttpErrorMessage(res));
} }
const data = (await res.json()) as { message?: string }; const data = (await res.json()) as { message?: string };
@@ -47,6 +41,6 @@ export async function resetPassword(token: string, password: string) {
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(await parseError(res)); throw new Error(await readHttpErrorMessage(res));
} }
} }
+55
View File
@@ -0,0 +1,55 @@
import { getCookie as serializeStoredCookies } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
import type { createAuthClient } from "better-auth/react";
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
type AuthClient = ReturnType<typeof createAuthClient>;
const CHUNK_MARKER = "\u0001ba-chunks:";
function readSecureStoreValueSync(key: string): string | null {
const value = SecureStore.getItem(key);
if (value == null) return null;
if (!value.startsWith(CHUNK_MARKER)) return value;
const count = Number(value.slice(CHUNK_MARKER.length));
if (!Number.isInteger(count) || count < 1) return null;
let assembled = "";
for (let index = 0; index < count; index += 1) {
const chunk = SecureStore.getItem(`${key}.${index}`);
if (chunk == null) return null;
assembled += chunk;
}
return assembled;
}
/** Read session cookie string for tRPC requests (Expo client plugin + SecureStore fallback). */
export function getAuthCookie(
authClient: AuthClient,
storagePrefix: string,
): string | null {
const fromClient = (
authClient as AuthClient & { getCookie?: () => string }
).getCookie?.();
if (fromClient?.trim()) return fromClient.trim();
const raw = readSecureStoreValueSync(
normalizeSecureStoreKey(`${storagePrefix}_cookie`),
);
if (!raw || raw === "{}") return null;
const cookie = serializeStoredCookies(raw);
return cookie.trim() || null;
}
export function getAuthCookieHeaders(
authClient: AuthClient,
storagePrefix: string,
): Record<string, string> {
const cookie = getAuthCookie(authClient, storagePrefix);
return cookie
? { cookie, Cookie: cookie, "x-beenvoice-auth-cookie": cookie }
: {};
}
+60
View File
@@ -0,0 +1,60 @@
import type { createAuthClient } from "better-auth/react";
import { authStoragePrefix } from "@/lib/accounts";
import {
clearAuthStorage,
GUEST_AUTH_STORAGE_PREFIX,
prepareForAdditionalSignIn,
} from "@/lib/auth-storage";
type AuthClient = ReturnType<typeof createAuthClient>;
type PerformAuthResetInput = {
authClient: AuthClient;
clearActiveAccount: () => Promise<void>;
activeAccountId?: string | null;
refetchSession?: () => Promise<unknown>;
};
/** Sign out, wipe local auth storage, and return to guest mode for a clean sign-in screen. */
export async function performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
refetchSession,
}: PerformAuthResetInput): Promise<void> {
const accountPrefix = activeAccountId ? authStoragePrefix(activeAccountId) : null;
try {
await authClient.signOut();
} catch {
// Continue clearing local state even when the server session is already gone.
}
if (accountPrefix) {
await clearAuthStorage(accountPrefix);
}
await clearAuthStorage(GUEST_AUTH_STORAGE_PREFIX);
await clearActiveAccount();
await refetchSession?.();
}
/**
* When the auth stack is shown, discard stale SecureStore sessions so sign-in starts clean.
* Expired account sessions switch back to guest storage; orphaned guest copies are cleared.
*/
export async function prepareAuthScreenSession(
authClient: AuthClient,
activeAccountId: string | null,
clearActiveAccount: () => Promise<void>,
): Promise<void> {
const session = await authClient.getSession();
if (session.data?.user) return;
if (activeAccountId) {
await clearAuthStorage(authStoragePrefix(activeAccountId));
await clearActiveAccount();
}
await prepareForAdditionalSignIn();
}
+2
View File
@@ -74,6 +74,8 @@ export async function migrateAuthStorage(fromPrefix: string, toPrefix: string):
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)), copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
), ),
); );
await clearAuthStorage(fromPrefix);
} }
export async function clearAuthStorage(prefix: string): Promise<void> { export async function clearAuthStorage(prefix: string): Promise<void> {
+8 -2
View File
@@ -1,6 +1,6 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { isUnauthorizedError } from "@/lib/trpc-errors"; import { isRateLimitError, isUnauthorizedError } from "@/lib/trpc-errors";
export function createAppQueryClient(onUnauthorized: () => void) { export function createAppQueryClient(onUnauthorized: () => void) {
const handleError = (error: unknown) => { const handleError = (error: unknown) => {
@@ -16,7 +16,13 @@ export function createAppQueryClient(onUnauthorized: () => void) {
queries: { queries: {
staleTime: 30_000, staleTime: 30_000,
retry: (failureCount, error) => { retry: (failureCount, error) => {
if (isUnauthorizedError(error)) return false; if (isUnauthorizedError(error) || isRateLimitError(error)) return false;
return failureCount < 1;
},
},
mutations: {
retry: (failureCount, error) => {
if (isRateLimitError(error)) return false;
return failureCount < 1; return failureCount < 1;
}, },
}, },
+116 -3
View File
@@ -1,8 +1,121 @@
import { TRPCClientError } from "@trpc/client"; import { TRPCClientError } from "@trpc/client";
export function isUnauthorizedError(error: unknown): boolean { function errorMessage(error: unknown): string {
if (error instanceof TRPCClientError) return error.message;
if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message: unknown }).message;
if (typeof message === "string") return message;
}
return "";
}
function errorStatus(error: unknown): number | undefined {
if (typeof error !== "object" || error === null || !("status" in error)) return undefined;
const status = (error as { status: unknown }).status;
return typeof status === "number" ? status : undefined;
}
export function parseRetryAfterSeconds(value: string | number | null | undefined): number | null {
if (value == null || value === "") return null;
const seconds = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Math.ceil(seconds);
}
export function isRateLimitError(error: unknown): boolean {
if (errorStatus(error) === 429) return true;
if (error instanceof TRPCClientError && error.data?.code === "TOO_MANY_REQUESTS") {
return true;
}
const message = errorMessage(error).toLowerCase();
return ( return (
error instanceof TRPCClientError && message.includes("too many") ||
(error.data?.code === "UNAUTHORIZED" || error.message === "UNAUTHORIZED") message.includes("rate limit") ||
message.includes("try again later")
); );
} }
export function isUnauthorizedError(error: unknown): boolean {
if (isRateLimitError(error)) return false;
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") return true;
}
if (errorStatus(error) === 401) return true;
const message = errorMessage(error).toLowerCase();
return message === "unauthorized" || message.includes("not authenticated");
}
export function formatRateLimitMessage(retryAfterSeconds?: number | null): string {
const retryAfter = parseRetryAfterSeconds(retryAfterSeconds ?? null);
if (retryAfter != null) {
if (retryAfter < 60) {
return `Too many attempts. Wait ${retryAfter} second${retryAfter === 1 ? "" : "s"} and try again.`;
}
const minutes = Math.ceil(retryAfter / 60);
return `Too many attempts. Wait about ${minutes} minute${minutes === 1 ? "" : "s"} and try again.`;
}
return "Too many attempts. Please wait a moment and try again.";
}
export function formatTrpcErrorMessage(error: unknown, fallback = "Something went wrong"): string {
if (isRateLimitError(error)) {
return formatRateLimitMessage();
}
if (isUnauthorizedError(error)) {
return "Your session expired. Sign in again to continue.";
}
if (error instanceof TRPCClientError) {
return error.message || fallback;
}
if (error instanceof Error) {
return error.message || fallback;
}
return fallback;
}
type AuthLikeError = {
message?: string;
status?: number;
};
export function formatAuthErrorMessage(error: AuthLikeError | null | undefined): string {
if (!error) return "Something went wrong";
if (isRateLimitError(error)) {
return formatRateLimitMessage();
}
const message = error.message ?? "";
if (message.toLowerCase().includes("internal") || message.includes("500")) {
return "Server error — is the API running with Postgres? Check beenvoice dev + docker.";
}
return message || "Invalid email or password";
}
export async function readHttpErrorMessage(response: Response): Promise<string> {
if (response.status === 429) {
const retryAfter = parseRetryAfterSeconds(
response.headers.get("x-retry-after") ?? response.headers.get("retry-after"),
);
return formatRateLimitMessage(retryAfter);
}
const data = (await response.json().catch(() => ({}))) as {
error?: string;
message?: string;
};
const message = data.error ?? data.message;
if (message && isRateLimitError({ message, status: response.status })) {
return formatRateLimitMessage();
}
return message ?? "Something went wrong";
}
+50 -10
View File
@@ -1,25 +1,65 @@
import { httpBatchLink } from "@trpc/client"; import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query"; import { createTRPCReact } from "@trpc/react-query";
import { useCallback, useRef, useState, type ReactNode } from "react"; import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext"; import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { getAuthCookieHeaders } from "@/lib/auth-cookie";
import { performAuthReset } from "@/lib/auth-session";
import { createAppQueryClient } from "@/lib/query-client"; import { createAppQueryClient } from "@/lib/query-client";
import type { AppRouter } from "beenvoice/server/api/root"; import type { AppRouter } from "beenvoice/server/api/root";
export const api = createTRPCReact<AppRouter>(); export const api = createTRPCReact<AppRouter>();
export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: ReactNode }) { export function TRPCProvider({
apiUrl,
children,
}: {
apiUrl: string;
children: ReactNode;
}) {
const authClient = useAuthClient(); const authClient = useAuthClient();
const { authStoragePrefix, activeAccountId, clearActiveAccount } =
useAccounts();
const { refetch } = useSession(); const { refetch } = useSession();
const authStoragePrefixRef = useRef(authStoragePrefix);
authStoragePrefixRef.current = authStoragePrefix;
const mountedRef = useRef(true);
const resettingRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const handleUnauthorized = useCallback(async () => { const handleUnauthorized = useCallback(async () => {
if (!activeAccountId || resettingRef.current || !mountedRef.current) return;
const session = await authClient.getSession(); const session = await authClient.getSession();
if (!session.data?.user) { if (session.data?.user || !mountedRef.current) return;
await authClient.signOut();
await refetch(); resettingRef.current = true;
try {
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
refetchSession: refetch,
});
} finally {
resettingRef.current = false;
} }
}, [authClient, refetch]); }, [authClient, clearActiveAccount, activeAccountId, refetch]);
const onUnauthorizedRef = useRef(handleUnauthorized); const onUnauthorizedRef = useRef(handleUnauthorized);
onUnauthorizedRef.current = handleUnauthorized; onUnauthorizedRef.current = handleUnauthorized;
@@ -37,10 +77,10 @@ export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: R
url: `${apiUrl}/api/trpc`, url: `${apiUrl}/api/trpc`,
transformer: SuperJSON, transformer: SuperJSON,
headers() { headers() {
const cookie = ( return getAuthCookieHeaders(
authClient as { getCookie?: () => string | null | undefined } authClient,
).getCookie?.(); authStoragePrefixRef.current,
return cookie ? { cookie } : {}; );
}, },
}), }),
], ],