@k2b/cloud 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/_internal/capabilities.ts +12 -0
- package/src/_internal/registry.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +119 -99
- package/src/access/messages.ts +30 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/capabilities.ts +34 -12
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +57 -30
- package/src/ai/chat/live-turn.browser-harness.tsx +37 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +324 -2
- package/src/ai/chat/presentation.tsx +257 -103
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +609 -0
- package/src/ai/client/projection.ts +37 -6
- package/src/ai/executor.ts +38 -5
- package/src/ai/protocol.ts +20 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/quota-provider.ts +14 -5
- package/src/ai/store.ts +94 -3
- package/src/ai/stream.ts +2 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +12 -2
- package/src/contracts/registry.ts +2 -0
- package/src/shared/app-presentation.ts +10 -2
- package/src/styles/effects.css +69 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"@tailwindcss/typography": "0.5.20",
|
|
101
101
|
"@k2b/nessi": "0.12.1",
|
|
102
102
|
"@k2b/ssr": "0.14.0",
|
|
103
|
-
"@k2b/ui": "0.
|
|
103
|
+
"@k2b/ui": "0.16.0",
|
|
104
104
|
"@k2b/stdlib": "0.27.0",
|
|
105
105
|
"@k2b/sync": "7.0.0",
|
|
106
106
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -786,6 +786,18 @@ const presentationOverlays = (catalog: CapabilityPresentationCatalog, requestedL
|
|
|
786
786
|
});
|
|
787
787
|
};
|
|
788
788
|
|
|
789
|
+
/** The display title of one Query or Action in the requested locale, without rebuilding the manifest. */
|
|
790
|
+
export const resolveCapabilityOperationTitle = (
|
|
791
|
+
operation: Pick<CapabilityQueryManifest, "localId" | "title">,
|
|
792
|
+
group: "queries" | "actions",
|
|
793
|
+
catalog: CapabilityPresentationCatalog | undefined,
|
|
794
|
+
requestedLocale: string,
|
|
795
|
+
): string =>
|
|
796
|
+
(catalog ? presentationOverlays(catalog, requestedLocale) : []).reduce(
|
|
797
|
+
(title, translation) => translation[group]?.[operation.localId]?.title ?? title,
|
|
798
|
+
operation.title,
|
|
799
|
+
);
|
|
800
|
+
|
|
789
801
|
/** Resolve only human presentation; stable IDs, flags, tags, aliases, and data shapes stay untouched. */
|
|
790
802
|
export const resolveCapabilityManifestPresentation = (
|
|
791
803
|
manifest: CapabilityManifest,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { query } from "@k2b/stdlib/solid";
|
|
2
|
+
import { Button, InlineGuidance, useLocale } from "@k2b/ui";
|
|
3
|
+
import { createSignal, createUniqueId, For, Show } from "solid-js";
|
|
4
|
+
import { CloudAvatar } from "../account/Avatar";
|
|
5
|
+
import type { BaseUser, EntityListItem, PaginationResponse } from "../contracts/shared";
|
|
6
|
+
import { accessMessages } from "./messages";
|
|
7
|
+
|
|
8
|
+
/** Members per request. Further pages load only when the viewer asks for them. */
|
|
9
|
+
const PAGE_SIZE = 20;
|
|
10
|
+
|
|
11
|
+
type MembersPage =
|
|
12
|
+
/** The directory withholds group members from this viewer (guest accounts). */
|
|
13
|
+
| { visible: false; directoryGroup: boolean }
|
|
14
|
+
| { visible: true; directoryGroup: boolean; members: BaseUser[]; total: number; page: number; hasNext: boolean };
|
|
15
|
+
|
|
16
|
+
/** Reads the Accounts directory with the viewer's own visibility: `null` when it is withheld. */
|
|
17
|
+
const listEntities = async (params: Record<string, string>, signal: AbortSignal) => {
|
|
18
|
+
const url = new URL("/api/accounts/entities", window.location.origin);
|
|
19
|
+
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
|
20
|
+
const response = await fetch(url, { signal, credentials: "same-origin" });
|
|
21
|
+
if (response.status === 403) return null;
|
|
22
|
+
if (!response.ok) throw new Error(response.statusText || `HTTP ${response.status}`);
|
|
23
|
+
const data: { items: EntityListItem[]; pagination: PaginationResponse } = await response.json();
|
|
24
|
+
return data;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Who currently receives access through a group grant: direct members and
|
|
29
|
+
* members of nested groups, as access resolution counts them. Loading starts
|
|
30
|
+
* when the row mounts in the browser, so the list usually opens complete
|
|
31
|
+
* instead of growing after the viewer expands it.
|
|
32
|
+
*/
|
|
33
|
+
export const createGroupCoverage = (groupId: string) => {
|
|
34
|
+
const [open, setOpen] = createSignal(false);
|
|
35
|
+
const members = query.createInfinite<string, MembersPage, number>({
|
|
36
|
+
source: () => groupId,
|
|
37
|
+
loadPage: async (id, { cursor, abortSignal }) => {
|
|
38
|
+
const page = cursor ?? 1;
|
|
39
|
+
const [list, group] = await Promise.all([
|
|
40
|
+
listEntities(
|
|
41
|
+
{ kinds: "user", member_of_group_id: id, recursive: "true", page: String(page), per_page: String(PAGE_SIZE) },
|
|
42
|
+
abortSignal,
|
|
43
|
+
),
|
|
44
|
+
// Only the first page needs the group itself, for the directory hint.
|
|
45
|
+
page === 1 ? listEntities({ kinds: "group", group_ids: id, per_page: "1" }, abortSignal) : null,
|
|
46
|
+
]);
|
|
47
|
+
const directoryGroup = group?.items.some((item) => item.kind === "group" && item.group.provider === "ipa") ?? false;
|
|
48
|
+
if (!list) return { visible: false, directoryGroup };
|
|
49
|
+
return {
|
|
50
|
+
visible: true,
|
|
51
|
+
directoryGroup,
|
|
52
|
+
members: list.items.flatMap((item) => (item.kind === "user" ? [item.user] : [])),
|
|
53
|
+
total: list.pagination.total,
|
|
54
|
+
page,
|
|
55
|
+
hasNext: list.pagination.has_next,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
getNextCursor: (page) => (page.visible && page.hasNext ? page.page + 1 : null),
|
|
59
|
+
});
|
|
60
|
+
return { open, setOpen, panelId: createUniqueId(), members };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type GroupCoverage = ReturnType<typeof createGroupCoverage>;
|
|
64
|
+
|
|
65
|
+
const firstPage = (coverage: GroupCoverage) => coverage.members.pages()[0];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The members toggle beside the group name. Its label never changes, so
|
|
69
|
+
* loading the members moves nothing in the row; the count leads the list.
|
|
70
|
+
*/
|
|
71
|
+
export function GroupCoverageToggle(props: { coverage: GroupCoverage; groupName: string }) {
|
|
72
|
+
const locale = useLocale();
|
|
73
|
+
const t = () => accessMessages.resolve([locale()]).t;
|
|
74
|
+
return (
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
aria-expanded={props.coverage.open()}
|
|
78
|
+
aria-controls={props.coverage.panelId}
|
|
79
|
+
// The group name tells several group rows apart.
|
|
80
|
+
aria-label={t().groupMembersOf({ name: props.groupName })}
|
|
81
|
+
onClick={() => props.coverage.setOpen(!props.coverage.open())}
|
|
82
|
+
// The size sits on the children: the scoped button normalization resets the button's own font.
|
|
83
|
+
class="focus-ui flex items-center gap-0.5 self-center rounded text-dimmed transition-colors hover:text-secondary"
|
|
84
|
+
>
|
|
85
|
+
<span class="text-xs">{t().groupMembers}</span>
|
|
86
|
+
<i
|
|
87
|
+
class="ti ti-chevron-down shrink-0 text-[10px] transition-transform motion-reduce:transition-none"
|
|
88
|
+
classList={{ "rotate-180": props.coverage.open() }}
|
|
89
|
+
aria-hidden="true"
|
|
90
|
+
/>
|
|
91
|
+
</button>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The expanded member list. It stays in the document while collapsed so `aria-controls` always resolves. */
|
|
96
|
+
export function GroupCoveragePanel(props: { coverage: GroupCoverage; groupName: string }) {
|
|
97
|
+
const locale = useLocale();
|
|
98
|
+
const t = () => accessMessages.resolve([locale()]).t;
|
|
99
|
+
const members = () => props.coverage.members;
|
|
100
|
+
const shown = () =>
|
|
101
|
+
members()
|
|
102
|
+
.pages()
|
|
103
|
+
.flatMap((page) => (page.visible ? page.members : []));
|
|
104
|
+
const total = () => {
|
|
105
|
+
const page = firstPage(props.coverage);
|
|
106
|
+
return page?.visible ? page.total : null;
|
|
107
|
+
};
|
|
108
|
+
const remaining = () => Math.max((total() ?? 0) - shown().length, 0);
|
|
109
|
+
let panel: HTMLDivElement | undefined;
|
|
110
|
+
let list: HTMLUListElement | undefined;
|
|
111
|
+
let more: HTMLButtonElement | undefined;
|
|
112
|
+
let retry: HTMLButtonElement | undefined;
|
|
113
|
+
// A loading button is disabled, which drops keyboard focus, and the buttons leave on a failure or once
|
|
114
|
+
// everything is shown. Unless the viewer has moved on, focus continues where they can act next.
|
|
115
|
+
const settleFocus = () => {
|
|
116
|
+
const active = document.activeElement;
|
|
117
|
+
if (active && active !== document.body && active.isConnected && !panel?.contains(active)) return;
|
|
118
|
+
const next = members().error() ? retry : members().hasMore() ? more : list;
|
|
119
|
+
if (next?.isConnected) next.focus();
|
|
120
|
+
};
|
|
121
|
+
const showMore = async () => {
|
|
122
|
+
await members().loadMore();
|
|
123
|
+
settleFocus();
|
|
124
|
+
};
|
|
125
|
+
const tryAgain = async () => {
|
|
126
|
+
await members().refresh();
|
|
127
|
+
settleFocus();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
// Indented to the name column: avatar (1.75rem) plus the row gap (0.5rem).
|
|
132
|
+
<div ref={panel} id={props.coverage.panelId} hidden={!props.coverage.open()} class="flex flex-col gap-2 pb-2 pl-9">
|
|
133
|
+
<Show when={members().pages().length === 0 && !members().error()}>
|
|
134
|
+
<InlineGuidance loading>{t().groupMembersLoading}</InlineGuidance>
|
|
135
|
+
</Show>
|
|
136
|
+
<Show when={firstPage(props.coverage)?.visible === false}>
|
|
137
|
+
<InlineGuidance icon="ti ti-eye-off">{t().groupMembersHidden}</InlineGuidance>
|
|
138
|
+
</Show>
|
|
139
|
+
<Show when={total() !== null}>
|
|
140
|
+
<p class="text-sm text-dimmed">{t().groupMemberCount({ count: total() ?? 0 })}</p>
|
|
141
|
+
</Show>
|
|
142
|
+
<Show when={firstPage(props.coverage)?.directoryGroup}>
|
|
143
|
+
<InlineGuidance icon="ti ti-info-circle">{t().directoryGroupHint}</InlineGuidance>
|
|
144
|
+
</Show>
|
|
145
|
+
<Show when={shown().length > 0}>
|
|
146
|
+
<ul ref={list} tabindex="-1" aria-label={t().groupMembersOf({ name: props.groupName })} class="flex flex-col gap-1 outline-none">
|
|
147
|
+
<For each={shown()}>
|
|
148
|
+
{(user) => {
|
|
149
|
+
const name = user.displayName || user.mail || user.uid;
|
|
150
|
+
return (
|
|
151
|
+
<li class="flex min-w-0 items-center gap-2 text-sm">
|
|
152
|
+
<CloudAvatar username={name} userId={user.id} avatarHash={user.avatarHash} size="xs" class="h-6 w-6 shrink-0" />
|
|
153
|
+
<span class="truncate">{name}</span>
|
|
154
|
+
</li>
|
|
155
|
+
);
|
|
156
|
+
}}
|
|
157
|
+
</For>
|
|
158
|
+
</ul>
|
|
159
|
+
</Show>
|
|
160
|
+
<Show when={members().error()}>
|
|
161
|
+
<InlineGuidance tone="danger" icon="ti ti-alert-circle" role="alert">
|
|
162
|
+
<span>{t().groupMembersFailed}</span>{" "}
|
|
163
|
+
<Button ref={retry} variant="text" size="xs" onClick={() => void tryAgain()}>
|
|
164
|
+
{t().retry}
|
|
165
|
+
</Button>
|
|
166
|
+
</InlineGuidance>
|
|
167
|
+
</Show>
|
|
168
|
+
<Show when={members().hasMore() && !members().error()}>
|
|
169
|
+
<Button ref={more} variant="text" size="xs" class="self-start" loading={members().loadingMore()} onClick={() => void showMore()}>
|
|
170
|
+
{t().showMoreMembers({ count: Math.min(remaining(), PAGE_SIZE) })}
|
|
171
|
+
</Button>
|
|
172
|
+
</Show>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { mutation } from "@k2b/stdlib/solid";
|
|
2
2
|
import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
|
|
3
|
-
import {
|
|
3
|
+
import { For, Show } from "solid-js";
|
|
4
|
+
import { createStore } from "solid-js/store";
|
|
4
5
|
import { CloudAvatar } from "../account/Avatar";
|
|
5
6
|
import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
|
|
6
7
|
import { groupDisplayName } from "../shared/account-display";
|
|
8
|
+
import { createGroupCoverage, type GroupCoverage, GroupCoveragePanel, GroupCoverageToggle } from "./GroupCoverage";
|
|
7
9
|
import { isManagerEntry } from "./managers";
|
|
8
10
|
import { accessMessages } from "./messages";
|
|
9
11
|
import PrincipalPicker from "./PrincipalPicker";
|
|
@@ -154,7 +156,9 @@ const getPrincipalIcon = (entry: AccessEntry, t: ReturnType<typeof accessMessage
|
|
|
154
156
|
export default function PermissionEditor(props: PermissionEditorProps) {
|
|
155
157
|
const locale = useLocale();
|
|
156
158
|
const t = () => accessMessages.resolve([locale()]).t;
|
|
157
|
-
|
|
159
|
+
// A store keeps each row's identity across level changes, so an expanded member list stays open. It holds
|
|
160
|
+
// copies: store writes go into the objects themselves, and the caller's entries are not the editor's to change.
|
|
161
|
+
const [entries, setEntries] = createStore<AccessEntry[]>(props.initialEntries.map((entry) => ({ ...entry })));
|
|
158
162
|
const canEdit = () => props.canEdit !== false;
|
|
159
163
|
const allowPublic = () => props.allowPublic === true;
|
|
160
164
|
const allowAuthenticated = () => props.allowAuthenticated !== false;
|
|
@@ -178,7 +182,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
178
182
|
display: { displayName: string; serviceAccountKind?: ServiceAccountKind };
|
|
179
183
|
}) => props.grantAccess(data.principal, data.permission, data.display),
|
|
180
184
|
onSuccess: (newEntry) => {
|
|
181
|
-
setEntries(
|
|
185
|
+
setEntries(entries.length, { ...newEntry });
|
|
182
186
|
},
|
|
183
187
|
onError: (err) => prompts.error(err.message),
|
|
184
188
|
});
|
|
@@ -190,7 +194,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
190
194
|
},
|
|
191
195
|
onSuccess: (result) => {
|
|
192
196
|
if (result) {
|
|
193
|
-
setEntries(
|
|
197
|
+
setEntries((entry) => entry.id === result.accessId, "permission", result.permission);
|
|
194
198
|
}
|
|
195
199
|
},
|
|
196
200
|
onError: (err) => prompts.error(err.message),
|
|
@@ -208,14 +212,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
208
212
|
return entry.id;
|
|
209
213
|
},
|
|
210
214
|
onSuccess: (accessId) => {
|
|
211
|
-
if (accessId) setEntries(
|
|
215
|
+
if (accessId) setEntries((current) => current.filter((entry) => entry.id !== accessId));
|
|
212
216
|
},
|
|
213
217
|
onError: (err) => prompts.error(err.message),
|
|
214
218
|
});
|
|
215
219
|
const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
|
|
216
220
|
// The service refuses to remove the last manager; the row says so up front.
|
|
217
221
|
const lastManagerId = () => {
|
|
218
|
-
const managers = entries
|
|
222
|
+
const managers = entries.filter(isManagerEntry);
|
|
219
223
|
return managers.length === 1 ? managers[0]!.id : null;
|
|
220
224
|
};
|
|
221
225
|
|
|
@@ -223,25 +227,30 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
223
227
|
<div class="flex flex-col gap-3">
|
|
224
228
|
{/* Existing entries */}
|
|
225
229
|
<div class="flex flex-col gap-1">
|
|
226
|
-
<For each={entries
|
|
227
|
-
{(entry) =>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
230
|
+
<For each={entries}>
|
|
231
|
+
{(entry) => {
|
|
232
|
+
// A row keeps its principal, so its group coverage is created once per row.
|
|
233
|
+
const coverage = entry.principal.type === "group" ? createGroupCoverage(entry.principal.groupId) : undefined;
|
|
234
|
+
return (
|
|
235
|
+
<AccessEntryRow
|
|
236
|
+
entry={entry}
|
|
237
|
+
coverage={coverage}
|
|
238
|
+
canEdit={canEdit()}
|
|
239
|
+
disabled={busy()}
|
|
240
|
+
lastManager={entry.id === lastManagerId()}
|
|
241
|
+
allowed={allowed(entry.principal)}
|
|
242
|
+
singlePicker={allowed(entry.principal).length === 1}
|
|
243
|
+
onUpdatePermission={(permission) => {
|
|
244
|
+
if (!busy()) void updateMut.mutate({ accessId: entry.id, permission });
|
|
245
|
+
}}
|
|
246
|
+
onRevoke={() => {
|
|
247
|
+
if (!busy()) void revokeMut.mutate(entry);
|
|
248
|
+
}}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
}}
|
|
243
252
|
</For>
|
|
244
|
-
<Show when={entries
|
|
253
|
+
<Show when={entries.length === 0}>
|
|
245
254
|
<Placeholder align="left" class="px-1 py-2" description={t().noDirectGrants} />
|
|
246
255
|
</Show>
|
|
247
256
|
</div>
|
|
@@ -251,7 +260,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
251
260
|
a higher level. KISS: one decision per step. */}
|
|
252
261
|
<Show when={canEdit()}>
|
|
253
262
|
<PrincipalPicker
|
|
254
|
-
existing={entries
|
|
263
|
+
existing={entries.map((e) => e.principal)}
|
|
255
264
|
allowPublic={allowPublic()}
|
|
256
265
|
allowAuthenticated={allowAuthenticated()}
|
|
257
266
|
allowServiceAccounts={props.allowServiceAccounts}
|
|
@@ -280,6 +289,8 @@ function AccessEntryRow(props: {
|
|
|
280
289
|
/** When true the per-row picker collapses to a non-interactive badge
|
|
281
290
|
* (single-level mode — there's nothing to switch to). */
|
|
282
291
|
singlePicker: boolean;
|
|
292
|
+
/** Present for group grants: who currently receives access through the group. */
|
|
293
|
+
coverage?: GroupCoverage;
|
|
283
294
|
onUpdatePermission: (permission: GrantableLevel) => void;
|
|
284
295
|
onRevoke: () => void;
|
|
285
296
|
}) {
|
|
@@ -306,89 +317,98 @@ function AccessEntryRow(props: {
|
|
|
306
317
|
);
|
|
307
318
|
|
|
308
319
|
return (
|
|
309
|
-
<div
|
|
310
|
-
<
|
|
311
|
-
when={props.entry.principal.type === "user"}
|
|
312
|
-
fallback={
|
|
313
|
-
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
|
314
|
-
<i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
|
|
315
|
-
</div>
|
|
316
|
-
}
|
|
317
|
-
>
|
|
318
|
-
<CloudAvatar
|
|
319
|
-
username={displayName()}
|
|
320
|
-
userId={props.entry.principal.type === "user" ? props.entry.principal.userId : undefined}
|
|
321
|
-
avatarHash={props.entry.avatarHash}
|
|
322
|
-
size="xs"
|
|
323
|
-
class="h-7 w-7"
|
|
324
|
-
/>
|
|
325
|
-
</Show>
|
|
326
|
-
|
|
327
|
-
{/* Display name. Name and label share one line so every row keeps its height on phones:
|
|
328
|
-
when both do not fit, each gets an equal share and the shorter one stays whole. */}
|
|
329
|
-
<div class="grid min-w-0 flex-1 auto-cols-[minmax(0,max-content)] grid-flow-col items-baseline gap-1">
|
|
330
|
-
<span class="truncate text-sm">{displayName()}</span>
|
|
331
|
-
<Show when={props.entry.principal.type === "public"}>
|
|
332
|
-
<span class="truncate text-xs text-dimmed">({t().anyoneWithLink})</span>
|
|
333
|
-
</Show>
|
|
320
|
+
<div>
|
|
321
|
+
<div class="group/access-row flex items-center gap-2 py-1.5">
|
|
334
322
|
<Show
|
|
335
|
-
when={
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
323
|
+
when={props.entry.principal.type === "user"}
|
|
324
|
+
fallback={
|
|
325
|
+
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
|
326
|
+
<i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
|
|
327
|
+
</div>
|
|
339
328
|
}
|
|
340
329
|
>
|
|
341
|
-
|
|
330
|
+
<CloudAvatar
|
|
331
|
+
username={displayName()}
|
|
332
|
+
userId={props.entry.principal.type === "user" ? props.entry.principal.userId : undefined}
|
|
333
|
+
avatarHash={props.entry.avatarHash}
|
|
334
|
+
size="xs"
|
|
335
|
+
class="h-7 w-7"
|
|
336
|
+
/>
|
|
342
337
|
</Show>
|
|
343
|
-
</div>
|
|
344
338
|
|
|
345
|
-
|
|
339
|
+
{/* Display name. Name and label share one line so every row keeps its height on phones:
|
|
340
|
+
when both do not fit, each gets an equal share and the shorter one stays whole. A group's
|
|
341
|
+
members toggle always stays whole; the name takes the rest. */}
|
|
342
|
+
<div
|
|
343
|
+
class="grid min-w-0 flex-1 auto-cols-[minmax(0,max-content)] grid-flow-col items-baseline gap-1"
|
|
344
|
+
classList={{ "grid-cols-[minmax(0,max-content)_max-content]": props.coverage !== undefined }}
|
|
345
|
+
>
|
|
346
|
+
<span class="truncate text-sm">{displayName()}</span>
|
|
347
|
+
<Show when={props.entry.principal.type === "public"}>
|
|
348
|
+
<span class="truncate text-xs text-dimmed">({t().anyoneWithLink})</span>
|
|
349
|
+
</Show>
|
|
350
|
+
<Show
|
|
351
|
+
when={
|
|
352
|
+
props.entry.principal.type === "service_account" &&
|
|
353
|
+
props.entry.serviceAccountKind &&
|
|
354
|
+
serviceAccountKindDisplay(props.entry.serviceAccountKind, t()).label
|
|
355
|
+
}
|
|
356
|
+
>
|
|
357
|
+
{(label) => <span class="truncate text-xs text-dimmed">({label()})</span>}
|
|
358
|
+
</Show>
|
|
359
|
+
<Show when={props.coverage}>{(coverage) => <GroupCoverageToggle coverage={coverage()} groupName={displayName()} />}</Show>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{/* Permission badge — interactive single-value picker when editable,
|
|
346
363
|
plain span otherwise. */}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
364
|
+
<Show when={isInteractive()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
|
|
365
|
+
<SelectChip
|
|
366
|
+
aria-label={t().permissionFor({ name: displayName() })}
|
|
367
|
+
value={() => props.entry.permission as GrantableLevel}
|
|
368
|
+
options={props.allowed.map((option) => {
|
|
369
|
+
const locked = props.lastManager && option.level !== "admin";
|
|
370
|
+
return {
|
|
371
|
+
value: option.level,
|
|
372
|
+
label: option.label,
|
|
373
|
+
icon: `ti ${option.icon}`,
|
|
374
|
+
disabled: locked,
|
|
375
|
+
description: locked ? t().lastManagerOption({ level: manageLabel() }) : undefined,
|
|
376
|
+
};
|
|
377
|
+
})}
|
|
378
|
+
icon={`ti ${display().icon}`}
|
|
379
|
+
position="bottom-left"
|
|
380
|
+
onValueChange={(permission) => {
|
|
381
|
+
if (permission !== props.entry.permission) props.onUpdatePermission(permission);
|
|
382
|
+
}}
|
|
383
|
+
/>
|
|
384
|
+
</Show>
|
|
368
385
|
|
|
369
|
-
|
|
386
|
+
{/* Destructive row actions stay quiet until the row is engaged. They
|
|
370
387
|
remain keyboard reachable and stay visible on touch-sized layouts. */}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
>
|
|
376
|
-
<IconButton
|
|
377
|
-
type="button"
|
|
378
|
-
label={t().remove({ name: displayName() })}
|
|
379
|
-
variant="ghost"
|
|
380
|
-
size="xs"
|
|
381
|
-
onClick={props.onRevoke}
|
|
382
|
-
disabled={props.disabled || props.lastManager}
|
|
383
|
-
aria-label={t().remove({ name: displayName() })}
|
|
384
|
-
// A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
|
|
385
|
-
aria-description={props.lastManager ? removeLabel() : undefined}
|
|
386
|
-
class="focus-ui flex h-7 w-7 items-center justify-center rounded text-zinc-400 transition-colors focus:opacity-100 enabled:hover:bg-red-500/[0.08] enabled:hover:text-red-600 dark:enabled:hover:text-red-400"
|
|
388
|
+
<Show when={props.canEdit}>
|
|
389
|
+
<Tooltip.Anchor
|
|
390
|
+
content={removeLabel()}
|
|
391
|
+
class="shrink-0 opacity-100 transition-opacity sm:opacity-0 sm:group-hover/access-row:opacity-100 sm:group-focus-within/access-row:opacity-100"
|
|
387
392
|
>
|
|
388
|
-
<
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
393
|
+
<IconButton
|
|
394
|
+
type="button"
|
|
395
|
+
label={t().remove({ name: displayName() })}
|
|
396
|
+
variant="ghost"
|
|
397
|
+
size="xs"
|
|
398
|
+
onClick={props.onRevoke}
|
|
399
|
+
disabled={props.disabled || props.lastManager}
|
|
400
|
+
aria-label={t().remove({ name: displayName() })}
|
|
401
|
+
// A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
|
|
402
|
+
aria-description={props.lastManager ? removeLabel() : undefined}
|
|
403
|
+
class="focus-ui flex h-7 w-7 items-center justify-center rounded text-zinc-400 transition-colors focus:opacity-100 enabled:hover:bg-red-500/[0.08] enabled:hover:text-red-600 dark:enabled:hover:text-red-400"
|
|
404
|
+
>
|
|
405
|
+
<i class="ti ti-x text-sm" />
|
|
406
|
+
</IconButton>
|
|
407
|
+
</Tooltip.Anchor>
|
|
408
|
+
</Show>
|
|
409
|
+
</div>
|
|
410
|
+
{/* Expanding pushes the rows below down, only on the viewer's own request. */}
|
|
411
|
+
<Show when={props.coverage}>{(coverage) => <GroupCoveragePanel coverage={coverage()} groupName={displayName()} />}</Show>
|
|
392
412
|
</div>
|
|
393
413
|
);
|
|
394
414
|
}
|
package/src/access/messages.ts
CHANGED
|
@@ -29,6 +29,21 @@ export const accessMessages = i18n.define({
|
|
|
29
29
|
lastManager: ({ level }: { level: string }) =>
|
|
30
30
|
`The last entry with “${level}” access can't be lowered or removed. Give another person or group “${level}” access first.`,
|
|
31
31
|
lastManagerOption: ({ level }: { level: string }) => `Available once another person or group has “${level}” access`,
|
|
32
|
+
groupMembers: "Members",
|
|
33
|
+
groupMembersOf: ({ name }: { name: string }) => `Members of ${name}`,
|
|
34
|
+
groupMemberCount: ({ count }: { count: number }) =>
|
|
35
|
+
count === 0
|
|
36
|
+
? "No one receives access through this group right now."
|
|
37
|
+
: count === 1
|
|
38
|
+
? "1 person receives access through this group right now."
|
|
39
|
+
: `${count.toLocaleString("en")} people receive access through this group right now.`,
|
|
40
|
+
groupMembersLoading: "Loading members…",
|
|
41
|
+
groupMembersHidden: "Your account can't see who belongs to this group.",
|
|
42
|
+
groupMembersFailed: "Members couldn't be loaded.",
|
|
43
|
+
retry: "Try again",
|
|
44
|
+
showMoreMembers: ({ count }: { count: number }) => `Show ${count.toLocaleString("en")} more`,
|
|
45
|
+
directoryGroupHint:
|
|
46
|
+
"This group comes from the directory. Local accounts such as guests can't be members, so give them access directly.",
|
|
32
47
|
read: "Read",
|
|
33
48
|
readDescription: "Read this resource through the app API.",
|
|
34
49
|
write: "Write",
|
|
@@ -92,6 +107,21 @@ export const accessMessages = i18n.define({
|
|
|
92
107
|
lastManager: ({ level }) =>
|
|
93
108
|
`Der letzte Eintrag mit Zugriff „${level}“ kann nicht herabgestuft oder entfernt werden. Gib zuerst einer anderen Person oder Gruppe Zugriff „${level}“.`,
|
|
94
109
|
lastManagerOption: ({ level }) => `Möglich, sobald eine weitere Person oder Gruppe Zugriff „${level}“ hat`,
|
|
110
|
+
groupMembers: "Mitglieder",
|
|
111
|
+
groupMembersOf: ({ name }) => `Mitglieder von ${name}`,
|
|
112
|
+
groupMemberCount: ({ count }) =>
|
|
113
|
+
count === 0
|
|
114
|
+
? "Über diese Gruppe erhält gerade niemand Zugriff."
|
|
115
|
+
: count === 1
|
|
116
|
+
? "Über diese Gruppe erhält gerade 1 Person Zugriff."
|
|
117
|
+
: `Über diese Gruppe erhalten gerade ${count.toLocaleString("de")} Personen Zugriff.`,
|
|
118
|
+
groupMembersLoading: "Mitglieder werden geladen…",
|
|
119
|
+
groupMembersHidden: "Dein Konto kann nicht sehen, wer zu dieser Gruppe gehört.",
|
|
120
|
+
groupMembersFailed: "Die Mitglieder konnten nicht geladen werden.",
|
|
121
|
+
retry: "Erneut versuchen",
|
|
122
|
+
showMoreMembers: ({ count }) => `${count.toLocaleString("de")} weitere anzeigen`,
|
|
123
|
+
directoryGroupHint:
|
|
124
|
+
"Diese Gruppe stammt aus dem Verzeichnis. Lokale Konten wie Gäste können nicht Mitglied sein; gib ihnen direkt Zugriff.",
|
|
95
125
|
read: "Lesen",
|
|
96
126
|
readDescription: "Diese Ressource über die App-API lesen.",
|
|
97
127
|
write: "Schreiben",
|
|
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { listCapabilities } from "../_internal/registry";
|
|
5
5
|
import { ErrorResponseSchema } from "../contracts";
|
|
6
6
|
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
7
|
-
import { type AuthContext, auth, expectUserBackedActor, jsonResponse, rateLimit, requiresAuth, v } from "../server";
|
|
7
|
+
import { type AuthContext, auth, expectUserBackedActor, getLocale, jsonResponse, rateLimit, requiresAuth, v } from "../server";
|
|
8
8
|
import { type AiToolApprovalPreference, listAiToolApprovalPreferences, revokeAiToolApprovalPreference } from "./approvals";
|
|
9
9
|
import { buildAiCapabilityCatalog } from "./capabilities";
|
|
10
10
|
|
|
@@ -70,7 +70,7 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
|
|
|
70
70
|
describeRoute({
|
|
71
71
|
tags: ["AI"],
|
|
72
72
|
summary: "List remembered AI approvals",
|
|
73
|
-
description: "Lists AI tool approvals remembered for the current user.",
|
|
73
|
+
description: "Lists AI tool approvals remembered for the current user, with titles in the request locale.",
|
|
74
74
|
...requiresAuth,
|
|
75
75
|
responses: {
|
|
76
76
|
200: jsonResponse(AiApprovalPreferenceListSchema, "Remembered approvals"),
|
|
@@ -84,16 +84,16 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
|
|
|
84
84
|
listPreferences(user.id),
|
|
85
85
|
registry().catch(() => [] as CapabilityRegistryEntry[]),
|
|
86
86
|
]);
|
|
87
|
-
const catalogByName = new Map(buildAiCapabilityCatalog(capabilities).map((entry) => [entry.name, entry]));
|
|
87
|
+
const catalogByName = new Map(buildAiCapabilityCatalog(capabilities, getLocale(c)).map((entry) => [entry.name, entry]));
|
|
88
88
|
const approvals: AiApprovalPreferenceView[] = preferences.map((preference) => {
|
|
89
89
|
const capability = catalogByName.get(preference.toolName);
|
|
90
90
|
return {
|
|
91
91
|
...preference,
|
|
92
|
-
title: capability?.title ?? fallbackTitle(preference.toolName),
|
|
92
|
+
title: capability?.display.title ?? fallbackTitle(preference.toolName),
|
|
93
93
|
app: capability
|
|
94
94
|
? {
|
|
95
95
|
id: capability.appId,
|
|
96
|
-
name: capability.appName,
|
|
96
|
+
name: capability.display.appName,
|
|
97
97
|
icon: capability.app.appIcon,
|
|
98
98
|
...(capability.app.appAccent ? { accent: capability.app.appAccent } : {}),
|
|
99
99
|
}
|