import { createContext, useContext, useMemo, type ReactNode } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { DEFAULT_CURRENCY, ROLE_RANK, type Account, type Role, type Workspace, } from '@wellformed/shared'; import { api, ApiError, activeWorkspace, type SignupInput } from '../api/client'; import { usePublicConfig } from '../api/hooks'; interface SessionApi { /** Who is signed in, installation-wide. */ account: Account | null; /** Every workspace this account can open. */ workspaces: Workspace[]; /** The one this page is showing, when the URL names one the account belongs to. */ workspace: Workspace | null; /** True when the open workspace is the published demo and nobody is signed in. */ visiting: boolean; loading: boolean; /** False only when the API itself cannot be reached. */ reachable: boolean; /** Whether the account holds at least `role` **in the open workspace**. */ can(role: Role): boolean; /** Resolves with the workspaces the account can open, so the caller can route. */ signIn(email: string, password: string): Promise; signUp(input: SignupInput): Promise; signOut(): Promise; refresh(): Promise; } const SessionContext = createContext(null); export function SessionProvider({ children }: { children: ReactNode }): ReactNode { const queryClient = useQueryClient(); const install = usePublicConfig(); const me = useQuery({ queryKey: ['session'], queryFn: () => api.me(), retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2, staleTime: 60_000, }); const signIn = useMutation({ mutationFn: ({ email, password }: { email: string; password: string }) => api.login(email, password), onSuccess: (session) => { queryClient.setQueryData(['session'], session); void queryClient.invalidateQueries(); }, }); const signUp = useMutation({ mutationFn: (input: SignupInput) => api.signup(input), onSuccess: (session) => { queryClient.setQueryData(['session'], session); }, }); const signOut = useMutation({ mutationFn: () => api.logout(), onSuccess: () => { // Nothing cached was readable without the session that just ended. queryClient.clear(); queryClient.setQueryData(['session'], { account: null, workspaces: [] }); }, }); const account = me.data?.account ?? null; const workspaces = useMemo(() => me.data?.workspaces ?? [], [me.data]); /** * The open workspace is whichever one the URL names, matched against the * memberships the server just returned. Nothing else decides it: a slug the * account has no membership in resolves to null, and the shell offers the * picker rather than a grid that would 404 on every request. */ const workspace = useMemo(() => { const slug = activeWorkspace(); const mine = workspaces.find((w) => w.slug === slug); if (mine) return mine; // A published demo is a workspace the server answers for without a // membership. It is described as `viewer` here for the same reason the // server treats a visitor as one: every affordance in the app is gated on // this role, so a visitor gets a read-only grid without a second code // path. Matched by slug across all of them: a Dutch browser must still be // able to open the English demo from a link, and the other way round. const demo = install.data?.demos.find((candidate) => candidate.slug === slug); if (slug && demo?.slug === slug) { return { id: `demo:${demo.slug}`, slug: demo.slug, name: demo.name, role: 'viewer' as const, status: 'active' as const, currency: demo.currency, locale: demo.locale, createdAt: '', // A visitor is not billed for anything and never sees a plan notice. plan: 'free' as const, billingStatus: 'active' as const, trialEndsAt: null, }; } return null; }, [workspaces, install.data]); // Membership wins: a member browsing the demo is a member, not a visitor. const visiting = workspace !== null && !workspaces.some((w) => w.slug === workspace.slug); const value = useMemo( () => ({ account, workspaces, workspace, visiting, loading: me.isLoading, reachable: !(me.error instanceof ApiError && me.error.status === 0), can: (role) => (workspace ? ROLE_RANK[workspace.role] >= ROLE_RANK[role] : false), signIn: async (email, password) => { const session = await signIn.mutateAsync({ email, password }); return session.workspaces; }, signUp: async (input) => { const session = await signUp.mutateAsync(input); return session.workspaces[0]; }, signOut: async () => { await signOut.mutateAsync(); }, refresh: async () => { await me.refetch(); }, }), [account, workspaces, workspace, visiting, me, signIn, signUp, signOut], ); return {children}; } export function useSession(): SessionApi { const ctx = useContext(SessionContext); if (!ctx) throw new Error('useSession must be used inside a SessionProvider.'); return ctx; } /** * The currency the open workspace keeps its books in. * * Read here rather than passed down because everything that prints an amount — * a cell, the total under a column, a linked record — sits at a different depth, * and threading one string through all of them would be a prop nobody enjoys. */ export function useCurrency(): string { return useSession().workspace?.currency ?? DEFAULT_CURRENCY; }