@cosmicdrift/kumiko-bundled-features 0.124.0 → 0.125.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.
Files changed (88) hide show
  1. package/package.json +14 -6
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +199 -0
  3. package/src/admin-shell/__tests__/admin-shell.boot.test.ts +131 -0
  4. package/src/admin-shell/__tests__/overview-allowlist.test.ts +33 -0
  5. package/src/admin-shell/__tests__/overview-screens.boot.test.ts +33 -0
  6. package/src/admin-shell/constants.ts +11 -0
  7. package/src/admin-shell/feature.ts +134 -0
  8. package/src/admin-shell/i18n.ts +35 -0
  9. package/src/admin-shell/index.ts +17 -0
  10. package/src/admin-shell/overview-allowlist.ts +32 -0
  11. package/src/admin-shell/web/client-plugin.tsx +26 -0
  12. package/src/admin-shell/web/i18n.ts +16 -0
  13. package/src/admin-shell/web/index.ts +2 -0
  14. package/src/admin-shell/web/overview-layout.tsx +68 -0
  15. package/src/admin-shell/web/overview-query.ts +15 -0
  16. package/src/admin-shell/web/platform-overview-screen.tsx +75 -0
  17. package/src/admin-shell/web/tenant-overview-screen.tsx +90 -0
  18. package/src/audit/__tests__/audit-screens.boot.test.ts +29 -0
  19. package/src/audit/__tests__/audit-security.integration.test.ts +151 -0
  20. package/src/audit/__tests__/audit.integration.test.ts +0 -4
  21. package/src/audit/constants.ts +5 -2
  22. package/src/audit/feature.ts +24 -1
  23. package/src/audit/handlers/list.query.ts +2 -2
  24. package/src/audit/i18n.ts +18 -0
  25. package/src/audit/web/audit-log-screen.tsx +136 -0
  26. package/src/audit/web/client-plugin.tsx +20 -0
  27. package/src/audit/web/i18n.ts +12 -0
  28. package/src/audit/web/index.ts +4 -0
  29. package/src/auth-email-password/handlers/invite-create.write.ts +2 -2
  30. package/src/auth-email-password/web/use-shell-user.ts +5 -2
  31. package/src/cap-counter/__tests__/cap-list.boot.test.ts +27 -0
  32. package/src/cap-counter/__tests__/cap-list.security.integration.test.ts +53 -0
  33. package/src/cap-counter/constants.ts +2 -0
  34. package/src/cap-counter/feature.ts +20 -1
  35. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +28 -0
  36. package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +91 -0
  37. package/src/compliance-profiles/constants.ts +15 -0
  38. package/src/compliance-profiles/feature.ts +19 -1
  39. package/src/compliance-profiles/handlers/set-profile.write.ts +2 -2
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/web/client-plugin.tsx +22 -0
  42. package/src/compliance-profiles/web/compliance-profile-screen.tsx +152 -0
  43. package/src/compliance-profiles/web/i18n.ts +25 -0
  44. package/src/compliance-profiles/web/index.ts +6 -0
  45. package/src/delivery/__tests__/delivery-screens.boot.test.ts +27 -0
  46. package/src/delivery/__tests__/delivery-security.integration.test.ts +106 -0
  47. package/src/delivery/constants.ts +13 -51
  48. package/src/delivery/feature.ts +19 -2
  49. package/src/delivery/handlers/log.query.ts +14 -6
  50. package/src/delivery/index.ts +1 -0
  51. package/src/delivery/public-names.ts +36 -0
  52. package/src/delivery/web/client-plugin.tsx +20 -0
  53. package/src/delivery/web/delivery-log-screen.tsx +103 -0
  54. package/src/delivery/web/i18n.ts +25 -0
  55. package/src/delivery/web/index.ts +3 -0
  56. package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +31 -0
  57. package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +69 -0
  58. package/src/feature-toggles/constants.ts +14 -0
  59. package/src/feature-toggles/feature.ts +14 -1
  60. package/src/feature-toggles/handlers/list.query.ts +1 -1
  61. package/src/feature-toggles/handlers/registered.query.ts +1 -1
  62. package/src/feature-toggles/index.ts +6 -0
  63. package/src/feature-toggles/web/client-plugin.tsx +22 -0
  64. package/src/feature-toggles/web/i18n.ts +29 -0
  65. package/src/feature-toggles/web/index.ts +6 -0
  66. package/src/feature-toggles/web/toggle-admin-screen.tsx +136 -0
  67. package/src/jobs/__tests__/jobs-screens.boot.test.ts +36 -0
  68. package/src/jobs/__tests__/jobs-security.integration.test.ts +108 -0
  69. package/src/jobs/constants.ts +8 -1
  70. package/src/jobs/feature.ts +26 -0
  71. package/src/jobs/i18n.ts +32 -0
  72. package/src/jobs/web/client-plugin.tsx +22 -0
  73. package/src/jobs/web/i18n.ts +12 -0
  74. package/src/jobs/web/index.ts +4 -0
  75. package/src/jobs/web/job-run-detail-screen.tsx +169 -0
  76. package/src/jobs/web/job-runs-screen.tsx +148 -0
  77. package/src/tenant/__tests__/members-screens.boot.test.ts +44 -0
  78. package/src/tenant/__tests__/tenant-security.integration.test.ts +295 -0
  79. package/src/tenant/constants.ts +5 -0
  80. package/src/tenant/feature.ts +16 -0
  81. package/src/tenant/handlers/cancel-invitation.write.ts +2 -2
  82. package/src/tenant/handlers/invitations.query.ts +2 -2
  83. package/src/tenant/handlers/members.query.ts +2 -2
  84. package/src/tenant/web/client-plugin.tsx +20 -0
  85. package/src/tenant/web/i18n.ts +43 -0
  86. package/src/tenant/web/index.ts +4 -0
  87. package/src/tenant/web/members-screen.tsx +265 -0
  88. package/src/tier-engine/web/tier-admin-screen.tsx +7 -6
