diff --git a/.env.example b/.env.example index 60e478a..6e3efbf 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -NODE_ENV=development +NODE_ENV=production # Shared by apps/bot (gateway connection) and apps/webui (narrow REST calls + # BullMQ job enqueueing for guild backups, giveaways and the scheduler). BOT_TOKEN= @@ -15,12 +15,15 @@ BACKUP_RETENTION_DAYS=14 BACKUP_CRON=0 3 * * * BACKUP_DIR=/backups HEALTH_PORT=8080 +# Bot health stays internal (no published port). Override only if you expose it. PUBLIC_BASE_URL=http://localhost:8080 TWITCH_CLIENT_ID= TWITCH_CLIENT_SECRET= -# WebUI (apps/webui) -WEBUI_URL=http://10.111.0.65:3000 +# WebUI (apps/webui) – public URL behind Traefik +WEBUI_URL=https://dashboard.nexumi.de +# Discord Developer Portal OAuth2 redirect: +# https://dashboard.nexumi.de/api/auth/callback # Must be at least 32 characters long. Generate e.g. with `openssl rand -hex 32`. SESSION_SECRET= # Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C). diff --git a/apps/webui/src/app/login/page.tsx b/apps/webui/src/app/login/page.tsx index 1ec7b6e..737ad65 100644 --- a/apps/webui/src/app/login/page.tsx +++ b/apps/webui/src/app/login/page.tsx @@ -1,52 +1,12 @@ import { redirect } from 'next/navigation'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { getLocale, t } from '@/lib/i18n'; -import { getSession } from '@/lib/session'; interface LoginPageProps { searchParams: Promise<{ error?: string }>; } -const KNOWN_ERRORS = ['access_denied', 'invalid_request', 'invalid_state', 'oauth_failed'] as const; - +/** Alias for `/` so OAuth error redirects to `/login?error=…` keep working. */ export default async function LoginPage({ searchParams }: LoginPageProps) { - const session = await getSession(); - if (session) { - redirect('/dashboard'); - } - const { error } = await searchParams; - const locale = await getLocale(); - const errorMessage = - error && (KNOWN_ERRORS as readonly string[]).includes(error) - ? t(locale, `login.errors.${error}`) - : undefined; - - return ( -
- - -
- N -
- {t(locale, 'login.title')} - {t(locale, 'login.subtitle')} -
- - {errorMessage && ( -

- {errorMessage} -

- )} - - {t(locale, 'login.cta')} - -

{t(locale, 'login.footer')}

-
-
-
- ); + const target = error ? `/?error=${encodeURIComponent(error)}` : '/'; + redirect(target); } diff --git a/apps/webui/src/app/page.tsx b/apps/webui/src/app/page.tsx index 59bd845..f11278a 100644 --- a/apps/webui/src/app/page.tsx +++ b/apps/webui/src/app/page.tsx @@ -1,111 +1,17 @@ -import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared'; -import Image from 'next/image'; -import Link from 'next/link'; -import { SiteShell } from '@/components/site/site-shell'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { buildBotInviteUrl, env } from '@/lib/env'; -import { getLocale, t } from '@/lib/i18n'; -import { getPublicStats } from '@/lib/public-stats'; +import { redirect } from 'next/navigation'; +import { LoginCard } from '@/components/auth/login-card'; import { getSession } from '@/lib/session'; -const GROUP_ORDER: DashboardModuleGroup[] = [ - 'moderation', - 'engagement', - 'community', - 'utility', - 'integrations' -]; - -export default async function LandingPage() { - const [locale, stats, session] = await Promise.all([getLocale(), getPublicStats(), getSession()]); - const inviteUrl = buildBotInviteUrl(); - const isLoggedIn = Boolean(session); - - return ( - -
-
-
- -

Nexumi

-
-

{t(locale, 'landing.hero.subtitle')}

-
- - - {env.SUPPORT_SERVER_URL ? ( - - ) : null} -
-
-
- -
-
- - - - {t(locale, 'landing.stats.guilds')} - - - {stats.guildCount.toLocaleString(locale)} - - - - - {t(locale, 'landing.stats.users')} - - - - {stats.approximateUserCount.toLocaleString(locale)} - - -
-
- -
-
-
-

