06bc91ac13
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>
249 lines
7.2 KiB
TypeScript
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;
|
|
}
|