feat(panel-access): add safe custom-domain reset and gate SSL availability

This commit is contained in:
KodekaTeam committed 2026-07-29 15:28:57 +07:00
1 parent 6eb9b47290
commit b358138f77
3 files changed
+115 -3

No files matched your search

@@ -6,6 +6,7 @@ import {
ChevronDown, ChevronDown,
Globe2, Globe2,
Loader2, Loader2,
RotateCcw,
ShieldCheck, ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -60,7 +61,7 @@ export default function PanelAccessSettingsPanel({
const [panelDomain, setPanelDomain] = useState(initialDomain); const [panelDomain, setPanelDomain] = useState(initialDomain);
const [panelProxy, setPanelProxy] = useState<PanelAccessProxy>("NGINX"); const [panelProxy, setPanelProxy] = useState<PanelAccessProxy>("NGINX");
const [panelAutoSsl, setPanelAutoSsl] = useState( const [panelAutoSsl, setPanelAutoSsl] = useState(
settings.general.panelUrl.startsWith("https://") || !initialDomain, Boolean(initialDomain) && settings.general.panelUrl.startsWith("https://"),
); );
const [capabilities, setCapabilities] = const [capabilities, setCapabilities] =
useState<PanelAccessCapabilities | null>(null); useState<PanelAccessCapabilities | null>(null);
@@ -72,6 +73,13 @@ export default function PanelAccessSettingsPanel({
const selectedCapability = capabilities?.proxies.find( const selectedCapability = capabilities?.proxies.find(
(proxy) => proxy.type === panelProxy, (proxy) => proxy.type === panelProxy,
); );
const hasProvisionableProxy = Boolean(
capabilities?.proxies.some(
(proxy) => proxy.available && proxy.supportsProvisioning,
),
);
const sslDisabled = !panelDomain.trim() || !hasProvisionableProxy;
const hasCustomDomain = Boolean(initialDomain);
const canProvision = Boolean( const canProvision = Boolean(
panelDomain.trim() && panelDomain.trim() &&
selectedCapability?.available && selectedCapability?.available &&
@@ -121,7 +129,7 @@ export default function PanelAccessSettingsPanel({
const response = await settingsApi.provisionPanelDomain({ const response = await settingsApi.provisionPanelDomain({
domain: panelDomain.trim(), domain: panelDomain.trim(),
proxy: panelProxy, proxy: panelProxy,
autoSsl: panelAutoSsl, autoSsl: panelAutoSsl && !sslDisabled,
}); });
setMessage(response.message || response.data.provision.message); setMessage(response.message || response.data.provision.message);
onProvisioned?.(response.data.settings, response.message); onProvisioned?.(response.data.settings, response.message);
@@ -134,6 +142,34 @@ export default function PanelAccessSettingsPanel({
} }
}; };
const resetPanelDomain = async () => {
if (
!window.confirm(
"Reset the custom panel domain? This only removes it from Doktainer settings; reverse-proxy and SSL configuration on the server will remain unchanged.",
)
) {
return;
}
setProvisioning(true);
setError(null);
setMessage(null);
try {
const response = await settingsApi.resetPanelDomain();
setPanelDomain("");
setPanelAutoSsl(false);
setMessage(response.message || "Custom panel domain reset");
onProvisioned?.(response.data, response.message);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to reset panel domain",
);
} finally {
setProvisioning(false);
}
};
return ( return (
<div <div
className="card" className="card"
@@ -396,10 +432,25 @@ export default function PanelAccessSettingsPanel({
</div> </div>
</div> </div>
<Toggle <Toggle
checked={panelAutoSsl} checked={panelAutoSsl && !sslDisabled}
onChange={() => setPanelAutoSsl((current) => !current)} onChange={() => setPanelAutoSsl((current) => !current)}
disabled={sslDisabled}
/> />
</div> </div>
{sslDisabled ? (
<div
style={{
marginTop: -8,
fontSize: 11,
color: "var(--text-muted)",
lineHeight: 1.5,
}}
>
{!panelDomain.trim()
? "Enter a domain to enable HTTPS / SSL."
: "HTTPS / SSL is unavailable because no reverse proxy can be provisioned."}
</div>
) : null}
{capabilities?.autoSsl.reason ? ( {capabilities?.autoSsl.reason ? (
<div <div
style={{ style={{
@@ -423,6 +474,21 @@ export default function PanelAccessSettingsPanel({
paddingTop: 2, paddingTop: 2,
}} }}
> >
<button
type="button"
className="btn btn-ghost"
disabled={!hasCustomDomain || provisioning}
onClick={() => void resetPanelDomain()}
style={{
minWidth: 88,
opacity: hasCustomDomain && !provisioning ? 1 : 0.55,
cursor:
hasCustomDomain && !provisioning ? "pointer" : "not-allowed",
}}
>
<RotateCcw size={14} />
Reset
</button>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
+6
View File
@@ -1163,6 +1163,12 @@ export const settingsApi = {
message?: string; message?: string;
}>("/settings/panel-access/provision", body, { timeoutMs: 30000 }), }>("/settings/panel-access/provision", body, { timeoutMs: 30000 }),
resetPanelDomain: () =>
post<{ success: boolean; data: SettingsRecord; message?: string }>(
"/settings/panel-access/reset",
{},
),
update: (body: UpdateSettingsBody) => update: (body: UpdateSettingsBody) =>
patch<{ success: boolean; data: SettingsRecord; message?: string }>( patch<{ success: boolean; data: SettingsRecord; message?: string }>(
"/settings", "/settings",
+40
View File
@@ -1032,6 +1032,46 @@ export async function settingsRoutes(app: FastifyInstance) {
}, },
); );
app.post(
"/panel-access/reset",
{ preHandler: [authenticate] },
async (req, reply) => {
// Do not use request headers here: this route can be reached through the
// custom domain that is being removed.
const panelUrl = resolvePublicAppOrigin({
fallbackOrigin: "http://localhost:3000",
});
await prisma.userSettings.upsert({
where: { userId: req.userId! },
update: { panelUrl },
create: { userId: req.userId!, panelUrl },
});
const saved = await ensureModularSettings(
req.userId!,
req.organizationId,
);
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "PANEL_DOMAIN_RESET",
category: "SYSTEM",
level: "WARNING",
message: "Custom panel domain was removed from Panel Access settings",
meta: { panelUrl },
});
return reply.send({
success: true,
data: serializeSettings(saved),
message:
"Custom panel domain was reset. Existing reverse-proxy and SSL configuration was left unchanged.",
});
},
);
app.patch( app.patch(
"/", "/",
{ preHandler: [authenticate], bodyLimit: 512 * 1024 }, { preHandler: [authenticate], bodyLimit: 512 * 1024 },