feat(webui): system config dialog shell with general + login terms tabs
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { TabProps } from "./SystemConfigDialog";
|
||||
|
||||
export function BackupTab(_props: TabProps) {
|
||||
return <p className="text-sm text-muted-foreground">Implemented in the next task.</p>;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { mutate as globalMutate } from "swr";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { errorToast } from "@/lib/errorReporter";
|
||||
import {
|
||||
PUBLIC_SYSTEM_CONFIG_KEY,
|
||||
logoSrc,
|
||||
usePublicSystemConfig,
|
||||
} from "@/app/hooks/usePublicSystemConfig";
|
||||
import type { TabProps } from "./SystemConfigDialog";
|
||||
|
||||
function BrandingUpload({
|
||||
kind,
|
||||
label,
|
||||
currentSrc,
|
||||
}: {
|
||||
kind: "logo" | "favicon";
|
||||
label: string;
|
||||
currentSrc: string;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const call = async (url: string, init: RequestInit, fallback: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { message?: string }
|
||||
| null;
|
||||
if (!response.ok) throw new Error(body?.message || fallback);
|
||||
setPreview(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
void globalMutate(PUBLIC_SYSTEM_CONFIG_KEY);
|
||||
void globalMutate("/api/system/config");
|
||||
} catch (error) {
|
||||
errorToast(`branding.${kind}`, error instanceof Error ? error.message : fallback);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const upload = () => {
|
||||
const file = inputRef.current?.files?.[0];
|
||||
if (!file) return;
|
||||
const form = new FormData();
|
||||
form.set("kind", kind);
|
||||
form.set("file", file);
|
||||
void call("/api/system/branding", { method: "POST", body: form }, "Upload failed.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={preview ?? currentSrc}
|
||||
alt={`${label} preview`}
|
||||
className={kind === "logo" ? "size-8 shrink-0" : "size-5 shrink-0"}
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={kind === "logo" ? "image/png,image/jpeg,image/svg+xml" : "image/png,image/jpeg,image/svg+xml,image/x-icon"}
|
||||
className="block w-full text-xs text-muted-foreground file:mr-2 file:rounded-md file:border file:border-border file:bg-background file:px-2 file:py-1 file:text-xs"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
setPreview(file ? URL.createObjectURL(file) : null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !preview}
|
||||
onClick={upload}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void call(`/api/system/branding?kind=${kind}`, { method: "DELETE" }, "Restore failed.")
|
||||
}
|
||||
>
|
||||
Restore default
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralTab({ draft, setDraft }: TabProps) {
|
||||
const publicConfig = usePublicSystemConfig();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wordmark">Wordmark (top-left & login page)</Label>
|
||||
<Input
|
||||
id="wordmark"
|
||||
value={draft.branding.wordmark}
|
||||
maxLength={30}
|
||||
onChange={(event) =>
|
||||
setDraft((config) => ({
|
||||
...config,
|
||||
branding: { ...config.branding, wordmark: event.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<BrandingUpload kind="logo" label="Logo" currentSrc={logoSrc(publicConfig)} />
|
||||
<BrandingUpload
|
||||
kind="favicon"
|
||||
label="Favicon"
|
||||
currentSrc={
|
||||
publicConfig && publicConfig.faviconVersion > 0
|
||||
? `/api/system/branding/asset/favicon?v=${publicConfig.faviconVersion}`
|
||||
: "/favicon.ico"
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PNG, JPEG or SVG, up to 512KB. The favicon also accepts ICO. Changes
|
||||
apply on save for the wordmark; uploads apply immediately.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import type { TabProps } from "./SystemConfigDialog";
|
||||
|
||||
export function LoginTermsTab({ draft, setDraft }: TabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-[var(--brand)]"
|
||||
checked={draft.loginTerms.enabled}
|
||||
onChange={(event) =>
|
||||
setDraft((config) => ({
|
||||
...config,
|
||||
loginTerms: { ...config.loginTerms, enabled: event.target.checked },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Show terms text on the login page (display only)
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-terms">Terms text (Markdown)</Label>
|
||||
<textarea
|
||||
id="login-terms"
|
||||
value={draft.loginTerms.markdown}
|
||||
rows={6}
|
||||
maxLength={20000}
|
||||
placeholder="e.g. By signing in you agree to the acceptable use policy…"
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/20 focus-visible:border-[var(--brand)]"
|
||||
onChange={(event) =>
|
||||
setDraft((config) => ({
|
||||
...config,
|
||||
loginTerms: { ...config.loginTerms, markdown: event.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{draft.loginTerms.enabled && draft.loginTerms.markdown.trim() && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Preview</Label>
|
||||
<div className="max-h-48 overflow-y-auto rounded-md border border-border bg-muted/40 p-3 text-sm">
|
||||
<MarkdownContent content={draft.loginTerms.markdown} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { TabProps } from "./SystemConfigDialog";
|
||||
|
||||
export function SecurityTab(_props: TabProps) {
|
||||
return <p className="text-sm text-muted-foreground">Implemented in the next task.</p>;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import useSWR, { mutate as globalMutate } from "swr";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { errorToast } from "@/lib/errorReporter";
|
||||
import { PUBLIC_SYSTEM_CONFIG_KEY } from "@/app/hooks/usePublicSystemConfig";
|
||||
import { GeneralTab } from "./GeneralTab";
|
||||
import { LoginTermsTab } from "./LoginTermsTab";
|
||||
import { SecurityTab } from "./SecurityTab";
|
||||
import { BackupTab } from "./BackupTab";
|
||||
|
||||
export interface SystemConfig {
|
||||
branding: { wordmark: string; logoFile: string | null; faviconFile: string | null };
|
||||
loginTerms: { enabled: boolean; markdown: string };
|
||||
security: {
|
||||
authEnabled: boolean | null;
|
||||
sessionTtlHours: number | null;
|
||||
passwordMinLength: number | null;
|
||||
captchaEnabled: boolean | null;
|
||||
captchaThreshold: number | null;
|
||||
};
|
||||
backup: {
|
||||
backendDataDir: string;
|
||||
schedule: { enabled: boolean; intervalHours: number; keepCount: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface TabProps {
|
||||
draft: SystemConfig;
|
||||
setDraft: (updater: (config: SystemConfig) => SystemConfig) => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const CONFIG_KEY = "/api/system/config";
|
||||
|
||||
const fetcher = async (url: string): Promise<SystemConfig> => {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`Config request failed (${response.status}).`);
|
||||
return (await response.json()) as SystemConfig;
|
||||
};
|
||||
|
||||
type TabId = "general" | "terms" | "security" | "backup";
|
||||
const TABS: Array<{ id: TabId; label: string }> = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "terms", label: "Login terms" },
|
||||
{ id: "security", label: "Security" },
|
||||
{ id: "backup", label: "Data backup" },
|
||||
];
|
||||
|
||||
export function SystemConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { data, mutate } = useSWR<SystemConfig>(open ? CONFIG_KEY : null, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const [tab, setTab] = useState<TabId>("general");
|
||||
const [draft, setDraftState] = useState<SystemConfig | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setDraftState(data);
|
||||
}, [data]);
|
||||
|
||||
const setDraft = (updater: (config: SystemConfig) => SystemConfig) =>
|
||||
setDraftState((current) => (current ? updater(current) : current));
|
||||
|
||||
const dirty = Boolean(draft && data && JSON.stringify(draft) !== JSON.stringify(data));
|
||||
|
||||
const close = (next: boolean) => {
|
||||
if (!next && dirty && !window.confirm("Discard unsaved changes?")) return;
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const onSaved = () => {
|
||||
void mutate();
|
||||
void globalMutate(PUBLIC_SYSTEM_CONFIG_KEY);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch(CONFIG_KEY, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { message?: string }
|
||||
| null;
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.message || `Save failed (${response.status}).`);
|
||||
}
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
errorToast("system-config.save", error instanceof Error ? error.message : "Save failed.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={close}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>System config</DialogTitle>
|
||||
<DialogDescription>
|
||||
System-wide parameters. Admin only; unset fields fall back to
|
||||
environment defaults.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
{TABS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
className={`-mb-px border-b-2 px-3 py-1.5 text-sm transition-colors ${
|
||||
tab === id
|
||||
? "border-[var(--brand)] font-medium text-[var(--brand)]"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-[280px] py-4">
|
||||
{!draft ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
{tab === "general" && (
|
||||
<GeneralTab draft={draft} setDraft={setDraft} onSaved={onSaved} />
|
||||
)}
|
||||
{tab === "terms" && (
|
||||
<LoginTermsTab draft={draft} setDraft={setDraft} onSaved={onSaved} />
|
||||
)}
|
||||
{tab === "security" && (
|
||||
<SecurityTab draft={draft} setDraft={setDraft} onSaved={onSaved} />
|
||||
)}
|
||||
{tab === "backup" && (
|
||||
<BackupTab draft={draft} setDraft={setDraft} onSaved={onSaved} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => close(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={!draft || !dirty || saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import useSWR from "swr";
|
||||
|
||||
export interface PublicSystemConfig {
|
||||
wordmark: string;
|
||||
logoVersion: number;
|
||||
faviconVersion: number;
|
||||
loginTerms: { enabled: boolean; markdown: string };
|
||||
}
|
||||
|
||||
export const PUBLIC_SYSTEM_CONFIG_KEY = "/api/system/config/public";
|
||||
export const DEFAULT_WORDMARK = "AI4Scientist";
|
||||
|
||||
const fetcher = async (url: string): Promise<PublicSystemConfig> => {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`Config request failed (${response.status}).`);
|
||||
return (await response.json()) as PublicSystemConfig;
|
||||
};
|
||||
|
||||
export function usePublicSystemConfig(): PublicSystemConfig | null {
|
||||
const { data } = useSWR(PUBLIC_SYSTEM_CONFIG_KEY, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
return data ?? null;
|
||||
}
|
||||
|
||||
export function logoSrc(data: PublicSystemConfig | null): string {
|
||||
return data && data.logoVersion > 0
|
||||
? `/api/system/branding/asset/logo?v=${data.logoVersion}`
|
||||
: "/evoscientist-logo.png";
|
||||
}
|
||||
Reference in New Issue
Block a user