2fa policy check working

This commit is contained in:
miloschwartz
2025-10-24 14:31:50 -07:00
parent ddcf77a62d
commit 629f17294a
16 changed files with 724 additions and 88 deletions

View File

@@ -1,7 +1,11 @@
import { internal } from "@app/lib/api";
import { formatAxiosError, internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession";
import { GetOrgResponse } from "@server/routers/org";
import {
CheckOrgUserAccessResponse,
GetOrgResponse,
ListUserOrgsResponse
} from "@server/routers/org";
import { GetOrgUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
@@ -11,6 +15,9 @@ import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvide
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
import OrgPolicyResult from "@app/components/OrgPolicyResult";
import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout";
export default async function OrgLayout(props: {
children: React.ReactNode;
@@ -32,25 +39,46 @@ export default async function OrgLayout(props: {
redirect(`/`);
}
let accessRes: CheckOrgUserAccessResponse | null = null;
try {
const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
const checkOrgAccess = cache(() =>
internal.get<AxiosResponse<CheckOrgUserAccessResponse>>(
`/org/${orgId}/user/${user.userId}/check`,
cookie
)
);
const orgUser = await getOrgUser();
} catch {
const res = await checkOrgAccess();
accessRes = res.data.data;
} catch (e) {
redirect(`/`);
}
try {
const getOrg = cache(() =>
internal.get<AxiosResponse<GetOrgResponse>>(`/org/${orgId}`, cookie)
if (!accessRes?.allowed) {
// For non-admin users, show the member resources portal
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(async () =>
internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
await authCookieHeader()
)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch (e) {}
return (
<UserProvider user={user}>
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
<OrgPolicyResult
orgId={orgId}
userId={user.userId}
accessRes={accessRes}
/>
</Layout>
</UserProvider>
);
await getOrg();
} catch {
redirect(`/`);
}
let subscriptionStatus = null;

View File

@@ -0,0 +1,3 @@
export async function OrgPolicyPage() {
return <div>Org Policy Page</div>;
}

View File

@@ -42,11 +42,15 @@ import {
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { SwitchInput } from "@app/components/SwitchInput";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { Badge } from "@app/components/ui/badge";
// Schema for general organization settings
const GeneralFormSchema = z.object({
name: z.string(),
subnet: z.string().optional()
subnet: z.string().optional(),
requireTwoFactor: z.boolean().optional()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -60,6 +64,8 @@ export default function GeneralPage() {
const { user } = useUserContext();
const t = useTranslations();
const { env } = useEnvContext();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscriptionStatus = useSubscriptionStatusContext();
const [loadingDelete, setLoadingDelete] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
@@ -69,7 +75,8 @@ export default function GeneralPage() {
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: org?.org.name,
subnet: org?.org.subnet || "" // Add default value for subnet
subnet: org?.org.subnet || "", // Add default value for subnet
requireTwoFactor: org?.org.requireTwoFactor || false
},
mode: "onChange"
});
@@ -129,11 +136,15 @@ export default function GeneralPage() {
setLoadingSave(true);
try {
// Update organization
await api.post(`/org/${org?.org.orgId}`, {
const reqData = {
name: data.name
// subnet: data.subnet // Include subnet in the API request
});
} as any;
if (build !== "oss") {
reqData.requireTwoFactor = data.requireTwoFactor || false;
}
// Update organization
await api.post(`/org/${org?.org.orgId}`, reqData);
// Also save auth page settings if they have unsaved changes
if (
@@ -168,9 +179,7 @@ export default function GeneralPage() {
}}
dialog={
<div>
<p>
{t("orgQuestionRemove")}
</p>
<p>{t("orgQuestionRemove")}</p>
<p>{t("orgMessageRemove")}</p>
</div>
}
@@ -241,45 +250,122 @@ export default function GeneralPage() {
</SettingsSectionBody>
</SettingsSection>
{(build === "saas") && (
<AuthPageSettings ref={authPageSettingsRef} />
)}
{/* Security Settings Section */}
<SettingsSection>
<SettingsSectionHeader>
<div className="flex items-center gap-2">
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
{build === "enterprise" && !isUnlocked() ? (
<Badge variant="outlinePrimary">
{build === "enterprise"
? t("licenseBadge")
: t("subscriptionBadge")}
</Badge>
) : null}
</div>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="security-settings-form"
>
<FormField
control={form.control}
name="requireTwoFactor"
render={({ field }) => {
const isEnterpriseNotLicensed =
build === "enterprise" &&
!isUnlocked();
const isSaasNotSubscribed =
build === "saas" &&
!subscriptionStatus?.isSubscribed();
const isDisabled =
isEnterpriseNotLicensed ||
isSaasNotSubscribed;
const shouldDisableToggle = isDisabled;
{/* Save Button */}
<div className="flex justify-end">
return (
<FormItem className="col-span-2">
<div className="flex items-center gap-2">
<FormControl>
<SwitchInput
id="require-two-factor"
defaultChecked={
field.value ||
false
}
label={t(
"requireTwoFactorForAllUsers"
)}
disabled={
shouldDisableToggle
}
onCheckedChange={(
val
) => {
if (
!shouldDisableToggle
) {
form.setValue(
"requireTwoFactor",
val
);
}
}}
/>
</FormControl>
</div>
<FormMessage />
<FormDescription>
{isDisabled
? t(
"requireTwoFactorDisabledDescription"
)
: t(
"requireTwoFactorDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
<div className="flex justify-end gap-2">
{build !== "saas" && (
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center gap-2"
loading={loadingDelete}
disabled={loadingDelete}
>
{t("orgDelete")}
</Button>
)}
<Button
type="submit"
form="org-settings-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveGeneralSettings")}
{t("saveSettings")}
</Button>
</div>
{build !== "saas" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("orgDangerZone")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("orgDangerZoneDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionFooter>
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center gap-2"
loading={loadingDelete}
disabled={loadingDelete}
>
{t("orgDelete")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -56,6 +56,9 @@ import { build } from "@server/build";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { DomainRow } from "../../../../../../components/DomainsTable";
import { toASCII, toUnicode } from "punycode";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useUserContext } from "@app/hooks/useUserContext";
export default function GeneralForm() {
const [formKey, setFormKey] = useState(0);
@@ -65,6 +68,9 @@ export default function GeneralForm() {
const router = useRouter();
const t = useTranslations();
const [editDomainOpen, setEditDomainOpen] = useState(false);
const {licenseStatus } = useLicenseStatusContext();
const subscriptionStatus = useSubscriptionStatusContext();
const {user} = useUserContext();
const { env } = useEnvContext();

View File

@@ -22,6 +22,8 @@ import { headers } from "next/headers";
import { GetLoginPageResponse } from "@server/routers/loginPage/types";
import { GetOrgTierResponse } from "@server/routers/billing/types";
import { TierId } from "@server/lib/billing/tiers";
import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
export const dynamic = "force-dynamic";
@@ -136,6 +138,34 @@ export default async function ResourceAuthPage(props: {
);
}
const cookie = await authCookieHeader();
// Check org policy compliance before proceeding
let orgPolicyCheck: CheckOrgUserAccessResponse | null = null;
if (user && authInfo.orgId) {
try {
const policyRes = await internal.get<
AxiosResponse<CheckOrgUserAccessResponse>
>(`/org/${authInfo.orgId}/user/${user.userId}/check`, cookie);
orgPolicyCheck = policyRes.data.data;
} catch (e) {
console.error(formatAxiosError(e));
}
}
// If user is not compliant with org policies, show policy requirements
if (orgPolicyCheck && !orgPolicyCheck.allowed && orgPolicyCheck.policies) {
return (
<div className="w-full max-w-md">
<OrgPolicyRequired
orgId={authInfo.orgId}
policies={orgPolicyCheck.policies}
/>
</div>
);
}
if (!hasAuth) {
// no authentication so always go straight to the resource
redirect(redirectUrl);
@@ -151,7 +181,7 @@ export default async function ResourceAuthPage(props: {
>(
`/resource/${authInfo.resourceId}/get-exchange-token`,
{},
await authCookieHeader()
cookie
);
if (res.data.data.requestToken) {

View File

@@ -0,0 +1,59 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { Shield, ArrowRight } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
type OrgPolicyRequiredProps = {
orgId: string;
policies: {
requiredTwoFactor?: boolean;
};
};
export default function OrgPolicyRequired({
orgId,
policies
}: OrgPolicyRequiredProps) {
const t = useTranslations();
const policySteps = [];
if (policies?.requiredTwoFactor === false) {
policySteps.push(t("enableTwoFactorAuthentication"));
}
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-orange-100">
<Shield className="h-6 w-6 text-orange-600" />
</div>
<CardTitle className="text-xl font-semibold">
{t("additionalSecurityRequired")}
</CardTitle>
<CardDescription>
{t("organizationRequiresAdditionalSteps")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="pt-4">
<Link href={`/${orgId}`}>
<Button className="w-full">
{t("completeSecuritySteps")}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,195 @@
"use client";
import { useState } from "react";
import { CheckOrgUserAccessResponse } from "@server/routers/org";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { CheckCircle2, XCircle, Shield } from "lucide-react";
import Enable2FaDialog from "./Enable2FaDialog";
import { useTranslations } from "next-intl";
import { useUserContext } from "@app/hooks/useUserContext";
import { useRouter } from "next/navigation";
type OrgPolicyResultProps = {
orgId: string;
userId: string;
accessRes: CheckOrgUserAccessResponse;
};
type PolicyItem = {
id: string;
name: string;
description: string;
compliant: boolean;
action?: () => void;
actionText?: string;
};
export default function OrgPolicyResult({
orgId,
userId,
accessRes
}: OrgPolicyResultProps) {
const [show2FaDialog, setShow2FaDialog] = useState(false);
const t = useTranslations();
const { user } = useUserContext();
const router = useRouter();
// Determine if user is compliant with 2FA policy
const isTwoFactorCompliant = user?.twoFactorEnabled || false;
const policyKeys = Object.keys(accessRes.policies || {});
const policies: PolicyItem[] = [];
// Only add 2FA policy if the organization has it enforced
if (policyKeys.includes("requiredTwoFactor")) {
policies.push({
id: "two-factor",
name: t("twoFactorAuthentication"),
description: t("twoFactorDescription"),
compliant: isTwoFactorCompliant,
action: !isTwoFactorCompliant
? () => setShow2FaDialog(true)
: undefined,
actionText: !isTwoFactorCompliant ? t("enableTwoFactor") : undefined
});
// policies.push({
// id: "reauth-required",
// name: "Re-authentication",
// description:
// "It's been 30 days since you last verified your identity. Please log out and log back in to continue.",
// compliant: false,
// action: () => {},
// actionText: "Log Out"
// });
//
// policies.push({
// id: "password-rotation",
// name: "Password Rotation",
// description:
// "It's been 30 days since you last changed your password. Please update your password to continue.",
// compliant: false,
// action: () => {},
// actionText: "Change Password"
// });
}
const nonCompliantPolicies = policies.filter((policy) => !policy.compliant);
const allCompliant =
policies.length === 0 || nonCompliantPolicies.length === 0;
// Calculate progress
const completedPolicies = policies.filter(
(policy) => policy.compliant
).length;
const totalPolicies = policies.length;
const progressPercentage =
totalPolicies > 0 ? (completedPolicies / totalPolicies) * 100 : 100;
// If no policies are enforced, show a simple success message
if (policies.length === 0) {
return (
<div className="text-center py-8">
<CheckCircle2 className="mx-auto h-12 w-12 text-green-500 mb-4" />
<h2 className="text-lg font-semibold text-gray-900 mb-2">
{t("accessGranted")}
</h2>
<p className="text-sm text-gray-600">
{t("noSecurityRequirements")}
</p>
</div>
);
}
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{t("securityRequirements")}
</CardTitle>
<CardDescription>
{allCompliant
? t("allRequirementsMet")
: t("completeRequirementsToContinue")}
</CardDescription>
</CardHeader>
{/* Progress Bar */}
<div className="px-6 pb-4">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>
{completedPolicies} of {totalPolicies} steps
completed
</span>
<span>{Math.round(progressPercentage)}%</span>
</div>
<Progress value={progressPercentage} className="h-2" />
</div>
<CardContent className="space-y-4">
{policies.map((policy) => (
<div
key={policy.id}
className="flex items-start gap-3 p-4 border rounded-lg"
>
<div className="flex-shrink-0 mt-0.5">
{policy.compliant ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium">
{policy.name}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{policy.description}
</p>
{policy.action && policy.actionText && (
<div className="mt-3">
<Button
size="sm"
onClick={policy.action}
className="w-full sm:w-auto"
>
{policy.actionText}
</Button>
</div>
)}
</div>
</div>
))}
</CardContent>
</Card>
{allCompliant && (
<div className="text-center">
<p className="text-sm text-green-600 font-medium">
{t("allRequirementsMet")}
</p>
<p className="text-xs text-gray-500 mt-1">
{t("youCanNowAccessOrganization")}
</p>
</div>
)}
<Enable2FaDialog
open={show2FaDialog}
setOpen={(val) => {
setShow2FaDialog(val);
router.refresh();
}}
/>
</>
);
}