@podosoft/podokit 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +16 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +7 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +36 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +7 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +66 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/pending-approval/+page.svelte +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.server.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +79 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +44 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +43 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -4
- package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +187 -0
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +1 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +62 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +13 -4
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +93 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/agents/openai.yaml +4 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/apple.md +18 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +19 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/smtp.md +22 -0
- package/dist/templates/modules/auth/module.manifest.json +7 -4
- package/package.json +1 -1
|
@@ -83,6 +83,10 @@ const en = {
|
|
|
83
83
|
verifyLinkError: "That verification link is invalid or has expired. Request a new one below.",
|
|
84
84
|
resendVerification: "Resend verification email",
|
|
85
85
|
emailNotVerified: "Verify your email before signing in.",
|
|
86
|
+
approvalPendingTitle: "Waiting for approval",
|
|
87
|
+
approvalPendingDesc: "Your account was created and is waiting for an administrator to approve it. Try signing in again after approval.",
|
|
88
|
+
approvalPending: "Your account is waiting for administrator approval.",
|
|
89
|
+
continueWith: "Continue with {provider}",
|
|
86
90
|
},
|
|
87
91
|
nav: { overview: "Overview", users: "Users", sessions: "Sessions", organizations: "Organizations", audit: "Audit log", account: "Account", settings: "Settings", home: "Back to home", signOut: "Sign out" },
|
|
88
92
|
passkeys: {
|
|
@@ -161,6 +165,7 @@ const en = {
|
|
|
161
165
|
saved: "Setting updated", saveFailed: "Failed to update setting",
|
|
162
166
|
emailPassword: "Email & password", emailPasswordDesc: "Sign in with an email address and password.",
|
|
163
167
|
emailVerification: "Email verification", emailVerificationDesc: "Require new sign-ups to confirm their address.",
|
|
168
|
+
signupApproval: "Sign-up approval", signupApprovalDesc: "Require an administrator to approve new self-registered users. Disabling this only affects future registrations.",
|
|
164
169
|
magicLink: "Magic link", magicLinkDesc: "Passwordless sign-in via an emailed link.",
|
|
165
170
|
emailOtp: "Email one-time code", emailOtpDesc: "Passwordless sign-in with an emailed code.",
|
|
166
171
|
username: "Username sign-in", usernameDesc: "Let users sign in with a username instead of an email.",
|
|
@@ -259,6 +264,9 @@ const en = {
|
|
|
259
264
|
sendResetEmail: "Send password reset email",
|
|
260
265
|
resetEmailSent: "Password reset email sent",
|
|
261
266
|
sendVerificationOnCreate: "Send verification email",
|
|
267
|
+
pendingApproval: "Pending approval",
|
|
268
|
+
approve: "Approve sign-up",
|
|
269
|
+
approved: "Sign-up approved",
|
|
262
270
|
},
|
|
263
271
|
sessions: {
|
|
264
272
|
title: "Active sessions",
|
|
@@ -433,6 +441,10 @@ const ko: Messages = {
|
|
|
433
441
|
verifyLinkError: "인증 링크가 유효하지 않거나 만료되었습니다. 아래에서 다시 요청하세요.",
|
|
434
442
|
resendVerification: "인증 메일 다시 보내기",
|
|
435
443
|
emailNotVerified: "로그인 전에 이메일을 인증해 주세요.",
|
|
444
|
+
approvalPendingTitle: "승인 대기 중",
|
|
445
|
+
approvalPendingDesc: "계정이 생성되었으며 관리자의 승인을 기다리고 있습니다. 승인 후 다시 로그인해 주세요.",
|
|
446
|
+
approvalPending: "관리자의 계정 승인을 기다리고 있습니다.",
|
|
447
|
+
continueWith: "{provider}(으)로 계속",
|
|
436
448
|
resetFailed: "재설정에 실패했습니다",
|
|
437
449
|
},
|
|
438
450
|
nav: { overview: "개요", users: "회원", sessions: "세션", organizations: "조직", audit: "감사 로그", account: "계정", settings: "설정", home: "홈으로 돌아가기", signOut: "로그아웃" },
|
|
@@ -512,6 +524,7 @@ const ko: Messages = {
|
|
|
512
524
|
saved: "설정을 변경했습니다", saveFailed: "설정 변경에 실패했습니다",
|
|
513
525
|
emailPassword: "이메일·비밀번호", emailPasswordDesc: "이메일과 비밀번호로 로그인.",
|
|
514
526
|
emailVerification: "이메일 인증", emailVerificationDesc: "신규 가입 시 이메일 확인 요구.",
|
|
527
|
+
signupApproval: "회원가입 승인", signupApprovalDesc: "신규 자가 가입 사용자를 관리자가 승인해야 로그인할 수 있습니다. 끄면 이후 가입부터 자동 승인됩니다.",
|
|
515
528
|
magicLink: "매직 링크", magicLinkDesc: "이메일 링크로 비밀번호 없이 로그인.",
|
|
516
529
|
emailOtp: "이메일 일회용 코드", emailOtpDesc: "이메일로 받은 코드로 비밀번호 없이 로그인.",
|
|
517
530
|
username: "사용자명 로그인", usernameDesc: "이메일 대신 사용자명으로 로그인.",
|
|
@@ -610,6 +623,9 @@ const ko: Messages = {
|
|
|
610
623
|
sendResetEmail: "비밀번호 재설정 메일 보내기",
|
|
611
624
|
resetEmailSent: "재설정 메일을 보냈습니다",
|
|
612
625
|
sendVerificationOnCreate: "인증 메일 보내기",
|
|
626
|
+
pendingApproval: "승인 대기",
|
|
627
|
+
approve: "회원가입 승인",
|
|
628
|
+
approved: "회원가입을 승인했습니다",
|
|
613
629
|
},
|
|
614
630
|
sessions: {
|
|
615
631
|
title: "활성 세션",
|
|
@@ -10,7 +10,7 @@ export type AuthConfigView = {
|
|
|
10
10
|
social: Record<string, SocialProviderView>;
|
|
11
11
|
catalog: ReadonlyArray<{ id: string; label: string }>;
|
|
12
12
|
smtp: { enabled: boolean; host: string; port: number; secure: boolean; user: string; from: string; hasSecret: boolean };
|
|
13
|
-
server: { requireEmailVerification: boolean; allowDelete: boolean; hibp: boolean; auditLog: boolean };
|
|
13
|
+
server: { requireEmailVerification: boolean; requireSignupApproval: boolean; allowDelete: boolean; hibp: boolean; auditLog: boolean };
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// require2fa is a policy flag stored alongside the feature flags but not part of
|
|
28
28
|
// the typed Capabilities, so it is toggled but never read from `caps`.
|
|
29
29
|
type ToggleFlag = EditableFlag | "require2fa";
|
|
30
|
-
type ServerKey = "requireEmailVerification" | "hibp" | "allowDelete" | "auditLog";
|
|
30
|
+
type ServerKey = "requireEmailVerification" | "requireSignupApproval" | "hibp" | "allowDelete" | "auditLog";
|
|
31
31
|
|
|
32
32
|
// Every card renders from this one shape, so they share size and structure: a
|
|
33
33
|
// title (+ optional enable switch), a description, and a footer with a status
|
|
@@ -178,6 +178,12 @@
|
|
|
178
178
|
},
|
|
179
179
|
]);
|
|
180
180
|
const security = $derived<Item[]>([
|
|
181
|
+
serverCard(
|
|
182
|
+
"requireSignupApproval",
|
|
183
|
+
i18n.t.settings.signupApproval,
|
|
184
|
+
i18n.t.settings.signupApprovalDesc,
|
|
185
|
+
caps.signupApprovalRequired,
|
|
186
|
+
),
|
|
181
187
|
flagCard("twoFactor", i18n.t.settings.twoFactor, i18n.t.settings.twoFactorDesc),
|
|
182
188
|
{
|
|
183
189
|
key: "flag-require2fa",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
email: string;
|
|
33
33
|
role?: string | null;
|
|
34
34
|
banned?: boolean | null;
|
|
35
|
+
signupApproved?: boolean | null;
|
|
35
36
|
emailVerified?: boolean | null;
|
|
36
37
|
createdAt?: string | Date | null;
|
|
37
38
|
banReason?: string | null;
|
|
@@ -74,6 +75,7 @@
|
|
|
74
75
|
options: [
|
|
75
76
|
{ value: "", label: i18n.t.toolbar.all },
|
|
76
77
|
{ value: "active", label: i18n.t.users.active },
|
|
78
|
+
{ value: "pending", label: i18n.t.users.pendingApproval },
|
|
77
79
|
{ value: "banned", label: i18n.t.users.banned },
|
|
78
80
|
],
|
|
79
81
|
},
|
|
@@ -91,7 +93,8 @@
|
|
|
91
93
|
}
|
|
92
94
|
if (appliedFilters.role && (u.role ?? "user") !== appliedFilters.role) return false;
|
|
93
95
|
if (appliedFilters.status === "banned" && !u.banned) return false;
|
|
94
|
-
if (appliedFilters.status === "
|
|
96
|
+
if (appliedFilters.status === "pending" && u.signupApproved !== false) return false;
|
|
97
|
+
if (appliedFilters.status === "active" && (u.banned || u.signupApproved === false)) return false;
|
|
95
98
|
return true;
|
|
96
99
|
}),
|
|
97
100
|
);
|
|
@@ -117,6 +120,19 @@
|
|
|
117
120
|
await goto("/admin", { invalidateAll: true });
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
async function approve(u: Row): Promise<void> {
|
|
124
|
+
busy = true;
|
|
125
|
+
const { error } = await api.auth.admin.updateUser({
|
|
126
|
+
userId: u.id,
|
|
127
|
+
data: { signupApproved: true },
|
|
128
|
+
});
|
|
129
|
+
busy = false;
|
|
130
|
+
if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
|
|
131
|
+
if (mUser?.id === u.id) mUser = { ...mUser, signupApproved: true };
|
|
132
|
+
toast.success(i18n.t.users.approved);
|
|
133
|
+
await load();
|
|
134
|
+
}
|
|
135
|
+
|
|
120
136
|
// Create user
|
|
121
137
|
let createOpen = $state(false);
|
|
122
138
|
let form = $state({ name: "", email: "", password: "", confirm: "", role: "user", sendVerify: true });
|
|
@@ -372,7 +388,13 @@
|
|
|
372
388
|
<Table.Cell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
|
|
373
389
|
<Table.Cell>
|
|
374
390
|
<div class="flex flex-wrap gap-1">
|
|
375
|
-
{#if user.
|
|
391
|
+
{#if user.signupApproved === false}
|
|
392
|
+
<Badge variant="secondary">{i18n.t.users.pendingApproval}</Badge>
|
|
393
|
+
{:else if user.banned}
|
|
394
|
+
<Badge variant="destructive">{i18n.t.users.banned}</Badge>
|
|
395
|
+
{:else}
|
|
396
|
+
<Badge variant="outline">{i18n.t.users.active}</Badge>
|
|
397
|
+
{/if}
|
|
376
398
|
{#if emailVerificationEnabled}
|
|
377
399
|
{#if user.emailVerified}<Badge variant="outline" class="text-green-600 dark:text-green-400">{i18n.t.users.verified}</Badge>{:else}<Badge variant="secondary">{i18n.t.users.unverified}</Badge>{/if}
|
|
378
400
|
{/if}
|
|
@@ -387,8 +409,11 @@
|
|
|
387
409
|
{/snippet}
|
|
388
410
|
</DropdownMenu.Trigger>
|
|
389
411
|
<DropdownMenu.Content align="end">
|
|
412
|
+
{#if user.signupApproved === false}
|
|
413
|
+
<DropdownMenu.Item onSelect={() => approve(user)}>{i18n.t.users.approve}</DropdownMenu.Item>
|
|
414
|
+
{/if}
|
|
390
415
|
<DropdownMenu.Item onSelect={() => openManage(user)}>{i18n.t.users.manage}</DropdownMenu.Item>
|
|
391
|
-
<DropdownMenu.Item disabled={user.id === data.currentUserId} onSelect={() => impersonate(user)}>
|
|
416
|
+
<DropdownMenu.Item disabled={user.id === data.currentUserId || user.signupApproved === false} onSelect={() => impersonate(user)}>
|
|
392
417
|
{i18n.t.users.impersonate}
|
|
393
418
|
</DropdownMenu.Item>
|
|
394
419
|
</DropdownMenu.Content>
|
|
@@ -461,6 +486,14 @@
|
|
|
461
486
|
</Select.Root>
|
|
462
487
|
</div>
|
|
463
488
|
{#if mUser?.banned}<Badge variant="destructive" class="w-fit">{i18n.t.users.banned}</Badge>{/if}
|
|
489
|
+
{#if mUser?.signupApproved === false}
|
|
490
|
+
<div class="flex items-center gap-2">
|
|
491
|
+
<Badge variant="secondary">{i18n.t.users.pendingApproval}</Badge>
|
|
492
|
+
<Button type="button" variant="outline" size="sm" disabled={busy} onclick={() => { if (mUser) void approve(mUser); }}>
|
|
493
|
+
{i18n.t.users.approve}
|
|
494
|
+
</Button>
|
|
495
|
+
</div>
|
|
496
|
+
{/if}
|
|
464
497
|
{#if emailVerificationEnabled}
|
|
465
498
|
<div class="flex items-center gap-2">
|
|
466
499
|
{#if mUser?.emailVerified}<Badge variant="outline" class="text-green-600 dark:text-green-400">{i18n.t.users.verified}</Badge>
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { redirect } from "@sveltejs/kit";
|
|
1
2
|
import type { PageServerLoad } from "./$types";
|
|
2
|
-
import type
|
|
3
|
+
import { SIGNUP_APPROVAL_REQUIRED, type Capabilities } from "@podosoft/podokit-api-client";
|
|
3
4
|
|
|
4
5
|
// Capabilities is public so the login page can offer whichever sign-in methods
|
|
5
6
|
// (magic link, ...) the server actually enabled.
|
|
6
|
-
export const load: PageServerLoad = async ({ fetch }) => {
|
|
7
|
+
export const load: PageServerLoad = async ({ fetch, url }) => {
|
|
8
|
+
const oauthError = url.searchParams.get("error");
|
|
9
|
+
if (oauthError === SIGNUP_APPROVAL_REQUIRED) redirect(302, "/pending-approval");
|
|
7
10
|
try {
|
|
8
11
|
const res = await fetch("/api/account/capabilities");
|
|
9
|
-
if (res.ok) return { capabilities: (await res.json()) as Capabilities };
|
|
12
|
+
if (res.ok) return { capabilities: (await res.json()) as Capabilities, oauthError };
|
|
10
13
|
} catch {
|
|
11
14
|
/* fall through to defaults */
|
|
12
15
|
}
|
|
13
|
-
return { capabilities: null };
|
|
16
|
+
return { capabilities: null, oauthError };
|
|
14
17
|
};
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte
CHANGED
|
@@ -7,7 +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 { getI18n } from "$lib/i18n";
|
|
10
|
+
import { getI18n, fmt } from "$lib/i18n";
|
|
11
|
+
import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-api-client";
|
|
11
12
|
import { site } from "$lib/site.svelte";
|
|
12
13
|
|
|
13
14
|
import type { PageData } from "./$types";
|
|
@@ -22,6 +23,10 @@
|
|
|
22
23
|
let unverified = $state(false);
|
|
23
24
|
let loading = $state(false);
|
|
24
25
|
|
|
26
|
+
$effect(() => {
|
|
27
|
+
if (data.oauthError) error = i18n.t.auth.signInFailed;
|
|
28
|
+
});
|
|
29
|
+
|
|
25
30
|
// Second-factor step: a password/OTP sign-in with 2FA enabled returns
|
|
26
31
|
// `twoFactorRedirect` and no session; the user then verifies with an
|
|
27
32
|
// authenticator code or a backup code to complete the login.
|
|
@@ -30,6 +35,34 @@
|
|
|
30
35
|
let useBackupCode = $state(false);
|
|
31
36
|
let twoFaLoading = $state(false);
|
|
32
37
|
const redirectTo = (): string => page.url.searchParams.get("redirect") ?? "/admin";
|
|
38
|
+
const socialProviders = $derived(data.capabilities?.providers ?? []);
|
|
39
|
+
const providerLabels: Record<string, string> = {
|
|
40
|
+
github: "GitHub",
|
|
41
|
+
google: "Google",
|
|
42
|
+
apple: "Apple",
|
|
43
|
+
microsoft: "Microsoft",
|
|
44
|
+
};
|
|
45
|
+
const providerLabel = (provider: string): string =>
|
|
46
|
+
providerLabels[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
47
|
+
|
|
48
|
+
async function signInSocial(provider: string): Promise<void> {
|
|
49
|
+
error = null;
|
|
50
|
+
const callbackURL = new URL(redirectTo(), page.url.origin).toString();
|
|
51
|
+
type SocialProvider = Parameters<typeof api.auth.signIn.social>[0]["provider"];
|
|
52
|
+
const { error: authError } = await api.auth.signIn.social({
|
|
53
|
+
provider: provider as SocialProvider,
|
|
54
|
+
callbackURL,
|
|
55
|
+
errorCallbackURL: `${page.url.origin}/login`,
|
|
56
|
+
newUserCallbackURL: callbackURL,
|
|
57
|
+
});
|
|
58
|
+
if (authError) {
|
|
59
|
+
if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
|
|
60
|
+
await goto("/pending-approval");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
error = authError.message ?? i18n.t.auth.signInFailed;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
33
66
|
|
|
34
67
|
// Returns true when 2FA is still owed (caller shows the step instead of leaving).
|
|
35
68
|
function needsTwoFactor(data: unknown): boolean {
|
|
@@ -59,6 +92,10 @@
|
|
|
59
92
|
: await api.auth.twoFactor.verifyTotp({ code: twoFaCode.trim() });
|
|
60
93
|
twoFaLoading = false;
|
|
61
94
|
if (authError) {
|
|
95
|
+
if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
|
|
96
|
+
await goto("/pending-approval");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
62
99
|
error = twoFactorError(authError.code);
|
|
63
100
|
return;
|
|
64
101
|
}
|
|
@@ -78,10 +115,15 @@
|
|
|
78
115
|
error = null;
|
|
79
116
|
const { error: authError } = await api.auth.signIn.magicLink({
|
|
80
117
|
email,
|
|
81
|
-
callbackURL: page.url.
|
|
118
|
+
callbackURL: new URL(redirectTo(), page.url.origin).toString(),
|
|
119
|
+
errorCallbackURL: `${page.url.origin}/login`,
|
|
82
120
|
});
|
|
83
121
|
magicLoading = false;
|
|
84
122
|
if (authError) {
|
|
123
|
+
if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
|
|
124
|
+
await goto("/pending-approval");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
85
127
|
error = authError.message ?? i18n.t.auth.signInFailed;
|
|
86
128
|
return;
|
|
87
129
|
}
|
|
@@ -104,6 +146,10 @@
|
|
|
104
146
|
const { error: authError } = await api.auth.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
|
|
105
147
|
otpLoading = false;
|
|
106
148
|
if (authError) {
|
|
149
|
+
if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
|
|
150
|
+
await goto("/pending-approval");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
107
153
|
error = authError.message ?? i18n.t.auth.signInFailed;
|
|
108
154
|
return;
|
|
109
155
|
}
|
|
@@ -115,6 +161,10 @@
|
|
|
115
161
|
const { data, error: authError } = await api.auth.signIn.emailOtp({ email, otp: otpCode });
|
|
116
162
|
otpLoading = false;
|
|
117
163
|
if (authError) {
|
|
164
|
+
if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
|
|
165
|
+
await goto("/pending-approval");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
118
168
|
error = authError.message ?? i18n.t.auth.signInFailed;
|
|
119
169
|
return;
|
|
120
170
|
}
|
|
@@ -134,6 +184,10 @@
|
|
|
134
184
|
const res = await api.auth.signIn.passkey();
|
|
135
185
|
passkeyLoading = false;
|
|
136
186
|
if (res?.error) {
|
|
187
|
+
if ("code" in res.error && res.error.code === SIGNUP_APPROVAL_REQUIRED) {
|
|
188
|
+
await goto("/pending-approval");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
137
191
|
error = res.error.message ?? i18n.t.auth.signInFailed;
|
|
138
192
|
return;
|
|
139
193
|
}
|
|
@@ -152,6 +206,10 @@
|
|
|
152
206
|
: await api.auth.signIn.email({ email, password });
|
|
153
207
|
loading = false;
|
|
154
208
|
if (authError) {
|
|
209
|
+
if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
|
|
210
|
+
await goto("/pending-approval");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
155
213
|
// Surface an unverified address with a path to a fresh verification link.
|
|
156
214
|
unverified = authError.code === "EMAIL_NOT_VERIFIED";
|
|
157
215
|
error = unverified ? i18n.t.auth.emailNotVerified : (authError.message ?? i18n.t.auth.signInFailed);
|
|
@@ -225,8 +283,13 @@
|
|
|
225
283
|
</div>
|
|
226
284
|
<Button type="submit" disabled={loading}>{loading ? i18n.t.auth.signingIn : i18n.t.auth.signIn}</Button>
|
|
227
285
|
</form>
|
|
228
|
-
{#if magicLinkEnabled || emailOtpEnabled || passkeyEnabled}
|
|
286
|
+
{#if socialProviders.length > 0 || magicLinkEnabled || emailOtpEnabled || passkeyEnabled}
|
|
229
287
|
<div class="mt-4 flex flex-col gap-3 border-t pt-4">
|
|
288
|
+
{#each socialProviders as provider (provider)}
|
|
289
|
+
<Button type="button" variant="outline" class="w-full" onclick={() => signInSocial(provider)}>
|
|
290
|
+
{fmt(i18n.t.auth.continueWith, { provider: providerLabel(provider) })}
|
|
291
|
+
</Button>
|
|
292
|
+
{/each}
|
|
230
293
|
{#if passkeyEnabled}
|
|
231
294
|
<Button type="button" variant="outline" class="w-full" disabled={passkeyLoading} onclick={signInWithPasskey}>
|
|
232
295
|
{passkeyLoading ? i18n.t.auth.signingIn : i18n.t.auth.passkeyButton}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from "$app/navigation";
|
|
3
|
+
import { Button } from "$lib/components/ui/button";
|
|
4
|
+
import * as Card from "$lib/components/ui/card";
|
|
5
|
+
import { getI18n } from "$lib/i18n";
|
|
6
|
+
|
|
7
|
+
const i18n = getI18n();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<Card.Root>
|
|
11
|
+
<Card.Header>
|
|
12
|
+
<Card.Title role="heading" aria-level={1}>{i18n.t.auth.approvalPendingTitle}</Card.Title>
|
|
13
|
+
<Card.Description>{i18n.t.auth.approvalPendingDesc}</Card.Description>
|
|
14
|
+
</Card.Header>
|
|
15
|
+
<Card.Footer>
|
|
16
|
+
<Button class="w-full" onclick={() => goto("/login")}>{i18n.t.auth.backToSignIn}</Button>
|
|
17
|
+
</Card.Footer>
|
|
18
|
+
</Card.Root>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PageServerLoad } from "./$types";
|
|
2
|
+
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
3
|
+
|
|
4
|
+
export const load: PageServerLoad = async ({ fetch }) => {
|
|
5
|
+
try {
|
|
6
|
+
const res = await fetch("/api/account/capabilities");
|
|
7
|
+
if (res.ok) return { capabilities: (await res.json()) as Capabilities };
|
|
8
|
+
} catch {
|
|
9
|
+
/* fall through to a safe default */
|
|
10
|
+
}
|
|
11
|
+
return { capabilities: null };
|
|
12
|
+
};
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
import * as Alert from "$lib/components/ui/alert";
|
|
9
9
|
import { api } from "$lib/api";
|
|
10
10
|
import { getI18n } from "$lib/i18n";
|
|
11
|
+
import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-api-client";
|
|
12
|
+
import type { PageData } from "./$types";
|
|
11
13
|
|
|
14
|
+
let { data }: { data: PageData } = $props();
|
|
12
15
|
const i18n = getI18n();
|
|
13
16
|
let name = $state("");
|
|
14
17
|
let email = $state("");
|
|
@@ -30,6 +33,10 @@
|
|
|
30
33
|
});
|
|
31
34
|
if (authError) {
|
|
32
35
|
loading = false;
|
|
36
|
+
if (authError.code === SIGNUP_APPROVAL_REQUIRED) {
|
|
37
|
+
await goto("/pending-approval");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
33
40
|
error = authError.message ?? i18n.t.auth.signUpFailed;
|
|
34
41
|
return;
|
|
35
42
|
}
|
|
@@ -38,6 +45,10 @@
|
|
|
38
45
|
const { data: session } = await api.auth.getSession();
|
|
39
46
|
loading = false;
|
|
40
47
|
if (!session?.session) {
|
|
48
|
+
if (data.capabilities?.signupApprovalRequired) {
|
|
49
|
+
await goto("/pending-approval");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
41
52
|
await goto(`/verify-email?email=${encodeURIComponent(email)}`);
|
|
42
53
|
return;
|
|
43
54
|
}
|
|
@@ -78,3 +78,82 @@ test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwr
|
|
|
78
78
|
expect((await user.put("/api/account/auth-config", { data: { server: { hibp: true } } })).status()).toBe(403);
|
|
79
79
|
await user.dispose();
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
test("new registrations require approval when the policy is enabled @smoke", async ({ playwright }) => {
|
|
83
|
+
const admin = await adminCtx(playwright);
|
|
84
|
+
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
85
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
86
|
+
const email = `approval-${Date.now()}@example.com`;
|
|
87
|
+
const adminCreatedEmail = `approval-admin-${Date.now()}@example.com`;
|
|
88
|
+
let pendingId = "";
|
|
89
|
+
let adminCreatedId = "";
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const enabled = await admin.put("/api/account/auth-config", {
|
|
93
|
+
data: { server: { requireSignupApproval: true } },
|
|
94
|
+
});
|
|
95
|
+
expect(enabled.ok()).toBeTruthy();
|
|
96
|
+
expect((await enabled.json()).server.requireSignupApproval).toBe(true);
|
|
97
|
+
|
|
98
|
+
// authRuntime and its config store use a short cache; an auth request after
|
|
99
|
+
// the TTL applies the new policy without restarting the process.
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 3_200));
|
|
101
|
+
await anon.get("/api/auth/get-session");
|
|
102
|
+
|
|
103
|
+
const signup = await anon.post("/api/auth/sign-up/email", {
|
|
104
|
+
data: { email, password, name: "Pending User" },
|
|
105
|
+
});
|
|
106
|
+
expect(signup.ok()).toBeTruthy();
|
|
107
|
+
expect((await signup.json()).token).toBeNull();
|
|
108
|
+
expect((await anon.get("/api/auth/get-session")).status()).toBe(200);
|
|
109
|
+
expect((await (await anon.get("/api/auth/get-session")).json())?.session).toBeFalsy();
|
|
110
|
+
|
|
111
|
+
const listed = await admin.get(
|
|
112
|
+
`/api/auth/admin/list-users?searchValue=${encodeURIComponent(email)}&searchField=email`,
|
|
113
|
+
);
|
|
114
|
+
const pending = ((await listed.json()).users ?? [])[0] as {
|
|
115
|
+
id: string;
|
|
116
|
+
signupApproved?: boolean | null;
|
|
117
|
+
};
|
|
118
|
+
pendingId = pending.id;
|
|
119
|
+
expect(pending.signupApproved).toBe(false);
|
|
120
|
+
|
|
121
|
+
const blocked = await anon.post("/api/auth/sign-in/email", { data: { email, password } });
|
|
122
|
+
expect(blocked.status()).toBe(403);
|
|
123
|
+
expect((await blocked.json()).code).toBe("SIGNUP_APPROVAL_REQUIRED");
|
|
124
|
+
|
|
125
|
+
const approved = await admin.post("/api/auth/admin/update-user", {
|
|
126
|
+
data: { userId: pendingId, data: { signupApproved: true } },
|
|
127
|
+
});
|
|
128
|
+
expect(approved.ok()).toBeTruthy();
|
|
129
|
+
expect((await approved.json()).signupApproved).toBe(true);
|
|
130
|
+
expect((await anon.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
|
|
131
|
+
|
|
132
|
+
// Accounts created intentionally by an administrator bypass the queue.
|
|
133
|
+
const adminCreated = await admin.post("/api/auth/admin/create-user", {
|
|
134
|
+
data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
|
|
135
|
+
});
|
|
136
|
+
expect(adminCreated.ok()).toBeTruthy();
|
|
137
|
+
const adminCreatedUser = (await adminCreated.json()).user as {
|
|
138
|
+
id: string;
|
|
139
|
+
signupApproved?: boolean | null;
|
|
140
|
+
};
|
|
141
|
+
adminCreatedId = adminCreatedUser.id;
|
|
142
|
+
expect(adminCreatedUser.signupApproved).toBe(true);
|
|
143
|
+
const adminCreatedSession = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
144
|
+
expect(
|
|
145
|
+
(await adminCreatedSession.post("/api/auth/sign-in/email", {
|
|
146
|
+
data: { email: adminCreatedEmail, password },
|
|
147
|
+
})).ok(),
|
|
148
|
+
).toBeTruthy();
|
|
149
|
+
await adminCreatedSession.dispose();
|
|
150
|
+
} finally {
|
|
151
|
+
if (pendingId) await admin.post("/api/auth/admin/remove-user", { data: { userId: pendingId } });
|
|
152
|
+
if (adminCreatedId) await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
|
|
153
|
+
await admin.put("/api/account/auth-config", { data: { server: { requireSignupApproval: false } } });
|
|
154
|
+
await new Promise((resolve) => setTimeout(resolve, 3_200));
|
|
155
|
+
await anon.get("/api/auth/get-session");
|
|
156
|
+
await anon.dispose();
|
|
157
|
+
await admin.dispose();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
@@ -105,6 +105,7 @@ test("account capabilities reports optional-feature flags", async ({ playwright
|
|
|
105
105
|
expect(typeof caps.twoFactor).toBe("boolean");
|
|
106
106
|
expect(Array.isArray(caps.providers)).toBe(true);
|
|
107
107
|
expect(typeof caps.deleteAccount).toBe("boolean");
|
|
108
|
+
expect(typeof caps.signupApprovalRequired).toBe("boolean");
|
|
108
109
|
await ctx.dispose();
|
|
109
110
|
});
|
|
110
111
|
|
|
@@ -4,6 +4,8 @@ import { ready } from "../helpers/hydration";
|
|
|
4
4
|
|
|
5
5
|
test.use({ storageState: anonState });
|
|
6
6
|
|
|
7
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
|
|
7
9
|
test("login page renders @smoke", async ({ page }) => {
|
|
8
10
|
await page.goto("/login");
|
|
9
11
|
await expect(page.getByLabel("Email")).toBeVisible();
|
|
@@ -28,6 +30,48 @@ test("signup page renders", async ({ page }) => {
|
|
|
28
30
|
await expect(page.getByRole("button", { name: "Create account" })).toBeVisible();
|
|
29
31
|
});
|
|
30
32
|
|
|
33
|
+
test("pending approval errors show the approval status page", async ({ page }) => {
|
|
34
|
+
await page.goto("/login?error=SIGNUP_APPROVAL_REQUIRED");
|
|
35
|
+
await expect(page).toHaveURL(/\/pending-approval$/);
|
|
36
|
+
await expect(page.getByRole("heading", { name: "Waiting for approval" })).toBeVisible();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("configured social providers appear on the login page", async ({ page, playwright }) => {
|
|
40
|
+
const admin = await playwright.request.newContext({
|
|
41
|
+
baseURL: base,
|
|
42
|
+
extraHTTPHeaders: { origin: base },
|
|
43
|
+
});
|
|
44
|
+
await admin.post("/api/auth/sign-in/email", {
|
|
45
|
+
data: { email: ADMIN.email, password: ADMIN.password },
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
expect(
|
|
49
|
+
(await admin.put("/api/account/auth-config", {
|
|
50
|
+
data: {
|
|
51
|
+
social: {
|
|
52
|
+
google: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
clientId: "dummy-google-client-id",
|
|
55
|
+
clientSecret: "dummy-google-client-secret",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
})).ok(),
|
|
60
|
+
).toBeTruthy();
|
|
61
|
+
await expect
|
|
62
|
+
.poll(async () => {
|
|
63
|
+
await page.goto("/login");
|
|
64
|
+
return page.getByRole("button", { name: "Continue with Google" }).count();
|
|
65
|
+
}, { timeout: 8_000 })
|
|
66
|
+
.toBe(1);
|
|
67
|
+
} finally {
|
|
68
|
+
await admin.put("/api/account/auth-config", {
|
|
69
|
+
data: { social: { google: { enabled: false } } },
|
|
70
|
+
});
|
|
71
|
+
await admin.dispose();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
31
75
|
test("forgot-password shows a sent confirmation", async ({ page }) => {
|
|
32
76
|
await ready(page, "/forgot-password");
|
|
33
77
|
// Retry the submit to absorb any first-load hydration timing.
|
|
@@ -88,4 +88,5 @@ test("settings: the require-two-factor toggle is available @smoke", async ({ pag
|
|
|
88
88
|
// the require-2fa api/ui specs. Not toggled here — turning it on would gate this
|
|
89
89
|
// admin session.
|
|
90
90
|
await expect(page.getByRole("switch", { name: "Require two-factor" })).toBeVisible();
|
|
91
|
+
await expect(page.getByRole("switch", { name: "Sign-up approval" })).toBeVisible();
|
|
91
92
|
});
|