{t(locale, 'landing.features.title')}

-

{t(locale, 'landing.features.subtitle')}

-
- {GROUP_ORDER.map((group) => { - const modules = DASHBOARD_MODULES.filter((module) => module.group === group); - return ( -
-

- {t(locale, `moduleGroups.${group}`)} -

-
- {modules.map((module) => ( - - - {t(locale, `modules.${module.id}.label`)} - - - {t(locale, `modules.${module.id}.description`)} - - - ))} -
-
- ); - })} -
-
-
- ); +interface HomePageProps { + searchParams: Promise<{ error?: string }>; +} + +export default async function HomePage({ searchParams }: HomePageProps) { + const session = await getSession(); + if (session) { + redirect('/dashboard'); + } + + const { error } = await searchParams; + return ; } diff --git a/apps/webui/src/archived/landing-page.tsx b/apps/webui/src/archived/landing-page.tsx new file mode 100644 index 0000000..51a0285 --- /dev/null +++ b/apps/webui/src/archived/landing-page.tsx @@ -0,0 +1,117 @@ +/** + * Archived marketing landing page (Phase 8). + * Replaced by the login screen at `/` for the dashboard.nexumi.de deploy. + * A separate public landing will live on nexumi.de later. + * This file is not an App Router page and is not served. + */ +import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared'; +import Image from 'next/image'; +import Link from 'next/link'; +import { SiteShell } from '@/components/site/site-shell'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { buildBotInviteUrl, env } from '@/lib/env'; +import { getLocale, t } from '@/lib/i18n'; +import { getPublicStats } from '@/lib/public-stats'; +import { getSession } from '@/lib/session'; + +const GROUP_ORDER: DashboardModuleGroup[] = [ + 'moderation', + 'engagement', + 'community', + 'utility', + 'integrations' +]; + +export default async function ArchivedLandingPage() { + const [locale, stats, session] = await Promise.all([getLocale(), getPublicStats(), getSession()]); + const inviteUrl = buildBotInviteUrl(); + const isLoggedIn = Boolean(session); + + return ( + +
+
+
+ +

Nexumi

+
+

{t(locale, 'landing.hero.subtitle')}

+
+ + + {env.SUPPORT_SERVER_URL ? ( + + ) : null} +
+
+
+ +
+
+ + + + {t(locale, 'landing.stats.guilds')} + + + {stats.guildCount.toLocaleString(locale)} + + + + + {t(locale, 'landing.stats.users')} + + + + {stats.approximateUserCount.toLocaleString(locale)} + + +
+
+ +
+
+
+

{t(locale, 'landing.features.title')}

+

{t(locale, 'landing.features.subtitle')}

