@k2b/cloud 0.29.0 → 0.30.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 +5 -3
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +111 -3
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +10 -1
- package/src/_internal/registry-validation.ts +5 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +203 -185
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +12 -26
- package/src/ai/chat/tool-disclosure.tsx +21 -14
- package/src/ai/chat/tool-groups.ts +64 -37
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +4 -4
- package/src/ai/chat/turn-view.tsx +141 -69
- package/src/ai/code-runtime-tools.ts +59 -44
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +19 -21
- package/src/ai/index.ts +9 -1
- package/src/ai/migrate.ts +59 -12
- package/src/ai/protocol.ts +9 -4
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +20 -13
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/types.ts +10 -2
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/admin-outgoing-mail.ts +18 -0
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +259 -100
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/widgets.ts +28 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/admin/outgoing-mail.ts +30 -2
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/outgoing-mail.ts +56 -11
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +20 -0
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/services/accounts/users.ts +15 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/services/outbox.ts +24 -9
- package/src/services/outgoing-mail/index.ts +33 -5
- package/src/services/outgoing-mail/messages.ts +60 -19
- package/src/services/outgoing-mail/store.ts +63 -4
- package/src/shared/network-address.ts +1 -0
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +142 -0
- package/src/styles/utilities-markdown-table.css +5 -1
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import {
|
|
5
5
|
type AdminMailFilter,
|
|
6
6
|
type AdminMailRecord,
|
|
7
|
+
type MailFilter,
|
|
7
8
|
type MailMessage,
|
|
8
9
|
type MailPage,
|
|
9
10
|
type MailPageParams,
|
|
@@ -86,16 +87,17 @@ type LogRow = Pick<
|
|
|
86
87
|
| "sent_at"
|
|
87
88
|
| "content_purged_at"
|
|
88
89
|
>;
|
|
89
|
-
const logColumns = (db: SQL,
|
|
90
|
-
${
|
|
90
|
+
const logColumns = (db: SQL, ownApp?: string) => db`id, app_id, profile_key, batch_id, ref_scope, ref_id, to_addresses, subject,
|
|
91
|
+
${ownApp === undefined ? db`NULL::text AS text_body` : db`CASE WHEN app_id = ${ownApp} THEN text_body END AS text_body`}, attachments, status, error_code, error_message, smtp_response,
|
|
91
92
|
failures, attempt_count, actor_id, actor_name, created_at, created_at::text AS cursor_created_at, sent_at, content_purged_at`;
|
|
92
93
|
const metadata = async (id: string): Promise<AdminMailRecord | undefined> => {
|
|
93
|
-
const [row] = await sql<LogRow[]>`SELECT ${logColumns(sql
|
|
94
|
+
const [row] = await sql<LogRow[]>`SELECT ${logColumns(sql)} FROM outgoing_mail.messages WHERE id = ${id}::uuid`;
|
|
94
95
|
return row ? adminMessageRecord(row) : undefined;
|
|
95
96
|
};
|
|
96
97
|
const date = (value: Date | string) => new Date(value).toISOString();
|
|
97
98
|
export const messageRecord = (row: LogRow): MailRecord => ({
|
|
98
99
|
id: row.id,
|
|
100
|
+
appId: row.app_id,
|
|
99
101
|
profile: row.profile_key,
|
|
100
102
|
to: row.to_addresses,
|
|
101
103
|
subject: row.subject,
|
|
@@ -329,28 +331,67 @@ export const decodeMailCursor = (cursor?: string) => {
|
|
|
329
331
|
throw new OutgoingMailError("bad_input", "Invalid outgoing mail cursor.");
|
|
330
332
|
}
|
|
331
333
|
};
|
|
334
|
+
type LogFilter = AdminMailFilter & Pick<MailFilter, "apps"> & { recipientExact?: string };
|
|
335
|
+
/**
|
|
336
|
+
* The longest one search of the send log may run, the same budget as a Mail search; its statements share
|
|
337
|
+
* it. A broad search on a large log, or one that runs before Core has built the search indexes, could
|
|
338
|
+
* otherwise read every record on every page.
|
|
339
|
+
*/
|
|
340
|
+
const SEARCH_BUDGET_MS = 5_000;
|
|
341
|
+
const searchTooBroad = () =>
|
|
342
|
+
new OutgoingMailError("bad_input", "This search took too long. Narrow it with a longer term, fewer apps, or a shorter time range.");
|
|
343
|
+
const isStatementTimeout = (error: unknown) => {
|
|
344
|
+
const candidate = error as { code?: unknown; errno?: unknown } | null;
|
|
345
|
+
return candidate?.errno === "57014" || candidate?.code === "57014";
|
|
346
|
+
};
|
|
347
|
+
const withinSearchBudget = async <T>(read: (tx: SQL, limit: () => Promise<void>) => Promise<T>): Promise<T> => {
|
|
348
|
+
const deadline = Date.now() + SEARCH_BUDGET_MS;
|
|
349
|
+
try {
|
|
350
|
+
return await sql.begin((tx) =>
|
|
351
|
+
read(tx, async () => {
|
|
352
|
+
const remainingMs = Math.floor(deadline - Date.now());
|
|
353
|
+
if (remainingMs < 1) throw searchTooBroad();
|
|
354
|
+
await tx`SELECT set_config('statement_timeout', ${`${remainingMs}ms`}, true)`;
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw isStatementTimeout(error) ? searchTooBroad() : error;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
332
361
|
const list = async (
|
|
333
|
-
filter:
|
|
362
|
+
filter: LogFilter,
|
|
334
363
|
page: MailPageParams = {},
|
|
335
364
|
metadata = false,
|
|
365
|
+
ownApp?: string,
|
|
336
366
|
): Promise<MailPage<MailRecord | AdminMailRecord>> => {
|
|
337
367
|
const cursor = decodeMailCursor(page.cursor);
|
|
338
368
|
const perPage = page.perPage ?? 50;
|
|
339
369
|
const pageNumber = cursor?.page ?? page.page ?? 1;
|
|
340
|
-
const where =
|
|
341
|
-
${filter.
|
|
342
|
-
${filter.
|
|
343
|
-
${filter.
|
|
344
|
-
${filter.
|
|
345
|
-
${filter.
|
|
346
|
-
${filter.
|
|
347
|
-
${filter.
|
|
348
|
-
${filter.
|
|
349
|
-
${filter.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
${
|
|
353
|
-
|
|
370
|
+
const where = (db: SQL) => db`WHERE TRUE
|
|
371
|
+
${filter.apps === undefined ? db`` : db`AND app_id = ANY(${toPgTextArray(filter.apps)}::text[])`}
|
|
372
|
+
${filter.app === undefined ? db`` : db`AND app_id = ${filter.app}`}
|
|
373
|
+
${filter.profile === undefined ? db`` : db`AND profile_key = ${filter.profile}`}
|
|
374
|
+
${filter.ids === undefined ? db`` : db`AND id = ANY(${toPgUuidArray(filter.ids)}::uuid[])`}
|
|
375
|
+
${filter.batchId === undefined ? db`` : db`AND batch_id = ${filter.batchId}::uuid`}
|
|
376
|
+
${filter.status === undefined ? db`` : db`AND status = ANY(${toPgTextArray(filter.status)}::text[])`}
|
|
377
|
+
${filter.since === undefined ? db`` : db`AND created_at >= ${filter.since}::timestamptz`}
|
|
378
|
+
${filter.until === undefined ? db`` : db`AND created_at < ${filter.until}::timestamptz`}
|
|
379
|
+
${filter.q === undefined ? db`` : db`AND outgoing_mail.search_text(subject, to_addresses) ILIKE ${`%${escapeLikePattern(filter.q.toLowerCase())}%`}`}
|
|
380
|
+
${filter.recipientExact === undefined ? db`` : db`AND outgoing_mail.lower_addresses(to_addresses) @> ARRAY[pg_catalog.lower(${filter.recipientExact})]`}
|
|
381
|
+
${filter.ref === undefined ? db`` : db`AND ref_scope = ${filter.ref.scope}`}
|
|
382
|
+
${filter.ref?.id === undefined ? db`` : db`AND ref_id = ${filter.ref.id}`}
|
|
383
|
+
${filter.recipient === undefined ? db`` : db`AND EXISTS(SELECT 1 FROM unnest(to_addresses) AS address WHERE address ILIKE ${`%${escapeLikePattern(filter.recipient)}%`})`}`;
|
|
384
|
+
const read = async (db: SQL, limit: () => Promise<void>) => {
|
|
385
|
+
await limit();
|
|
386
|
+
const [count] = await db<{ total: number }[]>`SELECT count(*)::int AS total FROM outgoing_mail.messages ${where(db)}`;
|
|
387
|
+
await limit();
|
|
388
|
+
const rows = await db<LogRow[]>`SELECT ${logColumns(db, metadata ? undefined : ownApp)} FROM outgoing_mail.messages ${where(db)}
|
|
389
|
+
${cursor ? db`AND (created_at < ${cursor.createdAt}::timestamptz OR (created_at = ${cursor.createdAt}::timestamptz AND id > ${cursor.id}::uuid))` : db``}
|
|
390
|
+
ORDER BY created_at DESC, id LIMIT ${perPage + 1} OFFSET ${cursor ? 0 : (pageNumber - 1) * perPage}`;
|
|
391
|
+
return { total: count?.total ?? 0, rows };
|
|
392
|
+
};
|
|
393
|
+
const { total, rows } =
|
|
394
|
+
filter.q === undefined && filter.recipientExact === undefined ? await read(sql, async () => {}) : await withinSearchBudget(read);
|
|
354
395
|
const hasNext = rows.length > perPage;
|
|
355
396
|
const items = rows.slice(0, perPage);
|
|
356
397
|
const last = items.at(-1);
|
|
@@ -358,7 +399,7 @@ const list = async (
|
|
|
358
399
|
items: items.map(metadata ? adminMessageRecord : messageRecord),
|
|
359
400
|
page: pageNumber,
|
|
360
401
|
perPage,
|
|
361
|
-
total
|
|
402
|
+
total,
|
|
362
403
|
hasNext,
|
|
363
404
|
...(hasNext && last
|
|
364
405
|
? {
|
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
MailAppAccessSchema,
|
|
9
9
|
type MailBounceErrorCode,
|
|
10
10
|
type MailErrorCode,
|
|
11
|
+
type MailLogAccess,
|
|
12
|
+
MailLogAccessSchema,
|
|
13
|
+
MailLogAppIdSchema,
|
|
11
14
|
type MailProfile,
|
|
12
15
|
type MailProfileInput,
|
|
13
16
|
MailProfileInputSchema,
|
|
@@ -22,7 +25,7 @@ export class OutgoingMailError extends Error {
|
|
|
22
25
|
constructor(
|
|
23
26
|
public readonly code: MailErrorCode,
|
|
24
27
|
message: string,
|
|
25
|
-
public readonly status: 400 | 404 | 409 | 502 = 400,
|
|
28
|
+
public readonly status: 400 | 403 | 404 | 409 | 502 = 400,
|
|
26
29
|
) {
|
|
27
30
|
super(message);
|
|
28
31
|
}
|
|
@@ -75,11 +78,12 @@ const locked = async <T>(run: (tx: SQL) => Promise<T>): Promise<T> =>
|
|
|
75
78
|
await tx`SELECT pg_advisory_xact_lock(hashtextextended('outgoing_mail.policy', 0))`;
|
|
76
79
|
return run(tx);
|
|
77
80
|
});
|
|
78
|
-
const record = (db: SQL, context: MailAuditContext, action: string, target: string) =>
|
|
81
|
+
const record = (db: SQL, context: MailAuditContext, action: string, target: string, metadata?: Record<string, unknown>) =>
|
|
79
82
|
audit.record(
|
|
80
83
|
{
|
|
81
84
|
...context,
|
|
82
85
|
action: `outgoing_mail.${action}`,
|
|
86
|
+
...(metadata ? { metadata } : {}),
|
|
83
87
|
outcome: "allowed",
|
|
84
88
|
target: { type: action.startsWith("profile.") ? "outgoing_mail_profile" : "outgoing_mail_app", id: target },
|
|
85
89
|
},
|
|
@@ -241,7 +245,9 @@ const appsState = async (
|
|
|
241
245
|
const registered = registeredApps ?? (await listApps());
|
|
242
246
|
const { access, grants } = await accessRows(db);
|
|
243
247
|
const [profile] = await db<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE is_default`;
|
|
244
|
-
const
|
|
248
|
+
const logGrants = await db<{ app_id: string; source_app_id: string }[]>`
|
|
249
|
+
SELECT app_id, source_app_id FROM outgoing_mail.app_log_access ORDER BY source_app_id`;
|
|
250
|
+
const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id), ...logGrants.map((g) => g.app_id)]);
|
|
245
251
|
const items = [...ids]
|
|
246
252
|
.map((appId): AdminMailApp => {
|
|
247
253
|
const app = registered.find((a) => a.id === appId);
|
|
@@ -251,6 +257,13 @@ const appsState = async (
|
|
|
251
257
|
name: app?.name ?? appId,
|
|
252
258
|
registered: !!app,
|
|
253
259
|
declared: !!app?.platformPermissions?.includes("mail:send"),
|
|
260
|
+
readDeclared: appId !== "core" && !!app?.platformPermissions?.includes("mail:read"),
|
|
261
|
+
logApps:
|
|
262
|
+
appId === "core"
|
|
263
|
+
? []
|
|
264
|
+
: logGrants
|
|
265
|
+
.filter((g) => g.app_id === appId && g.source_app_id !== "core" && g.source_app_id !== appId)
|
|
266
|
+
.map((g) => g.source_app_id),
|
|
254
267
|
mode,
|
|
255
268
|
profiles: mode === "selected" ? grants.filter((g) => g.app_id === appId).map((g) => g.key) : [],
|
|
256
269
|
};
|
|
@@ -285,6 +298,41 @@ const setAppAccess = async (appId: string, value: MailAppAccess, context: MailAu
|
|
|
285
298
|
name: appId,
|
|
286
299
|
registered: false,
|
|
287
300
|
declared: false,
|
|
301
|
+
readDeclared: false,
|
|
302
|
+
logApps: [],
|
|
303
|
+
mode: "default",
|
|
304
|
+
profiles: [],
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
const logAppsFor = async (appId: string): Promise<string[]> => {
|
|
310
|
+
if (appId === "core") return [];
|
|
311
|
+
const rows = await sql<{ source_app_id: string }[]>`SELECT source_app_id FROM outgoing_mail.app_log_access
|
|
312
|
+
WHERE app_id = ${appId} AND source_app_id <> 'core' AND source_app_id <> app_id ORDER BY source_app_id`;
|
|
313
|
+
return rows.map((row) => row.source_app_id);
|
|
314
|
+
};
|
|
315
|
+
const setAppLogAccess = async (appId: string, value: MailLogAccess, context: MailAuditContext): Promise<AdminMailApp> => {
|
|
316
|
+
const reader = MailLogAppIdSchema.safeParse(appId);
|
|
317
|
+
const parsed = MailLogAccessSchema.safeParse(value);
|
|
318
|
+
if (!reader.success || appId === "core" || !parsed.success || parsed.data.apps.includes(appId))
|
|
319
|
+
throw new OutgoingMailError("bad_input", "Choose a valid reader and distinct source apps other than Core or the reader itself.");
|
|
320
|
+
const apps = [...parsed.data.apps].sort();
|
|
321
|
+
const registered = await listApps();
|
|
322
|
+
return locked(async (tx) => {
|
|
323
|
+
await tx`DELETE FROM outgoing_mail.app_log_access WHERE app_id = ${appId}`;
|
|
324
|
+
await tx`INSERT INTO outgoing_mail.app_log_access(app_id, source_app_id, created_by)
|
|
325
|
+
SELECT ${appId}, source_app_id, ${context.actor.userId ?? null}
|
|
326
|
+
FROM unnest(${toPgTextArray(apps)}::text[]) AS source_app_id`;
|
|
327
|
+
await record(tx, context, "app_log_access.update", appId, { apps });
|
|
328
|
+
return (
|
|
329
|
+
(await appsState(tx, registered)).items.find((app) => app.appId === appId) ?? {
|
|
330
|
+
appId,
|
|
331
|
+
name: appId,
|
|
332
|
+
registered: false,
|
|
333
|
+
declared: false,
|
|
334
|
+
readDeclared: false,
|
|
335
|
+
logApps: [],
|
|
288
336
|
mode: "default",
|
|
289
337
|
profiles: [],
|
|
290
338
|
}
|
|
@@ -319,7 +367,18 @@ const profilesForApp = async (appId: string): Promise<MailProfile[]> => {
|
|
|
319
367
|
quota: { dailyRecipients: row.daily_recipient_limit, usedLast24h: row.used },
|
|
320
368
|
}));
|
|
321
369
|
};
|
|
322
|
-
export const outgoingMailStore = {
|
|
370
|
+
export const outgoingMailStore = {
|
|
371
|
+
list,
|
|
372
|
+
get,
|
|
373
|
+
put,
|
|
374
|
+
setDefault,
|
|
375
|
+
delete: remove,
|
|
376
|
+
apps: appsState,
|
|
377
|
+
setAppAccess,
|
|
378
|
+
setAppLogAccess,
|
|
379
|
+
logAppsFor,
|
|
380
|
+
profilesForApp,
|
|
381
|
+
};
|
|
323
382
|
|
|
324
383
|
/** Platform email send path only. Never use this helper to shape API or CLI responses. */
|
|
325
384
|
export const resolveMailCredentials = async (key?: string | { id: string }) => {
|
|
@@ -65,6 +65,7 @@ const UNSAFE_IPV6_RANGES: ReadonlyArray<readonly [bigint, number]> = [
|
|
|
65
65
|
[ipv6ToBigInt("2001:2::")!, 48],
|
|
66
66
|
[ipv6ToBigInt("2001:db8::")!, 32],
|
|
67
67
|
[ipv6ToBigInt("2002::")!, 16],
|
|
68
|
+
[ipv6ToBigInt("3fff::")!, 20],
|
|
68
69
|
[ipv6ToBigInt("fc00::")!, 7],
|
|
69
70
|
[ipv6ToBigInt("fe80::")!, 10],
|
|
70
71
|
[ipv6ToBigInt("fec0::")!, 10],
|
|
@@ -2,11 +2,11 @@ import { documentNavigate } from "@k2b/ssr/nav";
|
|
|
2
2
|
import { IconButton, ScrollArea, TextInput, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createSignal, For, type JSX, Show } from "solid-js";
|
|
4
4
|
import { Dynamic } from "solid-js/web";
|
|
5
|
+
import { importOnDemand } from "../browser/reload";
|
|
5
6
|
import type { CloudTheme } from "../shared/theme";
|
|
6
7
|
import { AppBadge, useAppBadgeDescription } from "./AppBadge";
|
|
7
8
|
import { MobileProfileActions } from "./MobileProfileActions";
|
|
8
9
|
import { platformMessages } from "./platform-messages";
|
|
9
|
-
import { openRailEditor } from "./RailEditor";
|
|
10
10
|
import { readRailContext } from "./rail-context";
|
|
11
11
|
import { railMessages } from "./rail-messages";
|
|
12
12
|
import { projectRailNavigation } from "./rail-navigation";
|
|
@@ -64,6 +64,7 @@ export const AppLaunchpadPanel = (
|
|
|
64
64
|
const t = () => railMessages.resolve([locale()]).t;
|
|
65
65
|
const rail = readRailContext();
|
|
66
66
|
const [query, setQuery] = createSignal("");
|
|
67
|
+
const [editorLoading, setEditorLoading] = createSignal(false);
|
|
67
68
|
const messages = () => platformMessages.resolve([locale()]).t;
|
|
68
69
|
const matches = (label: string, description = "") =>
|
|
69
70
|
`${label} ${description}`.toLocaleLowerCase(locale()).includes(query().trim().toLocaleLowerCase(locale()));
|
|
@@ -106,14 +107,22 @@ export const AppLaunchpadPanel = (
|
|
|
106
107
|
label={t().customize}
|
|
107
108
|
tooltip={t().customize}
|
|
108
109
|
tooltipDelay={0}
|
|
110
|
+
aria-busy={editorLoading() ? "true" : undefined}
|
|
109
111
|
onClick={async () => {
|
|
112
|
+
if (editorLoading()) return;
|
|
113
|
+
// The editor and its validation load only when someone customizes the app bar, while this panel
|
|
114
|
+
// stays open and shows that it is busy. The button stays enabled so it keeps focus.
|
|
115
|
+
setEditorLoading(true);
|
|
116
|
+
const editor = await importOnDemand(() => import("./RailEditor"));
|
|
117
|
+
setEditorLoading(false);
|
|
118
|
+
if (!editor) return;
|
|
110
119
|
if (props.beforeSelect) {
|
|
111
120
|
if (!(await props.beforeSelect())) return;
|
|
112
121
|
} else props.close();
|
|
113
|
-
openRailEditor(locale());
|
|
122
|
+
editor.openRailEditor(locale());
|
|
114
123
|
}}
|
|
115
124
|
>
|
|
116
|
-
<i class="ti ti-adjustments-horizontal" aria-hidden="true" />
|
|
125
|
+
<i class={editorLoading() ? "ti ti-loader-2 animate-spin" : "ti ti-adjustments-horizontal"} aria-hidden="true" />
|
|
117
126
|
</IconButton>
|
|
118
127
|
</div>
|
|
119
128
|
</Show>
|
|
@@ -4,10 +4,10 @@ import {
|
|
|
4
4
|
ANNOUNCEMENTS_COOKIE,
|
|
5
5
|
ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
|
|
6
6
|
type AnnouncementCookieState,
|
|
7
|
-
type AnnouncementDisplayEntry,
|
|
8
7
|
mergeAnnouncementCookieState,
|
|
9
8
|
serializeAnnouncementCookieState,
|
|
10
|
-
} from "../contracts/
|
|
9
|
+
} from "../contracts/announcement-cookie";
|
|
10
|
+
import type { AnnouncementDisplayEntry } from "../contracts/announcements";
|
|
11
11
|
import { platformMessages } from "./platform-messages";
|
|
12
12
|
|
|
13
13
|
type Props = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { query } from "@k2b/stdlib/solid";
|
|
2
|
-
import { type DialogRender,
|
|
2
|
+
import { type DialogRender, toast, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createEffect, createSignal, For, onCleanup, onMount } from "solid-js";
|
|
4
4
|
import type { SearchItem } from "../api/search/schemas";
|
|
5
5
|
import CloudResourceSearch from "../browser/CloudResourceSearch";
|
|
@@ -7,7 +7,6 @@ import { COMMANDS_CHANGED, collectContextAwareCommands } from "../browser/comman
|
|
|
7
7
|
import { contextCommandsWithShortcuts } from "../browser/command-shortcuts";
|
|
8
8
|
import { openCommand, runContextAwareCommand } from "../browser/commands";
|
|
9
9
|
import type { NavigationSearchItem } from "../browser/navigation-search";
|
|
10
|
-
import { resourceSearchDialogOptions } from "../browser/resource-search-dialog";
|
|
11
10
|
import { resourceSearchMessages } from "../browser/resource-search-messages";
|
|
12
11
|
import { type GlobalSearchOptions, requestSearchNavigation } from "../browser/search-bridge";
|
|
13
12
|
import { loadSearchCommands, type PaletteCommand } from "../browser/search-commands";
|
|
@@ -139,44 +138,3 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
|
|
|
139
138
|
</div>
|
|
140
139
|
);
|
|
141
140
|
}
|
|
142
|
-
|
|
143
|
-
/** Owned by the layout island; neither dialog state nor callbacks cross SSR props. */
|
|
144
|
-
export const createGlobalSearchHost = (searchLinks: () => NavigationSearchItem[], searchResources = true) => {
|
|
145
|
-
const [request, setRequest] = createSignal<GlobalSearchOptions>({});
|
|
146
|
-
let controller: AbortController | undefined;
|
|
147
|
-
return {
|
|
148
|
-
open: (options: GlobalSearchOptions) => {
|
|
149
|
-
setRequest({ ...options });
|
|
150
|
-
if (controller) {
|
|
151
|
-
document.querySelector<HTMLInputElement>(".cloud-global-search input[role=combobox]")?.focus();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
const current = new AbortController();
|
|
155
|
-
controller = current;
|
|
156
|
-
const locale = document.documentElement.lang || "en";
|
|
157
|
-
void dialogCore
|
|
158
|
-
.open<void>(
|
|
159
|
-
(close, context) => (
|
|
160
|
-
<GlobalSearchDialog
|
|
161
|
-
request={request()}
|
|
162
|
-
searchLinks={searchLinks()}
|
|
163
|
-
searchResources={searchResources}
|
|
164
|
-
close={close}
|
|
165
|
-
context={context}
|
|
166
|
-
/>
|
|
167
|
-
),
|
|
168
|
-
{ ...resourceSearchDialogOptions(resourceSearchMessages.resolve([locale]).t.searchCloudResources), signal: current.signal },
|
|
169
|
-
)
|
|
170
|
-
.catch(() => {
|
|
171
|
-
toast.error(resourceSearchMessages.resolve([locale]).t.searchFailed);
|
|
172
|
-
})
|
|
173
|
-
.finally(() => {
|
|
174
|
-
if (controller === current) controller = undefined;
|
|
175
|
-
});
|
|
176
|
-
},
|
|
177
|
-
dispose: () => {
|
|
178
|
-
controller?.abort();
|
|
179
|
-
controller = undefined;
|
|
180
|
-
},
|
|
181
|
-
};
|
|
182
|
-
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { IconButton, Tooltip, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createEffect, onCleanup, onMount } from "solid-js";
|
|
3
|
+
import { registerContextAwareCommand } from "../browser/command-bridge";
|
|
3
4
|
import { attachCommandShortcuts } from "../browser/command-shortcuts";
|
|
4
|
-
import { registerContextAwareCommand, runContextAwareCommand } from "../browser/commands";
|
|
5
5
|
import type { NavigationSearchItem } from "../browser/navigation-search";
|
|
6
|
+
import { importOnDemand } from "../browser/reload";
|
|
6
7
|
import { openGlobalSearch } from "../browser/search";
|
|
7
8
|
import { registerGlobalSearchHost } from "../browser/search-bridge";
|
|
8
|
-
import { createGlobalSearchHost } from "./
|
|
9
|
+
import { createGlobalSearchHost } from "./global-search-host";
|
|
9
10
|
import { platformMessages } from "./platform-messages";
|
|
10
11
|
|
|
11
12
|
type GlobalSearchTriggerProps = {
|
|
@@ -26,7 +27,15 @@ export default function GlobalSearchTrigger(props: GlobalSearchTriggerProps) {
|
|
|
26
27
|
onMount(() => {
|
|
27
28
|
onCleanup(registerGlobalSearchHost(host.open, host.dispose));
|
|
28
29
|
});
|
|
29
|
-
|
|
30
|
+
// Commands, their schemas and the capability client load on the first shortcut, not with every page.
|
|
31
|
+
onMount(() =>
|
|
32
|
+
onCleanup(
|
|
33
|
+
attachCommandShortcuts(
|
|
34
|
+
(command) =>
|
|
35
|
+
void importOnDemand(() => import("../browser/commands")).then((commands) => commands?.runContextAwareCommand(command)),
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
30
39
|
createEffect(() =>
|
|
31
40
|
onCleanup(
|
|
32
41
|
registerContextAwareCommand({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IconButton, Tooltip, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createEffect, onCleanup } from "solid-js";
|
|
3
|
-
import { registerContextAwareCommand } from "../browser/
|
|
3
|
+
import { registerContextAwareCommand } from "../browser/command-bridge";
|
|
4
4
|
import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
|
|
5
5
|
import { openLayoutHelpDialog } from "./LayoutHelp";
|
|
6
6
|
import { platformMessages } from "./platform-messages";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { observeMobileShell, syncThemeColor, type ToastHandle, toast, useLocale } from "@k2b/ui";
|
|
2
2
|
import { onCleanup, onMount } from "solid-js";
|
|
3
3
|
import { renewAppSession } from "../browser/app-session";
|
|
4
|
-
import { PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "../contracts/pwa";
|
|
4
|
+
import { PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "../contracts/pwa-paths";
|
|
5
5
|
import { pwaMessages } from "./pwa-messages";
|
|
6
6
|
|
|
7
7
|
/** Set once per browsing session, so the first page renews and later navigations do not. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { hasAnyAppRole } from "../_internal/app-roles";
|
|
1
2
|
import type { RuntimeAppMeta } from "../contracts/app";
|
|
2
|
-
import { isPwaPartId } from "../contracts/pwa";
|
|
3
|
-
import
|
|
3
|
+
import { isPwaPartId } from "../contracts/pwa-paths";
|
|
4
|
+
import type { User } from "../contracts/shared";
|
|
4
5
|
import { resolveAppPresentation } from "../shared/app-presentation";
|
|
5
6
|
|
|
6
7
|
export type VisibleNavigationApp = RuntimeAppMeta & {
|
|
@@ -27,14 +28,11 @@ export const hasDedicatedRuntimeRoute = (apps: readonly RuntimeAppMeta[], pathna
|
|
|
27
28
|
return !!match && match.app.id !== currentAppId;
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
const hasAnyRole = (user: User | undefined, roles: readonly Role[] | undefined): boolean =>
|
|
31
|
-
!roles || (!!user && roles.some((role) => (role === "guest" ? user.profile === "guest" : hasRole(user, role))));
|
|
32
|
-
|
|
33
31
|
/** Apps rendered in the navigation for a given authenticated user. */
|
|
34
32
|
export const visibleNavigationApps = (apps: readonly RuntimeAppMeta[], user: User | undefined): VisibleNavigationApp[] =>
|
|
35
33
|
apps.filter(
|
|
36
34
|
(app): app is VisibleNavigationApp =>
|
|
37
|
-
!!app.nav && app.nav.section !== "hidden" && (!app.nav.requiresAuth || !!user) &&
|
|
35
|
+
!!app.nav && app.nav.section !== "hidden" && (!app.nav.requiresAuth || !!user) && hasAnyAppRole(user, app.nav.requiresRoles),
|
|
38
36
|
);
|
|
39
37
|
|
|
40
38
|
export type VisiblePwaPart = RuntimeAppMeta & { pwa: NonNullable<RuntimeAppMeta["pwa"]> };
|
|
@@ -49,7 +47,7 @@ export const visiblePwaParts = (apps: readonly RuntimeAppMeta[], user: User | un
|
|
|
49
47
|
return apps
|
|
50
48
|
.filter(
|
|
51
49
|
(app): app is VisiblePwaPart =>
|
|
52
|
-
!!app.pwa && isPwaPartId(app.id) && app.routes.includes(app.pwa.href) &&
|
|
50
|
+
!!app.pwa && isPwaPartId(app.id) && app.routes.includes(app.pwa.href) && hasAnyAppRole(user, app.pwa.requiresRoles),
|
|
53
51
|
)
|
|
54
52
|
.map((app) => resolveAppPresentation(app, locale))
|
|
55
53
|
.sort((a, b) => a.name.localeCompare(b.name, locale) || a.id.localeCompare(b.id));
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { dialogCore, toast } from "@k2b/ui";
|
|
2
|
+
import { createSignal } from "solid-js";
|
|
3
|
+
import type { NavigationSearchItem } from "../browser/navigation-search";
|
|
4
|
+
import { importOnDemand } from "../browser/reload";
|
|
5
|
+
import { resourceSearchDialogOptions } from "../browser/resource-search-dialog";
|
|
6
|
+
import { resourceSearchMessages } from "../browser/resource-search-messages";
|
|
7
|
+
import type { GlobalSearchOptions } from "../browser/search-bridge";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Owned by the layout island; neither dialog state nor callbacks cross SSR props. The dialog with search, commands
|
|
11
|
+
* and their schemas loads on first open, so no page pays for it before someone searches.
|
|
12
|
+
*/
|
|
13
|
+
export const createGlobalSearchHost = (searchLinks: () => NavigationSearchItem[], searchResources = true) => {
|
|
14
|
+
const [request, setRequest] = createSignal<GlobalSearchOptions>({});
|
|
15
|
+
let controller: AbortController | undefined;
|
|
16
|
+
return {
|
|
17
|
+
open: (options: GlobalSearchOptions) => {
|
|
18
|
+
setRequest({ ...options });
|
|
19
|
+
if (controller) {
|
|
20
|
+
document.querySelector<HTMLInputElement>(".cloud-global-search input[role=combobox]")?.focus();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const current = new AbortController();
|
|
24
|
+
controller = current;
|
|
25
|
+
const locale = document.documentElement.lang || "en";
|
|
26
|
+
void importOnDemand(() => import("./GlobalSearchDialog"))
|
|
27
|
+
.then((loaded) => {
|
|
28
|
+
if (!loaded || current.signal.aborted) return;
|
|
29
|
+
const GlobalSearchDialog = loaded.default;
|
|
30
|
+
return dialogCore.open<void>(
|
|
31
|
+
(close, context) => (
|
|
32
|
+
<GlobalSearchDialog
|
|
33
|
+
request={request()}
|
|
34
|
+
searchLinks={searchLinks()}
|
|
35
|
+
searchResources={searchResources}
|
|
36
|
+
close={close}
|
|
37
|
+
context={context}
|
|
38
|
+
/>
|
|
39
|
+
),
|
|
40
|
+
{ ...resourceSearchDialogOptions(resourceSearchMessages.resolve([locale]).t.searchCloudResources), signal: current.signal },
|
|
41
|
+
);
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {
|
|
44
|
+
toast.error(resourceSearchMessages.resolve([locale]).t.searchFailed);
|
|
45
|
+
})
|
|
46
|
+
.finally(() => {
|
|
47
|
+
if (controller === current) controller = undefined;
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
dispose: () => {
|
|
51
|
+
controller?.abort();
|
|
52
|
+
controller = undefined;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
};
|
package/src/ssr/help.ts
CHANGED
|
@@ -10,14 +10,14 @@ const manifests = new WeakMap<object, HelpManifestResult | null>();
|
|
|
10
10
|
const log = logger("help:ssr");
|
|
11
11
|
export const getLayoutHelp = (c: object): HelpManifestResult | null => manifests.get(c) ?? null;
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/** Help metadata for the current or an explicit app; `null` without a user or when the user may not see the app. */
|
|
14
14
|
export const preloadLayoutHelp = async (c: Context, appId?: string): Promise<HelpManifestResult | null> => {
|
|
15
15
|
if (manifests.has(c)) return getLayoutHelp(c);
|
|
16
|
-
if (!c.get("runtime") ||
|
|
16
|
+
if (!c.get("runtime") || !c.get("user")) return null;
|
|
17
17
|
const id = appId ?? resolveCurrentApp(getRuntimeContext(c).apps, c.req.path)?.id;
|
|
18
18
|
if (!id) return null;
|
|
19
19
|
try {
|
|
20
|
-
const manifest = await createHelpReader(getLocale(c)).manifest(id);
|
|
20
|
+
const manifest = await createHelpReader(getLocale(c), c.get("actor")).manifest(id);
|
|
21
21
|
manifests.set(c, manifest);
|
|
22
22
|
return manifest;
|
|
23
23
|
} catch (error) {
|
package/src/ssr/rail-context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { RailPreferences, RailSnapshot } from "../contracts/rail-preferences";
|
|
2
2
|
import type { RailApp } from "./rail-navigation";
|
|
3
3
|
|
|
4
4
|
export type RailContext = { apps: RailApp[]; settings: RailSnapshot };
|
|
@@ -8,9 +8,10 @@ export const readRailContext = (): RailContext | undefined => {
|
|
|
8
8
|
if (typeof document === "undefined") return undefined;
|
|
9
9
|
const text = document.getElementById("cloud-rail-data")?.textContent;
|
|
10
10
|
if (!text) return undefined;
|
|
11
|
-
// Framework-generated, escaped SSR data; never loaded from localStorage.
|
|
11
|
+
// Framework-generated, escaped SSR data; never loaded from localStorage. The server validates the snapshot when
|
|
12
|
+
// it reads it, so the shell does not load the schemas on every page.
|
|
12
13
|
const value: RailContext = JSON.parse(text);
|
|
13
|
-
return { apps: value.apps, settings:
|
|
14
|
+
return { apps: value.apps, settings: value.settings };
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export const publishRailPreferences = (settings: RailPreferences) => {
|
package/src/ssr/runtime.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { CloudRuntime } from "../contracts/app";
|
|
6
6
|
import { getLocale } from "../server/locale";
|
|
7
|
-
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
7
|
+
import { resolveAppPresentation, resolveAppPresentations } from "../shared/app-presentation";
|
|
8
8
|
|
|
9
9
|
export type RuntimeContext = CloudRuntime;
|
|
10
10
|
|
|
@@ -29,3 +29,16 @@ export const getLocalizedRuntimeContext = (carrier: RuntimeCarrier & { req: { ra
|
|
|
29
29
|
const locale = getLocale(carrier as Parameters<typeof getLocale>[0]);
|
|
30
30
|
return { ...runtime, apps: resolveAppPresentations(runtime.apps, locale) };
|
|
31
31
|
};
|
|
32
|
+
|
|
33
|
+
/** An app's name in the request locale while the app is registered; `fallback` (its stored base name) otherwise. */
|
|
34
|
+
export const localizedAppName = (
|
|
35
|
+
carrier: RuntimeCarrier & { req: { raw: { headers: Headers } } },
|
|
36
|
+
appId: string,
|
|
37
|
+
fallback: string,
|
|
38
|
+
): string => {
|
|
39
|
+
const runtime = carrier.get("runtime");
|
|
40
|
+
const apps =
|
|
41
|
+
runtime && typeof runtime === "object" && Array.isArray((runtime as RuntimeContext).apps) ? (runtime as RuntimeContext).apps : [];
|
|
42
|
+
const app = apps.find((entry) => entry.id === appId);
|
|
43
|
+
return app ? resolveAppPresentation(app, getLocale(carrier as Parameters<typeof getLocale>[0])).name : fallback;
|
|
44
|
+
};
|