@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.221.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 +9 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/auth-client.ts +1 -7
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
- package/src/feature-toggles/feature.ts +24 -4
- package/src/feature-toggles/handlers/registered.query.ts +27 -16
- package/src/feature-toggles/i18n.ts +5 -0
- package/src/feature-toggles/web/client-plugin.tsx +1 -5
- package/src/feature-toggles/web/index.ts +0 -1
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +7 -6
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/secrets/handlers/delete.write.ts +0 -4
- package/src/secrets/handlers/list.query.ts +0 -4
- package/src/secrets/handlers/set.write.ts +0 -4
- package/src/secrets/index.ts +3 -3
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/__tests__/feature.test.ts +18 -5
- package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
- package/src/tags/constants.ts +3 -2
- package/src/tags/entity.ts +2 -0
- package/src/tags/feature.ts +20 -11
- package/src/tags/handlers/create-tag.write.ts +5 -2
- package/src/tags/handlers/delete-tag.write.ts +5 -2
- package/src/tags/handlers/update-tag.write.ts +5 -2
- package/src/tags/i18n.ts +4 -0
- package/src/tags/index.ts +2 -0
- package/src/tags/screens.ts +66 -0
- package/src/tags/web/client-plugin.tsx +1 -6
- package/src/tags/web/index.ts +1 -0
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
- package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +3 -1
- package/src/tenant/handlers/update-member-roles.write.ts +16 -2
- package/src/tenant/i18n.ts +2 -3
- package/src/tenant/screens.ts +3 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +23 -6
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/i18n.ts +0 -6
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-profile/web/profile-screen.tsx +1 -1
- package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
package/src/user/schema/user.ts
CHANGED
|
@@ -71,9 +71,8 @@ export const userEntity = createEntity({
|
|
|
71
71
|
format: "email",
|
|
72
72
|
maxLength: 320,
|
|
73
73
|
personal: "self",
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
searchable: true,
|
|
74
|
+
// Roster search needs email hits — fuzzy expands to lookupable+searchable.
|
|
75
|
+
find: "fuzzy",
|
|
77
76
|
access: { write: access.privileged },
|
|
78
77
|
}),
|
|
79
78
|
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
unsafePushTables,
|
|
35
35
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
36
36
|
import {
|
|
37
|
+
expectSuccess,
|
|
37
38
|
resetBlindIndexKeyForTests,
|
|
38
39
|
resetPiiSubjectKmsForTests,
|
|
39
40
|
resetTestTables,
|
|
@@ -447,8 +448,8 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
|
|
|
447
448
|
systemUser,
|
|
448
449
|
tenantDb,
|
|
449
450
|
);
|
|
450
|
-
|
|
451
|
-
const invitationId =
|
|
451
|
+
expectSuccess(createResult);
|
|
452
|
+
const invitationId = createResult.data.id;
|
|
452
453
|
|
|
453
454
|
const rawRows = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
|
|
454
455
|
`SELECT email, email_bidx FROM read_tenant_invitations WHERE id = $1`,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
EXT_USER_DATA,
|
|
25
25
|
type JobContext,
|
|
26
26
|
SYSTEM_USER_ID,
|
|
27
|
+
type TenantId,
|
|
27
28
|
type UserDataDeleteHook,
|
|
28
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
30
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
@@ -245,16 +246,13 @@ describe("forget pipeline honours the effective tenant model", () => {
|
|
|
245
246
|
// `tenant-membership.created` events, so FORGET_USER needs its own
|
|
246
247
|
// `.created` event too, or the tenant would only ever show 1 distinct
|
|
247
248
|
// historical member (CO_MEMBER) even after it truly had 2.
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// different tenant, which would emit .created under the wrong tenantId
|
|
252
|
-
// and make it invisible to everHadMultipleMembers' tenant-scoped query.
|
|
249
|
+
// seedTenantMembership defaults `by` to createSystemUser(tenantId), so
|
|
250
|
+
// the stream tenant matches TENANT (unlike seedTenant, which defaults
|
|
251
|
+
// to TestUsers.systemAdmin).
|
|
253
252
|
await seedTenantMembership(stack.db, {
|
|
254
253
|
userId: FORGET_USER,
|
|
255
254
|
tenantId: TENANT,
|
|
256
255
|
roles: ["Member"],
|
|
257
|
-
by: createSystemUser(TENANT),
|
|
258
256
|
});
|
|
259
257
|
|
|
260
258
|
// CO_MEMBER joins for real (emits tenant-membership.created) and is then
|
|
@@ -265,7 +263,6 @@ describe("forget pipeline honours the effective tenant model", () => {
|
|
|
265
263
|
userId: CO_MEMBER,
|
|
266
264
|
tenantId: TENANT,
|
|
267
265
|
roles: ["Member"],
|
|
268
|
-
by: createSystemUser(TENANT),
|
|
269
266
|
});
|
|
270
267
|
const deleteResult = await membershipExecutor.delete(
|
|
271
268
|
{ id: created.id },
|
|
@@ -299,6 +296,47 @@ describe("forget pipeline honours the effective tenant model", () => {
|
|
|
299
296
|
// historical check must still resolve multi-user here.
|
|
300
297
|
expect(await rowCount()).toBe(1);
|
|
301
298
|
});
|
|
299
|
+
|
|
300
|
+
test("single-user history latch uses payload tenantId (cross-tenant SystemAdmin actor)", async () => {
|
|
301
|
+
await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c6");
|
|
302
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
303
|
+
const OTHER_TENANT = "dddddddd-dddd-4ddd-8ddd-0000000000aa" as TenantId;
|
|
304
|
+
|
|
305
|
+
// Membership events billed to a foreign actor tenant — mirrors real
|
|
306
|
+
// SystemAdmin addMember (event.tenant_id ≠ payload.tenantId).
|
|
307
|
+
await seedTenantMembership(stack.db, {
|
|
308
|
+
userId: FORGET_USER,
|
|
309
|
+
tenantId: TENANT,
|
|
310
|
+
roles: ["Member"],
|
|
311
|
+
by: createSystemUser(OTHER_TENANT),
|
|
312
|
+
});
|
|
313
|
+
const created = await seedTenantMembership(stack.db, {
|
|
314
|
+
userId: CO_MEMBER,
|
|
315
|
+
tenantId: TENANT,
|
|
316
|
+
roles: ["Member"],
|
|
317
|
+
by: createSystemUser(OTHER_TENANT),
|
|
318
|
+
});
|
|
319
|
+
const deleteResult = await membershipExecutor.delete(
|
|
320
|
+
{ id: created.id },
|
|
321
|
+
createSystemUser(OTHER_TENANT),
|
|
322
|
+
createTenantDb(stack.db, TENANT, "system"),
|
|
323
|
+
);
|
|
324
|
+
if (!deleteResult.isSuccess) {
|
|
325
|
+
throw new Error(`test setup: co-member delete failed: ${deleteResult.error.code}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const result = await runForgetCleanup({
|
|
329
|
+
db: stack.db,
|
|
330
|
+
registry: stack.registry,
|
|
331
|
+
now: nowInstant(),
|
|
332
|
+
tenantModel: "single-user",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
expect(result.errors).toHaveLength(0);
|
|
336
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
337
|
+
// Payload-tenant latch must still see 2 historical members.
|
|
338
|
+
expect(await rowCount()).toBe(1);
|
|
339
|
+
});
|
|
302
340
|
});
|
|
303
341
|
|
|
304
342
|
describe("run-forget-cleanup job — real glue-code, not a hand-set tenantModel", () => {
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
import {
|
|
42
42
|
type DbRunner,
|
|
43
43
|
entityEventName,
|
|
44
|
+
executeRawQuery,
|
|
44
45
|
nullBlindIndexesForSubject,
|
|
45
46
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
46
47
|
import {
|
|
@@ -53,7 +54,6 @@ import {
|
|
|
53
54
|
type UserDataDeleteStrategy,
|
|
54
55
|
type UserDataStorageProvider,
|
|
55
56
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
56
|
-
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
57
57
|
import {
|
|
58
58
|
purgeSearchDocumentsForSubject,
|
|
59
59
|
type SearchAdapter,
|
|
@@ -519,12 +519,12 @@ async function runInSubTransaction(
|
|
|
519
519
|
begin?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
|
|
520
520
|
savepoint?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
|
|
521
521
|
};
|
|
522
|
-
// savepoint-FIRST — empirisch (Bun 1.4.0
|
|
523
|
-
// mutually exclusive: eine TransactionSql exposed
|
|
524
|
-
// nur die Top-Level-Connection hat ausschließlich
|
|
525
|
-
// wählte im Tx-Fall das nested BEGIN (Prod-Incident-
|
|
526
|
-
// savepoint-first trifft im Tx-Fall den Savepoint
|
|
527
|
-
// sauber auf begin zurück.
|
|
522
|
+
// savepoint-FIRST — empirisch (Bun 1.3.14, unter 1.4.0 nicht neu geprüft)
|
|
523
|
+
// sind die Flächen NICHT mutually exclusive: eine TransactionSql exposed
|
|
524
|
+
// begin UND savepoint, nur die Top-Level-Connection hat ausschließlich
|
|
525
|
+
// begin. begin-first wählte im Tx-Fall das nested BEGIN (Prod-Incident-
|
|
526
|
+
// Klasse, s. Header); savepoint-first trifft im Tx-Fall den Savepoint
|
|
527
|
+
// und fällt top-level sauber auf begin zurück.
|
|
528
528
|
const open = runner.savepoint ?? runner.begin;
|
|
529
529
|
if (!open) {
|
|
530
530
|
throw new Error(
|
|
@@ -570,26 +570,36 @@ async function resolveEffectiveTenantModel(
|
|
|
570
570
|
// enough: a tenant that ever had >1 distinct member must stay multi-user for
|
|
571
571
|
// the forget path even after everyone but one is removed, or the sole
|
|
572
572
|
// remaining member's forget request wipes the departed co-member's leftover
|
|
573
|
-
// rows.
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
573
|
+
// rows.
|
|
574
|
+
//
|
|
575
|
+
// Filter by payload->>'tenantId' (not the event column tenant_id): cross-
|
|
576
|
+
// tenant SystemAdmin adds land under the actor's tenant_id (#2347). SQL
|
|
577
|
+
// aggregation stays fail-safe when userId payloads are unreadable: more
|
|
578
|
+
// created-rows than distinct userIds ⇒ treat as multi-user.
|
|
577
579
|
async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise<boolean> {
|
|
578
|
-
const
|
|
580
|
+
const eventType = entityEventName("tenant-membership", "created");
|
|
581
|
+
const rows = await executeRawQuery<{
|
|
582
|
+
event_count: number;
|
|
583
|
+
distinct_users: number;
|
|
584
|
+
}>(
|
|
579
585
|
db,
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
if (createdEvents.length >= MAX_MEMBERSHIP_CREATED_EVENTS) return true;
|
|
589
|
-
const distinctUserIds = new Set(
|
|
590
|
-
createdEvents.map((e) => e.payload["userId"]).filter((v): v is string => typeof v === "string"),
|
|
586
|
+
`SELECT
|
|
587
|
+
COUNT(*)::int AS event_count,
|
|
588
|
+
COUNT(DISTINCT payload->>'userId')::int AS distinct_users
|
|
589
|
+
FROM kumiko_events
|
|
590
|
+
WHERE aggregate_type = 'tenant-membership'
|
|
591
|
+
AND type = $1
|
|
592
|
+
AND payload->>'tenantId' = $2`,
|
|
593
|
+
[eventType, tenantId],
|
|
591
594
|
);
|
|
592
|
-
|
|
595
|
+
const row = rows[0];
|
|
596
|
+
if (!row) return false;
|
|
597
|
+
if (row.distinct_users > 1) return true;
|
|
598
|
+
// Unreadable / missing userId payloads: fail-safe to multi-user
|
|
599
|
+
// (including a lone created-event with no readable userId).
|
|
600
|
+
if (row.event_count >= 1 && row.distinct_users < 1) return true;
|
|
601
|
+
if (row.event_count > 1 && row.distinct_users <= 1) return true;
|
|
602
|
+
return false;
|
|
593
603
|
}
|
|
594
604
|
|
|
595
605
|
// Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.
|
|
@@ -273,6 +273,29 @@ describe("PrivacyCenterScreen", () => {
|
|
|
273
273
|
assign.mockRestore();
|
|
274
274
|
}
|
|
275
275
|
});
|
|
276
|
+
|
|
277
|
+
test("Download-Button does not navigate when downloadByJob returns no url", async () => {
|
|
278
|
+
const assign = spyOn(window.location, "assign").mockImplementation(() => {});
|
|
279
|
+
try {
|
|
280
|
+
const { view } = renderCenter({
|
|
281
|
+
me: activeMe,
|
|
282
|
+
downloadUrl: undefined,
|
|
283
|
+
exportStatus: {
|
|
284
|
+
hasJob: true,
|
|
285
|
+
job: { id: "job-123", status: EXPORT_JOB_STATUS.Done, expiresAt: "2026-07-11T00:00:00Z" },
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
await waitForDownloadReady(view);
|
|
289
|
+
fireEvent.click(view.getByTestId("privacy-export-download"));
|
|
290
|
+
// Banner uses t(i18nKey) — assert the resolved EN copy, not the raw key.
|
|
291
|
+
await waitFor(() => {
|
|
292
|
+
expect(view.container.textContent).toContain("Download unavailable — please try again.");
|
|
293
|
+
});
|
|
294
|
+
expect(assign).not.toHaveBeenCalled();
|
|
295
|
+
} finally {
|
|
296
|
+
assign.mockRestore();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
276
299
|
});
|
|
277
300
|
|
|
278
301
|
describe("QN-Drift-Pins (client-Konstanten vs. Feature-Originale)", () => {
|
|
@@ -387,7 +387,7 @@ export function PrivacyCenterScreen({
|
|
|
387
387
|
};
|
|
388
388
|
|
|
389
389
|
return (
|
|
390
|
-
<FormScreenShell className="flex flex-col gap-6" testId="privacy-center-screen">
|
|
390
|
+
<FormScreenShell className="flex flex-col gap-6" testId="privacy-center-screen" maxWidth="3xl">
|
|
391
391
|
<Heading variant="page">{t("userDataRights.privacyCenter.title")}</Heading>
|
|
392
392
|
<p className="text-sm text-muted-foreground">{t("userDataRights.privacyCenter.intro")}</p>
|
|
393
393
|
<ExportSection />
|
|
@@ -361,7 +361,7 @@ export function ProfileScreen(): ReactNode {
|
|
|
361
361
|
};
|
|
362
362
|
|
|
363
363
|
return (
|
|
364
|
-
<FormScreenShell className="flex flex-col gap-6" testId="profile-screen">
|
|
364
|
+
<FormScreenShell className="flex flex-col gap-6" testId="profile-screen" maxWidth="3xl">
|
|
365
365
|
<Heading variant="page">{t("profile.title")}</Heading>
|
|
366
366
|
{/* Die zwei kurzen Konto-Forms teilen sich eine Reihe (md+); die
|
|
367
367
|
Danger-Zone bleibt volle Breite darunter. */}
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
// @runtime client
|
|
2
|
-
import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
5
|
-
import { FeatureToggleHandlers, FeatureToggleQueries } from "../constants";
|
|
6
|
-
|
|
7
|
-
type ToggleRow = {
|
|
8
|
-
readonly name: string;
|
|
9
|
-
readonly toggleable: boolean;
|
|
10
|
-
readonly default: boolean | null;
|
|
11
|
-
readonly override: boolean | null;
|
|
12
|
-
readonly effective: boolean | null;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
type RegisteredResponse = {
|
|
16
|
-
readonly items: readonly ToggleRow[];
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
type State =
|
|
20
|
-
| { readonly kind: "loading" }
|
|
21
|
-
| { readonly kind: "error"; readonly message: string }
|
|
22
|
-
| { readonly kind: "ready"; readonly items: readonly ToggleRow[] };
|
|
23
|
-
|
|
24
|
-
export function ToggleAdminScreen(): ReactNode {
|
|
25
|
-
const t = useTranslation();
|
|
26
|
-
const { Banner, Card, DataTable, Heading, Text } = usePrimitives();
|
|
27
|
-
const dispatcher = useDispatcher();
|
|
28
|
-
const [state, setState] = useState<State>({ kind: "loading" });
|
|
29
|
-
const [actionError, setActionError] = useState<string | null>(null);
|
|
30
|
-
|
|
31
|
-
const load = useCallback(async (): Promise<void> => {
|
|
32
|
-
setState({ kind: "loading" });
|
|
33
|
-
const res = await dispatcher.query<RegisteredResponse>(FeatureToggleQueries.registered, {});
|
|
34
|
-
if (!res.isSuccess) {
|
|
35
|
-
setState({ kind: "error", message: res.error.message });
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
setState({ kind: "ready", items: res.data.items });
|
|
39
|
-
}, [dispatcher]);
|
|
40
|
-
|
|
41
|
-
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
void load();
|
|
44
|
-
}, [load]);
|
|
45
|
-
|
|
46
|
-
const onSet = async (featureName: string, enabled: boolean): Promise<void> => {
|
|
47
|
-
setActionError(null);
|
|
48
|
-
const res = await dispatcher.write(FeatureToggleHandlers.set, { featureName, enabled });
|
|
49
|
-
if (!res.isSuccess) {
|
|
50
|
-
setActionError(res.error.message);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
await load();
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
if (state.kind === "loading") {
|
|
57
|
-
return (
|
|
58
|
-
<FormScreenShell testId="toggle-admin-screen">
|
|
59
|
-
<Text variant="small">{t("feature-toggles.admin.loading")}</Text>
|
|
60
|
-
</FormScreenShell>
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (state.kind === "error") {
|
|
65
|
-
return (
|
|
66
|
-
<FormScreenShell testId="toggle-admin-screen">
|
|
67
|
-
<Banner variant="error">{state.message}</Banner>
|
|
68
|
-
</FormScreenShell>
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return (
|
|
73
|
-
<FormScreenShell testId="toggle-admin-screen" maxWidth="4xl" className="flex flex-col gap-6">
|
|
74
|
-
<Heading variant="page">{t("feature-toggles.admin.title")}</Heading>
|
|
75
|
-
|
|
76
|
-
{actionError !== null && <Banner variant="error">{actionError}</Banner>}
|
|
77
|
-
|
|
78
|
-
<Card options={{ padded: false }}>
|
|
79
|
-
<DataTable
|
|
80
|
-
testId="toggle-admin-table"
|
|
81
|
-
columns={[
|
|
82
|
-
{
|
|
83
|
-
field: "feature",
|
|
84
|
-
label: t("feature-toggles.admin.col.feature"),
|
|
85
|
-
type: "string",
|
|
86
|
-
sortable: false,
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
field: "default",
|
|
90
|
-
label: t("feature-toggles.admin.col.default"),
|
|
91
|
-
type: "string",
|
|
92
|
-
sortable: false,
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
field: "override",
|
|
96
|
-
label: t("feature-toggles.admin.col.override"),
|
|
97
|
-
type: "string",
|
|
98
|
-
sortable: false,
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
field: "effective",
|
|
102
|
-
label: t("feature-toggles.admin.col.effective"),
|
|
103
|
-
type: "string",
|
|
104
|
-
sortable: false,
|
|
105
|
-
},
|
|
106
|
-
]}
|
|
107
|
-
rows={state.items.map((row) => ({
|
|
108
|
-
id: row.name,
|
|
109
|
-
values: {
|
|
110
|
-
feature: row.name,
|
|
111
|
-
default: formatFlag(row.default),
|
|
112
|
-
override: formatFlag(row.override),
|
|
113
|
-
effective: formatFlag(row.effective),
|
|
114
|
-
toggleable: row.toggleable,
|
|
115
|
-
effectiveBool: row.effective ?? row.default ?? false,
|
|
116
|
-
},
|
|
117
|
-
}))}
|
|
118
|
-
rowActions={[
|
|
119
|
-
{
|
|
120
|
-
id: "toggle",
|
|
121
|
-
label: t("feature-toggles.admin.toggle"),
|
|
122
|
-
style: "secondary",
|
|
123
|
-
isVisible: (row) => row.values["toggleable"] === true,
|
|
124
|
-
onTrigger: (row) => void onSet(row.id, !(row.values["effectiveBool"] as boolean)),
|
|
125
|
-
},
|
|
126
|
-
]}
|
|
127
|
-
rowActionMode="inline"
|
|
128
|
-
/>
|
|
129
|
-
</Card>
|
|
130
|
-
</FormScreenShell>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function formatFlag(value: boolean | null): string {
|
|
135
|
-
if (value === null) return "inherit";
|
|
136
|
-
return value ? "on" : "off";
|
|
137
|
-
}
|