@@ -8,6 +8,7 @@ import {
8
8
  defineFeature,
9
9
  type FeatureDefinition,
10
10
  } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { MEMBERS_SCREEN_ID } from "./constants";
11
12
  import { activeTenantIdsQuery } from "./handlers/active-tenant-ids.query";
12
13
  import { addMemberWrite } from "./handlers/add-member.write";
13
14
  import { cancelInvitationWrite } from "./handlers/cancel-invitation.write";
@@ -134,6 +135,21 @@ export function createTenantFeature(): FeatureDefinition {
134
135
  );
135
136
  r.screen(tenantListScreen);
136
137
  r.screen(tenantEditScreen);
138
+ // Tenant-admin team UI: members list + invite/cancel (no role-edit — updateMemberRoles
139
+ // stays SystemAdmin-only). Screen access matches handler access.admin.
140
+ r.screen({
141
+ id: MEMBERS_SCREEN_ID,
142
+ type: "custom",
143
+ renderer: { react: { __component: "MembersScreen" } },
144
+ access: { roles: access.admin },
145
+ });
146
+ r.nav({
147
+ id: "members",
148
+ label: "tenant:nav.members",
149
+ icon: "users",
150
+ screen: "tenant:screen:members",
151
+ order: 20,
152
+ });
137
153
 
138
154
  return { handlers, queries };
139
155
  });
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
14
14
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
15
- import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
16
16
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
17
17
  import { z } from "zod";
18
18
  // kumiko-lint-ignore cross-feature-import cancel needs invite-token-store für Redis-cleanup
