@podosoft/podokit 0.16.4 → 0.17.1
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/deploy-compose-exec.d.ts +93 -0
- package/dist/deploy-compose-exec.js +198 -0
- package/dist/deploy-compose-profile.d.ts +107 -0
- package/dist/deploy-compose-profile.js +361 -0
- package/dist/deploy-compose-render.d.ts +48 -0
- package/dist/deploy-compose-render.js +333 -0
- package/dist/deploy-compose-sync.d.ts +144 -0
- package/dist/deploy-compose-sync.js +412 -0
- package/dist/deploy-compose.d.ts +98 -0
- package/dist/deploy-compose.js +667 -0
- package/dist/deploy-dispatch.d.ts +33 -0
- package/dist/deploy-dispatch.js +58 -0
- package/dist/deploy-driver.d.ts +25 -0
- package/dist/deploy-driver.js +78 -0
- package/dist/deploy-profile.d.ts +3 -10
- package/dist/deploy-profile.js +82 -291
- package/dist/deploy-schema.d.ts +56 -0
- package/dist/deploy-schema.js +261 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +178 -6
- package/dist/lib.d.ts +9 -0
- package/dist/lib.js +30 -1
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
- package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +34 -3
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +68 -7
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +60 -8
- package/dist/templates/fullstack-nest-svelte/apps/web/server.js +31 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +15 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.js +267 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +17 -7
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +50 -1
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md +64 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-dockerignore +7 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +7 -0
- package/dist/templates/fullstack-nest-svelte/dot-github/workflows/release.yml +142 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/README.md +18 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +6 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +8 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +11 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +15 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.test.ts +62 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +12 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +13 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +27 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +20 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +26 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +10 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
- package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +2 -1
- package/dist/templates/todo/AGENTS.md +2 -1
- package/dist/templates/todo/Dockerfile.dev +34 -3
- package/dist/templates/todo/apps/api/Dockerfile +68 -7
- package/dist/templates/todo/apps/web/Dockerfile +60 -8
- package/dist/templates/todo/apps/web/server.js +31 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +15 -1
- package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.js +267 -0
- package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
- package/dist/templates/todo/apps/web/vite.config.ts +17 -7
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +47 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/release.md +64 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/todo/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
- package/dist/templates/todo/dot-dockerignore +7 -0
- package/dist/templates/todo/dot-env.example +7 -0
- package/dist/templates/todo/dot-github/workflows/release.yml +142 -0
- package/dist/templates/todo/infra/k3s/README.md +18 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +6 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +8 -0
- package/dist/templates/todo/infra/k3s/web-deployment.yaml +11 -1
- package/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Example k3s manifests
|
|
2
|
+
|
|
3
|
+
These are **illustrative**. `podo deploy` does not read or apply them.
|
|
4
|
+
|
|
5
|
+
For a real deployment, initialize a profile and let PodoKit render the manifests it
|
|
6
|
+
will actually apply — with image digests, probes, disruption budgets, a migration
|
|
7
|
+
Job, and a confirmation hash:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
podo deploy init --profile production --context production --host app.example.com
|
|
11
|
+
podo deploy render --profile production --release v1.2.3
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Keep these files if you want a starting point for hand-managed manifests, or delete
|
|
15
|
+
the directory if you deploy with `podo deploy`. If you do apply them by hand, replace
|
|
16
|
+
every `example.com` and `ghcr.io/example/...` placeholder first, and note that the
|
|
17
|
+
image tags below are placeholders too: the deployment tooling rejects `latest`,
|
|
18
|
+
branch tags, and empty tags, and so should you.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Example only. See README.md in this directory; `podo deploy` renders its own.
|
|
1
2
|
apiVersion: apps/v1
|
|
2
3
|
kind: Deployment
|
|
3
4
|
metadata:
|
|
@@ -13,7 +14,8 @@ spec:
|
|
|
13
14
|
spec:
|
|
14
15
|
containers:
|
|
15
16
|
- name: api
|
|
16
|
-
|
|
17
|
+
# Placeholder. Use one immutable, stable SemVer tag for api and web.
|
|
18
|
+
image: ghcr.io/example/podokit-api:v0.0.0
|
|
17
19
|
ports:
|
|
18
20
|
- containerPort: 3000
|
|
19
21
|
envFrom:
|
|
@@ -22,3 +24,6 @@ spec:
|
|
|
22
24
|
readinessProbe:
|
|
23
25
|
httpGet: { path: /health/ready, port: 3000 }
|
|
24
26
|
initialDelaySeconds: 5
|
|
27
|
+
livenessProbe:
|
|
28
|
+
httpGet: { path: /health, port: 3000 }
|
|
29
|
+
initialDelaySeconds: 15
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Example only. See README.md in this directory; `podo deploy` renders its own.
|
|
1
2
|
apiVersion: traefik.io/v1alpha1
|
|
2
3
|
kind: Middleware
|
|
3
4
|
metadata:
|
|
@@ -14,6 +15,13 @@ metadata:
|
|
|
14
15
|
annotations:
|
|
15
16
|
traefik.ingress.kubernetes.io/router.middlewares: podokit-compression@kubernetescrd
|
|
16
17
|
spec:
|
|
18
|
+
# Name the controller explicitly rather than relying on a default IngressClass.
|
|
19
|
+
ingressClassName: traefik
|
|
20
|
+
# app-config sets an https:// origin, so the route has to terminate TLS. Create the
|
|
21
|
+
# Secret out of band (cert-manager, or an existing certificate) and name it here.
|
|
22
|
+
tls:
|
|
23
|
+
- hosts: [example.com]
|
|
24
|
+
secretName: app-tls
|
|
17
25
|
rules:
|
|
18
26
|
- host: example.com
|
|
19
27
|
http:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Example only. See README.md in this directory; `podo deploy` renders its own.
|
|
1
2
|
apiVersion: apps/v1
|
|
2
3
|
kind: Deployment
|
|
3
4
|
metadata:
|
|
@@ -13,8 +14,17 @@ spec:
|
|
|
13
14
|
spec:
|
|
14
15
|
containers:
|
|
15
16
|
- name: web
|
|
16
|
-
|
|
17
|
+
# Placeholder. Use one immutable, stable SemVer tag for api and web.
|
|
18
|
+
image: ghcr.io/example/podokit-web:v0.0.0
|
|
17
19
|
ports:
|
|
18
20
|
- containerPort: 3000
|
|
19
21
|
envFrom:
|
|
20
22
|
- configMapRef: { name: app-config }
|
|
23
|
+
# Without these a rolling update sends traffic to a process that has not
|
|
24
|
+
# bound its port yet.
|
|
25
|
+
readinessProbe:
|
|
26
|
+
httpGet: { path: /, port: 3000 }
|
|
27
|
+
initialDelaySeconds: 5
|
|
28
|
+
livenessProbe:
|
|
29
|
+
httpGet: { path: /, port: 3000 }
|
|
30
|
+
initialDelaySeconds: 15
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as Table from "$lib/components/ui/table";
|
|
10
10
|
import * as Dialog from "$lib/components/ui/dialog";
|
|
11
11
|
import DataTable, { type DataTableColumn, type SortState, DEFAULT_PAGE_SIZE } from "$lib/components/data-table.svelte";
|
|
12
|
+
import { deviceLabel } from "$lib/device-label";
|
|
12
13
|
import { toast } from "svelte-sonner";
|
|
13
14
|
import { api } from "$lib/api";
|
|
14
15
|
import { getI18n, fmt, formatDateTime } from "$lib/i18n";
|
|
@@ -218,9 +219,16 @@
|
|
|
218
219
|
// Security — change password
|
|
219
220
|
let currentPassword = $state("");
|
|
220
221
|
let newPassword = $state("");
|
|
222
|
+
let confirmNewPassword = $state("");
|
|
221
223
|
let changing = $state(false);
|
|
222
224
|
async function changePassword(event: SubmitEvent): Promise<void> {
|
|
223
225
|
event.preventDefault();
|
|
226
|
+
// Checked here rather than by the API, which only sees one new password and
|
|
227
|
+
// has no way to know it is not the one that was meant.
|
|
228
|
+
if (newPassword !== confirmNewPassword) {
|
|
229
|
+
toast.error(i18n.t.account.passwordMismatch);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
224
232
|
changing = true;
|
|
225
233
|
const { error } = await api.auth.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });
|
|
226
234
|
changing = false;
|
|
@@ -229,6 +237,7 @@
|
|
|
229
237
|
toast.success(i18n.t.account.changed);
|
|
230
238
|
currentPassword = "";
|
|
231
239
|
newPassword = "";
|
|
240
|
+
confirmNewPassword = "";
|
|
232
241
|
await loadSessions();
|
|
233
242
|
}
|
|
234
243
|
}
|
|
@@ -382,7 +391,7 @@
|
|
|
382
391
|
let sessionsSort = $state<SortState | null>(null);
|
|
383
392
|
let busy = $state(false);
|
|
384
393
|
const sessionsColumns: DataTableColumn<Session>[] = [
|
|
385
|
-
{ key: "userAgent", label: i18n.t.sessions.device, sortable: true, value: (s) => s.userAgent ?? "" },
|
|
394
|
+
{ key: "userAgent", label: i18n.t.sessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
|
|
386
395
|
{ key: "ipAddress", label: i18n.t.sessions.ip, sortable: true, value: (s) => s.ipAddress ?? "" },
|
|
387
396
|
{ key: "createdAt", label: i18n.t.sessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
|
|
388
397
|
{ key: "status", label: i18n.t.sessions.status },
|
|
@@ -632,6 +641,10 @@
|
|
|
632
641
|
<Label for="new">{i18n.t.account.newPassword}</Label>
|
|
633
642
|
<Input id="new" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
|
634
643
|
</div>
|
|
644
|
+
<div class="flex flex-col gap-2">
|
|
645
|
+
<Label for="new-confirm">{i18n.t.account.confirmNewPassword}</Label>
|
|
646
|
+
<Input id="new-confirm" type="password" bind:value={confirmNewPassword} required autocomplete="new-password" />
|
|
647
|
+
</div>
|
|
635
648
|
<Button type="submit" class="w-fit" disabled={changing}>{changing ? i18n.t.account.updating : i18n.t.account.update}</Button>
|
|
636
649
|
</form>
|
|
637
650
|
</Card.Content>
|
|
@@ -793,7 +806,7 @@
|
|
|
793
806
|
>
|
|
794
807
|
{#snippet row(s)}
|
|
795
808
|
{@const isCurrent = s.id === data.currentSessionId}
|
|
796
|
-
<Table.Cell class="max-w-xs truncate">{s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
|
|
809
|
+
<Table.Cell class="max-w-xs truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
|
|
797
810
|
<Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
|
|
798
811
|
<Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
|
|
799
812
|
<Table.Cell>{#if isCurrent}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { deviceLabel } from "./device-label";
|
|
3
|
+
|
|
4
|
+
describe("deviceLabel", () => {
|
|
5
|
+
it("names the browser and the machine", () => {
|
|
6
|
+
expect(
|
|
7
|
+
deviceLabel(
|
|
8
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
|
9
|
+
),
|
|
10
|
+
).toBe("Chrome · macOS");
|
|
11
|
+
expect(
|
|
12
|
+
deviceLabel(
|
|
13
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
|
14
|
+
),
|
|
15
|
+
).toBe("Chrome · Windows");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Each of these contains an earlier engine's token, so a naive check in the wrong
|
|
19
|
+
// order reports every one of them as Chrome or Safari.
|
|
20
|
+
it("does not mistake a derivative for the engine it embeds", () => {
|
|
21
|
+
expect(deviceLabel("Mozilla/5.0 (Windows NT 10.0) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0")).toBe(
|
|
22
|
+
"Edge · Windows",
|
|
23
|
+
);
|
|
24
|
+
expect(deviceLabel("Mozilla/5.0 (Windows NT 10.0) Chrome/141.0.0.0 Safari/537.36 OPR/121.0.0.0")).toBe(
|
|
25
|
+
"Opera · Windows",
|
|
26
|
+
);
|
|
27
|
+
expect(
|
|
28
|
+
deviceLabel("Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/141.0.0.0 SamsungBrowser/28.0 Safari/537.36"),
|
|
29
|
+
).toBe("Samsung Internet · Android");
|
|
30
|
+
expect(
|
|
31
|
+
deviceLabel("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15"),
|
|
32
|
+
).toBe("Safari · macOS");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("names an iPhone and an iPad rather than the engine's host platform", () => {
|
|
36
|
+
expect(
|
|
37
|
+
deviceLabel("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1"),
|
|
38
|
+
).toBe("Safari · iPhone");
|
|
39
|
+
// iPadOS reports Macintosh in the same string, so order decides this one.
|
|
40
|
+
expect(
|
|
41
|
+
deviceLabel("Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1"),
|
|
42
|
+
).toBe("Safari · iPad");
|
|
43
|
+
expect(deviceLabel("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) CriOS/141.0.0.0 Mobile/15E148 Safari/604.1")).toBe(
|
|
44
|
+
"Chrome · iPhone",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("gives up on something that is not a browser, so the caller can show it raw", () => {
|
|
49
|
+
// The exact value is the whole point for these: "node" is the symptom of a
|
|
50
|
+
// proxy that forgot to forward the header, and a CLI's name identifies it.
|
|
51
|
+
expect(deviceLabel("node")).toBeNull();
|
|
52
|
+
expect(deviceLabel("curl/8.7.1")).toBeNull();
|
|
53
|
+
expect(deviceLabel("")).toBeNull();
|
|
54
|
+
expect(deviceLabel(null)).toBeNull();
|
|
55
|
+
expect(deviceLabel(undefined)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("reports whichever half it can read", () => {
|
|
59
|
+
expect(deviceLabel("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")).toBe("Windows");
|
|
60
|
+
expect(deviceLabel("Firefox/142.0")).toBe("Firefox");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A readable device name for a session's user agent.
|
|
3
|
+
*
|
|
4
|
+
* The sessions tables label this column "device", and a raw user agent does not
|
|
5
|
+
* answer that question — truncated to fit a cell, every modern browser starts with
|
|
6
|
+
* the same "Mozilla/5.0 (…" and the part that differs is off the end. This reduces
|
|
7
|
+
* it to the two things someone scanning their own sessions is looking for: which
|
|
8
|
+
* browser, on which machine.
|
|
9
|
+
*
|
|
10
|
+
* Returns null when the string is not a browser at all — a CLI, a script, or the
|
|
11
|
+
* proxy's own default. The caller shows the raw value then, because for those the
|
|
12
|
+
* exact string is the only useful information.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Order matters: every one of these also contains an earlier engine's token. */
|
|
16
|
+
const BROWSERS: Array<[RegExp, string]> = [
|
|
17
|
+
[/\bEdg(?:e|A|iOS)?\//, "Edge"],
|
|
18
|
+
[/\bOPR\/|\bOpera\//, "Opera"],
|
|
19
|
+
[/\bSamsungBrowser\//, "Samsung Internet"],
|
|
20
|
+
[/\bFirefox\/|\bFxiOS\//, "Firefox"],
|
|
21
|
+
[/\bCriOS\//, "Chrome"],
|
|
22
|
+
[/\bChrome\/|\bChromium\//, "Chrome"],
|
|
23
|
+
[/\bSafari\//, "Safari"],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** iPadOS reports as Macintosh, so the iPad check has to come before macOS. */
|
|
27
|
+
const PLATFORMS: Array<[RegExp, string]> = [
|
|
28
|
+
[/\biPhone\b/, "iPhone"],
|
|
29
|
+
[/\biPad\b/, "iPad"],
|
|
30
|
+
[/\bAndroid\b/, "Android"],
|
|
31
|
+
[/\bCrOS\b/, "ChromeOS"],
|
|
32
|
+
[/\bWindows NT\b/, "Windows"],
|
|
33
|
+
[/\bMacintosh\b|\bMac OS X\b/, "macOS"],
|
|
34
|
+
[/\bLinux\b/, "Linux"],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function match(candidates: Array<[RegExp, string]>, value: string): string | null {
|
|
38
|
+
for (const [pattern, label] of candidates) {
|
|
39
|
+
if (pattern.test(value)) return label;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function deviceLabel(userAgent: string | null | undefined): string | null {
|
|
45
|
+
if (!userAgent) return null;
|
|
46
|
+
const browser = match(BROWSERS, userAgent);
|
|
47
|
+
const platform = match(PLATFORMS, userAgent);
|
|
48
|
+
if (browser && platform) return `${browser} · ${platform}`;
|
|
49
|
+
return browser ?? platform;
|
|
50
|
+
}
|
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
"email": "Email",
|
|
54
54
|
"emailOrUsername": "Email or username",
|
|
55
55
|
"password": "Password",
|
|
56
|
+
"confirmPassword": "Confirm password",
|
|
57
|
+
"passwordMismatch": "Passwords do not match",
|
|
56
58
|
"forgot": "Forgot?",
|
|
57
59
|
"signIn": "Sign in",
|
|
58
60
|
"signingIn": "Signing in…",
|
|
@@ -93,6 +95,7 @@
|
|
|
93
95
|
"requestFailed": "Request failed",
|
|
94
96
|
"newTitle": "Set a new password",
|
|
95
97
|
"newPassword": "New password",
|
|
98
|
+
"confirmNewPassword": "Confirm new password",
|
|
96
99
|
"updatePassword": "Update password",
|
|
97
100
|
"updating": "Updating…",
|
|
98
101
|
"missingToken": "Missing or invalid reset token.",
|
|
@@ -528,6 +531,8 @@
|
|
|
528
531
|
"changePassword": "Change password",
|
|
529
532
|
"currentPassword": "Current password",
|
|
530
533
|
"newPassword": "New password",
|
|
534
|
+
"confirmNewPassword": "Confirm new password",
|
|
535
|
+
"passwordMismatch": "Passwords do not match",
|
|
531
536
|
"update": "Update password",
|
|
532
537
|
"updating": "Updating…",
|
|
533
538
|
"changed": "Password changed",
|
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
"email": "이메일",
|
|
54
54
|
"emailOrUsername": "이메일 또는 사용자명",
|
|
55
55
|
"password": "비밀번호",
|
|
56
|
+
"confirmPassword": "비밀번호 확인",
|
|
57
|
+
"passwordMismatch": "비밀번호가 일치하지 않습니다",
|
|
56
58
|
"forgot": "비밀번호 찾기",
|
|
57
59
|
"signIn": "로그인",
|
|
58
60
|
"signingIn": "로그인 중…",
|
|
@@ -92,6 +94,7 @@
|
|
|
92
94
|
"requestFailed": "요청에 실패했습니다",
|
|
93
95
|
"newTitle": "새 비밀번호 설정",
|
|
94
96
|
"newPassword": "새 비밀번호",
|
|
97
|
+
"confirmNewPassword": "새 비밀번호 확인",
|
|
95
98
|
"updatePassword": "비밀번호 변경",
|
|
96
99
|
"updating": "변경 중…",
|
|
97
100
|
"missingToken": "재설정 토큰이 없거나 유효하지 않습니다.",
|
|
@@ -528,6 +531,8 @@
|
|
|
528
531
|
"changePassword": "비밀번호 변경",
|
|
529
532
|
"currentPassword": "현재 비밀번호",
|
|
530
533
|
"newPassword": "새 비밀번호",
|
|
534
|
+
"confirmNewPassword": "새 비밀번호 확인",
|
|
535
|
+
"passwordMismatch": "비밀번호가 일치하지 않습니다",
|
|
531
536
|
"update": "비밀번호 변경",
|
|
532
537
|
"updating": "변경 중…",
|
|
533
538
|
"changed": "비밀번호를 변경했습니다",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { toast } from "svelte-sonner";
|
|
10
10
|
import { api } from "$lib/api";
|
|
11
11
|
import { getI18n, fmt, formatDateTime } from "$lib/i18n";
|
|
12
|
+
import { deviceLabel } from "$lib/device-label";
|
|
12
13
|
|
|
13
14
|
const i18n = getI18n();
|
|
14
15
|
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
|
|
44
45
|
const columns = $derived<DataTableColumn<Row>[]>([
|
|
45
46
|
{ key: "user", label: i18n.t.adminSessions.user, sortable: true, value: (r) => r.userName },
|
|
46
|
-
{ key: "device", label: i18n.t.adminSessions.device, sortable: true, value: (r) => r.userAgent },
|
|
47
|
+
{ key: "device", label: i18n.t.adminSessions.device, sortable: true, value: (r) => deviceLabel(r.userAgent) ?? r.userAgent ?? "" },
|
|
47
48
|
{ key: "ip", label: i18n.t.adminSessions.ip, sortable: true, value: (r) => r.ipAddress },
|
|
48
49
|
{ key: "createdAt", label: i18n.t.adminSessions.since, sortable: true },
|
|
49
50
|
{ key: "expiresAt", label: i18n.t.adminSessions.expires, sortable: true, value: (r) => (r.expiresAt ? new Date(r.expiresAt).getTime() : 0) },
|
|
@@ -134,7 +135,7 @@
|
|
|
134
135
|
</div>
|
|
135
136
|
<div class="text-muted-foreground text-xs">{r.userEmail}</div>
|
|
136
137
|
</Table.Cell>
|
|
137
|
-
<Table.Cell class="max-w-xs truncate">{r.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
|
|
138
|
+
<Table.Cell class="max-w-xs truncate" title={r.userAgent ?? ""}>{deviceLabel(r.userAgent) ?? r.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
|
|
138
139
|
<Table.Cell class="text-muted-foreground">{r.ipAddress ?? "—"}</Table.Cell>
|
|
139
140
|
<Table.Cell class="text-muted-foreground">{formatDateTime(r.createdAt)}</Table.Cell>
|
|
140
141
|
<Table.Cell class="text-muted-foreground">{r.expiresAt ? formatDateTime(r.expiresAt) : "—"}</Table.Cell>
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { api } from "$lib/api";
|
|
18
18
|
import { getI18n, fmt, formatDateTime } from "$lib/i18n";
|
|
19
19
|
import { cn } from "$lib/utils";
|
|
20
|
+
import { deviceLabel } from "$lib/device-label";
|
|
20
21
|
import type { PageData } from "./$types";
|
|
21
22
|
|
|
22
23
|
let { data }: { data: PageData } = $props();
|
|
@@ -194,7 +195,7 @@
|
|
|
194
195
|
let mSessionsPage = $state(1);
|
|
195
196
|
let mSessionsSort = $state<SortState | null>(null);
|
|
196
197
|
const mSessionsColumns: DataTableColumn<Session>[] = [
|
|
197
|
-
{ key: "userAgent", label: i18n.t.adminSessions.device, sortable: true },
|
|
198
|
+
{ key: "userAgent", label: i18n.t.adminSessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
|
|
198
199
|
{ key: "ipAddress", label: i18n.t.adminSessions.ip, sortable: true },
|
|
199
200
|
{ key: "createdAt", label: i18n.t.adminSessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
|
|
200
201
|
{ key: "actions", label: "", class: "w-10" },
|
|
@@ -557,7 +558,7 @@
|
|
|
557
558
|
empty={i18n.t.adminSessions.empty}
|
|
558
559
|
>
|
|
559
560
|
{#snippet row(s)}
|
|
560
|
-
<Table.Cell class="max-w-40 truncate">{s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
|
|
561
|
+
<Table.Cell class="max-w-40 truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
|
|
561
562
|
<Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
|
|
562
563
|
<Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
|
|
563
564
|
<Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.token)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
|
|
@@ -11,14 +11,21 @@
|
|
|
11
11
|
|
|
12
12
|
const i18n = getI18n();
|
|
13
13
|
let password = $state("");
|
|
14
|
+
let confirmPassword = $state("");
|
|
14
15
|
let error = $state<string | null>(null);
|
|
15
16
|
let loading = $state(false);
|
|
16
17
|
const token = $derived(page.url.searchParams.get("token") ?? "");
|
|
17
18
|
|
|
18
19
|
async function submit(event: SubmitEvent): Promise<void> {
|
|
19
20
|
event.preventDefault();
|
|
20
|
-
loading = true;
|
|
21
21
|
error = null;
|
|
22
|
+
// The token is single-use: a mistyped password here is not a retry, it is
|
|
23
|
+
// another round trip through the mailbox to get a link that still works.
|
|
24
|
+
if (password !== confirmPassword) {
|
|
25
|
+
error = i18n.t.auth.passwordMismatch;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
loading = true;
|
|
22
29
|
const { error: authError } = await api.auth.resetPassword({ newPassword: password, token });
|
|
23
30
|
loading = false;
|
|
24
31
|
if (authError) {
|
|
@@ -45,6 +52,10 @@
|
|
|
45
52
|
<Label for="password">{i18n.t.auth.newPassword}</Label>
|
|
46
53
|
<Input id="password" type="password" bind:value={password} required autocomplete="new-password" />
|
|
47
54
|
</div>
|
|
55
|
+
<div class="flex flex-col gap-2">
|
|
56
|
+
<Label for="confirm-password">{i18n.t.auth.confirmNewPassword}</Label>
|
|
57
|
+
<Input id="confirm-password" type="password" bind:value={confirmPassword} required autocomplete="new-password" />
|
|
58
|
+
</div>
|
|
48
59
|
<Button type="submit" disabled={loading || !token}>{loading ? i18n.t.auth.updating : i18n.t.auth.updatePassword}</Button>
|
|
49
60
|
</form>
|
|
50
61
|
</Card.Content>
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte
CHANGED
|
@@ -18,14 +18,22 @@
|
|
|
18
18
|
let name = $state("");
|
|
19
19
|
let email = $state("");
|
|
20
20
|
let password = $state("");
|
|
21
|
+
let confirmPassword = $state("");
|
|
21
22
|
let error = $state<string | null>(null);
|
|
22
23
|
let loading = $state(false);
|
|
23
24
|
const redirectTo = (): string => safeAuthRedirect(page.url.searchParams.get("redirect"));
|
|
24
25
|
|
|
25
26
|
async function submit(event: SubmitEvent): Promise<void> {
|
|
26
27
|
event.preventDefault();
|
|
27
|
-
loading = true;
|
|
28
28
|
error = null;
|
|
29
|
+
// Before the request, not after: a typo here costs the account. Sign-up is the
|
|
30
|
+
// one password entry with no way back — the address is not verified yet, so the
|
|
31
|
+
// reset link that would rescue it goes somewhere the user cannot read.
|
|
32
|
+
if (password !== confirmPassword) {
|
|
33
|
+
error = i18n.t.auth.passwordMismatch;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
loading = true;
|
|
29
37
|
// callbackURL: where the verification link lands once confirmed (absolute so
|
|
30
38
|
// it returns to the web app, not the API origin). Ignored when verification is off.
|
|
31
39
|
const { error: authError } = await api.auth.signUp.email({
|
|
@@ -85,6 +93,10 @@
|
|
|
85
93
|
<Label for="password">{i18n.t.auth.password}</Label>
|
|
86
94
|
<Input id="password" type="password" bind:value={password} required autocomplete="new-password" />
|
|
87
95
|
</div>
|
|
96
|
+
<div class="flex flex-col gap-2">
|
|
97
|
+
<Label for="confirm-password">{i18n.t.auth.confirmPassword}</Label>
|
|
98
|
+
<Input id="confirm-password" type="password" bind:value={confirmPassword} required autocomplete="new-password" />
|
|
99
|
+
</div>
|
|
88
100
|
<Button type="submit" disabled={loading}>{loading ? i18n.t.auth.creating : i18n.t.auth.create}</Button>
|
|
89
101
|
</form>
|
|
90
102
|
</Card.Content>
|
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
export type Account = { name: string; email: string; password: string };
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The password the suite signs in with, per account.
|
|
5
|
+
*
|
|
6
|
+
* A test must never change an existing account's credentials. `seed.setup.ts` signs up
|
|
7
|
+
* idempotently and then signs in with these values, which is right on a stack the suite
|
|
8
|
+
* created — and wrong on every stack where the account already exists with a password of
|
|
9
|
+
* its own. An install bootstrapped with `admin:bootstrap` is exactly that: `admin@example.com`
|
|
10
|
+
* is a real account somebody logs in as, and resetting it to match the constant takes their
|
|
11
|
+
* login with it while the run goes green.
|
|
12
|
+
*
|
|
13
|
+
* So the constant is a DEFAULT and the environment wins:
|
|
14
|
+
*
|
|
15
|
+
* ```bash
|
|
16
|
+
* E2E_BASE_URL=https://app.example.com E2E_ADMIN_PASSWORD='…' npx playwright test …
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Without the override the run fails with `INVALID_EMAIL_OR_PASSWORD`, which is the correct
|
|
20
|
+
* outcome — a refusal, not a silent overwrite. Do not commit a real value here.
|
|
21
|
+
*/
|
|
22
|
+
function seedPassword(envVar: "E2E_ADMIN_PASSWORD" | "E2E_USER_PASSWORD"): string {
|
|
23
|
+
const value = process.env[envVar];
|
|
24
|
+
return value !== undefined && value.length > 0 ? value : "Podokit3e-Str0ng!pw";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const ADMIN = { name: "Admin", email: "admin@example.com", password: seedPassword("E2E_ADMIN_PASSWORD") };
|
|
28
|
+
export const USER = { name: "Normal User", email: "user@example.com", password: seedPassword("E2E_USER_PASSWORD") };
|
|
4
29
|
export const adminState = "playwright/.auth/admin.json";
|
|
5
30
|
export const userState = "playwright/.auth/user.json";
|
|
6
31
|
export const userBaselineState = "playwright/.auth/user-baseline.json";
|
|
@@ -161,12 +161,31 @@ test("account security and sessions sub-navigation", async ({ page }) => {
|
|
|
161
161
|
await ready(page, "/admin/account");
|
|
162
162
|
await page.getByRole("button", { name: "Security" }).click();
|
|
163
163
|
await expect(page.getByLabel("Current password")).toBeVisible();
|
|
164
|
+
// `exact` because "Confirm new password" also contains "New password".
|
|
165
|
+
await expect(page.getByLabel("New password", { exact: true })).toBeVisible();
|
|
166
|
+
await expect(page.getByLabel("Confirm new password")).toBeVisible();
|
|
164
167
|
await page.getByRole("button", { name: "Sessions" }).click();
|
|
165
168
|
await expect(page.getByText("Active sessions")).toBeVisible();
|
|
166
169
|
await expect(page.getByText("Current", { exact: true })).toBeVisible();
|
|
167
170
|
await expect(page.getByRole("button", { name: "Sign out other sessions" })).toBeVisible();
|
|
168
171
|
});
|
|
169
172
|
|
|
173
|
+
// Changing a password is the one form here whose mistake is silent: the change
|
|
174
|
+
// succeeds, and the person finds out at the next sign-in with a password they
|
|
175
|
+
// cannot reproduce.
|
|
176
|
+
test("changing a password refuses a mistyped confirmation", async ({ page }) => {
|
|
177
|
+
await ready(page, "/admin/account");
|
|
178
|
+
await page.getByRole("button", { name: "Security" }).click();
|
|
179
|
+
await page.getByLabel("Current password").fill(ADMIN.password);
|
|
180
|
+
await page.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
|
|
181
|
+
await page.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pwX");
|
|
182
|
+
// The control: the message must not already be on screen, or this test would pass
|
|
183
|
+
// against a form that shows it unconditionally.
|
|
184
|
+
await expect(page.getByText("Passwords do not match")).toBeHidden();
|
|
185
|
+
await page.getByRole("button", { name: "Update password" }).click();
|
|
186
|
+
await expect(page.getByText("Passwords do not match")).toBeVisible();
|
|
187
|
+
});
|
|
188
|
+
|
|
170
189
|
test("account nav shows the core sections", async ({ page }) => {
|
|
171
190
|
await ready(page, "/admin/account");
|
|
172
191
|
await expect(page.getByRole("button", { name: "Profile", exact: true })).toBeVisible();
|
|
@@ -182,7 +201,7 @@ test("two-factor setup shows a scannable QR code when enabled", async ({ page })
|
|
|
182
201
|
await expect(page.getByRole("button", { name: "Enable", exact: true })).toBeVisible();
|
|
183
202
|
// Start setup (password + Enable) and confirm the QR renders. Stop before
|
|
184
203
|
// verifying so 2FA stays inactive and the shared admin session keeps working.
|
|
185
|
-
await page.locator("#tf-on-pw").fill(
|
|
204
|
+
await page.locator("#tf-on-pw").fill(ADMIN.password);
|
|
186
205
|
await page.getByRole("button", { name: "Enable", exact: true }).click();
|
|
187
206
|
await expect(page.getByRole("img", { name: "TOTP QR code" })).toBeVisible();
|
|
188
207
|
});
|
|
@@ -47,6 +47,8 @@ test("authentication return targets reject external and recursive redirects", ()
|
|
|
47
47
|
test("signup page renders", async ({ page }) => {
|
|
48
48
|
await page.goto("/signup");
|
|
49
49
|
await expect(page.getByLabel("Name")).toBeVisible();
|
|
50
|
+
await expect(page.getByLabel("Password", { exact: true })).toBeVisible();
|
|
51
|
+
await expect(page.getByLabel("Confirm password")).toBeVisible();
|
|
50
52
|
await expect(page.getByRole("button", { name: "Create account" })).toBeVisible();
|
|
51
53
|
});
|
|
52
54
|
|
|
@@ -120,8 +122,31 @@ test("signup creates an account and enters the app @smoke", async ({ page }) =>
|
|
|
120
122
|
const email = `signup-${Date.now()}@example.com`;
|
|
121
123
|
await page.getByLabel("Name").fill("New User");
|
|
122
124
|
await page.getByLabel("Email").fill(email);
|
|
123
|
-
|
|
125
|
+
// `exact` because "Confirm password" also contains "Password".
|
|
126
|
+
await page.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
|
|
127
|
+
await page.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pw");
|
|
124
128
|
await page.getByRole("button", { name: "Create account" }).click();
|
|
125
129
|
// Verification off → straight into the app; on → the verify-email page.
|
|
126
130
|
await expect(page).toHaveURL((url) => url.pathname === "/" || url.pathname === "/verify-email");
|
|
127
131
|
});
|
|
132
|
+
|
|
133
|
+
// A typo here is not recoverable the way a mistyped login is: the address has not
|
|
134
|
+
// been verified, so the reset link that would rescue the account is sent to an
|
|
135
|
+
// inbox the person cannot open.
|
|
136
|
+
test("signup refuses a mistyped password confirmation", async ({ page }) => {
|
|
137
|
+
await ready(page, "/signup");
|
|
138
|
+
const email = `signup-mismatch-${Date.now()}@example.com`;
|
|
139
|
+
await page.getByLabel("Name").fill("New User");
|
|
140
|
+
await page.getByLabel("Email").fill(email);
|
|
141
|
+
await page.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
|
|
142
|
+
await page.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pwX");
|
|
143
|
+
await page.getByRole("button", { name: "Create account" }).click();
|
|
144
|
+
await expect(page.getByText("Passwords do not match")).toBeVisible();
|
|
145
|
+
// Still on the form, and no account was created for that address.
|
|
146
|
+
await expect(page).toHaveURL(/\/signup/);
|
|
147
|
+
const probe = await page.context().request.post("/api/auth/sign-in/email", {
|
|
148
|
+
data: { email, password: "Podokit3e-Str0ng!pw" },
|
|
149
|
+
headers: { origin: new URL(page.url()).origin },
|
|
150
|
+
});
|
|
151
|
+
expect(probe.ok()).toBeFalsy();
|
|
152
|
+
});
|
|
@@ -31,7 +31,16 @@ test("forgot password: request, open the emailed link, set a new password, sign
|
|
|
31
31
|
await page.goto(await waitForLink(email));
|
|
32
32
|
await page.waitForLoadState("load");
|
|
33
33
|
await page.waitForTimeout(400);
|
|
34
|
-
|
|
34
|
+
// `exact` because "Confirm new password" also contains "New password".
|
|
35
|
+
await page.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
|
|
36
|
+
// A mismatch is refused, and the single-use token survives it: the same page
|
|
37
|
+
// still accepts the corrected pair below.
|
|
38
|
+
await page.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pwX");
|
|
39
|
+
await page.getByRole("button", { name: "Update password" }).click();
|
|
40
|
+
await expect(page.getByText("Passwords do not match")).toBeVisible();
|
|
41
|
+
await expect(page).not.toHaveURL(/\/login/);
|
|
42
|
+
|
|
43
|
+
await page.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pw");
|
|
35
44
|
await page.getByRole("button", { name: "Update password" }).click();
|
|
36
45
|
await expect(page).toHaveURL(/\/login/);
|
|
37
46
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test, type Page } from "@playwright/test";
|
|
2
|
-
import { anonState } from "../helpers/accounts";
|
|
2
|
+
import { ADMIN, USER, anonState } from "../helpers/accounts";
|
|
3
3
|
import { totpCode } from "../helpers/totp";
|
|
4
4
|
|
|
5
5
|
test.use({ storageState: anonState });
|
|
@@ -59,7 +59,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({
|
|
|
59
59
|
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
60
60
|
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
61
61
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
62
|
-
await admin.post("/api/auth/sign-in/email", { data: { email:
|
|
62
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
|
|
63
63
|
|
|
64
64
|
const email = `rq-ui-${Date.now()}@example.com`;
|
|
65
65
|
const pw = "Podokit3e-Str0ng!pw";
|
|
@@ -97,7 +97,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({
|
|
|
97
97
|
await admin.put("/api/account/settings", { data: { require2fa: false } });
|
|
98
98
|
await expect(async () => {
|
|
99
99
|
const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
100
|
-
await probe.post("/api/auth/sign-in/email", { data: { email:
|
|
100
|
+
await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
|
|
101
101
|
const status = (await probe.get("/api/account/me")).status();
|
|
102
102
|
await probe.dispose();
|
|
103
103
|
expect(status).toBe(200);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Example only. See infra/k3s/README.md; `podo deploy` renders its own.
|
|
1
2
|
# BullMQ worker — runs the same API image with the worker entrypoint.
|
|
2
3
|
# It has no HTTP server, so there is no Service or Ingress.
|
|
3
4
|
apiVersion: apps/v1
|
|
@@ -15,7 +16,7 @@ spec:
|
|
|
15
16
|
spec:
|
|
16
17
|
containers:
|
|
17
18
|
- name: worker
|
|
18
|
-
image: ghcr.io/example/podokit-api:
|
|
19
|
+
image: ghcr.io/example/podokit-api:v0.0.0
|
|
19
20
|
command: ["node", "dist/main-worker"]
|
|
20
21
|
envFrom:
|
|
21
22
|
- configMapRef: { name: app-config }
|
|
@@ -70,7 +70,8 @@ Useful commands:
|
|
|
70
70
|
- `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
|
|
71
71
|
- `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
|
|
72
72
|
- `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
|
|
73
|
-
- `podo deploy` — plan, apply, verify, or roll back an immutable Kubernetes release. Use `.agents/skills/podokit-deploy`.
|
|
73
|
+
- `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
|
|
74
|
+
- `podo deploy sync` — copy local build output into a running Compose deployment and restart it, for iterating without a release. Not a release: no image is published and the next `apply` discards it. Use `.agents/skills/podokit-deploy-fast`.
|
|
74
75
|
|
|
75
76
|
## Commit conventions
|
|
76
77
|
|