@camstack/ui-library 1.2.45 → 1.2.47

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/dist/index.cjs CHANGED
@@ -10362,6 +10362,29 @@ var Lightbulb = createLucideIcon("lightbulb", [
10362
10362
  * This source code is licensed under the ISC license.
10363
10363
  * See the LICENSE file in the root directory of this source tree.
10364
10364
  */
10365
+ var Link2 = createLucideIcon("link-2", [
10366
+ ["path", {
10367
+ d: "M9 17H7A5 5 0 0 1 7 7h2",
10368
+ key: "8i5ue5"
10369
+ }],
10370
+ ["path", {
10371
+ d: "M15 7h2a5 5 0 1 1 0 10h-2",
10372
+ key: "1b9ql8"
10373
+ }],
10374
+ ["line", {
10375
+ x1: "8",
10376
+ x2: "16",
10377
+ y1: "12",
10378
+ y2: "12",
10379
+ key: "1jonct"
10380
+ }]
10381
+ ]);
10382
+ /**
10383
+ * @license lucide-react v0.576.0 - ISC
10384
+ *
10385
+ * This source code is licensed under the ISC license.
10386
+ * See the LICENSE file in the root directory of this source tree.
10387
+ */
10365
10388
  var ListChecks = createLucideIcon("list-checks", [
10366
10389
  ["path", {
10367
10390
  d: "M13 5h8",
@@ -46880,6 +46903,345 @@ function KebabMenu({ items, header, triggerClassName, title = "More actions" })
46880
46903
  });
46881
46904
  }
46882
46905
  //#endregion
46906
+ //#region src/composites/device-selector-picker.tsx
46907
+ /**
46908
+ * <DeviceSelectorPicker> — the operator-facing editor for a v3
46909
+ * {@link DeviceSelector}: the "on WHICH devices" half of a scope grant.
46910
+ *
46911
+ * Four tabs, one per selector kind, because the kinds are not interchangeable
46912
+ * and the operator has to choose between them deliberately:
46913
+ *
46914
+ * All → every device, now and forever. DYNAMIC.
46915
+ * By type → every device of a `DeviceType`. DYNAMIC — a camera added
46916
+ * tomorrow is covered with no re-grant.
46917
+ * By room → every device carrying an operator-assigned `location` label.
46918
+ * DYNAMIC. Rendered ONLY when the fleet actually has labels;
46919
+ * an empty tab reads as a broken feature, not as "unused".
46920
+ * Chosen → an explicit id list. STATIC — this is the one that does NOT
46921
+ * cover a new device, and the tab says so.
46922
+ *
46923
+ * Counts are LIVE (computed off `deviceManager.listAll`) so "Camera · 12"
46924
+ * answers "how much am I granting" without leaving the panel.
46925
+ *
46926
+ * Shared, not forked: the raw `<ScopePicker>` device row and the simple
46927
+ * Access panel both render this. Two editors for one wire shape is how a
46928
+ * selector ends up meaning different things in two places.
46929
+ */
46930
+ /** Human label for a `DeviceType` — 'lawn-mower' → 'Lawn mower'. */
46931
+ function deviceTypeLabel(type) {
46932
+ const spaced = type.replaceAll("-", " ");
46933
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
46934
+ }
46935
+ /** Which tab a selector belongs to. */
46936
+ function tabForSelector(selector) {
46937
+ return selector.kind;
46938
+ }
46939
+ /**
46940
+ * A fresh selector when the operator switches tabs. Switching is destructive
46941
+ * by nature — an `ids` list has no meaning as a `types` list — so each tab
46942
+ * starts empty rather than guessing a translation.
46943
+ */
46944
+ function emptySelectorFor(tab) {
46945
+ switch (tab) {
46946
+ case "all": return { kind: "all" };
46947
+ case "types": return {
46948
+ kind: "types",
46949
+ types: []
46950
+ };
46951
+ case "locations": return {
46952
+ kind: "locations",
46953
+ locations: []
46954
+ };
46955
+ case "ids": return {
46956
+ kind: "ids",
46957
+ ids: []
46958
+ };
46959
+ }
46960
+ }
46961
+ function DeviceSelectorPicker({ value, onChange, includeLinked, onIncludeLinkedChange, clampToDeviceIds, dense = false }) {
46962
+ const { data: rawDevices } = useDeviceManagerListAll({});
46963
+ const [search, setSearch] = (0, react$1.useState)("");
46964
+ const devices = (0, react$1.useMemo)(() => {
46965
+ const all = (rawDevices ?? []).map((d) => ({
46966
+ id: d.id,
46967
+ name: d.name,
46968
+ type: d.type,
46969
+ location: d.location,
46970
+ parentDeviceId: d.parentDeviceId
46971
+ }));
46972
+ if (clampToDeviceIds == null) return all;
46973
+ const allowed = new Set(clampToDeviceIds);
46974
+ return all.filter((d) => allowed.has(d.id));
46975
+ }, [rawDevices, clampToDeviceIds]);
46976
+ const typeOptions = (0, react$1.useMemo)(() => {
46977
+ const counts = /* @__PURE__ */ new Map();
46978
+ for (const d of devices) counts.set(d.type, (counts.get(d.type) ?? 0) + 1);
46979
+ if (value.kind === "types") {
46980
+ for (const t of value.types) if (!counts.has(t)) counts.set(t, 0);
46981
+ }
46982
+ return [...counts.entries()].map(([type, count]) => ({
46983
+ value: type,
46984
+ label: deviceTypeLabel(type),
46985
+ count
46986
+ })).sort((a, b) => a.label.localeCompare(b.label));
46987
+ }, [devices, value]);
46988
+ const locationOptions = (0, react$1.useMemo)(() => {
46989
+ const counts = /* @__PURE__ */ new Map();
46990
+ for (const d of devices) {
46991
+ if (d.location === null || d.location.length === 0) continue;
46992
+ counts.set(d.location, (counts.get(d.location) ?? 0) + 1);
46993
+ }
46994
+ if (value.kind === "locations") {
46995
+ for (const l of value.locations) if (!counts.has(l)) counts.set(l, 0);
46996
+ }
46997
+ return [...counts.entries()].map(([location, count]) => ({
46998
+ value: location,
46999
+ label: location,
47000
+ count
47001
+ })).sort((a, b) => a.label.localeCompare(b.label));
47002
+ }, [devices, value]);
47003
+ /** The Room tab exists only when the fleet has rooms. */
47004
+ const hasLocations = locationOptions.length > 0;
47005
+ const visibleDevices = (0, react$1.useMemo)(() => {
47006
+ const q = search.trim().toLowerCase();
47007
+ return [...q.length === 0 ? devices : devices.filter((d) => d.name.toLowerCase().includes(q) || String(d.id).includes(q) || (d.location ?? "").toLowerCase().includes(q))].sort((a, b) => a.name.localeCompare(b.name));
47008
+ }, [devices, search]);
47009
+ const activeTab = tabForSelector(value);
47010
+ const tabs = [
47011
+ {
47012
+ id: "all",
47013
+ label: "All"
47014
+ },
47015
+ {
47016
+ id: "types",
47017
+ label: "By type"
47018
+ },
47019
+ ...hasLocations || activeTab === "locations" ? [{
47020
+ id: "locations",
47021
+ label: "By room"
47022
+ }] : [],
47023
+ {
47024
+ id: "ids",
47025
+ label: "Chosen"
47026
+ }
47027
+ ];
47028
+ const toggleType = (type) => {
47029
+ const current = value.kind === "types" ? value.types : [];
47030
+ onChange({
47031
+ kind: "types",
47032
+ types: current.some((t) => String(t) === type) ? current.filter((t) => String(t) !== type) : [...current, ...typeFromString(type)]
47033
+ });
47034
+ };
47035
+ const toggleLocation = (location) => {
47036
+ const current = value.kind === "locations" ? value.locations : [];
47037
+ onChange({
47038
+ kind: "locations",
47039
+ locations: current.includes(location) ? current.filter((l) => l !== location) : [...current, location]
47040
+ });
47041
+ };
47042
+ const toggleId = (id) => {
47043
+ const current = value.kind === "ids" ? value.ids : [];
47044
+ onChange({
47045
+ kind: "ids",
47046
+ ids: current.includes(id) ? current.filter((v) => v !== id) : [...current, id]
47047
+ });
47048
+ };
47049
+ const pad = dense ? "p-2" : "p-3";
47050
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
47051
+ className: "space-y-2",
47052
+ children: [
47053
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
47054
+ className: "flex flex-wrap gap-1 border-b border-border pb-1",
47055
+ children: tabs.map((t) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
47056
+ type: "button",
47057
+ onClick: () => onChange(emptySelectorFor(t.id)),
47058
+ className: `shrink-0 whitespace-nowrap rounded px-2 py-1 text-[11px] transition-colors ${activeTab === t.id ? "bg-primary/15 text-primary font-medium" : "text-foreground-subtle hover:bg-foreground-subtle/10 hover:text-foreground"}`,
47059
+ children: t.label
47060
+ }, t.id))
47061
+ }),
47062
+ activeTab === "all" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
47063
+ className: `rounded border border-border bg-surface ${pad}`,
47064
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
47065
+ className: "text-[11px] text-foreground",
47066
+ children: [
47067
+ "Every device in this deployment — ",
47068
+ devices.length,
47069
+ " right now."
47070
+ ]
47071
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
47072
+ className: "mt-1 text-[10px] text-foreground-subtle",
47073
+ children: "Devices added later are covered automatically."
47074
+ })]
47075
+ }),
47076
+ activeTab === "types" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckboxGrid, {
47077
+ options: typeOptions,
47078
+ selected: value.kind === "types" ? value.types.map((t) => String(t)) : [],
47079
+ onToggle: toggleType,
47080
+ emptyLabel: "No devices to pick a type from.",
47081
+ footnote: "Devices of a checked type are covered automatically as they are added.",
47082
+ pad
47083
+ }),
47084
+ activeTab === "locations" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckboxGrid, {
47085
+ options: locationOptions,
47086
+ selected: value.kind === "locations" ? [...value.locations] : [],
47087
+ onToggle: toggleLocation,
47088
+ emptyLabel: "No device has a room assigned yet.",
47089
+ footnote: "A device with no room set is never covered by a room rule.",
47090
+ pad
47091
+ }),
47092
+ activeTab === "ids" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
47093
+ className: `rounded border border-border bg-surface ${pad} space-y-2`,
47094
+ children: [
47095
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
47096
+ className: "relative",
47097
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-foreground-subtle" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
47098
+ type: "search",
47099
+ value: search,
47100
+ onChange: (e) => setSearch(e.target.value),
47101
+ placeholder: "Search devices…",
47102
+ className: "w-full rounded border border-border bg-background py-1 pl-7 pr-2 text-[11px] text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-1 focus:ring-primary"
47103
+ })]
47104
+ }),
47105
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
47106
+ className: "max-h-56 overflow-auto rounded border border-border",
47107
+ children: [visibleDevices.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
47108
+ className: "px-2 py-3 text-[10px] italic text-foreground-subtle",
47109
+ children: "No device matches."
47110
+ }), visibleDevices.map((d) => {
47111
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
47112
+ className: "flex cursor-pointer items-center gap-2 px-2 py-1.5 text-[11px] hover:bg-foreground-subtle/10",
47113
+ children: [
47114
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
47115
+ type: "checkbox",
47116
+ checked: value.kind === "ids" && value.ids.includes(d.id),
47117
+ onChange: () => toggleId(d.id),
47118
+ className: "rounded border-border focus:ring-primary"
47119
+ }),
47120
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
47121
+ className: "min-w-0 flex-1 truncate text-foreground",
47122
+ children: d.name
47123
+ }),
47124
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
47125
+ className: "shrink-0 font-mono text-[10px] text-foreground-subtle",
47126
+ children: [
47127
+ "#",
47128
+ d.id,
47129
+ d.location !== null && d.location.length > 0 ? ` · ${d.location}` : ""
47130
+ ]
47131
+ })
47132
+ ]
47133
+ }, d.id);
47134
+ })]
47135
+ }),
47136
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
47137
+ className: "text-[10px] text-foreground-subtle",
47138
+ children: [
47139
+ "A fixed list — a device added later is ",
47140
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
47141
+ className: "font-medium",
47142
+ children: "not"
47143
+ }),
47144
+ " covered until you edit this."
47145
+ ]
47146
+ })
47147
+ ]
47148
+ }),
47149
+ includeLinked !== void 0 && onIncludeLinkedChange !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IncludeLinkedToggle, {
47150
+ value: includeLinked,
47151
+ onChange: onIncludeLinkedChange,
47152
+ pad
47153
+ })
47154
+ ]
47155
+ });
47156
+ }
47157
+ /**
47158
+ * Turn a raw string back into the `DeviceType` union without a cast. The
47159
+ * enum's values ARE the strings, so a lookup over its members is both exact
47160
+ * and total — an unknown string yields no entry and selects nothing.
47161
+ */
47162
+ function typeFromString(raw) {
47163
+ return Object.values(_camstack_types.DeviceType).filter((t) => String(t) === raw);
47164
+ }
47165
+ function CheckboxGrid({ options, selected, onToggle, emptyLabel, footnote, pad }) {
47166
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
47167
+ className: `rounded border border-border bg-surface ${pad} space-y-2`,
47168
+ children: [options.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
47169
+ className: "text-[10px] italic text-foreground-subtle",
47170
+ children: emptyLabel
47171
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
47172
+ className: "grid max-h-56 grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-1 overflow-auto",
47173
+ children: options.map((o) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
47174
+ className: "flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-[11px] hover:bg-foreground-subtle/10",
47175
+ children: [
47176
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
47177
+ type: "checkbox",
47178
+ checked: selected.includes(o.value),
47179
+ onChange: () => onToggle(o.value),
47180
+ className: "rounded border-border focus:ring-primary"
47181
+ }),
47182
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
47183
+ className: "min-w-0 flex-1 truncate text-foreground",
47184
+ children: o.label
47185
+ }),
47186
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
47187
+ className: "shrink-0 font-mono text-[10px] text-foreground-subtle",
47188
+ children: o.count
47189
+ })
47190
+ ]
47191
+ }, o.value))
47192
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
47193
+ className: "text-[10px] text-foreground-subtle",
47194
+ children: footnote
47195
+ })]
47196
+ });
47197
+ }
47198
+ /**
47199
+ * The toggle whose OFF state is a real semantic restriction, so the hint says
47200
+ * what it costs rather than describing the mechanism: without it, an operator
47201
+ * granted a camera cannot fire the siren or floodlight attached to it,
47202
+ * because `create` does not inherit parent → child by default.
47203
+ */
47204
+ function IncludeLinkedToggle({ value, onChange, pad }) {
47205
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
47206
+ className: `rounded border border-border bg-surface ${pad}`,
47207
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
47208
+ className: "flex cursor-pointer items-start gap-2",
47209
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
47210
+ type: "checkbox",
47211
+ checked: value,
47212
+ onChange: (e) => onChange(e.target.checked),
47213
+ className: "mt-0.5 rounded border-border focus:ring-primary"
47214
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
47215
+ className: "min-w-0",
47216
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
47217
+ className: "flex items-center gap-1.5 text-[11px] font-medium text-foreground",
47218
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Link2, { className: "h-3 w-3" }), "Include linked devices"]
47219
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
47220
+ className: "mt-0.5 block text-[10px] text-foreground-subtle",
47221
+ children: value ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: "Accessories attached to a granted camera — siren, floodlight, PIR — are covered too." }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
47222
+ "Only the devices selected above. An operator granted a camera will",
47223
+ " ",
47224
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
47225
+ className: "font-medium text-foreground",
47226
+ children: "not"
47227
+ }),
47228
+ " be able to trigger the siren or light attached to it."
47229
+ ] })
47230
+ })]
47231
+ })]
47232
+ })
47233
+ });
47234
+ }
47235
+ /** Summary line for a selector — used by the raw picker's collapsed row. */
47236
+ function describeDeviceSelector(selector) {
47237
+ switch (selector.kind) {
47238
+ case "all": return "All devices";
47239
+ case "ids": return selector.ids.length === 0 ? "No device chosen" : `${selector.ids.length} device${selector.ids.length === 1 ? "" : "s"}`;
47240
+ case "types": return selector.types.length === 0 ? "No type chosen" : selector.types.map((t) => deviceTypeLabel(String(t))).join(", ");
47241
+ case "locations": return selector.locations.length === 0 ? "No room chosen" : selector.locations.join(", ");
47242
+ }
47243
+ }
47244
+ //#endregion
46883
47245
  //#region src/composites/scope-picker.tsx
