From b54081113bf89d4dce50aba836b3df360b06fc46 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 1 Jul 2026 12:09:54 +0000 Subject: [PATCH] Initial commit: portal --- .gitignore | 45 ++++++++++ Dockerfile | 11 +++ docker-compose.yml | 10 +++ index.html | 13 +++ nginx.conf | 29 +++++++ package.json | 24 ++++++ src/App.tsx | 29 +++++++ src/components/AuthGate.tsx | 33 ++++++++ src/components/Sidebar.tsx | 89 ++++++++++++++++++++ src/index.css | 26 ++++++ src/main.tsx | 8 ++ src/pages/AdminUsers.tsx | 162 ++++++++++++++++++++++++++++++++++++ src/pages/AppFrame.tsx | 42 ++++++++++ src/pages/Dashboard.tsx | 86 +++++++++++++++++++ src/pages/Login.tsx | 71 ++++++++++++++++ src/types.ts | 17 ++++ tsconfig.json | 12 +++ vite.config.ts | 29 +++++++ 18 files changed, 736 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 src/App.tsx create mode 100644 src/components/AuthGate.tsx create mode 100644 src/components/Sidebar.tsx create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/pages/AdminUsers.tsx create mode 100644 src/pages/AppFrame.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/Login.tsx create mode 100644 src/types.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4ce5d8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c77a7b7 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3423982 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/index.html b/index.html new file mode 100644 index 0000000..556fb91 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + HNF Manage + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..69caeaa --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9a189fb --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..b5f9f76 --- /dev/null +++ b/src/App.tsx @@ -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 ( + + + } /> + + {user => ( + + } /> + } /> + : } /> + } /> + } /> + + )} + + } /> + + + ) +} diff --git a/src/components/AuthGate.tsx b/src/components/AuthGate.tsx new file mode 100644 index 0000000..a5b5a2b --- /dev/null +++ b/src/components/AuthGate.tsx @@ -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(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 ( +
+
Loading…
+
+ ) + } + + return user ? <>{children(user)} : null +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..2dfe201 --- /dev/null +++ b/src/components/Sidebar.tsx @@ -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 ( + + ) +} + +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', + } +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..7c271fb --- /dev/null +++ b/src/index.css @@ -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; } diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..177c1df --- /dev/null +++ b/src/main.tsx @@ -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( + +) diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx new file mode 100644 index 0000000..37e38aa --- /dev/null +++ b/src/pages/AdminUsers.tsx @@ -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([]) + 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 ( +
+ +
+
+

Users

+ +
+ + {showCreate && { setShowCreate(false); load() }} />} + +
+ {users.map(u => ( +
+
+
+
{u.name}
+
{u.email}
+
+
+ toggle(u.id, 'active', u.active)} /> + toggle(u.id, 'is_admin', u.is_admin)} /> + toggle(u.id, 'offsite_allowed', u.offsite_allowed)} /> +
+
+
+ {allApps.map(app => { + const has = u.app_slugs.includes(app.slug) + return ( + + ) + })} +
+
+ ))} +
+
+
+ ) +} + +function Toggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { + return ( + + ) +} + +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) => + setForm(v => ({ ...v, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value })) + + return ( +
+

New User

+
+ + + +
+
+ + +
+ {error &&

{error}

} + +
+ ) +} + +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, +} diff --git a/src/pages/AppFrame.tsx b/src/pages/AppFrame.tsx new file mode 100644 index 0000000..7fc1ed1 --- /dev/null +++ b/src/pages/AppFrame.tsx @@ -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 ( +
+
+

App not found or not permitted.

+ +
+
+ ) + } + + return ( +
+ +
+
+