@podosoft/podokit 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/dev.d.ts +26 -0
- package/dist/dev.js +297 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +82 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +7 -1
- package/dist/locale.d.ts +22 -0
- package/dist/locale.js +184 -0
- package/dist/lockfile.js +4 -0
- package/dist/templates/fullstack-nest-svelte/README.md +42 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/README.md +7 -5
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/en.json +6 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/ko.json +6 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +3 -9
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -11
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.docker +2 -2
- package/dist/templates/fullstack-nest-svelte/dot-gitignore +3 -0
- package/dist/templates/fullstack-nest-svelte/dot-podokit/dev.json +4 -0
- package/dist/templates/fullstack-nest-svelte/tests/README.md +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-errors.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-redirect.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +8 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +564 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +564 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +13 -12
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -722
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +4 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +4 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +8 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +15 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +4 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +32 -35
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +10 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +11 -7
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +78 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +28 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +59 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -0
- package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +22 -11
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +6 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +50 -25
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +44 -0
- package/dist/templates/todo/README.md +42 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/README.md +7 -5
- package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/locales/en.json +6 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/locales/ko.json +6 -0
- package/dist/templates/todo/apps/web/vite.config.ts +3 -9
- package/dist/templates/todo/compose.dev.yaml +6 -11
- package/dist/templates/todo/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
- package/dist/templates/todo/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
- package/dist/templates/todo/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
- package/dist/templates/todo/dot-env.docker +2 -2
- package/dist/templates/todo/dot-gitignore +3 -0
- package/dist/templates/todo/dot-podokit/dev.json +4 -0
- package/dist/templates/todo/tests/README.md +10 -4
- package/dist/update.js +23 -5
- package/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/en.ts +0 -10
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/ko.ts +0 -11
- package/dist/templates/todo/apps/web/src/lib/i18n/en.ts +0 -10
- package/dist/templates/todo/apps/web/src/lib/i18n/ko.ts +0 -11
|
@@ -1,13 +1,66 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
|
-
import { anonState } from "../helpers/accounts";
|
|
2
|
+
import { ADMIN, adminState, anonState } from "../helpers/accounts";
|
|
3
3
|
import { ready } from "../helpers/hydration";
|
|
4
4
|
|
|
5
5
|
test.use({ storageState: anonState });
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
const origin = { origin: base };
|
|
9
|
+
|
|
10
|
+
test("language switch updates messages and <html lang>", async ({ page }) => {
|
|
8
11
|
await ready(page, "/login");
|
|
9
|
-
await page.
|
|
10
|
-
await page.getByRole("
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
const initial = await page.locator("html").getAttribute("lang");
|
|
13
|
+
await page.getByRole("button", { name: /^(Language|언어)$/ }).click();
|
|
14
|
+
if (initial === "ko") {
|
|
15
|
+
await page.getByRole("menuitem", { name: /English/ }).click();
|
|
16
|
+
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
|
17
|
+
await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
|
|
18
|
+
} else {
|
|
19
|
+
await page.getByRole("menuitem", { name: /한국어/ }).click();
|
|
20
|
+
await expect(page.locator("html")).toHaveAttribute("lang", "ko");
|
|
21
|
+
await expect(page.getByRole("button", { name: "로그인", exact: true })).toBeVisible();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("explicit English overrides a Korean site fallback", async ({ page, playwright }) => {
|
|
26
|
+
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
27
|
+
await admin.post("/api/auth/sign-in/email", {
|
|
28
|
+
data: { email: ADMIN.email, password: ADMIN.password },
|
|
29
|
+
});
|
|
30
|
+
const previous = await (await admin.get("/api/site/settings")).json() as { locale?: string };
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
expect((await admin.put("/api/site/settings", { data: { locale: "ko" } })).ok()).toBeTruthy();
|
|
34
|
+
await page.context().addCookies([{ name: "locale", value: "en", url: base }]);
|
|
35
|
+
await ready(page, "/login");
|
|
36
|
+
|
|
37
|
+
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
|
38
|
+
await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
|
|
39
|
+
} finally {
|
|
40
|
+
await admin.put("/api/site/settings", { data: { locale: previous.locale ?? "en" } });
|
|
41
|
+
await admin.dispose();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("managed admin labels react to locale changes", async ({ browser }) => {
|
|
46
|
+
const context = await browser.newContext({ storageState: adminState });
|
|
47
|
+
const page = await context.newPage();
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await ready(page, "/admin/users");
|
|
51
|
+
const initial = await page.locator("html").getAttribute("lang");
|
|
52
|
+
if (initial === "ko") {
|
|
53
|
+
await expect(page.getByRole("columnheader", { name: "이메일" })).toBeVisible();
|
|
54
|
+
await page.getByRole("button", { name: "언어" }).click();
|
|
55
|
+
await page.getByRole("menuitem", { name: /English/ }).click();
|
|
56
|
+
await expect(page.getByRole("columnheader", { name: "Email" })).toBeVisible();
|
|
57
|
+
} else {
|
|
58
|
+
await expect(page.getByRole("columnheader", { name: "Email" })).toBeVisible();
|
|
59
|
+
await page.getByRole("button", { name: "Language" }).click();
|
|
60
|
+
await page.getByRole("menuitem", { name: /한국어/ }).click();
|
|
61
|
+
await expect(page.getByRole("columnheader", { name: "이메일" })).toBeVisible();
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
await context.close();
|
|
65
|
+
}
|
|
13
66
|
});
|
|
@@ -37,5 +37,5 @@ test("login: an emailed one-time code signs a user in @smoke", async ({ page, pl
|
|
|
37
37
|
const otp = await waitForOtp(email);
|
|
38
38
|
await page.getByLabel("One-time code").fill(otp);
|
|
39
39
|
await page.getByRole("button", { name: "Verify code" }).click();
|
|
40
|
-
await expect(page).toHaveURL(
|
|
40
|
+
await expect(page).toHaveURL(new URL("/", base).toString());
|
|
41
41
|
});
|
|
@@ -32,5 +32,5 @@ test("register a passkey and sign in with it @smoke", async ({ page, baseURL })
|
|
|
32
32
|
await page.context().request.post("/api/auth/sign-out", { headers: origin });
|
|
33
33
|
await ready(page, "/login");
|
|
34
34
|
await page.getByRole("button", { name: "Use a passkey" }).click();
|
|
35
|
-
await expect(page).toHaveURL(
|
|
35
|
+
await expect(page).toHaveURL(new URL("/", baseURL).toString());
|
|
36
36
|
});
|
|
@@ -7,6 +7,7 @@ test("settings: add and remove a social login provider @smoke", async ({ page })
|
|
|
7
7
|
const dialog = page.getByRole("dialog");
|
|
8
8
|
const addBtn = dialog.getByRole("button", { name: "Add provider" });
|
|
9
9
|
const openConfigure = async (): Promise<void> => {
|
|
10
|
+
await page.waitForLoadState("networkidle");
|
|
10
11
|
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
11
12
|
await page.getByRole("button", { name: "Configure" }).click();
|
|
12
13
|
};
|
|
@@ -27,12 +28,16 @@ test("settings: add and remove a social login provider @smoke", async ({ page })
|
|
|
27
28
|
const callback = await dialog.locator("code").textContent();
|
|
28
29
|
const providerId = callback?.trim().split("/").pop() ?? ""; // first addable provider (not hard-coded)
|
|
29
30
|
expect(providerId).not.toBe("");
|
|
31
|
+
const expectedCallback = new URL(`/api/auth/callback/${providerId}`, page.url()).toString();
|
|
32
|
+
expect(callback?.trim()).toBe(expectedCallback);
|
|
30
33
|
try {
|
|
31
34
|
await dialog.locator("#social-id").fill("test-client-id");
|
|
32
35
|
await dialog.locator("#social-secret").fill("test-secret");
|
|
33
36
|
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
|
34
37
|
// Save returns to the list view, where the new provider row has an enable switch.
|
|
35
38
|
await expect(dialog.getByRole("switch").first()).toBeVisible();
|
|
39
|
+
const saved = await (await page.request.get("/api/account/auth-config")).json();
|
|
40
|
+
expect(saved.social[providerId].redirectURI).toBe(expectedCallback);
|
|
36
41
|
} finally {
|
|
37
42
|
if (providerId) {
|
|
38
43
|
await page.request
|
|
@@ -73,8 +73,8 @@ test("require-2fa: a new sign-up is forced through the enrolment page @smoke", a
|
|
|
73
73
|
}).toPass({ timeout: 8000 });
|
|
74
74
|
await probe.dispose();
|
|
75
75
|
|
|
76
|
-
// Signing in
|
|
77
|
-
await openHydratedLogin(page);
|
|
76
|
+
// Signing in toward a protected route lands on the mandatory enrolment page.
|
|
77
|
+
await openHydratedLogin(page, "/admin");
|
|
78
78
|
await page.getByLabel("Email").fill(email);
|
|
79
79
|
await page.getByLabel("Password").fill(pw);
|
|
80
80
|
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
@@ -16,6 +16,7 @@ function usage() {
|
|
|
16
16
|
|
|
17
17
|
Usage:
|
|
18
18
|
npm run auth:configure -- --provider google --require-signup-approval
|
|
19
|
+
npm run auth:configure -- --provider google --redirect-only
|
|
19
20
|
npm run auth:configure -- --smtp
|
|
20
21
|
npm run auth:configure -- --check-only --provider google
|
|
21
22
|
|
|
@@ -34,6 +35,7 @@ SMTP environment (with --smtp):
|
|
|
34
35
|
|
|
35
36
|
Options:
|
|
36
37
|
--provider <id> Configure a social provider
|
|
38
|
+
--redirect-only Update only the provider callback; keep credentials and enabled state
|
|
37
39
|
--smtp Configure SMTP from environment variables
|
|
38
40
|
--require-signup-approval Require admin approval for future registrations
|
|
39
41
|
--no-require-signup-approval Auto-approve future registrations
|
|
@@ -114,6 +116,7 @@ async function main() {
|
|
|
114
116
|
const rawOrigin = process.env.AUTH_SETUP_ORIGIN ?? process.env.BETTER_AUTH_URL ?? "http://localhost:5002";
|
|
115
117
|
const origin = new URL(rawOrigin).origin;
|
|
116
118
|
const provider = value("--provider");
|
|
119
|
+
const redirectOnly = has("--redirect-only");
|
|
117
120
|
const configureSmtp = has("--smtp");
|
|
118
121
|
const approval = has("--require-signup-approval")
|
|
119
122
|
? true
|
|
@@ -122,8 +125,12 @@ async function main() {
|
|
|
122
125
|
: undefined;
|
|
123
126
|
|
|
124
127
|
if (provider && !/^[a-z0-9-]+$/.test(provider)) throw new Error("Provider id must use lowercase letters, numbers, or hyphens.");
|
|
128
|
+
if (redirectOnly && !provider) throw new Error("--redirect-only requires --provider <id>.");
|
|
125
129
|
|
|
126
|
-
|
|
130
|
+
const redirectURI = provider
|
|
131
|
+
? process.env.OAUTH_REDIRECT_URI ?? `${origin}/api/auth/callback/${provider}`
|
|
132
|
+
: undefined;
|
|
133
|
+
if (redirectURI) console.log(`OAuth callback URL: ${redirectURI}`);
|
|
127
134
|
if (has("--check-only")) {
|
|
128
135
|
const capabilities = await publicCapabilities(origin);
|
|
129
136
|
console.log(JSON.stringify(capabilities, null, 2));
|
|
@@ -135,16 +142,20 @@ async function main() {
|
|
|
135
142
|
|
|
136
143
|
const body = {};
|
|
137
144
|
if (provider) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
[provider]
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
if (redirectOnly) {
|
|
146
|
+
body.social = { [provider]: { redirectURI } };
|
|
147
|
+
} else {
|
|
148
|
+
const clientId = process.env.OAUTH_CLIENT_ID ?? process.env[providerEnvName(provider, "CLIENT_ID")];
|
|
149
|
+
const clientSecret = process.env.OAUTH_CLIENT_SECRET ?? process.env[providerEnvName(provider, "CLIENT_SECRET")];
|
|
150
|
+
body.social = {
|
|
151
|
+
[provider]: {
|
|
152
|
+
enabled: true,
|
|
153
|
+
clientId: required("OAUTH_CLIENT_ID", clientId),
|
|
154
|
+
clientSecret: required("OAUTH_CLIENT_SECRET", clientSecret),
|
|
155
|
+
redirectURI,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
148
159
|
}
|
|
149
160
|
if (configureSmtp) {
|
|
150
161
|
body.smtp = {
|
|
@@ -3,7 +3,7 @@ import { APIError } from "better-auth/api";
|
|
|
3
3
|
import { twoFactor, haveIBeenPwned, magicLink, emailOTP, username, multiSession, phoneNumber, organization, jwt, bearer } from "better-auth/plugins";
|
|
4
4
|
import { actionEmail, sendMail } from "../mail/mailer";
|
|
5
5
|
import { sendSms } from "../sms/sms";
|
|
6
|
-
import { createFeatureGate } from "./feature-gate";
|
|
6
|
+
import { assertUserCreationAllowed, createFeatureGate, createSignupOpenCheck } from "./feature-gate";
|
|
7
7
|
import { orgAc, orgRoles } from "./org-permissions";
|
|
8
8
|
import { pool } from "./db";
|
|
9
9
|
import { type AuthConfig, envAuthConfig, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
|
|
@@ -44,6 +44,7 @@ function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialP
|
|
|
44
44
|
// server-enforced toggles) apply without a restart. Everything below the injection
|
|
45
45
|
// markers is contributed by other PodoKit modules (admin-dashboard, audit-log).
|
|
46
46
|
export function buildAuth(config: AuthConfig) {
|
|
47
|
+
const isSignupOpen = createSignupOpenCheck(pool);
|
|
47
48
|
const plugins: BetterAuthPlugin[] = [
|
|
48
49
|
twoFactor(),
|
|
49
50
|
magicLink({
|
|
@@ -130,6 +131,7 @@ export function buildAuth(config: AuthConfig) {
|
|
|
130
131
|
before: async (user, context) => {
|
|
131
132
|
const adminEmail = adminEmails.has(user.email.toLowerCase());
|
|
132
133
|
const createdByAdmin = context?.path === "/admin/create-user";
|
|
134
|
+
assertUserCreationAllowed(await isSignupOpen(), context?.path);
|
|
133
135
|
return {
|
|
134
136
|
data: {
|
|
135
137
|
...user,
|
|
@@ -168,7 +170,9 @@ export function buildAuth(config: AuthConfig) {
|
|
|
168
170
|
// Request-time hooks. The feature gate turns the admin Settings toggles into a
|
|
169
171
|
// real server boundary (disabled features 404). Other PodoKit modules add their
|
|
170
172
|
// own hooks below the marker.
|
|
171
|
-
const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
|
|
173
|
+
const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
|
|
174
|
+
before: createFeatureGate(pool, isSignupOpen),
|
|
175
|
+
};
|
|
172
176
|
// podokit:begin:auth-hooks
|
|
173
177
|
// podokit:end:auth-hooks
|
|
174
178
|
|
|
@@ -2,6 +2,8 @@ import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
|
2
2
|
import type { Pool } from "pg";
|
|
3
3
|
import { FLAG_DEFAULTS, type FeatureFlag } from "../settings/flag-defaults";
|
|
4
4
|
|
|
5
|
+
export const PUBLIC_SIGNUP_DISABLED = "PUBLIC_SIGNUP_DISABLED" as const;
|
|
6
|
+
|
|
5
7
|
/** Auth-relative path prefixes owned by each admin-toggleable feature. A path is
|
|
6
8
|
* gated when it equals a prefix or starts with a prefix ending in "/". */
|
|
7
9
|
const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
|
|
@@ -19,12 +21,55 @@ const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
|
|
|
19
21
|
|
|
20
22
|
const CACHE_TTL_MS = 3_000;
|
|
21
23
|
|
|
24
|
+
export type SignupOpenCheck = () => Promise<boolean>;
|
|
25
|
+
|
|
26
|
+
/** Public registration is the default until an administrator explicitly closes
|
|
27
|
+
* it. The short cache keeps every auth flow on the same live site-setting
|
|
28
|
+
* policy without rebuilding the Better Auth instance. */
|
|
29
|
+
export function createSignupOpenCheck(pool: Pool): SignupOpenCheck {
|
|
30
|
+
let signupOpen = true;
|
|
31
|
+
let fetchedAt = 0;
|
|
32
|
+
|
|
33
|
+
return async (): Promise<boolean> => {
|
|
34
|
+
if (Date.now() - fetchedAt < CACHE_TTL_MS) return signupOpen;
|
|
35
|
+
try {
|
|
36
|
+
const res = await pool.query<{ value: string }>(
|
|
37
|
+
'SELECT "value" FROM "app_setting" WHERE "key" = $1',
|
|
38
|
+
["site.allowSignup"],
|
|
39
|
+
);
|
|
40
|
+
signupOpen = res.rows[0]?.value !== "false";
|
|
41
|
+
} catch {
|
|
42
|
+
signupOpen = true;
|
|
43
|
+
}
|
|
44
|
+
fetchedAt = Date.now();
|
|
45
|
+
return signupOpen;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Administrator-created users are intentional and remain available when the
|
|
50
|
+
* public site is invite-only. Every other new-user path, including OAuth
|
|
51
|
+
* callbacks, must follow the public registration setting. */
|
|
52
|
+
export function isUserCreationAllowed(signupOpen: boolean, requestPath?: string): boolean {
|
|
53
|
+
return signupOpen || requestPath === "/admin/create-user";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function assertUserCreationAllowed(signupOpen: boolean, requestPath?: string): void {
|
|
57
|
+
if (isUserCreationAllowed(signupOpen, requestPath)) return;
|
|
58
|
+
throw APIError.from("FORBIDDEN", {
|
|
59
|
+
code: PUBLIC_SIGNUP_DISABLED,
|
|
60
|
+
message: "Public sign-up is disabled.",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
22
64
|
/** Server-side enforcement for the admin Settings toggles: requests to a disabled
|
|
23
65
|
* feature's endpoints are rejected with 404, so "off" is a real boundary rather
|
|
24
66
|
* than hidden UI. Flags are read from app_setting through a small TTL cache
|
|
25
67
|
* (a toggle takes effect within ~CACHE_TTL_MS); before the migration has run,
|
|
26
68
|
* the shipped defaults apply. */
|
|
27
|
-
export function createFeatureGate(
|
|
69
|
+
export function createFeatureGate(
|
|
70
|
+
pool: Pool,
|
|
71
|
+
isSignupOpen: SignupOpenCheck = createSignupOpenCheck(pool),
|
|
72
|
+
) {
|
|
28
73
|
let cache: Record<FeatureFlag, boolean> = { ...FLAG_DEFAULTS };
|
|
29
74
|
let fetchedAt = 0;
|
|
30
75
|
|
|
@@ -45,32 +90,12 @@ export function createFeatureGate(pool: Pool) {
|
|
|
45
90
|
return cache;
|
|
46
91
|
}
|
|
47
92
|
|
|
48
|
-
// Registration can be closed from the admin Settings ("Allow sign-up"). It is a
|
|
49
|
-
// site setting (`site.allowSignup`), so the row is absent until an admin turns
|
|
50
|
-
// it off — a missing/any-non-"false" value means open, matching the shipped
|
|
51
|
-
// default. Cached the same way as the feature flags.
|
|
52
|
-
let signupOpen = true;
|
|
53
|
-
let signupFetchedAt = 0;
|
|
54
|
-
async function isSignupOpen(): Promise<boolean> {
|
|
55
|
-
if (Date.now() - signupFetchedAt < CACHE_TTL_MS) return signupOpen;
|
|
56
|
-
try {
|
|
57
|
-
const res = await pool.query<{ value: string }>(
|
|
58
|
-
'SELECT "value" FROM "app_setting" WHERE "key" = $1',
|
|
59
|
-
["site.allowSignup"],
|
|
60
|
-
);
|
|
61
|
-
signupOpen = res.rows[0]?.value !== "false";
|
|
62
|
-
} catch {
|
|
63
|
-
signupOpen = true;
|
|
64
|
-
}
|
|
65
|
-
signupFetchedAt = Date.now();
|
|
66
|
-
return signupOpen;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
93
|
return createAuthMiddleware(async (ctx) => {
|
|
70
|
-
//
|
|
71
|
-
//
|
|
94
|
+
// Reject explicit sign-up endpoints early. OAuth starts remain available so
|
|
95
|
+
// existing social users can sign in; their new-user callback is enforced by
|
|
96
|
+
// the database hook that uses this same policy.
|
|
72
97
|
if (ctx.path === "/sign-up/email" || ctx.path.startsWith("/sign-up/")) {
|
|
73
|
-
|
|
98
|
+
assertUserCreationAllowed(await isSignupOpen(), ctx.path);
|
|
74
99
|
return;
|
|
75
100
|
}
|
|
76
101
|
const feature = FEATURE_PATHS.find((f) =>
|
|
@@ -26,7 +26,7 @@ Use the generated `auth:configure` command so credentials enter the encrypted `a
|
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
4. Create or verify the initial administrator with `admin:bootstrap` before enabling mandatory email verification or other runtime authentication settings.
|
|
29
|
-
5. Derive the default callback as `<BETTER_AUTH_URL>/api/auth/callback/<provider>`. Only set `OAUTH_REDIRECT_URI` when the provider requires an explicit override.
|
|
29
|
+
5. Derive the default callback as `<BETTER_AUTH_URL>/api/auth/callback/<provider>`. Only set `OAUTH_REDIRECT_URI` when the provider requires an explicit override. If credentials are already stored, use `--provider <id> --redirect-only` to update the callback without reading or replacing the client secret.
|
|
30
30
|
|
|
31
31
|
## Handle secrets safely
|
|
32
32
|
|
|
@@ -12,8 +12,52 @@ Use a Google Cloud project owned by the organization, not a personal project.
|
|
|
12
12
|
|
|
13
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
14
|
|
|
15
|
+
## Development
|
|
16
|
+
|
|
17
|
+
Keep normal development on the portless `*.localhost` origin provided by `podo dev`.
|
|
18
|
+
For an actual OAuth round trip, expose the web container through a stable HTTPS
|
|
19
|
+
development hostname using a Cloudflare Named Tunnel, a reserved ngrok domain, or a
|
|
20
|
+
preview deployment. Use a separate Google Cloud project and Web client for development.
|
|
21
|
+
|
|
22
|
+
1. Add `https://app-dev.example.com` as an authorized JavaScript origin.
|
|
23
|
+
2. Add `https://app-dev.example.com/api/auth/callback/google` as the exact redirect URI.
|
|
24
|
+
3. Include both `http://app.localhost` and `https://app-dev.example.com` in
|
|
25
|
+
`CORS_ORIGIN`. Use the HTTPS origin as `BETTER_AUTH_URL` for the tunnel profile.
|
|
26
|
+
4. Open Admin Settings through the HTTPS origin before saving Google. The displayed
|
|
27
|
+
callback is persisted. With the CLI, `AUTH_SETUP_ORIGIN` derives and persists the
|
|
28
|
+
same callback; set `OAUTH_REDIRECT_URI` only for an explicit override. Configure
|
|
29
|
+
without putting the secret on the command line:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
export AUTH_SETUP_ORIGIN="https://app-dev.example.com"
|
|
33
|
+
export AUTH_SETUP_ADMIN_EMAIL="admin@example.com"
|
|
34
|
+
export AUTH_SETUP_ADMIN_PASSWORD="<from-secret-manager>"
|
|
35
|
+
export OAUTH_CLIENT_ID="<google-client-id>"
|
|
36
|
+
export OAUTH_CLIENT_SECRET="<from-secret-manager>"
|
|
37
|
+
npm run auth:configure -w <app>-api -- --provider google --dry-run
|
|
38
|
+
npm run auth:configure -w <app>-api -- --provider google
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If only an existing callback is stale, keep the stored client ID, client
|
|
42
|
+
secret, and enabled state:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
AUTH_SETUP_ORIGIN=https://app-dev.example.com \
|
|
46
|
+
npm run auth:configure -w <app>-api -- --provider google --redirect-only
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
5. Start and finish Google sign-in through the HTTPS origin. Do not mix it with
|
|
50
|
+
`app.localhost`; OAuth state cookies are scoped to the origin that created them.
|
|
51
|
+
|
|
52
|
+
Protect development content with the tunnel provider's access control, but bypass
|
|
53
|
+
the exact `/api/auth/callback/*` path so Google can reach it. Quick tunnels with a
|
|
54
|
+
changing hostname are unsuitable for a registered callback. See the PodoKit
|
|
55
|
+
`docs/oauth-development.md` guide for the provider-neutral boundary.
|
|
56
|
+
|
|
15
57
|
Official references:
|
|
16
58
|
|
|
17
59
|
- https://developers.google.com/identity/protocols/oauth2/web-server
|
|
18
60
|
- https://developers.google.com/identity/protocols/oauth2/policies
|
|
61
|
+
- https://support.google.com/cloud/answer/15549257
|
|
19
62
|
- https://better-auth.com/docs/authentication/google
|
|
63
|
+
- https://better-auth.com/docs/plugins/oauth-proxy
|
|
@@ -8,10 +8,49 @@ Full-stack TypeScript app generated with [PodoKit](https://github.com/podosoft-d
|
|
|
8
8
|
|
|
9
9
|
## Getting started
|
|
10
10
|
|
|
11
|
+
### Recommended: containerized development
|
|
12
|
+
|
|
11
13
|
```bash
|
|
12
14
|
{{packageManager}} install
|
|
13
15
|
cp .env.example .env
|
|
16
|
+
npx @podosoft/podokit dev watch
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Open the URL printed by the command; a new project defaults to
|
|
20
|
+
**http://{{projectName}}.localhost**. The committed `.podokit/dev.json` is the
|
|
21
|
+
source of truth if you change the hostname. The first running PodoKit project starts
|
|
22
|
+
one user-level Traefik gateway on `127.0.0.1:80`; additional projects reuse it and
|
|
23
|
+
route by that hostname. Even a single app uses
|
|
24
|
+
the same topology, with one route and no project-specific host port.
|
|
25
|
+
|
|
26
|
+
In a second terminal, apply the included Todo migration and use the lifecycle helpers:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api
|
|
30
|
+
npx @podosoft/podokit dev url
|
|
31
|
+
npx @podosoft/podokit dev ps
|
|
32
|
+
npx @podosoft/podokit dev logs
|
|
33
|
+
npx @podosoft/podokit dev down
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If installed modules require extra services, use their profiles on the initial
|
|
37
|
+
watch command instead:
|
|
14
38
|
|
|
39
|
+
```bash
|
|
40
|
+
npx @podosoft/podokit dev watch \
|
|
41
|
+
--profile cache --profile storage --profile queue
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pass the same profile flags to other lifecycle commands when applicable. `dev down`
|
|
45
|
+
removes this project's stack and route. If it is the final registered
|
|
46
|
+
project, it also removes the shared gateway and network. Source changes are synced
|
|
47
|
+
through Compose Watch, including Vite HMR on the same portless browser origin.
|
|
48
|
+
|
|
49
|
+
### Alternative: host processes
|
|
50
|
+
|
|
51
|
+
Use this loop when you want only the web and API processes on the host:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
15
54
|
# start local PostgreSQL + Redis
|
|
16
55
|
docker compose -f infra/docker/docker-compose.yml up -d
|
|
17
56
|
|
|
@@ -25,6 +64,9 @@ docker compose -f infra/docker/docker-compose.yml up -d
|
|
|
25
64
|
- API: http://localhost:5002 — health at `/health`, docs at `/api-docs`
|
|
26
65
|
- Web: http://localhost:5001
|
|
27
66
|
|
|
67
|
+
For multi-project routing, container profiles, and OAuth over a stable HTTPS
|
|
68
|
+
tunnel, see the PodoKit [development guide](https://github.com/podosoft-dev/podokit/blob/main/docs/development.md).
|
|
69
|
+
|
|
28
70
|
## Database & migrations
|
|
29
71
|
|
|
30
72
|
The API uses TypeORM with PostgreSQL. A sample `Todo` entity and an initial migration are included — replace them with your own domain model.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Localization
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Locale definitions live in `locales/<code>.json`. Product copy and intentional
|
|
4
|
+
module overrides live in the app-owned `catalogs/app/<code>.json` files.
|
|
4
5
|
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
The admin-dashboard module installs the composed JSON message runtime and its
|
|
7
|
+
complete English and Korean catalogs. Use `podo locale add`, `validate`, and
|
|
8
|
+
`activate` to extend it. See the generated `$podokit-add-locale` skill for the
|
|
9
|
+
complete workflow.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -2,15 +2,10 @@ import { sveltekit } from "@sveltejs/kit/vite";
|
|
|
2
2
|
import tailwindcss from "@tailwindcss/vite";
|
|
3
3
|
import { defineConfig } from "vite";
|
|
4
4
|
|
|
5
|
-
// Inside the dev container
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// HMR client must target that same port — otherwise the ws connects to :80,
|
|
9
|
-
// fails, and Vite silently falls back to a full page reload on every change
|
|
10
|
-
// (which wipes in-progress form input). compose.dev.yaml injects
|
|
11
|
-
// VITE_HMR_CLIENT_PORT to match the published Traefik port (default 80).
|
|
5
|
+
// Inside the dev container, bind to all interfaces and accept local gateway or
|
|
6
|
+
// HTTPS tunnel hostnames. Vite derives HMR's protocol, host, and port from the
|
|
7
|
+
// browser origin so the same configuration works for both entry points.
|
|
12
8
|
const inDocker = process.env.VITE_DOCKER === "1";
|
|
13
|
-
const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT) || 80;
|
|
14
9
|
|
|
15
10
|
export default defineConfig({
|
|
16
11
|
plugins: [tailwindcss(), sveltekit()],
|
|
@@ -19,7 +14,6 @@ export default defineConfig({
|
|
|
19
14
|
host: true,
|
|
20
15
|
port: 5001,
|
|
21
16
|
allowedHosts: true,
|
|
22
|
-
hmr: { clientPort: hmrClientPort },
|
|
23
17
|
}
|
|
24
18
|
: undefined,
|
|
25
19
|
});
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# Containerized development environment.
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# --profile
|
|
3
|
+
# podo dev watch # core stack
|
|
4
|
+
# podo dev watch --profile cache --profile storage \
|
|
5
|
+
# --profile queue # with modules
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# containerized stacks can run at once without colliding — the web container reads
|
|
10
|
-
# the same value as VITE_HMR_CLIENT_PORT so Vite HMR follows automatically.
|
|
7
|
+
# A user-level PodoKit gateway owns loopback :80 and routes each *.localhost host
|
|
8
|
+
# to this stack. The legacy per-project Traefik service is opt-in only.
|
|
11
9
|
# api/postgres/redis/minio have NO published ports — they talk by service name
|
|
12
10
|
# on the internal network, so this stack never collides with other projects.
|
|
13
11
|
#
|
|
@@ -24,6 +22,7 @@ name: {{projectName}}-dev
|
|
|
24
22
|
|
|
25
23
|
services:
|
|
26
24
|
traefik:
|
|
25
|
+
profiles: [podokit-legacy-proxy]
|
|
27
26
|
image: traefik:v3.5
|
|
28
27
|
command:
|
|
29
28
|
- --providers.file.filename=/etc/traefik/dynamic.yml
|
|
@@ -52,10 +51,6 @@ services:
|
|
|
52
51
|
# getClientAddress() tied to the visitor instead of the proxy container.
|
|
53
52
|
- ADDRESS_HEADER=x-forwarded-for
|
|
54
53
|
- XFF_DEPTH=1
|
|
55
|
-
# Match Traefik's published port so HMR's WebSocket targets the right port
|
|
56
|
-
# (default 80). Without this, HMR connects to :80 and a non-80 stack falls
|
|
57
|
-
# back to a full page reload on every edit.
|
|
58
|
-
- VITE_HMR_CLIENT_PORT=${TRAEFIK_PORT:-80}
|
|
59
54
|
depends_on: [api]
|
|
60
55
|
networks: [podokit]
|
|
61
56
|
develop:
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-add-locale
|
|
3
|
+
description: Add, translate, validate, activate, or troubleshoot a JSON locale in a PodoKit-generated SvelteKit app. Use when a user asks to add a language, split or edit locale JSON, check translation coverage or placeholders, configure fallback behavior, or verify locale changes through Vite HMR.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PodoKit Add Locale
|
|
7
|
+
|
|
8
|
+
Add languages through the PodoKit locale commands and the app-owned JSON catalog. Keep module catalogs managed so `podo update` can continue to update them.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Inspect the existing locale state.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
podo locale list
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
2. Add a canonical BCP 47 language tag. Set `--direction rtl` only for right-to-left languages.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
podo locale add pt-BR --name "Português (Brasil)"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
3. Read [references/catalog-format.md](references/catalog-format.md), then translate the new app-owned file at `apps/web/src/lib/i18n/catalogs/app/<locale>.json`.
|
|
25
|
+
|
|
26
|
+
4. Validate key coverage, JSON value types, and interpolation placeholders.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
podo locale validate pt-BR
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
5. Activate the locale. Partial catalogs are permitted because runtime fallback fills missing keys, but report the coverage percentage to the user.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
podo locale activate pt-BR
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
6. Run the app with its documented container-based development command. Verify the language switch, one complete key, one fallback key, and locale persistence. Editing an existing JSON file must update through Vite HMR without restarting the container. Adding a new locale file may trigger an automatic full-page reload.
|
|
39
|
+
|
|
40
|
+
## Rules
|
|
41
|
+
|
|
42
|
+
- Treat `en` as the stable final fallback and never deactivate it.
|
|
43
|
+
- Preserve placeholders exactly. For example, translate `Hello {name}` without renaming or removing `{name}`.
|
|
44
|
+
- Put product-specific copy only in `catalogs/app/`. Do not edit `catalogs/admin-dashboard/` or other module catalogs directly.
|
|
45
|
+
- Keep locale definition filenames identical to their canonical codes, including case such as `pt-BR.json`.
|
|
46
|
+
- Do not activate a locale definition before its JSON parses and `podo locale validate` succeeds.
|
|
47
|
+
- Do not use the UI message catalogs for user-authored records. Dynamic content needs locale-aware records and a separate publication/fallback policy.
|
|
48
|
+
- When deactivating a locale, preserve its files so it can be reactivated without losing translations.
|
|
49
|
+
|
|
50
|
+
## Fallback And Site Default
|
|
51
|
+
|
|
52
|
+
The message runtime resolves missing keys in this order: selected locale, configured site default, then English. The site default is an administrator choice, not a build-time constant. Changing it must not delete or rewrite catalogs.
|
|
53
|
+
|
|
54
|
+
If a requested language is unsupported or inactive, resolve it to the configured site default. Keep localized URL and SEO behavior in the consuming app; the catalogs provide translated strings but do not define routing policy.
|
|
55
|
+
|
|
56
|
+
## Completion Checklist
|
|
57
|
+
|
|
58
|
+
- `podo locale validate` succeeds for every active locale.
|
|
59
|
+
- The new locale definition is active and has the intended display name and direction.
|
|
60
|
+
- App-owned JSON contains the new product copy; managed module catalogs remain untouched.
|
|
61
|
+
- Development edits update without a container restart.
|
|
62
|
+
- Automated tests cover selection, fallback, placeholders, and at least one incomplete catalog.
|
|
63
|
+
- User-facing locale setup or routing documentation is updated when behavior changes.
|