@podosoft/podokit 0.12.3 → 0.13.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/fullstack-nest-svelte/apps/api/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +109 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +73 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +15 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +44 -4
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +6 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +1 -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 +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +32 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +77 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +4 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -0
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +3 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +6 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +12 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +55 -3
- package/dist/templates/modules/auth/module.manifest.json +5 -4
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
- package/dist/templates/todo/apps/api/package.json +1 -1
- package/dist/templates/todo/apps/web/package.json +1 -1
- package/package.json +1 -1
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"migration:generate": "npm run typeorm -- migration:generate"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@podosoft/podokit-contracts": "^0.
|
|
17
|
+
"@podosoft/podokit-contracts": "^0.3.0",
|
|
18
18
|
"@nestjs/common": "^11.1.27",
|
|
19
19
|
"@nestjs/config": "^4.0.4",
|
|
20
20
|
"@nestjs/core": "^11.1.27",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from "$app/navigation";
|
|
3
|
+
import { onMount } from "svelte";
|
|
4
|
+
import { api } from "$lib/api";
|
|
5
|
+
|
|
6
|
+
let { timeoutMinutes }: { timeoutMinutes: number | null } = $props();
|
|
7
|
+
|
|
8
|
+
const ACTIVITY_KEY = "podokit:session-last-activity";
|
|
9
|
+
const SERVER_REFRESH_INTERVAL_MS = 60_000;
|
|
10
|
+
const activityEvents = ["pointerdown", "keydown", "touchstart", "scroll"] as const;
|
|
11
|
+
|
|
12
|
+
let mounted = $state(false);
|
|
13
|
+
let lastActivityAt = $state(Date.now());
|
|
14
|
+
let lastServerRefreshAt = $state(Date.now());
|
|
15
|
+
let signingOut = false;
|
|
16
|
+
let previousTimeoutMs: number | null = null;
|
|
17
|
+
const timeoutMs = $derived(timeoutMinutes === null ? null : timeoutMinutes * 60_000);
|
|
18
|
+
|
|
19
|
+
function storeActivity(value: number): void {
|
|
20
|
+
try {
|
|
21
|
+
localStorage.setItem(ACTIVITY_KEY, String(value));
|
|
22
|
+
} catch {
|
|
23
|
+
// Storage can be unavailable in hardened browser modes; this tab still works.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function signOutForIdleTimeout(): Promise<void> {
|
|
28
|
+
if (signingOut) return;
|
|
29
|
+
signingOut = true;
|
|
30
|
+
try {
|
|
31
|
+
await api.auth.signOut();
|
|
32
|
+
} finally {
|
|
33
|
+
try {
|
|
34
|
+
localStorage.removeItem(ACTIVITY_KEY);
|
|
35
|
+
} catch {
|
|
36
|
+
// Ignore unavailable storage while completing sign-out.
|
|
37
|
+
}
|
|
38
|
+
await goto("/login?reason=idle", { invalidateAll: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function refreshServerSession(): Promise<void> {
|
|
43
|
+
const { data, error } = await api.auth.getSession();
|
|
44
|
+
if (error || !data) await signOutForIdleTimeout();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function recordActivity(): void {
|
|
48
|
+
if (timeoutMs === null || signingOut) return;
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
if (now - lastActivityAt >= timeoutMs) {
|
|
51
|
+
void signOutForIdleTimeout();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
lastActivityAt = now;
|
|
55
|
+
storeActivity(now);
|
|
56
|
+
if (now - lastServerRefreshAt >= SERVER_REFRESH_INTERVAL_MS) {
|
|
57
|
+
lastServerRefreshAt = now;
|
|
58
|
+
void refreshServerSession();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function receiveActivity(event: StorageEvent): void {
|
|
63
|
+
if (event.key !== ACTIVITY_KEY || event.newValue === null) return;
|
|
64
|
+
const value = Number(event.newValue);
|
|
65
|
+
if (Number.isFinite(value) && value > lastActivityAt) lastActivityAt = value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
onMount(() => {
|
|
69
|
+
mounted = true;
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
lastActivityAt = now;
|
|
72
|
+
lastServerRefreshAt = now;
|
|
73
|
+
storeActivity(now);
|
|
74
|
+
for (const event of activityEvents) {
|
|
75
|
+
document.addEventListener(event, recordActivity, { passive: true, capture: true });
|
|
76
|
+
}
|
|
77
|
+
window.addEventListener("focus", recordActivity);
|
|
78
|
+
window.addEventListener("storage", receiveActivity);
|
|
79
|
+
return () => {
|
|
80
|
+
for (const event of activityEvents) {
|
|
81
|
+
document.removeEventListener(event, recordActivity, { capture: true });
|
|
82
|
+
}
|
|
83
|
+
window.removeEventListener("focus", recordActivity);
|
|
84
|
+
window.removeEventListener("storage", receiveActivity);
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
$effect(() => {
|
|
89
|
+
if (!mounted) return;
|
|
90
|
+
const currentTimeoutMs = timeoutMs;
|
|
91
|
+
if (currentTimeoutMs !== previousTimeoutMs) {
|
|
92
|
+
previousTimeoutMs = currentTimeoutMs;
|
|
93
|
+
if (currentTimeoutMs !== null) {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
lastActivityAt = now;
|
|
96
|
+
lastServerRefreshAt = now;
|
|
97
|
+
storeActivity(now);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (currentTimeoutMs === null) return;
|
|
101
|
+
const remaining = currentTimeoutMs - (Date.now() - lastActivityAt);
|
|
102
|
+
if (remaining <= 0) {
|
|
103
|
+
void signOutForIdleTimeout();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const timer = window.setTimeout(() => void signOutForIdleTimeout(), remaining);
|
|
107
|
+
return () => window.clearTimeout(timer);
|
|
108
|
+
});
|
|
109
|
+
</script>
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte
CHANGED
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
import { ModeWatcher } from "mode-watcher";
|
|
4
4
|
import { site, type SiteSettings } from "$lib/site.svelte";
|
|
5
5
|
import { applyTheme, themeConfigFromSettings } from "$lib/site/apply-theme";
|
|
6
|
+
import SessionIdleTimeout from "$lib/components/session-idle-timeout.svelte";
|
|
6
7
|
|
|
7
8
|
const loadedSite = $derived(
|
|
8
9
|
(page.data as { site?: Partial<SiteSettings> | null }).site,
|
|
9
10
|
);
|
|
11
|
+
const sessionIdleTimeoutMinutes = $derived(
|
|
12
|
+
(page.data as { sessionIdleTimeoutMinutes?: number | null }).sessionIdleTimeoutMinutes ?? null,
|
|
13
|
+
);
|
|
10
14
|
|
|
11
15
|
// Seed the reactive site settings for every route without taking ownership of
|
|
12
16
|
// the application's root layout or public pages.
|
|
@@ -37,3 +41,4 @@
|
|
|
37
41
|
</svelte:head>
|
|
38
42
|
|
|
39
43
|
<ModeWatcher />
|
|
44
|
+
<SessionIdleTimeout timeoutMinutes={sessionIdleTimeoutMinutes} />
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"noAccount": "No account?",
|
|
76
76
|
"signUp": "Sign up",
|
|
77
77
|
"signInFailed": "Sign in failed",
|
|
78
|
+
"idleLogout": "You were signed out after a period of inactivity. Sign in to continue.",
|
|
78
79
|
"createTitle": "Create account",
|
|
79
80
|
"createDesc": "Sign up to get started.",
|
|
80
81
|
"name": "Name",
|
|
@@ -317,6 +318,12 @@
|
|
|
317
318
|
"twoFactorDesc": "TOTP second factor with backup codes.",
|
|
318
319
|
"require2fa": "Require two-factor",
|
|
319
320
|
"require2faDesc": "Every user must enrol in 2FA after signing up before using the app.",
|
|
321
|
+
"autoLogout": "Automatic logout",
|
|
322
|
+
"autoLogoutDesc": "End sessions after a period without user activity.",
|
|
323
|
+
"autoLogoutMinutes": "{minutes} min",
|
|
324
|
+
"autoLogoutDialogDesc": "Choose how long a signed-in user can remain inactive. The policy applies to every account and open browser tab.",
|
|
325
|
+
"autoLogoutField": "Idle time (minutes)",
|
|
326
|
+
"autoLogoutHint": "Enter a whole number from 5 to 10080 minutes (7 days).",
|
|
320
327
|
"socialProviders": "Social providers",
|
|
321
328
|
"socialProvidersDesc": "OAuth sign-in (Google, GitHub).",
|
|
322
329
|
"breachCheck": "Breached-password check",
|
|
@@ -330,7 +337,7 @@
|
|
|
330
337
|
"emailTitle": "Email",
|
|
331
338
|
"emailSubtitle": "Outgoing mail (SMTP) and the email-verification policy.",
|
|
332
339
|
"securityTitle": "Security & policy",
|
|
333
|
-
"securitySubtitle": "Second factor, breached-password rejection, account deletion, and audit logging.",
|
|
340
|
+
"securitySubtitle": "Second factor, automatic logout, breached-password rejection, account deletion, and audit logging.",
|
|
334
341
|
"advancedTitle": "Advanced",
|
|
335
342
|
"advancedSubtitle": "API keys, organizations, and acting as an OIDC identity provider.",
|
|
336
343
|
"socialTitle": "Social login",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"noAccount": "계정이 없으신가요?",
|
|
76
76
|
"signUp": "회원가입",
|
|
77
77
|
"signInFailed": "로그인에 실패했습니다",
|
|
78
|
+
"idleLogout": "활동이 없어 자동으로 로그아웃되었습니다. 계속하려면 다시 로그인하세요.",
|
|
78
79
|
"createTitle": "회원가입",
|
|
79
80
|
"createDesc": "가입하고 시작하세요.",
|
|
80
81
|
"name": "이름",
|
|
@@ -317,6 +318,12 @@
|
|
|
317
318
|
"twoFactorDesc": "TOTP 2차 인증 + 백업 코드.",
|
|
318
319
|
"require2fa": "2FA 필수화",
|
|
319
320
|
"require2faDesc": "모든 사용자가 가입 후 2FA를 등록해야 앱을 사용할 수 있습니다.",
|
|
321
|
+
"autoLogout": "자동 로그아웃",
|
|
322
|
+
"autoLogoutDesc": "사용자 활동이 없는 시간이 지나면 세션을 종료합니다.",
|
|
323
|
+
"autoLogoutMinutes": "{minutes}분",
|
|
324
|
+
"autoLogoutDialogDesc": "로그인한 사용자가 활동 없이 유지할 수 있는 시간을 설정합니다. 모든 계정과 열린 브라우저 탭에 적용됩니다.",
|
|
325
|
+
"autoLogoutField": "활동 없음 시간(분)",
|
|
326
|
+
"autoLogoutHint": "5분부터 10080분(7일) 사이의 정수를 입력하세요.",
|
|
320
327
|
"socialProviders": "소셜 로그인",
|
|
321
328
|
"socialProvidersDesc": "OAuth 로그인 (Google, GitHub).",
|
|
322
329
|
"breachCheck": "유출 비밀번호 검사",
|
|
@@ -330,7 +337,7 @@
|
|
|
330
337
|
"emailTitle": "이메일",
|
|
331
338
|
"emailSubtitle": "메일 발송(SMTP) 설정과 이메일 인증 정책.",
|
|
332
339
|
"securityTitle": "보안·정책",
|
|
333
|
-
"securitySubtitle": "2단계 인증, 유출 비밀번호 거부, 계정 삭제, 감사 로그.",
|
|
340
|
+
"securitySubtitle": "2단계 인증, 자동 로그아웃, 유출 비밀번호 거부, 계정 삭제, 감사 로그.",
|
|
334
341
|
"advancedTitle": "고급",
|
|
335
342
|
"advancedSubtitle": "API 키, 조직, OIDC 공급자(자체 IdP) 역할.",
|
|
336
343
|
"socialTitle": "소셜 로그인",
|
|
@@ -10,7 +10,14 @@ 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: {
|
|
13
|
+
server: {
|
|
14
|
+
requireEmailVerification: boolean;
|
|
15
|
+
requireSignupApproval: boolean;
|
|
16
|
+
allowDelete: boolean;
|
|
17
|
+
hibp: boolean;
|
|
18
|
+
auditLog: boolean;
|
|
19
|
+
sessionIdleTimeoutMinutes: number | null;
|
|
20
|
+
};
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { moduleSettingsSections } from "$lib/admin/registry.svelte";
|
|
15
15
|
import { toast } from "svelte-sonner";
|
|
16
16
|
import { api } from "$lib/api";
|
|
17
|
-
import { getI18n } from "$lib/i18n";
|
|
17
|
+
import { fmt, getI18n } from "$lib/i18n";
|
|
18
18
|
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
19
19
|
import type { AuthConfigView, SocialProviderView } from "./+page.server";
|
|
20
20
|
|
|
@@ -93,14 +93,21 @@
|
|
|
93
93
|
// ── Dialogs. Secret fields are write-only; leaving them blank keeps the stored
|
|
94
94
|
// secret. The Social login dialog manages every provider (list ⇄ add/edit
|
|
95
95
|
// form) in one popup. ──
|
|
96
|
-
let openDialog = $state<"social" | "smtp" | null>(null);
|
|
96
|
+
let openDialog = $state<"social" | "smtp" | "sessionIdle" | null>(null);
|
|
97
97
|
let socialView = $state<"list" | "form">("list");
|
|
98
98
|
let socialForm = $state({ id: "", clientId: "", clientSecret: "", redirectURI: "", enabled: true, adding: false });
|
|
99
99
|
let smtp = $state({ enabled: false, host: "", port: 587, secure: false, user: "", from: "", pass: "" });
|
|
100
|
+
let sessionIdleTimeoutMinutes = $state(30);
|
|
100
101
|
$effect(() => {
|
|
101
102
|
if (!ac) return;
|
|
102
103
|
smtp = { enabled: ac.smtp.enabled, host: ac.smtp.host, port: ac.smtp.port, secure: ac.smtp.secure, user: ac.smtp.user, from: ac.smtp.from, pass: "" };
|
|
104
|
+
sessionIdleTimeoutMinutes = ac.server.sessionIdleTimeoutMinutes ?? 30;
|
|
103
105
|
});
|
|
106
|
+
const sessionIdleTimeoutValid = $derived(
|
|
107
|
+
Number.isInteger(Number(sessionIdleTimeoutMinutes))
|
|
108
|
+
&& Number(sessionIdleTimeoutMinutes) >= 5
|
|
109
|
+
&& Number(sessionIdleTimeoutMinutes) <= 10_080,
|
|
110
|
+
);
|
|
104
111
|
const editingView = $derived(socialForm.adding ? undefined : ac?.social[socialForm.id]);
|
|
105
112
|
|
|
106
113
|
function socialRedirectURI(): string {
|
|
@@ -142,6 +149,22 @@
|
|
|
142
149
|
const done = await put({ smtp: { enabled: smtp.enabled, host: smtp.host, port: Number(smtp.port), secure: smtp.secure, user: smtp.user, from: smtp.from, ...(smtp.pass ? { pass: smtp.pass } : {}) } });
|
|
143
150
|
if (done) openDialog = null;
|
|
144
151
|
}
|
|
152
|
+
async function toggleSessionIdle(enabled: boolean): Promise<void> {
|
|
153
|
+
await put({
|
|
154
|
+
server: {
|
|
155
|
+
sessionIdleTimeoutMinutes: enabled
|
|
156
|
+
? (ac?.server.sessionIdleTimeoutMinutes ?? 30)
|
|
157
|
+
: null,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async function saveSessionIdleTimeout(): Promise<void> {
|
|
162
|
+
if (!sessionIdleTimeoutValid) return;
|
|
163
|
+
const done = await put({
|
|
164
|
+
server: { sessionIdleTimeoutMinutes: Number(sessionIdleTimeoutMinutes) },
|
|
165
|
+
});
|
|
166
|
+
if (done) openDialog = null;
|
|
167
|
+
}
|
|
145
168
|
|
|
146
169
|
// ── Card builders (uniform Item shape) ──
|
|
147
170
|
const flagCard = (flag: EditableFlag, name: string, desc: string): Item => ({
|
|
@@ -199,6 +222,28 @@
|
|
|
199
222
|
// Only meaningful when 2FA itself is on; disabled otherwise.
|
|
200
223
|
toggle: { checked: data.require2fa, disabled: saving || !caps.twoFactor, onChange: (v) => toggleFlag("require2fa", v) },
|
|
201
224
|
},
|
|
225
|
+
{
|
|
226
|
+
key: "session-idle-timeout",
|
|
227
|
+
name: i18n.t.settings.autoLogout,
|
|
228
|
+
desc: i18n.t.settings.autoLogoutDesc,
|
|
229
|
+
status: ac?.server.sessionIdleTimeoutMinutes
|
|
230
|
+
? {
|
|
231
|
+
on: true,
|
|
232
|
+
label: fmt(i18n.t.settings.autoLogoutMinutes, {
|
|
233
|
+
minutes: ac.server.sessionIdleTimeoutMinutes,
|
|
234
|
+
}),
|
|
235
|
+
}
|
|
236
|
+
: { on: false, label: i18n.t.settings.disabled },
|
|
237
|
+
toggle: {
|
|
238
|
+
checked: ac?.server.sessionIdleTimeoutMinutes !== null && ac?.server.sessionIdleTimeoutMinutes !== undefined,
|
|
239
|
+
disabled: saving || !ac,
|
|
240
|
+
onChange: toggleSessionIdle,
|
|
241
|
+
},
|
|
242
|
+
configure: {
|
|
243
|
+
label: i18n.t.settings.configure,
|
|
244
|
+
onClick: () => (openDialog = "sessionIdle"),
|
|
245
|
+
},
|
|
246
|
+
},
|
|
202
247
|
serverCard("hibp", i18n.t.settings.breachCheck, i18n.t.settings.breachCheckDesc, caps.passwordBreachCheck),
|
|
203
248
|
serverCard("allowDelete", i18n.t.settings.accountDeletion, i18n.t.settings.accountDeletionDesc, caps.deleteAccount),
|
|
204
249
|
serverCard("auditLog", i18n.t.settings.auditLog, i18n.t.settings.auditLogDesc, caps.auditLog),
|
|
@@ -352,6 +397,32 @@
|
|
|
352
397
|
</Dialog.Content>
|
|
353
398
|
</Dialog.Root>
|
|
354
399
|
|
|
400
|
+
<!-- Sliding session expiration — enforced by better-auth and mirrored by the browser timer. -->
|
|
401
|
+
<Dialog.Root open={openDialog === "sessionIdle"} onOpenChange={(v) => { if (!v) openDialog = null; }}>
|
|
402
|
+
<Dialog.Content>
|
|
403
|
+
<Dialog.Header>
|
|
404
|
+
<Dialog.Title>{i18n.t.settings.autoLogout}</Dialog.Title>
|
|
405
|
+
<Dialog.Description>{i18n.t.settings.autoLogoutDialogDesc}</Dialog.Description>
|
|
406
|
+
</Dialog.Header>
|
|
407
|
+
<div class="flex flex-col gap-2">
|
|
408
|
+
<Label for="session-idle-timeout">{i18n.t.settings.autoLogoutField}</Label>
|
|
409
|
+
<Input
|
|
410
|
+
id="session-idle-timeout"
|
|
411
|
+
type="number"
|
|
412
|
+
min="5"
|
|
413
|
+
max="10080"
|
|
414
|
+
step="1"
|
|
415
|
+
bind:value={sessionIdleTimeoutMinutes}
|
|
416
|
+
/>
|
|
417
|
+
<p class="text-muted-foreground text-xs">{i18n.t.settings.autoLogoutHint}</p>
|
|
418
|
+
</div>
|
|
419
|
+
<Dialog.Footer>
|
|
420
|
+
<Button variant="outline" onclick={() => (openDialog = null)}>{i18n.t.settings.cancel}</Button>
|
|
421
|
+
<Button disabled={saving || !sessionIdleTimeoutValid} onclick={saveSessionIdleTimeout}>{i18n.t.settings.save}</Button>
|
|
422
|
+
</Dialog.Footer>
|
|
423
|
+
</Dialog.Content>
|
|
424
|
+
</Dialog.Root>
|
|
425
|
+
|
|
355
426
|
<!-- SMTP (email delivery) dialog — reached from the email-verification card -->
|
|
356
427
|
<Dialog.Root open={openDialog === "smtp"} onOpenChange={(v) => { if (!v) openDialog = null; }}>
|
|
357
428
|
<Dialog.Content>
|
|
@@ -6,12 +6,13 @@ import { SIGNUP_APPROVAL_REQUIRED, type Capabilities } from "@podosoft/podokit-a
|
|
|
6
6
|
// (magic link, ...) the server actually enabled.
|
|
7
7
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
|
8
8
|
const oauthError = url.searchParams.get("error");
|
|
9
|
+
const idleLogout = url.searchParams.get("reason") === "idle";
|
|
9
10
|
if (oauthError === SIGNUP_APPROVAL_REQUIRED) redirect(302, "/pending-approval");
|
|
10
11
|
try {
|
|
11
12
|
const res = await fetch("/api/account/capabilities");
|
|
12
|
-
if (res.ok) return { capabilities: (await res.json()) as Capabilities, oauthError };
|
|
13
|
+
if (res.ok) return { capabilities: (await res.json()) as Capabilities, oauthError, idleLogout };
|
|
13
14
|
} catch {
|
|
14
15
|
/* fall through to defaults */
|
|
15
16
|
}
|
|
16
|
-
return { capabilities: null, oauthError };
|
|
17
|
+
return { capabilities: null, oauthError, idleLogout };
|
|
17
18
|
};
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte
CHANGED
|
@@ -226,6 +226,11 @@
|
|
|
226
226
|
<Card.Description>{i18n.t.auth.signInDesc}</Card.Description>
|
|
227
227
|
</Card.Header>
|
|
228
228
|
<Card.Content>
|
|
229
|
+
{#if data.idleLogout}
|
|
230
|
+
<Alert.Root data-testid="session-timeout-message" class="mb-4">
|
|
231
|
+
<Alert.Description>{i18n.t.auth.idleLogout}</Alert.Description>
|
|
232
|
+
</Alert.Root>
|
|
233
|
+
{/if}
|
|
229
234
|
{#if error}
|
|
230
235
|
<Alert.Root data-testid="auth-error" variant="destructive" class="mb-4">
|
|
231
236
|
<Alert.Description>
|
|
@@ -3,6 +3,7 @@ import { redirect } from "@sveltejs/kit";
|
|
|
3
3
|
import { safeAuthRedirect } from "$lib/auth-redirect";
|
|
4
4
|
import { loadMessages } from "$lib/i18n/messages";
|
|
5
5
|
import { isPublicPath, requireBackendAvailable } from "$lib/server/guards";
|
|
6
|
+
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
6
7
|
|
|
7
8
|
// "/" is a public landing page. Auth pages return signed-in users to their safe
|
|
8
9
|
// requested page, and the admin area requires a session. /maintenance is public
|
|
@@ -42,6 +43,19 @@ export const load: LayoutServerLoad = async (event) => {
|
|
|
42
43
|
if (!locals.user && !isPublicPath(pathname)) {
|
|
43
44
|
redirect(303, `/login?redirect=${encodeURIComponent(`${pathname}${url.search}`)}`);
|
|
44
45
|
}
|
|
46
|
+
let sessionIdleTimeoutMinutes: number | null = null;
|
|
47
|
+
if (locals.user) {
|
|
48
|
+
try {
|
|
49
|
+
const response = await event.fetch("/api/account/capabilities");
|
|
50
|
+
if (response.ok) {
|
|
51
|
+
const capabilities = (await response.json()) as Capabilities;
|
|
52
|
+
sessionIdleTimeoutMinutes = capabilities.sessionIdleTimeoutMinutes ?? null;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Keep automatic logout disabled in the browser if policy lookup fails.
|
|
56
|
+
// The server-side session expiration remains authoritative.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
45
59
|
const messages = await loadMessages(locals.locale, site?.locale);
|
|
46
|
-
return { user: locals.user, locale: locals.locale, messages, site };
|
|
60
|
+
return { user: locals.user, locale: locals.locale, messages, site, sessionIdleTimeoutMinutes };
|
|
47
61
|
};
|
|
@@ -16,7 +16,7 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
|
|
|
16
16
|
return ctx;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
test("admin can enable Google OAuth from the DB and it applies without a restart
|
|
19
|
+
test("admin can enable Google OAuth from the DB and it applies without a restart", async ({ playwright }) => {
|
|
20
20
|
const admin = await adminCtx(playwright);
|
|
21
21
|
await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // clean start
|
|
22
22
|
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
@@ -50,7 +50,7 @@ test("admin can enable Google OAuth from the DB and it applies without a restart
|
|
|
50
50
|
await admin.dispose();
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
test("admin can add and remove a social provider dynamically
|
|
53
|
+
test("admin can add and remove a social provider dynamically", async ({ playwright }) => {
|
|
54
54
|
const admin = await adminCtx(playwright);
|
|
55
55
|
// The catalog offers providers beyond the two legacy env-backed ones.
|
|
56
56
|
const catalog = (await (await admin.get("/api/account/auth-config")).json()).catalog as Array<{ id: string }>;
|
|
@@ -83,7 +83,47 @@ test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwr
|
|
|
83
83
|
await user.dispose();
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test("
|
|
86
|
+
test("automatic logout validates and applies a sliding session lifetime", async ({ playwright }) => {
|
|
87
|
+
const admin = await adminCtx(playwright);
|
|
88
|
+
const freshAdmin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
expect(
|
|
92
|
+
(await admin.put("/api/account/auth-config", {
|
|
93
|
+
data: { server: { sessionIdleTimeoutMinutes: 4 } },
|
|
94
|
+
})).status(),
|
|
95
|
+
).toBe(400);
|
|
96
|
+
|
|
97
|
+
const enabled = await admin.put("/api/account/auth-config", {
|
|
98
|
+
data: { server: { sessionIdleTimeoutMinutes: 30 } },
|
|
99
|
+
});
|
|
100
|
+
expect(enabled.ok()).toBeTruthy();
|
|
101
|
+
expect((await enabled.json()).server.sessionIdleTimeoutMinutes).toBe(30);
|
|
102
|
+
|
|
103
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
104
|
+
expect(caps.sessionIdleTimeoutMinutes).toBe(30);
|
|
105
|
+
|
|
106
|
+
const signIn = await freshAdmin.post("/api/auth/sign-in/email", {
|
|
107
|
+
data: { email: ADMIN.email, password: ADMIN.password },
|
|
108
|
+
});
|
|
109
|
+
expect(signIn.ok()).toBeTruthy();
|
|
110
|
+
expect(signIn.headers()["set-cookie"]).toMatch(/Max-Age=1800(?:;|,)/i);
|
|
111
|
+
|
|
112
|
+
const sessions = (await (await admin.get("/api/auth/list-sessions")).json()) as Array<{
|
|
113
|
+
expiresAt: string;
|
|
114
|
+
}>;
|
|
115
|
+
const latestExpiry = Math.max(...sessions.map((session) => new Date(session.expiresAt).getTime()));
|
|
116
|
+
expect(latestExpiry).toBeLessThanOrEqual(Date.now() + 30 * 60_000 + 10_000);
|
|
117
|
+
} finally {
|
|
118
|
+
await admin.put("/api/account/auth-config", {
|
|
119
|
+
data: { server: { sessionIdleTimeoutMinutes: null } },
|
|
120
|
+
});
|
|
121
|
+
await freshAdmin.dispose();
|
|
122
|
+
await admin.dispose();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("new registrations require approval when the policy is enabled", async ({ playwright }) => {
|
|
87
127
|
const admin = await adminCtx(playwright);
|
|
88
128
|
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
89
129
|
const password = "Podokit3e-Str0ng!pw";
|
|
@@ -162,7 +202,7 @@ test("new registrations require approval when the policy is enabled @smoke", asy
|
|
|
162
202
|
}
|
|
163
203
|
});
|
|
164
204
|
|
|
165
|
-
test("closed public sign-up blocks new OAuth users but preserves existing sign-in
|
|
205
|
+
test("closed public sign-up blocks new OAuth users but preserves existing sign-in", async ({ playwright }) => {
|
|
166
206
|
const admin = await adminCtx(playwright);
|
|
167
207
|
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
168
208
|
const password = "Podokit3e-Str0ng!pw";
|
|
@@ -12,7 +12,7 @@ test("account/me is 401 when unauthenticated @smoke", async ({ request }) => {
|
|
|
12
12
|
expect(res.status()).toBe(401);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
test("rejects a breached password on sign-up
|
|
15
|
+
test("rejects a breached password on sign-up", async ({ playwright }) => {
|
|
16
16
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
17
17
|
await ctx.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
|
|
18
18
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
@@ -25,7 +25,7 @@ test("rejects a breached password on sign-up @smoke", async ({ playwright }) =>
|
|
|
25
25
|
await ctx.dispose();
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
test("phone number: verify with the code delivered to the SMS sink
|
|
28
|
+
test("phone number: verify with the code delivered to the SMS sink", async ({ playwright }) => {
|
|
29
29
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
30
30
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
31
31
|
test.skip(!caps?.phoneNumber, "phone number not enabled");
|
|
@@ -74,7 +74,7 @@ test("two-factor: enable with a real TOTP code and require it at sign-in @smoke"
|
|
|
74
74
|
await ctx2.dispose();
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test("two-factor: a backup code completes sign-in and is single-use
|
|
77
|
+
test("two-factor: a backup code completes sign-in and is single-use", async ({ playwright }) => {
|
|
78
78
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
79
79
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
80
80
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
@@ -110,7 +110,7 @@ test("two-factor: a backup code completes sign-in and is single-use @smoke", asy
|
|
|
110
110
|
await ctx3.dispose();
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
test("two-factor: regenerating backup codes invalidates the old set
|
|
113
|
+
test("two-factor: regenerating backup codes invalidates the old set", async ({ playwright }) => {
|
|
114
114
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
115
115
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
116
116
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
@@ -142,7 +142,7 @@ test("two-factor: regenerating backup codes invalidates the old set @smoke", asy
|
|
|
142
142
|
await ctxNew.dispose();
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
-
test("require-2fa: gates an un-enrolled user until they enrol
|
|
145
|
+
test("require-2fa: gates an un-enrolled user until they enrol", async ({ playwright }) => {
|
|
146
146
|
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
147
147
|
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
148
148
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
@@ -180,7 +180,7 @@ test("require-2fa: gates an un-enrolled user until they enrol @smoke", async ({
|
|
|
180
180
|
}
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
-
test("multi-session holds several accounts and switches between them
|
|
183
|
+
test("multi-session holds several accounts and switches between them", async ({ playwright }) => {
|
|
184
184
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
185
185
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
186
186
|
test.skip(!caps?.multiSession, "multi-session not enabled");
|
|
@@ -201,7 +201,7 @@ test("multi-session holds several accounts and switches between them @smoke", as
|
|
|
201
201
|
await ctx.dispose();
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
-
test("a user can sign in with a username
|
|
204
|
+
test("a user can sign in with a username", async ({ playwright }) => {
|
|
205
205
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
206
206
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
207
207
|
test.skip(!caps?.username, "username not enabled");
|
|
@@ -219,7 +219,7 @@ test("a user can sign in with a username @smoke", async ({ playwright }) => {
|
|
|
219
219
|
await ctx2.dispose();
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
-
test("bearer token authenticates API requests without a cookie
|
|
222
|
+
test("bearer token authenticates API requests without a cookie", async ({ playwright }) => {
|
|
223
223
|
const email = `bearer-${Date.now()}@example.com`;
|
|
224
224
|
const pw = "Podokit3e-Str0ng!pw";
|
|
225
225
|
const signup = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
@@ -241,7 +241,7 @@ test("bearer token authenticates API requests without a cookie @smoke", async ({
|
|
|
241
241
|
await api.dispose();
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
-
test("a user can create, list and revoke a personal API key
|
|
244
|
+
test("a user can create, list and revoke a personal API key", async ({ playwright }) => {
|
|
245
245
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
246
246
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
247
247
|
test.skip(!caps?.apiKey, "api keys not enabled");
|
|
@@ -310,7 +310,7 @@ test("a user can create, list and delete an organization @smoke", async ({ playw
|
|
|
310
310
|
await ctx.dispose();
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
-
test("an organization can have a parent and manager members
|
|
313
|
+
test("an organization can have a parent and manager members", async ({ playwright }) => {
|
|
314
314
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
315
315
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
316
316
|
test.skip(!caps?.organization, "organizations not enabled");
|
|
@@ -4,7 +4,7 @@ import { clearMailpit, mailpitReachable, waitForLink, waitForOtp } from "../help
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
5
|
const origin = { origin: base };
|
|
6
6
|
|
|
7
|
-
test("email one-time code signs a user in
|
|
7
|
+
test("email one-time code signs a user in", async ({ playwright }) => {
|
|
8
8
|
test.skip(!(await mailpitReachable()), "Mailpit not available");
|
|
9
9
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
10
10
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
@@ -22,7 +22,7 @@ test("email one-time code signs a user in @smoke", async ({ playwright }) => {
|
|
|
22
22
|
await ctx.dispose();
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
test("organization invitation can be sent and accepted
|
|
25
|
+
test("organization invitation can be sent and accepted", async ({ playwright }) => {
|
|
26
26
|
const owner = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
27
27
|
const caps = await (await owner.get("/api/account/capabilities")).json();
|
|
28
28
|
test.skip(!caps?.organization, "organizations not enabled");
|
|
@@ -49,7 +49,7 @@ test("organization invitation can be sent and accepted @smoke", async ({ playwri
|
|
|
49
49
|
await owner.dispose();
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
test("magic link signs a user in
|
|
52
|
+
test("magic link signs a user in", async ({ playwright }) => {
|
|
53
53
|
test.skip(!(await mailpitReachable()), "Mailpit not available");
|
|
54
54
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
55
55
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
@@ -11,7 +11,7 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
|
|
|
11
11
|
return ctx;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
test("theme settings: valid preset/radius/accent round-trip
|
|
14
|
+
test("theme settings: valid preset/radius/accent round-trip", async ({ playwright }) => {
|
|
15
15
|
const admin = await adminCtx(playwright);
|
|
16
16
|
const put = await admin.put("/api/site/settings", {
|
|
17
17
|
data: { themePreset: "slate", themeRadius: "0.75", brandColor: "#2563eb" },
|
|
@@ -29,7 +29,7 @@ test("theme settings: valid preset/radius/accent round-trip @smoke", async ({ pl
|
|
|
29
29
|
await anon.dispose();
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
test("theme settings: valid per-token overrides accepted
|
|
32
|
+
test("theme settings: valid per-token overrides accepted", async ({ playwright }) => {
|
|
33
33
|
const admin = await adminCtx(playwright);
|
|
34
34
|
const overrides = JSON.stringify({ light: { primary: "#123456" }, dark: { background: "#0a0a0a" } });
|
|
35
35
|
const put = await admin.put("/api/site/settings", { data: { themeOverrides: overrides } });
|
|
@@ -22,7 +22,7 @@ async function loginFresh(page: import("@playwright/test").Page, playwright: imp
|
|
|
22
22
|
await expect(page).toHaveURL(/\/admin\/account/);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
test("account: register and verify a phone number
|
|
25
|
+
test("account: register and verify a phone number", async ({ page, playwright }) => {
|
|
26
26
|
test.skip(!(await smsSinkReachable()), "sms sink unreachable");
|
|
27
27
|
await loginFresh(page, playwright, `acc-ph-${Date.now()}@example.com`);
|
|
28
28
|
const phoneInput = page.locator("#phone");
|
|
@@ -38,14 +38,14 @@ test("account: register and verify a phone number @smoke", async ({ page, playwr
|
|
|
38
38
|
await expect(page.getByText("Phone verified")).toBeVisible();
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
test("account: change email address
|
|
41
|
+
test("account: change email address", async ({ page, playwright }) => {
|
|
42
42
|
await loginFresh(page, playwright, `acc-em-${Date.now()}@example.com`);
|
|
43
43
|
await page.locator("#email").fill(`changed-${Date.now()}@example.com`);
|
|
44
44
|
await page.getByRole("button", { name: "Change email" }).click();
|
|
45
45
|
await expect(page.getByText(/Email address updated|approve the change/)).toBeVisible();
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
test("account: delete my own account
|
|
48
|
+
test("account: delete my own account", async ({ page, playwright }) => {
|
|
49
49
|
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
50
50
|
await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: PW } });
|
|
51
51
|
try {
|