Merge pull request 'feat(web): read-write configuration from admin UI' (#124) from feat-admin-config-ui into main

Reviewed-on: #124
This commit was merged in pull request #124.
This commit is contained in:
2026-02-15 23:12:04 +00:00

View File

@@ -182,8 +182,7 @@ export function ModuleCard({
{/* Dependencies note */} {/* Dependencies note */}
{deps.length > 0 && expanded && ( {deps.length > 0 && expanded && (
<div style={depNoteStyle}> <div style={depNoteStyle}>
Requires:{" "} Requires: {deps.map((d) => allModules[d]?.name ?? d).join(", ")}
{deps.map((d) => allModules[d]?.name ?? d).join(", ")}
</div> </div>
)} )}
@@ -194,7 +193,9 @@ export function ModuleCard({
{/* Footer */} {/* Footer */}
<div style={footerStyle}> <div style={footerStyle}>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> <div
style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}
>
{hasEdits && ( {hasEdits && (
<button <button
onClick={handleSave} onClick={handleSave}
@@ -214,14 +215,26 @@ export function ModuleCard({
</button> </button>
)} )}
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}> <div
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
{saveSuccess && ( {saveSuccess && (
<span style={{ color: "var(--ctp-green)", fontSize: "var(--font-body)" }}> <span
style={{
color: "var(--ctp-green)",
fontSize: "var(--font-body)",
}}
>
Saved Saved
</span> </span>
)} )}
{saveError && ( {saveError && (
<span style={{ color: "var(--ctp-red)", fontSize: "var(--font-body)" }}> <span
style={{
color: "var(--ctp-red)",
fontSize: "var(--font-body)",
}}
>
{saveError} {saveError}
</span> </span>
)} )}
@@ -251,11 +264,21 @@ export function ModuleCard({
> >
{testResult.success ? "OK" : "Failed"} {testResult.success ? "OK" : "Failed"}
</span> </span>
<span style={{ color: "var(--ctp-subtext0)", fontSize: "var(--font-body)" }}> <span
style={{
color: "var(--ctp-subtext0)",
fontSize: "var(--font-body)",
}}
>
{testResult.message} {testResult.message}
</span> </span>
{testResult.latency_ms > 0 && ( {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 {testResult.latency_ms}ms
</span> </span>
)} )}
@@ -279,9 +302,22 @@ function renderModuleFields(
case "core": case "core":
return ( return (
<FieldGrid> <FieldGrid>
<ReadOnlyField label="Host" value={settings.host} /> <EditableField
<ReadOnlyField label="Port" value={settings.port} /> label="Host"
<ReadOnlyField label="Base URL" value={settings.base_url} /> 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 <ReadOnlyField
label="Read Only" label="Read Only"
value={settings.readonly ? "Yes" : "No"} value={settings.readonly ? "Yes" : "No"}
@@ -291,33 +327,96 @@ function renderModuleFields(
case "schemas": case "schemas":
return ( return (
<FieldGrid> <FieldGrid>
<ReadOnlyField label="Directory" value={settings.directory} /> <EditableField
<ReadOnlyField label="Default" value={settings.default} /> 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} /> <ReadOnlyField label="Schema Count" value={settings.count} />
</FieldGrid> </FieldGrid>
); );
case "database": case "database":
return ( return (
<FieldGrid> <FieldGrid>
<ReadOnlyField label="Host" value={settings.host} /> <EditableField
<ReadOnlyField label="Port" value={settings.port} /> label="Host"
<ReadOnlyField label="Database" value={settings.name} /> value={getValue("host")}
<ReadOnlyField label="User" value={settings.user} /> onChange={(v) => setValue("host", v)}
<ReadOnlyField label="SSL Mode" value={settings.sslmode} /> />
<ReadOnlyField label="Max Connections" value={settings.max_connections} /> <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> </FieldGrid>
); );
case "storage": case "storage":
return ( return (
<FieldGrid> <FieldGrid>
<ReadOnlyField label="Endpoint" value={settings.endpoint} /> <EditableField
<ReadOnlyField label="Bucket" value={settings.bucket} /> label="Endpoint"
<ReadOnlyField label="SSL" value={settings.use_ssl ? "Yes" : "No"} /> value={getValue("endpoint")}
<ReadOnlyField label="Region" value={settings.region} /> 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> </FieldGrid>
); );
case "auth": case "auth":
return renderAuthFields(settings); return renderAuthFields(settings, getValue, setValue);
case "freecad": case "freecad":
return ( return (
<FieldGrid> <FieldGrid>
@@ -386,33 +485,114 @@ function renderModuleFields(
} }
} }
function renderAuthFields(settings: Record<string, unknown>) { function renderAuthFields(
const local = (settings.local ?? {}) as Record<string, unknown>; settings: Record<string, unknown>,
const ldap = (settings.ldap ?? {}) as Record<string, unknown>; getValue: (key: string) => unknown,
const oidc = (settings.oidc ?? {}) as Record<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 ( return (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}> <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<SubSection title="Local Auth"> <SubSection title="Local Auth">
<FieldGrid> <FieldGrid>
<ReadOnlyField label="Enabled" value={local.enabled ? "Yes" : "No"} /> <CheckboxField
<ReadOnlyField label="Default Admin" value={local.default_admin_username} /> 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> </FieldGrid>
</SubSection> </SubSection>
<SubSection title="LDAP / FreeIPA"> <SubSection title="LDAP / FreeIPA">
<FieldGrid> <FieldGrid>
<ReadOnlyField label="Enabled" value={ldap.enabled ? "Yes" : "No"} /> <CheckboxField
<ReadOnlyField label="URL" value={ldap.url} /> label="Enabled"
<ReadOnlyField label="Base DN" value={ldap.base_dn} /> value={ldap.enabled}
<ReadOnlyField label="Bind DN" value={ldap.bind_dn} /> 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> </FieldGrid>
</SubSection> </SubSection>
<SubSection title="OIDC / Keycloak"> <SubSection title="OIDC / Keycloak">
<FieldGrid> <FieldGrid>
<ReadOnlyField label="Enabled" value={oidc.enabled ? "Yes" : "No"} /> <CheckboxField
<ReadOnlyField label="Issuer URL" value={oidc.issuer_url} /> label="Enabled"
<ReadOnlyField label="Client ID" value={oidc.client_id} /> value={oidc.enabled}
<ReadOnlyField label="Redirect URL" value={oidc.redirect_url} /> 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> </FieldGrid>
</SubSection> </SubSection>
</div> </div>
@@ -440,17 +620,9 @@ function SubSection({
); );
} }
function ReadOnlyField({ function ReadOnlyField({ label, value }: { label: string; value: unknown }) {
label,
value,
}: {
label: string;
value: unknown;
}) {
const display = const display =
value === undefined || value === null || value === "" value === undefined || value === null || value === "" ? "—" : String(value);
? "—"
: String(value);
return ( return (
<div> <div>
<div style={fieldLabelStyle}>{label}</div> <div style={fieldLabelStyle}>{label}</div>
@@ -476,7 +648,7 @@ function EditableField({
<div> <div>
<div style={fieldLabelStyle}>{label}</div> <div style={fieldLabelStyle}>{label}</div>
<input <input
type={type} type={isRedacted ? "password" : type}
value={isRedacted ? "" : strVal} value={isRedacted ? "" : strVal}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={isRedacted ? "••••••••" : undefined} 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 --- // --- Styles ---
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {