@podosoft/podokit 0.7.0 → 0.9.0
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/index.js +25 -0
- package/dist/remove.d.ts +28 -0
- package/dist/remove.js +170 -0
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +8 -2
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +10 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +58 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +39 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +10 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +13 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +96 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +122 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +106 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +12 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +61 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +129 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +17 -0
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +8 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/two-factor-required.guard.ts +68 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +5 -1
- package/dist/templates/modules/auth/module.manifest.json +10 -0
- package/dist/templates/todo/AGENTS.md +1 -0
- package/dist/templates/todo/apps/web/vite.config.ts +8 -2
- package/dist/templates/todo/compose.dev.yaml +10 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const create_1 = require("./create");
|
|
|
8
8
|
const prompt_1 = require("./prompt");
|
|
9
9
|
const templates_1 = require("./templates");
|
|
10
10
|
const add_1 = require("./add");
|
|
11
|
+
const remove_1 = require("./remove");
|
|
11
12
|
const inspect_1 = require("./inspect");
|
|
12
13
|
const update_1 = require("./update");
|
|
13
14
|
const eject_1 = require("./eject");
|
|
@@ -16,6 +17,7 @@ const HELP = `podo — PodoKit project generator
|
|
|
16
17
|
Usage:
|
|
17
18
|
podo create <name> [options]
|
|
18
19
|
podo add <module>
|
|
20
|
+
podo remove <module> Un-apply a module (inverse of add)
|
|
19
21
|
podo status Show version, modules, file tiers, and local edits
|
|
20
22
|
podo diff List PodoKit-managed files you have edited
|
|
21
23
|
podo doctor Check framework versions against supported ranges
|
|
@@ -111,6 +113,29 @@ async function main(argv) {
|
|
|
111
113
|
}
|
|
112
114
|
return;
|
|
113
115
|
}
|
|
116
|
+
if (args.command === "remove") {
|
|
117
|
+
const moduleName = args.name;
|
|
118
|
+
if (!moduleName) {
|
|
119
|
+
fail("Usage: podo remove <module>");
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const result = (0, remove_1.removeModule)({ projectRoot: process.cwd(), module: moduleName, modulesDir });
|
|
123
|
+
process.stdout.write(`\nRemoved ${result.module}: ${result.removed.length} file(s) deleted` +
|
|
124
|
+
`${result.unwired.length ? `, un-wired ${result.unwired.length} target(s)` : ""}.\n`);
|
|
125
|
+
if (result.keptShared.length) {
|
|
126
|
+
process.stdout.write(`\nKept (shared with another module): ${result.keptShared.join(", ")}\n`);
|
|
127
|
+
}
|
|
128
|
+
if (result.keptEdited.length) {
|
|
129
|
+
process.stdout.write(`\nKept (you edited these — delete manually if you want them gone):\n` +
|
|
130
|
+
result.keptEdited.map((f) => ` ${f}`).join("\n") +
|
|
131
|
+
"\n");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
fail(err.message);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
114
139
|
if (args.command === "status") {
|
|
115
140
|
try {
|
|
116
141
|
const s = (0, inspect_1.status)(process.cwd());
|
package/dist/remove.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface RemoveOptions {
|
|
2
|
+
projectRoot: string;
|
|
3
|
+
module: string;
|
|
4
|
+
modulesDir: string;
|
|
5
|
+
/** PodoKit version stamped into the lockfile. Defaults to the CLI version. */
|
|
6
|
+
podokitVersion?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RemoveResult {
|
|
9
|
+
module: string;
|
|
10
|
+
/** Module overlay files deleted from the project. */
|
|
11
|
+
removed: string[];
|
|
12
|
+
/** Module files kept because the user edited them (delete manually if desired). */
|
|
13
|
+
keptEdited: string[];
|
|
14
|
+
/** Module files kept because another installed module also ships them. */
|
|
15
|
+
keptShared: string[];
|
|
16
|
+
/** Injection targets whose wiring was removed. */
|
|
17
|
+
unwired: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Remove a previously-added module from a generated project: the inverse of
|
|
21
|
+
* `podo add`. Un-wires the module's marker injections, deletes its overlay files
|
|
22
|
+
* (keeping any the user edited or another installed module also ships), prunes
|
|
23
|
+
* the package.json deps/scripts and .env.example lines it introduced that no
|
|
24
|
+
* other installed module still needs, then drops it from the manifest and
|
|
25
|
+
* recomputes the lock. Does not cascade: a module still required by another
|
|
26
|
+
* installed module is refused rather than force-removed.
|
|
27
|
+
*/
|
|
28
|
+
export declare function removeModule(options: RemoveOptions): RemoveResult;
|
package/dist/remove.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.removeModule = removeModule;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
|
|
7
|
+
const add_1 = require("./add");
|
|
8
|
+
const lockfile_1 = require("./lockfile");
|
|
9
|
+
function readModuleManifest(moduleDir) {
|
|
10
|
+
return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(moduleDir, "module.manifest.json"), "utf8"));
|
|
11
|
+
}
|
|
12
|
+
function readJson(path) {
|
|
13
|
+
return JSON.parse((0, node_fs_1.readFileSync)(path, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
function projectName(projectRoot) {
|
|
16
|
+
const pkg = readJson((0, node_path_1.join)(projectRoot, "package.json"));
|
|
17
|
+
return typeof pkg.name === "string" ? pkg.name : "app";
|
|
18
|
+
}
|
|
19
|
+
/** Output paths a module writes into a project (dot- convention applied). */
|
|
20
|
+
function moduleFilePaths(moduleDir, vars) {
|
|
21
|
+
const filesDir = (0, node_path_1.join)(moduleDir, "files");
|
|
22
|
+
if (!(0, node_fs_1.existsSync)(filesDir))
|
|
23
|
+
return [];
|
|
24
|
+
return [...(0, podokit_template_engine_1.renderTemplate)(filesDir, vars).keys()];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Remove a previously-added module from a generated project: the inverse of
|
|
28
|
+
* `podo add`. Un-wires the module's marker injections, deletes its overlay files
|
|
29
|
+
* (keeping any the user edited or another installed module also ships), prunes
|
|
30
|
+
* the package.json deps/scripts and .env.example lines it introduced that no
|
|
31
|
+
* other installed module still needs, then drops it from the manifest and
|
|
32
|
+
* recomputes the lock. Does not cascade: a module still required by another
|
|
33
|
+
* installed module is refused rather than force-removed.
|
|
34
|
+
*/
|
|
35
|
+
function removeModule(options) {
|
|
36
|
+
const { projectRoot, module, modulesDir } = options;
|
|
37
|
+
const manifest = (0, lockfile_1.readManifest)(projectRoot);
|
|
38
|
+
if (!manifest) {
|
|
39
|
+
throw new Error("Not a PodoKit project: .podokit/manifest.json not found.");
|
|
40
|
+
}
|
|
41
|
+
if (!manifest.modules.some((m) => m.name === module)) {
|
|
42
|
+
const installed = manifest.modules.map((m) => m.name).join(", ") || "(none)";
|
|
43
|
+
throw new Error(`Module "${module}" is not installed. Installed: ${installed}.`);
|
|
44
|
+
}
|
|
45
|
+
const moduleDir = (0, add_1.resolveModuleDir)(module, modulesDir, projectRoot);
|
|
46
|
+
if (!moduleDir) {
|
|
47
|
+
throw new Error(`Cannot resolve module "${module}" to un-apply it. Is it still installed/bundled?`);
|
|
48
|
+
}
|
|
49
|
+
const targetManifest = readModuleManifest(moduleDir);
|
|
50
|
+
// Resolve the manifests of the OTHER installed modules once — used for the
|
|
51
|
+
// requires guard and to decide which files/deps/env are shared.
|
|
52
|
+
const others = manifest.modules
|
|
53
|
+
.filter((m) => m.name !== module)
|
|
54
|
+
.map((m) => {
|
|
55
|
+
const dir = (0, add_1.resolveModuleDir)(m.name, modulesDir, projectRoot);
|
|
56
|
+
return dir ? { name: m.name, dir, manifest: readModuleManifest(dir) } : null;
|
|
57
|
+
})
|
|
58
|
+
.filter((m) => m !== null);
|
|
59
|
+
// Guard: refuse if another installed module requires this one.
|
|
60
|
+
const dependents = others.filter((o) => (o.manifest.requires ?? []).includes(module)).map((o) => o.name);
|
|
61
|
+
if (dependents.length) {
|
|
62
|
+
throw new Error(`Cannot remove "${module}": required by ${dependents.join(", ")}. Remove ${dependents.length > 1 ? "those" : "that"} first.`);
|
|
63
|
+
}
|
|
64
|
+
const vars = { projectName: projectName(projectRoot) };
|
|
65
|
+
const sharedFiles = new Set(others.flatMap((o) => moduleFilePaths(o.dir, vars)));
|
|
66
|
+
const lock = (0, lockfile_1.readFilesLock)(projectRoot);
|
|
67
|
+
// 1) un-wire marker injections (idempotent; skips optional/absent).
|
|
68
|
+
const unwired = [];
|
|
69
|
+
for (const injection of targetManifest.inject ?? []) {
|
|
70
|
+
const target = (0, node_path_1.join)(projectRoot, injection.file);
|
|
71
|
+
if (!(0, node_fs_1.existsSync)(target))
|
|
72
|
+
continue;
|
|
73
|
+
const content = (0, node_fs_1.readFileSync)(target, "utf8");
|
|
74
|
+
const next = (0, podokit_template_engine_1.removeAtMarker)(content, injection.text);
|
|
75
|
+
if (next !== content) {
|
|
76
|
+
(0, node_fs_1.writeFileSync)(target, next);
|
|
77
|
+
unwired.push(injection.file);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 2) delete overlay files, preserving edited and shared ones.
|
|
81
|
+
const removed = [];
|
|
82
|
+
const keptEdited = [];
|
|
83
|
+
const keptShared = [];
|
|
84
|
+
for (const rel of moduleFilePaths(moduleDir, vars)) {
|
|
85
|
+
const abs = (0, node_path_1.join)(projectRoot, rel);
|
|
86
|
+
if (!(0, node_fs_1.existsSync)(abs))
|
|
87
|
+
continue;
|
|
88
|
+
if (sharedFiles.has(rel)) {
|
|
89
|
+
keptShared.push(rel);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const locked = lock?.files[rel];
|
|
93
|
+
if (locked && (0, podokit_template_engine_1.hashContent)((0, node_fs_1.readFileSync)(abs)) !== locked.outHash) {
|
|
94
|
+
keptEdited.push(rel);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
(0, node_fs_1.rmSync)(abs);
|
|
98
|
+
pruneEmptyDirs(projectRoot, (0, node_path_1.dirname)(rel));
|
|
99
|
+
removed.push(rel);
|
|
100
|
+
}
|
|
101
|
+
// 3) prune package.json deps/scripts this module added that no other needs.
|
|
102
|
+
pruneAppPackage(projectRoot, targetManifest, others);
|
|
103
|
+
// 4) prune .env.example lines this module added that no other needs.
|
|
104
|
+
pruneEnvExample(projectRoot, targetManifest, others);
|
|
105
|
+
// 5) drop from the manifest (re-number order) and recompute the lock.
|
|
106
|
+
manifest.modules = manifest.modules
|
|
107
|
+
.filter((m) => m.name !== module)
|
|
108
|
+
.map((m, i) => ({ ...m, order: i }));
|
|
109
|
+
(0, lockfile_1.writeManifest)(projectRoot, manifest);
|
|
110
|
+
(0, lockfile_1.writeFilesLock)(projectRoot, (0, lockfile_1.computeFilesLock)(projectRoot, manifest.ownedGlobs));
|
|
111
|
+
return { module, removed, keptEdited, keptShared, unwired };
|
|
112
|
+
}
|
|
113
|
+
/** Remove now-empty directories left behind by a deleted file, up to the root. */
|
|
114
|
+
function pruneEmptyDirs(projectRoot, relDir) {
|
|
115
|
+
let dir = relDir;
|
|
116
|
+
while (dir && dir !== "." && dir !== "/") {
|
|
117
|
+
const abs = (0, node_path_1.join)(projectRoot, dir);
|
|
118
|
+
try {
|
|
119
|
+
(0, node_fs_1.rmdirSync)(abs); // removes only an empty directory; throws otherwise
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return; // not empty (or gone) — stop climbing
|
|
123
|
+
}
|
|
124
|
+
dir = (0, node_path_1.dirname)(dir);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function pruneAppPackage(projectRoot, target, others) {
|
|
128
|
+
if (!(target.dependencies || target.devDependencies || target.scripts))
|
|
129
|
+
return;
|
|
130
|
+
const appPkgPath = (0, node_path_1.join)(projectRoot, "apps", target.targetApp, "package.json");
|
|
131
|
+
if (!(0, node_fs_1.existsSync)(appPkgPath))
|
|
132
|
+
return;
|
|
133
|
+
const keep = (section) => new Set(others.flatMap((o) => Object.keys(o.manifest[section] ?? {})));
|
|
134
|
+
const pkg = readJson(appPkgPath);
|
|
135
|
+
let changed = false;
|
|
136
|
+
for (const section of ["dependencies", "devDependencies", "scripts"]) {
|
|
137
|
+
const declared = target[section];
|
|
138
|
+
const current = pkg[section];
|
|
139
|
+
if (!declared || !current)
|
|
140
|
+
continue;
|
|
141
|
+
const shared = keep(section);
|
|
142
|
+
for (const name of Object.keys(declared)) {
|
|
143
|
+
if (shared.has(name))
|
|
144
|
+
continue;
|
|
145
|
+
if (name in current) {
|
|
146
|
+
delete current[name];
|
|
147
|
+
changed = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (current && Object.keys(current).length === 0)
|
|
151
|
+
delete pkg[section];
|
|
152
|
+
}
|
|
153
|
+
if (changed)
|
|
154
|
+
(0, node_fs_1.writeFileSync)(appPkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
155
|
+
}
|
|
156
|
+
function pruneEnvExample(projectRoot, target, others) {
|
|
157
|
+
if (!target.env?.length)
|
|
158
|
+
return;
|
|
159
|
+
const file = (0, node_path_1.join)(projectRoot, ".env.example");
|
|
160
|
+
if (!(0, node_fs_1.existsSync)(file))
|
|
161
|
+
return;
|
|
162
|
+
const shared = new Set(others.flatMap((o) => o.manifest.env ?? []));
|
|
163
|
+
const drop = new Set(target.env.filter((line) => !shared.has(line)));
|
|
164
|
+
if (!drop.size)
|
|
165
|
+
return;
|
|
166
|
+
const kept = (0, node_fs_1.readFileSync)(file, "utf8")
|
|
167
|
+
.split("\n")
|
|
168
|
+
.filter((line) => !drop.has(line));
|
|
169
|
+
(0, node_fs_1.writeFileSync)(file, kept.join("\n"));
|
|
170
|
+
}
|
|
@@ -66,6 +66,7 @@ in `.podokit/files.lock` — respect it:
|
|
|
66
66
|
Useful commands:
|
|
67
67
|
|
|
68
68
|
- `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
|
|
69
|
+
- `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
|
|
69
70
|
- `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
|
|
70
71
|
- `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
|
|
71
72
|
|
|
@@ -3,8 +3,14 @@ import tailwindcss from "@tailwindcss/vite";
|
|
|
3
3
|
import { defineConfig } from "vite";
|
|
4
4
|
|
|
5
5
|
// Inside the dev container (compose.dev.yaml sets VITE_DOCKER=1): bind to all
|
|
6
|
-
// interfaces, allow the Traefik hostname, and route HMR
|
|
6
|
+
// interfaces, allow the Traefik hostname, and route HMR's WebSocket back through
|
|
7
|
+
// Traefik. The browser reaches the app at Traefik's published host port, so the
|
|
8
|
+
// HMR client must target that same port — otherwise the ws connects to :80,
|
|
9
|
+
// fails, and Vite silently falls back to a full page reload on every change
|
|
10
|
+
// (which wipes in-progress form input). compose.dev.yaml injects
|
|
11
|
+
// VITE_HMR_CLIENT_PORT to match the published Traefik port (default 80).
|
|
7
12
|
const inDocker = process.env.VITE_DOCKER === "1";
|
|
13
|
+
const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT) || 80;
|
|
8
14
|
|
|
9
15
|
export default defineConfig({
|
|
10
16
|
plugins: [tailwindcss(), sveltekit()],
|
|
@@ -13,7 +19,7 @@ export default defineConfig({
|
|
|
13
19
|
host: true,
|
|
14
20
|
port: 5001,
|
|
15
21
|
allowedHosts: true,
|
|
16
|
-
hmr: { clientPort:
|
|
22
|
+
hmr: { clientPort: hmrClientPort },
|
|
17
23
|
}
|
|
18
24
|
: undefined,
|
|
19
25
|
});
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
# docker compose -f compose.dev.yaml --profile cache \
|
|
5
5
|
# --profile storage --profile queue watch # with modules
|
|
6
6
|
#
|
|
7
|
-
# Only Traefik binds a host port (80). The app is at http://app.localhost.
|
|
7
|
+
# Only Traefik binds a host port (default 80). The app is at http://app.localhost.
|
|
8
|
+
# Set TRAEFIK_PORT to publish on another port (e.g. TRAEFIK_PORT=8001) so several
|
|
9
|
+
# containerized stacks can run at once without colliding — the web container reads
|
|
10
|
+
# the same value as VITE_HMR_CLIENT_PORT so Vite HMR follows automatically.
|
|
8
11
|
# api/postgres/redis/minio have NO published ports — they talk by service name
|
|
9
12
|
# on the internal network, so this stack never collides with other projects.
|
|
10
13
|
#
|
|
@@ -29,8 +32,8 @@ services:
|
|
|
29
32
|
- --api.dashboard=true
|
|
30
33
|
- --api.insecure=true
|
|
31
34
|
ports:
|
|
32
|
-
- "80:80"
|
|
33
|
-
- "127.0.0.1
|
|
35
|
+
- "${TRAEFIK_PORT:-80}:80"
|
|
36
|
+
- "127.0.0.1:${TRAEFIK_DASHBOARD_PORT:-8080}:8080" # Traefik dashboard (dev only)
|
|
34
37
|
volumes:
|
|
35
38
|
- ./infra/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
|
|
36
39
|
networks: [podokit]
|
|
@@ -45,6 +48,10 @@ services:
|
|
|
45
48
|
environment:
|
|
46
49
|
- VITE_DOCKER=1
|
|
47
50
|
- BACKEND_INTERNAL_URL=http://api:5002
|
|
51
|
+
# Match Traefik's published port so HMR's WebSocket targets the right port
|
|
52
|
+
# (default 80). Without this, HMR connects to :80 and a non-80 stack falls
|
|
53
|
+
# back to a full page reload on every edit.
|
|
54
|
+
- VITE_HMR_CLIENT_PORT=${TRAEFIK_PORT:-80}
|
|
48
55
|
depends_on: [api]
|
|
49
56
|
networks: [podokit]
|
|
50
57
|
develop:
|
|
@@ -16,6 +16,20 @@ const en = {
|
|
|
16
16
|
language: { label: "Language" },
|
|
17
17
|
toolbar: { filter: "Filter", search: "Search", searchButton: "Search", all: "All" },
|
|
18
18
|
theme: { toggle: "Toggle theme", light: "Light", dark: "Dark", system: "System" },
|
|
19
|
+
setup2fa: {
|
|
20
|
+
title: "Set up two-factor authentication",
|
|
21
|
+
subtitle: "This site requires two-factor authentication. Add an authenticator app to continue.",
|
|
22
|
+
passwordLabel: "Confirm your password",
|
|
23
|
+
begin: "Continue",
|
|
24
|
+
step1: "1. Scan with your authenticator app",
|
|
25
|
+
step2: "2. Save your backup codes",
|
|
26
|
+
step3: "3. Enter the 6-digit code",
|
|
27
|
+
savedConfirm: "I've saved my backup codes",
|
|
28
|
+
activate: "Activate and continue",
|
|
29
|
+
signOut: "Sign out",
|
|
30
|
+
wrongPassword: "That password is incorrect.",
|
|
31
|
+
enableFailed: "Couldn't start setup. Please try again.",
|
|
32
|
+
},
|
|
19
33
|
auth: {
|
|
20
34
|
signInTitle: "Sign in",
|
|
21
35
|
signInDesc: "Enter your credentials to continue.",
|
|
@@ -30,6 +44,15 @@ const en = {
|
|
|
30
44
|
magicLinkSent: "Check your email for a sign-in link.",
|
|
31
45
|
magicLinkNeedsEmail: "Enter your email first.",
|
|
32
46
|
otpButton: "Email me a code", otpCode: "One-time code", otpVerify: "Verify code",
|
|
47
|
+
verify: "Verify",
|
|
48
|
+
twoFactorPrompt: "Enter the 6-digit code from your authenticator app.",
|
|
49
|
+
twoFactorBackupPrompt: "Enter one of your backup codes.",
|
|
50
|
+
twoFactorCodeLabel: "Authentication code",
|
|
51
|
+
backupCodeLabel: "Backup code",
|
|
52
|
+
twoFactorUseBackup: "Use a backup code instead",
|
|
53
|
+
twoFactorUseAuthenticator: "Use your authenticator app instead",
|
|
54
|
+
twoFactorInvalidCode: "That code isn't valid. Check it and try again.",
|
|
55
|
+
twoFactorLocked: "Too many attempts. Please wait a moment and try again.",
|
|
33
56
|
noAccount: "No account?",
|
|
34
57
|
signUp: "Sign up",
|
|
35
58
|
signInFailed: "Sign in failed",
|
|
@@ -132,6 +155,7 @@ const en = {
|
|
|
132
155
|
organization: "Organizations", organizationDesc: "Multi-tenant organizations with members and email invitations.",
|
|
133
156
|
oidcProvider: "OIDC provider", oidcProviderDesc: "Act as an identity provider so other apps can sign in with this one.",
|
|
134
157
|
twoFactor: "Two-factor authentication", twoFactorDesc: "TOTP second factor with backup codes.",
|
|
158
|
+
require2fa: "Require two-factor", require2faDesc: "Every user must enrol in 2FA after signing up before using the app.",
|
|
135
159
|
socialProviders: "Social providers", socialProvidersDesc: "OAuth sign-in (Google, GitHub).",
|
|
136
160
|
breachCheck: "Breached-password check", breachCheckDesc: "Reject passwords found in known breaches (Have I Been Pwned).",
|
|
137
161
|
accountDeletion: "Account deletion", accountDeletionDesc: "Let users delete their own account.",
|
|
@@ -281,6 +305,11 @@ const en = {
|
|
|
281
305
|
scanHint: "Add this to your authenticator app, then enter the 6-digit code.",
|
|
282
306
|
setupKey: "Setup key (otpauth URI)",
|
|
283
307
|
backupCodes: "Backup codes — store them somewhere safe",
|
|
308
|
+
backupCodesHint: "Each code works once if you lose your authenticator. Save them now — they won't be shown again.",
|
|
309
|
+
downloadBackupCodes: "Download",
|
|
310
|
+
regenerateBackupCodes: "Regenerate backup codes",
|
|
311
|
+
regenerateBackupCodesHint: "Generating a new set invalidates your previous backup codes.",
|
|
312
|
+
backupCodesRegenerated: "New backup codes generated",
|
|
284
313
|
code: "Verification code",
|
|
285
314
|
verify: "Verify",
|
|
286
315
|
twoFactorEnabled: "Two-factor enabled",
|
|
@@ -322,6 +351,20 @@ const ko: Messages = {
|
|
|
322
351
|
language: { label: "언어" },
|
|
323
352
|
toolbar: { filter: "필터", search: "검색", searchButton: "검색", all: "전체" },
|
|
324
353
|
theme: { toggle: "테마 전환", light: "라이트", dark: "다크", system: "시스템" },
|
|
354
|
+
setup2fa: {
|
|
355
|
+
title: "2단계 인증 설정",
|
|
356
|
+
subtitle: "이 사이트는 2단계 인증이 필수입니다. 계속하려면 인증 앱을 등록하세요.",
|
|
357
|
+
passwordLabel: "비밀번호 확인",
|
|
358
|
+
begin: "계속",
|
|
359
|
+
step1: "1. 인증 앱으로 스캔",
|
|
360
|
+
step2: "2. 백업 코드 저장",
|
|
361
|
+
step3: "3. 6자리 코드 입력",
|
|
362
|
+
savedConfirm: "백업 코드를 저장했습니다",
|
|
363
|
+
activate: "활성화하고 계속",
|
|
364
|
+
signOut: "로그아웃",
|
|
365
|
+
wrongPassword: "비밀번호가 올바르지 않습니다.",
|
|
366
|
+
enableFailed: "설정을 시작할 수 없습니다. 다시 시도해 주세요.",
|
|
367
|
+
},
|
|
325
368
|
auth: {
|
|
326
369
|
signInTitle: "로그인",
|
|
327
370
|
signInDesc: "계속하려면 로그인하세요.",
|
|
@@ -336,6 +379,15 @@ const ko: Messages = {
|
|
|
336
379
|
magicLinkSent: "이메일의 로그인 링크를 확인하세요.",
|
|
337
380
|
magicLinkNeedsEmail: "먼저 이메일을 입력하세요.",
|
|
338
381
|
otpButton: "코드 이메일로 받기", otpCode: "일회용 코드", otpVerify: "코드 확인",
|
|
382
|
+
verify: "확인",
|
|
383
|
+
twoFactorPrompt: "인증 앱의 6자리 코드를 입력하세요.",
|
|
384
|
+
twoFactorBackupPrompt: "백업 코드 중 하나를 입력하세요.",
|
|
385
|
+
twoFactorCodeLabel: "인증 코드",
|
|
386
|
+
backupCodeLabel: "백업 코드",
|
|
387
|
+
twoFactorUseBackup: "백업 코드로 대신 인증",
|
|
388
|
+
twoFactorUseAuthenticator: "인증 앱으로 대신 인증",
|
|
389
|
+
twoFactorInvalidCode: "코드가 올바르지 않습니다. 다시 확인해 주세요.",
|
|
390
|
+
twoFactorLocked: "시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.",
|
|
339
391
|
noAccount: "계정이 없으신가요?",
|
|
340
392
|
signUp: "회원가입",
|
|
341
393
|
signInFailed: "로그인에 실패했습니다",
|
|
@@ -438,6 +490,7 @@ const ko: Messages = {
|
|
|
438
490
|
organization: "조직", organizationDesc: "멤버와 이메일 초대를 갖춘 멀티테넌트 조직.",
|
|
439
491
|
oidcProvider: "OIDC 제공자", oidcProviderDesc: "다른 앱이 이 앱으로 로그인하도록 신원 제공자 역할.",
|
|
440
492
|
twoFactor: "2단계 인증", twoFactorDesc: "TOTP 2차 인증 + 백업 코드.",
|
|
493
|
+
require2fa: "2FA 필수화", require2faDesc: "모든 사용자가 가입 후 2FA를 등록해야 앱을 사용할 수 있습니다.",
|
|
441
494
|
socialProviders: "소셜 로그인", socialProvidersDesc: "OAuth 로그인 (Google, GitHub).",
|
|
442
495
|
breachCheck: "유출 비밀번호 검사", breachCheckDesc: "유출된 비밀번호 거부 (Have I Been Pwned).",
|
|
443
496
|
accountDeletion: "계정 삭제", accountDeletionDesc: "사용자가 자신의 계정을 삭제.",
|
|
@@ -587,6 +640,11 @@ const ko: Messages = {
|
|
|
587
640
|
scanHint: "인증 앱에 등록한 뒤 6자리 코드를 입력하세요.",
|
|
588
641
|
setupKey: "설정 키 (otpauth URI)",
|
|
589
642
|
backupCodes: "백업 코드 — 안전한 곳에 보관하세요",
|
|
643
|
+
backupCodesHint: "인증 앱을 분실했을 때 각 코드는 한 번씩 사용할 수 있습니다. 지금 저장하세요 — 다시 표시되지 않습니다.",
|
|
644
|
+
downloadBackupCodes: "다운로드",
|
|
645
|
+
regenerateBackupCodes: "백업 코드 재발급",
|
|
646
|
+
regenerateBackupCodesHint: "새로 발급하면 이전 백업 코드는 무효화됩니다.",
|
|
647
|
+
backupCodesRegenerated: "새 백업 코드를 발급했습니다",
|
|
590
648
|
code: "인증 코드",
|
|
591
649
|
verify: "확인",
|
|
592
650
|
twoFactorEnabled: "2단계 인증을 사용합니다",
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { redirect } from "@sveltejs/kit";
|
|
1
2
|
import type { LayoutServerLoad } from "./$types";
|
|
2
3
|
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
3
4
|
|
|
@@ -26,5 +27,21 @@ export const load: LayoutServerLoad = async ({ locals, fetch }) => {
|
|
|
26
27
|
} catch {
|
|
27
28
|
/* keep defaults */
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
// Require-two-factor policy: a signed-in user who hasn't enrolled is sent to the
|
|
32
|
+
// enrolment page until they do (the API's TwoFactorRequiredGuard is the real
|
|
33
|
+
// boundary; this is the UX). /setup-2fa lives outside this layout, so no loop.
|
|
34
|
+
const user = locals.user as (App.Locals["user"] & { twoFactorEnabled?: boolean }) | null;
|
|
35
|
+
let mustEnrol = false;
|
|
36
|
+
if (user && !user.twoFactorEnabled) {
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch("/api/account/require-2fa");
|
|
39
|
+
mustEnrol = res.ok && ((await res.json()) as { require2fa?: boolean }).require2fa === true;
|
|
40
|
+
} catch {
|
|
41
|
+
/* don't block the app if the policy check fails */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (mustEnrol) redirect(302, "/setup-2fa");
|
|
45
|
+
|
|
29
46
|
return { user: locals.user, impersonating: !!locals.session?.impersonatedBy, capabilities };
|
|
30
47
|
};
|
|
@@ -162,8 +162,29 @@
|
|
|
162
162
|
if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
|
|
163
163
|
twoFaOn = false;
|
|
164
164
|
twoFaPassword = "";
|
|
165
|
+
regenCodes = null;
|
|
165
166
|
toast.success(i18n.t.account.twoFactorDisabled);
|
|
166
167
|
}
|
|
168
|
+
// Backup codes: regenerate a fresh set (invalidating the old ones) and download.
|
|
169
|
+
let regenCodes = $state<string[] | null>(null);
|
|
170
|
+
async function regenerateBackupCodes(): Promise<void> {
|
|
171
|
+
twoFaBusy = true;
|
|
172
|
+
const { data: res, error } = await api.auth.twoFactor.generateBackupCodes({ password: twoFaPassword });
|
|
173
|
+
twoFaBusy = false;
|
|
174
|
+
if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
|
|
175
|
+
regenCodes = (res?.backupCodes ?? []) as string[];
|
|
176
|
+
twoFaPassword = "";
|
|
177
|
+
toast.success(i18n.t.account.backupCodesRegenerated);
|
|
178
|
+
}
|
|
179
|
+
function downloadBackupCodes(codes: string[]): void {
|
|
180
|
+
const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain" });
|
|
181
|
+
const url = URL.createObjectURL(blob);
|
|
182
|
+
const a = document.createElement("a");
|
|
183
|
+
a.href = url;
|
|
184
|
+
a.download = "backup-codes.txt";
|
|
185
|
+
a.click();
|
|
186
|
+
URL.revokeObjectURL(url);
|
|
187
|
+
}
|
|
167
188
|
|
|
168
189
|
// Passkeys (WebAuthn) — passwordless credentials bound to this device/browser.
|
|
169
190
|
type Passkey = { id: string; name: string | null; createdAt: string | Date };
|
|
@@ -470,7 +491,21 @@
|
|
|
470
491
|
<Label for="tf-off-pw">{i18n.t.account.password}</Label>
|
|
471
492
|
<Input id="tf-off-pw" type="password" bind:value={twoFaPassword} autocomplete="current-password" />
|
|
472
493
|
</div>
|
|
473
|
-
<
|
|
494
|
+
<div class="flex flex-wrap gap-2">
|
|
495
|
+
<Button disabled={twoFaBusy || !twoFaPassword} onclick={regenerateBackupCodes}>{i18n.t.account.regenerateBackupCodes}</Button>
|
|
496
|
+
<Button variant="destructive" disabled={twoFaBusy || !twoFaPassword} onclick={disable2fa}>{i18n.t.account.disable}</Button>
|
|
497
|
+
</div>
|
|
498
|
+
<p class="text-muted-foreground text-xs">{i18n.t.account.regenerateBackupCodesHint}</p>
|
|
499
|
+
{#if regenCodes}
|
|
500
|
+
<div class="flex flex-col gap-1">
|
|
501
|
+
<Label>{i18n.t.account.backupCodes}</Label>
|
|
502
|
+
<p class="text-muted-foreground text-xs">{i18n.t.account.backupCodesHint}</p>
|
|
503
|
+
<div class="bg-muted grid grid-cols-2 gap-1 rounded p-2 font-mono text-xs" data-testid="backup-codes">
|
|
504
|
+
{#each regenCodes as c (c)}<span>{c}</span>{/each}
|
|
505
|
+
</div>
|
|
506
|
+
<Button variant="outline" size="sm" class="w-fit" onclick={() => downloadBackupCodes(regenCodes!)}>{i18n.t.account.downloadBackupCodes}</Button>
|
|
507
|
+
</div>
|
|
508
|
+
{/if}
|
|
474
509
|
{:else if setup}
|
|
475
510
|
<p class="text-muted-foreground text-sm">{i18n.t.account.scanHint}</p>
|
|
476
511
|
{#if qrDataUrl}
|
|
@@ -482,9 +517,11 @@
|
|
|
482
517
|
</div>
|
|
483
518
|
<div class="flex flex-col gap-1">
|
|
484
519
|
<Label>{i18n.t.account.backupCodes}</Label>
|
|
485
|
-
<
|
|
520
|
+
<p class="text-muted-foreground text-xs">{i18n.t.account.backupCodesHint}</p>
|
|
521
|
+
<div class="bg-muted grid grid-cols-2 gap-1 rounded p-2 font-mono text-xs" data-testid="backup-codes">
|
|
486
522
|
{#each setup.backupCodes as c (c)}<span>{c}</span>{/each}
|
|
487
523
|
</div>
|
|
524
|
+
<Button variant="outline" size="sm" class="w-fit" onclick={() => downloadBackupCodes(setup!.backupCodes)}>{i18n.t.account.downloadBackupCodes}</Button>
|
|
488
525
|
</div>
|
|
489
526
|
<div class="flex flex-col gap-2">
|
|
490
527
|
<Label for="tf-code">{i18n.t.account.code}</Label>
|
|
@@ -22,5 +22,14 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
|
|
|
22
22
|
} catch {
|
|
23
23
|
/* leave null — the page shows the DB-config sections as unavailable */
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
// The require-two-factor policy lives in the feature-flag store (not the typed
|
|
26
|
+
// Capabilities), so read it from its own endpoint for the Settings toggle.
|
|
27
|
+
let require2fa = false;
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch("/api/account/require-2fa");
|
|
30
|
+
if (res.ok) require2fa = ((await res.json()) as { require2fa?: boolean }).require2fa === true;
|
|
31
|
+
} catch {
|
|
32
|
+
/* default off */
|
|
33
|
+
}
|
|
34
|
+
return { authConfig, require2fa };
|
|
26
35
|
};
|
|
@@ -16,12 +16,15 @@
|
|
|
16
16
|
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
17
17
|
import type { AuthConfigView, SocialProviderView } from "./+page.server";
|
|
18
18
|
|
|
19
|
-
let { data }: { data: { capabilities: Capabilities; authConfig: AuthConfigView | null } } = $props();
|
|
19
|
+
let { data }: { data: { capabilities: Capabilities; authConfig: AuthConfigView | null; require2fa: boolean } } = $props();
|
|
20
20
|
const i18n = getI18n();
|
|
21
21
|
const caps = $derived(data.capabilities);
|
|
22
22
|
const ac = $derived(data.authConfig);
|
|
23
23
|
|
|
24
24
|
type EditableFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider";
|
|
25
|
+
// require2fa is a policy flag stored alongside the feature flags but not part of
|
|
26
|
+
// the typed Capabilities, so it is toggled but never read from `caps`.
|
|
27
|
+
type ToggleFlag = EditableFlag | "require2fa";
|
|
25
28
|
type ServerKey = "requireEmailVerification" | "hibp" | "allowDelete" | "auditLog";
|
|
26
29
|
|
|
27
30
|
// Every card renders from this one shape, so they share size and structure: a
|
|
@@ -54,7 +57,7 @@
|
|
|
54
57
|
return ok;
|
|
55
58
|
}
|
|
56
59
|
// Feature flags live in a separate store (app_setting) with their own endpoint.
|
|
57
|
-
async function toggleFlag(flag:
|
|
60
|
+
async function toggleFlag(flag: ToggleFlag, next: boolean): Promise<void> {
|
|
58
61
|
saving = true;
|
|
59
62
|
try {
|
|
60
63
|
await api.put("/account/settings", { [flag]: next });
|
|
@@ -174,6 +177,14 @@
|
|
|
174
177
|
]);
|
|
175
178
|
const security = $derived<Item[]>([
|
|
176
179
|
flagCard("twoFactor", i18n.t.settings.twoFactor, i18n.t.settings.twoFactorDesc),
|
|
180
|
+
{
|
|
181
|
+
key: "flag-require2fa",
|
|
182
|
+
name: i18n.t.settings.require2fa,
|
|
183
|
+
desc: i18n.t.settings.require2faDesc,
|
|
184
|
+
status: { on: data.require2fa, label: data.require2fa ? i18n.t.settings.enabled : i18n.t.settings.disabled },
|
|
185
|
+
// Only meaningful when 2FA itself is on; disabled otherwise.
|
|
186
|
+
toggle: { checked: data.require2fa, disabled: saving || !caps.twoFactor, onChange: (v) => toggleFlag("require2fa", v) },
|
|
187
|
+
},
|
|
177
188
|
serverCard("hibp", i18n.t.settings.breachCheck, i18n.t.settings.breachCheckDesc, caps.passwordBreachCheck),
|
|
178
189
|
serverCard("allowDelete", i18n.t.settings.accountDeletion, i18n.t.settings.accountDeletionDesc, caps.deleteAccount),
|
|
179
190
|
serverCard("auditLog", i18n.t.settings.auditLog, i18n.t.settings.auditLogDesc, caps.auditLog),
|