Compare commits
5
Commits
ca26b9d0ce
...
f55e5ff2fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55e5ff2fe | ||
|
|
6f0d6e1ca9 | ||
|
|
8e566d06e0 | ||
|
|
4ba53a239f | ||
|
|
dcf9b0d58d |
+34
-11
@@ -172,6 +172,38 @@ Firmen-Gitea nebeneinander.
|
|||||||
|
|
||||||
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
|
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
|
||||||
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
|
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
|
||||||
|
|
||||||
|
### 4.4 Automatischer Abgleich
|
||||||
|
|
||||||
|
Standardmäßig **aus** — jeder Durchlauf verbraucht fremde API-Kontingente, das soll eine
|
||||||
|
bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
|
||||||
|
|
||||||
|
| Einstellung | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| Abstand | 15 Minuten bis täglich |
|
||||||
|
| Projekte je Durchlauf | 1–100, Voreinstellung 25 |
|
||||||
|
| Fertige und archivierte auslassen | Dort ändert sich selten noch etwas |
|
||||||
|
|
||||||
|
Wie der Durchlauf arbeitet:
|
||||||
|
|
||||||
|
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
|
||||||
|
ein Durchlauf fällig ist, und startet ihn. Kein zusätzlicher Container, kein externer
|
||||||
|
Cron. Zwei Durchläufe überschneiden sich nie.
|
||||||
|
- **Am längsten nicht abgeglichene zuerst.** Was nicht in den Durchlauf passt, kommt beim
|
||||||
|
nächsten Mal.
|
||||||
|
- **Zurückhalten nach Fehlschlägen:** Nach jedem Fehlversuch verdoppelt sich der Abstand
|
||||||
|
bis zum nächsten Versuch (gedeckelt beim 16-fachen). Ein Repository mit abgelaufenem
|
||||||
|
Token blockiert damit nicht dauerhaft das Kontingent.
|
||||||
|
- Kurze Pause zwischen zwei Projekten, damit kein Schwall entsteht.
|
||||||
|
- **Pro Projekt abschaltbar** über einen Schalter im Projektdialog. Manuell
|
||||||
|
synchronisieren geht unabhängig davon immer.
|
||||||
|
- Der Adminbereich zeigt letzten und nächsten Durchlauf sowie einen Kurzbericht
|
||||||
|
(aktualisiert / fehlgeschlagen / zurückgestellt) und hat einen Knopf **Jetzt abgleichen**.
|
||||||
|
- Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
||||||
|
`POST /api/admin/sync/run` per eigenem Cron auf.
|
||||||
|
|
||||||
|
Der Abgleich läuft über alle Konten hinweg — er ist ein Serverauftrag, keine
|
||||||
|
Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
|
||||||
- **URL-Erkennung** für `https://…`, `git@host:owner/repo.git` und GitLab-Weboberflächen-URLs
|
- **URL-Erkennung** für `https://…`, `git@host:owner/repo.git` und GitLab-Weboberflächen-URLs
|
||||||
mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten.
|
mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten.
|
||||||
- **Ohne Token** funktionieren öffentliche Repositories, mit deutlich strengerem Rate-Limit.
|
- **Ohne Token** funktionieren öffentliche Repositories, mit deutlich strengerem Rate-Limit.
|
||||||
@@ -270,14 +302,5 @@ Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
|
|||||||
|
|
||||||
## 7. Ideen für später
|
## 7. Ideen für später
|
||||||
|
|
||||||
Bewusst nicht umgesetzt, aber vorgemerkt:
|
Was als Nächstes kommen könnte, steht mit Aufwandsschätzung und offenen Fragen in
|
||||||
|
**[ROADMAP.md](ROADMAP.md)** — gedacht zum Durchsprechen und schrittweisen Abarbeiten.
|
||||||
- Kanban-Board mit Drag & Drop zwischen den Statusspalten
|
|
||||||
- Webhooks, damit Pushes den Snapshot ohne Knopfdruck aktualisieren
|
|
||||||
- Markdown-Rendering in Beschreibung und Notizen
|
|
||||||
- Volltextsuche über Notizen hinweg
|
|
||||||
- Export eines Projekts als Markdown
|
|
||||||
- Automatischer Sync im Hintergrund (Intervall pro Verbindung)
|
|
||||||
- Passwort-Zurücksetzen per E-Mail (aktuell setzt ein Administrator das Passwort neu)
|
|
||||||
- Zwei-Faktor per TOTP als Alternative zum Passkey
|
|
||||||
- Geteilte Projekte zwischen Konten (heute ist die Trennung absichtlich hart)
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ den Admin-Einstellungen.
|
|||||||
Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst dahinter,
|
Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst dahinter,
|
||||||
Access Tokens werden verschlüsselt abgelegt.
|
Access Tokens werden verschlüsselt abgelegt.
|
||||||
|
|
||||||
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**.
|
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**,
|
||||||
|
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -181,6 +182,21 @@ Nebula auffindbare Credentials nutzt.
|
|||||||
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
|
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
|
||||||
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
|
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
|
||||||
|
|
||||||
|
### Automatisch statt auf Knopfdruck
|
||||||
|
|
||||||
|
Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den
|
||||||
|
Hintergrund verlegen: Abstand von 15 Minuten bis täglich, Anzahl Projekte je Durchlauf,
|
||||||
|
und wahlweise ohne fertige und archivierte Projekte. Standardmäßig ist er **aus**, weil
|
||||||
|
jeder Durchlauf API-Kontingent verbraucht.
|
||||||
|
|
||||||
|
Der Zeitgeber läuft im Serverprozess — kein zusätzlicher Container, kein externer Cron.
|
||||||
|
Nach Fehlschlägen wird ein Repository mit wachsendem Abstand zurückgehalten, damit ein
|
||||||
|
abgelaufener Token nicht dauerhaft Kontingent verbrennt. Einzelne Projekte lassen sich im
|
||||||
|
Projektdialog ausnehmen; manuell synchronisieren geht immer.
|
||||||
|
|
||||||
|
Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
||||||
|
`POST /api/admin/sync/run` per eigenem Cron auf.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
@@ -250,7 +266,8 @@ ausschließlich Daten des angemeldeten Kontos.
|
|||||||
| `POST` | `/api/account/password` | Eigenes Passwort setzen |
|
| `POST` | `/api/account/password` | Eigenes Passwort setzen |
|
||||||
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
|
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
|
||||||
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
|
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
|
||||||
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart und Registrierung (Admin) |
|
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart, Registrierung, Abgleich (Admin) |
|
||||||
|
| `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (Admin) |
|
||||||
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
|
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
|
||||||
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
|
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
|
||||||
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
|
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
|
||||||
|
|||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Sammlung dessen, was als Nächstes kommen könnte — zum Durchsprechen und
|
||||||
|
Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
|
||||||
|
|
||||||
|
**Aufwand:** `S` = überschaubar · `M` = halber Tag · `L` = größerer Brocken
|
||||||
|
|
||||||
|
Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Als Nächstes empfohlen
|
||||||
|
|
||||||
|
### 1.1 Export und Import `M`
|
||||||
|
|
||||||
|
Dein Vorschlag, in vier Stufen — jede für sich nutzbar:
|
||||||
|
|
||||||
|
| Stufe | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| **Projekt-Export** | Ein Projekt oder alle als JSON: Stammdaten, Notizen, Repo-Verknüpfung. Ein Klick auf der Projektseite, einer im Dashboard für alles. |
|
||||||
|
| **Projekt-Import** | JSON hochladen, Vorschau, dann übernehmen. Konfliktstrategie wählbar: überspringen · umbenennen · überschreiben. |
|
||||||
|
| **Markdown-Export** | Ein Projekt als lesbares `.md` — Beschreibung, Notizen, aktueller Git-Stand. Gut zum Archivieren oder für ein README. |
|
||||||
|
| **Voll-Backup** | `pg_dump` aus dem Container heraus, per Knopf im Adminbereich als Datei. Plus dokumentierter Rückweg. |
|
||||||
|
|
||||||
|
**Offene Fragen, die wir klären sollten:**
|
||||||
|
- **Git-Tokens mitexportieren?** Mein Vorschlag: **nein**. Die Verbindung wird
|
||||||
|
mitexportiert (Name, System, Basis-URL), das Token muss beim Import neu
|
||||||
|
hinterlegt werden. Ein Export-JSON landet sonst schnell irgendwo, wo es
|
||||||
|
nicht hingehört.
|
||||||
|
- **Reichweite:** Export der eigenen Daten für jeden — Voll-Backup nur für
|
||||||
|
Administratoren?
|
||||||
|
- **Snapshots mitnehmen?** Ich würde sie weglassen: sie sind reproduzierbar
|
||||||
|
und blähen die Datei auf. Ein Sync nach dem Import holt sie zurück.
|
||||||
|
|
||||||
|
### 1.2 Markdown in Beschreibung und Notizen `S`
|
||||||
|
|
||||||
|
Heute wird alles als reiner Text dargestellt. Mit Markdown werden aus
|
||||||
|
`- [ ] Aufgabe` echte Checklisten, Codeblöcke bekommen Hervorhebung, Links
|
||||||
|
werden klickbar.
|
||||||
|
|
||||||
|
Zu beachten: HTML muss beim Rendern entfernt werden, sonst reißt es ein
|
||||||
|
XSS-Loch. Mit `react-markdown` plus `rehype-sanitize` ist das sauber lösbar.
|
||||||
|
|
||||||
|
### 1.3 Volltextsuche über Notizen `S`
|
||||||
|
|
||||||
|
Die Suche im Board deckt Namen, Kurzbeschreibung und Tags ab — Notizen nicht.
|
||||||
|
Postgres kann das nativ (`tsvector` mit deutscher Konfiguration), also ohne
|
||||||
|
zusätzlichen Dienst.
|
||||||
|
|
||||||
|
### 1.4 Kanban-Board mit Drag & Drop `M`
|
||||||
|
|
||||||
|
Die gruppierte Ansicht gibt es schon, aber Statuswechsel geht nur über das
|
||||||
|
Menü. Spalten mit Ziehen und Ablegen wären der natürlichere Weg.
|
||||||
|
|
||||||
|
Bibliothek: `@dnd-kit` — leichtgewichtig, tastaturbedienbar. Auf Mobilgeräten
|
||||||
|
bleibt die Listenansicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · Danach
|
||||||
|
|
||||||
|
### 2.1 Offene Pull Requests und Issues als Liste `S`
|
||||||
|
|
||||||
|
Aktuell werden sie nur gezählt. Titel, Autor, Alter und Link wären deutlich
|
||||||
|
nützlicher — die Daten holen wir ohnehin schon ab.
|
||||||
|
|
||||||
|
### 2.2 CI-Status je Branch `M`
|
||||||
|
|
||||||
|
Grün oder rot direkt an der Projektkarte, gespeist aus GitHub Actions,
|
||||||
|
GitLab Pipelines und Gitea Actions. Erfordert je Provider einen weiteren
|
||||||
|
Endpunkt und einen zusätzlichen Token-Scope.
|
||||||
|
|
||||||
|
### 2.3 Webhooks statt Polling `M`
|
||||||
|
|
||||||
|
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
|
||||||
|
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
|
||||||
|
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
|
||||||
|
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
|
||||||
|
|
||||||
|
### 2.4 Commit-Aktivität als Verlauf `S`
|
||||||
|
|
||||||
|
Kleine Sparkline auf der Karte, Heatmap auf der Projektseite. Datenbasis ist
|
||||||
|
da, es fehlt nur die Darstellung.
|
||||||
|
|
||||||
|
### 2.5 Aufgaben und Checklisten `M`
|
||||||
|
|
||||||
|
Eigene Entität statt Freitext-Notiz, mit Häkchen und automatisch
|
||||||
|
berechnetem Fortschritt. Ersetzt das manuelle Schieben des Reglers.
|
||||||
|
|
||||||
|
### 2.6 Benachrichtigungen `M`
|
||||||
|
|
||||||
|
Bei Sync-Fehlern, neuen Releases oder Projekten, die lange stillstehen.
|
||||||
|
Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit dem
|
||||||
|
automatischen Abgleich ergibt das ein brauchbares Frühwarnsystem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 · Größere Brocken
|
||||||
|
|
||||||
|
### 3.1 Automatisierte Tests und CI `L`
|
||||||
|
|
||||||
|
Aktuell prüfe ich jede Änderung von Hand — Typecheck, Build, API-Durchläufe,
|
||||||
|
Browser-Screenshots. Das gehört in eine Pipeline:
|
||||||
|
|
||||||
|
- **Vitest** für die Logik: URL-Parsing, SSRF-Regeln, Passwort-Hashing,
|
||||||
|
Backoff-Berechnung
|
||||||
|
- **Playwright** für die Wege durch die App: Einrichtung, Anmeldung,
|
||||||
|
Datentrennung
|
||||||
|
- **GitHub Actions** oder Gitea Actions, je nachdem wohin du das Repo legst
|
||||||
|
|
||||||
|
Aus meiner Sicht der wertvollste Punkt der ganzen Liste, sobald das Projekt
|
||||||
|
länger lebt: er macht alles Weitere billiger und sicherer.
|
||||||
|
|
||||||
|
### 3.2 Mehrere Repositories je Projekt `M`
|
||||||
|
|
||||||
|
Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischentabelle
|
||||||
|
und Anpassungen in der Anzeige.
|
||||||
|
|
||||||
|
### 3.3 Zwei-Faktor per TOTP `M`
|
||||||
|
|
||||||
|
Als Alternative zum Passkey — nützlich, wenn die Instanz aus dem Internet
|
||||||
|
erreichbar ist und nicht jedes Gerät Passkeys kann.
|
||||||
|
|
||||||
|
### 3.4 Passwort-Zurücksetzen per E-Mail `M`
|
||||||
|
|
||||||
|
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
|
||||||
|
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
|
||||||
|
|
||||||
|
### 3.5 Mehrsprachigkeit `L`
|
||||||
|
|
||||||
|
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
|
||||||
|
Voraussetzung, falls du das Projekt veröffentlichen willst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 · Kleinigkeiten
|
||||||
|
|
||||||
|
| Idee | Aufwand | Nutzen |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Schnellsuche per `Strg+K` | `S` | Springt direkt zu Projekt oder Aktion |
|
||||||
|
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal |
|
||||||
|
| Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert |
|
||||||
|
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
|
||||||
|
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
|
||||||
|
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
|
||||||
|
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
|
||||||
|
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
|
||||||
|
| `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7 |
|
||||||
|
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 · Bewusst nicht
|
||||||
|
|
||||||
|
Damit klar ist, was **nicht** kommt und warum:
|
||||||
|
|
||||||
|
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
|
||||||
|
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
|
||||||
|
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
|
||||||
|
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
|
||||||
|
Übersicht, keine Entwicklungsumgebung.
|
||||||
|
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
|
||||||
|
- **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist
|
||||||
|
der Punkt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erledigt
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Dashboard mit Status, Notizen, Git-Anbindung | ✓ |
|
||||||
|
| GitHub, GitLab und Gitea | ✓ |
|
||||||
|
| Benutzerverwaltung, Passwort und Passkeys | ✓ |
|
||||||
|
| Einzel- und Mehrbenutzerbetrieb | ✓ |
|
||||||
|
| Härtung: CSRF, CSP, SSRF, Ratenbegrenzung, Datentrennung | ✓ |
|
||||||
|
| Docker- und Standalone-Betrieb | ✓ |
|
||||||
|
| Automatischer Abgleich mit Zeitplan | ✓ |
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "AppSettings" ADD COLUMN "autoSyncActiveOnly" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "autoSyncBatchSize" INTEGER NOT NULL DEFAULT 25,
|
||||||
|
ADD COLUMN "autoSyncEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "autoSyncIntervalMinutes" INTEGER NOT NULL DEFAULT 60,
|
||||||
|
ADD COLUMN "lastAutoSyncAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "lastAutoSyncReport" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "autoSync" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "syncFailureCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -137,6 +137,24 @@ model AppSettings {
|
|||||||
allowRegistration Boolean @default(false)
|
allowRegistration Boolean @default(false)
|
||||||
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
|
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
|
||||||
rpName String @default("Nebula")
|
rpName String @default("Nebula")
|
||||||
|
|
||||||
|
// ── Automatischer Abgleich mit den Git-Systemen ────────────
|
||||||
|
/// Standardmaessig aus: der Abgleich verbraucht fremde API-Kontingente,
|
||||||
|
/// das soll eine bewusste Entscheidung sein.
|
||||||
|
autoSyncEnabled Boolean @default(false)
|
||||||
|
/// Abstand zwischen zwei Durchlaeufen in Minuten.
|
||||||
|
autoSyncIntervalMinutes Int @default(60)
|
||||||
|
/// Fertige und archivierte Projekte auslassen – dort aendert sich selten
|
||||||
|
/// noch etwas.
|
||||||
|
autoSyncActiveOnly Boolean @default(true)
|
||||||
|
/// Hoechstzahl Projekte je Durchlauf, damit ein grosses Board nicht auf
|
||||||
|
/// einen Schlag in ein Rate-Limit laeuft.
|
||||||
|
autoSyncBatchSize Int @default(25)
|
||||||
|
|
||||||
|
lastAutoSyncAt DateTime?
|
||||||
|
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
|
||||||
|
lastAutoSyncReport Json?
|
||||||
|
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +225,12 @@ model Project {
|
|||||||
|
|
||||||
lastSyncedAt DateTime?
|
lastSyncedAt DateTime?
|
||||||
syncError String?
|
syncError String?
|
||||||
|
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
|
||||||
|
/// einem Repo, dessen Token staendig Fehler wirft.
|
||||||
|
autoSync Boolean @default(true)
|
||||||
|
/// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach
|
||||||
|
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
|
||||||
|
syncFailureCount Int @default(0)
|
||||||
|
|
||||||
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
|
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
|
||||||
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
||||||
|
|||||||
+27
-3
@@ -1,5 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { AdminManager, type AdminData } from "@/components/admin/AdminManager";
|
import {
|
||||||
|
AdminManager,
|
||||||
|
type AdminData,
|
||||||
|
type AutoSyncReport,
|
||||||
|
} from "@/components/admin/AdminManager";
|
||||||
import { requireAdminPage } from "@/lib/auth/guard";
|
import { requireAdminPage } from "@/lib/auth/guard";
|
||||||
import { getAppSettings } from "@/lib/auth/settings";
|
import { getAppSettings } from "@/lib/auth/settings";
|
||||||
import { userSafeSelect } from "@/lib/auth/users";
|
import { userSafeSelect } from "@/lib/auth/users";
|
||||||
@@ -15,12 +19,23 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const admin = await requireAdminPage();
|
const admin = await requireAdminPage();
|
||||||
|
|
||||||
const [settings, users] = await Promise.all([
|
const [settings, users, eligible] = await Promise.all([
|
||||||
getAppSettings(),
|
getAppSettings(),
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
select: userSafeSelect,
|
select: userSafeSelect,
|
||||||
orderBy: [{ role: "asc" }, { username: "asc" }],
|
orderBy: [{ role: "asc" }, { username: "asc" }],
|
||||||
}),
|
}),
|
||||||
|
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
|
||||||
|
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
|
||||||
|
prisma.project.count({
|
||||||
|
where: {
|
||||||
|
autoSync: true,
|
||||||
|
OR: [
|
||||||
|
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
||||||
|
{ repoUrl: { not: null } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data: AdminData = {
|
const data: AdminData = {
|
||||||
@@ -30,6 +45,15 @@ export default async function AdminPage() {
|
|||||||
currentUserId: admin.id,
|
currentUserId: admin.id,
|
||||||
appUrl: APP_URL,
|
appUrl: APP_URL,
|
||||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||||
|
autoSync: {
|
||||||
|
enabled: settings.autoSyncEnabled,
|
||||||
|
intervalMinutes: settings.autoSyncIntervalMinutes,
|
||||||
|
activeOnly: settings.autoSyncActiveOnly,
|
||||||
|
batchSize: settings.autoSyncBatchSize,
|
||||||
|
lastRunAt: settings.lastAutoSyncAt,
|
||||||
|
lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null,
|
||||||
|
eligible,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +66,7 @@ export default async function AdminPage() {
|
|||||||
Admin-Einstellungen
|
Admin-Einstellungen
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
Betriebsart, Registrierung und Benutzerkonten.
|
Betriebsart, automatischer Abgleich und Benutzerkonten.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,14 @@ export async function PATCH(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { mode, allowRegistration } = parsed.data;
|
const {
|
||||||
|
mode,
|
||||||
|
allowRegistration,
|
||||||
|
autoSyncEnabled,
|
||||||
|
autoSyncIntervalMinutes,
|
||||||
|
autoSyncActiveOnly,
|
||||||
|
autoSyncBatchSize,
|
||||||
|
} = parsed.data;
|
||||||
|
|
||||||
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
||||||
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
|
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
|
||||||
@@ -45,18 +52,27 @@ export async function PATCH(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoSyncFields = {
|
||||||
|
...(autoSyncEnabled !== undefined ? { autoSyncEnabled } : {}),
|
||||||
|
...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}),
|
||||||
|
...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}),
|
||||||
|
...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
const settings = await prisma.appSettings.upsert({
|
const settings = await prisma.appSettings.upsert({
|
||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
create: {
|
create: {
|
||||||
id: "singleton",
|
id: "singleton",
|
||||||
...(mode ? { mode } : {}),
|
...(mode ? { mode } : {}),
|
||||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||||
|
...autoSyncFields,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
...(mode ? { mode } : {}),
|
...(mode ? { mode } : {}),
|
||||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||||
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
|
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
|
||||||
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
|
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
|
||||||
|
...autoSyncFields,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdminApi } from "@/lib/auth/guard";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||||
|
import { isAutoSyncRunning, runAutoSyncPass } from "@/lib/sync/scheduler";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoesst einen Abgleich sofort an – unabhaengig vom Zeitplan.
|
||||||
|
*
|
||||||
|
* Dient zwei Zwecken: dem Knopf "Jetzt abgleichen" im Adminbereich und
|
||||||
|
* einem externen Zeitgeber, falls jemand lieber von aussen taktet als den
|
||||||
|
* eingebauten Automatismus zu nutzen.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const limited = rateLimit(
|
||||||
|
`sync-run:${clientIp(request)}`,
|
||||||
|
LIMITS.outbound.limit,
|
||||||
|
LIMITS.outbound.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
if (isAutoSyncRunning()) {
|
||||||
|
return jsonError("Es laeuft bereits ein Abgleich.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await runAutoSyncPass("manuell");
|
||||||
|
if (!report) {
|
||||||
|
return jsonError("Es laeuft bereits ein Abgleich.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ report });
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ export async function POST(request: Request) {
|
|||||||
email: email ?? null,
|
email: email ?? null,
|
||||||
passwordHash: await hashPassword(password),
|
passwordHash: await hashPassword(password),
|
||||||
role: "USER",
|
role: "USER",
|
||||||
|
// Die Registrierung meldet direkt an.
|
||||||
|
lastLoginAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export async function POST(request: Request) {
|
|||||||
email: email ?? null,
|
email: email ?? null,
|
||||||
passwordHash: await hashPassword(password),
|
passwordHash: await hashPassword(password),
|
||||||
role: "ADMIN",
|
role: "ADMIN",
|
||||||
|
// Die Einrichtung meldet direkt an – sonst stuende im Adminbereich
|
||||||
|
// "noch nie angemeldet", obwohl gerade eine Sitzung laeuft.
|
||||||
|
lastLoginAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export async function PATCH(request: Request, { params }: Params) {
|
|||||||
"accent",
|
"accent",
|
||||||
"tags",
|
"tags",
|
||||||
"favorite",
|
"favorite",
|
||||||
|
"autoSync",
|
||||||
"connectionId",
|
"connectionId",
|
||||||
] as const) {
|
] as const) {
|
||||||
if (data[key] !== undefined) update[key] = data[key];
|
if (data[key] !== undefined) update[key] = data[key];
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export async function POST(request: Request) {
|
|||||||
accent: data.accent,
|
accent: data.accent,
|
||||||
tags: data.tags,
|
tags: data.tags,
|
||||||
favorite: data.favorite,
|
favorite: data.favorite,
|
||||||
|
autoSync: data.autoSync,
|
||||||
repoUrl: data.repoUrl ?? null,
|
repoUrl: data.repoUrl ?? null,
|
||||||
repoOwner,
|
repoOwner,
|
||||||
repoName,
|
repoName,
|
||||||
|
|||||||
@@ -69,6 +69,26 @@
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flaeche fuer alles, was ueber dem Inhalt schwebt: Menues, Dialoge.
|
||||||
|
*
|
||||||
|
* Bewusst nahezu deckend statt Glas. Ein Overlay muss lesbar sein, egal
|
||||||
|
* was darunter liegt - bei 3,5 % Deckkraft schien der Inhalt dahinter
|
||||||
|
* durch. backdrop-filter hilft hier nicht: liegt das Element in einem
|
||||||
|
* Vorfahren, der selbst backdrop-filter setzt (unsere Karten), bleibt die
|
||||||
|
* Weichzeichnung wirkungslos.
|
||||||
|
*/
|
||||||
|
.popover {
|
||||||
|
/* Vollstaendig deckend: bei 97 % schimmerte darunterliegender Text
|
||||||
|
(die Schaltflaechen "Bearbeiten" und "Loeschen") noch durch. */
|
||||||
|
background: #090b18;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 0 rgba(255, 255, 255, 0.07) inset,
|
||||||
|
0 28px 70px -24px rgba(0, 0, 0, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Text & Akzente ───────────────────────────────────────── */
|
/* ── Text & Akzente ───────────────────────────────────────── */
|
||||||
|
|
||||||
.text-gradient {
|
.text-gradient {
|
||||||
@@ -190,4 +210,61 @@
|
|||||||
background: #0a0c1a;
|
background: #0a0c1a;
|
||||||
color: #e6e8f5;
|
color: #e6e8f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schieberegler. Der native Regler zeichnet die Spur rechts vom Griff in
|
||||||
|
* einem hellen Standardton - im dunklen Layout ein grelles Band. Deshalb
|
||||||
|
* vollstaendig selbst gezeichnet: der gefuellte Anteil kommt aus der
|
||||||
|
* Variablen --fill, die die Komponente aus dem Wert setzt.
|
||||||
|
*/
|
||||||
|
.range {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#8b5cf6 0%,
|
||||||
|
#d946ef var(--fill, 0%),
|
||||||
|
rgba(255, 255, 255, 0.1) var(--fill, 0%),
|
||||||
|
rgba(255, 255, 255, 0.1) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.range:focus-visible {
|
||||||
|
outline: 2px solid rgba(167, 139, 250, 0.8);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f5f3ff;
|
||||||
|
border: 2px solid #7c3aed;
|
||||||
|
box-shadow: 0 0 14px -2px rgba(168, 85, 247, 0.9);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-webkit-slider-thumb:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-moz-range-thumb {
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f5f3ff;
|
||||||
|
border: 2px solid #7c3aed;
|
||||||
|
box-shadow: 0 0 14px -2px rgba(168, 85, 247, 0.9);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-moz-range-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -63,7 +63,9 @@ export default async function DashboardPage() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
label="Ideen"
|
label="Ideen"
|
||||||
value={ideas}
|
value={ideas}
|
||||||
hint={STATUS_META.IDEA.description}
|
// Kurzer Hinweis statt der langen Statusbeschreibung – die wurde in
|
||||||
|
// der schmalen Kachel mitten im Wort abgeschnitten.
|
||||||
|
hint="Noch nichts gebaut"
|
||||||
icon={Lightbulb}
|
icon={Lightbulb}
|
||||||
color={STATUS_META.IDEA.color}
|
color={STATUS_META.IDEA.color}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface FormState {
|
|||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
repoOwner: string;
|
repoOwner: string;
|
||||||
repoName: string;
|
repoName: string;
|
||||||
|
autoSync: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
const EMPTY: FormState = {
|
||||||
@@ -47,6 +48,7 @@ const EMPTY: FormState = {
|
|||||||
repoUrl: "",
|
repoUrl: "",
|
||||||
repoOwner: "",
|
repoOwner: "",
|
||||||
repoName: "",
|
repoName: "",
|
||||||
|
autoSync: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
|
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
|
||||||
@@ -87,6 +89,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
|||||||
repoUrl: project.repoUrl ?? "",
|
repoUrl: project.repoUrl ?? "",
|
||||||
repoOwner: project.repoOwner ?? "",
|
repoOwner: project.repoOwner ?? "",
|
||||||
repoName: project.repoName ?? "",
|
repoName: project.repoName ?? "",
|
||||||
|
autoSync: project.autoSync,
|
||||||
}
|
}
|
||||||
: EMPTY,
|
: EMPTY,
|
||||||
);
|
);
|
||||||
@@ -154,6 +157,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
|||||||
repoUrl: form.repoUrl,
|
repoUrl: form.repoUrl,
|
||||||
repoOwner: form.repoOwner,
|
repoOwner: form.repoOwner,
|
||||||
repoName: form.repoName,
|
repoName: form.repoName,
|
||||||
|
autoSync: form.autoSync,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -287,7 +291,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
|||||||
step={5}
|
step={5}
|
||||||
value={form.progress}
|
value={form.progress}
|
||||||
onChange={(event) => update("progress", Number(event.target.value))}
|
onChange={(event) => update("progress", Number(event.target.value))}
|
||||||
className="w-full accent-violet-500"
|
className="range"
|
||||||
|
style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -413,6 +418,22 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.autoSync}
|
||||||
|
onChange={(event) => update("autoSync", event.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 accent-violet-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Am automatischen Abgleich teilnehmen
|
||||||
|
<span className="mt-0.5 block text-[11px] text-slate-600">
|
||||||
|
Wirkt nur, wenn der Abgleich im Adminbereich eingeschaltet ist. Manuell
|
||||||
|
synchronisieren geht unabhaengig davon immer.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{repoError ? <p className="text-xs text-rose-300">{repoError}</p> : null}
|
{repoError ? <p className="text-xs text-rose-300">{repoError}</p> : null}
|
||||||
|
|
||||||
{repoPickerOpen ? (
|
{repoPickerOpen ? (
|
||||||
|
|||||||
@@ -193,13 +193,8 @@ export function ProjectHeader({
|
|||||||
<span>Fortschritt</span>
|
<span>Fortschritt</span>
|
||||||
<span className="tabular-nums text-slate-300">{progress}%</span>
|
<span className="tabular-nums text-slate-300">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
|
||||||
<div className="h-2 w-full overflow-hidden rounded-full bg-white/6">
|
gezeichneten Balken: so gibt es einen sichtbaren Griff. */}
|
||||||
<div
|
|
||||||
className="accent-bar h-full rounded-full transition-[width] duration-300"
|
|
||||||
style={{ width: `${Math.max(1, progress)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -211,10 +206,10 @@ export function ProjectHeader({
|
|||||||
onTouchEnd={() => patch({ progress })}
|
onTouchEnd={() => patch({ progress })}
|
||||||
onKeyUp={() => patch({ progress })}
|
onKeyUp={() => patch({ progress })}
|
||||||
aria-label="Fortschritt anpassen"
|
aria-label="Fortschritt anpassen"
|
||||||
className="absolute inset-0 h-2 w-full cursor-pointer opacity-0"
|
className="range"
|
||||||
|
style={{ "--fill": `${progress}%` } as React.CSSProperties}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProjectDialog
|
<ProjectDialog
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useRef, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
||||||
import type { ProjectStatus } from "@prisma/client";
|
import type { ProjectStatus } from "@prisma/client";
|
||||||
|
import { Popover } from "@/components/ui/Popover";
|
||||||
import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status";
|
import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export function StatusSelect({
|
|||||||
onChanged,
|
onChanged,
|
||||||
}: StatusSelectProps) {
|
}: StatusSelectProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [current, setCurrent] = useState<ProjectStatus>(status);
|
const [current, setCurrent] = useState<ProjectStatus>(status);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
@@ -54,10 +56,12 @@ export function StatusSelect({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
// Die Karte im Board ist ein Link – der darf hier nicht ausloesen.
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setOpen((value) => !value);
|
setOpen((value) => !value);
|
||||||
@@ -82,21 +86,12 @@ export function StatusSelect({
|
|||||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open ? (
|
<Popover
|
||||||
<>
|
anchorRef={triggerRef}
|
||||||
<button
|
open={open}
|
||||||
type="button"
|
onClose={() => setOpen(false)}
|
||||||
aria-label="Auswahl schliessen"
|
|
||||||
className="fixed inset-0 z-30 cursor-default"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
role="listbox"
|
role="listbox"
|
||||||
className="glass fade-up absolute right-0 z-40 mt-2 w-60 overflow-hidden p-1.5"
|
width={264}
|
||||||
>
|
>
|
||||||
{STATUS_ORDER.map((value) => {
|
{STATUS_ORDER.map((value) => {
|
||||||
const option = STATUS_META[value];
|
const option = STATUS_META[value];
|
||||||
@@ -114,7 +109,7 @@ export function StatusSelect({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
|
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
|
||||||
active ? "bg-white/8" : "hover:bg-white/5",
|
active ? "bg-white/8" : "hover:bg-white/6",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -133,9 +128,7 @@ export function StatusSelect({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
UserRound,
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Popover } from "@/components/ui/Popover";
|
||||||
import type { SessionUser } from "@/lib/auth/session";
|
import type { SessionUser } from "@/lib/auth/session";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -85,23 +86,7 @@ function UserMenu({ user }: { user: SessionUser }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function onPointerDown(event: MouseEvent) {
|
|
||||||
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
|
|
||||||
}
|
|
||||||
function onKey(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") setOpen(false);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", onPointerDown);
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", onPointerDown);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -115,8 +100,9 @@ function UserMenu({ user }: { user: SessionUser }) {
|
|||||||
const initials = label.slice(0, 2).toUpperCase();
|
const initials = label.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={containerRef}>
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => setOpen((value) => !value)}
|
||||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
|
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
|
||||||
@@ -132,11 +118,7 @@ function UserMenu({ user }: { user: SessionUser }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open ? (
|
<Popover anchorRef={triggerRef} open={open} onClose={() => setOpen(false)} width={240}>
|
||||||
<div
|
|
||||||
role="menu"
|
|
||||||
className="glass fade-up absolute right-0 z-50 mt-2 w-60 overflow-hidden p-1.5"
|
|
||||||
>
|
|
||||||
<div className="border-b border-white/6 px-3 py-2">
|
<div className="border-b border-white/6 px-3 py-2">
|
||||||
<p className="truncate text-sm text-slate-100">{label}</p>
|
<p className="truncate text-sm text-slate-100">{label}</p>
|
||||||
<p className="truncate text-[11px] text-slate-500">
|
<p className="truncate text-[11px] text-slate-500">
|
||||||
@@ -177,8 +159,7 @@ function UserMenu({ user }: { user: SessionUser }) {
|
|||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
Abmelden
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</Popover>
|
||||||
) : null}
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
@@ -36,6 +37,16 @@ export interface AdminUser {
|
|||||||
_count: { credentials: number; projects: number; sessions: number };
|
_count: { credentials: number; projects: number; sessions: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutoSyncReport {
|
||||||
|
at: string;
|
||||||
|
trigger: "zeitplan" | "manuell";
|
||||||
|
candidates: number;
|
||||||
|
synced: number;
|
||||||
|
failed: number;
|
||||||
|
skipped: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminData {
|
export interface AdminData {
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
@@ -43,6 +54,16 @@ export interface AdminData {
|
|||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
passkeysAvailable: boolean;
|
passkeysAvailable: boolean;
|
||||||
|
autoSync: {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalMinutes: number;
|
||||||
|
activeOnly: boolean;
|
||||||
|
batchSize: number;
|
||||||
|
lastRunAt: string | Date | null;
|
||||||
|
lastReport: AutoSyncReport | null;
|
||||||
|
/** Projekte mit Repository, die grundsaetzlich in Frage kommen. */
|
||||||
|
eligible: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notice = { ok: boolean; text: string } | null;
|
type Notice = { ok: boolean; text: string } | null;
|
||||||
@@ -162,6 +183,8 @@ export function AdminManager({ data }: { data: AdminData }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AutoSyncSection data={data.autoSync} />
|
||||||
|
|
||||||
{/* ── Instanz ─────────────────────────────────────── */}
|
{/* ── Instanz ─────────────────────────────────────── */}
|
||||||
<section className="glass p-5">
|
<section className="glass p-5">
|
||||||
<div className="mb-4 flex items-start gap-3">
|
<div className="mb-4 flex items-start gap-3">
|
||||||
@@ -264,6 +287,256 @@ export function AdminManager({ data }: { data: AdminData }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INTERVALS = [
|
||||||
|
{ value: 15, label: "alle 15 Minuten" },
|
||||||
|
{ value: 30, label: "alle 30 Minuten" },
|
||||||
|
{ value: 60, label: "stuendlich" },
|
||||||
|
{ value: 180, label: "alle 3 Stunden" },
|
||||||
|
{ value: 360, label: "alle 6 Stunden" },
|
||||||
|
{ value: 720, label: "alle 12 Stunden" },
|
||||||
|
{ value: 1440, label: "taeglich" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AutoSyncSection({ data }: { data: AdminData["autoSync"] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [enabled, setEnabled] = useState(data.enabled);
|
||||||
|
const [interval, setIntervalMinutes] = useState(data.intervalMinutes);
|
||||||
|
const [activeOnly, setActiveOnly] = useState(data.activeOnly);
|
||||||
|
const [batchSize, setBatchSize] = useState(data.batchSize);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<Notice>(null);
|
||||||
|
|
||||||
|
async function patch(payload: Record<string, unknown>) {
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
|
||||||
|
setNotice({ ok: true, text: "Gespeichert." });
|
||||||
|
router.refresh();
|
||||||
|
} catch (caught) {
|
||||||
|
setEnabled(data.enabled);
|
||||||
|
setIntervalMinutes(data.intervalMinutes);
|
||||||
|
setActiveOnly(data.activeOnly);
|
||||||
|
setBatchSize(data.batchSize);
|
||||||
|
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNow() {
|
||||||
|
setRunning(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/sync/run", { method: "POST" });
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(body.error ?? "Abgleich fehlgeschlagen.");
|
||||||
|
const report = body.report as AutoSyncReport;
|
||||||
|
setNotice({
|
||||||
|
ok: true,
|
||||||
|
text:
|
||||||
|
`${report.synced} aktualisiert, ${report.failed} fehlgeschlagen, ` +
|
||||||
|
`${report.skipped} zurueckgestellt (${Math.round(report.durationMs / 1000)} s).`,
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (caught) {
|
||||||
|
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRun =
|
||||||
|
enabled && data.lastRunAt
|
||||||
|
? new Date(new Date(data.lastRunAt).getTime() + interval * 60_000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="glass p-5">
|
||||||
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-cyan-500/12 text-cyan-300">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||||
|
Automatischer Abgleich
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 max-w-xl text-sm text-slate-400">
|
||||||
|
Holt Branches, Tags, Commits und Releases im Hintergrund. Gilt fuer alle
|
||||||
|
Konten – die Daten bleiben dabei getrennt, abgeglichen wird nur, was
|
||||||
|
ohnehin verknuepft ist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost px-3 py-1.5 text-xs"
|
||||||
|
onClick={runNow}
|
||||||
|
disabled={running || data.eligible === 0}
|
||||||
|
title={data.eligible === 0 ? "Kein Projekt mit Repository verknuepft" : undefined}
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Jetzt abgleichen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => {
|
||||||
|
setEnabled(event.target.checked);
|
||||||
|
void patch({ autoSyncEnabled: event.target.checked });
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-4 w-4 accent-violet-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm text-slate-200">Regelmaessig abgleichen</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||||
|
Standardmaessig aus. Jeder Durchlauf verbraucht Kontingent bei GitHub,
|
||||||
|
GitLab und Gitea – ohne Token ist das schnell erschoepft.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{enabled ? (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="autosync-interval">
|
||||||
|
Abstand
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="autosync-interval"
|
||||||
|
className="field"
|
||||||
|
value={interval}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = Number(event.target.value);
|
||||||
|
setIntervalMinutes(value);
|
||||||
|
void patch({ autoSyncIntervalMinutes: value });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{INTERVALS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="autosync-batch">
|
||||||
|
Projekte je Durchlauf: {batchSize}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="autosync-batch"
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={batchSize}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setBatchSize(Number(event.target.value))}
|
||||||
|
onMouseUp={() => patch({ autoSyncBatchSize: batchSize })}
|
||||||
|
onTouchEnd={() => patch({ autoSyncBatchSize: batchSize })}
|
||||||
|
onKeyUp={() => patch({ autoSyncBatchSize: batchSize })}
|
||||||
|
className="range mt-2"
|
||||||
|
style={{ "--fill": `${batchSize}%` } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
<p className="mt-1.5 text-[11px] text-slate-500">
|
||||||
|
Am laengsten nicht abgeglichene zuerst. Der Rest kommt beim naechsten Mal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeOnly}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => {
|
||||||
|
setActiveOnly(event.target.checked);
|
||||||
|
void patch({ autoSyncActiveOnly: event.target.checked });
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-4 w-4 accent-violet-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm text-slate-200">
|
||||||
|
Fertige und archivierte Projekte auslassen
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||||
|
Dort aendert sich selten noch etwas.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Zustand */}
|
||||||
|
<dl className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="glass-soft px-3 py-2.5">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
||||||
|
Verknuepfte Projekte
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm tabular-nums text-slate-200">{data.eligible}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="glass-soft px-3 py-2.5">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
||||||
|
Letzter Durchlauf
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
|
||||||
|
{data.lastRunAt ? formatRelative(data.lastRunAt) : "noch keiner"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="glass-soft px-3 py-2.5">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
||||||
|
Naechster Durchlauf
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
|
||||||
|
{enabled ? (nextRun ? formatDateTime(nextRun) : "in Kuerze") : "abgeschaltet"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{data.lastReport ? (
|
||||||
|
<p className="mt-3 text-[11px] text-slate-500" suppressHydrationWarning>
|
||||||
|
Zuletzt ({data.lastReport.trigger}): {data.lastReport.synced} aktualisiert,{" "}
|
||||||
|
{data.lastReport.failed} fehlgeschlagen, {data.lastReport.skipped} zurueckgestellt
|
||||||
|
in {Math.round(data.lastReport.durationMs / 1000)} s.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
||||||
|
notice.ok
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notice.text}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ModeCard({
|
function ModeCard({
|
||||||
active,
|
active,
|
||||||
disabled,
|
disabled,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ interface ModalProps {
|
|||||||
width?: "md" | "lg" | "xl";
|
width?: "md" | "lg" | "xl";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog per Portal am body.
|
||||||
|
*
|
||||||
|
* Ohne Portal waere "position: fixed" wirkungslos: Dialoge werden aus Karten
|
||||||
|
* heraus geoeffnet, und deren backdrop-filter erzeugt einen neuen
|
||||||
|
* Bezugsrahmen. Der Dialog wuerde sich dann an der Karte ausrichten statt am
|
||||||
|
* Fenster.
|
||||||
|
*/
|
||||||
export function Modal({
|
export function Modal({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
@@ -23,6 +32,10 @@ export function Modal({
|
|||||||
footer,
|
footer,
|
||||||
width = "lg",
|
width = "lg",
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
@@ -37,22 +50,22 @@ export function Modal({
|
|||||||
};
|
};
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!mounted || !open) return null;
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-8">
|
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-4 sm:p-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dialog schliessen"
|
aria-label="Dialog schliessen"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="fixed inset-0 cursor-default bg-[#03040c]/80 backdrop-blur-sm"
|
className="fixed inset-0 cursor-default bg-[#03040c]/85 backdrop-blur-sm"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass fade-up relative z-10 my-auto w-full p-6",
|
"popover fade-up relative z-10 my-auto w-full rounded-2xl p-6",
|
||||||
width === "md" && "max-w-lg",
|
width === "md" && "max-w-lg",
|
||||||
width === "lg" && "max-w-2xl",
|
width === "lg" && "max-w-2xl",
|
||||||
width === "xl" && "max-w-4xl",
|
width === "xl" && "max-w-4xl",
|
||||||
@@ -79,6 +92,7 @@ export function Modal({
|
|||||||
|
|
||||||
{footer ? <div className="mt-6 flex justify-end gap-2">{footer}</div> : null}
|
{footer ? <div className="mt-6 flex justify-end gap-2">{footer}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schwebendes Menue, das per Portal am body haengt.
|
||||||
|
*
|
||||||
|
* Warum nicht einfach ein absolut positioniertes Kind?
|
||||||
|
*
|
||||||
|
* 1. Unsere Karten setzen overflow-hidden (fuer den Akzentstreifen und den
|
||||||
|
* Farbverlaufsrand). Ein Kind wuerde daran abgeschnitten.
|
||||||
|
* 2. .glass setzt backdrop-filter, und die Projektkarte bekommt beim Hovern
|
||||||
|
* ein transform. Beides erzeugt einen Stacking-Context und einen neuen
|
||||||
|
* Bezugsrahmen: z-index wirkt dann nur noch innerhalb der Karte, und
|
||||||
|
* selbst position: fixed bezieht sich auf die Karte statt auf das
|
||||||
|
* Fenster.
|
||||||
|
*
|
||||||
|
* Am body haengend entfaellt beides. Die Position wird aus dem Ankerelement
|
||||||
|
* berechnet und beim Scrollen und bei Groessenaenderungen nachgefuehrt.
|
||||||
|
*/
|
||||||
|
export function Popover({
|
||||||
|
anchorRef,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
align = "end",
|
||||||
|
width = 240,
|
||||||
|
className,
|
||||||
|
labelledBy,
|
||||||
|
role = "menu",
|
||||||
|
}: {
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** "end" richtet die rechte Kante am Anker aus. */
|
||||||
|
align?: "start" | "end";
|
||||||
|
width?: number;
|
||||||
|
className?: string;
|
||||||
|
labelledBy?: string;
|
||||||
|
role?: "menu" | "listbox" | "dialog";
|
||||||
|
}) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
const place = useCallback(() => {
|
||||||
|
const anchor = anchorRef.current;
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const margin = 8;
|
||||||
|
const gap = 6;
|
||||||
|
|
||||||
|
// Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln.
|
||||||
|
// Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit
|
||||||
|
// links stuende das Menue sonst quer ueber der Seite statt an seinem
|
||||||
|
// Ausloeser.
|
||||||
|
let left = align === "end" ? rect.right - width : rect.left;
|
||||||
|
if (left < margin) left = rect.left;
|
||||||
|
if (left + width > window.innerWidth - margin) left = rect.right - width;
|
||||||
|
left = Math.min(Math.max(margin, left), window.innerWidth - width - margin);
|
||||||
|
|
||||||
|
const panelHeight = panelRef.current?.offsetHeight ?? 0;
|
||||||
|
let top = rect.bottom + gap;
|
||||||
|
|
||||||
|
// Nach oben klappen, wenn unten kein Platz ist, oben aber schon.
|
||||||
|
const fitsBelow = top + panelHeight <= window.innerHeight - margin;
|
||||||
|
const fitsAbove = rect.top - gap - panelHeight >= margin;
|
||||||
|
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
|
||||||
|
|
||||||
|
setPosition({ top, left });
|
||||||
|
}, [anchorRef, align, width]);
|
||||||
|
|
||||||
|
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
|
||||||
|
// Server-Rendering. Ein Aufblitzen an falscher Stelle gibt es trotzdem
|
||||||
|
// nicht, weil das Panel bis zur berechneten Position unsichtbar bleibt.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
place();
|
||||||
|
}, [open, place]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
// capture: true, damit auch das Scrollen innerer Container ankommt.
|
||||||
|
const onScroll = () => place();
|
||||||
|
window.addEventListener("scroll", onScroll, true);
|
||||||
|
window.addEventListener("resize", onScroll);
|
||||||
|
|
||||||
|
function onPointerDown(event: MouseEvent) {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (panelRef.current?.contains(target)) return;
|
||||||
|
if (anchorRef.current?.contains(target)) return;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll, true);
|
||||||
|
window.removeEventListener("resize", onScroll);
|
||||||
|
document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open, place, onClose, anchorRef]);
|
||||||
|
|
||||||
|
if (!mounted || !open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role={role}
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: position?.top ?? -9999,
|
||||||
|
left: position?.left ?? -9999,
|
||||||
|
width,
|
||||||
|
// Bis die Position steht, unsichtbar halten – sonst blitzt das
|
||||||
|
// Menue kurz an der falschen Stelle auf.
|
||||||
|
visibility: position ? "visible" : "hidden",
|
||||||
|
}}
|
||||||
|
className={cn("popover fade-up z-[100] overflow-hidden p-1.5", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Wird von Next einmal beim Start des Servers ausgefuehrt.
|
||||||
|
*
|
||||||
|
* Hier haengt der Zeitgeber fuer den automatischen Abgleich.
|
||||||
|
*
|
||||||
|
* Wichtig ist die Form der Abfrage: Next uebersetzt diese Datei auch fuer den
|
||||||
|
* Edge-Runtime (weil es eine Middleware gibt). Dort gibt es weder node:crypto
|
||||||
|
* noch node:net, die der Abgleich mitbringt. Der Import muss deshalb *in* der
|
||||||
|
* positiven Bedingung stehen – dann ersetzt der Bundler NEXT_RUNTIME beim
|
||||||
|
* Bauen und entfernt den ganzen Block aus dem Edge-Bundle. Mit einem frueh
|
||||||
|
* abbrechenden "if (… !== 'nodejs') return" bliebe der Import stehen und der
|
||||||
|
* Build schluege fehl.
|
||||||
|
*/
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
const { describeConfig } = await import("@/lib/config");
|
||||||
|
console.log(`[nebula] Konfiguration: ${describeConfig()}`);
|
||||||
|
|
||||||
|
const { startAutoSyncScheduler } = await import("@/lib/sync/scheduler");
|
||||||
|
startAutoSyncScheduler();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,12 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
|||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
await prisma.project.update({
|
await prisma.project.update({
|
||||||
where: { id: project.id },
|
where: { id: project.id },
|
||||||
data: { syncError: message, lastSyncedAt: new Date() },
|
data: {
|
||||||
|
syncError: message,
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
// Zaehler fuers Zurueckhalten beim automatischen Abgleich.
|
||||||
|
syncFailureCount: { increment: 1 },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return { ok: false, message };
|
return { ok: false, message };
|
||||||
}
|
}
|
||||||
@@ -127,6 +132,7 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
|||||||
data: {
|
data: {
|
||||||
lastSyncedAt: new Date(),
|
lastSyncedAt: new Date(),
|
||||||
syncError: null,
|
syncError: null,
|
||||||
|
syncFailureCount: 0,
|
||||||
defaultBranch: data.defaultBranch,
|
defaultBranch: data.defaultBranch,
|
||||||
repoUrl: data.htmlUrl ?? undefined,
|
repoUrl: data.htmlUrl ?? undefined,
|
||||||
},
|
},
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { getAppSettings } from "@/lib/auth/settings";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { syncProject } from "@/lib/sync";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatischer Abgleich mit den Git-Systemen.
|
||||||
|
*
|
||||||
|
* Laeuft im Serverprozess selbst, angestossen von instrumentation.ts. Fuer
|
||||||
|
* eine Einzelinstanz ist das die einfachste tragfaehige Loesung: kein
|
||||||
|
* zusaetzlicher Container, kein externer Cron, keine zweite Konfiguration.
|
||||||
|
* Wer lieber von aussen taktet, ruft stattdessen POST /api/admin/sync/run
|
||||||
|
* auf und laesst den Automatismus aus.
|
||||||
|
*
|
||||||
|
* Ruecksicht auf fremde Kontingente:
|
||||||
|
* - je Durchlauf nur eine begrenzte Zahl Projekte
|
||||||
|
* - die am laengsten nicht abgeglichenen zuerst
|
||||||
|
* - nach Fehlschlaegen wird zurueckgehalten, mit wachsendem Abstand
|
||||||
|
* - kurze Pause zwischen den Projekten
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TICK_MS = 60_000;
|
||||||
|
/** Kleine Verschnaufpause zwischen zwei Projekten. */
|
||||||
|
const PAUSE_MS = 400;
|
||||||
|
/** Hoechstens so lange wird nach Fehlschlaegen zurueckgehalten. */
|
||||||
|
const MAX_BACKOFF_STEPS = 4;
|
||||||
|
|
||||||
|
export interface AutoSyncReport {
|
||||||
|
at: string;
|
||||||
|
trigger: "zeitplan" | "manuell";
|
||||||
|
candidates: number;
|
||||||
|
synced: number;
|
||||||
|
failed: number;
|
||||||
|
/** Wegen Zurueckhaltung nach Fehlschlaegen uebersprungen. */
|
||||||
|
skipped: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ueber globalThis, damit der Hot-Reload im Entwicklungsbetrieb nicht bei
|
||||||
|
// jedem Neuladen einen weiteren Zeitgeber startet.
|
||||||
|
const globalForScheduler = globalThis as unknown as {
|
||||||
|
nebulaAutoSyncTimer?: NodeJS.Timeout;
|
||||||
|
nebulaAutoSyncRunning?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startAutoSyncScheduler() {
|
||||||
|
if (globalForScheduler.nebulaAutoSyncTimer) return;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void tick();
|
||||||
|
}, TICK_MS);
|
||||||
|
// Der Zeitgeber darf den Prozess nicht am Beenden hindern.
|
||||||
|
timer.unref?.();
|
||||||
|
globalForScheduler.nebulaAutoSyncTimer = timer;
|
||||||
|
|
||||||
|
// Kurz nach dem Start einmal nachsehen, statt eine volle Minute zu warten.
|
||||||
|
const initial = setTimeout(() => void tick(), 20_000);
|
||||||
|
initial.unref?.();
|
||||||
|
|
||||||
|
console.log("[nebula] Zeitgeber fuer den automatischen Abgleich laeuft.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prueft, ob ein Durchlauf faellig ist, und startet ihn gegebenenfalls. */
|
||||||
|
async function tick() {
|
||||||
|
try {
|
||||||
|
const settings = await getAppSettings();
|
||||||
|
if (!settings.autoSyncEnabled) return;
|
||||||
|
|
||||||
|
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
|
||||||
|
const last = settings.lastAutoSyncAt?.getTime() ?? 0;
|
||||||
|
if (Date.now() - last < intervalMs) return;
|
||||||
|
|
||||||
|
await runAutoSyncPass("zeitplan");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf
|
||||||
|
* waehrend eines laufenden Durchlaufs bekommt null zurueck.
|
||||||
|
*/
|
||||||
|
export async function runAutoSyncPass(
|
||||||
|
trigger: AutoSyncReport["trigger"],
|
||||||
|
): Promise<AutoSyncReport | null> {
|
||||||
|
if (globalForScheduler.nebulaAutoSyncRunning) return null;
|
||||||
|
globalForScheduler.nebulaAutoSyncRunning = true;
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let synced = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await getAppSettings();
|
||||||
|
const batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize));
|
||||||
|
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
|
||||||
|
|
||||||
|
const where: Prisma.ProjectWhereInput = {
|
||||||
|
autoSync: true,
|
||||||
|
// Ohne Repository gibt es nichts abzugleichen.
|
||||||
|
OR: [
|
||||||
|
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
||||||
|
{ repoUrl: { not: null } },
|
||||||
|
],
|
||||||
|
...(settings.autoSyncActiveOnly
|
||||||
|
? { status: { notIn: ["DONE", "ARCHIVED"] } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer.
|
||||||
|
const candidates = await prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }],
|
||||||
|
take: batchSize * 4,
|
||||||
|
select: { id: true, name: true, lastSyncedAt: true, syncFailureCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const due = candidates.filter((project) => {
|
||||||
|
if (project.syncFailureCount === 0) return true;
|
||||||
|
if (!project.lastSyncedAt) return true;
|
||||||
|
|
||||||
|
// Nach jedem Fehlschlag doppelt so lange warten, gedeckelt.
|
||||||
|
const steps = Math.min(project.syncFailureCount, MAX_BACKOFF_STEPS);
|
||||||
|
const wait = intervalMs * 2 ** steps;
|
||||||
|
const ready = Date.now() - project.lastSyncedAt.getTime() >= wait;
|
||||||
|
if (!ready) skipped += 1;
|
||||||
|
return ready;
|
||||||
|
});
|
||||||
|
|
||||||
|
const batch = due.slice(0, batchSize);
|
||||||
|
|
||||||
|
for (const project of batch) {
|
||||||
|
const result = await syncProject(project.id);
|
||||||
|
if (result.ok) synced += 1;
|
||||||
|
else failed += 1;
|
||||||
|
if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
const report: AutoSyncReport = {
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
trigger,
|
||||||
|
candidates: candidates.length,
|
||||||
|
synced,
|
||||||
|
failed,
|
||||||
|
skipped,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.appSettings.update({
|
||||||
|
where: { id: "singleton" },
|
||||||
|
data: {
|
||||||
|
lastAutoSyncAt: new Date(),
|
||||||
|
lastAutoSyncReport: report as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[nebula] Abgleich (${trigger}): ${synced} aktualisiert, ${failed} fehlgeschlagen, ` +
|
||||||
|
`${skipped} zurueckgestellt, ${report.durationMs} ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return report;
|
||||||
|
} finally {
|
||||||
|
globalForScheduler.nebulaAutoSyncRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAutoSyncRunning(): boolean {
|
||||||
|
return Boolean(globalForScheduler.nebulaAutoSyncRunning);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export const projectCreateSchema = z.object({
|
|||||||
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||||
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||||
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
|
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
|
||||||
|
/** Am automatischen Abgleich teilnehmen. */
|
||||||
|
autoSync: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const projectUpdateSchema = projectCreateSchema.partial();
|
export const projectUpdateSchema = projectCreateSchema.partial();
|
||||||
@@ -107,6 +109,11 @@ export const profileUpdateSchema = z.object({
|
|||||||
export const adminSettingsSchema = z.object({
|
export const adminSettingsSchema = z.object({
|
||||||
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(),
|
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(),
|
||||||
allowRegistration: z.boolean().optional(),
|
allowRegistration: z.boolean().optional(),
|
||||||
|
autoSyncEnabled: z.boolean().optional(),
|
||||||
|
/** Untergrenze 5 Minuten – haeufiger waere den Git-APIs gegenueber unfair. */
|
||||||
|
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
|
||||||
|
autoSyncActiveOnly: z.boolean().optional(),
|
||||||
|
autoSyncBatchSize: z.coerce.number().int().min(1).max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const adminUserCreateSchema = z.object({
|
export const adminUserCreateSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user