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:
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
@@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 />);
|
||||
@@ -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; }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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": []
|
||||
}
|
||||
Reference in New Issue
Block a user