Fix mtls to be the same as the other auth sections

This commit is contained in:
Owen
2026-10-20 09:28:05 -04:00
parent ec184cdf33
commit bf0c6613c9
8 changed files with 163 additions and 165 deletions
+4 -2
View File
@@ -933,6 +933,9 @@
"policyAuthHeaderAuthTitle": "Basic Header Auth",
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
"policyAuthHeaderAuthSummary": "Header configured",
"policyAuthMtlsTitle": "Client Certificate (mTLS)",
"policyAuthMtlsDescription": "Require a client certificate signed by a trusted CA during the TLS handshake",
"policyAuthMtlsSummary": "{count} trusted CA certificates",
"policyAuthHeaderName": "Username",
"policyAuthHeaderValue": "Password",
"policyAuthSetPasscode": "Set Passcode",
@@ -3939,7 +3942,7 @@
"backToHome": "Go back to home",
"needToSignInToOrg": "Need to use your organization's identity provider?",
"mtls": "mTLS",
"mtlsCaCertificates": "Trusted CA certificates",
"mtlsCaCertificates": "Set Client Certificates",
"mtlsCaCertificatesDescription": "Clients must present a certificate signed by one of these certificate authorities to connect. Upload public CA certificates only, never a private key.",
"mtlsCertificateName": "Name (optional)",
"mtlsCertificateNamePlaceholder": "Corporate device CA",
@@ -3956,7 +3959,6 @@
"mtlsCertificateErrorRemoveDescription": "The certificate could not be removed.",
"mtlsLastCertificateRemoved": "mTLS was turned off because the last CA certificate was removed.",
"mtlsNoCertificates": "No CA certificates added yet",
"mtlsSummary": "{count} trusted CA certificates",
"mtlsRemoveCertificate": "Remove CA certificate",
"mtlsExpired": "Expired",
"mtlsExpiresSoon": "Expires soon",
@@ -1,14 +1,7 @@
"use client";
import { ResourceMtlsSection } from "@app/components/ResourceMtlsSection";
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
import { SettingsContainer } from "@app/components/Settings";
export default function ResourceAuthenticationPage() {
return (
<SettingsContainer>
<ResourcePolicyEditForm section="authentication" />
<ResourceMtlsSection />
</SettingsContainer>
);
return <ResourcePolicyEditForm section="authentication" />;
}
-152
View File
@@ -1,152 +0,0 @@
"use client";
import { PolicyAuthMethodRow } from "@app/components/resource-policy/PolicyAuthMethodRow";
import { ResourceMtlsCertificatesCredenza } from "@app/components/ResourceMtlsCertificatesCredenza";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { resourceQueries } from "@app/lib/queries";
import type { UpdateResourceResponse } from "@server/routers/resource";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { AlertCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
export function ResourceMtlsSection() {
const t = useTranslations();
const { env } = useEnvContext();
const { resource, updateResource } = useResourceContext();
const api = createApiClient({ env });
const supportsMtls =
!env.flags.disableEnterpriseFeatures && resource.mode === "http";
const { data: certificates = [], isLoading: isLoadingCertificates } =
useQuery({
...resourceQueries.resourceMtlsCertificates({
resourceId: resource.resourceId
}),
enabled: supportsMtls
});
const [mtlsEnabled, setMtlsEnabled] = useState(resource.mtlsEnabled);
const [certificatesOpen, setCertificatesOpen] = useState(false);
const [saving, setSaving] = useState(false);
// The server switches mTLS off when the last CA is removed.
useEffect(() => {
setMtlsEnabled(resource.mtlsEnabled);
}, [resource.mtlsEnabled]);
if (!supportsMtls) {
return null;
}
const hasCertificates = certificates.length > 0;
function onToggle(active: boolean) {
setMtlsEnabled(active);
setCertificatesOpen(active);
}
function onCertificatesOpenChange(open: boolean) {
setCertificatesOpen(open);
// mTLS can't be enabled without a trusted CA.
if (!open && !hasCertificates) {
setMtlsEnabled(false);
}
}
async function onSave() {
setSaving(true);
try {
await api.post<AxiosResponse<UpdateResourceResponse>>(
`/resource/${resource.resourceId}`,
{ mtlsEnabled }
);
updateResource({ mtlsEnabled });
toast({
title: t("resourceUpdated"),
description: t("resourceUpdatedDescription")
});
} catch (e) {
toast({
variant: "destructive",
title: t("resourceErrorUpdate"),
description: formatAxiosError(
e,
t("resourceErrorUpdateDescription")
)
});
} finally {
setSaving(false);
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("mtlsEnforcement")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("mtlsEnforcementDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-4">
{!resource.ssl && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("mtlsRequiresSsl")}
</AlertDescription>
</Alert>
)}
<PolicyAuthMethodRow
id="mtls"
title={t("enableMtls")}
description={t("enableMtlsDescription")}
summary={t("mtlsSummary", {
count: certificates.length
})}
active={mtlsEnabled}
onConfigure={() => setCertificatesOpen(true)}
onToggle={onToggle}
disabled={!resource.ssl}
/>
</div>
<ResourceMtlsCertificatesCredenza
open={certificatesOpen}
onOpenChange={onCertificatesOpenChange}
certificates={certificates}
isLoading={isLoadingCertificates}
/>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="button"
loading={saving}
disabled={
saving ||
mtlsEnabled === resource.mtlsEnabled ||
(mtlsEnabled && !hasCertificates)
}
onClick={onSave}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
);
}
@@ -13,6 +13,7 @@ import type { PolicyAuthMethodId } from "./policy-auth-method-id";
import {
getEmailWhitelistSummary,
getHeaderAuthSummary,
getMtlsSummary,
getPasscodeSummary,
getPincodeSummary
} from "./policy-auth-summaries";
@@ -31,6 +32,14 @@ export type PolicyAuthOtherMethodsSectionProps = {
onTogglePasscode: (active: boolean) => void;
onToggleEmail: (active: boolean) => void;
onToggleHeaderAuth: (active: boolean) => void;
mtls?: {
active: boolean;
certificateCount: number;
sslEnabled: boolean;
disabled?: boolean;
onConfigure: () => void;
onToggle: (active: boolean) => void;
};
};
export function PolicyAuthOtherMethodsSection({
@@ -46,7 +55,8 @@ export function PolicyAuthOtherMethodsSection({
onTogglePincode,
onTogglePasscode,
onToggleEmail,
onToggleHeaderAuth
onToggleHeaderAuth,
mtls
}: PolicyAuthOtherMethodsSectionProps) {
const t = useTranslations();
@@ -113,6 +123,26 @@ export function PolicyAuthOtherMethodsSection({
onToggle={onToggleHeaderAuth}
disabled={disabled}
/>
{mtls && (
<PolicyAuthMethodRow
id="mtls"
title={t("policyAuthMtlsTitle")}
description={
mtls.sslEnabled
? t("policyAuthMtlsDescription")
: t("mtlsRequiresSsl")
}
summary={getMtlsSummary({
t,
count: mtls.certificateCount
})}
active={mtls.active}
onConfigure={mtls.onConfigure}
onToggle={mtls.onToggle}
disabled={mtls.disabled || !mtls.sslEnabled}
/>
)}
</div>
</SettingsFormCell>
</SettingsFormGrid>
@@ -14,10 +14,12 @@ import {
type SelectedRole
} from "@app/components/roles-selector";
import { UsersSelector } from "@app/components/users-selector";
import { ResourceMtlsCertificatesCredenza } from "@app/components/ResourceMtlsCertificatesCredenza";
import { Button } from "@app/components/ui/button";
import { Form, FormField } from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useResourceMtls } from "@app/hooks/useResourceMtls";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { resourceQueries } from "@app/lib/queries";
@@ -258,6 +260,12 @@ export function PolicyAuthStackSectionEdit({
const [editingMethod, setEditingMethod] =
useState<PolicyAuthMethodId | null>(null);
const mtls = useResourceMtls();
// mTLS is resource-level, so it stays editable when the policy is shared.
const mtlsReadonly = readonly;
const canSaveMtls =
mtls.isDirty && !(mtls.enabled && !mtls.hasCertificates);
const sso = useWatch({ control: form.control, name: "sso" });
const skipToIdpId = useWatch({
control: form.control,
@@ -414,6 +422,10 @@ export function PolicyAuthStackSectionEdit({
.catch(handleError)
);
policyUpdates.emailWhitelistEnabled = payload.emailWhitelistEnabled;
if (canSaveMtls) {
requests.push(mtls.save().catch(handleError));
}
}
try {
@@ -488,7 +500,8 @@ export function PolicyAuthStackSectionEdit({
}),
api.post(`/resource/${resourceId}/users`, {
userIds: currentResourceUserIds
})
}),
...(canSaveMtls ? [mtls.save()] : [])
]);
toast({
@@ -692,6 +705,32 @@ export function PolicyAuthStackSectionEdit({
}
)
}
mtls={
mtls.supported
? {
active: mtls.enabled,
certificateCount:
mtls.certificates.length,
sslEnabled: mtls.sslEnabled,
disabled: mtlsReadonly,
onConfigure: () =>
openMethodEditor("mtls"),
onToggle: (active) =>
handleToggle(
"mtls",
active,
() =>
mtls.setEnabled(
false
),
() =>
mtls.setEnabled(
true
)
)
}
: undefined
}
/>
)}
</SettingsSectionForm>
@@ -770,6 +809,22 @@ export function PolicyAuthStackSectionEdit({
setHeaderAuthActive(true);
}}
/>
{mtls.supported && (
<ResourceMtlsCertificatesCredenza
open={editingMethod === "mtls"}
onOpenChange={(open) => {
if (open) return;
closeCredenza();
// mTLS can't be enabled without a trusted CA.
if (!mtls.hasCertificates) {
mtls.setEnabled(false);
}
}}
certificates={mtls.certificates}
isLoading={mtls.isLoadingCertificates}
/>
)}
</>
)}
</SettingsSectionBody>
@@ -4,7 +4,8 @@ export type PolicyAuthMethodId =
| "pincode"
| "passcode"
| "email"
| "headerAuth";
| "headerAuth"
| "mtls";
export const setPasswordSchema = z.object({
password: z.string().min(4).max(100)
@@ -43,3 +43,10 @@ export function getHeaderAuthSummary({
}: SummaryParams & { headerName: string }) {
return headerName || t("policyAuthHeaderAuthSummary");
}
export function getMtlsSummary({
t,
count
}: SummaryParams & { count: number }) {
return t("policyAuthMtlsSummary", { count });
}
+62
View File
@@ -0,0 +1,62 @@
import ResourceContext from "@app/contexts/resourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { resourceQueries } from "@app/lib/queries";
import type { UpdateResourceResponse } from "@server/routers/resource";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { useContext, useEffect, useState } from "react";
// mTLS is a resource-level setting, so it is only available when editing
// authentication in the context of an HTTP resource.
export function useResourceMtls() {
const resourceContext = useContext(ResourceContext);
const { env } = useEnvContext();
const api = createApiClient({ env });
const resource = resourceContext?.resource;
const supported =
!!resource &&
!env.flags.disableEnterpriseFeatures &&
resource.mode === "http";
const { data: certificates = [], isLoading: isLoadingCertificates } =
useQuery({
...resourceQueries.resourceMtlsCertificates({
resourceId: resource?.resourceId ?? 0
}),
enabled: supported
});
const [enabled, setEnabled] = useState(Boolean(resource?.mtlsEnabled));
// The server switches mTLS off when the last CA is removed.
useEffect(() => {
setEnabled(Boolean(resource?.mtlsEnabled));
}, [resource?.mtlsEnabled]);
const hasCertificates = certificates.length > 0;
const isDirty = supported && enabled !== Boolean(resource?.mtlsEnabled);
async function save(): Promise<AxiosResponse | void> {
if (!supported || !isDirty) return;
const res = await api.post<AxiosResponse<UpdateResourceResponse>>(
`/resource/${resource!.resourceId}`,
{ mtlsEnabled: enabled }
);
resourceContext!.updateResource({ mtlsEnabled: enabled });
return res;
}
return {
supported,
sslEnabled: Boolean(resource?.ssl),
enabled,
setEnabled,
certificates,
isLoadingCertificates,
hasCertificates,
isDirty,
save
};
}