@cosmicdrift/kumiko-bundled-features 0.59.1 → 0.60.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 +2 -1
- package/src/config/__tests__/app-override-visibility.integration.test.ts +6 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +38 -0
- package/src/config/__tests__/inherited-redaction.integration.test.ts +29 -0
- package/src/custom-fields/__tests__/feature.test.ts +57 -4
- package/src/custom-fields/feature.ts +19 -4
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +61 -1
- package/src/files-provider-s3/s3-provider.ts +9 -3
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +92 -1
- package/src/managed-pages/handlers/set.write.ts +14 -4
- package/src/subscription-stripe/__tests__/runtime.test.ts +59 -5
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +105 -0
- package/src/subscription-stripe/feature.ts +2 -1
- package/src/tags/__tests__/feature.test.ts +34 -0
- package/src/tags/__tests__/tags.integration.test.ts +66 -0
- package/src/tags/constants.ts +11 -2
- package/src/tags/feature.ts +26 -21
- package/src/tags/handlers/assign-tag.write.ts +4 -6
- package/src/tags/handlers/create-tag.write.ts +4 -6
- package/src/tags/handlers/remove-tag.write.ts +4 -6
- package/src/tags/index.ts +1 -0
- package/src/tier-engine/__tests__/drift.test.ts +4 -0
- package/src/tier-engine/__tests__/resolver.integration.test.ts +30 -0
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +118 -0
- package/src/tier-engine/constants.ts +13 -0
- package/src/tier-engine/entity.ts +5 -0
- package/src/tier-engine/feature.ts +51 -3
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +36 -0
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +99 -0
- package/src/tier-engine/i18n.ts +39 -0
- package/src/tier-engine/web/client-plugin.tsx +27 -0
- package/src/tier-engine/web/index.ts +8 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +161 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
- package/src/user-data-rights/deletion-token.ts +9 -3
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +22 -3
- package/src/user-profile/__tests__/profile-screen.test.tsx +61 -3
- package/src/user-profile/i18n.ts +2 -3
- package/src/user-profile/web/profile-screen.tsx +29 -5
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
defineEntityListHandler,
|
|
53
53
|
defineEntityUpdateHandler,
|
|
54
54
|
defineFeature,
|
|
55
|
+
defineQueryHandler,
|
|
55
56
|
type FeatureDefinition,
|
|
56
57
|
HookPhases,
|
|
57
58
|
type SessionUser,
|
|
@@ -61,11 +62,14 @@ import {
|
|
|
61
62
|
type TierResolverPlugin,
|
|
62
63
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
63
64
|
import { getAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
|
|
65
|
+
import { z } from "zod";
|
|
64
66
|
import { tierAssignmentAggregateId } from "./aggregate-id";
|
|
65
67
|
import type { TierMap } from "./compose-app";
|
|
66
|
-
import { TIER_ENGINE_FEATURE } from "./constants";
|
|
68
|
+
import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE } from "./constants";
|
|
67
69
|
import { tierAssignmentEntity } from "./entity";
|
|
68
70
|
import { getActiveTierQuery } from "./handlers/active-tier.query";
|
|
71
|
+
import { getTenantTierQuery } from "./handlers/get-tenant-tier.query";
|
|
72
|
+
import { createSetTenantTierWrite } from "./handlers/set-tenant-tier.write";
|
|
69
73
|
|
|
70
74
|
// Drizzle-table for the tier-assignment-entity. Built once at module-load
|
|
71
75
|
// from the entity definition — same shape buildEntityTable would produce
|
|
@@ -168,7 +172,7 @@ export function createTierEngineFeature<
|
|
|
168
172
|
>(opts: CreateTierEngineOptions<TCaps> = {}): FeatureDefinition {
|
|
169
173
|
return defineFeature(TIER_ENGINE_FEATURE, (r) => {
|
|
170
174
|
r.describe(
|
|
171
|
-
|
|
175
|
+
'Stores a `tier-assignment` entity per tenant (which pricing tier is active) and, when configured with a `TierMap`, registers itself as the `tenantTierResolver` extension so the dispatcher automatically gates `r.toggleable()` features per tenant based on their assigned tier. Call `createTierEngineFeature({ defaultTier, tierMap })` to get full tier composition \u2014 including an `inTransaction` entity hook that atomically writes the default tier when a new tenant is created \u2014 or use `createTierEngineFeature()` without options for storage-only mode when you manage tier assignment yourself via `composeApp`. A SystemAdmin-only `set-tenant-tier` write plus `get-tenant-tier`/`tier-options` reads let an operator assign a tier to ANY tenant manually \u2014 without a billing purchase \u2014 stamping `source: "manual"` so a future Stripe\u2192tier sync won\'t overwrite the grant. Apps surface this via the `tier-admin` screen.',
|
|
172
176
|
);
|
|
173
177
|
r.requires("config");
|
|
174
178
|
r.requires("tenant");
|
|
@@ -183,6 +187,41 @@ export function createTierEngineFeature<
|
|
|
183
187
|
r.queryHandler(defineEntityListHandler("tier-assignment", tierAssignmentEntity, adminAccess));
|
|
184
188
|
r.queryHandler(getActiveTierQuery);
|
|
185
189
|
|
|
190
|
+
// \u2500\u2500 Manueller Tier-Grant (SystemAdmin, ohne Billing) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
191
|
+
// Cross-tenant set + read f\u00fcr den tier-admin-Screen. tier-options liefert
|
|
192
|
+
// dem Client die App-Tier-Namen aus der tierMap-Closure (sonst hartkodiert).
|
|
193
|
+
//
|
|
194
|
+
// onAssigned h\u00e4lt den Resolver-Cache nach einem direkten Executor-Write
|
|
195
|
+
// warm (der den postSave-Hook NICHT feuert). Late-bind via Holder: ohne
|
|
196
|
+
// tierMap bleibt es no-op (kein Resolver), im tierMap-Block unten wird
|
|
197
|
+
// die echte Cache-Update-Funktion eingeh\u00e4ngt \u2014 analog alwaysOnHolder.
|
|
198
|
+
const onTierAssigned: { fn: (tenantId: TenantId, tier: string) => void } = { fn: () => {} };
|
|
199
|
+
r.writeHandler(
|
|
200
|
+
createSetTenantTierWrite({
|
|
201
|
+
onAssigned: (tenantId, tier) => onTierAssigned.fn(tenantId, tier),
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
r.queryHandler(getTenantTierQuery);
|
|
205
|
+
r.queryHandler(
|
|
206
|
+
defineQueryHandler({
|
|
207
|
+
name: "tier-options",
|
|
208
|
+
schema: z.object({}),
|
|
209
|
+
access: { roles: ["SystemAdmin"] },
|
|
210
|
+
handler: async () => ({ tiers: opts.tierMap ? Object.keys(opts.tierMap) : [] }),
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Custom React-Screen für den manuellen Grant. SystemAdmin-only fest
|
|
215
|
+
// verdrahtet (Platform-Admin-Hoheit, nicht App-konfigurierbar). App
|
|
216
|
+
// platziert ihn nur via r.nav("tier-engine:screen:tier-admin"); die
|
|
217
|
+
// Komponente liefert tierEngineClient() aus dem ./web-subpath.
|
|
218
|
+
r.screen({
|
|
219
|
+
id: TIER_ADMIN_SCREEN_ID,
|
|
220
|
+
type: "custom",
|
|
221
|
+
renderer: { react: { __component: "TierAdminScreen" } },
|
|
222
|
+
access: { roles: ["SystemAdmin"] },
|
|
223
|
+
});
|
|
224
|
+
|
|
186
225
|
// ───────────────────────────────────────────────────────────────────
|
|
187
226
|
// Resolver-extension (only when tierMap is configured)
|
|
188
227
|
// ───────────────────────────────────────────────────────────────────
|
|
@@ -203,6 +242,15 @@ export function createTierEngineFeature<
|
|
|
203
242
|
// Requests (build läuft pre-listen via runDevApp/runProdApp-pickup).
|
|
204
243
|
const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
|
|
205
244
|
|
|
245
|
+
// set-tenant-tier schreibt direkt über den Executor → der postSave-Hook
|
|
246
|
+
// unten feuert dabei NICHT. Diese Funktion repliziert den Cache-Update
|
|
247
|
+
// des Hooks, damit ein manueller Grant das effektive Feature-Set sofort
|
|
248
|
+
// ändert (nicht nur die Projektion). Selber Cache, selbe mergeAlwaysOn-
|
|
249
|
+
// Semantik wie der Hook.
|
|
250
|
+
onTierAssigned.fn = (tenantId, tier) => {
|
|
251
|
+
cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, tier)));
|
|
252
|
+
};
|
|
253
|
+
|
|
206
254
|
// Invalidation: tier-assignment events update the cache.
|
|
207
255
|
r.entityHook("postSave", "tier-assignment", async (result) => {
|
|
208
256
|
// result.data has tenantId + tier (after entity-update merge)
|
|
@@ -299,7 +347,7 @@ export function createTierEngineFeature<
|
|
|
299
347
|
const tdb = createTenantDb(rawDb, newTenantId, "system");
|
|
300
348
|
|
|
301
349
|
await tierAssignmentExecutor.create(
|
|
302
|
-
{ id: aggregateId, tier: defaultTier },
|
|
350
|
+
{ id: aggregateId, tier: defaultTier, source: "default" },
|
|
303
351
|
systemUser,
|
|
304
352
|
tdb,
|
|
305
353
|
);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
buildEntityTable,
|
|
4
|
+
createTenantDb,
|
|
5
|
+
type DbConnection,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { defineQueryHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { tierAssignmentEntity } from "../entity";
|
|
10
|
+
|
|
11
|
+
// Liest das Tier-Assignment eines BELIEBIGEN Tenants (cross-tenant) für den
|
|
12
|
+
// tier-admin-Screen. SystemAdmin-only. get-active-tier liest nur den eigenen
|
|
13
|
+
// Tenant — hier ein "system"-mode TenantDb auf den Ziel-Tenant (kein Filter),
|
|
14
|
+
// damit der Admin das Tier fremder Tenants sehen kann. null wenn noch keins.
|
|
15
|
+
|
|
16
|
+
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
17
|
+
|
|
18
|
+
type TierAssignmentRow = {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly version: number;
|
|
21
|
+
readonly tier: string;
|
|
22
|
+
readonly source: string | null;
|
|
23
|
+
readonly tenantId: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const getTenantTierQuery = defineQueryHandler({
|
|
27
|
+
name: "get-tenant-tier",
|
|
28
|
+
schema: z.object({ tenantId: z.string().min(1) }),
|
|
29
|
+
access: { roles: ["SystemAdmin"] },
|
|
30
|
+
handler: async (query, ctx) => {
|
|
31
|
+
const tenantId = query.payload.tenantId as TenantId; // @cast-boundary engine-bridge
|
|
32
|
+
const tdb = createTenantDb(ctx.db.raw as DbConnection, tenantId, "system"); // @cast-boundary db-runner
|
|
33
|
+
const row = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
|
|
34
|
+
return row ?? null;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
buildEntityTable,
|
|
4
|
+
createEventStoreExecutor,
|
|
5
|
+
createTenantDb,
|
|
6
|
+
type DbConnection,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { tierAssignmentAggregateId } from "../aggregate-id";
|
|
11
|
+
import { tierAssignmentEntity } from "../entity";
|
|
12
|
+
|
|
13
|
+
// SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
|
|
14
|
+
// Billing. Cross-tenant, daher SystemAdmin-only (kein TenantAdmin: sonst
|
|
15
|
+
// Gratis-Self-Upgrade).
|
|
16
|
+
//
|
|
17
|
+
// **Cross-tenant-Mechanik:** ein "system"-mode TenantDb auf den Ziel-Tenant legt
|
|
18
|
+
// KEINEN Tenant-Filter an (tenant-db.ts:141 — mode==="system" überspringt ihn);
|
|
19
|
+
// der executor-user wird ebenfalls auf den Ziel-Tenant gestellt, sonst landet das
|
|
20
|
+
// Event im Stream des Admins (Memory feedback_event_store_tenant_consistency).
|
|
21
|
+
// Das set.write-"override-user"-Muster trägt NICHT für beliebige Tenants — es
|
|
22
|
+
// funktioniert nur für SYSTEM_TENANT_ID (immer im IN-Filter). Dies ist das
|
|
23
|
+
// auto-default-Hook-Muster (feature.ts), generalisiert auf einen Request-Handler.
|
|
24
|
+
//
|
|
25
|
+
// `source: "manual"` markiert den Grant, damit ein späterer Stripe→Tier-Sync ihn
|
|
26
|
+
// nicht plättet. Upsert: ein Aggregat pro Tenant (deterministische aggregate-id).
|
|
27
|
+
//
|
|
28
|
+
// **Effective-Set-Invalidation (kritisch):** der Executor-Write feuert NICHT
|
|
29
|
+
// den `tier-assignment:postSave`-entityHook (Hooks laufen nur im Entity-
|
|
30
|
+
// Handler-Pfad, nicht bei direktem executor.create/update). Ohne Cache-Update
|
|
31
|
+
// bliebe das Feature-Gate auf dem alten Tier hängen — die Projektion zeigt
|
|
32
|
+
// "pro", das Gate verhält sich weiter wie "free", bis der Prozess neu startet.
|
|
33
|
+
// Daher ruft der Handler nach erfolgreichem Write `opts.onAssigned(tenantId,
|
|
34
|
+
// tier)`; feature.ts verdrahtet das auf denselben Cache-Update wie der Hook
|
|
35
|
+
// (storage-only ohne tierMap = no-op).
|
|
36
|
+
|
|
37
|
+
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
38
|
+
const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEntity, {
|
|
39
|
+
entityName: "tier-assignment",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
type TierAssignmentRow = {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly version: number;
|
|
45
|
+
readonly tier: string;
|
|
46
|
+
readonly source: string | null;
|
|
47
|
+
readonly tenantId: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type SetTenantTierOptions = {
|
|
51
|
+
/** Nach erfolgreichem Write aufgerufen, damit feature.ts den Resolver-
|
|
52
|
+
* Cache aktualisieren kann (der Executor-Write feuert den postSave-Hook
|
|
53
|
+
* nicht). Ohne tierMap kein Resolver → no-op. */
|
|
54
|
+
readonly onAssigned?: (tenantId: TenantId, tier: string) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
58
|
+
return defineWriteHandler({
|
|
59
|
+
name: "set-tenant-tier",
|
|
60
|
+
schema: z.object({
|
|
61
|
+
tenantId: z.string().min(1),
|
|
62
|
+
tier: z.string().min(1).max(50),
|
|
63
|
+
}),
|
|
64
|
+
access: { roles: ["SystemAdmin"] },
|
|
65
|
+
handler: async (event, ctx) => {
|
|
66
|
+
const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
|
|
67
|
+
const rawDb = ctx.db.raw as DbConnection; // @cast-boundary db-runner
|
|
68
|
+
const tdb = createTenantDb(rawDb, tenantId, "system");
|
|
69
|
+
const systemUser = { ...event.user, tenantId };
|
|
70
|
+
const tier = event.payload.tier;
|
|
71
|
+
|
|
72
|
+
const existing = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
|
|
73
|
+
|
|
74
|
+
if (existing) {
|
|
75
|
+
const result = await executor.update(
|
|
76
|
+
{
|
|
77
|
+
id: existing.id,
|
|
78
|
+
version: existing.version,
|
|
79
|
+
changes: { tier, source: "manual" },
|
|
80
|
+
},
|
|
81
|
+
systemUser,
|
|
82
|
+
tdb,
|
|
83
|
+
);
|
|
84
|
+
if (!result.isSuccess) return result;
|
|
85
|
+
opts.onAssigned?.(tenantId, tier);
|
|
86
|
+
return { isSuccess: true as const, data: { tenantId, tier, isNew: false } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const result = await executor.create(
|
|
90
|
+
{ id: tierAssignmentAggregateId(tenantId), tier, source: "manual", tenantId },
|
|
91
|
+
systemUser,
|
|
92
|
+
tdb,
|
|
93
|
+
);
|
|
94
|
+
if (!result.isSuccess) return result;
|
|
95
|
+
opts.onAssigned?.(tenantId, tier);
|
|
96
|
+
return { isSuccess: true as const, data: { tenantId, tier, isNew: true } };
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Default-Bundles für den TierAdminScreen. Werden vom tierEngineClient()
|
|
3
|
+
// als Fallback-Bundle in den LocaleProvider gehängt — Apps überschreiben
|
|
4
|
+
// einzelne Keys via `tierEngineClient({ translations: { de: { … } } })`.
|
|
5
|
+
|
|
6
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
|
|
8
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
9
|
+
de: {
|
|
10
|
+
"tier-admin.title": "Tier manuell zuweisen",
|
|
11
|
+
"tier-admin.explainer":
|
|
12
|
+
"Weise einem Tenant ein Tier ohne Kauf zu. Der Grant wird als „manuell“ markiert und von einem späteren Billing-Sync nicht überschrieben.",
|
|
13
|
+
"tier-admin.tenant.label": "Tenant",
|
|
14
|
+
"tier-admin.current.label": "Aktuelles Tier",
|
|
15
|
+
"tier-admin.current.none": "— noch keins —",
|
|
16
|
+
"tier-admin.tier.label": "Neues Tier",
|
|
17
|
+
"tier-admin.submit": "Tier zuweisen",
|
|
18
|
+
"tier-admin.success": "Tier „{tier}“ zugewiesen.",
|
|
19
|
+
"tier-admin.error.generic": "Konnte das Tier nicht zuweisen.",
|
|
20
|
+
"tier-admin.error.load": "Tenants konnten nicht geladen werden.",
|
|
21
|
+
"tier-admin.error.noTiers":
|
|
22
|
+
"Diese App hat keine TierMap konfiguriert — es gibt keine zuweisbaren Tiers.",
|
|
23
|
+
},
|
|
24
|
+
en: {
|
|
25
|
+
"tier-admin.title": "Assign tier manually",
|
|
26
|
+
"tier-admin.explainer":
|
|
27
|
+
"Grant a tenant a tier without a purchase. The grant is marked as “manual” and a later billing sync won't overwrite it.",
|
|
28
|
+
"tier-admin.tenant.label": "Tenant",
|
|
29
|
+
"tier-admin.current.label": "Current tier",
|
|
30
|
+
"tier-admin.current.none": "— none yet —",
|
|
31
|
+
"tier-admin.tier.label": "New tier",
|
|
32
|
+
"tier-admin.submit": "Assign tier",
|
|
33
|
+
"tier-admin.success": "Assigned tier “{tier}”.",
|
|
34
|
+
"tier-admin.error.generic": "Could not assign the tier.",
|
|
35
|
+
"tier-admin.error.load": "Failed to load tenants.",
|
|
36
|
+
"tier-admin.error.noTiers":
|
|
37
|
+
"This app has no TierMap configured — there are no assignable tiers.",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client-Feature-Factory für tier-engine. Liefert den TierAdminScreen
|
|
3
|
+
// (gemappt auf die Screen-id "tier-admin") + Default-Translations. Apps
|
|
4
|
+
// hängen es in createKumikoApp({ clientFeatures: [tierEngineClient()] }) ein;
|
|
5
|
+
// der Screen selbst wird server-seitig vom Feature als custom-Screen
|
|
6
|
+
// registriert (r.screen), die App platziert ihn nur via r.nav.
|
|
7
|
+
|
|
8
|
+
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
9
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
10
|
+
import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE } from "../constants";
|
|
11
|
+
import { defaultTranslations } from "../i18n";
|
|
12
|
+
import { TierAdminScreen } from "./tier-admin-screen";
|
|
13
|
+
|
|
14
|
+
export type TierEngineClientOptions = {
|
|
15
|
+
/** Key-weise Overrides über die Default-Bundles (de/en). */
|
|
16
|
+
readonly translations?: TranslationsByLocale;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function tierEngineClient(options?: TierEngineClientOptions): ClientFeatureDefinition {
|
|
20
|
+
return {
|
|
21
|
+
name: TIER_ENGINE_FEATURE,
|
|
22
|
+
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
23
|
+
components: {
|
|
24
|
+
[TIER_ADMIN_SCREEN_ID]: TierAdminScreen,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Public exports für die Browser-Seite des tier-engine Features. Konsumiert
|
|
3
|
+
// über `@cosmicdrift/kumiko-bundled-features/tier-engine/web` — die
|
|
4
|
+
// Server-Seite (createTierEngineFeature) lebt unter
|
|
5
|
+
// `@cosmicdrift/kumiko-bundled-features/tier-engine` und hat keine React-Deps.
|
|
6
|
+
|
|
7
|
+
export { type TierEngineClientOptions, tierEngineClient } from "./client-plugin";
|
|
8
|
+
export { TierAdminScreen } from "./tier-admin-screen";
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// TierAdminScreen — SystemAdmin weist einem beliebigen Tenant ein Tier zu,
|
|
3
|
+
// ohne Billing-Kauf. Tenant-Picker (tenant:query:list) → aktuelles Tier
|
|
4
|
+
// (get-tenant-tier) → Tier-Dropdown (tier-options) → Speichern
|
|
5
|
+
// (set-tenant-tier). Apps registrieren die Komponente als custom-Screen:
|
|
6
|
+
// r.screen({ id: "tier-admin", type: "custom",
|
|
7
|
+
// renderer: { react: { __component: "TierAdminScreen" } },
|
|
8
|
+
// access: { roles: ["SystemAdmin"] } })
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
useDispatcher,
|
|
12
|
+
usePrimitives,
|
|
13
|
+
useQuery,
|
|
14
|
+
useTranslation,
|
|
15
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
16
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
17
|
+
import { TierEngineHandlers, TierEngineQueries } from "../constants";
|
|
18
|
+
|
|
19
|
+
const TENANT_LIST_QUERY = "tenant:query:list";
|
|
20
|
+
|
|
21
|
+
type TenantRow = { readonly id: string; readonly name: string };
|
|
22
|
+
type TenantListResponse = { readonly rows: readonly TenantRow[] };
|
|
23
|
+
type TierAssignmentRow = { readonly tier: string; readonly source: string | null };
|
|
24
|
+
type TierOptionsResponse = { readonly tiers: readonly string[] };
|
|
25
|
+
type SetTenantTierResponse = {
|
|
26
|
+
readonly tenantId: string;
|
|
27
|
+
readonly tier: string;
|
|
28
|
+
readonly isNew: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type Status =
|
|
32
|
+
| { kind: "idle" }
|
|
33
|
+
| { kind: "submitting" }
|
|
34
|
+
| { kind: "success"; tier: string }
|
|
35
|
+
| { kind: "error"; messageKey: string };
|
|
36
|
+
|
|
37
|
+
export function TierAdminScreen(): ReactNode {
|
|
38
|
+
const t = useTranslation();
|
|
39
|
+
const { Section, Field, Input, Button, Banner, Heading } = usePrimitives();
|
|
40
|
+
const dispatcher = useDispatcher();
|
|
41
|
+
|
|
42
|
+
// ponytail: nur die erste Seite (default-limit, nextCursor ignoriert) —
|
|
43
|
+
// reicht für Apps mit wenigen Tenants (cashcolt). Pagination/Suche
|
|
44
|
+
// nachrüsten, wenn ein Operator mit vielen Tenants nicht alle sieht.
|
|
45
|
+
const tenantsQuery = useQuery<TenantListResponse | null>(TENANT_LIST_QUERY, {});
|
|
46
|
+
const tierOptionsQuery = useQuery<TierOptionsResponse | null>(TierEngineQueries.tierOptions, {});
|
|
47
|
+
|
|
48
|
+
const [tenantId, setTenantId] = useState("");
|
|
49
|
+
const [tier, setTier] = useState("");
|
|
50
|
+
const [status, setStatus] = useState<Status>({ kind: "idle" });
|
|
51
|
+
|
|
52
|
+
const currentTierQuery = useQuery<TierAssignmentRow | null>(
|
|
53
|
+
TierEngineQueries.getTenantTier,
|
|
54
|
+
{ tenantId },
|
|
55
|
+
{ enabled: tenantId !== "" },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Tenant-Wechsel: Auswahl + Status zurücksetzen, damit kein Tier eines
|
|
59
|
+
// anderen Tenants stehen bleibt (Mis-Grant-Schutz auf UI-Ebene). tenantId
|
|
60
|
+
// ist hier reiner Trigger (Body liest es nicht) — Biome's "extra dep"-
|
|
61
|
+
// Autofix würde die Deps leeren und den Reset auf den Mount beschränken.
|
|
62
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: tenantId ist der gewollte Reset-Trigger, nicht entfernen.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setTier("");
|
|
65
|
+
setStatus({ kind: "idle" });
|
|
66
|
+
}, [tenantId]);
|
|
67
|
+
|
|
68
|
+
const tenantOptions = (tenantsQuery.data?.rows ?? []).map((row) => ({
|
|
69
|
+
value: row.id,
|
|
70
|
+
label: row.name,
|
|
71
|
+
}));
|
|
72
|
+
const tierOptions = tierOptionsQuery.data?.tiers ?? [];
|
|
73
|
+
const currentTier = currentTierQuery.data?.tier ?? null;
|
|
74
|
+
|
|
75
|
+
const onSave = async (): Promise<void> => {
|
|
76
|
+
if (tenantId === "" || tier === "") return;
|
|
77
|
+
setStatus({ kind: "submitting" });
|
|
78
|
+
const res = await dispatcher.write<SetTenantTierResponse>(TierEngineHandlers.setTenantTier, {
|
|
79
|
+
tenantId,
|
|
80
|
+
tier,
|
|
81
|
+
});
|
|
82
|
+
if (!res.isSuccess) {
|
|
83
|
+
setStatus({ kind: "error", messageKey: "tier-admin.error.generic" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
setStatus({ kind: "success", tier: res.data.tier });
|
|
87
|
+
void currentTierQuery.refetch();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const submitting = status.kind === "submitting";
|
|
91
|
+
const canSubmit = tenantId !== "" && tier !== "" && !submitting;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="p-6 flex flex-col gap-6 max-w-xl" data-testid="tier-admin-screen">
|
|
95
|
+
<Heading variant="page">{t("tier-admin.title")}</Heading>
|
|
96
|
+
<p className="text-sm text-muted-foreground">{t("tier-admin.explainer")}</p>
|
|
97
|
+
|
|
98
|
+
{tenantsQuery.error !== null && (
|
|
99
|
+
<Banner variant="error" testId="tier-admin-load-error">
|
|
100
|
+
{t("tier-admin.error.load")}
|
|
101
|
+
</Banner>
|
|
102
|
+
)}
|
|
103
|
+
{tierOptions.length === 0 && tierOptionsQuery.loading !== true && (
|
|
104
|
+
<Banner variant="info" testId="tier-admin-no-tiers">
|
|
105
|
+
{t("tier-admin.error.noTiers")}
|
|
106
|
+
</Banner>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
<Section>
|
|
110
|
+
<Field id="tier-admin-tenant" label={t("tier-admin.tenant.label")} required>
|
|
111
|
+
<Input
|
|
112
|
+
kind="select"
|
|
113
|
+
id="tier-admin-tenant"
|
|
114
|
+
name="tier-admin-tenant"
|
|
115
|
+
value={tenantId}
|
|
116
|
+
onChange={setTenantId}
|
|
117
|
+
options={tenantOptions}
|
|
118
|
+
/>
|
|
119
|
+
</Field>
|
|
120
|
+
|
|
121
|
+
{tenantId !== "" && (
|
|
122
|
+
<p className="text-sm text-muted-foreground" data-testid="tier-admin-current">
|
|
123
|
+
{t("tier-admin.current.label")}: {currentTier ?? t("tier-admin.current.none")}
|
|
124
|
+
</p>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
<Field id="tier-admin-tier" label={t("tier-admin.tier.label")} required>
|
|
128
|
+
<Input
|
|
129
|
+
kind="select"
|
|
130
|
+
id="tier-admin-tier"
|
|
131
|
+
name="tier-admin-tier"
|
|
132
|
+
value={tier}
|
|
133
|
+
onChange={setTier}
|
|
134
|
+
options={tierOptions}
|
|
135
|
+
disabled={tierOptions.length === 0}
|
|
136
|
+
/>
|
|
137
|
+
</Field>
|
|
138
|
+
|
|
139
|
+
{status.kind === "success" && (
|
|
140
|
+
<Banner variant="info" testId="tier-admin-success">
|
|
141
|
+
{t("tier-admin.success", { tier: status.tier })}
|
|
142
|
+
</Banner>
|
|
143
|
+
)}
|
|
144
|
+
{status.kind === "error" && (
|
|
145
|
+
<Banner variant="error" testId="tier-admin-error">
|
|
146
|
+
{t(status.messageKey)}
|
|
147
|
+
</Banner>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
<Button
|
|
151
|
+
onClick={() => void onSave()}
|
|
152
|
+
disabled={!canSubmit}
|
|
153
|
+
loading={submitting}
|
|
154
|
+
testId="tier-admin-submit"
|
|
155
|
+
>
|
|
156
|
+
{t("tier-admin.submit")}
|
|
157
|
+
</Button>
|
|
158
|
+
</Section>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -174,6 +174,17 @@ describe("anonymous deletion flow", () => {
|
|
|
174
174
|
});
|
|
175
175
|
expect(second.status).toBe(422);
|
|
176
176
|
expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
|
|
177
|
+
|
|
178
|
+
// #354/2: der anonyme Endpoint gibt einen generischen reason zurück und
|
|
179
|
+
// leakt NICHT den konkreten User-Status (currentStatus), den ein
|
|
180
|
+
// Token-Inhaber sonst proben könnte.
|
|
181
|
+
const body = (await second.json()) as {
|
|
182
|
+
error: { details?: { reason?: string } };
|
|
183
|
+
};
|
|
184
|
+
expect(body.error.details?.reason).toBe("cannot_process_deletion");
|
|
185
|
+
const serialized = JSON.stringify(body.error);
|
|
186
|
+
expect(serialized).not.toContain("currentStatus");
|
|
187
|
+
expect(serialized).not.toContain(USER_STATUS.DeletionRequested);
|
|
177
188
|
});
|
|
178
189
|
|
|
179
190
|
test("request-by-email für nicht-existente Email → success, KEINE Mail (enumeration-safe)", async () => {
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
// purpose to "deletion-request". Mirrors auth-email-password/reset-token.ts —
|
|
3
3
|
// email-verified account deletion is an auth-adjacent proof-of-email-ownership
|
|
4
4
|
// flow, so it reuses the same self-contained token mechanism (no DB row, no
|
|
5
|
-
// Redis: the userId + expiry are baked into the signed token
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// Redis: the userId + expiry are baked into the signed token).
|
|
6
|
+
//
|
|
7
|
+
// The token is NOT single-use: replaying it on a still-pending (non-active)
|
|
8
|
+
// user is a no-op (confirm hits non-active → cannot_process_deletion). That
|
|
9
|
+
// idempotency is only bounded though — after a cancel-deletion the user is
|
|
10
|
+
// active again and a still-valid token re-arms a second grace period. The
|
|
11
|
+
// replay window is bounded by the TTL; the full fix (per-request requestId
|
|
12
|
+
// bound into the token + the user row, nulled on cancel) is deferred as review
|
|
13
|
+
// finding #354/1 (needs a shared user-entity migration).
|
|
8
14
|
|
|
9
15
|
import type { Temporal } from "temporal-polyfill";
|
|
10
16
|
import { signToken, verifyToken } from "../auth-email-password";
|
|
@@ -19,8 +19,16 @@ function invalidToken(): UnprocessableError {
|
|
|
19
19
|
|
|
20
20
|
// Anonymer Apex-Flow Schritt 2: Verify-Link-Target. Verifiziert das
|
|
21
21
|
// HMAC-Token, extrahiert die userId und startet die Grace-Period über die
|
|
22
|
-
// geteilte Logik.
|
|
23
|
-
//
|
|
22
|
+
// geteilte Logik.
|
|
23
|
+
//
|
|
24
|
+
// Idempotenz ist NUR bounded: ein zweites Confirm auf einen noch-pending
|
|
25
|
+
// (DeletionRequested) User trifft non-active → cannot_process_deletion. ABER
|
|
26
|
+
// nach einem cancel-deletion (status → Active, gracePeriodEnd → null) ist der
|
|
27
|
+
// User wieder aktiv; ein noch-gültiges Token (TTL aus request-deletion-by-email)
|
|
28
|
+
// re-armt dann eine zweite Grace-Period (replay-after-cancel). Das Risiko ist
|
|
29
|
+
// durch die Token-TTL begrenzt; der vollständige Fix (requestId pro Request im
|
|
30
|
+
// Token + auf der User-Row, vom cancel genullt) ist als review-finding #354/1
|
|
31
|
+
// deferred — er braucht eine Migration der geteilten user-Entity.
|
|
24
32
|
export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByTokenOptions = {}) {
|
|
25
33
|
return defineWriteHandler({
|
|
26
34
|
name: "confirm-deletion-by-token",
|
|
@@ -34,7 +42,18 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
34
42
|
if (!verified.ok) return writeFailure(invalidToken());
|
|
35
43
|
|
|
36
44
|
const res = await startDeletionGracePeriod(ctx, verified.userId, event.user.tenantId);
|
|
37
|
-
if (!res.ok)
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
// Generischer 422 statt res.error: dieser Endpoint ist anonym-öffentlich,
|
|
47
|
+
// res.error trägt den konkreten User-Status (currentStatus aus
|
|
48
|
+
// user_not_in_active_state) und würde einem Token-Inhaber das Proben des
|
|
49
|
+
// Account-Status erlauben (#354/2). Der authentifizierte request-deletion-
|
|
50
|
+
// Pfad zeigt dem User legitim seinen eigenen Status.
|
|
51
|
+
return writeFailure(
|
|
52
|
+
new UnprocessableError("cannot_process_deletion", {
|
|
53
|
+
details: { reason: "cannot_process_deletion" },
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
38
57
|
|
|
39
58
|
return {
|
|
40
59
|
isSuccess: true as const,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Wrapper analog renderer-web/test-utils, hier lokal weil die
|
|
4
4
|
// Dependency-Richtung renderer-web → bundled-features verbietet.
|
|
5
5
|
|
|
6
|
-
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
7
7
|
import { createStore, type Dispatcher, type DispatcherStatus } from "@cosmicdrift/kumiko-headless";
|
|
8
8
|
import {
|
|
9
9
|
createStaticLocaleResolver,
|
|
@@ -16,10 +16,10 @@ import {
|
|
|
16
16
|
TokensProvider,
|
|
17
17
|
} from "@cosmicdrift/kumiko-renderer";
|
|
18
18
|
import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-web";
|
|
19
|
-
import { render, waitFor } from "@testing-library/react";
|
|
19
|
+
import { fireEvent, render, waitFor } from "@testing-library/react";
|
|
20
20
|
import type { ReactNode } from "react";
|
|
21
21
|
import { defaultTranslations } from "../i18n";
|
|
22
|
-
import { ProfileScreen } from "../web/profile-screen";
|
|
22
|
+
import { formatDeletionDate, ProfileScreen } from "../web/profile-screen";
|
|
23
23
|
|
|
24
24
|
const stubLiveEvents: LiveEventSubscriber = () => () => {};
|
|
25
25
|
const stubTokens = {
|
|
@@ -94,8 +94,66 @@ describe("ProfileScreen", () => {
|
|
|
94
94
|
const banner = view.getByTestId("profile-danger-requested");
|
|
95
95
|
expect(banner.textContent).toContain("2026-07-11");
|
|
96
96
|
expect(banner.textContent).not.toContain("{date}");
|
|
97
|
+
// #322/2: nur der Datums-Teil, kein roher ISO-Zeitstempel mehr.
|
|
98
|
+
expect(banner.textContent).not.toContain("T00:00");
|
|
99
|
+
expect(banner.textContent).not.toContain(":00:00");
|
|
97
100
|
expect(view.queryByTestId("profile-danger-delete")).toBeNull();
|
|
98
101
|
expect(view.getByTestId("profile-danger-cancel")).toBeTruthy();
|
|
99
102
|
expect(view.container.textContent).not.toContain("profile.");
|
|
100
103
|
});
|
|
104
|
+
|
|
105
|
+
// #322/3: nach erfolgreichem Email-Wechsel triggert der Screen den
|
|
106
|
+
// Verification-Versand. Schlägt der fehl, darf der Erfolg nicht umkehren —
|
|
107
|
+
// aber der Fehler wird nicht mehr stumm verschluckt (sonst wartet der User
|
|
108
|
+
// auf eine Mail, die nie kommt) und die Success-Message verspricht keinen
|
|
109
|
+
// Versand mehr.
|
|
110
|
+
test("email change: verification-send failure is surfaced (not swallowed), change still succeeds", async () => {
|
|
111
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
112
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockRejectedValue(new Error("no network in test"));
|
|
113
|
+
try {
|
|
114
|
+
const view = renderProfile(activeMe);
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
if (view.queryByTestId("profile-email-form") === null) throw new Error("not mounted yet");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
|
|
120
|
+
const pwInput = view.container.querySelector<HTMLInputElement>("#profile-email-password");
|
|
121
|
+
if (!emailInput || !pwInput) throw new Error("email form inputs not found");
|
|
122
|
+
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
|
123
|
+
fireEvent.change(pwInput, { target: { value: "current-pw" } });
|
|
124
|
+
fireEvent.submit(view.getByTestId("profile-email-form"));
|
|
125
|
+
|
|
126
|
+
// De-Swallow: der fehlgeschlagene Verification-Versand wird geloggt.
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
const warned = warnSpy.mock.calls.some((c) => String(c[0]).includes("[user-profile]"));
|
|
129
|
+
if (!warned) throw new Error("verification-send failure not surfaced");
|
|
130
|
+
});
|
|
131
|
+
// Wechsel bleibt erfolgreich: das Eingabefeld wird zurückgesetzt.
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
if (emailInput.value !== "") throw new Error("email input not cleared after success");
|
|
134
|
+
});
|
|
135
|
+
// Die Success-Message verspricht keinen Link-Versand mehr.
|
|
136
|
+
expect(view.container.textContent).not.toContain("verification link");
|
|
137
|
+
expect(view.container.textContent).not.toContain("Bestätigungslink");
|
|
138
|
+
} finally {
|
|
139
|
+
warnSpy.mockRestore();
|
|
140
|
+
fetchSpy.mockRestore();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("formatDeletionDate", () => {
|
|
146
|
+
test("ISO instant → date part only (strips time + Z)", () => {
|
|
147
|
+
expect(formatDeletionDate("2026-07-11T00:00:00.000Z")).toBe("2026-07-11");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("null / undefined / empty → em dash", () => {
|
|
151
|
+
expect(formatDeletionDate(null)).toBe("—");
|
|
152
|
+
expect(formatDeletionDate(undefined)).toBe("—");
|
|
153
|
+
expect(formatDeletionDate("")).toBe("—");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("date-only string without time → returned as-is", () => {
|
|
157
|
+
expect(formatDeletionDate("2026-07-11")).toBe("2026-07-11");
|
|
158
|
+
});
|
|
101
159
|
});
|
package/src/user-profile/i18n.ts
CHANGED
|
@@ -17,8 +17,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
17
17
|
"profile.email.new": "Neue E-Mail",
|
|
18
18
|
"profile.email.currentPassword": "Aktuelles Passwort",
|
|
19
19
|
"profile.email.submit": "E-Mail ändern",
|
|
20
|
-
"profile.email.success":
|
|
21
|
-
"E-Mail geändert. Wir haben einen Bestätigungslink an die neue Adresse geschickt.",
|
|
20
|
+
"profile.email.success": "E-Mail geändert. Bitte bestätige deine neue Adresse.",
|
|
22
21
|
|
|
23
22
|
"profile.password.title": "Passwort",
|
|
24
23
|
"profile.password.old": "Aktuelles Passwort",
|
|
@@ -53,7 +52,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
53
52
|
"profile.email.new": "New email",
|
|
54
53
|
"profile.email.currentPassword": "Current password",
|
|
55
54
|
"profile.email.submit": "Change email",
|
|
56
|
-
"profile.email.success": "Email changed.
|
|
55
|
+
"profile.email.success": "Email changed. Please confirm your new address.",
|
|
57
56
|
|
|
58
57
|
"profile.password.title": "Password",
|
|
59
58
|
"profile.password.old": "Current password",
|