@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.
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +38 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +4 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +4 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +33 -0
- package/package.json +1 -1
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte
CHANGED
|
@@ -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.
|
|
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": {
|