@podosoft/podokit 0.11.1 → 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/add.d.ts +16 -0
- package/dist/add.js +63 -20
- package/dist/assemble.js +9 -9
- 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.d.ts +5 -3
- package/dist/lockfile.js +21 -5
- package/dist/remove.js +26 -23
- package/dist/templates/fullstack-nest-svelte/README.md +42 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/scripts/.keep +1 -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 +8 -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/scripts/bootstrap-admin.mjs +187 -0
- 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 +14 -3
- 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 +15 -2
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/agents/openai.yaml +2 -2
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +51 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +44 -0
- package/dist/templates/modules/auth/module.manifest.json +3 -0
- package/dist/templates/todo/README.md +42 -0
- package/dist/templates/todo/apps/api/Dockerfile +1 -0
- package/dist/templates/todo/apps/api/scripts/.keep +1 -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 +8 -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 +48 -7
- 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
|
@@ -40,14 +40,21 @@ const empty: SiteSettings = {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
let current = $state<SiteSettings>({ ...empty });
|
|
43
|
+
let initialized = $state(false);
|
|
43
44
|
|
|
44
45
|
export const site = {
|
|
45
46
|
get value(): SiteSettings {
|
|
46
47
|
return current;
|
|
47
48
|
},
|
|
49
|
+
get initialized(): boolean {
|
|
50
|
+
return initialized;
|
|
51
|
+
},
|
|
48
52
|
/** Seed from the server load (idempotent). */
|
|
49
53
|
init(v: Partial<SiteSettings> | null | undefined): void {
|
|
50
|
-
if (v)
|
|
54
|
+
if (v) {
|
|
55
|
+
current = { ...empty, ...v };
|
|
56
|
+
initialized = true;
|
|
57
|
+
}
|
|
51
58
|
},
|
|
52
59
|
/** Merge in changes so the layout re-applies title/favicon immediately. */
|
|
53
60
|
patch(v: Partial<SiteSettings>): void {
|
|
@@ -28,13 +28,13 @@
|
|
|
28
28
|
let searchField = $state("actor");
|
|
29
29
|
let appliedSearchField = $state("actor");
|
|
30
30
|
|
|
31
|
-
const columns
|
|
31
|
+
const columns = $derived<DataTableColumn<Entry>[]>([
|
|
32
32
|
{ key: "createdAt", label: i18n.t.audit.when, sortable: true, class: "whitespace-nowrap" },
|
|
33
33
|
{ key: "actor", label: i18n.t.audit.actor, sortable: true, value: (e) => e.actorName ?? e.actorEmail },
|
|
34
34
|
{ key: "action", label: i18n.t.audit.action, sortable: true },
|
|
35
35
|
{ key: "target", label: i18n.t.audit.target, sortable: true, value: (e) => e.targetLabel },
|
|
36
36
|
{ key: "ip", label: i18n.t.audit.ip, sortable: true },
|
|
37
|
-
];
|
|
37
|
+
]);
|
|
38
38
|
|
|
39
39
|
const actions = $derived([...new Set(entries.map((e) => e.action))].sort());
|
|
40
40
|
const filters = $derived<ToolbarFilter[]>([
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
},
|
|
46
46
|
]);
|
|
47
47
|
|
|
48
|
-
const searchFields
|
|
48
|
+
const searchFields = $derived<ToolbarSearchField[]>([
|
|
49
49
|
{ value: "actor", label: i18n.t.audit.actor },
|
|
50
50
|
{ value: "target", label: i18n.t.audit.target },
|
|
51
|
-
];
|
|
51
|
+
]);
|
|
52
52
|
|
|
53
53
|
const filtered = $derived(
|
|
54
54
|
entries.filter((e) => {
|
|
@@ -17,13 +17,13 @@
|
|
|
17
17
|
let orgs = $state<Org[]>([]);
|
|
18
18
|
let sort = $state<SortState | null>({ key: "createdAt", dir: "desc" });
|
|
19
19
|
let listPage = $state(1);
|
|
20
|
-
const columns
|
|
20
|
+
const columns = $derived<DataTableColumn<Org>[]>([
|
|
21
21
|
{ key: "name", label: i18n.t.organizations.name, sortable: true },
|
|
22
22
|
{ key: "slug", label: i18n.t.organizations.slug, sortable: true },
|
|
23
23
|
{ key: "parent", label: i18n.t.organizations.parent, sortable: true, value: (o) => orgName(o.parentOrganizationId) },
|
|
24
24
|
{ key: "createdAt", label: i18n.t.organizations.created, sortable: true },
|
|
25
25
|
{ key: "actions", label: "", class: "w-10" },
|
|
26
|
-
];
|
|
26
|
+
]);
|
|
27
27
|
let busy = $state(false);
|
|
28
28
|
let createOpen = $state(false);
|
|
29
29
|
let form = $state({ name: "", slug: "", parentOrganizationId: "" });
|
|
@@ -41,19 +41,19 @@
|
|
|
41
41
|
let searchField = $state("email");
|
|
42
42
|
let appliedSearchField = $state("email");
|
|
43
43
|
|
|
44
|
-
const columns
|
|
44
|
+
const columns = $derived<DataTableColumn<Row>[]>([
|
|
45
45
|
{ key: "user", label: i18n.t.adminSessions.user, sortable: true, value: (r) => r.userName },
|
|
46
46
|
{ key: "device", label: i18n.t.adminSessions.device, sortable: true, value: (r) => r.userAgent },
|
|
47
47
|
{ key: "ip", label: i18n.t.adminSessions.ip, sortable: true, value: (r) => r.ipAddress },
|
|
48
48
|
{ key: "createdAt", label: i18n.t.adminSessions.since, sortable: true },
|
|
49
49
|
{ key: "expiresAt", label: i18n.t.adminSessions.expires, sortable: true, value: (r) => (r.expiresAt ? new Date(r.expiresAt).getTime() : 0) },
|
|
50
50
|
{ key: "actions", label: "", class: "w-10" },
|
|
51
|
-
];
|
|
51
|
+
]);
|
|
52
52
|
|
|
53
|
-
const searchFields
|
|
53
|
+
const searchFields = $derived<ToolbarSearchField[]>([
|
|
54
54
|
{ value: "email", label: i18n.t.users.email },
|
|
55
55
|
{ value: "name", label: i18n.t.users.name },
|
|
56
|
-
];
|
|
56
|
+
]);
|
|
57
57
|
|
|
58
58
|
const filtered = $derived(
|
|
59
59
|
rows.filter((r) => {
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
const callbackBase = $derived(typeof window === "undefined" ? "" : window.location.origin);
|
|
75
|
+
const providerCallback = (id: string): string => `${callbackBase}/api/auth/callback/${id}`;
|
|
75
76
|
const labelOf = (id: string): string => ac?.catalog.find((c) => c.id === id)?.label ?? id;
|
|
76
77
|
const configured = $derived(Object.values(ac?.social ?? {}));
|
|
77
78
|
const enabledProviders = $derived(configured.filter((p) => p.enabled && p.clientId && p.hasSecret));
|
|
@@ -102,6 +103,11 @@
|
|
|
102
103
|
});
|
|
103
104
|
const editingView = $derived(socialForm.adding ? undefined : ac?.social[socialForm.id]);
|
|
104
105
|
|
|
106
|
+
function socialRedirectURI(): string {
|
|
107
|
+
const configured = socialForm.redirectURI.trim();
|
|
108
|
+
return socialForm.adding || !configured ? providerCallback(socialForm.id) : configured;
|
|
109
|
+
}
|
|
110
|
+
|
|
105
111
|
function openSocialManager(): void {
|
|
106
112
|
socialView = "list";
|
|
107
113
|
openDialog = "social";
|
|
@@ -122,7 +128,7 @@
|
|
|
122
128
|
[socialForm.id]: {
|
|
123
129
|
enabled: socialForm.enabled,
|
|
124
130
|
clientId: socialForm.clientId,
|
|
125
|
-
redirectURI:
|
|
131
|
+
redirectURI: socialRedirectURI(),
|
|
126
132
|
...(socialForm.clientSecret ? { clientSecret: socialForm.clientSecret } : {}),
|
|
127
133
|
},
|
|
128
134
|
},
|
|
@@ -335,7 +341,7 @@
|
|
|
335
341
|
<div class="flex flex-col gap-1"><Label for="social-id">{i18n.t.settings.clientId}</Label><Input id="social-id" bind:value={socialForm.clientId} autocomplete="off" /></div>
|
|
336
342
|
<div class="flex flex-col gap-1"><Label for="social-secret">{i18n.t.settings.clientSecret}</Label><Input id="social-secret" type="password" bind:value={socialForm.clientSecret} autocomplete="off" placeholder={editingView?.hasSecret ? i18n.t.settings.secretSet : ""} /></div>
|
|
337
343
|
{#if socialForm.id}
|
|
338
|
-
<p class="text-muted-foreground text-xs">{i18n.t.settings.redirectUri}: <code class="break-all">{
|
|
344
|
+
<p class="text-muted-foreground text-xs">{i18n.t.settings.redirectUri}: <code class="break-all">{socialRedirectURI()}</code></p>
|
|
339
345
|
{/if}
|
|
340
346
|
</div>
|
|
341
347
|
<Dialog.Footer>
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
import { Label } from "$lib/components/ui/label";
|
|
4
4
|
import { Button } from "$lib/components/ui/button";
|
|
5
5
|
import { Switch } from "$lib/components/ui/switch";
|
|
6
|
+
import * as Select from "$lib/components/ui/select";
|
|
6
7
|
import * as Card from "$lib/components/ui/card";
|
|
7
8
|
import { toast } from "svelte-sonner";
|
|
8
9
|
import { api } from "$lib/api";
|
|
9
10
|
import { getI18n } from "$lib/i18n";
|
|
11
|
+
import { defaultLocale, LOCALES, localeNames, resolveLocale } from "$lib/i18n/messages";
|
|
10
12
|
import { site, type SiteSettings } from "$lib/site.svelte";
|
|
11
13
|
|
|
12
14
|
const i18n = getI18n();
|
|
@@ -21,14 +23,14 @@
|
|
|
21
23
|
footerText: "",
|
|
22
24
|
termsUrl: "",
|
|
23
25
|
privacyUrl: "",
|
|
24
|
-
locale:
|
|
26
|
+
locale: defaultLocale,
|
|
25
27
|
timezone: "",
|
|
26
28
|
});
|
|
27
29
|
let maintenanceMode = $state(false);
|
|
28
30
|
let allowSignup = $state(true);
|
|
29
31
|
let seeded = $state(false);
|
|
30
32
|
$effect(() => {
|
|
31
|
-
if (seeded) return;
|
|
33
|
+
if (seeded || !site.initialized) return;
|
|
32
34
|
form = {
|
|
33
35
|
name: s.name ?? "",
|
|
34
36
|
description: s.description ?? "",
|
|
@@ -36,7 +38,7 @@
|
|
|
36
38
|
footerText: s.footerText ?? "",
|
|
37
39
|
termsUrl: s.termsUrl ?? "",
|
|
38
40
|
privacyUrl: s.privacyUrl ?? "",
|
|
39
|
-
locale: s.locale
|
|
41
|
+
locale: resolveLocale(s.locale),
|
|
40
42
|
timezone: s.timezone ?? "",
|
|
41
43
|
};
|
|
42
44
|
maintenanceMode = s.maintenanceMode === "true";
|
|
@@ -157,7 +159,16 @@
|
|
|
157
159
|
</div>
|
|
158
160
|
<div class="flex flex-col gap-1.5">
|
|
159
161
|
<Label for="locale">{t.locale}</Label>
|
|
160
|
-
<
|
|
162
|
+
<Select.Root type="single" bind:value={form.locale}>
|
|
163
|
+
<Select.Trigger id="locale" class="w-full">
|
|
164
|
+
{localeNames[form.locale] ?? form.locale ?? ""}
|
|
165
|
+
</Select.Trigger>
|
|
166
|
+
<Select.Content>
|
|
167
|
+
{#each LOCALES as locale (locale)}
|
|
168
|
+
<Select.Item value={locale}>{localeNames[locale] ?? locale}</Select.Item>
|
|
169
|
+
{/each}
|
|
170
|
+
</Select.Content>
|
|
171
|
+
</Select.Root>
|
|
161
172
|
</div>
|
|
162
173
|
<div class="flex flex-col gap-1.5">
|
|
163
174
|
<Label for="timezone">{t.timezone}</Label>
|
|
@@ -55,14 +55,14 @@
|
|
|
55
55
|
let filterValues = $state<Record<string, string>>({ role: "", status: "" });
|
|
56
56
|
let appliedFilters = $state<Record<string, string>>({ role: "", status: "" });
|
|
57
57
|
|
|
58
|
-
const columns
|
|
58
|
+
const columns = $derived<DataTableColumn<Row>[]>([
|
|
59
59
|
{ key: "name", label: i18n.t.users.name, sortable: true },
|
|
60
60
|
{ key: "email", label: i18n.t.users.email, sortable: true },
|
|
61
61
|
{ key: "role", label: i18n.t.users.role, sortable: true },
|
|
62
62
|
{ key: "status", label: i18n.t.users.status },
|
|
63
63
|
{ key: "createdAt", label: i18n.t.users.joined, sortable: true, value: (u) => (u.createdAt ? new Date(u.createdAt).getTime() : 0) },
|
|
64
64
|
{ key: "actions", label: "", class: "w-10" },
|
|
65
|
-
];
|
|
65
|
+
]);
|
|
66
66
|
const filters = $derived<ToolbarFilter[]>([
|
|
67
67
|
{
|
|
68
68
|
key: "role",
|
|
@@ -80,10 +80,10 @@
|
|
|
80
80
|
],
|
|
81
81
|
},
|
|
82
82
|
]);
|
|
83
|
-
const searchFields
|
|
83
|
+
const searchFields = $derived<ToolbarSearchField[]>([
|
|
84
84
|
{ value: "email", label: i18n.t.users.email },
|
|
85
85
|
{ value: "name", label: i18n.t.users.name },
|
|
86
|
-
];
|
|
86
|
+
]);
|
|
87
87
|
|
|
88
88
|
const filtered = $derived(
|
|
89
89
|
users.filter((u) => {
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import * as Card from "$lib/components/ui/card";
|
|
8
8
|
import * as Alert from "$lib/components/ui/alert";
|
|
9
9
|
import { api } from "$lib/api";
|
|
10
|
+
import { PUBLIC_SIGNUP_DISABLED } from "$lib/auth-errors";
|
|
11
|
+
import { safeAuthRedirect, withAuthRedirect } from "$lib/auth-redirect";
|
|
10
12
|
import { getI18n, fmt } from "$lib/i18n";
|
|
11
13
|
import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-api-client";
|
|
12
14
|
import { site } from "$lib/site.svelte";
|
|
@@ -24,7 +26,8 @@
|
|
|
24
26
|
let loading = $state(false);
|
|
25
27
|
|
|
26
28
|
$effect(() => {
|
|
27
|
-
if (data.oauthError) error = i18n.t.auth.
|
|
29
|
+
if (data.oauthError === PUBLIC_SIGNUP_DISABLED) error = i18n.t.auth.publicSignupDisabled;
|
|
30
|
+
else if (data.oauthError) error = i18n.t.auth.signInFailed;
|
|
28
31
|
});
|
|
29
32
|
|
|
30
33
|
// Second-factor step: a password/OTP sign-in with 2FA enabled returns
|
|
@@ -34,7 +37,10 @@
|
|
|
34
37
|
let twoFaCode = $state("");
|
|
35
38
|
let useBackupCode = $state(false);
|
|
36
39
|
let twoFaLoading = $state(false);
|
|
37
|
-
const redirectTo = (): string => page.url.searchParams.get("redirect")
|
|
40
|
+
const redirectTo = (): string => safeAuthRedirect(page.url.searchParams.get("redirect"));
|
|
41
|
+
const authErrorCallback = (): string =>
|
|
42
|
+
new URL(withAuthRedirect("/login", redirectTo()), page.url.origin).toString();
|
|
43
|
+
const signupHref = (): string => withAuthRedirect("/signup", redirectTo());
|
|
38
44
|
const socialProviders = $derived(data.capabilities?.providers ?? []);
|
|
39
45
|
const providerLabels: Record<string, string> = {
|
|
40
46
|
github: "GitHub",
|
|
@@ -45,6 +51,18 @@
|
|
|
45
51
|
const providerLabel = (provider: string): string =>
|
|
46
52
|
providerLabels[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
47
53
|
|
|
54
|
+
async function handlePolicyError(code: string | undefined): Promise<boolean> {
|
|
55
|
+
if (code === SIGNUP_APPROVAL_REQUIRED) {
|
|
56
|
+
await goto("/pending-approval");
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
if (code === PUBLIC_SIGNUP_DISABLED) {
|
|
60
|
+
error = i18n.t.auth.publicSignupDisabled;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
48
66
|
async function signInSocial(provider: string): Promise<void> {
|
|
49
67
|
error = null;
|
|
50
68
|
const callbackURL = new URL(redirectTo(), page.url.origin).toString();
|
|
@@ -52,14 +70,11 @@
|
|
|
52
70
|
const { error: authError } = await api.auth.signIn.social({
|
|
53
71
|
provider: provider as SocialProvider,
|
|
54
72
|
callbackURL,
|
|
55
|
-
errorCallbackURL:
|
|
73
|
+
errorCallbackURL: authErrorCallback(),
|
|
56
74
|
newUserCallbackURL: callbackURL,
|
|
57
75
|
});
|
|
58
76
|
if (authError) {
|
|
59
|
-
if (authError.code
|
|
60
|
-
await goto("/pending-approval");
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
77
|
+
if (await handlePolicyError(authError.code)) return;
|
|
63
78
|
error = authError.message ?? i18n.t.auth.signInFailed;
|
|
64
79
|
}
|
|
65
80
|
}
|
|
@@ -92,10 +107,7 @@
|
|
|
92
107
|
: await api.auth.twoFactor.verifyTotp({ code: twoFaCode.trim() });
|
|
93
108
|
twoFaLoading = false;
|
|
94
109
|
if (authError) {
|
|
95
|
-
if (authError.code
|
|
96
|
-
await goto("/pending-approval");
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
110
|
+
if (await handlePolicyError(authError.code)) return;
|
|
99
111
|
error = twoFactorError(authError.code);
|
|
100
112
|
return;
|
|
101
113
|
}
|
|
@@ -116,14 +128,11 @@
|
|
|
116
128
|
const { error: authError } = await api.auth.signIn.magicLink({
|
|
117
129
|
email,
|
|
118
130
|
callbackURL: new URL(redirectTo(), page.url.origin).toString(),
|
|
119
|
-
errorCallbackURL:
|
|
131
|
+
errorCallbackURL: authErrorCallback(),
|
|
120
132
|
});
|
|
121
133
|
magicLoading = false;
|
|
122
134
|
if (authError) {
|
|
123
|
-
if (authError.code
|
|
124
|
-
await goto("/pending-approval");
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
135
|
+
if (await handlePolicyError(authError.code)) return;
|
|
127
136
|
error = authError.message ?? i18n.t.auth.signInFailed;
|
|
128
137
|
return;
|
|
129
138
|
}
|
|
@@ -146,10 +155,7 @@
|
|
|
146
155
|
const { error: authError } = await api.auth.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
|
|
147
156
|
otpLoading = false;
|
|
148
157
|
if (authError) {
|
|
149
|
-
if (authError.code
|
|
150
|
-
await goto("/pending-approval");
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
158
|
+
if (await handlePolicyError(authError.code)) return;
|
|
153
159
|
error = authError.message ?? i18n.t.auth.signInFailed;
|
|
154
160
|
return;
|
|
155
161
|
}
|
|
@@ -161,10 +167,7 @@
|
|
|
161
167
|
const { data, error: authError } = await api.auth.signIn.emailOtp({ email, otp: otpCode });
|
|
162
168
|
otpLoading = false;
|
|
163
169
|
if (authError) {
|
|
164
|
-
if (authError.code
|
|
165
|
-
await goto("/pending-approval");
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
170
|
+
if (await handlePolicyError(authError.code)) return;
|
|
168
171
|
error = authError.message ?? i18n.t.auth.signInFailed;
|
|
169
172
|
return;
|
|
170
173
|
}
|
|
@@ -184,14 +187,11 @@
|
|
|
184
187
|
const res = await api.auth.signIn.passkey();
|
|
185
188
|
passkeyLoading = false;
|
|
186
189
|
if (res?.error) {
|
|
187
|
-
if ("code" in res.error
|
|
188
|
-
await goto("/pending-approval");
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
190
|
+
if (await handlePolicyError("code" in res.error ? res.error.code : undefined)) return;
|
|
191
191
|
error = res.error.message ?? i18n.t.auth.signInFailed;
|
|
192
192
|
return;
|
|
193
193
|
}
|
|
194
|
-
await goto(
|
|
194
|
+
await goto(redirectTo(), { invalidateAll: true });
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
async function submit(event: SubmitEvent): Promise<void> {
|
|
@@ -206,10 +206,7 @@
|
|
|
206
206
|
: await api.auth.signIn.email({ email, password });
|
|
207
207
|
loading = false;
|
|
208
208
|
if (authError) {
|
|
209
|
-
if (authError.code
|
|
210
|
-
await goto("/pending-approval");
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
209
|
+
if (await handlePolicyError(authError.code)) return;
|
|
213
210
|
// Surface an unverified address with a path to a fresh verification link.
|
|
214
211
|
unverified = authError.code === "EMAIL_NOT_VERIFIED";
|
|
215
212
|
error = unverified ? i18n.t.auth.emailNotVerified : (authError.message ?? i18n.t.auth.signInFailed);
|
|
@@ -230,7 +227,7 @@
|
|
|
230
227
|
</Card.Header>
|
|
231
228
|
<Card.Content>
|
|
232
229
|
{#if error}
|
|
233
|
-
<Alert.Root variant="destructive" class="mb-4">
|
|
230
|
+
<Alert.Root data-testid="auth-error" variant="destructive" class="mb-4">
|
|
234
231
|
<Alert.Description>
|
|
235
232
|
{error}
|
|
236
233
|
{#if unverified}
|
|
@@ -325,7 +322,7 @@
|
|
|
325
322
|
</Card.Content>
|
|
326
323
|
{#if signupOpen}
|
|
327
324
|
<Card.Footer class="justify-center">
|
|
328
|
-
<p class="text-muted-foreground text-sm">{i18n.t.auth.noAccount} <a href=
|
|
325
|
+
<p class="text-muted-foreground text-sm">{i18n.t.auth.noAccount} <a href={signupHref()} class="text-foreground hover:underline">{i18n.t.auth.signUp}</a></p>
|
|
329
326
|
</Card.Footer>
|
|
330
327
|
{/if}
|
|
331
328
|
</Card.Root>
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import * as Card from "$lib/components/ui/card";
|
|
8
8
|
import * as Alert from "$lib/components/ui/alert";
|
|
9
9
|
import { api } from "$lib/api";
|
|
10
|
+
import { PUBLIC_SIGNUP_DISABLED } from "$lib/auth-errors";
|
|
11
|
+
import { safeAuthRedirect, withAuthRedirect } from "$lib/auth-redirect";
|
|
10
12
|
import { getI18n } from "$lib/i18n";
|
|
11
13
|
import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-api-client";
|
|
12
14
|
import type { PageData } from "./$types";
|
|
@@ -18,6 +20,7 @@
|
|
|
18
20
|
let password = $state("");
|
|
19
21
|
let error = $state<string | null>(null);
|
|
20
22
|
let loading = $state(false);
|
|
23
|
+
const redirectTo = (): string => safeAuthRedirect(page.url.searchParams.get("redirect"));
|
|
21
24
|
|
|
22
25
|
async function submit(event: SubmitEvent): Promise<void> {
|
|
23
26
|
event.preventDefault();
|
|
@@ -29,7 +32,7 @@
|
|
|
29
32
|
name,
|
|
30
33
|
email,
|
|
31
34
|
password,
|
|
32
|
-
callbackURL:
|
|
35
|
+
callbackURL: new URL(redirectTo(), page.url.origin).toString(),
|
|
33
36
|
});
|
|
34
37
|
if (authError) {
|
|
35
38
|
loading = false;
|
|
@@ -37,6 +40,10 @@
|
|
|
37
40
|
await goto("/pending-approval");
|
|
38
41
|
return;
|
|
39
42
|
}
|
|
43
|
+
if (authError.code === PUBLIC_SIGNUP_DISABLED) {
|
|
44
|
+
error = i18n.t.auth.publicSignupDisabled;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
40
47
|
error = authError.message ?? i18n.t.auth.signUpFailed;
|
|
41
48
|
return;
|
|
42
49
|
}
|
|
@@ -52,7 +59,7 @@
|
|
|
52
59
|
await goto(`/verify-email?email=${encodeURIComponent(email)}`);
|
|
53
60
|
return;
|
|
54
61
|
}
|
|
55
|
-
await goto(
|
|
62
|
+
await goto(redirectTo(), { invalidateAll: true });
|
|
56
63
|
}
|
|
57
64
|
</script>
|
|
58
65
|
|
|
@@ -82,6 +89,6 @@
|
|
|
82
89
|
</form>
|
|
83
90
|
</Card.Content>
|
|
84
91
|
<Card.Footer class="justify-center">
|
|
85
|
-
<p class="text-muted-foreground text-sm">{i18n.t.auth.haveAccount} <a href="/login" class="text-foreground hover:underline">{i18n.t.auth.signIn}</a></p>
|
|
92
|
+
<p class="text-muted-foreground text-sm">{i18n.t.auth.haveAccount} <a href={withAuthRedirect("/login", redirectTo())} class="text-foreground hover:underline">{i18n.t.auth.signIn}</a></p>
|
|
86
93
|
</Card.Footer>
|
|
87
94
|
</Card.Root>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { LayoutServerLoad } from "./$types";
|
|
2
2
|
import { redirect } from "@sveltejs/kit";
|
|
3
|
+
import { safeAuthRedirect } from "$lib/auth-redirect";
|
|
4
|
+
import { loadMessages } from "$lib/i18n/messages";
|
|
3
5
|
|
|
4
|
-
// "/" is a public landing page. Auth pages
|
|
5
|
-
// the admin area requires a session. /maintenance is public
|
|
6
|
-
// the rest of the site is held back.
|
|
6
|
+
// "/" is a public landing page. Auth pages return signed-in users to their safe
|
|
7
|
+
// requested page, and the admin area requires a session. /maintenance is public
|
|
8
|
+
// so it renders while the rest of the site is held back.
|
|
7
9
|
const PUBLIC_PATHS = [
|
|
8
10
|
"/",
|
|
9
11
|
"/login",
|
|
@@ -41,14 +43,16 @@ export const load: LayoutServerLoad = async (event) => {
|
|
|
41
43
|
}
|
|
42
44
|
// Registration closed from the admin Settings: send /signup back to /login.
|
|
43
45
|
if (site?.allowSignup === "false" && pathname === "/signup") {
|
|
44
|
-
|
|
46
|
+
const returnTo = safeAuthRedirect(url.searchParams.get("redirect"));
|
|
47
|
+
redirect(303, `/login?redirect=${encodeURIComponent(returnTo)}`);
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
if (locals.user && AUTH_PATHS.includes(pathname)) {
|
|
48
|
-
redirect(303, "
|
|
51
|
+
redirect(303, safeAuthRedirect(url.searchParams.get("redirect")));
|
|
49
52
|
}
|
|
50
53
|
if (!locals.user && !isPublic(pathname)) {
|
|
51
|
-
redirect(303, `/login?redirect=${encodeURIComponent(pathname)}`);
|
|
54
|
+
redirect(303, `/login?redirect=${encodeURIComponent(`${pathname}${url.search}`)}`);
|
|
52
55
|
}
|
|
53
|
-
|
|
56
|
+
const messages = await loadMessages(locals.locale, site?.locale);
|
|
57
|
+
return { user: locals.user, locale: locals.locale, messages, site };
|
|
54
58
|
};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
|
+
import {
|
|
3
|
+
PUBLIC_SIGNUP_DISABLED,
|
|
4
|
+
isUserCreationAllowed,
|
|
5
|
+
} from "../../apps/api/src/auth/feature-gate";
|
|
2
6
|
import { ADMIN } from "../helpers/accounts";
|
|
3
7
|
|
|
4
8
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
@@ -157,3 +161,77 @@ test("new registrations require approval when the policy is enabled @smoke", asy
|
|
|
157
161
|
await admin.dispose();
|
|
158
162
|
}
|
|
159
163
|
});
|
|
164
|
+
|
|
165
|
+
test("closed public sign-up blocks new OAuth users but preserves existing sign-in @smoke", async ({ playwright }) => {
|
|
166
|
+
const admin = await adminCtx(playwright);
|
|
167
|
+
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
168
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
169
|
+
const adminCreatedEmail = `closed-admin-${Date.now()}@example.com`;
|
|
170
|
+
const initialSite = (await (await anon.get("/api/site/settings")).json()) as {
|
|
171
|
+
allowSignup?: string | null;
|
|
172
|
+
};
|
|
173
|
+
const initialAllowSignup = initialSite.allowSignup === "false" ? "false" : "true";
|
|
174
|
+
let adminCreatedId = "";
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
expect(
|
|
178
|
+
(await admin.put("/api/account/auth-config", {
|
|
179
|
+
data: {
|
|
180
|
+
social: {
|
|
181
|
+
google: {
|
|
182
|
+
enabled: true,
|
|
183
|
+
clientId: "dummy-google-client-id",
|
|
184
|
+
clientSecret: "dummy-google-client-secret",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
})).ok(),
|
|
189
|
+
).toBeTruthy();
|
|
190
|
+
expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
|
|
191
|
+
|
|
192
|
+
// The site policy uses a short cache shared by sign-up requests and user
|
|
193
|
+
// creation hooks, so wait for the setting to become live.
|
|
194
|
+
await new Promise((resolve) => setTimeout(resolve, 3_200));
|
|
195
|
+
|
|
196
|
+
const signup = await anon.post("/api/auth/sign-up/email", {
|
|
197
|
+
data: {
|
|
198
|
+
email: `closed-${Date.now()}@example.com`,
|
|
199
|
+
password,
|
|
200
|
+
name: "Closed Registration",
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
expect(signup.status()).toBe(403);
|
|
204
|
+
expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
|
|
205
|
+
|
|
206
|
+
// OAuth initiation must stay available for existing social users. Only a
|
|
207
|
+
// callback that attempts to create a new user is rejected by the DB hook.
|
|
208
|
+
const social = await anon.post("/api/auth/sign-in/social", {
|
|
209
|
+
data: { provider: "google", callbackURL: `${base}/` },
|
|
210
|
+
});
|
|
211
|
+
expect(social.ok()).toBeTruthy();
|
|
212
|
+
expect(((await social.json()).url as string)).toContain("accounts.google.com");
|
|
213
|
+
expect(isUserCreationAllowed(false, "/callback/google")).toBe(false);
|
|
214
|
+
expect(isUserCreationAllowed(false, undefined)).toBe(false);
|
|
215
|
+
|
|
216
|
+
// Deliberate administrator provisioning remains available while the public
|
|
217
|
+
// site is closed.
|
|
218
|
+
expect(isUserCreationAllowed(false, "/admin/create-user")).toBe(true);
|
|
219
|
+
const adminCreated = await admin.post("/api/auth/admin/create-user", {
|
|
220
|
+
data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
|
|
221
|
+
});
|
|
222
|
+
expect(adminCreated.ok()).toBeTruthy();
|
|
223
|
+
adminCreatedId = ((await adminCreated.json()).user as { id: string }).id;
|
|
224
|
+
} finally {
|
|
225
|
+
if (adminCreatedId) {
|
|
226
|
+
await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
|
|
227
|
+
}
|
|
228
|
+
await admin.put("/api/site/settings", { data: { allowSignup: initialAllowSignup } });
|
|
229
|
+
await admin.put("/api/account/auth-config", {
|
|
230
|
+
data: { social: { google: { enabled: false } } },
|
|
231
|
+
});
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, 3_200));
|
|
233
|
+
await anon.get("/api/auth/get-session");
|
|
234
|
+
await anon.dispose();
|
|
235
|
+
await admin.dispose();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { safeAuthRedirect, withAuthRedirect } from "../../apps/web/src/lib/auth-redirect";
|
|
2
3
|
import { anonState, ADMIN } from "../helpers/accounts";
|
|
3
4
|
import { ready } from "../helpers/hydration";
|
|
4
5
|
|
|
@@ -24,6 +25,25 @@ test("invalid credentials show an error", async ({ page }) => {
|
|
|
24
25
|
await expect(page).toHaveURL(/\/login/);
|
|
25
26
|
});
|
|
26
27
|
|
|
28
|
+
test("login returns to the requested page instead of the admin dashboard", async ({ page }) => {
|
|
29
|
+
await ready(page, "/login?redirect=%2F%3Ffrom%3Dlogin");
|
|
30
|
+
await page.locator("#email").fill(ADMIN.email);
|
|
31
|
+
await page.locator("#password").fill(ADMIN.password);
|
|
32
|
+
await page.locator('form button[type="submit"]').click();
|
|
33
|
+
await expect(page).toHaveURL((url) => url.pathname === "/" && url.searchParams.get("from") === "login");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("authentication return targets reject external and recursive redirects", () => {
|
|
37
|
+
expect(safeAuthRedirect("/products?page=2")).toBe("/products?page=2");
|
|
38
|
+
expect(safeAuthRedirect("https://example.com/steal")).toBe("/");
|
|
39
|
+
expect(safeAuthRedirect("//example.com/steal")).toBe("/");
|
|
40
|
+
expect(safeAuthRedirect("/\\example.com/steal")).toBe("/");
|
|
41
|
+
expect(safeAuthRedirect("/login")).toBe("/");
|
|
42
|
+
expect(withAuthRedirect("/login", "/products?page=2")).toBe(
|
|
43
|
+
"/login?redirect=%2Fproducts%3Fpage%3D2",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
27
47
|
test("signup page renders", async ({ page }) => {
|
|
28
48
|
await page.goto("/signup");
|
|
29
49
|
await expect(page.getByLabel("Name")).toBeVisible();
|
|
@@ -36,6 +56,13 @@ test("pending approval errors show the approval status page", async ({ page }) =
|
|
|
36
56
|
await expect(page.getByRole("heading", { name: "Waiting for approval" })).toBeVisible();
|
|
37
57
|
});
|
|
38
58
|
|
|
59
|
+
test("closed social registration errors explain that existing users may sign in", async ({ page }) => {
|
|
60
|
+
await page.goto("/login?error=PUBLIC_SIGNUP_DISABLED");
|
|
61
|
+
await expect(page.getByTestId("auth-error")).toContainText(
|
|
62
|
+
/Public sign-up is closed|공개 가입이 닫혀 있습니다/,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
39
66
|
test("configured social providers appear on the login page", async ({ page, playwright }) => {
|
|
40
67
|
const admin = await playwright.request.newContext({
|
|
41
68
|
baseURL: base,
|
|
@@ -96,5 +123,5 @@ test("signup creates an account and enters the app @smoke", async ({ page }) =>
|
|
|
96
123
|
await page.getByLabel("Password").fill("Podokit3e-Str0ng!pw");
|
|
97
124
|
await page.getByRole("button", { name: "Create account" }).click();
|
|
98
125
|
// Verification off → straight into the app; on → the verify-email page.
|
|
99
|
-
await expect(page).toHaveURL(
|
|
126
|
+
await expect(page).toHaveURL((url) => url.pathname === "/" || url.pathname === "/verify-email");
|
|
100
127
|
});
|
|
@@ -38,5 +38,5 @@ test("forgot password: request, open the emailed link, set a new password, sign
|
|
|
38
38
|
await page.getByLabel("Email").fill(email);
|
|
39
39
|
await page.getByLabel("Password").fill("Podokit3e-N3wStr0ng!pw");
|
|
40
40
|
await page.getByRole("button", { name: "Sign in" }).click();
|
|
41
|
-
await expect(page).toHaveURL(
|
|
41
|
+
await expect(page).toHaveURL(new URL("/", base).toString());
|
|
42
42
|
});
|
|
@@ -28,6 +28,21 @@ async function saveGeneral(
|
|
|
28
28
|
await expect(page.getByText("General settings saved.").last()).toBeVisible();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
test("general: empty locale displays the application default", async ({ page }) => {
|
|
32
|
+
await ready(page, "/admin/settings");
|
|
33
|
+
const origin = new URL(page.url()).origin;
|
|
34
|
+
const settingsUrl = `${origin}/api/site/settings`;
|
|
35
|
+
const previous = await (await page.request.get(settingsUrl)).json() as { locale?: string };
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
expect((await page.request.put(settingsUrl, { data: { locale: "" } })).ok()).toBeTruthy();
|
|
39
|
+
await ready(page, "/admin/settings");
|
|
40
|
+
await expect(page.locator("#locale")).toContainText("English");
|
|
41
|
+
} finally {
|
|
42
|
+
await page.request.put(settingsUrl, { data: { locale: previous.locale ?? "" } });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
31
46
|
test("general: footer text and links render on public pages @smoke", async ({ page, browser }) => {
|
|
32
47
|
await ready(page, "/admin/settings");
|
|
33
48
|
const origin = new URL(page.url()).origin;
|