@podosoft/podokit 0.17.2 → 0.17.4
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/routes/account/+page.server.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +40 -7
- 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/routes/account/+page.server.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { loadAccountData } from "$lib/account-data.server";
|
|
2
|
+
import { loadProtectedLayout } from "$lib/server/protected-layout";
|
|
2
3
|
import type { PageServerLoad } from "./$types";
|
|
3
4
|
|
|
4
|
-
export const load: PageServerLoad = ({ locals, fetch }) =>
|
|
5
|
+
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
|
6
|
+
await loadProtectedLayout({ locals, fetch });
|
|
7
|
+
return loadAccountData(locals, fetch);
|
|
8
|
+
};
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts
CHANGED
|
@@ -8,6 +8,6 @@ export const load: PageServerLoad = ({ locals }) => {
|
|
|
8
8
|
requireBackendAvailable(locals);
|
|
9
9
|
if (!locals.user) redirect(302, "/login");
|
|
10
10
|
const user = locals.user as App.Locals["user"] & { twoFactorEnabled?: boolean };
|
|
11
|
-
if (user.twoFactorEnabled) redirect(302, "/
|
|
11
|
+
if (user.twoFactorEnabled) redirect(302, "/account");
|
|
12
12
|
return { user: locals.user };
|
|
13
13
|
};
|
|
@@ -15,11 +15,13 @@ async function loginFresh(page: import("@playwright/test").Page, playwright: imp
|
|
|
15
15
|
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
16
16
|
await api.post("/api/auth/sign-up/email", { data: { email, password: PW, name: "Acc" } });
|
|
17
17
|
await api.dispose();
|
|
18
|
-
await ready(page, `/login?redirect=${encodeURIComponent("/
|
|
18
|
+
await ready(page, `/login?redirect=${encodeURIComponent("/account")}`);
|
|
19
19
|
await page.locator("#email").fill(email);
|
|
20
20
|
await page.locator("#password").fill(PW);
|
|
21
21
|
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
22
|
-
await expect(page).toHaveURL(
|
|
22
|
+
await expect(page).toHaveURL((url) => url.pathname === "/account");
|
|
23
|
+
await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
|
|
24
|
+
await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
test("account: register and verify a phone number", async ({ page, playwright }) => {
|
|
@@ -15,6 +15,12 @@ async function openHydratedLogin(page: Page, redirect?: string): Promise<void> {
|
|
|
15
15
|
await page.waitForLoadState("networkidle");
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
async function expectAccountPage(page: Page): Promise<void> {
|
|
19
|
+
await expect(page).toHaveURL((url) => url.pathname === "/account");
|
|
20
|
+
await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
|
|
21
|
+
await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
// A disposable account (never the shared admin/user) so enabling 2FA here can't
|
|
19
25
|
// invalidate the seeded sessions the other auth specs rely on.
|
|
20
26
|
test("two-factor: sign in with a backup code from the login page", async ({ page, playwright }) => {
|
|
@@ -31,7 +37,7 @@ test("two-factor: sign in with a backup code from the login page", async ({ page
|
|
|
31
37
|
await api.dispose();
|
|
32
38
|
|
|
33
39
|
// Password sign-in hands off to the second-factor step (no session yet).
|
|
34
|
-
await openHydratedLogin(page, "/
|
|
40
|
+
await openHydratedLogin(page, "/account");
|
|
35
41
|
await page.getByLabel("Email").fill(email);
|
|
36
42
|
await page.getByLabel("Password").fill(pw);
|
|
37
43
|
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
@@ -52,7 +58,30 @@ test("two-factor: sign in with a backup code from the login page", async ({ page
|
|
|
52
58
|
// The real code completes the login.
|
|
53
59
|
await page.getByLabel("Backup code").fill(backupCode);
|
|
54
60
|
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
55
|
-
await
|
|
61
|
+
await expectAccountPage(page);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("two-factor: sign in with an authenticator code from the login page", async ({ page, playwright }) => {
|
|
65
|
+
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
66
|
+
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
67
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
68
|
+
|
|
69
|
+
const email = `tf-totp-ui-${Date.now()}@example.com`;
|
|
70
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
71
|
+
await api.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "UI" } });
|
|
72
|
+
const enable = await (await api.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
|
|
73
|
+
await api.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
|
|
74
|
+
await api.dispose();
|
|
75
|
+
|
|
76
|
+
await openHydratedLogin(page, "/account");
|
|
77
|
+
await page.getByLabel("Email").fill(email);
|
|
78
|
+
await page.getByLabel("Password").fill(pw);
|
|
79
|
+
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
80
|
+
await expect(page.getByTestId("two-factor-step")).toBeVisible();
|
|
81
|
+
await page.getByLabel("Authentication code").fill(totpCode(enable.totpURI));
|
|
82
|
+
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
83
|
+
|
|
84
|
+
await expectAccountPage(page);
|
|
56
85
|
});
|
|
57
86
|
|
|
58
87
|
test("require-2fa: a new sign-up is forced through the enrolment page", async ({ page, playwright }) => {
|
|
@@ -74,7 +103,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({
|
|
|
74
103
|
await probe.dispose();
|
|
75
104
|
|
|
76
105
|
// Signing in toward a protected route lands on the mandatory enrolment page.
|
|
77
|
-
await openHydratedLogin(page, "/
|
|
106
|
+
await openHydratedLogin(page, "/account");
|
|
78
107
|
await page.getByLabel("Email").fill(email);
|
|
79
108
|
await page.getByLabel("Password").fill(pw);
|
|
80
109
|
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
@@ -91,8 +120,12 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({
|
|
|
91
120
|
await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
|
|
92
121
|
await page.getByRole("button", { name: "Activate and continue" }).click();
|
|
93
122
|
|
|
94
|
-
// Enrolled → the gate lets
|
|
95
|
-
await
|
|
123
|
+
// Enrolled → the gate lets the regular user into their account page.
|
|
124
|
+
await expectAccountPage(page);
|
|
125
|
+
|
|
126
|
+
// An enrolled user who revisits setup is returned to the same non-admin page.
|
|
127
|
+
await page.goto("/setup-2fa");
|
|
128
|
+
await expectAccountPage(page);
|
|
96
129
|
} finally {
|
|
97
130
|
await admin.put("/api/account/settings", { data: { require2fa: false } });
|
|
98
131
|
await expect(async () => {
|
|
@@ -119,14 +152,14 @@ test("account: regenerate backup codes shows a fresh set", async ({ page, playwr
|
|
|
119
152
|
await api.dispose();
|
|
120
153
|
|
|
121
154
|
// Sign in (backup-code path) straight to the account page.
|
|
122
|
-
await openHydratedLogin(page, "/
|
|
155
|
+
await openHydratedLogin(page, "/account");
|
|
123
156
|
await page.getByLabel("Email").fill(email);
|
|
124
157
|
await page.getByLabel("Password").fill(pw);
|
|
125
158
|
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
126
159
|
await page.getByRole("button", { name: "Use a backup code instead" }).click();
|
|
127
160
|
await page.getByLabel("Backup code").fill(backupCode);
|
|
128
161
|
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
129
|
-
await
|
|
162
|
+
await expectAccountPage(page);
|
|
130
163
|
|
|
131
164
|
// Security → regenerate backup codes → a fresh set is shown.
|
|
132
165
|
await page.getByRole("button", { name: "Security" }).click();
|
|
@@ -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.4",
|
|
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": {
|