Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface AuditLog {
id: string;
actorUserId: string | null;
actorType: string;
action: string;
targetType: string | null;
targetId: string | null;
createdAt: string;
}
export function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [filter, setFilter] = useState("");
const [error, setError] = useState<string | null>(null);
const load = (action = "") => {
api<{ logs: AuditLog[] }>(`/admin/v1/audit-logs?action=${encodeURIComponent(action)}`)
.then((r) => setLogs(r.logs))
.catch((e) => setError(String(e.message)));
};
useEffect(() => load(), []);
return (
<div>
<h2>Audit logs</h2>
<div className="row" style={{ marginBottom: "1rem" }}>
<input
placeholder="Filtrera på action …"
value={filter}
onChange={(e) => setFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && load(filter)}
/>
<button className="ghost" onClick={() => load(filter)}>
Filtrera
</button>
</div>
{error && <div className="error-text">{error}</div>}
<div className="panel">
<table>
<thead>
<tr>
<th>Tid</th>
<th>Aktör</th>
<th>Typ</th>
<th>Action</th>
<th>Mål</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id}>
<td>{new Date(log.createdAt).toLocaleString("sv-SE")}</td>
<td>
<code>{log.actorUserId?.slice(0, 8) ?? "system"}</code>
</td>
<td>{log.actorType}</td>
<td>
<code>{log.action}</code>
</td>
<td>{log.targetType ? `${log.targetType}:${log.targetId?.slice(0, 8)}` : ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface Health {
checks: Record<string, { ok: boolean; detail?: string }>;
}
interface JobsOverview {
scanJobs: Array<{ status: string; count: number }>;
queue: Record<string, number>;
outboxPending: number;
}
export function DashboardPage() {
const [health, setHealth] = useState<Health | null>(null);
const [jobs, setJobs] = useState<JobsOverview | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const load = () => {
api<Health>("/admin/v1/system/health")
.then(setHealth)
.catch((e) => setError(String(e.message)));
api<JobsOverview>("/admin/v1/jobs/overview")
.then(setJobs)
.catch(() => undefined);
};
load();
const timer = setInterval(load, 15_000);
return () => clearInterval(timer);
}, []);
return (
<div>
<h2>Systemöversikt</h2>
{error && <div className="error-text">{error}</div>}
<div className="stat-grid">
{health &&
Object.entries(health.checks).map(([name, check]) => (
<div className="stat" key={name}>
<div className="value">
<span className={`badge ${check.ok ? "ok" : "err"}`}>
{check.ok ? "OK" : "FEL"}
</span>
</div>
<div className="label">
{name} {check.detail ? ` ${check.detail}` : ""}
</div>
</div>
))}
{jobs && (
<div className="stat">
<div className="value">{jobs.outboxPending}</div>
<div className="label">Outbox väntar</div>
</div>
)}
</div>
{jobs && (
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h3>Jobbköer</h3>
<table>
<thead>
<tr>
<th>-status</th>
<th>Antal</th>
</tr>
</thead>
<tbody>
{Object.entries(jobs.queue).map(([status, count]) => (
<tr key={status}>
<td>{status}</td>
<td>{count}</td>
</tr>
))}
</tbody>
</table>
<h3>Skanningsjobb</h3>
<table>
<thead>
<tr>
<th>Status</th>
<th>Antal</th>
</tr>
</thead>
<tbody>
{jobs.scanJobs.map((row) => (
<tr key={row.status}>
<td>{row.status}</td>
<td>{row.count}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface Flag {
key: string;
enabled: boolean;
rolloutPercent: number;
descriptionSv: string | null;
}
export function FlagsPage() {
const [flags, setFlags] = useState<Flag[]>([]);
const [error, setError] = useState<string | null>(null);
const load = () => {
api<{ flags: Flag[] }>("/admin/v1/flags")
.then((r) => setFlags(r.flags))
.catch((e) => setError(String(e.message)));
};
useEffect(load, []);
const toggle = async (flag: Flag) => {
await api(`/admin/v1/flags/${flag.key}`, {
method: "PUT",
body: {
enabled: !flag.enabled,
rolloutPercent: flag.rolloutPercent,
descriptionSv: flag.descriptionSv ?? undefined,
},
});
load();
};
const setRollout = async (flag: Flag, percent: number) => {
await api(`/admin/v1/flags/${flag.key}`, {
method: "PUT",
body: {
enabled: flag.enabled,
rolloutPercent: percent,
descriptionSv: flag.descriptionSv ?? undefined,
},
});
load();
};
return (
<div>
<h2>Feature flags</h2>
<p className="muted">
Launch Advanced-funktioner rullas ut gradvis härifrån (spec §1, Del 3).
</p>
{error && <div className="error-text">{error}</div>}
<div className="panel">
<table>
<thead>
<tr>
<th>Flagga</th>
<th>Beskrivning</th>
<th>Rollout</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{flags.map((flag) => (
<tr key={flag.key}>
<td>
<code>{flag.key}</code>
</td>
<td>{flag.descriptionSv}</td>
<td>
<select
value={flag.rolloutPercent}
onChange={(e) => void setRollout(flag, Number(e.target.value))}
>
{[0, 5, 10, 25, 50, 75, 100].map((p) => (
<option key={p} value={p}>
{p} %
</option>
))}
</select>
</td>
<td>
<span className={`badge ${flag.enabled ? "ok" : ""}`}>
{flag.enabled ? "PÅ" : "AV"}
</span>
</td>
<td>
<button className="ghost" onClick={() => void toggle(flag)}>
{flag.enabled ? "Stäng av" : "Slå på"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useState, type FormEvent } from "react";
import { BRAND, login, verifyTotp } from "../api.js";
export function LoginPage({ onLogin }: { onLogin: () => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// 2FA-steget: sätts när kontot har TOTP aktiverat.
const [preAuthToken, setPreAuthToken] = useState<string | null>(null);
const [code, setCode] = useState("");
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const result = await login(email, password);
if (result.totpRequired && result.preAuthToken) {
setPreAuthToken(result.preAuthToken);
} else {
onLogin();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Inloggningen misslyckades.");
} finally {
setBusy(false);
}
};
const submitCode = async (e: FormEvent) => {
e.preventDefault();
if (!preAuthToken) return;
setBusy(true);
setError(null);
try {
await verifyTotp(preAuthToken, code);
onLogin();
} catch (err) {
setError(err instanceof Error ? err.message : "Fel engångskod.");
} finally {
setBusy(false);
}
};
if (preAuthToken) {
return (
<div className="login-wrap">
<form className="login-box" onSubmit={submitCode}>
<h1>Tvåfaktorsautentisering</h1>
<p className="muted">Ange den sexsiffriga koden från din autentiseringsapp.</p>
<input
inputMode="numeric"
autoComplete="one-time-code"
placeholder="000000"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
autoFocus
required
/>
{error && <div className="error-text">{error}</div>}
<button className="primary" disabled={busy || code.length !== 6}>
{busy ? "Verifierar …" : "Verifiera"}
</button>
<button
type="button"
className="ghost"
onClick={() => {
setPreAuthToken(null);
setCode("");
setError(null);
}}
>
Avbryt
</button>
</form>
</div>
);
}
return (
<div className="login-wrap">
<form className="login-box" onSubmit={submit}>
<h1>{BRAND.name} Admin</h1>
<input
type="email"
placeholder="E-post"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Lösenord"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && <div className="error-text">{error}</div>}
<button className="primary" disabled={busy}>
{busy ? "Loggar in …" : "Logga in"}
</button>
<div className="muted">Kräver konto med admin-roll.</div>
</form>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface PendingRecipe {
id: string;
titleSv: string;
descriptionSv: string;
status: string;
creatorDisplayName: string | null;
moderationNote: string | null;
createdAt: string;
}
export function ModerationPage() {
const [recipes, setRecipes] = useState<PendingRecipe[]>([]);
const [error, setError] = useState<string | null>(null);
const load = () => {
api<{ recipes: PendingRecipe[] }>("/admin/v1/moderation/recipes")
.then((r) => setRecipes(r.recipes))
.catch((e) => setError(String(e.message)));
};
useEffect(load, []);
const act = async (id: string, action: "approve" | "reject" | "request_changes") => {
const note =
action === "approve" ? undefined : (prompt("Motivering till skaparen:") ?? undefined);
await api(`/admin/v1/moderation/recipes/${id}`, { method: "POST", body: { action, note } });
load();
};
return (
<div>
<h2>Receptmoderering</h2>
<p className="muted">
Flöde enligt spec §35: submission AI-kontroll dubblettkontroll moderering
publicering.
</p>
{error && <div className="error-text">{error}</div>}
{recipes.length === 0 && <div className="panel">Inga recept väntar granskning. 🎉</div>}
{recipes.map((recipe) => (
<div className="panel" key={recipe.id}>
<div className="row" style={{ justifyContent: "space-between" }}>
<div>
<strong>{recipe.titleSv}</strong> <span className="badge">{recipe.status}</span>
<div className="muted">
av {recipe.creatorDisplayName ?? "okänd"} ·{" "}
{new Date(recipe.createdAt).toLocaleString("sv-SE")}
</div>
</div>
<div className="row">
<button className="primary" onClick={() => act(recipe.id, "approve")}>
Godkänn
</button>
<button className="ghost" onClick={() => act(recipe.id, "request_changes")}>
Begär ändringar
</button>
<button className="ghost danger" onClick={() => act(recipe.id, "reject")}>
Avslå
</button>
</div>
</div>
<p>{recipe.descriptionSv}</p>
{recipe.moderationNote && (
<div className="muted">AI-kontroll & dubbletter: {recipe.moderationNote}</div>
)}
</div>
))}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface Sub {
id: string;
userId: string;
provider: string;
plan: string;
status: string;
expiresAt: string | null;
lastVerifiedAt: string | null;
}
export function SubscriptionsPage() {
const [subs, setSubs] = useState<Sub[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api<{ subscriptions: Sub[] }>("/admin/v1/subscriptions")
.then((r) => setSubs(r.subscriptions))
.catch((e) => setError(String(e.message)));
}, []);
const statusBadge = (status: string) =>
status === "active" || status === "trial" ? "ok" : status === "in_grace" ? "warn" : "err";
return (
<div>
<h2>Prenumerationer</h2>
<p className="muted">
Backend är source of truth (spec §47). Promo-planer delas ut via API:t.
</p>
{error && <div className="error-text">{error}</div>}
<div className="panel">
<table>
<thead>
<tr>
<th>Användare</th>
<th>Plan</th>
<th>Provider</th>
<th>Status</th>
<th>Går ut</th>
<th>Senast verifierad</th>
</tr>
</thead>
<tbody>
{subs.map((s) => (
<tr key={s.id}>
<td>
<code>{s.userId.slice(0, 8)}</code>
</td>
<td>{s.plan}</td>
<td>{s.provider}</td>
<td>
<span className={`badge ${statusBadge(s.status)}`}>{s.status}</span>
</td>
<td>{s.expiresAt ? new Date(s.expiresAt).toLocaleDateString("sv-SE") : ""}</td>
<td>
{s.lastVerifiedAt ? new Date(s.lastVerifiedAt).toLocaleString("sv-SE") : ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface AdminUser {
id: string;
email: string;
displayName: string;
role: string;
onboardingCompleted: boolean;
createdAt: string;
deletedAt: string | null;
}
export function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [search, setSearch] = useState("");
const [error, setError] = useState<string | null>(null);
const load = (q = "") => {
api<{ users: AdminUser[] }>(`/admin/v1/users?search=${encodeURIComponent(q)}`)
.then((r) => setUsers(r.users))
.catch((e) => setError(String(e.message)));
};
useEffect(() => load(), []);
return (
<div>
<h2>Användare</h2>
<div className="row" style={{ marginBottom: "1rem" }}>
<input
placeholder="Sök e-post …"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && load(search)}
/>
<button className="ghost" onClick={() => load(search)}>
Sök
</button>
</div>
{error && <div className="error-text">{error}</div>}
<div className="panel">
<table>
<thead>
<tr>
<th>E-post</th>
<th>Namn</th>
<th>Roll</th>
<th>Onboarding</th>
<th>Skapad</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.email}</td>
<td>{u.displayName}</td>
<td>
<span className={`badge ${u.role === "admin" ? "warn" : ""}`}>{u.role}</span>
</td>
<td>{u.onboardingCompleted ? "✓" : ""}</td>
<td>{new Date(u.createdAt).toLocaleDateString("sv-SE")}</td>
<td>
{u.deletedAt ? (
<span className="badge err">raderad</span>
) : (
<span className="badge ok">aktiv</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}