@@ -37,7 +37,7 @@ const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitati
37
37
  export const cancelInvitationWrite = defineWriteHandler({
38
38
  name: "cancel-invitation",
39
39
  schema: CancelInvitationSchema,
40
- access: { roles: ["Admin"] },
40
+ access: { roles: access.admin },
41
41
  handler: async (event, ctx) => {
42
42
  const invitation = await fetchOne(ctx.db.raw, tenantInvitationsTable, {
43
43
  id: event.payload.invitationId,
@@ -1,5 +1,5 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
4
  import { decryptStoredPii } from "../../shared";
5
5
  import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table";
@@ -16,7 +16,7 @@ import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table";
16
16
  export const invitationsQuery = defineQueryHandler({
17
17
  name: "invitations",
18
18
  schema: z.object({}),
19
- access: { roles: ["Admin", "SystemAdmin"] },
19
+ access: { roles: access.admin },
20
20
  handler: async (query, ctx) => {
21
21
  const rows = await selectMany<Record<string, unknown>>(ctx.db, tenantInvitationsTable, {
22
22
  tenantId: query.user.tenantId,
@@ -1,5 +1,5 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
4
4
  import { z } from "zod";
5
5
  import { tenantMembershipsTable } from "../membership-table";
@@ -7,7 +7,7 @@ import { tenantMembershipsTable } from "../membership-table";
7
7
  export const membersQuery = defineQueryHandler({
8
8
  name: "members",
9
9
  schema: z.object({}),
10
- access: { roles: ["Admin", "SystemAdmin"] },
10
+ access: { roles: access.admin },
11
11
  handler: async (query, ctx) => {
12
12
  const rows = await selectMany(ctx.db, tenantMembershipsTable, {
13
13
  tenantId: query.user.tenantId,
@@ -0,0 +1,20 @@
1
+ // @runtime client
2
+ import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
+ import { MEMBERS_SCREEN_ID, TENANT_FEATURE } from "../constants";
5
+ import { defaultTranslations } from "./i18n";
6
+ import { MembersScreen } from "./members-screen";
7
+
8
+ export type TenantClientOptions = {
9
+ readonly translations?: TranslationsByLocale;
10
+ };
11
+
12
+ export function tenantClient(options?: TenantClientOptions): ClientFeatureDefinition {
13
+ return {
14
+ name: TENANT_FEATURE,
15
+ translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
16
+ components: {
17
+ [MEMBERS_SCREEN_ID]: MembersScreen,
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,43 @@
1
+ // @runtime client
2
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
+
4
+ export const defaultTranslations: TranslationsByLocale = {
5
+ de: {
6
+ "tenant.members.title": "Mitglieder",
7
+ "tenant.members.loading": "Lade Mitglieder…",
8
+ "tenant.members.active": "Aktive Mitglieder",
9
+ "tenant.members.invite.title": "Neue Einladung",
10
+ "tenant.members.invite.email": "E-Mail",
11
+ "tenant.members.invite.role": "Rolle",
12
+ "tenant.members.invite.submit": "Einladung senden",
13
+ "tenant.members.invite.submitting": "Einladung wird gesendet…",
14
+ "tenant.members.invite.success": "Einladung an {email} gesendet",
15
+ "tenant.members.pending": "Ausstehende Einladungen",
16
+ "tenant.members.pending.empty": "Keine ausstehenden Einladungen.",
17
+ "tenant.members.cancel": "Zurückziehen",
18
+ "tenant.members.col.userId": "User-ID",
19
+ "tenant.members.col.roles": "Rollen",
20
+ "tenant.members.col.email": "E-Mail",
21
+ "tenant.members.col.expires": "Gültig bis",
22
+ "tenant.nav.members": "Team",
23
+ },
24
+ en: {
25
+ "tenant.members.title": "Members",
26
+ "tenant.members.loading": "Loading members…",
27
+ "tenant.members.active": "Active members",
28
+ "tenant.members.invite.title": "New invitation",
29
+ "tenant.members.invite.email": "Email",
30
+ "tenant.members.invite.role": "Role",
31
+ "tenant.members.invite.submit": "Send invitation",
32
+ "tenant.members.invite.submitting": "Sending invitation…",
33
+ "tenant.members.invite.success": "Invitation sent to {email}",
34
+ "tenant.members.pending": "Pending invitations",
35
+ "tenant.members.pending.empty": "No pending invitations.",
36
+ "tenant.members.cancel": "Cancel",
37
+ "tenant.members.col.userId": "User ID",
38
+ "tenant.members.col.roles": "Roles",
39
+ "tenant.members.col.email": "Email",
40
+ "tenant.members.col.expires": "Valid until",
41
+ "tenant.nav.members": "Team",
42
+ },
43
+ };
@@ -0,0 +1,4 @@
1
+ // @runtime client
2
+ export { type TenantClientOptions, tenantClient } from "./client-plugin";
3
+ export { defaultTranslations } from "./i18n";
4
+ export { MembersScreen, type MembersScreenProps } from "./members-screen";
@@ -0,0 +1,265 @@
1
+ // @runtime client
2
+ // MembersScreen — active members + pending invitations for the current tenant.
3
+ // Read-only member list; invite + cancel only (no updateMemberRoles — SystemAdmin-only).
4
+
5
+ import { AuthHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/constants";
6
+ import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
7
+ import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { type ReactNode, useCallback, useEffect, useState } from "react";
9
+ import { DEFAULT_INVITE_ROLE_OPTIONS, TenantHandlers, TenantQueries } from "../constants";
10
+
11
+ type MemberRow = {
12
+ readonly id: string;
13
+ readonly userId: string;
14
+ readonly tenantId: string;
15
+ readonly roles: readonly string[];
16
+ };
17
+
18
+ type InvitationRow = {
19
+ readonly id: string;
20
+ readonly email: string;
21
+ readonly role: string;
22
+ readonly status: string;
23
+ readonly expiresAt: string;
24
+ };
25
+
26
+ type State =
27
+ | { readonly kind: "loading" }
28
+ | { readonly kind: "error"; readonly message: string }
29
+ | {
30
+ readonly kind: "ready";
31
+ readonly members: readonly MemberRow[];
32
+ readonly invitations: readonly InvitationRow[];
33
+ };
34
+
35
+ type RoleOption = (typeof DEFAULT_INVITE_ROLE_OPTIONS)[number];
36
+
37
+ export type MembersScreenProps = {
38
+ /** Invite-role allowlist; defaults to User/Admin/Editor — never reserved/global roles. */
39
+ readonly inviteRoleOptions?: readonly RoleOption[];
40
+ };
41
+
42
+ export function MembersScreen({
43
+ inviteRoleOptions = DEFAULT_INVITE_ROLE_OPTIONS,
44
+ }: MembersScreenProps): ReactNode {
45
+ const t = useTranslation();
46
+ const { Banner, Button, Card, DataTable, Field, Form, Heading, Input, Text } = usePrimitives();
47
+ const dispatcher = useDispatcher();
48
+ const [state, setState] = useState<State>({ kind: "loading" });
49
+ const [inviteEmail, setInviteEmail] = useState("");
50
+ const [inviteRole, setInviteRole] = useState<RoleOption>(inviteRoleOptions[0] ?? "User");
51
+ const [submitting, setSubmitting] = useState(false);
52
+ const [actionError, setActionError] = useState<string | null>(null);
53
+ const [lastInvited, setLastInvited] = useState<string | null>(null);
54
+
55
+ const refresh = useCallback(async (): Promise<void> => {
56
+ const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {});
57
+ if (!membersRes.isSuccess) {
58
+ setState({ kind: "error", message: membersRes.error.message });
59
+ return;
60
+ }
61
+ const invitationsRes = await dispatcher.query<readonly InvitationRow[]>(
62
+ TenantQueries.invitations,
63
+ {},
64
+ );
65
+ if (!invitationsRes.isSuccess) {
66
+ setState({ kind: "error", message: invitationsRes.error.message });
67
+ return;
68
+ }
69
+ setState({ kind: "ready", members: membersRes.data, invitations: invitationsRes.data });
70
+ }, [dispatcher]);
71
+
72
+ useEffect(() => {
73
+ void refresh();
74
+ }, [refresh]);
75
+
76
+ const onInvite = (e?: React.FormEvent): void => {
77
+ e?.preventDefault();
78
+ void (async (): Promise<void> => {
79
+ setActionError(null);
80
+ setSubmitting(true);
81
+ const email = inviteEmail.trim();
82
+ const res = await dispatcher.write(AuthHandlers.inviteCreate, { email, role: inviteRole });
83
+ setSubmitting(false);
84
+ if (!res.isSuccess) {
85
+ setActionError(res.error.message);
86
+ return;
87
+ }
88
+ setLastInvited(email);
89
+ setInviteEmail("");
90
+ await refresh();
91
+ })();
92
+ };
93
+
94
+ const onCancel = async (invitationId: string): Promise<void> => {
95
+ setActionError(null);
96
+ const res = await dispatcher.write(TenantHandlers.cancelInvitation, { invitationId });
97
+ if (!res.isSuccess) {
98
+ setActionError(res.error.message);
99
+ return;
100
+ }
101
+ await refresh();
102
+ };
103
+
104
+ if (state.kind === "loading") {
105
+ return (
106
+ <FormScreenShell testId="members-screen">
107
+ <Text variant="small">{t("tenant.members.loading")}</Text>
108
+ </FormScreenShell>
109
+ );
110
+ }
111
+
112
+ if (state.kind === "error") {
113
+ return (
114
+ <FormScreenShell testId="members-screen">
115
+ <Banner variant="error">{state.message}</Banner>
116
+ </FormScreenShell>
117
+ );
118
+ }
119
+
120
+ const roleSelectOptions = inviteRoleOptions.map((r) => ({ value: r, label: r }));
121
+
122
+ return (
123
+ <FormScreenShell testId="members-screen" className="flex flex-col gap-6">
124
+ <Heading variant="page">{t("tenant.members.title")}</Heading>
125
+
126
+ <Card
127
+ slots={{ title: `${t("tenant.members.active")} (${state.members.length})` }}
128
+ options={{ padded: false }}
129
+ >
130
+ <DataTable
131
+ testId="members-active-table"
132
+ columns={[
133
+ {
134
+ field: "userId",
135
+ label: t("tenant.members.col.userId"),
136
+ type: "string",
137
+ sortable: false,
138
+ },
139
+ {
140
+ field: "roles",
141
+ label: t("tenant.members.col.roles"),
142
+ type: "string",
143
+ sortable: false,
144
+ },
145
+ ]}
146
+ rows={state.members.map((m) => ({
147
+ id: m.id,
148
+ values: { userId: m.userId, roles: m.roles.join(", ") },
149
+ }))}
150
+ />
151
+ </Card>
152
+
153
+ <Form
154
+ testId="invite-form"
155
+ title={t("tenant.members.invite.title")}
156
+ onSubmit={onInvite}
157
+ actions={
158
+ <Button
159
+ type="submit"
160
+ variant="primary"
161
+ loading={submitting}
162
+ disabled={submitting}
163
+ testId="invite-submit"
164
+ >
165
+ {submitting ? t("tenant.members.invite.submitting") : t("tenant.members.invite.submit")}
166
+ </Button>
167
+ }
168
+ >
169
+ <div className="grid gap-4 sm:grid-cols-2">
170
+ <Field id="invite-email" label={t("tenant.members.invite.email")} required>
171
+ <Input
172
+ kind="email"
173
+ id="invite-email"
174
+ name="invite-email"
175
+ value={inviteEmail}
176
+ onChange={setInviteEmail}
177
+ disabled={submitting}
178
+ required
179
+ autoComplete="email"
180
+ />
181
+ </Field>
182
+ <Field id="invite-role" label={t("tenant.members.invite.role")} required>
183
+ <Input
184
+ kind="select"
185
+ id="invite-role"
186
+ name="invite-role"
187
+ value={inviteRole}
188
+ onChange={(v) => setInviteRole(v as RoleOption)}
189
+ options={roleSelectOptions}
190
+ disabled={submitting}
191
+ required
192
+ />
193
+ </Field>
194
+ </div>
195
+ {lastInvited !== null && !submitting && (
196
+ <Banner variant="info">
197
+ {t("tenant.members.invite.success", { email: lastInvited })}
198
+ </Banner>
199
+ )}
200
+ {actionError !== null && <Banner variant="error">{actionError}</Banner>}
201
+ </Form>
202
+
203
+ <Card
204
+ slots={{ title: `${t("tenant.members.pending")} (${state.invitations.length})` }}
205
+ options={{ padded: false }}
206
+ >
207
+ <DataTable
208
+ testId="pending-list"
209
+ columns={[
210
+ {
211
+ field: "email",
212
+ label: t("tenant.members.col.email"),
213
+ type: "string",
214
+ sortable: false,
215
+ },
216
+ {
217
+ field: "role",
218
+ label: t("tenant.members.col.roles"),
219
+ type: "string",
220
+ sortable: false,
221
+ },
222
+ {
223
+ field: "expires",
224
+ label: t("tenant.members.col.expires"),
225
+ type: "string",
226
+ sortable: false,
227
+ },
228
+ ]}
229
+ rows={state.invitations.map((inv) => ({
230
+ id: inv.id,
231
+ values: {
232
+ email: inv.email,
233
+ role: inv.role,
234
+ expires: formatExpiresAt(inv.expiresAt),
235
+ },
236
+ }))}
237
+ rowActions={[
238
+ {
239
+ id: "cancel",
240
+ label: t("tenant.members.cancel"),
241
+ style: "danger",
242
+ onTrigger: (row) => void onCancel(row.id),
243
+ },
244
+ ]}
245
+ rowActionMode="inline"
246
+ emptyState={<Text variant="small">{t("tenant.members.pending.empty")}</Text>}
247
+ />
248
+ </Card>
249
+ </FormScreenShell>
250
+ );
251
+ }
252
+
253
+ function formatExpiresAt(iso: string): string {
254
+ try {
255
+ return new Date(iso).toLocaleString(undefined, {
256
+ day: "2-digit",
257
+ month: "2-digit",
258
+ year: "numeric",
259
+ hour: "2-digit",
260
+ minute: "2-digit",
261
+ });
262
+ } catch {
263
+ return iso;
264
+ }
265
+ }
@@ -13,6 +13,7 @@ import {
13
13
  useQuery,
14
14
  useTranslation,
15
15
  } from "@cosmicdrift/kumiko-renderer";
16
+ import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
16
17
  import { type ReactNode, useEffect, useState } from "react";
17
18
  import { TierEngineHandlers, TierEngineQueries } from "../constants";
18
19
 
@@ -36,7 +37,7 @@ type Status =
36
37
 
37
38
  export function TierAdminScreen(): ReactNode {
38
39
  const t = useTranslation();
39
- const { Section, Field, Input, Button, Banner, Heading } = usePrimitives();
40
+ const { Section, Field, Input, Button, Banner, Heading, Text } = usePrimitives();
40
41
  const dispatcher = useDispatcher();
41
42
 
42
43
  // ponytail: nur die erste Seite (default-limit, nextCursor ignoriert) —
@@ -91,9 +92,9 @@ export function TierAdminScreen(): ReactNode {
91
92
  const canSubmit = tenantId !== "" && tier !== "" && !submitting;
92
93
 
93
94
  return (
94
- <div className="p-6 flex flex-col gap-6 max-w-xl" data-testid="tier-admin-screen">
95
+ <FormScreenShell testId="tier-admin-screen" className="flex max-w-xl flex-col gap-6">
95
96
  <Heading variant="page">{t("tier-admin.title")}</Heading>
96
- <p className="text-sm text-muted-foreground">{t("tier-admin.explainer")}</p>
97
+ <Text variant="small">{t("tier-admin.explainer")}</Text>
97
98
 
98
99
  {tenantsQuery.error !== null && (
99
100
  <Banner variant="error" testId="tier-admin-load-error">
@@ -119,9 +120,9 @@ export function TierAdminScreen(): ReactNode {
119
120
  </Field>
120
121
 
121
122
  {tenantId !== "" && (
122
- <p className="text-sm text-muted-foreground" data-testid="tier-admin-current">
123
+ <Text variant="small" testId="tier-admin-current">
123
124
  {t("tier-admin.current.label")}: {currentTier ?? t("tier-admin.current.none")}
124
- </p>
125
+ </Text>
125
126
  )}
126
127
 
127
128
  <Field id="tier-admin-tier" label={t("tier-admin.tier.label")} required>
@@ -156,6 +157,6 @@ export function TierAdminScreen(): ReactNode {
156
157
  {t("tier-admin.submit")}
157
158
  </Button>
158
159
  </Section>
159
- </div>
160
+ </FormScreenShell>
160
161
  );
161
162
  }