@podosoft/podokit 0.10.2 → 0.11.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/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +16 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +7 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +36 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +7 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +66 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/pending-approval/+page.svelte +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.server.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +79 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +44 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +43 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -4
- package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +187 -0
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +1 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +62 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +13 -4
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +93 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/agents/openai.yaml +4 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/apple.md +18 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +19 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/smtp.md +22 -0
- package/dist/templates/modules/auth/module.manifest.json +7 -4
- package/package.json +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
|
|
4
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
|
+
|
|
4
6
|
test("admin can create a user with a custom role @smoke", async ({ page }) => {
|
|
5
7
|
await ready(page, "/admin/users");
|
|
6
8
|
await page.getByRole("button", { name: "Add user" }).click();
|
|
@@ -49,3 +51,44 @@ test("row menu exposes admin actions", async ({ page }) => {
|
|
|
49
51
|
await expect(page.getByRole("menuitem", { name: "Manage" })).toBeVisible();
|
|
50
52
|
await expect(page.getByRole("menuitem", { name: "Impersonate" })).toBeVisible();
|
|
51
53
|
});
|
|
54
|
+
|
|
55
|
+
test("admin can approve a pending registration from the user list @smoke", async ({ page }) => {
|
|
56
|
+
const email = `pending-ui-${Date.now()}@example.com`;
|
|
57
|
+
const created = await page.request.post("/api/auth/admin/create-user", {
|
|
58
|
+
headers: { origin: base },
|
|
59
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "Pending UI", role: "user" },
|
|
60
|
+
});
|
|
61
|
+
expect(created.ok()).toBeTruthy();
|
|
62
|
+
const userId = ((await created.json()).user?.id ?? "") as string;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
expect(userId).toBeTruthy();
|
|
66
|
+
expect(
|
|
67
|
+
(await page.request.post("/api/auth/admin/update-user", {
|
|
68
|
+
headers: { origin: base },
|
|
69
|
+
data: { userId, data: { signupApproved: false } },
|
|
70
|
+
})).ok(),
|
|
71
|
+
).toBeTruthy();
|
|
72
|
+
|
|
73
|
+
await ready(page, "/admin/users");
|
|
74
|
+
await page.locator("#toolbar-search").fill(email);
|
|
75
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
76
|
+
const row = page.getByRole("row", { name: new RegExp(email) });
|
|
77
|
+
await expect(row.getByText("Pending approval")).toBeVisible();
|
|
78
|
+
await row.getByRole("button").click();
|
|
79
|
+
const response = page.waitForResponse(
|
|
80
|
+
(res) => res.url().endsWith("/api/auth/admin/update-user") && res.request().method() === "POST",
|
|
81
|
+
);
|
|
82
|
+
await page.getByRole("menuitem", { name: "Approve sign-up" }).click();
|
|
83
|
+
expect((await response).ok()).toBeTruthy();
|
|
84
|
+
await expect(page.getByText("Sign-up approved")).toBeVisible();
|
|
85
|
+
await expect(row.getByText("Active")).toBeVisible();
|
|
86
|
+
} finally {
|
|
87
|
+
if (userId) {
|
|
88
|
+
await page.request.post("/api/auth/admin/remove-user", {
|
|
89
|
+
headers: { origin: base },
|
|
90
|
+
data: { userId },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
{
|
|
37
37
|
"file": "apps/api/src/auth/auth.ts",
|
|
38
38
|
"marker": "// podokit:end:auth-options",
|
|
39
|
-
"text": "trustedOrigins: (process.env.CORS_ORIGIN ?? \"http://localhost:5001\").split(\",\").map((origin) => origin.trim())
|
|
39
|
+
"text": "trustedOrigins: (process.env.CORS_ORIGIN ?? \"http://localhost:5001\").split(\",\").map((origin) => origin.trim()),"
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
"file": "AGENTS.md",
|
|
43
43
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
44
44
|
"optional": true,
|
|
45
|
-
"text": "### admin-dashboard\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Emails in `ADMIN_EMAILS` become admins on sign-up.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
|
|
45
|
+
"text": "### admin-dashboard\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Emails in `ADMIN_EMAILS` become admins on sign-up. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"file": "apps/api/src/app.module.ts",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
],
|
|
58
58
|
"instructions": [
|
|
59
59
|
"Set ADMIN_EMAILS in .env (comma-separated). Those emails become admins on sign-up.",
|
|
60
|
-
"Re-run the auth migration to add the admin columns: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
|
|
60
|
+
"Re-run the auth migration to add the admin and sign-up approval columns: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
|
|
61
61
|
"Start the app (npm run dev), open /signup, register an ADMIN_EMAILS address, then sign in.",
|
|
62
|
-
"Admin pages: /admin/users (list, ban, set role, revoke sessions). Signed-in users manage their profile and security at /account; /admin/account remains available in the admin shell. Admin, account, authentication, maintenance, and API responses include X-Robots-Tag: noindex, nofollow by default.",
|
|
62
|
+
"Admin pages: /admin/users (list, approve pending registrations, ban, set role, revoke sessions). Signed-in users manage their profile and security at /account; /admin/account remains available in the admin shell. Admin, account, authentication, maintenance, and API responses include X-Robots-Tag: noindex, nofollow by default.",
|
|
63
63
|
"Password reset emails go through the mailer module (installed automatically with auth); in dev without SMTP they are logged to the API console. Configure SMTP in admin settings or via SMTP_* env for production."
|
|
64
64
|
]
|
|
65
65
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
|
|
5
|
+
function has(flag) {
|
|
6
|
+
return args.includes(flag);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function value(flag) {
|
|
10
|
+
const index = args.indexOf(flag);
|
|
11
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function usage() {
|
|
15
|
+
console.log(`Configure PodoKit authentication through the admin API.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
npm run auth:configure -- --provider google --require-signup-approval
|
|
19
|
+
npm run auth:configure -- --smtp
|
|
20
|
+
npm run auth:configure -- --check-only --provider google
|
|
21
|
+
|
|
22
|
+
Required environment for writes:
|
|
23
|
+
AUTH_SETUP_ORIGIN Public app origin (or BETTER_AUTH_URL)
|
|
24
|
+
AUTH_SETUP_ADMIN_EMAIL Administrator email
|
|
25
|
+
AUTH_SETUP_ADMIN_PASSWORD Administrator password
|
|
26
|
+
|
|
27
|
+
OAuth environment:
|
|
28
|
+
OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET
|
|
29
|
+
OAUTH_REDIRECT_URI (optional)
|
|
30
|
+
Provider-specific variables such as GOOGLE_CLIENT_ID also work.
|
|
31
|
+
|
|
32
|
+
SMTP environment (with --smtp):
|
|
33
|
+
SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, MAIL_FROM
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--provider <id> Configure a social provider
|
|
37
|
+
--smtp Configure SMTP from environment variables
|
|
38
|
+
--require-signup-approval Require admin approval for future registrations
|
|
39
|
+
--no-require-signup-approval Auto-approve future registrations
|
|
40
|
+
--check-only Check public capabilities without writing
|
|
41
|
+
--dry-run Print a redacted plan without writing
|
|
42
|
+
`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function required(name, current) {
|
|
46
|
+
if (!current) throw new Error(`${name} is required.`);
|
|
47
|
+
return current;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function providerEnvName(provider, suffix) {
|
|
51
|
+
return `${provider.toUpperCase().replaceAll("-", "_")}_${suffix}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function bool(value, fallback = false) {
|
|
55
|
+
if (value === undefined) return fallback;
|
|
56
|
+
return value === "true";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function redactedPlan(body) {
|
|
60
|
+
return {
|
|
61
|
+
...body,
|
|
62
|
+
...(body.social
|
|
63
|
+
? {
|
|
64
|
+
social: Object.fromEntries(
|
|
65
|
+
Object.entries(body.social).map(([id, provider]) => [
|
|
66
|
+
id,
|
|
67
|
+
{ ...provider, clientSecret: provider.clientSecret ? "<redacted>" : undefined },
|
|
68
|
+
]),
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
: {}),
|
|
72
|
+
...(body.smtp
|
|
73
|
+
? { smtp: { ...body.smtp, pass: body.smtp.pass ? "<redacted>" : undefined } }
|
|
74
|
+
: {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function cookies(response) {
|
|
79
|
+
const getSetCookie = response.headers.getSetCookie;
|
|
80
|
+
const values = typeof getSetCookie === "function"
|
|
81
|
+
? getSetCookie.call(response.headers)
|
|
82
|
+
: [response.headers.get("set-cookie")].filter(Boolean);
|
|
83
|
+
return values.map((item) => item.split(";", 1)[0]).join("; ");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function signIn(origin, email, password) {
|
|
87
|
+
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json", origin },
|
|
90
|
+
body: JSON.stringify({ email, password }),
|
|
91
|
+
redirect: "manual",
|
|
92
|
+
});
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const text = await response.text();
|
|
95
|
+
throw new Error(`Administrator sign-in failed (${response.status}): ${text}`);
|
|
96
|
+
}
|
|
97
|
+
const cookie = cookies(response);
|
|
98
|
+
if (!cookie) throw new Error("Administrator sign-in returned no session cookie.");
|
|
99
|
+
return cookie;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function publicCapabilities(origin) {
|
|
103
|
+
const response = await fetch(`${origin}/api/account/capabilities`);
|
|
104
|
+
if (!response.ok) throw new Error(`Capabilities check failed (${response.status}).`);
|
|
105
|
+
return response.json();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function main() {
|
|
109
|
+
if (has("--help") || has("-h")) {
|
|
110
|
+
usage();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rawOrigin = process.env.AUTH_SETUP_ORIGIN ?? process.env.BETTER_AUTH_URL ?? "http://localhost:5002";
|
|
115
|
+
const origin = new URL(rawOrigin).origin;
|
|
116
|
+
const provider = value("--provider");
|
|
117
|
+
const configureSmtp = has("--smtp");
|
|
118
|
+
const approval = has("--require-signup-approval")
|
|
119
|
+
? true
|
|
120
|
+
: has("--no-require-signup-approval")
|
|
121
|
+
? false
|
|
122
|
+
: undefined;
|
|
123
|
+
|
|
124
|
+
if (provider && !/^[a-z0-9-]+$/.test(provider)) throw new Error("Provider id must use lowercase letters, numbers, or hyphens.");
|
|
125
|
+
|
|
126
|
+
if (provider) console.log(`OAuth callback URL: ${origin}/api/auth/callback/${provider}`);
|
|
127
|
+
if (has("--check-only")) {
|
|
128
|
+
const capabilities = await publicCapabilities(origin);
|
|
129
|
+
console.log(JSON.stringify(capabilities, null, 2));
|
|
130
|
+
if (provider && !capabilities.providers?.includes(provider)) {
|
|
131
|
+
throw new Error(`Provider "${provider}" is not enabled.`);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const body = {};
|
|
137
|
+
if (provider) {
|
|
138
|
+
const clientId = process.env.OAUTH_CLIENT_ID ?? process.env[providerEnvName(provider, "CLIENT_ID")];
|
|
139
|
+
const clientSecret = process.env.OAUTH_CLIENT_SECRET ?? process.env[providerEnvName(provider, "CLIENT_SECRET")];
|
|
140
|
+
body.social = {
|
|
141
|
+
[provider]: {
|
|
142
|
+
enabled: true,
|
|
143
|
+
clientId: required("OAUTH_CLIENT_ID", clientId),
|
|
144
|
+
clientSecret: required("OAUTH_CLIENT_SECRET", clientSecret),
|
|
145
|
+
...(process.env.OAUTH_REDIRECT_URI ? { redirectURI: process.env.OAUTH_REDIRECT_URI } : {}),
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (configureSmtp) {
|
|
150
|
+
body.smtp = {
|
|
151
|
+
enabled: true,
|
|
152
|
+
host: required("SMTP_HOST", process.env.SMTP_HOST),
|
|
153
|
+
port: Number(process.env.SMTP_PORT ?? 587),
|
|
154
|
+
secure: bool(process.env.SMTP_SECURE),
|
|
155
|
+
user: process.env.SMTP_USER ?? "",
|
|
156
|
+
from: process.env.MAIL_FROM ?? "",
|
|
157
|
+
...(process.env.SMTP_PASS ? { pass: process.env.SMTP_PASS } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (approval !== undefined) body.server = { requireSignupApproval: approval };
|
|
161
|
+
|
|
162
|
+
if (Object.keys(body).length === 0) throw new Error("Choose --provider, --smtp, or a sign-up approval option.");
|
|
163
|
+
if (has("--dry-run")) {
|
|
164
|
+
console.log(JSON.stringify(redactedPlan(body), null, 2));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const email = required("AUTH_SETUP_ADMIN_EMAIL", process.env.AUTH_SETUP_ADMIN_EMAIL);
|
|
169
|
+
const password = required("AUTH_SETUP_ADMIN_PASSWORD", process.env.AUTH_SETUP_ADMIN_PASSWORD);
|
|
170
|
+
const cookie = await signIn(origin, email, password);
|
|
171
|
+
const response = await fetch(`${origin}/api/account/auth-config`, {
|
|
172
|
+
method: "PUT",
|
|
173
|
+
headers: { "content-type": "application/json", origin, cookie },
|
|
174
|
+
body: JSON.stringify(body),
|
|
175
|
+
});
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const text = await response.text();
|
|
178
|
+
throw new Error(`Authentication configuration failed (${response.status}): ${text}`);
|
|
179
|
+
}
|
|
180
|
+
console.log("Authentication configuration updated.");
|
|
181
|
+
console.log(JSON.stringify(await publicCapabilities(origin), null, 2));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
main().catch((error) => {
|
|
185
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
});
|
|
@@ -60,6 +60,7 @@ export class AccountController {
|
|
|
60
60
|
providers: snapshot.providers,
|
|
61
61
|
deleteAccount: snapshot.allowDelete,
|
|
62
62
|
emailVerification: snapshot.requireEmailVerification,
|
|
63
|
+
signupApprovalRequired: snapshot.requireSignupApproval,
|
|
63
64
|
passwordBreachCheck: snapshot.passwordBreachCheck,
|
|
64
65
|
auditLog: snapshot.auditLog,
|
|
65
66
|
roles: ROLE_NAMES,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { betterAuth, type BetterAuthOptions, type BetterAuthPlugin } from "better-auth";
|
|
2
|
+
import { APIError } from "better-auth/api";
|
|
2
3
|
import { twoFactor, haveIBeenPwned, magicLink, emailOTP, username, multiSession, phoneNumber, organization, jwt, bearer } from "better-auth/plugins";
|
|
3
4
|
import { actionEmail, sendMail } from "../mail/mailer";
|
|
4
5
|
import { sendSms } from "../sms/sms";
|
|
@@ -9,12 +10,20 @@ import { type AuthConfig, envAuthConfig, SUPPORTED_PROVIDER_IDS } from "@podosof
|
|
|
9
10
|
import { apiKey } from "@better-auth/api-key";
|
|
10
11
|
import { passkey } from "@better-auth/passkey";
|
|
11
12
|
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
13
|
+
import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
|
|
12
14
|
// podokit:begin:auth-imports
|
|
13
15
|
// podokit:end:auth-imports
|
|
14
16
|
|
|
15
17
|
// Web origin(s) where the browser runs (WebAuthn ceremonies must match these).
|
|
16
18
|
const webOrigins = (process.env.CORS_ORIGIN ?? "http://localhost:5001").split(",").map((o) => o.trim());
|
|
17
19
|
|
|
20
|
+
const adminEmails = new Set(
|
|
21
|
+
(process.env.ADMIN_EMAILS ?? "")
|
|
22
|
+
.split(",")
|
|
23
|
+
.map((email) => email.trim().toLowerCase())
|
|
24
|
+
.filter(Boolean),
|
|
25
|
+
);
|
|
26
|
+
|
|
18
27
|
// Enabled social providers, from the resolved config (DB-first, env fallback).
|
|
19
28
|
// Admins add/edit/remove providers dynamically (Settings page), so this maps the
|
|
20
29
|
// whole config.social record. Secrets arrive already decrypted; a provider needs
|
|
@@ -115,6 +124,47 @@ export function buildAuth(config: AuthConfig) {
|
|
|
115
124
|
// podokit:begin:auth-plugins
|
|
116
125
|
// podokit:end:auth-plugins
|
|
117
126
|
|
|
127
|
+
const databaseHooks: NonNullable<BetterAuthOptions["databaseHooks"]> = {
|
|
128
|
+
user: {
|
|
129
|
+
create: {
|
|
130
|
+
before: async (user, context) => {
|
|
131
|
+
const adminEmail = adminEmails.has(user.email.toLowerCase());
|
|
132
|
+
const createdByAdmin = context?.path === "/admin/create-user";
|
|
133
|
+
return {
|
|
134
|
+
data: {
|
|
135
|
+
...user,
|
|
136
|
+
// The admin plugin contributes `role` before this hook. Preserve an
|
|
137
|
+
// explicitly requested admin-created role, while bootstrap emails
|
|
138
|
+
// always become administrators.
|
|
139
|
+
...(Object.prototype.hasOwnProperty.call(user, "role")
|
|
140
|
+
? { role: adminEmail ? "admin" : user.role }
|
|
141
|
+
: {}),
|
|
142
|
+
// Existing rows are treated as approved through the field default.
|
|
143
|
+
// Only self-registration while the policy is enabled starts pending.
|
|
144
|
+
signupApproved: adminEmail || createdByAdmin || !config.requireSignupApproval,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
session: {
|
|
151
|
+
create: {
|
|
152
|
+
before: async (session, context) => {
|
|
153
|
+
if (!context) return;
|
|
154
|
+
const user = (await context.context.internalAdapter.findUserById(session.userId)) as {
|
|
155
|
+
signupApproved?: boolean | null;
|
|
156
|
+
} | null;
|
|
157
|
+
if (user?.signupApproved === false) {
|
|
158
|
+
throw APIError.from("FORBIDDEN", {
|
|
159
|
+
code: SIGNUP_APPROVAL_REQUIRED,
|
|
160
|
+
message: "Your account is waiting for administrator approval.",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
118
168
|
// Request-time hooks. The feature gate turns the admin Settings toggles into a
|
|
119
169
|
// real server boundary (disabled features 404). Other PodoKit modules add their
|
|
120
170
|
// own hooks below the marker.
|
|
@@ -126,6 +176,9 @@ export function buildAuth(config: AuthConfig) {
|
|
|
126
176
|
database: pool,
|
|
127
177
|
emailAndPassword: {
|
|
128
178
|
enabled: true,
|
|
179
|
+
// Pending email/password registrations should complete without creating a
|
|
180
|
+
// session. All later sign-in methods are enforced by the session hook below.
|
|
181
|
+
autoSignIn: !config.requireSignupApproval,
|
|
129
182
|
requireEmailVerification: config.requireEmailVerification,
|
|
130
183
|
sendResetPassword: async ({ user, url }) => {
|
|
131
184
|
await sendMail({
|
|
@@ -163,6 +216,14 @@ export function buildAuth(config: AuthConfig) {
|
|
|
163
216
|
},
|
|
164
217
|
},
|
|
165
218
|
user: {
|
|
219
|
+
additionalFields: {
|
|
220
|
+
signupApproved: {
|
|
221
|
+
type: "boolean",
|
|
222
|
+
required: false,
|
|
223
|
+
defaultValue: true,
|
|
224
|
+
input: false,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
166
227
|
// Self-service email change. When email verification is on, better-auth sends
|
|
167
228
|
// an approval link to the current address before switching; otherwise it
|
|
168
229
|
// changes immediately.
|
|
@@ -193,6 +254,7 @@ export function buildAuth(config: AuthConfig) {
|
|
|
193
254
|
// Self-service account deletion — admin-toggleable (auth_config), applied live.
|
|
194
255
|
deleteUser: { enabled: config.allowDelete },
|
|
195
256
|
},
|
|
257
|
+
databaseHooks,
|
|
196
258
|
// podokit:begin:auth-options
|
|
197
259
|
// podokit:end:auth-options
|
|
198
260
|
});
|
|
@@ -16,7 +16,7 @@ export type AuthConfigView = {
|
|
|
16
16
|
/** Catalog of addable providers (id + display label) for the "add provider" picker. */
|
|
17
17
|
catalog: ReadonlyArray<{ id: string; label: string }>;
|
|
18
18
|
smtp: { enabled: boolean; host: string; port: number; secure: boolean; user: string; from: string; hasSecret: boolean };
|
|
19
|
-
server: { requireEmailVerification: boolean; allowDelete: boolean; hibp: boolean; auditLog: boolean };
|
|
19
|
+
server: { requireEmailVerification: boolean; requireSignupApproval: boolean; allowDelete: boolean; hibp: boolean; auditLog: boolean };
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
type ProviderUpdate = { enabled?: boolean; clientId?: string; clientSecret?: string; redirectURI?: string; delete?: boolean };
|
|
@@ -24,7 +24,7 @@ export type AuthConfigUpdate = {
|
|
|
24
24
|
/** Per-provider upsert (keyed by provider id); `{ delete: true }` removes it. */
|
|
25
25
|
social?: Record<string, ProviderUpdate>;
|
|
26
26
|
smtp?: { enabled?: boolean; host?: string; port?: number; secure?: boolean; user?: string; pass?: string; from?: string };
|
|
27
|
-
server?: { requireEmailVerification?: boolean; allowDelete?: boolean; hibp?: boolean; auditLog?: boolean };
|
|
27
|
+
server?: { requireEmailVerification?: boolean; requireSignupApproval?: boolean; allowDelete?: boolean; hibp?: boolean; auditLog?: boolean };
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
@Injectable()
|
|
@@ -53,7 +53,13 @@ export class AuthConfigService {
|
|
|
53
53
|
const smtpRow = byKey.get("smtp");
|
|
54
54
|
const smtpC = (smtpRow?.config ?? {}) as { host?: string; port?: number; secure?: boolean; user?: string; from?: string };
|
|
55
55
|
const serverRow = byKey.get("server");
|
|
56
|
-
const serverC = (serverRow?.config ?? {}) as {
|
|
56
|
+
const serverC = (serverRow?.config ?? {}) as {
|
|
57
|
+
requireEmailVerification?: boolean;
|
|
58
|
+
requireSignupApproval?: boolean;
|
|
59
|
+
allowDelete?: boolean;
|
|
60
|
+
hibp?: boolean;
|
|
61
|
+
auditLog?: boolean;
|
|
62
|
+
};
|
|
57
63
|
return {
|
|
58
64
|
social,
|
|
59
65
|
catalog: SUPPORTED_SOCIAL_PROVIDERS,
|
|
@@ -68,6 +74,7 @@ export class AuthConfigService {
|
|
|
68
74
|
},
|
|
69
75
|
server: {
|
|
70
76
|
requireEmailVerification: serverC.requireEmailVerification ?? env.requireEmailVerification,
|
|
77
|
+
requireSignupApproval: serverC.requireSignupApproval ?? env.requireSignupApproval,
|
|
71
78
|
allowDelete: serverC.allowDelete ?? env.allowDelete,
|
|
72
79
|
hibp: serverC.hibp ?? env.hibp,
|
|
73
80
|
auditLog: serverC.auditLog ?? env.auditLog,
|
|
@@ -111,7 +118,9 @@ export class AuthConfigService {
|
|
|
111
118
|
private async upsertServer(u: NonNullable<AuthConfigUpdate["server"]>): Promise<void> {
|
|
112
119
|
const row = (await this.repo.findOneBy({ key: "server" })) ?? this.repo.create({ key: "server", enabled: true, config: {}, secret: null });
|
|
113
120
|
const config = { ...(row.config as Record<string, unknown>) };
|
|
114
|
-
for (const f of ["requireEmailVerification", "allowDelete", "hibp", "auditLog"] as const)
|
|
121
|
+
for (const f of ["requireEmailVerification", "requireSignupApproval", "allowDelete", "hibp", "auditLog"] as const) {
|
|
122
|
+
if (u[f] !== undefined) config[f] = u[f];
|
|
123
|
+
}
|
|
115
124
|
row.config = config;
|
|
116
125
|
await this.repo.save(row);
|
|
117
126
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-configure-auth
|
|
3
|
+
description: Configure and verify PodoKit social OAuth providers, SMTP delivery, and the administrator sign-up approval policy. Use when enabling Google, Apple, or another Better Auth provider; calculating callback URLs; applying credentials through Admin Settings; configuring outbound mail; or diagnosing why a configured sign-in method is unavailable.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Configure PodoKit authentication
|
|
7
|
+
|
|
8
|
+
Use the generated `auth:configure` command so credentials enter the encrypted `auth_config` store through the admin API. Keep provider secrets, SMTP passwords, and administrator passwords out of source files, command arguments, logs, and commits.
|
|
9
|
+
|
|
10
|
+
## Choose the workflow
|
|
11
|
+
|
|
12
|
+
- Read [references/google.md](references/google.md) for Google sign-in, including External audience requirements.
|
|
13
|
+
- Read [references/apple.md](references/apple.md) for Apple sign-in and client-secret rotation.
|
|
14
|
+
- Read [references/smtp.md](references/smtp.md) for Google Workspace SMTP relay and DNS authentication.
|
|
15
|
+
- Run the same generated command for other provider IDs. Confirm the provider appears in Admin Settings before applying it.
|
|
16
|
+
|
|
17
|
+
## Prepare the app
|
|
18
|
+
|
|
19
|
+
1. Confirm the `auth` and `admin-dashboard` modules are installed with `podo status`.
|
|
20
|
+
2. Confirm `BETTER_AUTH_URL` is the public authentication origin. Production should use HTTPS.
|
|
21
|
+
3. Run Better Auth and TypeORM migrations before configuring a new app:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm run migrate:all -w <app>-api
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
4. Derive the default callback as `<BETTER_AUTH_URL>/api/auth/callback/<provider>`. Only set `OAUTH_REDIRECT_URI` when the provider requires an explicit override.
|
|
28
|
+
|
|
29
|
+
## Handle secrets safely
|
|
30
|
+
|
|
31
|
+
Load secrets from the deployment secret manager or prompt for them in the current shell. Do not place secret values directly in command arguments.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export AUTH_SETUP_ORIGIN="https://example.com"
|
|
35
|
+
export AUTH_SETUP_ADMIN_EMAIL="admin@example.com"
|
|
36
|
+
printf "Admin password: " >&2
|
|
37
|
+
IFS= read -r -s AUTH_SETUP_ADMIN_PASSWORD
|
|
38
|
+
printf "\n" >&2
|
|
39
|
+
export AUTH_SETUP_ADMIN_PASSWORD
|
|
40
|
+
printf "OAuth client secret: " >&2
|
|
41
|
+
IFS= read -r -s OAUTH_CLIENT_SECRET
|
|
42
|
+
printf "\n" >&2
|
|
43
|
+
export OAUTH_CLIENT_SECRET
|
|
44
|
+
export OAUTH_CLIENT_ID="provider-client-id"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Unset sensitive variables when finished:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
unset AUTH_SETUP_ADMIN_PASSWORD OAUTH_CLIENT_SECRET SMTP_PASS
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Preview and apply
|
|
54
|
+
|
|
55
|
+
Preview a redacted plan first:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm run auth:configure -w <app>-api -- --provider google --require-signup-approval --dry-run
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Apply OAuth credentials and the approval policy:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm run auth:configure -w <app>-api -- --provider google --require-signup-approval
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Configure SMTP separately or in the same run. Passwordless IP-based relays may omit `SMTP_USER` and `SMTP_PASS`.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
export SMTP_HOST="smtp-relay.example.com"
|
|
71
|
+
export SMTP_PORT="587"
|
|
72
|
+
export SMTP_SECURE="false"
|
|
73
|
+
export MAIL_FROM="Example <noreply@example.com>"
|
|
74
|
+
npm run auth:configure -w <app>-api -- --smtp
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Verify
|
|
78
|
+
|
|
79
|
+
1. Check the public capability snapshot:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm run auth:configure -w <app>-api -- --provider google --check-only
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
2. Open `/login` and confirm the provider button appears.
|
|
86
|
+
3. With sign-up approval enabled, register a disposable account and confirm it reaches `/pending-approval` without a session.
|
|
87
|
+
4. Open `/admin/users`, filter by **Pending approval**, approve the account, and confirm sign-in succeeds.
|
|
88
|
+
5. Confirm administrator-created users and emails in `ADMIN_EMAILS` do not enter the approval queue.
|
|
89
|
+
6. Send a real verification or password-reset email and inspect SPF, DKIM, and DMARC results in the receiving mailbox.
|
|
90
|
+
|
|
91
|
+
## Preserve provider independence
|
|
92
|
+
|
|
93
|
+
Treat approval as a user lifecycle policy, not a provider option. Do not add Google-specific approval checks. New providers such as Apple must use the same user field and session gate automatically. Keep provider-specific setup and rotation details in a reference file while reusing `auth:configure` and Admin Settings.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Apple sign-in
|
|
2
|
+
|
|
3
|
+
Apple uses the same PodoKit provider pipeline and sign-up approval gate, but its credentials have a different lifecycle.
|
|
4
|
+
|
|
5
|
+
1. Create an App ID with Sign in with Apple enabled.
|
|
6
|
+
2. Create a Services ID for the web application and register the domain and callback `https://example.com/api/auth/callback/apple`.
|
|
7
|
+
3. Create a Sign in with Apple key and retain the Team ID, Key ID, Services ID, and downloaded private key in a secret manager.
|
|
8
|
+
4. Generate the OAuth client secret as a signed JWT. Apple client secrets expire, so automate rotation before expiry and reapply the new value with `auth:configure`.
|
|
9
|
+
5. Configure the provider with `--provider apple`; use the Services ID as `OAUTH_CLIENT_ID` and the signed JWT as `OAUTH_CLIENT_SECRET`.
|
|
10
|
+
6. Test first-time consent carefully because Apple may only provide the user's name on the first authorization.
|
|
11
|
+
|
|
12
|
+
Do not store the Apple private key or generated client secret in the repository.
|
|
13
|
+
|
|
14
|
+
Official references:
|
|
15
|
+
|
|
16
|
+
- https://developer.apple.com/help/account/configure-app-capabilities/configure-sign-in-with-apple-for-the-web/
|
|
17
|
+
- https://developer.apple.com/documentation/signinwithapplerestapi/generate-and-validate-tokens
|
|
18
|
+
- https://better-auth.com/docs/authentication/apple
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Google sign-in
|
|
2
|
+
|
|
3
|
+
Use a Google Cloud project owned by the organization, not a personal project.
|
|
4
|
+
|
|
5
|
+
1. Configure the Google Auth Platform brand, support email, developer contact, privacy policy, and terms links.
|
|
6
|
+
2. Choose **External** when every Google account may sign in. Publish the app to **Production** after testing; test mode restricts users and refresh-token lifetime.
|
|
7
|
+
3. Create a **Web application** OAuth client.
|
|
8
|
+
4. Add the web origin, for example `https://example.com`, to authorized JavaScript origins.
|
|
9
|
+
5. Add the exact callback `https://example.com/api/auth/callback/google` to authorized redirect URIs.
|
|
10
|
+
6. Supply the client ID and secret to `auth:configure`; never commit either value.
|
|
11
|
+
7. Request only the scopes the application uses. Basic sign-in normally needs OpenID, email, and profile.
|
|
12
|
+
|
|
13
|
+
Google may require verification for an External production app, especially when sensitive scopes or branding are involved. Keep the homepage, privacy policy, terms, and verified domain consistent with the OAuth consent screen.
|
|
14
|
+
|
|
15
|
+
Official references:
|
|
16
|
+
|
|
17
|
+
- https://developers.google.com/identity/protocols/oauth2/web-server
|
|
18
|
+
- https://developers.google.com/identity/protocols/oauth2/policies
|
|
19
|
+
- https://better-auth.com/docs/authentication/google
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# SMTP delivery
|
|
2
|
+
|
|
3
|
+
Prefer an organizational SMTP relay over a personal mailbox password.
|
|
4
|
+
|
|
5
|
+
For Google Workspace SMTP relay:
|
|
6
|
+
|
|
7
|
+
1. Add an SMTP relay rule in the Google Admin console.
|
|
8
|
+
2. Restrict relay access to the production server's static public IP where possible.
|
|
9
|
+
3. Restrict allowed senders to addresses in the organization's domains.
|
|
10
|
+
4. Use `smtp-relay.gmail.com` on port 587 with STARTTLS (`SMTP_SECURE=false` in Nodemailer-style configuration), and require TLS in the Google Workspace relay policy.
|
|
11
|
+
5. Leave `SMTP_USER` and `SMTP_PASS` empty for an IP-authorized relay.
|
|
12
|
+
6. Set `MAIL_FROM` to an approved organizational sender.
|
|
13
|
+
7. Publish SPF, enable Google DKIM signing, and publish DMARC. Start DMARC conservatively, review reports, then strengthen enforcement.
|
|
14
|
+
8. Send a verification or password-reset message and inspect authentication results at the recipient.
|
|
15
|
+
|
|
16
|
+
App passwords are a fallback only. They require two-step verification and may be unavailable under organization policies or Advanced Protection.
|
|
17
|
+
|
|
18
|
+
Official references:
|
|
19
|
+
|
|
20
|
+
- https://support.google.com/a/answer/2956491
|
|
21
|
+
- https://support.google.com/a/answer/176600
|
|
22
|
+
- https://support.google.com/accounts/answer/185833
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"@types/pg": "^8.11.10"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"migrate:all": "node dist/migrate"
|
|
20
|
+
"migrate:all": "node dist/migrate",
|
|
21
|
+
"auth:configure": "node scripts/configure-auth.mjs"
|
|
21
22
|
},
|
|
22
23
|
"env": [
|
|
23
24
|
"# \u2500\u2500 Auth (better-auth) \u2014 the only required secrets \u2500\u2500",
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
"BETTER_AUTH_SECRET=change-me-in-production-min-32-characters",
|
|
28
29
|
"BETTER_AUTH_URL=http://localhost:5002",
|
|
29
30
|
"# Everything below is OPTIONAL. OAuth providers, SMTP, and the server-enforced",
|
|
30
|
-
"# toggles (email verification, breached-password check, self-delete) \u2014 plus the",
|
|
31
|
+
"# toggles (email verification, sign-up approval, breached-password check, self-delete) \u2014 plus the",
|
|
31
32
|
"# feature flags \u2014 are configured on the admin Settings page, stored encrypted in",
|
|
32
33
|
"# the DB and applied live (no restart). These env vars still work as a fallback/",
|
|
33
34
|
"# override (e.g. for secrets-manager injection in production); the DB takes",
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
"# SMS_WEBHOOK_URL=http://localhost:8095/sms",
|
|
39
40
|
"# AUTH_HIBP=false",
|
|
40
41
|
"# AUTH_EMAIL_VERIFICATION=false",
|
|
42
|
+
"# AUTH_REQUIRE_SIGNUP_APPROVAL=false",
|
|
41
43
|
"# AUTH_ALLOW_DELETE=false",
|
|
42
44
|
"# GOOGLE_CLIENT_ID=",
|
|
43
45
|
"# GOOGLE_CLIENT_SECRET=",
|
|
@@ -104,7 +106,7 @@
|
|
|
104
106
|
"file": "AGENTS.md",
|
|
105
107
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
106
108
|
"optional": true,
|
|
107
|
-
"text": "### auth (better-auth)\n- The API is **secure by default**: every route needs a session except `/health` and `/api/auth/*`. Opt a route out with `@Public()`; read the user with `@Session()`.\n- Configure OAuth providers, SMTP, and the server toggles on the admin **Settings** page (DB-backed, applied live \u2014 not via env). Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required env vars.\n- Development setup: create auth tables with `npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts`, then run TypeORM migrations. Production images can run both from compiled output with `npm run migrate:all`.\n"
|
|
109
|
+
"text": "### auth (better-auth)\n- The API is **secure by default**: every route needs a session except `/health` and `/api/auth/*`. Opt a route out with `@Public()`; read the user with `@Session()`.\n- Configure OAuth providers, SMTP, sign-up approval, and the other server toggles on the admin **Settings** page (DB-backed, applied live \u2014 not via env). Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required env vars.\n- Use `npm run auth:configure -w <app>-api -- ...` and `.claude/skills/podokit-configure-auth` to apply OAuth/SMTP credentials without committing secrets. The sign-up approval gate is provider-independent; do not add provider-specific approval checks.\n- Development setup: create auth tables with `npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts`, then run TypeORM migrations. Production images can run both from compiled output with `npm run migrate:all`.\n"
|
|
108
110
|
}
|
|
109
111
|
],
|
|
110
112
|
"instructions": [
|
|
@@ -114,6 +116,7 @@
|
|
|
114
116
|
"After building a production image, run all Better Auth and TypeORM migrations: npm run migrate:all",
|
|
115
117
|
"Sign up: curl -c cookies.txt -XPOST localhost:5002/api/auth/sign-up/email -H 'content-type: application/json' -d '{\"email\":\"a@example.com\",\"password\":\"password123\",\"name\":\"A\"}'",
|
|
116
118
|
"Call a protected route with the session: curl -b cookies.txt localhost:5002/account/me",
|
|
117
|
-
"
|
|
119
|
+
"Use npm run auth:configure -w <app>-api -- --help for redacted OAuth, SMTP, and sign-up approval automation.",
|
|
120
|
+
"Everything is protected by default; mark public routes with @Public(). Configure OAuth providers, SMTP, and server-enforced toggles on the admin Settings page \u2014 stored encrypted in the DB and applied live (no restart). Environment variables (GOOGLE_CLIENT_*, SMTP_*, AUTH_*) still work as an optional fallback/override."
|
|
118
121
|
]
|
|
119
122
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|