feat(admin): Abgleich einstellbar und Schalter je Projekt
Adminbereich, Abschnitt "Automatischer Abgleich": - ein- und ausschalten - Abstand von 15 Minuten bis taeglich - Projekte je Durchlauf - fertige und archivierte auslassen - Zustand auf einen Blick: verknuepfte Projekte, letzter und naechster Durchlauf, Kurzbericht des letzten Laufs - Knopf "Jetzt abgleichen" fuer einen sofortigen Durchlauf Der Endpunkt POST /api/admin/sync/run dient zugleich als Einstieg fuer einen externen Zeitgeber, falls jemand lieber von aussen taktet als den eingebauten Automatismus zu nutzen. Im Projektdialog laesst sich ein einzelnes Projekt vom Abgleich ausnehmen - nuetzlich bei einem Repo, dessen Token staendig Fehler wirft. Manuell synchronisieren geht unabhaengig davon immer. Die Zahl der in Frage kommenden Projekte wird ueber alle Konten gezaehlt: der Abgleich ist ein Serverauftrag, keine Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte. Nachgewiesen: Durchlauf 1 aktualisiert das echte Repo und meldet das kaputte als Fehlschlag, Durchlauf 2 stellt das kaputte zurueck, das ausgenommene Projekt bleibt in beiden unberuehrt. Der Zeitplan loeste eigenstaendig aus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+27
-3
@@ -1,5 +1,9 @@
|
||||
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 { getAppSettings } from "@/lib/auth/settings";
|
||||
import { userSafeSelect } from "@/lib/auth/users";
|
||||
@@ -15,12 +19,23 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function AdminPage() {
|
||||
const admin = await requireAdminPage();
|
||||
|
||||
const [settings, users] = await Promise.all([
|
||||
const [settings, users, eligible] = await Promise.all([
|
||||
getAppSettings(),
|
||||
prisma.user.findMany({
|
||||
select: userSafeSelect,
|
||||
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 = {
|
||||
@@ -30,6 +45,15 @@ export default async function AdminPage() {
|
||||
currentUserId: admin.id,
|
||||
appUrl: APP_URL,
|
||||
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 (
|
||||
@@ -42,7 +66,7 @@ export default async function AdminPage() {
|
||||
Admin-Einstellungen
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Betriebsart, Registrierung und Benutzerkonten.
|
||||
Betriebsart, automatischer Abgleich und Benutzerkonten.
|
||||
</p>
|
||||
</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 –
|
||||
// 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({
|
||||
where: { id: "singleton" },
|
||||
create: {
|
||||
id: "singleton",
|
||||
...(mode ? { mode } : {}),
|
||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||
...autoSyncFields,
|
||||
},
|
||||
update: {
|
||||
...(mode ? { mode } : {}),
|
||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
|
||||
...(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 });
|
||||
}
|
||||
@@ -74,6 +74,7 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
"accent",
|
||||
"tags",
|
||||
"favorite",
|
||||
"autoSync",
|
||||
"connectionId",
|
||||
] as const) {
|
||||
if (data[key] !== undefined) update[key] = data[key];
|
||||
|
||||
@@ -96,6 +96,7 @@ export async function POST(request: Request) {
|
||||
accent: data.accent,
|
||||
tags: data.tags,
|
||||
favorite: data.favorite,
|
||||
autoSync: data.autoSync,
|
||||
repoUrl: data.repoUrl ?? null,
|
||||
repoOwner,
|
||||
repoName,
|
||||
|
||||
@@ -31,6 +31,7 @@ interface FormState {
|
||||
repoUrl: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
autoSync: boolean;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
@@ -47,6 +48,7 @@ const EMPTY: FormState = {
|
||||
repoUrl: "",
|
||||
repoOwner: "",
|
||||
repoName: "",
|
||||
autoSync: true,
|
||||
};
|
||||
|
||||
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
|
||||
@@ -87,6 +89,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
repoUrl: project.repoUrl ?? "",
|
||||
repoOwner: project.repoOwner ?? "",
|
||||
repoName: project.repoName ?? "",
|
||||
autoSync: project.autoSync,
|
||||
}
|
||||
: EMPTY,
|
||||
);
|
||||
@@ -154,6 +157,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
repoUrl: form.repoUrl,
|
||||
repoOwner: form.repoOwner,
|
||||
repoName: form.repoName,
|
||||
autoSync: form.autoSync,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -414,6 +418,22 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
</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}
|
||||
|
||||
{repoPickerOpen ? (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Loader2,
|
||||
Lock,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
@@ -36,6 +37,16 @@ export interface AdminUser {
|
||||
_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 {
|
||||
mode: Mode;
|
||||
allowRegistration: boolean;
|
||||
@@ -43,6 +54,16 @@ export interface AdminData {
|
||||
currentUserId: string;
|
||||
appUrl: string;
|
||||
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;
|
||||
@@ -162,6 +183,8 @@ export function AdminManager({ data }: { data: AdminData }) {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<AutoSyncSection data={data.autoSync} />
|
||||
|
||||
{/* ── Instanz ─────────────────────────────────────── */}
|
||||
<section className="glass p-5">
|
||||
<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({
|
||||
active,
|
||||
disabled,
|
||||
|
||||
@@ -21,6 +21,8 @@ export const projectCreateSchema = z.object({
|
||||
repoOwner: 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()),
|
||||
/** Am automatischen Abgleich teilnehmen. */
|
||||
autoSync: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const projectUpdateSchema = projectCreateSchema.partial();
|
||||
@@ -107,6 +109,11 @@ export const profileUpdateSchema = z.object({
|
||||
export const adminSettingsSchema = z.object({
|
||||
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).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({
|
||||
|
||||
Reference in New Issue
Block a user