@podosoft/podokit 0.12.4 → 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.
Files changed (50) hide show
  1. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -1
  2. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
  3. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +109 -0
  4. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +5 -0
  5. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +8 -1
  6. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +8 -1
  7. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +8 -1
  8. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +73 -2
  9. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +3 -2
  10. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +5 -0
  11. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +15 -1
  12. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +44 -4
  13. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +10 -10
  14. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +3 -3
  15. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -2
  16. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +3 -3
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +6 -6
  18. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +1 -1
  19. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +2 -2
  20. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +1 -1
  21. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
  22. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +3 -3
  23. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +2 -2
  24. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +1 -1
  25. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  26. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +1 -1
  27. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +2 -2
  28. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  29. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +2 -2
  30. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +32 -2
  31. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +77 -7
  32. package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +1 -1
  33. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +1 -1
  34. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
  35. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +4 -4
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +1 -1
  38. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  39. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +3 -6
  40. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +6 -0
  41. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +12 -5
  42. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -1
  43. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +55 -3
  44. package/dist/templates/modules/auth/module.manifest.json +5 -4
  45. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  46. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  47. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  48. package/dist/templates/todo/apps/api/package.json +1 -1
  49. package/dist/templates/todo/apps/web/package.json +1 -1
  50. 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.2.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",
@@ -13,7 +13,7 @@
13
13
  "dependencies": {
14
14
  "@internationalized/date": "^3.12.2",
15
15
  "@lucide/svelte": "^1.23.0",
16
- "@podosoft/podokit-api-client": "^0.6.0",
16
+ "@podosoft/podokit-api-client": "^0.7.0",
17
17
  "bits-ui": "^2.18.1",
18
18
  "clsx": "^2.1.1",
19
19
  "mode-watcher": "^1.1.0",
@@ -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>
@@ -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: { requireEmailVerification: boolean; requireSignupApproval: boolean; allowDelete: boolean; hibp: boolean; auditLog: boolean };
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
  };
@@ -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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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("new registrations require approval when the policy is enabled @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ playwright }) => {
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 @smoke", async ({ page, playwright }) => {
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 @smoke", async ({ page, playwright }) => {
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 @smoke", async ({ page, playwright }) => {
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 {