From 99a3ed1b6b8cbe23e2c0a9a58bca130220a8cbc8 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 28 May 2026 19:28:45 +0200 Subject: [PATCH] feat(dashboard): capabilities helper for role-aware UI shadowing Pure-function module that mirrors crates/manager-core/src/authz.rs and lets dashboard pages decide which create / edit / delete affordances to render. Widens the vitest include so the truth-table test runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashboard/src/lib/capabilities.test.ts | 60 ++++++++++++++++++++++++++ dashboard/src/lib/capabilities.ts | 43 ++++++++++++++++++ dashboard/vitest.config.ts | 2 +- 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/lib/capabilities.test.ts create mode 100644 dashboard/src/lib/capabilities.ts diff --git a/dashboard/src/lib/capabilities.test.ts b/dashboard/src/lib/capabilities.test.ts new file mode 100644 index 0000000..0ce3993 --- /dev/null +++ b/dashboard/src/lib/capabilities.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import type { AppRole, MeDto } from './api'; +import { canAdminApp, canCreateApp, canManageUsers, canWriteApp } from './capabilities'; + +function me(role: MeDto['instance_role']): MeDto { + return { id: 'u', username: 'u', instance_role: role, email: null }; +} + +const ROLES: MeDto['instance_role'][] = ['owner', 'admin', 'member']; +const APP_ROLES: (AppRole | null)[] = ['app_admin', 'editor', 'viewer', null]; + +describe('capabilities', () => { + it('null caller is denied everything', () => { + expect(canCreateApp(null)).toBe(false); + expect(canManageUsers(null)).toBe(false); + expect(canWriteApp(null, 'app_admin')).toBe(false); + expect(canAdminApp(null, 'app_admin')).toBe(false); + }); + + it('canCreateApp + canManageUsers: owner/admin yes, member no', () => { + expect(canCreateApp(me('owner'))).toBe(true); + expect(canCreateApp(me('admin'))).toBe(true); + expect(canCreateApp(me('member'))).toBe(false); + expect(canManageUsers(me('owner'))).toBe(true); + expect(canManageUsers(me('admin'))).toBe(true); + expect(canManageUsers(me('member'))).toBe(false); + }); + + it('owner + admin can write and admin every app regardless of my_role', () => { + for (const role of ['owner', 'admin'] as const) { + for (const appRole of APP_ROLES) { + expect(canWriteApp(me(role), appRole)).toBe(true); + expect(canAdminApp(me(role), appRole)).toBe(true); + } + } + }); + + it('member: write requires app_admin or editor; admin requires app_admin', () => { + const m = me('member'); + expect(canWriteApp(m, 'app_admin')).toBe(true); + expect(canWriteApp(m, 'editor')).toBe(true); + expect(canWriteApp(m, 'viewer')).toBe(false); + expect(canWriteApp(m, null)).toBe(false); + + expect(canAdminApp(m, 'app_admin')).toBe(true); + expect(canAdminApp(m, 'editor')).toBe(false); + expect(canAdminApp(m, 'viewer')).toBe(false); + expect(canAdminApp(m, null)).toBe(false); + }); + + it('canAdminApp implies canWriteApp for every combination', () => { + for (const role of ROLES) { + for (const appRole of APP_ROLES) { + if (canAdminApp(me(role), appRole)) { + expect(canWriteApp(me(role), appRole)).toBe(true); + } + } + } + }); +}); diff --git a/dashboard/src/lib/capabilities.ts b/dashboard/src/lib/capabilities.ts new file mode 100644 index 0000000..baf559d --- /dev/null +++ b/dashboard/src/lib/capabilities.ts @@ -0,0 +1,43 @@ +// Permission predicates the dashboard uses to shadow create / edit / +// delete affordances. Mirrors the canonical role → capability rules in +// crates/manager-core/src/authz.rs: +// +// owner / admin instance role → implicit app_admin on every app +// app_admin → settings, domain claims, delete app, delete scripts +// editor → CRUD on scripts, routes, sandbox config (no script delete) +// viewer → read scripts + execution logs +// member with no membership → no access +// +// These helpers are read-only and have no Svelte runes — callers pass +// the current `MeDto` and (when relevant) the per-app `my_role` they +// already hold. Hiding here never authorizes anything; the backend's +// `require(Capability::…)` is always the ground truth. + +import type { AppRole, MeDto } from './api'; + +/** Owner + admin only. Members never see "New app". */ +export function canCreateApp(me: MeDto | null): boolean { + if (!me) return false; + return me.instance_role === 'owner' || me.instance_role === 'admin'; +} + +/** Owner + admin only — the "Users" admin page is also gated this way. */ +export function canManageUsers(me: MeDto | null): boolean { + if (!me) return false; + return me.instance_role === 'owner' || me.instance_role === 'admin'; +} + +/** Can mutate scripts and routes (Save, +Add route, remove route). */ +export function canWriteApp(me: MeDto | null, appMyRole: AppRole | null): boolean { + if (!me) return false; + if (me.instance_role === 'owner' || me.instance_role === 'admin') return true; + return appMyRole === 'app_admin' || appMyRole === 'editor'; +} + +/** Can take app-admin actions: app settings, domain claims, delete + * app, delete scripts, manage members. */ +export function canAdminApp(me: MeDto | null, appMyRole: AppRole | null): boolean { + if (!me) return false; + if (me.instance_role === 'owner' || me.instance_role === 'admin') return true; + return appMyRole === 'app_admin'; +} diff --git a/dashboard/vitest.config.ts b/dashboard/vitest.config.ts index af3c81a..5a55eb0 100644 --- a/dashboard/vitest.config.ts +++ b/dashboard/vitest.config.ts @@ -9,7 +9,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/lib/rhai/**/*.test.ts'], + include: ['src/lib/**/*.test.ts'], environment: 'node' } });