|
|
@@ -1,7 +1,21 @@
|
|
|
import * as React from "react";
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
-import { KeyRound, UserRound } from "lucide-react";
|
|
|
+import {
|
|
|
+ Database,
|
|
|
+ Globe2,
|
|
|
+ KeyRound,
|
|
|
+ LayoutDashboard,
|
|
|
+ Palette,
|
|
|
+ PanelLeftClose,
|
|
|
+ RotateCcw,
|
|
|
+ Save,
|
|
|
+ Server,
|
|
|
+ ShieldCheck,
|
|
|
+ Timer,
|
|
|
+ UserRound,
|
|
|
+} from "lucide-react";
|
|
|
import { createApiKey, listApiKeys, revokeApiKey } from "@/api";
|
|
|
+import { mockMode } from "@/api/mock";
|
|
|
import { ApiErrorState } from "@/components/shared/ApiErrorState";
|
|
|
import { EmptyState } from "@/components/shared/EmptyState";
|
|
|
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
|
|
@@ -9,33 +23,63 @@ import { MetricCard } from "@/components/shared/MetricCard";
|
|
|
import { PageHeader } from "@/components/shared/PageHeader";
|
|
|
import { StatusBadge } from "@/components/shared/StatusBadge";
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
import { Dialog } from "@/components/ui/dialog";
|
|
|
import { Input, Textarea } from "@/components/ui/input";
|
|
|
+import { Select } from "@/components/ui/select";
|
|
|
import { toast } from "@/components/ui/toaster";
|
|
|
-import { formatDateTime, truncateMiddle } from "@/lib/utils";
|
|
|
+import { LANGUAGE_LABELS, SUPPORTED_LANGUAGES, type SupportedLanguage } from "@/i18n";
|
|
|
+import { NAV_ITEMS } from "@/lib/constants";
|
|
|
+import { cn, formatDateTime, truncateMiddle } from "@/lib/utils";
|
|
|
import { useAuthStore } from "@/stores/auth";
|
|
|
+import { type SiteSettings, useUiStore } from "@/stores/ui";
|
|
|
import type { ApiKeyCreateResponse, ApiKeyResponse } from "@/types";
|
|
|
|
|
|
+type SettingsDraft = SiteSettings & {
|
|
|
+ theme: "light" | "dark";
|
|
|
+ language: SupportedLanguage;
|
|
|
+ sidebarCollapsed: boolean;
|
|
|
+};
|
|
|
+
|
|
|
export function SettingsPage() {
|
|
|
const { t } = useTranslation();
|
|
|
- const { userId } = useAuthStore();
|
|
|
+ const { userId, username, displayName, tokenExpiresTime } = useAuthStore();
|
|
|
+ const {
|
|
|
+ theme,
|
|
|
+ language,
|
|
|
+ sidebarCollapsed,
|
|
|
+ siteSettings,
|
|
|
+ setTheme,
|
|
|
+ setLanguage,
|
|
|
+ setSidebarCollapsed,
|
|
|
+ setSiteSettings,
|
|
|
+ resetSiteSettings,
|
|
|
+ } = useUiStore();
|
|
|
+ const [draft, setDraft] = React.useState<SettingsDraft>(() => ({
|
|
|
+ ...siteSettings,
|
|
|
+ theme,
|
|
|
+ language,
|
|
|
+ sidebarCollapsed,
|
|
|
+ }));
|
|
|
+ const [formError, setFormError] = React.useState<string>();
|
|
|
const [apiKeys, setApiKeys] = React.useState<ApiKeyResponse[]>([]);
|
|
|
const [newKey, setNewKey] = React.useState<ApiKeyCreateResponse>();
|
|
|
- const [loading, setLoading] = React.useState(true);
|
|
|
- const [error, setError] = React.useState<string>();
|
|
|
+ const [keysLoading, setKeysLoading] = React.useState(true);
|
|
|
+ const [keysError, setKeysError] = React.useState<string>();
|
|
|
const [createOpen, setCreateOpen] = React.useState(false);
|
|
|
|
|
|
+ const c = React.useCallback((key: string, fallback: string) => t(key, { defaultValue: fallback }), [t]);
|
|
|
+
|
|
|
const load = React.useCallback(async () => {
|
|
|
- setLoading(true);
|
|
|
- setError(undefined);
|
|
|
+ setKeysLoading(true);
|
|
|
+ setKeysError(undefined);
|
|
|
try {
|
|
|
const keys = await listApiKeys();
|
|
|
setApiKeys(keys);
|
|
|
} catch (err) {
|
|
|
- setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
|
|
|
+ setKeysError(err instanceof Error ? err.message : t("errors.failedToLoad"));
|
|
|
} finally {
|
|
|
- setLoading(false);
|
|
|
+ setKeysLoading(false);
|
|
|
}
|
|
|
}, [t]);
|
|
|
|
|
|
@@ -43,33 +87,222 @@ export function SettingsPage() {
|
|
|
void load();
|
|
|
}, [load]);
|
|
|
|
|
|
+ React.useEffect(() => {
|
|
|
+ setDraft({ ...siteSettings, theme, language, sidebarCollapsed });
|
|
|
+ }, [language, sidebarCollapsed, siteSettings, theme]);
|
|
|
+
|
|
|
async function revoke(id: string) {
|
|
|
- await revokeApiKey(id);
|
|
|
- toast.success(t("settings.apiKeyRevoked"));
|
|
|
- void load();
|
|
|
+ try {
|
|
|
+ await revokeApiKey(id);
|
|
|
+ toast.success(t("settings.apiKeyRevoked"));
|
|
|
+ void load();
|
|
|
+ } catch {
|
|
|
+ toast.error(t("errors.failedToSave"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveSettings() {
|
|
|
+ const validation = validateSettings(draft);
|
|
|
+ if (validation) {
|
|
|
+ setFormError(validation);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setFormError(undefined);
|
|
|
+ setTheme(draft.theme);
|
|
|
+ setLanguage(draft.language);
|
|
|
+ setSidebarCollapsed(draft.sidebarCollapsed);
|
|
|
+ setSiteSettings({
|
|
|
+ workspaceName: draft.workspaceName.trim(),
|
|
|
+ defaultRoute: draft.defaultRoute,
|
|
|
+ density: draft.density,
|
|
|
+ dashboardRefreshSeconds: Number(draft.dashboardRefreshSeconds),
|
|
|
+ runRetentionDays: Number(draft.runRetentionDays),
|
|
|
+ requireConfirmations: draft.requireConfirmations,
|
|
|
+ showMockBanner: draft.showMockBanner,
|
|
|
+ gatewayBasePath: draft.gatewayBasePath.trim(),
|
|
|
+ });
|
|
|
+ toast.success(c("settings.siteSettingsSaved", "Site settings saved"));
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetDraft() {
|
|
|
+ resetSiteSettings();
|
|
|
+ const current = useUiStore.getState();
|
|
|
+ setDraft({
|
|
|
+ ...current.siteSettings,
|
|
|
+ theme: current.theme,
|
|
|
+ language: current.language,
|
|
|
+ sidebarCollapsed: current.sidebarCollapsed,
|
|
|
+ });
|
|
|
+ setFormError(undefined);
|
|
|
+ toast.info(c("settings.siteSettingsReset", "Site settings reset"));
|
|
|
}
|
|
|
|
|
|
- if (loading) return <LoadingSpinner label={t("common.loading")} />;
|
|
|
- if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
|
|
|
+ const routeOptions = NAV_ITEMS.map((item) => ({ value: item.path, label: t(item.labelKey) }));
|
|
|
|
|
|
return (
|
|
|
<div className="space-y-6">
|
|
|
<PageHeader
|
|
|
title={t("settings.title")}
|
|
|
- description={t("settings.description")}
|
|
|
- actions={<Button onClick={() => setCreateOpen(true)}><KeyRound className="h-4 w-4" /> {t("settings.newApiKey")}</Button>}
|
|
|
+ description={c("settings.description", "Configure workspace behavior, gateway defaults, safety policy, and operator API keys.")}
|
|
|
+ actions={
|
|
|
+ <>
|
|
|
+ <Button variant="outline" onClick={resetDraft}>
|
|
|
+ <RotateCcw className="h-4 w-4" /> {c("settings.resetSite", "Reset")}
|
|
|
+ </Button>
|
|
|
+ <Button variant="secondary" onClick={saveSettings}>
|
|
|
+ <Save className="h-4 w-4" /> {c("settings.saveSite", "Save Settings")}
|
|
|
+ </Button>
|
|
|
+ <Button onClick={() => setCreateOpen(true)}>
|
|
|
+ <KeyRound className="h-4 w-4" /> {t("settings.newApiKey")}
|
|
|
+ </Button>
|
|
|
+ </>
|
|
|
+ }
|
|
|
/>
|
|
|
- <div className="grid gap-4 md:grid-cols-2">
|
|
|
- <MetricCard label={t("settings.user")} value={userId || t("settings.unset")} icon={UserRound} />
|
|
|
+
|
|
|
+ {formError ? <p className="rounded-md border border-red-500/25 bg-red-500/5 p-3 text-sm">{formError}</p> : null}
|
|
|
+
|
|
|
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
|
+ <MetricCard label={c("settings.workspace", "Workspace")} value={draft.workspaceName || t("settings.unset")} icon={LayoutDashboard} />
|
|
|
+ <MetricCard label={t("settings.user")} value={displayName || username || userId || t("settings.unset")} icon={UserRound} />
|
|
|
<MetricCard label={t("settings.apiKeys")} value={apiKeys.length} icon={KeyRound} />
|
|
|
+ <MetricCard label={c("settings.gateway", "Gateway")} value={draft.gatewayBasePath || "/gateway"} icon={Server} />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="grid gap-6 xl:grid-cols-[1fr_420px]">
|
|
|
+ <div className="space-y-6">
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle>{c("settings.sitePreferences", "Site Preferences")}</CardTitle>
|
|
|
+ <CardDescription>{c("settings.sitePreferencesDescription", "Control the workspace identity, navigation, language, and visual density.")}</CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="grid gap-4 md:grid-cols-2">
|
|
|
+ <SettingField label={c("settings.workspaceName", "Workspace Name")}>
|
|
|
+ <Input value={draft.workspaceName} onChange={(event) => setDraft({ ...draft, workspaceName: event.target.value })} />
|
|
|
+ </SettingField>
|
|
|
+ <SettingField label={c("settings.defaultLanding", "Default Landing Page")}>
|
|
|
+ <Select value={draft.defaultRoute} onChange={(event) => setDraft({ ...draft, defaultRoute: event.target.value })} options={routeOptions} />
|
|
|
+ </SettingField>
|
|
|
+ <SettingField label={c("settings.theme", "Theme")}>
|
|
|
+ <Select
|
|
|
+ value={draft.theme}
|
|
|
+ onChange={(event) => setDraft({ ...draft, theme: event.target.value as SettingsDraft["theme"] })}
|
|
|
+ options={[
|
|
|
+ { value: "dark", label: c("settings.dark", "Dark") },
|
|
|
+ { value: "light", label: c("settings.light", "Light") },
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ </SettingField>
|
|
|
+ <SettingField label={c("settings.language", "Language")}>
|
|
|
+ <Select
|
|
|
+ value={draft.language}
|
|
|
+ onChange={(event) => setDraft({ ...draft, language: event.target.value as SupportedLanguage })}
|
|
|
+ options={SUPPORTED_LANGUAGES.map((item) => ({ value: item, label: LANGUAGE_LABELS[item] }))}
|
|
|
+ />
|
|
|
+ </SettingField>
|
|
|
+ <SettingField label={c("settings.density", "Density")}>
|
|
|
+ <Select
|
|
|
+ value={draft.density}
|
|
|
+ onChange={(event) => setDraft({ ...draft, density: event.target.value as SiteSettings["density"] })}
|
|
|
+ options={[
|
|
|
+ { value: "comfortable", label: c("settings.comfortable", "Comfortable") },
|
|
|
+ { value: "compact", label: c("settings.compact", "Compact") },
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ </SettingField>
|
|
|
+ <SettingField label={c("settings.sidebar", "Sidebar")}>
|
|
|
+ <Select
|
|
|
+ value={draft.sidebarCollapsed ? "collapsed" : "expanded"}
|
|
|
+ onChange={(event) => setDraft({ ...draft, sidebarCollapsed: event.target.value === "collapsed" })}
|
|
|
+ options={[
|
|
|
+ { value: "expanded", label: c("settings.expanded", "Expanded") },
|
|
|
+ { value: "collapsed", label: c("settings.collapsed", "Collapsed") },
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ </SettingField>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle>{c("settings.gatewayRuntime", "Gateway & Runtime")}</CardTitle>
|
|
|
+ <CardDescription>{c("settings.gatewayRuntimeDescription", "Defaults used by the frontend client and operational dashboards.")}</CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="grid gap-4 md:grid-cols-3">
|
|
|
+ <SettingField label={c("settings.gatewayBasePath", "Gateway Base Path")}>
|
|
|
+ <Input value={draft.gatewayBasePath} onChange={(event) => setDraft({ ...draft, gatewayBasePath: event.target.value })} />
|
|
|
+ </SettingField>
|
|
|
+ <SettingField label={c("settings.dashboardRefresh", "Dashboard Refresh")}>
|
|
|
+ <NumberInput
|
|
|
+ value={draft.dashboardRefreshSeconds}
|
|
|
+ suffix={c("settings.seconds", "seconds")}
|
|
|
+ min={5}
|
|
|
+ max={300}
|
|
|
+ onChange={(value) => setDraft({ ...draft, dashboardRefreshSeconds: value })}
|
|
|
+ />
|
|
|
+ </SettingField>
|
|
|
+ <SettingField label={c("settings.runRetention", "Run Retention")}>
|
|
|
+ <NumberInput
|
|
|
+ value={draft.runRetentionDays}
|
|
|
+ suffix={c("settings.days", "days")}
|
|
|
+ min={1}
|
|
|
+ max={365}
|
|
|
+ onChange={(value) => setDraft({ ...draft, runRetentionDays: value })}
|
|
|
+ />
|
|
|
+ </SettingField>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-6">
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle>{c("settings.safetyPolicy", "Safety Policy")}</CardTitle>
|
|
|
+ <CardDescription>{c("settings.safetyPolicyDescription", "Operator guardrails for destructive actions and demo-mode visibility.")}</CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="space-y-3">
|
|
|
+ <ToggleRow
|
|
|
+ icon={ShieldCheck}
|
|
|
+ title={c("settings.requireConfirmations", "Require Confirmations")}
|
|
|
+ description={c("settings.requireConfirmationsDescription", "Keep confirmation dialogs enabled for destructive actions.")}
|
|
|
+ checked={draft.requireConfirmations}
|
|
|
+ onChange={(value) => setDraft({ ...draft, requireConfirmations: value })}
|
|
|
+ />
|
|
|
+ <ToggleRow
|
|
|
+ icon={Database}
|
|
|
+ title={c("settings.showMockBanner", "Show Mock Banner")}
|
|
|
+ description={mockMode ? c("settings.showMockBannerDescription", "Display a visible banner when the frontend is using mock fallback data.") : c("settings.mockModeOff", "Mock fallback is not active in this session.")}
|
|
|
+ checked={draft.showMockBanner}
|
|
|
+ onChange={(value) => setDraft({ ...draft, showMockBanner: value })}
|
|
|
+ />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle>{c("settings.environment", "Environment")}</CardTitle>
|
|
|
+ <CardDescription>{c("settings.environmentDescription", "Read-only context for the current browser session.")}</CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="space-y-3 text-sm">
|
|
|
+ <InfoRow icon={Globe2} label={c("settings.language", "Language")} value={LANGUAGE_LABELS[language]} />
|
|
|
+ <InfoRow icon={Palette} label={c("settings.theme", "Theme")} value={c(`settings.${theme}`, theme)} />
|
|
|
+ <InfoRow icon={PanelLeftClose} label={c("settings.sidebar", "Sidebar")} value={sidebarCollapsed ? c("settings.collapsed", "Collapsed") : c("settings.expanded", "Expanded")} />
|
|
|
+ <InfoRow icon={Timer} label={c("settings.tokenExpires", "Token Expires")} value={formatDateTime(tokenExpiresTime)} />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
|
|
|
<Card>
|
|
|
<CardHeader>
|
|
|
<CardTitle>{t("settings.gatewayApiKeys")}</CardTitle>
|
|
|
+ <CardDescription>{c("settings.gatewayApiKeysDescription", "Create and revoke gateway API keys for automation, local testing, and operator access.")}</CardDescription>
|
|
|
</CardHeader>
|
|
|
<CardContent>
|
|
|
- {apiKeys.length ? (
|
|
|
+ {keysLoading ? (
|
|
|
+ <LoadingSpinner label={t("common.loading")} />
|
|
|
+ ) : keysError ? (
|
|
|
+ <ApiErrorState message={keysError} onRetry={() => void load()} />
|
|
|
+ ) : apiKeys.length ? (
|
|
|
<div className="overflow-x-auto">
|
|
|
<table className="w-full text-left text-sm">
|
|
|
<thead className="text-xs text-muted-foreground">
|
|
|
@@ -101,7 +334,7 @@ export function SettingsPage() {
|
|
|
</table>
|
|
|
</div>
|
|
|
) : (
|
|
|
- <EmptyState icon={KeyRound} title={t("settings.noApiKeys")} description={t("settings.createGatewayKey")} />
|
|
|
+ <EmptyState icon={KeyRound} title={t("settings.noApiKeys")} description={t("settings.createGatewayKey")} actionLabel={t("settings.newApiKey")} onAction={() => setCreateOpen(true)} />
|
|
|
)}
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
@@ -122,6 +355,106 @@ export function SettingsPage() {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+function SettingField({ label, children }: { label: string; children: React.ReactNode }) {
|
|
|
+ return (
|
|
|
+ <label className="block space-y-2 text-sm">
|
|
|
+ <span className="text-muted-foreground">{label}</span>
|
|
|
+ {children}
|
|
|
+ </label>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function NumberInput({
|
|
|
+ value,
|
|
|
+ suffix,
|
|
|
+ min,
|
|
|
+ max,
|
|
|
+ onChange,
|
|
|
+}: {
|
|
|
+ value: number;
|
|
|
+ suffix: string;
|
|
|
+ min: number;
|
|
|
+ max: number;
|
|
|
+ onChange: (value: number) => void;
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <Input
|
|
|
+ type="number"
|
|
|
+ min={min}
|
|
|
+ max={max}
|
|
|
+ value={value}
|
|
|
+ onChange={(event) => onChange(Number(event.target.value))}
|
|
|
+ />
|
|
|
+ <span className="shrink-0 text-sm text-muted-foreground">{suffix}</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function ToggleRow({
|
|
|
+ icon: Icon,
|
|
|
+ title,
|
|
|
+ description,
|
|
|
+ checked,
|
|
|
+ onChange,
|
|
|
+}: {
|
|
|
+ icon: typeof ShieldCheck;
|
|
|
+ title: string;
|
|
|
+ description: string;
|
|
|
+ checked: boolean;
|
|
|
+ onChange: (checked: boolean) => void;
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ role="switch"
|
|
|
+ aria-checked={checked}
|
|
|
+ onClick={() => onChange(!checked)}
|
|
|
+ className="flex w-full items-center justify-between gap-4 rounded-md border border-border bg-muted/30 p-3 text-left transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
+ >
|
|
|
+ <span className="flex min-w-0 items-start gap-3">
|
|
|
+ <span className="grid h-9 w-9 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
|
|
|
+ <Icon className="h-4 w-4" />
|
|
|
+ </span>
|
|
|
+ <span className="min-w-0">
|
|
|
+ <span className="block text-sm font-medium">{title}</span>
|
|
|
+ <span className="mt-1 block text-sm leading-5 text-muted-foreground">{description}</span>
|
|
|
+ </span>
|
|
|
+ </span>
|
|
|
+ <span className={cn("relative h-6 w-11 shrink-0 rounded-full border transition", checked ? "border-primary bg-primary" : "border-border bg-muted")}>
|
|
|
+ <span className={cn("absolute top-0.5 h-5 w-5 rounded-full bg-white transition", checked ? "left-5" : "left-0.5")} />
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function InfoRow({ icon: Icon, label, value }: { icon: typeof Globe2; label: string; value: string }) {
|
|
|
+ return (
|
|
|
+ <div className="flex items-center gap-3 rounded-md border border-border bg-muted/30 p-3">
|
|
|
+ <Icon className="h-4 w-4 text-muted-foreground" />
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">{label}</p>
|
|
|
+ <p className="mt-1 truncate font-medium">{value}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function validateSettings(settings: SettingsDraft) {
|
|
|
+ if (!settings.workspaceName.trim()) return "Workspace name is required.";
|
|
|
+ const gateway = settings.gatewayBasePath.trim();
|
|
|
+ if (!gateway.startsWith("/") && !gateway.startsWith("http://") && !gateway.startsWith("https://")) {
|
|
|
+ return "Gateway base path must start with /, http://, or https://.";
|
|
|
+ }
|
|
|
+ if (!Number.isFinite(settings.dashboardRefreshSeconds) || settings.dashboardRefreshSeconds < 5 || settings.dashboardRefreshSeconds > 300) {
|
|
|
+ return "Dashboard refresh must be between 5 and 300 seconds.";
|
|
|
+ }
|
|
|
+ if (!Number.isFinite(settings.runRetentionDays) || settings.runRetentionDays < 1 || settings.runRetentionDays > 365) {
|
|
|
+ return "Run retention must be between 1 and 365 days.";
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+}
|
|
|
+
|
|
|
function CreateApiKeyDialog({
|
|
|
open,
|
|
|
onOpenChange,
|
|
|
@@ -158,10 +491,10 @@ function CreateApiKeyDialog({
|
|
|
<Dialog open={open} onOpenChange={onOpenChange} title={t("settings.createApiKey")}>
|
|
|
{createdKey ? (
|
|
|
<div className="space-y-4">
|
|
|
- <div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3 text-sm text-amber-100">
|
|
|
+ <div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-100">
|
|
|
{t("settings.copyKeyNow")}
|
|
|
</div>
|
|
|
- <div className="rounded-md border border-border bg-muted/60 p-3 font-mono text-xs break-all">{createdKey.api_key}</div>
|
|
|
+ <div className="break-all rounded-md border border-border bg-muted/60 p-3 font-mono text-xs">{createdKey.api_key}</div>
|
|
|
<p className="text-sm text-muted-foreground">{t("settings.prefix")}: {truncateMiddle(createdKey.key_prefix, 20)}</p>
|
|
|
<div className="flex justify-end">
|
|
|
<Button onClick={() => onOpenChange(false)}>{t("settings.done")}</Button>
|