46884
47246
  /**
46885
47247
  * <ScopePicker> — operator-facing editor for a `TokenScope[]` value.
@@ -46893,13 +47255,24 @@ function KebabMenu({ items, header, triggerClassName, title = "More actions" })
46893
47255
  * - category → target: 'device' | 'system'
46894
47256
  * - capability → target: cap name from `KNOWN_CAP_NAMES`
46895
47257
  * - addon → target: addon id from `useAddonsList`
46896
- * - device → targets: readonly deviceId[] (multi-select)
47258
+ * - device → a v3 `DeviceSelector` (all / ids / types / locations)
47259
+ * plus `includeLinked`, edited by `<DeviceSelectorPicker>`
46897
47260
  *
46898
47261
  * `access` is always 3 independent checkboxes (view / create / delete).
46899
47262
  *
46900
47263
  * `clampToParent` narrows the target dropdown + access checkboxes to
46901
47264
  * what the CALLER possesses. Admins pass `null` to allow everything.
47265
+ *
47266
+ * This is the RAW editor. The simple, two-question Access panel on
47267
+ * `/system/users` composes the same wire shape through `ACCESS_ROLES`; this
47268
+ * one is what an operator drops into when a grant set is not a plain role.
46902
47269
  */
47270
+ /** `<select>` value → the tri-state `includeLinked` it means. */
47271
+ var INCLUDE_LINKED_VALUES = {
47272
+ "": void 0,
47273
+ yes: true,
47274
+ no: false
47275
+ };
46903
47276
  var ACCESS_OPTIONS = [
46904
47277
  {
46905
47278
  value: "view",
@@ -46930,14 +47303,24 @@ function defaultRow() {
46930
47303
  };
46931
47304
  }
46932
47305
  /**
46933
- * Collect the union of device targets a parent grants. Used to clamp
46934
- * the device picker so a child grant can't refer to a deviceId the
46935
- * caller themselves doesn't have.
47306
+ * The deviceIds a parent's grants actually resolve to, against the live
47307
+ * fleet. Used to clamp the device picker so a child grant can't refer to a
47308
+ * device the caller themselves doesn't have.
47309
+ *
47310
+ * v3 selectors are not a literal id list, so this RESOLVES them through the
47311
+ * shared resolver rather than reading ids off the grant — a `types:['camera']`
47312
+ * parent must clamp to its cameras, and re-deriving that here is exactly the
47313
+ * drift D103 warns about. A parent holding `category:device` (unrestricted)
47314
+ * yields the whole fleet.
46936
47315
  */
46937
- function parentDeviceTargets(parent) {
47316
+ function parentDeviceIds(parent, fleet) {
46938
47317
  if (parent == null) return [];
46939
47318
  const out = /* @__PURE__ */ new Set();
46940
- for (const s of parent) if (s.type === "device") for (const t of s.targets) out.add(t);
47319
+ for (const access of [
47320
+ "view",
47321
+ "create",
47322
+ "delete"
47323
+ ]) for (const id of (0, _camstack_types.resolveViewableDeviceIds)(parent, fleet, access)) out.add(id);
46941
47324
  return [...out];
46942
47325
  }
46943
47326
  function clampedSingleTargets(type, parent, addonChoices, capChoices) {
@@ -46968,7 +47351,7 @@ function clampedAccessForRow(row, parent) {
46968
47351
  ];
46969
47352
  const matches = parent.filter((s) => {
46970
47353
  if (s.type !== row.type) return false;
46971
- if (s.type === "device" && row.type === "device") return s.targets.some((t) => row.targets.includes(t));
47354
+ if (s.type === "device" && row.type === "device") return true;
46972
47355
  if (s.type !== "device" && row.type !== "device") return s.target === row.target;
46973
47356
  return false;
46974
47357
  });
@@ -46988,11 +47371,13 @@ function ScopePicker({ value, onChange, clampToParent, emptyHint }) {
46988
47371
  value: c,
46989
47372
  label: c
46990
47373
  })), []);
