@openparachute/hub 0.3.0-rc.1 → 0.5.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/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branded HTML for the hub admin pages — login + config portal (#46). Same
|
|
3
|
+
* privacy posture as `oauth-ui.ts` (no third-party fonts, inline CSS, no JS),
|
|
4
|
+
* but laid out wider and quieter — admin pages aren't an authorization
|
|
5
|
+
* decision, they're a config form. Sharing the brand mark + palette but not
|
|
6
|
+
* the per-page chrome keeps the visual context distinct ("you're configuring
|
|
7
|
+
* a module" vs. "you're authorizing an app").
|
|
8
|
+
*
|
|
9
|
+
* Pure functions — DB, filesystem, lifecycle live in `admin-handlers.ts`.
|
|
10
|
+
*/
|
|
11
|
+
import { renderCsrfHiddenInput } from "./csrf.ts";
|
|
12
|
+
import type { ConfigSchemaProperty } from "./module-manifest.ts";
|
|
13
|
+
import { escapeHtml } from "./oauth-ui.ts";
|
|
14
|
+
|
|
15
|
+
import type { ConfigurableModule } from "./admin-config.ts";
|
|
16
|
+
|
|
17
|
+
// --- shared chrome ---------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const PALETTE = {
|
|
20
|
+
bg: "#faf8f4",
|
|
21
|
+
bgSoft: "#f3f0ea",
|
|
22
|
+
fg: "#2c2a26",
|
|
23
|
+
fgMuted: "#6b6860",
|
|
24
|
+
fgDim: "#9a9690",
|
|
25
|
+
accent: "#4a7c59",
|
|
26
|
+
accentHover: "#3d6849",
|
|
27
|
+
accentSoft: "rgba(74, 124, 89, 0.08)",
|
|
28
|
+
border: "#e4e0d8",
|
|
29
|
+
borderLight: "#ece9e2",
|
|
30
|
+
cardBg: "#ffffff",
|
|
31
|
+
danger: "#a3392b",
|
|
32
|
+
dangerSoft: "rgba(163, 57, 43, 0.08)",
|
|
33
|
+
success: "#3d6849",
|
|
34
|
+
successSoft: "rgba(61, 104, 73, 0.08)",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
const FONT_SERIF = `Georgia, "Times New Roman", serif`;
|
|
38
|
+
const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
|
|
39
|
+
const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
|
|
40
|
+
|
|
41
|
+
function escapeAttr(s: string): string {
|
|
42
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function baseDocument(title: string, body: string, layout: "narrow" | "wide" = "narrow"): string {
|
|
46
|
+
const cls = layout === "wide" ? "main-wide" : "";
|
|
47
|
+
return `<!doctype html>
|
|
48
|
+
<html lang="en">
|
|
49
|
+
<head>
|
|
50
|
+
<meta charset="utf-8" />
|
|
51
|
+
<title>${escapeHtml(title)}</title>
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
53
|
+
<meta name="referrer" content="no-referrer" />
|
|
54
|
+
<style>${STYLES}</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<main class="${cls}">
|
|
58
|
+
${body}
|
|
59
|
+
</main>
|
|
60
|
+
</body>
|
|
61
|
+
</html>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- /admin/login ----------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export interface AdminLoginProps {
|
|
67
|
+
/** Continuation path after successful login — submitted as a hidden field. */
|
|
68
|
+
next: string;
|
|
69
|
+
csrfToken: string;
|
|
70
|
+
errorMessage?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function renderAdminLogin(props: AdminLoginProps): string {
|
|
74
|
+
const { next, csrfToken, errorMessage } = props;
|
|
75
|
+
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
76
|
+
const body = `
|
|
77
|
+
<div class="card">
|
|
78
|
+
<div class="card-header">
|
|
79
|
+
<div class="brand">
|
|
80
|
+
<span class="brand-mark">⌬</span>
|
|
81
|
+
<span class="brand-name">Parachute</span>
|
|
82
|
+
<span class="brand-tag">admin</span>
|
|
83
|
+
</div>
|
|
84
|
+
<h1>Sign in</h1>
|
|
85
|
+
<p class="subtitle">to administer this hub</p>
|
|
86
|
+
</div>
|
|
87
|
+
${error}
|
|
88
|
+
<form method="POST" action="/admin/login" class="auth-form">
|
|
89
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
90
|
+
<input type="hidden" name="next" value="${escapeAttr(next)}" />
|
|
91
|
+
<label class="field">
|
|
92
|
+
<span class="field-label">Username</span>
|
|
93
|
+
<input type="text" name="username" autocomplete="username" autofocus required />
|
|
94
|
+
</label>
|
|
95
|
+
<label class="field">
|
|
96
|
+
<span class="field-label">Password</span>
|
|
97
|
+
<input type="password" name="password" autocomplete="current-password" required />
|
|
98
|
+
</label>
|
|
99
|
+
<button type="submit" class="btn btn-primary">Sign in</button>
|
|
100
|
+
</form>
|
|
101
|
+
</div>`;
|
|
102
|
+
return baseDocument("Sign in to Parachute Hub admin", body);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- /admin/config ---------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export interface ModuleStatus {
|
|
108
|
+
/** Top-level error (e.g. config write failed). */
|
|
109
|
+
errorMessage?: string;
|
|
110
|
+
/** Per-field errors keyed by property name. */
|
|
111
|
+
fieldErrors?: Record<string, string>;
|
|
112
|
+
/** "Saved" / "Saved and restarted" banner. */
|
|
113
|
+
successMessage?: string;
|
|
114
|
+
/** Pending values to re-render on validation failure. */
|
|
115
|
+
pending?: Record<string, string | boolean | undefined>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface AdminConfigModuleView {
|
|
119
|
+
module: ConfigurableModule;
|
|
120
|
+
/** Current on-disk values; `validateAndCoerce`-ready (strings/numbers/booleans). */
|
|
121
|
+
current: Record<string, unknown>;
|
|
122
|
+
/** Set when config.json existed but couldn't be parsed. */
|
|
123
|
+
parseError?: string;
|
|
124
|
+
status?: ModuleStatus;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface AdminConfigPageProps {
|
|
128
|
+
modules: AdminConfigModuleView[];
|
|
129
|
+
csrfToken: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function renderAdminConfigPage(props: AdminConfigPageProps): string {
|
|
133
|
+
const { modules, csrfToken } = props;
|
|
134
|
+
if (modules.length === 0) {
|
|
135
|
+
const body = `
|
|
136
|
+
<div class="card admin-empty">
|
|
137
|
+
${header()}
|
|
138
|
+
<h1>Module config</h1>
|
|
139
|
+
<p class="subtitle">No installed module declares a <code>configSchema</code> in its <code>.parachute/module.json</code>.</p>
|
|
140
|
+
<p class="empty-hint">
|
|
141
|
+
Once a module ships an editable config schema, this page will let you tune its values and restart it
|
|
142
|
+
without leaving the hub. Until then there's nothing to configure here.
|
|
143
|
+
</p>
|
|
144
|
+
</div>`;
|
|
145
|
+
return baseDocument("Module config — Parachute Hub", body);
|
|
146
|
+
}
|
|
147
|
+
const sections = modules.map((m) => renderModuleSection(m, csrfToken)).join("\n");
|
|
148
|
+
const body = `
|
|
149
|
+
<div class="page-header">
|
|
150
|
+
${header()}
|
|
151
|
+
<h1>Module config</h1>
|
|
152
|
+
<p class="subtitle">Edit a module's configuration and restart it without leaving the hub.</p>
|
|
153
|
+
</div>
|
|
154
|
+
${sections}`;
|
|
155
|
+
return baseDocument("Module config — Parachute Hub", body, "wide");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function header(): string {
|
|
159
|
+
return `
|
|
160
|
+
<div class="brand">
|
|
161
|
+
<span class="brand-mark">⌬</span>
|
|
162
|
+
<span class="brand-name">Parachute</span>
|
|
163
|
+
<span class="brand-tag">admin</span>
|
|
164
|
+
</div>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderModuleSection(view: AdminConfigModuleView, csrfToken: string): string {
|
|
168
|
+
const { module, current, parseError, status } = view;
|
|
169
|
+
const action = `/admin/config/${encodeURIComponent(module.name)}`;
|
|
170
|
+
const banner = renderStatusBanner(status, parseError);
|
|
171
|
+
const tagline = module.tagline
|
|
172
|
+
? `<p class="module-tagline">${escapeHtml(module.tagline)}</p>`
|
|
173
|
+
: "";
|
|
174
|
+
const fields = Object.entries(module.schema.properties)
|
|
175
|
+
.map(([key, prop]) => {
|
|
176
|
+
const required = (module.schema.required ?? []).includes(key);
|
|
177
|
+
const error = status?.fieldErrors?.[key];
|
|
178
|
+
const pending = status?.pending?.[key];
|
|
179
|
+
const value = pending !== undefined ? pending : current[key];
|
|
180
|
+
return renderField(key, prop, value, required, error);
|
|
181
|
+
})
|
|
182
|
+
.join("\n ");
|
|
183
|
+
return `
|
|
184
|
+
<section class="card module-card" id="module-${escapeAttr(module.name)}">
|
|
185
|
+
<header class="module-header">
|
|
186
|
+
<h2 class="module-name">${escapeHtml(module.displayName)}</h2>
|
|
187
|
+
<code class="module-id">${escapeHtml(module.name)}</code>
|
|
188
|
+
${tagline}
|
|
189
|
+
</header>
|
|
190
|
+
${banner}
|
|
191
|
+
<form method="POST" action="${escapeAttr(action)}" class="config-form">
|
|
192
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
193
|
+
${fields}
|
|
194
|
+
<div class="button-row">
|
|
195
|
+
<button type="submit" class="btn btn-primary">Save & restart ${escapeHtml(module.displayName)}</button>
|
|
196
|
+
</div>
|
|
197
|
+
</form>
|
|
198
|
+
</section>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function renderStatusBanner(
|
|
202
|
+
status: ModuleStatus | undefined,
|
|
203
|
+
parseError: string | undefined,
|
|
204
|
+
): string {
|
|
205
|
+
if (status?.successMessage) {
|
|
206
|
+
return `<p class="banner banner-success">${escapeHtml(status.successMessage)}</p>`;
|
|
207
|
+
}
|
|
208
|
+
if (status?.errorMessage) {
|
|
209
|
+
return `<p class="banner banner-error">${escapeHtml(status.errorMessage)}</p>`;
|
|
210
|
+
}
|
|
211
|
+
if (parseError) {
|
|
212
|
+
return `<p class="banner banner-warn">Existing <code>config.json</code> couldn't be parsed (${escapeHtml(parseError)}). Submitting will overwrite it with the values below.</p>`;
|
|
213
|
+
}
|
|
214
|
+
return "";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function renderField(
|
|
218
|
+
key: string,
|
|
219
|
+
prop: ConfigSchemaProperty,
|
|
220
|
+
current: unknown,
|
|
221
|
+
required: boolean,
|
|
222
|
+
error: string | undefined,
|
|
223
|
+
): string {
|
|
224
|
+
const description = prop.description
|
|
225
|
+
? `<span class="field-description">${escapeHtml(prop.description)}</span>`
|
|
226
|
+
: "";
|
|
227
|
+
const errorEl = error ? `<span class="field-error">${escapeHtml(error)}</span>` : "";
|
|
228
|
+
const requiredMark = required ? `<span class="field-required" aria-hidden="true">*</span>` : "";
|
|
229
|
+
const labelText = `${escapeHtml(key)}${requiredMark}`;
|
|
230
|
+
const inputHtml = renderInput(key, prop, current);
|
|
231
|
+
return `<label class="field${error ? " field-has-error" : ""}">
|
|
232
|
+
<span class="field-label">${labelText}</span>
|
|
233
|
+
${description}
|
|
234
|
+
${inputHtml}
|
|
235
|
+
${errorEl}
|
|
236
|
+
</label>`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function renderInput(key: string, prop: ConfigSchemaProperty, current: unknown): string {
|
|
240
|
+
const name = escapeAttr(key);
|
|
241
|
+
if (prop.type === "boolean") {
|
|
242
|
+
const checked = coerceBooleanCurrent(current, prop) ? " checked" : "";
|
|
243
|
+
return `<span class="checkbox-row">
|
|
244
|
+
<input type="checkbox" name="${name}" value="true"${checked} />
|
|
245
|
+
<span class="checkbox-hint">${escapeHtml(prop.description ?? "Enabled when checked.")}</span>
|
|
246
|
+
</span>`;
|
|
247
|
+
}
|
|
248
|
+
if (prop.enum) {
|
|
249
|
+
const fallback = prop.default ?? prop.enum[0];
|
|
250
|
+
const selected = current ?? fallback;
|
|
251
|
+
const options = prop.enum
|
|
252
|
+
.map((opt) => {
|
|
253
|
+
const v = String(opt);
|
|
254
|
+
const isSelected = String(selected) === v ? " selected" : "";
|
|
255
|
+
return `<option value="${escapeAttr(v)}"${isSelected}>${escapeHtml(v)}</option>`;
|
|
256
|
+
})
|
|
257
|
+
.join("");
|
|
258
|
+
return `<select name="${name}">${options}</select>`;
|
|
259
|
+
}
|
|
260
|
+
const inputType = prop.type === "string" ? "text" : "number";
|
|
261
|
+
const step = prop.type === "integer" ? ' step="1"' : prop.type === "number" ? ' step="any"' : "";
|
|
262
|
+
const fallback = prop.default;
|
|
263
|
+
const value = current !== undefined && current !== null ? current : fallback;
|
|
264
|
+
const valueAttr =
|
|
265
|
+
value !== undefined && value !== null ? ` value="${escapeAttr(String(value))}"` : "";
|
|
266
|
+
const placeholder =
|
|
267
|
+
prop.default !== undefined ? ` placeholder="${escapeAttr(`default: ${prop.default}`)}"` : "";
|
|
268
|
+
return `<input type="${inputType}" name="${name}"${step}${valueAttr}${placeholder} />`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function coerceBooleanCurrent(current: unknown, prop: ConfigSchemaProperty): boolean {
|
|
272
|
+
if (typeof current === "boolean") return current;
|
|
273
|
+
if (current === undefined || current === null) {
|
|
274
|
+
return prop.default === true;
|
|
275
|
+
}
|
|
276
|
+
if (typeof current === "string") return current === "true";
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- error page ------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
export function renderAdminError(props: { title: string; message: string }): string {
|
|
283
|
+
const body = `
|
|
284
|
+
<div class="card">
|
|
285
|
+
${header()}
|
|
286
|
+
<h1 class="error-title">${escapeHtml(props.title)}</h1>
|
|
287
|
+
<p class="subtitle">${escapeHtml(props.message)}</p>
|
|
288
|
+
</div>`;
|
|
289
|
+
return baseDocument(props.title, body);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- styles ----------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
const STYLES = `
|
|
295
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
296
|
+
html, body { margin: 0; padding: 0; }
|
|
297
|
+
body {
|
|
298
|
+
font-family: ${FONT_SANS};
|
|
299
|
+
background: ${PALETTE.bg};
|
|
300
|
+
color: ${PALETTE.fg};
|
|
301
|
+
line-height: 1.55;
|
|
302
|
+
min-height: 100vh;
|
|
303
|
+
-webkit-font-smoothing: antialiased;
|
|
304
|
+
-moz-osx-font-smoothing: grayscale;
|
|
305
|
+
}
|
|
306
|
+
main {
|
|
307
|
+
display: flex;
|
|
308
|
+
align-items: center;
|
|
309
|
+
justify-content: center;
|
|
310
|
+
min-height: 100vh;
|
|
311
|
+
padding: 1.5rem;
|
|
312
|
+
}
|
|
313
|
+
main.main-wide {
|
|
314
|
+
align-items: flex-start;
|
|
315
|
+
padding: 2.5rem 1.5rem;
|
|
316
|
+
}
|
|
317
|
+
.card {
|
|
318
|
+
width: 100%;
|
|
319
|
+
max-width: 30rem;
|
|
320
|
+
background: ${PALETTE.cardBg};
|
|
321
|
+
border: 1px solid ${PALETTE.border};
|
|
322
|
+
border-radius: 12px;
|
|
323
|
+
padding: 2rem 1.75rem;
|
|
324
|
+
box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
|
|
325
|
+
}
|
|
326
|
+
main.main-wide { flex-direction: column; align-items: center; gap: 1.25rem; }
|
|
327
|
+
main.main-wide .card { max-width: 42rem; }
|
|
328
|
+
.page-header {
|
|
329
|
+
width: 100%;
|
|
330
|
+
max-width: 42rem;
|
|
331
|
+
margin-bottom: 0.25rem;
|
|
332
|
+
padding: 0 0.25rem;
|
|
333
|
+
}
|
|
334
|
+
.page-header h1 { font-size: 2rem; }
|
|
335
|
+
.card-header { margin-bottom: 1.5rem; }
|
|
336
|
+
.brand {
|
|
337
|
+
display: flex;
|
|
338
|
+
align-items: center;
|
|
339
|
+
gap: 0.5rem;
|
|
340
|
+
color: ${PALETTE.accent};
|
|
341
|
+
font-weight: 500;
|
|
342
|
+
font-size: 0.95rem;
|
|
343
|
+
margin-bottom: 1.25rem;
|
|
344
|
+
}
|
|
345
|
+
.brand-mark { font-size: 1.1rem; line-height: 1; }
|
|
346
|
+
.brand-name { letter-spacing: 0.01em; }
|
|
347
|
+
.brand-tag {
|
|
348
|
+
text-transform: uppercase;
|
|
349
|
+
letter-spacing: 0.06em;
|
|
350
|
+
font-size: 0.7rem;
|
|
351
|
+
color: ${PALETTE.fgMuted};
|
|
352
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
353
|
+
padding: 0.05rem 0.4rem;
|
|
354
|
+
border-radius: 999px;
|
|
355
|
+
}
|
|
356
|
+
h1 {
|
|
357
|
+
font-family: ${FONT_SERIF};
|
|
358
|
+
font-weight: 400;
|
|
359
|
+
font-size: 1.75rem;
|
|
360
|
+
line-height: 1.2;
|
|
361
|
+
margin: 0 0 0.4rem;
|
|
362
|
+
color: ${PALETTE.fg};
|
|
363
|
+
}
|
|
364
|
+
.subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
|
|
365
|
+
|
|
366
|
+
.auth-form, .config-form { display: flex; flex-direction: column; gap: 0.9rem; }
|
|
367
|
+
.field { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
368
|
+
.field-label {
|
|
369
|
+
font-size: 0.85rem;
|
|
370
|
+
font-weight: 500;
|
|
371
|
+
color: ${PALETTE.fgMuted};
|
|
372
|
+
letter-spacing: 0.01em;
|
|
373
|
+
font-family: ${FONT_MONO};
|
|
374
|
+
}
|
|
375
|
+
.field-required { color: ${PALETTE.danger}; margin-left: 0.2rem; }
|
|
376
|
+
.field-description {
|
|
377
|
+
font-size: 0.85rem;
|
|
378
|
+
color: ${PALETTE.fgMuted};
|
|
379
|
+
line-height: 1.45;
|
|
380
|
+
}
|
|
381
|
+
.field-error {
|
|
382
|
+
font-size: 0.82rem;
|
|
383
|
+
color: ${PALETTE.danger};
|
|
384
|
+
font-weight: 500;
|
|
385
|
+
}
|
|
386
|
+
.field-has-error input[type=text],
|
|
387
|
+
.field-has-error input[type=number],
|
|
388
|
+
.field-has-error select {
|
|
389
|
+
border-color: ${PALETTE.danger};
|
|
390
|
+
}
|
|
391
|
+
input[type=text], input[type=password], input[type=number], select {
|
|
392
|
+
font: inherit;
|
|
393
|
+
width: 100%;
|
|
394
|
+
padding: 0.6rem 0.75rem;
|
|
395
|
+
border: 1px solid ${PALETTE.border};
|
|
396
|
+
border-radius: 6px;
|
|
397
|
+
background: ${PALETTE.bg};
|
|
398
|
+
color: ${PALETTE.fg};
|
|
399
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
400
|
+
}
|
|
401
|
+
input[type=text]:focus, input[type=password]:focus, input[type=number]:focus, select:focus {
|
|
402
|
+
outline: none;
|
|
403
|
+
border-color: ${PALETTE.accent};
|
|
404
|
+
background: ${PALETTE.cardBg};
|
|
405
|
+
box-shadow: 0 0 0 3px ${PALETTE.accentSoft};
|
|
406
|
+
}
|
|
407
|
+
.checkbox-row {
|
|
408
|
+
display: flex;
|
|
409
|
+
align-items: flex-start;
|
|
410
|
+
gap: 0.5rem;
|
|
411
|
+
padding: 0.45rem 0;
|
|
412
|
+
}
|
|
413
|
+
.checkbox-row input[type=checkbox] { margin-top: 0.2rem; }
|
|
414
|
+
.checkbox-hint { font-size: 0.85rem; color: ${PALETTE.fgMuted}; }
|
|
415
|
+
|
|
416
|
+
.btn {
|
|
417
|
+
font: inherit;
|
|
418
|
+
font-weight: 500;
|
|
419
|
+
padding: 0.65rem 1.25rem;
|
|
420
|
+
border-radius: 6px;
|
|
421
|
+
border: 1px solid transparent;
|
|
422
|
+
cursor: pointer;
|
|
423
|
+
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
424
|
+
min-height: 2.5rem;
|
|
425
|
+
}
|
|
426
|
+
.btn-primary {
|
|
427
|
+
background: ${PALETTE.accent};
|
|
428
|
+
color: ${PALETTE.cardBg};
|
|
429
|
+
margin-top: 0.4rem;
|
|
430
|
+
}
|
|
431
|
+
.btn-primary:hover { background: ${PALETTE.accentHover}; }
|
|
432
|
+
.button-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
|
|
433
|
+
|
|
434
|
+
.error-banner {
|
|
435
|
+
background: ${PALETTE.dangerSoft};
|
|
436
|
+
border: 1px solid ${PALETTE.danger};
|
|
437
|
+
border-radius: 6px;
|
|
438
|
+
color: ${PALETTE.danger};
|
|
439
|
+
padding: 0.6rem 0.8rem;
|
|
440
|
+
margin: 0 0 1rem;
|
|
441
|
+
font-size: 0.9rem;
|
|
442
|
+
}
|
|
443
|
+
.error-title { color: ${PALETTE.danger}; }
|
|
444
|
+
|
|
445
|
+
.module-card { padding: 1.5rem 1.5rem 1.75rem; }
|
|
446
|
+
.module-header { margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid ${PALETTE.borderLight}; }
|
|
447
|
+
.module-name {
|
|
448
|
+
font-family: ${FONT_SERIF};
|
|
449
|
+
font-weight: 400;
|
|
450
|
+
font-size: 1.4rem;
|
|
451
|
+
margin: 0 0 0.25rem;
|
|
452
|
+
color: ${PALETTE.fg};
|
|
453
|
+
}
|
|
454
|
+
.module-id {
|
|
455
|
+
font-family: ${FONT_MONO};
|
|
456
|
+
font-size: 0.78rem;
|
|
457
|
+
color: ${PALETTE.fgMuted};
|
|
458
|
+
background: ${PALETTE.bgSoft};
|
|
459
|
+
padding: 0.1rem 0.4rem;
|
|
460
|
+
border-radius: 4px;
|
|
461
|
+
}
|
|
462
|
+
.module-tagline {
|
|
463
|
+
margin: 0.5rem 0 0;
|
|
464
|
+
color: ${PALETTE.fgMuted};
|
|
465
|
+
font-size: 0.9rem;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.banner {
|
|
469
|
+
margin: 0 0 1rem;
|
|
470
|
+
padding: 0.55rem 0.75rem;
|
|
471
|
+
border-radius: 6px;
|
|
472
|
+
font-size: 0.88rem;
|
|
473
|
+
}
|
|
474
|
+
.banner-success {
|
|
475
|
+
background: ${PALETTE.successSoft};
|
|
476
|
+
border: 1px solid ${PALETTE.success};
|
|
477
|
+
color: ${PALETTE.success};
|
|
478
|
+
}
|
|
479
|
+
.banner-error {
|
|
480
|
+
background: ${PALETTE.dangerSoft};
|
|
481
|
+
border: 1px solid ${PALETTE.danger};
|
|
482
|
+
color: ${PALETTE.danger};
|
|
483
|
+
}
|
|
484
|
+
.banner-warn {
|
|
485
|
+
background: ${PALETTE.bgSoft};
|
|
486
|
+
border: 1px dashed ${PALETTE.fgDim};
|
|
487
|
+
color: ${PALETTE.fgMuted};
|
|
488
|
+
}
|
|
489
|
+
.banner code {
|
|
490
|
+
font-family: ${FONT_MONO};
|
|
491
|
+
background: ${PALETTE.cardBg};
|
|
492
|
+
padding: 0.05rem 0.35rem;
|
|
493
|
+
border-radius: 4px;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.empty-hint {
|
|
497
|
+
margin-top: 1rem;
|
|
498
|
+
color: ${PALETTE.fgMuted};
|
|
499
|
+
font-size: 0.9rem;
|
|
500
|
+
}
|
|
501
|
+
.empty-hint code {
|
|
502
|
+
font-family: ${FONT_MONO};
|
|
503
|
+
font-size: 0.82rem;
|
|
504
|
+
background: ${PALETTE.bgSoft};
|
|
505
|
+
padding: 0.1rem 0.4rem;
|
|
506
|
+
border-radius: 4px;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
@media (max-width: 480px) {
|
|
510
|
+
main { padding: 0.75rem; }
|
|
511
|
+
main.main-wide { padding: 1rem 0.75rem; }
|
|
512
|
+
.card { padding: 1.5rem 1.25rem; border-radius: 10px; }
|
|
513
|
+
h1 { font-size: 1.5rem; }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
@media (prefers-color-scheme: dark) {
|
|
517
|
+
body { background: #1a1815; color: #e8e4dc; }
|
|
518
|
+
.card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
|
|
519
|
+
h1 { color: #f0ece4; }
|
|
520
|
+
.subtitle, .field-label, .field-description { color: #a8a29a; }
|
|
521
|
+
input[type=text], input[type=password], input[type=number], select {
|
|
522
|
+
background: #1f1c18; border-color: #3a362f; color: #e8e4dc;
|
|
523
|
+
}
|
|
524
|
+
input[type=text]:focus, input[type=password]:focus, input[type=number]:focus, select:focus {
|
|
525
|
+
background: #25221d;
|
|
526
|
+
}
|
|
527
|
+
.module-id { background: #1f1c18; color: #a8a29a; }
|
|
528
|
+
.module-header { border-color: #3a362f; }
|
|
529
|
+
.empty-hint code { background: #1f1c18; }
|
|
530
|
+
.banner-warn { background: #1f1c18; }
|
|
531
|
+
.banner code { background: #1a1815; }
|
|
532
|
+
.brand-tag { border-color: #3a362f; color: #a8a29a; }
|
|
533
|
+
}
|
|
534
|
+
`;
|