@cosmicdrift/kumiko-bundled-features 0.209.1 → 0.211.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 +8 -8
- package/src/auth-mfa/schema/user-mfa.ts +8 -5
- package/src/auth-mfa-user-data/hooks.ts +9 -8
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -2
- package/src/billing-foundation/entities.ts +5 -3
- package/src/billing-foundation/tenant-destroy-hook.ts +1 -1
- package/src/compliance-profiles/schema/profile-selection.ts +2 -1
- package/src/config/table.ts +4 -1
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +4 -4
- package/src/custom-fields/feature.ts +2 -2
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
- package/src/data-retention/schema/tenant-retention-override.ts +6 -3
- package/src/delivery/tables.ts +1 -1
- package/src/document-ingest-foundation/entity.ts +6 -3
- package/src/file-derivatives/changes.json +9 -1
- package/src/files/README.md +11 -11
- package/src/form-draft/entity.ts +6 -3
- package/src/inbound-mail-foundation/entities.ts +13 -11
- package/src/inbound-mail-foundation/feature.ts +5 -5
- package/src/ledger/entity.ts +5 -3
- package/src/managed-pages/screens/branding-screen.ts +2 -1
- package/src/managed-pages/table.ts +3 -2
- package/src/notes-history/entity.ts +8 -4
- package/src/notes-history-user-data/hooks.ts +6 -5
- package/src/personal-access-tokens/changes.json +7 -0
- package/src/personal-access-tokens/schema/api-token.ts +2 -1
- package/src/sessions/feature.ts +0 -4
- package/src/sessions/schema/user-session.ts +4 -2
- package/src/tags/entity.ts +11 -5
- package/src/template-resolver/__tests__/collection-ownership.integration.test.ts +1 -1
- package/src/template-resolver/changes.json +9 -1
- package/src/template-resolver/table.ts +4 -1
- package/src/template-resolver/user-content-table.ts +5 -2
- package/src/template-resolver-user-data/hooks.ts +6 -5
- package/src/tenant/__tests__/members-screens.boot.test.ts +79 -4
- package/src/tenant/__tests__/tenant-security.integration.test.ts +260 -3
- package/src/tenant/changes.json +6 -0
- package/src/tenant/constants.ts +17 -4
- package/src/tenant/feature.ts +28 -11
- package/src/tenant/handlers/team-list.query.ts +250 -0
- package/src/tenant/i18n.ts +20 -0
- package/src/tenant/invitation-table.ts +3 -3
- package/src/tenant/screens.ts +111 -3
- package/src/tenant/web/client-plugin.tsx +11 -6
- package/src/tenant/web/index.ts +2 -2
- package/src/tenant/web/member-roles-cell.tsx +12 -0
- package/src/tenant/web/member-status-cell.tsx +23 -0
- package/src/tenant-lifecycle/boot-checks.ts +2 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +58 -0
- package/src/tenant-settings/feature.ts +12 -0
- package/src/user/schema/user.ts +5 -5
- package/src/user-data-rights/__tests__/boot-checks.test.ts +73 -9
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +2 -2
- package/src/user-data-rights/boot-checks.ts +2 -2
- package/src/user-data-rights/schema/download-token.ts +12 -10
- package/src/user-data-rights/schema/export-job.ts +5 -4
- package/src/tenant/web/i18n.ts +0 -24
- package/src/tenant/web/members-screen.tsx +0 -270
|
@@ -96,7 +96,17 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
96
96
|
|
|
97
97
|
test("V3: pii entity without any EXT_USER_DATA hook → throws the guard's own message", () => {
|
|
98
98
|
const bad = defineFeature("crm", (r) => {
|
|
99
|
-
r.entity(
|
|
99
|
+
r.entity(
|
|
100
|
+
"contact",
|
|
101
|
+
createEntity({
|
|
102
|
+
fields: {
|
|
103
|
+
email: createTextField({
|
|
104
|
+
personal: "self",
|
|
105
|
+
find: "none",
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
100
110
|
});
|
|
101
111
|
expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
|
|
102
112
|
});
|
|
@@ -110,7 +120,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
110
120
|
source: "contact",
|
|
111
121
|
table: projectionTable,
|
|
112
122
|
apply: { [contactCreated.name]: async () => {} },
|
|
113
|
-
entity: createEntity({
|
|
123
|
+
entity: createEntity({
|
|
124
|
+
fields: {
|
|
125
|
+
email: createTextField({
|
|
126
|
+
personal: "self",
|
|
127
|
+
find: "none",
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
114
131
|
});
|
|
115
132
|
});
|
|
116
133
|
expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
|
|
@@ -120,7 +137,13 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
120
137
|
const bad = defineFeature("tasks", (r) => {
|
|
121
138
|
r.entity(
|
|
122
139
|
"task",
|
|
123
|
-
createEntity({
|
|
140
|
+
createEntity({
|
|
141
|
+
fields: {
|
|
142
|
+
assigneeId: createTextField({
|
|
143
|
+
personal: "ref",
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
124
147
|
);
|
|
125
148
|
});
|
|
126
149
|
expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
|
|
@@ -131,7 +154,13 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
131
154
|
r.requires("user-data-rights");
|
|
132
155
|
r.entity(
|
|
133
156
|
"task",
|
|
134
|
-
createEntity({
|
|
157
|
+
createEntity({
|
|
158
|
+
fields: {
|
|
159
|
+
assigneeId: createTextField({
|
|
160
|
+
personal: "ref",
|
|
161
|
+
}),
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
135
164
|
);
|
|
136
165
|
r.useExtension(EXT_USER_DATA, "task", { export: async () => null, delete: async () => {} });
|
|
137
166
|
});
|
|
@@ -148,7 +177,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
148
177
|
source: "contact",
|
|
149
178
|
table: projectionTable,
|
|
150
179
|
apply: { [contactCreated.name]: async () => {} },
|
|
151
|
-
entity: createEntity({
|
|
180
|
+
entity: createEntity({
|
|
181
|
+
fields: {
|
|
182
|
+
email: createTextField({
|
|
183
|
+
personal: "self",
|
|
184
|
+
find: "none",
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
152
188
|
});
|
|
153
189
|
r.useExtension(EXT_USER_DATA, "contact-summary", {
|
|
154
190
|
export: async () => null,
|
|
@@ -162,7 +198,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
162
198
|
const bad = defineFeature("billing", (r) => {
|
|
163
199
|
r.entity(
|
|
164
200
|
"subscription",
|
|
165
|
-
createEntity({
|
|
201
|
+
createEntity({
|
|
202
|
+
fields: {
|
|
203
|
+
providerCustomerId: createTextField({
|
|
204
|
+
personal: "tenant",
|
|
205
|
+
find: "none",
|
|
206
|
+
}),
|
|
207
|
+
},
|
|
208
|
+
}),
|
|
166
209
|
);
|
|
167
210
|
});
|
|
168
211
|
expect(() => validateBoot([...minimalTenantLifecycleFeatures(), bad])).toThrow(
|
|
@@ -174,7 +217,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
174
217
|
const bad = defineFeature("billing", (r) => {
|
|
175
218
|
r.entity(
|
|
176
219
|
"subscription",
|
|
177
|
-
createEntity({
|
|
220
|
+
createEntity({
|
|
221
|
+
fields: {
|
|
222
|
+
providerCustomerId: createTextField({
|
|
223
|
+
personal: "tenant",
|
|
224
|
+
find: "none",
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
178
228
|
);
|
|
179
229
|
});
|
|
180
230
|
const features = minimalTenantLifecycleFeatures().concat(bad);
|
|
@@ -186,7 +236,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
186
236
|
const notMounted = defineFeature("billing-standalone", (r) => {
|
|
187
237
|
r.entity(
|
|
188
238
|
"subscription",
|
|
189
|
-
createEntity({
|
|
239
|
+
createEntity({
|
|
240
|
+
fields: {
|
|
241
|
+
providerCustomerId: createTextField({
|
|
242
|
+
personal: "tenant",
|
|
243
|
+
find: "none",
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
190
247
|
);
|
|
191
248
|
});
|
|
192
249
|
expect(() => validateBoot([...baseFeatures(), notMounted])).not.toThrow();
|
|
@@ -197,7 +254,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
197
254
|
r.requires("tenant-lifecycle");
|
|
198
255
|
r.entity(
|
|
199
256
|
"subscription",
|
|
200
|
-
createEntity({
|
|
257
|
+
createEntity({
|
|
258
|
+
fields: {
|
|
259
|
+
providerCustomerId: createTextField({
|
|
260
|
+
personal: "tenant",
|
|
261
|
+
find: "none",
|
|
262
|
+
}),
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
201
265
|
);
|
|
202
266
|
r.useExtension(EXT_TENANT_DATA, "subscription", { destroy: async () => {} });
|
|
203
267
|
});
|
package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts
CHANGED
|
@@ -46,8 +46,8 @@ const noteEntity = createEntity({
|
|
|
46
46
|
authorId: createTextField({ required: true }),
|
|
47
47
|
body: createTextField({
|
|
48
48
|
required: true,
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
personal: { of: "authorId" },
|
|
50
|
+
find: "fuzzy",
|
|
51
51
|
}),
|
|
52
52
|
},
|
|
53
53
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
EXT_USER_DATA,
|
|
3
3
|
type FeatureDefinition,
|
|
4
|
-
type
|
|
4
|
+
type ResolvedPiiFlags,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { entitiesOf } from "../shared";
|
|
7
7
|
|
|
@@ -58,7 +58,7 @@ export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition
|
|
|
58
58
|
if (hookedEntities.has(entityName)) continue;
|
|
59
59
|
const subjectFields = Object.entries(entity.fields)
|
|
60
60
|
.filter(([, field]) => {
|
|
61
|
-
const annot = field as
|
|
61
|
+
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
62
62
|
return Boolean(annot.pii) || Boolean(annot.userOwned) || Boolean(annot.subjectRef);
|
|
63
63
|
})
|
|
64
64
|
.map(([name]) => name);
|
|
@@ -75,21 +75,23 @@ export const exportDownloadTokenEntity = createEntity({
|
|
|
75
75
|
// (z.B. broken Sync-Tool).
|
|
76
76
|
useCount: createBigIntField({}),
|
|
77
77
|
|
|
78
|
-
// Audit:
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// (
|
|
78
|
+
// Audit: source IP of the last download. Helps the DPO investigate
|
|
79
|
+
// unusual activity ("token was used from 5 different IPs"). Plain
|
|
80
|
+
// IPv4/IPv6, no hash — DPO needs it directly readable.
|
|
81
|
+
// `reason: "is_business_data"` because token-audit data isn't user
|
|
82
|
+
// PII (it belongs to the tenant operator).
|
|
83
83
|
lastUsedFromIp: createTextField({
|
|
84
|
-
maxLength: 45,
|
|
85
|
-
|
|
84
|
+
maxLength: 45,
|
|
85
|
+
personal: false,
|
|
86
|
+
reason: "is_business_data",
|
|
86
87
|
}),
|
|
87
88
|
|
|
88
|
-
// Audit:
|
|
89
|
-
// vs
|
|
89
|
+
// Audit: user agent of the last download (distinguishes email
|
|
90
|
+
// client vs browser vs CLI tool). Same `reason` as above.
|
|
90
91
|
lastUsedUserAgent: createTextField({
|
|
91
92
|
maxLength: 500,
|
|
92
|
-
|
|
93
|
+
personal: false,
|
|
94
|
+
reason: "is_business_data",
|
|
93
95
|
}),
|
|
94
96
|
},
|
|
95
97
|
|
|
@@ -133,11 +133,12 @@ export const exportJobEntity = createEntity({
|
|
|
133
133
|
// verbleiben.
|
|
134
134
|
expiresAt: createTimestampField({}),
|
|
135
135
|
|
|
136
|
-
// Failed-
|
|
137
|
-
//
|
|
138
|
-
//
|
|
136
|
+
// Failed-state diagnostics. longText because hook errors with stack
|
|
137
|
+
// traces can run several KB. `reason: "is_business_data"` because
|
|
138
|
+
// job status is publicly visible in UI polling (no PII).
|
|
139
139
|
errorMessage: createLongTextField({
|
|
140
|
-
|
|
140
|
+
personal: false,
|
|
141
|
+
reason: "is_business_data",
|
|
141
142
|
}),
|
|
142
143
|
|
|
143
144
|
// Audit-Info fuer Operator: wie gross war der Export. Hilft beim
|
package/src/tenant/web/i18n.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// @runtime client
|
|
2
|
-
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
|
|
4
|
-
export const defaultTranslations: TranslationsByLocale = {
|
|
5
|
-
en: {
|
|
6
|
-
"tenant.members.title": "Members",
|
|
7
|
-
"tenant.members.loading": "Loading members…",
|
|
8
|
-
"tenant.members.active": "Active members",
|
|
9
|
-
"tenant.members.invite.title": "New invitation",
|
|
10
|
-
"tenant.members.invite.email": "Email",
|
|
11
|
-
"tenant.members.invite.role": "Role",
|
|
12
|
-
"tenant.members.invite.submit": "Send invitation",
|
|
13
|
-
"tenant.members.invite.submitting": "Sending invitation…",
|
|
14
|
-
"tenant.members.invite.success": "Invitation sent to {email}",
|
|
15
|
-
"tenant.members.pending": "Pending invitations",
|
|
16
|
-
"tenant.members.pending.empty": "No pending invitations.",
|
|
17
|
-
"tenant.members.cancel": "Cancel",
|
|
18
|
-
"tenant.members.col.userId": "User ID",
|
|
19
|
-
"tenant.members.col.roles": "Roles",
|
|
20
|
-
"tenant.members.col.email": "Email",
|
|
21
|
-
"tenant.members.col.expires": "Valid until",
|
|
22
|
-
"tenant.nav.members": "Team",
|
|
23
|
-
},
|
|
24
|
-
};
|
|
@@ -1,270 +0,0 @@
|
|
|
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 email: string | null;
|
|
16
|
-
readonly displayName: string | null;
|
|
17
|
-
readonly roles: readonly string[];
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
type InvitationRow = {
|
|
21
|
-
readonly id: string;
|
|
22
|
-
readonly email: string;
|
|
23
|
-
readonly role: string;
|
|
24
|
-
readonly status: string;
|
|
25
|
-
readonly expiresAt: string;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
type State =
|
|
29
|
-
| { readonly kind: "loading" }
|
|
30
|
-
| { readonly kind: "error"; readonly message: string }
|
|
31
|
-
| {
|
|
32
|
-
readonly kind: "ready";
|
|
33
|
-
readonly members: readonly MemberRow[];
|
|
34
|
-
readonly invitations: readonly InvitationRow[];
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
type RoleOption = (typeof DEFAULT_INVITE_ROLE_OPTIONS)[number];
|
|
38
|
-
|
|
39
|
-
export type MembersScreenProps = {
|
|
40
|
-
/** Invite-role allowlist; defaults to User/Admin/Editor — never reserved/global roles. */
|
|
41
|
-
readonly inviteRoleOptions?: readonly RoleOption[];
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
export function MembersScreen({
|
|
45
|
-
inviteRoleOptions = DEFAULT_INVITE_ROLE_OPTIONS,
|
|
46
|
-
}: MembersScreenProps): ReactNode {
|
|
47
|
-
const t = useTranslation();
|
|
48
|
-
const { Banner, Button, Card, DataTable, Field, Form, Heading, Input, Text } = usePrimitives();
|
|
49
|
-
const dispatcher = useDispatcher();
|
|
50
|
-
const [state, setState] = useState<State>({ kind: "loading" });
|
|
51
|
-
const [inviteEmail, setInviteEmail] = useState("");
|
|
52
|
-
const [inviteRole, setInviteRole] = useState<RoleOption>(inviteRoleOptions[0] ?? "User");
|
|
53
|
-
const [submitting, setSubmitting] = useState(false);
|
|
54
|
-
const [actionError, setActionError] = useState<string | null>(null);
|
|
55
|
-
const [lastInvited, setLastInvited] = useState<string | null>(null);
|
|
56
|
-
|
|
57
|
-
const refresh = useCallback(async (): Promise<void> => {
|
|
58
|
-
const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {});
|
|
59
|
-
if (!membersRes.isSuccess) {
|
|
60
|
-
setState({ kind: "error", message: membersRes.error.message });
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
const invitationsRes = await dispatcher.query<readonly InvitationRow[]>(
|
|
64
|
-
TenantQueries.invitations,
|
|
65
|
-
{},
|
|
66
|
-
);
|
|
67
|
-
if (!invitationsRes.isSuccess) {
|
|
68
|
-
setState({ kind: "error", message: invitationsRes.error.message });
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
setState({ kind: "ready", members: membersRes.data, invitations: invitationsRes.data });
|
|
72
|
-
}, [dispatcher]);
|
|
73
|
-
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
void refresh();
|
|
76
|
-
}, [refresh]);
|
|
77
|
-
|
|
78
|
-
const onInvite = (e?: React.FormEvent): void => {
|
|
79
|
-
e?.preventDefault();
|
|
80
|
-
void (async (): Promise<void> => {
|
|
81
|
-
setActionError(null);
|
|
82
|
-
setSubmitting(true);
|
|
83
|
-
const email = inviteEmail.trim();
|
|
84
|
-
const res = await dispatcher.write(AuthHandlers.inviteCreate, { email, role: inviteRole });
|
|
85
|
-
setSubmitting(false);
|
|
86
|
-
if (!res.isSuccess) {
|
|
87
|
-
setActionError(res.error.message);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
setLastInvited(email);
|
|
91
|
-
setInviteEmail("");
|
|
92
|
-
await refresh();
|
|
93
|
-
})();
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const onCancel = async (invitationId: string): Promise<void> => {
|
|
97
|
-
setActionError(null);
|
|
98
|
-
const res = await dispatcher.write(TenantHandlers.cancelInvitation, { invitationId });
|
|
99
|
-
if (!res.isSuccess) {
|
|
100
|
-
setActionError(res.error.message);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
await refresh();
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
if (state.kind === "loading") {
|
|
107
|
-
return (
|
|
108
|
-
<FormScreenShell testId="members-screen">
|
|
109
|
-
<Text variant="small">{t("tenant.members.loading")}</Text>
|
|
110
|
-
</FormScreenShell>
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (state.kind === "error") {
|
|
115
|
-
return (
|
|
116
|
-
<FormScreenShell testId="members-screen">
|
|
117
|
-
<Banner variant="error">{state.message}</Banner>
|
|
118
|
-
</FormScreenShell>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const roleSelectOptions = inviteRoleOptions.map((r) => ({ value: r, label: r }));
|
|
123
|
-
|
|
124
|
-
return (
|
|
125
|
-
<FormScreenShell testId="members-screen" className="flex flex-col gap-6">
|
|
126
|
-
<Heading variant="page">{t("tenant.members.title")}</Heading>
|
|
127
|
-
|
|
128
|
-
<Card
|
|
129
|
-
slots={{ title: `${t("tenant.members.active")} (${state.members.length})` }}
|
|
130
|
-
options={{ padded: false }}
|
|
131
|
-
>
|
|
132
|
-
<DataTable
|
|
133
|
-
testId="members-active-table"
|
|
134
|
-
columns={[
|
|
135
|
-
{
|
|
136
|
-
field: "email",
|
|
137
|
-
label: t("tenant.members.col.email"),
|
|
138
|
-
type: "string",
|
|
139
|
-
sortable: false,
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
field: "roles",
|
|
143
|
-
label: t("tenant.members.col.roles"),
|
|
144
|
-
type: "string",
|
|
145
|
-
sortable: false,
|
|
146
|
-
},
|
|
147
|
-
]}
|
|
148
|
-
rows={state.members.map((m) => ({
|
|
149
|
-
id: m.id,
|
|
150
|
-
values: {
|
|
151
|
-
email: m.email ?? `${m.userId.slice(0, 8)}…`,
|
|
152
|
-
roles: m.roles.join(", "),
|
|
153
|
-
},
|
|
154
|
-
}))}
|
|
155
|
-
/>
|
|
156
|
-
</Card>
|
|
157
|
-
|
|
158
|
-
<Form
|
|
159
|
-
testId="invite-form"
|
|
160
|
-
title={t("tenant.members.invite.title")}
|
|
161
|
-
onSubmit={onInvite}
|
|
162
|
-
actions={
|
|
163
|
-
<Button
|
|
164
|
-
type="submit"
|
|
165
|
-
variant="primary"
|
|
166
|
-
loading={submitting}
|
|
167
|
-
disabled={submitting}
|
|
168
|
-
testId="invite-submit"
|
|
169
|
-
>
|
|
170
|
-
{submitting ? t("tenant.members.invite.submitting") : t("tenant.members.invite.submit")}
|
|
171
|
-
</Button>
|
|
172
|
-
}
|
|
173
|
-
>
|
|
174
|
-
<div className="grid gap-4 sm:grid-cols-2">
|
|
175
|
-
<Field id="invite-email" label={t("tenant.members.invite.email")} required>
|
|
176
|
-
<Input
|
|
177
|
-
kind="email"
|
|
178
|
-
id="invite-email"
|
|
179
|
-
name="invite-email"
|
|
180
|
-
value={inviteEmail}
|
|
181
|
-
onChange={setInviteEmail}
|
|
182
|
-
disabled={submitting}
|
|
183
|
-
required
|
|
184
|
-
autoComplete="email"
|
|
185
|
-
/>
|
|
186
|
-
</Field>
|
|
187
|
-
<Field id="invite-role" label={t("tenant.members.invite.role")} required>
|
|
188
|
-
<Input
|
|
189
|
-
kind="select"
|
|
190
|
-
id="invite-role"
|
|
191
|
-
name="invite-role"
|
|
192
|
-
value={inviteRole}
|
|
193
|
-
onChange={(v) => setInviteRole(v as RoleOption)}
|
|
194
|
-
options={roleSelectOptions}
|
|
195
|
-
disabled={submitting}
|
|
196
|
-
required
|
|
197
|
-
/>
|
|
198
|
-
</Field>
|
|
199
|
-
</div>
|
|
200
|
-
{lastInvited !== null && !submitting && (
|
|
201
|
-
<Banner variant="info">
|
|
202
|
-
{t("tenant.members.invite.success", { email: lastInvited })}
|
|
203
|
-
</Banner>
|
|
204
|
-
)}
|
|
205
|
-
{actionError !== null && <Banner variant="error">{actionError}</Banner>}
|
|
206
|
-
</Form>
|
|
207
|
-
|
|
208
|
-
<Card
|
|
209
|
-
slots={{ title: `${t("tenant.members.pending")} (${state.invitations.length})` }}
|
|
210
|
-
options={{ padded: false }}
|
|
211
|
-
>
|
|
212
|
-
<DataTable
|
|
213
|
-
testId="pending-list"
|
|
214
|
-
columns={[
|
|
215
|
-
{
|
|
216
|
-
field: "email",
|
|
217
|
-
label: t("tenant.members.col.email"),
|
|
218
|
-
type: "string",
|
|
219
|
-
sortable: false,
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
field: "role",
|
|
223
|
-
label: t("tenant.members.col.roles"),
|
|
224
|
-
type: "string",
|
|
225
|
-
sortable: false,
|
|
226
|
-
},
|
|
227
|
-
{
|
|
228
|
-
field: "expires",
|
|
229
|
-
label: t("tenant.members.col.expires"),
|
|
230
|
-
type: "string",
|
|
231
|
-
sortable: false,
|
|
232
|
-
},
|
|
233
|
-
]}
|
|
234
|
-
rows={state.invitations.map((inv) => ({
|
|
235
|
-
id: inv.id,
|
|
236
|
-
values: {
|
|
237
|
-
email: inv.email,
|
|
238
|
-
role: inv.role,
|
|
239
|
-
expires: formatExpiresAt(inv.expiresAt),
|
|
240
|
-
},
|
|
241
|
-
}))}
|
|
242
|
-
rowActions={[
|
|
243
|
-
{
|
|
244
|
-
id: "cancel",
|
|
245
|
-
label: t("tenant.members.cancel"),
|
|
246
|
-
style: "danger",
|
|
247
|
-
onTrigger: (row) => void onCancel(row.id),
|
|
248
|
-
},
|
|
249
|
-
]}
|
|
250
|
-
rowActionMode="inline"
|
|
251
|
-
emptyState={<Text variant="small">{t("tenant.members.pending.empty")}</Text>}
|
|
252
|
-
/>
|
|
253
|
-
</Card>
|
|
254
|
-
</FormScreenShell>
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function formatExpiresAt(iso: string): string {
|
|
259
|
-
try {
|
|
260
|
-
return new Date(iso).toLocaleString(undefined, {
|
|
261
|
-
day: "2-digit",
|
|
262
|
-
month: "2-digit",
|
|
263
|
-
year: "numeric",
|
|
264
|
-
hour: "2-digit",
|
|
265
|
-
minute: "2-digit",
|
|
266
|
-
});
|
|
267
|
-
} catch {
|
|
268
|
-
return iso;
|
|
269
|
-
}
|
|
270
|
-
}
|