chore: initial project setup with backend, frontend, Android app, and CI/CD

Add complete NexaMFA push MFA system with:
- FastAPI backend with PostgreSQL, Redis, OIDC provider, and Prometheus metrics
- React TypeScript admin console
- Android Kotlin/Jetpack Compose app with biometric authentication
- Docker Compose deployment configuration
- Gitea CI workflow for backend, frontend, and Android builds
- Environment configuration template with security settings
- Documentation for security model, deployment
This commit is contained in:
2026-06-28 09:37:51 +02:00
commit f925009977
57 changed files with 2776 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD wget -qO- http://localhost/ || exit 1
+2
View File
@@ -0,0 +1,2 @@
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
+10
View File
@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "nexamfa-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"lucide-react": "latest",
"vite": "latest",
"react": "latest",
"react-dom": "latest"
},
"devDependencies": {
"typescript": "latest",
"@types/react": "latest",
"@types/react-dom": "latest"
}
}
+133
View File
@@ -0,0 +1,133 @@
import React, { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import { Activity, Ban, History, KeyRound, RefreshCw, Settings, ShieldCheck, Smartphone, Users } from "lucide-react";
import "./styles.css";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const tabs = [
["users", Users],
["devices", Smartphone],
["challenges", Activity],
["audit", History],
["settings", Settings],
] as const;
type User = { id: string; username: string; display_name?: string; email?: string; created_at: string };
type Device = { id: string; user_id: string; name: string; platform: string; public_key_alg: string; is_revoked: boolean; last_seen_at?: string; created_at: string };
type Challenge = { id: string; user_id: string; device_id?: string; status: string; relying_party: string; requester_ip: string; location?: string; issued_at: string; expires_at: string; responded_at?: string };
type Audit = { id: string; actor?: string; action: string; target_type?: string; target_id?: string; ip_address?: string; created_at: string };
function useAdminToken() {
const [token, setToken] = useState(localStorage.getItem("nexamfa_admin_token") ?? "");
const save = (value: string) => {
localStorage.setItem("nexamfa_admin_token", value);
setToken(value);
};
return { token, save };
}
async function api<T>(path: string, token: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...(init?.headers ?? {}) },
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
function Login({ onSave }: { onSave: (token: string) => void }) {
const [value, setValue] = useState("");
return (
<main className="login">
<section className="loginPanel">
<ShieldCheck size={32} />
<h1>NexaMFA Admin</h1>
<input type="password" placeholder="Admin bearer token" value={value} onChange={(e) => setValue(e.target.value)} />
<button onClick={() => onSave(value)}><KeyRound size={16} /> Sign in</button>
</section>
</main>
);
}
function App() {
const { token, save } = useAdminToken();
const [tab, setTab] = useState("users");
const [users, setUsers] = useState<User[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [challenges, setChallenges] = useState<Challenge[]>([]);
const [audit, setAudit] = useState<Audit[]>([]);
const [error, setError] = useState("");
const userById = useMemo(() => Object.fromEntries(users.map((u) => [u.id, u.username])), [users]);
async function load() {
if (!token) return;
setError("");
try {
const [u, d, c, a] = await Promise.all([
api<User[]>("/api/admin/users", token),
api<Device[]>("/api/admin/devices", token),
api<Challenge[]>("/api/admin/challenges", token),
api<Audit[]>("/api/admin/audit", token),
]);
setUsers(u); setDevices(d); setChallenges(c); setAudit(a);
} catch (e) {
setError(e instanceof Error ? e.message : "Request failed");
}
}
async function revoke(id: string) {
await api(`/api/admin/devices/${id}/revoke`, token, { method: "POST" });
await load();
}
useEffect(() => { load(); }, [token]);
if (!token) return <Login onSave={save} />;
return (
<div className="shell">
<aside>
<h1>NexaMFA</h1>
{tabs.map(([name, Icon]) => (
<button key={name} className={tab === name ? "active" : ""} onClick={() => setTab(name as string)}>
<Icon size={18} /> {name}
</button>
))}
</aside>
<main>
<header>
<div>
<h2>{tab}</h2>
<p>{users.length} users · {devices.filter((d) => !d.is_revoked).length} active devices · {challenges.length} recent challenges</p>
</div>
<button className="iconBtn" onClick={load} title="Refresh"><RefreshCw size={18} /></button>
</header>
{error && <pre className="error">{error}</pre>}
{tab === "users" && <Table headers={["Username", "Display", "Email", "Created"]} rows={users.map((u) => [u.username, u.display_name ?? "", u.email ?? "", fmt(u.created_at)])} />}
{tab === "devices" && (
<table>
<thead><tr><th>Name</th><th>User</th><th>Platform</th><th>Key</th><th>Status</th><th>Last seen</th><th /></tr></thead>
<tbody>{devices.map((d) => <tr key={d.id}>
<td>{d.name}</td><td>{userById[d.user_id] ?? d.user_id}</td><td>{d.platform}</td><td>{d.public_key_alg}</td>
<td><span className={d.is_revoked ? "bad" : "good"}>{d.is_revoked ? "revoked" : "active"}</span></td><td>{fmt(d.last_seen_at)}</td>
<td>{!d.is_revoked && <button className="danger" onClick={() => revoke(d.id)}><Ban size={16} /> Revoke</button>}</td>
</tr>)}</tbody>
</table>
)}
{tab === "challenges" && <Table headers={["User", "Service", "IP", "Status", "Issued", "Expires"]} rows={challenges.map((c) => [userById[c.user_id] ?? c.user_id, c.relying_party, c.requester_ip, c.status, fmt(c.issued_at), fmt(c.expires_at)])} />}
{tab === "audit" && <Table headers={["Action", "Actor", "Target", "IP", "Time"]} rows={audit.map((a) => [a.action, a.actor ?? "", `${a.target_type ?? ""} ${a.target_id ?? ""}`, a.ip_address ?? "", fmt(a.created_at)])} />}
{tab === "settings" && <section className="settings"><label>API base URL<input value={API_BASE || "same origin"} readOnly /></label><label>Admin token<input type="password" value={token} onChange={(e) => save(e.target.value)} /></label></section>}
</main>
</div>
);
}
function Table({ headers, rows }: { headers: string[]; rows: string[][] }) {
return <table><thead><tr>{headers.map((h) => <th key={h}>{h}</th>)}</tr></thead><tbody>{rows.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}</tbody></table>;
}
function fmt(value?: string) {
return value ? new Date(value).toLocaleString() : "";
}
createRoot(document.getElementById("root")!).render(<App />);
+45
View File
@@ -0,0 +1,45 @@
:root {
color: #18212f;
background: #eef2f5;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; }
button, input { font: inherit; }
button { cursor: pointer; border: 1px solid #c7d2da; background: #fff; color: #18212f; min-height: 36px; border-radius: 6px; display: inline-flex; align-items: center; gap: 8px; padding: 0 12px; }
button:hover { border-color: #2563eb; }
.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
aside { background: #111827; color: #f8fafc; padding: 20px 12px; }
aside h1 { font-size: 20px; margin: 0 8px 24px; }
aside button { width: 100%; justify-content: flex-start; margin-bottom: 6px; color: #cbd5e1; background: transparent; border-color: transparent; text-transform: capitalize; }
aside button.active, aside button:hover { color: #fff; background: #243244; border-color: #3b4b61; }
main { padding: 24px; overflow: auto; }
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
h2 { margin: 0; text-transform: capitalize; }
p { margin: 6px 0 0; color: #64748b; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dbe3ea; border-radius: 8px; overflow: hidden; }
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #edf2f7; font-size: 14px; white-space: nowrap; }
th { background: #f8fafc; color: #475569; font-weight: 650; }
tr:last-child td { border-bottom: 0; }
.good, .bad { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 8px; font-size: 12px; font-weight: 700; }
.good { background: #dcfce7; color: #166534; }
.bad { background: #fee2e2; color: #991b1b; }
.danger { color: #991b1b; border-color: #fecaca; }
.iconBtn { width: 40px; padding: 0; justify-content: center; }
.error { background: #fff1f2; color: #9f1239; padding: 12px; border-radius: 8px; overflow: auto; }
.settings { display: grid; gap: 16px; max-width: 640px; }
label { display: grid; gap: 6px; color: #475569; font-weight: 650; }
input { min-height: 40px; border: 1px solid #cbd5e1; border-radius: 6px; padding: 0 10px; }
.login { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.loginPanel { width: min(420px, 100%); background: #fff; border: 1px solid #dbe3ea; border-radius: 8px; padding: 24px; display: grid; gap: 14px; }
.loginPanel h1 { margin: 0; font-size: 24px; }
@media (max-width: 800px) {
.shell { grid-template-columns: 1fr; }
aside { position: sticky; top: 0; z-index: 1; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; padding: 10px; }
aside h1 { display: none; }
aside button { justify-content: center; margin: 0; padding: 0 8px; }
main { padding: 16px; }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}