feat(web): read-write configuration from admin UI
Convert all module settings from read-only to editable fields in the admin settings page: - Core: host, port, base_url (read-only stays read-only) - Schemas: directory, default (count stays read-only) - Database: host, port, name, user, password, sslmode (dropdown), max_connections - Storage: endpoint, bucket, use_ssl (checkbox), region - Auth: local/ldap/oidc sub-sections with enabled checkboxes, connection fields, and secret fields (password input for redacted) New field components: SelectField (dropdown), CheckboxField (toggle). Redacted fields now render as password inputs with placeholder. Auth uses nested key handling to send sub-section objects. Backend already persists overrides and flags restart-required changes. Closes #117
This commit is contained in:
@@ -182,8 +182,7 @@ export function ModuleCard({
|
||||
{/* Dependencies note */}
|
||||
{deps.length > 0 && expanded && (
|
||||
<div style={depNoteStyle}>
|
||||
Requires:{" "}
|
||||
{deps.map((d) => allModules[d]?.name ?? d).join(", ")}
|
||||
Requires: {deps.map((d) => allModules[d]?.name ?? d).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -194,7 +193,9 @@ export function ModuleCard({
|
||||
|
||||
{/* Footer */}
|
||||
<div style={footerStyle}>
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<div
|
||||
style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}
|
||||
>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
@@ -214,14 +215,26 @@ export function ModuleCard({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
{saveSuccess && (
|
||||
<span style={{ color: "var(--ctp-green)", fontSize: "var(--font-body)" }}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ctp-green)",
|
||||
fontSize: "var(--font-body)",
|
||||
}}
|
||||
>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{saveError && (
|
||||
<span style={{ color: "var(--ctp-red)", fontSize: "var(--font-body)" }}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ctp-red)",
|
||||
fontSize: "var(--font-body)",
|
||||
}}
|
||||
>
|
||||
{saveError}
|
||||
</span>
|
||||
)}
|
||||
@@ -251,11 +264,21 @@ export function ModuleCard({
|
||||
>
|
||||
{testResult.success ? "OK" : "Failed"}
|
||||
</span>
|
||||
<span style={{ color: "var(--ctp-subtext0)", fontSize: "var(--font-body)" }}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontSize: "var(--font-body)",
|
||||
}}
|
||||
>
|
||||
{testResult.message}
|
||||
</span>
|
||||
{testResult.latency_ms > 0 && (
|
||||
<span style={{ color: "var(--ctp-overlay1)", fontSize: "var(--font-body)" }}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ctp-overlay1)",
|
||||
fontSize: "var(--font-body)",
|
||||
}}
|
||||
>
|
||||
{testResult.latency_ms}ms
|
||||
</span>
|
||||
)}
|
||||
@@ -279,9 +302,22 @@ function renderModuleFields(
|
||||
case "core":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Host" value={settings.host} />
|
||||
<ReadOnlyField label="Port" value={settings.port} />
|
||||
<ReadOnlyField label="Base URL" value={settings.base_url} />
|
||||
<EditableField
|
||||
label="Host"
|
||||
value={getValue("host")}
|
||||
onChange={(v) => setValue("host", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Port"
|
||||
value={getValue("port")}
|
||||
onChange={(v) => setValue("port", Number(v))}
|
||||
type="number"
|
||||
/>
|
||||
<EditableField
|
||||
label="Base URL"
|
||||
value={getValue("base_url")}
|
||||
onChange={(v) => setValue("base_url", v)}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Read Only"
|
||||
value={settings.readonly ? "Yes" : "No"}
|
||||
@@ -291,33 +327,96 @@ function renderModuleFields(
|
||||
case "schemas":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Directory" value={settings.directory} />
|
||||
<ReadOnlyField label="Default" value={settings.default} />
|
||||
<EditableField
|
||||
label="Directory"
|
||||
value={getValue("directory")}
|
||||
onChange={(v) => setValue("directory", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Default"
|
||||
value={getValue("default")}
|
||||
onChange={(v) => setValue("default", v)}
|
||||
/>
|
||||
<ReadOnlyField label="Schema Count" value={settings.count} />
|
||||
</FieldGrid>
|
||||
);
|
||||
case "database":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Host" value={settings.host} />
|
||||
<ReadOnlyField label="Port" value={settings.port} />
|
||||
<ReadOnlyField label="Database" value={settings.name} />
|
||||
<ReadOnlyField label="User" value={settings.user} />
|
||||
<ReadOnlyField label="SSL Mode" value={settings.sslmode} />
|
||||
<ReadOnlyField label="Max Connections" value={settings.max_connections} />
|
||||
<EditableField
|
||||
label="Host"
|
||||
value={getValue("host")}
|
||||
onChange={(v) => setValue("host", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Port"
|
||||
value={getValue("port")}
|
||||
onChange={(v) => setValue("port", Number(v))}
|
||||
type="number"
|
||||
/>
|
||||
<EditableField
|
||||
label="Database"
|
||||
value={getValue("name")}
|
||||
onChange={(v) => setValue("name", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="User"
|
||||
value={getValue("user")}
|
||||
onChange={(v) => setValue("user", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Password"
|
||||
value={getValue("password")}
|
||||
onChange={(v) => setValue("password", v)}
|
||||
/>
|
||||
<SelectField
|
||||
label="SSL Mode"
|
||||
value={getValue("sslmode")}
|
||||
options={[
|
||||
"disable",
|
||||
"allow",
|
||||
"prefer",
|
||||
"require",
|
||||
"verify-ca",
|
||||
"verify-full",
|
||||
]}
|
||||
onChange={(v) => setValue("sslmode", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Max Connections"
|
||||
value={getValue("max_connections")}
|
||||
onChange={(v) => setValue("max_connections", Number(v))}
|
||||
type="number"
|
||||
/>
|
||||
</FieldGrid>
|
||||
);
|
||||
case "storage":
|
||||
return (
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Endpoint" value={settings.endpoint} />
|
||||
<ReadOnlyField label="Bucket" value={settings.bucket} />
|
||||
<ReadOnlyField label="SSL" value={settings.use_ssl ? "Yes" : "No"} />
|
||||
<ReadOnlyField label="Region" value={settings.region} />
|
||||
<EditableField
|
||||
label="Endpoint"
|
||||
value={getValue("endpoint")}
|
||||
onChange={(v) => setValue("endpoint", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Bucket"
|
||||
value={getValue("bucket")}
|
||||
onChange={(v) => setValue("bucket", v)}
|
||||
/>
|
||||
<CheckboxField
|
||||
label="Use SSL"
|
||||
value={getValue("use_ssl")}
|
||||
onChange={(v) => setValue("use_ssl", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Region"
|
||||
value={getValue("region")}
|
||||
onChange={(v) => setValue("region", v)}
|
||||
/>
|
||||
</FieldGrid>
|
||||
);
|
||||
case "auth":
|
||||
return renderAuthFields(settings);
|
||||
return renderAuthFields(settings, getValue, setValue);
|
||||
case "freecad":
|
||||
return (
|
||||
<FieldGrid>
|
||||
@@ -386,33 +485,114 @@ function renderModuleFields(
|
||||
}
|
||||
}
|
||||
|
||||
function renderAuthFields(settings: Record<string, unknown>) {
|
||||
const local = (settings.local ?? {}) as Record<string, unknown>;
|
||||
const ldap = (settings.ldap ?? {}) as Record<string, unknown>;
|
||||
const oidc = (settings.oidc ?? {}) as Record<string, unknown>;
|
||||
function renderAuthFields(
|
||||
settings: Record<string, unknown>,
|
||||
getValue: (key: string) => unknown,
|
||||
setValue: (key: string, value: unknown) => void,
|
||||
) {
|
||||
const local = (getValue("local") ?? settings.local ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const ldap = (getValue("ldap") ?? settings.ldap ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const oidc = (getValue("oidc") ?? settings.oidc ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const setNested = (
|
||||
section: string,
|
||||
current: Record<string, unknown>,
|
||||
field: string,
|
||||
v: unknown,
|
||||
) => {
|
||||
setValue(section, { ...current, [field]: v });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<SubSection title="Local Auth">
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Enabled" value={local.enabled ? "Yes" : "No"} />
|
||||
<ReadOnlyField label="Default Admin" value={local.default_admin_username} />
|
||||
<CheckboxField
|
||||
label="Enabled"
|
||||
value={local.enabled}
|
||||
onChange={(v) => setNested("local", local, "enabled", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Default Admin"
|
||||
value={local.default_admin_username}
|
||||
onChange={(v) =>
|
||||
setNested("local", local, "default_admin_username", v)
|
||||
}
|
||||
/>
|
||||
<EditableField
|
||||
label="Default Admin Password"
|
||||
value={local.default_admin_password}
|
||||
onChange={(v) =>
|
||||
setNested("local", local, "default_admin_password", v)
|
||||
}
|
||||
/>
|
||||
</FieldGrid>
|
||||
</SubSection>
|
||||
<SubSection title="LDAP / FreeIPA">
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Enabled" value={ldap.enabled ? "Yes" : "No"} />
|
||||
<ReadOnlyField label="URL" value={ldap.url} />
|
||||
<ReadOnlyField label="Base DN" value={ldap.base_dn} />
|
||||
<ReadOnlyField label="Bind DN" value={ldap.bind_dn} />
|
||||
<CheckboxField
|
||||
label="Enabled"
|
||||
value={ldap.enabled}
|
||||
onChange={(v) => setNested("ldap", ldap, "enabled", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="URL"
|
||||
value={ldap.url}
|
||||
onChange={(v) => setNested("ldap", ldap, "url", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Base DN"
|
||||
value={ldap.base_dn}
|
||||
onChange={(v) => setNested("ldap", ldap, "base_dn", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Bind DN"
|
||||
value={ldap.bind_dn}
|
||||
onChange={(v) => setNested("ldap", ldap, "bind_dn", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Bind Password"
|
||||
value={ldap.bind_password}
|
||||
onChange={(v) => setNested("ldap", ldap, "bind_password", v)}
|
||||
/>
|
||||
</FieldGrid>
|
||||
</SubSection>
|
||||
<SubSection title="OIDC / Keycloak">
|
||||
<FieldGrid>
|
||||
<ReadOnlyField label="Enabled" value={oidc.enabled ? "Yes" : "No"} />
|
||||
<ReadOnlyField label="Issuer URL" value={oidc.issuer_url} />
|
||||
<ReadOnlyField label="Client ID" value={oidc.client_id} />
|
||||
<ReadOnlyField label="Redirect URL" value={oidc.redirect_url} />
|
||||
<CheckboxField
|
||||
label="Enabled"
|
||||
value={oidc.enabled}
|
||||
onChange={(v) => setNested("oidc", oidc, "enabled", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Issuer URL"
|
||||
value={oidc.issuer_url}
|
||||
onChange={(v) => setNested("oidc", oidc, "issuer_url", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Client ID"
|
||||
value={oidc.client_id}
|
||||
onChange={(v) => setNested("oidc", oidc, "client_id", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Client Secret"
|
||||
value={oidc.client_secret}
|
||||
onChange={(v) => setNested("oidc", oidc, "client_secret", v)}
|
||||
/>
|
||||
<EditableField
|
||||
label="Redirect URL"
|
||||
value={oidc.redirect_url}
|
||||
onChange={(v) => setNested("oidc", oidc, "redirect_url", v)}
|
||||
/>
|
||||
</FieldGrid>
|
||||
</SubSection>
|
||||
</div>
|
||||
@@ -440,17 +620,9 @@ function SubSection({
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: unknown;
|
||||
}) {
|
||||
function ReadOnlyField({ label, value }: { label: string; value: unknown }) {
|
||||
const display =
|
||||
value === undefined || value === null || value === ""
|
||||
? "—"
|
||||
: String(value);
|
||||
value === undefined || value === null || value === "" ? "—" : String(value);
|
||||
return (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{label}</div>
|
||||
@@ -476,7 +648,7 @@ function EditableField({
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{label}</div>
|
||||
<input
|
||||
type={type}
|
||||
type={isRedacted ? "password" : type}
|
||||
value={isRedacted ? "" : strVal}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={isRedacted ? "••••••••" : undefined}
|
||||
@@ -487,6 +659,57 @@ function EditableField({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: unknown;
|
||||
options: string[];
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{label}</div>
|
||||
<select
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={fieldInputStyle}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: unknown;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
style={{ accentColor: "var(--ctp-mauve)" }}
|
||||
/>
|
||||
<div style={fieldLabelStyle}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
|
||||
Reference in New Issue
Block a user