@cosmicdrift/kumiko-bundled-features 0.232.0 → 0.234.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/cap-overview/__tests__/usage-math.test.ts +12 -0
- package/src/cap-overview/changes.json +6 -0
- package/src/cap-overview/handlers/caps-usage.query.ts +2 -1
- package/src/cap-overview/types.ts +5 -2
- package/src/cap-overview/usage-math.ts +6 -4
- package/src/cap-overview/web/__tests__/cap-cards-panel.test.tsx +53 -0
- package/src/cap-overview/web/__tests__/cap-usage-bar.test.tsx +36 -0
- package/src/cap-overview/web/cap-cards-panel.tsx +11 -7
- package/src/cap-overview/web/cap-usage-bar.tsx +9 -0
- package/src/cap-overview/web/cap-usage-cell.tsx +2 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +13 -0
- package/src/sessions/feature.ts +4 -70
- package/src/sessions/screens.ts +84 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +14 -0
- package/src/tenant/screens.ts +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.234.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.234.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.234.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.234.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.234.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.234.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.234.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.234.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.234.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -22,6 +22,10 @@ describe("computeFraction", () => {
|
|
|
22
22
|
test("used 0 of a positive limit -> 0", () => {
|
|
23
23
|
expect(computeFraction(0, 5)).toBe(0);
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
test("limit null (unlimited) -> 0, regardless of used", () => {
|
|
27
|
+
expect(computeFraction(165, null)).toBe(0);
|
|
28
|
+
});
|
|
25
29
|
});
|
|
26
30
|
|
|
27
31
|
describe("computeUnclampedFraction", () => {
|
|
@@ -37,6 +41,10 @@ describe("computeUnclampedFraction", () => {
|
|
|
37
41
|
test("used 3 of limit 5 -> 0.6, same as the clamped case", () => {
|
|
38
42
|
expect(computeUnclampedFraction(3, 5)).toBe(0.6);
|
|
39
43
|
});
|
|
44
|
+
|
|
45
|
+
test("limit null (unlimited) -> 0, regardless of used", () => {
|
|
46
|
+
expect(computeUnclampedFraction(165, null)).toBe(0);
|
|
47
|
+
});
|
|
40
48
|
});
|
|
41
49
|
|
|
42
50
|
describe("computeTone", () => {
|
|
@@ -57,4 +65,8 @@ describe("computeTone", () => {
|
|
|
57
65
|
test("fraction >= 1 -> danger", () => {
|
|
58
66
|
expect(computeTone(1)).toBe("danger");
|
|
59
67
|
});
|
|
68
|
+
|
|
69
|
+
test("unlimited (limit null) never escalates to warn/danger", () => {
|
|
70
|
+
expect(computeTone(computeFraction(165, null))).toBe("default");
|
|
71
|
+
});
|
|
60
72
|
});
|
|
@@ -4,5 +4,11 @@
|
|
|
4
4
|
"type": "improvement",
|
|
5
5
|
"title": "Add cap-overview feature: tier + cap usage visibility per tenant.",
|
|
6
6
|
"detail": "New read-only bundled feature. SystemAdmin gets a paged, searchable tenant list with tier/billing columns and per-cap usage bars, plus a per-tenant dashboard reachable from the list. TenantAdmin gets a 'My usage' dashboard showing their own tenant's cap usage as cards. Reads tier-engine, billing-foundation and tenant projections plus caller-supplied CapSpec usage callbacks; every handler enforces explicit tenant scoping (no ambient tenant filter, cross-tenant reads require SystemAdmin + acknowledgeCrossTenant)."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.233.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "CapSpec.limit can now return null for an unlimited usage meter.",
|
|
12
|
+
"detail": "A CapSpec's `limit(tier)` may now return `null` to mean unlimited: the cap is still counted (used shows the raw count) but never capped, has no percent, and renders no progress bar or badge. This is a distinct meaning from `limit <= 0`, which unchanged still means 'not part of this tier' and renders as 0/0%."
|
|
7
13
|
}
|
|
8
14
|
]
|
|
@@ -86,7 +86,8 @@ export function createCapsUsageQuery(caps: readonly CapSpec[]): QueryHandlerDefi
|
|
|
86
86
|
limit,
|
|
87
87
|
fraction,
|
|
88
88
|
tone: computeTone(fraction),
|
|
89
|
-
percent:
|
|
89
|
+
percent:
|
|
90
|
+
limit === null ? null : Math.round(computeUnclampedFraction(used, limit) * 100),
|
|
90
91
|
...(cap.icon !== undefined && { icon: cap.icon }),
|
|
91
92
|
...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
|
|
92
93
|
};
|
|
@@ -14,7 +14,9 @@ export type CapIconKey = "file" | "hash" | "database" | "users" | "mail" | "gaug
|
|
|
14
14
|
export type CapSpec = {
|
|
15
15
|
readonly id: string;
|
|
16
16
|
readonly label: string;
|
|
17
|
-
|
|
17
|
+
// `null` = unlimited (a pure usage meter: counted but never capped).
|
|
18
|
+
// `<= 0` = not part of this tier — existing, unchanged meaning (usage-math.ts).
|
|
19
|
+
readonly limit: (tier: string) => number | null;
|
|
18
20
|
// `null` means "no measurement exists yet for this cap" (not "0 used") —
|
|
19
21
|
// callers that haven't wired up instrumentation return null instead of a
|
|
20
22
|
// number they don't actually have.
|
|
@@ -44,7 +46,8 @@ export type CapUsage = {
|
|
|
44
46
|
// `null` means not measured yet — `fraction`/`tone` stay non-nullable
|
|
45
47
|
// (0 / "default") so no consumer needs null-arithmetic to render.
|
|
46
48
|
readonly used: number | null;
|
|
47
|
-
|
|
49
|
+
// `limit: null` means unlimited — a pure usage meter with no cap to hit.
|
|
50
|
+
readonly limit: number | null;
|
|
48
51
|
readonly fraction: number;
|
|
49
52
|
};
|
|
50
53
|
|
|
@@ -7,21 +7,23 @@ import type { CapUsageTone } from "./types";
|
|
|
7
7
|
const WARN_THRESHOLD = 0.8;
|
|
8
8
|
const DANGER_THRESHOLD = 1;
|
|
9
9
|
|
|
10
|
-
// limit
|
|
11
|
-
|
|
10
|
+
// limit === null means unlimited (no cap to measure against); limit <= 0
|
|
11
|
+
// means "not part of this tier" — both render as 0, never NaN/Infinity.
|
|
12
|
+
function rawFraction(used: number, limit: number | null): number {
|
|
13
|
+
if (limit === null) return 0;
|
|
12
14
|
if (limit <= 0) return 0;
|
|
13
15
|
const fraction = used / limit;
|
|
14
16
|
return Number.isFinite(fraction) ? fraction : 0;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
export function computeFraction(used: number, limit: number): number {
|
|
19
|
+
export function computeFraction(used: number, limit: number | null): number {
|
|
18
20
|
return Math.min(1, Math.max(0, rawFraction(used, limit)));
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
// Unclamped — for display of the over-limit case (e.g. "140%"). Bar width
|
|
22
24
|
// and tone still use the clamped computeFraction; only percent shows the
|
|
23
25
|
// real ratio.
|
|
24
|
-
export function computeUnclampedFraction(used: number, limit: number): number {
|
|
26
|
+
export function computeUnclampedFraction(used: number, limit: number | null): number {
|
|
25
27
|
return rawFraction(used, limit);
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -96,6 +96,19 @@ function nullCapRow(overrides: Partial<CapUsageWithMeta> = {}): CapUsageWithMeta
|
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
function unlimitedCapRow(overrides: Partial<CapUsageWithMeta> = {}): CapUsageWithMeta {
|
|
100
|
+
return {
|
|
101
|
+
id: "cap-unlimited",
|
|
102
|
+
label: "cap.label.unlimited",
|
|
103
|
+
tone: "default",
|
|
104
|
+
percent: null,
|
|
105
|
+
used: 165,
|
|
106
|
+
limit: null,
|
|
107
|
+
fraction: 0,
|
|
108
|
+
...overrides,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
99
112
|
beforeEach(() => {
|
|
100
113
|
queryState = { data: { rows: [] }, loading: false, error: null };
|
|
101
114
|
useQuerySpy.mockClear();
|
|
@@ -233,6 +246,46 @@ describe("CapCardsPanel", () => {
|
|
|
233
246
|
expect(screen.getAllByTestId("cap-usage-bar")).toHaveLength(1);
|
|
234
247
|
});
|
|
235
248
|
|
|
249
|
+
test("an unlimited cap (limit: null) shows the raw used count, no denominator, no percent badge, no bar", () => {
|
|
250
|
+
queryState = {
|
|
251
|
+
data: { rows: [unlimitedCapRow()] },
|
|
252
|
+
loading: false,
|
|
253
|
+
error: null,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
renderPanel(undefined);
|
|
257
|
+
|
|
258
|
+
expect(screen.getByTestId("cap-card").textContent).toContain("165");
|
|
259
|
+
expect(screen.queryByText("165 / null")).toBeNull();
|
|
260
|
+
expect(screen.queryByText("165 / 0")).toBeNull();
|
|
261
|
+
expect(screen.queryByText("null%")).toBeNull();
|
|
262
|
+
expect(screen.queryByText("0%")).toBeNull();
|
|
263
|
+
expect(screen.queryByTestId("cap-usage-bar")).toBeNull();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("a limited card and an unlimited card in the same panel each render their own state", () => {
|
|
267
|
+
queryState = {
|
|
268
|
+
data: {
|
|
269
|
+
rows: [
|
|
270
|
+
capRow({ id: "c1", label: "Storage", used: 3, limit: 10, tone: "default", percent: 30 }),
|
|
271
|
+
unlimitedCapRow({ id: "c2", label: "Api calls" }),
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
loading: false,
|
|
275
|
+
error: null,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
renderPanel(undefined);
|
|
279
|
+
|
|
280
|
+
expect(screen.getAllByTestId("cap-card")).toHaveLength(2);
|
|
281
|
+
expect(screen.getByText("3 / 10")).toBeTruthy();
|
|
282
|
+
expect(screen.getByText("30%")).toBeTruthy();
|
|
283
|
+
expect(screen.getAllByTestId("cap-usage-bar")).toHaveLength(1);
|
|
284
|
+
expect(screen.getAllByTestId("cap-card")[1]?.textContent).toContain("165");
|
|
285
|
+
expect(screen.queryByText("165 / null")).toBeNull();
|
|
286
|
+
expect(screen.queryByText("null%")).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
|
|
236
289
|
test("loading with empty rows shows the loading text instead of the empty state", () => {
|
|
237
290
|
queryState = { data: null, loading: true, error: null };
|
|
238
291
|
|
|
@@ -73,4 +73,40 @@ describe("CapUsageBar", () => {
|
|
|
73
73
|
expect(fillClass()).not.toContain("bg-primary");
|
|
74
74
|
expect(fillClass()).not.toContain("bg-status-critical");
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
test("renders the raw used count instead of a bar when limit is null (unlimited)", () => {
|
|
78
|
+
const usage: CapUsage = { used: 165, limit: null, fraction: 0 };
|
|
79
|
+
render(
|
|
80
|
+
<Wrapper>
|
|
81
|
+
<CapUsageBar usage={usage} />
|
|
82
|
+
</Wrapper>,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(screen.queryByTestId("cap-usage-bar")).toBeNull();
|
|
86
|
+
expect(screen.getByTestId("cap-usage-unlimited").textContent).toBe("165");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("renders nothing for an unlimited cap when showLabel is false", () => {
|
|
90
|
+
const usage: CapUsage = { used: 165, limit: null, fraction: 0 };
|
|
91
|
+
render(
|
|
92
|
+
<Wrapper>
|
|
93
|
+
<CapUsageBar usage={usage} showLabel={false} />
|
|
94
|
+
</Wrapper>,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(screen.queryByTestId("cap-usage-bar")).toBeNull();
|
|
98
|
+
expect(screen.queryByTestId("cap-usage-unlimited")).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("not-measured wins over unlimited when both used and limit are null", () => {
|
|
102
|
+
const usage: CapUsage = { used: null, limit: null, fraction: 0 };
|
|
103
|
+
render(
|
|
104
|
+
<Wrapper>
|
|
105
|
+
<CapUsageBar usage={usage} />
|
|
106
|
+
</Wrapper>,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(screen.getByTestId("cap-usage-not-measured")).toBeTruthy();
|
|
110
|
+
expect(screen.queryByTestId("cap-usage-unlimited")).toBeNull();
|
|
111
|
+
});
|
|
76
112
|
});
|
|
@@ -35,11 +35,9 @@ const CAP_ICONS: Record<CapIconKey, ComponentType<{ className?: string }>> = {
|
|
|
35
35
|
gauge: Gauge,
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
// Narrows `
|
|
39
|
-
//
|
|
40
|
-
function isMeasured(
|
|
41
|
-
cap: CapUsageWithMeta,
|
|
42
|
-
): cap is CapUsageWithMeta & { used: number; percent: number } {
|
|
38
|
+
// Narrows `used` only — `percent` can independently be null for an
|
|
39
|
+
// unlimited cap (limit: null) even though used is measured.
|
|
40
|
+
function isMeasured(cap: CapUsageWithMeta): cap is CapUsageWithMeta & { used: number } {
|
|
43
41
|
return cap.used !== null;
|
|
44
42
|
}
|
|
45
43
|
|
|
@@ -89,12 +87,18 @@ export function CapCardsPanel({ filterParams }: ExtensionSectionProps): ReactNod
|
|
|
89
87
|
key={cap.id}
|
|
90
88
|
icon={Icon !== undefined ? <Icon className="size-4" /> : undefined}
|
|
91
89
|
label={t(cap.label)}
|
|
92
|
-
value={
|
|
90
|
+
value={
|
|
91
|
+
!isMeasured(cap)
|
|
92
|
+
? "—"
|
|
93
|
+
: cap.limit === null
|
|
94
|
+
? String(cap.used)
|
|
95
|
+
: `${cap.used} / ${cap.limit}`
|
|
96
|
+
}
|
|
93
97
|
{...(cap.accentColor !== undefined && { accentColor: cap.accentColor })}
|
|
94
98
|
testId="cap-card"
|
|
95
99
|
>
|
|
96
100
|
<div className="flex flex-col gap-2">
|
|
97
|
-
{
|
|
101
|
+
{cap.percent !== null && (
|
|
98
102
|
<div className="flex items-center justify-between gap-2">
|
|
99
103
|
<StatusBadge tone={TONE_TO_STATUS[cap.tone]}>{`${cap.percent}%`}</StatusBadge>
|
|
100
104
|
</div>
|
|
@@ -24,6 +24,15 @@ export function CapUsageBar({
|
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
if (usage.limit === null) {
|
|
28
|
+
// A progress bar with no denominator says nothing — show the raw count instead.
|
|
29
|
+
return showLabel ? (
|
|
30
|
+
<Text variant="small" testId="cap-usage-unlimited">
|
|
31
|
+
{String(usage.used)}
|
|
32
|
+
</Text>
|
|
33
|
+
) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
return (
|
|
28
37
|
<div className="flex flex-col gap-1">
|
|
29
38
|
{Progress ? (
|
|
@@ -7,9 +7,10 @@ import { CapUsageBar } from "./cap-usage-bar";
|
|
|
7
7
|
function isCapUsage(value: unknown): value is CapUsage {
|
|
8
8
|
if (typeof value !== "object" || value === null) return false;
|
|
9
9
|
const used = (value as { used?: unknown }).used;
|
|
10
|
+
const limit = (value as { limit?: unknown }).limit;
|
|
10
11
|
return (
|
|
11
12
|
(typeof used === "number" || used === null) &&
|
|
12
|
-
typeof
|
|
13
|
+
(typeof limit === "number" || limit === null) &&
|
|
13
14
|
typeof (value as { fraction?: unknown }).fraction === "number"
|
|
14
15
|
);
|
|
15
16
|
}
|
|
@@ -36,6 +36,19 @@ describe("sessions screens + query access alignment (kumiko-framework#255)", ()
|
|
|
36
36
|
}
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
+
test("session-list timestamp columns declare renderer.format (fw#2569)", () => {
|
|
40
|
+
const sessions = createSessionsFeature();
|
|
41
|
+
const list = sessions.screens[SESSION_LIST_SCREEN_ID];
|
|
42
|
+
if (list?.type !== "projectionList") throw new Error("expected projectionList");
|
|
43
|
+
for (const field of ["createdAt", "expiresAt", "revokedAt"] as const) {
|
|
44
|
+
const column = list.columns.find(
|
|
45
|
+
(c): c is Exclude<(typeof list.columns)[number], string> =>
|
|
46
|
+
typeof c !== "string" && c.field === field,
|
|
47
|
+
);
|
|
48
|
+
expect(column?.renderer).toEqual({ format: "timestamp" });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
39
52
|
test("session-list row-action navigates to session-detail with entityId 'id'", () => {
|
|
40
53
|
const sessions = createSessionsFeature();
|
|
41
54
|
const list = sessions.screens[SESSION_LIST_SCREEN_ID];
|
package/src/sessions/feature.ts
CHANGED
|
@@ -4,12 +4,7 @@ import {
|
|
|
4
4
|
type SessionStoreProvider,
|
|
5
5
|
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
6
6
|
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
-
import {
|
|
8
|
-
access,
|
|
9
|
-
defineFeature,
|
|
10
|
-
type FeatureDefinition,
|
|
11
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
-
import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID, SessionQueries } from "./constants";
|
|
7
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
8
|
import { cleanupJob } from "./handlers/cleanup.job";
|
|
14
9
|
import { detailQuery } from "./handlers/detail.query";
|
|
15
10
|
import { listQuery } from "./handlers/list.query";
|
|
@@ -19,6 +14,7 @@ import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
|
|
|
19
14
|
import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
|
|
20
15
|
import { SESSIONS_I18N } from "./i18n";
|
|
21
16
|
import { userSessionEntity } from "./schema/user-session";
|
|
17
|
+
import { sessionDetailScreen, sessionListScreen } from "./screens";
|
|
22
18
|
import {
|
|
23
19
|
createSessionCallbacks,
|
|
24
20
|
type SessionAllOthersRevoker,
|
|
@@ -211,70 +207,8 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
211
207
|
|
|
212
208
|
r.translations({ keys: SESSIONS_I18N });
|
|
213
209
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
r.screen({
|
|
217
|
-
id: SESSION_LIST_SCREEN_ID,
|
|
218
|
-
type: "projectionList",
|
|
219
|
-
query: SessionQueries.list,
|
|
220
|
-
// Mirrors list.query's own fallback (unrecognised/absent sort → createdAt
|
|
221
|
-
// desc) — kept in sync by hand, boot-validator only requires the field
|
|
222
|
-
// be present once the query accepts `sort` (fw#2230).
|
|
223
|
-
defaultSort: { field: "createdAt", dir: "desc" },
|
|
224
|
-
columns: [
|
|
225
|
-
{ field: "id", label: "sessions.list.col.id" },
|
|
226
|
-
{ field: "userId", label: "sessions.list.col.userId" },
|
|
227
|
-
{ field: "createdAt", label: "sessions.list.col.createdAt" },
|
|
228
|
-
{ field: "expiresAt", label: "sessions.list.col.expiresAt" },
|
|
229
|
-
{ field: "revokedAt", label: "sessions.list.col.revokedAt" },
|
|
230
|
-
],
|
|
231
|
-
rowActions: [
|
|
232
|
-
{
|
|
233
|
-
kind: "navigate",
|
|
234
|
-
id: "open",
|
|
235
|
-
label: "sessions.list.action.open",
|
|
236
|
-
screen: SESSION_DETAIL_SCREEN_ID,
|
|
237
|
-
entityId: "id",
|
|
238
|
-
rowClick: true,
|
|
239
|
-
},
|
|
240
|
-
],
|
|
241
|
-
access: listAccess,
|
|
242
|
-
});
|
|
243
|
-
r.screen({
|
|
244
|
-
id: SESSION_DETAIL_SCREEN_ID,
|
|
245
|
-
type: "projectionDetail",
|
|
246
|
-
query: SessionQueries.detail,
|
|
247
|
-
listScreenId: SESSION_LIST_SCREEN_ID,
|
|
248
|
-
layout: {
|
|
249
|
-
sections: [
|
|
250
|
-
{
|
|
251
|
-
fields: [
|
|
252
|
-
"id",
|
|
253
|
-
"userId",
|
|
254
|
-
// The shim (projection-detail-shim.ts) stamps every field as
|
|
255
|
-
// type:"text" — field.renderer is the only way this screen
|
|
256
|
-
// type reaches real per-type formatting instead of a raw ISO
|
|
257
|
-
// string (fw#2245).
|
|
258
|
-
{ field: "createdAt", renderer: { format: "timestamp" } },
|
|
259
|
-
{ field: "expiresAt", renderer: { format: "timestamp" } },
|
|
260
|
-
{ field: "revokedAt", renderer: { format: "timestamp" } },
|
|
261
|
-
"ip",
|
|
262
|
-
"userAgent",
|
|
263
|
-
],
|
|
264
|
-
},
|
|
265
|
-
],
|
|
266
|
-
},
|
|
267
|
-
fieldLabels: {
|
|
268
|
-
id: "sessions.detail.field.id",
|
|
269
|
-
userId: "sessions.detail.field.userId",
|
|
270
|
-
createdAt: "sessions.detail.field.createdAt",
|
|
271
|
-
expiresAt: "sessions.detail.field.expiresAt",
|
|
272
|
-
revokedAt: "sessions.detail.field.revokedAt",
|
|
273
|
-
ip: "sessions.detail.field.ip",
|
|
274
|
-
userAgent: "sessions.detail.field.userAgent",
|
|
275
|
-
},
|
|
276
|
-
access: listAccess,
|
|
277
|
-
});
|
|
210
|
+
r.screen(sessionListScreen);
|
|
211
|
+
r.screen(sessionDetailScreen);
|
|
278
212
|
r.nav({
|
|
279
213
|
id: "session-list",
|
|
280
214
|
label: "sessions:nav.sessionList",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
type ProjectionDetailScreenDefinition,
|
|
4
|
+
type ProjectionListScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID, SessionQueries } from "./constants";
|
|
7
|
+
|
|
8
|
+
const listAccess = { roles: access.admin };
|
|
9
|
+
|
|
10
|
+
export const sessionListScreen: ProjectionListScreenDefinition = {
|
|
11
|
+
id: SESSION_LIST_SCREEN_ID,
|
|
12
|
+
type: "projectionList",
|
|
13
|
+
query: SessionQueries.list,
|
|
14
|
+
// Mirrors list.query's own fallback (unrecognised/absent sort → createdAt
|
|
15
|
+
// desc) — kept in sync by hand, boot-validator only requires the field
|
|
16
|
+
// be present once the query accepts `sort` (fw#2230).
|
|
17
|
+
defaultSort: { field: "createdAt", dir: "desc" },
|
|
18
|
+
columns: [
|
|
19
|
+
{ field: "id", label: "sessions.list.col.id" },
|
|
20
|
+
{ field: "userId", label: "sessions.list.col.userId" },
|
|
21
|
+
{
|
|
22
|
+
field: "createdAt",
|
|
23
|
+
label: "sessions.list.col.createdAt",
|
|
24
|
+
renderer: { format: "timestamp" },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
field: "expiresAt",
|
|
28
|
+
label: "sessions.list.col.expiresAt",
|
|
29
|
+
renderer: { format: "timestamp" },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
field: "revokedAt",
|
|
33
|
+
label: "sessions.list.col.revokedAt",
|
|
34
|
+
renderer: { format: "timestamp" },
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
rowActions: [
|
|
38
|
+
{
|
|
39
|
+
kind: "navigate",
|
|
40
|
+
id: "open",
|
|
41
|
+
label: "sessions.list.action.open",
|
|
42
|
+
screen: SESSION_DETAIL_SCREEN_ID,
|
|
43
|
+
entityId: "id",
|
|
44
|
+
rowClick: true,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
access: listAccess,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const sessionDetailScreen: ProjectionDetailScreenDefinition = {
|
|
51
|
+
id: SESSION_DETAIL_SCREEN_ID,
|
|
52
|
+
type: "projectionDetail",
|
|
53
|
+
query: SessionQueries.detail,
|
|
54
|
+
listScreenId: SESSION_LIST_SCREEN_ID,
|
|
55
|
+
layout: {
|
|
56
|
+
sections: [
|
|
57
|
+
{
|
|
58
|
+
fields: [
|
|
59
|
+
"id",
|
|
60
|
+
"userId",
|
|
61
|
+
// The shim (projection-detail-shim.ts) stamps every field as
|
|
62
|
+
// type:"text" — field.renderer is the only way this screen
|
|
63
|
+
// type reaches real per-type formatting instead of a raw ISO
|
|
64
|
+
// string (fw#2245).
|
|
65
|
+
{ field: "createdAt", renderer: { format: "timestamp" } },
|
|
66
|
+
{ field: "expiresAt", renderer: { format: "timestamp" } },
|
|
67
|
+
{ field: "revokedAt", renderer: { format: "timestamp" } },
|
|
68
|
+
"ip",
|
|
69
|
+
"userAgent",
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
fieldLabels: {
|
|
75
|
+
id: "sessions.detail.field.id",
|
|
76
|
+
userId: "sessions.detail.field.userId",
|
|
77
|
+
createdAt: "sessions.detail.field.createdAt",
|
|
78
|
+
expiresAt: "sessions.detail.field.expiresAt",
|
|
79
|
+
revokedAt: "sessions.detail.field.revokedAt",
|
|
80
|
+
ip: "sessions.detail.field.ip",
|
|
81
|
+
userAgent: "sessions.detail.field.userAgent",
|
|
82
|
+
},
|
|
83
|
+
access: listAccess,
|
|
84
|
+
};
|
|
@@ -54,6 +54,20 @@ describe("tenant members screen + handler access alignment", () => {
|
|
|
54
54
|
}
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test("members screen timestamp columns declare renderer.format (fw#2569)", () => {
|
|
58
|
+
const tenant = createTenantFeature();
|
|
59
|
+
const screen = tenant.screens[MEMBERS_SCREEN_ID];
|
|
60
|
+
if (screen?.type !== "projectionList")
|
|
61
|
+
throw new Error("expected members screen to be projectionList");
|
|
62
|
+
for (const field of ["createdAt", "lastSeenAt"] as const) {
|
|
63
|
+
const column = screen.columns.find(
|
|
64
|
+
(c): c is Exclude<(typeof screen.columns)[number], string> =>
|
|
65
|
+
typeof c !== "string" && c.field === field,
|
|
66
|
+
);
|
|
67
|
+
expect(column?.renderer).toEqual({ format: "timestamp" });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
57
71
|
test("cancel-invitation row action is only visible on pending rows and cancels via TenantHandlers.cancelInvitation", () => {
|
|
58
72
|
const tenant = createTenantFeature();
|
|
59
73
|
const screen = tenant.screens[MEMBERS_SCREEN_ID];
|
package/src/tenant/screens.ts
CHANGED
|
@@ -91,8 +91,16 @@ export function createMembersScreen(options?: {
|
|
|
91
91
|
label: "tenant.members.col.status",
|
|
92
92
|
renderer: { react: { __component: MEMBER_STATUS_CELL_COMPONENT } },
|
|
93
93
|
},
|
|
94
|
-
{
|
|
95
|
-
|
|
94
|
+
{
|
|
95
|
+
field: "createdAt",
|
|
96
|
+
label: "tenant.members.col.created",
|
|
97
|
+
renderer: { format: "timestamp" },
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
field: "lastSeenAt",
|
|
101
|
+
label: "tenant.members.col.lastActivity",
|
|
102
|
+
renderer: { format: "timestamp" },
|
|
103
|
+
},
|
|
96
104
|
],
|
|
97
105
|
defaultSort: { field: "createdAt", dir: "desc" },
|
|
98
106
|
searchable: true,
|