mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-21 12:09:04 +02:00
Fix mtls to be the same as the other auth sections
This commit is contained in:
+4
-2
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user