Enhance API with OIDC support, including login and callback endpoints. Update environment variables for OIDC configuration in .env.example. Add new features to the catalog service for listing software families, Minecraft versions, and deployment regions. Implement server management actions such as kill, delete, and update in the servers module. Integrate feature flags for maintenance mode in server operations. Update pnpm-lock.yaml with new dependencies and versions.
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 12s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
TheOnlyMace
2026-07-05 18:39:53 +02:00
parent bf36cb3159
commit 50cd4b3ffd
225 changed files with 17824 additions and 436 deletions

View File

@@ -15,10 +15,19 @@
"cancel": "Abbrechen",
"back": "Zurück",
"footer": "Selbstgehostete Minecraft-Server-Plattform.",
"servers": "Server"
"servers": "Server",
"features": "Funktionen",
"pricing": "Preise",
"docs": "Dokumentation",
"status": "Status",
"impressum": "Impressum",
"privacy": "Datenschutz",
"terms": "AGB",
"admin": "Admin",
"phaseBadge": "MVP · Phasen 09"
},
"landing": {
"badge": "Phase 0 · Control Plane",
"badge": "MVP · Phase 9 abgeschlossen",
"title": "Minecraft-Server on demand",
"subtitle": "Erstellen, konfigurieren und betreiben Sie Minecraft-Server über ein zentrales Webpanel — mandantenfähig, skalierbar und produktionsbereit.",
"ctaPrimary": "Jetzt starten",
@@ -205,7 +214,36 @@
"players": "Spieler",
"worlds": "Welten",
"backups": "Backups",
"addons": "Add-ons"
"addons": "Add-ons",
"software": "Software",
"schedules": "Zeitpläne",
"access": "Zugriffe"
},
"wizard": {
"stepsLabel": "Erstellungsschritte",
"steps": {
"basics": "Grundlagen",
"software": "Software",
"review": "Prüfen"
},
"next": "Weiter",
"back": "Zurück"
},
"software": {
"title": "Software",
"description": "Installierte Server-Software und Version.",
"loading": "Wird geladen…",
"loadError": "Software-Informationen konnten nicht geladen werden.",
"family": "Software-Familie",
"version": "Minecraft-Version",
"edition": "Edition",
"changeHint": "Software-Wechsel wird in einer zukünftigen Version unterstützt.",
"reinstall": "Software neu installieren",
"reinstalling": "Wird neu installiert…",
"reinstallConfirm": "Server-Software wirklich neu installieren? Zuerst wird ein Backup erstellt.",
"reinstallStarted": "Neuinstallation gestartet. Der Server ist während der Installation nicht verfügbar.",
"reinstallError": "Neuinstallation konnte nicht gestartet werden.",
"reinstallStoppedHint": "Stoppen Sie den Server, bevor Sie die Software neu installieren."
},
"console": {
"loading": "Wird geladen…",
@@ -233,10 +271,27 @@
"save": "Speichern",
"saving": "Wird gespeichert…",
"saved": "Datei gespeichert.",
"upload": "Hochladen",
"uploading": "Wird hochgeladen…",
"uploaded": "Datei hochgeladen.",
"delete": "Löschen",
"deleted": "Gelöscht.",
"deleteConfirm": "{name} wirklich löschen?",
"archive": "Auswahl archivieren",
"archiving": "Wird archiviert…",
"archived": "Archiv erstellt.",
"unarchive": "Archiv entpacken",
"unarchiving": "Wird entpackt…",
"unarchived": "Archiv-Entpackung gestartet.",
"unarchiveConfirm": "{name} ins aktuelle Verzeichnis entpacken?",
"errors": {
"loadFailed": "Verzeichnis konnte nicht geladen werden.",
"readFailed": "Datei konnte nicht gelesen werden.",
"saveFailed": "Datei konnte nicht gespeichert werden."
"saveFailed": "Datei konnte nicht gespeichert werden.",
"deleteFailed": "Datei konnte nicht gelöscht werden.",
"uploadFailed": "Datei konnte nicht hochgeladen werden.",
"archiveFailed": "Archiv konnte nicht erstellt werden.",
"unarchiveFailed": "Archiv konnte nicht entpackt werden."
}
},
"properties": {
@@ -280,14 +335,32 @@
"loading": "Wird geladen…",
"onlineCount": "Spieler online",
"whitelistCount": "Whitelist-Einträge",
"operatorsCount": "Operatoren",
"onlineTitle": "Spieler online",
"whitelistTitle": "Whitelist",
"operatorsTitle": "Operatoren",
"bansTitle": "Gebannte Spieler",
"bansEmpty": "Keine gebannten Spieler.",
"empty": "Keine Spieler online.",
"retry": "Erneut versuchen",
"kick": "Kicken",
"addWhitelist": "Zur Whitelist hinzufügen",
"addOperator": "Operator hinzufügen",
"addBan": "Spieler bannen",
"remove": "Entfernen",
"fields": {
"name": "Spielername",
"uuid": "UUID",
"reason": "Grund"
},
"columns": {
"name": "Spieler",
"ping": "Ping"
"ping": "Ping",
"actions": "Aktionen"
},
"errors": {
"loadFailed": "Spielerliste konnte nicht geladen werden."
"loadFailed": "Spielerliste konnte nicht geladen werden.",
"actionFailed": "Aktion fehlgeschlagen."
}
},
"worlds": {
@@ -347,6 +420,473 @@
"remove": "Entfernen",
"installed": "Installiert",
"empty": "Noch keine Add-ons installiert."
},
"schedules": {
"title": "Zeitpläne",
"description": "Geplante Start-, Stopp- und Wartungsaktionen.",
"loading": "Wird geladen…",
"empty": "Noch keine Zeitpläne vorhanden.",
"create": "Zeitplan erstellen",
"edit": "Bearbeiten",
"save": "Änderungen speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"enable": "Aktivieren",
"disable": "Deaktivieren",
"saving": "Wird gespeichert…",
"deleteConfirm": "Zeitplan \"{name}\" wirklich löschen?",
"fields": {
"name": "Name",
"cron": "Cron-Ausdruck",
"timezone": "Zeitzone",
"taskType": "Aufgabe",
"enabled": "Aktiv",
"command": "Konsolenbefehl",
"commandPlaceholder": "say Server startet neu"
},
"columns": {
"name": "Name",
"cron": "Cron",
"task": "Aufgabe",
"status": "Status",
"actions": "Aktionen"
},
"status": {
"enabled": "Aktiv",
"disabled": "Inaktiv"
},
"taskTypes": {
"START": "Server starten",
"STOP": "Server stoppen",
"RESTART": "Server neu starten",
"BACKUP": "Backup erstellen",
"CONSOLE_COMMAND": "Befehl ausführen",
"UPDATE_CHECK": "Updates prüfen",
"MAINTENANCE": "Wartung"
},
"errors": {
"loadFailed": "Zeitpläne konnten nicht geladen werden.",
"saveFailed": "Zeitplan konnte nicht gespeichert werden.",
"deleteFailed": "Zeitplan konnte nicht gelöscht werden."
}
},
"access": {
"title": "Zugriffe",
"description": "Freigaben und Berechtigungen für diesen Server.",
"loading": "Wird geladen…",
"empty": "Noch keine Mitglieder. Laden Sie jemanden ein, um diesen Server zu teilen.",
"invite": "Mitglied einladen",
"sendInvite": "Einladung senden",
"inviting": "Wird gesendet…",
"cancel": "Abbrechen",
"remove": "Entfernen",
"inviteSuccess": "Mitglied erfolgreich eingeladen.",
"invitePending": "Einladung vermerkt. Der Benutzer muss sich registrieren, bevor er auf diesen Server zugreifen kann.",
"removeConfirm": "{name} von diesem Server entfernen?",
"fields": {
"email": "E-Mail-Adresse",
"emailPlaceholder": "benutzer@beispiel.de",
"role": "Rolle"
},
"columns": {
"member": "Mitglied",
"email": "E-Mail",
"role": "Rolle",
"status": "Status",
"actions": "Aktionen"
},
"roles": {
"OWNER": "Inhaber",
"ADMIN": "Administrator",
"OPERATOR": "Operator",
"DEVELOPER": "Entwickler",
"VIEWER": "Betrachter"
},
"status": {
"active": "Aktiv",
"pending": "Ausstehend"
},
"errors": {
"loadFailed": "Mitglieder konnten nicht geladen werden.",
"inviteFailed": "Einladung konnte nicht gesendet werden.",
"removeFailed": "Mitglied konnte nicht entfernt werden."
}
}
},
"account": {
"nav": {
"label": "Kontonavigation",
"dashboard": "Dashboard",
"servers": "Server",
"billing": "Abrechnung",
"profile": "Profil",
"notifications": "Benachrichtigungen",
"security": "Sicherheit"
},
"billing": {
"title": "Abrechnung",
"subtitle": "Credits und Transaktionsverlauf.",
"loading": "Wird geladen…",
"loadError": "Abrechnungsdaten konnten nicht geladen werden.",
"balanceTitle": "Aktuelles Guthaben",
"periodStart": "Abrechnungszeitraum ab {date}",
"creditsLabel": "Verbleibende Credits",
"transactionsTitle": "Transaktionen",
"transactionsEmpty": "Noch keine Transaktionen in diesem Zeitraum.",
"columns": {
"date": "Datum",
"type": "Typ",
"amount": "Betrag",
"reference": "Referenz"
}
},
"profile": {
"title": "Profil",
"subtitle": "Ihre Kontoinformationen.",
"loading": "Wird geladen…",
"notSignedIn": "Nicht angemeldet.",
"accountInfo": "Kontodaten",
"accountInfoDescription": "Profilinformationen aus Ihrem Konto.",
"fields": {
"email": "E-Mail",
"username": "Benutzername",
"displayName": "Anzeigename",
"emailVerified": "E-Mail verifiziert",
"twoFactor": "Zwei-Faktor-Auth"
},
"yes": "Ja",
"no": "Nein",
"enabled": "Aktiv",
"disabled": "Inaktiv",
"securityHint": "Passwort, 2FA und Sitzungen verwalten unter",
"securityLink": "Sicherheit"
},
"notifications": {
"title": "Benachrichtigungen",
"subtitle": "E-Mail- und In-App-Benachrichtigungen.",
"loading": "Wird geladen…",
"listTitle": "Neueste Benachrichtigungen",
"unreadCount": "{count, plural, one {# ungelesene Benachrichtigung} other {# ungelesene Benachrichtigungen}}",
"allRead": "Alles gelesen.",
"markRead": "Als gelesen markieren",
"markAllRead": "Alle als gelesen markieren",
"emptyTitle": "Keine Benachrichtigungen",
"emptyDescription": "Sie haben noch keine Benachrichtigungen erhalten.",
"preferencesTitle": "Einstellungen",
"preferencesDescription": "Wählen Sie, welche Benachrichtigungen Sie pro Kanal erhalten.",
"enabled": "Aktiv",
"disabled": "Inaktiv",
"channels": {
"IN_APP": "In-App",
"EMAIL": "E-Mail",
"WEBHOOK": "Webhook",
"DISCORD": "Discord"
},
"types": {
"SERVER_READY": "Server bereit",
"SERVER_START_FAILED": "Serverstart fehlgeschlagen",
"SERVER_STOPPED": "Server gestoppt",
"IDLE_STOP_WARNING": "Leerlauf-Stopp-Warnung",
"BACKUP_SUCCESS": "Backup erfolgreich",
"BACKUP_FAILED": "Backup fehlgeschlagen",
"RESTORE_SUCCESS": "Wiederherstellung erfolgreich",
"RESTORE_FAILED": "Wiederherstellung fehlgeschlagen",
"CREDITS_LOW": "Wenig Credits",
"BILLING_ISSUE": "Abrechnungsproblem",
"INVITE": "Einladung",
"NODE_INCIDENT": "Node-Vorfall",
"MAINTENANCE": "Wartung",
"SECURITY": "Sicherheit",
"NEW_LOGIN": "Neue Anmeldung",
"TWO_FA_CHANGED": "Zwei-Faktor geändert"
},
"errors": {
"loadFailed": "Benachrichtigungen konnten nicht geladen werden.",
"markReadFailed": "Benachrichtigung konnte nicht aktualisiert werden.",
"preferencesFailed": "Einstellungen konnten nicht gespeichert werden."
}
}
},
"admin": {
"backToPanel": "Zurück zum Panel",
"nav": {
"label": "Admin-Navigation",
"dashboard": "Dashboard",
"nodes": "Nodes",
"users": "Benutzer",
"jobs": "Jobs",
"audit": "Audit"
},
"dashboard": {
"title": "Admin-Dashboard",
"subtitle": "Plattformweite Kennzahlen und Systemstatus.",
"loading": "Wird geladen…",
"loadError": "Dashboard-Daten konnten nicht geladen werden.",
"generatedAt": "Stand: {time}",
"stats": {
"activeUsers": "Aktive Benutzer",
"totalServers": "Server gesamt",
"runningServers": "Laufende Server",
"queueLength": "Warteschlange",
"onlineNodes": "Online-Nodes",
"failedJobs24h": "Fehlgeschlagene Jobs (24h)",
"openAbuseReports": "Offene Abuse-Reports",
"openTickets": "Offene Tickets"
}
},
"nodes": {
"title": "Game-Nodes",
"subtitle": "Node-Status und Wartungsaktionen.",
"loading": "Wird geladen…",
"loadError": "Nodes konnten nicht geladen werden.",
"empty": "Keine Nodes registriert.",
"drain": "Drain",
"maintenance": "Wartung",
"exitMaintenance": "Wartung beenden",
"actionError": "Aktion fehlgeschlagen.",
"columns": {
"name": "Node",
"status": "Status",
"servers": "Server",
"ram": "RAM",
"actions": "Aktionen"
}
},
"users": {
"title": "Benutzer",
"subtitle": "Benutzerkonten suchen und verwalten.",
"loading": "Wird geladen…",
"loadError": "Benutzer konnten nicht geladen werden.",
"empty": "Keine Benutzer gefunden.",
"searchPlaceholder": "E-Mail oder Benutzername…",
"search": "Suchen",
"suspend": "Sperren",
"unsuspend": "Entsperren",
"actionError": "Aktion fehlgeschlagen.",
"columns": {
"user": "Benutzer",
"status": "Status",
"roles": "Rollen",
"servers": "Server",
"actions": "Aktionen"
}
},
"jobs": {
"title": "Hintergrund-Jobs",
"subtitle": "Aktuelle und kürzlich abgeschlossene Queue-Jobs.",
"loading": "Wird geladen…",
"loadError": "Jobs konnten nicht geladen werden.",
"empty": "Keine Jobs gefunden.",
"columns": {
"queue": "Queue",
"job": "Job",
"status": "Status",
"attempts": "Versuche",
"created": "Erstellt"
}
},
"audit": {
"title": "Audit-Log",
"subtitle": "Plattformweite Sicherheits- und Lifecycle-Ereignisse.",
"loading": "Wird geladen…",
"loadError": "Audit-Ereignisse konnten nicht geladen werden.",
"empty": "Keine Ereignisse gefunden.",
"loadMore": "Mehr laden",
"columns": {
"time": "Zeit",
"action": "Aktion",
"user": "Benutzer",
"entity": "Entität"
}
}
},
"public": {
"pricing": {
"title": "Preise",
"description": "Transparente Tarife für Minecraft-Server-Hosting.",
"cta": "Jetzt registrieren →",
"tiers": {
"starter": {
"name": "Starter",
"description": "Ideal zum Ausprobieren.",
"price": "Kostenlos",
"features": ["2 GB RAM", "1 Server", "Idle-Shutdown", "Community-Support"]
},
"standard": {
"name": "Standard",
"description": "Für kleine Communities.",
"price": "Credits",
"features": ["4 GB RAM", "3 Server", "Backups", "Modrinth Add-ons"]
},
"pro": {
"name": "Pro",
"description": "Für anspruchsvolle Welten.",
"price": "Credits",
"features": ["8+ GB RAM", "Unbegrenzte Server*", "Prioritäts-Queue", "WHMCS-Integration"]
}
}
},
"features": {
"title": "Funktionen",
"description": "Alles für professionelles Minecraft-Server-Hosting.",
"items": {
"control": {
"title": "Zentrale Steuerung",
"description": "Lifecycle, Konsole, Dateien und Properties über ein einheitliches Panel."
},
"nodes": {
"title": "Multi-Node",
"description": "Horizontale Skalierung mit Agent-basierter Orchestrierung."
},
"security": {
"title": "Sicherheit",
"description": "Sessions, 2FA, CSRF-Schutz und auditierbare Aktionen."
},
"billing": {
"title": "Credit-Abrechnung",
"description": "Laufzeitbasierte Abrechnung mit transparentem Guthaben."
},
"backups": {
"title": "Backups & Welten",
"description": "Manuelle und geplante Backups, Welt-Upload und -Download."
},
"whmcs": {
"title": "WHMCS-Integration",
"description": "Provisioning, SSO und Abrechnung über WHMCS."
}
}
},
"status": {
"title": "Systemstatus",
"description": "Aktueller Betriebsstatus der Plattformkomponenten.",
"operational": "Betriebsbereit",
"selfHostedNote": "Status basiert auf Self-Hosted-Deployment. Konfigurieren Sie Health-Checks für Live-Monitoring.",
"components": {
"api": {
"name": "API",
"description": "REST Control Plane unter /api/v1"
},
"worker": {
"name": "Worker",
"description": "BullMQ Hintergrundverarbeitung"
},
"panel": {
"name": "Web-Panel",
"description": "Next.js Benutzeroberfläche"
},
"dns": {
"name": "DNS / Edge",
"description": "Join-to-Start und Edge-Gateway"
}
}
},
"docs": {
"title": "Dokumentation",
"description": "Einstieg, API-Referenz und Betriebshinweise.",
"openapi": "Interaktive API-Dokumentation:",
"openapiLink": "OpenAPI / Swagger",
"cta": "Konto erstellen →",
"sections": {
"gettingStarted": {
"title": "Erste Schritte",
"body": "Registrieren Sie sich, verifizieren Sie Ihre E-Mail, erstellen Sie einen Server über den mehrstufigen Assistenten und starten Sie ihn über das Dashboard."
},
"api": {
"title": "API",
"body": "Die REST-API unter /api/v1 bietet Endpunkte für Auth, Server-Lifecycle, Dateien, Backups und Admin-Funktionen. Authentifizierung erfolgt über Session-Cookies."
},
"operations": {
"title": "Betrieb",
"body": "Deployen Sie Control Plane, Worker und Node-Agent über Docker Compose oder Ansible. Siehe docs/operations im Repository."
},
"whmcs": {
"title": "WHMCS",
"body": "Das Server-Modul provisioniert Dienste automatisch. SSO ermöglicht nahtlosen Zugang aus dem WHMCS Client Area."
}
}
},
"impressum": {
"title": "Impressum",
"description": "Angaben gemäß § 5 TMG.",
"notice": "Dies ist ein Impressums-Entwurf. Ersetzen Sie die Platzhalterangaben vor dem Produktivbetrieb.",
"sections": {
"operator": {
"title": "Anbieter",
"body": "HexaHost\nPlattform für Game-Server-Hosting"
},
"address": {
"title": "Anschrift",
"body": "HexaHost\nMusterstraße 1\n12345 Musterstadt\nDeutschland"
},
"contact": {
"title": "Kontakt",
"body": "E-Mail: kontakt@hexahost.example\nTelefon: +49 (0) 123 456789"
},
"responsible": {
"title": "Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV",
"body": "Max Mustermann\nHexaHost\nMusterstraße 1\n12345 Musterstadt"
},
"register": {
"title": "Registereintrag",
"body": "Registergericht: Amtsgericht Musterstadt\nRegisternummer: HRB 12345\nUSt-IdNr.: DE123456789"
},
"dispute": {
"title": "Streitbeilegung",
"body": "Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen."
}
}
},
"privacy": {
"title": "Datenschutzerklärung",
"description": "Informationen zur Verarbeitung personenbezogener Daten.",
"sections": {
"controller": {
"title": "Verantwortlicher",
"body": "Der Betreiber dieser selbstgehosteten Instanz ist für die Datenverarbeitung verantwortlich. Passen Sie diese Erklärung an Ihre Organisation an."
},
"data": {
"title": "Erhobene Daten",
"body": "Wir verarbeiten Kontodaten (E-Mail, Benutzername), Sitzungsinformationen, Server-Metadaten und Nutzungsdaten zur Abrechnung."
},
"cookies": {
"title": "Cookies",
"body": "Session-Cookies (hgc_session) und CSRF-Cookies (hgc_csrf) sind für die Authentifizierung erforderlich."
},
"rights": {
"title": "Ihre Rechte",
"body": "Sie haben das Recht auf Auskunft, Berichtigung, Löschung und Datenübertragbarkeit gemäß DSGVO."
},
"contact": {
"title": "Kontakt",
"body": "Wenden Sie sich für Datenschutzanfragen an den in Ihrem Impressum genannten Betreiber."
}
}
},
"terms": {
"title": "Allgemeine Geschäftsbedingungen",
"description": "Nutzungsbedingungen der Plattform.",
"sections": {
"scope": {
"title": "Geltungsbereich",
"body": "Diese AGB gelten für die Nutzung der Minecraft-Server-Hosting-Plattform durch registrierte Benutzer."
},
"usage": {
"title": "Nutzung",
"body": "Server dürfen nur für legale Zwecke betrieben werden. Missbrauch kann zur Sperrung führen."
},
"billing": {
"title": "Abrechnung",
"body": "Credits werden nach Laufzeit verbraucht. Guthaben ist nicht übertragbar, sofern nicht anders vereinbart."
},
"liability": {
"title": "Haftung",
"body": "Der Betreiber haftet nur bei Vorsatz und grober Fahrlässigkeit. Datenverluste sind durch eigene Backups abzusichern."
},
"changes": {
"title": "Änderungen",
"body": "Der Betreiber kann diese Bedingungen mit angemessener Frist ändern. Fortgesetzte Nutzung gilt als Zustimmung."
}
}
}
}
}

