mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-23 05:16:38 +00:00
Merge branch 'dev' into feat/internal-user-passkey-support
This commit is contained in:
@@ -34,6 +34,7 @@ export type UserRow = {
|
||||
status: string;
|
||||
role: string;
|
||||
isOwner: boolean;
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
type UsersTableProps = {
|
||||
@@ -170,6 +171,39 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "isTwoFactorEnabled",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
2FA Enabled
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const userRow = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>{userRow.isTwoFactorEnabled && (
|
||||
<span className="text-green-500">
|
||||
{t('enabled')}
|
||||
</span>
|
||||
) || (
|
||||
<span className="text-white/50">
|
||||
{t('disabled')}
|
||||
</span>
|
||||
)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
|
||||
@@ -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,14 +44,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user } = userOrgUserContext();
|
||||
|
||||
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { orgId } = useParams();
|
||||
|
||||
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 +97,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 +111,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 +197,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-primary"> This user currently has 2FA enabled.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
@@ -186,6 +243,8 @@ export default function AccessControlsPage() {
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||
idpName: user.idpName || t('idpNameInternal'),
|
||||
status: t('userConfirmed'),
|
||||
role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'),
|
||||
isOwner: user.isOwner || false
|
||||
isOwner: user.isOwner || false,
|
||||
isTwoFactorEnabled: user.twoFactorEnabled || false,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,10 @@ export default function LocaleSwitcher() {
|
||||
{
|
||||
value: 'zh-CN',
|
||||
label: '简体中文'
|
||||
},
|
||||
{
|
||||
value: 'ko-KR',
|
||||
label: '한국어'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -210,6 +210,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();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const locales = ['en-US', 'es-ES', 'fr-FR', 'de-DE', 'nl-NL', 'it-IT', 'pl-PL', 'pt-PT', 'tr-TR', 'zh-CN'] as const;
|
||||
export const locales = ['en-US', 'es-ES', 'fr-FR', 'de-DE', 'nl-NL', 'it-IT', 'pl-PL', 'pt-PT', 'tr-TR', 'zh-CN', 'ko-KR'] as const;
|
||||
export const defaultLocale: Locale = 'en-US';
|
||||
Reference in New Issue
Block a user