@cosmicdrift/kumiko-bundled-features 0.125.2 → 0.127.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 +7 -6
- package/src/audit/__tests__/audit-security.integration.test.ts +50 -0
- package/src/audit/i18n.ts +9 -0
- package/src/audit/web/audit-log-screen.tsx +112 -2
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +206 -2
- package/src/billing-foundation/entities.ts +28 -4
- package/src/billing-foundation/events.ts +6 -2
- package/src/billing-foundation/feature.ts +9 -1
- package/src/billing-foundation/get-subscription-for-tenant.ts +16 -5
- package/src/billing-foundation/handlers/create-portal-session.write.ts +17 -1
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +15 -1
- package/src/billing-foundation/handlers/process-event.write.ts +39 -3
- package/src/billing-foundation/tenant-destroy-hook.ts +47 -0
- package/src/cap-counter/entity.ts +1 -1
- package/src/cap-counter/feature.ts +10 -2
- package/src/cap-counter/i18n.ts +12 -0
- package/src/compliance-profiles/feature.ts +3 -0
- package/src/compliance-profiles/i18n.ts +6 -0
- package/src/config/feature.ts +3 -0
- package/src/config/i18n.ts +9 -0
- package/src/delivery/feature.ts +3 -0
- package/src/delivery/i18n.ts +6 -0
- package/src/feature-toggles/feature.ts +3 -0
- package/src/feature-toggles/i18n.ts +6 -0
- package/src/jobs/i18n.ts +4 -0
- package/src/jobs/web/job-runs-screen.tsx +9 -9
- package/src/managed-pages/i18n.ts +5 -0
- package/src/managed-pages/screens/page-screens.ts +5 -1
- package/src/personal-access-tokens/feature.ts +2 -0
- package/src/personal-access-tokens/i18n.ts +5 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +7 -3
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +12 -1
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +9 -0
- package/src/subscription-stripe/feature.ts +2 -0
- package/src/tags/feature.ts +2 -0
- package/src/tags/i18n.ts +5 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +7 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +20 -5
- package/src/tenant/feature.ts +4 -1
- package/src/tenant/handlers/members.query.ts +19 -4
- package/src/tenant/i18n.ts +28 -0
- package/src/tenant/index.ts +7 -2
- package/src/tenant/schema/tenant.ts +27 -2
- package/src/tenant/screens.ts +3 -2
- package/src/tenant/web/members-screen.tsx +8 -3
- package/src/tenant-lifecycle/__tests__/stages.test.ts +22 -0
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +469 -0
- package/src/tenant-lifecycle/constants.ts +56 -0
- package/src/tenant-lifecycle/events.ts +46 -0
- package/src/tenant-lifecycle/feature.ts +105 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +81 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +83 -0
- package/src/tenant-lifecycle/index.ts +7 -0
- package/src/tenant-lifecycle/lib/revoke-tenant-sessions.ts +16 -0
- package/src/tenant-lifecycle/lifecycle-gate.ts +58 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +368 -0
- package/src/tenant-lifecycle/stages.ts +206 -0
- package/src/tier-engine/feature.ts +2 -0
- package/src/tier-engine/i18n.ts +8 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +20 -14
- package/src/user/feature.ts +3 -0
- package/src/user/i18n.ts +20 -0
- package/src/user/schema/user.ts +1 -0
- package/src/user/screens.ts +3 -15
- package/src/user-data-rights/feature.ts +17 -0
- package/src/user-data-rights/i18n.ts +32 -33
- package/src/user-data-rights/schema/export-job.ts +2 -0
- package/src/user-data-rights/screens.ts +2 -1
- package/src/user-data-rights/web/i18n.ts +9 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { configuredPiiSubjectKms, type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
|
+
import {
|
|
4
|
+
createEventStoreExecutor,
|
|
5
|
+
createTenantDb,
|
|
6
|
+
type DbRunner,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import {
|
|
9
|
+
createSystemUser,
|
|
10
|
+
EXT_EXTERNAL_RESOURCE,
|
|
11
|
+
EXT_INFRA_RESOURCE,
|
|
12
|
+
EXT_SEARCH_ADAPTER,
|
|
13
|
+
EXT_STORAGE_PROVIDER,
|
|
14
|
+
EXT_TENANT_DATA,
|
|
15
|
+
type Registry,
|
|
16
|
+
type TenantId,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
19
|
+
import {
|
|
20
|
+
tenantEntity,
|
|
21
|
+
tenantMembershipEntity,
|
|
22
|
+
tenantMembershipsTable,
|
|
23
|
+
tenantTable,
|
|
24
|
+
} from "../tenant";
|
|
25
|
+
import type { TenantDestructionStageName } from "./constants";
|
|
26
|
+
import { invalidateTenantLifecycleGate } from "./lifecycle-gate";
|
|
27
|
+
|
|
28
|
+
export type DestructionStageCtx = {
|
|
29
|
+
readonly db: DbRunner;
|
|
30
|
+
readonly registry: Registry;
|
|
31
|
+
readonly tenantId: TenantId;
|
|
32
|
+
readonly log?: (message: string) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type DestructionStage = {
|
|
36
|
+
readonly name: TenantDestructionStageName;
|
|
37
|
+
readonly maxAttempts: number;
|
|
38
|
+
readonly run: (ctx: DestructionStageCtx) => Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
42
|
+
const tenantMembershipCrud = createEventStoreExecutor(
|
|
43
|
+
tenantMembershipsTable,
|
|
44
|
+
tenantMembershipEntity,
|
|
45
|
+
{
|
|
46
|
+
entityName: "tenant-membership",
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
async function runExtensionDestroyHooks(
|
|
51
|
+
registry: Registry,
|
|
52
|
+
extensionName: string,
|
|
53
|
+
hookKey: "destroyTenant",
|
|
54
|
+
ctx: DestructionStageCtx,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const usages = registry.getExtensionUsages(extensionName);
|
|
57
|
+
for (const usage of usages) {
|
|
58
|
+
const hook = usage.options?.[hookKey];
|
|
59
|
+
if (typeof hook !== "function") continue;
|
|
60
|
+
await (hook as (tenantId: TenantId, hookCtx: DestructionStageCtx) => Promise<void>)(
|
|
61
|
+
ctx.tenantId,
|
|
62
|
+
ctx,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
|
|
68
|
+
const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
|
|
69
|
+
for (const usage of usages) {
|
|
70
|
+
const destroy = usage.options?.["destroy"] as
|
|
71
|
+
| ((ctx: DestructionStageCtx) => Promise<void>)
|
|
72
|
+
| undefined;
|
|
73
|
+
if (!destroy) continue;
|
|
74
|
+
await destroy(ctx);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
|
|
79
|
+
const kms = configuredPiiSubjectKms();
|
|
80
|
+
if (!kms) {
|
|
81
|
+
ctx.log?.("[tenant-lifecycle] subject-keys stage skipped: no KMS adapter configured");
|
|
82
|
+
// skip: KMS optional — apps without crypto-shredding still run other destroy stages
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const memberships = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {
|
|
86
|
+
tenantId: ctx.tenantId,
|
|
87
|
+
});
|
|
88
|
+
const subjects: SubjectId[] = [
|
|
89
|
+
{ kind: "tenant", tenantId: ctx.tenantId },
|
|
90
|
+
...memberships.map((m) => ({ kind: "user" as const, userId: m.userId })),
|
|
91
|
+
];
|
|
92
|
+
for (const subject of subjects) {
|
|
93
|
+
await kms.eraseKey(subject, {
|
|
94
|
+
requestId: `tenant-lifecycle:destroy:${ctx.tenantId}`,
|
|
95
|
+
eraseReason: "tenant-destroy stage subject-keys",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function purgeTenantCache(ctx: DestructionStageCtx): Promise<void> {
|
|
101
|
+
// ponytail: Redis SCAN+DEL is wired when ctx carries a redis client; until
|
|
102
|
+
// then this stage is a documented no-op (no cache layer in test stack).
|
|
103
|
+
ctx.log?.("[tenant-lifecycle] cache stage: no redis client in ctx — skipped");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
|
|
107
|
+
const now = getTemporal().Now.instant();
|
|
108
|
+
const user = createSystemUser(ctx.tenantId);
|
|
109
|
+
const db = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
110
|
+
// Per-row forget() through the executor, not a bulk deleteMany: memberships
|
|
111
|
+
// are an ES-managed projection (add/remove/update-roles all go through
|
|
112
|
+
// tenantMembershipCrud), so a raw table write here is eventless — a future
|
|
113
|
+
// projection rebuild would replay the historical add-member events and
|
|
114
|
+
// resurrect rows this stage removed. forget() (Art.17 hard-purge) keeps the
|
|
115
|
+
// erasure rebuild-safe and gives each membership its own audit event.
|
|
116
|
+
const memberships = await selectMany<{ id: string }>(ctx.db, tenantMembershipsTable, {
|
|
117
|
+
tenantId: ctx.tenantId,
|
|
118
|
+
});
|
|
119
|
+
for (const membership of memberships) {
|
|
120
|
+
const result = await tenantMembershipCrud.forget({ id: membership.id }, user, db);
|
|
121
|
+
// executor writes return {isSuccess:false} on failure, they don't throw —
|
|
122
|
+
// a silently-discarded result here would report this stage "succeeded"
|
|
123
|
+
// while membership PII survives. Throw so the pipeline's retry/abandon
|
|
124
|
+
// handling (runNextDestructionStage) sees it instead.
|
|
125
|
+
if (!result.isSuccess) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`tenant-lifecycle: failed to forget membership ${membership.id} for tenant ${ctx.tenantId}: ${result.error.message}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
await tenantCrud.update(
|
|
132
|
+
{
|
|
133
|
+
id: ctx.tenantId,
|
|
134
|
+
changes: {
|
|
135
|
+
status: "destroyed",
|
|
136
|
+
destroyedAt: now,
|
|
137
|
+
isEnabled: false,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
user,
|
|
141
|
+
db,
|
|
142
|
+
{ skipOptimisticLock: true },
|
|
143
|
+
);
|
|
144
|
+
invalidateTenantLifecycleGate(ctx.tenantId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const DESTRUCTION_STAGES: readonly DestructionStage[] = [
|
|
148
|
+
{
|
|
149
|
+
name: "external-resources",
|
|
150
|
+
maxAttempts: 3,
|
|
151
|
+
run: (ctx) =>
|
|
152
|
+
runExtensionDestroyHooks(ctx.registry, EXT_EXTERNAL_RESOURCE, "destroyTenant", ctx),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "search-indices",
|
|
156
|
+
maxAttempts: 3,
|
|
157
|
+
run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_SEARCH_ADAPTER, "destroyTenant", ctx),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "cache",
|
|
161
|
+
maxAttempts: 1,
|
|
162
|
+
run: purgeTenantCache,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "app-data",
|
|
166
|
+
maxAttempts: 3,
|
|
167
|
+
run: runTenantDataHooks,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "subject-keys",
|
|
171
|
+
maxAttempts: 3,
|
|
172
|
+
run: eraseSubjectKeys,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "files",
|
|
176
|
+
maxAttempts: 3,
|
|
177
|
+
run: (ctx) =>
|
|
178
|
+
runExtensionDestroyHooks(ctx.registry, EXT_STORAGE_PROVIDER, "destroyTenant", ctx),
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "infra-resources",
|
|
182
|
+
maxAttempts: 3,
|
|
183
|
+
run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_INFRA_RESOURCE, "destroyTenant", ctx),
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "tenant-row",
|
|
187
|
+
maxAttempts: 1,
|
|
188
|
+
run: tombstoneTenantRow,
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
export function pickNextStage(
|
|
193
|
+
completedStages: ReadonlySet<string>,
|
|
194
|
+
abandonedStages: ReadonlySet<string>,
|
|
195
|
+
): DestructionStage | null {
|
|
196
|
+
if (abandonedStages.size > 0) return null;
|
|
197
|
+
for (const stage of DESTRUCTION_STAGES) {
|
|
198
|
+
if (completedStages.has(stage.name)) continue;
|
|
199
|
+
return stage;
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function isDestructionPipelineComplete(completedStages: ReadonlySet<string>): boolean {
|
|
205
|
+
return DESTRUCTION_STAGES.every((stage) => completedStages.has(stage.name));
|
|
206
|
+
}
|
|
@@ -73,6 +73,7 @@ import { tierAssignmentEntity } from "./entity";
|
|
|
73
73
|
import { getActiveTierQuery } from "./handlers/active-tier.query";
|
|
74
74
|
import { getTenantTierQuery } from "./handlers/get-tenant-tier.query";
|
|
75
75
|
import { createSetTenantTierWrite } from "./handlers/set-tenant-tier.write";
|
|
76
|
+
import { TIER_ENGINE_I18N } from "./i18n";
|
|
76
77
|
import { isTrialActive, type TrialPolicy } from "./trial";
|
|
77
78
|
|
|
78
79
|
// Drizzle-table for the tier-assignment-entity. Built once at module-load
|
|
@@ -234,6 +235,7 @@ export function createTierEngineFeature<
|
|
|
234
235
|
// verdrahtet (Platform-Admin-Hoheit, nicht App-konfigurierbar). App
|
|
235
236
|
// platziert ihn nur via r.nav("tier-engine:screen:tier-admin"); die
|
|
236
237
|
// Komponente liefert tierEngineClient() aus dem ./web-subpath.
|
|
238
|
+
r.translations({ keys: TIER_ENGINE_I18N });
|
|
237
239
|
r.screen({
|
|
238
240
|
id: TIER_ADMIN_SCREEN_ID,
|
|
239
241
|
type: "custom",
|
package/src/tier-engine/i18n.ts
CHANGED
|
@@ -5,8 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
7
7
|
|
|
8
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
9
|
+
|
|
10
|
+
export const TIER_ENGINE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
11
|
+
"screen:tier-admin.title": { de: "Tier manuell zuweisen", en: "Assign tier manually" },
|
|
12
|
+
};
|
|
13
|
+
|
|
8
14
|
export const defaultTranslations: TranslationsByLocale = {
|
|
9
15
|
de: {
|
|
16
|
+
"screen:tier-admin.title": "Tier manuell zuweisen",
|
|
10
17
|
"tier-admin.title": "Tier manuell zuweisen",
|
|
11
18
|
"tier-admin.explainer":
|
|
12
19
|
"Weise einem Tenant ein Tier ohne Kauf zu. Der Grant wird als „manuell“ markiert und von einem späteren Billing-Sync nicht überschrieben.",
|
|
@@ -22,6 +29,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
22
29
|
"Diese App hat keine TierMap konfiguriert — es gibt keine zuweisbaren Tiers.",
|
|
23
30
|
},
|
|
24
31
|
en: {
|
|
32
|
+
"screen:tier-admin.title": "Assign tier manually",
|
|
25
33
|
"tier-admin.title": "Assign tier manually",
|
|
26
34
|
"tier-admin.explainer":
|
|
27
35
|
"Grant a tenant a tier without a purchase. The grant is marked as “manual” and a later billing sync won't overwrite it.",
|
|
@@ -37,7 +37,7 @@ type Status =
|
|
|
37
37
|
|
|
38
38
|
export function TierAdminScreen(): ReactNode {
|
|
39
39
|
const t = useTranslation();
|
|
40
|
-
const {
|
|
40
|
+
const { Field, Input, Button, Banner, Form, Text } = usePrimitives();
|
|
41
41
|
const dispatcher = useDispatcher();
|
|
42
42
|
|
|
43
43
|
// ponytail: nur die erste Seite (default-limit, nextCursor ignoriert) —
|
|
@@ -92,8 +92,7 @@ export function TierAdminScreen(): ReactNode {
|
|
|
92
92
|
const canSubmit = tenantId !== "" && tier !== "" && !submitting;
|
|
93
93
|
|
|
94
94
|
return (
|
|
95
|
-
<FormScreenShell testId="tier-admin-screen" className="flex
|
|
96
|
-
<Heading variant="page">{t("tier-admin.title")}</Heading>
|
|
95
|
+
<FormScreenShell testId="tier-admin-screen" className="flex flex-col gap-6">
|
|
97
96
|
<Text variant="small">{t("tier-admin.explainer")}</Text>
|
|
98
97
|
|
|
99
98
|
{tenantsQuery.error !== null && (
|
|
@@ -107,7 +106,23 @@ export function TierAdminScreen(): ReactNode {
|
|
|
107
106
|
</Banner>
|
|
108
107
|
)}
|
|
109
108
|
|
|
110
|
-
<
|
|
109
|
+
<Form
|
|
110
|
+
testId="tier-admin-form"
|
|
111
|
+
onSubmit={(e) => {
|
|
112
|
+
e?.preventDefault();
|
|
113
|
+
void onSave();
|
|
114
|
+
}}
|
|
115
|
+
actions={
|
|
116
|
+
<Button
|
|
117
|
+
type="submit"
|
|
118
|
+
disabled={!canSubmit}
|
|
119
|
+
loading={submitting}
|
|
120
|
+
testId="tier-admin-submit"
|
|
121
|
+
>
|
|
122
|
+
{t("tier-admin.submit")}
|
|
123
|
+
</Button>
|
|
124
|
+
}
|
|
125
|
+
>
|
|
111
126
|
<Field id="tier-admin-tenant" label={t("tier-admin.tenant.label")} required>
|
|
112
127
|
<Input
|
|
113
128
|
kind="select"
|
|
@@ -147,16 +162,7 @@ export function TierAdminScreen(): ReactNode {
|
|
|
147
162
|
{t(status.messageKey)}
|
|
148
163
|
</Banner>
|
|
149
164
|
)}
|
|
150
|
-
|
|
151
|
-
<Button
|
|
152
|
-
onClick={() => void onSave()}
|
|
153
|
-
disabled={!canSubmit}
|
|
154
|
-
loading={submitting}
|
|
155
|
-
testId="tier-admin-submit"
|
|
156
|
-
>
|
|
157
|
-
{t("tier-admin.submit")}
|
|
158
|
-
</Button>
|
|
159
|
-
</Section>
|
|
165
|
+
</Form>
|
|
160
166
|
</FormScreenShell>
|
|
161
167
|
);
|
|
162
168
|
}
|
package/src/user/feature.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { findForAuthQuery } from "./handlers/find-for-auth.query";
|
|
|
5
5
|
import { listQuery } from "./handlers/list.query";
|
|
6
6
|
import { meQuery } from "./handlers/me.query";
|
|
7
7
|
import { updateWrite } from "./handlers/update.write";
|
|
8
|
+
import { USER_I18N } from "./i18n";
|
|
8
9
|
import { userEntity } from "./schema/user";
|
|
9
10
|
import { userEditScreen, userListScreen } from "./screens";
|
|
10
11
|
|
|
@@ -43,6 +44,8 @@ export function createUserFeature(): FeatureDefinition {
|
|
|
43
44
|
r.screen(userListScreen);
|
|
44
45
|
r.screen(userEditScreen);
|
|
45
46
|
|
|
47
|
+
r.translations({ keys: USER_I18N });
|
|
48
|
+
|
|
46
49
|
return { handlers, queries };
|
|
47
50
|
});
|
|
48
51
|
}
|
package/src/user/i18n.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
2
|
+
|
|
3
|
+
export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"screen:user-list.title": { de: "Benutzer", en: "Users" },
|
|
5
|
+
"screen:user-edit.title": { de: "Benutzer bearbeiten", en: "Edit user" },
|
|
6
|
+
"user:entity:user:field:email": { de: "E-Mail", en: "Email" },
|
|
7
|
+
"user:entity:user:field:displayName": { de: "Anzeigename", en: "Display name" },
|
|
8
|
+
"user:entity:user:field:status": { de: "Status", en: "Status" },
|
|
9
|
+
"user:entity:user:field:emailVerified": { de: "E-Mail bestätigt", en: "Email verified" },
|
|
10
|
+
"user:entity:user:field:locale": { de: "Sprache", en: "Locale" },
|
|
11
|
+
"user:entity:user:field:status:option:active": { de: "Aktiv", en: "Active" },
|
|
12
|
+
"user:entity:user:field:status:option:restricted": { de: "Eingeschränkt", en: "Restricted" },
|
|
13
|
+
"user:entity:user:field:status:option:deletionRequested": {
|
|
14
|
+
de: "Löschung angefordert",
|
|
15
|
+
en: "Deletion requested",
|
|
16
|
+
},
|
|
17
|
+
"user:entity:user:field:status:option:deleted": { de: "Gelöscht", en: "Deleted" },
|
|
18
|
+
"user:entity:user:field:emailVerified:option:true": { de: "Ja", en: "Yes" },
|
|
19
|
+
"user:entity:user:field:emailVerified:option:false": { de: "Nein", en: "No" },
|
|
20
|
+
};
|
package/src/user/schema/user.ts
CHANGED
package/src/user/screens.ts
CHANGED
|
@@ -7,10 +7,6 @@ import type {
|
|
|
7
7
|
// feature runs with `r.systemScope()`, the entityList query returns every
|
|
8
8
|
// user across all tenants — the SystemAdmin platform roster. Both screens are
|
|
9
9
|
// SystemAdmin-gated and stay inert until an app navs them (no auto-nav).
|
|
10
|
-
//
|
|
11
|
-
// Field labels come from the renderer's humanizeSlug fallback (no i18n keys
|
|
12
|
-
// registered) — "Display Name", "Email Verified" etc. Apps can override via
|
|
13
|
-
// their own translations under the `user:entity:user:field:*` convention.
|
|
14
10
|
|
|
15
11
|
export const userListScreen: EntityListScreenDefinition = {
|
|
16
12
|
id: "user-list",
|
|
@@ -26,9 +22,9 @@ export const userListScreen: EntityListScreenDefinition = {
|
|
|
26
22
|
entityId: "id",
|
|
27
23
|
},
|
|
28
24
|
],
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
searchable:
|
|
25
|
+
defaultSort: { field: "status", dir: "asc" },
|
|
26
|
+
// ponytail: screen searchable but email is encrypted PII — list search uses non-encrypted columns only.
|
|
27
|
+
searchable: true,
|
|
32
28
|
access: { roles: ["SystemAdmin"] },
|
|
33
29
|
};
|
|
34
30
|
|
|
@@ -44,14 +40,6 @@ export const userEditScreen: EntityEditScreenDefinition = {
|
|
|
44
40
|
},
|
|
45
41
|
],
|
|
46
42
|
},
|
|
47
|
-
// `roles` is deliberately NOT editable here: it is a raw JSON text column
|
|
48
|
-
// (`["SystemAdmin"]`) — a free-text input would let a typo corrupt the
|
|
49
|
-
// privilege column on a live platform. Role management needs a dedicated
|
|
50
|
-
// surface; the list still shows status for triage.
|
|
51
|
-
//
|
|
52
|
-
// Create dispatches user:write:user:create (email + displayName required —
|
|
53
|
-
// both in the form). Delete is suppressed: there is no user:write:user:delete
|
|
54
|
-
// — user removal is the GDPR status/forget flow, not a hard delete.
|
|
55
43
|
allowDelete: false,
|
|
56
44
|
access: { roles: ["SystemAdmin"] },
|
|
57
45
|
};
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { requestExportWrite } from "./handlers/request-export.write";
|
|
29
29
|
import { restrictAccountWrite } from "./handlers/restrict-account.write";
|
|
30
30
|
import { createRunForgetCleanupHandler } from "./handlers/run-forget-cleanup.write";
|
|
31
|
+
import { USER_DATA_RIGHTS_I18N } from "./i18n";
|
|
31
32
|
import {
|
|
32
33
|
type GdprMailDefaults,
|
|
33
34
|
isMailTransportAvailable,
|
|
@@ -257,6 +258,22 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
257
258
|
r.screen(exportJobDetailScreen);
|
|
258
259
|
r.screen(downloadAttemptListScreen);
|
|
259
260
|
|
|
261
|
+
r.nav({
|
|
262
|
+
id: "export-job-list",
|
|
263
|
+
label: "user-data-rights:nav.exportJobs",
|
|
264
|
+
icon: "download",
|
|
265
|
+
screen: "user-data-rights:screen:export-job-list",
|
|
266
|
+
order: 25,
|
|
267
|
+
access: { roles: ["SystemAdmin"] },
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
r.translations({
|
|
271
|
+
keys: {
|
|
272
|
+
...USER_DATA_RIGHTS_I18N,
|
|
273
|
+
"user-data-rights:nav.exportJobs": { de: "DSGVO-Exporte", en: "GDPR exports" },
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
260
277
|
// Dormant Self-Service-Screen (Art. 15/17/18/20): Export, Aktivitäts-
|
|
261
278
|
// protokoll, Einschränkung, Löschung in einem Screen. Kein r.nav — die
|
|
262
279
|
// App platziert ihn im eingeloggten Bereich. Die React-Component kommt
|
|
@@ -1,37 +1,36 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Default-Translations fuer user-data-rights Error-Wire-Keys (S2.U3 Atom 4b.fix3).
|
|
4
|
-
//
|
|
5
|
-
// Wire-Errors aus den download-handlers tragen i18nKeys statt fixe
|
|
6
|
-
// Strings — UI rendert die Keys ueber den Renderer-LocaleProvider. Apps
|
|
7
|
-
// koennen einzelne Keys via `userDataRightsClient({ translations: { de:
|
|
8
|
-
// {...} } })` ueberschreiben (Pattern matched auth-email-password).
|
|
9
|
-
//
|
|
10
|
-
// **Scope 4b.fix3:** nur die download-error-keys. Andere Keys (UI-
|
|
11
|
-
// Texte fuer Forget-Pfad, Status-Labels, Banner) kommen mit Atom 6+
|
|
12
|
-
// (UI-Integration).
|
|
1
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
13
2
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export
|
|
17
|
-
de:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
3
|
+
export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"screen:export-job-list.title": { de: "DSGVO-Exporte", en: "GDPR exports" },
|
|
5
|
+
"screen:export-job-detail.title": { de: "Export-Job", en: "Export job" },
|
|
6
|
+
"screen:download-attempt-list.title": { de: "Download-Versuche", en: "Download attempts" },
|
|
7
|
+
"screen:privacy-center.title": { de: "Datenschutz", en: "Privacy" },
|
|
8
|
+
"user-data-rights:entity:export-job:field:userId": { de: "Benutzer", en: "User" },
|
|
9
|
+
"user-data-rights:entity:export-job:field:status": { de: "Status", en: "Status" },
|
|
10
|
+
"user-data-rights:entity:export-job:field:requestedAt": { de: "Angefordert", en: "Requested" },
|
|
11
|
+
"user-data-rights:entity:export-job:field:completedAt": { de: "Abgeschlossen", en: "Completed" },
|
|
12
|
+
"user-data-rights:entity:export-job:field:expiresAt": { de: "Läuft ab", en: "Expires" },
|
|
13
|
+
"user-data-rights:entity:export-job:field:requestedFromTenantId": {
|
|
14
|
+
de: "Mandant",
|
|
15
|
+
en: "Tenant",
|
|
16
|
+
},
|
|
17
|
+
"user-data-rights:entity:export-job:field:startedAt": { de: "Gestartet", en: "Started" },
|
|
18
|
+
"user-data-rights:entity:export-job:field:downloadStorageKey": {
|
|
19
|
+
de: "Speicher-Schlüssel",
|
|
20
|
+
en: "Storage key",
|
|
21
|
+
},
|
|
22
|
+
"user-data-rights:entity:export-job:field:bytesWritten": { de: "Bytes", en: "Bytes" },
|
|
23
|
+
"user-data-rights:entity:export-job:field:errorMessage": { de: "Fehler", en: "Error" },
|
|
24
|
+
"user-data-rights:entity:download-attempt:field:attemptedAt": {
|
|
25
|
+
de: "Zeitpunkt",
|
|
26
|
+
en: "Attempted at",
|
|
26
27
|
},
|
|
27
|
-
en:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
"The export is not yet ready or has failed. Please check the status endpoint.",
|
|
34
|
-
"userDataRights.errors.download.signedUrlNotSupported":
|
|
35
|
-
"The download is currently unavailable due to a server configuration issue. The operator has been notified.",
|
|
28
|
+
"user-data-rights:entity:download-attempt:field:result": { de: "Ergebnis", en: "Result" },
|
|
29
|
+
"user-data-rights:entity:download-attempt:field:via": { de: "Via", en: "Via" },
|
|
30
|
+
"user-data-rights:entity:download-attempt:field:ip": { de: "IP", en: "IP" },
|
|
31
|
+
"user-data-rights:entity:download-attempt:field:attemptedByUserId": {
|
|
32
|
+
de: "Benutzer",
|
|
33
|
+
en: "User",
|
|
36
34
|
},
|
|
35
|
+
"user-data-rights:entity:download-attempt:field:jobId": { de: "Job", en: "Job" },
|
|
37
36
|
};
|
|
@@ -73,6 +73,7 @@ export const exportJobEntity = createEntity({
|
|
|
73
73
|
// nur Storage.
|
|
74
74
|
userId: createTextField({
|
|
75
75
|
required: true,
|
|
76
|
+
searchable: true,
|
|
76
77
|
}),
|
|
77
78
|
|
|
78
79
|
// **requestedFromTenantId** — der Tenant aus dem der User den Antrag
|
|
@@ -102,6 +103,7 @@ export const exportJobEntity = createEntity({
|
|
|
102
103
|
// entsteht ohne Klick.
|
|
103
104
|
requestedAt: createTimestampField({
|
|
104
105
|
required: true,
|
|
106
|
+
sortable: true,
|
|
105
107
|
}),
|
|
106
108
|
|
|
107
109
|
// Wann hat der Worker mit dem Pickup angefangen. NULL solange
|
|
@@ -141,5 +141,14 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
141
141
|
"userDataRights.privacyCenter.deletion.dialogTitle": "Delete your account?",
|
|
142
142
|
"userDataRights.privacyCenter.deletion.dialogDescription":
|
|
143
143
|
"Confirming starts the deletion grace period. You can cancel the deletion until it ends.",
|
|
144
|
+
|
|
145
|
+
"userDataRights.errors.download.notFound":
|
|
146
|
+
"The download link is invalid or belongs to a different account.",
|
|
147
|
+
"userDataRights.errors.download.expired":
|
|
148
|
+
"Your download has expired. Please request a new export.",
|
|
149
|
+
"userDataRights.errors.download.unavailable":
|
|
150
|
+
"The export is not yet ready or has failed. Please check the status endpoint.",
|
|
151
|
+
"userDataRights.errors.download.signedUrlNotSupported":
|
|
152
|
+
"The download is currently unavailable due to a server configuration issue. The operator has been notified.",
|
|
144
153
|
},
|
|
145
154
|
};
|