Initial commit (unpacked platform)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>Kö-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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 på 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user