@glw907/cairn-cms 0.29.0 → 0.34.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/CHANGELOG.md +111 -0
- package/dist/components/AdminLayout.svelte +372 -44
- package/dist/components/AdminLayout.svelte.d.ts +5 -4
- package/dist/components/CairnLogo.svelte +28 -0
- package/dist/components/CairnLogo.svelte.d.ts +15 -0
- package/dist/components/ComponentForm.svelte +1 -1
- package/dist/components/ConceptList.svelte +240 -45
- package/dist/components/ConceptList.svelte.d.ts +12 -2
- package/dist/components/ConfirmPage.svelte +20 -3
- package/dist/components/EditPage.svelte +12 -7
- package/dist/components/LoginPage.svelte +27 -5
- package/dist/components/ManageEditors.svelte +8 -5
- package/dist/components/NavTree.svelte +2 -2
- package/dist/components/admin-icons.d.ts +13 -0
- package/dist/components/admin-icons.js +15 -0
- package/dist/components/cairn-admin.css +5516 -37
- package/dist/components/cairn-favicon.d.ts +2 -0
- package/dist/components/cairn-favicon.js +7 -0
- package/dist/components/chrome-guard.d.ts +9 -0
- package/dist/components/chrome-guard.js +55 -0
- package/dist/components/fonts/BricolageGrotesque-OFL.txt +93 -0
- package/dist/components/fonts/Figtree-OFL.txt +93 -0
- package/dist/components/fonts/bricolage-grotesque.woff2 +0 -0
- package/dist/components/fonts/figtree.woff2 +0 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +4 -1
- package/dist/render/authoring.d.ts +3 -0
- package/dist/render/authoring.js +5 -0
- package/dist/render/registry.d.ts +2 -0
- package/dist/render/registry.js +15 -0
- package/dist/render/rehype-dispatch.d.ts +9 -6
- package/dist/render/rehype-dispatch.js +12 -6
- package/dist/render/remark-directives.js +1 -1
- package/dist/sveltekit/content-routes.d.ts +12 -1
- package/dist/sveltekit/content-routes.js +37 -13
- package/dist/sveltekit/guard.js +32 -0
- package/dist/sveltekit/https-required-page.d.ts +5 -0
- package/dist/sveltekit/https-required-page.js +216 -0
- package/package.json +16 -2
- package/src/lib/components/AdminLayout.svelte +372 -44
- package/src/lib/components/CairnLogo.svelte +28 -0
- package/src/lib/components/ComponentForm.svelte +1 -1
- package/src/lib/components/ConceptList.svelte +240 -45
- package/src/lib/components/ConfirmPage.svelte +20 -3
- package/src/lib/components/EditPage.svelte +12 -7
- package/src/lib/components/LoginPage.svelte +27 -5
- package/src/lib/components/ManageEditors.svelte +8 -5
- package/src/lib/components/NavTree.svelte +2 -2
- package/src/lib/components/admin-icons.ts +15 -0
- package/src/lib/components/cairn-admin.css +162 -7
- package/src/lib/components/cairn-favicon.ts +9 -0
- package/src/lib/components/chrome-guard.ts +62 -0
- package/src/lib/components/fonts/BricolageGrotesque-OFL.txt +93 -0
- package/src/lib/components/fonts/Figtree-OFL.txt +93 -0
- package/src/lib/components/fonts/bricolage-grotesque.woff2 +0 -0
- package/src/lib/components/fonts/figtree.woff2 +0 -0
- package/src/lib/index.ts +4 -2
- package/src/lib/render/authoring.ts +7 -0
- package/src/lib/render/registry.ts +20 -0
- package/src/lib/render/rehype-dispatch.ts +13 -6
- package/src/lib/render/remark-directives.ts +1 -1
- package/src/lib/sveltekit/content-routes.ts +51 -14
- package/src/lib/sveltekit/guard.ts +36 -0
- package/src/lib/sveltekit/https-required-page.ts +220 -0
|
@@ -22,15 +22,20 @@ export interface NavConcept {
|
|
|
22
22
|
label: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/** The admin layout's data: site identity, the signed-in user, the nav,
|
|
25
|
+
/** The admin layout's data: site identity, the signed-in user, the nav, the active path, and theme. */
|
|
26
26
|
export interface LayoutData {
|
|
27
27
|
siteName: string;
|
|
28
|
-
user: { displayName: string; role: Role };
|
|
28
|
+
user: { displayName: string; email: string; role: Role };
|
|
29
29
|
concepts: NavConcept[];
|
|
30
30
|
pathname: string;
|
|
31
31
|
canManageEditors: boolean;
|
|
32
32
|
/** The nav menu's label when the site configures one; gates the Navigation nav entry. Null otherwise. */
|
|
33
33
|
navLabel: string | null;
|
|
34
|
+
/** The admin theme resolved for SSR: the persisted cookie choice, or the light default. */
|
|
35
|
+
theme: 'cairn-admin' | 'cairn-admin-dark';
|
|
36
|
+
/** The nav group labels the user has collapsed, from the persisted cookie. Read at SSR so a
|
|
37
|
+
* collapsed group renders collapsed with no flash. Empty when none are collapsed. */
|
|
38
|
+
collapsedNav: string[];
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/** One row in a concept's list view. */
|
|
@@ -83,6 +88,8 @@ export interface ContentEvent {
|
|
|
83
88
|
request: Request;
|
|
84
89
|
locals: { editor?: Editor | null };
|
|
85
90
|
platform?: { env?: GithubKeyEnv };
|
|
91
|
+
/** SvelteKit's cookie jar; the layout load reads the persisted admin theme. Optional for non-route callers. */
|
|
92
|
+
cookies?: { get(name: string): string | undefined };
|
|
86
93
|
}
|
|
87
94
|
|
|
88
95
|
/** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
|
|
@@ -109,16 +116,24 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
109
116
|
const mintToken =
|
|
110
117
|
deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
111
118
|
|
|
112
|
-
/** Layout load for every admin page: the nav, the user, and the
|
|
119
|
+
/** Layout load for every admin page: the nav, the user, the active path, and the resolved theme. */
|
|
113
120
|
function layoutLoad(event: ContentEvent): LayoutData {
|
|
114
121
|
const editor = sessionOf(event);
|
|
122
|
+
const cookieTheme = event.cookies?.get('cairn-admin-theme');
|
|
123
|
+
const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
|
|
124
|
+
const cookieCollapsed = event.cookies?.get('cairn-admin-nav-collapsed');
|
|
125
|
+
const collapsedNav = cookieCollapsed
|
|
126
|
+
? cookieCollapsed.split(',').map((part) => decodeURIComponent(part)).filter(Boolean)
|
|
127
|
+
: [];
|
|
115
128
|
return {
|
|
116
129
|
siteName: runtime.siteName,
|
|
117
|
-
user: { displayName: editor.displayName, role: editor.role },
|
|
130
|
+
user: { displayName: editor.displayName, email: editor.email, role: editor.role },
|
|
118
131
|
concepts: runtime.concepts.map((c) => ({ id: c.id, label: c.label })),
|
|
119
132
|
pathname: event.url.pathname,
|
|
120
133
|
canManageEditors: editor.role === 'owner',
|
|
121
134
|
navLabel: runtime.navMenu?.label ?? null,
|
|
135
|
+
theme,
|
|
136
|
+
collapsedNav,
|
|
122
137
|
};
|
|
123
138
|
}
|
|
124
139
|
|
|
@@ -338,14 +353,17 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
338
353
|
throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
|
|
339
354
|
}
|
|
340
355
|
|
|
341
|
-
/**
|
|
342
|
-
* the file removal and the manifest patch in one commit. The inbound recheck here is the
|
|
343
|
-
* authoritative gate, closing the load-to-delete race.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
356
|
+
/** The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
|
|
357
|
+
* commit the file removal and the manifest patch in one commit. The inbound recheck here is the
|
|
358
|
+
* authoritative gate, closing the load-to-delete race. Both the editor delete (id from params) and
|
|
359
|
+
* the list delete (id from the form body) call this with an already-validated id, so the guard is
|
|
360
|
+
* enforced once. */
|
|
361
|
+
async function deleteEntry(
|
|
362
|
+
event: ContentEvent,
|
|
363
|
+
concept: ConceptDescriptor,
|
|
364
|
+
id: string,
|
|
365
|
+
editor: Editor,
|
|
366
|
+
): Promise<ReturnType<typeof fail> | never> {
|
|
349
367
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
350
368
|
const token = await mintToken(event.platform?.env ?? {});
|
|
351
369
|
|
|
@@ -355,7 +373,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
355
373
|
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
356
374
|
const inbound = inboundLinks(manifest, concept.id, id);
|
|
357
375
|
if (inbound.length) {
|
|
358
|
-
return fail(409, { inboundLinks: inbound });
|
|
376
|
+
return fail(409, { inboundLinks: inbound, id });
|
|
359
377
|
}
|
|
360
378
|
|
|
361
379
|
const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
|
|
@@ -379,6 +397,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
379
397
|
throw redirect(303, `/admin/${concept.id}`);
|
|
380
398
|
}
|
|
381
399
|
|
|
400
|
+
/** Delete an entry from its editor. The id comes from the route param. */
|
|
401
|
+
async function deleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
402
|
+
const editor = sessionOf(event);
|
|
403
|
+
const concept = conceptOf(runtime, event.params);
|
|
404
|
+
const id = event.params.id ?? '';
|
|
405
|
+
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
406
|
+
return deleteEntry(event, concept, id, editor);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Delete an entry from the concept list. The id comes from the form body. */
|
|
410
|
+
async function listDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
411
|
+
const editor = sessionOf(event);
|
|
412
|
+
const concept = conceptOf(runtime, event.params);
|
|
413
|
+
const form = await event.request.formData();
|
|
414
|
+
const id = String(form.get('id') ?? '');
|
|
415
|
+
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
416
|
+
return deleteEntry(event, concept, id, editor);
|
|
417
|
+
}
|
|
418
|
+
|
|
382
419
|
/** Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
|
|
383
420
|
* atomic commit, so no internal link breaks. The collision check and the inbound recompute here
|
|
384
421
|
* are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
|
|
@@ -466,5 +503,5 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
466
503
|
throw redirect(303, `/admin/${concept.id}/${newId}?renamed=1`);
|
|
467
504
|
}
|
|
468
505
|
|
|
469
|
-
return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, deleteAction, renameAction, mintToken };
|
|
506
|
+
return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, deleteAction, listDeleteAction, renameAction, mintToken };
|
|
470
507
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { redirect, error } from '@sveltejs/kit';
|
|
5
5
|
import { resolveSession } from '../auth/store.js';
|
|
6
6
|
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
|
+
import { httpsRequiredPage } from './https-required-page.js';
|
|
7
8
|
import type { Editor } from '../auth/types.js';
|
|
8
9
|
import type { HandleInput, RequestContext } from './types.js';
|
|
9
10
|
|
|
@@ -16,6 +17,22 @@ function isAdminPath(pathname: string): boolean {
|
|
|
16
17
|
return pathname === '/admin' || pathname.startsWith('/admin/');
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Local development (`wrangler dev`) legitimately speaks http; a deployed host does not. The hostname
|
|
22
|
+
* comes from the client `Host` header, so this is UX only: it decides whether to show the help page,
|
|
23
|
+
* never whether to grant access. The session gate below runs regardless. Do not make it an auth check.
|
|
24
|
+
*/
|
|
25
|
+
function isLocalHost(hostname: string): boolean {
|
|
26
|
+
return (
|
|
27
|
+
hostname === 'localhost' ||
|
|
28
|
+
hostname === '127.0.0.1' ||
|
|
29
|
+
hostname === '0.0.0.0' ||
|
|
30
|
+
hostname === '::1' ||
|
|
31
|
+
hostname === '[::1]' ||
|
|
32
|
+
hostname.endsWith('.localhost')
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
/**
|
|
20
37
|
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
21
38
|
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
@@ -29,11 +46,30 @@ function applySecurityHeaders(headers: Headers): void {
|
|
|
29
46
|
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
30
47
|
}
|
|
31
48
|
|
|
49
|
+
/** The hardened 400 help page for a deployed admin request that arrived over http. */
|
|
50
|
+
function httpsRequiredResponse(url: URL): Response {
|
|
51
|
+
const httpsUrl = new URL(url);
|
|
52
|
+
httpsUrl.protocol = 'https:';
|
|
53
|
+
const headers = new Headers({
|
|
54
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
55
|
+
'Cache-Control': 'no-store',
|
|
56
|
+
});
|
|
57
|
+
applySecurityHeaders(headers);
|
|
58
|
+
return new Response(httpsRequiredPage(httpsUrl.toString()), { status: 400, headers });
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
33
62
|
export function createAuthGuard() {
|
|
34
63
|
return async function handle({ event, resolve }: HandleInput): Promise<Response> {
|
|
35
64
|
const { pathname } = event.url;
|
|
36
65
|
if (!isAdminPath(pathname)) return resolve(event);
|
|
66
|
+
// A deployed admin request over http never works: the magic-link form POST would fail the
|
|
67
|
+
// framework's CSRF guard with an opaque 403. Serve the help page instead, before resolve()
|
|
68
|
+
// runs that check. This covers the public login/auth paths too, since that is where the form
|
|
69
|
+
// posts. Local http (wrangler dev) is exempt.
|
|
70
|
+
if (event.url.protocol === 'http:' && !isLocalHost(event.url.hostname)) {
|
|
71
|
+
return httpsRequiredResponse(event.url);
|
|
72
|
+
}
|
|
37
73
|
if (!isPublicAdminPath(pathname)) {
|
|
38
74
|
const env = event.platform?.env ?? {};
|
|
39
75
|
const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// The standalone "this admin needs HTTPS" page. The auth guard serves it when a request reaches a
|
|
2
|
+
// deployed Worker over http, which is the one case that makes the magic-link sign-in fail: the
|
|
3
|
+
// JS-free login form posts over http, and the framework's CSRF guard rejects a form POST whose
|
|
4
|
+
// origin scheme does not match, so the editor would otherwise hit an opaque 403. This page names
|
|
5
|
+
// the problem, says why https is needed, and gives the exact Cloudflare fix.
|
|
6
|
+
//
|
|
7
|
+
// It is served raw from the edge, before SvelteKit renders anything, so it carries no external
|
|
8
|
+
// request: the Warm Stone tokens are inlined for both colour schemes and the type falls back to the
|
|
9
|
+
// system stack (the shipped admin fonts are not reachable from here). The cairn glyph is the same
|
|
10
|
+
// public-domain Temaki mark the admin chrome uses. See docs/internal/admin-design-system.md.
|
|
11
|
+
|
|
12
|
+
/** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
|
|
13
|
+
function escapeHtml(value: string): string {
|
|
14
|
+
return value
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The cairn stone-stack glyph (Temaki, CC0), drawn in currentColor like CairnLogo.svelte.
|
|
22
|
+
const CAIRN_GLYPH =
|
|
23
|
+
'<path d="M6.28 14C5.56 14 1 13.89 1 12.91C1 11.46 2.16 11.07 3.2 10.81C4.36 10.51 13.18 9.77 ' +
|
|
24
|
+
'13.76 10.07C14.46 10.43 13.52 12.49 12.44 12.77C11.28 13.07 10.21 14 8.48 14C7.05 14 9.69 14 ' +
|
|
25
|
+
'6.28 14ZM6.92 4.5C6.67 4.5 5 4.43 5 3.88C5 3.07 5.75 2.51 5.96 2.35C6.36 2.03 6.32 1.62 6.54 ' +
|
|
26
|
+
'1.27C6.84 0.79 7.61 0.5 7.88 0.5C8.1 0.5 8.75 0.9 9.23 1.42C9.45 1.66 10 2.77 10 3.12C10 4.22 ' +
|
|
27
|
+
'9.36 4.5 8.85 4.5C8.33 4.5 8.15 4.5 6.92 4.5ZM3.68 8.22C3 7.73 3.67 6.86 4.57 6.21C5.38 5.63 ' +
|
|
28
|
+
'5.92 5.96 6.79 5.7C8.33 5.24 9.02 5.72 9.02 5.72L10.9 6.82C12.03 7.63 10.99 7.67 10.38 8.56C9.79 ' +
|
|
29
|
+
'9.42 8.18 9.11 7.42 9.33C6.78 9.53 5.75 9.71 4.62 8.9L3.68 8.22Z"/>';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render the full HTML document for the HTTPS-required page.
|
|
33
|
+
* @param httpsUrl The same request rebuilt over https, offered as the one-click recovery link.
|
|
34
|
+
*/
|
|
35
|
+
export function httpsRequiredPage(httpsUrl: string): string {
|
|
36
|
+
const href = escapeHtml(httpsUrl);
|
|
37
|
+
return `<!doctype html>
|
|
38
|
+
<html lang="en">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8" />
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
42
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
43
|
+
<title>HTTPS required · Cairn</title>
|
|
44
|
+
<style>
|
|
45
|
+
:root {
|
|
46
|
+
color-scheme: light;
|
|
47
|
+
--bg: oklch(96.5% 0.006 75);
|
|
48
|
+
--glow: oklch(52% 0.2 293 / 0.06);
|
|
49
|
+
--panel: oklch(99% 0.004 75);
|
|
50
|
+
--recessed: oklch(95% 0.008 75);
|
|
51
|
+
--ink: oklch(26% 0.014 75);
|
|
52
|
+
--muted: oklch(48% 0.01 75);
|
|
53
|
+
--subtle: oklch(42% 0.01 75);
|
|
54
|
+
--primary: oklch(52% 0.2 293);
|
|
55
|
+
--primary-content: oklch(98% 0.012 293);
|
|
56
|
+
--border: oklch(93% 0.008 75);
|
|
57
|
+
--shadow: 0 1px 2px oklch(28% 0.02 75 / 0.05), 0 18px 40px -12px oklch(28% 0.02 75 / 0.16);
|
|
58
|
+
--radius-box: 1rem;
|
|
59
|
+
--radius-field: 0.625rem;
|
|
60
|
+
--font: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
61
|
+
}
|
|
62
|
+
@media (prefers-color-scheme: dark) {
|
|
63
|
+
:root {
|
|
64
|
+
color-scheme: dark;
|
|
65
|
+
--bg: oklch(15.5% 0.009 75);
|
|
66
|
+
--glow: oklch(68% 0.18 293 / 0.1);
|
|
67
|
+
--panel: oklch(24% 0.01 75);
|
|
68
|
+
--recessed: oklch(20% 0.01 75);
|
|
69
|
+
--ink: oklch(93% 0.006 75);
|
|
70
|
+
--muted: oklch(72% 0.01 75);
|
|
71
|
+
--subtle: oklch(80% 0.008 75);
|
|
72
|
+
--primary: oklch(68% 0.18 293);
|
|
73
|
+
--primary-content: oklch(20% 0.04 293);
|
|
74
|
+
--border: oklch(30% 0.014 75);
|
|
75
|
+
--shadow: 0 1px 2px oklch(0% 0 0 / 0.35), 0 18px 40px -12px oklch(0% 0 0 / 0.55);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
* { box-sizing: border-box; }
|
|
79
|
+
body {
|
|
80
|
+
margin: 0;
|
|
81
|
+
min-height: 100vh;
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
justify-content: center;
|
|
85
|
+
padding: 1.5rem;
|
|
86
|
+
font-family: var(--font);
|
|
87
|
+
color: var(--ink);
|
|
88
|
+
background-color: var(--bg);
|
|
89
|
+
background-image: radial-gradient(80rem 50rem at 50% -20%, var(--glow), transparent 60%);
|
|
90
|
+
-webkit-font-smoothing: antialiased;
|
|
91
|
+
-moz-osx-font-smoothing: grayscale;
|
|
92
|
+
line-height: 1.55;
|
|
93
|
+
}
|
|
94
|
+
main {
|
|
95
|
+
width: 100%;
|
|
96
|
+
max-width: 30rem;
|
|
97
|
+
background: var(--panel);
|
|
98
|
+
border: 1px solid var(--border);
|
|
99
|
+
border-radius: var(--radius-box);
|
|
100
|
+
box-shadow: var(--shadow);
|
|
101
|
+
padding: 2.25rem;
|
|
102
|
+
}
|
|
103
|
+
.brand { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1.75rem; }
|
|
104
|
+
.brand .tile {
|
|
105
|
+
display: grid;
|
|
106
|
+
place-items: center;
|
|
107
|
+
width: 2rem;
|
|
108
|
+
height: 2rem;
|
|
109
|
+
border-radius: 0.75rem;
|
|
110
|
+
background: var(--primary);
|
|
111
|
+
color: var(--primary-content);
|
|
112
|
+
box-shadow: 0 1px 2px oklch(0% 0 0 / 0.12);
|
|
113
|
+
}
|
|
114
|
+
.brand .tile svg { width: 1.25rem; height: 1.25rem; }
|
|
115
|
+
.brand .word {
|
|
116
|
+
font-weight: 700;
|
|
117
|
+
font-size: 1.25rem;
|
|
118
|
+
letter-spacing: -0.01em;
|
|
119
|
+
}
|
|
120
|
+
.eyebrow {
|
|
121
|
+
display: inline-flex;
|
|
122
|
+
align-items: center;
|
|
123
|
+
gap: 0.4rem;
|
|
124
|
+
font-size: 0.6875rem;
|
|
125
|
+
font-weight: 600;
|
|
126
|
+
text-transform: uppercase;
|
|
127
|
+
letter-spacing: 0.08em;
|
|
128
|
+
color: var(--muted);
|
|
129
|
+
margin-bottom: 0.6rem;
|
|
130
|
+
}
|
|
131
|
+
.eyebrow svg { width: 0.85rem; height: 0.85rem; }
|
|
132
|
+
h1 {
|
|
133
|
+
margin: 0 0 0.75rem;
|
|
134
|
+
font-size: 1.6rem;
|
|
135
|
+
font-weight: 800;
|
|
136
|
+
letter-spacing: -0.02em;
|
|
137
|
+
line-height: 1.15;
|
|
138
|
+
}
|
|
139
|
+
p { margin: 0 0 1rem; color: var(--subtle); }
|
|
140
|
+
.cta {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
gap: 0.5rem;
|
|
144
|
+
margin: 0.25rem 0 0.5rem;
|
|
145
|
+
padding: 0.7rem 1.15rem;
|
|
146
|
+
border-radius: var(--radius-field);
|
|
147
|
+
background: var(--primary);
|
|
148
|
+
color: var(--primary-content);
|
|
149
|
+
font-weight: 600;
|
|
150
|
+
font-size: 0.95rem;
|
|
151
|
+
text-decoration: none;
|
|
152
|
+
box-shadow: 0 4px 14px -4px oklch(52% 0.2 293 / 0.5);
|
|
153
|
+
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
154
|
+
}
|
|
155
|
+
.cta:hover { transform: translateY(-1px); box-shadow: 0 8px 20px -6px oklch(52% 0.2 293 / 0.55); }
|
|
156
|
+
.cta:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
|
157
|
+
.cta svg { width: 1rem; height: 1rem; }
|
|
158
|
+
.fix {
|
|
159
|
+
margin-top: 1.75rem;
|
|
160
|
+
padding: 1.1rem 1.2rem;
|
|
161
|
+
background: var(--recessed);
|
|
162
|
+
border: 1px solid var(--border);
|
|
163
|
+
border-radius: var(--radius-field);
|
|
164
|
+
}
|
|
165
|
+
.fix h2 {
|
|
166
|
+
margin: 0 0 0.5rem;
|
|
167
|
+
font-size: 0.8125rem;
|
|
168
|
+
font-weight: 700;
|
|
169
|
+
letter-spacing: 0.01em;
|
|
170
|
+
}
|
|
171
|
+
.fix p { margin: 0 0 0.65rem; font-size: 0.875rem; }
|
|
172
|
+
.fix p:last-child { margin-bottom: 0; }
|
|
173
|
+
.path {
|
|
174
|
+
display: block;
|
|
175
|
+
font-size: 0.8125rem;
|
|
176
|
+
font-weight: 600;
|
|
177
|
+
color: var(--ink);
|
|
178
|
+
letter-spacing: 0.01em;
|
|
179
|
+
margin: 0 0 0.65rem;
|
|
180
|
+
}
|
|
181
|
+
.path .arrow { color: var(--muted); padding: 0 0.35rem; font-weight: 400; }
|
|
182
|
+
.foot {
|
|
183
|
+
margin-top: 1.75rem;
|
|
184
|
+
text-align: center;
|
|
185
|
+
font-size: 0.75rem;
|
|
186
|
+
color: var(--muted);
|
|
187
|
+
}
|
|
188
|
+
</style>
|
|
189
|
+
</head>
|
|
190
|
+
<body>
|
|
191
|
+
<main>
|
|
192
|
+
<div class="brand">
|
|
193
|
+
<span class="tile"><svg viewBox="0 0 15 15" fill="currentColor" aria-hidden="true">${CAIRN_GLYPH}</svg></span>
|
|
194
|
+
<span class="word">Cairn</span>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<span class="eyebrow">
|
|
198
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
199
|
+
Secure connection required
|
|
200
|
+
</span>
|
|
201
|
+
<h1>This admin needs a secure connection</h1>
|
|
202
|
+
<p>You opened this page over http. Sign-in only works over https, so open the secure version to continue.</p>
|
|
203
|
+
|
|
204
|
+
<a class="cta" href="${href}">
|
|
205
|
+
Open over HTTPS
|
|
206
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
207
|
+
</a>
|
|
208
|
+
|
|
209
|
+
<div class="fix">
|
|
210
|
+
<h2>If you run this site</h2>
|
|
211
|
+
<p>Turn on Always Use HTTPS in Cloudflare. It upgrades every request to https before it reaches the site:</p>
|
|
212
|
+
<span class="path">SSL/TLS<span class="arrow">›</span>Edge Certificates<span class="arrow">›</span>Always Use HTTPS</span>
|
|
213
|
+
<p>Keep HSTS on too. The browser then stays on https and sign-in works.</p>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<p class="foot">Powered by Cairn</p>
|
|
217
|
+
</main>
|
|
218
|
+
</body>
|
|
219
|
+
</html>`;
|
|
220
|
+
}
|