create-authenik8-app 2.4.12 → 2.4.14
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/README.md +160 -57
- package/assets/logo.svg +53 -0
- package/dist/src/bin/index.js +199 -125
- package/dist/src/bin/index.js.map +1 -1
- package/dist/src/lib/args.d.ts +18 -0
- package/dist/src/lib/args.d.ts.map +1 -0
- package/dist/src/lib/args.js +82 -0
- package/dist/src/lib/args.js.map +1 -0
- package/dist/src/lib/constants.d.ts +1 -0
- package/dist/src/lib/constants.d.ts.map +1 -1
- package/dist/src/lib/constants.js +10 -11
- package/dist/src/lib/constants.js.map +1 -1
- package/dist/src/lib/preflight.d.ts +3 -0
- package/dist/src/lib/preflight.d.ts.map +1 -0
- package/dist/src/lib/preflight.js +21 -0
- package/dist/src/lib/preflight.js.map +1 -0
- package/dist/src/lib/process.d.ts +6 -2
- package/dist/src/lib/process.d.ts.map +1 -1
- package/dist/src/lib/process.js +66 -8
- package/dist/src/lib/process.js.map +1 -1
- package/dist/src/lib/projectName.d.ts +2 -0
- package/dist/src/lib/projectName.d.ts.map +1 -0
- package/dist/src/lib/projectName.js +6 -0
- package/dist/src/lib/projectName.js.map +1 -0
- package/dist/src/lib/schemas.d.ts +192 -0
- package/dist/src/lib/schemas.d.ts.map +1 -0
- package/dist/src/lib/schemas.js +171 -0
- package/dist/src/lib/schemas.js.map +1 -0
- package/dist/src/lib/state.d.ts.map +1 -1
- package/dist/src/lib/state.js +20 -3
- package/dist/src/lib/state.js.map +1 -1
- package/dist/src/lib/types.d.ts +6 -13
- package/dist/src/lib/types.d.ts.map +1 -1
- package/dist/src/lib/ui.d.ts +8 -3
- package/dist/src/lib/ui.d.ts.map +1 -1
- package/dist/src/lib/ui.js +76 -112
- package/dist/src/lib/ui.js.map +1 -1
- package/dist/src/steps/configurePrisma.d.ts.map +1 -1
- package/dist/src/steps/configurePrisma.js +83 -9
- package/dist/src/steps/configurePrisma.js.map +1 -1
- package/dist/src/steps/createProject.d.ts +2 -1
- package/dist/src/steps/createProject.d.ts.map +1 -1
- package/dist/src/steps/createProject.js +148 -27
- package/dist/src/steps/createProject.js.map +1 -1
- package/dist/src/steps/finalSetup.d.ts +3 -3
- package/dist/src/steps/finalSetup.d.ts.map +1 -1
- package/dist/src/steps/finalSetup.js +24 -28
- package/dist/src/steps/finalSetup.js.map +1 -1
- package/dist/src/steps/installAuth.d.ts +1 -1
- package/dist/src/steps/installAuth.d.ts.map +1 -1
- package/dist/src/steps/installAuth.js +5 -4
- package/dist/src/steps/installAuth.js.map +1 -1
- package/dist/src/steps/installDeps.d.ts +11 -4
- package/dist/src/steps/installDeps.d.ts.map +1 -1
- package/dist/src/steps/installDeps.js +45 -77
- package/dist/src/steps/installDeps.js.map +1 -1
- package/dist/src/steps/prompts.d.ts +1 -1
- package/dist/src/steps/prompts.d.ts.map +1 -1
- package/dist/src/steps/prompts.js +42 -30
- package/dist/src/steps/prompts.js.map +1 -1
- package/dist/src/utils/jwk.d.ts +2 -0
- package/dist/src/utils/jwk.d.ts.map +1 -0
- package/dist/src/utils/jwk.js +44 -0
- package/dist/src/utils/jwk.js.map +1 -0
- package/dist/src/utils/output.d.ts +1 -1
- package/dist/src/utils/output.d.ts.map +1 -1
- package/dist/src/utils/output.js +75 -68
- package/dist/src/utils/output.js.map +1 -1
- package/package.json +17 -9
- package/templates/AGENT_IDENTITY.md +112 -0
- package/templates/THREAT_MODEL.md +27 -5
- package/templates/express-auth/README.md +13 -10
- package/templates/express-auth/docker-compose.yml +10 -0
- package/templates/express-auth/gitignore.template +6 -0
- package/templates/express-auth/package.json +12 -5
- package/templates/express-auth/src/app.ts +2 -0
- package/templates/express-auth/src/controllers/auth.controller.ts +2 -6
- package/templates/express-auth/src/controllers/protected.controller.ts +25 -10
- package/templates/express-auth/src/middleware/auth.middleware.ts +1 -0
- package/templates/express-auth/src/routes/protected.routes.ts +3 -1
- package/templates/express-auth/src/server.ts +6 -12
- package/templates/express-auth/src/utils/security.ts +119 -32
- package/templates/express-auth+/README.md +13 -10
- package/templates/express-auth+/docker-compose.yml +10 -0
- package/templates/express-auth+/gitignore.template +6 -0
- package/templates/express-auth+/package.json +12 -5
- package/templates/express-auth+/src/auth/auth.ts +3 -2
- package/templates/express-auth+/src/auth/controllers/password.controller.ts +3 -7
- package/templates/express-auth+/src/auth/controllers/protected.controller.ts +26 -11
- package/templates/express-auth+/src/auth/identity.adapter.ts +90 -0
- package/templates/express-auth+/src/auth/middleware/auth.middleware.ts +3 -4
- package/templates/express-auth+/src/auth/routes/protected.routes.ts +2 -2
- package/templates/express-auth+/src/oauth-providers/github/src/auth/auth.ts +3 -2
- package/templates/express-auth+/src/oauth-providers/google/src/auth/auth.ts +3 -2
- package/templates/express-auth+/src/oauth-providers/google-github/src/auth/auth.ts +3 -2
- package/templates/express-auth+/src/server.ts +5 -10
- package/templates/express-auth+/src/utils/security.ts +109 -28
- package/templates/express-base/README.md +13 -6
- package/templates/express-base/app.ts +2 -0
- package/templates/express-base/controllers/base.controller.ts +33 -19
- package/templates/express-base/docker-compose.yml +10 -0
- package/templates/express-base/gitignore.template +6 -0
- package/templates/express-base/package.json +11 -5
- package/templates/express-base/routes/base.routes.ts +1 -1
- package/templates/express-base/src/.env.example +7 -2
- package/templates/express-base/src/server.ts +6 -12
- package/templates/express-base/tsconfig.json +7 -40
- package/templates/express-base/utils/security.ts +111 -15
- package/templates/fullstack/.env.example +24 -0
- package/templates/fullstack/PRESET_CONTRACT.md +46 -0
- package/templates/fullstack/README.md +65 -0
- package/templates/fullstack/THREAT_MODEL.md +30 -0
- package/templates/fullstack/apps/api/package.json +48 -0
- package/templates/fullstack/apps/api/prisma/schema.prisma +124 -0
- package/templates/fullstack/apps/api/prisma/seed.ts +36 -0
- package/templates/fullstack/apps/api/prisma.config.ts +18 -0
- package/templates/fullstack/apps/api/src/app.ts +68 -0
- package/templates/fullstack/apps/api/src/auth/auth.controller.ts +125 -0
- package/templates/fullstack/apps/api/src/auth/auth.routes.ts +50 -0
- package/templates/fullstack/apps/api/src/auth/auth.service.ts +257 -0
- package/templates/fullstack/apps/api/src/auth/authenik8.ts +67 -0
- package/templates/fullstack/apps/api/src/auth/cookies.ts +36 -0
- package/templates/fullstack/apps/api/src/config/env.ts +72 -0
- package/templates/fullstack/apps/api/src/config/logger.ts +17 -0
- package/templates/fullstack/apps/api/src/config/mailer.ts +32 -0
- package/templates/fullstack/apps/api/src/config/origins.ts +27 -0
- package/templates/fullstack/apps/api/src/config/prisma.ts +9 -0
- package/templates/fullstack/apps/api/src/middleware/authenticate.ts +35 -0
- package/templates/fullstack/apps/api/src/middleware/csrf.ts +60 -0
- package/templates/fullstack/apps/api/src/middleware/origin.ts +12 -0
- package/templates/fullstack/apps/api/src/middleware/request-id.ts +9 -0
- package/templates/fullstack/apps/api/src/modules/admin/admin.controller.ts +17 -0
- package/templates/fullstack/apps/api/src/modules/admin/admin.routes.ts +14 -0
- package/templates/fullstack/apps/api/src/modules/admin/admin.service.ts +54 -0
- package/templates/fullstack/apps/api/src/modules/projects/project.controller.ts +22 -0
- package/templates/fullstack/apps/api/src/modules/projects/project.policy.ts +14 -0
- package/templates/fullstack/apps/api/src/modules/projects/project.repository.ts +20 -0
- package/templates/fullstack/apps/api/src/modules/projects/project.routes.ts +21 -0
- package/templates/fullstack/apps/api/src/modules/projects/project.service.ts +41 -0
- package/templates/fullstack/apps/api/src/modules/users/user.controller.ts +26 -0
- package/templates/fullstack/apps/api/src/modules/users/user.presenter.ts +13 -0
- package/templates/fullstack/apps/api/src/modules/users/user.routes.ts +21 -0
- package/templates/fullstack/apps/api/src/modules/users/user.service.ts +71 -0
- package/templates/fullstack/apps/api/src/openapi.ts +32 -0
- package/templates/fullstack/apps/api/src/server.ts +29 -0
- package/templates/fullstack/apps/api/src/types.ts +15 -0
- package/templates/fullstack/apps/api/src/utils/crypto.ts +4 -0
- package/templates/fullstack/apps/api/src/utils/http.ts +33 -0
- package/templates/fullstack/apps/api/src/utils/sealed-value.ts +37 -0
- package/templates/fullstack/apps/api/tests/project.policy.test.ts +26 -0
- package/templates/fullstack/apps/api/tests/registration.test.ts +69 -0
- package/templates/fullstack/apps/api/tests/security.test.ts +98 -0
- package/templates/fullstack/apps/api/tests/validation.test.ts +37 -0
- package/templates/fullstack/apps/api/tsconfig.json +16 -0
- package/templates/fullstack/apps/api/vitest.config.ts +9 -0
- package/templates/fullstack/apps/web/index.html +14 -0
- package/templates/fullstack/apps/web/package.json +31 -0
- package/templates/fullstack/apps/web/public/authenik8-logo.svg +53 -0
- package/templates/fullstack/apps/web/src/App.tsx +45 -0
- package/templates/fullstack/apps/web/src/auth/AuthProvider.tsx +53 -0
- package/templates/fullstack/apps/web/src/auth/RouteGuards.tsx +16 -0
- package/templates/fullstack/apps/web/src/auth/providers.ts +3 -0
- package/templates/fullstack/apps/web/src/components/AppShell.tsx +80 -0
- package/templates/fullstack/apps/web/src/components/AuthShell.tsx +26 -0
- package/templates/fullstack/apps/web/src/components/Page.tsx +30 -0
- package/templates/fullstack/apps/web/src/features/projects/ProjectPages.tsx +79 -0
- package/templates/fullstack/apps/web/src/main.tsx +24 -0
- package/templates/fullstack/apps/web/src/pages/DashboardPage.tsx +48 -0
- package/templates/fullstack/apps/web/src/pages/StatusPages.tsx +8 -0
- package/templates/fullstack/apps/web/src/pages/admin/AuditPage.tsx +19 -0
- package/templates/fullstack/apps/web/src/pages/admin/UsersPage.tsx +24 -0
- package/templates/fullstack/apps/web/src/pages/auth/LoginPage.tsx +51 -0
- package/templates/fullstack/apps/web/src/pages/auth/OAuthCallbackPage.tsx +26 -0
- package/templates/fullstack/apps/web/src/pages/auth/RecoveryPages.tsx +53 -0
- package/templates/fullstack/apps/web/src/pages/auth/RegisterPage.tsx +86 -0
- package/templates/fullstack/apps/web/src/pages/auth/VerifyEmailPage.tsx +24 -0
- package/templates/fullstack/apps/web/src/pages/settings/ProfilePage.tsx +34 -0
- package/templates/fullstack/apps/web/src/pages/settings/SecurityPage.tsx +57 -0
- package/templates/fullstack/apps/web/src/styles.css +289 -0
- package/templates/fullstack/apps/web/src/token-storage.test.ts +12 -0
- package/templates/fullstack/apps/web/tsconfig.app.json +19 -0
- package/templates/fullstack/apps/web/tsconfig.json +7 -0
- package/templates/fullstack/apps/web/tsconfig.node.json +10 -0
- package/templates/fullstack/apps/web/vite.config.ts +18 -0
- package/templates/fullstack/docker-compose.yml +35 -0
- package/templates/fullstack/docs/PRODUCTION.md +30 -0
- package/templates/fullstack/gitignore.template +8 -0
- package/templates/fullstack/package.json +39 -0
- package/templates/fullstack/packages/api-client/package.json +20 -0
- package/templates/fullstack/packages/api-client/src/index.ts +203 -0
- package/templates/fullstack/packages/api-client/tsconfig.json +14 -0
- package/templates/fullstack/packages/contracts/package.json +20 -0
- package/templates/fullstack/packages/contracts/src/index.ts +146 -0
- package/templates/fullstack/packages/contracts/tsconfig.json +13 -0
- package/templates/fullstack/packages/ui/package.json +22 -0
- package/templates/fullstack/packages/ui/src/index.tsx +33 -0
- package/templates/fullstack/packages/ui/tsconfig.json +14 -0
- package/templates/prisma/postgresql/.env.example +6 -2
- package/templates/prisma/postgresql/client.ts +17 -0
- package/templates/prisma/postgresql/prisma.config.ts +12 -0
- package/templates/prisma/postgresql/schema.prisma +0 -1
- package/templates/prisma/sqlite/.env.example +6 -2
- package/templates/prisma/sqlite/client.ts +16 -0
- package/templates/prisma/sqlite/prisma.config.ts +12 -0
- package/templates/prisma/sqlite/schema.prisma +0 -1
- package/dist/src/tests/templated-routes.test.d.ts +0 -2
- package/dist/src/tests/templated-routes.test.d.ts.map +0 -1
- package/dist/src/tests/templated-routes.test.js +0 -325
- package/dist/src/tests/templated-routes.test.js.map +0 -1
- package/dist/templates/THREAT_MODEL.md +0 -138
- package/dist/templates/express-auth/src/controllers/protected.controller.d.ts +0 -8
- package/dist/templates/express-auth/src/controllers/protected.controller.d.ts.map +0 -1
- package/dist/templates/express-auth/src/controllers/protected.controller.js +0 -50
- package/dist/templates/express-auth/src/controllers/protected.controller.js.map +0 -1
- package/dist/templates/express-auth/src/routes/protected.routes.d.ts +0 -2
- package/dist/templates/express-auth/src/routes/protected.routes.d.ts.map +0 -1
- package/dist/templates/express-auth/src/routes/protected.routes.js +0 -16
- package/dist/templates/express-auth/src/routes/protected.routes.js.map +0 -1
- package/dist/templates/express-auth/src/utils/security.d.ts +0 -8
- package/dist/templates/express-auth/src/utils/security.d.ts.map +0 -1
- package/dist/templates/express-auth/src/utils/security.js +0 -55
- package/dist/templates/express-auth/src/utils/security.js.map +0 -1
- package/dist/templates/express-base/controllers/base.controller.d.ts +0 -12
- package/dist/templates/express-base/controllers/base.controller.d.ts.map +0 -1
- package/dist/templates/express-base/controllers/base.controller.js +0 -77
- package/dist/templates/express-base/controllers/base.controller.js.map +0 -1
- package/dist/templates/express-base/routes/base.routes.d.ts +0 -2
- package/dist/templates/express-base/routes/base.routes.d.ts.map +0 -1
- package/dist/templates/express-base/routes/base.routes.js +0 -20
- package/dist/templates/express-base/routes/base.routes.js.map +0 -1
- package/dist/templates/express-base/utils/security.d.ts +0 -5
- package/dist/templates/express-base/utils/security.d.ts.map +0 -1
- package/dist/templates/express-base/utils/security.js +0 -40
- package/dist/templates/express-base/utils/security.js.map +0 -1
- package/dist/templates/package.json +0 -27
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../src/config/env.js", () => ({
|
|
4
|
+
env: {
|
|
5
|
+
COOKIE_SECURE: false,
|
|
6
|
+
NODE_ENV: "test",
|
|
7
|
+
REFRESH_SECRET: "test-refresh-secret-with-more-than-32-characters",
|
|
8
|
+
WEB_ORIGIN: "http://localhost:5173",
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { readRefreshCookie, refreshCookieName, refreshCookieOptions, setRefreshCookie } from "../src/auth/cookies.js";
|
|
13
|
+
import { csrfCookieName, issueCsrfToken, requireCsrf } from "../src/middleware/csrf.js";
|
|
14
|
+
import { requireAllowedOrigin } from "../src/middleware/origin.js";
|
|
15
|
+
|
|
16
|
+
describe("browser session defenses", () => {
|
|
17
|
+
it("restricts the refresh cookie", () => {
|
|
18
|
+
expect(refreshCookieOptions()).toMatchObject({
|
|
19
|
+
httpOnly: true,
|
|
20
|
+
sameSite: "strict",
|
|
21
|
+
path: "/api",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("encrypts refresh tokens before cookie storage", () => {
|
|
26
|
+
const cookie = vi.fn();
|
|
27
|
+
const rawToken = "refresh-token-value";
|
|
28
|
+
setRefreshCookie({ cookie } as never, rawToken);
|
|
29
|
+
|
|
30
|
+
const sealedToken = cookie.mock.calls[0]?.[1] as string;
|
|
31
|
+
expect(sealedToken).not.toContain(rawToken);
|
|
32
|
+
expect(readRefreshCookie({ cookies: { [refreshCookieName]: sealedToken } } as never)).toBe(rawToken);
|
|
33
|
+
expect(readRefreshCookie({ cookies: { [refreshCookieName]: `${sealedToken}tampered` } } as never)).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("requires the signed CSRF cookie and matching request header", () => {
|
|
37
|
+
const cookie = vi.fn();
|
|
38
|
+
const token = issueCsrfToken({ cookies: {} } as never, { cookie } as never);
|
|
39
|
+
const next = vi.fn();
|
|
40
|
+
const status = vi.fn().mockReturnThis();
|
|
41
|
+
const json = vi.fn();
|
|
42
|
+
|
|
43
|
+
requireCsrf(
|
|
44
|
+
{ cookies: { [csrfCookieName]: token }, get: () => token, id: "request-3" } as never,
|
|
45
|
+
{ status, json } as never,
|
|
46
|
+
next,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(cookie).toHaveBeenCalledWith(csrfCookieName, token, expect.objectContaining({ httpOnly: true, sameSite: "strict" }));
|
|
50
|
+
expect(next).toHaveBeenCalledOnce();
|
|
51
|
+
expect(status).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects a missing or mismatched CSRF token without disclosing it", () => {
|
|
55
|
+
const status = vi.fn().mockReturnThis();
|
|
56
|
+
const json = vi.fn();
|
|
57
|
+
const next = vi.fn();
|
|
58
|
+
requireCsrf(
|
|
59
|
+
{ cookies: {}, get: () => undefined, id: "request-4" } as never,
|
|
60
|
+
{ status, json } as never,
|
|
61
|
+
next,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(status).toHaveBeenCalledWith(403);
|
|
65
|
+
expect(json).toHaveBeenCalledWith({
|
|
66
|
+
error: { code: "CSRF_REJECTED", message: "Request verification failed" },
|
|
67
|
+
requestId: "request-4",
|
|
68
|
+
});
|
|
69
|
+
expect(next).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects a mismatched cookie request origin", () => {
|
|
73
|
+
const status = vi.fn().mockReturnThis();
|
|
74
|
+
const json = vi.fn();
|
|
75
|
+
const next = vi.fn();
|
|
76
|
+
requireAllowedOrigin(
|
|
77
|
+
{ get: () => "https://attacker.example", id: "request-1" } as never,
|
|
78
|
+
{ status, json } as never,
|
|
79
|
+
next,
|
|
80
|
+
);
|
|
81
|
+
expect(status).toHaveBeenCalledWith(403);
|
|
82
|
+
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.objectContaining({ code: "ORIGIN_REJECTED" }) }));
|
|
83
|
+
expect(next).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("allows the local preview origin during development", () => {
|
|
87
|
+
const status = vi.fn().mockReturnThis();
|
|
88
|
+
const json = vi.fn();
|
|
89
|
+
const next = vi.fn();
|
|
90
|
+
requireAllowedOrigin(
|
|
91
|
+
{ get: () => "http://localhost:4173", id: "request-2" } as never,
|
|
92
|
+
{ status, json } as never,
|
|
93
|
+
next,
|
|
94
|
+
);
|
|
95
|
+
expect(next).toHaveBeenCalledOnce();
|
|
96
|
+
expect(status).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
identifierSchema,
|
|
4
|
+
pageSchema,
|
|
5
|
+
projectCreateSchema,
|
|
6
|
+
registerSchema,
|
|
7
|
+
} from "@authenik8/contracts";
|
|
8
|
+
|
|
9
|
+
describe("request validation", () => {
|
|
10
|
+
it("normalizes valid registration input", () => {
|
|
11
|
+
expect(registerSchema.parse({
|
|
12
|
+
name: " Jane Example ",
|
|
13
|
+
email: " JANE@EXAMPLE.COM ",
|
|
14
|
+
password: "SecurePass1",
|
|
15
|
+
})).toEqual({
|
|
16
|
+
name: "Jane Example",
|
|
17
|
+
email: "jane@example.com",
|
|
18
|
+
password: "SecurePass1",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("rejects weak registration input and unknown fields", () => {
|
|
23
|
+
expect(registerSchema.safeParse({
|
|
24
|
+
name: "J",
|
|
25
|
+
email: "not-an-email",
|
|
26
|
+
password: "password",
|
|
27
|
+
role: "ADMIN",
|
|
28
|
+
}).success).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("normalizes project text and bounds route/query values", () => {
|
|
32
|
+
expect(projectCreateSchema.parse({ name: " Project Alpha ", description: " notes\r\n" }))
|
|
33
|
+
.toMatchObject({ name: "Project Alpha", description: "notes" });
|
|
34
|
+
expect(identifierSchema.safeParse("not-a-uuid").success).toBe(false);
|
|
35
|
+
expect(pageSchema.parse("2")).toBe(2);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"],
|
|
15
|
+
"exclude": ["tests"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="theme-color" content="#17492f" />
|
|
7
|
+
<link rel="icon" href="/authenik8-logo.svg" />
|
|
8
|
+
<title>Authenik8 Workspace</title>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@authenik8/web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"typecheck": "tsc -b --pretty false"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@authenik8/api-client": "0.1.0",
|
|
14
|
+
"@authenik8/contracts": "0.1.0",
|
|
15
|
+
"@authenik8/ui": "0.1.0",
|
|
16
|
+
"@tanstack/react-query": "^5.90.0",
|
|
17
|
+
"lucide-react": "^0.468.0",
|
|
18
|
+
"react": "^19.2.0",
|
|
19
|
+
"react-dom": "^19.2.0",
|
|
20
|
+
"react-router-dom": "^7.9.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^25.0.0",
|
|
24
|
+
"@types/react": "^19.2.0",
|
|
25
|
+
"@types/react-dom": "^19.2.0",
|
|
26
|
+
"@vitejs/plugin-react": "^5.1.0",
|
|
27
|
+
"typescript": "^6.0.3",
|
|
28
|
+
"vite": "^7.2.0",
|
|
29
|
+
"vitest": "^4.1.8"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<svg width="680" height="680" viewBox="0 0 680 680" role="img" aria-labelledby="title desc" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<title id="title">Authenik8 A8 logo</title>
|
|
3
|
+
<desc id="desc">A geometric Authenik8 mark with interlocked A and 8 forms in a mint green security badge.</desc>
|
|
4
|
+
<defs>
|
|
5
|
+
<radialGradient id="badgeFill" cx="50%" cy="42%" r="64%">
|
|
6
|
+
<stop offset="0" stop-color="#246a43" />
|
|
7
|
+
<stop offset="1" stop-color="#17492f" />
|
|
8
|
+
</radialGradient>
|
|
9
|
+
<filter id="softGlow" x="-18%" y="-18%" width="136%" height="136%">
|
|
10
|
+
<feGaussianBlur stdDeviation="10" result="blur" />
|
|
11
|
+
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.49 0 0 0 0 0.91 0 0 0 0 0.67 0 0 0 0.45 0" result="glow" />
|
|
12
|
+
<feMerge>
|
|
13
|
+
<feMergeNode in="glow" />
|
|
14
|
+
<feMergeNode in="SourceGraphic" />
|
|
15
|
+
</feMerge>
|
|
16
|
+
</filter>
|
|
17
|
+
</defs>
|
|
18
|
+
|
|
19
|
+
<g opacity="0.34" fill="#7ce8aa">
|
|
20
|
+
<circle cx="100" cy="100" r="3.5" />
|
|
21
|
+
<circle cx="160" cy="100" r="3.5" />
|
|
22
|
+
<circle cx="220" cy="100" r="3.5" />
|
|
23
|
+
<circle cx="460" cy="100" r="3.5" />
|
|
24
|
+
<circle cx="520" cy="100" r="3.5" />
|
|
25
|
+
<circle cx="580" cy="100" r="3.5" />
|
|
26
|
+
<circle cx="100" cy="160" r="3.5" />
|
|
27
|
+
<circle cx="580" cy="160" r="3.5" />
|
|
28
|
+
<circle cx="100" cy="520" r="3.5" />
|
|
29
|
+
<circle cx="580" cy="520" r="3.5" />
|
|
30
|
+
<circle cx="100" cy="580" r="3.5" />
|
|
31
|
+
<circle cx="160" cy="580" r="3.5" />
|
|
32
|
+
<circle cx="220" cy="580" r="3.5" />
|
|
33
|
+
<circle cx="460" cy="580" r="3.5" />
|
|
34
|
+
<circle cx="520" cy="580" r="3.5" />
|
|
35
|
+
<circle cx="580" cy="580" r="3.5" />
|
|
36
|
+
</g>
|
|
37
|
+
|
|
38
|
+
<circle cx="340" cy="340" r="280" fill="none" stroke="#7ce8aa" stroke-width="2" stroke-dasharray="8 14" opacity="0.35" />
|
|
39
|
+
|
|
40
|
+
<g filter="url(#softGlow)">
|
|
41
|
+
<circle cx="340" cy="340" r="240" fill="url(#badgeFill)" />
|
|
42
|
+
<circle cx="340" cy="340" r="240" fill="none" stroke="#39c978" stroke-width="5" />
|
|
43
|
+
<circle cx="340" cy="340" r="224" fill="none" stroke="#bdf6d4" stroke-width="1.4" opacity="0.28" />
|
|
44
|
+
|
|
45
|
+
<polygon points="226 490 262 490 342 260 322 260" fill="#f7fff9" />
|
|
46
|
+
<polygon points="356 260 336 260 418 490 454 490" fill="#f7fff9" />
|
|
47
|
+
<rect x="270" y="388" width="98" height="32" rx="5" fill="#f7fff9" />
|
|
48
|
+
|
|
49
|
+
<rect x="360" y="262" width="92" height="96" rx="36" fill="#a7dfbf" />
|
|
50
|
+
<rect x="354" y="362" width="102" height="112" rx="42" fill="#a7dfbf" />
|
|
51
|
+
<rect x="358" y="349" width="94" height="22" rx="5" fill="#a7dfbf" />
|
|
52
|
+
</g>
|
|
53
|
+
</svg>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Route, Routes } from "react-router-dom";
|
|
2
|
+
import { AdminRoute, ProtectedRoute } from "./auth/RouteGuards";
|
|
3
|
+
import { AppShell } from "./components/AppShell";
|
|
4
|
+
import { LoginPage } from "./pages/auth/LoginPage";
|
|
5
|
+
import { RegisterPage } from "./pages/auth/RegisterPage";
|
|
6
|
+
import { ForgotPasswordPage, ResetPasswordPage } from "./pages/auth/RecoveryPages";
|
|
7
|
+
import { VerifyEmailPage } from "./pages/auth/VerifyEmailPage";
|
|
8
|
+
import { OAuthCallbackPage } from "./pages/auth/OAuthCallbackPage";
|
|
9
|
+
import { DashboardPage } from "./pages/DashboardPage";
|
|
10
|
+
import { ProjectsPage, ProjectDetailPage, ProjectFormPage } from "./features/projects/ProjectPages";
|
|
11
|
+
import { ProfilePage } from "./pages/settings/ProfilePage";
|
|
12
|
+
import { SecurityPage } from "./pages/settings/SecurityPage";
|
|
13
|
+
import { UsersPage } from "./pages/admin/UsersPage";
|
|
14
|
+
import { AuditPage } from "./pages/admin/AuditPage";
|
|
15
|
+
import { ForbiddenPage, NotFoundPage } from "./pages/StatusPages";
|
|
16
|
+
|
|
17
|
+
export function App() {
|
|
18
|
+
return (
|
|
19
|
+
<Routes>
|
|
20
|
+
<Route path="/login" element={<LoginPage />} />
|
|
21
|
+
<Route path="/register" element={<RegisterPage />} />
|
|
22
|
+
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
|
23
|
+
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
|
24
|
+
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
|
25
|
+
<Route path="/auth/callback" element={<OAuthCallbackPage />} />
|
|
26
|
+
<Route element={<ProtectedRoute />}>
|
|
27
|
+
<Route element={<AppShell />}>
|
|
28
|
+
<Route index element={<DashboardPage />} />
|
|
29
|
+
<Route path="projects" element={<ProjectsPage />} />
|
|
30
|
+
<Route path="projects/new" element={<ProjectFormPage />} />
|
|
31
|
+
<Route path="projects/:id" element={<ProjectDetailPage />} />
|
|
32
|
+
<Route path="projects/:id/edit" element={<ProjectFormPage />} />
|
|
33
|
+
<Route path="settings/profile" element={<ProfilePage />} />
|
|
34
|
+
<Route path="settings/security" element={<SecurityPage />} />
|
|
35
|
+
<Route path="forbidden" element={<ForbiddenPage />} />
|
|
36
|
+
<Route element={<AdminRoute />}>
|
|
37
|
+
<Route path="admin/users" element={<UsersPage />} />
|
|
38
|
+
<Route path="admin/audit" element={<AuditPage />} />
|
|
39
|
+
</Route>
|
|
40
|
+
</Route>
|
|
41
|
+
</Route>
|
|
42
|
+
<Route path="*" element={<NotFoundPage />} />
|
|
43
|
+
</Routes>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import type { LoginInput, User } from "@authenik8/contracts";
|
|
3
|
+
import { authApi } from "@authenik8/api-client";
|
|
4
|
+
|
|
5
|
+
type AuthContextValue = {
|
|
6
|
+
user: User | null;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
login: (input: LoginInput) => Promise<void>;
|
|
9
|
+
logout: () => Promise<void>;
|
|
10
|
+
completeOAuth: (code: string) => Promise<void>;
|
|
11
|
+
setUser: (user: User) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
15
|
+
|
|
16
|
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
17
|
+
const [user, setUser] = useState<User | null>(null);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let active = true;
|
|
22
|
+
authApi.restore()
|
|
23
|
+
.then((result) => { if (active) setUser(result?.user ?? null); })
|
|
24
|
+
.finally(() => { if (active) setLoading(false); });
|
|
25
|
+
return () => { active = false; };
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
const value = useMemo<AuthContextValue>(() => ({
|
|
29
|
+
user,
|
|
30
|
+
loading,
|
|
31
|
+
async login(input) {
|
|
32
|
+
const result = await authApi.login(input);
|
|
33
|
+
setUser(result.user);
|
|
34
|
+
},
|
|
35
|
+
async logout() {
|
|
36
|
+
await authApi.logout();
|
|
37
|
+
setUser(null);
|
|
38
|
+
},
|
|
39
|
+
async completeOAuth(code) {
|
|
40
|
+
const result = await authApi.exchangeOAuth(code);
|
|
41
|
+
setUser(result.user);
|
|
42
|
+
},
|
|
43
|
+
setUser,
|
|
44
|
+
}), [user, loading]);
|
|
45
|
+
|
|
46
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useAuth() {
|
|
50
|
+
const value = useContext(AuthContext);
|
|
51
|
+
if (!value) throw new Error("useAuth must be used inside AuthProvider");
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
|
2
|
+
import { Spinner } from "@authenik8/ui";
|
|
3
|
+
import { useAuth } from "./AuthProvider";
|
|
4
|
+
|
|
5
|
+
export function ProtectedRoute() {
|
|
6
|
+
const { user, loading } = useAuth();
|
|
7
|
+
const location = useLocation();
|
|
8
|
+
if (loading) return <div className="screen-loader"><Spinner label="Restoring session" /></div>;
|
|
9
|
+
if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
|
10
|
+
return <Outlet />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function AdminRoute() {
|
|
14
|
+
const { user } = useAuth();
|
|
15
|
+
return user?.role === "ADMIN" ? <Outlet /> : <Navigate to="/forbidden" replace />;
|
|
16
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Activity,
|
|
3
|
+
FolderKanban,
|
|
4
|
+
LayoutDashboard,
|
|
5
|
+
LogOut,
|
|
6
|
+
Menu,
|
|
7
|
+
Settings,
|
|
8
|
+
ShieldCheck,
|
|
9
|
+
Users,
|
|
10
|
+
X,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
|
14
|
+
import { Button } from "@authenik8/ui";
|
|
15
|
+
import { useAuth } from "../auth/AuthProvider";
|
|
16
|
+
|
|
17
|
+
const mainLinks = [
|
|
18
|
+
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
|
19
|
+
{ to: "/projects", label: "Projects", icon: FolderKanban },
|
|
20
|
+
{ to: "/settings/profile", label: "Settings", icon: Settings },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function AppShell() {
|
|
24
|
+
const { user, logout } = useAuth();
|
|
25
|
+
const [open, setOpen] = useState(false);
|
|
26
|
+
const location = useLocation();
|
|
27
|
+
|
|
28
|
+
const nav = (
|
|
29
|
+
<>
|
|
30
|
+
<NavLink className="brand" to="/" onClick={() => setOpen(false)}>
|
|
31
|
+
<img src="/authenik8-logo.svg" alt="" />
|
|
32
|
+
<span>Authenik8 <small>Workspace</small></span>
|
|
33
|
+
</NavLink>
|
|
34
|
+
<nav className="side-nav" aria-label="Primary navigation">
|
|
35
|
+
<p className="nav-label">Workspace</p>
|
|
36
|
+
{mainLinks.map(({ icon: Icon, ...link }) => (
|
|
37
|
+
<NavLink key={link.to} to={link.to} end={link.end} onClick={() => setOpen(false)}>
|
|
38
|
+
<Icon size={18} /> <span>{link.label}</span>
|
|
39
|
+
</NavLink>
|
|
40
|
+
))}
|
|
41
|
+
{user?.role === "ADMIN" && (
|
|
42
|
+
<>
|
|
43
|
+
<p className="nav-label">Administration</p>
|
|
44
|
+
<NavLink to="/admin/users" onClick={() => setOpen(false)}><Users size={18} /> <span>Users</span></NavLink>
|
|
45
|
+
<NavLink to="/admin/audit" onClick={() => setOpen(false)}><Activity size={18} /> <span>Audit trail</span></NavLink>
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</nav>
|
|
49
|
+
<div className="sidebar-account">
|
|
50
|
+
<div className="avatar">{user?.name.slice(0, 2).toUpperCase()}</div>
|
|
51
|
+
<div><strong>{user?.name}</strong><span>{user?.email}</span></div>
|
|
52
|
+
</div>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="app-layout">
|
|
58
|
+
<aside className="sidebar">{nav}</aside>
|
|
59
|
+
{open && <div className="mobile-scrim" onClick={() => setOpen(false)} />}
|
|
60
|
+
<aside className={`mobile-sidebar ${open ? "is-open" : ""}`} aria-hidden={!open}>{nav}</aside>
|
|
61
|
+
<div className="app-main">
|
|
62
|
+
<header className="topbar">
|
|
63
|
+
<Button className="icon-button menu-button" variant="ghost" onClick={() => setOpen(!open)} aria-label={open ? "Close navigation" : "Open navigation"} title={open ? "Close navigation" : "Open navigation"}>
|
|
64
|
+
{open ? <X size={20} /> : <Menu size={20} />}
|
|
65
|
+
</Button>
|
|
66
|
+
<div className="breadcrumb"><ShieldCheck size={17} /><span>{titleForPath(location.pathname)}</span></div>
|
|
67
|
+
<Button className="icon-button" variant="ghost" onClick={() => void logout()} aria-label="Sign out" title="Sign out"><LogOut size={19} /></Button>
|
|
68
|
+
</header>
|
|
69
|
+
<main className="page"><Outlet /></main>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function titleForPath(pathname: string) {
|
|
76
|
+
if (pathname.startsWith("/projects")) return "Projects";
|
|
77
|
+
if (pathname.startsWith("/settings")) return "Account settings";
|
|
78
|
+
if (pathname.startsWith("/admin")) return "Administration";
|
|
79
|
+
return "Dashboard";
|
|
80
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
|
|
4
|
+
export function AuthShell({ title, subtitle, children, footer }: { title: string; subtitle: string; children: ReactNode; footer: ReactNode }) {
|
|
5
|
+
return (
|
|
6
|
+
<main className="auth-layout">
|
|
7
|
+
<section className="auth-brand-panel">
|
|
8
|
+
<Link className="brand" to="/"><img src="/authenik8-logo.svg" alt="" /><span>Authenik8 <small>Workspace</small></span></Link>
|
|
9
|
+
<div className="auth-brand-copy">
|
|
10
|
+
<img className="auth-logo" src="/authenik8-logo.svg" alt="Authenik8" />
|
|
11
|
+
<span className="security-mark">A8 / IDENTITY</span>
|
|
12
|
+
<h1>Secure product foundations, already connected.</h1>
|
|
13
|
+
<p>One application boundary from browser session to owned data.</p>
|
|
14
|
+
</div>
|
|
15
|
+
<div className="auth-signal"><span className="signal-dot" /> Identity services operational</div>
|
|
16
|
+
</section>
|
|
17
|
+
<section className="auth-form-panel">
|
|
18
|
+
<div className="auth-form-wrap">
|
|
19
|
+
<header><h2>{title}</h2><p>{subtitle}</p></header>
|
|
20
|
+
{children}
|
|
21
|
+
<footer>{footer}</footer>
|
|
22
|
+
</div>
|
|
23
|
+
</section>
|
|
24
|
+
</main>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { AlertTriangle, CheckCircle2 } from "lucide-react";
|
|
3
|
+
import { ApiError } from "@authenik8/api-client";
|
|
4
|
+
|
|
5
|
+
export function PageHeader({ title, description, action }: { title: string; description?: string; action?: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<header className="page-header">
|
|
8
|
+
<div><h1>{title}</h1>{description && <p>{description}</p>}</div>
|
|
9
|
+
{action && <div className="page-actions">{action}</div>}
|
|
10
|
+
</header>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ErrorNotice({ error }: { error: unknown }) {
|
|
15
|
+
const fieldMessage = error instanceof ApiError
|
|
16
|
+
? Object.values(error.fields ?? {}).flat()[0]
|
|
17
|
+
: undefined;
|
|
18
|
+
const message = error instanceof ApiError
|
|
19
|
+
? fieldMessage ?? error.message
|
|
20
|
+
: "Something went wrong. Try again.";
|
|
21
|
+
return <div className="notice notice-error" role="alert"><AlertTriangle size={17} /><span>{message}</span></div>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function SuccessNotice({ children }: { children: ReactNode }) {
|
|
25
|
+
return <div className="notice notice-success" role="status"><CheckCircle2 size={17} /><span>{children}</span></div>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Field({ label, htmlFor, error, children }: { label: string; htmlFor: string; error?: string; children: ReactNode }) {
|
|
29
|
+
return <label className="field" htmlFor={htmlFor}><span>{label}</span>{children}{error && <small>{error}</small>}</label>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useState, type FormEvent } from "react";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { ArrowLeft, Edit3, FolderKanban, Plus, Save, Trash2 } from "lucide-react";
|
|
4
|
+
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
5
|
+
import { Badge, Button, EmptyState, Input, Spinner } from "@authenik8/ui";
|
|
6
|
+
import { projectApi } from "@authenik8/api-client";
|
|
7
|
+
import type { ProjectStatus } from "@authenik8/contracts";
|
|
8
|
+
import { ErrorNotice, Field, PageHeader } from "../../components/Page";
|
|
9
|
+
|
|
10
|
+
export function ProjectsPage() {
|
|
11
|
+
const query = useQuery({ queryKey: ["projects"], queryFn: projectApi.list });
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<PageHeader title="Projects" description="Owned work in this workspace." action={<Link className="button button-primary" to="/projects/new"><Plus size={17} /> New project</Link>} />
|
|
15
|
+
<section className="panel">
|
|
16
|
+
{query.isPending ? <div className="panel-loading"><Spinner /></div> : query.error ? <ErrorNotice error={query.error} /> : query.data!.projects.length === 0 ? (
|
|
17
|
+
<EmptyState icon={<FolderKanban />} title="No projects found" action={<Link className="button button-primary" to="/projects/new">Create project</Link>}>Projects you create will appear here.</EmptyState>
|
|
18
|
+
) : <div className="table-wrap"><table><thead><tr><th>Project</th><th>Status</th><th>Created</th><th>Updated</th><th><span className="sr-only">Actions</span></th></tr></thead><tbody>
|
|
19
|
+
{query.data!.projects.map((project) => <tr key={project.id}><td><Link className="table-link" to={`/projects/${project.id}`}>{project.name}</Link><small className="table-subtitle">{project.description || "No description"}</small></td><td><Badge tone={project.status === "ACTIVE" ? "success" : project.status === "ARCHIVED" ? "neutral" : "warning"}>{project.status}</Badge></td><td>{formatDate(project.createdAt)}</td><td>{formatDate(project.updatedAt)}</td><td><Link className="icon-link" to={`/projects/${project.id}/edit`} aria-label={`Edit ${project.name}`} title="Edit project"><Edit3 size={17} /></Link></td></tr>)}
|
|
20
|
+
</tbody></table></div>}
|
|
21
|
+
</section>
|
|
22
|
+
</>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ProjectFormPage() {
|
|
27
|
+
const { id } = useParams();
|
|
28
|
+
const edit = Boolean(id);
|
|
29
|
+
const navigate = useNavigate();
|
|
30
|
+
const client = useQueryClient();
|
|
31
|
+
const project = useQuery({ queryKey: ["project", id], queryFn: () => projectApi.get(id!), enabled: edit });
|
|
32
|
+
const [form, setForm] = useState<{ name: string; description: string; status: ProjectStatus }>({ name: "", description: "", status: "DRAFT" });
|
|
33
|
+
useEffect(() => { if (project.data) setForm(project.data.project); }, [project.data]);
|
|
34
|
+
const mutation = useMutation({
|
|
35
|
+
mutationFn: () => edit ? projectApi.update(id!, form) : projectApi.create(form),
|
|
36
|
+
onSuccess: async ({ project: saved }) => {
|
|
37
|
+
await client.invalidateQueries({ queryKey: ["projects"] });
|
|
38
|
+
navigate(`/projects/${saved.id}`);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
function submit(event: FormEvent) { event.preventDefault(); mutation.mutate(); }
|
|
42
|
+
if (edit && project.isPending) return <div className="screen-loader"><Spinner /></div>;
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<PageHeader title={edit ? "Edit project" : "Create project"} description="Define the project details and lifecycle state." />
|
|
46
|
+
<form className="form-panel" onSubmit={submit}>
|
|
47
|
+
{mutation.error && <ErrorNotice error={mutation.error} />}
|
|
48
|
+
<Field label="Project name" htmlFor="project-name"><Input id="project-name" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} maxLength={120} required /></Field>
|
|
49
|
+
<Field label="Description" htmlFor="description"><textarea id="description" className="input textarea" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} maxLength={2000} rows={6} /></Field>
|
|
50
|
+
<Field label="Status" htmlFor="status"><select id="status" className="input" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as ProjectStatus })}><option value="DRAFT">Draft</option><option value="ACTIVE">Active</option><option value="ARCHIVED">Archived</option></select></Field>
|
|
51
|
+
<div className="form-actions"><Button type="button" variant="secondary" onClick={() => navigate(-1)}>Cancel</Button><Button type="submit" disabled={mutation.isPending}><Save size={17} /> {mutation.isPending ? "Saving..." : "Save project"}</Button></div>
|
|
52
|
+
</form>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function ProjectDetailPage() {
|
|
58
|
+
const { id } = useParams();
|
|
59
|
+
const navigate = useNavigate();
|
|
60
|
+
const client = useQueryClient();
|
|
61
|
+
const query = useQuery({ queryKey: ["project", id], queryFn: () => projectApi.get(id!) });
|
|
62
|
+
const remove = useMutation({ mutationFn: () => projectApi.remove(id!), onSuccess: async () => { await client.invalidateQueries({ queryKey: ["projects"] }); navigate("/projects"); } });
|
|
63
|
+
if (query.isPending) return <div className="screen-loader"><Spinner /></div>;
|
|
64
|
+
if (query.error) return <ErrorNotice error={query.error} />;
|
|
65
|
+
const project = query.data!.project;
|
|
66
|
+
return (
|
|
67
|
+
<>
|
|
68
|
+
<Link className="back-link" to="/projects"><ArrowLeft size={15} /> Projects</Link>
|
|
69
|
+
<PageHeader title={project.name} description={`Created ${formatDate(project.createdAt)}`} action={<div className="action-row"><Link className="button button-secondary" to={`/projects/${project.id}/edit`}><Edit3 size={17} /> Edit</Link><Button variant="danger" onClick={() => { if (window.confirm("Delete this project permanently?")) remove.mutate(); }}><Trash2 size={17} /> Delete</Button></div>} />
|
|
70
|
+
{remove.error && <ErrorNotice error={remove.error} />}
|
|
71
|
+
<section className="detail-grid">
|
|
72
|
+
<div className="detail-main"><p className="detail-label">Description</p><p>{project.description || "No description has been added."}</p></div>
|
|
73
|
+
<dl className="detail-meta"><div><dt>Status</dt><dd><Badge tone={project.status === "ACTIVE" ? "success" : "neutral"}>{project.status}</Badge></dd></div><div><dt>Last updated</dt><dd>{formatDate(project.updatedAt)}</dd></div><div><dt>Owner ID</dt><dd><code>{project.ownerId}</code></dd></div></dl>
|
|
74
|
+
</section>
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const formatDate = (value: string) => new Date(value).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { BrowserRouter } from "react-router-dom";
|
|
5
|
+
import { AuthProvider } from "./auth/AuthProvider";
|
|
6
|
+
import { App } from "./App";
|
|
7
|
+
import "./styles.css";
|
|
8
|
+
|
|
9
|
+
const queryClient = new QueryClient({
|
|
10
|
+
defaultOptions: {
|
|
11
|
+
queries: { staleTime: 20_000, retry: 1, refetchOnWindowFocus: false },
|
|
12
|
+
mutations: { retry: 0 },
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
createRoot(document.getElementById("root")!).render(
|
|
17
|
+
<StrictMode>
|
|
18
|
+
<QueryClientProvider client={queryClient}>
|
|
19
|
+
<BrowserRouter>
|
|
20
|
+
<AuthProvider><App /></AuthProvider>
|
|
21
|
+
</BrowserRouter>
|
|
22
|
+
</QueryClientProvider>
|
|
23
|
+
</StrictMode>,
|
|
24
|
+
);
|