+
+ {GROUP_ORDER.map((group) => { + const modules = DASHBOARD_MODULES.filter((module) => module.group === group); + return ( +
+

+ {t(locale, `moduleGroups.${group}`)} +

+
+ {modules.map((module) => ( + + + {t(locale, `modules.${module.id}.label`)} + + + {t(locale, `modules.${module.id}.description`)} + + + ))} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/webui/src/components/auth/login-card.tsx b/apps/webui/src/components/auth/login-card.tsx new file mode 100644 index 0000000..e9aa30f --- /dev/null +++ b/apps/webui/src/components/auth/login-card.tsx @@ -0,0 +1,40 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { getLocale, t } from '@/lib/i18n'; + +const KNOWN_ERRORS = ['access_denied', 'invalid_request', 'invalid_state', 'oauth_failed'] as const; + +export async function LoginCard({ error }: { error?: string }) { + const locale = await getLocale(); + const errorMessage = + error && (KNOWN_ERRORS as readonly string[]).includes(error) + ? t(locale, `login.errors.${error}`) + : undefined; + + return ( +
+ + +
+ N +
+ {t(locale, 'login.title')} + {t(locale, 'login.subtitle')} +
+ + {errorMessage && ( +

+ {errorMessage} +

+ )} + + {t(locale, 'login.cta')} + +

{t(locale, 'login.footer')}

+
+
+
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml index 6bb89e5..1a6bdeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: POSTGRES_PASSWORD: nexumi volumes: - postgres_data:/var/lib/postgresql/data + networks: + - internal healthcheck: test: ["CMD-SHELL", "pg_isready -U nexumi -d nexumi"] interval: 5s @@ -18,6 +20,8 @@ services: command: redis-server --appendonly yes volumes: - redis_data:/data + networks: + - internal healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -36,6 +40,8 @@ services: condition: service_healthy volumes: - backups:/backups + networks: + - internal healthcheck: test: [ @@ -57,12 +63,16 @@ services: condition: service_healthy redis: condition: service_healthy - ports: - - "3000:3000" + networks: + - internal + - traefik-network labels: - "traefik.enable=true" - - "traefik.http.routers.nexumi.rule=Host(`nexumi.de`)" - - "traefik.http.services.nexumi.loadbalancer.server.port=3000" + - "traefik.docker.network=traefik-network" + - "traefik.http.routers.nexumi-dashboard.rule=Host(`dashboard.nexumi.de`)" + - "traefik.http.routers.nexumi-dashboard.entrypoints=websecure" + - "traefik.http.routers.nexumi-dashboard.tls.certresolver=letsencrypt" + - "traefik.http.services.nexumi-dashboard.loadbalancer.server.port=3000" healthcheck: test: [ @@ -78,3 +88,9 @@ volumes: postgres_data: redis_data: backups: + +networks: + traefik-network: + external: true + internal: + driver: bridge diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index a81050a..de92f55 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -1,7 +1,7 @@ ## Phase 6 – WebUI-Fundament (Status: abgeschlossen, manuell bestätigt) - OAuth/Sessions, Settings-Framework, Layout, Modul-Toggles, Access-Rules -- Login manuell bestätigt (`WEBUI_URL=http://10.111.0.65:3000`) +- Login manuell bestätigt ## Phase 7 – WebUI Modul-Seiten + Owner-Panel (Status: abgeschlossen, manuell bestätigt) @@ -20,6 +20,26 @@ - Landing `/`, Status `/status`, Rechtsseiten, Public APIs, Core-Commands, `docs/verification.md` - Manuell bestätigt (User-Freigabe) +## Deploy-Branch (`deploy`) – Traefik + Dashboard-Domain + +### Abgeschlossen (Code auf Branch `deploy`; `main` unverändert) + +- Traefik-Vollstack: Compose-Netz `traefik-network` (external) + `internal` +- WebUI hinter Traefik: Host `dashboard.nexumi.de`, Entrypoint `websecure`, `certresolver=letsencrypt` +- Host-Port `3000` entfernt; nur Traefik exponiert die WebUI +- `WEBUI_URL=https://dashboard.nexumi.de` (OAuth-Callback: `/api/auth/callback`) +- Marketing-Landing archiviert unter `apps/webui/src/archived/landing-page.tsx` +- `/` zeigt Login; `/login` leitet auf `/` weiter (Query erhalten) +- **SPEC-Abweichung (bewusst):** öffentliche Landing später extern auf `nexumi.de`; Dashboard unter Subdomain + +### Manuell testen (Deploy) + +- [ ] DNS `dashboard.nexumi.de` → Traefik-Host +- [ ] Discord OAuth Redirect: `https://dashboard.nexumi.de/api/auth/callback` +- [ ] `docker compose up -d --build` (Netz `traefik-network` muss existieren) +- [ ] TLS-Zertifikat (Resolver-Name ggf. an Traefik anpassen) +- [ ] Login `/` → Dashboard; Session-Cookie `Secure` + ## Post-Phase – Premium + DSGVO (Status: implementiert, manuelle Tests offen) ### Abgeschlossen (Code) @@ -43,7 +63,8 @@ - Musik- und KI-Modul (nur auf Zuruf) - Premium-Zahlungsanbindung +- Externe Marketing-Landing auf `nexumi.de` ## Nächster geplanter Schritt -- Nach manueller Freigabe: Musik/KI auf Zuruf. +- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).