Files
Termix/src/ui/sidebar/AdminSettingsSections.tsx
T
ZacharyZcR b00551c394 feat(hosts): add instance-wide predefined tag suggestions (#1548)
* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes
2026-10-03 21:04:50 +08:00

533 lines
18 KiB
TypeScript

import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SettingRow } from "@/components/section-card";
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import { AdminHostTags } from "./AdminHostTags";
import type { TlsStatus } from "@/api/tls-api";
type GeneralSettingsSectionProps = {
open: boolean;
onToggle: () => void;
notificationPrivateEndpoints: string[];
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
allowRegistration: boolean;
handleToggleRegistration: () => void;
allowPasswordLogin: boolean;
passwordLoginForced?: boolean;
handleTogglePasswordLogin: () => void;
externalAutoProvision: boolean;
handleToggleExternalAutoProvision: () => void;
secondFactorAfterExternalLogin: boolean;
handleToggleSecondFactorAfterExternalLogin: () => void;
allowPasswordReset: boolean;
handleTogglePasswordReset: () => void;
sessionTimeout: string;
setSessionTimeout: Dispatch<SetStateAction<string>>;
handleSaveSessionTimeout: () => void;
statusInterval: string;
setStatusInterval: Dispatch<SetStateAction<string>>;
handleSaveMonitoring: () => void;
logLevel: string;
handleSaveLogLevel: (level: string) => void;
};
export function AdminGeneralSettingsSection({
open,
onToggle,
notificationPrivateEndpoints,
onSaveNotificationPrivateEndpoints,
allowRegistration,
handleToggleRegistration,
allowPasswordLogin,
passwordLoginForced,
handleTogglePasswordLogin,
externalAutoProvision,
handleToggleExternalAutoProvision,
secondFactorAfterExternalLogin,
handleToggleSecondFactorAfterExternalLogin,
allowPasswordReset,
handleTogglePasswordReset,
sessionTimeout,
setSessionTimeout,
handleSaveSessionTimeout,
statusInterval,
setStatusInterval,
handleSaveMonitoring,
logLevel,
handleSaveLogLevel,
}: GeneralSettingsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionGeneral")}
icon={<Settings className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-0 pt-2">
<AdminHostTags />
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.notificationPrivateEndpoints")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.notificationPrivateEndpointsDesc")}
</span>
<Input
className="rounded-none"
defaultValue={notificationPrivateEndpoints.join(", ")}
placeholder="ntfy.internal, 192.168.1.20"
onBlur={(event) =>
onSaveNotificationPrivateEndpoints(
event.target.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</div>
<div className="border-t border-border pt-3 mt-2">
<SettingRow
label={t("admin.allowRegistration")}
description={t("admin.allowRegistrationDesc")}
>
<AdminToggle
on={allowRegistration}
onToggle={handleToggleRegistration}
/>
</SettingRow>
</div>
<SettingRow
label={t("admin.allowPasswordLogin")}
description={t("admin.allowPasswordLoginDesc")}
>
<AdminToggle
on={allowPasswordLogin}
onToggle={handleTogglePasswordLogin}
/>
</SettingRow>
{passwordLoginForced && (
<p className="text-[10px] text-destructive">
{t("admin.passwordLoginForced")}
</p>
)}
<SettingRow
label={t("admin.oidcAutoProvision")}
description={t("admin.oidcAutoProvisionDesc")}
>
<AdminToggle
on={externalAutoProvision}
onToggle={handleToggleExternalAutoProvision}
/>
</SettingRow>
<SettingRow
label={t("admin.secondFactorAfterExternalLogin")}
description={t("admin.secondFactorAfterExternalLoginDesc")}
>
<AdminToggle
on={secondFactorAfterExternalLogin}
onToggle={handleToggleSecondFactorAfterExternalLogin}
/>
</SettingRow>
<SettingRow
label={t("admin.allowPasswordReset")}
description={t("admin.allowPasswordResetDesc")}
>
<AdminToggle
on={allowPasswordReset}
onToggle={handleTogglePasswordReset}
/>
</SettingRow>
<div className="flex flex-col gap-2 pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.sessionTimeout")}
</span>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={720}
value={sessionTimeout}
onChange={(e) => setSessionTimeout(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.hours")}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveSessionTimeout}
>
{t("common.save")}
</Button>
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionTimeoutRange")}
</span>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.monitoringDefaults")}
</span>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.statusCheck")}
</label>
<div className="flex items-center gap-2">
<Input
type="number"
value={statusInterval}
onChange={(e) => setStatusInterval(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.sec")}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveMonitoring}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.logLevel")}
</span>
<div className="flex gap-1.5 flex-wrap">
{["debug", "info", "warn", "error"].map((l) => (
<button
key={l}
onClick={() => handleSaveLogLevel(l)}
className={`px-2 py-1 text-[10px] font-semibold border capitalize transition-colors ${logLevel === l ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{l}
</button>
))}
</div>
</div>
</div>
</AccordionSection>
);
}
type DatabaseSectionProps = {
open: boolean;
onToggle: () => void;
importFile: File | null;
setImportFile: Dispatch<SetStateAction<File | null>>;
exportLoading: boolean;
importLoading: boolean;
handleExportDatabase: () => void;
handleImportDatabase: () => void;
};
export function AdminDatabaseSection({
open,
onToggle,
importFile,
setImportFile,
exportLoading,
importLoading,
handleExportDatabase,
handleImportDatabase,
}: DatabaseSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionDatabase")}
icon={<Database className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium">
{t("admin.exportDatabase")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.exportDatabaseDesc")}
</span>
<Button
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand mt-1"
onClick={handleExportDatabase}
disabled={exportLoading}
>
{exportLoading ? t("admin.exporting") : t("admin.export")}
</Button>
</div>
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-xs font-medium">
{t("admin.importDatabase")}
</span>
<span className="text-[10px] text-muted-foreground">
{importFile
? t("admin.importDatabaseSelected", { name: importFile.name })
: t("admin.importDatabaseDesc")}
</span>
<div className="flex items-center gap-2 mt-1">
<div className="relative">
<input
type="file"
accept=".sqlite,.db"
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
variant="outline"
size="sm"
className="pointer-events-none text-xs"
>
{importFile ? t("admin.changeFile") : t("admin.selectFile")}
</Button>
</div>
{importFile && (
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleImportDatabase}
disabled={importLoading}
>
{importLoading ? t("admin.importing") : t("admin.import")}
</Button>
)}
</div>
</div>
</div>
</AccordionSection>
);
}
type AdminHostDefaultsSectionProps = {
open: boolean;
onToggle: () => void;
};
export function AdminHostDefaultsSection({
open,
onToggle,
}: AdminHostDefaultsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionHostDefaults")}
icon={<Server className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("hostDefaults.adminSectionDescription")}
</span>
<Button
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={() =>
window.dispatchEvent(
new CustomEvent("termix:open-host-defaults", {
detail: { level: "admin" },
}),
)
}
>
{t("hostDefaults.editServerDefaults")}
</Button>
</div>
</AccordionSection>
);
}
function certState(
status: TlsStatus | null,
): "none" | "valid" | "expiring" | "expired" {
const cert = status?.certificate;
if (!cert) return "none";
const left = new Date(cert.notAfter).getTime() - Date.now();
if (left <= 0) return "expired";
if (left < 30 * 86_400_000) return "expiring";
return "valid";
}
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
none: "text-muted-foreground",
valid: "text-green-500",
expiring: "text-yellow-500",
expired: "text-destructive",
};
type AdminSSLSectionProps = {
open: boolean;
onToggle: () => void;
status: TlsStatus | null;
manualCertDraft: string;
setManualCertDraft: Dispatch<SetStateAction<string>>;
manualKeyDraft: string;
setManualKeyDraft: Dispatch<SetStateAction<string>>;
manualUploading: boolean;
handleManualUpload: () => void;
};
export function AdminSSLSection({
open,
onToggle,
status,
manualCertDraft,
setManualCertDraft,
manualKeyDraft,
setManualKeyDraft,
manualUploading,
handleManualUpload,
}: AdminSSLSectionProps) {
const { t } = useTranslation();
const cert = status?.certificate ?? null;
const state = certState(status);
const expiry = cert ? new Date(cert.notAfter).toLocaleDateString() : "";
const certStatusLabel: Record<ReturnType<typeof certState>, string> = {
none: t("admin.sslCertStatusNone"),
valid: t("admin.sslCertStatusValid"),
expiring: t("admin.sslCertStatusExpiring"),
expired: t("admin.sslCertStatusExpired"),
};
return (
<AccordionSection
label={t("admin.sectionSsl")}
icon={<Lock className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.sslDescription")}{" "}
<a
href="https://docs.termix.site/features/networking/ssl"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.sslDocsLink")}
</a>
</span>
<div className="flex flex-col gap-0.5 p-2 border border-border bg-background/50">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslCertStatus")}
</span>
<span
className={`text-xs font-medium ${CERT_STATUS_STYLES[state]}`}
>
{certStatusLabel[state]}
</span>
</div>
{cert && (
<>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslNames", { names: cert.names.join(", ") || "-" })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslIssuer", { issuer: cert.issuer })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCertExpiresAt", { date: expiry })}
</span>
{cert.selfSigned && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslSelfSigned")}
</span>
)}
{status?.renewal && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslRenewedBy", {
plugin: status.renewal.pluginName,
})}
</span>
)}
</>
)}
</div>
{cert && !cert.selfSigned && !status?.renewal && (
<div className="p-2 border border-yellow-500/40 bg-yellow-500/10 text-[10px] text-yellow-600 dark:text-yellow-400">
{t("admin.sslNotRenewed", { date: expiry })}
</div>
)}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest border-t border-border pt-2">
{t("admin.sslManualTitle")}
</span>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualCert")}
</label>
<textarea
rows={5}
value={manualCertDraft}
onChange={(e) => setManualCertDraft(e.target.value)}
placeholder={t("admin.sslManualCertPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualKey")}
</label>
<textarea
rows={5}
value={manualKeyDraft}
onChange={(e) => setManualKeyDraft(e.target.value)}
placeholder={t("admin.sslManualKeyPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslManualDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleManualUpload}
disabled={manualUploading}
>
<RefreshCw
className={`size-3 ${manualUploading ? "animate-spin" : ""}`}
/>
{manualUploading
? t("admin.sslManualUploadLoading")
: t("admin.sslManualUpload")}
</Button>
<span className="text-[10px] text-muted-foreground border-t border-border pt-2">
{t("admin.sslInfoNote")}
</span>
</div>
</AccordionSection>
);
}