Added ldap and pam authentication
This commit is contained in:
+8
-5
@@ -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) {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user