@madojs/mado 0.5.1 → 0.6.1
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/AGENTS.md +26 -0
- package/CHANGELOG.md +265 -0
- package/MADO_V1_PLAN.md +179 -0
- package/README.md +31 -13
- package/ROADMAP.md +28 -7
- package/TODO.md +72 -0
- package/dist/src/forms.d.ts +37 -4
- package/dist/src/forms.js +331 -57
- package/dist/src/forms.js.map +1 -1
- package/dist/src/html/bindings.d.ts +41 -0
- package/dist/src/html/bindings.js +163 -6
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html.d.ts +2 -0
- package/dist/src/html.js +1 -0
- package/dist/src/html.js.map +1 -1
- package/dist/src/index.d.ts +6 -6
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/page.d.ts +56 -0
- package/dist/src/page.js +17 -0
- package/dist/src/page.js.map +1 -1
- package/dist/src/resource.js +11 -0
- package/dist/src/resource.js.map +1 -1
- package/dist/src/router/manifest.d.ts +16 -1
- package/dist/src/router/manifest.js +210 -40
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/match.d.ts +7 -2
- package/dist/src/router/match.js +14 -4
- package/dist/src/router/match.js.map +1 -1
- package/dist/src/router/navigation.d.ts +10 -0
- package/dist/src/router/navigation.js +71 -3
- package/dist/src/router/navigation.js.map +1 -1
- package/dist/src/signal.d.ts +15 -1
- package/dist/src/signal.js +112 -16
- package/dist/src/signal.js.map +1 -1
- package/docs/en/02-project-layout.md +99 -40
- package/docs/en/10-app-architecture.md +141 -0
- package/docs/en/11-layouts.md +115 -0
- package/docs/en/12-auth-and-api.md +217 -0
- package/docs/en/13-deployment.md +192 -0
- package/docs/en/14-testing.md +82 -0
- package/docs/en/15-error-handling.md +100 -0
- package/docs/en/16-bake-cookbook.md +93 -0
- package/docs/en/README.md +7 -0
- package/docs/fr/10-app-architecture.md +61 -0
- package/docs/fr/11-layouts.md +35 -0
- package/docs/fr/12-auth-and-api.md +35 -0
- package/docs/fr/13-deployment.md +39 -0
- package/docs/fr/14-testing.md +41 -0
- package/docs/fr/15-error-handling.md +50 -0
- package/docs/fr/16-bake-cookbook.md +35 -0
- package/docs/fr/README.md +7 -0
- package/docs/ru/10-app-architecture.md +100 -0
- package/docs/ru/11-layouts.md +47 -0
- package/docs/ru/12-auth-and-api.md +53 -0
- package/docs/ru/13-deployment.md +60 -0
- package/docs/ru/14-testing.md +50 -0
- package/docs/ru/15-error-handling.md +56 -0
- package/docs/ru/16-bake-cookbook.md +55 -0
- package/docs/ru/README.md +7 -0
- package/docs/uk/10-app-architecture.md +56 -0
- package/docs/uk/11-layouts.md +34 -0
- package/docs/uk/12-auth-and-api.md +34 -0
- package/docs/uk/13-deployment.md +39 -0
- package/docs/uk/14-testing.md +34 -0
- package/docs/uk/15-error-handling.md +32 -0
- package/docs/uk/16-bake-cookbook.md +36 -0
- package/docs/uk/README.md +7 -0
- package/llms.txt +9 -1
- package/package.json +3 -1
- package/scripts/_config.mjs +224 -0
- package/scripts/bake.mjs +266 -121
- package/scripts/bundle.mjs +133 -67
- package/scripts/cli.mjs +195 -27
- package/scripts/preview.mjs +125 -21
- package/server/serve.mjs +161 -10
- package/starters/admin/README.md +63 -0
- package/starters/admin/index.html +28 -0
- package/starters/admin/mado.config.json +22 -0
- package/starters/admin/package.json +24 -0
- package/starters/admin/public/favicon.svg +4 -0
- package/starters/admin/src/components/x-button.ts +55 -0
- package/starters/admin/src/components/x-input.ts +74 -0
- package/starters/admin/src/layouts/app.ts +101 -0
- package/starters/admin/src/layouts/auth.ts +41 -0
- package/starters/admin/src/lib/api.ts +133 -0
- package/starters/admin/src/lib/auth.ts +83 -0
- package/starters/admin/src/main.ts +15 -0
- package/starters/admin/src/pages/admin/dashboard.ts +48 -0
- package/starters/admin/src/pages/admin/order-detail.ts +80 -0
- package/starters/admin/src/pages/admin/orders.ts +117 -0
- package/starters/admin/src/pages/home.ts +34 -0
- package/starters/admin/src/pages/login.ts +70 -0
- package/starters/admin/src/pages/not-found.ts +12 -0
- package/starters/admin/src/routes.ts +40 -0
- package/starters/admin/src/styles/global.ts +86 -0
- package/starters/admin/tsconfig.json +15 -0
- package/starters/crud/index.html +12 -4
- package/starters/crud/mado.config.json +20 -0
- package/starters/crud/package.json +9 -3
- package/starters/crud/src/pages/home.ts +16 -0
- package/starters/crud/src/routes.ts +4 -2
- package/starters/minimal/index.html +12 -4
- package/starters/minimal/mado.config.json +20 -0
- package/starters/minimal/package.json +9 -3
- package/starters/minimal/src/pages/home.ts +17 -0
- package/starters/minimal/src/routes.ts +4 -2
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// <x-input label name type placeholder required value @input @blur>
|
|
2
|
+
//
|
|
3
|
+
// Labeled input that proxies its events. Use inside `useForm()`:
|
|
4
|
+
//
|
|
5
|
+
// <x-input name="email" type="email" required
|
|
6
|
+
// @input=${form.onInput} @blur=${form.onBlur}></x-input>
|
|
7
|
+
|
|
8
|
+
import { component, css, html } from "@madojs/mado";
|
|
9
|
+
|
|
10
|
+
component(
|
|
11
|
+
"x-input",
|
|
12
|
+
({ host }) => () => {
|
|
13
|
+
const label = host.getAttribute("label") ?? "";
|
|
14
|
+
const name = host.getAttribute("name") ?? "";
|
|
15
|
+
const type = host.getAttribute("type") ?? "text";
|
|
16
|
+
const placeholder = host.getAttribute("placeholder") ?? "";
|
|
17
|
+
const required = host.hasAttribute("required");
|
|
18
|
+
const value = host.getAttribute("value") ?? "";
|
|
19
|
+
const error = host.getAttribute("error");
|
|
20
|
+
|
|
21
|
+
return html`
|
|
22
|
+
<label>
|
|
23
|
+
${label
|
|
24
|
+
? html`<span class="lbl">${label}${required ? html`<em>*</em>` : null}</span>`
|
|
25
|
+
: null}
|
|
26
|
+
<input
|
|
27
|
+
name=${name}
|
|
28
|
+
type=${type}
|
|
29
|
+
placeholder=${placeholder}
|
|
30
|
+
?required=${required}
|
|
31
|
+
.value=${value}
|
|
32
|
+
>
|
|
33
|
+
${error ? html`<small class="err">${error}</small>` : null}
|
|
34
|
+
</label>
|
|
35
|
+
`;
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
styles: css`
|
|
39
|
+
:host { display: block; }
|
|
40
|
+
label { display: block; }
|
|
41
|
+
.lbl {
|
|
42
|
+
display: block;
|
|
43
|
+
font-size: 12px;
|
|
44
|
+
color: var(--fg-muted);
|
|
45
|
+
margin-bottom: var(--space-1);
|
|
46
|
+
}
|
|
47
|
+
.lbl em {
|
|
48
|
+
color: var(--danger);
|
|
49
|
+
font-style: normal;
|
|
50
|
+
margin-left: 2px;
|
|
51
|
+
}
|
|
52
|
+
input {
|
|
53
|
+
width: 100%;
|
|
54
|
+
padding: 8px 10px;
|
|
55
|
+
font: inherit;
|
|
56
|
+
background: var(--bg);
|
|
57
|
+
color: var(--fg);
|
|
58
|
+
border: 1px solid var(--border);
|
|
59
|
+
border-radius: var(--radius-sm);
|
|
60
|
+
}
|
|
61
|
+
input:focus {
|
|
62
|
+
outline: none;
|
|
63
|
+
border-color: var(--accent);
|
|
64
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
|
|
65
|
+
}
|
|
66
|
+
.err {
|
|
67
|
+
display: block;
|
|
68
|
+
color: var(--danger);
|
|
69
|
+
font-size: 12px;
|
|
70
|
+
margin-top: var(--space-1);
|
|
71
|
+
}
|
|
72
|
+
`,
|
|
73
|
+
},
|
|
74
|
+
);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Admin shell layout: top bar + sidebar + content slot.
|
|
2
|
+
//
|
|
3
|
+
// A layout is just a `page({ view })` whose view renders `${ctx.child}`. The
|
|
4
|
+
// router wraps every page in the enclosing group with this template, so the
|
|
5
|
+
// shell is ALWAYS the outer frame, never below the page content.
|
|
6
|
+
//
|
|
7
|
+
// Keep the shell stateless if you can. Auth/user signals belong in
|
|
8
|
+
// src/lib/auth.ts; the layout reads them.
|
|
9
|
+
|
|
10
|
+
import { component, css, html, navigate, page } from "@madojs/mado";
|
|
11
|
+
import { accessToken } from "../lib/api.js";
|
|
12
|
+
import { logout } from "../lib/auth.js";
|
|
13
|
+
import "../components/x-button.js";
|
|
14
|
+
|
|
15
|
+
component(
|
|
16
|
+
"x-admin-shell",
|
|
17
|
+
() => () => html`
|
|
18
|
+
<header>
|
|
19
|
+
<a href="/admin" data-link class="brand">__APP_NAME__</a>
|
|
20
|
+
<nav>
|
|
21
|
+
<a href="/admin" data-link>Dashboard</a>
|
|
22
|
+
<a href="/admin/orders" data-link>Orders</a>
|
|
23
|
+
</nav>
|
|
24
|
+
<span class="spacer"></span>
|
|
25
|
+
<span class="user">${() => maskToken(accessToken())}</span>
|
|
26
|
+
<x-button
|
|
27
|
+
variant="ghost"
|
|
28
|
+
@click=${async () => {
|
|
29
|
+
await logout();
|
|
30
|
+
navigate("/login", { replace: true });
|
|
31
|
+
}}
|
|
32
|
+
>Sign out</x-button>
|
|
33
|
+
</header>
|
|
34
|
+
<aside>
|
|
35
|
+
<nav>
|
|
36
|
+
<a href="/admin" data-link>Overview</a>
|
|
37
|
+
<a href="/admin/orders" data-link>Orders</a>
|
|
38
|
+
</nav>
|
|
39
|
+
</aside>
|
|
40
|
+
<main>
|
|
41
|
+
<slot></slot>
|
|
42
|
+
</main>
|
|
43
|
+
`,
|
|
44
|
+
{
|
|
45
|
+
styles: css`
|
|
46
|
+
:host {
|
|
47
|
+
display: grid;
|
|
48
|
+
grid-template-columns: 220px 1fr;
|
|
49
|
+
grid-template-rows: 56px 1fr;
|
|
50
|
+
grid-template-areas:
|
|
51
|
+
"topbar topbar"
|
|
52
|
+
"side main";
|
|
53
|
+
min-height: 100vh;
|
|
54
|
+
}
|
|
55
|
+
header {
|
|
56
|
+
grid-area: topbar;
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: var(--space-4);
|
|
60
|
+
padding: 0 var(--space-5);
|
|
61
|
+
background: var(--bg-elevated);
|
|
62
|
+
border-bottom: 1px solid var(--border);
|
|
63
|
+
}
|
|
64
|
+
header .brand { font-weight: 700; color: var(--fg); }
|
|
65
|
+
header nav { display: flex; gap: var(--space-3); }
|
|
66
|
+
header nav a { color: var(--fg-muted); }
|
|
67
|
+
header nav a:hover { color: var(--fg); text-decoration: none; }
|
|
68
|
+
header .user { color: var(--fg-muted); font-variant-numeric: tabular-nums; }
|
|
69
|
+
.spacer { flex: 1; }
|
|
70
|
+
aside {
|
|
71
|
+
grid-area: side;
|
|
72
|
+
border-right: 1px solid var(--border);
|
|
73
|
+
background: var(--bg-elevated);
|
|
74
|
+
padding: var(--space-4);
|
|
75
|
+
}
|
|
76
|
+
aside nav { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
77
|
+
aside nav a {
|
|
78
|
+
padding: var(--space-2) var(--space-3);
|
|
79
|
+
border-radius: var(--radius-sm);
|
|
80
|
+
color: var(--fg-muted);
|
|
81
|
+
}
|
|
82
|
+
aside nav a:hover {
|
|
83
|
+
background: var(--bg);
|
|
84
|
+
color: var(--fg);
|
|
85
|
+
text-decoration: none;
|
|
86
|
+
}
|
|
87
|
+
main { grid-area: main; padding: var(--space-5); }
|
|
88
|
+
`,
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
function maskToken(token: string | null): string {
|
|
93
|
+
if (!token) return "—";
|
|
94
|
+
return `signed in · …${token.slice(-4)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default page({
|
|
98
|
+
view: ({ child }) => html`
|
|
99
|
+
<x-admin-shell>${child}</x-admin-shell>
|
|
100
|
+
`,
|
|
101
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Centered card layout for auth pages (login, signup, password reset).
|
|
2
|
+
// Identical shape to layouts/app.ts but a different shell.
|
|
3
|
+
|
|
4
|
+
import { component, css, html, page } from "@madojs/mado";
|
|
5
|
+
|
|
6
|
+
component(
|
|
7
|
+
"x-auth-shell",
|
|
8
|
+
() => () => html`
|
|
9
|
+
<main>
|
|
10
|
+
<div class="card">
|
|
11
|
+
<slot></slot>
|
|
12
|
+
</div>
|
|
13
|
+
</main>
|
|
14
|
+
`,
|
|
15
|
+
{
|
|
16
|
+
styles: css`
|
|
17
|
+
:host {
|
|
18
|
+
display: block;
|
|
19
|
+
min-height: 100vh;
|
|
20
|
+
}
|
|
21
|
+
main {
|
|
22
|
+
min-height: 100vh;
|
|
23
|
+
display: grid;
|
|
24
|
+
place-items: center;
|
|
25
|
+
padding: var(--space-5);
|
|
26
|
+
}
|
|
27
|
+
.card {
|
|
28
|
+
background: var(--bg-elevated);
|
|
29
|
+
border: 1px solid var(--border);
|
|
30
|
+
border-radius: var(--radius);
|
|
31
|
+
box-shadow: var(--shadow-1);
|
|
32
|
+
padding: var(--space-6);
|
|
33
|
+
width: min(360px, 100%);
|
|
34
|
+
}
|
|
35
|
+
`,
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export default page({
|
|
40
|
+
view: ({ child }) => html`<x-auth-shell>${child}</x-auth-shell>`,
|
|
41
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Blessed API client recipe.
|
|
2
|
+
//
|
|
3
|
+
// One JSON fetch wrapper. One error type. Bearer token from `accessToken`.
|
|
4
|
+
// A refresh path that asks `/api/auth/refresh` (HttpOnly cookie) and retries
|
|
5
|
+
// once. Aborts via `AbortSignal`. JSON in / JSON out.
|
|
6
|
+
//
|
|
7
|
+
// Copy and adjust to your backend. Keep `api` as the single fetch boundary so
|
|
8
|
+
// that auth, refresh, error parsing and tracing live in exactly one place.
|
|
9
|
+
|
|
10
|
+
import { signal } from "@madojs/mado";
|
|
11
|
+
|
|
12
|
+
/** Memory-only access token. Refresh restores it from an HttpOnly cookie. */
|
|
13
|
+
export const accessToken = signal<string | null>(null);
|
|
14
|
+
|
|
15
|
+
/** Typed HTTP error with structured `body` for the UI to inspect. */
|
|
16
|
+
export class ApiError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
public status: number,
|
|
19
|
+
public body: unknown,
|
|
20
|
+
message: string,
|
|
21
|
+
) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "ApiError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ApiInit extends Omit<RequestInit, "body"> {
|
|
28
|
+
/** JSON-serialisable body. If set, `content-type: application/json` is added. */
|
|
29
|
+
json?: unknown;
|
|
30
|
+
/** Override the default base URL for this call. */
|
|
31
|
+
baseUrl?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Join a base path and a relative path without losing prefixes.
|
|
36
|
+
* Works with both relative (`/api`) and absolute (`https://...`) bases.
|
|
37
|
+
*/
|
|
38
|
+
function joinUrl(base: string, path: string): string {
|
|
39
|
+
if (/^https?:\/\//.test(path)) return path;
|
|
40
|
+
|
|
41
|
+
const p = path.replace(/^\/+/, "");
|
|
42
|
+
|
|
43
|
+
// Absolute URL base — keep its pathname prefix (for example /api).
|
|
44
|
+
if (/^https?:\/\//.test(base)) {
|
|
45
|
+
return new URL(p, base.replace(/\/+$/, "") + "/").href;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Relative base (e.g. "/api") — simple string join, normalising slashes.
|
|
49
|
+
const b = base.replace(/\/+$/, "");
|
|
50
|
+
return p ? `${b}/${p}` : b || "/";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create an API client bound to a base URL. Returns an `api()` function that
|
|
55
|
+
* speaks JSON, attaches the access token, and retries once after a 401 if a
|
|
56
|
+
* refresh succeeds.
|
|
57
|
+
*
|
|
58
|
+
* export const api = createApiClient("/api");
|
|
59
|
+
* const user = await api<User>("/users/me");
|
|
60
|
+
* await api("/posts", { method: "POST", json: { title: "hi" } });
|
|
61
|
+
*/
|
|
62
|
+
export function createApiClient(baseUrl: string) {
|
|
63
|
+
let refreshing: Promise<boolean> | null = null;
|
|
64
|
+
|
|
65
|
+
async function refresh(): Promise<boolean> {
|
|
66
|
+
if (refreshing) return refreshing;
|
|
67
|
+
refreshing = (async () => {
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(joinUrl(baseUrl, "/auth/refresh"), {
|
|
70
|
+
method: "POST",
|
|
71
|
+
credentials: "include",
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) return false;
|
|
74
|
+
const data = (await res.json().catch(() => null)) as
|
|
75
|
+
| { accessToken?: string }
|
|
76
|
+
| null;
|
|
77
|
+
if (!data?.accessToken) return false;
|
|
78
|
+
accessToken.set(data.accessToken);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
} finally {
|
|
83
|
+
refreshing = null;
|
|
84
|
+
}
|
|
85
|
+
})();
|
|
86
|
+
return refreshing;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function request<T>(
|
|
90
|
+
path: string,
|
|
91
|
+
init: ApiInit = {},
|
|
92
|
+
retried = false,
|
|
93
|
+
): Promise<T> {
|
|
94
|
+
const url = joinUrl(init.baseUrl ?? baseUrl, path);
|
|
95
|
+
const headers = new Headers(init.headers);
|
|
96
|
+
if (init.json !== undefined && !headers.has("content-type")) {
|
|
97
|
+
headers.set("content-type", "application/json");
|
|
98
|
+
}
|
|
99
|
+
const token = accessToken();
|
|
100
|
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
101
|
+
|
|
102
|
+
const res = await fetch(url, {
|
|
103
|
+
...init,
|
|
104
|
+
headers,
|
|
105
|
+
credentials: init.credentials ?? "include",
|
|
106
|
+
body:
|
|
107
|
+
init.json !== undefined
|
|
108
|
+
? JSON.stringify(init.json)
|
|
109
|
+
: (init as RequestInit).body,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (res.status === 401) {
|
|
113
|
+
if (!retried && (await refresh())) {
|
|
114
|
+
return request<T>(path, init, true);
|
|
115
|
+
}
|
|
116
|
+
accessToken.set(null);
|
|
117
|
+
throw new ApiError(401, null, "Unauthorized");
|
|
118
|
+
}
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
const body = await res.json().catch(() => null);
|
|
121
|
+
throw new ApiError(res.status, body, `HTTP ${res.status} ${res.statusText}`);
|
|
122
|
+
}
|
|
123
|
+
if (res.status === 204) return null as unknown as T;
|
|
124
|
+
return (await res.json()) as T;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return function api<T>(path: string, init: ApiInit = {}): Promise<T> {
|
|
128
|
+
return request<T>(path, init);
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Default app-wide client. Change the base URL via mado.config.json dev.proxy. */
|
|
133
|
+
export const api = createApiClient("/api");
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Blessed auth recipe: memory-only access token + HttpOnly-cookie refresh +
|
|
2
|
+
// `requireAuth` guard for use in nested route groups.
|
|
3
|
+
//
|
|
4
|
+
// Usage in routes.ts:
|
|
5
|
+
//
|
|
6
|
+
// "/admin": layout({
|
|
7
|
+
// layout: () => import("./layouts/app.js"),
|
|
8
|
+
// guard: requireAuth,
|
|
9
|
+
// routes: { ... },
|
|
10
|
+
// })
|
|
11
|
+
//
|
|
12
|
+
// When a user lands on a protected route, requireAuth() tries to silently
|
|
13
|
+
// restore the access token via the refresh cookie. If that fails, it redirects
|
|
14
|
+
// to `/login?return=<original-url>`. The login page reads `return` and
|
|
15
|
+
// navigates back after a successful sign-in.
|
|
16
|
+
|
|
17
|
+
import type { Guard } from "@madojs/mado";
|
|
18
|
+
import { accessToken, api, ApiError } from "./api.js";
|
|
19
|
+
|
|
20
|
+
let restorePromise: Promise<boolean> | null = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Try once per session to restore the access token from the HttpOnly refresh
|
|
24
|
+
* cookie. Subsequent calls reuse the same promise so a hard refresh that hits
|
|
25
|
+
* five protected routes does not fire five refresh requests.
|
|
26
|
+
*/
|
|
27
|
+
export async function restoreSession(): Promise<boolean> {
|
|
28
|
+
if (accessToken()) return true;
|
|
29
|
+
if (restorePromise) return restorePromise;
|
|
30
|
+
restorePromise = (async () => {
|
|
31
|
+
try {
|
|
32
|
+
const data = await api<{ accessToken: string }>("/auth/refresh", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
});
|
|
35
|
+
accessToken.set(data.accessToken);
|
|
36
|
+
return true;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
// 401 is expected for unauthenticated visitors.
|
|
39
|
+
if (e instanceof ApiError && e.status === 401) return false;
|
|
40
|
+
return false;
|
|
41
|
+
} finally {
|
|
42
|
+
restorePromise = null;
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
return restorePromise;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Route guard: only let the user in if they have a valid session. Otherwise
|
|
50
|
+
* redirect to /login, preserving the original URL as `?return=`.
|
|
51
|
+
*/
|
|
52
|
+
export const requireAuth: Guard = async ({ path }) => {
|
|
53
|
+
if (accessToken()) return;
|
|
54
|
+
if (await restoreSession()) return;
|
|
55
|
+
return {
|
|
56
|
+
redirect: `/login?return=${encodeURIComponent(path)}`,
|
|
57
|
+
replace: true,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export interface LoginCredentials {
|
|
62
|
+
email: string;
|
|
63
|
+
password: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Log in. Persists the access token in memory; refresh cookie is set by the server. */
|
|
67
|
+
export async function login(creds: LoginCredentials): Promise<void> {
|
|
68
|
+
const data = await api<{ accessToken: string }>("/auth/login", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
json: creds,
|
|
71
|
+
});
|
|
72
|
+
accessToken.set(data.accessToken);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Log out everywhere: drop token in memory and tell the server to invalidate the refresh cookie. */
|
|
76
|
+
export async function logout(): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
await api("/auth/logout", { method: "POST" });
|
|
79
|
+
} catch {
|
|
80
|
+
// Best-effort; even if the network is offline we still clear locally.
|
|
81
|
+
}
|
|
82
|
+
accessToken.set(null);
|
|
83
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// App entry point.
|
|
2
|
+
//
|
|
3
|
+
// The single job of main.ts is: mount the router into #app. Everything else
|
|
4
|
+
// (layouts, guards, pages, auth) is declared in routes.ts and the modules it
|
|
5
|
+
// imports. Do NOT wrap routes in a custom shell here — the shell is a `layout()`
|
|
6
|
+
// (see src/layouts/) so it can be different per route group.
|
|
7
|
+
|
|
8
|
+
import { html, render } from "@madojs/mado";
|
|
9
|
+
import "./styles/global.js";
|
|
10
|
+
import router from "./routes.js";
|
|
11
|
+
|
|
12
|
+
const app = document.getElementById("app");
|
|
13
|
+
if (!app) throw new Error("#app not found");
|
|
14
|
+
|
|
15
|
+
render(html`${router.view}`, app);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Admin dashboard. Demonstrates `resource()` for loading data with
|
|
2
|
+
// loading/error states.
|
|
3
|
+
|
|
4
|
+
import { html, jsonFetcher, page, resource } from "@madojs/mado";
|
|
5
|
+
|
|
6
|
+
interface Stats {
|
|
7
|
+
orders: number;
|
|
8
|
+
revenue: number;
|
|
9
|
+
customers: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default page({
|
|
13
|
+
title: "Dashboard",
|
|
14
|
+
view: () => {
|
|
15
|
+
const stats = resource(() => "/api/admin/stats", jsonFetcher<Stats>(), {
|
|
16
|
+
staleTime: 30_000,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return html`
|
|
20
|
+
<h1 style="margin:0 0 24px;">Dashboard</h1>
|
|
21
|
+
${() => {
|
|
22
|
+
if (stats.loading()) return html`<p class="muted">Loading…</p>`;
|
|
23
|
+
if (stats.error())
|
|
24
|
+
return html`<p style="color:var(--danger);">${stats.error()?.message}</p>`;
|
|
25
|
+
const s = stats.data();
|
|
26
|
+
if (!s) return null;
|
|
27
|
+
return html`
|
|
28
|
+
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-4);">
|
|
29
|
+
<div class="card">
|
|
30
|
+
<div class="muted">Orders</div>
|
|
31
|
+
<div style="font-size:24px;font-weight:700;">${s.orders}</div>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="card">
|
|
34
|
+
<div class="muted">Revenue</div>
|
|
35
|
+
<div style="font-size:24px;font-weight:700;">
|
|
36
|
+
$${s.revenue.toLocaleString()}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="card">
|
|
40
|
+
<div class="muted">Customers</div>
|
|
41
|
+
<div style="font-size:24px;font-weight:700;">${s.customers}</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
`;
|
|
45
|
+
}}
|
|
46
|
+
`;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Order detail page. Reads :id from params, fetches the order, and shows
|
|
2
|
+
// inline loading/error/empty/ready states.
|
|
3
|
+
|
|
4
|
+
import { each, html, jsonFetcher, page, resource } from "@madojs/mado";
|
|
5
|
+
|
|
6
|
+
interface OrderDetail {
|
|
7
|
+
id: string;
|
|
8
|
+
customer: string;
|
|
9
|
+
total: number;
|
|
10
|
+
status: string;
|
|
11
|
+
items: Array<{ sku: string; name: string; qty: number; price: number }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default page<{ id: string }>({
|
|
15
|
+
title: ({ id }) => `Order ${id}`,
|
|
16
|
+
view: ({ params }) => {
|
|
17
|
+
const order = resource(
|
|
18
|
+
() => `/api/admin/orders/${params.id}`,
|
|
19
|
+
jsonFetcher<OrderDetail>(),
|
|
20
|
+
{ staleTime: 15_000 },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return html`
|
|
24
|
+
<p>
|
|
25
|
+
<a href="/admin/orders" data-link>← All orders</a>
|
|
26
|
+
</p>
|
|
27
|
+
${() => {
|
|
28
|
+
if (order.loading() && !order.data())
|
|
29
|
+
return html`<p class="muted">Loading order…</p>`;
|
|
30
|
+
if (order.error())
|
|
31
|
+
return html`<p style="color:var(--danger);">${order.error()?.message}</p>`;
|
|
32
|
+
const o = order.data();
|
|
33
|
+
if (!o)
|
|
34
|
+
return html`<p class="muted">Order not found.</p>`;
|
|
35
|
+
return html`
|
|
36
|
+
<h1 style="margin:0 0 8px;">Order ${o.id}</h1>
|
|
37
|
+
<p class="muted" style="margin:0 0 24px;">${o.customer} · ${o.status}</p>
|
|
38
|
+
<div class="card">
|
|
39
|
+
<table style="width:100%;border-collapse:collapse;">
|
|
40
|
+
<thead>
|
|
41
|
+
<tr style="text-align:left;border-bottom:1px solid var(--border);">
|
|
42
|
+
<th style="padding:8px 0;">SKU</th>
|
|
43
|
+
<th style="padding:8px 0;">Item</th>
|
|
44
|
+
<th style="padding:8px 0;text-align:right;">Qty</th>
|
|
45
|
+
<th style="padding:8px 0;text-align:right;">Price</th>
|
|
46
|
+
</tr>
|
|
47
|
+
</thead>
|
|
48
|
+
<tbody>
|
|
49
|
+
${each(
|
|
50
|
+
o.items,
|
|
51
|
+
(it) => it.sku,
|
|
52
|
+
(it) => html`
|
|
53
|
+
<tr style="border-bottom:1px solid var(--border);">
|
|
54
|
+
<td style="padding:8px 0;">${it.sku}</td>
|
|
55
|
+
<td style="padding:8px 0;">${it.name}</td>
|
|
56
|
+
<td style="padding:8px 0;text-align:right;">${it.qty}</td>
|
|
57
|
+
<td style="padding:8px 0;text-align:right;font-variant-numeric:tabular-nums;">
|
|
58
|
+
$${it.price.toFixed(2)}
|
|
59
|
+
</td>
|
|
60
|
+
</tr>
|
|
61
|
+
`,
|
|
62
|
+
)}
|
|
63
|
+
</tbody>
|
|
64
|
+
<tfoot>
|
|
65
|
+
<tr>
|
|
66
|
+
<td colspan="3" style="padding-top:12px;text-align:right;font-weight:600;">
|
|
67
|
+
Total
|
|
68
|
+
</td>
|
|
69
|
+
<td style="padding-top:12px;text-align:right;font-weight:600;font-variant-numeric:tabular-nums;">
|
|
70
|
+
$${o.total.toFixed(2)}
|
|
71
|
+
</td>
|
|
72
|
+
</tr>
|
|
73
|
+
</tfoot>
|
|
74
|
+
</table>
|
|
75
|
+
</div>
|
|
76
|
+
`;
|
|
77
|
+
}}
|
|
78
|
+
`;
|
|
79
|
+
},
|
|
80
|
+
});
|