@podosoft/podokit 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/dist/add.d.ts +2 -0
- package/dist/add.js +5 -1
- package/dist/assemble.d.ts +14 -0
- package/dist/assemble.js +60 -0
- package/dist/create.d.ts +2 -0
- package/dist/create.js +7 -0
- package/dist/eject.d.ts +11 -0
- package/dist/eject.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +122 -1
- package/dist/inspect.d.ts +43 -0
- package/dist/inspect.js +82 -0
- package/dist/lockfile.d.ts +80 -0
- package/dist/lockfile.js +193 -0
- package/dist/templates/base/dot-env.example +11 -6
- package/dist/templates/fullstack-nest-svelte/README.md +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
- package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
- package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
- package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
- package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
- package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
- package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
- package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
- package/dist/templates/modules/audit-log/module.manifest.json +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
- package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
- package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
- package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
- package/dist/templates/modules/auth/module.manifest.json +54 -20
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
- package/dist/templates/modules/bullmq/module.manifest.json +4 -4
- package/dist/templates/modules/file-upload/module.manifest.json +3 -3
- package/dist/templates/modules/job-progress/module.manifest.json +9 -9
- package/dist/templates/modules/logging/module.manifest.json +3 -3
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
- package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
- package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
- package/dist/templates/modules/redis/module.manifest.json +4 -4
- package/dist/templates/modules/sse/module.manifest.json +4 -4
- package/dist/templates/todo/README.md +2 -2
- package/dist/templates/todo/apps/api/package.json +1 -0
- package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
- package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
- package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
- package/dist/templates/todo/apps/api/src/main.ts +1 -1
- package/dist/templates/todo/apps/web/package.json +2 -2
- package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
- package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
- package/dist/templates/todo/dot-env.example +10 -5
- package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
- package/dist/templates/todo/tests/README.md +1 -1
- package/dist/templates/todo/tests/package.json +2 -1
- package/dist/templates/todo/tests/playwright.config.ts +1 -1
- package/dist/update.d.ts +49 -0
- package/dist/update.js +158 -0
- package/package.json +2 -2
|
@@ -1,59 +1,204 @@
|
|
|
1
1
|
import { betterAuth, type BetterAuthOptions, type BetterAuthPlugin } from "better-auth";
|
|
2
|
-
import { twoFactor } from "better-auth/plugins";
|
|
3
|
-
import {
|
|
4
|
-
|
|
2
|
+
import { twoFactor, haveIBeenPwned, magicLink, emailOTP, username, multiSession, phoneNumber, organization, jwt, bearer } from "better-auth/plugins";
|
|
3
|
+
import { actionEmail, sendMail } from "../mail/mailer";
|
|
4
|
+
import { sendSms } from "../sms/sms";
|
|
5
|
+
import { createFeatureGate } from "./feature-gate";
|
|
6
|
+
import { orgAc, orgRoles } from "./org-permissions";
|
|
7
|
+
import { pool } from "./db";
|
|
8
|
+
import { type AuthConfig, envAuthConfig, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
|
|
9
|
+
import { apiKey } from "@better-auth/api-key";
|
|
10
|
+
import { passkey } from "@better-auth/passkey";
|
|
11
|
+
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
12
|
+
// podokit:begin:auth-imports
|
|
13
|
+
// podokit:end:auth-imports
|
|
5
14
|
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
};
|
|
15
|
+
// Web origin(s) where the browser runs (WebAuthn ceremonies must match these).
|
|
16
|
+
const webOrigins = (process.env.CORS_ORIGIN ?? "http://localhost:5001").split(",").map((o) => o.trim());
|
|
17
|
+
|
|
18
|
+
// Enabled social providers, from the resolved config (DB-first, env fallback).
|
|
19
|
+
// Admins add/edit/remove providers dynamically (Settings page), so this maps the
|
|
20
|
+
// whole config.social record. Secrets arrive already decrypted; a provider needs
|
|
21
|
+
// enabled + id + secret, and must be a better-auth-supported provider id (the
|
|
22
|
+
// allowlist guards betterAuth() from an unknown key). The provider option shape
|
|
23
|
+
// (clientId/clientSecret/redirectURI) is common to every supported provider.
|
|
24
|
+
function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialProviders"]> {
|
|
25
|
+
const providers: Record<string, { clientId: string; clientSecret: string; redirectURI?: string }> = {};
|
|
26
|
+
for (const [id, p] of Object.entries(config.social)) {
|
|
27
|
+
if (!SUPPORTED_PROVIDER_IDS.has(id) || !p.enabled || !p.clientId || !p.clientSecret) continue;
|
|
28
|
+
providers[id] = { clientId: p.clientId, clientSecret: p.clientSecret, ...(p.redirectURI ? { redirectURI: p.redirectURI } : {}) };
|
|
20
29
|
}
|
|
21
|
-
return providers
|
|
30
|
+
return providers as NonNullable<BetterAuthOptions["socialProviders"]>;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
// Build a better-auth instance from resolved config. Called at boot and rebuilt by
|
|
34
|
+
// auth-provider.ts when the DB config changes, so admin edits (OAuth credentials,
|
|
35
|
+
// server-enforced toggles) apply without a restart. Everything below the injection
|
|
36
|
+
// markers is contributed by other PodoKit modules (admin-dashboard, audit-log).
|
|
37
|
+
export function buildAuth(config: AuthConfig) {
|
|
38
|
+
const plugins: BetterAuthPlugin[] = [
|
|
39
|
+
twoFactor(),
|
|
40
|
+
magicLink({
|
|
41
|
+
sendMagicLink: async ({ email, url }: { email: string; url: string; token: string }) => {
|
|
42
|
+
await sendMail({
|
|
43
|
+
to: email,
|
|
44
|
+
subject: "Your sign-in link",
|
|
45
|
+
text: `Sign in: ${url}`,
|
|
46
|
+
html: actionEmail("Sign in to your account", "Click the button below to sign in. This link expires shortly.", url, "Sign in"),
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
emailOTP({
|
|
51
|
+
sendVerificationOTP: async ({ email, otp }: { email: string; otp: string; type: string }) => {
|
|
52
|
+
await sendMail({
|
|
53
|
+
to: email,
|
|
54
|
+
subject: "Your sign-in code",
|
|
55
|
+
text: `Your one-time code is ${otp}. It expires shortly.`,
|
|
56
|
+
html: `<p>Your one-time sign-in code is:</p><p style="font-size:24px;font-weight:bold;letter-spacing:3px">${otp}</p><p>It expires shortly.</p>`,
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
// Adds username/displayUsername columns; lets users sign in with a username.
|
|
61
|
+
username(),
|
|
62
|
+
// Let one browser hold several signed-in accounts and switch between them.
|
|
63
|
+
multiSession(),
|
|
64
|
+
// Phone-number sign-in/verification. SMS delivery is a dev stub (logs the code);
|
|
65
|
+
// wire a real provider in sendOTP for production.
|
|
66
|
+
phoneNumber({
|
|
67
|
+
sendOTP: async ({ phoneNumber: to, code }: { phoneNumber: string; code: string }) => {
|
|
68
|
+
await sendSms({ to, body: `Your verification code is ${code}` });
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
// User-issued API keys (DB-backed). Distinct from the static X-API-Key module.
|
|
72
|
+
apiKey(),
|
|
73
|
+
// Passwordless sign-in with WebAuthn passkeys. The ceremony runs at the web
|
|
74
|
+
// origin, so expected origins are the CORS origins and rpID is their host.
|
|
75
|
+
// Change rpName to your product name (shown in the browser passkey prompt).
|
|
76
|
+
passkey({ rpName: "PodoKit", rpID: new URL(webOrigins[0]).hostname, origin: webOrigins }),
|
|
77
|
+
// Multi-tenant organizations: teams with members, roles, and email invitations.
|
|
78
|
+
organization({
|
|
79
|
+
// Custom member roles (adds "manager") and a parent-organization link so orgs
|
|
80
|
+
// can form a hierarchy. See org-permissions.ts.
|
|
81
|
+
ac: orgAc,
|
|
82
|
+
roles: orgRoles,
|
|
83
|
+
schema: { organization: { additionalFields: { parentOrganizationId: { type: "string", required: false, input: true } } } },
|
|
84
|
+
sendInvitationEmail: async (data: { email: string; id: string; organization: { name: string }; inviter: { user: { name: string; email: string } } }) => {
|
|
85
|
+
const url = `${webOrigins[0]}/accept-invitation/${data.id}`;
|
|
86
|
+
await sendMail({
|
|
87
|
+
to: data.email,
|
|
88
|
+
subject: `You are invited to ${data.organization.name}`,
|
|
89
|
+
text: `${data.inviter.user.name} invited you to ${data.organization.name}. Accept: ${url}`,
|
|
90
|
+
html: actionEmail(
|
|
91
|
+
`Join ${data.organization.name}`,
|
|
92
|
+
`${data.inviter.user.name} (${data.inviter.user.email}) invited you to join ${data.organization.name}.`,
|
|
93
|
+
url,
|
|
94
|
+
"Accept invitation",
|
|
95
|
+
),
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
// Accept the session token via `Authorization: Bearer <token>` (returned in the
|
|
100
|
+
// `set-auth-token` response header on sign-in) instead of a cookie — for API and
|
|
101
|
+
// mobile clients. Infrastructure plugin (always on), like jwt(); no UI toggle.
|
|
102
|
+
bearer(),
|
|
103
|
+
// JWT/JWKS signing keys — required by the OAuth provider to sign id_tokens.
|
|
104
|
+
jwt(),
|
|
105
|
+
// Act as an OAuth2/OIDC identity provider: other apps can "Sign in with this app".
|
|
106
|
+
// Register clients at /api/auth/oauth2/create-client; discovery at
|
|
107
|
+
// /api/auth/.well-known/openid-configuration. See docs/modules.md.
|
|
108
|
+
oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent" }),
|
|
109
|
+
];
|
|
110
|
+
// Reject passwords found in known breaches (Have I Been Pwned, k-anonymity range
|
|
111
|
+
// API). Admin-toggleable (auth_config), applied live on the next request.
|
|
112
|
+
if (config.hibp) {
|
|
113
|
+
plugins.push(haveIBeenPwned());
|
|
114
|
+
}
|
|
115
|
+
// podokit:begin:auth-plugins
|
|
116
|
+
// podokit:end:auth-plugins
|
|
117
|
+
|
|
118
|
+
// Request-time hooks. The feature gate turns the admin Settings toggles into a
|
|
119
|
+
// real server boundary (disabled features 404). Other PodoKit modules add their
|
|
120
|
+
// own hooks below the marker.
|
|
121
|
+
const hooks: NonNullable<BetterAuthOptions["hooks"]> = { before: createFeatureGate(pool) };
|
|
122
|
+
// podokit:begin:auth-hooks
|
|
123
|
+
// podokit:end:auth-hooks
|
|
30
124
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
|
47
|
-
advanced: {
|
|
48
|
-
ipAddress: {
|
|
49
|
-
// The API sits behind the SvelteKit server proxy, which forwards the
|
|
50
|
-
// resolved client IP as x-forwarded-for; trust it so sessions record an IP.
|
|
51
|
-
ipAddressHeaders: ["x-forwarded-for"],
|
|
125
|
+
return betterAuth({
|
|
126
|
+
database: pool,
|
|
127
|
+
emailAndPassword: {
|
|
128
|
+
enabled: true,
|
|
129
|
+
requireEmailVerification: config.requireEmailVerification,
|
|
130
|
+
sendResetPassword: async ({ user, url }) => {
|
|
131
|
+
await sendMail({
|
|
132
|
+
to: user.email,
|
|
133
|
+
subject: "Reset your password",
|
|
134
|
+
text: `Reset your password: ${url}`,
|
|
135
|
+
html: actionEmail("Reset your password", "Click the button below to choose a new password.", url, "Reset password"),
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
// podokit:begin:auth-email-password
|
|
139
|
+
// podokit:end:auth-email-password
|
|
52
140
|
},
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
141
|
+
emailVerification: {
|
|
142
|
+
sendOnSignUp: config.requireEmailVerification,
|
|
143
|
+
autoSignInAfterVerification: true,
|
|
144
|
+
sendVerificationEmail: async ({ user, url }) => {
|
|
145
|
+
await sendMail({
|
|
146
|
+
to: user.email,
|
|
147
|
+
subject: "Verify your email",
|
|
148
|
+
text: `Verify your email: ${url}`,
|
|
149
|
+
html: actionEmail("Verify your email", "Confirm your address to finish setting up your account.", url, "Verify email"),
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
socialProviders: buildSocial(config),
|
|
154
|
+
plugins,
|
|
155
|
+
hooks,
|
|
156
|
+
secret: process.env.BETTER_AUTH_SECRET ?? "change-me-in-production-min-32-characters",
|
|
157
|
+
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:5002",
|
|
158
|
+
advanced: {
|
|
159
|
+
ipAddress: {
|
|
160
|
+
// The API sits behind the SvelteKit server proxy, which forwards the
|
|
161
|
+
// resolved client IP as x-forwarded-for; trust it so sessions record an IP.
|
|
162
|
+
ipAddressHeaders: ["x-forwarded-for"],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
user: {
|
|
166
|
+
// Self-service email change. When email verification is on, better-auth sends
|
|
167
|
+
// an approval link to the current address before switching; otherwise it
|
|
168
|
+
// changes immediately.
|
|
169
|
+
changeEmail: {
|
|
170
|
+
enabled: true,
|
|
171
|
+
sendChangeEmailVerification: async ({
|
|
172
|
+
user,
|
|
173
|
+
newEmail,
|
|
174
|
+
url,
|
|
175
|
+
}: {
|
|
176
|
+
user: { email: string };
|
|
177
|
+
newEmail: string;
|
|
178
|
+
url: string;
|
|
179
|
+
}) => {
|
|
180
|
+
await sendMail({
|
|
181
|
+
to: user.email,
|
|
182
|
+
subject: "Approve your email change",
|
|
183
|
+
text: `Approve changing your email to ${newEmail}: ${url}`,
|
|
184
|
+
html: actionEmail(
|
|
185
|
+
"Approve email change",
|
|
186
|
+
`Confirm changing your email address to ${newEmail}.`,
|
|
187
|
+
url,
|
|
188
|
+
"Approve change",
|
|
189
|
+
),
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
// Self-service account deletion — admin-toggleable (auth_config), applied live.
|
|
194
|
+
deleteUser: { enabled: config.allowDelete },
|
|
195
|
+
},
|
|
196
|
+
// podokit:begin:auth-options
|
|
197
|
+
// podokit:end:auth-options
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Bootstrap instance from environment config. Used by the better-auth CLI
|
|
202
|
+
// (`--config auth.ts`) and as the guaranteed last-good fallback. The runtime
|
|
203
|
+
// mount (auth-provider.ts) rebuilds from DB config on top of this.
|
|
204
|
+
export const auth = buildAuth(envAuthConfig());
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Pool } from "pg";
|
|
2
|
+
|
|
3
|
+
// One shared pg connection pool, in its own module so it can be imported by
|
|
4
|
+
// auth.ts, the mailer, and the config store without an import cycle (auth.ts
|
|
5
|
+
// imports the mailer, which needs the pool for runtime SMTP config).
|
|
6
|
+
export const pool = new Pool({
|
|
7
|
+
host: process.env.POSTGRES_HOST ?? "localhost",
|
|
8
|
+
port: Number(process.env.POSTGRES_PORT ?? 5432),
|
|
9
|
+
user: process.env.POSTGRES_USER ?? "podokit",
|
|
10
|
+
password: process.env.POSTGRES_PASSWORD ?? "podokit",
|
|
11
|
+
database: process.env.POSTGRES_DB ?? "podokit",
|
|
12
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import { FLAG_DEFAULTS, type FeatureFlag } from "../settings/flag-defaults";
|
|
4
|
+
|
|
5
|
+
/** Auth-relative path prefixes owned by each admin-toggleable feature. A path is
|
|
6
|
+
* gated when it equals a prefix or starts with a prefix ending in "/". */
|
|
7
|
+
const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
|
|
8
|
+
{ flag: "twoFactor", prefixes: ["/two-factor/"] },
|
|
9
|
+
{ flag: "magicLink", prefixes: ["/sign-in/magic-link", "/magic-link/"] },
|
|
10
|
+
{ flag: "emailOtp", prefixes: ["/email-otp/", "/sign-in/email-otp"] },
|
|
11
|
+
{ flag: "username", prefixes: ["/sign-in/username"] },
|
|
12
|
+
{ flag: "multiSession", prefixes: ["/multi-session/"] },
|
|
13
|
+
{ flag: "phoneNumber", prefixes: ["/phone-number/", "/sign-in/phone-number"] },
|
|
14
|
+
{ flag: "apiKey", prefixes: ["/api-key/"] },
|
|
15
|
+
{ flag: "passkey", prefixes: ["/passkey/", "/sign-in/passkey"] },
|
|
16
|
+
{ flag: "organization", prefixes: ["/organization"] },
|
|
17
|
+
{ flag: "oidcProvider", prefixes: ["/oauth2/", "/.well-known/openid-configuration", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource"] },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const CACHE_TTL_MS = 3_000;
|
|
21
|
+
|
|
22
|
+
/** Server-side enforcement for the admin Settings toggles: requests to a disabled
|
|
23
|
+
* feature's endpoints are rejected with 404, so "off" is a real boundary rather
|
|
24
|
+
* than hidden UI. Flags are read from app_setting through a small TTL cache
|
|
25
|
+
* (a toggle takes effect within ~CACHE_TTL_MS); before the migration has run,
|
|
26
|
+
* the shipped defaults apply. */
|
|
27
|
+
export function createFeatureGate(pool: Pool) {
|
|
28
|
+
let cache: Record<FeatureFlag, boolean> = { ...FLAG_DEFAULTS };
|
|
29
|
+
let fetchedAt = 0;
|
|
30
|
+
|
|
31
|
+
async function flags(): Promise<Record<FeatureFlag, boolean>> {
|
|
32
|
+
if (Date.now() - fetchedAt < CACHE_TTL_MS) return cache;
|
|
33
|
+
try {
|
|
34
|
+
const res = await pool.query<{ key: string; value: string }>(
|
|
35
|
+
'SELECT "key", "value" FROM "app_setting" WHERE "key" = ANY($1)',
|
|
36
|
+
[Object.keys(FLAG_DEFAULTS)],
|
|
37
|
+
);
|
|
38
|
+
const next = { ...FLAG_DEFAULTS };
|
|
39
|
+
for (const row of res.rows) next[row.key as FeatureFlag] = row.value === "true";
|
|
40
|
+
cache = next;
|
|
41
|
+
} catch {
|
|
42
|
+
cache = { ...FLAG_DEFAULTS };
|
|
43
|
+
}
|
|
44
|
+
fetchedAt = Date.now();
|
|
45
|
+
return cache;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return createAuthMiddleware(async (ctx) => {
|
|
49
|
+
const feature = FEATURE_PATHS.find((f) =>
|
|
50
|
+
f.prefixes.some((p) => ctx.path === p || ctx.path.startsWith(p)),
|
|
51
|
+
);
|
|
52
|
+
if (!feature) return;
|
|
53
|
+
const enabled = (await flags())[feature.flag];
|
|
54
|
+
if (!enabled) throw new APIError("NOT_FOUND", { message: "This feature is disabled." });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createAccessControl } from "better-auth/plugins/access";
|
|
2
|
+
import { defaultStatements, adminAc, ownerAc, memberAc } from "better-auth/plugins/organization/access";
|
|
3
|
+
|
|
4
|
+
/** Access-control for organization MEMBERS (separate from the app-level roles in
|
|
5
|
+
* permissions.ts). Adds a "manager" role alongside the built-in owner/admin/member
|
|
6
|
+
* — any number of members can hold it, so an org can have several managers. A
|
|
7
|
+
* manager runs the org (members, invitations, settings) but can't delete it. */
|
|
8
|
+
export const orgAc = createAccessControl(defaultStatements);
|
|
9
|
+
|
|
10
|
+
export const orgRoles = {
|
|
11
|
+
owner: ownerAc,
|
|
12
|
+
admin: adminAc,
|
|
13
|
+
manager: orgAc.newRole({
|
|
14
|
+
organization: ["update"],
|
|
15
|
+
member: ["create", "update", "delete"],
|
|
16
|
+
invitation: ["create", "cancel"],
|
|
17
|
+
}),
|
|
18
|
+
member: memberAc,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Assignable org role names, surfaced to the admin UI. */
|
|
22
|
+
export const ORG_ROLE_NAMES = Object.keys(orgRoles);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createAccessControl } from "better-auth/plugins/access";
|
|
2
|
+
import { defaultStatements, adminAc, userAc } from "better-auth/plugins/admin/access";
|
|
3
|
+
|
|
4
|
+
/** Access-control statement: the admin plugin's built-in resources (user, session)
|
|
5
|
+
* plus an example "content" resource. Add your own resources and actions here. */
|
|
6
|
+
export const statement = {
|
|
7
|
+
...defaultStatements,
|
|
8
|
+
content: ["read", "create", "update", "delete"],
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export const ac = createAccessControl(statement);
|
|
12
|
+
|
|
13
|
+
/** Roles and what each may do. `admin` keeps full user/session control and all
|
|
14
|
+
* content actions; `moderator` manages content only; `user` can read content.
|
|
15
|
+
* Assign roles from the Users admin page; check them with the permission guard. */
|
|
16
|
+
export const roles = {
|
|
17
|
+
admin: ac.newRole({ ...adminAc.statements, content: ["read", "create", "update", "delete"] }),
|
|
18
|
+
moderator: ac.newRole({ content: ["read", "create", "update", "delete"] }),
|
|
19
|
+
user: ac.newRole({ ...userAc.statements, content: ["read"] }),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Assignable role names, surfaced to the admin UI via /account/capabilities. */
|
|
23
|
+
export const ROLE_NAMES = Object.keys(roles);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Body, Controller, ForbiddenException, Get, Put } from "@nestjs/common";
|
|
2
|
+
import { Session, type UserSession } from "@thallesp/nestjs-better-auth";
|
|
3
|
+
import { ApiTags } from "@nestjs/swagger";
|
|
4
|
+
import { AuthConfigService, type AuthConfigView, type AuthConfigUpdate } from "./auth-config.service";
|
|
5
|
+
|
|
6
|
+
function isAdmin(session: UserSession): boolean {
|
|
7
|
+
const role = session.user?.role;
|
|
8
|
+
const roles = Array.isArray(role) ? role : (role ?? "").split(",").map((r: string) => r.trim());
|
|
9
|
+
return roles.includes("admin");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Admin-only management of DB-backed auth config (OAuth providers, SMTP,
|
|
13
|
+
// server-enforced toggles). GET returns non-secret fields + `hasSecret`; PUT
|
|
14
|
+
// encrypts and stores secrets, and the change applies live (the auth instance
|
|
15
|
+
// rebuilds on the next request — see auth/auth-provider.ts). Secrets are never
|
|
16
|
+
// returned to the client.
|
|
17
|
+
@ApiTags("account")
|
|
18
|
+
@Controller("account")
|
|
19
|
+
export class AuthConfigController {
|
|
20
|
+
constructor(private readonly service: AuthConfigService) {}
|
|
21
|
+
|
|
22
|
+
@Get("auth-config")
|
|
23
|
+
async get(@Session() session: UserSession): Promise<AuthConfigView> {
|
|
24
|
+
if (!isAdmin(session)) throw new ForbiddenException("Admins only");
|
|
25
|
+
return this.service.describe();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Put("auth-config")
|
|
29
|
+
async put(@Session() session: UserSession, @Body() body: AuthConfigUpdate): Promise<AuthConfigView> {
|
|
30
|
+
if (!isAdmin(session)) throw new ForbiddenException("Admins only");
|
|
31
|
+
return this.service.update(body);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from "typeorm";
|
|
2
|
+
|
|
3
|
+
/** Admin-managed auth configuration, one row per section: one `social.<id>` row
|
|
4
|
+
* per configured OAuth provider (added/removed dynamically — "social.google",
|
|
5
|
+
* "social.github", "social.apple", …), plus "smtp" and "server". Non-secret
|
|
6
|
+
* fields live in `config` (jsonb); secrets (OAuth client secret, SMTP password)
|
|
7
|
+
* are stored encrypted in `secret` (AES-256-GCM envelope — see auth/secret.ts).
|
|
8
|
+
* The runtime reads this table through auth/config-store.ts (raw pool) to
|
|
9
|
+
* (re)build better-auth. */
|
|
10
|
+
@Entity({ name: "auth_config" })
|
|
11
|
+
export class AuthConfigRow {
|
|
12
|
+
@PrimaryColumn({ type: "text" })
|
|
13
|
+
key!: string;
|
|
14
|
+
|
|
15
|
+
@Column({ type: "boolean", default: false })
|
|
16
|
+
enabled!: boolean;
|
|
17
|
+
|
|
18
|
+
@Column({ type: "jsonb", default: {} })
|
|
19
|
+
config!: Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
@Column({ type: "text", nullable: true })
|
|
22
|
+
secret!: string | null;
|
|
23
|
+
|
|
24
|
+
@UpdateDateColumn()
|
|
25
|
+
updatedAt!: Date;
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from "@nestjs/common";
|
|
2
|
+
import { TypeOrmModule } from "@nestjs/typeorm";
|
|
3
|
+
import { AuthConfigRow } from "./auth-config.entity";
|
|
4
|
+
import { AuthConfigService } from "./auth-config.service";
|
|
5
|
+
import { AuthConfigController } from "./auth-config.controller";
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [TypeOrmModule.forFeature([AuthConfigRow])],
|
|
9
|
+
controllers: [AuthConfigController],
|
|
10
|
+
providers: [AuthConfigService],
|
|
11
|
+
})
|
|
12
|
+
export class AuthConfigModule {}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { BadRequestException, Injectable } from "@nestjs/common";
|
|
2
|
+
import { InjectRepository } from "@nestjs/typeorm";
|
|
3
|
+
import { Repository } from "typeorm";
|
|
4
|
+
import { AuthConfigRow } from "./auth-config.entity";
|
|
5
|
+
import { encryptSecret, envAuthConfig, socialKey, SUPPORTED_PROVIDER_IDS, SUPPORTED_SOCIAL_PROVIDERS } from "@podosoft/podokit-auth";
|
|
6
|
+
|
|
7
|
+
/** One configured social provider as shown to the admin (no secret — just a
|
|
8
|
+
* `hasSecret` flag). */
|
|
9
|
+
export type SocialProviderView = { id: string; enabled: boolean; clientId: string; redirectURI: string; hasSecret: boolean };
|
|
10
|
+
|
|
11
|
+
/** Admin-facing view — non-secret fields plus a `hasSecret` flag. Never exposes
|
|
12
|
+
* the stored client secret / SMTP password. Social providers are dynamic: the
|
|
13
|
+
* map holds one entry per configured provider (google, github, apple, …). */
|
|
14
|
+
export type AuthConfigView = {
|
|
15
|
+
social: Record<string, SocialProviderView>;
|
|
16
|
+
/** Catalog of addable providers (id + display label) for the "add provider" picker. */
|
|
17
|
+
catalog: ReadonlyArray<{ id: string; label: string }>;
|
|
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 };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ProviderUpdate = { enabled?: boolean; clientId?: string; clientSecret?: string; redirectURI?: string; delete?: boolean };
|
|
23
|
+
export type AuthConfigUpdate = {
|
|
24
|
+
/** Per-provider upsert (keyed by provider id); `{ delete: true }` removes it. */
|
|
25
|
+
social?: Record<string, ProviderUpdate>;
|
|
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 };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
@Injectable()
|
|
31
|
+
export class AuthConfigService {
|
|
32
|
+
constructor(@InjectRepository(AuthConfigRow) private readonly repo: Repository<AuthConfigRow>) {}
|
|
33
|
+
|
|
34
|
+
async describe(): Promise<AuthConfigView> {
|
|
35
|
+
const rows = await this.repo.find();
|
|
36
|
+
const byKey = new Map(rows.map((r) => [r.key, r]));
|
|
37
|
+
const env = envAuthConfig();
|
|
38
|
+
|
|
39
|
+
const social: Record<string, SocialProviderView> = {};
|
|
40
|
+
for (const row of rows) {
|
|
41
|
+
if (!row.key.startsWith("social.")) continue;
|
|
42
|
+
const id = row.key.slice("social.".length);
|
|
43
|
+
if (!SUPPORTED_PROVIDER_IDS.has(id)) continue;
|
|
44
|
+
const c = (row.config ?? {}) as { clientId?: string; redirectURI?: string };
|
|
45
|
+
social[id] = { id, enabled: row.enabled, clientId: c.clientId ?? "", redirectURI: c.redirectURI ?? "", hasSecret: !!row.secret };
|
|
46
|
+
}
|
|
47
|
+
// Surface env-configured providers (google/github) that have no DB row yet, so
|
|
48
|
+
// the admin sees them as already-active rather than missing.
|
|
49
|
+
for (const [id, p] of Object.entries(env.social)) {
|
|
50
|
+
if (!social[id]) social[id] = { id, enabled: p.enabled, clientId: p.clientId, redirectURI: p.redirectURI ?? "", hasSecret: !!p.clientSecret };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const smtpRow = byKey.get("smtp");
|
|
54
|
+
const smtpC = (smtpRow?.config ?? {}) as { host?: string; port?: number; secure?: boolean; user?: string; from?: string };
|
|
55
|
+
const serverRow = byKey.get("server");
|
|
56
|
+
const serverC = (serverRow?.config ?? {}) as { requireEmailVerification?: boolean; allowDelete?: boolean; hibp?: boolean; auditLog?: boolean };
|
|
57
|
+
return {
|
|
58
|
+
social,
|
|
59
|
+
catalog: SUPPORTED_SOCIAL_PROVIDERS,
|
|
60
|
+
smtp: {
|
|
61
|
+
enabled: smtpRow?.enabled ?? false,
|
|
62
|
+
host: smtpC.host ?? "",
|
|
63
|
+
port: smtpC.port ?? 587,
|
|
64
|
+
secure: smtpC.secure ?? false,
|
|
65
|
+
user: smtpC.user ?? "",
|
|
66
|
+
from: smtpC.from ?? "",
|
|
67
|
+
hasSecret: !!smtpRow?.secret,
|
|
68
|
+
},
|
|
69
|
+
server: {
|
|
70
|
+
requireEmailVerification: serverC.requireEmailVerification ?? env.requireEmailVerification,
|
|
71
|
+
allowDelete: serverC.allowDelete ?? env.allowDelete,
|
|
72
|
+
hibp: serverC.hibp ?? env.hibp,
|
|
73
|
+
auditLog: serverC.auditLog ?? env.auditLog,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async update(dto: AuthConfigUpdate): Promise<AuthConfigView> {
|
|
79
|
+
for (const [id, u] of Object.entries(dto.social ?? {})) {
|
|
80
|
+
if (!SUPPORTED_PROVIDER_IDS.has(id)) throw new BadRequestException(`Unsupported social provider: ${id}`);
|
|
81
|
+
if (u.delete) await this.repo.delete({ key: socialKey(id) });
|
|
82
|
+
else await this.upsertProvider(socialKey(id), u);
|
|
83
|
+
}
|
|
84
|
+
if (dto.smtp) await this.upsertSmtp(dto.smtp);
|
|
85
|
+
if (dto.server) await this.upsertServer(dto.server);
|
|
86
|
+
return this.describe();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async upsertProvider(key: string, u: ProviderUpdate): Promise<void> {
|
|
90
|
+
const row = (await this.repo.findOneBy({ key })) ?? this.repo.create({ key, enabled: false, config: {}, secret: null });
|
|
91
|
+
const config = { ...(row.config as { clientId?: string; redirectURI?: string }) };
|
|
92
|
+
if (u.clientId !== undefined) config.clientId = u.clientId;
|
|
93
|
+
if (u.redirectURI !== undefined) config.redirectURI = u.redirectURI;
|
|
94
|
+
if (u.enabled !== undefined) row.enabled = u.enabled;
|
|
95
|
+
// Replace the secret only when a non-empty value is supplied; otherwise keep it.
|
|
96
|
+
if (u.clientSecret) row.secret = encryptSecret(u.clientSecret);
|
|
97
|
+
row.config = config;
|
|
98
|
+
await this.repo.save(row);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async upsertSmtp(u: NonNullable<AuthConfigUpdate["smtp"]>): Promise<void> {
|
|
102
|
+
const row = (await this.repo.findOneBy({ key: "smtp" })) ?? this.repo.create({ key: "smtp", enabled: false, config: {}, secret: null });
|
|
103
|
+
const config = { ...(row.config as Record<string, unknown>) };
|
|
104
|
+
for (const f of ["host", "port", "secure", "user", "from"] as const) if (u[f] !== undefined) config[f] = u[f];
|
|
105
|
+
if (u.enabled !== undefined) row.enabled = u.enabled;
|
|
106
|
+
if (u.pass) row.secret = encryptSecret(u.pass);
|
|
107
|
+
row.config = config;
|
|
108
|
+
await this.repo.save(row);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async upsertServer(u: NonNullable<AuthConfigUpdate["server"]>): Promise<void> {
|
|
112
|
+
const row = (await this.repo.findOneBy({ key: "server" })) ?? this.repo.create({ key: "server", enabled: true, config: {}, secret: null });
|
|
113
|
+
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];
|
|
115
|
+
row.config = config;
|
|
116
|
+
await this.repo.save(row);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import nodemailer, { type Transporter } from "nodemailer";
|
|
2
|
+
import { pool } from "../auth/db";
|
|
3
|
+
import { createConfigStore, type SmtpConfig } from "@podosoft/podokit-auth";
|
|
4
|
+
|
|
5
|
+
// SMTP is admin-configurable in the DB (auth_config), falling back to SMTP_* env,
|
|
6
|
+
// and applied live: the transport is rebuilt when the config changes (short TTL),
|
|
7
|
+
// so no restart is needed. With neither DB nor env config, fall back to a transport
|
|
8
|
+
// that just logs the message so nothing crashes for lack of mail config.
|
|
9
|
+
const store = createConfigStore(pool);
|
|
10
|
+
const TTL_MS = 3_000;
|
|
11
|
+
|
|
12
|
+
function buildTransport(smtp: SmtpConfig | null): Transporter {
|
|
13
|
+
if (!smtp) return nodemailer.createTransport({ jsonTransport: true });
|
|
14
|
+
return nodemailer.createTransport({
|
|
15
|
+
host: smtp.host,
|
|
16
|
+
port: smtp.port,
|
|
17
|
+
secure: smtp.secure,
|
|
18
|
+
auth: smtp.user ? { user: smtp.user, pass: smtp.pass ?? "" } : undefined,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let transport = buildTransport(null);
|
|
23
|
+
let transportFrom = process.env.MAIL_FROM ?? "PodoKit <no-reply@example.com>";
|
|
24
|
+
let hasSmtp = false;
|
|
25
|
+
let checkedAt = 0;
|
|
26
|
+
let signature = "";
|
|
27
|
+
|
|
28
|
+
async function getTransport(): Promise<Transporter> {
|
|
29
|
+
if (checkedAt !== 0 && Date.now() - checkedAt < TTL_MS) return transport;
|
|
30
|
+
checkedAt = Date.now();
|
|
31
|
+
try {
|
|
32
|
+
const smtp = await store.smtpConfig();
|
|
33
|
+
const next = JSON.stringify(smtp ?? null);
|
|
34
|
+
if (next !== signature) {
|
|
35
|
+
transport = buildTransport(smtp);
|
|
36
|
+
transportFrom = smtp?.from ?? process.env.MAIL_FROM ?? "PodoKit <no-reply@example.com>";
|
|
37
|
+
hasSmtp = !!smtp;
|
|
38
|
+
signature = next;
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
/* keep the last-good transport */
|
|
42
|
+
}
|
|
43
|
+
return transport;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Mail = { to: string; subject: string; text: string; html?: string };
|
|
47
|
+
|
|
48
|
+
export async function sendMail(mail: Mail): Promise<void> {
|
|
49
|
+
const t = await getTransport();
|
|
50
|
+
const info = await t.sendMail({ from: transportFrom, ...mail });
|
|
51
|
+
if (!hasSmtp) {
|
|
52
|
+
// JSON transport: surface the message so the link is grabbable from logs.
|
|
53
|
+
console.log(`[mailer] no SMTP configured; message not delivered:\n${info.message as string}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Minimal HTML wrapper for a titled call-to-action email.
|
|
58
|
+
export function actionEmail(title: string, body: string, url: string, cta: string): string {
|
|
59
|
+
return `<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto">
|
|
60
|
+
<h1 style="font-size:20px">${title}</h1>
|
|
61
|
+
<p>${body}</p>
|
|
62
|
+
<p><a href="${url}" style="display:inline-block;padding:10px 16px;background:#111;color:#fff;border-radius:8px;text-decoration:none">${cta}</a></p>
|
|
63
|
+
<p style="color:#666;font-size:13px">Or paste this link into your browser:<br>${url}</p>
|
|
64
|
+
</div>`;
|
|
65
|
+
}
|