diff --git a/app.json b/app.json
index 41833f5..2322639 100644
--- a/app.json
+++ b/app.json
@@ -10,7 +10,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app",
- "buildNumber": "13",
+ "buildNumber": "17",
"icon": "./assets/beenvoice.icon",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
diff --git a/app/(app)/entities/index.tsx b/app/(app)/entities/index.tsx
index f79f95c..0778aba 100644
--- a/app/(app)/entities/index.tsx
+++ b/app/(app)/entities/index.tsx
@@ -1,6 +1,7 @@
import { router } from "expo-router";
import { useState } from "react";
import {
+ Alert,
Pressable,
RefreshControl,
ScrollView,
@@ -15,6 +16,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
+import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { fonts, spacing } from "@/constants/theme";
@@ -38,6 +40,12 @@ export default function EntitiesScreen() {
const clientsQuery = api.clients.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 isLoading =
@@ -68,6 +76,20 @@ export default function EntitiesScreen() {
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 (
@@ -112,25 +134,45 @@ export default function EntitiesScreen() {
) : (
clients.map((client) => (
- 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),
+ },
+ ]}
>
-
-
- {client.name}
- {client.email ? (
- {client.email}
- ) : null}
- {client.defaultHourlyRate != null ? (
-
- {formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
- /hr
-
- ) : null}
-
-
-
+ router.push(`/(app)/entities/clients/${client.id}`)}>
+
+
+ {client.name}
+ {client.email ? (
+ {client.email}
+ ) : null}
+ {client.defaultHourlyRate != null ? (
+
+ {formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
+ /hr
+
+ ) : null}
+
+
+
+
))
)
) : businesses.length === 0 ? (
@@ -142,25 +184,45 @@ export default function EntitiesScreen() {
) : (
businesses.map((business) => (
- 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),
+ },
+ ]}
>
-
-
-
- {business.name}
- {business.isDefault ? (
- Default
+ router.push(`/(app)/entities/businesses/${business.id}`)}>
+
+
+
+ {business.name}
+ {business.isDefault ? (
+ Default
+ ) : null}
+
+ {business.nickname ? (
+ {business.nickname}
) : null}
+ {business.email ? {business.email} : null}
- {business.nickname ? (
- {business.nickname}
- ) : null}
- {business.email ? {business.email} : null}
-
-
-
+
+
+
))
)}
diff --git a/app/(app)/index.tsx b/app/(app)/index.tsx
index bdb64b8..36184a3 100644
--- a/app/(app)/index.tsx
+++ b/app/(app)/index.tsx
@@ -20,6 +20,7 @@ import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
+import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc";
export default function DashboardScreen() {
@@ -41,7 +42,7 @@ export default function DashboardScreen() {
Could not load dashboard
- {statsQuery.error.message}
+ {formatTrpcErrorMessage(statsQuery.error)}
@@ -62,7 +63,7 @@ export default function DashboardScreen() {
: "No change vs last month";
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 (
@@ -146,8 +147,31 @@ export default function DashboardScreen() {
variant="secondary"
onPress={() => router.push("/(app)/invoices")}
/>
+
@@ -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 (
@@ -134,35 +155,82 @@ export default function InvoicesScreen() {
) : (
invoices.map((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 (
- router.push(`/(app)/invoices/${invoice.id}`)}
- onLongPress={() => promptStatusChange(invoice.id, status)}
- >
-
-
-
-
-
- {invoice.invoicePrefix}
- {invoice.invoiceNumber}
-
-
- {invoice.client?.name ?? "Client"}
+
+ router.push(`/(app)/invoices/${invoice.id}`)}
+ onLongPress={() => promptStatusChange(invoice.id, status)}
+ >
+
+
+
+
+ {label}
+
+ {invoice.client?.name ?? "Client"}
+
+
+
+ {formatCurrency(invoice.totalAmount, invoice.currency)}
-
- {formatCurrency(invoice.totalAmount, invoice.currency)}
-
+
+ Due {formatDate(invoice.dueDate)}
+
+
-
- Due {formatDate(invoice.dueDate)}
-
-
-
-
-
+
+
+
);
})
)}
diff --git a/app/(app)/invoices/new.tsx b/app/(app)/invoices/new.tsx
index 59d7115..a21c2d1 100644
--- a/app/(app)/invoices/new.tsx
+++ b/app/(app)/invoices/new.tsx
@@ -203,6 +203,15 @@ export default function NewInvoiceScreen() {
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() {
if (!canCreate) return;
setError(null);
@@ -327,6 +336,7 @@ export default function NewInvoiceScreen() {
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
+ onDuplicate={() => duplicateItem(index)}
/>
))}
diff --git a/app/(app)/invoices/send/[id].tsx b/app/(app)/invoices/send/[id].tsx
index 282699f..070fbeb 100644
--- a/app/(app)/invoices/send/[id].tsx
+++ b/app/(app)/invoices/send/[id].tsx
@@ -51,10 +51,10 @@ export default function InvoiceSendScreen() {
onError: (err) => Alert.alert("Could not send invoice", err.message),
});
- const invoice = invoiceQuery.data;
const previewInput = useMemo(
- () => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
- [invoice],
+ () =>
+ invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
+ [invoiceQuery.data],
);
if (!id) {
@@ -65,10 +65,11 @@ export default function InvoiceSendScreen() {
return ;
}
- if (!invoice) {
+ if (!invoiceQuery.data) {
return ;
}
+ const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice);
const clientEmail = invoice.client?.email?.trim() ?? "";
const businessName = invoice.business?.name ?? "Your business";
@@ -91,7 +92,7 @@ export default function InvoiceSendScreen() {
}
sendInvoice.mutate({
- invoiceId: invoice!.id,
+ invoiceId: invoice.id,
customMessage: customMessage.trim() || undefined,
});
}
diff --git a/app/(app)/settings.tsx b/app/(app)/settings.tsx
index 81bf957..b83b773 100644
--- a/app/(app)/settings.tsx
+++ b/app/(app)/settings.tsx
@@ -21,6 +21,7 @@ import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
+import { performAuthReset } from "@/lib/auth-session";
import { api } from "@/lib/trpc";
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
@@ -89,16 +90,20 @@ export default function SettingsScreen() {
async (result) => {
await finishAccountRemoval({
result,
+ authClient,
clearActiveAccount,
- signOut: () => authClient.signOut(),
+ activeAccountId,
});
},
);
}
async function handleSignOut() {
- await authClient.signOut();
- await clearActiveAccount();
+ await performAuthReset({
+ authClient,
+ clearActiveAccount,
+ activeAccountId,
+ });
router.replace("/(auth)/sign-in");
}
diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx
index bab2fd8..00fb02c 100644
--- a/app/(auth)/register.tsx
+++ b/app/(auth)/register.tsx
@@ -81,11 +81,16 @@ export default function RegisterScreen() {
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
- await completeSignInAfterAuth(authClient, {
+ const completed = await completeSignInAfterAuth(authClient, {
apiUrl,
activeAccountId,
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) {
setError(err instanceof Error ? err.message : "Registration failed");
diff --git a/app/(auth)/sign-in.tsx b/app/(auth)/sign-in.tsx
index ba62994..cbfd412 100644
--- a/app/(auth)/sign-in.tsx
+++ b/app/(auth)/sign-in.tsx
@@ -19,6 +19,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
import { signInWithAuthentik } from "@/lib/auth-oauth";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
+import { formatAuthErrorMessage } from "@/lib/trpc-errors";
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function SignInScreen() {
@@ -81,12 +82,7 @@ export default function SignInScreen() {
});
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");
- }
+ setError(formatAuthErrorMessage(signInError));
return;
}
@@ -108,7 +104,7 @@ export default function SignInScreen() {
);
if (oauthError) {
- setError(oauthError.message ?? "Could not sign in with Authentik");
+ setError(formatAuthErrorMessage(oauthError));
return;
}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 37c9956..b94e71e 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -16,6 +16,7 @@ import { View } from "react-native";
import { StatusBar } from "expo-status-bar";
import "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
import { BrandBackground } from "@/components/BrandBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
@@ -84,28 +85,30 @@ export default function RootLayout() {
}
return (
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
function RootNavigator() {
- const { data: session, isPending, error } = useSession();
+ const { data: session, isPending } = useSession();
if (isPending) {
return ;
}
- const isAuthenticated = Boolean(session?.user) && !error;
+ const isAuthenticated = Boolean(session?.user);
return (
authClient.signOut(),
+ activeAccountId,
});
},
);
diff --git a/components/SessionSync.tsx b/components/SessionSync.tsx
index a52da8d..d05a1e2 100644
--- a/components/SessionSync.tsx
+++ b/components/SessionSync.tsx
@@ -1,12 +1,18 @@
import { useEffect, useRef } from "react";
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. */
export function SessionSync() {
- const { refetch } = useSession();
+ const authClient = useAuthClient();
+ const { activeAccountId, clearActiveAccount } = useAccounts();
+ const { data: session, refetch } = useSession();
const wasBackgrounded = useRef(false);
+ const resettingRef = useRef(false);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
@@ -17,11 +23,30 @@ export function SessionSync() {
if (nextState !== "active" || !wasBackgrounded.current) return;
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();
- }, [refetch]);
+ }, [authClient, refetch, session?.user, activeAccountId, clearActiveAccount]);
return null;
}
diff --git a/components/invoices/LineItemEditor.tsx b/components/invoices/LineItemEditor.tsx
index f729aae..a863c59 100644
--- a/components/invoices/LineItemEditor.tsx
+++ b/components/invoices/LineItemEditor.tsx
@@ -3,6 +3,7 @@ import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { CompactDateField } from "@/components/ui/CompactDateField";
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
+import { SwipeableRow } from "@/components/SwipeableRow";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatShortDate } from "@/lib/format";
@@ -21,6 +22,7 @@ type LineItemEditorProps = {
currency: string;
onChange: (patch: Partial) => void;
onRemove: () => void;
+ onDuplicate?: () => void;
readOnly?: boolean;
isLast?: boolean;
};
@@ -38,6 +40,7 @@ export function LineItemEditor({
currency,
onChange,
onRemove,
+ onDuplicate,
readOnly = false,
isLast = false,
}: LineItemEditorProps) {
@@ -70,7 +73,7 @@ export function LineItemEditor({
);
}
- return (
+ const content = (
);
+
+ 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 (
+
+ {content}
+
+ );
}
const styles = StyleSheet.create({
diff --git a/components/time-clock/TimeClockPanel.tsx b/components/time-clock/TimeClockPanel.tsx
index 298b191..b8b9ef1 100644
--- a/components/time-clock/TimeClockPanel.tsx
+++ b/components/time-clock/TimeClockPanel.tsx
@@ -13,7 +13,9 @@ import { router } from "expo-router";
import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
+import { SwipeableRow } from "@/components/SwipeableRow";
import { TabScrollView } from "@/components/TabScrollView";
+import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
@@ -98,6 +100,8 @@ export function TimeClockPanel({
const [agoMinutesText, setAgoMinutesText] = useState("60");
const [optionsExpanded, setOptionsExpanded] = useState(false);
const [clientsExpanded, setClientsExpanded] = useState(false);
+ const [editEntryId, setEditEntryId] = useState(null);
+ const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
const [featuredClientIds, setFeaturedClientIds] = useState([]);
const [storedLastClientId, setStoredLastClientId] = useState(null);
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({
onSuccess: async (data) => {
await endTimeClockLiveActivity();
@@ -184,8 +197,10 @@ export function TimeClockPanel({
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
+ setDescription(running.description ?? "");
setStopNote("");
setRateText(running.rate != null ? String(running.rate) : "");
+ setRunningStartedAt(new Date(running.startedAt));
}, [running]);
useEffect(() => {
@@ -269,8 +284,7 @@ export function TimeClockPanel({
}, [agoMinutes, startMode, startedAt]);
const clockInErrors = useMemo(() => {
- const next: { clientId?: string; rate?: string; start?: string } = {};
- if (!clientId) next.clientId = "Choose a client to start";
+ const next: { rate?: string; start?: string } = {};
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate";
}
@@ -278,7 +292,7 @@ export function TimeClockPanel({
next.start = "Enter how long ago you started";
}
return next;
- }, [agoMinutes, clientId, rateText, startMode]);
+ }, [agoMinutes, rateText, startMode]);
const canClockIn = Object.keys(clockInErrors).length === 0;
@@ -307,6 +321,14 @@ export function TimeClockPanel({
function selectClient(nextClientId: string) {
const client = clients.find((c) => c.id === nextClientId);
+ if (running) {
+ updateRunning.mutate({
+ clientId: nextClientId,
+ invoiceId: "",
+ rate: client?.defaultHourlyRate ?? undefined,
+ });
+ return;
+ }
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
@@ -316,6 +338,27 @@ export function TimeClockPanel({
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) {
setStartMode(mode);
if (mode !== "now") setOptionsExpanded(true);
@@ -363,7 +406,9 @@ export function TimeClockPanel({
rate: effectiveRate ?? undefined,
startedAt: backdated,
});
- await persistClientChoice(clientId);
+ if (clientId) {
+ await persistClientChoice(clientId);
+ }
setStartMode("now");
setStartedAt(new Date());
setAgoMinutes(60);
@@ -445,7 +490,7 @@ export function TimeClockPanel({
>
) : (
- Choose a client and clock in. A draft invoice is created automatically if needed.
+ Start the timer anytime — add client, invoice, and details later.
)}
@@ -457,6 +502,62 @@ export function TimeClockPanel({
{running ? (
+
+
+
+
+
+ Client
+
+ selectClient("")}
+ />
+ {clients.map((client) => (
+ selectClient(client.id)}
+ />
+ ))}
+
+
+
+ {clientId ? (
+
+ Invoice
+
+ selectInvoice("")}
+ />
+ {billableInvoices.map((invoice) => (
+ selectInvoice(invoice.id)}
+ />
+ ))}
+
+
+ ) : null}
+
Client
{clients.length === 0 ? (
- Add a client first to start tracking time.
+ No clients yet — you can still start the timer and assign a client later.
) : (
<>
@@ -511,20 +612,15 @@ export function TimeClockPanel({
) : null}
>
)}
- {clockInErrors.clientId ? (
- {clockInErrors.clientId}
- ) : null}
-
+
- {clientId ? (
-
- Invoice
+
+ Invoice (optional)
+ {!clientId ? (
+ Pick a client to attach a draft invoice.
+ ) : (
- setInvoiceId("")}
- />
+ setInvoiceId("")} />
{billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
return (
@@ -537,11 +633,10 @@ export function TimeClockPanel({
);
})}
-
- ) : null}
+ )}
+
- {clientId ? (
-
+
) : null}
- ) : null}
@@ -667,41 +761,55 @@ export function TimeClockPanel({
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null;
- const row = (
- <>
-
- {formatRunningTimerLabel(entry.description)}
-
- {entry.client?.name ?? "No client"}
- {invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
-
-
- {entry.hours ?? "—"}h
- >
- );
-
- if (!entry.invoice) {
- return (
-
- {row}
-
- );
- }
-
return (
- router.push(`/(app)/invoices/${entry.invoice!.id}`)}
- style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
+ 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);
+ }
+ },
+ },
+ ]}
>
- {row}
-
+
+
+ {formatRunningTimerLabel(entry.description)}
+
+ {entry.client?.name ?? "No client"}
+ {invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
+
+
+ {entry.hours ?? "—"}h
+
+
);
})}
) : null}
+
+ setEditEntryId(null)}
+ />
);
}
diff --git a/components/time-clock/TimeEntryEditSheet.tsx b/components/time-clock/TimeEntryEditSheet.tsx
new file mode 100644
index 0000000..e1cb305
--- /dev/null
+++ b/components/time-clock/TimeEntryEditSheet.tsx
@@ -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 (
+
+
+
+ Edit time entry
+
+ Close
+
+
+
+
+ {entryQuery.isLoading ? (
+ Loading…
+ ) : (
+ <>
+
+
+ Client
+
+ {
+ setClientId("");
+ setInvoiceId("");
+ }}
+ />
+ {(clientsQuery.data ?? []).map((client) => (
+ {
+ setClientId(client.id);
+ setInvoiceId("");
+ }}
+ />
+ ))}
+
+
+ {clientId ? (
+ <>
+ Invoice
+
+ setInvoiceId("")}
+ />
+ {(billableQuery.data ?? []).map((invoice) => (
+ setInvoiceId(invoice.id)}
+ />
+ ))}
+
+ >
+ ) : null}
+
+
+
+
+
+
+ {hoursPreview != null ? (
+
+ {hoursPreview.toFixed(2)}h
+ {rate != null && rate > 0
+ ? ` · ${formatCurrency(hoursPreview * rate)}`
+ : ""}
+
+ ) : null}
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+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,
+ },
+ });
diff --git a/lib/account-actions.ts b/lib/account-actions.ts
index 0f3de42..e8923c0 100644
--- a/lib/account-actions.ts
+++ b/lib/account-actions.ts
@@ -1,24 +1,33 @@
import { router } from "expo-router";
import { Alert } from "react-native";
+import type { createAuthClient } from "better-auth/react";
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
+import { performAuthReset } from "@/lib/auth-session";
+
+type AuthClient = ReturnType;
type FinishAccountRemovalInput = {
result: RemoveAccountResult;
+ authClient: AuthClient;
clearActiveAccount: () => Promise;
- signOut: () => Promise;
+ activeAccountId: string | null;
};
/** Navigate to sign-in when the last saved account was removed. */
export async function finishAccountRemoval({
result,
+ authClient,
clearActiveAccount,
- signOut,
+ activeAccountId,
}: FinishAccountRemovalInput): Promise {
if (result.remainingCount > 0) return;
- await signOut();
- await clearActiveAccount();
+ await performAuthReset({
+ authClient,
+ clearActiveAccount,
+ activeAccountId,
+ });
router.replace("/(auth)/sign-in");
}
diff --git a/lib/auth-api.ts b/lib/auth-api.ts
index f85b2cc..5e7a1a8 100644
--- a/lib/auth-api.ts
+++ b/lib/auth-api.ts
@@ -1,11 +1,5 @@
import { getApiUrl } from "@/lib/config";
-
-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";
-}
+import { readHttpErrorMessage } from "@/lib/trpc-errors";
export async function registerAccount(input: {
firstName: string;
@@ -20,7 +14,7 @@ export async function registerAccount(input: {
});
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) {
- throw new Error(await parseError(res));
+ throw new Error(await readHttpErrorMessage(res));
}
const data = (await res.json()) as { message?: string };
@@ -47,6 +41,6 @@ export async function resetPassword(token: string, password: string) {
});
if (!res.ok) {
- throw new Error(await parseError(res));
+ throw new Error(await readHttpErrorMessage(res));
}
}
diff --git a/lib/auth-cookie.ts b/lib/auth-cookie.ts
new file mode 100644
index 0000000..041060e
--- /dev/null
+++ b/lib/auth-cookie.ts
@@ -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;
+
+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 {
+ const cookie = getAuthCookie(authClient, storagePrefix);
+ return cookie
+ ? { cookie, Cookie: cookie, "x-beenvoice-auth-cookie": cookie }
+ : {};
+}
diff --git a/lib/auth-session.ts b/lib/auth-session.ts
new file mode 100644
index 0000000..9727a48
--- /dev/null
+++ b/lib/auth-session.ts
@@ -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;
+
+type PerformAuthResetInput = {
+ authClient: AuthClient;
+ clearActiveAccount: () => Promise;
+ activeAccountId?: string | null;
+ refetchSession?: () => Promise;
+};
+
+/** 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 {
+ 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,
+): Promise {
+ const session = await authClient.getSession();
+ if (session.data?.user) return;
+
+ if (activeAccountId) {
+ await clearAuthStorage(authStoragePrefix(activeAccountId));
+ await clearActiveAccount();
+ }
+
+ await prepareForAdditionalSignIn();
+}
diff --git a/lib/auth-storage.ts b/lib/auth-storage.ts
index f5e0c49..2c08552 100644
--- a/lib/auth-storage.ts
+++ b/lib/auth-storage.ts
@@ -74,6 +74,8 @@ export async function migrateAuthStorage(fromPrefix: string, toPrefix: string):
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
),
);
+
+ await clearAuthStorage(fromPrefix);
}
export async function clearAuthStorage(prefix: string): Promise {
diff --git a/lib/query-client.ts b/lib/query-client.ts
index 980fb77..21e8b36 100644
--- a/lib/query-client.ts
+++ b/lib/query-client.ts
@@ -1,6 +1,6 @@
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) {
const handleError = (error: unknown) => {
@@ -16,7 +16,13 @@ export function createAppQueryClient(onUnauthorized: () => void) {
queries: {
staleTime: 30_000,
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;
},
},
diff --git a/lib/trpc-errors.ts b/lib/trpc-errors.ts
index c312751..36858b2 100644
--- a/lib/trpc-errors.ts
+++ b/lib/trpc-errors.ts
@@ -1,8 +1,121 @@
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 (
- error instanceof TRPCClientError &&
- (error.data?.code === "UNAUTHORIZED" || error.message === "UNAUTHORIZED")
+ message.includes("too many") ||
+ 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 {
+ 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";
+}
diff --git a/lib/trpc.tsx b/lib/trpc.tsx
index c09201e..073cd02 100644
--- a/lib/trpc.tsx
+++ b/lib/trpc.tsx
@@ -1,25 +1,65 @@
import { httpBatchLink } from "@trpc/client";
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 { useAccounts } from "@/contexts/AccountsContext";
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 type { AppRouter } from "beenvoice/server/api/root";
export const api = createTRPCReact();
-export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: ReactNode }) {
+export function TRPCProvider({
+ apiUrl,
+ children,
+}: {
+ apiUrl: string;
+ children: ReactNode;
+}) {
const authClient = useAuthClient();
+ const { authStoragePrefix, activeAccountId, clearActiveAccount } =
+ useAccounts();
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 () => {
+ if (!activeAccountId || resettingRef.current || !mountedRef.current) return;
+
const session = await authClient.getSession();
- if (!session.data?.user) {
- await authClient.signOut();
- await refetch();
+ if (session.data?.user || !mountedRef.current) return;
+
+ 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);
onUnauthorizedRef.current = handleUnauthorized;
@@ -37,10 +77,10 @@ export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: R
url: `${apiUrl}/api/trpc`,
transformer: SuperJSON,
headers() {
- const cookie = (
- authClient as { getCookie?: () => string | null | undefined }
- ).getCookie?.();
- return cookie ? { cookie } : {};
+ return getAuthCookieHeaders(
+ authClient,
+ authStoragePrefixRef.current,
+ );
},
}),
],