Initial commit: portal

This commit is contained in:
jtricerolph 2026-07-01 12:09:54 +00:00
commit b54081113b
18 changed files with 736 additions and 0 deletions

162
src/pages/AdminUsers.tsx Normal file
View file

@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import { Sidebar } from '../components/Sidebar'
import type { User } from '../types'
interface ManagedUser {
id: number
email: string
name: string
active: boolean
is_admin: boolean
offsite_allowed: boolean
app_slugs: string[]
}
export function AdminUsers({ user }: { user: User }) {
const [users, setUsers] = useState<ManagedUser[]>([])
const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([])
const [showCreate, setShowCreate] = useState(false)
async function load() {
const [u, a] = await Promise.all([
fetch('/api/auth/admin/users', { credentials: 'include' }).then(r => r.json()),
fetch('/api/auth/admin/apps', { credentials: 'include' }).then(r => r.json()),
])
setUsers(u)
setAllApps(a)
}
useEffect(() => { load() }, [])
async function toggle(userId: number, field: string, current: boolean) {
await fetch(`/api/auth/admin/users/${userId}`, {
method: 'PATCH', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: !current }),
})
load()
}
async function grantRevoke(userId: number, slug: string, has: boolean) {
await fetch(`/api/auth/admin/users/${userId}/apps/${slug}`, {
method: has ? 'DELETE' : 'POST', credentials: 'include',
})
load()
}
return (
<div style={{ display: 'flex', height: '100dvh' }}>
<Sidebar user={user} />
<main style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.2rem' }}>Users</h1>
<button onClick={() => setShowCreate(v => !v)} style={goldBtn}>
{showCreate ? 'Cancel' : '+ New user'}
</button>
</div>
{showCreate && <CreateUserForm onCreated={() => { setShowCreate(false); load() }} />}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{users.map(u => (
<div key={u.id} style={{
background: 'var(--surface)', borderRadius: '10px',
padding: '1rem 1.25rem', border: '1px solid var(--surface-2)',
opacity: u.active ? 1 : 0.5,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: '140px' }}>
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{u.name}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>{u.email}</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<Toggle label="Active" on={u.active} onClick={() => toggle(u.id, 'active', u.active)} />
<Toggle label="Admin" on={u.is_admin} onClick={() => toggle(u.id, 'is_admin', u.is_admin)} />
<Toggle label="Offsite" on={u.offsite_allowed} onClick={() => toggle(u.id, 'offsite_allowed', u.offsite_allowed)} />
</div>
</div>
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
{allApps.map(app => {
const has = u.app_slugs.includes(app.slug)
return (
<button key={app.slug} onClick={() => grantRevoke(u.id, app.slug, has)} style={{
background: has ? 'var(--navy)' : 'var(--surface-2)',
border: `1px solid ${has ? 'var(--gold)' : 'var(--surface-2)'}`,
color: has ? 'var(--gold)' : 'var(--text-muted)',
borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem',
}}>
{app.name}
</button>
)
})}
</div>
</div>
))}
</div>
</main>
</div>
)
}
function Toggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
return (
<button onClick={onClick} style={{
background: on ? 'var(--navy)' : 'var(--surface-2)',
border: `1px solid ${on ? 'var(--gold)' : 'var(--surface-2)'}`,
color: on ? 'var(--gold)' : 'var(--text-muted)',
borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem',
}}>
{label}
</button>
)
}
function CreateUserForm({ onCreated }: { onCreated: () => void }) {
const [form, setForm] = useState({ email: '', name: '', password: '', is_admin: false, offsite_allowed: false })
const [error, setError] = useState('')
async function submit(e: React.FormEvent) {
e.preventDefault()
const res = await fetch('/api/auth/admin/users', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (res.ok) onCreated()
else setError('Failed to create user')
}
const f = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm(v => ({ ...v, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
return (
<form onSubmit={submit} style={{
background: 'var(--surface)', borderRadius: '10px', padding: '1.25rem',
border: '1px solid var(--gold)', marginBottom: '1rem',
display: 'flex', flexDirection: 'column', gap: '0.75rem',
}}>
<h2 style={{ fontSize: '0.9rem', color: 'var(--gold)' }}>New User</h2>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<input placeholder="Full name" value={form.name} onChange={f('name')} required style={{ ...inp, flex: 1 }} />
<input type="email" placeholder="Email" value={form.email} onChange={f('email')} required style={{ ...inp, flex: 1 }} />
<input type="password" placeholder="Password" value={form.password} onChange={f('password')} required style={{ ...inp, flex: 1 }} />
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<label style={chk}><input type="checkbox" checked={form.is_admin} onChange={f('is_admin')} /> Admin</label>
<label style={chk}><input type="checkbox" checked={form.offsite_allowed} onChange={f('offsite_allowed')} /> Offsite access</label>
</div>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.85rem' }}>{error}</p>}
<button type="submit" style={goldBtn}>Create user</button>
</form>
)
}
const inp: React.CSSProperties = {
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
borderRadius: '6px', color: 'var(--text)', padding: '0.6rem 0.75rem', fontSize: '0.875rem',
}
const chk: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.875rem', cursor: 'pointer' }
const goldBtn: React.CSSProperties = {
background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none',
borderRadius: '6px', padding: '0.5rem 1rem', fontSize: '0.875rem', fontWeight: 600,
}

42
src/pages/AppFrame.tsx Normal file
View file

@ -0,0 +1,42 @@
import { useParams, useNavigate } from 'react-router-dom'
import { Sidebar } from '../components/Sidebar'
import type { User } from '../types'
export function AppFrame({ user }: { user: User }) {
const { slug } = useParams<{ slug: string }>()
const navigate = useNavigate()
const app = user.apps.find(a => a.slug === slug)
if (!app) {
return (
<div style={{ height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}>
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>App not found or not permitted.</p>
<button onClick={() => navigate('/')} style={{
background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none',
borderRadius: '6px', padding: '0.5rem 1.25rem', fontWeight: 600,
}}>
Back to dashboard
</button>
</div>
</div>
)
}
return (
<div style={{ display: 'flex', height: '100dvh' }}>
<Sidebar user={user} activeSlug={slug} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{
height: '3px', background: app.theme_color, flexShrink: 0,
}} />
<iframe
src={`${app.base_path}/`}
title={app.name}
style={{ flex: 1, border: 'none', width: '100%' }}
allow="clipboard-read; clipboard-write"
/>
</div>
</div>
)
}

86
src/pages/Dashboard.tsx Normal file
View file

@ -0,0 +1,86 @@
import { useNavigate } from 'react-router-dom'
import { Sidebar } from '../components/Sidebar'
import type { User, App } from '../types'
export function Dashboard({ user }: { user: User }) {
const navigate = useNavigate()
return (
<div style={{ display: 'flex', height: '100dvh' }}>
<Sidebar user={user} />
<main style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
<h1 style={{ fontSize: '1.1rem', color: 'var(--text-muted)', fontWeight: 400, marginBottom: '1.5rem' }}>
Good {greeting()}, <span style={{ color: 'var(--text)', fontWeight: 600 }}>{user.name}</span>
</h1>
{user.apps.length === 0 ? (
<div style={{ color: 'var(--text-muted)', padding: '3rem 0', textAlign: 'center' }}>
No apps assigned yet. Contact an administrator.
</div>
) : (
<div style={{
display: 'grid', gap: '1rem',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
}}>
{user.apps.map(app => (
<AppTile key={app.slug} app={app} onOpen={() => navigate(`/app/${app.slug}`)} />
))}
</div>
)}
</main>
</div>
)
}
function AppTile({ app, onOpen }: { app: App; onOpen: () => void }) {
return (
<div style={{
background: 'var(--surface)', borderRadius: '12px',
border: '1px solid var(--surface-2)', overflow: 'hidden',
display: 'flex', flexDirection: 'column',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = app.theme_color)}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--surface-2)')}
>
<div style={{
height: '6px',
background: app.theme_color,
}} />
<div style={{ padding: '1.25rem', flex: 1 }}>
<div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>{app.icon}</div>
<h2 style={{ fontSize: '1rem', marginBottom: '0.3rem' }}>{app.name}</h2>
{app.description && (
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>
{app.description}
</p>
)}
</div>
<div style={{ padding: '0.75rem 1.25rem', display: 'flex', gap: '0.5rem',
borderTop: '1px solid var(--surface-2)' }}>
<button onClick={onOpen} style={{
flex: 1, background: app.theme_color, color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.5rem', fontSize: '0.85rem', fontWeight: 600,
}}>
Open
</button>
<button
onClick={() => window.open(`${app.base_path}/?install=1`, '_blank')}
title="Install as PWA"
style={{
background: 'var(--surface-2)', border: 'none', borderRadius: '6px',
padding: '0.5rem 0.6rem', fontSize: '0.85rem', color: 'var(--text-muted)',
}}>
⊕
</button>
</div>
</div>
)
}
function greeting() {
const h = new Date().getHours()
if (h < 12) return 'morning'
if (h < 17) return 'afternoon'
return 'evening'
}

71
src/pages/Login.tsx Normal file
View file

@ -0,0 +1,71 @@
import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const location = useLocation()
const from = (location.state as { from?: string })?.from || '/'
async function submit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (res.ok) navigate(from, { replace: true })
else setError('Invalid email or password')
} catch {
setError('Connection error — please try again')
} finally {
setLoading(false)
}
}
return (
<div style={{ height: '100dvh', display: 'flex', alignItems: 'center',
justifyContent: 'center', padding: '1.5rem', background: 'var(--navy-dark)' }}>
<div style={{ width: '100%', maxWidth: '360px' }}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>🏨</div>
<h1 style={{ fontSize: '1.5rem', color: 'var(--gold)', letterSpacing: '0.05em' }}>HNF MANAGE</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginTop: '0.25rem' }}>
Hotel Number Four
</p>
</div>
<div style={{ background: 'var(--surface)', borderRadius: '12px',
padding: '1.75rem', border: '1px solid var(--surface-2)' }}>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email address" required autoComplete="email" style={input} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password" style={input} />
{error && <p style={{ color: 'var(--danger)', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
<button type="submit" disabled={loading} style={{
background: loading ? 'var(--surface-2)' : 'var(--gold)',
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
border: 'none', borderRadius: '8px', padding: '0.75rem',
fontSize: '1rem', fontWeight: 700, marginTop: '0.25rem',
}}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>
)
}
const input: React.CSSProperties = {
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
borderRadius: '8px', color: 'var(--text)', padding: '0.7rem 0.875rem',
fontSize: '1rem', width: '100%', outline: 'none',
}