a member's screens render, and the shell stops asking for things it cannot have
Three findings from granting Files to a role and signing in as the member. THE BLANK SCREEN. WorkspaceView returns null until workspace.isLoaded, and isLoaded was the success flag of GET /api/dashboards — which the `dashboards` capability gated. So a member with files granted got a completely blank Files screen and no request to /api/file-browser at all: the panel never mounted. Terminal, Chat and every other workspace screen were the same. /api/dashboards is not a feature. It is the per-user key-value store where every screen keeps its layout, entirely `personal`, every row keyed to the caller. Gating it does not restrict an account, it breaks it — which is the definition of `core` at the top of the registry. Moved there. And the failure mode was wrong independently: `isLoaded` now covers a failed fetch as well as a successful one, with `loadFailed` for the difference, so a screen that cannot remember its layout still renders with defaults instead of showing nothing and explaining nothing. THE STRAY REQUESTS. Six shell-level queries gated on isAuthenticated but not on capability, so a member's first paint fired 403s at /server-settings/settings, /jobs/counts (every three seconds, forever), /chat/models, /plans, /music/now-playing and the chat access policy. Each now checks the capability it needs. JobsIndicator and RescanButton also render nothing without `tasks` and `items` — the header was offering two links to a screen the member cannot open and a button that would 403. THE PERMISSIONS SCREEN. It listed all fourteen app capabilities on a server where none of their sidecars are installed. Offering to grant Photos on a machine with no Immich is not a permission decision. It now shows only what is installed, lists the rest as "nothing installed for these yet" so their absence reads as a fact rather than a bug, and marks confined rows as needing a Linux account. Fails open on a degraded read. Found while checking that: the headscale catalogue entry claimed only the `headscale` capability, but the same sidecar also serves `vpn` — a member enrolling their own device — so vpn was never subtracted. Hence `alsoServes`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
||||
import { MusicHeart } from '../apps/Music/MusicHeart';
|
||||
@@ -23,6 +24,8 @@ const MUSIC_API = '/api/music';
|
||||
|
||||
export const MusicPlayerHost = () => {
|
||||
const { token, get, put, delete: del } = useClient();
|
||||
const { can } = useCapabilities();
|
||||
const canUseMusic = can('music');
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { current, index, queue, playing, toggle, next, prev, setPlaying, syncIndex, close, loadQueue } =
|
||||
@@ -119,8 +122,12 @@ export const MusicPlayerHost = () => {
|
||||
|
||||
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
|
||||
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
|
||||
//
|
||||
// Also skipped without the `music` capability. This host is mounted by the shell for every account, so it
|
||||
// used to reach for `/music/now-playing` on a member's very first paint and 403.
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
if (!canUseMusic) return;
|
||||
restoredRef.current = true;
|
||||
if (queue.length) return;
|
||||
(async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
|
||||
type AccessPolicy = {
|
||||
allowedModels: string[];
|
||||
@@ -12,11 +13,13 @@ const QUERY_KEY = ['ACCESS_POLICY'];
|
||||
export function useAccessPolicy() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
// Under `/server-settings`, so `server-admin`: owner only. Authentication alone was never the right gate.
|
||||
const { isOwner } = useCapabilities();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: policy = { allowedModels: [] } } = useQuery<AccessPolicy>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
enabled: isAuthenticated && isOwner,
|
||||
queryFn: () => client.get<AccessPolicy>('/server-settings/chat-providers/access-policy'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
@@ -16,7 +16,11 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
||||
const {
|
||||
data: state = {},
|
||||
isSuccess,
|
||||
isError,
|
||||
} = useQuery<UserState>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<UserState>('/dashboards'),
|
||||
@@ -72,7 +76,17 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
|
||||
[key, defaultValue, queryClient],
|
||||
);
|
||||
|
||||
return { key, value, setValue, isLoaded: isSuccess };
|
||||
// `isLoaded` is true on a FAILED fetch as well as a successful one, and the distinction is `loadFailed`.
|
||||
//
|
||||
// Consumers use `isLoaded` to decide whether to render at all — `WorkspaceView` returns null without it.
|
||||
// While that meant only "the request is in flight" it was fine. The moment `/dashboards` could answer 403,
|
||||
// it meant a permanently blank screen: no layout, no panels, no request to the feature the screen is for,
|
||||
// and nothing on screen to say why. A screen that cannot remember its layout should still BE a screen.
|
||||
//
|
||||
// So a failed load resolves to the caller's `defaultValue` and renders. `loadFailed` is exposed so a
|
||||
// consumer can say "not saved" rather than pretend, and `setValue` still attempts its PATCH — if the
|
||||
// failure was transient the write succeeds, and if it was a 403 the existing revert path reports it.
|
||||
return { key, value, setValue, isLoaded: isSuccess || isError, loadFailed: isError };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { useAccessPolicy } from './useAccessPolicy';
|
||||
import { useSettings } from './useSettings';
|
||||
import type { ModelOption } from 'officerdev';
|
||||
@@ -26,10 +27,13 @@ export function getHostHome(): string {
|
||||
export function useModels() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
// `/chat` is `kind: 'execution'` — the agent runs unsandboxed as the server owner, so it is owner-only and
|
||||
// never grantable. The model list is no use to anyone who cannot open a chat.
|
||||
const { can } = useCapabilities();
|
||||
|
||||
const { data: models = [] } = useQuery<ModelOption[]>({
|
||||
queryKey: ['CHAT_MODELS'],
|
||||
enabled: isAuthenticated,
|
||||
enabled: isAuthenticated && can('chat'),
|
||||
queryFn: async () => {
|
||||
const data = await client.get<{
|
||||
models: ModelOption[];
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const usePlans = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { can } = useCapabilities();
|
||||
|
||||
const { data: plans = [] } = useQuery<string[]>({
|
||||
queryKey: ['PLANS'],
|
||||
enabled: isAuthenticated,
|
||||
enabled: isAuthenticated && can('plans'),
|
||||
queryFn: () => client.get<string[]>('/plans'),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
|
||||
type AIHarnesses = {
|
||||
claudeCode: boolean;
|
||||
@@ -18,8 +19,14 @@ export const useServerSettings = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// `/server-settings` is the `server-admin` capability: owner only, and not grantable at any level. This
|
||||
// hook is mounted by shell components that every account loads, so without the guard a member's first
|
||||
// paint fired a 403 at it.
|
||||
const { isOwner } = useCapabilities();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: SETTINGS_KEY,
|
||||
enabled: isOwner,
|
||||
queryFn: () => client.get<ServerSettings>('/server-settings/settings'),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user