View File

@@ -15,10 +15,19 @@
"cancel": "Cancel",
"back": "Back",
"footer": "Self-hosted Minecraft server platform.",
"servers": "Servers"
"servers": "Servers",
"features": "Features",
"pricing": "Pricing",
"docs": "Documentation",
"status": "Status",
"impressum": "Legal notice",
"privacy": "Privacy",
"terms": "Terms",
"admin": "Admin",
"phaseBadge": "MVP · Phases 09"
},
"landing": {
"badge": "Phase 0 · Control Plane",
"badge": "MVP · Phase 9 complete",
"title": "Minecraft servers on demand",
"subtitle": "Create, configure, and operate Minecraft servers through a central web panel — multi-tenant, scalable, and production-ready.",
"ctaPrimary": "Get started",
@@ -205,7 +214,36 @@
"players": "Players",
"worlds": "Worlds",
"backups": "Backups",
"addons": "Add-ons"
"addons": "Add-ons",
"software": "Software",
"schedules": "Schedules",
"access": "Access"
},
"wizard": {
"stepsLabel": "Creation steps",
"steps": {
"basics": "Basics",
"software": "Software",
"review": "Review"
},
"next": "Next",
"back": "Back"
},
"software": {
"title": "Software",
"description": "Installed server software and version.",
"loading": "Loading…",
"loadError": "Could not load software information.",
"family": "Software family",
"version": "Minecraft version",
"edition": "Edition",
"changeHint": "Software switching will be supported in a future release.",
"reinstall": "Reinstall software",
"reinstalling": "Reinstalling…",
"reinstallConfirm": "Reinstall server software? A backup will be created first.",
"reinstallStarted": "Reinstall started. The server will be unavailable during installation.",
"reinstallError": "Could not start reinstall.",
"reinstallStoppedHint": "Stop the server before reinstalling software."
},
"console": {
"loading": "Loading…",
@@ -233,10 +271,27 @@
"save": "Save",
"saving": "Saving…",
"saved": "File saved.",
"upload": "Upload",
"uploading": "Uploading…",
"uploaded": "File uploaded.",
"delete": "Delete",
"deleted": "Deleted.",
"deleteConfirm": "Delete {name}?",
"archive": "Archive selected",
"archiving": "Archiving…",
"archived": "Archive created.",
"unarchive": "Extract archive",
"unarchiving": "Extracting…",
"unarchived": "Archive extraction started.",
"unarchiveConfirm": "Extract {name} into the current directory?",
"errors": {
"loadFailed": "Could not load directory.",
"readFailed": "Could not read file.",
"saveFailed": "Could not save file."
"saveFailed": "Could not save file.",
"deleteFailed": "Could not delete file.",
"uploadFailed": "Could not upload file.",
"archiveFailed": "Could not create archive.",
"unarchiveFailed": "Could not extract archive."
}
},
"properties": {
@@ -280,14 +335,32 @@
"loading": "Loading…",
"onlineCount": "Online players",
"whitelistCount": "Whitelist entries",
"operatorsCount": "Operators",
"onlineTitle": "Online players",
"whitelistTitle": "Whitelist",
"operatorsTitle": "Operators",
"bansTitle": "Banned players",
"bansEmpty": "No banned players.",
"empty": "No players online.",
"retry": "Retry",
"kick": "Kick",
"addWhitelist": "Add to whitelist",
"addOperator": "Add operator",
"addBan": "Ban player",
"remove": "Remove",
"fields": {
"name": "Player name",
"uuid": "UUID",
"reason": "Reason"
},
"columns": {
"name": "Player",
"ping": "Ping"
"ping": "Ping",
"actions": "Actions"
},
"errors": {
"loadFailed": "Could not load player list."
"loadFailed": "Could not load player list.",
"actionFailed": "Action failed."
}
},
"worlds": {
@@ -347,6 +420,473 @@
"remove": "Remove",
"installed": "Installed",
"empty": "No add-ons installed yet."
},
"schedules": {
"title": "Schedules",
"description": "Scheduled start, stop, and maintenance actions.",
"loading": "Loading…",
"empty": "No schedules yet.",
"create": "Create schedule",
"edit": "Edit",
"save": "Save changes",
"cancel": "Cancel",
"delete": "Delete",
"enable": "Enable",
"disable": "Disable",
"saving": "Saving…",
"deleteConfirm": "Delete schedule \"{name}\"?",
"fields": {
"name": "Name",
"cron": "Cron expression",
"timezone": "Timezone",
"taskType": "Task",
"enabled": "Enabled",
"command": "Console command",
"commandPlaceholder": "say Server restarting"
},
"columns": {
"name": "Name",
"cron": "Cron",
"task": "Task",
"status": "Status",
"actions": "Actions"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"taskTypes": {
"START": "Start server",
"STOP": "Stop server",
"RESTART": "Restart server",
"BACKUP": "Create backup",
"CONSOLE_COMMAND": "Run command",
"UPDATE_CHECK": "Check for updates",
"MAINTENANCE": "Maintenance"
},
"errors": {
"loadFailed": "Could not load schedules.",
"saveFailed": "Could not save schedule.",
"deleteFailed": "Could not delete schedule."
}
},
"access": {
"title": "Access",
"description": "Sharing and permissions for this server.",
"loading": "Loading…",
"empty": "No members yet. Invite someone to share this server.",
"invite": "Invite member",
"sendInvite": "Send invite",
"inviting": "Sending…",
"cancel": "Cancel",
"remove": "Remove",
"inviteSuccess": "Member invited successfully.",
"invitePending": "Invite noted. The user must register before they can access this server.",
"removeConfirm": "Remove {name} from this server?",
"fields": {
"email": "Email address",
"emailPlaceholder": "user@example.com",
"role": "Role"
},
"columns": {
"member": "Member",
"email": "Email",
"role": "Role",
"status": "Status",
"actions": "Actions"
},
"roles": {
"OWNER": "Owner",
"ADMIN": "Admin",
"OPERATOR": "Operator",
"DEVELOPER": "Developer",
"VIEWER": "Viewer"
},
"status": {
"active": "Active",
"pending": "Pending"
},
"errors": {
"loadFailed": "Could not load members.",
"inviteFailed": "Could not send invite.",
"removeFailed": "Could not remove member."
}
}
},
"account": {
"nav": {
"label": "Account navigation",
"dashboard": "Dashboard",
"servers": "Servers",
"billing": "Billing",
"profile": "Profile",
"notifications": "Notifications",
"security": "Security"
},
"billing": {
"title": "Billing",
"subtitle": "Credits and transaction history.",
"loading": "Loading…",
"loadError": "Could not load billing data.",
"balanceTitle": "Current balance",
"periodStart": "Billing period from {date}",
"creditsLabel": "Credits remaining",
"transactionsTitle": "Transactions",
"transactionsEmpty": "No transactions in this period yet.",
"columns": {
"date": "Date",
"type": "Type",
"amount": "Amount",
"reference": "Reference"
}
},
"profile": {
"title": "Profile",
"subtitle": "Your account information.",
"loading": "Loading…",
"notSignedIn": "Not signed in.",
"accountInfo": "Account details",
"accountInfoDescription": "Profile information from your account.",
"fields": {
"email": "Email",
"username": "Username",
"displayName": "Display name",
"emailVerified": "Email verified",
"twoFactor": "Two-factor auth"
},
"yes": "Yes",
"no": "No",
"enabled": "Enabled",
"disabled": "Disabled",
"securityHint": "Manage password, 2FA, and sessions at",
"securityLink": "Security"
},
"notifications": {
"title": "Notifications",
"subtitle": "Email and in-app notifications.",
"loading": "Loading…",
"listTitle": "Recent notifications",
"unreadCount": "{count, plural, one {# unread notification} other {# unread notifications}}",
"allRead": "All caught up.",
"markRead": "Mark read",
"markAllRead": "Mark all read",
"emptyTitle": "No notifications",
"emptyDescription": "You have not received any notifications yet.",
"preferencesTitle": "Preferences",
"preferencesDescription": "Choose which notifications you receive on each channel.",
"enabled": "Enabled",
"disabled": "Disabled",
"channels": {
"IN_APP": "In-app",
"EMAIL": "Email",
"WEBHOOK": "Webhook",
"DISCORD": "Discord"
},
"types": {
"SERVER_READY": "Server ready",
"SERVER_START_FAILED": "Server start failed",
"SERVER_STOPPED": "Server stopped",
"IDLE_STOP_WARNING": "Idle stop warning",
"BACKUP_SUCCESS": "Backup succeeded",
"BACKUP_FAILED": "Backup failed",
"RESTORE_SUCCESS": "Restore succeeded",
"RESTORE_FAILED": "Restore failed",
"CREDITS_LOW": "Low credits",
"BILLING_ISSUE": "Billing issue",
"INVITE": "Invite",
"NODE_INCIDENT": "Node incident",
"MAINTENANCE": "Maintenance",
"SECURITY": "Security",
"NEW_LOGIN": "New login",
"TWO_FA_CHANGED": "Two-factor changed"
},
"errors": {
"loadFailed": "Could not load notifications.",
"markReadFailed": "Could not update notification.",
"preferencesFailed": "Could not save preferences."
}
}
},
"admin": {
"backToPanel": "Back to panel",
"nav": {
"label": "Admin navigation",
"dashboard": "Dashboard",
"nodes": "Nodes",
"users": "Users",
"jobs": "Jobs",
"audit": "Audit"
},
"dashboard": {
"title": "Admin dashboard",
"subtitle": "Platform-wide metrics and system status.",
"loading": "Loading…",
"loadError": "Could not load dashboard data.",
"generatedAt": "As of {time}",
"stats": {
"activeUsers": "Active users",
"totalServers": "Total servers",
"runningServers": "Running servers",
"queueLength": "Queue length",
"onlineNodes": "Online nodes",
"failedJobs24h": "Failed jobs (24h)",
"openAbuseReports": "Open abuse reports",
"openTickets": "Open tickets"
}
},
"nodes": {
"title": "Game nodes",
"subtitle": "Node status and maintenance actions.",
"loading": "Loading…",
"loadError": "Could not load nodes.",
"empty": "No nodes registered.",
"drain": "Drain",
"maintenance": "Maintenance",
"exitMaintenance": "Exit maintenance",
"actionError": "Action failed.",
"columns": {
"name": "Node",
"status": "Status",
"servers": "Servers",
"ram": "RAM",
"actions": "Actions"
}
},
"users": {
"title": "Users",
"subtitle": "Search and manage user accounts.",
"loading": "Loading…",
"loadError": "Could not load users.",
"empty": "No users found.",
"searchPlaceholder": "Email or username…",
"search": "Search",
"suspend": "Suspend",
"unsuspend": "Unsuspend",
"actionError": "Action failed.",
"columns": {
"user": "User",
"status": "Status",
"roles": "Roles",
"servers": "Servers",
"actions": "Actions"
}
},
"jobs": {
"title": "Background jobs",
"subtitle": "Current and recently completed queue jobs.",
"loading": "Loading…",
"loadError": "Could not load jobs.",
"empty": "No jobs found.",
"columns": {
"queue": "Queue",
"job": "Job",
"status": "Status",
"attempts": "Attempts",
"created": "Created"
}
},
"audit": {
"title": "Audit log",
"subtitle": "Platform-wide security and lifecycle events.",
"loading": "Loading…",
"loadError": "Could not load audit events.",
"empty": "No events found.",
"loadMore": "Load more",
"columns": {
"time": "Time",
"action": "Action",
"user": "User",
"entity": "Entity"
}
}
},
"public": {
"pricing": {
"title": "Pricing",
"description": "Transparent plans for Minecraft server hosting.",
"cta": "Register now →",
"tiers": {
"starter": {
"name": "Starter",
"description": "Great for trying things out.",
"price": "Free",
"features": ["2 GB RAM", "1 server", "Idle shutdown", "Community support"]
},
"standard": {
"name": "Standard",
"description": "For small communities.",
"price": "Credits",
"features": ["4 GB RAM", "3 servers", "Backups", "Modrinth add-ons"]
},
"pro": {
"name": "Pro",
"description": "For demanding worlds.",
"price": "Credits",
"features": ["8+ GB RAM", "Unlimited servers*", "Priority queue", "WHMCS integration"]
}
}
},
"features": {
"title": "Features",
"description": "Everything for professional Minecraft server hosting.",
"items": {
"control": {
"title": "Central control",
"description": "Lifecycle, console, files, and properties through a unified panel."
},
"nodes": {
"title": "Multi-node",
"description": "Horizontal scaling with agent-based orchestration."
},
"security": {
"title": "Security",
"description": "Sessions, 2FA, CSRF protection, and auditable actions."
},
"billing": {
"title": "Credit billing",
"description": "Runtime-based billing with transparent balance."
},
"backups": {
"title": "Backups & worlds",
"description": "Manual and scheduled backups, world upload and download."
},
"whmcs": {
"title": "WHMCS integration",
"description": "Provisioning, SSO, and billing through WHMCS."
}
}
},
"status": {
"title": "System status",
"description": "Current operational status of platform components.",
"operational": "Operational",
"selfHostedNote": "Status reflects self-hosted deployment. Configure health checks for live monitoring.",
"components": {
"api": {
"name": "API",
"description": "REST control plane at /api/v1"
},
"worker": {
"name": "Worker",
"description": "BullMQ background processing"
},
"panel": {
"name": "Web panel",
"description": "Next.js user interface"
},
"dns": {
"name": "DNS / Edge",
"description": "Join-to-start and edge gateway"
}
}
},
"docs": {
"title": "Documentation",
"description": "Getting started, API reference, and operations guides.",
"openapi": "Interactive API documentation:",
"openapiLink": "OpenAPI / Swagger",
"cta": "Create account →",
"sections": {
"gettingStarted": {
"title": "Getting started",
"body": "Register, verify your email, create a server through the multi-step wizard, and start it from the dashboard."
},
"api": {
"title": "API",
"body": "The REST API at /api/v1 provides endpoints for auth, server lifecycle, files, backups, and admin functions. Authentication uses session cookies."
},
"operations": {
"title": "Operations",
"body": "Deploy control plane, worker, and node agent via Docker Compose or Ansible. See docs/operations in the repository."
},
"whmcs": {
"title": "WHMCS",
"body": "The server module provisions services automatically. SSO enables seamless access from the WHMCS client area."
}
}
},
"impressum": {
"title": "Legal notice",
"description": "Information according to applicable law (German Impressum).",
"notice": "This is a template legal notice. Replace placeholder details before production use.",
"sections": {
"operator": {
"title": "Service provider",
"body": "HexaHost\nGame server hosting platform"
},
"address": {
"title": "Address",
"body": "HexaHost\nMusterstraße 1\n12345 Musterstadt\nGermany"
},
"contact": {
"title": "Contact",
"body": "Email: kontakt@hexahost.example\nPhone: +49 (0) 123 456789"
},
"responsible": {
"title": "Responsible for content (§ 55 Abs. 2 RStV)",
"body": "Max Mustermann\nHexaHost\nMusterstraße 1\n12345 Musterstadt"
},
"register": {
"title": "Commercial register",
"body": "Register court: Amtsgericht Musterstadt\nRegistration number: HRB 12345\nVAT ID: DE123456789"
},
"dispute": {
"title": "Dispute resolution",
"body": "We are not obliged or willing to participate in dispute resolution proceedings before a consumer arbitration board."
}
}
},
"privacy": {
"title": "Privacy policy",
"description": "Information on processing personal data.",
"sections": {
"controller": {
"title": "Controller",
"body": "The operator of this self-hosted instance is responsible for data processing. Adapt this policy to your organization."
},
"data": {
"title": "Data collected",
"body": "We process account data (email, username), session information, server metadata, and usage data for billing."
},
"cookies": {
"title": "Cookies",
"body": "Session cookies (hgc_session) and CSRF cookies (hgc_csrf) are required for authentication."
},
"rights": {
"title": "Your rights",
"body": "You have the right to access, rectify, delete, and port your data under applicable privacy law."
},
"contact": {
"title": "Contact",
"body": "For privacy requests, contact the operator listed in your legal notice."
}
}
},
"terms": {
"title": "Terms of service",
"description": "Terms of use for the platform.",
"sections": {
"scope": {
"title": "Scope",
"body": "These terms apply to use of the Minecraft server hosting platform by registered users."
},
"usage": {
"title": "Usage",
"body": "Servers may only be operated for lawful purposes. Abuse may result in suspension."
},
"billing": {
"title": "Billing",
"body": "Credits are consumed based on runtime. Balance is non-transferable unless otherwise agreed."
},
"liability": {
"title": "Liability",
"body": "The operator is liable only for intent and gross negligence. Protect against data loss with your own backups."
},
"changes": {
"title": "Changes",
"body": "The operator may change these terms with reasonable notice. Continued use constitutes acceptance."
}
}
}
}
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { BillingContent } from "@/components/account/billing-content";
interface BillingPageProps {
params: Promise<{ locale: string }>;
}
export default async function BillingPage({ params }: BillingPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <BillingContent />;
}

