Add CalendarClock icon, planner/settings capabilities
- backend/src/auth.js: extract caps from JWT (planner/settings), hasCap/requireCap
- backend/src/routes/config.js: gate categories + test-connection on requireCap('settings')
- frontend/src/types.ts: add caps[] to User, export can() helper
- frontend/src/App.tsx: gate /settings route on can(user, 'settings')
- frontend/src/components/Layout.tsx: gate nav item on can(user, 'settings')
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
773d323060
commit
135f1f6cf0
6 changed files with 39 additions and 13 deletions
|
|
@ -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 = {
|
request.user = {
|
||||||
email: payload.sub,
|
email: payload.sub,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
is_admin: payload.is_admin ?? false,
|
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}` })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { requireAuth } from '../auth.js'
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
import { getConfig, setConfig } from '../db.js'
|
import { getConfig, setConfig } from '../db.js'
|
||||||
import { fetchSitesList, testConnection } from '../lib/newbook.js'
|
import { fetchSitesList, testConnection } from '../lib/newbook.js'
|
||||||
|
|
||||||
|
|
@ -121,10 +121,9 @@ export async function configRoutes(app) {
|
||||||
return { ok: true, date }
|
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) => {
|
app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||||
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
|
|
||||||
|
|
||||||
let sites
|
let sites
|
||||||
try {
|
try {
|
||||||
|
|
@ -160,10 +159,9 @@ export async function configRoutes(app) {
|
||||||
return { categories: ordered }
|
return { categories: ordered }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── PUT /api/categories — admin: save order + exclusions ─────────────────
|
// ── PUT /api/categories — settings cap required ──────────────────────────
|
||||||
|
|
||||||
app.put('/api/categories', async (req, reply) => {
|
app.put('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||||
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
|
|
||||||
|
|
||||||
const { order, excluded } = req.body || {}
|
const { order, excluded } = req.body || {}
|
||||||
if (!Array.isArray(order)) return reply.status(400).send({ error: 'Invalid data' })
|
if (!Array.isArray(order)) return reply.status(400).send({ error: 'Invalid data' })
|
||||||
|
|
@ -179,10 +177,9 @@ export async function configRoutes(app) {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── POST /api/newbook/test — admin ────────────────────────────────────────
|
// ── POST /api/newbook/test — settings cap required ───────────────────────
|
||||||
|
|
||||||
app.post('/api/newbook/test', async (req, reply) => {
|
app.post('/api/newbook/test', { preHandler: requireCap('settings') }, async (_req, _reply) => {
|
||||||
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
|
|
||||||
return testConnection()
|
return testConnection()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { AuthGate } from './components/AuthGate'
|
||||||
import { Layout } from './components/Layout'
|
import { Layout } from './components/Layout'
|
||||||
import { Planner } from './pages/Planner'
|
import { Planner } from './pages/Planner'
|
||||||
import { CategorySettings } from './pages/CategorySettings'
|
import { CategorySettings } from './pages/CategorySettings'
|
||||||
|
import { can } from './types'
|
||||||
import type { User } from './types'
|
import type { User } from './types'
|
||||||
|
|
||||||
function AppRoutes({ user }: { user: User }) {
|
function AppRoutes({ user }: { user: User }) {
|
||||||
|
|
@ -11,7 +12,7 @@ function AppRoutes({ user }: { user: User }) {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/planner" replace />} />
|
<Route path="/" element={<Navigate to="/planner" replace />} />
|
||||||
<Route path="/planner" element={<Planner />} />
|
<Route path="/planner" element={<Planner />} />
|
||||||
<Route path="/settings" element={user.is_admin ? <CategorySettings /> : <Navigate to="/planner" replace />} />
|
<Route path="/settings" element={can(user, 'settings') ? <CategorySettings /> : <Navigate to="/planner" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/planner" replace />} />
|
<Route path="*" element={<Navigate to="/planner" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { CalendarClock, Settings, LogOut } from 'lucide-react'
|
import { CalendarClock, Settings, LogOut } from 'lucide-react'
|
||||||
|
import { can } from '../types'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -30,7 +31,7 @@ export function Layout({ user, children }: Props) {
|
||||||
|
|
||||||
<div style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
|
<div style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
|
||||||
<NavItem to="/planner" icon={CalendarClock} label="Planner" />
|
<NavItem to="/planner" icon={CalendarClock} label="Planner" />
|
||||||
{user.is_admin && <NavItem to="/settings" icon={Settings} label="Category Settings" />}
|
{can(user, 'settings') && <NavItem to="/settings" icon={Settings} label="Category Settings" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
|
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ export interface User {
|
||||||
email: string
|
email: string
|
||||||
name: string
|
name: string
|
||||||
is_admin: boolean
|
is_admin: boolean
|
||||||
|
caps: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function can(user: User, cap: string): boolean {
|
||||||
|
return user.is_admin || user.caps.includes(cap)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DayData {
|
export interface DayData {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
|
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
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue