mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-12 16:06:38 +00:00
Admins can enable 2FA
Added the feature for admins to force 2FA on accounts. The next time the user logs in they will have to setup 2FA on their account.
This commit is contained in:
@@ -27,6 +27,7 @@ import { ListRolesResponse } from "@server/routers/role";
|
||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
@@ -43,7 +44,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user } = userOrgUserContext();
|
||||
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
||||
|
||||
console.log("User:", user);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
@@ -51,6 +54,7 @@ export default function AccessControlsPage() {
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [enable2FA, setEnable2FA] = useState(user.twoFactorEnabled || false);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -96,7 +100,8 @@ export default function AccessControlsPage() {
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
// Update user role
|
||||
const roleRes = await api
|
||||
.post<
|
||||
AxiosResponse<InviteUserResponse>
|
||||
>(`/role/${values.roleId}/add/${user.userId}`)
|
||||
@@ -109,9 +114,34 @@ export default function AccessControlsPage() {
|
||||
t('accessRoleErrorAddDescription')
|
||||
)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
// Update 2FA status if it changed
|
||||
if (enable2FA !== user.twoFactorEnabled) {
|
||||
const twoFARes = await api
|
||||
.patch(`/org/${orgId}/user/${user.userId}/2fa`, {
|
||||
twoFactorEnabled: enable2FA
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error updating 2FA",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"Failed to update 2FA status"
|
||||
)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
if (twoFARes && twoFARes.status === 200) {
|
||||
// Update the user context with the new 2FA status
|
||||
updateOrgUser({ twoFactorEnabled: enable2FA });
|
||||
}
|
||||
}
|
||||
|
||||
if (roleRes && roleRes.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('userSaved'),
|
||||
@@ -170,6 +200,36 @@ export default function AccessControlsPage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-2fa"
|
||||
checked={enable2FA}
|
||||
onCheckedChange={(
|
||||
e
|
||||
) =>
|
||||
setEnable2FA(
|
||||
e as boolean
|
||||
)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-2fa"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable 2FA for this user
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
When enabled, the user will be required to set up their authenticator app on their next login.
|
||||
{user.twoFactorEnabled && (
|
||||
<span className="text-blue-600"> This user currently has 2FA enabled.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
289
src/app/auth/2fa/setup/page.tsx
Normal file
289
src/app/auth/2fa/setup/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
RequestTotpSecretResponse,
|
||||
VerifyTotpResponse
|
||||
} from "@server/routers/auth";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Setup2FAPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams?.get("redirect");
|
||||
const email = searchParams?.get("email");
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [secretKey, setSecretKey] = useState("");
|
||||
const [secretUri, setSecretUri] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
// Redirect to login if no email is provided
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
}, [email, router]);
|
||||
|
||||
const enableSchema = z.object({
|
||||
password: z.string().min(1, { message: t('passwordRequired') })
|
||||
});
|
||||
|
||||
const confirmSchema = z.object({
|
||||
code: z.string().length(6, { message: t('pincodeInvalid') })
|
||||
});
|
||||
|
||||
const enableForm = useForm<z.infer<typeof enableSchema>>({
|
||||
resolver: zodResolver(enableSchema),
|
||||
defaultValues: {
|
||||
password: ""
|
||||
}
|
||||
});
|
||||
|
||||
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
|
||||
resolver: zodResolver(confirmSchema),
|
||||
defaultValues: {
|
||||
code: ""
|
||||
}
|
||||
});
|
||||
|
||||
const request2fa = async (values: z.infer<typeof enableSchema>) => {
|
||||
if (!email) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<RequestTotpSecretResponse>>(
|
||||
`/auth/2fa/setup`,
|
||||
{
|
||||
email: email,
|
||||
password: values.password
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
title: t('otpErrorEnable'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('otpErrorEnableDescription')
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.data.data.secret) {
|
||||
setSecretKey(res.data.data.secret);
|
||||
setSecretUri(res.data.data.uri);
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
|
||||
if (!email) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const { password } = enableForm.getValues();
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<VerifyTotpResponse>>(`/auth/2fa/complete-setup`, {
|
||||
email: email,
|
||||
password: password,
|
||||
code: values.code
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
title: t('otpErrorEnable'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('otpErrorEnableDescription')
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.data.data.valid) {
|
||||
setBackupCodes(res.data.data.backupCodes || []);
|
||||
setStep(3);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
if (redirect) {
|
||||
router.push(redirect);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('otpSetup')}</CardTitle>
|
||||
<CardDescription>
|
||||
Your administrator has enabled two-factor authentication for <strong>{email}</strong>.
|
||||
Please complete the setup process to continue.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{step === 1 && (
|
||||
<Form {...enableForm}>
|
||||
<form
|
||||
onSubmit={enableForm.handleSubmit(request2fa)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={enableForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your current password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('otpSetupScanQr')}
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<QRCodeCanvas value={secretUri} size={200} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Manual entry key:</Label>
|
||||
<CopyTextBox
|
||||
text={secretKey}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form {...confirmForm}>
|
||||
<form
|
||||
onSubmit={confirmForm.handleSubmit(confirm2fa)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={confirmForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('otpSetupSecretCode')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter 6-digit code"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Verify and Complete Setup
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4 text-center">
|
||||
<CheckCircle2
|
||||
className="mx-auto text-green-500"
|
||||
size={48}
|
||||
/>
|
||||
<p className="font-semibold text-lg">
|
||||
{t('otpSetupSuccess')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('otpSetupSuccessStoreBackupCodes')}
|
||||
</p>
|
||||
|
||||
{backupCodes.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Backup codes:</Label>
|
||||
<CopyTextBox text={backupCodes.join("\n")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
className="w-full"
|
||||
>
|
||||
Continue to Application
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,6 +134,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.twoFactorSetupRequired) {
|
||||
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`;
|
||||
router.push(setupUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (onLogin) {
|
||||
await onLogin();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user