View File

@@ -0,0 +1,24 @@
import { setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { AccountNav } from "@/components/layout/account-nav";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
interface AccountLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function AccountLayout({ children, params }: AccountLayoutProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<AccountNav />
<DashboardUserInfo />
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { NotificationsContent } from "@/components/account/notifications-content";
interface NotificationsPageProps {
params: Promise<{ locale: string }>;
}
export default async function NotificationsPage({ params }: NotificationsPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <NotificationsContent />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ProfileContent } from "@/components/account/profile-content";
interface ProfilePageProps {
params: Promise<{ locale: string }>;
}
export default async function ProfilePage({ params }: ProfilePageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <ProfileContent />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { AdminAuditContent } from "@/components/admin/admin-audit-content";
interface AdminAuditPageProps {
params: Promise<{ locale: string }>;
}
export default async function AdminAuditPage({ params }: AdminAuditPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <AdminAuditContent />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { AdminJobsContent } from "@/components/admin/admin-jobs-content";
interface AdminJobsPageProps {
params: Promise<{ locale: string }>;
}
export default async function AdminJobsPage({ params }: AdminJobsPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <AdminJobsContent />;
}

View File

@@ -0,0 +1,34 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { AdminNav } from "@/components/layout/admin-nav";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
import { Link } from "@/i18n/navigation";
interface AdminLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function AdminLayout({ children, params }: AdminLayoutProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("admin");
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-2">
<Link
href="/dashboard"
className="text-xs font-medium text-sky-600 hover:underline dark:text-sky-400"
>
{t("backToPanel")}
</Link>
</div>
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<AdminNav />
<DashboardUserInfo />
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { AdminNodesContent } from "@/components/admin/admin-nodes-content";
interface AdminNodesPageProps {
params: Promise<{ locale: string }>;
}
export default async function AdminNodesPage({ params }: AdminNodesPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <AdminNodesContent />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { AdminDashboardContent } from "@/components/admin/admin-dashboard-content";
interface AdminPageProps {
params: Promise<{ locale: string }>;
}
export default async function AdminPage({ params }: AdminPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <AdminDashboardContent />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { AdminUsersContent } from "@/components/admin/admin-users-content";
interface AdminUsersPageProps {
params: Promise<{ locale: string }>;
}
export default async function AdminUsersPage({ params }: AdminUsersPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <AdminUsersContent />;
}

View File

@@ -1,6 +1,6 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
import { AccountNav } from "@/components/layout/account-nav";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
interface DashboardLayoutProps {
@@ -14,32 +14,11 @@ export default async function DashboardLayout({
}: DashboardLayoutProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("common");
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="Dashboard navigation">
<ul className="flex gap-4 text-sm">
<li>
<Link
href="/dashboard"
className="font-medium text-zinc-900 dark:text-zinc-50"
aria-current="page"
>
{t("dashboard")}
</Link>
</li>
<li>
<Link
href="/servers"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
{t("servers")}
</Link>
</li>
</ul>
</nav>
<AccountNav />
<DashboardUserInfo />
</div>
{children}

View File

@@ -0,0 +1,48 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
import { Link } from "@/i18n/navigation";
import { getApiUrl } from "@/lib/env";
interface DocsPageProps {
params: Promise<{ locale: string }>;
}
export default async function DocsPage({ params }: DocsPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.docs");
const apiUrl = getApiUrl();
const sections = ["gettingStarted", "api", "operations", "whmcs"] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="space-y-6">
{sections.map((section) => (
<section key={section}>
<h2 className="text-lg font-medium text-zinc-900 dark:text-zinc-50">
{t(`sections.${section}.title`)}
</h2>
<p className="mt-2 text-sm">{t(`sections.${section}.body`)}</p>
</section>
))}
<p className="text-sm">
{t("openapi")}{" "}
<a
href={`${apiUrl}/docs`}
className="font-medium text-sky-600 hover:underline dark:text-sky-400"
target="_blank"
rel="noopener noreferrer"
>
{t("openapiLink")}
</a>
</p>
<p className="text-sm">
<Link href="/register" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("cta")}
</Link>
</p>
</div>
</PublicPageShell>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
interface FeaturesPageProps {
params: Promise<{ locale: string }>;
}
export default async function FeaturesPage({ params }: FeaturesPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.features");
const items = ["control", "nodes", "security", "billing", "backups", "whmcs"] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="grid gap-6 md:grid-cols-2">
{items.map((item) => (
<Card key={item}>
<CardHeader>
<CardTitle>{t(`items.${item}.title`)}</CardTitle>
<CardDescription>{t(`items.${item}.description`)}</CardDescription>
</CardHeader>
</Card>
))}
</div>
</PublicPageShell>
);
}

View File

@@ -0,0 +1,37 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
interface ImpressumPageProps {
params: Promise<{ locale: string }>;
}
export default async function ImpressumPage({ params }: ImpressumPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.impressum");
const sections = [
"operator",
"address",
"contact",
"responsible",
"register",
"dispute",
] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="space-y-6 text-sm">
{sections.map((section) => (
<section key={section}>
<h2 className="text-base font-medium text-zinc-900 dark:text-zinc-50">
{t(`sections.${section}.title`)}
</h2>
<p className="mt-2 whitespace-pre-line">{t(`sections.${section}.body`)}</p>
</section>
))}
<p className="text-zinc-500">{t("notice")}</p>
</div>
</PublicPageShell>
);
}

View File

@@ -0,0 +1,51 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
import { Link } from "@/i18n/navigation";
interface PricingPageProps {
params: Promise<{ locale: string }>;
}
export default async function PricingPage({ params }: PricingPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.pricing");
const tiers = ["starter", "standard", "pro"] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="grid gap-6 md:grid-cols-3">
{tiers.map((tier) => (
<Card key={tier}>
<CardHeader>
<CardTitle>{t(`tiers.${tier}.name`)}</CardTitle>
<CardDescription>{t(`tiers.${tier}.description`)}</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{t(`tiers.${tier}.price`)}
</p>
<ul className="mt-4 space-y-2 text-sm">
{(t.raw(`tiers.${tier}.features`) as string[]).map((feature) => (
<li key={feature} className="flex gap-2">
<span className="text-sky-600 dark:text-sky-400" aria-hidden="true">
</span>
{feature}
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
<p className="text-center text-sm">
<Link href="/register" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("cta")}
</Link>
</p>
</PublicPageShell>
);
}

View File

@@ -0,0 +1,29 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
interface PrivacyPageProps {
params: Promise<{ locale: string }>;
}
export default async function PrivacyPage({ params }: PrivacyPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.privacy");
const sections = ["controller", "data", "cookies", "rights", "contact"] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="space-y-6 text-sm">
{sections.map((section) => (
<section key={section}>
<h2 className="text-base font-medium text-zinc-900 dark:text-zinc-50">
{t(`sections.${section}.title`)}
</h2>
<p className="mt-2">{t(`sections.${section}.body`)}</p>
</section>
))}
</div>
</PublicPageShell>
);
}

View File

@@ -1,5 +1,7 @@
import { setRequestLocale } from "next-intl/server";
import { SecurityContent } from "@/components/auth/security-content";
import { AccountNav } from "@/components/layout/account-nav";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
interface SecurityPageProps {
params: Promise<{ locale: string }>;
@@ -11,6 +13,10 @@ export default async function SecurityPage({ params }: SecurityPageProps) {
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<AccountNav />
<DashboardUserInfo />
</div>
<SecurityContent />
</div>
);

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerAccess } from "@/components/servers/server-access";
interface ServerAccessPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerAccessPage({ params }: ServerAccessPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerAccess serverId={id} />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerSchedules } from "@/components/servers/server-schedules";
interface ServerSchedulesPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerSchedulesPage({ params }: ServerSchedulesPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerSchedules serverId={id} />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerSoftware } from "@/components/servers/server-software";
interface ServerSoftwarePageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerSoftwarePage({ params }: ServerSoftwarePageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerSoftware serverId={id} />;
}

View File

@@ -1,6 +1,6 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
import { AccountNav } from "@/components/layout/account-nav";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
interface ServersLayoutProps {
@@ -11,32 +11,11 @@ interface ServersLayoutProps {
export default async function ServersLayout({ children, params }: ServersLayoutProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("common");
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="App navigation">
<ul className="flex gap-4 text-sm">
<li>
<Link
href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
{t("dashboard")}
</Link>
</li>
<li>
<Link
href="/servers"
className="font-medium text-zinc-900 dark:text-zinc-50"
aria-current="page"
>
{t("servers")}
</Link>
</li>
</ul>
</nav>
<AccountNav />
<DashboardUserInfo />
</div>
{children}

View File

@@ -0,0 +1,38 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
interface StatusPageProps {
params: Promise<{ locale: string }>;
}
export default async function StatusPage({ params }: StatusPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.status");
const components = ["api", "worker", "panel", "dns"] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="space-y-4">
{components.map((component) => (
<Card key={component}>
<CardHeader className="flex flex-row items-center justify-between gap-4 pb-2">
<div>
<CardTitle className="text-base">{t(`components.${component}.name`)}</CardTitle>
<CardDescription>{t(`components.${component}.description`)}</CardDescription>
</div>
<span className="inline-flex rounded-full bg-emerald-100 px-3 py-1 font-mono text-xs text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
{t("operational")}
</span>
</CardHeader>
<CardContent>
<p className="font-mono text-xs text-zinc-500">{t("selfHostedNote")}</p>
</CardContent>
</Card>
))}
</div>
</PublicPageShell>
);
}

View File

@@ -0,0 +1,29 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
interface TermsPageProps {
params: Promise<{ locale: string }>;
}
export default async function TermsPage({ params }: TermsPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.terms");
const sections = ["scope", "usage", "billing", "liability", "changes"] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="space-y-6 text-sm">
{sections.map((section) => (
<section key={section}>
<h2 className="text-base font-medium text-zinc-900 dark:text-zinc-50">
{t(`sections.${section}.title`)}
</h2>
<p className="mt-2">{t(`sections.${section}.body`)}</p>
</section>
))}
</div>
</PublicPageShell>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { getWallet } from "@/lib/api/billing";
export function BillingContent() {
const t = useTranslations("account.billing");
const { data, isLoading, isError } = useQuery({
queryKey: ["wallet"],
queryFn: getWallet,
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError || !data) {
return <p className="text-sm text-red-600 dark:text-red-400">{t("loadError")}</p>;
}
const transactions = data.transactions ?? [];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
</div>
<Card>
<CardHeader>
<CardTitle>{t("balanceTitle")}</CardTitle>
<CardDescription>
{t("periodStart", {
date: new Date(data.periodStart).toLocaleDateString(),
})}
</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-3xl font-semibold text-zinc-900 dark:text-zinc-50">
{data.balance}
</p>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("creditsLabel")}</p>
</CardContent>
</Card>
<section>
<h2 className="mb-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
{t("transactionsTitle")}
</h2>
{transactions.length === 0 ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("transactionsEmpty")}</p>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 font-medium">{t("columns.date")}</th>
<th className="px-4 py-3 font-medium">{t("columns.type")}</th>
<th className="px-4 py-3 font-medium">{t("columns.amount")}</th>
<th className="px-4 py-3 font-medium">{t("columns.reference")}</th>
</tr>
</thead>
<tbody>
{transactions.map((tx) => (
<tr
key={tx.id}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800"
>
<td className="px-4 py-3 font-mono text-xs">
{new Date(tx.createdAt).toLocaleString()}
</td>
<td className="px-4 py-3 font-mono text-xs">{tx.type}</td>
<td className="px-4 py-3 font-mono">{tx.amount}</td>
<td className="px-4 py-3 font-mono text-xs">
{tx.referenceType ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,217 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { ApiError } from "@/lib/api/auth";
import {
getNotificationPreferences,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
updateNotificationPreferences,
type Notification,
type NotificationPreference,
} from "@/lib/api/notifications";
function formatDate(value: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export function NotificationsContent() {
const t = useTranslations("account.notifications");
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: ["notifications"],
queryFn: listNotifications,
});
const { data: preferencesData } = useQuery({
queryKey: ["notification-preferences"],
queryFn: getNotificationPreferences,
});
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["notifications"] });
void queryClient.invalidateQueries({ queryKey: ["notification-preferences"] });
};
const markReadMutation = useMutation({
mutationFn: markNotificationRead,
onSuccess: () => {
setError(null);
invalidate();
},
onError: (err: unknown) => {
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.markReadFailed"));
},
});
const markAllReadMutation = useMutation({
mutationFn: markAllNotificationsRead,
onSuccess: () => {
setError(null);
invalidate();
},
onError: (err: unknown) => {
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.markReadFailed"));
},
});
const preferencesMutation = useMutation({
mutationFn: (preferences: NotificationPreference[]) =>
updateNotificationPreferences(preferences),
onSuccess: () => {
setError(null);
invalidate();
},
onError: (err: unknown) => {
setError(
err instanceof ApiError ? (err.detail ?? err.title) : t("errors.preferencesFailed"),
);
},
});
function togglePreference(pref: NotificationPreference) {
const current = preferencesData?.preferences ?? [];
const next = current.some(
(p) => p.type === pref.type && p.channel === pref.channel,
)
? current.map((p) =>
p.type === pref.type && p.channel === pref.channel
? { ...p, enabled: !p.enabled }
: p,
)
: [...current, { ...pref, enabled: false }];
preferencesMutation.mutate(next);
}
const notifications = data?.notifications ?? [];
const unreadCount = data?.unreadCount ?? 0;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
</div>
{unreadCount > 0 ? (
<Button
size="sm"
variant="secondary"
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
>
{t("markAllRead")}
</Button>
) : null}
</div>
{error ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
<Card>
<CardHeader>
<CardTitle>{t("listTitle")}</CardTitle>
<CardDescription>
{unreadCount > 0 ? t("unreadCount", { count: unreadCount }) : t("allRead")}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
) : isError ? (
<p className="text-sm text-red-600 dark:text-red-400">{t("errors.loadFailed")}</p>
) : notifications.length === 0 ? (
<div>
<p className="font-medium text-zinc-900 dark:text-zinc-100">{t("emptyTitle")}</p>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{t("emptyDescription")}
</p>
</div>
) : (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{notifications.map((notification: Notification) => (
<li
key={notification.id}
className={`flex flex-wrap items-start justify-between gap-3 py-4 ${
notification.readAt ? "opacity-70" : ""
}`}
>
<div className="min-w-0 flex-1">
<p className="font-medium text-zinc-900 dark:text-zinc-100">
{notification.title}
</p>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{notification.body}
</p>
<p className="mt-2 text-xs text-zinc-500">
{formatDate(notification.createdAt)} · {t(`types.${notification.type}`)}
</p>
</div>
{!notification.readAt ? (
<Button
size="sm"
variant="secondary"
onClick={() => markReadMutation.mutate(notification.id)}
disabled={markReadMutation.isPending}
>
{t("markRead")}
</Button>
) : null}
</li>
))}
</ul>
)}
</CardContent>
</Card>
{(preferencesData?.preferences ?? []).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>{t("preferencesTitle")}</CardTitle>
<CardDescription>{t("preferencesDescription")}</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{preferencesData!.preferences.map((pref) => (
<li
key={`${pref.type}-${pref.channel}`}
className="flex flex-wrap items-center justify-between gap-3 py-3"
>
<div>
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t(`types.${pref.type}`)}
</p>
<p className="text-xs text-zinc-500">{t(`channels.${pref.channel}`)}</p>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={pref.enabled}
onChange={() => togglePreference(pref)}
disabled={preferencesMutation.isPending}
/>
{pref.enabled ? t("enabled") : t("disabled")}
</label>
</li>
))}
</ul>
</CardContent>
</Card>
) : null}
</div>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { useAuth } from "@/components/providers/auth-provider";
export function ProfileContent() {
const t = useTranslations("account.profile");
const { user, isLoading } = useAuth();
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (!user) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("notSignedIn")}</p>;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
</div>
<Card>
<CardHeader>
<CardTitle>{t("accountInfo")}</CardTitle>
<CardDescription>{t("accountInfoDescription")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between gap-4">
<span className="text-zinc-600 dark:text-zinc-400">{t("fields.email")}</span>
<span className="font-mono text-zinc-900 dark:text-zinc-50">{user.email}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-600 dark:text-zinc-400">{t("fields.username")}</span>
<span className="font-mono text-zinc-900 dark:text-zinc-50">{user.username}</span>
</div>
{user.displayName ? (
<div className="flex justify-between gap-4">
<span className="text-zinc-600 dark:text-zinc-400">{t("fields.displayName")}</span>
<span className="text-zinc-900 dark:text-zinc-50">{user.displayName}</span>
</div>
) : null}
<div className="flex justify-between gap-4">
<span className="text-zinc-600 dark:text-zinc-400">{t("fields.emailVerified")}</span>
<span className="font-mono text-zinc-900 dark:text-zinc-50">
{user.emailVerified || user.emailVerifiedAt ? t("yes") : t("no")}
</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-600 dark:text-zinc-400">{t("fields.twoFactor")}</span>
<span className="font-mono text-zinc-900 dark:text-zinc-50">
{user.twoFactorEnabled ? t("enabled") : t("disabled")}
</span>
</div>
</CardContent>
</Card>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{t("securityHint")}{" "}
<Link href="/security" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("securityLink")}
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { listAdminAuditEvents } from "@/lib/api/admin";
export function AdminAuditContent() {
const t = useTranslations("admin.audit");
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [allItems, setAllItems] = useState<
Awaited<ReturnType<typeof listAdminAuditEvents>>["items"]
>([]);
const { data, isLoading, isError, isFetching } = useQuery({
queryKey: ["admin", "audit", cursor],
queryFn: async () => {
const result = await listAdminAuditEvents({ limit: 50, cursor });
if (!cursor) {
setAllItems(result.items);
} else {
setAllItems((prev) => [...prev, ...result.items]);
}
return result;
},
});
const items = cursor ? allItems : (data?.items ?? []);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
</div>
{isLoading && items.length === 0 ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
) : isError ? (
<p className="text-sm text-red-600 dark:text-red-400">{t("loadError")}</p>
) : items.length === 0 ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("empty")}</p>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 font-medium">{t("columns.time")}</th>
<th className="px-4 py-3 font-medium">{t("columns.action")}</th>
<th className="px-4 py-3 font-medium">{t("columns.user")}</th>
<th className="px-4 py-3 font-medium">{t("columns.entity")}</th>
</tr>
</thead>
<tbody>
{items.map((event) => (
<tr
key={event.id}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800"
>
<td className="px-4 py-3 font-mono text-xs whitespace-nowrap">
{new Date(event.createdAt).toLocaleString()}
</td>
<td className="px-4 py-3 font-mono text-xs">{event.action}</td>
<td className="px-4 py-3 font-mono text-xs">
{event.user?.email ?? "—"}
</td>
<td className="px-4 py-3 font-mono text-xs">
{event.entityType ?? "—"}
{event.entityId ? ` · ${event.entityId.slice(0, 8)}` : ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data?.nextCursor ? (
<Button
variant="secondary"
isLoading={isFetching}
onClick={() => setCursor(data.nextCursor ?? undefined)}
>
{t("loadMore")}
</Button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { Card, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { getAdminDashboardStats } from "@/lib/api/admin";
export function AdminDashboardContent() {
const t = useTranslations("admin.dashboard");
const { data, isLoading, isError } = useQuery({
queryKey: ["admin", "dashboard"],
queryFn: getAdminDashboardStats,
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError || !data) {
return (
<p className="text-sm text-red-600 dark:text-red-400">{t("loadError")}</p>
);
}
const stats = [
{ key: "activeUsers", value: data.activeUsers },
{ key: "totalServers", value: data.totalServers },
{ key: "runningServers", value: data.runningServers },
{ key: "queueLength", value: data.queueLength },
{ key: "onlineNodes", value: data.onlineNodes },
{ key: "failedJobs24h", value: data.failedJobs24h },
{ key: "openAbuseReports", value: data.openAbuseReports },
{ key: "openTickets", value: data.openTickets },
] as const;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.key}>
<CardHeader className="pb-2">
<CardDescription>{t(`stats.${stat.key}`)}</CardDescription>
<CardTitle className="font-mono text-2xl">{stat.value}</CardTitle>
</CardHeader>
</Card>
))}
</div>
<p className="font-mono text-xs text-zinc-500">
{t("generatedAt", { time: new Date(data.generatedAt).toLocaleString() })}
</p>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { listAdminJobs } from "@/lib/api/admin";
export function AdminJobsContent() {
const t = useTranslations("admin.jobs");
const { data, isLoading, isError } = useQuery({
queryKey: ["admin", "jobs"],
queryFn: () => listAdminJobs({ limit: 50 }),
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError) {
return <p className="text-sm text-red-600 dark:text-red-400">{t("loadError")}</p>;
}
const jobs = data?.jobs ?? [];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
</div>
{jobs.length === 0 ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("empty")}</p>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 font-medium">{t("columns.queue")}</th>
<th className="px-4 py-3 font-medium">{t("columns.job")}</th>
<th className="px-4 py-3 font-medium">{t("columns.status")}</th>
<th className="px-4 py-3 font-medium">{t("columns.attempts")}</th>
<th className="px-4 py-3 font-medium">{t("columns.created")}</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr
key={job.id}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800"
>
<td className="px-4 py-3 font-mono text-xs">{job.queue}</td>
<td className="px-4 py-3 font-mono text-xs">{job.jobName}</td>
<td className="px-4 py-3 font-mono text-xs">{job.status}</td>
<td className="px-4 py-3">{job.attempts}</td>
<td className="px-4 py-3 font-mono text-xs">
{new Date(job.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { Button } from "@hexahost/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import {
drainAdminNode,
listAdminNodes,
setAdminNodeMaintenance,
} from "@/lib/api/admin";
import { ApiError } from "@/lib/api/auth";
export function AdminNodesContent() {
const t = useTranslations("admin.nodes");
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
queryKey: ["admin", "nodes"],
queryFn: listAdminNodes,
});
const drainMutation = useMutation({
mutationFn: drainAdminNode,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "nodes"] }),
});
const maintenanceMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
setAdminNodeMaintenance(id, enabled),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "nodes"] }),
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError) {
return <p className="text-sm text-red-600 dark:text-red-400">{t("loadError")}</p>;
}
const nodes = data?.nodes ?? [];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
</div>
{nodes.length === 0 ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("empty")}</p>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 font-medium">{t("columns.name")}</th>
<th className="px-4 py-3 font-medium">{t("columns.status")}</th>
<th className="px-4 py-3 font-medium">{t("columns.servers")}</th>
<th className="px-4 py-3 font-medium">{t("columns.ram")}</th>
<th className="px-4 py-3 font-medium">{t("columns.actions")}</th>
</tr>
</thead>
<tbody>
{nodes.map((node) => (
<tr
key={node.id}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800"
>
<td className="px-4 py-3">
<p className="font-medium text-zinc-900 dark:text-zinc-50">{node.name}</p>
<p className="font-mono text-xs text-zinc-500">{node.hostname}</p>
</td>
<td className="px-4 py-3 font-mono text-xs">{node.status}</td>
<td className="px-4 py-3">
{node.activeServers} / {node.maxServers}
</td>
<td className="px-4 py-3">
{node.availableRamMb} / {node.maxRamMb} MB
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
isLoading={drainMutation.isPending}
onClick={() => void drainMutation.mutate(node.id)}
>
{t("drain")}
</Button>
<Button
size="sm"
variant="secondary"
isLoading={maintenanceMutation.isPending}
onClick={() =>
void maintenanceMutation.mutate({
id: node.id,
enabled: node.status !== "MAINTENANCE",
})
}
>
{node.status === "MAINTENANCE"
? t("exitMaintenance")
: t("maintenance")}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{(drainMutation.isError || maintenanceMutation.isError) && (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{drainMutation.error instanceof ApiError
? (drainMutation.error.detail ?? drainMutation.error.title)
: maintenanceMutation.error instanceof ApiError
? (maintenanceMutation.error.detail ?? maintenanceMutation.error.title)
: t("actionError")}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import { Button } from "@hexahost/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { searchAdminUsers, suspendAdminUser, unsuspendAdminUser } from "@/lib/api/admin";
import { ApiError } from "@/lib/api/auth";
export function AdminUsersContent() {
const t = useTranslations("admin.users");
const queryClient = useQueryClient();
const [query, setQuery] = useState("");
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["admin", "users", query],
queryFn: () => searchAdminUsers({ q: query || undefined, limit: 50 }),
});
const suspendMutation = useMutation({
mutationFn: suspendAdminUser,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "users"] }),
});
const unsuspendMutation = useMutation({
mutationFn: unsuspendAdminUser,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "users"] }),
});
const users = data?.users ?? [];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
</div>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
void refetch();
}}
>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("searchPlaceholder")}
className="flex h-10 flex-1 rounded-md border border-zinc-300 bg-white px-3 text-sm dark:border-zinc-700 dark:bg-zinc-950"
/>
<Button type="submit" variant="secondary">
{t("search")}
</Button>
</form>
{isLoading ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
) : isError ? (
<p className="text-sm text-red-600 dark:text-red-400">{t("loadError")}</p>
) : users.length === 0 ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("empty")}</p>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 font-medium">{t("columns.user")}</th>
<th className="px-4 py-3 font-medium">{t("columns.status")}</th>
<th className="px-4 py-3 font-medium">{t("columns.roles")}</th>
<th className="px-4 py-3 font-medium">{t("columns.servers")}</th>
<th className="px-4 py-3 font-medium">{t("columns.actions")}</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800"
>
<td className="px-4 py-3">
<p className="font-medium text-zinc-900 dark:text-zinc-50">
{user.displayName ?? user.username}
</p>
<p className="font-mono text-xs text-zinc-500">{user.email}</p>
</td>
<td className="px-4 py-3 font-mono text-xs">{user.status}</td>
<td className="px-4 py-3 font-mono text-xs">{user.roles.join(", ")}</td>
<td className="px-4 py-3">{user.serverCount}</td>
<td className="px-4 py-3">
{user.status === "SUSPENDED" ? (
<Button
size="sm"
variant="secondary"
isLoading={unsuspendMutation.isPending}
onClick={() => void unsuspendMutation.mutate(user.id)}
>
{t("unsuspend")}
</Button>
) : (
<Button
size="sm"
variant="secondary"
isLoading={suspendMutation.isPending}
onClick={() => void suspendMutation.mutate(user.id)}
>
{t("suspend")}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{(suspendMutation.isError || unsuspendMutation.isError) && (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{suspendMutation.error instanceof ApiError
? (suspendMutation.error.detail ?? suspendMutation.error.title)
: unsuspendMutation.error instanceof ApiError
? (unsuspendMutation.error.detail ?? unsuspendMutation.error.title)
: t("actionError")}
</p>
)}
</div>
);
}

View File

@@ -33,6 +33,14 @@ export function DashboardUserInfo() {
<p className="font-mono text-xs text-zinc-500">{user.email}</p>
</div>
<div className="flex items-center gap-2">
{user.roles?.includes("SUPER_ADMIN") ? (
<Link
href="/admin"
className="text-xs font-medium text-amber-600 hover:underline dark:text-amber-400"
>
{t("admin")}
</Link>
) : null}
<Link
href="/security"
className="text-xs font-medium text-sky-600 hover:underline dark:text-sky-400"

View File

@@ -0,0 +1,45 @@
"use client";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
const links = [
{ key: "dashboard", href: "/dashboard" },
{ key: "servers", href: "/servers" },
{ key: "billing", href: "/billing" },
{ key: "profile", href: "/profile" },
{ key: "notifications", href: "/notifications" },
{ key: "security", href: "/security" },
] as const;
export function AccountNav() {
const t = useTranslations("account.nav");
const pathname = usePathname();
return (
<nav aria-label={t("label")}>
<ul className="flex flex-wrap gap-4 text-sm">
{links.map((link) => {
const active =
pathname === link.href || pathname.startsWith(`${link.href}/`);
return (
<li key={link.key}>
<Link
href={link.href}
className={
active
? "font-medium text-zinc-900 dark:text-zinc-50"
: "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
}
aria-current={active ? "page" : undefined}
>
{t(link.key)}
</Link>
</li>
);
})}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
const links = [
{ key: "dashboard", href: "/admin" },
{ key: "nodes", href: "/admin/nodes" },
{ key: "users", href: "/admin/users" },
{ key: "jobs", href: "/admin/jobs" },
{ key: "audit", href: "/admin/audit" },
] as const;
export function AdminNav() {
const t = useTranslations("admin.nav");
const pathname = usePathname();
function isActive(href: string): boolean {
if (href === "/admin") {
return pathname === "/admin";
}
return pathname === href || pathname.startsWith(`${href}/`);
}
return (
<nav aria-label={t("label")}>
<ul className="flex flex-wrap gap-4 text-sm">
{links.map((link) => {
const active = isActive(link.href);
return (
<li key={link.key}>
<Link
href={link.href}
className={
active
? "font-medium text-zinc-900 dark:text-zinc-50"
: "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
}
aria-current={active ? "page" : undefined}
>
{t(link.key)}
</Link>
</li>
);
})}
</ul>
</nav>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { getAppName } from "@/lib/env";
export function Footer() {
@@ -10,13 +11,27 @@ export function Footer() {
return (
<footer className="border-t border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-8 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:text-zinc-400">
<p>
© {year} {appName}. {t("footer")}
</p>
<p className="font-mono text-xs text-zinc-500 dark:text-zinc-500">
Phase 0 · Frontend Foundation
</p>
<div className="mx-auto flex max-w-6xl flex-col gap-4 px-4 py-8 sm:px-6">
<nav className="flex flex-wrap gap-4 text-sm text-zinc-600 dark:text-zinc-400">
<Link href="/impressum" className="hover:text-zinc-900 dark:hover:text-zinc-100">
{t("impressum")}
</Link>
<Link href="/privacy" className="hover:text-zinc-900 dark:hover:text-zinc-100">
{t("privacy")}
</Link>
<Link href="/terms" className="hover:text-zinc-900 dark:hover:text-zinc-100">
{t("terms")}
</Link>
<Link href="/status" className="hover:text-zinc-900 dark:hover:text-zinc-100">
{t("status")}
</Link>
</nav>
<div className="flex flex-col gap-2 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between dark:text-zinc-400">
<p>
© {year} {appName}. {t("footer")}
</p>
<p className="font-mono text-xs text-zinc-500 dark:text-zinc-500">{t("phaseBadge")}</p>
</div>
</div>
</footer>
);

View File

@@ -24,6 +24,24 @@ export function Header() {
className="flex items-center gap-2 sm:gap-4"
aria-label="Main navigation"
>
<Link
href="/features"
className="hidden text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 md:inline"
>
{t("features")}
</Link>
<Link
href="/pricing"
className="hidden text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 md:inline"
>
{t("pricing")}
</Link>
<Link
href="/docs"
className="hidden text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 lg:inline"
>
{t("docs")}
</Link>
<Link
href="/dashboard"
className="hidden text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 sm:inline"

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
interface PublicPageShellProps {
title: string;
description?: string;
children: ReactNode;
}
export function PublicPageShell({ title, description, children }: PublicPageShellProps) {
return (
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<header className="mx-auto max-w-3xl text-center">
<h1 className="text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-50">
{title}
</h1>
{description ? (
<p className="mt-4 text-lg text-zinc-600 dark:text-zinc-400">{description}</p>
) : null}
</header>
<div className="mx-auto mt-12 max-w-3xl space-y-6 text-zinc-700 dark:text-zinc-300">
{children}
</div>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
type ReactNode,
} from "react";
import {
clearCsrfToken,
getCurrentUser,
submitLogout,
type CurrentUser,
@@ -54,6 +55,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
try {
await submitLogout();
} finally {
clearCsrfToken();
setUser(null);
router.push("/login");
}

View File

@@ -1,212 +1 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError } from "@/lib/api/auth";
import { createServer, listPlans } from "@/lib/api/servers";
import {
createServerSchema,
MINECRAFT_VERSIONS,
SOFTWARE_FAMILIES,
type CreateServerFormValues,
} from "@/lib/schemas/server";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function CreateServerForm() {
const t = useTranslations("servers");
const tv = useTranslations("validation");
const router = useRouter();
const [apiError, setApiError] = useState<string | null>(null);
const { data: plansData, isLoading: plansLoading } = useQuery({
queryKey: ["plans"],
queryFn: listPlans,
retry: false,
});
const schema = createServerSchema({
nameRequired: tv("serverNameRequired"),
nameMax: tv("serverNameMax"),
planRequired: tv("planRequired"),
planInvalid: tv("planInvalid"),
versionRequired: tv("versionRequired"),
softwareRequired: tv("softwareRequired"),
eulaRequired: tv("eulaRequired"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<CreateServerFormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
planId: "",
minecraftVersion: MINECRAFT_VERSIONS[0],
softwareFamily: "VANILLA",
eulaAccepted: false,
},
});
const createMutation = useMutation({
mutationFn: createServer,
onSuccess: (server) => {
router.push(`/servers/${server.id}`);
},
onError: (error: unknown) => {
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
},
});
async function onSubmit(values: CreateServerFormValues) {
setApiError(null);
await createMutation.mutateAsync({
...values,
eulaAccepted: true,
});
}
const plans = plansData?.plans ?? [];
return (
<Card className="max-w-xl">
<CardHeader>
<CardTitle>{t("createTitle")}</CardTitle>
<CardDescription>{t("createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("name")}
</label>
<input
id="name"
type="text"
className={inputClassName}
aria-invalid={errors.name ? "true" : "false"}
{...register("name")}
/>
{errors.name ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.name.message}</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="planId" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("plan")}
</label>
<select
id="planId"
className={inputClassName}
disabled={plansLoading}
aria-invalid={errors.planId ? "true" : "false"}
{...register("planId")}
>
<option value="">{plansLoading ? t("loading") : t("selectPlan")}</option>
{plans.map((plan) => (
<option key={plan.id} value={plan.id}>
{plan.name} ({plan.maxRamMb} MB)
</option>
))}
</select>
{errors.planId ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.planId.message}</p>
) : null}
{!plansLoading && plans.length === 0 ? (
<p className="text-sm text-amber-700 dark:text-amber-300">{t("noPlans")}</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="softwareFamily"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("software")}
</label>
<select
id="softwareFamily"
className={inputClassName}
aria-invalid={errors.softwareFamily ? "true" : "false"}
{...register("softwareFamily")}
>
{SOFTWARE_FAMILIES.map((family) => (
<option key={family} value={family}>
{t(`softwareOptions.${family}`)}
</option>
))}
</select>
{errors.softwareFamily ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.softwareFamily.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="minecraftVersion"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("version")}
</label>
<select
id="minecraftVersion"
className={inputClassName}
aria-invalid={errors.minecraftVersion ? "true" : "false"}
{...register("minecraftVersion")}
>
{MINECRAFT_VERSIONS.map((version) => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
{errors.minecraftVersion ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.minecraftVersion.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input type="checkbox" className="mt-1" {...register("eulaAccepted")} />
<span>{t("eula")}</span>
</label>
{errors.eulaAccepted ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.eulaAccepted.message}</p>
) : null}
</div>
{apiError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{apiError}
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" isLoading={isSubmitting || createMutation.isPending}>
{t("create")}
</Button>
<Link
href="/servers"
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("cancel")}
</Link>
</div>
</form>
</CardContent>
</Card>
);
}
export { CreateServerWizard as CreateServerForm } from "./create-server-wizard";

View File

@@ -0,0 +1,290 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError } from "@/lib/api/auth";
import { createServer, listPlans } from "@/lib/api/servers";
import {
createServerSchema,
MINECRAFT_VERSIONS,
SOFTWARE_FAMILIES,
type CreateServerFormValues,
} from "@/lib/schemas/server";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
const STEPS = ["basics", "software", "review"] as const;
type WizardStep = (typeof STEPS)[number];
export function CreateServerWizard() {
const t = useTranslations("servers");
const tw = useTranslations("servers.wizard");
const tv = useTranslations("validation");
const router = useRouter();
const [step, setStep] = useState<WizardStep>("basics");
const [apiError, setApiError] = useState<string | null>(null);
const { data: plansData, isLoading: plansLoading } = useQuery({
queryKey: ["plans"],
queryFn: listPlans,
retry: false,
});
const schema = createServerSchema({
nameRequired: tv("serverNameRequired"),
nameMax: tv("serverNameMax"),
planRequired: tv("planRequired"),
planInvalid: tv("planInvalid"),
versionRequired: tv("versionRequired"),
softwareRequired: tv("softwareRequired"),
eulaRequired: tv("eulaRequired"),
});
const form = useForm<CreateServerFormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
planId: "",
minecraftVersion: MINECRAFT_VERSIONS[0],
softwareFamily: "VANILLA",
eulaAccepted: false,
},
mode: "onChange",
});
const {
register,
handleSubmit,
trigger,
watch,
formState: { errors, isSubmitting },
} = form;
const createMutation = useMutation({
mutationFn: createServer,
onSuccess: (server) => {
router.push(`/servers/${server.id}`);
},
onError: (error: unknown) => {
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
},
});
const plans = plansData?.plans ?? [];
const values = watch();
const stepIndex = STEPS.indexOf(step);
async function goNext() {
setApiError(null);
if (step === "basics") {
const valid = await trigger(["name", "planId"]);
if (valid) setStep("software");
return;
}
if (step === "software") {
const valid = await trigger(["softwareFamily", "minecraftVersion"]);
if (valid) setStep("review");
}
}
function goBack() {
setApiError(null);
if (step === "software") setStep("basics");
if (step === "review") setStep("software");
}
async function onSubmit(formValues: CreateServerFormValues) {
setApiError(null);
await createMutation.mutateAsync({
...formValues,
eulaAccepted: true,
});
}
const selectedPlan = plans.find((plan) => plan.id === values.planId);
return (
<Card className="max-w-xl">
<CardHeader>
<CardTitle>{t("createTitle")}</CardTitle>
<CardDescription>{t("createDescription")}</CardDescription>
<ol className="mt-4 flex gap-2" aria-label={tw("stepsLabel")}>
{STEPS.map((stepKey, index) => (
<li
key={stepKey}
className={[
"flex-1 rounded-full py-1 text-center text-xs font-medium",
index <= stepIndex
? "bg-sky-600 text-white dark:bg-sky-500"
: "bg-zinc-200 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
].join(" ")}
>
{tw(`steps.${stepKey}`)}
</li>
))}
</ol>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
{step === "basics" ? (
<>
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("name")}
</label>
<input id="name" type="text" className={inputClassName} {...register("name")} />
{errors.name ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.name.message}</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="planId" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("plan")}
</label>
<select
id="planId"
className={inputClassName}
disabled={plansLoading}
{...register("planId")}
>
<option value="">{plansLoading ? t("loading") : t("selectPlan")}</option>
{plans.map((plan) => (
<option key={plan.id} value={plan.id}>
{plan.name} ({plan.maxRamMb} MB)
</option>
))}
</select>
{errors.planId ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.planId.message}</p>
) : null}
{!plansLoading && plans.length === 0 ? (
<p className="text-sm text-amber-700 dark:text-amber-300">{t("noPlans")}</p>
) : null}
</div>
</>
) : null}
{step === "software" ? (
<>
<div className="space-y-2">
<label
htmlFor="softwareFamily"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("software")}
</label>
<select id="softwareFamily" className={inputClassName} {...register("softwareFamily")}>
{SOFTWARE_FAMILIES.map((family) => (
<option key={family} value={family}>
{t(`softwareOptions.${family}`)}
</option>
))}
</select>
{errors.softwareFamily ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.softwareFamily.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="minecraftVersion"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("version")}
</label>
<select id="minecraftVersion" className={inputClassName} {...register("minecraftVersion")}>
{MINECRAFT_VERSIONS.map((version) => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
{errors.minecraftVersion ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.minecraftVersion.message}
</p>
) : null}
</div>
</>
) : null}
{step === "review" ? (
<>
<dl className="space-y-2 rounded-lg border border-zinc-200 p-4 text-sm dark:border-zinc-800">
<div className="flex justify-between gap-4">
<dt className="text-zinc-600 dark:text-zinc-400">{t("name")}</dt>
<dd className="font-medium text-zinc-900 dark:text-zinc-50">{values.name}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-zinc-600 dark:text-zinc-400">{t("plan")}</dt>
<dd className="font-medium text-zinc-900 dark:text-zinc-50">
{selectedPlan?.name ?? "—"}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-zinc-600 dark:text-zinc-400">{t("software")}</dt>
<dd className="font-medium text-zinc-900 dark:text-zinc-50">
{t(`softwareOptions.${values.softwareFamily}`)}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-zinc-600 dark:text-zinc-400">{t("version")}</dt>
<dd className="font-medium text-zinc-900 dark:text-zinc-50">
{values.minecraftVersion}
</dd>
</div>
</dl>
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input type="checkbox" className="mt-1" {...register("eulaAccepted")} />
<span>{t("eula")}</span>
</label>
{errors.eulaAccepted ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.eulaAccepted.message}
</p>
) : null}
</div>
</>
) : null}
{apiError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{apiError}
</p>
) : null}
<div className="flex gap-2">
{step !== "basics" ? (
<Button type="button" variant="secondary" onClick={goBack}>
{tw("back")}
</Button>
) : null}
{step !== "review" ? (
<Button type="button" onClick={() => void goNext()}>
{tw("next")}
</Button>
) : (
<Button type="submit" isLoading={isSubmitting || createMutation.isPending}>
{t("create")}
</Button>
)}
<Link
href="/servers"
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("cancel")}
</Link>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { ApiError } from "@/lib/api/auth";
import {
inviteMember,
listMembers,
removeMember,
type MemberRole,
type ServerMember,
} from "@/lib/api/members";
interface ServerAccessProps {
serverId: string;
}
const INVITE_ROLES: MemberRole[] = ["ADMIN", "OPERATOR", "DEVELOPER", "VIEWER"];
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300";
function memberDisplayName(member: ServerMember): string {
return member.displayName ?? member.username;
}
export function ServerAccess({ serverId }: ServerAccessProps) {
const t = useTranslations("servers.access");
const queryClient = useQueryClient();
const [showInviteForm, setShowInviteForm] = useState(false);
const [email, setEmail] = useState("");
const [role, setRole] = useState<MemberRole>("VIEWER");
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: ["server-members", serverId],
queryFn: () => listMembers(serverId),
});
const invalidate = () =>
void queryClient.invalidateQueries({ queryKey: ["server-members", serverId] });
const inviteMutation = useMutation({
mutationFn: () => inviteMember(serverId, { email: email.trim(), role }),
onSuccess: (response) => {
setError(null);
if (response.status === "pending") {
setInfo(t("invitePending"));
} else {
setInfo(t("inviteSuccess"));
setShowInviteForm(false);
setEmail("");
setRole("VIEWER");
invalidate();
}
},
onError: (err: unknown) => {
setInfo(null);
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.inviteFailed"));
},
});
const removeMutation = useMutation({
mutationFn: (memberId: string) => removeMember(serverId, memberId),
onSuccess: () => {
setError(null);
setInfo(null);
invalidate();
},
onError: (err: unknown) => {
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.removeFailed"));
},
});
function startInvite() {
setShowInviteForm(true);
setError(null);
setInfo(null);
}
function cancelInvite() {
setShowInviteForm(false);
setEmail("");
setRole("VIEWER");
setError(null);
setInfo(null);
}
function handleRemove(member: ServerMember) {
const name = memberDisplayName(member);
if (!window.confirm(t("removeConfirm", { name }))) {
return;
}
removeMutation.mutate(member.id);
}
const members = data?.members ?? [];
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</div>
{!showInviteForm ? (
<Button size="sm" onClick={startInvite}>
{t("invite")}
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4">
{error ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
{info ? (
<p className="text-sm text-sky-600 dark:text-sky-400" role="status">
{info}
</p>
) : null}
{showInviteForm ? (
<div className="space-y-4 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1 sm:col-span-2">
<label className={labelClassName} htmlFor="member-email">
{t("fields.email")}
</label>
<input
id="member-email"
type="email"
className={inputClassName}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("fields.emailPlaceholder")}
/>
</div>
<div className="space-y-1">
<label className={labelClassName} htmlFor="member-role">
{t("fields.role")}
</label>
<select
id="member-role"
className={inputClassName}
value={role}
onChange={(e) => setRole(e.target.value as MemberRole)}
>
{INVITE_ROLES.map((memberRole) => (
<option key={memberRole} value={memberRole}>
{t(`roles.${memberRole}`)}
</option>
))}
</select>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending || !email.trim()}
>
{inviteMutation.isPending ? t("inviting") : t("sendInvite")}
</Button>
<Button variant="secondary" onClick={cancelInvite}>
{t("cancel")}
</Button>
</div>
</div>
) : null}
{isLoading ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
) : isError ? (
<p className="text-sm text-red-600 dark:text-red-400">{t("errors.loadFailed")}</p>
) : members.length === 0 && !showInviteForm ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("empty")}</p>
) : members.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
<thead className="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.member")}
</th>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.email")}
</th>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.role")}
</th>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.status")}
</th>
<th className="px-4 py-3 text-right font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.actions")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 bg-white dark:divide-zinc-800 dark:bg-zinc-950">
{members.map((member) => (
<tr key={member.id}>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
{memberDisplayName(member)}
</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400">{member.email}</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400">
{t(`roles.${member.role}`)}
</td>
<td className="px-4 py-3">
<span
className={
member.acceptedAt
? "text-green-600 dark:text-green-400"
: "text-amber-600 dark:text-amber-400"
}
>
{member.acceptedAt ? t("status.active") : t("status.pending")}
</span>
</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<Button
size="sm"
variant="secondary"
onClick={() => handleRemove(member)}
disabled={removeMutation.isPending || member.role === "OWNER"}
>
{t("remove")}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -3,9 +3,17 @@
import { Button } from "@hexahost/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ApiError } from "@/lib/api/auth";
import { listFiles, readFile, writeFile } from "@/lib/api/files";
import {
archiveFiles,
deleteFile,
listFiles,
readFile,
unarchiveFile,
uploadFile,
writeFile,
} from "@/lib/api/files";
import { getServer } from "@/lib/api/servers";
interface ServerFilesProps {
@@ -15,14 +23,35 @@ interface ServerFilesProps {
const textareaClassName =
"min-h-80 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 font-mono text-sm text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
if (!base64) {
reject(new Error("Failed to encode file"));
return;
}
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}
export function ServerFiles({ serverId }: ServerFilesProps) {
const t = useTranslations("servers.files");
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentPath, setCurrentPath] = useState("");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
const [editorContent, setEditorContent] = useState("");
const [fileVersion, setFileVersion] = useState<number | undefined>();
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const { data: server } = useQuery({
@@ -32,6 +61,10 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
const canAccessFiles =
server?.status === "RUNNING" || server?.status === "STOPPED";
const isReadOnly = server?.status === "STOPPED";
const invalidateListing = () =>
void queryClient.invalidateQueries({ queryKey: ["server-files", serverId, currentPath] });
const {
data: listing,
@@ -60,6 +93,10 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
}
}, [fileData]);
useEffect(() => {
setSelectedPaths([]);
}, [currentPath]);
const saveMutation = useMutation({
mutationFn: () =>
writeFile(serverId, {
@@ -80,6 +117,75 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
},
});
const deleteMutation = useMutation({
mutationFn: (path: string) => deleteFile(serverId, path),
onSuccess: () => {
setActionError(null);
setActionSuccess(t("deleted"));
if (selectedFile) {
setSelectedFile(null);
setEditorContent("");
}
invalidateListing();
setSelectedPaths([]);
setTimeout(() => setActionSuccess(null), 2000);
},
onError: (err: unknown) => {
setActionSuccess(null);
setActionError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.deleteFailed"));
},
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const content = await readFileAsBase64(file);
const targetPath = currentPath ? `${currentPath}/${file.name}` : file.name;
return uploadFile(serverId, { path: targetPath, content });
},
onSuccess: () => {
setActionError(null);
setActionSuccess(t("uploaded"));
invalidateListing();
setTimeout(() => setActionSuccess(null), 2000);
},
onError: (err: unknown) => {
setActionSuccess(null);
setActionError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.uploadFailed"));
},
});
const archiveMutation = useMutation({
mutationFn: (paths: string[]) => archiveFiles(serverId, { paths }),
onSuccess: () => {
setActionError(null);
setActionSuccess(t("archived"));
invalidateListing();
setSelectedPaths([]);
setTimeout(() => setActionSuccess(null), 2000);
},
onError: (err: unknown) => {
setActionSuccess(null);
setActionError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.archiveFailed"));
},
});
const unarchiveMutation = useMutation({
mutationFn: (archivePath: string) =>
unarchiveFile(serverId, { archivePath, destinationPath: currentPath || "." }),
onSuccess: () => {
setActionError(null);
setActionSuccess(t("unarchived"));
invalidateListing();
setTimeout(() => setActionSuccess(null), 2000);
},
onError: (err: unknown) => {
setActionSuccess(null);
setActionError(
err instanceof ApiError ? (err.detail ?? err.title) : t("errors.unarchiveFailed"),
);
},
});
function handleSelectEntry(path: string, isDir: boolean) {
setError(null);
setSaveSuccess(false);
@@ -106,6 +212,46 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
setError(null);
}
function togglePathSelection(path: string) {
setSelectedPaths((prev) =>
prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path],
);
}
function handleDelete(path: string) {
if (!window.confirm(t("deleteConfirm", { name: path.split("/").pop() ?? path }))) {
return;
}
deleteMutation.mutate(path);
}
function handleArchiveSelected() {
if (selectedPaths.length === 0) {
return;
}
archiveMutation.mutate(selectedPaths);
}
function handleUnarchive() {
const archivePath = selectedFile ?? selectedPaths[0];
if (!archivePath) {
return;
}
if (!window.confirm(t("unarchiveConfirm", { name: archivePath.split("/").pop() ?? archivePath }))) {
return;
}
unarchiveMutation.mutate(archivePath);
}
async function handleUploadChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || isReadOnly) {
return;
}
uploadMutation.mutate(file);
}
if (!server) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
@@ -118,18 +264,52 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
);
}
const isReadOnly = server.status === "STOPPED";
const busy =
deleteMutation.isPending ||
uploadMutation.isPending ||
archiveMutation.isPending ||
unarchiveMutation.isPending;
return (
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("browser")}</h2>
{currentPath ? (
<Button size="sm" variant="secondary" onClick={handleNavigateUp}>
{t("up")}
</Button>
) : null}
<div className="flex flex-wrap gap-2">
{!isReadOnly ? (
<>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(event) => void handleUploadChange(event)}
/>
<Button
size="sm"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={busy}
>
{uploadMutation.isPending ? t("uploading") : t("upload")}
</Button>
</>
) : null}
{selectedPaths.length > 0 && !isReadOnly ? (
<Button size="sm" variant="secondary" onClick={handleArchiveSelected} disabled={busy}>
{archiveMutation.isPending ? t("archiving") : t("archive")}
</Button>
) : null}
{(selectedFile ?? selectedPaths[0])?.endsWith(".tar.gz") && !isReadOnly ? (
<Button size="sm" variant="secondary" onClick={handleUnarchive} disabled={busy}>
{unarchiveMutation.isPending ? t("unarchiving") : t("unarchive")}
</Button>
) : null}
{currentPath ? (
<Button size="sm" variant="secondary" onClick={handleNavigateUp}>
{t("up")}
</Button>
) : null}
</div>
</div>
<p className="truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
@@ -140,6 +320,15 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
<p className="text-xs text-amber-700 dark:text-amber-300">{t("readOnlyStopped")}</p>
) : null}
{actionError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{actionError}
</p>
) : null}
{actionSuccess ? (
<p className="text-sm text-green-600 dark:text-green-400">{actionSuccess}</p>
) : null}
{listingLoading ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
) : listingError ? (
@@ -150,11 +339,20 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
<li className="px-3 py-2 text-sm text-zinc-500 dark:text-zinc-400">{t("empty")}</li>
) : (
(listing?.entries ?? []).map((entry) => (
<li key={entry.path}>
<li key={entry.path} className="flex items-center gap-2">
{!isReadOnly ? (
<input
type="checkbox"
className="ml-3"
checked={selectedPaths.includes(entry.path)}
onChange={() => togglePathSelection(entry.path)}
aria-label={entry.name}
/>
) : null}
<button
type="button"
onClick={() => handleSelectEntry(entry.path, entry.isDir)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-zinc-50 dark:hover:bg-zinc-900"
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<span className="w-8 shrink-0 text-xs text-zinc-400">
{entry.isDir ? "DIR" : "FILE"}
@@ -164,6 +362,17 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
<span className="ml-auto text-xs text-zinc-500">{entry.size} B</span>
) : null}
</button>
{!isReadOnly ? (
<Button
size="sm"
variant="secondary"
className="mr-2 shrink-0"
onClick={() => handleDelete(entry.path)}
disabled={busy}
>
{t("delete")}
</Button>
) : null}
</li>
))
)}
@@ -212,10 +421,7 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
) : null}
{!isReadOnly ? (
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? t("saving") : t("save")}
</Button>
) : null}

