W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
Build Kvitt images / build (push) Failing after 1h14m5s

web/ is the browser client's scaffold: Vite 8, React 19, TypeScript 5.9 strict, React Router 7 (basename /app, every
fixed route with a lazy placeholder page), TanStack Query 5 (refetch on focus and every 60 s), openapi-typescript
types generated from contracts/openapi.yaml with openapi-fetch. Reads go through GET /sync (no REST reads exist for
the ledger, SPEC §7): useLedger() pulls from cursor 0 and then incrementally, restarting on a new epoch or
cursor_ahead. Login pairs the browser as "Web · <browser>" with an invite code (E-3); every call sends the SPEC
§14.3.1 headers, a 401 signs out. kvitt-domain's split, balances, allocation and settlement rules are ported to
web/src/lib and pass every case of contracts/test-vectors. Design tokens from design-spec §2 in tokens.css
(light-dark()), Material Symbols Rounded (Apache-2.0) as generated inline SVG, sv/en catalogs generated from
docs/ux/strings.csv plus web-strings.csv. Gate: cd web && npm run check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 21:00:03 +02:00
parent 3fbd393082
commit 2ae65a5a9f
154 changed files with 15651 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, type ReactNode } from 'react';
import { languageOf } from '../i18n/language';
import { createApi, type ApiOptions } from './client';
import type { SessionStore } from './session';
import { ApiContext } from './useApi';
interface Props {
session: SessionStore;
queryClient: QueryClient;
baseUrl?: string;
fetch?: ApiOptions['fetch'];
children: ReactNode;
}
/** Provides the API client and the query cache; signing out clears every cached query. */
export function ApiProvider({ session, queryClient, baseUrl, fetch, children }: Props) {
const api = useMemo(
() =>
createApi({
baseUrl: baseUrl ?? window.location.origin,
session,
// The page language, which I18nProvider keeps on <html lang>.
language: () => languageOf(document.documentElement.lang),
fetch,
}),
[baseUrl, session, fetch],
);
useEffect(
() =>
session.subscribe(() => {
if (session.get() === null) queryClient.clear();
}),
[session, queryClient],
);
const value = useMemo(() => ({ api, session }), [api, session]);
return (
<QueryClientProvider client={queryClient}>
<ApiContext.Provider value={value}>{children}</ApiContext.Provider>
</QueryClientProvider>
);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* The typed API client (openapi-fetch over the generated `schema.d.ts`). Same origin as the SPA (`/me`, `/sync`, …).
* Every call carries the headers of SPEC §14.3.1: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version`,
* `X-Kvitt-App-Version-Name`, `Accept-Language`, and `Authorization: Bearer <token>` while signed in. A 401 signs the
* session out (`unauthorized` or `device_revoked`), which sends the router to /app/login.
*/
import createClient, { type Client } from 'openapi-fetch';
import { BUILD } from '../app/buildInfo';
import type { Language } from '../i18n/language';
import type { paths } from './schema';
import type { SessionStore } from './session';
import type { ErrorBody, ErrorCode } from './types';
/** The protocol version this client speaks (SPEC §14.4). */
export const PROTOCOL_VERSION = '1';
/** The operations' header parameters are set by the middleware below, so callers never pass them. */
type WithoutHeaderParams<Operation> = Operation extends { parameters: infer Params }
? Omit<Operation, 'parameters'> & { parameters: Omit<Params, 'header'> & { header?: never } }
: Operation;
export type ApiPaths = {
[Path in keyof paths]: { [Method in keyof paths[Path]]: WithoutHeaderParams<paths[Path][Method]> };
};
export type Api = Client<ApiPaths>;
export interface ApiOptions {
/** The origin the SPA is served from; the API lives at its root (OpenAPI `servers`). */
baseUrl: string;
session: SessionStore;
language: () => Language;
fetch?: (request: Request) => Promise<Response>;
}
/** An API failure: an error body of SPEC §14.3.2, or `network` when the server could not be reached. */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: ErrorCode | 'network' | 'unexpected',
readonly field: string | null,
message: string,
readonly retryAfterSeconds: number | null = null,
) {
super(message);
this.name = 'ApiError';
}
}
function isErrorBody(value: unknown): value is ErrorBody {
return typeof value === 'object' && value !== null && typeof (value as { code?: unknown }).code === 'string';
}
async function errorCodeOf(response: Response): Promise<string | null> {
try {
const body: unknown = await response.clone().json();
return isErrorBody(body) ? body.code : null;
} catch (error) {
if (error instanceof SyntaxError || error instanceof TypeError) return null;
throw error;
}
}
export function createApi(options: ApiOptions): Api {
const client = createClient<ApiPaths>({ baseUrl: options.baseUrl, fetch: options.fetch });
client.use({
onRequest({ request }) {
request.headers.set('X-Kvitt-Protocol', PROTOCOL_VERSION);
request.headers.set('X-Kvitt-App-Version', String(BUILD.versionCode));
request.headers.set('X-Kvitt-App-Version-Name', BUILD.versionName);
request.headers.set('Accept-Language', options.language());
const session = options.session.get();
if (session !== null) request.headers.set('Authorization', `Bearer ${session.token}`);
return request;
},
async onResponse({ request, response }) {
if (response.status === 401 && request.headers.has('Authorization')) {
const code = await errorCodeOf(response);
options.session.signOut(code === 'device_revoked' ? 'device_revoked' : 'unauthorized');
}
return response;
},
});
return client;
}
interface Outcome<D> {
data?: D;
error?: unknown;
response: Response;
}
async function settle<D>(pending: Promise<Outcome<D>>): Promise<Outcome<D>> {
try {
return await pending;
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
throw new ApiError(0, 'network', null, cause instanceof Error ? cause.message : 'network error');
}
}
function failure(outcome: Outcome<unknown>): ApiError {
const { response, error } = outcome;
const retryAfter = response.headers.get('Retry-After');
const retryAfterSeconds = retryAfter === null ? null : Number(retryAfter);
if (isErrorBody(error)) {
return new ApiError(response.status, error.code, error.field, error.message, retryAfterSeconds);
}
return new ApiError(response.status, 'unexpected', null, `HTTP ${response.status}`, retryAfterSeconds);
}
/** The response body of a successful call; throws ApiError otherwise. */
export async function unwrap<D>(pending: Promise<Outcome<D>>): Promise<D> {
const outcome = await settle(pending);
if (!outcome.response.ok) throw failure(outcome);
if (outcome.data === undefined) throw new ApiError(outcome.response.status, 'unexpected', null, 'empty body');
return outcome.data;
}
/** For calls answered with 204 No Content; throws ApiError on failure. */
export async function unwrapEmpty(pending: Promise<Outcome<unknown>>): Promise<void> {
const outcome = await settle(pending);
if (!outcome.response.ok) throw failure(outcome);
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import { FakeServer, errorResponse, json } from '../test/fakeServer';
import { ANNA, ANNA_ID, GROCERIES, JACOB, JACOB_ID, account, expense, page, settlement } from '../test/fixtures';
import { createApi } from './client';
import { ledgerBalances, live, pullLedger } from './ledger';
import { SessionStore } from './session';
function apiFor(server: FakeServer) {
const session = new SessionStore('kvitt.session.ledger-test');
session.signIn({ token: 'kvt_ledger', userId: JACOB_ID, deviceId: 'd', inviteKind: 'device' });
return createApi({ baseUrl: 'http://kvitt.test', session, language: () => 'sv', fetch: server.fetch });
}
const X = '01929c5e-0000-7000-8000-0000000000a1';
const Y = '01929c5e-0000-7000-8000-0000000000a2';
describe('pullLedger (SPEC §14.4.2)', () => {
it('pulls every page from cursor 0, then incrementally from the cursor it holds', async () => {
const pages = [
page(0, { users: [JACOB, ANNA], categories: [GROCERIES] }, true),
page(16, {
accounts: [account(X, 'Vardag', 17)],
expenses: [expense('e1', X, 1001, JACOB_ID, { [JACOB_ID]: 501, [ANNA_ID]: 500 }, 18)],
}),
page(18, { expenses: [expense('e2', X, 100, ANNA_ID, { [JACOB_ID]: 60, [ANNA_ID]: 40 }, 19)] }),
];
const server = new FakeServer().on('GET', '/sync', () => {
const next = pages.shift();
return next === undefined ? json(page(19, {})) : json(next);
});
const api = apiFor(server);
const first = await pullLedger(api, undefined);
expect(server.calls('GET', '/sync').map((r) => r.search)).toEqual(['?since=0&limit=1000', '?since=16&limit=1000']);
expect(first.cursor).toBe(18);
expect(first.users.size).toBe(2);
const second = await pullLedger(api, first);
expect(server.calls('GET', '/sync').at(-1)?.search).toBe('?since=18&limit=1000');
expect(second.cursor).toBe(19);
expect(ledgerBalances(second).global).toEqual({ [JACOB_ID]: 440, [ANNA_ID]: -440 });
});
it('keeps the newer server_version, applies tombstones and leaves deleted rows out of balances', async () => {
const server = new FakeServer().on('GET', '/sync', (request) =>
request.search.startsWith('?since=0')
? json(
page(0, {
users: [JACOB, ANNA],
accounts: [account(X, 'Vardag', 17)],
expenses: [expense('e1', X, 1000, JACOB_ID, { [JACOB_ID]: 500, [ANNA_ID]: 500 }, 18)],
}),
)
: json(
page(18, {
expenses: [
expense('e1', X, 1000, JACOB_ID, { [JACOB_ID]: 500, [ANNA_ID]: 500 }, 20, '2026-10-17T07:00:00.000Z'),
],
settlements: [settlement('s1', ANNA_ID, JACOB_ID, { [X]: 200 }, 21)],
}),
),
);
const api = apiFor(server);
const first = await pullLedger(api, undefined);
expect(ledgerBalances(first).perAccount[X]).toEqual({ [JACOB_ID]: 500, [ANNA_ID]: -500 });
const second = await pullLedger(api, first);
expect(live(second.expenses)).toHaveLength(0);
expect(second.expenses.get('e1')?.server_version).toBe(20);
expect(ledgerBalances(second).perAccount[X]).toEqual({ [JACOB_ID]: -200, [ANNA_ID]: 200 });
});
it('starts again from 0 when the epoch changes (SPEC §14.4.8)', async () => {
let call = 0;
const server = new FakeServer().on('GET', '/sync', () => {
call += 1;
if (call === 1) return json(page(0, { users: [JACOB, ANNA], accounts: [account(X, 'Vardag', 17)] }));
return json(
page(
0,
{ users: [JACOB, ANNA], accounts: [account(Y, 'Huset', 3)] },
false,
'ffffffffffffffffffffffffffffffff',
),
);
});
const api = apiFor(server);
const restored = await pullLedger(api, await pullLedger(api, undefined));
expect([...restored.accounts.keys()]).toEqual([Y]);
expect(restored.epoch).toBe('ffffffffffffffffffffffffffffffff');
expect(server.calls('GET', '/sync').map((r) => r.search)).toEqual([
'?since=0&limit=1000',
'?since=17&limit=1000',
'?since=0&limit=1000',
]);
});
it('treats cursor_ahead as a restart from 0', async () => {
const server = new FakeServer().on('GET', '/sync', (request) =>
request.search.startsWith('?since=0') ? json(page(0, { users: [JACOB] })) : errorResponse('cursor_ahead', 409),
);
const api = apiFor(server);
const held = { ...(await pullLedger(api, undefined)), cursor: 900 };
const again = await pullLedger(api, held);
expect(again.cursor).toBe(15);
});
});
+205
View File
@@ -0,0 +1,205 @@
/**
* The household ledger, read through sync pulls (SPEC §7: accounts, categories, templates, expenses, settlements and
* audit entries are read only through `GET /sync`). The web is online-only: the ledger lives in the TanStack Query
* cache (key `['ledger']`), the first read pulls from cursor 0, every refetch (window focus, every 60 s, after a
* write) pulls incrementally from the cursor it holds. A new `epoch` or 409 `cursor_ahead` starts again from 0
* (SPEC §14.4.8). Rows are applied by `server_version`, tombstones included (SPEC §14.4.2).
*/
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { computeBalances, type Balances } from '../lib/balances';
import { ApiError, unwrap, type Api } from './client';
import { useApi } from './useApi';
import type {
Account,
AuditEntry,
Category,
Expense,
RecurringTemplate,
Settlement,
SyncPullResponse,
User,
} from './types';
export const LEDGER_QUERY_KEY = ['ledger'] as const;
/** Rows per pull page: the protocol maximum (SPEC §9.6), so a full first read takes the fewest round trips. */
export const PULL_PAGE_LIMIT = 1000;
export interface Ledger {
epoch: string;
/** The highest `server_version` applied. */
cursor: number;
serverTime: string;
users: ReadonlyMap<string, User>;
categories: ReadonlyMap<string, Category>;
accounts: ReadonlyMap<string, Account>;
recurringTemplates: ReadonlyMap<string, RecurringTemplate>;
expenses: ReadonlyMap<string, Expense>;
settlements: ReadonlyMap<string, Settlement>;
auditEntries: ReadonlyMap<string, AuditEntry>;
}
export function emptyLedger(epoch = ''): Ledger {
return {
epoch,
cursor: 0,
serverTime: '',
users: new Map(),
categories: new Map(),
accounts: new Map(),
recurringTemplates: new Map(),
expenses: new Map(),
settlements: new Map(),
auditEntries: new Map(),
};
}
/** Rows to apply, as in a pull page or the response of a REST write. */
export interface LedgerRows {
users?: readonly User[];
categories?: readonly Category[];
accounts?: readonly Account[];
recurringTemplates?: readonly RecurringTemplate[];
expenses?: readonly Expense[];
settlements?: readonly Settlement[];
auditEntries?: readonly AuditEntry[];
}
function upsert<T extends { id: string; server_version: number }>(
current: ReadonlyMap<string, T>,
rows: readonly T[] | undefined,
): ReadonlyMap<string, T> {
if (rows === undefined || rows.length === 0) return current;
const next = new Map(current);
for (const row of rows) {
const existing = next.get(row.id);
if (existing === undefined || existing.server_version < row.server_version) next.set(row.id, row);
}
return next;
}
/** Applies rows (newer `server_version` wins; audit entries are insert-or-ignore by id). Pure. */
export function applyRows(ledger: Ledger, rows: LedgerRows): Ledger {
let auditEntries = ledger.auditEntries;
if (rows.auditEntries !== undefined && rows.auditEntries.length > 0) {
const next = new Map(auditEntries);
for (const entry of rows.auditEntries) if (!next.has(entry.id)) next.set(entry.id, entry);
auditEntries = next;
}
return {
...ledger,
users: upsert(ledger.users, rows.users),
categories: upsert(ledger.categories, rows.categories),
accounts: upsert(ledger.accounts, rows.accounts),
recurringTemplates: upsert(ledger.recurringTemplates, rows.recurringTemplates),
expenses: upsert(ledger.expenses, rows.expenses),
settlements: upsert(ledger.settlements, rows.settlements),
auditEntries,
};
}
/** Applies one pull page (SPEC §14.4.2 phone steps 2 to 4, without the pending-row rule: the web has no local edits). */
export function applyPage(ledger: Ledger, page: SyncPullResponse): Ledger {
const applied = applyRows(ledger, {
users: page.users,
categories: page.categories,
accounts: page.accounts,
recurringTemplates: page.recurring_templates,
expenses: page.expenses,
settlements: page.settlements,
auditEntries: page.audit_entries,
});
return { ...applied, epoch: page.epoch, cursor: page.next_since, serverTime: page.server_time };
}
/**
* Pulls until `has_more` is false, starting from `previous` (incremental) or from 0. A changed epoch or a
* `cursor_ahead` answer discards what is held and pulls everything again, once.
*/
export async function pullLedger(api: Api, previous: Ledger | undefined, signal?: AbortSignal): Promise<Ledger> {
let ledger = previous ?? emptyLedger();
let restarted = previous === undefined;
for (;;) {
let page: SyncPullResponse;
try {
page = await unwrap(
api.GET('/sync', { params: { query: { since: ledger.cursor, limit: PULL_PAGE_LIMIT } }, signal }),
);
} catch (error) {
if (error instanceof ApiError && error.code === 'cursor_ahead' && !restarted) {
ledger = emptyLedger();
restarted = true;
continue;
}
throw error;
}
if (ledger.epoch !== '' && page.epoch !== ledger.epoch) {
if (restarted) throw new ApiError(200, 'unexpected', null, 'epoch changed during a full pull');
ledger = emptyLedger();
restarted = true;
continue;
}
ledger = applyPage(ledger, page);
if (!page.has_more) return ledger;
}
}
function ledgerQuery(api: Api, queryClient: QueryClient) {
return {
queryKey: LEDGER_QUERY_KEY,
queryFn: ({ signal }: { signal: AbortSignal }) =>
pullLedger(api, queryClient.getQueryData<Ledger>(LEDGER_QUERY_KEY), signal),
structuralSharing: false,
};
}
/** The ledger, optionally through a selector (memoise `select` with useCallback when it is expensive). */
export function useLedger<T = Ledger>(select?: (ledger: Ledger) => T) {
const api = useApi();
const queryClient = useQueryClient();
return useQuery({ ...ledgerQuery(api, queryClient), select });
}
/** Merges the rows a REST write returned (SPEC §7: every write returns its row) and pulls what else changed. */
export function useLedgerWriter() {
const queryClient = useQueryClient();
const apply = useCallback(
(rows: LedgerRows) => {
queryClient.setQueryData<Ledger>(LEDGER_QUERY_KEY, (current) =>
current === undefined ? current : applyRows(current, rows),
);
},
[queryClient],
);
const refresh = useCallback(() => queryClient.invalidateQueries({ queryKey: LEDGER_QUERY_KEY }), [queryClient]);
return { apply, refresh };
}
/** Non-deleted rows of a ledger map. */
export function live<T extends { deleted_at: string | null }>(rows: ReadonlyMap<string, T>): T[] {
return [...rows.values()].filter((row) => row.deleted_at === null);
}
/** Household user ids, sorted (slot order of design-spec §2.5). */
export function householdUserIds(ledger: Ledger): string[] {
return [...ledger.users.keys()].sort();
}
/** Balances of SPEC §9.3 over non-deleted rows, every non-deleted account included. */
export function ledgerBalances(ledger: Ledger): Balances {
return computeBalances(
householdUserIds(ledger),
live(ledger.accounts).map((a) => a.id),
live(ledger.expenses).map((e) => ({
accountId: e.account_id,
amountOre: e.amount_ore,
payerUserId: e.payer_user_id,
shares: Object.fromEntries(e.splits.map((s) => [s.user_id, s.share_ore])),
})),
live(ledger.settlements).map((s) => ({
fromUserId: s.from_user_id,
toUserId: s.to_user_id,
allocations: Object.fromEntries(s.allocations.map((a) => [a.account_id, a.amount_ore])),
})),
);
}
+14
View File
@@ -0,0 +1,14 @@
/** E-7 `GET /me`: the signed-in user, this device and the household (REST, not part of the sync ledger). */
import { useQuery } from '@tanstack/react-query';
import { unwrap } from './client';
import { useApi } from './useApi';
export const ME_QUERY_KEY = ['me'] as const;
export function useMe() {
const api = useApi();
return useQuery({
queryKey: ME_QUERY_KEY,
queryFn: ({ signal }) => unwrap(api.GET('/me', { signal })),
});
}
+34
View File
@@ -0,0 +1,34 @@
/**
* E-11 profile pictures. The endpoint needs the bearer token, so an <img src> cannot load it directly: the picture is
* fetched as a Blob (cached per user and `picture_version`, AC-4.6) and shown through an object URL.
*/
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { unwrap } from './client';
import { useApi } from './useApi';
export function useUserPicture(userId: string, pictureVersion: number | null, size: 128 | 512 = 128): string | null {
const api = useApi();
const query = useQuery({
queryKey: ['picture', userId, pictureVersion, size],
enabled: pictureVersion !== null,
staleTime: Infinity,
queryFn: ({ signal }) =>
unwrap(
api.GET('/users/{user_id}/picture', {
params: { path: { user_id: userId }, query: { size, v: pictureVersion ?? undefined } },
parseAs: 'blob',
signal,
}),
),
});
const blob = query.data instanceof Blob ? query.data : null;
const url = useMemo(() => (blob === null ? null : URL.createObjectURL(blob)), [blob]);
useEffect(
() => () => {
if (url !== null) URL.revokeObjectURL(url);
},
[url],
);
return url;
}
+45
View File
@@ -0,0 +1,45 @@
/**
* E-32 `POST /sync` for the web's expense and settlement writes. The web holds no pending rows: a write is pushed at
* once (online-only), its accepted entity is merged into the ledger, and a rejection is returned to the caller to
* show (SPEC §14.4.6 reasons). Batches are 1 to 100 mutations (SPEC §9.6).
*/
import { uuidv7 } from '../lib/ids';
import { unwrap, type Api } from './client';
import type { Ledger, LedgerRows } from './ledger';
import { applyRows } from './ledger';
import type { Expense, Mutation, MutationResult, PushedExpense, PushedSettlement, Settlement } from './types';
export const MAX_MUTATIONS_PER_PUSH = 100;
export function expenseMutation(entity: PushedExpense): Mutation {
return { mutation_id: uuidv7(), entity_type: 'expense', entity };
}
export function settlementMutation(entity: PushedSettlement): Mutation {
return { mutation_id: uuidv7(), entity_type: 'settlement', entity };
}
export async function pushMutations(api: Api, mutations: readonly Mutation[]): Promise<MutationResult[]> {
if (mutations.length < 1 || mutations.length > MAX_MUTATIONS_PER_PUSH) {
throw new RangeError(`a push holds 1 to ${MAX_MUTATIONS_PER_PUSH} mutations`);
}
const response = await unwrap(api.POST('/sync', { body: { mutations: [...mutations] } }));
return response.results;
}
/** The rows of the results to merge into the ledger: accepted entities and the `current` row of rejections. */
export function rowsOfResults(results: readonly MutationResult[]): LedgerRows {
const expenses: Expense[] = [];
const settlements: Settlement[] = [];
for (const result of results) {
const row = result.status === 'accepted' ? result.entity : result.current;
if (row === null) continue;
if (result.entity_type === 'expense' && 'expense_date' in row) expenses.push(row);
if (result.entity_type === 'settlement' && 'settlement_date' in row) settlements.push(row);
}
return { expenses, settlements };
}
export function applyResults(ledger: Ledger, results: readonly MutationResult[]): Ledger {
return applyRows(ledger, rowsOfResults(results));
}
+25
View File
@@ -0,0 +1,25 @@
import { QueryClient } from '@tanstack/react-query';
import { ApiError } from './client';
/** Refetch interval of every query: the web's stand-in for the app's sync (owner decision for the web, 2026-10-21). */
export const REFETCH_INTERVAL_MS = 60_000;
function shouldRetry(failureCount: number, error: unknown): boolean {
// A 4xx other than 429 will not change on its own; network errors, 429 and 5xx follow TanStack's default of 3.
if (error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 429) return false;
return failureCount < 3;
}
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
refetchInterval: REFETCH_INTERVAL_MS,
refetchIntervalInBackground: false,
retry: shouldRetry,
},
mutations: { retry: false },
},
});
}
+2724
View File
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
/**
* The web session: the device token and who it belongs to, kept in localStorage (`kvitt.session`). The token is never
* logged and never put in a URL (SPEC §8.2). A 401 clears it (client.ts) and the router sends the user to /app/login.
*/
import { useSyncExternalStore } from 'react';
import { readStored, writeStored } from '../lib/storage';
import type { InviteKind } from './types';
export interface Session {
token: string;
userId: string;
deviceId: string;
inviteKind: InviteKind;
}
/** Why the last session ended: the user signed out, or the server answered 401 (`unauthorized` / `device_revoked`). */
export type SignOutReason = 'user' | 'unauthorized' | 'device_revoked';
const STORAGE_KEY = 'kvitt.session';
function isSession(value: unknown): value is Session {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.token === 'string' &&
typeof v.userId === 'string' &&
typeof v.deviceId === 'string' &&
(v.inviteKind === 'admin_setup' || v.inviteKind === 'partner' || v.inviteKind === 'device')
);
}
function parseStored(text: string | null): Session | null {
if (text === null) return null;
try {
const value: unknown = JSON.parse(text);
return isSession(value) ? value : null;
} catch (error) {
if (error instanceof SyntaxError) return null;
throw error;
}
}
export class SessionStore {
private session: Session | null;
private reason: SignOutReason | null = null;
private readonly listeners = new Set<() => void>();
constructor(private readonly storageKey: string = STORAGE_KEY) {
this.session = parseStored(readStored(storageKey));
}
get = (): Session | null => this.session;
/** Why the previous session ended, until the next sign-in. */
lastSignOut = (): SignOutReason | null => this.reason;
signIn(session: Session): void {
this.session = session;
this.reason = null;
writeStored(this.storageKey, JSON.stringify(session));
this.emit();
}
signOut(reason: SignOutReason): void {
if (this.session === null) return;
this.session = null;
this.reason = reason;
writeStored(this.storageKey, null);
this.emit();
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
private emit(): void {
for (const listener of this.listeners) listener();
}
}
export function useSessionValue(store: SessionStore): Session | null {
return useSyncExternalStore(store.subscribe, store.get, store.get);
}
+26
View File
@@ -0,0 +1,26 @@
/** Wire objects of SPEC §14.3.3, from the generated `schema.d.ts` (contracts/openapi.yaml). */
import type { components } from './schema';
type Schemas = components['schemas'];
export type User = Schemas['User'];
export type Device = Schemas['Device'];
export type Invite = Schemas['Invite'];
export type Account = Schemas['Account'];
export type Category = Schemas['Category'];
export type Expense = Schemas['Expense'];
export type Settlement = Schemas['Settlement'];
export type RecurringTemplate = Schemas['RecurringTemplate'];
export type AuditEntry = Schemas['AuditEntry'];
export type ErrorBody = Schemas['Error'];
export type ErrorCode = Schemas['ErrorCode'];
export type InviteKind = Schemas['InviteKind'];
export type DefaultCategoryKey = Schemas['DefaultCategoryKey'];
export type SplitMode = Schemas['SplitMode'];
export type MeResponse = Schemas['MeResponse'];
export type SyncPullResponse = Schemas['SyncPullResponse'];
export type Mutation = Schemas['Mutation'];
export type PushedExpense = Schemas['PushedExpense'];
export type PushedSettlement = Schemas['PushedSettlement'];
export type MutationResult = Schemas['MutationResult'];
export type SyncPushResponse = Schemas['SyncPushResponse'];
+24
View File
@@ -0,0 +1,24 @@
import { createContext, useContext } from 'react';
import type { Api } from './client';
import type { SessionStore } from './session';
export interface ApiContextValue {
api: Api;
session: SessionStore;
}
export const ApiContext = createContext<ApiContextValue | null>(null);
function useApiContext(): ApiContextValue {
const value = useContext(ApiContext);
if (value === null) throw new Error('useApi outside ApiProvider');
return value;
}
export function useApi(): Api {
return useApiContext().api;
}
export function useSessionStore(): SessionStore {
return useApiContext().session;
}