@tristan2828/ui-foundation 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/bin/ui-foundation.mjs +249 -0
- package/conventions/AGENTS.md +149 -0
- package/conventions/agents/deny-impl-read.mjs +36 -0
- package/conventions/agents/skills/new-entity/SKILL.md +22 -0
- package/conventions/agents/spec-tester.md +35 -0
- package/conventions/docs/add-an-entity.md +204 -0
- package/conventions/docs/cell-patterns.md +204 -0
- package/conventions/docs/column-options.md +122 -0
- package/conventions/docs/design-language.md +116 -0
- package/conventions/docs/entity-plan-template.md +67 -0
- package/dist/api/contracts.d.ts +20 -0
- package/dist/api/contracts.js +4 -0
- package/dist/api/gateway/auth.d.ts +9 -0
- package/dist/api/gateway/auth.js +37 -0
- package/dist/api/gateway/errors.d.ts +5 -0
- package/dist/api/gateway/errors.js +36 -0
- package/dist/api/query-client.d.ts +2 -0
- package/dist/api/query-client.js +19 -0
- package/dist/api/schema.d.ts +246 -0
- package/dist/api/schema.js +5 -0
- package/dist/api/transport/index.d.ts +5 -0
- package/dist/api/transport/index.js +14 -0
- package/dist/auth/auth-context.d.ts +19 -0
- package/dist/auth/auth-context.js +12 -0
- package/dist/auth/auth-provider.d.ts +4 -0
- package/dist/auth/auth-provider.js +101 -0
- package/dist/auth/use-auth.d.ts +2 -0
- package/dist/auth/use-auth.js +9 -0
- package/dist/components/app/app-shell.d.ts +28 -0
- package/dist/components/app/app-shell.js +64 -0
- package/dist/components/app/data-table.d.ts +56 -0
- package/dist/components/app/data-table.js +167 -0
- package/dist/components/app/entity-form.d.ts +14 -0
- package/dist/components/app/entity-form.js +8 -0
- package/dist/components/app/error-state.d.ts +6 -0
- package/dist/components/app/error-state.js +20 -0
- package/dist/components/app/foundation-providers.d.ts +9 -0
- package/dist/components/app/foundation-providers.js +15 -0
- package/dist/components/app/mock-mode-banner.d.ts +3 -0
- package/dist/components/app/mock-mode-banner.js +16 -0
- package/dist/components/app/multi-choice.d.ts +14 -0
- package/dist/components/app/multi-choice.js +13 -0
- package/dist/components/app/password-input.d.ts +3 -0
- package/dist/components/app/password-input.js +30 -0
- package/dist/components/app/route-error-boundary.d.ts +1 -0
- package/dist/components/app/route-error-boundary.js +13 -0
- package/dist/components/theme-provider.d.ts +2 -0
- package/dist/components/theme-provider.js +8 -0
- package/dist/components/ui/badge.d.ts +7 -0
- package/dist/components/ui/badge.js +50 -0
- package/dist/components/ui/button.d.ts +8 -0
- package/dist/components/ui/button.js +34 -0
- package/dist/components/ui/calendar.d.ts +10 -0
- package/dist/components/ui/calendar.js +76 -0
- package/dist/components/ui/card.d.ts +11 -0
- package/dist/components/ui/card.js +25 -0
- package/dist/components/ui/combobox.d.ts +24 -0
- package/dist/components/ui/combobox.js +62 -0
- package/dist/components/ui/dialog.d.ts +17 -0
- package/dist/components/ui/dialog.js +37 -0
- package/dist/components/ui/empty.d.ts +11 -0
- package/dist/components/ui/empty.js +33 -0
- package/dist/components/ui/field.d.ts +24 -0
- package/dist/components/ui/field.js +67 -0
- package/dist/components/ui/input-group.d.ts +18 -0
- package/dist/components/ui/input-group.js +57 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/input.js +8 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/label.js +7 -0
- package/dist/components/ui/popover.d.ts +9 -0
- package/dist/components/ui/popover.js +23 -0
- package/dist/components/ui/select.d.ts +15 -0
- package/dist/components/ui/select.js +35 -0
- package/dist/components/ui/separator.d.ts +3 -0
- package/dist/components/ui/separator.js +8 -0
- package/dist/components/ui/sheet.d.ts +14 -0
- package/dist/components/ui/sheet.js +38 -0
- package/dist/components/ui/sidebar.d.ts +63 -0
- package/dist/components/ui/sidebar.js +255 -0
- package/dist/components/ui/skeleton.d.ts +2 -0
- package/dist/components/ui/skeleton.js +6 -0
- package/dist/components/ui/sonner.d.ts +3 -0
- package/dist/components/ui/sonner.js +24 -0
- package/dist/components/ui/spinner.d.ts +2 -0
- package/dist/components/ui/spinner.js +7 -0
- package/dist/components/ui/table.d.ts +13 -0
- package/dist/components/ui/table.js +45 -0
- package/dist/components/ui/textarea.d.ts +3 -0
- package/dist/components/ui/textarea.js +7 -0
- package/dist/components/ui/tooltip.d.ts +6 -0
- package/dist/components/ui/tooltip.js +16 -0
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.js +5 -0
- package/dist/hooks/table-url-changes.d.ts +14 -0
- package/dist/hooks/table-url-changes.js +32 -0
- package/dist/hooks/use-debounced-value.d.ts +1 -0
- package/dist/hooks/use-debounced-value.js +12 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +14 -0
- package/dist/hooks/use-table-url-state.d.ts +14 -0
- package/dist/hooks/use-table-url-state.js +43 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +28 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +1 -0
- package/dist/mocks/auth.d.ts +9 -0
- package/dist/mocks/auth.js +79 -0
- package/dist/mocks/e2e-hooks.d.ts +3 -0
- package/dist/mocks/e2e-hooks.js +28 -0
- package/dist/mocks/index.d.ts +3 -0
- package/dist/mocks/index.js +5 -0
- package/dist/mocks/override.d.ts +23 -0
- package/dist/mocks/override.js +1 -0
- package/dist/routes/login-schema.d.ts +7 -0
- package/dist/routes/login-schema.js +13 -0
- package/dist/routes/login.d.ts +8 -0
- package/dist/routes/login.js +58 -0
- package/dist/routes/register-schema.d.ts +9 -0
- package/dist/routes/register-schema.js +23 -0
- package/dist/routes/register.d.ts +1 -0
- package/dist/routes/register.js +58 -0
- package/dist/routes/return-path.d.ts +1 -0
- package/dist/routes/return-path.js +9 -0
- package/dist/testing/index.d.ts +40 -0
- package/dist/testing/index.js +103 -0
- package/eslint/index.d.ts +11 -0
- package/eslint/index.js +179 -0
- package/openapi/foundation.yaml +156 -0
- package/package.json +158 -0
- package/styles/index.css +88 -0
- package/styles/theme.css +209 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# <Entity> — entity plan
|
|
2
|
+
|
|
3
|
+
The input to the entity playbook (`docs/foundation/add-an-entity.md`, which any AI
|
|
4
|
+
tool can follow; Claude Code's `/new-entity` is a shortcut). It builds
|
|
5
|
+
exactly what this file says — no invented fields, options or rules. Copy
|
|
6
|
+
this file to `docs/entities/<entity>.md` (kebab-case, singular: `game.md`)
|
|
7
|
+
and fill it in, or ask your AI tool to plan the entity with you and it
|
|
8
|
+
will talk it through and write this file. `docs/entities/widget.md` (the
|
|
9
|
+
template's demo entity, until you delete it) is a filled-in example.
|
|
10
|
+
|
|
11
|
+
## Purpose
|
|
12
|
+
|
|
13
|
+
One or two sentences: what one record is, and who uses the screens.
|
|
14
|
+
|
|
15
|
+
## Fields
|
|
16
|
+
|
|
17
|
+
| Field | Label | Type | Required | Options / rules | List | Filter |
|
|
18
|
+
|---|---|---|---|---|---|---|
|
|
19
|
+
| title | Title | text | yes | 1–200 chars | column, sortable | search |
|
|
20
|
+
| status | Status | single choice | yes | Backlog, Playing, Finished; default Backlog | column, sortable | yes |
|
|
21
|
+
| priority | Priority | single choice | no | Low, Normal, High; empty means not set | column, sortable | yes |
|
|
22
|
+
|
|
23
|
+
- **Field**: camelCase, as it appears in the API (`releaseDate`).
|
|
24
|
+
- **Type** — what the foundation supports today:
|
|
25
|
+
- *Demonstrated by the Widget reference* (copied directly): `text` (one
|
|
26
|
+
line), `long text`, `decimal` (fixed places — say how many),
|
|
27
|
+
`date-time`, `email`, `single choice` (list the options, in display
|
|
28
|
+
order; see the note below if it is *optional*), `multi choice` (list
|
|
29
|
+
the options; any number can be picked —
|
|
30
|
+
chips on the form, badges in the table, an any-of filter), `reference`
|
|
31
|
+
(to another entity — name it; a searchable combobox).
|
|
32
|
+
- *Close variants* (built by a small, stated change to the nearest
|
|
33
|
+
pattern): `integer` (from decimal), `url` (from email — a format
|
|
34
|
+
check), `date` (from date-time — no time part).
|
|
35
|
+
- *Not supported yet:* boolean, file, and anything else.
|
|
36
|
+
Write it anyway with a note; the playbook will stop and raise it
|
|
37
|
+
rather than improvise.
|
|
38
|
+
- **Required**: `yes`, or `no` (then say whether empty means "unknown").
|
|
39
|
+
An **optional `single choice`** needs one extra decision: the label for
|
|
40
|
+
"not set" (`Not checked`, `Any`, `None`…), because the form shows it as
|
|
41
|
+
a real option rather than a blank. Say it in the Options column. Widget
|
|
42
|
+
has no optional choice field, so the playbook builds this one from the
|
|
43
|
+
worked example in `docs/foundation/add-an-entity.md` step 6 rather than by copying
|
|
44
|
+
a screen — a Base UI `Select` can't take `""` as an item value, so the
|
|
45
|
+
pattern needs a sentinel and gets the display wrong without one.
|
|
46
|
+
- **List**: `column` if it shows in the table, and `sortable` if you can
|
|
47
|
+
sort by it; blank if it's form-only.
|
|
48
|
+
- **Filter**: `search` (the text search box), `yes` (a filter control), or
|
|
49
|
+
blank.
|
|
50
|
+
|
|
51
|
+
## List screen
|
|
52
|
+
|
|
53
|
+
- Default sort:
|
|
54
|
+
- Page size: 10 unless there's a reason.
|
|
55
|
+
- Anything else the table must show or do:
|
|
56
|
+
|
|
57
|
+
## Screens and access
|
|
58
|
+
|
|
59
|
+
- Screens: list, create, edit, delete (the only shape the playbook builds
|
|
60
|
+
today).
|
|
61
|
+
- Ownership: **shared** (every signed-in user sees every record) or
|
|
62
|
+
**per-user** (each user sees only their own).
|
|
63
|
+
|
|
64
|
+
## Open questions
|
|
65
|
+
|
|
66
|
+
Anything not decided yet. The playbook won't start while this section has
|
|
67
|
+
an unresolved item.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type Page<T> = {
|
|
2
|
+
items: T[];
|
|
3
|
+
total: number;
|
|
4
|
+
page: number;
|
|
5
|
+
pageSize: number;
|
|
6
|
+
};
|
|
7
|
+
export type AppError = {
|
|
8
|
+
kind: "validation" | "notfound" | "auth" | "server" | "network";
|
|
9
|
+
message: string;
|
|
10
|
+
fieldErrors?: Record<string, string[]>;
|
|
11
|
+
};
|
|
12
|
+
export type QuerySpec = {
|
|
13
|
+
page: number;
|
|
14
|
+
pageSize: number;
|
|
15
|
+
sort?: {
|
|
16
|
+
field: string;
|
|
17
|
+
dir: "asc" | "desc";
|
|
18
|
+
};
|
|
19
|
+
filters?: Record<string, unknown>;
|
|
20
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { components } from "../schema.js";
|
|
2
|
+
type User = components["schemas"]["User"];
|
|
3
|
+
type LoginRequest = components["schemas"]["LoginRequest"];
|
|
4
|
+
type RegisterRequest = components["schemas"]["RegisterRequest"];
|
|
5
|
+
export declare function login(input: LoginRequest): Promise<User>;
|
|
6
|
+
export declare function register(input: RegisterRequest): Promise<User>;
|
|
7
|
+
export declare function logout(): Promise<void>;
|
|
8
|
+
export declare function getCurrentUser(): Promise<User>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { safeFetch, toAppError } from "./errors.js";
|
|
2
|
+
export async function login(input) {
|
|
3
|
+
const res = await safeFetch("/auth/login", {
|
|
4
|
+
method: "POST",
|
|
5
|
+
headers: { "content-type": "application/json" },
|
|
6
|
+
body: JSON.stringify(input),
|
|
7
|
+
});
|
|
8
|
+
if (res.status !== 200)
|
|
9
|
+
throw toAppError(res.status, res.body);
|
|
10
|
+
return res.body;
|
|
11
|
+
}
|
|
12
|
+
// Auto-logs in on success — the backend sets the same session cookie
|
|
13
|
+
// /auth/login does (see backend/app/routers/auth.py's _start_session), so
|
|
14
|
+
// the gateway shape is identical to login's: a User on 200, AppError
|
|
15
|
+
// otherwise (a duplicate email arrives as kind:'validation' with
|
|
16
|
+
// fieldErrors.email, same as any other field error).
|
|
17
|
+
export async function register(input) {
|
|
18
|
+
const res = await safeFetch("/auth/register", {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
body: JSON.stringify(input),
|
|
22
|
+
});
|
|
23
|
+
if (res.status !== 200)
|
|
24
|
+
throw toAppError(res.status, res.body);
|
|
25
|
+
return res.body;
|
|
26
|
+
}
|
|
27
|
+
export async function logout() {
|
|
28
|
+
const res = await safeFetch("/auth/logout", { method: "POST" });
|
|
29
|
+
if (res.status !== 204)
|
|
30
|
+
throw toAppError(res.status, res.body);
|
|
31
|
+
}
|
|
32
|
+
export async function getCurrentUser() {
|
|
33
|
+
const res = await safeFetch("/auth/me");
|
|
34
|
+
if (res.status !== 200)
|
|
35
|
+
throw toAppError(res.status, res.body);
|
|
36
|
+
return res.body;
|
|
37
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ApiResponse } from "../transport/index.js";
|
|
2
|
+
import type { AppError } from "../contracts.js";
|
|
3
|
+
export declare function safeFetch(path: string, init?: RequestInit): Promise<ApiResponse>;
|
|
4
|
+
export declare function toAppError(status: number, body: unknown): AppError;
|
|
5
|
+
export declare function networkError(): AppError;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Shared wire-error -> AppError translation for the gateway, plus a thin
|
|
2
|
+
// wrapper that turns a rejected apiFetch (network-level failure) into
|
|
3
|
+
// AppError{kind:'network'} instead of letting a raw fetch error escape. This
|
|
4
|
+
// is the anti-corruption layer's error seam (docs/ARCHITECTURE.md).
|
|
5
|
+
import { apiFetch } from "../transport/index.js";
|
|
6
|
+
export async function safeFetch(path, init) {
|
|
7
|
+
try {
|
|
8
|
+
return await apiFetch(path, init);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw networkError();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function toAppError(status, body) {
|
|
15
|
+
if (status === 422) {
|
|
16
|
+
const detail = body?.detail ?? [];
|
|
17
|
+
const fieldErrors = {};
|
|
18
|
+
for (const issue of detail) {
|
|
19
|
+
// The field is the last *string* segment: integer segments index into
|
|
20
|
+
// an array field (["body", "tags", 0] is a bad tag), and a form binds
|
|
21
|
+
// errors by field name, never by position.
|
|
22
|
+
const field = String([...issue.loc].reverse().find((segment) => typeof segment === "string") ?? issue.loc[issue.loc.length - 1]);
|
|
23
|
+
(fieldErrors[field] ??= []).push(issue.msg);
|
|
24
|
+
}
|
|
25
|
+
return { kind: "validation", message: "Validation failed", fieldErrors };
|
|
26
|
+
}
|
|
27
|
+
const message = body?.detail ?? `Request failed with status ${status}`;
|
|
28
|
+
if (status === 404)
|
|
29
|
+
return { kind: "notfound", message };
|
|
30
|
+
if (status === 401)
|
|
31
|
+
return { kind: "auth", message };
|
|
32
|
+
return { kind: "server", message };
|
|
33
|
+
}
|
|
34
|
+
export function networkError() {
|
|
35
|
+
return { kind: "network", message: "Network error — check your connection and try again." };
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The default QueryClient retries every failed query 3x with exponential
|
|
2
|
+
// backoff (~7s total) regardless of why it failed. That's right for a
|
|
3
|
+
// network blip but wrong for a 404, 401, or 422 — the gateway already
|
|
4
|
+
// classifies those as AppError.kind values that will never change on
|
|
5
|
+
// retry, so retrying just makes the user wait ~7s to see an error the
|
|
6
|
+
// first response already told us was final.
|
|
7
|
+
import { QueryClient } from "@tanstack/react-query";
|
|
8
|
+
function isTransient(error) {
|
|
9
|
+
return error.kind === "network";
|
|
10
|
+
}
|
|
11
|
+
export function createQueryClient() {
|
|
12
|
+
return new QueryClient({
|
|
13
|
+
defaultOptions: {
|
|
14
|
+
queries: {
|
|
15
|
+
retry: (failureCount, error) => isTransient(error) && failureCount < 2,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file was auto-generated by openapi-typescript.
|
|
3
|
+
* Do not make direct changes to the file.
|
|
4
|
+
*/
|
|
5
|
+
export interface paths {
|
|
6
|
+
"/auth/login": {
|
|
7
|
+
parameters: {
|
|
8
|
+
query?: never;
|
|
9
|
+
header?: never;
|
|
10
|
+
path?: never;
|
|
11
|
+
cookie?: never;
|
|
12
|
+
};
|
|
13
|
+
get?: never;
|
|
14
|
+
put?: never;
|
|
15
|
+
/** Log in with email and password, receiving a session cookie */
|
|
16
|
+
post: operations["login"];
|
|
17
|
+
delete?: never;
|
|
18
|
+
options?: never;
|
|
19
|
+
head?: never;
|
|
20
|
+
patch?: never;
|
|
21
|
+
trace?: never;
|
|
22
|
+
};
|
|
23
|
+
"/auth/register": {
|
|
24
|
+
parameters: {
|
|
25
|
+
query?: never;
|
|
26
|
+
header?: never;
|
|
27
|
+
path?: never;
|
|
28
|
+
cookie?: never;
|
|
29
|
+
};
|
|
30
|
+
get?: never;
|
|
31
|
+
put?: never;
|
|
32
|
+
/** Create an account and immediately log in, receiving a session cookie */
|
|
33
|
+
post: operations["register"];
|
|
34
|
+
delete?: never;
|
|
35
|
+
options?: never;
|
|
36
|
+
head?: never;
|
|
37
|
+
patch?: never;
|
|
38
|
+
trace?: never;
|
|
39
|
+
};
|
|
40
|
+
"/auth/logout": {
|
|
41
|
+
parameters: {
|
|
42
|
+
query?: never;
|
|
43
|
+
header?: never;
|
|
44
|
+
path?: never;
|
|
45
|
+
cookie?: never;
|
|
46
|
+
};
|
|
47
|
+
get?: never;
|
|
48
|
+
put?: never;
|
|
49
|
+
/** Clear the current session */
|
|
50
|
+
post: operations["logout"];
|
|
51
|
+
delete?: never;
|
|
52
|
+
options?: never;
|
|
53
|
+
head?: never;
|
|
54
|
+
patch?: never;
|
|
55
|
+
trace?: never;
|
|
56
|
+
};
|
|
57
|
+
"/auth/me": {
|
|
58
|
+
parameters: {
|
|
59
|
+
query?: never;
|
|
60
|
+
header?: never;
|
|
61
|
+
path?: never;
|
|
62
|
+
cookie?: never;
|
|
63
|
+
};
|
|
64
|
+
/** The currently authenticated user */
|
|
65
|
+
get: operations["getCurrentUser"];
|
|
66
|
+
put?: never;
|
|
67
|
+
post?: never;
|
|
68
|
+
delete?: never;
|
|
69
|
+
options?: never;
|
|
70
|
+
head?: never;
|
|
71
|
+
patch?: never;
|
|
72
|
+
trace?: never;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export type webhooks = Record<string, never>;
|
|
76
|
+
export interface components {
|
|
77
|
+
schemas: {
|
|
78
|
+
User: {
|
|
79
|
+
readonly id: number;
|
|
80
|
+
/** Format: email */
|
|
81
|
+
email: string;
|
|
82
|
+
name: string;
|
|
83
|
+
};
|
|
84
|
+
LoginRequest: {
|
|
85
|
+
/** Format: email */
|
|
86
|
+
email: string;
|
|
87
|
+
password: string;
|
|
88
|
+
};
|
|
89
|
+
RegisterRequest: {
|
|
90
|
+
email: components["schemas"]["User"]["email"];
|
|
91
|
+
name: components["schemas"]["User"]["name"];
|
|
92
|
+
password: components["schemas"]["LoginRequest"]["password"];
|
|
93
|
+
};
|
|
94
|
+
/** @description FastAPI's default 422 shape (RequestValidationError). */
|
|
95
|
+
ValidationErrorBody: {
|
|
96
|
+
detail: {
|
|
97
|
+
loc: (string | number)[];
|
|
98
|
+
msg: string;
|
|
99
|
+
type: string;
|
|
100
|
+
}[];
|
|
101
|
+
};
|
|
102
|
+
/** @description FastAPI's default HTTPException shape. */
|
|
103
|
+
HTTPErrorBody: {
|
|
104
|
+
detail: string;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
responses: {
|
|
108
|
+
/** @description Validation failed on one or more fields. */
|
|
109
|
+
ValidationError: {
|
|
110
|
+
headers: {
|
|
111
|
+
[name: string]: unknown;
|
|
112
|
+
};
|
|
113
|
+
content: {
|
|
114
|
+
"application/json": components["schemas"]["ValidationErrorBody"];
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/** @description Resource not found. */
|
|
118
|
+
NotFoundError: {
|
|
119
|
+
headers: {
|
|
120
|
+
[name: string]: unknown;
|
|
121
|
+
};
|
|
122
|
+
content: {
|
|
123
|
+
"application/json": components["schemas"]["HTTPErrorBody"];
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
/** @description Missing or invalid credentials. */
|
|
127
|
+
UnauthorizedError: {
|
|
128
|
+
headers: {
|
|
129
|
+
[name: string]: unknown;
|
|
130
|
+
};
|
|
131
|
+
content: {
|
|
132
|
+
"application/json": components["schemas"]["HTTPErrorBody"];
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
/** @description Unhandled server error. */
|
|
136
|
+
ServerError: {
|
|
137
|
+
headers: {
|
|
138
|
+
[name: string]: unknown;
|
|
139
|
+
};
|
|
140
|
+
content: {
|
|
141
|
+
"application/json": components["schemas"]["HTTPErrorBody"];
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
parameters: never;
|
|
146
|
+
requestBodies: never;
|
|
147
|
+
headers: never;
|
|
148
|
+
pathItems: never;
|
|
149
|
+
}
|
|
150
|
+
export type $defs = Record<string, never>;
|
|
151
|
+
export interface operations {
|
|
152
|
+
login: {
|
|
153
|
+
parameters: {
|
|
154
|
+
query?: never;
|
|
155
|
+
header?: never;
|
|
156
|
+
path?: never;
|
|
157
|
+
cookie?: never;
|
|
158
|
+
};
|
|
159
|
+
requestBody: {
|
|
160
|
+
content: {
|
|
161
|
+
"application/json": components["schemas"]["LoginRequest"];
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
responses: {
|
|
165
|
+
/** @description Authenticated. Sets the session_id cookie. */
|
|
166
|
+
200: {
|
|
167
|
+
headers: {
|
|
168
|
+
[name: string]: unknown;
|
|
169
|
+
};
|
|
170
|
+
content: {
|
|
171
|
+
"application/json": components["schemas"]["User"];
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
401: components["responses"]["UnauthorizedError"];
|
|
175
|
+
422: components["responses"]["ValidationError"];
|
|
176
|
+
500: components["responses"]["ServerError"];
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
register: {
|
|
180
|
+
parameters: {
|
|
181
|
+
query?: never;
|
|
182
|
+
header?: never;
|
|
183
|
+
path?: never;
|
|
184
|
+
cookie?: never;
|
|
185
|
+
};
|
|
186
|
+
requestBody: {
|
|
187
|
+
content: {
|
|
188
|
+
"application/json": components["schemas"]["RegisterRequest"];
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
responses: {
|
|
192
|
+
/** @description Registered and authenticated. Sets the session_id cookie. */
|
|
193
|
+
200: {
|
|
194
|
+
headers: {
|
|
195
|
+
[name: string]: unknown;
|
|
196
|
+
};
|
|
197
|
+
content: {
|
|
198
|
+
"application/json": components["schemas"]["User"];
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
422: components["responses"]["ValidationError"];
|
|
202
|
+
500: components["responses"]["ServerError"];
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
logout: {
|
|
206
|
+
parameters: {
|
|
207
|
+
query?: never;
|
|
208
|
+
header?: never;
|
|
209
|
+
path?: never;
|
|
210
|
+
cookie?: never;
|
|
211
|
+
};
|
|
212
|
+
requestBody?: never;
|
|
213
|
+
responses: {
|
|
214
|
+
/** @description Logged out (always, whether or not a session existed). */
|
|
215
|
+
204: {
|
|
216
|
+
headers: {
|
|
217
|
+
[name: string]: unknown;
|
|
218
|
+
};
|
|
219
|
+
content?: never;
|
|
220
|
+
};
|
|
221
|
+
500: components["responses"]["ServerError"];
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
getCurrentUser: {
|
|
225
|
+
parameters: {
|
|
226
|
+
query?: never;
|
|
227
|
+
header?: never;
|
|
228
|
+
path?: never;
|
|
229
|
+
cookie?: never;
|
|
230
|
+
};
|
|
231
|
+
requestBody?: never;
|
|
232
|
+
responses: {
|
|
233
|
+
/** @description OK. */
|
|
234
|
+
200: {
|
|
235
|
+
headers: {
|
|
236
|
+
[name: string]: unknown;
|
|
237
|
+
};
|
|
238
|
+
content: {
|
|
239
|
+
"application/json": components["schemas"]["User"];
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
401: components["responses"]["UnauthorizedError"];
|
|
243
|
+
500: components["responses"]["ServerError"];
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The only file allowed to call fetch directly — see AGENTS.md ("NEVER
|
|
2
|
+
// import from api/transport/ outside api/gateway/") and the matching
|
|
3
|
+
// no-restricted-imports / no-restricted-syntax rules in eslint.config.js.
|
|
4
|
+
// Owns the base URL and the raw HTTP call; knows nothing about widgets,
|
|
5
|
+
// pagination, or error shapes — that translation is the gateway's job.
|
|
6
|
+
const BASE_URL = "/api";
|
|
7
|
+
export async function apiFetch(path, init) {
|
|
8
|
+
const response = await fetch(`${BASE_URL}${path}`, init);
|
|
9
|
+
if (response.status === 204) {
|
|
10
|
+
return { status: response.status, body: undefined };
|
|
11
|
+
}
|
|
12
|
+
const body = await response.json().catch(() => undefined);
|
|
13
|
+
return { status: response.status, body };
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AppError } from '../api/contracts.js';
|
|
2
|
+
export type AuthUser = {
|
|
3
|
+
id: number;
|
|
4
|
+
name: string;
|
|
5
|
+
email: string;
|
|
6
|
+
};
|
|
7
|
+
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'unavailable';
|
|
8
|
+
export type AuthContextValue = {
|
|
9
|
+
user: AuthUser | null;
|
|
10
|
+
status: AuthStatus;
|
|
11
|
+
/** Why the session check failed; set only when status is 'unavailable'. */
|
|
12
|
+
error: AppError | null;
|
|
13
|
+
/** Re-runs the session check (the 'unavailable' state's retry). */
|
|
14
|
+
retry: () => void;
|
|
15
|
+
login: (email: string, password: string) => Promise<void>;
|
|
16
|
+
logout: () => Promise<void>;
|
|
17
|
+
register: (email: string, name: string, password: string) => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
export declare const AuthContext: import("react").Context<AuthContextValue>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
// Session-cookie auth against the backend. auth-provider.tsx is the only
|
|
3
|
+
// file that knows how any of this works; see AGENTS.md's hard rule.
|
|
4
|
+
export const AuthContext = createContext({
|
|
5
|
+
user: null,
|
|
6
|
+
status: 'loading',
|
|
7
|
+
error: null,
|
|
8
|
+
retry: () => { },
|
|
9
|
+
login: async () => { },
|
|
10
|
+
logout: async () => { },
|
|
11
|
+
register: async () => { },
|
|
12
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { getCurrentUser, login as loginRequest, logout as logoutRequest, register as registerRequest, } from '../api/gateway/auth.js';
|
|
5
|
+
import { AuthContext } from './auth-context.js';
|
|
6
|
+
// TanStack Query, not useEffect — AGENTS.md's "NEVER fetch in useEffect"
|
|
7
|
+
// rule applies here too. A 401 here just means "not logged in", which the
|
|
8
|
+
// global QueryClient retry policy (api/query-client.ts) already treats as
|
|
9
|
+
// final rather than retrying.
|
|
10
|
+
const AUTH_QUERY_KEY = ['auth', 'me'];
|
|
11
|
+
function isAuthError(error) {
|
|
12
|
+
return error?.kind === 'auth';
|
|
13
|
+
}
|
|
14
|
+
// Every cached query except the session itself belongs to whoever was
|
|
15
|
+
// logged in when it ran. Dropped on every session change so the next user
|
|
16
|
+
// never sees the previous user's rows — widgets are per-user on the
|
|
17
|
+
// backend. `removeQueries` with a predicate, not `clear()`: clear() would
|
|
18
|
+
// also drop the auth query this provider's own useQuery observes, and that
|
|
19
|
+
// observer wouldn't pick up the replacement setQueryData creates.
|
|
20
|
+
function removeUserScopedQueries(queryClient) {
|
|
21
|
+
queryClient.removeQueries({ predicate: (query) => query.queryKey[0] !== AUTH_QUERY_KEY[0] });
|
|
22
|
+
}
|
|
23
|
+
function startSession(queryClient, user) {
|
|
24
|
+
removeUserScopedQueries(queryClient);
|
|
25
|
+
queryClient.setQueryData(AUTH_QUERY_KEY, user);
|
|
26
|
+
}
|
|
27
|
+
function endSession(queryClient) {
|
|
28
|
+
removeUserScopedQueries(queryClient);
|
|
29
|
+
queryClient.setQueryData(AUTH_QUERY_KEY, null);
|
|
30
|
+
}
|
|
31
|
+
// This is the only file that knows how auth works — everything else
|
|
32
|
+
// consumes useAuth() from ./use-auth, and the package exports nothing else
|
|
33
|
+
// of it (docs/ARCHITECTURE.md "The layers").
|
|
34
|
+
export function AuthProvider({ children }) {
|
|
35
|
+
const queryClient = useQueryClient();
|
|
36
|
+
const { data: user, error, isLoading, refetch, } = useQuery({
|
|
37
|
+
queryKey: AUTH_QUERY_KEY,
|
|
38
|
+
queryFn: getCurrentUser,
|
|
39
|
+
});
|
|
40
|
+
// A session can expire while the app is open: the next request for any
|
|
41
|
+
// other resource comes back 401. End the session here, once, rather than
|
|
42
|
+
// leaving each screen to show a "Not authorized" error inside a shell
|
|
43
|
+
// that still thinks it's logged in — AppShell then redirects to /login.
|
|
44
|
+
// A subscription, not a fetch; the auth query's own 401 is excluded (it
|
|
45
|
+
// is already the "logged out" signal).
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const onError = (error) => {
|
|
48
|
+
// Deferred: mutating the cache from inside its own notify callback
|
|
49
|
+
// would re-enter the cache mid-dispatch.
|
|
50
|
+
if (isAuthError(error))
|
|
51
|
+
queueMicrotask(() => endSession(queryClient));
|
|
52
|
+
};
|
|
53
|
+
const unsubscribeQueries = queryClient.getQueryCache().subscribe((event) => {
|
|
54
|
+
if (event.type === 'updated' &&
|
|
55
|
+
event.action.type === 'error' &&
|
|
56
|
+
event.query.queryKey[0] !== AUTH_QUERY_KEY[0]) {
|
|
57
|
+
onError(event.action.error);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
const unsubscribeMutations = queryClient.getMutationCache().subscribe((event) => {
|
|
61
|
+
if (event.type === 'updated' && event.action.type === 'error') {
|
|
62
|
+
onError(event.action.error);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return () => {
|
|
66
|
+
unsubscribeQueries();
|
|
67
|
+
unsubscribeMutations();
|
|
68
|
+
};
|
|
69
|
+
}, [queryClient]);
|
|
70
|
+
// A 401 always means logged out, even if a user was cached from before
|
|
71
|
+
// (a background refetch of /auth/me after the session expired). Any other
|
|
72
|
+
// failure with no user is 'unavailable', not logged out.
|
|
73
|
+
const status = isLoading
|
|
74
|
+
? 'loading'
|
|
75
|
+
: isAuthError(error)
|
|
76
|
+
? 'unauthenticated'
|
|
77
|
+
: user
|
|
78
|
+
? 'authenticated'
|
|
79
|
+
: error
|
|
80
|
+
? 'unavailable'
|
|
81
|
+
: 'unauthenticated';
|
|
82
|
+
async function login(email, password) {
|
|
83
|
+
startSession(queryClient, await loginRequest({ email, password }));
|
|
84
|
+
}
|
|
85
|
+
async function logout() {
|
|
86
|
+
await logoutRequest();
|
|
87
|
+
endSession(queryClient);
|
|
88
|
+
}
|
|
89
|
+
async function register(email, name, password) {
|
|
90
|
+
startSession(queryClient, await registerRequest({ email, name, password }));
|
|
91
|
+
}
|
|
92
|
+
return (_jsx(AuthContext.Provider, { value: {
|
|
93
|
+
user: status === 'authenticated' ? (user ?? null) : null,
|
|
94
|
+
status,
|
|
95
|
+
error: status === 'unavailable' ? error : null,
|
|
96
|
+
retry: () => void refetch(),
|
|
97
|
+
login,
|
|
98
|
+
logout,
|
|
99
|
+
register,
|
|
100
|
+
}, children: children }));
|
|
101
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { AuthContext } from './auth-context.js';
|
|
3
|
+
// Re-exported so the app root can mount the provider without importing
|
|
4
|
+
// auth-provider.tsx directly — this file is the one boundary everything
|
|
5
|
+
// else, including main.tsx, is allowed to import auth from.
|
|
6
|
+
export { AuthProvider } from './auth-provider.js';
|
|
7
|
+
export function useAuth() {
|
|
8
|
+
return useContext(AuthContext);
|
|
9
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ComponentType, type ReactNode } from 'react';
|
|
2
|
+
export type NavItem = {
|
|
3
|
+
/** The route this entry links to. */
|
|
4
|
+
to: string;
|
|
5
|
+
label: string;
|
|
6
|
+
icon: ComponentType;
|
|
7
|
+
/** Active only on an exact match (use for '/'); otherwise on any sub-path too. */
|
|
8
|
+
end?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type AppShellProps = {
|
|
11
|
+
/** Shown at the top of the sidebar (hidden on the collapsed icon rail). */
|
|
12
|
+
title: string;
|
|
13
|
+
/** The app's primary navigation, in order. */
|
|
14
|
+
nav: readonly NavItem[];
|
|
15
|
+
/**
|
|
16
|
+
* More sidebar groups under the primary nav — reference links, say. Built
|
|
17
|
+
* from the sidebar primitives (`@tristan2828/ui-foundation/ui/sidebar`).
|
|
18
|
+
* Rendered inside the Primary landmark, so keep it navigation.
|
|
19
|
+
*/
|
|
20
|
+
sidebarExtra?: ReactNode;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the sidebar starts expanded for someone who has never toggled
|
|
23
|
+
* it. After the first toggle their choice is remembered (the sidebar
|
|
24
|
+
* primitive writes a cookie, read back here). A wide table wants false.
|
|
25
|
+
*/
|
|
26
|
+
defaultSidebarOpen?: boolean;
|
|
27
|
+
};
|
|
28
|
+
export declare function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen }: AppShellProps): import("react").JSX.Element;
|