@podosoft/podokit 0.17.1 → 0.17.2

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.
@@ -384,6 +384,8 @@
384
384
  }
385
385
 
386
386
  // Sessions — this user's own devices
387
+ /** better-auth's code for "this session is older than `session.freshAge`". */
388
+ const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
387
389
  type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
388
390
  const PAGE_SIZE = DEFAULT_PAGE_SIZE;
389
391
  let sessions = $state<Session[]>([]);
@@ -397,8 +399,30 @@
397
399
  { key: "status", label: i18n.t.sessions.status },
398
400
  { key: "actions", label: "", class: "w-10" },
399
401
  ];
402
+ /**
403
+ * True when the session is too old to list sessions.
404
+ *
405
+ * better-auth guards `/list-sessions` (and `/unlink-account`) with its freshness
406
+ * middleware, and `session.freshAge` defaults to 24 hours. So for anyone who signed
407
+ * in yesterday — which is most people, most of the time — this call returns 403
408
+ * `SESSION_NOT_FRESH`. It is not a failure to report as one: the tab has to say what
409
+ * happened and what to do, or it is a blank table under an untranslated toast.
410
+ */
411
+ let sessionsStale = $state(false);
412
+ async function signOutToRefresh(): Promise<void> {
413
+ busy = true;
414
+ await api.auth.signOut();
415
+ await goto("/login", { invalidateAll: true });
416
+ }
400
417
  async function loadSessions(): Promise<void> {
401
418
  const { data: res, error } = await api.auth.listSessions();
419
+ if (error?.code === SESSION_NOT_FRESH) {
420
+ // Explained in the panel below, not thrown at the corner of the screen.
421
+ sessionsStale = true;
422
+ sessions = [];
423
+ return;
424
+ }
425
+ sessionsStale = false;
402
426
  if (error) return void toast.error(error.message ?? i18n.t.sessions.loadFailed);
403
427
  // Current session first (users expect it on top; also keeps it on page 1),
404
428
  // then newest to oldest.
@@ -791,9 +815,21 @@
791
815
  <Card.Root>
792
816
  <Card.Header class="flex flex-row items-center justify-between gap-2">
793
817
  <Card.Title>{i18n.t.sessions.title}</Card.Title>
794
- <Button variant="outline" size="sm" disabled={busy || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
818
+ <Button variant="outline" size="sm" disabled={busy || sessionsStale || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
795
819
  </Card.Header>
796
820
  <Card.Content>
821
+ {#if sessionsStale}
822
+ <!-- The list is withheld by the server, so the card explains the rule and
823
+ offers the only thing that satisfies it. A toast would have scrolled
824
+ away and left an empty table behind it. -->
825
+ <div class="flex flex-col items-start gap-3" data-testid="sessions-stale">
826
+ <p class="text-muted-foreground text-sm">{i18n.t.sessions.staleTitle}</p>
827
+ <p class="text-muted-foreground text-xs">{i18n.t.sessions.staleHint}</p>
828
+ <Button variant="outline" size="sm" disabled={busy} onclick={signOutToRefresh} data-testid="sessions-stale-signin">
829
+ {i18n.t.sessions.staleAction}
830
+ </Button>
831
+ </div>
832
+ {:else}
797
833
  <DataTable
798
834
  columns={sessionsColumns}
799
835
  rows={sessions}
@@ -815,6 +851,7 @@
815
851
  </Table.Cell>
816
852
  {/snippet}
817
853
  </DataTable>
854
+ {/if}
818
855
  </Card.Content>
819
856
  </Card.Root>
820
857
  {:else if section === "apiKeys"}
@@ -483,7 +483,10 @@
483
483
  "switchAccount": "Switch",
484
484
  "switchFailed": "Failed to switch account",
485
485
  "othersRevoked": "Signed out other sessions",
486
- "total": "{count} sessions"
486
+ "total": "{count} sessions",
487
+ "staleTitle": "Sign in again to see where you are signed in.",
488
+ "staleHint": "For your security this list is only shown to a session that signed in recently.",
489
+ "staleAction": "Sign in again"
487
490
  },
488
491
  "adminSessions": {
489
492
  "title": "User sessions",
@@ -483,7 +483,10 @@
483
483
  "switchAccount": "전환",
484
484
  "switchFailed": "계정 전환에 실패했습니다",
485
485
  "othersRevoked": "다른 세션을 모두 종료했습니다",
486
- "total": "세션 {count}개"
486
+ "total": "세션 {count}개",
487
+ "staleTitle": "어디에서 로그인되어 있는지 보려면 다시 로그인하세요.",
488
+ "staleHint": "보안을 위해 이 목록은 최근에 로그인한 세션에만 보여줍니다.",
489
+ "staleAction": "다시 로그인"
487
490
  },
488
491
  "adminSessions": {
489
492
  "title": "사용자 세션",
@@ -170,6 +170,39 @@ test("account security and sessions sub-navigation", async ({ page }) => {
170
170
  await expect(page.getByRole("button", { name: "Sign out other sessions" })).toBeVisible();
171
171
  });
172
172
 
173
+ /**
174
+ * ⚠ THE SUITE ALWAYS SIGNS IN FIRST, WHICH IS WHY THIS NEEDED A MOCK.
175
+ *
176
+ * better-auth guards `/list-sessions` with its freshness middleware and
177
+ * `session.freshAge` defaults to 24 hours, so the endpoint returns 403
178
+ * `SESSION_NOT_FRESH` to anyone who signed in yesterday. The seeded session is
179
+ * minutes old, so the test above passes for every run while the screen is broken for
180
+ * real users — the tab showed an empty table under an untranslated "Session is not
181
+ * fresh" toast. Forcing the response is the only way to reach the state the suite's
182
+ * own setup prevents.
183
+ */
184
+ test("sessions tab explains a session that is too old to list them", async ({ page }) => {
185
+ await page.route("**/api/auth/list-sessions", (route) =>
186
+ route.fulfill({
187
+ status: 403,
188
+ contentType: "application/json",
189
+ body: JSON.stringify({ message: "Session is not fresh", code: "SESSION_NOT_FRESH" }),
190
+ }),
191
+ );
192
+
193
+ await ready(page, "/admin/account");
194
+ await page.getByRole("button", { name: "Sessions" }).click();
195
+
196
+ const panel = page.getByTestId("sessions-stale");
197
+ await expect(panel).toBeVisible();
198
+ // The raw upstream string must not be what a person reads.
199
+ await expect(page.getByText("Session is not fresh")).toHaveCount(0);
200
+ // And the one action that satisfies the rule is offered rather than described.
201
+ await expect(page.getByTestId("sessions-stale-signin")).toBeVisible();
202
+ // Revoking is meaningless while the list is withheld, so it is not offered either.
203
+ await expect(page.getByRole("button", { name: "Sign out other sessions" })).toBeDisabled();
204
+ });
205
+
173
206
  // Changing a password is the one form here whose mistake is silent: the change
174
207
  // succeeds, and the person finds out at the next sign-in with a password they
175
208
  // cannot reproduce.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {