@glw907/cairn-cms 0.3.0 → 0.4.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/README.md +14 -6
- package/dist/auth/admins.d.ts +33 -0
- package/dist/auth/admins.d.ts.map +1 -0
- package/dist/auth/admins.js +90 -0
- package/dist/auth/config.d.ts +2097 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/auth/config.js +78 -0
- package/dist/auth/guard.d.ts +34 -0
- package/dist/auth/guard.d.ts.map +1 -0
- package/dist/auth/guard.js +47 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +6 -0
- package/dist/auth/schema.d.ts +750 -0
- package/dist/auth/schema.d.ts.map +1 -0
- package/dist/auth/schema.js +93 -0
- package/dist/components/AdminLayout.svelte +6 -6
- package/dist/components/AdminLayout.svelte.d.ts +2 -2
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
- package/dist/components/ConfirmPage.svelte +31 -0
- package/dist/components/ConfirmPage.svelte.d.ts +11 -0
- package/dist/components/ConfirmPage.svelte.d.ts.map +1 -0
- package/dist/components/LoginPage.svelte +35 -18
- package/dist/components/LoginPage.svelte.d.ts +0 -2
- package/dist/components/LoginPage.svelte.d.ts.map +1 -1
- package/dist/components/ManageAdmins.svelte +1 -1
- package/dist/components/ManageAdmins.svelte.d.ts +1 -1
- package/dist/components/ManageAdmins.svelte.d.ts.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/email.d.ts.map +1 -1
- package/dist/email.js +15 -7
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/sveltekit/index.d.ts +7 -60
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +37 -157
- package/package.json +34 -4
- package/src/lib/auth/admins.ts +106 -0
- package/src/lib/auth/config.ts +108 -0
- package/src/lib/auth/guard.ts +60 -0
- package/src/lib/auth/index.ts +6 -0
- package/src/lib/auth/schema.ts +112 -0
- package/src/lib/components/AdminLayout.svelte +6 -6
- package/src/lib/components/ConfirmPage.svelte +31 -0
- package/src/lib/components/LoginPage.svelte +35 -18
- package/src/lib/components/ManageAdmins.svelte +1 -1
- package/src/lib/components/index.ts +1 -0
- package/src/lib/email.ts +14 -7
- package/src/lib/index.ts +2 -2
- package/src/lib/sveltekit/index.ts +46 -228
- package/dist/auth.d.ts +0 -25
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js +0 -132
- package/src/lib/auth.ts +0 -185
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { relations, sql } from "drizzle-orm";
|
|
2
|
+
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
|
3
|
+
|
|
4
|
+
export const user = sqliteTable("user", {
|
|
5
|
+
id: text("id").primaryKey(),
|
|
6
|
+
name: text("name").notNull(),
|
|
7
|
+
email: text("email").notNull().unique(),
|
|
8
|
+
emailVerified: integer("email_verified", { mode: "boolean" })
|
|
9
|
+
.default(false)
|
|
10
|
+
.notNull(),
|
|
11
|
+
image: text("image"),
|
|
12
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
13
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
14
|
+
.notNull(),
|
|
15
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
16
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
17
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
18
|
+
.notNull(),
|
|
19
|
+
role: text("role"),
|
|
20
|
+
banned: integer("banned", { mode: "boolean" }).default(false),
|
|
21
|
+
banReason: text("ban_reason"),
|
|
22
|
+
banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const session = sqliteTable(
|
|
26
|
+
"session",
|
|
27
|
+
{
|
|
28
|
+
id: text("id").primaryKey(),
|
|
29
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
30
|
+
token: text("token").notNull().unique(),
|
|
31
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
32
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
33
|
+
.notNull(),
|
|
34
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
35
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
36
|
+
.notNull(),
|
|
37
|
+
ipAddress: text("ip_address"),
|
|
38
|
+
userAgent: text("user_agent"),
|
|
39
|
+
userId: text("user_id")
|
|
40
|
+
.notNull()
|
|
41
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
42
|
+
impersonatedBy: text("impersonated_by"),
|
|
43
|
+
},
|
|
44
|
+
(table) => [index("session_userId_idx").on(table.userId)],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
export const account = sqliteTable(
|
|
48
|
+
"account",
|
|
49
|
+
{
|
|
50
|
+
id: text("id").primaryKey(),
|
|
51
|
+
accountId: text("account_id").notNull(),
|
|
52
|
+
providerId: text("provider_id").notNull(),
|
|
53
|
+
userId: text("user_id")
|
|
54
|
+
.notNull()
|
|
55
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
56
|
+
accessToken: text("access_token"),
|
|
57
|
+
refreshToken: text("refresh_token"),
|
|
58
|
+
idToken: text("id_token"),
|
|
59
|
+
accessTokenExpiresAt: integer("access_token_expires_at", {
|
|
60
|
+
mode: "timestamp_ms",
|
|
61
|
+
}),
|
|
62
|
+
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
|
|
63
|
+
mode: "timestamp_ms",
|
|
64
|
+
}),
|
|
65
|
+
scope: text("scope"),
|
|
66
|
+
password: text("password"),
|
|
67
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
68
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
69
|
+
.notNull(),
|
|
70
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
71
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
72
|
+
.notNull(),
|
|
73
|
+
},
|
|
74
|
+
(table) => [index("account_userId_idx").on(table.userId)],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
export const verification = sqliteTable(
|
|
78
|
+
"verification",
|
|
79
|
+
{
|
|
80
|
+
id: text("id").primaryKey(),
|
|
81
|
+
identifier: text("identifier").notNull(),
|
|
82
|
+
value: text("value").notNull(),
|
|
83
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
84
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
85
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
86
|
+
.notNull(),
|
|
87
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
88
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
89
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
90
|
+
.notNull(),
|
|
91
|
+
},
|
|
92
|
+
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
export const userRelations = relations(user, ({ many }) => ({
|
|
96
|
+
sessions: many(session),
|
|
97
|
+
accounts: many(account),
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
export const sessionRelations = relations(session, ({ one }) => ({
|
|
101
|
+
user: one(user, {
|
|
102
|
+
fields: [session.userId],
|
|
103
|
+
references: [user.id],
|
|
104
|
+
}),
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
export const accountRelations = relations(account, ({ one }) => ({
|
|
108
|
+
user: one(user, {
|
|
109
|
+
fields: [account.userId],
|
|
110
|
+
references: [user.id],
|
|
111
|
+
}),
|
|
112
|
+
}));
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
// (the login page lives under this layout) it falls back to a minimal centered shell.
|
|
8
8
|
// Each site's `admin/+layout.svelte` is a one-line shim that forwards `data` + `children`.
|
|
9
9
|
import type { Snippet } from 'svelte';
|
|
10
|
-
import type {
|
|
10
|
+
import type { CairnUser } from '../auth';
|
|
11
11
|
|
|
12
12
|
let {
|
|
13
13
|
data,
|
|
14
14
|
children,
|
|
15
15
|
}: {
|
|
16
|
-
data: { siteName: string;
|
|
16
|
+
data: { siteName: string; user: CairnUser | null; pathname: string };
|
|
17
17
|
children: Snippet;
|
|
18
18
|
} = $props();
|
|
19
19
|
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
active: data.pathname.startsWith('/admin/admins'),
|
|
42
42
|
},
|
|
43
43
|
]);
|
|
44
|
-
const visibleNav = $derived(nav.filter((item) => !item.owner || data.
|
|
44
|
+
const visibleNav = $derived(nav.filter((item) => !item.owner || data.user?.role === 'owner'));
|
|
45
45
|
|
|
46
46
|
// Close the slide-over after a nav tap on mobile (no-op on desktop where it's pinned open).
|
|
47
47
|
function closeDrawer(): void {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
<meta name="robots" content="noindex, nofollow" />
|
|
69
69
|
</svelte:head>
|
|
70
70
|
|
|
71
|
-
{#if data.
|
|
71
|
+
{#if data.user}
|
|
72
72
|
<div class="drawer min-h-screen bg-base-200 lg:drawer-open" data-pagefind-ignore>
|
|
73
73
|
<input id="admin-drawer" type="checkbox" class="drawer-toggle" />
|
|
74
74
|
|
|
@@ -111,8 +111,8 @@
|
|
|
111
111
|
</ul>
|
|
112
112
|
|
|
113
113
|
<div class="border-t border-base-300 p-4">
|
|
114
|
-
<p class="text-sm font-medium">{data.
|
|
115
|
-
<p class="text-xs opacity-60">{data.
|
|
114
|
+
<p class="text-sm font-medium">{data.user.name}</p>
|
|
115
|
+
<p class="text-xs opacity-60">{data.user.email}</p>
|
|
116
116
|
<form method="POST" action="/admin/auth/logout" class="mt-3">
|
|
117
117
|
<button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">Sign out</button>
|
|
118
118
|
</form>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The scanner-safe confirm surface (C2). A GET renders this static page — nothing is consumed.
|
|
3
|
+
// The token rides in a hidden field; only the explicit form POST (the route's default action →
|
|
4
|
+
// confirmSignIn) verifies it. Mail scanners GET URLs but don't submit forms, so prefetch can't
|
|
5
|
+
// burn the link. JS-free by design.
|
|
6
|
+
interface Props {
|
|
7
|
+
data: { token: string; siteName: string; error: string | null };
|
|
8
|
+
}
|
|
9
|
+
let { data }: Props = $props();
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<svelte:head>
|
|
13
|
+
<title>Confirm sign-in · {data.siteName} CMS</title>
|
|
14
|
+
</svelte:head>
|
|
15
|
+
|
|
16
|
+
<div class="mx-auto mt-16 max-w-md rounded-box border border-base-300 bg-base-100 p-8">
|
|
17
|
+
<h1 class="text-2xl font-bold">Confirm sign-in</h1>
|
|
18
|
+
<p class="mt-1 text-sm opacity-70">to {data.siteName} CMS</p>
|
|
19
|
+
|
|
20
|
+
{#if data.error || !data.token}
|
|
21
|
+
<div class="alert alert-error mt-6">
|
|
22
|
+
<span>This sign-in link is invalid or expired. Request a new one.</span>
|
|
23
|
+
</div>
|
|
24
|
+
<a href="/admin/login" class="btn btn-primary mt-6">Back to sign-in</a>
|
|
25
|
+
{:else}
|
|
26
|
+
<form method="POST" class="mt-6 flex flex-col gap-3">
|
|
27
|
+
<input type="hidden" name="token" value={data.token} />
|
|
28
|
+
<button type="submit" class="btn btn-primary">Confirm sign-in</button>
|
|
29
|
+
</form>
|
|
30
|
+
{/if}
|
|
31
|
+
</div>
|
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// The magic-link sign-in page.
|
|
3
|
-
//
|
|
2
|
+
// The magic-link sign-in page. Requests a link via the better-auth client (client-side, same
|
|
3
|
+
// origin). To avoid enumeration the UI shows the SAME neutral copy whether or not the email is
|
|
4
|
+
// on the allowlist — the server only emails actual editors (see auth/config.ts send gate).
|
|
5
|
+
import { createAuthClient } from 'better-auth/svelte';
|
|
6
|
+
import { magicLinkClient } from 'better-auth/client/plugins';
|
|
7
|
+
|
|
8
|
+
// The browser client lives in the one component that needs it (requesting a link). Sign-out
|
|
9
|
+
// and editor management go through server endpoints, so no shared client module is needed —
|
|
10
|
+
// and a component-local const keeps better-auth's deep client types out of the packaged .d.ts.
|
|
11
|
+
const authClient = createAuthClient({ plugins: [magicLinkClient()] });
|
|
12
|
+
|
|
4
13
|
interface Props {
|
|
5
|
-
data: { siteName: string
|
|
14
|
+
data: { siteName: string };
|
|
6
15
|
}
|
|
7
16
|
let { data }: Props = $props();
|
|
8
17
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
let email = $state('');
|
|
19
|
+
let requested = $state(false);
|
|
20
|
+
let busy = $state(false);
|
|
21
|
+
|
|
22
|
+
async function request(event: SubmitEvent) {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
busy = true;
|
|
25
|
+
// The magic-link email points at our /admin/auth/confirm page (built in config.ts), not a
|
|
26
|
+
// GET-verify URL — so the result is the same regardless of allowlist membership.
|
|
27
|
+
await authClient.signIn.magicLink({ email });
|
|
28
|
+
busy = false;
|
|
29
|
+
requested = true;
|
|
30
|
+
}
|
|
15
31
|
</script>
|
|
16
32
|
|
|
17
33
|
<svelte:head>
|
|
@@ -22,26 +38,27 @@
|
|
|
22
38
|
<h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
|
|
23
39
|
<p class="mt-1 text-sm opacity-70">Sign in with your editor email.</p>
|
|
24
40
|
|
|
25
|
-
{#if
|
|
41
|
+
{#if requested}
|
|
26
42
|
<div class="alert alert-success mt-6">
|
|
27
|
-
<span>
|
|
43
|
+
<span>
|
|
44
|
+
If that address is on the editor list, a sign-in link is on its way. It expires in 10
|
|
45
|
+
minutes.
|
|
46
|
+
</span>
|
|
28
47
|
</div>
|
|
29
48
|
{:else}
|
|
30
|
-
{
|
|
31
|
-
<div class="alert alert-error mt-6">
|
|
32
|
-
<span>{errorMessages[data.error] ?? 'Something went wrong. Try again.'}</span>
|
|
33
|
-
</div>
|
|
34
|
-
{/if}
|
|
35
|
-
<form method="POST" action="/admin/auth/request" class="mt-6 flex flex-col gap-3">
|
|
49
|
+
<form onsubmit={request} class="mt-6 flex flex-col gap-3">
|
|
36
50
|
<input
|
|
37
51
|
type="email"
|
|
38
52
|
name="email"
|
|
53
|
+
bind:value={email}
|
|
39
54
|
required
|
|
40
55
|
autocomplete="email"
|
|
41
56
|
placeholder="you@example.com"
|
|
42
57
|
class="input w-full"
|
|
43
58
|
/>
|
|
44
|
-
<button type="submit" class="btn btn-primary"
|
|
59
|
+
<button type="submit" class="btn btn-primary" disabled={busy}>
|
|
60
|
+
{busy ? 'Sending…' : 'Email me a sign-in link'}
|
|
61
|
+
</button>
|
|
45
62
|
</form>
|
|
46
63
|
{/if}
|
|
47
64
|
</div>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ones. Reuses the same neutral DaisyUI chrome as the rest of the admin (panels, alerts,
|
|
4
4
|
// table, buttons). Data comes from `adminsLoad` merged with `adminLayoutLoad` (siteName);
|
|
5
5
|
// mutations post to the page's named form actions (`?/add`, `?/remove`, `?/setRole`).
|
|
6
|
-
import type { AdminsData } from '../
|
|
6
|
+
import type { AdminsData } from '../auth';
|
|
7
7
|
|
|
8
8
|
interface Props {
|
|
9
9
|
data: AdminsData & { siteName: string };
|
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
export { default as AdminLayout } from './AdminLayout.svelte';
|
|
4
4
|
export { default as AdminList } from './AdminList.svelte';
|
|
5
5
|
export { default as LoginPage } from './LoginPage.svelte';
|
|
6
|
+
export { default as ConfirmPage } from './ConfirmPage.svelte';
|
|
6
7
|
export { default as EditPage } from './EditPage.svelte';
|
|
7
8
|
export { default as ManageAdmins } from './ManageAdmins.svelte';
|
package/src/lib/email.ts
CHANGED
|
@@ -25,11 +25,18 @@ export async function sendMagicLink(
|
|
|
25
25
|
from: string,
|
|
26
26
|
): Promise<void> {
|
|
27
27
|
const expiry = "This link expires in 10 minutes and works only once. If you didn't request it, ignore this email.";
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
try {
|
|
29
|
+
await sender.send({
|
|
30
|
+
to,
|
|
31
|
+
from,
|
|
32
|
+
subject: `Your ${siteName} sign-in link`,
|
|
33
|
+
text: `Sign in to ${siteName}:\n\n${link}\n\n${expiry}`,
|
|
34
|
+
html: `<p>Sign in to ${siteName}:</p><p><a href="${link}">Confirm sign-in</a></p><p style="color:#666;font-size:0.9em">${expiry}</p>`,
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// H6: Email Sending is beta + the sole auth channel. Surface + audit; a Resend fallback
|
|
38
|
+
// can slot in behind this same signature if Sending proves unreliable.
|
|
39
|
+
console.error(`magic-link email send failed for ${to}:`, err);
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
35
42
|
}
|
package/src/lib/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// cairn-cms public API. Consumers import
|
|
2
|
-
|
|
1
|
+
// cairn-cms public API. Consumers import content/email/github/adapter from 'cairn-cms';
|
|
2
|
+
// auth (better-auth factory, guards, manage-editors) lives at the 'cairn-cms/auth' subpath.
|
|
3
3
|
export * from './email';
|
|
4
4
|
export * from './github';
|
|
5
5
|
export * from './carta';
|