diff --git a/backend/src/auth.js b/backend/src/auth.js index 37ef96c..d3c950b 100644 --- a/backend/src/auth.js +++ b/backend/src/auth.js @@ -27,9 +27,31 @@ export async function requireAuth(request, reply) { } } + const prefix = `${APP_SLUG}:` + let caps + if (Array.isArray(payload.caps)) { + caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length)) + } else { + // Legacy token: grant planner access, withhold settings + caps = ['planner'] + } + request.user = { email: payload.sub, name: payload.name, is_admin: payload.is_admin ?? false, + caps, + } +} + +export function hasCap(request, cap) { + return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true +} + +export function requireCap(cap) { + return async (request, reply) => { + if (!hasCap(request, cap)) { + return reply.status(403).send({ error: `Missing capability: ${cap}` }) + } } } diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js index 7d4a310..e2672f5 100644 --- a/backend/src/routes/config.js +++ b/backend/src/routes/config.js @@ -1,4 +1,4 @@ -import { requireAuth } from '../auth.js' +import { requireAuth, requireCap } from '../auth.js' import { getConfig, setConfig } from '../db.js' import { fetchSitesList, testConnection } from '../lib/newbook.js' @@ -121,10 +121,9 @@ export async function configRoutes(app) { return { ok: true, date } }) - // ── GET /api/categories — admin: fetch from Newbook + saved order ───────── + // ── GET /api/categories — settings cap required ────────────────────────── - app.get('/api/categories', async (req, reply) => { - if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' }) + app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => { let sites try { @@ -160,10 +159,9 @@ export async function configRoutes(app) { return { categories: ordered } }) - // ── PUT /api/categories — admin: save order + exclusions ───────────────── + // ── PUT /api/categories — settings cap required ────────────────────────── - app.put('/api/categories', async (req, reply) => { - if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' }) + app.put('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => { const { order, excluded } = req.body || {} if (!Array.isArray(order)) return reply.status(400).send({ error: 'Invalid data' }) @@ -179,10 +177,9 @@ export async function configRoutes(app) { return { ok: true } }) - // ── POST /api/newbook/test — admin ──────────────────────────────────────── + // ── POST /api/newbook/test — settings cap required ─────────────────────── - app.post('/api/newbook/test', async (req, reply) => { - if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' }) + app.post('/api/newbook/test', { preHandler: requireCap('settings') }, async (_req, _reply) => { return testConnection() }) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e7000c..b5ec171 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { AuthGate } from './components/AuthGate' import { Layout } from './components/Layout' import { Planner } from './pages/Planner' import { CategorySettings } from './pages/CategorySettings' +import { can } from './types' import type { User } from './types' function AppRoutes({ user }: { user: User }) { @@ -11,7 +12,7 @@ function AppRoutes({ user }: { user: User }) { } /> } /> - : } /> + : } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 2becb73..beffb1a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,6 @@ import { NavLink } from 'react-router-dom' import { CalendarClock, Settings, LogOut } from 'lucide-react' +import { can } from '../types' import type { User } from '../types' interface Props { @@ -30,7 +31,7 @@ export function Layout({ user, children }: Props) {
- {user.is_admin && } + {can(user, 'settings') && }
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ea02651..f202624 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2,6 +2,11 @@ export interface User { email: string name: string is_admin: boolean + caps: string[] +} + +export function can(user: User, cap: string): boolean { + return user.is_admin || user.caps.includes(cap) } export interface DayData { diff --git a/seed-app.js b/seed-app.js index 5efff06..d41825a 100644 --- a/seed-app.js +++ b/seed-app.js @@ -7,7 +7,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL }) await pool.query(` INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) - VALUES ('hk-planner', 'HK Planner', 'Housekeeping workload and hours planning', '/hk-planner', 'CalendarClock', '#2d6a4f', 'Housekeeping', '10.10.10.116', 3080) + VALUES ('hk-planner', 'HK Planner', 'Housekeeping workload and hours planning', '/hk-planner', 'CalendarClock', '#2d6a4f', 'Housekeeping', '10.10.10.118', 3080) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description,