@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.
Files changed (31) hide show
  1. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +1 -0
  2. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +1 -0
  3. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +16 -0
  4. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +1 -0
  5. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +1 -0
  6. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +1 -1
  7. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +7 -1
  8. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +36 -3
  9. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +7 -4
  10. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +66 -3
  11. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/pending-approval/+page.svelte +18 -0
  12. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.server.ts +12 -0
  13. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +11 -0
  14. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
  15. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +79 -0
  16. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +1 -0
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +44 -0
  18. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +1 -0
  19. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +43 -0
  20. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -4
  21. package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +187 -0
  22. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +1 -0
  23. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +62 -0
  24. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +13 -4
  25. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +93 -0
  26. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/agents/openai.yaml +4 -0
  27. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/apple.md +18 -0
  28. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +19 -0
  29. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/smtp.md +22 -0
  30. package/dist/templates/modules/auth/module.manifest.json +7 -4
  31. 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()),\n databaseHooks: {\n user: {\n create: {\n before: async (user) => ({\n data: {\n ...user,\n role: (process.env.ADMIN_EMAILS ?? \"\")\n .split(\",\")\n .map((email) => email.trim().toLowerCase())\n .includes(user.email.toLowerCase())\n ? \"admin\"\n : \"user\",\n },\n }),\n },\n },\n },"
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 { requireEmailVerification?: boolean; allowDelete?: boolean; hibp?: boolean; auditLog?: boolean };
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) if (u[f] !== undefined) config[f] = u[f];
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,4 @@
1
+ interface:
2
+ display_name: "PodoKit Auth Setup"
3
+ short_description: "Configure social login, SMTP, and signup approval"
4
+ default_prompt: "Use $podokit-configure-auth to configure Google login, SMTP, and sign-up approval safely."
@@ -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
- "Everything is protected by default; mark public routes with @Public(). Configure OAuth (Google/GitHub), 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."
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.10.2",
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": {