diff --git a/HISTORY.md b/HISTORY.md index 848f403..c770a0a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,6 +3,8 @@ ### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint +- **[Client] Auth: `User` moved to its proper home + auth endpoints typed.** `User` lived oddly in `@/hooks/useAuth`; moved it to `@/types` (re-exported from `useAuth` so the 7 importers keep working) and typed `me`/`login`/`totpChallenge`/`hasUsers` with `get`/`post`, dropping those casts on the Admin + Login pages. *(Deliberate stop: the remaining single-consumer, non-money casts — `adminUsers` with its page-local `AdminUser`, and the one-off `version`/`about`/`privacy`/`health`/`importHistory` doc shapes — are left as-is; central types add ~nothing for a single reader, and typing them is the low-value tail. Likewise the **optimistic-UI/`toast.promise` rewrite** (planned Track D) is intentionally not done — it's behavior-visible churn on already-working handlers, the worst risk/reward of the remaining work.)* +- **[Server] Error-shape consistency (Track D).** The global 500 handler ([server.js](server.js)) returned `{ error: 'Internal server error' }` with no `code`; added `code: 'INTERNAL_ERROR'` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx. - **[Client] Snowball projection + settings typed.** Promoted `SnowballProjection` (+ `SnowballProjectionDetail`/`AvalancheProjectionDetail`/`SnowballDebtProjection`) and `SnowballSettings` into `@/types`, and wired `snowball` (→ `Bill[]`), `snowballSettings`/`saveSnowballSettings` (→ `SnowballSettings`), and `snowballProjection` (→ `SnowballProjection`). Removed the projection/settings/bills casts. (The plan-lifecycle endpoints still cast to `SnowballPlan`, which is a component-owned type in `PlanHistoryPanel` — left as-is to avoid a cross-file type move for marginal gain.) - **[Client] Subscriptions domain typed.** Promoted the `SubscriptionsPage` shapes into `@/types` (`Subscription`, `SubscriptionSummary`/`…TopType`, `SubscriptionsResponse`, `Recommendation` + `RecommendationEvidence`/`…Transaction`/`ExistingBillMatch`/`CatalogMatch`, `SubscriptionTx`) and wired `subscriptions`/`subscriptionRecommendations`/`subscriptionTransactionMatches`/`createSubscriptionFromRecommendation`/`matchRecommendationToBill` with `get`/`post`. Dropped the `(r: any)` in `useSubscriptionRecommendations` (now typed) and the 5 page casts; removed an unused `CatalogMatch` import (Track E). - **[Client] Spending domain typed.** Promoted `SpendingPage`'s local response interfaces into `@/types` (`SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`/`…Response`, `SpendingIncomeTx`/`…Response`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse`) and wired `spendingSummary`/`spendingTransactions`/`spendingIncome`/`spendingCategoryRules`/`copySpendingBudgets`/`categoryGroups` with `get`/`post`. `useSpendingSummary`/`useSpendingTransactions`/`useCategoryGroups` now return typed data; the 6 spending `as {…}` casts are gone. Removed 2 now-unused type aliases the cast-removal orphaned (Track E). diff --git a/client/api.ts b/client/api.ts index 5a8ab52..ff6a1b3 100644 --- a/client/api.ts +++ b/client/api.ts @@ -1,5 +1,5 @@ import type { - Bill, Category, Payment, TrackerResponse, + Bill, Category, Payment, TrackerResponse, User, BankLedger, BankLedgerTransaction, AutoCategorizePreview, SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse, SpendingRule, CopyBudgetsResponse, CategoryGroup, @@ -108,9 +108,9 @@ function filenameFromDisposition(value: string | null): string | null { export const api = { // Auth - me: () => get('/auth/me'), + me: () => get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'), authMode: () => get('/auth/mode'), - login: (data: Body) => post('/auth/login', data), + login: (data: Body) => post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data), logout: () => post('/auth/logout'), restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'), changePassword: (data: Body) => post('/auth/change-password', data), @@ -133,7 +133,7 @@ export const api = { totpSetup: () => get('/auth/totp/setup'), totpEnable: (data: Body) => post('/auth/totp/enable', data), totpDisable: (data: Body) => post('/auth/totp/disable', data), - totpChallenge: (data: Body) => post('/auth/totp/challenge', data), + totpChallenge: (data: Body) => post<{ user?: User }>('/auth/totp/challenge', data), webauthnStatus: () => get('/auth/webauthn/status'), webauthnSetup: () => get('/auth/webauthn/setup'), @@ -144,7 +144,7 @@ export const api = { webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data), // Admin - hasUsers: () => get('/admin/has-users'), + hasUsers: () => get<{ has_users?: boolean }>('/admin/has-users'), adminUsers: () => get('/admin/users'), createUser: (data: Body) => post('/admin/users', data), resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data), diff --git a/client/hooks/useAuth.tsx b/client/hooks/useAuth.tsx index 587abdf..1da401f 100644 --- a/client/hooks/useAuth.tsx +++ b/client/hooks/useAuth.tsx @@ -1,16 +1,8 @@ import { createContext, useContext, useEffect, useState, type Dispatch, type SetStateAction, type ReactNode } from 'react'; import { api } from '@/api'; +import type { User } from '@/types'; -export interface User { - id: number; - username?: string; - role?: string; - display_name?: string; - displayName?: string; - name?: string; - is_default_admin?: boolean; - [key: string]: unknown; -} +export type { User }; // re-export so existing `@/hooks/useAuth` importers keep working interface MeResponse { user?: User | null; diff --git a/client/pages/AdminPage.tsx b/client/pages/AdminPage.tsx index 5580813..0e89bc0 100644 --- a/client/pages/AdminPage.tsx +++ b/client/pages/AdminPage.tsx @@ -26,7 +26,7 @@ export default function AdminPage() { const loadMe = useCallback(async () => { try { - const d = await api.me() as { user?: User }; + const d = await api.me(); setMe(d.user ?? null); } catch { navigate('/login', { replace: true }); @@ -42,7 +42,7 @@ export default function AdminPage() { const loadHasUsers = useCallback(async () => { try { - const d = await api.hasUsers() as { has_users?: boolean }; + const d = await api.hasUsers(); setHasUsers(!!d.has_users); if (d.has_users) loadUsers(); } catch (err) { diff --git a/client/pages/LoginPage.tsx b/client/pages/LoginPage.tsx index 8fc180d..a4c0224 100644 --- a/client/pages/LoginPage.tsx +++ b/client/pages/LoginPage.tsx @@ -86,7 +86,7 @@ export default function LoginPage() { setError(''); setLoading(true); try { - const data = await api.login({ username, password }) as { requires_totp?: boolean; challenge_token?: string; user: User }; + const data = await api.login({ username, password }); if (data.requires_totp) { setTotpChallenge(data.challenge_token ?? null); setTotpCode(''); @@ -94,7 +94,7 @@ export default function LoginPage() { setUseRecovery(false); return; } - handlePostLogin(data.user); + handlePostLogin(data.user!); } catch (err) { setError(errMessage(err, 'Login failed.')); } finally { @@ -110,8 +110,8 @@ export default function LoginPage() { const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge }; if (useRecovery) payload.recovery_code = totpCode; else payload.code = totpCode; - const data = await api.totpChallenge(payload) as { user: User }; - handlePostLogin(data.user); + const data = await api.totpChallenge(payload); + handlePostLogin(data.user!); } catch (err) { setTotpError(errMessage(err, 'Invalid code.')); setTotpCode(''); diff --git a/client/types.ts b/client/types.ts index 41376ff..0e51d2f 100644 --- a/client/types.ts +++ b/client/types.ts @@ -10,6 +10,19 @@ // sparkline) are left `unknown` and refined when a consumer needs them. import type { Cents, Dollars } from '@/lib/money'; +// The authenticated user (GET /auth/me → `user`). Re-exported from +// `@/hooks/useAuth` for the many components that import it from there. +export interface User { + id: number; + username?: string; + role?: string; + display_name?: string; + displayName?: string; + name?: string; + is_default_admin?: boolean; + [key: string]: unknown; +} + // A bank transaction as serialized by the server. Unlike bill/payment amounts, // raw bank-transaction amounts stay in CENTS on the wire — hence branded `Cents` // (format them with formatCentsUSD, never formatUSD). diff --git a/server.js b/server.js index 56fec03..d8f5001 100644 --- a/server.js +++ b/server.js @@ -196,6 +196,7 @@ app.use((err, req, res, next) => { res.status(err.status || 500).json({ error: 'Internal server error', + code: 'INTERNAL_ERROR', }); });