@podosoft/podokit 0.17.1 → 0.17.3
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/dev.js +24 -2
- package/dist/templates/fullstack-nest-svelte/README.md +3 -0
- 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/dist/templates/todo/README.md +3 -0
- package/dist/update.js +20 -0
- package/package.json +1 -1
package/dist/dev.js
CHANGED
|
@@ -156,10 +156,15 @@ function writeRuntimeFiles(runtime) {
|
|
|
156
156
|
(0, node_fs_1.writeFileSync)(runtime.runtimeComposePath, renderRuntimeCompose(runtime));
|
|
157
157
|
}
|
|
158
158
|
function registerRoute(runtime) {
|
|
159
|
-
const
|
|
159
|
+
const entries = activeEntries();
|
|
160
|
+
const collision = entries.find((entry) => entry.hostname === runtime.config.hostname && entry.projectRoot !== runtime.projectRoot);
|
|
160
161
|
if (collision) {
|
|
161
162
|
throw new Error(`Development hostname ${runtime.config.hostname} is already registered by ${collision.projectRoot}. Change .podokit/dev.json or run podo dev down there.`);
|
|
162
163
|
}
|
|
164
|
+
for (const stale of entries.filter((entry) => entry.projectRoot === runtime.projectRoot && entry.routeId !== runtime.routeId)) {
|
|
165
|
+
(0, node_fs_1.rmSync)((0, node_path_1.join)(registryDirectory(), `${stale.routeId}.json`), { force: true });
|
|
166
|
+
(0, node_fs_1.rmSync)((0, node_path_1.join)(routesDirectory(), `${stale.routeId}.yml`), { force: true });
|
|
167
|
+
}
|
|
163
168
|
(0, node_fs_1.mkdirSync)(registryDirectory(), { recursive: true });
|
|
164
169
|
(0, node_fs_1.mkdirSync)(routesDirectory(), { recursive: true });
|
|
165
170
|
(0, node_fs_1.writeFileSync)(routePath(runtime), renderRoute(runtime));
|
|
@@ -307,6 +312,23 @@ function runDevCommand(projectRoot, action, args, runner = defaultRunner) {
|
|
|
307
312
|
}
|
|
308
313
|
return;
|
|
309
314
|
}
|
|
315
|
+
if (action === "up") {
|
|
316
|
+
ensureNetwork(runner);
|
|
317
|
+
registerRoute(runtime);
|
|
318
|
+
try {
|
|
319
|
+
ensureGateway(runner);
|
|
320
|
+
process.stdout.write(`Development URL: http://${runtime.config.hostname}\n`);
|
|
321
|
+
if (runtime.config.publicUrl)
|
|
322
|
+
process.stdout.write(`Public OAuth URL: ${runtime.config.publicUrl}\n`);
|
|
323
|
+
runCompose(runtime, composeCommand("up", args, profiles), runner);
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
unregisterRoute(runtime);
|
|
327
|
+
stopGatewayWhenUnused(runner);
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
310
332
|
if (action === "down") {
|
|
311
333
|
runCompose(runtime, composeCommand("down", args, profiles), runner);
|
|
312
334
|
unregisterRoute(runtime);
|
|
@@ -317,5 +339,5 @@ function runDevCommand(projectRoot, action, args, runner = defaultRunner) {
|
|
|
317
339
|
runCompose(runtime, composeCommand(action, args, profiles), runner);
|
|
318
340
|
return;
|
|
319
341
|
}
|
|
320
|
-
throw new Error("Usage: podo dev <watch|exec|logs|ps|down|url> [docker compose options]");
|
|
342
|
+
throw new Error("Usage: podo dev <watch|up|exec|logs|ps|down|url> [docker compose options]");
|
|
321
343
|
}
|
|
@@ -27,6 +27,7 @@ Run lifecycle and container commands from a second terminal:
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
npx @podosoft/podokit dev url
|
|
30
|
+
npx @podosoft/podokit dev up -d # detached stack without source watching
|
|
30
31
|
npx @podosoft/podokit dev ps
|
|
31
32
|
npx @podosoft/podokit dev logs
|
|
32
33
|
npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api
|
|
@@ -35,6 +36,8 @@ npx @podosoft/podokit dev down
|
|
|
35
36
|
|
|
36
37
|
`dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
|
|
37
38
|
`storage`, and `queue` when installed modules require Redis, MinIO, or a worker.
|
|
39
|
+
`dev up` uses the same shared gateway and module profiles without keeping Compose
|
|
40
|
+
Watch attached.
|
|
38
41
|
You can still activate an additional Compose profile explicitly:
|
|
39
42
|
|
|
40
43
|
```bash
|
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.
|
|
@@ -28,6 +28,7 @@ In a second terminal, apply the included Todo migration and use the lifecycle he
|
|
|
28
28
|
```bash
|
|
29
29
|
npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api
|
|
30
30
|
npx @podosoft/podokit dev url
|
|
31
|
+
npx @podosoft/podokit dev up -d # detached stack without source watching
|
|
31
32
|
npx @podosoft/podokit dev ps
|
|
32
33
|
npx @podosoft/podokit dev logs
|
|
33
34
|
npx @podosoft/podokit dev down
|
|
@@ -35,6 +36,8 @@ npx @podosoft/podokit dev down
|
|
|
35
36
|
|
|
36
37
|
`dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
|
|
37
38
|
`storage`, and `queue` when installed modules require Redis, MinIO, or a worker.
|
|
39
|
+
`dev up` uses the same shared gateway and module profiles without keeping Compose
|
|
40
|
+
Watch attached.
|
|
38
41
|
You can still activate an additional Compose profile explicitly:
|
|
39
42
|
|
|
40
43
|
```bash
|
package/dist/update.js
CHANGED
|
@@ -151,6 +151,14 @@ function planUpdate(projectRoot, templatesDir) {
|
|
|
151
151
|
const classified = (0, lockfile_1.classifyTier)(path, newText ?? disk ?? "", ownedGlobs, managedOverrides);
|
|
152
152
|
const tier = controlledByTarget ? classified : locked?.tier ?? classified;
|
|
153
153
|
if (tier === "owned") {
|
|
154
|
+
// A file introduced by a newer template or module has no previous lock
|
|
155
|
+
// entry. Seed it once when the application has not already claimed that
|
|
156
|
+
// path; subsequent edits or deletions stay protected by the recorded
|
|
157
|
+
// owned entry.
|
|
158
|
+
if (!locked && disk === null && newText !== null) {
|
|
159
|
+
changes.push({ path, tier, action: "add", note: "new owned seed in this version" });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
154
162
|
const explicitlyOwned = ownedGlobs.some((glob) => !glob.includes("*") && (0, lockfile_1.matchGlob)(path, glob));
|
|
155
163
|
changes.push({
|
|
156
164
|
path,
|
|
@@ -283,6 +291,18 @@ function updatedFilesLock(projectRoot, newTree, previous, ownedGlobs, managedOve
|
|
|
283
291
|
delete next.files[path];
|
|
284
292
|
}
|
|
285
293
|
}
|
|
294
|
+
// Keep a tombstone for an owned seed that the application deleted or moved.
|
|
295
|
+
// Without the previous entry, the next update would mistake the missing path
|
|
296
|
+
// for a newly introduced seed and recreate it.
|
|
297
|
+
for (const [path, entry] of Object.entries(previous.files)) {
|
|
298
|
+
if (entry.tier !== "owned" || next.files[path])
|
|
299
|
+
continue;
|
|
300
|
+
const newText = treeText(newTree, path);
|
|
301
|
+
if (newText !== null &&
|
|
302
|
+
(0, lockfile_1.classifyTier)(path, newText, ownedGlobs, managedOverrides) === "owned") {
|
|
303
|
+
next.files[path] = entry;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
286
306
|
return next;
|
|
287
307
|
}
|
|
288
308
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.3",
|
|
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": {
|