View File

@@ -1,15 +1,40 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@hexahost/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { getPlayers } from "@/lib/api/players";
import { useState } from "react";
import { ApiError } from "@/lib/api/auth";
import {
addBan,
addOperator,
addWhitelist,
getPlayers,
kickPlayer,
removeBan,
removeOperator,
removeWhitelist,
} from "@/lib/api/players";
interface ServerPlayersProps {
serverId: string;
}
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300";
export function ServerPlayers({ serverId }: ServerPlayersProps) {
const t = useTranslations("servers.players");
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [whitelistName, setWhitelistName] = useState("");
const [whitelistUuid, setWhitelistUuid] = useState("");
const [operatorName, setOperatorName] = useState("");
const [operatorUuid, setOperatorUuid] = useState("");
const [banName, setBanName] = useState("");
const [banReason, setBanReason] = useState("");
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["server-players", serverId],
@@ -17,6 +42,75 @@ export function ServerPlayers({ serverId }: ServerPlayersProps) {
refetchInterval: 15000,
});
const invalidate = () =>
void queryClient.invalidateQueries({ queryKey: ["server-players", serverId] });
const mutationOptions = {
onSuccess: () => {
setError(null);
invalidate();
},
onError: (err: unknown) => {
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.actionFailed"));
},
};
const addWhitelistMutation = useMutation({
mutationFn: () =>
addWhitelist(serverId, { name: whitelistName.trim(), uuid: whitelistUuid.trim() }),
...mutationOptions,
onSuccess: () => {
mutationOptions.onSuccess();
setWhitelistName("");
setWhitelistUuid("");
},
});
const removeWhitelistMutation = useMutation({
mutationFn: (uuid: string) => removeWhitelist(serverId, { uuid }),
...mutationOptions,
});
const addOperatorMutation = useMutation({
mutationFn: () =>
addOperator(serverId, { name: operatorName.trim(), uuid: operatorUuid.trim() }),
...mutationOptions,
onSuccess: () => {
mutationOptions.onSuccess();
setOperatorName("");
setOperatorUuid("");
},
});
const removeOperatorMutation = useMutation({
mutationFn: (uuid: string) => removeOperator(serverId, { uuid }),
...mutationOptions,
});
const addBanMutation = useMutation({
mutationFn: () =>
addBan(serverId, {
name: banName.trim(),
reason: banReason.trim() || undefined,
}),
...mutationOptions,
onSuccess: () => {
mutationOptions.onSuccess();
setBanName("");
setBanReason("");
},
});
const removeBanMutation = useMutation({
mutationFn: (name: string) => removeBan(serverId, { name }),
...mutationOptions,
});
const kickMutation = useMutation({
mutationFn: (name: string) => kickPlayer(serverId, { name }),
...mutationOptions,
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
@@ -36,61 +130,257 @@ export function ServerPlayers({ serverId }: ServerPlayersProps) {
);
}
const busy =
addWhitelistMutation.isPending ||
removeWhitelistMutation.isPending ||
addOperatorMutation.isPending ||
removeOperatorMutation.isPending ||
addBanMutation.isPending ||
removeBanMutation.isPending ||
kickMutation.isPending;
return (
<div className="space-y-6">
<div className="space-y-8">
{error ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{t("onlineCount")}</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{data.online.length}
<span className="text-base font-normal text-zinc-500"> / {data.maxPlayers}</span>
</p>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{t("whitelistCount")}</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{data.whitelistCount}
{data.whitelist.length}
</p>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{t("operatorsCount")}</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{data.operators.length}
</p>
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
<thead className="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.name")}
</th>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.ping")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 bg-white dark:divide-zinc-800 dark:bg-zinc-950">
{data.online.length === 0 ? (
<section className="space-y-3">
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("onlineTitle")}</h2>
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
<thead className="bg-zinc-50 dark:bg-zinc-900">
<tr>
<td
colSpan={2}
className="px-4 py-6 text-center text-zinc-500 dark:text-zinc-400"
>
{t("empty")}
</td>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.name")}
</th>
<th className="px-4 py-3 text-right font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.actions")}
</th>
</tr>
) : (
data.online.map((player) => (
<tr key={player.uuid ?? player.name}>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
{player.name}
</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400">
{player.ping !== undefined ? `${player.ping} ms` : "—"}
</thead>
<tbody className="divide-y divide-zinc-200 bg-white dark:divide-zinc-800 dark:bg-zinc-950">
{data.online.length === 0 ? (
<tr>
<td colSpan={2} className="px-4 py-6 text-center text-zinc-500 dark:text-zinc-400">
{t("empty")}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
) : (
data.online.map((player) => (
<tr key={player.uuid ?? player.name}>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
{player.name}
</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant="secondary"
onClick={() => kickMutation.mutate(player.name)}
disabled={busy}
>
{t("kick")}
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("whitelistTitle")}</h2>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<label className={labelClassName} htmlFor="whitelist-name">
{t("fields.name")}
</label>
<input
id="whitelist-name"
className={inputClassName}
value={whitelistName}
onChange={(e) => setWhitelistName(e.target.value)}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className={labelClassName} htmlFor="whitelist-uuid">
{t("fields.uuid")}
</label>
<input
id="whitelist-uuid"
className={inputClassName}
value={whitelistUuid}
onChange={(e) => setWhitelistUuid(e.target.value)}
/>
</div>
</div>
<Button
size="sm"
onClick={() => addWhitelistMutation.mutate()}
disabled={busy || !whitelistName.trim() || !whitelistUuid.trim()}
>
{t("addWhitelist")}
</Button>
<PlayerEntryList
entries={data.whitelist}
onRemove={(uuid) => removeWhitelistMutation.mutate(uuid)}
busy={busy}
removeLabel={t("remove")}
/>
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("operatorsTitle")}</h2>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<label className={labelClassName} htmlFor="operator-name">
{t("fields.name")}
</label>
<input
id="operator-name"
className={inputClassName}
value={operatorName}
onChange={(e) => setOperatorName(e.target.value)}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className={labelClassName} htmlFor="operator-uuid">
{t("fields.uuid")}
</label>
<input
id="operator-uuid"
className={inputClassName}
value={operatorUuid}
onChange={(e) => setOperatorUuid(e.target.value)}
/>
</div>
</div>
<Button
size="sm"
onClick={() => addOperatorMutation.mutate()}
disabled={busy || !operatorName.trim() || !operatorUuid.trim()}
>
{t("addOperator")}
</Button>
<PlayerEntryList
entries={data.operators}
onRemove={(uuid) => removeOperatorMutation.mutate(uuid)}
busy={busy}
removeLabel={t("remove")}
/>
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("bansTitle")}</h2>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<label className={labelClassName} htmlFor="ban-name">
{t("fields.name")}
</label>
<input
id="ban-name"
className={inputClassName}
value={banName}
onChange={(e) => setBanName(e.target.value)}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className={labelClassName} htmlFor="ban-reason">
{t("fields.reason")}
</label>
<input
id="ban-reason"
className={inputClassName}
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
/>
</div>
</div>
<Button size="sm" onClick={() => addBanMutation.mutate()} disabled={busy || !banName.trim()}>
{t("addBan")}
</Button>
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{(data.bans ?? []).length === 0 ? (
<li className="px-4 py-3 text-sm text-zinc-500 dark:text-zinc-400">{t("bansEmpty")}</li>
) : (
(data.bans ?? []).map((ban) => (
<li key={ban.uuid ?? ban.name} className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<p className="font-medium text-zinc-900 dark:text-zinc-100">{ban.name}</p>
{ban.reason ? (
<p className="text-xs text-zinc-500">{ban.reason}</p>
) : null}
</div>
<Button
size="sm"
variant="secondary"
onClick={() => removeBanMutation.mutate(ban.name)}
disabled={busy}
>
{t("remove")}
</Button>
</li>
))
)}
</ul>
</section>
</div>
);
}
function PlayerEntryList({
entries,
onRemove,
busy,
removeLabel,
}: {
entries: Array<{ uuid: string; name: string }>;
onRemove: (uuid: string) => void;
busy: boolean;
removeLabel: string;
}) {
if (entries.length === 0) {
return null;
}
return (
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{entries.map((entry) => (
<li key={entry.uuid} className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<p className="font-medium text-zinc-900 dark:text-zinc-100">{entry.name}</p>
<p className="font-mono text-xs text-zinc-500">{entry.uuid}</p>
</div>
<Button size="sm" variant="secondary" onClick={() => onRemove(entry.uuid)} disabled={busy}>
{removeLabel}
</Button>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,389 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState, Fragment } from "react";
import { ApiError } from "@/lib/api/auth";
import {
createSchedule,
deleteSchedule,
listSchedules,
updateSchedule,
type Schedule,
type ScheduleTaskType,
} from "@/lib/api/schedules";
interface ServerSchedulesProps {
serverId: string;
}
const TASK_TYPES: ScheduleTaskType[] = [
"START",
"STOP",
"RESTART",
"BACKUP",
"CONSOLE_COMMAND",
"UPDATE_CHECK",
"MAINTENANCE",
];
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300";
interface ScheduleFormState {
name: string;
cronExpr: string;
timezone: string;
enabled: boolean;
taskType: ScheduleTaskType;
command: string;
}
const defaultForm: ScheduleFormState = {
name: "",
cronExpr: "0 4 * * *",
timezone: "UTC",
enabled: true,
taskType: "RESTART",
command: "",
};
function scheduleToForm(schedule: Schedule): ScheduleFormState {
const task = schedule.tasks[0];
const command =
task?.taskType === "CONSOLE_COMMAND" && task.payload?.command
? String(task.payload.command)
: "";
return {
name: schedule.name,
cronExpr: schedule.cronExpr,
timezone: schedule.timezone,
enabled: schedule.enabled,
taskType: task?.taskType ?? "RESTART",
command,
};
}
function formToPayload(form: ScheduleFormState) {
const payload =
form.taskType === "CONSOLE_COMMAND" && form.command.trim()
? { command: form.command.trim() }
: undefined;
return {
name: form.name.trim(),
cronExpr: form.cronExpr.trim(),
timezone: form.timezone.trim() || "UTC",
enabled: form.enabled,
tasks: [{ taskType: form.taskType, payload }],
};
}
export function ServerSchedules({ serverId }: ServerSchedulesProps) {
const t = useTranslations("servers.schedules");
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<ScheduleFormState>(defaultForm);
const [error, setError] = useState<string | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: ["server-schedules", serverId],
queryFn: () => listSchedules(serverId),
});
const invalidate = () =>
void queryClient.invalidateQueries({ queryKey: ["server-schedules", serverId] });
const createMutation = useMutation({
mutationFn: () => createSchedule(serverId, formToPayload(form)),
onSuccess: () => {
setError(null);
setShowForm(false);
setForm(defaultForm);
invalidate();
},
onError: (err: unknown) => {
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed"));
},
});
const updateMutation = useMutation({
mutationFn: (scheduleId: string) =>
updateSchedule(serverId, scheduleId, formToPayload(form)),
onSuccess: () => {
setError(null);
setEditingId(null);
setForm(defaultForm);
invalidate();
},
onError: (err: unknown) => {
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed"));
},
});
const deleteMutation = useMutation({
mutationFn: (scheduleId: string) => deleteSchedule(serverId, scheduleId),
onSuccess: () => {
setError(null);
invalidate();
},
onError: (err: unknown) => {
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.deleteFailed"));
},
});
const toggleMutation = useMutation({
mutationFn: (schedule: Schedule) =>
updateSchedule(serverId, schedule.id, { enabled: !schedule.enabled }),
onSuccess: () => invalidate(),
onError: (err: unknown) => {
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed"));
},
});
function startCreate() {
setEditingId(null);
setForm(defaultForm);
setShowForm(true);
setError(null);
}
function startEdit(schedule: Schedule) {
setShowForm(false);
setEditingId(schedule.id);
setForm(scheduleToForm(schedule));
setError(null);
}
function cancelForm() {
setShowForm(false);
setEditingId(null);
setForm(defaultForm);
setError(null);
}
function handleDelete(schedule: Schedule) {
if (!window.confirm(t("deleteConfirm", { name: schedule.name }))) {
return;
}
deleteMutation.mutate(schedule.id);
}
const isSaving = createMutation.isPending || updateMutation.isPending;
function renderForm(onSubmit: () => void, submitLabel: string) {
return (
<div className="space-y-4 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1 sm:col-span-2">
<label className={labelClassName} htmlFor="schedule-name">
{t("fields.name")}
</label>
<input
id="schedule-name"
className={inputClassName}
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="space-y-1">
<label className={labelClassName} htmlFor="schedule-cron">
{t("fields.cron")}
</label>
<input
id="schedule-cron"
className={inputClassName}
value={form.cronExpr}
onChange={(e) => setForm((prev) => ({ ...prev, cronExpr: e.target.value }))}
/>
</div>
<div className="space-y-1">
<label className={labelClassName} htmlFor="schedule-timezone">
{t("fields.timezone")}
</label>
<input
id="schedule-timezone"
className={inputClassName}
value={form.timezone}
onChange={(e) => setForm((prev) => ({ ...prev, timezone: e.target.value }))}
/>
</div>
<div className="space-y-1">
<label className={labelClassName} htmlFor="schedule-task">
{t("fields.taskType")}
</label>
<select
id="schedule-task"
className={inputClassName}
value={form.taskType}
onChange={(e) =>
setForm((prev) => ({ ...prev, taskType: e.target.value as ScheduleTaskType }))
}
>
{TASK_TYPES.map((type) => (
<option key={type} value={type}>
{t(`taskTypes.${type}`)}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2 pt-6">
<input
id="schedule-enabled"
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm((prev) => ({ ...prev, enabled: e.target.checked }))}
/>
<label className={labelClassName} htmlFor="schedule-enabled">
{t("fields.enabled")}
</label>
</div>
{form.taskType === "CONSOLE_COMMAND" ? (
<div className="space-y-1 sm:col-span-2">
<label className={labelClassName} htmlFor="schedule-command">
{t("fields.command")}
</label>
<input
id="schedule-command"
className={inputClassName}
value={form.command}
onChange={(e) => setForm((prev) => ({ ...prev, command: e.target.value }))}
placeholder={t("fields.commandPlaceholder")}
/>
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={onSubmit} disabled={isSaving || !form.name.trim()}>
{isSaving ? t("saving") : submitLabel}
</Button>
<Button variant="secondary" onClick={cancelForm}>
{t("cancel")}
</Button>
</div>
</div>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</div>
{!showForm && !editingId ? (
<Button size="sm" onClick={startCreate}>
{t("create")}
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4">
{error ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
{showForm
? renderForm(() => createMutation.mutate(), t("create"))
: null}
{isLoading ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
) : isError ? (
<p className="text-sm text-red-600 dark:text-red-400">{t("errors.loadFailed")}</p>
) : (data?.schedules ?? []).length === 0 && !showForm ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("empty")}</p>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
<thead className="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.name")}
</th>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.cron")}
</th>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.task")}
</th>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.status")}
</th>
<th className="px-4 py-3 text-right font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.actions")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 bg-white dark:divide-zinc-800 dark:bg-zinc-950">
{(data?.schedules ?? []).map((schedule) => (
<Fragment key={schedule.id}>
<tr>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
{schedule.name}
</td>
<td className="px-4 py-3 font-mono text-zinc-600 dark:text-zinc-400">
{schedule.cronExpr}
</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400">
{schedule.tasks.map((task) => t(`taskTypes.${task.taskType}`)).join(", ")}
</td>
<td className="px-4 py-3">
<span
className={
schedule.enabled
? "text-green-600 dark:text-green-400"
: "text-zinc-500"
}
>
{schedule.enabled ? t("status.enabled") : t("status.disabled")}
</span>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => toggleMutation.mutate(schedule)}
disabled={toggleMutation.isPending}
>
{schedule.enabled ? t("disable") : t("enable")}
</Button>
<Button size="sm" variant="secondary" onClick={() => startEdit(schedule)}>
{t("edit")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleDelete(schedule)}
disabled={deleteMutation.isPending}
>
{t("delete")}
</Button>
</div>
</td>
</tr>
{editingId === schedule.id ? (
<tr key={`${schedule.id}-edit`}>
<td colSpan={5} className="px-4 py-4">
{renderForm(() => updateMutation.mutate(schedule.id), t("save"))}
</td>
</tr>
) : null}
</Fragment>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { ApiError } from "@/lib/api/auth";
import { getServer } from "@/lib/api/servers";
import { reinstallServer } from "@/lib/api/software";
interface ServerSoftwareProps {
serverId: string;
}
export function ServerSoftware({ serverId }: ServerSoftwareProps) {
const t = useTranslations("servers.software");
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
});
const reinstallMutation = useMutation({
mutationFn: () =>
reinstallServer(serverId, {
softwareFamily: data!.softwareFamily,
minecraftVersion: data!.minecraftVersion,
createBackup: true,
}),
onSuccess: () => {
setError(null);
setSuccess(true);
void queryClient.invalidateQueries({ queryKey: ["server", serverId] });
setTimeout(() => setSuccess(false), 4000);
},
onError: (err: unknown) => {
setSuccess(false);
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("reinstallError"));
},
});
function handleReinstall() {
if (!data || !window.confirm(t("reinstallConfirm"))) {
return;
}
reinstallMutation.mutate();
}
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError || !data) {
return <p className="text-sm text-red-600 dark:text-red-400">{t("loadError")}</p>;
}
const canReinstall = data.status === "STOPPED";
return (
<Card>
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="flex justify-between gap-4">
<span className="text-zinc-600 dark:text-zinc-400">{t("family")}</span>
<span className="font-mono text-zinc-900 dark:text-zinc-50">
{data.softwareFamily}
</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-600 dark:text-zinc-400">{t("version")}</span>
<span className="font-mono text-zinc-900 dark:text-zinc-50">
{data.minecraftVersion}
</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-600 dark:text-zinc-400">{t("edition")}</span>
<span className="font-mono text-zinc-900 dark:text-zinc-50">{data.edition}</span>
</div>
{!canReinstall ? (
<p className="text-xs text-amber-700 dark:text-amber-300">{t("reinstallStoppedHint")}</p>
) : null}
{error ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
{success ? (
<p className="text-sm text-green-600 dark:text-green-400">{t("reinstallStarted")}</p>
) : null}
<Button
variant="secondary"
onClick={handleReinstall}
disabled={!canReinstall || reinstallMutation.isPending}
>
{reinstallMutation.isPending ? t("reinstalling") : t("reinstall")}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -12,10 +12,13 @@ const tabs = [
{ key: "console", suffix: "/console" },
{ key: "files", suffix: "/files" },
{ key: "properties", suffix: "/properties" },
{ key: "software", suffix: "/software" },
{ key: "players", suffix: "/players" },
{ key: "worlds", suffix: "/worlds" },
{ key: "backups", suffix: "/backups" },
{ key: "addons", suffix: "/addons" },
{ key: "schedules", suffix: "/schedules" },
{ key: "access", suffix: "/access" },
] as const;
export function ServerTabs({ serverId }: ServerTabsProps) {

View File

@@ -0,0 +1,143 @@
import { apiFetch } from "./auth";
export interface AdminDashboardStats {
activeUsers: number;
totalServers: number;
runningServers: number;
queueLength: number;
onlineNodes: number;
failedJobs24h: number;
openAbuseReports: number;
openTickets: number;
generatedAt: string;
}
export interface AdminUser {
id: string;
email: string;
username: string;
displayName: string | null;
status: string;
roles: string[];
twoFactorEnabled: boolean;
sessionCount: number;
serverCount: number;
createdAt: string;
}
export interface AdminJob {
id: string;
queue: string;
jobName: string;
status: string;
attempts: number;
error: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
}
export interface AdminAuditEvent {
id: string;
action: string;
entityType: string | null;
entityId: string | null;
metadata: unknown;
ipAddress: string | null;
createdAt: string;
user: { id: string; email: string; username: string } | null;
}
export interface GameNodeSummary {
id: string;
name: string;
hostname: string;
status: string;
maxServers: number;
maxRamMb: number;
platformRamMb: number;
activeServers: number;
reservedRamMb: number;
availableRamMb: number;
lastHeartbeatAt: string | null;
agentVersion: string | null;
drainRequestedAt: string | null;
createdAt: string;
}
export async function getAdminDashboardStats(): Promise<AdminDashboardStats> {
return apiFetch<AdminDashboardStats>("/admin/dashboard");
}
export async function listAdminNodes(): Promise<{ nodes: GameNodeSummary[] }> {
return apiFetch<{ nodes: GameNodeSummary[] }>("/admin/nodes");
}
export async function drainAdminNode(nodeId: string): Promise<GameNodeSummary> {
return apiFetch<GameNodeSummary>(`/admin/nodes/${nodeId}/drain`, {
method: "POST",
});
}
export async function setAdminNodeMaintenance(
nodeId: string,
enabled: boolean,
): Promise<GameNodeSummary> {
return apiFetch<GameNodeSummary>(`/admin/nodes/${nodeId}/maintenance`, {
method: "POST",
body: JSON.stringify({ enabled }),
});
}
export async function searchAdminUsers(params?: {
q?: string;
limit?: number;
}): Promise<{ users: AdminUser[] }> {
const search = new URLSearchParams();
if (params?.q) search.set("q", params.q);
if (params?.limit) search.set("limit", String(params.limit));
const query = search.toString();
return apiFetch<{ users: AdminUser[] }>(
`/admin/users${query ? `?${query}` : ""}`,
);
}
export async function suspendAdminUser(userId: string): Promise<{ id: string; status: string }> {
return apiFetch<{ id: string; status: string }>(`/admin/users/${userId}/suspend`, {
method: "POST",
});
}
export async function unsuspendAdminUser(userId: string): Promise<{ id: string; status: string }> {
return apiFetch<{ id: string; status: string }>(`/admin/users/${userId}/unsuspend`, {
method: "POST",
});
}
export async function listAdminJobs(params?: {
limit?: number;
status?: string;
queue?: string;
}): Promise<{ jobs: AdminJob[] }> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", String(params.limit));
if (params?.status) search.set("status", params.status);
if (params?.queue) search.set("queue", params.queue);
const query = search.toString();
return apiFetch<{ jobs: AdminJob[] }>(`/admin/jobs${query ? `?${query}` : ""}`);
}
export async function listAdminAuditEvents(params?: {
limit?: number;
cursor?: string;
action?: string;
}): Promise<{ items: AdminAuditEvent[]; nextCursor: string | null }> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", String(params.limit));
if (params?.cursor) search.set("cursor", params.cursor);
if (params?.action) search.set("action", params.action);
const query = search.toString();
return apiFetch<{ items: AdminAuditEvent[]; nextCursor: string | null }>(
`/admin/audit${query ? `?${query}` : ""}`,
);
}

View File

@@ -102,6 +102,40 @@ async function parseProblemResponse(response: Response): Promise<ApiError> {
});
}
let csrfToken: string | null = null;
const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
async function fetchCsrfToken(): Promise<string> {
const endpoint = `${getApiUrl()}/auth/csrf`;
const response = await fetch(endpoint, {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch CSRF token");
}
const data = (await response.json()) as { token: string };
csrfToken = data.token;
return data.token;
}
async function ensureCsrfToken(method: string): Promise<void> {
if (!MUTATION_METHODS.has(method.toUpperCase())) {
return;
}
if (!csrfToken) {
await fetchCsrfToken();
}
}
export function clearCsrfToken(): void {
csrfToken = null;
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
@@ -112,12 +146,19 @@ export async function apiFetch<T>(
throw new Error("Invalid API URL configuration");
}
const method = (options.method ?? "GET").toUpperCase();
await ensureCsrfToken(method);
const headers = new Headers(options.headers);
if (options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (MUTATION_METHODS.has(method) && csrfToken) {
headers.set("x-csrf-token", csrfToken);
}
const response = await fetch(endpoint, {
...options,
credentials: "include",

View File

@@ -0,0 +1,47 @@
import { apiFetch } from "./auth";
export interface DataExportRequest {
id: string;
status: "PENDING" | "PROCESSING" | "READY" | "FAILED" | "EXPIRED";
downloadUrl: string | null;
expiresAt: string | null;
completedAt: string | null;
createdAt: string;
}
export interface AccountDeletionRequest {
id: string;
status: "PENDING" | "SCHEDULED" | "COMPLETED" | "CANCELLED";
scheduledFor: string;
completedAt: string | null;
createdAt: string;
}
export async function requestDataExport(): Promise<DataExportRequest> {
return apiFetch<DataExportRequest>("/account/compliance/export", {
method: "POST",
});
}
export async function listDataExports(): Promise<{ exports: DataExportRequest[] }> {
return apiFetch("/account/compliance/export");
}
export async function requestAccountDeletion(
confirmEmail: string,
): Promise<AccountDeletionRequest> {
return apiFetch<AccountDeletionRequest>("/account/compliance/deletion", {
method: "POST",
body: JSON.stringify({ confirmEmail }),
});
}
export async function getAccountDeletionStatus(): Promise<AccountDeletionRequest | null> {
return apiFetch<AccountDeletionRequest | null>("/account/compliance/deletion");
}
export async function cancelAccountDeletion(): Promise<AccountDeletionRequest> {
return apiFetch<AccountDeletionRequest>("/account/compliance/deletion", {
method: "DELETE",
});
}

View File

@@ -35,9 +35,28 @@ export async function listFiles(
params.set("path", path);
}
const query = params.toString();
return apiFetch<ListFilesResponse>(
`/servers/${serverId}/files${query ? `?${query}` : ""}`,
);
const response = await apiFetch<{
path: string;
entries: Array<{
name: string;
path: string;
type?: "file" | "directory";
isDir?: boolean;
size?: number;
modifiedAt?: string;
}>;
}>(`/servers/${serverId}/files${query ? `?${query}` : ""}`);
return {
path: response.path,
entries: response.entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDir: entry.isDir ?? entry.type === "directory",
size: entry.size ?? 0,
modifiedAt: entry.modifiedAt,
})),
};
}
export async function readFile(
@@ -59,3 +78,62 @@ export async function writeFile(
body: JSON.stringify(payload),
});
}
export interface UploadFilePayload {
path: string;
content: string;
encoding?: "utf-8" | "base64";
}
export interface ArchiveFilesPayload {
paths: string[];
archiveName?: string;
}
export interface UnarchiveFilePayload {
archivePath: string;
destinationPath?: string;
}
export async function deleteFile(
serverId: string,
path: string,
): Promise<void> {
const params = new URLSearchParams({ path });
await apiFetch<void>(`/servers/${serverId}/files?${params.toString()}`, {
method: "DELETE",
});
}
export async function uploadFile(
serverId: string,
payload: UploadFilePayload,
): Promise<{ path: string }> {
return apiFetch(`/servers/${serverId}/files/upload`, {
method: "POST",
body: JSON.stringify({
encoding: "base64",
...payload,
}),
});
}
export async function archiveFiles(
serverId: string,
payload: ArchiveFilesPayload,
): Promise<{ archivePath: string }> {
return apiFetch(`/servers/${serverId}/files/archive`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function unarchiveFile(
serverId: string,
payload: UnarchiveFilePayload,
): Promise<{ jobId?: string }> {
return apiFetch(`/servers/${serverId}/files/unarchive`, {
method: "POST",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,52 @@
import { apiFetch } from "./auth";
export type MemberRole = "ADMIN" | "OPERATOR" | "DEVELOPER" | "VIEWER";
export type ServerMemberRole = MemberRole | "OWNER";
export interface ServerMember {
id: string;
userId: string;
role: ServerMemberRole;
email: string;
username: string;
displayName: string | null;
acceptedAt: string | null;
}
export interface MemberListResponse {
members: ServerMember[];
}
export interface InviteMemberPayload {
email: string;
role: MemberRole;
}
export interface InviteMemberResponse {
status?: string;
message?: string;
id?: string;
userId?: string;
role?: string;
}
export async function listMembers(serverId: string): Promise<MemberListResponse> {
return apiFetch<MemberListResponse>(`/servers/${serverId}/members`);
}
export async function inviteMember(
serverId: string,
payload: InviteMemberPayload,
): Promise<InviteMemberResponse> {
return apiFetch<InviteMemberResponse>(`/servers/${serverId}/members`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function removeMember(serverId: string, memberId: string): Promise<void> {
await apiFetch<void>(`/servers/${serverId}/members/${memberId}`, {
method: "DELETE",
});
}

View File

@@ -0,0 +1,75 @@
import { apiFetch } from "./auth";
export type NotificationType =
| "SERVER_READY"
| "SERVER_START_FAILED"
| "SERVER_STOPPED"
| "IDLE_STOP_WARNING"
| "BACKUP_SUCCESS"
| "BACKUP_FAILED"
| "RESTORE_SUCCESS"
| "RESTORE_FAILED"
| "CREDITS_LOW"
| "BILLING_ISSUE"
| "INVITE"
| "NODE_INCIDENT"
| "MAINTENANCE"
| "SECURITY"
| "NEW_LOGIN"
| "TWO_FA_CHANGED";
export type NotificationChannel = "IN_APP" | "EMAIL" | "WEBHOOK" | "DISCORD";
export interface Notification {
id: string;
type: NotificationType;
channel: NotificationChannel;
title: string;
body: string;
readAt: string | null;
createdAt: string;
}
export interface NotificationListResponse {
notifications: Notification[];
unreadCount: number;
}
export interface NotificationPreference {
type: NotificationType;
channel: NotificationChannel;
enabled: boolean;
}
export interface NotificationPreferencesResponse {
preferences: NotificationPreference[];
}
export async function listNotifications(): Promise<NotificationListResponse> {
return apiFetch<NotificationListResponse>("/account/notifications");
}
export async function markNotificationRead(id: string): Promise<{ read: boolean }> {
return apiFetch(`/account/notifications/${id}/read`, {
method: "POST",
});
}
export async function markAllNotificationsRead(): Promise<{ read: boolean }> {
return apiFetch("/account/notifications/read-all", {
method: "POST",
});
}
export async function getNotificationPreferences(): Promise<NotificationPreferencesResponse> {
return apiFetch<NotificationPreferencesResponse>("/account/notifications/preferences");
}
export async function updateNotificationPreferences(
preferences: NotificationPreference[],
): Promise<NotificationPreferencesResponse> {
return apiFetch<NotificationPreferencesResponse>("/account/notifications/preferences", {
method: "PATCH",
body: JSON.stringify({ preferences }),
});
}

View File

@@ -6,12 +6,124 @@ export interface OnlinePlayer {
ping?: number;
}
export interface WhitelistEntry {
uuid: string;
name: string;
}
export interface OperatorEntry {
uuid: string;
name: string;
level?: number;
bypassesPlayerLimit?: boolean;
}
export interface BanEntry {
uuid?: string;
name: string;
reason?: string;
source?: string;
expires?: string;
}
export interface PlayerListResponse {
online: OnlinePlayer[];
maxPlayers: number;
whitelistCount: number;
whitelist: WhitelistEntry[];
operators: OperatorEntry[];
bans?: BanEntry[];
}
export interface AddWhitelistPayload {
uuid: string;
name: string;
}
export interface AddOperatorPayload {
uuid: string;
name: string;
level?: number;
bypassesPlayerLimit?: boolean;
}
export interface RemovePlayerPayload {
uuid: string;
}
export interface AddBanPayload {
name: string;
reason?: string;
}
export async function getPlayers(serverId: string): Promise<PlayerListResponse> {
return apiFetch<PlayerListResponse>(`/servers/${serverId}/players`);
}
export async function addWhitelist(
serverId: string,
payload: AddWhitelistPayload,
): Promise<{ whitelist: WhitelistEntry[] }> {
return apiFetch(`/servers/${serverId}/players/whitelist`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function removeWhitelist(
serverId: string,
payload: RemovePlayerPayload,
): Promise<{ whitelist: WhitelistEntry[] }> {
return apiFetch(`/servers/${serverId}/players/whitelist`, {
method: "DELETE",
body: JSON.stringify(payload),
});
}
export async function addOperator(
serverId: string,
payload: AddOperatorPayload,
): Promise<{ operators: OperatorEntry[] }> {
return apiFetch(`/servers/${serverId}/players/operators`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function removeOperator(
serverId: string,
payload: RemovePlayerPayload,
): Promise<{ operators: OperatorEntry[] }> {
return apiFetch(`/servers/${serverId}/players/operators`, {
method: "DELETE",
body: JSON.stringify(payload),
});
}
export async function addBan(
serverId: string,
payload: AddBanPayload,
): Promise<{ bans: BanEntry[] }> {
return apiFetch(`/servers/${serverId}/players/bans`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function removeBan(
serverId: string,
payload: { name: string },
): Promise<{ bans: BanEntry[] }> {
return apiFetch(`/servers/${serverId}/players/bans`, {
method: "DELETE",
body: JSON.stringify(payload),
});
}
export async function kickPlayer(
serverId: string,
payload: AddBanPayload,
): Promise<{ kicked: boolean }> {
return apiFetch(`/servers/${serverId}/players/kick`, {
method: "POST",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,78 @@
import { apiFetch } from "./auth";
export type ScheduleTaskType =
| "START"
| "STOP"
| "RESTART"
| "BACKUP"
| "CONSOLE_COMMAND"
| "UPDATE_CHECK"
| "MAINTENANCE";
export interface ScheduledTask {
id: string;
taskType: ScheduleTaskType;
payload: Record<string, unknown> | null;
}
export interface Schedule {
id: string;
name: string;
timezone: string;
cronExpr: string;
enabled: boolean;
nextRunAt: string | null;
lastRunAt: string | null;
tasks: ScheduledTask[];
}
export interface ScheduleListResponse {
schedules: Schedule[];
}
export interface CreateSchedulePayload {
name: string;
timezone?: string;
cronExpr: string;
enabled?: boolean;
tasks: Array<{
taskType: ScheduleTaskType;
payload?: Record<string, unknown>;
}>;
}
export type UpdateSchedulePayload = Partial<CreateSchedulePayload>;
export async function listSchedules(serverId: string): Promise<ScheduleListResponse> {
return apiFetch<ScheduleListResponse>(`/servers/${serverId}/schedules`);
}
export async function createSchedule(
serverId: string,
payload: CreateSchedulePayload,
): Promise<Schedule> {
return apiFetch<Schedule>(`/servers/${serverId}/schedules`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function updateSchedule(
serverId: string,
scheduleId: string,
payload: UpdateSchedulePayload,
): Promise<Schedule> {
return apiFetch<Schedule>(`/servers/${serverId}/schedules/${scheduleId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function deleteSchedule(
serverId: string,
scheduleId: string,
): Promise<void> {
await apiFetch<void>(`/servers/${serverId}/schedules/${scheduleId}`, {
method: "DELETE",
});
}

View File

@@ -0,0 +1,27 @@
import { apiFetch } from "./auth";
import type { SoftwareFamily } from "./servers";
export interface ReinstallServerPayload {
softwareFamily: SoftwareFamily;
minecraftVersion: string;
createBackup?: boolean;
}
export interface ReinstallServerResponse {
serverId: string;
jobId: string;
status: string;
}
export async function reinstallServer(
serverId: string,
payload: ReinstallServerPayload,
): Promise<ReinstallServerResponse> {
return apiFetch<ReinstallServerResponse>(`/servers/${serverId}/software/reinstall`, {
method: "POST",
body: JSON.stringify({
createBackup: true,
...payload,
}),
});
}

View File

@@ -4,7 +4,15 @@ import { routing } from "./i18n/routing";
const intlMiddleware = createMiddleware(routing);
const protectedPaths = ["/dashboard", "/security", "/servers"];
const protectedPaths = [
"/dashboard",
"/security",
"/servers",
"/billing",
"/profile",
"/notifications",
"/admin",
];
function getPathWithoutLocale(pathname: string): string {
if (pathname === "/en" || pathname.startsWith("/en/")) {
@@ -22,7 +30,31 @@ function buildLocalizedPath(path: string, pathname: string): string {
return path;
}
export default function middleware(request: NextRequest) {
function getApiUrl(): string {
return process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001/api/v1";
}
async function fetchCurrentUserRoles(
sessionValue: string,
): Promise<string[] | null> {
try {
const response = await fetch(`${getApiUrl()}/me`, {
headers: { Cookie: `hgc_session=${sessionValue}` },
cache: "no-store",
});
if (!response.ok) {
return null;
}
const data = (await response.json()) as { user?: { roles?: string[] } };
return data.user?.roles ?? null;
} catch {
return null;
}
}
export default async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const pathWithoutLocale = getPathWithoutLocale(pathname);
@@ -41,6 +73,20 @@ export default function middleware(request: NextRequest) {
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
const isAdminRoute =
pathWithoutLocale === "/admin" ||
pathWithoutLocale.startsWith("/admin/");
if (isAdminRoute) {
const roles = await fetchCurrentUserRoles(session.value);
if (!roles?.includes("SUPER_ADMIN")) {
const dashboardUrl = request.nextUrl.clone();
dashboardUrl.pathname = buildLocalizedPath("/dashboard", pathname);
return NextResponse.redirect(dashboardUrl);
}
}
}
return intlMiddleware(request);