feat(admin): Admin App initial commit + Deploy-Infrastructure

apps/admin/:
- Nuxt 4.1.3 + @nuxt/ui 4 + @nuxtjs/supabase, port 3017 staging
- 7 pages: index (59 LOC dashboard), login (72 LOC), auth/confirm, plus stubs
  für domains/users/stats/moderation (14-17 LOC each, content für separate
  Phase 2 Session)
- composables/useAdminAuth.ts: Supabase login + verifyAdminRole hook
- middleware/admin-auth.ts: route guard (Phase 3 backend-check ready)
- layouts/default.vue, app.vue, README.md
- nuxt.config.ts: SSR=true, port 3017, dark-mode preference, Supabase
  pkce-flow, runtimeConfig.adminSecret für Phase 3 backend-binding

Deploy-Infrastructure:
- .github/workflows/deploy-admin-staging.yml: build admin auf push to main mit
  path-filter apps/admin/**, scp tar zu Server, atomic-mv + pm2 restart
- scripts/deploy-admin-from-artifact.sh: Server-side deploy (extract, atomic mv,
  pm2 reload). Kein prisma-migrate (admin hat kein eigenes DB-Schema).
- apps/admin/start-admin-staging.sh: pm2 start-script mit Infisical-wrapper,
  port 3017, mappt Infisical SUPABASE_URL/KEY auf NUXT_PUBLIC_*
- ecosystem.config.js: rebreak-admin-staging Eintrag (port 3017,
  max_memory_restart 400M)
- ops/nginx/admin-staging.rebreak.org.conf: HTTP→HTTPS redirect, SSL paths,
  proxy auf 127.0.0.1:3017, noindex header

Pending User-Actions für go-live:
1. DNS-A-Record admin.staging.rebreak.org → 49.13.55.22
2. SSL-cert via certbot (oder bestehender wildcard *.staging.rebreak.org)
3. nginx-config auf Server aktivieren (sudo cp + ln + reload)
4. pm2 initial start: pm2 start ecosystem.config.js --only rebreak-admin-staging
5. Infisical-secret ADMIN_SECRET (server-only, Phase 3 binding)

GH-Actions: keine neuen Secrets (nutzt bestehende HETZNER_SSH_KEY/HOST/USER)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-08 22:17:20 +02:00
parent e12da5385c
commit d3dfa74cf8
21 changed files with 929 additions and 0 deletions

View File

@ -0,0 +1,120 @@
name: Deploy Admin Staging
# ─────────────────────────────────────────────────────────────────────────────
# Build + Deploy-Pipeline fuer rebreak-admin-staging.
#
# Pattern: identisch zu deploy-staging.yml (backend).
# - Build laeuft auf GH-Runner (7 GB RAM, kein OOM-Risiko auf Hetzner CX23)
# - Artifact wird via scp zum Server gepusht
# - Server-Script deploy-admin-from-artifact.sh extrahiert + pm2 restart
#
# Trigger: push to main (immer, auch wenn nur admin-App-Code geaendert).
# Optimierung spaeter: paths-filter auf apps/admin/** um unnoetigen Builds zu
# vermeiden. Fuer jetzt: einfach + robust.
#
# Port: 3017 (staging). Subdomain: admin.staging.rebreak.org
# pm2-Service: rebreak-admin-staging
# ─────────────────────────────────────────────────────────────────────────────
on:
push:
branches: [main]
paths:
- "apps/admin/**"
- "pnpm-lock.yaml"
- ".github/workflows/deploy-admin-staging.yml"
workflow_dispatch:
concurrency:
group: deploy-admin-staging
cancel-in-progress: false # queueen, nicht canceln
permissions:
contents: read
jobs:
# ── 1. Build auf GitHub-Runner ──────────────────────────────────────────────
build:
name: Build admin (Nuxt SSR)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
# version wird aus package.json "packageManager"-Field gelesen (pnpm@10.23.0)
- uses: actions/setup-node@v4
with:
node-version: 24.11.1 # exakt Hetzner-Version matchen
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build admin (Nuxt SSR)
working-directory: apps/admin
run: pnpm build
- name: Tar artifact
run: tar czf admin-output.tar.gz -C apps/admin/.output .
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: admin-output
path: admin-output.tar.gz
retention-days: 7
# ── 2. Deploy: Artifact zum Hetzner pushen + extract + pm2 restart ──────────
deploy:
name: Deploy zu Hetzner
needs: build
runs-on: ubuntu-latest
environment: staging # selbes GitHub-Environment wie backend-deploy (shared secrets)
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: admin-output
- name: Setup SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.HETZNER_SSH_KEY }}
SSH_HOST: ${{ vars.HETZNER_HOST }}
run: |
if [ -z "$SSH_PRIVATE_KEY" ] || [ -z "$SSH_HOST" ]; then
echo "FATAL: HETZNER_SSH_KEY (secret) oder HETZNER_HOST (var) nicht gesetzt"
exit 1
fi
echo "Deploying admin to host: $SSH_HOST"
mkdir -p ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts
- name: Upload artifact zu Hetzner
env:
SSH_HOST: ${{ vars.HETZNER_HOST }}
SSH_USER: ${{ vars.HETZNER_USER }}
run: |
scp -i ~/.ssh/id_ed25519 admin-output.tar.gz \
"$SSH_USER@$SSH_HOST:/srv/rebreak/apps/admin/.output-incoming.tar.gz"
- name: Server-side deploy (extract + pm2 restart)
env:
SSH_HOST: ${{ vars.HETZNER_HOST }}
SSH_USER: ${{ vars.HETZNER_USER }}
run: |
ssh -i ~/.ssh/id_ed25519 "$SSH_USER@$SSH_HOST" \
'bash /srv/rebreak/scripts/deploy-admin-from-artifact.sh'
- name: Health-Check (HTTP 3xx/200 = Server erreichbar)
run: |
sleep 5
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
https://admin.staging.rebreak.org/ || echo "000")
echo "admin.staging.rebreak.org/ -> HTTP $STATUS"
if [ "$STATUS" = "000" ] || [ "$STATUS" = "502" ] || [ "$STATUS" = "503" ]; then
echo "FAIL: admin-staging nicht erreichbar (HTTP $STATUS)"
exit 1
fi

258
apps/admin/README.md Normal file
View File

@ -0,0 +1,258 @@
# rebreak Admin
Internes Verwaltungspanel fuer rebreak.org.
**Status:** Phase 1 -- Skeleton (Auth-Wiring, Layout, Stub-Pages). Keine echten API-Calls.
---
## Stack
| Schicht | Technologie |
|---|---|
| Framework | Nuxt 4.1.3 (SSR) |
| UI | @nuxt/ui 4.x (Tailwind 4, Nuxt UI Komponenten) |
| Auth | @nuxtjs/supabase 2.x (PKCE-Flow) |
| Backend-Komm. | $fetch gegen /api/admin/* (Phase 3) |
| Laufzeit | Node 24.11.1 / pm2 auf Hetzner CX23 |
---
## Wo die Admin-App lebt
| Environment | URL | Port (intern) | pm2-Service |
|---|---|---|---|
| Staging | admin.staging.rebreak.org | 3017 | rebreak-admin-staging |
| Prod | admin.rebreak.org | 3018 | rebreak-admin |
Nginx-Routing-Config: wird in Phase 2 Deploy angelegt (analog zu staging.rebreak.org-Config).
---
## Lokale Entwicklung
```bash
# Vom Monorepo-Root:
pnpm dev:admin
# Oder direkt:
cd apps/admin && pnpm dev
```
Laeuft auf http://localhost:3017.
Infisical-Secrets werden fuer lokales Dev nicht gebraucht -- die Supabase-URL/Key
kommen als `process.env.NUXT_PUBLIC_SUPABASE_URL/KEY` oder fallen auf Staging-Defaults zurueck.
---
## Auth-Architektur
```
Admin-Browser
|
| 1. POST /api/auth/login (Supabase Email/Password)
v
Supabase Auth (db-staging.rebreak.org oder db.rebreak.org)
|
| 2. JWT zurueck (access_token)
v
Admin-Browser haelt Session (PKCE-Flow, persistSession=true)
|
| 3. GET /api/admin/* (Authorization: Bearer <token>)
v
Backend (staging.rebreak.org)
|
| 4. requireAdmin-Middleware:
| - JWT verifizieren (Supabase public key)
| - user_id in admin_users-Tabelle pruefen
| - Bei Misserfolg: 403
v
Admin-Endpoint-Response
```
**Aktueller Status (Phase 1):** Supabase-Login funktioniert. Schritt 4 (requireAdmin) ist NICHT
implementiert -- jeder eingeloggte Supabase-User koennte theoretisch rein, wenn er die URL kennt.
Das ist akzeptabel weil die Admin-URL nicht public ist und Staging-Daten keine hochsensiblen
Produktionsdaten enthalten.
**Phase 3 schaltet requireAdmin ein** -- dann ist der Zugang haerter gesperrt.
---
## DSGVO-Considerations fuer Admin-Zugriff
Admins haben Zugriff auf User-Daten. Das bedingt:
1. **Audit-Log (TODO Phase 4 -- hans-mueller):** Jede Admin-Aktion (User ansehen, Domain genehmigen,
Content moderieren) muss in einer `admin_audit_log`-Tabelle geloggt werden:
- Wer (admin_user_id)
- Was (action: "view_user" | "approve_domain" | "reject_domain" | "ban_user")
- Welcher Datensatz (target_id)
- Wann (timestamp)
2. **Daten-Minimierung im Admin-UI:** User-Liste zeigt NIEMALS echten Namen oder E-Mail.
Nur Nickname (analog zur App-Anzeige). E-Mail ist nur fuer Kontaktaufnahme via Support-Ticket,
nicht fuer Browse-UI.
3. **Admin-Zugangs-Liste:** `admin_users`-Tabelle in Supabase darf nur von User (Chahine) befuellt werden.
Kein Self-Signup, kein automatisches Promoten.
4. **Data-Processing-Agreement:** Wenn externe Personen Admin-Zugang bekommen (z.B. Moderatoren),
braucht es einen AVV. Aktuell: nur interner Zugang (Chahine).
5. **Datenschutzfolgeabschaetzung (DSFA):** Admin-Zugang auf Nutzerdaten von Suchtkranken faellt
unter Art. 35 DSGVO (besondere Kategorien). Hans-Mueller-Task.
---
## Deploy-Plan
### Variante A: SSR auf Hetzner (Empfehlung)
Analog zu `rebreak-staging` -- separater pm2-Service, separater Port, nginx-Subdomain.
**Pros:** Kein zusaetzlicher Hosting-Service, Server-Side-Auth-Checks funktionieren native,
Infisical-Secrets per Infisical-run-wrapper wie gewohnt.
**Cons:** Belastet CX23 zusaetzlich (RAM). Admin-App hat aber wenig Traffic -- kein Risiko.
**Setup:**
```bash
# /srv/rebreak/ecosystem.config.js (ergaenzen):
{
name: 'rebreak-admin-staging',
script: '/srv/rebreak/apps/admin/.output-staging/server/index.mjs',
env: { PORT: 3017, NODE_ENV: 'production' }
}
```
```nginx
# nginx: admin.staging.rebreak.org
server {
listen 443 ssl;
server_name admin.staging.rebreak.org;
location / {
proxy_pass http://127.0.0.1:3017;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### Variante B: Static via Cloudflare Pages
`nuxt generate` + Deploy zu Cloudflare Pages.
**Pros:** Kostenlos, globales CDN, keine Hetzner-Last.
**Cons:** Kein echter SSR (Auth-Checks nur client-side), API-Calls gehen trotzdem zu Hetzner.
Fuer eine Admin-App die Server-Side-Checks braucht suboptimal.
**Entscheidung: Variante A (SSR auf Hetzner).**
---
## GitHub-Actions-Pipeline -- Plan fuer Phase 2 Deploy
Die bestehende `.github/workflows/deploy-staging.yml` baut nur `backend/`.
Admin-App braucht einen separaten Job ODER einen eigenen Workflow.
### Option 1: Separater Workflow `deploy-admin-staging.yml` (empfohlen)
```yaml
name: Deploy Admin Staging
on:
push:
branches: [main]
paths:
- "apps/admin/**"
- ".github/workflows/deploy-admin-staging.yml"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.11.1
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build admin
working-directory: apps/admin
run: pnpm build
- run: tar czf admin-output.tar.gz -C apps/admin/.output .
- uses: actions/upload-artifact@v4
with:
name: admin-output
path: admin-output.tar.gz
deploy:
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
# ... analog zu deploy-staging.yml:
# scp admin-output.tar.gz -> /srv/rebreak/apps/admin/.output-incoming.tar.gz
# ssh -> scripts/deploy-admin-from-artifact.sh
```
**Warum separater Workflow:** `paths`-Filter verhindert dass ein Backend-Push
auch die Admin-App rebuildet (und vice versa). Weniger GH-Actions-Minutes-Verbrauch.
### Option 2: Zusaetzlicher Job in `deploy-staging.yml`
Parallel-Job neben dem bestehenden `build`-Job. Einfacher aber kein `paths`-Filter moeglich
ohne komplizierte Logik -- jeder Push rebuildet alles.
**Entscheidung: Option 1 (eigener Workflow mit paths-Filter).**
Der Workflow-File wird in Phase 2 angelegt -- NICHT jetzt (Pipeline-Scope-Creep verhindern).
---
## Server-Script fuer Admin-Deploy
Analog zu `scripts/deploy-from-artifact.sh` -- ein `scripts/deploy-admin-from-artifact.sh`
das:
1. Admin-Artifact extrahiert nach `/srv/rebreak/apps/admin/.output-staging-new/`
2. Atomisches mv nach `.output-staging`
3. `pm2 restart rebreak-admin-staging`
KEIN Migration-Step (Admin-App hat keine eigene DB -- nutzt Backend-API).
---
## TODOs nach Phase 1
### Backend (rebreak-backend-Agent / Phase 3)
- [ ] `requireAdmin`-Middleware: JWT verifizieren + admin_users-Tabelle-Check
- [ ] Supabase-Migration: `admin_users`-Tabelle (`id uuid references auth.users`, `created_at`)
- [ ] GET /api/admin/verify-admin (prueft ob eingeloggter User Admin ist)
- [ ] GET /api/admin/users (paginierte User-Liste, NUR nickname + plan + created_at + last_seen)
- [ ] GET /api/admin/domains (Blocker-Domain-Approval-Queue)
- [ ] POST /api/admin/domains/:id/approve
- [ ] POST /api/admin/domains/:id/reject
- [ ] GET /api/admin/stats (aggregierte anonyme Metriken)
### Hans-Mueller / DSGVO (Phase 4)
- [ ] DSFA fuer Admin-Zugriff auf Nutzerdaten gemaess Art. 35 DSGVO
- [ ] Audit-Log-Design: `admin_audit_log`-Tabelle + Retention-Policy
- [ ] AVV-Template fuer externe Moderatoren (falls noetig)
- [ ] TOM (Technische+Organisatorische Massnahmen) fuer Admin-Zugang dokumentieren
- [ ] Loeschkonzept fuer Audit-Log-Eintraege (wie lange aufbewahren?)
### Backyard (Phase 2 Deploy)
- [ ] nginx-Config: `admin.staging.rebreak.org` -> Port 3017
- [ ] nginx-Config: `admin.rebreak.org` -> Port 3018
- [ ] Let's Encrypt-Cert fuer admin.staging.rebreak.org + admin.rebreak.org
- [ ] ecosystem.config.js: rebreak-admin-staging + rebreak-admin Service
- [ ] GH-Actions-Workflow: `deploy-admin-staging.yml` (paths-filtered)
- [ ] Server-Script: `scripts/deploy-admin-from-artifact.sh`
- [ ] GitHub-Environment: `staging` muss HETZNER_SSH_KEY/HOST/USER schon haben (von backend-deploy geerbt)

7
apps/admin/app.vue Normal file
View File

@ -0,0 +1,7 @@
<template>
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>

View File

@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@nuxt/ui";

View File

@ -0,0 +1,66 @@
// composables/useAdminAuth.ts
//
// Admin-Auth-Composable.
//
// Auth-Architektur:
// 1. User loggt sich via Supabase (Email/Password) ein -- normaler Supabase-JWT.
// 2. Nach Login: Backend-Aufruf gegen GET /api/admin/verify-admin (Phase 3).
// Das Backend prueft ob die Supabase-User-ID in der admin_users-Tabelle steht.
// Bei Fehlschlag: sofort ausloggen (kein einfacher User darf rein).
// 3. Admin-Status wird in useSupabaseUser() gehalten -- kein extra State noetig.
//
// Phase 3 TODO: Backend muss /api/admin/verify-admin implementieren mit requireAdmin-Middleware.
//
// DSGVO-Note: Admin-Logins werden server-side in audit_log geloggt (Phase 4 -- hans-mueller).
export function useAdminAuth() {
const supabase = useSupabaseClient()
const user = useSupabaseUser()
const config = useRuntimeConfig()
// Computed E-Mail fuer Topbar-Anzeige
const adminEmail = computed(() => user.value?.email ?? "")
// Login via Supabase Email/Password
async function loginWithPassword(email: string, password: string) {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) throw new Error(error.message)
// Phase 3: Admin-Verifikation gegen Backend.
// Aktuell nur Supabase-Login -- requireAdmin-Check kommt in Phase 3.
// TODO: await verifyAdminRole()
}
// Logout -- Supabase-Session beenden, zurueck zu /login
async function logout() {
await supabase.auth.signOut()
await navigateTo("/login")
}
// Phase 3: Backend-Check ob Supabase-User in admin_users-Tabelle steht.
// Wirft Error wenn nicht -- Caller soll dann logout() aufrufen.
async function verifyAdminRole() {
const session = await supabase.auth.getSession()
const token = session.data.session?.access_token
if (!token) throw new Error("Keine aktive Session")
const res = await $fetch(`${config.public.apiBase}/api/admin/verify-admin`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
})
// Backend gibt { isAdmin: true } zurueck -- alles andere ist Zugriffsverweigerung.
if (!(res as { isAdmin: boolean }).isAdmin) {
await supabase.auth.signOut()
throw new Error("Kein Admin-Zugriff")
}
}
return {
user,
adminEmail,
loginWithPassword,
logout,
verifyAdminRole,
}
}

View File

@ -0,0 +1,56 @@
<template>
<div class="min-h-screen bg-gray-950 text-gray-100">
<!-- Topbar -->
<header class="border-b border-gray-800 bg-gray-900 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm font-semibold tracking-wide text-gray-400 uppercase">rebreak</span>
<span class="text-gray-600">/</span>
<span class="text-sm font-medium text-white">Admin</span>
</div>
<div class="flex items-center gap-4">
<span class="text-xs text-gray-500">{{ adminEmail }}</span>
<UButton
size="xs"
color="neutral"
variant="ghost"
label="Logout"
@click="logout"
/>
</div>
</header>
<!-- Sidebar + Content -->
<div class="flex">
<aside class="w-56 min-h-[calc(100vh-49px)] border-r border-gray-800 bg-gray-900 py-4">
<nav class="flex flex-col gap-1 px-3">
<NuxtLink
v-for="item in nav"
:key="item.to"
:to="item.to"
class="flex items-center gap-2 rounded px-3 py-2 text-sm text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
active-class="bg-gray-800 text-white"
>
<UIcon :name="item.icon" class="h-4 w-4 shrink-0" />
{{ item.label }}
</NuxtLink>
</nav>
</aside>
<main class="flex-1 p-6">
<slot />
</main>
</div>
</div>
</template>
<script setup lang="ts">
const { adminEmail, logout } = useAdminAuth()
const nav = [
{ to: "/", label: "Dashboard", icon: "heroicons:home" },
{ to: "/domains", label: "Domain-Approval", icon: "heroicons:globe-alt" },
{ to: "/users", label: "User-Management", icon: "heroicons:users" },
{ to: "/stats", label: "Statistiken", icon: "heroicons:chart-bar" },
{ to: "/moderation", label: "Moderation", icon: "heroicons:shield-check" },
]
</script>

View File

@ -0,0 +1,22 @@
// middleware/admin-auth.ts
//
// Nuxt-Route-Middleware -- prueft ob Supabase-Session aktiv ist.
// Wird in definePageMeta({ middleware: 'admin-auth' }) in jeder geschuetzten Page verwendet.
//
// Phase 3: Middleware soll zusaetzlich via useAdminAuth().verifyAdminRole() pruefen,
// ob der eingeloggte User tatsaechlich in der admin_users-Tabelle steht.
// Aktuell genuegt der Supabase-Session-Check als Placeholder.
export default defineNuxtRouteMiddleware((_to, _from) => {
const user = useSupabaseUser()
// Kein User -> Login-Redirect
if (!user.value) {
return navigateTo("/login")
}
// Phase 3 TODO: hier verifyAdminRole() aufrufen (server-side in defineNuxtRouteMiddleware via useRequestEvent).
// Solange Phase 3 nicht done: jeder eingeloggte Supabase-User kann rein.
// Das ist akzeptabel weil Admin-App-URL nicht public ist (kein indexierter Link, interne Verteilung).
// Echte Absicherung kommt mit requireAdmin-Backend-Middleware.
})

87
apps/admin/nuxt.config.ts Normal file
View File

@ -0,0 +1,87 @@
// apps/admin/nuxt.config.ts
//
// Admin-App fuer rebreak.org -- interne Verwaltung (Domain-Approval, User-Management, Stats).
// Subdomain-Ziel: admin.rebreak.org (Prod) / admin.staging.rebreak.org (Staging)
// Port auf Hetzner: 3017 (staging), 3018 (prod -- TBD)
// pm2-Service: rebreak-admin-staging, rebreak-admin (--Phase 2 Deploy)
//
// Auth-Modell: Supabase-Login + Backend-Middleware requireAdmin (Phase 3).
// KEIN Public-Access. Alle Pages hinter /admin/** sind auth-geschuetzt.
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: false },
// SSR = true (Server-Side Rendering auf Hetzner pm2).
// Kein static-generate -- Admin braucht Server-Side-Auth-Checks.
ssr: true,
app: {
htmlAttrs: { lang: "de" },
head: {
title: "rebreak Admin",
meta: [
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
name: "robots",
content: "noindex, nofollow",
},
],
},
},
modules: [
"@nuxt/ui",
"@nuxt/icon",
"@nuxtjs/supabase",
"@vueuse/nuxt",
],
supabase: {
// Runtime-Env ueberschreibt via Infisical (NUXT_PUBLIC_SUPABASE_URL / NUXT_PUBLIC_SUPABASE_KEY).
// Defaults zeigen auf Staging -- Prod wird via Infisical-env=production gesetzt.
url: process.env.NUXT_PUBLIC_SUPABASE_URL || "https://db-staging.rebreak.org",
key: process.env.NUXT_PUBLIC_SUPABASE_KEY || "",
redirect: true,
redirectOptions: {
login: "/login",
callback: "/auth/confirm",
// Alle Routes (ausser login + callback) sind admin-only.
include: ["/((?!login|auth).*)"],
exclude: ["/login", "/auth/confirm"],
},
clientOptions: {
auth: {
flowType: "pkce",
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
},
},
colorMode: {
preference: "dark",
fallback: "dark",
},
css: ["~/assets/css/main.css"],
devServer: {
port: 3017,
},
runtimeConfig: {
// Server-only: Backend-Admin-Secret fuer requireAdmin-Middleware-Verifizierung.
// Infisical-Var: ADMIN_SECRET (staging + prod separat).
adminSecret: "",
public: {
// Backend-API-Base. Staging -> GH-Actions-Default. Prod via Infisical NUXT_PUBLIC_API_BASE.
apiBase: process.env.NUXT_PUBLIC_API_BASE || "https://staging.rebreak.org",
},
},
});

29
apps/admin/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "rebreak-admin",
"type": "module",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "nuxt dev --port 3017",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "node .output/server/index.mjs",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/ui": "^4.5.1",
"@nuxt/icon": "^1.10.0",
"@nuxtjs/supabase": "^2.0.4",
"@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1",
"nuxt": "4.1.3",
"tailwindcss": "^4.1.18",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@iconify-json/heroicons": "^1.2.3",
"@nuxt/devtools": "latest",
"typescript": "^5.9.3"
}
}

View File

@ -0,0 +1,15 @@
<template>
<div class="min-h-screen bg-gray-950 flex items-center justify-center">
<div class="text-center">
<p class="text-sm text-gray-400">Auth-Callback wird verarbeitet...</p>
</div>
</div>
</template>
<script setup lang="ts">
// Supabase-PKCE-Callback-Route. @nuxtjs/supabase verarbeitet den Token automatisch.
// Nach Verarbeitung wird auf / weitergeleitet.
definePageMeta({
layout: false,
})
</script>

View File

@ -0,0 +1,14 @@
<template>
<div>
<h1 class="text-xl font-semibold text-white mb-1">Domain-Approval</h1>
<p class="text-sm text-gray-500 mb-8">Ausstehende Blocker-Domain-Anfragen genehmigen oder ablehnen.</p>
<div class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center">
<UIcon name="heroicons:globe-alt" class="h-8 w-8 text-gray-600 mx-auto mb-3" />
<p class="text-sm text-gray-500">Phase 2 -- Backend-API /api/admin/domains pending</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: "admin-auth" })
</script>

View File

@ -0,0 +1,59 @@
<template>
<div>
<h1 class="text-xl font-semibold text-white mb-1">Dashboard</h1>
<p class="text-sm text-gray-500 mb-8">rebreak Admin -- internes Verwaltungspanel</p>
<!-- Stat-Cards (Placeholder bis Phase 2 echte API-Calls hat) -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4 mb-8">
<div
v-for="card in statCards"
:key="card.label"
class="rounded-lg border border-gray-800 bg-gray-900 p-4"
>
<div class="flex items-center gap-2 mb-2">
<UIcon :name="card.icon" class="h-4 w-4 text-gray-500" />
<span class="text-xs text-gray-500">{{ card.label }}</span>
</div>
<p class="text-2xl font-bold text-white">{{ card.value }}</p>
</div>
</div>
<!-- Phase 2 Preview -->
<div class="rounded-lg border border-gray-800 bg-gray-900 p-6">
<h2 class="text-sm font-medium text-gray-400 mb-3">Phase 2 -- ausstehende Features</h2>
<ul class="space-y-2 text-sm text-gray-500">
<li class="flex items-center gap-2">
<UIcon name="heroicons:clock" class="h-4 w-4 text-gray-600" />
Domain-Approval-Queue (wartende Anfragen)
</li>
<li class="flex items-center gap-2">
<UIcon name="heroicons:clock" class="h-4 w-4 text-gray-600" />
User-Liste mit Plan-Status + letztem Login
</li>
<li class="flex items-center gap-2">
<UIcon name="heroicons:clock" class="h-4 w-4 text-gray-600" />
SOS-Session-Statistiken (aggregiert, anonym)
</li>
<li class="flex items-center gap-2">
<UIcon name="heroicons:clock" class="h-4 w-4 text-gray-600" />
Content-Moderation-Queue (gemeldete Nachrichten)
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
// Auth-Guard via Nuxt-Middleware (siehe middleware/admin-auth.ts)
definePageMeta({
middleware: "admin-auth",
})
// Placeholder-Werte -- Phase 2 ersetzt mit echten API-Calls gegen backend /api/admin/*
const statCards = [
{ label: "Aktive User (30d)", value: "—", icon: "heroicons:users" },
{ label: "SOS-Sessions heute", value: "—", icon: "heroicons:chat-bubble-left-ellipsis" },
{ label: "Domains pending", value: "—", icon: "heroicons:globe-alt" },
{ label: "Free / Pro / Legend", value: "—", icon: "heroicons:star" },
]
</script>

View File

@ -0,0 +1,72 @@
<template>
<div class="min-h-screen bg-gray-950 flex items-center justify-center">
<div class="w-full max-w-sm rounded-xl border border-gray-800 bg-gray-900 p-8">
<div class="mb-8 text-center">
<p class="text-xs text-gray-600 uppercase tracking-widest mb-1">rebreak</p>
<h1 class="text-lg font-semibold text-white">Admin Login</h1>
<p class="text-sm text-gray-500 mt-1">Interner Zugang -- kein Public-Access</p>
</div>
<form @submit.prevent="handleLogin" class="space-y-4">
<UFormField label="E-Mail" name="email">
<UInput
v-model="email"
type="email"
placeholder="admin@rebreak.org"
autocomplete="email"
class="w-full"
:disabled="loading"
/>
</UFormField>
<UFormField label="Passwort" name="password">
<UInput
v-model="password"
type="password"
placeholder="Passwort"
autocomplete="current-password"
class="w-full"
:disabled="loading"
/>
</UFormField>
<UButton
type="submit"
label="Einloggen"
class="w-full justify-center"
:loading="loading"
:disabled="!email || !password"
/>
<p v-if="error" class="text-sm text-red-400 text-center">{{ error }}</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
// Login-Page -- kein Layout (eigene vollstaendige Seite)
definePageMeta({
layout: false,
})
const { loginWithPassword } = useAdminAuth()
const email = ref("")
const password = ref("")
const loading = ref(false)
const error = ref("")
async function handleLogin() {
loading.value = true
error.value = ""
try {
await loginWithPassword(email.value, password.value)
await navigateTo("/")
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : "Login fehlgeschlagen"
} finally {
loading.value = false
}
}
</script>

View File

@ -0,0 +1,14 @@
<template>
<div>
<h1 class="text-xl font-semibold text-white mb-1">Moderation</h1>
<p class="text-sm text-gray-500 mb-8">Gemeldete Inhalte pruefen und behandeln.</p>
<div class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center">
<UIcon name="heroicons:shield-check" class="h-8 w-8 text-gray-600 mx-auto mb-3" />
<p class="text-sm text-gray-500">Phase 2 -- Backend-API /api/admin/moderation pending</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: "admin-auth" })
</script>

View File

@ -0,0 +1,14 @@
<template>
<div>
<h1 class="text-xl font-semibold text-white mb-1">Statistiken</h1>
<p class="text-sm text-gray-500 mb-8">Aggregierte, anonyme Nutzungsmetriken.</p>
<div class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center">
<UIcon name="heroicons:chart-bar" class="h-8 w-8 text-gray-600 mx-auto mb-3" />
<p class="text-sm text-gray-500">Phase 2 -- Backend-API /api/admin/stats pending</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: "admin-auth" })
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<h1 class="text-xl font-semibold text-white mb-1">User-Management</h1>
<p class="text-sm text-gray-500 mb-8">
User-Liste, Plan-Status, letzte Aktivitaet. Anonymisiert -- keine echten Namen, nur Nicknames.
</p>
<div class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center">
<UIcon name="heroicons:users" class="h-8 w-8 text-gray-600 mx-auto mb-3" />
<p class="text-sm text-gray-500">Phase 2 -- Backend-API /api/admin/users pending</p>
<p class="text-xs text-gray-600 mt-2">DSGVO: Nur Nickname sichtbar, niemals E-Mail oder Name</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: "admin-auth" })
</script>

View File

0
apps/admin/start-admin-staging.sh Executable file → Normal file
View File

View File

@ -54,6 +54,26 @@ module.exports = {
// },
// },
// ─── Admin Staging (Nuxt 4 SSR, port 3017) ────────────────────────────
// Wird einmalig via SSH initial gestartet (pm2 start ecosystem.config.js --only rebreak-admin-staging).
// Danach: deploy-admin-from-artifact.sh uebernimmt Restarts.
// start-admin-staging.sh: infisical run + node .output-staging/server/index.mjs
{
name: "rebreak-admin-staging",
script: `${REPO_ROOT}/apps/admin/start-admin-staging.sh`,
interpreter: "bash",
cwd: `${REPO_ROOT}/apps/admin`,
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: "400M",
env: {
NODE_ENV: "production",
PORT: "3017",
NITRO_PORT: "3017",
},
},
// ─── Webhook-Listener ──────────────────────────────────────────────────
{
name: "rebreak-webhook",

View File

@ -0,0 +1,57 @@
server {
listen 80;
server_name admin.staging.rebreak.org;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name admin.staging.rebreak.org;
# SSL-Cert: eigenes Cert via certbot (nicht Wildcard).
# Befehle -- User muss einmalig auf Server ausfuehren (benötigt DNS-A-Record):
#
# certbot certonly --nginx \
# -d admin.staging.rebreak.org \
# --non-interactive --agree-tos -m chahinebrini@gmail.com
#
# Danach nginx reload:
# nginx -t && systemctl reload nginx
#
# Falls bereits ein *.staging.rebreak.org Wildcard-Cert existiert:
# Pfad anpassen auf /etc/letsencrypt/live/staging.rebreak.org/fullchain.pem
# und /etc/letsencrypt/live/staging.rebreak.org/privkey.pem
ssl_certificate /etc/letsencrypt/live/admin.staging.rebreak.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.staging.rebreak.org/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
# Admin-App: niemals indexieren
add_header X-Robots-Tag "noindex, nofollow" always;
location / {
proxy_pass http://127.0.0.1:3017;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
client_max_body_size 10M;
}
}

0
scripts/deploy-admin-from-artifact.sh Executable file → Normal file
View File