From f902e87e2535bc9b747dbf7dff83a00adb0785ed Mon Sep 17 00:00:00 2001
From: jtricerolph
Date: Tue, 7 Jul 2026 12:57:17 +0000
Subject: [PATCH] =?UTF-8?q?Add=20App=20Settings=20tab=20to=20admin=20setti?=
=?UTF-8?q?ngs=20=E2=80=94=20per-app=20session=20timeout=20UI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Sonnet 4.6
---
src/pages/AdminSettings.tsx | 124 ++++++++++++++++++++++++++++++++++--
1 file changed, 120 insertions(+), 4 deletions(-)
diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx
index 78aaf53..b1a9c52 100644
--- a/src/pages/AdminSettings.tsx
+++ b/src/pages/AdminSettings.tsx
@@ -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 = {
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([])
const [loading, setLoading] = useState(true)
@@ -58,7 +68,7 @@ export function AdminSettings({ user }: { user: User }) {
- {(['integrations', 'rooms'] as const).map(t => (
+ {(['integrations', 'rooms', 'apps'] as const).map(t => (
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'}
))}
@@ -84,6 +94,7 @@ export function AdminSettings({ user }: { user: User }) {
)}
{tab === 'rooms' && }
+ {tab === 'apps' && }
)
@@ -426,6 +437,111 @@ function RoomsTab() {
)
}
+function AppsTab() {
+ const [apps, setApps] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [values, setValues] = useState>({})
+ const [saving, setSaving] = useState>({})
+ const [saved, setSaved] = useState>({})
+
+ 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 = {}
+ 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>((acc, a) => {
+ const cat = a.category ?? 'Other'
+ ;(acc[cat] ??= []).push(a)
+ return acc
+ }, {})
+
+ if (loading) return Loading…
+
+ return (
+
+
+ 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).
+
+
+ {Object.entries(byCategory).sort(([a], [b]) => a.localeCompare(b)).map(([cat, catApps]) => (
+
+
+ {cat}
+
+
+ {catApps.map(app => (
+
+
+
+ {app.name}
+
+
+
+ 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' }}
+ />
+ hours
+
+
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'}
+
+
+ ))}
+
+
+ ))}
+
+ )
+}
+
const inputStyle: React.CSSProperties = {
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',