feat(settings): add domain provisioning for Custom Panel URL

This commit is contained in:
KodekaTeam committed 2026-06-11 00:13:55 +07:00
1 parent b70c93af85
commit 3ca1aa1450
8 files changed
+1186 -23

No files matched your search

@@ -39,12 +39,35 @@ export default function GeneralSettingsPanel({
fontSize: 15,
fontWeight: 700,
color: "var(--text-primary)",
marginBottom: 20,
marginBottom: 4,
}}
>
General Settings
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<div>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Panel Identity
</div>
<div
style={{
marginTop: 3,
fontSize: 12,
color: "var(--text-muted)",
lineHeight: 1.5,
}}
>
Name and workspace preferences
</div>
</div>
<div
style={{
display: "grid",
@@ -64,26 +87,6 @@ export default function GeneralSettingsPanel({
}
/>
</div>
<div>
<FieldLabel>Panel URL</FieldLabel>
<input
className="input"
readOnly
disabled
value={settings.general.panelUrl}
onChange={(event) =>
updateGeneral("panelUrl", event.target.value)
}
/>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
gap: 16,
}}
>
<div>
<FieldLabel>Timezone</FieldLabel>
<select
@@ -101,6 +104,15 @@ export default function GeneralSettingsPanel({
))}
</select>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
gap: 16,
}}
>
<div>
<FieldLabel>Session Timeout</FieldLabel>
<select
@@ -122,6 +134,7 @@ export default function GeneralSettingsPanel({
</select>
</div>
</div>
<div>
<FieldLabel>Theme</FieldLabel>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
@@ -153,6 +166,7 @@ export default function GeneralSettingsPanel({
</div>
</div>
</div>
<SettingsFooterActions
saving={saving}
resetting={resetting}
@@ -0,0 +1,431 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
AlertTriangle,
Globe2,
Loader2,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import {
settingsApi,
type PanelAccessCapabilities,
type PanelAccessProxy,
type SettingsRecord,
} from "@/lib/api";
import {
FieldLabel,
Toggle,
} from "@/app/settings/components/SettingsPrimitives";
import type { EditableSettings } from "@/app/settings/components/settings-types";
interface PanelAccessSettingsPanelProps {
settings: EditableSettings;
onProvisioned?: (settings: SettingsRecord, message?: string) => void;
}
const proxyOptions = [
{ value: "NGINX", label: "Nginx" },
{ value: "CADDY", label: "Caddy" },
{ value: "TRAEFIK", label: "Traefik" },
] as const satisfies Array<{ value: PanelAccessProxy; label: string }>;
function resolveInitialDomain(panelUrl: string) {
try {
const parsed = new URL(panelUrl);
const host = parsed.hostname.toLowerCase();
const isLocalOrIp =
host === "localhost" ||
host === "127.0.0.1" ||
host === "0.0.0.0" ||
host === "::1" ||
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) ||
host.endsWith(".local");
return isLocalOrIp ? "" : parsed.hostname;
} catch {
return "";
}
}
export default function PanelAccessSettingsPanel({
settings,
onProvisioned,
}: PanelAccessSettingsPanelProps) {
const initialDomain = useMemo(
() => resolveInitialDomain(settings.general.panelUrl),
[settings.general.panelUrl],
);
const [panelDomain, setPanelDomain] = useState(initialDomain);
const [panelProxy, setPanelProxy] = useState<PanelAccessProxy>("NGINX");
const [panelAutoSsl, setPanelAutoSsl] = useState(
settings.general.panelUrl.startsWith("https://") || !initialDomain,
);
const [capabilities, setCapabilities] =
useState<PanelAccessCapabilities | null>(null);
const [loadingCapabilities, setLoadingCapabilities] = useState(true);
const [provisioning, setProvisioning] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const selectedCapability = capabilities?.proxies.find(
(proxy) => proxy.type === panelProxy,
);
const panelRoutePreview = panelDomain.trim()
? `${panelDomain.trim()} -> ${panelProxy}`
: "No custom panel domain configured";
const canProvision = Boolean(
panelDomain.trim() &&
selectedCapability?.available &&
selectedCapability.supportsProvisioning &&
!provisioning,
);
useEffect(() => {
let mounted = true;
const loadCapabilities = async () => {
setLoadingCapabilities(true);
setError(null);
try {
const response = await settingsApi.getPanelAccessCapabilities();
if (!mounted) return;
setCapabilities(response.data);
if (response.data.defaultProxy) {
setPanelProxy(response.data.defaultProxy);
}
} catch (err) {
if (!mounted) return;
setError(
err instanceof Error
? err.message
: "Failed to inspect panel host reverse proxy",
);
} finally {
if (mounted) setLoadingCapabilities(false);
}
};
void loadCapabilities();
return () => {
mounted = false;
};
}, []);
const provisionPanelDomain = async () => {
setProvisioning(true);
setError(null);
setMessage(null);
try {
const response = await settingsApi.provisionPanelDomain({
domain: panelDomain.trim(),
proxy: panelProxy,
autoSsl: panelAutoSsl,
});
setMessage(response.message || response.data.provision.message);
onProvisioned?.(response.data.settings, response.message);
} catch (err) {
setError(
err instanceof Error ? err.message : "Panel domain provisioning failed",
);
} finally {
setProvisioning(false);
}
};
return (
<div
className="card"
style={{ padding: 24, display: "flex", flexDirection: "column", gap: 20 }}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
>
<div style={{ minWidth: 0 }}>
<h2
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 15,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
<Globe2 size={16} color="var(--accent-green)" />
Panel Access
</h2>
<div
style={{
marginTop: 5,
fontSize: 12,
color: "var(--text-muted)",
lineHeight: 1.5,
}}
>
Custom domain provisioning for the Doktainer panel
</div>
</div>
<span className="ui-badge badge-info">
{loadingCapabilities
? "Inspecting"
: (capabilities?.target.label ?? "Panel host")}
</span>
</div>
{error ? (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
padding: "10px 12px",
border: "1px solid rgba(239,68,68,0.28)",
borderRadius: 8,
background: "rgba(239,68,68,0.1)",
color: "#ef4444",
fontSize: 12,
lineHeight: 1.5,
}}
>
<AlertTriangle size={14} style={{ flexShrink: 0, marginTop: 2 }} />
<span>{error}</span>
</div>
) : null}
{message ? (
<div
style={{
padding: "10px 12px",
border: "1px solid rgba(16,185,129,0.28)",
borderRadius: 8,
background: "rgba(16,185,129,0.1)",
color: "#10b981",
fontSize: 12,
lineHeight: 1.5,
}}
>
{message}
</div>
) : null}
{capabilities?.target.diagnostic ? (
<div
style={{
padding: "10px 12px",
border: "1px solid rgba(59,130,246,0.24)",
borderRadius: 8,
background: "rgba(59,130,246,0.07)",
color: "var(--text-secondary)",
fontSize: 12,
lineHeight: 1.5,
}}
>
{capabilities.target.diagnostic}
</div>
) : null}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
gap: 16,
}}
>
<div>
<FieldLabel>Domain</FieldLabel>
<input
className="input"
placeholder="panel.example.com"
value={panelDomain}
onChange={(event) => setPanelDomain(event.target.value)}
/>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
gap: 16,
alignItems: "stretch",
}}
>
<div>
<FieldLabel>Reverse Proxy</FieldLabel>
<div
className="ui-pill-switch"
style={{
border: "1px solid var(--border)",
background: "var(--bg-input)",
}}
>
{proxyOptions.map((option) => {
const active = panelProxy === option.value;
const capability = capabilities?.proxies.find(
(proxy) => proxy.type === option.value,
);
const disabled =
loadingCapabilities ||
!capability?.available ||
!capability.supportsProvisioning;
return (
<button
key={option.value}
type="button"
onClick={() => {
if (!disabled) setPanelProxy(option.value);
}}
disabled={disabled}
title={capability?.reason ?? undefined}
style={{
minHeight: 34,
flex: "1 1 120px",
border: "none",
borderRadius: 6,
background: active ? "#1d4ed8" : "transparent",
color: disabled
? "var(--text-muted)"
: active
? "white"
: "var(--text-secondary)",
fontSize: 12,
fontWeight: 700,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : 1,
}}
>
{option.label}
</button>
);
})}
</div>
{selectedCapability?.reason ? (
<div
style={{
marginTop: 7,
fontSize: 11,
color: "var(--text-muted)",
lineHeight: 1.5,
}}
>
{selectedCapability.reason}
</div>
) : null}
</div>
<div
style={{
minHeight: 62,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "11px 12px",
border: "1px solid var(--border)",
borderRadius: 8,
background: "var(--bg-input)",
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 7,
fontSize: 12,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
<ShieldCheck size={14} color="var(--accent-green)" />
Auto SSL
</div>
<div
style={{
marginTop: 2,
fontSize: 11,
color: "var(--text-muted)",
}}
>
Certbot or proxy-managed TLS
</div>
</div>
<Toggle
checked={panelAutoSsl}
onChange={() => setPanelAutoSsl((current) => !current)}
/>
</div>
</div>
{capabilities?.autoSsl.reason ? (
<div
style={{
marginTop: -8,
fontSize: 11,
color: "var(--text-muted)",
lineHeight: 1.5,
}}
>
{capabilities.autoSsl.reason}
</div>
) : null}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
padding: "12px 14px",
border: "1px solid rgba(59,130,246,0.24)",
borderRadius: 8,
background: "rgba(59,130,246,0.07)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
minWidth: 0,
color: "var(--text-secondary)",
fontSize: 12,
}}
>
<RefreshCw size={14} color="var(--accent-blue)" />
<span>{panelRoutePreview}</span>
</div>
<button
type="button"
className="btn btn-primary"
disabled={!canProvision}
onClick={() => void provisionPanelDomain()}
style={{
minWidth: 168,
opacity: canProvision ? 1 : 0.55,
cursor: canProvision ? "pointer" : "not-allowed",
}}
>
{provisioning ? (
<>
<Loader2 size={14} className="animate-spin" />
Provisioning
</>
) : (
"Provision Panel Domain"
)}
</button>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { Lock, Settings, type LucideIcon } from "lucide-react";
import { Globe2, Lock, Settings, type LucideIcon } from "lucide-react";
import type { SettingsTab } from "@/app/settings/components/settings-types";
export const TIMEZONE_OPTIONS = [
@@ -25,10 +25,12 @@ export const SETTINGS_TABS: Array<{
icon: LucideIcon;
}> = [
{ id: "general", label: "General", icon: Settings },
{ id: "panel-access", label: "Panel Access", icon: Globe2 },
{ id: "security", label: "Security", icon: Lock },
];
export const ACTIVE_TAB_LABELS: Record<SettingsTab, string> = {
general: "General",
"panel-access": "Panel Access",
security: "Security",
};
@@ -1,7 +1,7 @@
import type { Dispatch, SetStateAction } from "react";
import type { SettingsRecord } from "@/lib/api";
export type SettingsTab = "general" | "security";
export type SettingsTab = "general" | "panel-access" | "security";
export type IntegrationProvider = "cloudflare" | "awsS3" | "github" | "gitlab";
+11
View File
@@ -5,6 +5,7 @@ import GuardedPage from "@/components/GuardedPage";
import { auth, settingsApi, type SettingsRecord } from "@/lib/api";
import { getStoredTheme, storeUiPreferences } from "@/lib/preferences";
import GeneralSettingsPanel from "@/app/settings/components/GeneralSettingsPanel";
import PanelAccessSettingsPanel from "@/app/settings/components/PanelAccessSettingsPanel";
import SecuritySettingsPanel from "@/app/settings/components/SecuritySettingsPanel";
import {
Banner,
@@ -329,6 +330,16 @@ export default function SettingsPage() {
/>
) : null}
{activeTab === "panel-access" ? (
<PanelAccessSettingsPanel
settings={settings}
onProvisioned={(nextSettings, message) => {
syncSettingsFromServer(nextSettings);
setSuccess(message || "Panel domain provisioned");
}}
/>
) : null}
{activeTab === "security" ? (
<SecuritySettingsPanel
settings={settings}