backoffice nextJs replaces admin in rust
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
API_LISTEN_ADDR=0.0.0.0:8080
|
||||
ADMIN_UI_LISTEN_ADDR=0.0.0.0:8082
|
||||
BACKOFFICE_PORT=8082
|
||||
API_BASE_URL=http://api:8080
|
||||
INDEXER_LISTEN_ADDR=0.0.0.0:8081
|
||||
INDEXER_SCAN_INTERVAL_SECONDS=5
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@ target/
|
||||
.DS_Store
|
||||
tmp/
|
||||
libraries/
|
||||
node_modules/
|
||||
.next/
|
||||
|
||||
11
PLAN.md
11
PLAN.md
@@ -5,7 +5,7 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
- API REST (librairies, livres, metadonnees, recherche, streaming pages),
|
||||
- indexation incrementale,
|
||||
- recherche full-text,
|
||||
- admin UI (Rust SSR) pour gerer librairies/jobs/tokens,
|
||||
- backoffice web (Next.js) pour gerer librairies/jobs/tokens,
|
||||
- deploiement Docker Compose homelab.
|
||||
|
||||
## Decisions figees
|
||||
@@ -19,6 +19,7 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
- Rendu PDF: a la volee
|
||||
- CBR: extraction temporaire disque (`unrar-free`, commande `unrar`) + cleanup
|
||||
- Formats pages: `webp`, `jpeg`, `png`
|
||||
- Backoffice UI: Next.js (remplace Rust SSR)
|
||||
|
||||
---
|
||||
|
||||
@@ -134,12 +135,14 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
|
||||
**DoD:** Service stable sous charge homelab.
|
||||
|
||||
### T16 - Admin UI Rust SSR
|
||||
### T16 - Backoffice Next.js
|
||||
- [x] Bootstrap app Next.js (`apps/backoffice`)
|
||||
- [x] Vue Libraries
|
||||
- [x] Vue Jobs
|
||||
- [x] Vue API Tokens (create/list/revoke)
|
||||
- [x] Auth backoffice via token API
|
||||
|
||||
**DoD:** Admin complet utilisable sans SPA lourde.
|
||||
**DoD:** Backoffice Next.js utilisable pour l'administration complete.
|
||||
|
||||
### T17 - Observabilite et hardening
|
||||
- [x] Logs structures `tracing`
|
||||
@@ -206,3 +209,5 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
- 2026-03-05: Lot 3 avancee: endpoint pages (`/books/:id/pages/:n`) actif avec cache LRU, ETag/Cache-Control, limite concurrence rendu et timeouts.
|
||||
- 2026-03-05: hardening API: readiness expose sans auth via `route_layer`, metriques simples `/metrics`, rate limiting lecture (120 req/s).
|
||||
- 2026-03-05: smoke + bench scripts corriges et verifies (`infra/smoke.sh`, `infra/bench.sh`).
|
||||
- 2026-03-05: pivot backoffice valide: remplacement de l'admin UI Rust SSR par une app Next.js.
|
||||
- 2026-03-05: backoffice Next.js implemente (`apps/backoffice`) avec branding neon base sur le logo, actions libraries/jobs/tokens, et integration Docker Compose.
|
||||
|
||||
21
apps/backoffice/Dockerfile
Normal file
21
apps/backoffice/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY apps/backoffice/package.json apps/backoffice/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY apps/backoffice/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8082
|
||||
RUN apk add --no-cache wget
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
EXPOSE 8082
|
||||
CMD ["node", "server.js"]
|
||||
279
apps/backoffice/app/globals.css
Normal file
279
apps/backoffice/app/globals.css
Normal file
@@ -0,0 +1,279 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: hsl(36 33% 97%);
|
||||
--foreground: hsl(222 33% 15%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--line: hsl(32 18% 84%);
|
||||
--line-strong: hsl(32 18% 76%);
|
||||
--primary: hsl(198 78% 37%);
|
||||
--primary-soft: hsl(198 52% 90%);
|
||||
--cyan: hsl(192 85% 55%);
|
||||
--pink: hsl(338 82% 62%);
|
||||
--text-muted: hsl(220 13% 40%);
|
||||
--shadow-1: 0 1px 2px 0 rgb(23 32 46 / 0.06);
|
||||
--shadow-2: 0 12px 30px -12px rgb(23 32 46 / 0.22);
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: hsl(222 35% 10%);
|
||||
--foreground: hsl(38 20% 92%);
|
||||
--card: hsl(221 31% 13%);
|
||||
--line: hsl(219 18% 25%);
|
||||
--line-strong: hsl(219 18% 33%);
|
||||
--primary: hsl(194 76% 62%);
|
||||
--primary-soft: hsl(210 34% 24%);
|
||||
--cyan: hsl(192 85% 55%);
|
||||
--pink: hsl(338 82% 62%);
|
||||
--text-muted: hsl(218 17% 72%);
|
||||
--shadow-1: 0 1px 2px 0 rgb(2 8 18 / 0.35);
|
||||
--shadow-2: 0 12px 30px -12px rgb(2 8 18 / 0.55);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--foreground);
|
||||
font-family: "Avenir Next", "Segoe UI", "Noto Sans", sans-serif;
|
||||
background: var(--background);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(112deg, hsl(198 78% 37% / 0.14) 0%, hsl(192 85% 55% / 0.11) 28%, transparent 56%),
|
||||
linear-gradient(248deg, hsl(338 82% 62% / 0.11) 0%, transparent 46%),
|
||||
repeating-linear-gradient(135deg, hsl(222 33% 15% / 0.035) 0 1px, transparent 1px 11px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
main {
|
||||
position: relative;
|
||||
max-width: 1050px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 22px 40px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: "Baskerville", "Times New Roman", serif;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1px solid hsl(198 78% 37% / 0.25);
|
||||
background: hsl(36 33% 97% / 0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.brand img {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px -10px rgb(23 32 46 / 0.35);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
background: linear-gradient(90deg, var(--primary), var(--cyan), var(--pink));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-size: 1.02rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
margin-left: -4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.links-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.links a {
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
background: hsl(198 52% 90% / 0.65);
|
||||
color: hsl(222 33% 15%);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: hsl(0 0% 100% / 0.95);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid hsl(32 18% 86%);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
color: hsl(222 33% 18%);
|
||||
background: hsl(36 30% 95%);
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: "JetBrains Mono", "SF Mono", monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 10px 0 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: hsl(36 24% 93%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
form.inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
margin: 4px 8px 4px 0;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: white;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid hsl(198 78% 37% / 0.45);
|
||||
background: linear-gradient(95deg, hsl(198 78% 37% / 0.12), hsl(192 85% 55% / 0.15));
|
||||
color: hsl(222 33% 16%);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
box-shadow: 0 6px 20px -10px hsl(198 78% 37% / 0.5);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
min-width: 66px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.dark .top-nav {
|
||||
background: hsl(222 35% 10% / 0.72);
|
||||
border-bottom-color: hsl(194 76% 62% / 0.3);
|
||||
}
|
||||
|
||||
.dark .links a:hover {
|
||||
background: hsl(210 34% 24% / 0.8);
|
||||
color: hsl(38 20% 92%);
|
||||
}
|
||||
|
||||
.dark table {
|
||||
background: hsl(221 31% 13% / 0.95);
|
||||
}
|
||||
|
||||
.dark th {
|
||||
color: hsl(38 20% 92%);
|
||||
background: hsl(221 24% 17%);
|
||||
}
|
||||
|
||||
.dark th,
|
||||
.dark td {
|
||||
border-bottom-color: hsl(219 18% 22%);
|
||||
}
|
||||
|
||||
.dark input,
|
||||
.dark select,
|
||||
.dark button {
|
||||
background: hsl(221 31% 13%);
|
||||
color: hsl(38 20% 92%);
|
||||
}
|
||||
|
||||
.dark pre {
|
||||
background: hsl(221 24% 17%);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.top-nav {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.links-wrap {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.links {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 18px 14px 30px;
|
||||
}
|
||||
}
|
||||
6
apps/backoffice/app/health/route.ts
Normal file
6
apps/backoffice/app/health/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export async function GET() {
|
||||
return new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" }
|
||||
});
|
||||
}
|
||||
41
apps/backoffice/app/jobs/page.tsx
Normal file
41
apps/backoffice/app/jobs/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { listJobs } from "../../lib/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function JobsPage() {
|
||||
const jobs = await listJobs().catch(() => []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Index Jobs</h1>
|
||||
<div className="card">
|
||||
<form action="/jobs/rebuild" method="post">
|
||||
<input name="library_id" placeholder="optional library UUID" />
|
||||
<button type="submit">Queue Rebuild</button>
|
||||
</form>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id}>
|
||||
<td>
|
||||
<code>{job.id}</code>
|
||||
</td>
|
||||
<td>{job.type}</td>
|
||||
<td>{job.status}</td>
|
||||
<td>{job.created_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
apps/backoffice/app/jobs/rebuild/route.ts
Normal file
17
apps/backoffice/app/jobs/rebuild/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "../../../lib/api";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const form = await req.formData();
|
||||
const libraryId = String(form.get("library_id") || "").trim();
|
||||
const body = libraryId ? { library_id: libraryId } : {};
|
||||
|
||||
await apiFetch("/index/rebuild", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
}).catch(() => null);
|
||||
|
||||
revalidatePath("/jobs");
|
||||
return NextResponse.redirect(new URL("/jobs", req.url));
|
||||
}
|
||||
40
apps/backoffice/app/layout.tsx
Normal file
40
apps/backoffice/app/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "./theme-provider";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Stripstream Backoffice",
|
||||
description: "Backoffice administration for Stripstream Librarian"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<nav className="top-nav">
|
||||
<Link href="/" className="brand">
|
||||
<Image src="/logo.png" alt="Stripstream" width={36} height={36} />
|
||||
<span className="brand-name">StripStream</span>
|
||||
<span className="brand-subtitle">backoffice</span>
|
||||
</Link>
|
||||
<div className="links-wrap">
|
||||
<div className="links">
|
||||
<Link href="/">Dashboard</Link>
|
||||
<Link href="/libraries">Libraries</Link>
|
||||
<Link href="/jobs">Jobs</Link>
|
||||
<Link href="/tokens">Tokens</Link>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</nav>
|
||||
<main>{children}</main>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
11
apps/backoffice/app/page.tsx
Normal file
11
apps/backoffice/app/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<h1>Stripstream Backoffice</h1>
|
||||
<p>Manage libraries, indexing jobs, and API tokens from a Next.js admin interface.</p>
|
||||
<div className="card">
|
||||
<p>Use the navigation links above to access each admin section.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
apps/backoffice/app/theme-provider.tsx
Normal file
12
apps/backoffice/app/theme-provider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
28
apps/backoffice/app/theme-toggle.tsx
Normal file
28
apps/backoffice/app/theme-toggle.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const activeTheme = theme === "system" ? resolvedTheme : theme;
|
||||
const nextTheme = activeTheme === "dark" ? "light" : "dark";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="theme-toggle"
|
||||
onClick={() => setTheme(nextTheme)}
|
||||
aria-label="Toggle color theme"
|
||||
disabled={!mounted}
|
||||
>
|
||||
{mounted ? (activeTheme === "dark" ? "Dark" : "Light") : "Theme"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
27
apps/backoffice/app/tokens/create/route.ts
Normal file
27
apps/backoffice/app/tokens/create/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "../../../lib/api";
|
||||
|
||||
type CreatedToken = { token: string };
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const form = await req.formData();
|
||||
const name = String(form.get("name") || "").trim();
|
||||
const scope = String(form.get("scope") || "read").trim();
|
||||
|
||||
let created = "";
|
||||
if (name) {
|
||||
const res = await apiFetch<CreatedToken>("/admin/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, scope })
|
||||
}).catch(() => null);
|
||||
created = res?.token || "";
|
||||
}
|
||||
|
||||
revalidatePath("/tokens");
|
||||
const url = new URL("/tokens", req.url);
|
||||
if (created) {
|
||||
url.searchParams.set("created", created);
|
||||
}
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
66
apps/backoffice/app/tokens/page.tsx
Normal file
66
apps/backoffice/app/tokens/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { listTokens } from "../../lib/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TokensPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ created?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const tokens = await listTokens().catch(() => []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>API Tokens</h1>
|
||||
|
||||
{params.created ? (
|
||||
<div className="card">
|
||||
<strong>Token created:</strong>
|
||||
<pre>{params.created}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card">
|
||||
<form action="/tokens/create" method="post">
|
||||
<input name="name" placeholder="token name" required />
|
||||
<select name="scope" defaultValue="read">
|
||||
<option value="read">read</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button type="submit">Create Token</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Scope</th>
|
||||
<th>Prefix</th>
|
||||
<th>Revoked</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => (
|
||||
<tr key={token.id}>
|
||||
<td>{token.name}</td>
|
||||
<td>{token.scope}</td>
|
||||
<td>
|
||||
<code>{token.prefix}</code>
|
||||
</td>
|
||||
<td>{token.revoked_at ? "yes" : "no"}</td>
|
||||
<td>
|
||||
<form className="inline" action="/tokens/revoke" method="post">
|
||||
<input type="hidden" name="id" value={token.id} />
|
||||
<button type="submit">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
apps/backoffice/app/tokens/revoke/route.ts
Normal file
13
apps/backoffice/app/tokens/revoke/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "../../../lib/api";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const form = await req.formData();
|
||||
const id = String(form.get("id") || "").trim();
|
||||
if (id) {
|
||||
await apiFetch(`/admin/tokens/${id}`, { method: "DELETE" }).catch(() => null);
|
||||
}
|
||||
revalidatePath("/tokens");
|
||||
return NextResponse.redirect(new URL("/tokens", req.url));
|
||||
}
|
||||
67
apps/backoffice/lib/api.ts
Normal file
67
apps/backoffice/lib/api.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export type LibraryDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
root_path: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type IndexJobDto = {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TokenDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
prefix: string;
|
||||
revoked_at: string | null;
|
||||
};
|
||||
|
||||
function config() {
|
||||
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||
const token = process.env.API_BOOTSTRAP_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error("API_BOOTSTRAP_TOKEN is required for backoffice");
|
||||
}
|
||||
return { baseUrl: baseUrl.replace(/\/$/, ""), token };
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const { baseUrl, token } = config();
|
||||
const headers = new Headers(init?.headers || {});
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (init?.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`API ${path} failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return null as T;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function listLibraries() {
|
||||
return apiFetch<LibraryDto[]>("/libraries");
|
||||
}
|
||||
|
||||
export async function listJobs() {
|
||||
return apiFetch<IndexJobDto[]>("/index/status");
|
||||
}
|
||||
|
||||
export async function listTokens() {
|
||||
return apiFetch<TokenDto[]>("/admin/tokens");
|
||||
}
|
||||
6
apps/backoffice/next-env.d.ts
vendored
Normal file
6
apps/backoffice/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
7
apps/backoffice/next.config.mjs
Normal file
7
apps/backoffice/next.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
typedRoutes: true
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
1047
apps/backoffice/package-lock.json
generated
Normal file
1047
apps/backoffice/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
apps/backoffice/package.json
Normal file
22
apps/backoffice/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 8082",
|
||||
"build": "next build",
|
||||
"start": "next start -p 8082"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.13.14",
|
||||
"@types/react": "19.0.12",
|
||||
"@types/react-dom": "19.0.5",
|
||||
"typescript": "5.8.2"
|
||||
}
|
||||
}
|
||||
BIN
apps/backoffice/public/logo.png
Normal file
BIN
apps/backoffice/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
36
apps/backoffice/tsconfig.json
Normal file
36
apps/backoffice/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es2022"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -89,14 +89,17 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
admin-ui:
|
||||
backoffice:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/admin-ui/Dockerfile
|
||||
dockerfile: apps/backoffice/Dockerfile
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
- PORT=${BACKOFFICE_PORT:-8082}
|
||||
- HOSTNAME=0.0.0.0
|
||||
ports:
|
||||
- "8082:8082"
|
||||
- "${BACKOFFICE_PORT:-8082}:8082"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
|
||||
BASE_API="${BASE_API:-http://127.0.0.1:8080}"
|
||||
BASE_INDEXER="${BASE_INDEXER:-http://127.0.0.1:8081}"
|
||||
BASE_ADMIN="${BASE_ADMIN:-http://127.0.0.1:8082}"
|
||||
BASE_BACKOFFICE="${BASE_BACKOFFICE:-${BASE_ADMIN:-http://127.0.0.1:8082}}"
|
||||
TOKEN="${API_TOKEN:-stripstream-dev-bootstrap-token}"
|
||||
|
||||
echo "[smoke] health checks"
|
||||
@@ -11,7 +11,7 @@ curl -fsS "$BASE_API/health" >/dev/null
|
||||
curl -fsS "$BASE_API/ready" >/dev/null
|
||||
curl -fsS "$BASE_INDEXER/health" >/dev/null
|
||||
curl -fsS "$BASE_INDEXER/ready" >/dev/null
|
||||
curl -fsS "$BASE_ADMIN/health" >/dev/null
|
||||
curl -fsS "$BASE_BACKOFFICE/health" >/dev/null
|
||||
|
||||
echo "[smoke] list libraries"
|
||||
curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE_API/libraries" >/dev/null
|
||||
|
||||
Reference in New Issue
Block a user