@cosmicdrift/kumiko-samples 0.383.0 → 0.384.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 +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/event-classification.ts +83 -0
- package/packages/bundled-features/src/audit/feature.ts +3 -2
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +63 -5
- package/packages/bundled-features/src/audit/handlers/details-all-tenants.query.ts +6 -5
- package/packages/bundled-features/src/audit/handlers/details.query.ts +12 -6
- package/packages/bundled-features/src/audit/handlers/list-all-tenants.query.ts +20 -12
- package/packages/bundled-features/src/audit/handlers/list.query.ts +14 -3
- package/packages/bundled-features/src/audit/screens.ts +32 -4
- package/packages/bundled-features/src/audit/web/audit-event-cell.tsx +22 -0
- package/packages/bundled-features/src/audit/web/audit-event-labels.ts +30 -0
- package/packages/bundled-features/src/audit/web/client-plugin.tsx +10 -1
- package/packages/bundled-features/src/audit/web/constants.ts +11 -0
- package/packages/bundled-features/src/audit/web/i18n.ts +48 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/login-options.query.ts +19 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +7 -1
- package/packages/bundled-features/src/auth-email-password/{auth-paths.ts → web/auth-paths.ts} +7 -4
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +7 -0
- package/packages/bundled-features/src/auth-email-password/web/constants.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +31 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +68 -42
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/jobs/feature.ts +34 -127
- package/packages/bundled-features/src/jobs/handlers/consumer.write.ts +98 -0
- package/packages/bundled-features/src/jobs/handlers/consumers.query.ts +137 -0
- package/packages/bundled-features/src/jobs/screens.ts +262 -0
- package/packages/bundled-features/src/jobs/web/constants.ts +29 -0
- package/packages/bundled-features/src/jobs/web/i18n.ts +41 -0
- package/packages/bundled-features/src/secrets/i18n.ts +0 -3
- package/packages/bundled-features/src/sessions/changes.json +6 -0
- package/packages/bundled-features/src/sessions/constants.ts +3 -1
- package/packages/bundled-features/src/sessions/handlers/detail.query.ts +7 -3
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +16 -10
- package/packages/bundled-features/src/sessions/handlers/mine.query.ts +15 -9
- package/packages/bundled-features/src/sessions/screens.ts +23 -8
- package/packages/bundled-features/src/sessions/web/client-plugin.tsx +17 -0
- package/packages/bundled-features/src/sessions/web/constants.ts +3 -0
- package/packages/bundled-features/src/sessions/web/default-translations.ts +8 -0
- package/packages/bundled-features/src/sessions/web/i18n.ts +5 -1
- package/packages/bundled-features/src/sessions/web/index.ts +1 -0
- package/packages/bundled-features/src/sessions/web/session-device-cell.tsx +19 -0
- package/packages/bundled-features/src/sessions/web/user-agent.ts +74 -0
- package/packages/bundled-features/src/template-resolver/changes.json +6 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +34 -1
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +53 -0
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +51 -0
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +7 -38
- package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +18 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +33 -33
- package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -1
- package/samples/apps/showcase/src/features/items/schema/item.ts +8 -2
- package/samples/apps/ui-walkthrough/src/features/tasks/schema/task.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +4 -0
- package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -1
- package/samples/apps/use-all-bundled/src/app/invoice-reports-feature.ts +30 -0
- package/samples/apps/use-all-bundled/src/app/seed.ts +51 -0
- package/samples/apps/workspaces/src/features/demo/schema/order.ts +2 -2
- package/samples/recipes/custom-fields-basic/src/feature.ts +13 -3
- package/samples/recipes/designer-demo/src/feature.ts +6 -2
|
@@ -8,6 +8,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
|
8
8
|
import * as z from "zod";
|
|
9
9
|
import { decryptStoredPii } from "../../shared/index.ts";
|
|
10
10
|
import { userSessionTable } from "../schema/user-session.ts";
|
|
11
|
+
import { parsedDeviceSchema, parseUserAgent } from "../web/user-agent.ts";
|
|
11
12
|
|
|
12
13
|
// `sort` arrives raw from the client's query string. selectMany's orderBy
|
|
13
14
|
// has no column-existence check — an unrecognised field just gets
|
|
@@ -47,6 +48,7 @@ export const createListQuery = (adminRoles: readonly string[] = access.admin) =>
|
|
|
47
48
|
revokedAt: z.unknown(),
|
|
48
49
|
ip: z.string().nullable(),
|
|
49
50
|
userAgent: z.string().nullable(),
|
|
51
|
+
device: parsedDeviceSchema,
|
|
50
52
|
}),
|
|
51
53
|
),
|
|
52
54
|
nextCursor: z.string().nullable(),
|
|
@@ -79,17 +81,21 @@ export const createListQuery = (adminRoles: readonly string[] = access.admin) =>
|
|
|
79
81
|
...(query.payload.limit !== undefined && { limit: query.payload.limit }),
|
|
80
82
|
});
|
|
81
83
|
const decryptedRows = await Promise.all(
|
|
82
|
-
rows.map(async (r) =>
|
|
83
|
-
|
|
84
|
-
userId: r.userId,
|
|
85
|
-
createdAt: r.createdAt,
|
|
86
|
-
expiresAt: r.expiresAt,
|
|
87
|
-
revokedAt: r.revokedAt,
|
|
88
|
-
ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
|
|
89
|
-
userAgent: r.userAgent
|
|
84
|
+
rows.map(async (r) => {
|
|
85
|
+
const userAgent = r.userAgent
|
|
90
86
|
? await decryptStoredPii(r.userAgent, "userAgent", "sessions:list")
|
|
91
|
-
: r.userAgent
|
|
92
|
-
|
|
87
|
+
: r.userAgent;
|
|
88
|
+
return {
|
|
89
|
+
id: r.id,
|
|
90
|
+
userId: r.userId,
|
|
91
|
+
createdAt: r.createdAt,
|
|
92
|
+
expiresAt: r.expiresAt,
|
|
93
|
+
revokedAt: r.revokedAt,
|
|
94
|
+
ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
|
|
95
|
+
userAgent,
|
|
96
|
+
device: parseUserAgent(userAgent),
|
|
97
|
+
};
|
|
98
|
+
}),
|
|
93
99
|
);
|
|
94
100
|
return { rows: decryptedRows, nextCursor: null };
|
|
95
101
|
},
|
|
@@ -3,6 +3,7 @@ import { definePagedQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { decryptStoredPii } from "../../shared/index.ts";
|
|
5
5
|
import { userSessionTable } from "../schema/user-session.ts";
|
|
6
|
+
import { parsedDeviceSchema, parseUserAgent } from "../web/user-agent.ts";
|
|
6
7
|
|
|
7
8
|
// "My live sessions" — the backing data for a devices/sessions UI. Returns
|
|
8
9
|
// ONLY the current user's own, currently-live sessions, ordered by most-
|
|
@@ -29,6 +30,7 @@ export const mineQuery = definePagedQueryHandler({
|
|
|
29
30
|
expiresAt: z.unknown(),
|
|
30
31
|
ip: z.string().nullable(),
|
|
31
32
|
userAgent: z.string().nullable(),
|
|
33
|
+
device: parsedDeviceSchema,
|
|
32
34
|
current: z.boolean(),
|
|
33
35
|
}),
|
|
34
36
|
),
|
|
@@ -51,16 +53,20 @@ export const mineQuery = definePagedQueryHandler({
|
|
|
51
53
|
);
|
|
52
54
|
const currentSid = query.user.sid;
|
|
53
55
|
const decryptedRows = await Promise.all(
|
|
54
|
-
rows.map(async (r) =>
|
|
55
|
-
|
|
56
|
-
createdAt: r.createdAt,
|
|
57
|
-
expiresAt: r.expiresAt,
|
|
58
|
-
ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:mine") : r.ip,
|
|
59
|
-
userAgent: r.userAgent
|
|
56
|
+
rows.map(async (r) => {
|
|
57
|
+
const userAgent = r.userAgent
|
|
60
58
|
? await decryptStoredPii(r.userAgent, "userAgent", "sessions:mine")
|
|
61
|
-
: r.userAgent
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
: r.userAgent;
|
|
60
|
+
return {
|
|
61
|
+
id: r.id,
|
|
62
|
+
createdAt: r.createdAt,
|
|
63
|
+
expiresAt: r.expiresAt,
|
|
64
|
+
ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:mine") : r.ip,
|
|
65
|
+
userAgent,
|
|
66
|
+
device: parseUserAgent(userAgent),
|
|
67
|
+
current: currentSid === r.id,
|
|
68
|
+
};
|
|
69
|
+
}),
|
|
64
70
|
);
|
|
65
71
|
return { rows: decryptedRows, nextCursor: null };
|
|
66
72
|
},
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
SessionHandlers,
|
|
13
13
|
SessionQueries,
|
|
14
14
|
} from "./constants.ts";
|
|
15
|
+
import { SESSION_DEVICE_CELL_COMPONENT } from "./web/constants.ts";
|
|
16
|
+
|
|
17
|
+
const DEVICE_RENDERER = { react: { __component: SESSION_DEVICE_CELL_COMPONENT } } as const;
|
|
15
18
|
|
|
16
19
|
export const createSessionListScreen = (
|
|
17
20
|
adminRoles: readonly string[] = access.admin,
|
|
@@ -35,6 +38,11 @@ export const createSessionListScreen = (
|
|
|
35
38
|
refEntity: "user:user",
|
|
36
39
|
refLabelField: "displayName",
|
|
37
40
|
},
|
|
41
|
+
{
|
|
42
|
+
field: "device",
|
|
43
|
+
label: i18nKey("sessions.list.col.device"),
|
|
44
|
+
renderer: DEVICE_RENDERER,
|
|
45
|
+
},
|
|
38
46
|
{
|
|
39
47
|
field: "createdAt",
|
|
40
48
|
label: i18nKey("sessions.list.col.createdAt"),
|
|
@@ -43,6 +51,7 @@ export const createSessionListScreen = (
|
|
|
43
51
|
{
|
|
44
52
|
field: "expiresAt",
|
|
45
53
|
label: i18nKey("sessions.list.col.expiresAt"),
|
|
54
|
+
hideOnNarrow: true,
|
|
46
55
|
renderer: { format: "timestamp" },
|
|
47
56
|
},
|
|
48
57
|
{
|
|
@@ -85,6 +94,7 @@ export const createSessionDetailScreen = (
|
|
|
85
94
|
{ field: "createdAt", renderer: { format: "timestamp" } },
|
|
86
95
|
{ field: "expiresAt", renderer: { format: "timestamp" } },
|
|
87
96
|
{ field: "revokedAt", renderer: { format: "timestamp" } },
|
|
97
|
+
{ field: "device", renderer: DEVICE_RENDERER },
|
|
88
98
|
"ip",
|
|
89
99
|
"userAgent",
|
|
90
100
|
],
|
|
@@ -97,6 +107,7 @@ export const createSessionDetailScreen = (
|
|
|
97
107
|
createdAt: i18nKey("sessions.detail.field.createdAt"),
|
|
98
108
|
expiresAt: i18nKey("sessions.detail.field.expiresAt"),
|
|
99
109
|
revokedAt: i18nKey("sessions.detail.field.revokedAt"),
|
|
110
|
+
device: i18nKey("sessions.list.col.device"),
|
|
100
111
|
ip: i18nKey("sessions.detail.field.ip"),
|
|
101
112
|
userAgent: i18nKey("sessions.detail.field.userAgent"),
|
|
102
113
|
},
|
|
@@ -109,6 +120,17 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
|
|
|
109
120
|
type: "projectionList",
|
|
110
121
|
query: SessionQueries.mine,
|
|
111
122
|
columns: [
|
|
123
|
+
{ field: "device", label: i18nKey("sessions.mine.col.userAgent"), renderer: DEVICE_RENDERER },
|
|
124
|
+
{ field: "ip", label: i18nKey("sessions.mine.col.ip") },
|
|
125
|
+
{
|
|
126
|
+
field: "current",
|
|
127
|
+
label: i18nKey("sessions.mine.col.current"),
|
|
128
|
+
renderer: {
|
|
129
|
+
format: "boolean",
|
|
130
|
+
trueLabel: i18nKey("sessions.mine.current.yes"),
|
|
131
|
+
falseLabel: i18nKey("sessions.mine.current.no"),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
112
134
|
{
|
|
113
135
|
field: "createdAt",
|
|
114
136
|
label: i18nKey("sessions.list.col.createdAt"),
|
|
@@ -117,15 +139,8 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
|
|
|
117
139
|
{
|
|
118
140
|
field: "expiresAt",
|
|
119
141
|
label: i18nKey("sessions.list.col.expiresAt"),
|
|
120
|
-
renderer: { format: "timestamp" },
|
|
121
|
-
},
|
|
122
|
-
{ field: "ip", label: i18nKey("sessions.mine.col.ip") },
|
|
123
|
-
{ field: "userAgent", label: i18nKey("sessions.mine.col.userAgent") },
|
|
124
|
-
{
|
|
125
|
-
field: "current",
|
|
126
|
-
label: i18nKey("sessions.mine.col.current"),
|
|
127
142
|
hideOnNarrow: true,
|
|
128
|
-
renderer: { format: "
|
|
143
|
+
renderer: { format: "timestamp" },
|
|
129
144
|
},
|
|
130
145
|
],
|
|
131
146
|
rowActions: [
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
3
|
+
import { SESSION_DEVICE_CELL_COMPONENT, SESSIONS_FEATURE } from "./constants.ts";
|
|
4
|
+
import { defaultTranslations } from "./default-translations.ts";
|
|
5
|
+
import { SessionDeviceCell } from "./session-device-cell.tsx";
|
|
6
|
+
|
|
7
|
+
export type SessionsClientOptions = {
|
|
8
|
+
readonly translations?: TranslationsByLocale;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function sessionsClient(options?: SessionsClientOptions): ClientFeatureDefinition {
|
|
12
|
+
return {
|
|
13
|
+
name: SESSIONS_FEATURE,
|
|
14
|
+
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
15
|
+
columnRenderers: { [SESSION_DEVICE_CELL_COMPONENT]: SessionDeviceCell },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -22,7 +22,11 @@ export const SESSIONS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
22
22
|
"screen:my-sessions.title": { en: "Your sessions" },
|
|
23
23
|
"sessions.mine.col.ip": { en: "IP address" },
|
|
24
24
|
"sessions.mine.col.userAgent": { en: "Device" },
|
|
25
|
-
"sessions.mine.col.current": { en: "
|
|
25
|
+
"sessions.mine.col.current": { en: "Session" },
|
|
26
|
+
"sessions.mine.current.yes": { en: "This session" },
|
|
27
|
+
"sessions.mine.current.no": { en: "-" },
|
|
28
|
+
"sessions.list.col.device": { en: "Device" },
|
|
29
|
+
"sessions.device.unknown": { en: "Unknown device" },
|
|
26
30
|
"sessions.mine.revoke": { en: "Sign out" },
|
|
27
31
|
"sessions.mine.revoke.confirm": { en: "Sign this device out?" },
|
|
28
32
|
"sessions.mine.revokeAllOthers": { en: "Sign out all other devices" },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type SessionsClientOptions, sessionsClient } from "./client-plugin.tsx";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Column renderer for the parsed `device` of a session row, registered via `sessionsClient()`.
|
|
2
|
+
|
|
3
|
+
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { parsedDeviceSchema } from "./user-agent.ts";
|
|
6
|
+
|
|
7
|
+
export function SessionDeviceCell({ value }: ColumnRendererProps): ReactNode {
|
|
8
|
+
const t = useTranslation();
|
|
9
|
+
const parsed = parsedDeviceSchema.safeParse(value);
|
|
10
|
+
const device = parsed.success ? parsed.data : null;
|
|
11
|
+
if (device === null) return t("sessions.device.unknown");
|
|
12
|
+
const browser =
|
|
13
|
+
device.browser === null
|
|
14
|
+
? null
|
|
15
|
+
: device.browserMajor === null
|
|
16
|
+
? device.browser
|
|
17
|
+
: `${device.browser} ${device.browserMajor}`;
|
|
18
|
+
return [browser, device.os].filter((part) => part !== null).join(" · ");
|
|
19
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
|
|
3
|
+
export const USER_AGENT_BROWSERS = [
|
|
4
|
+
"Edge",
|
|
5
|
+
"Opera",
|
|
6
|
+
"Samsung Internet",
|
|
7
|
+
"Firefox",
|
|
8
|
+
"Chrome",
|
|
9
|
+
"Safari",
|
|
10
|
+
] as const;
|
|
11
|
+
export const USER_AGENT_OPERATING_SYSTEMS = [
|
|
12
|
+
"Windows",
|
|
13
|
+
"iOS",
|
|
14
|
+
"Android",
|
|
15
|
+
"macOS",
|
|
16
|
+
"ChromeOS",
|
|
17
|
+
"Linux",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type UserAgentBrowser = (typeof USER_AGENT_BROWSERS)[number];
|
|
21
|
+
export type UserAgentOperatingSystem = (typeof USER_AGENT_OPERATING_SYSTEMS)[number];
|
|
22
|
+
|
|
23
|
+
export const parsedDeviceSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
browser: z.enum(USER_AGENT_BROWSERS).nullable(),
|
|
26
|
+
browserMajor: z.number().int().nullable(),
|
|
27
|
+
os: z.enum(USER_AGENT_OPERATING_SYSTEMS).nullable(),
|
|
28
|
+
})
|
|
29
|
+
.nullable();
|
|
30
|
+
|
|
31
|
+
export type ParsedDevice = z.infer<typeof parsedDeviceSchema>;
|
|
32
|
+
|
|
33
|
+
// Order matters: Chromium forks and iOS Chrome/Firefox also carry the engine tokens of the browsers they derive from.
|
|
34
|
+
const BROWSER_PATTERNS: readonly {
|
|
35
|
+
readonly browser: UserAgentBrowser;
|
|
36
|
+
readonly pattern: RegExp;
|
|
37
|
+
}[] = [
|
|
38
|
+
{ browser: "Edge", pattern: /\bEdg(?:e|A|iOS)?\/(\d+)/ },
|
|
39
|
+
{ browser: "Opera", pattern: /\bOPR\/(\d+)/ },
|
|
40
|
+
{ browser: "Samsung Internet", pattern: /\bSamsungBrowser\/(\d+)/ },
|
|
41
|
+
{ browser: "Firefox", pattern: /\b(?:Firefox|FxiOS)\/(\d+)/ },
|
|
42
|
+
{ browser: "Chrome", pattern: /\b(?:Chrome|CriOS)\/(\d+)/ },
|
|
43
|
+
{ browser: "Safari", pattern: /\bVersion\/(\d+)[\s\S]*\bSafari\// },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// iPhone/iPad UAs say "like Mac OS X" and Android UAs say "Linux", so the more specific systems come first.
|
|
47
|
+
const OPERATING_SYSTEM_PATTERNS: readonly {
|
|
48
|
+
readonly os: UserAgentOperatingSystem;
|
|
49
|
+
readonly pattern: RegExp;
|
|
50
|
+
}[] = [
|
|
51
|
+
{ os: "Windows", pattern: /\bWindows\b/ },
|
|
52
|
+
{ os: "iOS", pattern: /\b(?:iPhone|iPad|iPod)\b/ },
|
|
53
|
+
{ os: "Android", pattern: /\bAndroid\b/ },
|
|
54
|
+
{ os: "macOS", pattern: /\bMacintosh\b/ },
|
|
55
|
+
{ os: "ChromeOS", pattern: /\bCrOS\b/ },
|
|
56
|
+
{ os: "Linux", pattern: /\bLinux\b/ },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export function parseUserAgent(userAgent: string | null): ParsedDevice {
|
|
60
|
+
if (userAgent === null || userAgent === "") return null;
|
|
61
|
+
let browser: UserAgentBrowser | null = null;
|
|
62
|
+
let browserMajor: number | null = null;
|
|
63
|
+
for (const candidate of BROWSER_PATTERNS) {
|
|
64
|
+
const match = candidate.pattern.exec(userAgent);
|
|
65
|
+
if (match) {
|
|
66
|
+
browser = candidate.browser;
|
|
67
|
+
browserMajor = match[1] === undefined ? null : Number(match[1]);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const os =
|
|
72
|
+
OPERATING_SYSTEM_PATTERNS.find((candidate) => candidate.pattern.test(userAgent))?.os ?? null;
|
|
73
|
+
return browser === null && os === null ? null : { browser, browserMajor, os };
|
|
74
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.384.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Template overview reports tenant overrides per template",
|
|
6
|
+
"detail": "template-resolver has a SystemAdmin query tenant-override-counts, and the overview rows for system templates carry overriddenBy { active, total } for SystemAdmins. The designer's overview reads it instead of issuing one tenant-overrides query per row. Reading it uses a declared unsafeRaw escape hatch because it counts across tenants."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.379.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -31,6 +31,7 @@ import { makeResourceUploadWrite } from "./handlers/resource-upload.write.ts";
|
|
|
31
31
|
import { makeSendTestWrite } from "./handlers/send-test.write.ts";
|
|
32
32
|
import { sendTestStatusQuery } from "./handlers/send-test-status.query.ts";
|
|
33
33
|
import { makeSetWrite } from "./handlers/set.write.ts";
|
|
34
|
+
import { tenantOverrideCountsQuery } from "./handlers/tenant-override-counts.query.ts";
|
|
34
35
|
import { tenantOverridesQuery } from "./handlers/tenant-overrides.query.ts";
|
|
35
36
|
import {
|
|
36
37
|
makeArchiveSystemWrite,
|
|
@@ -184,6 +185,7 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
184
185
|
overview: r.queryHandler(makeOverviewQuery(policy)),
|
|
185
186
|
layoutSource: r.queryHandler(layoutSourceQuery),
|
|
186
187
|
tenantOverrides: r.queryHandler(tenantOverridesQuery),
|
|
188
|
+
tenantOverrideCounts: r.queryHandler(tenantOverrideCountsQuery),
|
|
187
189
|
diffToSystem: r.queryHandler(diffToSystemQuery),
|
|
188
190
|
validate: r.queryHandler(validateQuery),
|
|
189
191
|
preview: r.queryHandler(previewQuery),
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
activeVersionOf,
|
|
13
13
|
draftViewerTenantId,
|
|
14
14
|
isHiddenSystemDraft,
|
|
15
|
+
isSystemAdmin,
|
|
15
16
|
parseTemplateDraft,
|
|
16
17
|
servedVersionOf,
|
|
17
18
|
} from "../template-draft.ts";
|
|
@@ -23,7 +24,9 @@ import {
|
|
|
23
24
|
FALLBACK_LOCALE,
|
|
24
25
|
type TemplateStatus,
|
|
25
26
|
} from "../web/constants.ts";
|
|
27
|
+
import { TemplateResolverQueries } from "../web/qualified-names.ts";
|
|
26
28
|
import { storedRecord } from "./template-gates.ts";
|
|
29
|
+
import { tenantOverrideCountsResultSchema } from "./tenant-override-counts.query.ts";
|
|
27
30
|
|
|
28
31
|
const OVERVIEW_KINDS = ["mail", "document-pdf", "mail-layout", "pdf-layout"] as const;
|
|
29
32
|
type OverviewKind = (typeof OVERVIEW_KINDS)[number];
|
|
@@ -65,6 +68,8 @@ export type OverviewRow = {
|
|
|
65
68
|
readonly modifiedAt: string;
|
|
66
69
|
readonly usedBy: readonly OverviewUsage[];
|
|
67
70
|
readonly usedByCount: number;
|
|
71
|
+
/** SystemAdmin only, system-origin rows: tenants overriding this template; total is null without the tenant feature. */
|
|
72
|
+
readonly overriddenBy?: { readonly active: number; readonly total: number | null };
|
|
68
73
|
};
|
|
69
74
|
|
|
70
75
|
export type OverviewResponse = {
|
|
@@ -308,6 +313,26 @@ function withUsage(
|
|
|
308
313
|
return { ...draft, usedBy, usedByCount: usedBy.length };
|
|
309
314
|
}
|
|
310
315
|
|
|
316
|
+
function withOverriddenBy(
|
|
317
|
+
rows: readonly OverviewRow[],
|
|
318
|
+
overrides: z.infer<typeof tenantOverrideCountsResultSchema>,
|
|
319
|
+
): OverviewRow[] {
|
|
320
|
+
const activeByKey = new Map(
|
|
321
|
+
overrides.counts.map((entry) => [`${entry.kind}|${entry.slug}`, entry.activeOverrides]),
|
|
322
|
+
);
|
|
323
|
+
return rows.map((row) =>
|
|
324
|
+
row.origin === "system"
|
|
325
|
+
? {
|
|
326
|
+
...row,
|
|
327
|
+
overriddenBy: {
|
|
328
|
+
active: activeByKey.get(`${row.kind}|${row.slug}`) ?? 0,
|
|
329
|
+
total: overrides.totalTenants,
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
: row,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
311
336
|
function filterRows(
|
|
312
337
|
rows: readonly OverviewRow[],
|
|
313
338
|
search: string | undefined,
|
|
@@ -373,9 +398,17 @@ export function makeOverviewQuery(policy: TemplateEditPolicy) {
|
|
|
373
398
|
deliveryPolicy,
|
|
374
399
|
);
|
|
375
400
|
const effectiveLayouts = effectiveMailLayoutSlugs(visibleRows, deliveryPolicy);
|
|
376
|
-
const
|
|
401
|
+
const rowsWithUsage = drafts.map((draft) =>
|
|
377
402
|
withUsage(draft, layoutSlugByDocument, ctx.registry, effectiveLayouts),
|
|
378
403
|
);
|
|
404
|
+
const overviewRows = isSystemAdmin(query.user)
|
|
405
|
+
? withOverriddenBy(
|
|
406
|
+
rowsWithUsage,
|
|
407
|
+
tenantOverrideCountsResultSchema.parse(
|
|
408
|
+
await ctx.query(TemplateResolverQueries.tenantOverrideCounts, {}),
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
: rowsWithUsage;
|
|
379
412
|
const filtered = filterRows(
|
|
380
413
|
overviewRows,
|
|
381
414
|
payload.search,
|
package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
|
|
5
|
+
import {
|
|
6
|
+
loadEnabledTenantIds,
|
|
7
|
+
pickBestRowPerTenant,
|
|
8
|
+
TENANT_OVERRIDES_REASON,
|
|
9
|
+
} from "./tenant-override-selection.ts";
|
|
10
|
+
|
|
11
|
+
export const tenantOverrideCountsResultSchema = z.object({
|
|
12
|
+
totalTenants: z.number().nullable(),
|
|
13
|
+
counts: z.array(z.object({ slug: z.string(), kind: z.string(), activeOverrides: z.number() })),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const tenantOverrideCountsQuery = defineQueryHandler({
|
|
17
|
+
name: "tenant-override-counts",
|
|
18
|
+
schema: z.object({}),
|
|
19
|
+
access: { roles: ["SystemAdmin"] },
|
|
20
|
+
description:
|
|
21
|
+
"Counts, per template slug and kind, the enabled tenants that override it with an active version, next to the number of enabled tenants; one query for the whole template overview instead of one tenant-overrides call per row.",
|
|
22
|
+
escapeHatch: { grants: ["unsafeRaw"], reason: TENANT_OVERRIDES_REASON },
|
|
23
|
+
handler: async (_query, ctx) => {
|
|
24
|
+
const found = await selectMany<TemplateResourceRow>(
|
|
25
|
+
ctx.db.unsafeRaw(),
|
|
26
|
+
templateResourcesTable,
|
|
27
|
+
{
|
|
28
|
+
scope: "tenant",
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
const enabledTenantIds = await loadEnabledTenantIds(ctx);
|
|
32
|
+
|
|
33
|
+
const rowsBySlugAndKind = new Map<
|
|
34
|
+
string,
|
|
35
|
+
{ slug: string; kind: string; rows: TemplateResourceRow[] }
|
|
36
|
+
>();
|
|
37
|
+
for (const row of found) {
|
|
38
|
+
const key = `${row.kind}|${row.slug}`;
|
|
39
|
+
const group = rowsBySlugAndKind.get(key) ?? { slug: row.slug, kind: row.kind, rows: [] };
|
|
40
|
+
group.rows.push(row);
|
|
41
|
+
rowsBySlugAndKind.set(key, group);
|
|
42
|
+
}
|
|
43
|
+
const counts = [...rowsBySlugAndKind.values()].map(({ slug, kind, rows }) => ({
|
|
44
|
+
slug,
|
|
45
|
+
kind,
|
|
46
|
+
activeOverrides: [...pickBestRowPerTenant(rows, enabledTenantIds).values()].filter(
|
|
47
|
+
(row) => row.status === "active",
|
|
48
|
+
).length,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
return { totalTenants: enabledTenantIds === null ? null : enabledTenantIds.size, counts };
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { TenantQueries } from "../../tenant/index.ts";
|
|
4
|
+
import type { TemplateResourceRow } from "../table.ts";
|
|
5
|
+
|
|
6
|
+
export const TENANT_OVERRIDES_REASON =
|
|
7
|
+
"SystemAdmin template overview lists which tenants override one system template; it reads tenant-scope template rows of every tenant (and the system row for the optional per-tenant diff), which the tenant-scoped db cannot reach";
|
|
8
|
+
|
|
9
|
+
const tenantIdsSchema = z.array(z.string());
|
|
10
|
+
|
|
11
|
+
type TenantIdQueryContext = {
|
|
12
|
+
readonly registry: { getQueryHandler(name: string): unknown };
|
|
13
|
+
readonly query: (name: string, payload: Record<string, never>) => Promise<unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Null when the tenant feature is not installed: every tenant row then counts. */
|
|
17
|
+
export async function loadEnabledTenantIds(
|
|
18
|
+
ctx: TenantIdQueryContext,
|
|
19
|
+
): Promise<ReadonlySet<string> | null> {
|
|
20
|
+
if (ctx.registry.getQueryHandler(TenantQueries.activeTenantIds) === undefined) return null;
|
|
21
|
+
return new Set(tenantIdsSchema.parse(await ctx.query(TenantQueries.activeTenantIds, {})));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// One row per tenant: its active override wins over a draft, then the newest version.
|
|
25
|
+
function isBetterRow(candidate: TemplateResourceRow, current: TemplateResourceRow): boolean {
|
|
26
|
+
if ((candidate.status === "active") !== (current.status === "active")) {
|
|
27
|
+
return candidate.status === "active";
|
|
28
|
+
}
|
|
29
|
+
return candidate.version > current.version;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isCountedTenantRow(
|
|
33
|
+
row: TemplateResourceRow,
|
|
34
|
+
enabledTenantIds: ReadonlySet<string> | null,
|
|
35
|
+
): boolean {
|
|
36
|
+
if (row.tenantId === SYSTEM_TENANT_ID || row.status === "archived") return false;
|
|
37
|
+
return enabledTenantIds === null || enabledTenantIds.has(row.tenantId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function pickBestRowPerTenant(
|
|
41
|
+
found: readonly TemplateResourceRow[],
|
|
42
|
+
enabledTenantIds: ReadonlySet<string> | null,
|
|
43
|
+
): Map<string, TemplateResourceRow> {
|
|
44
|
+
const bestByTenant = new Map<string, TemplateResourceRow>();
|
|
45
|
+
for (const row of found) {
|
|
46
|
+
if (!isCountedTenantRow(row, enabledTenantIds)) continue;
|
|
47
|
+
const current = bestByTenant.get(row.tenantId);
|
|
48
|
+
if (current === undefined || isBetterRow(row, current)) bestByTenant.set(row.tenantId, row);
|
|
49
|
+
}
|
|
50
|
+
return bestByTenant;
|
|
51
|
+
}
|
|
@@ -6,11 +6,12 @@ import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
|
|
|
6
6
|
import { servedVersionOf } from "../template-draft.ts";
|
|
7
7
|
import { buildOverrideDiff } from "./override-diff.ts";
|
|
8
8
|
import { kindSchema, localeSchema, templateSlugSchema } from "./shared.ts";
|
|
9
|
+
import {
|
|
10
|
+
loadEnabledTenantIds,
|
|
11
|
+
pickBestRowPerTenant,
|
|
12
|
+
TENANT_OVERRIDES_REASON,
|
|
13
|
+
} from "./tenant-override-selection.ts";
|
|
9
14
|
|
|
10
|
-
const TENANT_OVERRIDES_REASON =
|
|
11
|
-
"SystemAdmin template overview lists which tenants override one system template; it reads tenant-scope template rows of every tenant (and the system row for the optional per-tenant diff), which the tenant-scoped db cannot reach";
|
|
12
|
-
|
|
13
|
-
const tenantIdsSchema = z.array(z.string());
|
|
14
15
|
const directorySchema = z.object({
|
|
15
16
|
rows: z.array(z.object({ id: z.string(), label: z.string() })),
|
|
16
17
|
});
|
|
@@ -21,39 +22,10 @@ function labelsOf(raw: unknown): ReadonlyMap<string, string> {
|
|
|
21
22
|
return new Map(directorySchema.parse(raw).rows.map((row) => [row.id, row.label]));
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
// One row per tenant: its active override wins over a draft, then the newest version.
|
|
25
|
-
function isBetterRow(candidate: TemplateResourceRow, current: TemplateResourceRow): boolean {
|
|
26
|
-
if ((candidate.status === "active") !== (current.status === "active")) {
|
|
27
|
-
return candidate.status === "active";
|
|
28
|
-
}
|
|
29
|
-
return candidate.version > current.version;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
25
|
function statusOf(row: TemplateResourceRow): OverrideStatus {
|
|
33
26
|
return row.status === "active" ? "active" : "draft";
|
|
34
27
|
}
|
|
35
28
|
|
|
36
|
-
function isCountedTenantRow(
|
|
37
|
-
row: TemplateResourceRow,
|
|
38
|
-
enabledTenantIds: ReadonlySet<string> | null,
|
|
39
|
-
): boolean {
|
|
40
|
-
if (row.tenantId === SYSTEM_TENANT_ID || row.status === "archived") return false;
|
|
41
|
-
return enabledTenantIds === null || enabledTenantIds.has(row.tenantId);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function pickBestRowPerTenant(
|
|
45
|
-
found: readonly TemplateResourceRow[],
|
|
46
|
-
enabledTenantIds: ReadonlySet<string> | null,
|
|
47
|
-
): Map<string, TemplateResourceRow> {
|
|
48
|
-
const bestByTenant = new Map<string, TemplateResourceRow>();
|
|
49
|
-
for (const row of found) {
|
|
50
|
-
if (!isCountedTenantRow(row, enabledTenantIds)) continue;
|
|
51
|
-
const current = bestByTenant.get(row.tenantId);
|
|
52
|
-
if (current === undefined || isBetterRow(row, current)) bestByTenant.set(row.tenantId, row);
|
|
53
|
-
}
|
|
54
|
-
return bestByTenant;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
29
|
function toOverrideRow(
|
|
58
30
|
row: TemplateResourceRow,
|
|
59
31
|
tenantLabels: ReadonlyMap<string, string>,
|
|
@@ -95,11 +67,8 @@ export const tenantOverridesQuery = defineQueryHandler({
|
|
|
95
67
|
...(locale === undefined ? {} : { locale }),
|
|
96
68
|
});
|
|
97
69
|
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
const enabledTenantIds = hasTenantFeature
|
|
101
|
-
? new Set(tenantIdsSchema.parse(await ctx.query(TenantQueries.activeTenantIds, {})))
|
|
102
|
-
: null;
|
|
70
|
+
const enabledTenantIds = await loadEnabledTenantIds(ctx);
|
|
71
|
+
const hasTenantFeature = enabledTenantIds !== null;
|
|
103
72
|
|
|
104
73
|
const bestByTenant = pickBestRowPerTenant(found, enabledTenantIds);
|
|
105
74
|
const best = [...bestByTenant.values()];
|
|
@@ -29,6 +29,7 @@ export const TemplateResolverQueries = {
|
|
|
29
29
|
overview: "template-resolver:query:overview",
|
|
30
30
|
layoutSource: "template-resolver:query:layout-source",
|
|
31
31
|
tenantOverrides: "template-resolver:query:tenant-overrides",
|
|
32
|
+
tenantOverrideCounts: "template-resolver:query:tenant-override-counts",
|
|
32
33
|
diffToSystem: "template-resolver:query:diff-to-system",
|
|
33
34
|
validate: "template-resolver:query:validate",
|
|
34
35
|
preview: "template-resolver:query:preview",
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.384.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "privacy center boot test fixture declares a field stance",
|
|
6
|
+
"detail": "Test-only change. Two changes merged back to back on main: one added the privacy center deletion test with plain text fixture fields, the other made a personal-data stance mandatory on entity text fields. The fixture fields now state allowPlaintext, so the framework type check passes again."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.384.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Privacy center keeps the deletion section and speaks to the user",
|
|
12
|
+
"detail": "The deletion section only held a conditional field and the request-deletion and cancel-deletion actions, so the renderer dropped it as empty. It now stays whenever it has a visible action. The subtitle is written for the person on the page and names Art. 20, 18 and (when deletion is configured) 17."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.384.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Privacy center subtitle no longer mentions deletion when it is hidden",
|
|
18
|
+
"detail": "With privacyCenterShowDeletion false the screen description uses the new key userDataRights.privacyCenter.subtitleWithoutDeletion (English, German, French, Spanish and Italian provided) instead of the text that names Art. 17."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.379.0",
|
|
4
22
|
"type": "improvement",
|