46991
- const deviceChoices = (0, react$1.useMemo)(() => (devices ?? []).map((d) => ({
46992
- value: String(d.id),
46993
- label: `${d.name} (#${d.id} · ${d.addonId})`
46994
- })).sort((a, b) => a.label.localeCompare(b.label)), [devices]);
46995
- const parentDeviceClamp = (0, react$1.useMemo)(() => clampToParent == null ? null : parentDeviceTargets(clampToParent), [clampToParent]);
47374
+ const fleet = (0, react$1.useMemo)(() => (devices ?? []).map((d) => ({
47375
+ id: d.id,
47376
+ type: d.type,
47377
+ location: d.location,
47378
+ parentDeviceId: d.parentDeviceId
47379
+ })), [devices]);
47380
+ const parentDeviceClamp = (0, react$1.useMemo)(() => clampToParent == null ? null : parentDeviceIds(clampToParent, fleet), [clampToParent, fleet]);
46996
47381
  /**
46997
47382
  * Build a fresh scope row when `type` changes. The discriminated union
46998
47383
  * forces us to re-construct the object per variant so the resulting
@@ -47018,7 +47403,10 @@ function ScopePicker({ value, onChange, clampToParent, emptyHint }) {
47018
47403
  };
47019
47404
  case "device": return {
47020
47405
  type: "device",
47021
- targets: [],
47406
+ selector: {
47407
+ kind: "ids",
47408
+ ids: []
47409
+ },
47022
47410
  access: accessArr
47023
47411
  };
47024
47412
  }
@@ -47028,11 +47416,18 @@ function ScopePicker({ value, onChange, clampToParent, emptyHint }) {
47028
47416
  if (i !== idx) return s;
47029
47417
  if (patch.type !== void 0 && patch.type !== s.type) return rebuildForType(patch.type, patch.access ?? s.access);
47030
47418
  const access = [...patch.access ?? s.access];
47031
- if (s.type === "device") return {
47032
- type: "device",
47033
- targets: [...patch.targets ?? s.targets],
47034
- access
47035
- };
47419
+ if (s.type === "device") {
47420
+ const base = {
47421
+ type: "device",
47422
+ selector: patch.selector ?? s.selector,
47423
+ access
47424
+ };
47425
+ const linked = "includeLinked" in patch ? patch.includeLinked : s.includeLinked;
47426
+ return linked === void 0 ? base : {
47427
+ ...base,
47428
+ includeLinked: linked
47429
+ };
47430
+ }
47036
47431
  if (s.type === "category") return {
47037
47432
  type: "category",
47038
47433
  target: patch.target ?? s.target,
@@ -47091,11 +47486,35 @@ function ScopePicker({ value, onChange, clampToParent, emptyHint }) {
47091
47486
  })
47092
47487
  ]
47093
47488
  }),
47094
- row.type === "device" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceTargetPicker, {
47095
- value: row.targets,
47096
- choices: deviceChoices,
47097
- clampToParent: parentDeviceClamp,
47098
- onChange: (targets) => update(idx, { targets })
47489
+ row.type === "device" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
47490
+ className: "min-w-[200px] flex-1 space-y-1.5",
47491
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceSelectorPicker, {
47492
+ dense: true,
47493
+ value: row.selector,
47494
+ onChange: (selector) => update(idx, { selector }),
47495
+ clampToDeviceIds: parentDeviceClamp
47496
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
47497
+ className: "flex flex-wrap items-center gap-1.5 text-[10px] text-foreground-subtle",
47498
+ children: ["Linked devices", /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
47499
+ value: row.includeLinked === void 0 ? "" : row.includeLinked ? "yes" : "no",
47500
+ onChange: (e) => update(idx, { includeLinked: INCLUDE_LINKED_VALUES[e.target.value] }),
47501
+ className: "rounded border border-border bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-primary",
47502
+ children: [
47503
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
47504
+ value: "",
47505
+ children: "Automatic (view inherits)"
47506
+ }),
47507
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
47508
+ value: "yes",
47509
+ children: "Always included"
47510
+ }),
47511
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
47512
+ value: "no",
47513
+ children: "Never included"
47514
+ })
47515
+ ]
47516
+ })]
47517
+ })]
47099
47518
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
47100
47519
  value: row.target,
47101
47520
  onChange: (e) => update(idx, { target: e.target.value }),
@@ -47158,76 +47577,40 @@ function ScopePicker({ value, onChange, clampToParent, emptyHint }) {
47158
47577
  ]
47159
47578
  });
47160
47579
  }
47161
- function DeviceTargetPicker({ value, choices, clampToParent, onChange }) {
47162
- const [pendingAdd, setPendingAdd] = (0, react$1.useState)("");
47163
- const visibleChoices = (0, react$1.useMemo)(() => {
47164
- const filtered = clampToParent == null ? choices : choices.filter((c) => clampToParent.includes(c.value));
47165
- const taken = new Set(value);
47166
- return filtered.filter((c) => !taken.has(c.value));
47167
- }, [
47168
- choices,
47169
- clampToParent,
47170
- value
47171
- ]);
47172
- const labelFor = (id) => choices.find((c) => c.value === id)?.label ?? `#${id}`;
47173
- const handleAdd = (id) => {
47174
- if (!id) return;
47175
- onChange([...value, id]);
47176
- setPendingAdd("");
47177
- };
47178
- const handleRemove = (id) => {
47179
- onChange(value.filter((v) => v !== id));
47180
- };
47181
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
47182
- className: "flex-1 min-w-[160px] flex flex-wrap items-center gap-1",
47183
- children: [
47184
- value.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
47185
- className: "text-[10px] text-foreground-subtle italic px-1",
47186
- children: "no devices selected"
47187
- }),
47188
- value.map((id) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
47189
- className: "inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary",
47190
- children: [labelFor(id), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
47191
- type: "button",
47192
- onClick: () => handleRemove(id),
47193
- className: "hover:text-danger transition-colors",
47194
- title: "Remove device",
47195
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(X, { className: "h-2.5 w-2.5" })
47196
- })]
47197
- }, id)),
47198
- visibleChoices.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
47199
- value: pendingAdd,
47200
- onChange: (e) => handleAdd(e.target.value),
47201
- className: "rounded border border-border bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-primary",
47202
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
47203
- value: "",
47204
- children: "+ add device…"
47205
- }), visibleChoices.map((c) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
47206
- value: c.value,
47207
- children: c.label
47208
- }, c.value))]
47209
- })
47210
- ]
47211
- });
47212
- }
47213
47580
  /**
47214
47581
  * Validation helper. The wire schema (`TokenScopeSchema`) requires every
47215
- * scope to carry a non-empty target/targets and `access.length >= 1`.
47216
- * Use this before passing the picker output to a mutate call so the
47582
+ * scope to carry a usable target and `access.length >= 1`; a v3 device
47583
+ * selector additionally requires a non-empty list for every kind except
47584
+ * `all`. Use this before passing the picker output to a mutate call so the
47217
47585
  * user sees a clean inline error instead of a tRPC ZodError.
47586
+ *
47587
+ * An EMPTY selector list is rejected rather than silently treated as "all":
47588
+ * the schema's `.min(1)` would reject it server-side anyway, and the failure
47589
+ * mode of guessing wrong here is granting the whole fleet.
47218
47590
  */
47219
47591
  function validateScopes(scopes) {
47220
47592
  for (const s of scopes) {
47221
47593
  if (s.type === "device") {
47222
- if (s.targets.length === 0) return `Scope device: pick at least one device`;
47594
+ const problem = validateSelector(s.selector);
47595
+ if (problem !== null) return problem;
47223
47596
  } else if (s.target.trim().length === 0) return `Scope ${s.type}: pick a target`;
47224
47597
  if (s.access.length === 0) {
47225
- const targetDesc = s.type === "device" ? `[${s.targets.join(",")}]` : s.target;
47598
+ const targetDesc = s.type === "device" ? describeDeviceSelector(s.selector) : s.target;
47226
47599
  return `Scope ${s.type}:${targetDesc}: pick at least one access`;
47227
47600
  }
47228
47601
  }
47229
47602
  return null;
47230
47603
  }
47604
+ /** Non-empty check per selector kind. `all` selects the fleet and is always
47605
+ * complete. */
47606
+ function validateSelector(selector) {
47607
+ switch (selector.kind) {
47608
+ case "all": return null;
47609
+ case "ids": return selector.ids.length === 0 ? "Scope device: pick at least one device" : null;
47610
+ case "types": return selector.types.length === 0 ? "Scope device: pick at least one device type" : null;
47611
+ case "locations": return selector.locations.length === 0 ? "Scope device: pick at least one room" : null;
47612
+ }
47613
+ }
47231
47614
  //#endregion
