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:
parent
1ccaaed8be
commit
f902e87e25
1 changed files with 120 additions and 4 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
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 { Sidebar } from '../components/Sidebar'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
|
@ -36,8 +36,18 @@ const FIELD_LABELS: Record<string, string> = {
|
||||||
location_id: 'Default Location',
|
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 }) {
|
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 [integrations, setIntegrations] = useState<Integration[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
|
@ -58,7 +68,7 @@ export function AdminSettings({ user }: { user: User }) {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.75rem', borderBottom: '1px solid var(--card-border)', paddingBottom: '0' }}>
|
<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={{
|
<button key={t} onClick={() => setTab(t)} style={{
|
||||||
background: 'none', border: 'none', padding: '0.5rem 1rem',
|
background: 'none', border: 'none', padding: '0.5rem 1rem',
|
||||||
fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
|
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',
|
borderBottom: tab === t ? '2px solid var(--navy)' : '2px solid transparent',
|
||||||
marginBottom: '-1px',
|
marginBottom: '-1px',
|
||||||
}}>
|
}}>
|
||||||
{t === 'integrations' ? 'Integrations' : 'Rooms & Sites'}
|
{t === 'integrations' ? 'Integrations' : t === 'rooms' ? 'Rooms & Sites' : 'App Settings'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -84,6 +94,7 @@ export function AdminSettings({ user }: { user: User }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'rooms' && <RoomsTab />}
|
{tab === 'rooms' && <RoomsTab />}
|
||||||
|
{tab === 'apps' && <AppsTab />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 = {
|
const inputStyle: React.CSSProperties = {
|
||||||
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
|
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
|
||||||
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',
|
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue