Files
beenvoice-app/contexts/AccountsContext.tsx
soconnor 06bc91ac13 Redesign mobile time clock, add shortcuts, and improve account management.
Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable
client picker with last-client defaults, account refresh/remove, and softer
session handling on unauthorized API responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:06:17 -04:00

249 lines
7.2 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { LoadingScreen } from "@/components/LoadingScreen";
import {
authStoragePrefix,
buildAccountId,
loadAccounts,
loadActiveAccountId,
loadDraftInstanceUrl,
saveAccounts,
saveActiveAccountId,
saveDraftInstanceUrl,
type SavedAccount,
} from "@/lib/accounts";
import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL } from "@/lib/config";
import { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage";
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs";
export type RemoveAccountResult = {
wasActive: boolean;
remainingCount: number;
};
type AccountsContextValue = {
accounts: SavedAccount[];
activeAccount: SavedAccount | null;
activeAccountId: string | null;
apiUrl: string;
authStoragePrefix: string;
setInstanceUrl: (url: string) => Promise<string>;
switchAccount: (accountId: string) => Promise<void>;
registerAccount: (input: {
instanceUrl: string;
userId: string;
email: string;
name: string;
}) => Promise<SavedAccount>;
removeAccount: (accountId: string) => Promise<RemoveAccountResult>;
refreshAccounts: () => Promise<void>;
clearActiveAccount: () => Promise<void>;
};
const AccountsContext = createContext<AccountsContextValue | null>(null);
export function AccountsProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [accounts, setAccounts] = useState<SavedAccount[]>([]);
const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
const [apiUrl, setApiUrl] = useState(getApiUrl);
useEffect(() => {
Promise.all([loadAccounts(), loadActiveAccountId(), loadDraftInstanceUrl()])
.then(([storedAccounts, activeId, draftUrl]) => {
setAccounts(storedAccounts);
const active = storedAccounts.find((account) => account.id === activeId) ?? null;
if (active) {
setActiveAccountId(active.id);
setRuntimeApiUrl(active.instanceUrl);
} else if (draftUrl) {
setRuntimeApiUrl(draftUrl);
} else {
setRuntimeApiUrl(DEFAULT_API_URL);
}
setApiUrl(getApiUrl());
})
.finally(() => setReady(true));
}, []);
const activeAccount = useMemo(
() => accounts.find((account) => account.id === activeAccountId) ?? null,
[accounts, activeAccountId],
);
const setInstanceUrl = useCallback(
async (url: string) => {
const normalized = normalizeInstanceUrl(url);
if (!normalized) {
throw new Error("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
}
if (activeAccount) {
const nextAccounts = accounts.map((account) =>
account.id === activeAccount.id ? { ...account, instanceUrl: normalized } : account,
);
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
} else {
await saveDraftInstanceUrl(normalized);
}
await saveStoredInstanceUrl(normalized);
setRuntimeApiUrl(normalized);
setApiUrl(normalized);
return normalized;
},
[activeAccount, accounts],
);
const switchAccount = useCallback(
async (accountId: string) => {
const account = accounts.find((entry) => entry.id === accountId);
if (!account) return;
const nextAccounts = accounts.map((entry) =>
entry.id === accountId ? { ...entry, lastUsedAt: Date.now() } : entry,
);
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
await saveActiveAccountId(accountId);
setActiveAccountId(accountId);
setRuntimeApiUrl(account.instanceUrl);
setApiUrl(account.instanceUrl);
},
[accounts],
);
const registerAccount = useCallback(
async (input: { instanceUrl: string; userId: string; email: string; name: string }) => {
const id = buildAccountId(input.instanceUrl, input.userId);
const existing = accounts.find((account) => account.id === id);
const account: SavedAccount = {
id,
instanceUrl: input.instanceUrl,
userId: input.userId,
email: input.email,
name: input.name,
lastUsedAt: Date.now(),
};
const nextAccounts = existing
? accounts.map((entry) => (entry.id === id ? account : entry))
: [account, ...accounts.filter((entry) => entry.id !== id)];
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
await saveActiveAccountId(id);
await saveDraftInstanceUrl(null);
setActiveAccountId(id);
setRuntimeApiUrl(input.instanceUrl);
setApiUrl(input.instanceUrl);
await saveStoredInstanceUrl(input.instanceUrl);
return account;
},
[accounts],
);
const removeAccount = useCallback(
async (accountId: string): Promise<RemoveAccountResult> => {
const wasActive = activeAccountId === accountId;
await clearAuthStorage(authStoragePrefix(accountId));
await clearTimeClockPrefsForAccount(accountId);
const nextAccounts = accounts.filter((account) => account.id !== accountId);
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
if (wasActive) {
const fallback = nextAccounts[0] ?? null;
await saveActiveAccountId(fallback?.id ?? null);
setActiveAccountId(fallback?.id ?? null);
if (fallback) {
setRuntimeApiUrl(fallback.instanceUrl);
setApiUrl(fallback.instanceUrl);
}
}
return { wasActive, remainingCount: nextAccounts.length };
},
[accounts, activeAccountId],
);
const refreshAccounts = useCallback(async () => {
const stored = await loadAccounts();
const refreshed = await Promise.all(
stored.map(async (account) => {
const user = await readStoredSessionUser(authStoragePrefix(account.id));
if (!user?.name && !user?.email) return account;
return {
...account,
name: user.name?.trim() || account.name,
email: user.email?.trim() || account.email,
};
}),
);
setAccounts(refreshed);
await saveAccounts(refreshed);
}, []);
const clearActiveAccount = useCallback(async () => {
await saveActiveAccountId(null);
setActiveAccountId(null);
}, []);
const value = useMemo(
() => ({
accounts,
activeAccount,
activeAccountId,
apiUrl,
authStoragePrefix: activeAccount
? authStoragePrefix(activeAccount.id)
: "beenvoice:guest",
setInstanceUrl,
switchAccount,
registerAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
}),
[
accounts,
activeAccount,
activeAccountId,
apiUrl,
setInstanceUrl,
switchAccount,
registerAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
],
);
if (!ready) {
return <LoadingScreen message="Starting…" />;
}
return <AccountsContext.Provider value={value}>{children}</AccountsContext.Provider>;
}
export function useAccounts() {
const ctx = useContext(AccountsContext);
if (!ctx) throw new Error("useAccounts must be used within AccountsProvider");
return ctx;
}