refactor(ts): move User to @/types + type auth endpoints; add code to 500 (Track C/D)
Track C (auth): User lived in @/hooks/useAuth; moved it to @/types
(re-exported from useAuth for the 7 importers) and typed me/login/
totpChallenge/hasUsers, dropping those Admin/Login casts. Left the
single-consumer non-money casts (adminUsers, version/about/privacy/
health docs) and the optimistic-UI rewrite as deliberate low-value/
high-churn stops.
Track D: global 500 handler now emits code:'INTERNAL_ERROR' so an
unexpected error carries the same {error,code} field as structured 4xx.
typecheck/lint(0 err)/build/server-193 all green; probe 17/17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
65a477cab1
commit
1df4b1bf87
|
|
@ -3,6 +3,8 @@
|
||||||
|
|
||||||
### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint
|
### 🔌 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<T>`/`post<T>`, 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] 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<T>`/`post<T>`. Dropped the `(r: any)` in `useSubscriptionRecommendations` (now typed) and the 5 page casts; removed an unused `CatalogMatch` import (Track E).
|
- **[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<T>`/`post<T>`. 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<T>`/`post<T>`. `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).
|
- **[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<T>`/`post<T>`. `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).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type {
|
import type {
|
||||||
Bill, Category, Payment, TrackerResponse,
|
Bill, Category, Payment, TrackerResponse, User,
|
||||||
BankLedger, BankLedgerTransaction, AutoCategorizePreview,
|
BankLedger, BankLedgerTransaction, AutoCategorizePreview,
|
||||||
SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse,
|
SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse,
|
||||||
SpendingRule, CopyBudgetsResponse, CategoryGroup,
|
SpendingRule, CopyBudgetsResponse, CategoryGroup,
|
||||||
|
|
@ -108,9 +108,9 @@ function filenameFromDisposition(value: string | null): string | null {
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Auth
|
// Auth
|
||||||
me: () => get('/auth/me'),
|
me: () => get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
|
||||||
authMode: () => get('/auth/mode'),
|
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'),
|
logout: () => post('/auth/logout'),
|
||||||
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
|
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
|
||||||
changePassword: (data: Body) => post('/auth/change-password', data),
|
changePassword: (data: Body) => post('/auth/change-password', data),
|
||||||
|
|
@ -133,7 +133,7 @@ export const api = {
|
||||||
totpSetup: () => get('/auth/totp/setup'),
|
totpSetup: () => get('/auth/totp/setup'),
|
||||||
totpEnable: (data: Body) => post('/auth/totp/enable', data),
|
totpEnable: (data: Body) => post('/auth/totp/enable', data),
|
||||||
totpDisable: (data: Body) => post('/auth/totp/disable', 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'),
|
webauthnStatus: () => get('/auth/webauthn/status'),
|
||||||
webauthnSetup: () => get('/auth/webauthn/setup'),
|
webauthnSetup: () => get('/auth/webauthn/setup'),
|
||||||
|
|
@ -144,7 +144,7 @@ export const api = {
|
||||||
webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data),
|
webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data),
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
hasUsers: () => get('/admin/has-users'),
|
hasUsers: () => get<{ has_users?: boolean }>('/admin/has-users'),
|
||||||
adminUsers: () => get('/admin/users'),
|
adminUsers: () => get('/admin/users'),
|
||||||
createUser: (data: Body) => post('/admin/users', data),
|
createUser: (data: Body) => post('/admin/users', data),
|
||||||
resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data),
|
resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data),
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,8 @@
|
||||||
import { createContext, useContext, useEffect, useState, type Dispatch, type SetStateAction, type ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useState, type Dispatch, type SetStateAction, type ReactNode } from 'react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
export interface User {
|
export type { User }; // re-export so existing `@/hooks/useAuth` importers keep working
|
||||||
id: number;
|
|
||||||
username?: string;
|
|
||||||
role?: string;
|
|
||||||
display_name?: string;
|
|
||||||
displayName?: string;
|
|
||||||
name?: string;
|
|
||||||
is_default_admin?: boolean;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export default function AdminPage() {
|
||||||
|
|
||||||
const loadMe = useCallback(async () => {
|
const loadMe = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const d = await api.me() as { user?: User };
|
const d = await api.me();
|
||||||
setMe(d.user ?? null);
|
setMe(d.user ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
|
|
@ -42,7 +42,7 @@ export default function AdminPage() {
|
||||||
|
|
||||||
const loadHasUsers = useCallback(async () => {
|
const loadHasUsers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const d = await api.hasUsers() as { has_users?: boolean };
|
const d = await api.hasUsers();
|
||||||
setHasUsers(!!d.has_users);
|
setHasUsers(!!d.has_users);
|
||||||
if (d.has_users) loadUsers();
|
if (d.has_users) loadUsers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export default function LoginPage() {
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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) {
|
if (data.requires_totp) {
|
||||||
setTotpChallenge(data.challenge_token ?? null);
|
setTotpChallenge(data.challenge_token ?? null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
|
|
@ -94,7 +94,7 @@ export default function LoginPage() {
|
||||||
setUseRecovery(false);
|
setUseRecovery(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handlePostLogin(data.user);
|
handlePostLogin(data.user!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errMessage(err, 'Login failed.'));
|
setError(errMessage(err, 'Login failed.'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -110,8 +110,8 @@ export default function LoginPage() {
|
||||||
const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge };
|
const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge };
|
||||||
if (useRecovery) payload.recovery_code = totpCode;
|
if (useRecovery) payload.recovery_code = totpCode;
|
||||||
else payload.code = totpCode;
|
else payload.code = totpCode;
|
||||||
const data = await api.totpChallenge(payload) as { user: User };
|
const data = await api.totpChallenge(payload);
|
||||||
handlePostLogin(data.user);
|
handlePostLogin(data.user!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setTotpError(errMessage(err, 'Invalid code.'));
|
setTotpError(errMessage(err, 'Invalid code.'));
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,19 @@
|
||||||
// sparkline) are left `unknown` and refined when a consumer needs them.
|
// sparkline) are left `unknown` and refined when a consumer needs them.
|
||||||
import type { Cents, Dollars } from '@/lib/money';
|
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,
|
// 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`
|
// raw bank-transaction amounts stay in CENTS on the wire — hence branded `Cents`
|
||||||
// (format them with formatCentsUSD, never formatUSD).
|
// (format them with formatCentsUSD, never formatUSD).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue