Initial commit: portal
This commit is contained in:
commit
b54081113b
18 changed files with 736 additions and 0 deletions
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnp/
|
||||
.pnp.js
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Docker volumes (if any are mounted locally)
|
||||
postgres-data/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# Temp
|
||||
*.tar.gz
|
||||
*.tmp
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
services:
|
||||
portal:
|
||||
build: .
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost/health 2>/dev/null || wget -qO- http://localhost/ > /dev/null && echo ok || exit 1"]
|
||||
interval: 15s
|
||||
retries: 3
|
||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f1f35" />
|
||||
<title>HNF Manage</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location /api/auth/ {
|
||||
proxy_pass http://10.10.10.101:3001/api/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
24
package.json
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "hnf-portal",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-pwa": "^0.21.1"
|
||||
}
|
||||
}
|
||||
29
src/App.tsx
Normal file
29
src/App.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthGate } from './components/AuthGate'
|
||||
import { Login } from './pages/Login'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { AppFrame } from './pages/AppFrame'
|
||||
import { AdminUsers } from './pages/AdminUsers'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/*" element={
|
||||
<AuthGate>
|
||||
{user => (
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard user={user} />} />
|
||||
<Route path="/app/:slug" element={<AppFrame user={user} />} />
|
||||
<Route path="/admin/users" element={user.is_admin ? <AdminUsers user={user} /> : <Navigate to="/" />} />
|
||||
<Route path="/admin/monitor" element={<Navigate to="/notices/" />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
)}
|
||||
</AuthGate>
|
||||
} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
33
src/components/AuthGate.tsx
Normal file
33
src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface Props { children: (user: User) => React.ReactNode }
|
||||
|
||||
export function AuthGate({ children }: Props) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [checking, setChecking] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me', { credentials: 'include' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data) setUser(data)
|
||||
else navigate('/login', { replace: true, state: { from: location.pathname } })
|
||||
})
|
||||
.catch(() => navigate('/login', { replace: true }))
|
||||
.finally(() => setChecking(false))
|
||||
}, [])
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{ height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Loading…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return user ? <>{children(user)}</> : null
|
||||
}
|
||||
89
src/components/Sidebar.tsx
Normal file
89
src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface Props { user: User; activeSlug?: string }
|
||||
|
||||
export function Sidebar({ user, activeSlug }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<aside style={{
|
||||
width: 'var(--sidebar-w)', flexShrink: 0,
|
||||
background: 'var(--navy)', borderRight: '1px solid var(--surface-2)',
|
||||
display: 'flex', flexDirection: 'column', height: '100dvh',
|
||||
position: 'sticky', top: 0,
|
||||
}}>
|
||||
<div style={{ padding: '1.25rem 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }}>
|
||||
<div style={{ color: 'var(--gold)', fontWeight: 700, fontSize: '0.9rem', letterSpacing: '0.05em' }}>
|
||||
HNF MANAGE
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginTop: '0.2rem' }}>
|
||||
Hotel Number Four
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav style={{ flex: 1, overflowY: 'auto', padding: '0.5rem 0' }}>
|
||||
<NavLink to="/" end style={({ isActive }) => navItem(isActive && !activeSlug)}>
|
||||
🏠 Dashboard
|
||||
</NavLink>
|
||||
|
||||
{user.apps.length > 0 && (
|
||||
<div style={{ padding: '0.5rem 1rem 0.25rem', fontSize: '0.65rem',
|
||||
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
Apps
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.apps.map(app => (
|
||||
<NavLink key={app.slug} to={`/app/${app.slug}`}
|
||||
style={({ isActive }) => navItem(isActive)}>
|
||||
<span style={{ fontSize: '1rem' }}>{app.icon}</span>
|
||||
<span style={{ marginLeft: '0.5rem' }}>{app.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{user.is_admin && (
|
||||
<>
|
||||
<div style={{ padding: '0.5rem 1rem 0.25rem', fontSize: '0.65rem',
|
||||
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
marginTop: '0.5rem' }}>
|
||||
Admin
|
||||
</div>
|
||||
<NavLink to="/admin/users" style={({ isActive }) => navItem(isActive)}>
|
||||
👥 Users
|
||||
</NavLink>
|
||||
<NavLink to="/admin/monitor" style={({ isActive }) => navItem(isActive)}>
|
||||
📡 Monitor
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div style={{ padding: '0.875rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 600, marginBottom: '0.1rem' }}>{user.name}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '0.6rem' }}>{user.email}</div>
|
||||
<button onClick={logout} style={{
|
||||
background: 'none', border: '1px solid var(--surface-2)', borderRadius: '5px',
|
||||
color: 'var(--text-muted)', padding: '0.3rem 0.75rem', fontSize: '0.75rem', width: '100%',
|
||||
}}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function navItem(active: boolean): React.CSSProperties {
|
||||
return {
|
||||
display: 'flex', alignItems: 'center', padding: '0.55rem 1rem',
|
||||
fontSize: '0.875rem', color: active ? 'var(--gold)' : 'var(--text-muted)',
|
||||
background: active ? 'var(--surface)' : 'transparent',
|
||||
borderLeft: active ? '3px solid var(--gold)' : '3px solid transparent',
|
||||
transition: 'color 0.1s, background 0.1s',
|
||||
}
|
||||
}
|
||||
26
src/index.css
Normal file
26
src/index.css
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--navy: #1e3a5f;
|
||||
--navy-dark: #0f1f35;
|
||||
--gold: #c9a84c;
|
||||
--gold-light: #e8c96d;
|
||||
--surface: #1a2d47;
|
||||
--surface-2: #243d5c;
|
||||
--text: #e8edf2;
|
||||
--text-muted: #8ba3bc;
|
||||
--danger: #e05252;
|
||||
--sidebar-w: 220px;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
background: var(--navy-dark);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
8
src/main.tsx
Normal file
8
src/main.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode><App /></StrictMode>
|
||||
)
|
||||
162
src/pages/AdminUsers.tsx
Normal file
162
src/pages/AdminUsers.tsx
Normal 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
42
src/pages/AppFrame.tsx
Normal 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
86
src/pages/Dashboard.tsx
Normal 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
71
src/pages/Login.tsx
Normal 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',
|
||||
}
|
||||
17
src/types.ts
Normal file
17
src/types.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export interface App {
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
base_path: string
|
||||
icon: string
|
||||
theme_color: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
is_admin: boolean
|
||||
offsite_allowed: boolean
|
||||
apps: App[]
|
||||
}
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
29
vite.config.ts
Normal file
29
vite.config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'HNF Manage',
|
||||
short_name: 'Manage',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
theme_color: '#0f1f35',
|
||||
background_color: '#0f1f35',
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/index.html',
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue