Add App Settings tab to admin settings — per-app session timeout UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-07 12:57:17 +00:00
parent 1ccaaed8be
commit f902e87e25

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { CheckCircle, XCircle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'
import { CheckCircle, XCircle, RefreshCw, ChevronDown, ChevronUp, Clock } from 'lucide-react'
import { Sidebar } from '../components/Sidebar'
import type { User } from '../types'
@ -36,8 +36,18 @@ const FIELD_LABELS: Record<string, string> = {
location_id: 'Default Location',
}
interface AppRow {
id: number
slug: string
name: string
theme_color: string
category: string | null
active: boolean
max_session_hours: number | null
}
export function AdminSettings({ user }: { user: User }) {
const [tab, setTab] = useState<'integrations' | 'rooms'>('integrations')
const [tab, setTab] = useState<'integrations' | 'rooms' | 'apps'>('integrations')
const [integrations, setIntegrations] = useState<Integration[]>([])
const [loading, setLoading] = useState(true)
@ -58,7 +68,7 @@ export function AdminSettings({ user }: { user: User }) {
</p>
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.75rem', borderBottom: '1px solid var(--card-border)', paddingBottom: '0' }}>
{(['integrations', 'rooms'] as const).map(t => (
{(['integrations', 'rooms', 'apps'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{
background: 'none', border: 'none', padding: '0.5rem 1rem',
fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
@ -66,7 +76,7 @@ export function AdminSettings({ user }: { user: User }) {
borderBottom: tab === t ? '2px solid var(--navy)' : '2px solid transparent',
marginBottom: '-1px',
}}>
{t === 'integrations' ? 'Integrations' : 'Rooms & Sites'}
{t === 'integrations' ? 'Integrations' : t === 'rooms' ? 'Rooms & Sites' : 'App Settings'}
</button>
))}
</div>
@ -84,6 +94,7 @@ export function AdminSettings({ user }: { user: User }) {
)}
{tab === 'rooms' && <RoomsTab />}
{tab === 'apps' && <AppsTab />}
</main>
</div>
)
@ -426,6 +437,111 @@ function RoomsTab() {
)
}
function AppsTab() {
const [apps, setApps] = useState<AppRow[]>([])
const [loading, setLoading] = useState(true)
const [values, setValues] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<Record<string, boolean>>({})
const [saved, setSaved] = useState<Record<string, boolean>>({})
useEffect(() => {
fetch('/api/auth/admin/apps', { credentials: 'include' })
.then(r => r.json())
.then((data: AppRow[]) => {
const active = data.filter(a => a.active)
setApps(active)
const init: Record<string, string> = {}
active.forEach(a => { init[a.slug] = a.max_session_hours != null ? String(a.max_session_hours) : '' })
setValues(init)
})
.finally(() => setLoading(false))
}, [])
async function save(slug: string) {
setSaving(prev => ({ ...prev, [slug]: true }))
setSaved(prev => ({ ...prev, [slug]: false }))
const raw = values[slug].trim()
const max_session_hours = raw === '' ? null : parseInt(raw)
await fetch(`/api/auth/admin/apps/${slug}`, {
method: 'PATCH', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ max_session_hours }),
})
setSaving(prev => ({ ...prev, [slug]: false }))
setSaved(prev => ({ ...prev, [slug]: true }))
setTimeout(() => setSaved(prev => ({ ...prev, [slug]: false })), 2000)
}
const byCategory = apps.reduce<Record<string, AppRow[]>>((acc, a) => {
const cat = a.category ?? 'Other'
;(acc[cat] ??= []).push(a)
return acc
}, {})
if (loading) return <p style={{ color: 'var(--text-mid)' }}>Loading</p>
return (
<div style={{ maxWidth: '640px' }}>
<p style={{ fontSize: '0.83rem', color: 'var(--text-mid)', marginBottom: '1.5rem' }}>
Set a maximum session age per app. Users will be prompted to re-login when the limit is reached,
even if their global session is still valid. Leave blank to use the global default (30 days).
</p>
{Object.entries(byCategory).sort(([a], [b]) => a.localeCompare(b)).map(([cat, catApps]) => (
<div key={cat} style={{ marginBottom: '1.5rem' }}>
<p style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-mid)',
textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>
{cat}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{catApps.map(app => (
<div key={app.slug} style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderRadius: 'var(--radius)', padding: '0.85rem 1.1rem',
display: 'flex', alignItems: 'center', gap: '1rem',
boxShadow: 'var(--shadow-sm)',
}}>
<div style={{
width: '10px', height: '10px', borderRadius: '50%', flexShrink: 0,
background: app.theme_color,
}} />
<span style={{ flex: 1, fontSize: '0.88rem', fontWeight: 500, color: 'var(--text-dark)' }}>
{app.name}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Clock size={13} strokeWidth={1.75} style={{ color: 'var(--text-mid)', flexShrink: 0 }} />
<input
type="number" min="1"
value={values[app.slug] ?? ''}
onChange={e => setValues(prev => ({ ...prev, [app.slug]: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && save(app.slug)}
placeholder="Default (720h)"
style={{ ...inputStyle, width: '130px', textAlign: 'right' }}
/>
<span style={{ fontSize: '0.78rem', color: 'var(--text-mid)', flexShrink: 0 }}>hours</span>
</div>
<button
onClick={() => save(app.slug)}
disabled={saving[app.slug]}
style={{
background: saved[app.slug] ? '#16a34a' : 'var(--navy)',
color: '#fff', border: 'none', borderRadius: '6px',
padding: '0.35rem 0.85rem', fontSize: '0.78rem', fontWeight: 600,
cursor: saving[app.slug] ? 'wait' : 'pointer', flexShrink: 0,
transition: 'background 0.2s',
}}
>
{saving[app.slug] ? '…' : saved[app.slug] ? '✓' : 'Save'}
</button>
</div>
))}
</div>
</div>
))}
</div>
)
}
const inputStyle: React.CSSProperties = {
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',