47232
47615
  //#region src/contexts/zone-editing.tsx
47233
47616
  /**
@@ -48153,6 +48536,7 @@ exports.DeviceItem = DeviceItem;
48153
48536
  exports.DeviceList = DeviceList;
48154
48537
  exports.DeviceMultiSelectField = DeviceMultiSelectField;
48155
48538
  exports.DeviceSelectField = DeviceSelectField;
48539
+ exports.DeviceSelectorPicker = DeviceSelectorPicker;
48156
48540
  exports.DeviceStepMatrix = DeviceStepMatrix;
48157
48541
  exports.Dialog = Dialog;
48158
48542
  exports.DialogContent = DialogContent;
@@ -48350,10 +48734,12 @@ exports.cursorFractionFor = cursorFractionFor;
48350
48734
  exports.darkColors = require_theme_index.darkColors$1;
48351
48735
  exports.defaultTheme = require_theme_index.defaultTheme$1;
48352
48736
  exports.deriveDeviceKind = deriveDeviceKind;
48737
+ exports.describeDeviceSelector = describeDeviceSelector;
48353
48738
  exports.deviceMatchesFilter = deviceMatchesFilter;
48354
48739
  exports.deviceOptionLabel = deviceOptionLabel;
48355
48740
  exports.deviceRoleMeta = deviceRoleMeta;
48356
48741
  exports.deviceRoleMetaOf = deviceRoleMetaOf;
48742
+ exports.deviceTypeLabel = deviceTypeLabel;
48357
48743
  exports.deviceTypeMeta = deviceTypeMeta;
48358
48744
  exports.deviceTypeMetaOf = deviceTypeMetaOf;
48359
48745
  exports.devicesToOptions = devicesToOptions;
@@ -48411,6 +48797,7 @@ exports.statusIcons = statusIcons;
48411
48797
  exports.stepHasModelForFormat = stepHasModelForFormat;
48412
48798
  exports.stepModelOptions = stepModelOptions;
48413
48799
  exports.stripParentNamePrefix = stripParentNamePrefix;
48800
+ exports.tabForSelector = tabForSelector;
48414
48801
  exports.tankAlert = tankAlert;
48415
48802
  exports.themeToCss = require_theme_index.themeToCss$1;
48416
48803
  exports.trpc = trpc;