@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.
- package/package.json +14 -6
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +199 -0
- package/src/admin-shell/__tests__/admin-shell.boot.test.ts +131 -0
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +33 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +33 -0
- package/src/admin-shell/constants.ts +11 -0
- package/src/admin-shell/feature.ts +134 -0
- package/src/admin-shell/i18n.ts +35 -0
- package/src/admin-shell/index.ts +17 -0
- package/src/admin-shell/overview-allowlist.ts +32 -0
- package/src/admin-shell/web/client-plugin.tsx +26 -0
- package/src/admin-shell/web/i18n.ts +16 -0
- package/src/admin-shell/web/index.ts +2 -0
- package/src/admin-shell/web/overview-layout.tsx +68 -0
- package/src/admin-shell/web/overview-query.ts +15 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +75 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +90 -0
- package/src/audit/__tests__/audit-screens.boot.test.ts +29 -0
- package/src/audit/__tests__/audit-security.integration.test.ts +151 -0
- package/src/audit/__tests__/audit.integration.test.ts +0 -4
- package/src/audit/constants.ts +5 -2
- package/src/audit/feature.ts +24 -1
- package/src/audit/handlers/list.query.ts +2 -2
- package/src/audit/i18n.ts +18 -0
- package/src/audit/web/audit-log-screen.tsx +136 -0
- package/src/audit/web/client-plugin.tsx +20 -0
- package/src/audit/web/i18n.ts +12 -0
- package/src/audit/web/index.ts +4 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +2 -2
- package/src/auth-email-password/web/use-shell-user.ts +5 -2
- package/src/cap-counter/__tests__/cap-list.boot.test.ts +27 -0
- package/src/cap-counter/__tests__/cap-list.security.integration.test.ts +53 -0
- package/src/cap-counter/constants.ts +2 -0
- package/src/cap-counter/feature.ts +20 -1
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +28 -0
- package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +91 -0
- package/src/compliance-profiles/constants.ts +15 -0
- package/src/compliance-profiles/feature.ts +19 -1
- package/src/compliance-profiles/handlers/set-profile.write.ts +2 -2
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/web/client-plugin.tsx +22 -0
- package/src/compliance-profiles/web/compliance-profile-screen.tsx +152 -0
- package/src/compliance-profiles/web/i18n.ts +25 -0
- package/src/compliance-profiles/web/index.ts +6 -0
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +27 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +106 -0
- package/src/delivery/constants.ts +13 -51
- package/src/delivery/feature.ts +19 -2
- package/src/delivery/handlers/log.query.ts +14 -6
- package/src/delivery/index.ts +1 -0
- package/src/delivery/public-names.ts +36 -0
- package/src/delivery/web/client-plugin.tsx +20 -0
- package/src/delivery/web/delivery-log-screen.tsx +103 -0
- package/src/delivery/web/i18n.ts +25 -0
- package/src/delivery/web/index.ts +3 -0
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +31 -0
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +69 -0
- package/src/feature-toggles/constants.ts +14 -0
- package/src/feature-toggles/feature.ts +14 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +1 -1
- package/src/feature-toggles/index.ts +6 -0
- package/src/feature-toggles/web/client-plugin.tsx +22 -0
- package/src/feature-toggles/web/i18n.ts +29 -0
- package/src/feature-toggles/web/index.ts +6 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +136 -0
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +36 -0
- package/src/jobs/__tests__/jobs-security.integration.test.ts +108 -0
- package/src/jobs/constants.ts +8 -1
- package/src/jobs/feature.ts +26 -0
- package/src/jobs/i18n.ts +32 -0
- package/src/jobs/web/client-plugin.tsx +22 -0
- package/src/jobs/web/i18n.ts +12 -0
- package/src/jobs/web/index.ts +4 -0
- package/src/jobs/web/job-run-detail-screen.tsx +169 -0
- package/src/jobs/web/job-runs-screen.tsx +148 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +44 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +295 -0
- package/src/tenant/constants.ts +5 -0
- package/src/tenant/feature.ts +16 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +2 -2
- package/src/tenant/handlers/invitations.query.ts +2 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/web/client-plugin.tsx +20 -0
- package/src/tenant/web/i18n.ts +43 -0
- package/src/tenant/web/index.ts +4 -0
- package/src/tenant/web/members-screen.tsx +265 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +7 -6
package/src/tenant/feature.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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,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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
123
|
+
<Text variant="small" testId="tier-admin-current">
|
|
123
124
|
{t("tier-admin.current.label")}: {currentTier ?? t("tier-admin.current.none")}
|
|
124
|
-
</
|
|
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
|
-
</
|
|
160
|
+
</FormScreenShell>
|
|
160
161
|
);
|
|
161
162
|
}
|