Added ldap and pam authentication

This commit is contained in:
Martino Ferrari
2026-06-23 17:59:40 +02:00
parent ac24011487
commit 11120bedca
36 changed files with 1935 additions and 66 deletions
+8 -5
View File
@@ -5,7 +5,7 @@ import ViewMode from './ViewMode';
import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode';
import ControlDialogs from './ControlDialogs';
import { applyZoom, getStoredZoom } from './ZoomControl';
import { applyZoom, getStoredZoom, setServerDefaultZoom, watchDpr } from './ZoomControl';
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
import type { Interface, Me } from './lib/types';
@@ -29,11 +29,14 @@ export default function App() {
}, []);
useEffect(() => {
const dpr = window.devicePixelRatio ?? 1;
document.documentElement.style.setProperty('--dpr', String(dpr));
applyZoom(getStoredZoom());
applyZoom(getStoredZoom(), false);
const unwatchDpr = watchDpr();
if (!fsParam) wsClient.connect('/ws');
fetchMe().then(setMe);
fetchMe().then(me => {
setMe(me);
setServerDefaultZoom(me.defaultZoom);
});
return unwatchDpr;
}, []);
if (fsParam) {
+25 -3
View File
@@ -3,6 +3,8 @@ import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';
import { VersionTree, DiffViewer } from './VersionHistory';
import CueEditor from './CueEditor';
import { useAuth } from './lib/auth';
import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope';
// ── Types (mirror internal/confmgr) ──────────────────────────────────────────
@@ -32,6 +34,8 @@ interface ConfigSet {
version: number;
tag?: string;
owner?: string;
scope?: string;
groups?: string[];
parameters: Parameter[];
}
@@ -43,10 +47,12 @@ interface ConfigInstance {
version: number;
tag?: string;
owner?: string;
scope?: string;
groups?: string[];
values: Record<string, any>;
}
interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; }
interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; owner?: string; scope?: string; groups?: string[]; }
// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation
// logic bound to a set, run when instances of that set are saved.
@@ -292,7 +298,10 @@ function useUndoKeys(undo: () => void, redo: () => void, canUndo: boolean, canRe
// ── Sets manager ──────────────────────────────────────────────────────────────
function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
const me = useAuth();
const [sets, setSets] = useState<Meta[]>([]);
const [bucket, setBucket] = useState<Bucket>('mine');
const [scopeGroup, setScopeGroup] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const hist = useUndo<ConfigSet>();
const working = hist.present;
@@ -445,6 +454,8 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)
} catch (err) { setError(msg(err)); }
}
const visibleSets = filterByScope(sets, me, bucket, scopeGroup);
return (
<div class="cl-body">
<div class="cl-list">
@@ -457,8 +468,10 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)
<input ref={fileRef} type="file" accept="application/json,.json" style={{ display: 'none' }}
onChange={(e) => { const t = e.target as HTMLInputElement; const f = t.files?.[0]; if (f) importSet(f); t.value = ''; }} />
</div>
<ScopeFilter me={me} value={bucket} onChange={setBucket} group={scopeGroup} onGroupChange={setScopeGroup} />
{sets.length === 0 && <div class="hint cl-list-empty">No config sets yet.</div>}
{sets.map(s => (
{sets.length > 0 && visibleSets.length === 0 && <div class="hint cl-list-empty">No config sets in this view.</div>}
{visibleSets.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
<span class="cfg-badge" title="Current version">v{s.version}</span>
@@ -484,6 +497,8 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)
</div>
<textarea class="prop-input cfg-desc" rows={2} value={working.description ?? ''} placeholder="Description (optional)"
onInput={(e) => patch({ description: (e.target as HTMLTextAreaElement).value })} />
<ScopePicker me={me} scope={normalizeScope(working.scope)} groups={working.groups ?? []}
onChange={(scope, groups) => patch({ scope, groups })} />
<div class="cfg-section-head">
<span>Parameters</span>
@@ -802,9 +817,12 @@ function ParamEditor({ param, allOptions, onOpenSignals, signalMeta, onChange, o
// ── Instances manager ─────────────────────────────────────────────────────────
function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
const me = useAuth();
const [instances, setInstances] = useState<Meta[]>([]);
const [sets, setSets] = useState<Meta[]>([]);
const [setFilter, setSetFilter] = useState<string>('');
const [bucket, setBucket] = useState<Bucket>('mine');
const [scopeGroup, setScopeGroup] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const hist = useUndo<ConfigInstance>();
const working = hist.present;
@@ -984,7 +1002,8 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
}
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
const visibleInstances = instances.filter(s => !setFilter || s.setId === setFilter);
const scopedInstances = filterByScope(instances, me, bucket, scopeGroup);
const visibleInstances = scopedInstances.filter(s => !setFilter || s.setId === setFilter);
return (
<div class="cl-body">
@@ -994,6 +1013,7 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
<button class="panel-btn" disabled={busy || sets.length === 0} title="Snapshot a set's current live signal values into a new instance" onClick={() => setShowSnap(true)}> Snapshot</button>
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
</div>
<ScopeFilter me={me} value={bucket} onChange={setBucket} group={scopeGroup} onGroupChange={setScopeGroup} />
{sets.length > 1 && (
<div class="cl-list-filter">
<select class="prop-select" value={setFilter} title="Filter instances by config set"
@@ -1032,6 +1052,8 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
<button class="panel-btn" disabled={busy} title="Compare stored values against the current live signal values" onClick={liveDiff}>Diff vs current</button>
</div>
<div class="hint">Based on set <b>{setName(working.setId)}</b>{working.setVersion ? ` (v${working.setVersion})` : ''}.</div>
<ScopePicker me={me} scope={normalizeScope(working.scope)} groups={working.groups ?? []}
onChange={(scope, groups) => { hist.commit({ ...working, scope, groups }); setDirty(true); }} />
{!boundSet && <div class="cl-error">Bound set could not be loaded.</div>}
{boundSet && (
+15 -1
View File
@@ -8,6 +8,8 @@ import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup,
import { useFlowZoom, type Box } from './lib/flowZoom';
import { formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
import { wsClient } from './lib/ws';
import { useAuth } from './lib/auth';
import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope';
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
@@ -46,6 +48,9 @@ interface CLGraph {
nodes: CLNode[];
wires: CLWire[];
groups?: NodeGroup[];
owner?: string;
scope?: string;
scopeGroups?: string[];
}
interface DataSource { name: string; }
@@ -169,7 +174,10 @@ function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
interface Props { onClose: () => void; }
export default function ControlLogicEditor({ onClose }: Props) {
const me = useAuth();
const [graphs, setGraphs] = useState<CLGraph[]>([]);
const [bucket, setBucket] = useState<Bucket>('mine');
const [scopeGroup, setScopeGroup] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [graph, setGraph] = useState<CLGraph | null>(null);
const [dirty, setDirty] = useState(false);
@@ -308,6 +316,8 @@ export default function ControlLogicEditor({ onClose }: Props) {
}
}
const visibleGraphs = filterByScope(graphs, me, bucket, scopeGroup, g => g.scopeGroups ?? []);
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
<div class="cl-modal cl-modal-full">
@@ -331,8 +341,10 @@ export default function ControlLogicEditor({ onClose }: Props) {
<span>Graphs</span>
<button class="panel-btn" disabled={busy} onClick={createGraph}>+ New</button>
</div>
<ScopeFilter me={me} value={bucket} onChange={setBucket} group={scopeGroup} onGroupChange={setScopeGroup} />
{graphs.length === 0 && <div class="hint cl-list-empty">No control logic yet.</div>}
{graphs.map(g => (
{graphs.length > 0 && visibleGraphs.length === 0 && <div class="hint cl-list-empty">No graphs in this view.</div>}
{visibleGraphs.map(g => (
<div key={g.id}
class={`cl-list-item${selectedId === g.id ? ' cl-list-item-active' : ''}`}
onClick={() => selectGraph(g)}>
@@ -362,6 +374,8 @@ export default function ControlLogicEditor({ onClose }: Props) {
onChange={(e) => patchGraph({ enabled: (e.target as HTMLInputElement).checked })} />
Enabled
</label>
<ScopePicker me={me} scope={normalizeScope(graph.scope)} groups={graph.scopeGroups ?? []}
onChange={(scope, scopeGroups) => patchGraph({ scope, scopeGroups })} />
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
</div>
<div class="cl-editor-main">
+35 -2
View File
@@ -2,6 +2,7 @@ import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import { useAuth, canWrite } from './lib/auth';
import type { Interface, InterfaceListItem, Folder } from './lib/types';
import { ScopeFilter, bucketOf, type Bucket } from './lib/scope';
import ShareDialog from './ShareDialog';
interface Props {
@@ -50,6 +51,11 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
const dragId = useRef<string | null>(null);
const me = useAuth();
const writable = canWrite(me.level);
// Mine/Group/Global visibility filter. Default to Global so the primary
// navigation list isn't empty for users who own no panels; legacy/public
// panels live there.
const [bucket, setBucket] = useState<Bucket>('global');
const [scopeGroup, setScopeGroup] = useState('');
// Panels in a folder ('' = root), sorted by their stored order then name.
function panelsIn(folder: string): InterfaceListItem[] {
@@ -58,6 +64,26 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.name || a.id).localeCompare(b.name || b.id));
}
// Whether a panel falls in the active visibility bucket. In group mode a
// chosen group narrows to panels shared with that specific group.
function inScope(item: InterfaceListItem): boolean {
if (bucketOf(item, me) !== bucket) return false;
if (bucket === 'group' && scopeGroup) return (item.groups ?? []).includes(scopeGroup);
return true;
}
// Panels in a folder that pass the active visibility filter.
function visiblePanelsIn(folder: string): InterfaceListItem[] {
return panelsIn(folder).filter(inScope);
}
// A folder is shown when it (recursively) contains any in-scope panel, so
// empty branches collapse out under the active filter.
function folderHasVisible(id: string): boolean {
if (visiblePanelsIn(id).length > 0) return true;
return folders.some(f => f.parent === id && folderHasVisible(f.id));
}
// Persist a new ordering for a folder's panels (also moves panels between folders).
async function reorder(folder: string, ids: string[]) {
const res = await fetch('/api/v1/interfaces/reorder', {
@@ -116,6 +142,8 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
folder: item.folder ? String(item.folder) : '',
order: Number(item.order || 0),
perm: (item.perm as InterfaceListItem['perm']) || 'write',
scope: item.scope ? String(item.scope) : '',
groups: Array.isArray(item.groups) ? item.groups.map(String) : [],
})).filter((item: InterfaceListItem) => item.id);
setInterfaces(normalized);
setFolders(Array.isArray(folderData) ? folderData : []);
@@ -222,9 +250,11 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
}
// Render a folder and everything beneath it (subfolders first, then panels).
// Folders with no in-scope descendant panels are hidden under the filter.
function renderFolder(folder: Folder) {
if (!folderHasVisible(folder.id)) return null;
const children = folders.filter(f => f.parent === folder.id);
const panels = panelsIn(folder.id);
const panels = visiblePanelsIn(folder.id);
const open = expanded[folder.id] !== false; // default expanded
const canDrop = folder.perm === 'write';
return (
@@ -256,7 +286,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
}
const rootFolders = folders.filter(f => !f.parent);
const rootPanels = panelsIn('');
const rootPanels = visiblePanelsIn('');
return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
@@ -289,6 +319,9 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
</div>
)}
<ScopeFilter me={me} value={bucket} onChange={setBucket}
group={scopeGroup} onGroupChange={setScopeGroup} />
<div class="panel-list">
{loading ? (
<p class="hint">Loading</p>
+20 -1
View File
@@ -8,6 +8,7 @@ import { VersionTree, DiffViewer } from './VersionHistory';
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
import { useFlowZoom, type Box } from './lib/flowZoom';
import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
import { useAuth } from './lib/auth';
interface DataSource { name: string; }
interface SignalInfo { name: string; type?: string; }
@@ -337,8 +338,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const [verReload, setVerReload] = useState(0);
// Identity fields — editable only when creating a new signal.
const me = useAuth();
const [newName, setNewName] = useState('');
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(panelId ? 'panel' : 'user');
const [visibility, setVisibility] = useState<'panel' | 'user' | 'group' | 'global'>(panelId ? 'panel' : 'user');
const [scopeGroups, setScopeGroups] = useState<string[]>([]);
const [unit, setUnit] = useState('');
const [desc, setDesc] = useState('');
const [dispLow, setDispLow] = useState('0');
@@ -878,6 +881,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
if (create) {
body.visibility = visibility;
if (visibility === 'panel' && panelId) body.panel = panelId;
if (visibility === 'group') body.groups = scopeGroups;
}
const res = await fetch(
create ? '/api/v1/synthetic' : `/api/v1/synthetic/${encodeURIComponent(sigName)}`,
@@ -1146,9 +1150,24 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
{panelId && <option value="panel">This panel only</option>}
<option value="user">My signals</option>
<option value="group" disabled={me.groups.length === 0}>A group</option>
<option value="global">Global (all users)</option>
</select>
</div>
{visibility === 'group' && (
<div class="wizard-field">
<label>Groups</label>
<div class="scope-picker-groups">
{me.groups.length === 0 && <span class="hint">You are not in any group.</span>}
{me.groups.map(g => (
<label key={g} class="scope-picker-chk">
<input type="checkbox" checked={scopeGroups.includes(g)}
onChange={() => setScopeGroups(prev => prev.includes(g) ? prev.filter(x => x !== g) : [...prev, g])} />{g}
</label>
))}
</div>
</div>
)}
</Fragment>
) : (
<p class="hint">Editing <b>{name}</b>.</p>
+76 -5
View File
@@ -1,24 +1,95 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { useEffect, useState } from 'preact/hooks';
const LS_KEY = 'uopi:ui-zoom';
const ZOOM_EVENT = 'uopi:zoomchange';
const STEPS = [0.5, 0.6, 0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0, 2.5];
// serverDefaultZoom is the base scale configured server-side ([ui] default_zoom),
// pushed in once /me resolves. It is the fallback when this browser has no saved
// per-machine override, so a fresh client on a HiDPI screen starts enlarged
// without per-browser setup while a user's local A+/A choice still wins.
let serverDefaultZoom = 1;
// hasStoredZoom reports whether this browser has an explicit saved zoom override.
export function hasStoredZoom(): boolean {
return localStorage.getItem(LS_KEY) !== null;
}
// setServerDefaultZoom records the server default and, unless the user has a saved
// override, applies it immediately (without persisting, so a later server change
// still propagates).
export function setServerDefaultZoom(z: number): void {
serverDefaultZoom = isFinite(z) && z > 0 ? z : 1;
if (!hasStoredZoom()) {
applyZoom(serverDefaultZoom, false);
}
}
export function getStoredZoom(): number {
const v = parseFloat(localStorage.getItem(LS_KEY) ?? '1');
const raw = localStorage.getItem(LS_KEY);
const v = raw === null ? serverDefaultZoom : parseFloat(raw);
return isFinite(v) ? Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], v)) : 1;
}
export function applyZoom(z: number): number {
// dprFactor returns the device-pixel-ratio scaling applied on top of the manual
// zoom so the UI is proportionally larger on high-DPI displays by default.
// Firefox only honours CSS rem scaling (it does not auto-enlarge the root px the
// way Chrome does on some setups), so the ratio must be folded into the root
// font-size explicitly rather than left to the browser.
export function dprFactor(): number {
const dpr = window.devicePixelRatio;
return isFinite(dpr) && dpr > 0 ? dpr : 1;
}
// applyZoom sets the root font-size to scale the whole UI. When persist is true
// (a deliberate user action) the choice is saved as this browser's override;
// pass false to apply the server default transiently. A ZOOM_EVENT is dispatched
// so the ZoomControl's displayed percentage stays in sync regardless of source.
export function applyZoom(z: number, persist = true): number {
const clamped = Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], z));
localStorage.setItem(LS_KEY, String(clamped));
document.documentElement.style.fontSize = `${Math.round(16 * clamped)}px`;
if (persist) {
localStorage.setItem(LS_KEY, String(clamped));
}
const dpr = dprFactor();
document.documentElement.style.setProperty('--dpr', String(dpr));
document.documentElement.style.fontSize = `${Math.round(16 * clamped * dpr)}px`;
window.dispatchEvent(new CustomEvent(ZOOM_EVENT, { detail: clamped }));
return clamped;
}
// watchDpr re-applies the current zoom whenever the device-pixel-ratio changes
// (e.g. the window moves to a monitor with different scaling). matchMedia fires
// once per resolution change; the listener is re-armed each time. Returns an
// unsubscribe function.
export function watchDpr(): () => void {
let mql: MediaQueryList | null = null;
const onChange = () => {
// Re-apply at the new DPR; only persist if this browser actually has an
// override (otherwise a monitor change would freeze the server default).
applyZoom(getStoredZoom(), hasStoredZoom());
arm();
};
const arm = () => {
mql?.removeEventListener('change', onChange);
mql = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
mql.addEventListener('change', onChange);
};
arm();
return () => mql?.removeEventListener('change', onChange);
}
export default function ZoomControl() {
const [zoom, setZoom] = useState(getStoredZoom);
// Keep the displayed percentage in sync when zoom is applied from elsewhere
// (e.g. the server default arriving via /me, or a DPR change).
useEffect(() => {
const onZoom = (e: Event) => setZoom((e as CustomEvent<number>).detail);
window.addEventListener(ZOOM_EVENT, onZoom);
return () => window.removeEventListener(ZOOM_EVENT, onZoom);
}, []);
function step(dir: number) {
const idx = STEPS.findIndex(s => s >= zoom - 0.01);
const nextIdx = Math.max(0, Math.min(STEPS.length - 1, idx + dir));
+2 -1
View File
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
// Default identity used before /api/v1/me resolves (or if it fails): assume a
// trusted LAN user with full access, matching the backend default.
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true, canAdmin: true };
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true, canAdmin: true, defaultZoom: 1 };
// AuthContext carries the resolved identity + global access level for the
// current user throughout the app.
@@ -39,6 +39,7 @@ export async function fetchMe(): Promise<Me> {
canEditLogic: data.canEditLogic !== false,
canViewAudit: data.canViewAudit !== false,
canAdmin: data.canAdmin !== false,
defaultZoom: typeof data.defaultZoom === 'number' && data.defaultZoom > 0 ? data.defaultZoom : 1,
};
} catch {
return DEFAULT_ME;
+126
View File
@@ -0,0 +1,126 @@
import { h } from 'preact';
import type { Me } from './types';
// Visibility scope tokens shared by every user-owned, filterable object
// (config sets/instances, synthetic signals, control-logic graphs, panels).
// They mirror internal/access/scope.go. An empty/unknown token is treated as
// global so legacy objects without a scope stay visible to everyone.
export type Scope = 'private' | 'group' | 'global';
// Bucket is the per-tree selector position: which slice of the visible objects
// the user is currently looking at.
export type Bucket = 'mine' | 'group' | 'global';
// Scoped is the minimal shape the scope helpers read off any list item. The
// scope-groups list is not part of this shape because subsystems disagree on
// its field name (config/synthetic use `groups`, control-logic uses
// `scopeGroups`); filterByScope takes an explicit accessor for it instead.
export interface Scoped {
owner?: string;
scope?: string;
}
// normalizeScope coerces a raw scope string into a known token; empty/unknown
// becomes 'global' to match the backend's legacy-safe default.
export function normalizeScope(s?: string): Scope {
switch ((s ?? '').trim().toLowerCase()) {
case 'private': return 'private';
case 'group': return 'group';
default: return 'global';
}
}
// bucketOf assigns an item to exactly one selector bucket from the caller's
// perspective: items they own go to 'mine'; otherwise group-scoped items go to
// 'group' and everything else (global/empty/unknown) to 'global'.
export function bucketOf(item: Scoped, me: Me): Bucket {
if (item.owner && item.owner === me.user) return 'mine';
if (normalizeScope(item.scope) === 'group') return 'group';
return 'global';
}
// filterByScope keeps the items belonging to the active bucket. In group mode a
// non-empty group narrows to items shared with that specific group; groupsOf
// reads each item's scope-groups (defaulting to its `groups` field).
export function filterByScope<T extends Scoped>(
items: T[], me: Me, bucket: Bucket, group: string,
groupsOf: (t: T) => string[] = (t) => (t as any).groups ?? [],
): T[] {
return items.filter(it => {
if (bucketOf(it, me) !== bucket) return false;
if (bucket === 'group' && group) return groupsOf(it).includes(group);
return true;
});
}
// ScopeFilter is the segmented [Mine | Group ▾ | Global] selector shown above a
// filterable tree. The Group segment carries a combo to pick which group when
// the user belongs to more than one; it is disabled for users with no groups.
export function ScopeFilter({ me, value, onChange, group, onGroupChange }: {
me: Me;
value: Bucket;
onChange: (b: Bucket) => void;
group: string;
onGroupChange: (g: string) => void;
}) {
const hasGroups = me.groups.length > 0;
return (
<div class="scope-filter">
<div class="scope-segs">
<button class={`scope-seg${value === 'mine' ? ' scope-seg-active' : ''}`}
onClick={() => onChange('mine')}>Mine</button>
<button class={`scope-seg${value === 'group' ? ' scope-seg-active' : ''}`}
disabled={!hasGroups} title={hasGroups ? 'Shared with a group' : 'You are not in any group'}
onClick={() => onChange('group')}>Group</button>
<button class={`scope-seg${value === 'global' ? ' scope-seg-active' : ''}`}
onClick={() => onChange('global')}>Global</button>
</div>
{value === 'group' && hasGroups && (
<select class="scope-group-select" value={group}
onChange={(e) => onGroupChange((e.target as HTMLSelectElement).value)}>
<option value="">All my groups</option>
{me.groups.map(g => <option key={g} value={g}>{g}</option>)}
</select>
)}
</div>
);
}
// ScopePicker is the visibility editor used in create/save flows: a scope select
// plus, for group scope, checkboxes over the user's groups. It calls onChange
// with both the scope token and the chosen groups so callers persist them
// together.
export function ScopePicker({ me, scope, groups, onChange, disabled }: {
me: Me;
scope: Scope;
groups: string[];
onChange: (scope: Scope, groups: string[]) => void;
disabled?: boolean;
}) {
const toggle = (g: string) => {
const next = groups.includes(g) ? groups.filter(x => x !== g) : [...groups, g];
onChange('group', next);
};
return (
<div class="scope-picker">
<label class="scope-picker-label">Visibility</label>
<select class="prop-input scope-picker-select" value={scope} disabled={disabled}
onChange={(e) => onChange((e.target as HTMLSelectElement).value as Scope, groups)}>
<option value="private">Private (only me)</option>
<option value="group" disabled={me.groups.length === 0}>Group</option>
<option value="global">Global (everyone)</option>
</select>
{scope === 'group' && (
<div class="scope-picker-groups">
{me.groups.length === 0 && <span class="hint">You are not in any group.</span>}
{me.groups.map(g => (
<label key={g} class="scope-picker-chk">
<input type="checkbox" checked={groups.includes(g)} disabled={disabled}
onChange={() => toggle(g)} />{g}
</label>
))}
</div>
)}
</div>
);
}
+9 -1
View File
@@ -228,6 +228,9 @@ export interface Me {
// server statistics). False only when an admins allowlist is configured and
// excludes them.
canAdmin: boolean;
// Base UI scale multiplier configured server-side ([ui] default_zoom), applied
// when this browser has no saved zoom override. 1 means no scaling.
defaultZoom: number;
}
// Effective permission a user holds on a single panel or folder.
@@ -276,6 +279,10 @@ export interface InterfaceListItem {
folder?: string;
order?: number;
perm: Perm;
// Derived visibility bucket (private|group|global) + the groups a group-scoped
// panel is shared with, used by the Mine/Group/Global selector filter.
scope?: string;
groups?: string[];
}
// ── Synthetic signals ────────────────────────────────────────────────────────
@@ -327,7 +334,8 @@ export interface SignalDef {
};
// Visibility scope (default 'panel'). 'owner'/'panel' are stamped/filtered
// server-side; owner is set from the trusted identity on create.
visibility?: 'panel' | 'user' | 'global';
visibility?: 'panel' | 'user' | 'group' | 'global';
owner?: string;
groups?: string[]; // groups for 'group' visibility
panel?: string; // bound interface id when visibility === 'panel'
}
+56
View File
@@ -4817,6 +4817,62 @@ kbd {
margin-left: 0.4rem;
}
/* ── Scope filter (Mine / Group / Global selector) ── */
.scope-filter {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.3rem;
padding: 0.35rem 0.5rem;
}
.scope-segs {
display: flex;
align-items: center;
gap: 0.25rem;
}
.scope-seg {
background: transparent;
border: 1px solid transparent;
color: #94a3b8;
font-size: 0.78rem;
padding: 0.15rem 0.55rem;
border-radius: 6px;
cursor: pointer;
}
.scope-seg:hover:not(:disabled) { color: #e2e8f0; background: #232a3a; }
.scope-seg:disabled { opacity: 0.45; cursor: default; }
.scope-seg-active {
color: #e2e8f0;
background: #2d3748;
border-color: #3d4a63;
}
.scope-group-select {
background: #1a2030;
color: #e2e8f0;
border: 1px solid #3d4a63;
border-radius: 6px;
font-size: 0.78rem;
padding: 0.1rem 0.3rem;
width: 100%;
}
/* ── Scope picker (visibility editor in create/save flows) ── */
.scope-picker { display: flex; flex-direction: column; gap: 0.35rem; }
.scope-picker-label { font-size: 0.78rem; color: #94a3b8; }
.scope-picker-groups {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-left: 0.25rem;
}
.scope-picker-chk {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
color: #cbd5e1;
}
.cfg-editor {
display: flex;
flex-direction: column;