@cosmicdrift/kumiko-bundled-features 0.130.1 → 0.131.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/package.json +6 -6
- package/src/admin-shell/__tests__/admin-shell.boot.test.ts +17 -1
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +11 -2
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +2 -0
- package/src/admin-shell/feature.ts +2 -1
- package/src/admin-shell/i18n.ts +1 -0
- package/src/admin-shell/overview-allowlist.ts +5 -1
- package/src/admin-shell/web/platform-overview-screen.tsx +18 -1
- package/src/auth-email-password/web/auth-form-primitives.tsx +4 -19
- package/src/auth-email-password/web/forgot-password-screen.tsx +6 -6
- package/src/auth-email-password/web/invite-accept-screen.tsx +4 -4
- package/src/auth-email-password/web/login-screen.tsx +19 -19
- package/src/auth-email-password/web/reset-password-screen.tsx +6 -6
- package/src/auth-email-password/web/signup-complete-screen.tsx +4 -4
- package/src/auth-email-password/web/signup-screen.tsx +6 -6
- package/src/auth-email-password/web/verify-email-screen.tsx +9 -8
- package/src/text-content/web/__tests__/group-blocks.test.ts +3 -3
- package/src/text-content/web/client-plugin.tsx +51 -56
- package/src/user/constants.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.131.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -107,11 +107,11 @@
|
|
|
107
107
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
108
108
|
},
|
|
109
109
|
"dependencies": {
|
|
110
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
111
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
112
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
113
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
114
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
110
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.131.0",
|
|
111
|
+
"@cosmicdrift/kumiko-framework": "0.131.0",
|
|
112
|
+
"@cosmicdrift/kumiko-headless": "0.131.0",
|
|
113
|
+
"@cosmicdrift/kumiko-renderer": "0.131.0",
|
|
114
|
+
"@cosmicdrift/kumiko-renderer-web": "0.131.0",
|
|
115
115
|
"@mollie/api-client": "^4.5.0",
|
|
116
116
|
"@node-rs/argon2": "^2.0.2",
|
|
117
117
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -5,6 +5,7 @@ import { createConfigFeature } from "../../config/feature";
|
|
|
5
5
|
import { createJobsFeature } from "../../jobs/feature";
|
|
6
6
|
import { createTenantFeature } from "../../tenant/feature";
|
|
7
7
|
import { tierEngineFeature } from "../../tier-engine/feature";
|
|
8
|
+
import { createUserFeature } from "../../user/feature";
|
|
8
9
|
import {
|
|
9
10
|
ADMIN_SHELL_FEATURE,
|
|
10
11
|
DEFAULT_PLATFORM_WORKSPACE_ID,
|
|
@@ -14,6 +15,7 @@ import { createAdminShellFeature } from "../feature";
|
|
|
14
15
|
|
|
15
16
|
const features = [
|
|
16
17
|
createConfigFeature(),
|
|
18
|
+
createUserFeature(),
|
|
17
19
|
createTenantFeature(),
|
|
18
20
|
createAuditFeature(),
|
|
19
21
|
createJobsFeature(),
|
|
@@ -22,10 +24,22 @@ const features = [
|
|
|
22
24
|
];
|
|
23
25
|
|
|
24
26
|
describe("admin-shell boot + workspace composition", () => {
|
|
25
|
-
test("validateBoot with tenant, audit, jobs, tier-engine", () => {
|
|
27
|
+
test("validateBoot with user, tenant, audit, jobs, tier-engine", () => {
|
|
26
28
|
expect(() => validateBoot(features)).not.toThrow();
|
|
27
29
|
});
|
|
28
30
|
|
|
31
|
+
test("validateBoot fails without user (platform-overview user-count requires it)", () => {
|
|
32
|
+
const withoutUser = [
|
|
33
|
+
createConfigFeature(),
|
|
34
|
+
createTenantFeature(),
|
|
35
|
+
createAuditFeature(),
|
|
36
|
+
createJobsFeature(),
|
|
37
|
+
tierEngineFeature,
|
|
38
|
+
createAdminShellFeature(),
|
|
39
|
+
];
|
|
40
|
+
expect(() => validateBoot(withoutUser)).toThrow();
|
|
41
|
+
});
|
|
42
|
+
|
|
29
43
|
test("registers tenant + platform workspaces with qualified ids", () => {
|
|
30
44
|
const registry = createRegistry(features);
|
|
31
45
|
expect(
|
|
@@ -82,6 +96,7 @@ describe("admin-shell boot + workspace composition", () => {
|
|
|
82
96
|
});
|
|
83
97
|
const registry = createRegistry([
|
|
84
98
|
createConfigFeature(),
|
|
99
|
+
createUserFeature(),
|
|
85
100
|
createTenantFeature(),
|
|
86
101
|
createAuditFeature(),
|
|
87
102
|
createJobsFeature(),
|
|
@@ -103,6 +118,7 @@ describe("admin-shell boot + workspace composition", () => {
|
|
|
103
118
|
});
|
|
104
119
|
const registry = createRegistry([
|
|
105
120
|
createConfigFeature(),
|
|
121
|
+
createUserFeature(),
|
|
106
122
|
createTenantFeature(),
|
|
107
123
|
createAuditFeature(),
|
|
108
124
|
createJobsFeature(),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { JobQueries } from "../../jobs/constants";
|
|
3
3
|
import { TenantQueries } from "../../tenant/constants";
|
|
4
|
+
import { UserQueries } from "../../user/constants";
|
|
4
5
|
import {
|
|
5
6
|
isOverviewQueryAllowed,
|
|
6
7
|
PLATFORM_OVERVIEW_ALLOWED_QUERIES,
|
|
@@ -22,8 +23,16 @@ describe("overview query allowlist", () => {
|
|
|
22
23
|
expect(TENANT_OVERVIEW_ALLOWED_QUERIES).toContain("config:query:readiness");
|
|
23
24
|
});
|
|
24
25
|
|
|
25
|
-
test("platform allowlist is tenant:list + jobs:list
|
|
26
|
-
expect(PLATFORM_OVERVIEW_ALLOWED_QUERIES).toEqual([
|
|
26
|
+
test("platform allowlist is tenant:list + jobs:list + user:list", () => {
|
|
27
|
+
expect(PLATFORM_OVERVIEW_ALLOWED_QUERIES).toEqual([
|
|
28
|
+
TenantQueries.list,
|
|
29
|
+
JobQueries.list,
|
|
30
|
+
UserQueries.list,
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("platform overview allows the user-count query (fw#891 regression)", () => {
|
|
35
|
+
expect(isOverviewQueryAllowed("platform", UserQueries.list)).toBe(true);
|
|
27
36
|
});
|
|
28
37
|
|
|
29
38
|
test("platform queries are not tenant-allowlisted", () => {
|
|
@@ -5,11 +5,13 @@ import { createConfigFeature } from "../../config/feature";
|
|
|
5
5
|
import { createJobsFeature } from "../../jobs/feature";
|
|
6
6
|
import { createTenantFeature } from "../../tenant/feature";
|
|
7
7
|
import { tierEngineFeature } from "../../tier-engine/feature";
|
|
8
|
+
import { createUserFeature } from "../../user/feature";
|
|
8
9
|
import { PLATFORM_OVERVIEW_SCREEN_ID, TENANT_OVERVIEW_SCREEN_ID } from "../constants";
|
|
9
10
|
import { createAdminShellFeature } from "../feature";
|
|
10
11
|
|
|
11
12
|
const features = [
|
|
12
13
|
createConfigFeature(),
|
|
14
|
+
createUserFeature(),
|
|
13
15
|
createTenantFeature(),
|
|
14
16
|
createAuditFeature(),
|
|
15
17
|
createJobsFeature(),
|
|
@@ -36,13 +36,14 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
|
|
|
36
36
|
|
|
37
37
|
return defineFeature(ADMIN_SHELL_FEATURE, (r) => {
|
|
38
38
|
r.describe(
|
|
39
|
-
"Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`). Mount after tenant, audit, and jobs; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.",
|
|
39
|
+
"Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`). Mount after user, tenant, audit, and jobs; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.",
|
|
40
40
|
);
|
|
41
41
|
r.uiHints({
|
|
42
42
|
displayLabel: "Admin Shell",
|
|
43
43
|
category: "operations",
|
|
44
44
|
recommended: false,
|
|
45
45
|
});
|
|
46
|
+
r.requires("user");
|
|
46
47
|
r.requires("tenant");
|
|
47
48
|
r.requires("audit");
|
|
48
49
|
r.requires("jobs");
|
package/src/admin-shell/i18n.ts
CHANGED
|
@@ -30,6 +30,7 @@ export const ADMIN_SHELL_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
30
30
|
en: "Check required settings",
|
|
31
31
|
},
|
|
32
32
|
"admin-shell:overview.tenants": { de: "Mandanten", en: "Tenants" },
|
|
33
|
+
"admin-shell:overview.users": { de: "Benutzer", en: "Users" },
|
|
33
34
|
"admin-shell:overview.failedJobs": { de: "Fehlgeschlagene Jobs", en: "Failed jobs" },
|
|
34
35
|
"admin-shell:overview.failedJobsHint": { de: "Job-Runs prüfen", en: "Review job runs" },
|
|
35
36
|
};
|
|
@@ -13,7 +13,11 @@ export const TENANT_OVERVIEW_ALLOWED_QUERIES = [
|
|
|
13
13
|
] as const;
|
|
14
14
|
|
|
15
15
|
/** Platform workspace overview may only call these queries. */
|
|
16
|
-
export const PLATFORM_OVERVIEW_ALLOWED_QUERIES = [
|
|
16
|
+
export const PLATFORM_OVERVIEW_ALLOWED_QUERIES = [
|
|
17
|
+
"tenant:query:list",
|
|
18
|
+
"jobs:query:list",
|
|
19
|
+
"user:query:user:list",
|
|
20
|
+
] as const;
|
|
17
21
|
|
|
18
22
|
/** Regression guard — TenantAdmin overview must never touch these (HTTP 403). */
|
|
19
23
|
export const TENANT_OVERVIEW_FORBIDDEN_QUERIES = [
|
|
@@ -4,6 +4,7 @@ import { useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
|
4
4
|
import { type ReactNode, useEffect, useState } from "react";
|
|
5
5
|
import { JobQueries } from "../../jobs/constants";
|
|
6
6
|
import { TenantQueries } from "../../tenant/constants";
|
|
7
|
+
import { UserQueries } from "../../user/constants";
|
|
7
8
|
import { OverviewLayout, type OverviewState } from "./overview-layout";
|
|
8
9
|
import { overviewQuery } from "./overview-query";
|
|
9
10
|
|
|
@@ -27,6 +28,18 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
const usersRes = await overviewQuery<{ readonly rows: readonly unknown[] }>(
|
|
32
|
+
"platform",
|
|
33
|
+
dispatcher,
|
|
34
|
+
UserQueries.list,
|
|
35
|
+
{},
|
|
36
|
+
);
|
|
37
|
+
if (cancelled) return;
|
|
38
|
+
if (!usersRes.isSuccess) {
|
|
39
|
+
setState({ kind: "error", message: usersRes.error.message });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
const failedJobsRes = await overviewQuery<{ readonly rows: readonly unknown[] }>(
|
|
31
44
|
"platform",
|
|
32
45
|
dispatcher,
|
|
@@ -46,6 +59,10 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
46
59
|
label: t("admin-shell:overview.tenants"),
|
|
47
60
|
value: String(tenantsRes.data.rows.length),
|
|
48
61
|
},
|
|
62
|
+
{
|
|
63
|
+
label: t("admin-shell:overview.users"),
|
|
64
|
+
value: String(usersRes.data.rows.length),
|
|
65
|
+
},
|
|
49
66
|
{
|
|
50
67
|
label: t("admin-shell:overview.failedJobs"),
|
|
51
68
|
value: String(failedJobsRes.data.rows.length),
|
|
@@ -69,7 +86,7 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
69
86
|
title={t("admin-shell:overview.platformTitle")}
|
|
70
87
|
state={state}
|
|
71
88
|
loadingLabel={t("admin-shell:overview.loading")}
|
|
72
|
-
columns={
|
|
89
|
+
columns={3}
|
|
73
90
|
/>
|
|
74
91
|
);
|
|
75
92
|
}
|
|
@@ -9,14 +9,13 @@
|
|
|
9
9
|
// (full-screen, zentriert, max-w-sm). Web-only;
|
|
10
10
|
// eine Native-Variante landet bei Bedarf
|
|
11
11
|
// daneben (z.B. SafeArea + ScrollView).
|
|
12
|
-
// authButtonClass — Tailwind-Class für anchor-styled-as-button
|
|
13
|
-
// (z.B. "Zum Login"-Link nach Reset-Success).
|
|
14
|
-
// Nur dort, wo ein <a>-Tag rendert.
|
|
15
|
-
// authMutedLinkClass — Subtle-Link-Style.
|
|
16
12
|
// parseUrlToken — URL-Param-Helper (window.location.search).
|
|
13
|
+
//
|
|
14
|
+
// Link-Styles laufen über das Link-Primitive (variant="button"/"muted") —
|
|
15
|
+
// die früheren authButtonClass/authMutedLinkClass sind dorthin gewandert.
|
|
17
16
|
|
|
18
17
|
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
19
|
-
import { BareFormProvider
|
|
18
|
+
import { BareFormProvider } from "@cosmicdrift/kumiko-renderer-web";
|
|
20
19
|
import { createContext, type ReactNode, useContext, useEffect, useState } from "react";
|
|
21
20
|
|
|
22
21
|
// Wrappt die zentrierte Auth-Card in ihre Umgebung. Default = Fullscreen-
|
|
@@ -80,20 +79,6 @@ export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNod
|
|
|
80
79
|
return shell(card);
|
|
81
80
|
}
|
|
82
81
|
|
|
83
|
-
// Primary-button-Style für anchor-Tags die wie ein Button aussehen
|
|
84
|
-
// (z.B. "Zum Login"-Link nach Reset-Success — kein <Button> weil <a>).
|
|
85
|
-
export const authButtonClass = cn(
|
|
86
|
-
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors",
|
|
87
|
-
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
88
|
-
"disabled:pointer-events-none disabled:opacity-50",
|
|
89
|
-
"bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2",
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
// Subtle-Link-Style (für "Zurück zum Login"-Anchors). Fixed margin/
|
|
93
|
-
// alignment-classes lassen wir den Caller setzen — nur Farbe + hover.
|
|
94
|
-
export const authMutedLinkClass =
|
|
95
|
-
"text-sm text-muted-foreground hover:text-foreground underline-offset-4 hover:underline";
|
|
96
|
-
|
|
97
82
|
// Liest `?<paramName>=<value>` aus der aktuellen URL — typisches
|
|
98
83
|
// Pattern für Token-bearing Pages (reset, verify). Returnt "" wenn der
|
|
99
84
|
// Browser nicht da ist (SSR-safety) oder der Parameter fehlt.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
13
13
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
14
14
|
import { requestPasswordReset } from "./auth-client";
|
|
15
|
-
import { AuthCard
|
|
15
|
+
import { AuthCard } from "./auth-form-primitives";
|
|
16
16
|
|
|
17
17
|
export type ForgotPasswordScreenProps = {
|
|
18
18
|
readonly title?: string;
|
|
@@ -28,7 +28,7 @@ export function ForgotPasswordScreen({
|
|
|
28
28
|
loginHref = "/login",
|
|
29
29
|
}: ForgotPasswordScreenProps): ReactNode {
|
|
30
30
|
const t = useTranslation();
|
|
31
|
-
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
31
|
+
const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
|
|
32
32
|
const [email, setEmail] = useState("");
|
|
33
33
|
const [submitting, setSubmitting] = useState(false);
|
|
34
34
|
const [done, setDone] = useState(false);
|
|
@@ -77,9 +77,9 @@ export function ForgotPasswordScreen({
|
|
|
77
77
|
<p className="font-medium text-foreground">{t("auth.forgotPassword.successTitle")}</p>
|
|
78
78
|
<p className="mt-1">{t("auth.forgotPassword.successBody")}</p>
|
|
79
79
|
</Banner>
|
|
80
|
-
<
|
|
80
|
+
<Link href={loginHref} variant="muted">
|
|
81
81
|
{t("auth.forgotPassword.backToLogin")}
|
|
82
|
-
</
|
|
82
|
+
</Link>
|
|
83
83
|
</div>
|
|
84
84
|
) : (
|
|
85
85
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
@@ -101,9 +101,9 @@ export function ForgotPasswordScreen({
|
|
|
101
101
|
{submitting ? t("auth.forgotPassword.submitting") : t("auth.forgotPassword.submit")}
|
|
102
102
|
</Button>
|
|
103
103
|
</Form>
|
|
104
|
-
<
|
|
104
|
+
<Link href={loginHref} variant="muted" className="self-center">
|
|
105
105
|
{t("auth.forgotPassword.backToLogin")}
|
|
106
|
-
</
|
|
106
|
+
</Link>
|
|
107
107
|
</div>
|
|
108
108
|
)}
|
|
109
109
|
</AuthCard>
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
20
20
|
import { type FormEvent, type ReactNode, useContext, useState } from "react";
|
|
21
21
|
import { csrfHeader } from "./auth-client";
|
|
22
|
-
import { AuthCard,
|
|
22
|
+
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
23
23
|
import { SessionContext, UNAUTHENTICATED } from "./session";
|
|
24
24
|
|
|
25
25
|
export type InviteAcceptScreenProps = {
|
|
@@ -41,7 +41,7 @@ export function InviteAcceptScreen({
|
|
|
41
41
|
loginHref = "/login",
|
|
42
42
|
}: InviteAcceptScreenProps): ReactNode {
|
|
43
43
|
const t = useTranslation();
|
|
44
|
-
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
44
|
+
const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
|
|
45
45
|
// NOT useSession(): InviteAcceptScreen is a public route (anon invite-links
|
|
46
46
|
// are the documented use case) — useSession() throws without a
|
|
47
47
|
// <SessionProvider> ancestor. Read the context directly and fall back to
|
|
@@ -142,9 +142,9 @@ export function InviteAcceptScreen({
|
|
|
142
142
|
<AuthCard title={effectiveTitle}>
|
|
143
143
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
144
144
|
<p className="text-sm text-muted-foreground">{t("auth.inviteAccept.missingToken")}</p>
|
|
145
|
-
<
|
|
145
|
+
<Link href={loginHref} variant="muted">
|
|
146
146
|
{t("auth.inviteAccept.goToLogin")}
|
|
147
|
-
</
|
|
147
|
+
</Link>
|
|
148
148
|
</div>
|
|
149
149
|
</AuthCard>
|
|
150
150
|
);
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
13
13
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
14
14
|
import { type LoginFailure, requestEmailVerification } from "./auth-client";
|
|
15
|
-
import { AuthCard
|
|
15
|
+
import { AuthCard } from "./auth-form-primitives";
|
|
16
16
|
import { useSession } from "./session";
|
|
17
17
|
|
|
18
18
|
// Resend-Status für den "Bestätigungs-Mail erneut senden"-Flow, der bei
|
|
@@ -92,7 +92,7 @@ export function LoginScreen({
|
|
|
92
92
|
legalLinks,
|
|
93
93
|
}: LoginScreenProps): ReactNode {
|
|
94
94
|
const t = useTranslation();
|
|
95
|
-
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
95
|
+
const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
|
|
96
96
|
const session = useSession();
|
|
97
97
|
const [email, setEmail] = useState("");
|
|
98
98
|
const [password, setPassword] = useState("");
|
|
@@ -186,17 +186,17 @@ export function LoginScreen({
|
|
|
186
186
|
{error.reason === "email_not_verified" &&
|
|
187
187
|
email.trim().length > 0 &&
|
|
188
188
|
email === failedLoginEmail && (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
</
|
|
189
|
+
<span className="self-start">
|
|
190
|
+
<Button
|
|
191
|
+
variant="link"
|
|
192
|
+
onClick={() => void onResend()}
|
|
193
|
+
disabled={resendStatus.kind === "sending"}
|
|
194
|
+
>
|
|
195
|
+
{resendStatus.kind === "sending"
|
|
196
|
+
? t("auth.login.submitting")
|
|
197
|
+
: t("auth.login.resendVerification")}
|
|
198
|
+
</Button>
|
|
199
|
+
</span>
|
|
200
200
|
)}
|
|
201
201
|
{resendStatus.kind === "rateLimited" && (
|
|
202
202
|
<span className="text-xs">{t("auth.login.resendRateLimited")}</span>
|
|
@@ -212,14 +212,14 @@ export function LoginScreen({
|
|
|
212
212
|
</Button>
|
|
213
213
|
</Form>
|
|
214
214
|
{forgotPasswordHref !== undefined && (
|
|
215
|
-
<
|
|
215
|
+
<Link href={forgotPasswordHref} variant="muted" className="self-center">
|
|
216
216
|
{t("auth.login.forgotPassword")}
|
|
217
|
-
</
|
|
217
|
+
</Link>
|
|
218
218
|
)}
|
|
219
219
|
{signupHref !== undefined && (
|
|
220
|
-
<
|
|
220
|
+
<Link href={signupHref} variant="muted" className="self-center">
|
|
221
221
|
{t("auth.signup.title")}
|
|
222
|
-
</
|
|
222
|
+
</Link>
|
|
223
223
|
)}
|
|
224
224
|
{legalLinks !== undefined && legalLinks.length > 0 && (
|
|
225
225
|
<nav
|
|
@@ -227,9 +227,9 @@ export function LoginScreen({
|
|
|
227
227
|
className="flex items-center justify-center gap-3 pt-2 border-t border-border/50"
|
|
228
228
|
>
|
|
229
229
|
{legalLinks.map((link) => (
|
|
230
|
-
<
|
|
230
|
+
<Link key={link.href} href={link.href} variant="muted" className="text-xs">
|
|
231
231
|
{link.label}
|
|
232
|
-
</
|
|
232
|
+
</Link>
|
|
233
233
|
))}
|
|
234
234
|
</nav>
|
|
235
235
|
)}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
14
14
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
15
15
|
import { resetPassword } from "./auth-client";
|
|
16
|
-
import { AuthCard,
|
|
16
|
+
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
17
17
|
|
|
18
18
|
export type ResetPasswordScreenProps = {
|
|
19
19
|
readonly title?: string;
|
|
@@ -31,7 +31,7 @@ export function ResetPasswordScreen({
|
|
|
31
31
|
loginHref = "/login",
|
|
32
32
|
}: ResetPasswordScreenProps): ReactNode {
|
|
33
33
|
const t = useTranslation();
|
|
34
|
-
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
34
|
+
const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
|
|
35
35
|
const token = useUrlToken(tokenProp);
|
|
36
36
|
const [newPassword, setNewPassword] = useState("");
|
|
37
37
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
@@ -83,9 +83,9 @@ export function ResetPasswordScreen({
|
|
|
83
83
|
<AuthCard title={effectiveTitle}>
|
|
84
84
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
85
85
|
<p className="text-sm text-muted-foreground">{t("auth.resetPassword.missingToken")}</p>
|
|
86
|
-
<
|
|
86
|
+
<Link href={loginHref} variant="muted">
|
|
87
87
|
{t("auth.resetPassword.goToLogin")}
|
|
88
|
-
</
|
|
88
|
+
</Link>
|
|
89
89
|
</div>
|
|
90
90
|
</AuthCard>
|
|
91
91
|
);
|
|
@@ -99,9 +99,9 @@ export function ResetPasswordScreen({
|
|
|
99
99
|
<p className="font-medium text-foreground">{t("auth.resetPassword.successTitle")}</p>
|
|
100
100
|
<p className="mt-1">{t("auth.resetPassword.successBody")}</p>
|
|
101
101
|
</Banner>
|
|
102
|
-
<
|
|
102
|
+
<Link href={loginHref} variant="button">
|
|
103
103
|
{t("auth.resetPassword.goToLogin")}
|
|
104
|
-
</
|
|
104
|
+
</Link>
|
|
105
105
|
</div>
|
|
106
106
|
) : (
|
|
107
107
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
19
19
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
20
20
|
import { confirmSignup } from "./auth-client";
|
|
21
|
-
import { AuthCard,
|
|
21
|
+
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
22
22
|
|
|
23
23
|
export type SignupCompleteScreenProps = {
|
|
24
24
|
readonly title?: string;
|
|
@@ -43,7 +43,7 @@ export function SignupCompleteScreen({
|
|
|
43
43
|
loginHref = "/login",
|
|
44
44
|
}: SignupCompleteScreenProps): ReactNode {
|
|
45
45
|
const t = useTranslation();
|
|
46
|
-
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
46
|
+
const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
|
|
47
47
|
const token = useUrlToken(tokenProp);
|
|
48
48
|
const [password, setPassword] = useState("");
|
|
49
49
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
@@ -98,9 +98,9 @@ export function SignupCompleteScreen({
|
|
|
98
98
|
<AuthCard title={effectiveTitle}>
|
|
99
99
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
100
100
|
<p className="text-sm text-muted-foreground">{t("auth.signupComplete.missingToken")}</p>
|
|
101
|
-
<
|
|
101
|
+
<Link href={loginHref} variant="muted">
|
|
102
102
|
{t("auth.signup.haveAccount")}
|
|
103
|
-
</
|
|
103
|
+
</Link>
|
|
104
104
|
</div>
|
|
105
105
|
</AuthCard>
|
|
106
106
|
);
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
16
16
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
17
17
|
import { requestSignup } from "./auth-client";
|
|
18
|
-
import { AuthCard
|
|
18
|
+
import { AuthCard } from "./auth-form-primitives";
|
|
19
19
|
|
|
20
20
|
export type SignupScreenProps = {
|
|
21
21
|
readonly title?: string;
|
|
@@ -31,7 +31,7 @@ export function SignupScreen({
|
|
|
31
31
|
loginHref = "/login",
|
|
32
32
|
}: SignupScreenProps): ReactNode {
|
|
33
33
|
const t = useTranslation();
|
|
34
|
-
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
34
|
+
const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
|
|
35
35
|
const [email, setEmail] = useState("");
|
|
36
36
|
const [submitting, setSubmitting] = useState(false);
|
|
37
37
|
const [done, setDone] = useState(false);
|
|
@@ -95,9 +95,9 @@ export function SignupScreen({
|
|
|
95
95
|
<Button variant="secondary" onClick={onResend} disabled={submitting}>
|
|
96
96
|
{t("auth.signup.resend")}
|
|
97
97
|
</Button>
|
|
98
|
-
<
|
|
98
|
+
<Link href={loginHref} variant="muted">
|
|
99
99
|
{t("auth.signup.haveAccount")}
|
|
100
|
-
</
|
|
100
|
+
</Link>
|
|
101
101
|
</div>
|
|
102
102
|
) : (
|
|
103
103
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
@@ -120,9 +120,9 @@ export function SignupScreen({
|
|
|
120
120
|
{submitting ? t("auth.signup.submitting") : t("auth.signup.submit")}
|
|
121
121
|
</Button>
|
|
122
122
|
</Form>
|
|
123
|
-
<
|
|
123
|
+
<Link href={loginHref} variant="muted" className="self-center">
|
|
124
124
|
{t("auth.signup.haveAccount")}
|
|
125
|
-
</
|
|
125
|
+
</Link>
|
|
126
126
|
</div>
|
|
127
127
|
)}
|
|
128
128
|
</AuthCard>
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
// nicht den ersten valid-call beim Mount und den zweiten als invalid-
|
|
11
11
|
// Banner sehen.
|
|
12
12
|
|
|
13
|
-
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
13
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
14
14
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
15
15
|
import { verifyEmail } from "./auth-client";
|
|
16
|
-
import { AuthCard,
|
|
16
|
+
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
17
17
|
|
|
18
18
|
export type VerifyEmailScreenProps = {
|
|
19
19
|
readonly title?: string;
|
|
@@ -31,6 +31,7 @@ export function VerifyEmailScreen({
|
|
|
31
31
|
loginHref = "/login",
|
|
32
32
|
}: VerifyEmailScreenProps): ReactNode {
|
|
33
33
|
const t = useTranslation();
|
|
34
|
+
const { Link } = usePrimitives();
|
|
34
35
|
const token = useUrlToken(tokenProp);
|
|
35
36
|
const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
|
|
36
37
|
const startedRef = useRef(false);
|
|
@@ -50,9 +51,9 @@ export function VerifyEmailScreen({
|
|
|
50
51
|
<AuthCard title={title ?? t("auth.verifyEmail.errorTitle")}>
|
|
51
52
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
52
53
|
<p className="text-sm text-muted-foreground">{t("auth.verifyEmail.missingToken")}</p>
|
|
53
|
-
<
|
|
54
|
+
<Link href={loginHref} variant="muted">
|
|
54
55
|
{t("auth.verifyEmail.goToLogin")}
|
|
55
|
-
</
|
|
56
|
+
</Link>
|
|
56
57
|
</div>
|
|
57
58
|
</AuthCard>
|
|
58
59
|
);
|
|
@@ -75,9 +76,9 @@ export function VerifyEmailScreen({
|
|
|
75
76
|
<AuthCard title={title ?? t("auth.verifyEmail.successTitle")}>
|
|
76
77
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
77
78
|
<p className="text-sm text-muted-foreground">{t("auth.verifyEmail.successBody")}</p>
|
|
78
|
-
<
|
|
79
|
+
<Link href={loginHref} variant="button">
|
|
79
80
|
{t("auth.verifyEmail.goToLogin")}
|
|
80
|
-
</
|
|
81
|
+
</Link>
|
|
81
82
|
</div>
|
|
82
83
|
</AuthCard>
|
|
83
84
|
);
|
|
@@ -88,9 +89,9 @@ export function VerifyEmailScreen({
|
|
|
88
89
|
<AuthCard title={title ?? t("auth.verifyEmail.errorTitle")}>
|
|
89
90
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
90
91
|
<p className="text-sm text-muted-foreground">{t("auth.verifyEmail.errorBody")}</p>
|
|
91
|
-
<
|
|
92
|
+
<Link href={loginHref} variant="muted">
|
|
92
93
|
{t("auth.verifyEmail.goToLogin")}
|
|
93
|
-
</
|
|
94
|
+
</Link>
|
|
94
95
|
</div>
|
|
95
96
|
</AuthCard>
|
|
96
97
|
);
|
|
@@ -53,7 +53,7 @@ describe("groupBlocksByFolder", () => {
|
|
|
53
53
|
args: { slug: "imprint", lang: "de" },
|
|
54
54
|
});
|
|
55
55
|
expect(root.children).toBeUndefined();
|
|
56
|
-
expect(root.icon).
|
|
56
|
+
expect(root.icon).toBe("file");
|
|
57
57
|
});
|
|
58
58
|
test('folder="page" → Folder-Container mit child', () => {
|
|
59
59
|
const nodes = inside(
|
|
@@ -101,7 +101,7 @@ describe("groupBlocksByFolder", () => {
|
|
|
101
101
|
);
|
|
102
102
|
expect(nodes).toHaveLength(2);
|
|
103
103
|
expect(nodes[0]?.label).toBe("imprint");
|
|
104
|
-
expect(nodes[0]?.icon).
|
|
104
|
+
expect(nodes[0]?.icon).toBe("file");
|
|
105
105
|
expect(nodes[1]?.label).toBe("page");
|
|
106
106
|
expect(nodes[1]?.icon).toBe("folder");
|
|
107
107
|
});
|
|
@@ -184,7 +184,7 @@ describe("groupBlocksByFolder", () => {
|
|
|
184
184
|
const leaf = nodes[0];
|
|
185
185
|
const folder = nodes[1];
|
|
186
186
|
expect(leaf?.label).toBe("Page-Root");
|
|
187
|
-
expect(leaf?.icon).
|
|
187
|
+
expect(leaf?.icon).toBe("file");
|
|
188
188
|
expect(leaf?.target).toBeDefined();
|
|
189
189
|
expect(folder?.label).toBe("page");
|
|
190
190
|
expect(folder?.icon).toBe("folder");
|
|
@@ -77,6 +77,7 @@ function newFolderNode(): FolderNode {
|
|
|
77
77
|
function attachBlock(root: FolderNode, block: BlockSummary, tenantIdOverride?: string): void {
|
|
78
78
|
const leaf: TreeNode = {
|
|
79
79
|
label: block.title || block.slug,
|
|
80
|
+
icon: "file",
|
|
80
81
|
target: {
|
|
81
82
|
featureId: "text-content",
|
|
82
83
|
action: "edit",
|
|
@@ -217,9 +218,11 @@ type SetResponse = { readonly slug: string; readonly lang: string; readonly isNe
|
|
|
217
218
|
|
|
218
219
|
function TextContentEditor({
|
|
219
220
|
target,
|
|
220
|
-
onClose,
|
|
221
221
|
}: {
|
|
222
222
|
readonly target: TargetRef;
|
|
223
|
+
// onClose bleibt Teil des Resolver-Contracts (Visual-Panel liefert es),
|
|
224
|
+
// wird aber nicht mehr gebraucht: die Tree-Auswahl navigiert, kein
|
|
225
|
+
// Schließen-Button mehr. Nicht destrukturiert → kein unused binding.
|
|
223
226
|
readonly onClose: () => void;
|
|
224
227
|
}): ReactNode {
|
|
225
228
|
// @cast-boundary visual-tree-args — TargetRef.args ist erased zu
|
|
@@ -309,61 +312,53 @@ function TextContentEditor({
|
|
|
309
312
|
const disabled = submitting || loading || !canWrite;
|
|
310
313
|
|
|
311
314
|
return (
|
|
312
|
-
<
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
{canWrite && (
|
|
360
|
-
<Button type="submit" loading={submitting} disabled={disabled}>
|
|
361
|
-
{submitting ? "Speichern…" : "Speichern"}
|
|
362
|
-
</Button>
|
|
363
|
-
)}
|
|
364
|
-
</Form>
|
|
365
|
-
</div>
|
|
366
|
-
</div>
|
|
315
|
+
<Form
|
|
316
|
+
onSubmit={onSubmit}
|
|
317
|
+
testId="text-content-editor"
|
|
318
|
+
title={title || slug || "—"}
|
|
319
|
+
subtitle={lang !== "" ? `(${lang})` : undefined}
|
|
320
|
+
actions={
|
|
321
|
+
canWrite ? (
|
|
322
|
+
<Button type="submit" loading={submitting} disabled={disabled}>
|
|
323
|
+
{submitting ? "Speichern…" : "Speichern"}
|
|
324
|
+
</Button>
|
|
325
|
+
) : undefined
|
|
326
|
+
}
|
|
327
|
+
>
|
|
328
|
+
{loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
|
|
329
|
+
{loadError !== null && (
|
|
330
|
+
<Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
|
|
331
|
+
)}
|
|
332
|
+
{!canWrite && !loading && (
|
|
333
|
+
<Banner variant="info">
|
|
334
|
+
Read-only — TenantAdmin- oder SystemAdmin-Rolle für Änderungen erforderlich.
|
|
335
|
+
</Banner>
|
|
336
|
+
)}
|
|
337
|
+
<Field id="text-content-title" label="Titel" required>
|
|
338
|
+
<Input
|
|
339
|
+
kind="text"
|
|
340
|
+
id="text-content-title"
|
|
341
|
+
name="text-content-title"
|
|
342
|
+
value={title}
|
|
343
|
+
onChange={setTitle}
|
|
344
|
+
disabled={disabled}
|
|
345
|
+
required
|
|
346
|
+
/>
|
|
347
|
+
</Field>
|
|
348
|
+
<Field id="text-content-body" label="Inhalt">
|
|
349
|
+
<Input
|
|
350
|
+
kind="textarea"
|
|
351
|
+
id="text-content-body"
|
|
352
|
+
name="text-content-body"
|
|
353
|
+
value={body}
|
|
354
|
+
onChange={setBody}
|
|
355
|
+
disabled={disabled}
|
|
356
|
+
rows={14}
|
|
357
|
+
/>
|
|
358
|
+
</Field>
|
|
359
|
+
{saveError !== null && <Banner variant="error">{saveError}</Banner>}
|
|
360
|
+
{savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
|
|
361
|
+
</Form>
|
|
367
362
|
);
|
|
368
363
|
}
|
|
369
364
|
|
package/src/user/constants.ts
CHANGED