Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { BRAND, hasToken, setToken } from "./api.js";
|
||||
import { LoginPage } from "./pages/Login.js";
|
||||
import { DashboardPage } from "./pages/Dashboard.js";
|
||||
import { UsersPage } from "./pages/Users.js";
|
||||
import { ModerationPage } from "./pages/Moderation.js";
|
||||
import { FlagsPage } from "./pages/Flags.js";
|
||||
import { SubscriptionsPage } from "./pages/Subscriptions.js";
|
||||
import { AuditLogsPage } from "./pages/AuditLogs.js";
|
||||
|
||||
export function App() {
|
||||
const [authed, setAuthed] = useState(hasToken());
|
||||
|
||||
if (!authed) {
|
||||
return <LoginPage onLogin={() => setAuthed(true)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<nav className="sidebar">
|
||||
<h1>{BRAND.name} Admin</h1>
|
||||
<NavLink to="/" end>
|
||||
Översikt
|
||||
</NavLink>
|
||||
<NavLink to="/users">Användare</NavLink>
|
||||
<NavLink to="/moderation">Moderering</NavLink>
|
||||
<NavLink to="/flags">Feature flags</NavLink>
|
||||
<NavLink to="/subscriptions">Prenumerationer</NavLink>
|
||||
<NavLink to="/audit">Audit logs</NavLink>
|
||||
<button
|
||||
onClick={() => {
|
||||
setToken(null);
|
||||
setAuthed(false);
|
||||
}}
|
||||
>
|
||||
Logga ut
|
||||
</button>
|
||||
</nav>
|
||||
<main className="main">
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/moderation" element={<ModerationPage />} />
|
||||
<Route path="/flags" element={<FlagsPage />} />
|
||||
<Route path="/subscriptions" element={<SubscriptionsPage />} />
|
||||
<Route path="/audit" element={<AuditLogsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import brand from "../../../brand.config.json";
|
||||
export const BRAND = brand as { name: string; slug: string };
|
||||
const ADMIN_TOKEN_KEY = `${BRAND.slug}_admin_token`;
|
||||
|
||||
/** Enkel API-klient för adminpanelen. Token hålls i minne + sessionStorage. */
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:4000";
|
||||
|
||||
let accessToken: string | null = sessionStorage.getItem(ADMIN_TOKEN_KEY);
|
||||
|
||||
export function setToken(token: string | null): void {
|
||||
accessToken = token;
|
||||
if (token) sessionStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||
else sessionStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function hasToken(): boolean {
|
||||
return accessToken != null;
|
||||
}
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(
|
||||
path: string,
|
||||
options: { method?: string; body?: unknown } = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
body: options.body != null ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
error?: { code?: string; message?: string };
|
||||
} & T;
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) setToken(null);
|
||||
throw new ApiRequestError(
|
||||
res.status,
|
||||
json.error?.code ?? "UNKNOWN",
|
||||
json.error?.message ?? `HTTP ${res.status}`,
|
||||
);
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
totpRequired: boolean;
|
||||
preAuthToken?: string;
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<LoginResult> {
|
||||
const result = await api<{
|
||||
accessToken?: string;
|
||||
user?: { role: string };
|
||||
totpRequired?: boolean;
|
||||
preAuthToken?: string;
|
||||
}>("/v1/auth/login", { method: "POST", body: { email, password } });
|
||||
if (result.totpRequired && result.preAuthToken) {
|
||||
return { totpRequired: true, preAuthToken: result.preAuthToken };
|
||||
}
|
||||
if (!result.user || !result.accessToken) {
|
||||
throw new ApiRequestError(500, "INTERNAL", "Oväntat svar från inloggningen.");
|
||||
}
|
||||
if (result.user.role !== "admin" && result.user.role !== "moderator") {
|
||||
throw new ApiRequestError(403, "FORBIDDEN", "Kontot saknar admin-behörighet.");
|
||||
}
|
||||
setToken(result.accessToken);
|
||||
return { totpRequired: false };
|
||||
}
|
||||
|
||||
/** Steg 2 av inloggningen: engångskoden från autentiseringsappen. */
|
||||
export async function verifyTotp(preAuthToken: string, code: string): Promise<void> {
|
||||
const result = await api<{ accessToken: string; user: { role: string } }>(
|
||||
"/v1/auth/totp-verify",
|
||||
{ method: "POST", body: { preAuthToken, code } },
|
||||
);
|
||||
if (result.user.role !== "admin" && result.user.role !== "moderator") {
|
||||
throw new ApiRequestError(403, "FORBIDDEN", "Kontot saknar admin-behörighet.");
|
||||
}
|
||||
setToken(result.accessToken);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App.js";
|
||||
import "./styles.css";
|
||||
import { BRAND } from "./api.js";
|
||||
|
||||
document.title = `${BRAND.name} Admin`;
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
:root {
|
||||
--bg: #f7f8fa;
|
||||
--panel: #ffffff;
|
||||
--text: #1a202c;
|
||||
--muted: #64748b;
|
||||
--brand: #16803c;
|
||||
--brand-dark: #11602d;
|
||||
--border: #e2e8f0;
|
||||
--danger: #b91c1c;
|
||||
--warn: #b45309;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: #0f2417;
|
||||
color: #d7e5db;
|
||||
padding: 1.25rem 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar h1 { font-size: 1.1rem; margin: 0 0 1.5rem 0.5rem; color: #fff; }
|
||||
.sidebar a {
|
||||
display: block;
|
||||
padding: 0.55rem 0.75rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.sidebar a:hover { background: rgba(255, 255, 255, 0.08); }
|
||||
.sidebar a.active { background: var(--brand); color: #fff; }
|
||||
.sidebar button {
|
||||
margin: 1.5rem 0.5rem 0;
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
color: inherit;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main { flex: 1; padding: 1.5rem 2rem; max-width: 1100px; }
|
||||
.main h2 { margin-top: 0; }
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
|
||||
th { text-align: left; color: var(--muted); font-weight: 600; padding: 0.5rem 0.75rem; border-bottom: 2px solid var(--border); }
|
||||
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.badge.ok { background: #dcfce7; color: var(--brand-dark); }
|
||||
.badge.warn { background: #fef3c7; color: var(--warn); }
|
||||
.badge.err { background: #fee2e2; color: var(--danger); }
|
||||
|
||||
button.primary {
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
button.primary:hover { background: var(--brand-dark); }
|
||||
button.ghost {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
button.danger { border-color: #fecaca; color: var(--danger); }
|
||||
|
||||
input, select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.login-box {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
width: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.error-text { color: var(--danger); font-size: 0.85rem; }
|
||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; }
|
||||
.stat { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 1rem; }
|
||||
.stat .value { font-size: 1.6rem; font-weight: 700; }
|
||||
.stat .label { color: var(--muted); font-size: 0.8rem; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
Reference in New Issue
Block a user