mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-02 16:19:08 +00:00
Merge branch 'dev' into feat/internal-user-passkey-support
This commit is contained in:
@@ -34,7 +34,6 @@ export type UserRow = {
|
||||
status: string;
|
||||
role: string;
|
||||
isOwner: boolean;
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
type UsersTableProps = {
|
||||
@@ -171,39 +170,6 @@ 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,7 +27,6 @@ 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,
|
||||
@@ -44,14 +43,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
||||
const { orgUser: user } = 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();
|
||||
|
||||
@@ -97,8 +96,7 @@ export default function AccessControlsPage() {
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
// Update user role
|
||||
const roleRes = await api
|
||||
const res = await api
|
||||
.post<
|
||||
AxiosResponse<InviteUserResponse>
|
||||
>(`/role/${values.roleId}/add/${user.userId}`)
|
||||
@@ -111,34 +109,9 @@ export default function AccessControlsPage() {
|
||||
t('accessRoleErrorAddDescription')
|
||||
)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
// 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) {
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('userSaved'),
|
||||
@@ -197,36 +170,6 @@ 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>
|
||||
@@ -243,8 +186,6 @@ export default function AccessControlsPage() {
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,8 +81,7 @@ 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,
|
||||
isTwoFactorEnabled: user.twoFactorEnabled || false,
|
||||
isOwner: user.isOwner || false
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { UsersDataTable } from "./AdminUsersDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown } from "lucide-react";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
@@ -12,6 +12,12 @@ import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
|
||||
export type GlobalUserRow = {
|
||||
id: string;
|
||||
@@ -22,6 +28,8 @@ export type GlobalUserRow = {
|
||||
idpId: number | null;
|
||||
idpName: string;
|
||||
dateCreated: string;
|
||||
twoFactorEnabled: boolean | null;
|
||||
twoFactorSetupRequested: boolean | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -41,11 +49,11 @@ export default function UsersTable({ users }: Props) {
|
||||
const deleteUser = (id: string) => {
|
||||
api.delete(`/user/${id}`)
|
||||
.catch((e) => {
|
||||
console.error(t('userErrorDelete'), e);
|
||||
console.error(t("userErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('userErrorDelete'),
|
||||
description: formatAxiosError(e, t('userErrorDelete'))
|
||||
title: t("userErrorDelete"),
|
||||
description: formatAxiosError(e, t("userErrorDelete"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
@@ -84,7 +92,7 @@ export default function UsersTable({ users }: Props) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('username')}
|
||||
{t("username")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -100,7 +108,7 @@ export default function UsersTable({ users }: Props) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('email')}
|
||||
{t("email")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -116,7 +124,7 @@ export default function UsersTable({ users }: Props) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('name')}
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -132,28 +140,85 @@ export default function UsersTable({ users }: Props) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('identityProvider')}
|
||||
{t("identityProvider")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "twoFactorEnabled",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("twoFactor")}
|
||||
<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.twoFactorEnabled ||
|
||||
userRow.twoFactorSetupRequested ? (
|
||||
<span className="text-green-500">
|
||||
{t("enabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("disabled")}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"outlinePrimary"}
|
||||
className="ml-2"
|
||||
variant={"secondary"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setIsDeleteModalOpen(true);
|
||||
router.push(`/admin/users/${r.id}`);
|
||||
}}
|
||||
>
|
||||
{t('delete')}
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -174,26 +239,27 @@ export default function UsersTable({ users }: Props) {
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t('userQuestionRemove', {selectedUser: selected?.email || selected?.name || selected?.username})}
|
||||
{t("userQuestionRemove", {
|
||||
selectedUser:
|
||||
selected?.email ||
|
||||
selected?.name ||
|
||||
selected?.username
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
{t('userMessageRemove')}
|
||||
</b>
|
||||
<b>{t("userMessageRemove")}</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{t('userMessageConfirm')}
|
||||
</p>
|
||||
<p>{t("userMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t('userDeleteConfirm')}
|
||||
buttonText={t("userDeleteConfirm")}
|
||||
onConfirm={async () => deleteUser(selected!.id)}
|
||||
string={
|
||||
selected.email || selected.name || selected.username
|
||||
}
|
||||
title={t('userDeleteServer')}
|
||||
title={t("userDeleteServer")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
134
src/app/admin/users/[userId]/general/page.tsx
Normal file
134
src/app/admin/users/[userId]/general/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm
|
||||
} from "@app/components/Settings";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const { userId } = useParams();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||
const [userType, setUserType] = useState<UserType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch current user 2FA status
|
||||
const fetchUserData = async () => {
|
||||
setLoadingData(true);
|
||||
try {
|
||||
const response = await api.get(`/user/${userId}`);
|
||||
if (response.status === 200) {
|
||||
const userData = response.data.data;
|
||||
setTwoFactorEnabled(
|
||||
userData.twoFactorEnabled ||
|
||||
userData.twoFactorSetupRequested
|
||||
);
|
||||
setUserType(userData.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user data:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("userErrorDelete"),
|
||||
description: formatAxiosError(error, t("userErrorDelete"))
|
||||
});
|
||||
}
|
||||
setLoadingData(false);
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [userId]);
|
||||
|
||||
const handleTwoFactorToggle = (enabled: boolean) => {
|
||||
setTwoFactorEnabled(enabled);
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
console.log("twoFactorEnabled", twoFactorEnabled);
|
||||
await api.post(`/user/${userId}/2fa`, {
|
||||
twoFactorSetupRequested: twoFactorEnabled
|
||||
});
|
||||
|
||||
setTwoFactorEnabled(twoFactorEnabled);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("otpErrorEnable"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("otpErrorEnableDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("general")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("userDescription2")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<div className="space-y-6">
|
||||
<SwitchInput
|
||||
id="two-factor-auth"
|
||||
label={t("otpAuth")}
|
||||
checked={twoFactorEnabled}
|
||||
disabled={userType !== UserType.Internal}
|
||||
onCheckedChange={handleTwoFactorToggle}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSaveSettings}
|
||||
>
|
||||
{t("targetTlsSubmit")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
src/app/admin/users/[userId]/layout.tsx
Normal file
55
src/app/admin/users/[userId]/layout.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AdminGetUserResponse } from "@server/routers/user/adminGetUser";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { cache } from "react";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface UserLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ userId: string }>;
|
||||
}
|
||||
|
||||
export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
let user = null;
|
||||
try {
|
||||
const getUser = cache(async () =>
|
||||
internal.get<AxiosResponse<AdminGetUserResponse>>(
|
||||
`/user/${params.userId}`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getUser();
|
||||
user = res.data.data;
|
||||
} catch {
|
||||
redirect(`/admin/users`);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t('general'),
|
||||
href: "/admin/users/{userId}/general"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={`${user?.email || user?.name || user?.username}`}
|
||||
description={t('userDescription2')}
|
||||
/>
|
||||
<HorizontalTabs items={navItems}>
|
||||
{children}
|
||||
</HorizontalTabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
src/app/admin/users/[userId]/page.tsx
Normal file
8
src/app/admin/users/[userId]/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UserPage(props: {
|
||||
params: Promise<{ userId: string }>;
|
||||
}) {
|
||||
const { userId } = await props.params;
|
||||
redirect(`/admin/users/${userId}/general`);
|
||||
}
|
||||
@@ -38,7 +38,9 @@ export default async function UsersPage(props: PageProps) {
|
||||
idpId: row.idpId,
|
||||
idpName: row.idpName || t('idpNameInternal'),
|
||||
dateCreated: row.dateCreated,
|
||||
serverAdmin: row.serverAdmin
|
||||
serverAdmin: row.serverAdmin,
|
||||
twoFactorEnabled: row.twoFactorEnabled,
|
||||
twoFactorSetupRequested: row.twoFactorSetupRequested
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,29 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { 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,
|
||||
@@ -31,124 +9,31 @@ import {
|
||||
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 TwoFactorSetupForm from "@app/components/TwoFactorSetupForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
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');
|
||||
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 = () => {
|
||||
console.log("2FA setup complete", redirect, email);
|
||||
if (redirect) {
|
||||
router.push(redirect);
|
||||
const cleanUrl = cleanRedirect(redirect);
|
||||
console.log("Redirecting to:", cleanUrl);
|
||||
router.push(cleanUrl);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
@@ -158,132 +43,20 @@ export default function Setup2FAPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('otpSetup')}</CardTitle>
|
||||
<CardTitle>{t("otpSetup")}</CardTitle>
|
||||
<CardDescription>
|
||||
Your administrator has enabled two-factor authentication for <strong>{email}</strong>.
|
||||
Please complete the setup process to continue.
|
||||
{t("adminEnabled2FaOnYourAccount", { email: email || "your account" })}
|
||||
</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>
|
||||
)}
|
||||
<TwoFactorSetupForm
|
||||
email={email || undefined}
|
||||
onComplete={handleComplete}
|
||||
submitButtonText="Continue"
|
||||
showCancelButton={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
89
src/components/Enable2FaDialog.tsx
Normal file
89
src/components/Enable2FaDialog.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import TwoFactorSetupForm from "./TwoFactorSetupForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
type Enable2FaDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
};
|
||||
|
||||
export default function Enable2FaDialog({ open, setOpen }: Enable2FaDialogProps) {
|
||||
const t = useTranslations();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formRef = useRef<{ handleSubmit: () => void }>(null);
|
||||
const { user, updateUser } = useUserContext();
|
||||
|
||||
function reset() {
|
||||
setCurrentStep(1);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (formRef.current) {
|
||||
formRef.current.handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t('otpSetup')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('otpSetupDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<TwoFactorSetupForm
|
||||
ref={formRef}
|
||||
isDialog={true}
|
||||
submitButtonText={t('submit')}
|
||||
cancelButtonText="Close"
|
||||
showCancelButton={false}
|
||||
onComplete={() => {setOpen(false); updateUser({ twoFactorEnabled: true });}}
|
||||
onStepChange={setCurrentStep}
|
||||
onLoadingChange={setLoading}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</CredenzaClose>
|
||||
{(currentStep === 1 || currentStep === 2) && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t('submit')}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
RequestTotpSecretBody,
|
||||
RequestTotpSecretResponse,
|
||||
VerifyTotpBody,
|
||||
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 {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Enable2FaDialog from "./Enable2FaDialog";
|
||||
|
||||
type Enable2FaProps = {
|
||||
open: boolean;
|
||||
@@ -48,261 +9,5 @@ type Enable2FaProps = {
|
||||
};
|
||||
|
||||
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [secretKey, setSecretKey] = useState("");
|
||||
const [secretUri, setSecretUri] = useState("");
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
|
||||
const { user, updateUser } = useUserContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
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>) => {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<RequestTotpSecretResponse>>(
|
||||
`/auth/2fa/request`,
|
||||
{
|
||||
password: values.password
|
||||
} as RequestTotpSecretBody
|
||||
)
|
||||
.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>) => {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<VerifyTotpResponse>>(`/auth/2fa/enable`, {
|
||||
code: values.code
|
||||
} as VerifyTotpBody)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
title: t('otpErrorEnable'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('otpErrorEnableDescription')
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.data.data.valid) {
|
||||
setBackupCodes(res.data.data.backupCodes || []);
|
||||
updateUser({ twoFactorEnabled: true });
|
||||
setStep(3);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleVerify = () => {
|
||||
if (verificationCode.length !== 6) {
|
||||
setError(t('otpSetupCheckCode'));
|
||||
return;
|
||||
}
|
||||
if (verificationCode === "123456") {
|
||||
setSuccess(true);
|
||||
setStep(3);
|
||||
} else {
|
||||
setError(t('otpSetupCheckCodeRetry'));
|
||||
}
|
||||
};
|
||||
|
||||
function reset() {
|
||||
setLoading(false);
|
||||
setStep(1);
|
||||
setSecretKey("");
|
||||
setSecretUri("");
|
||||
setVerificationCode("");
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
setBackupCodes([]);
|
||||
enableForm.reset();
|
||||
confirmForm.reset();
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t('otpSetup')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('otpSetupDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{step === 1 && (
|
||||
<Form {...enableForm}>
|
||||
<form
|
||||
onSubmit={enableForm.handleSubmit(request2fa)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={enableForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t('otpSetupScanQr')}
|
||||
</p>
|
||||
<div className="h-[250px] mx-auto flex items-center justify-center">
|
||||
<QRCodeCanvas value={secretUri} size={200} />
|
||||
</div>
|
||||
<div className="max-w-md mx-auto">
|
||||
<CopyTextBox
|
||||
text={secretUri}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form {...confirmForm}>
|
||||
<form
|
||||
onSubmit={confirmForm.handleSubmit(
|
||||
confirm2fa
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={confirmForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('otpSetupSecretCode')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="code"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
{t('otpSetupSuccessStoreBackupCodes')}
|
||||
</p>
|
||||
|
||||
<div className="max-w-md mx-auto">
|
||||
<CopyTextBox text={backupCodes.join("\n")} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</CredenzaClose>
|
||||
{(step === 1 || step === 2) && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
if (step === 1) {
|
||||
enableForm.handleSubmit(request2fa)();
|
||||
} else {
|
||||
confirmForm.handleSubmit(confirm2fa)();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('submit')}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
return <Enable2FaDialog open={open} setOpen={setOpen} />;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ function getActionsCategories(root: boolean) {
|
||||
[t('actionGetOrg')]: "getOrg",
|
||||
[t('actionUpdateOrg')]: "updateOrg",
|
||||
[t('actionGetOrgUser')]: "getOrgUser",
|
||||
[t('actionListOrgDomains')]: "listOrgDomains",
|
||||
[t('actionInviteUser')]: "inviteUser",
|
||||
[t('actionRemoveUser')]: "removeUser",
|
||||
[t('actionListUsers')]: "listUsers",
|
||||
[t('actionListOrgDomains')]: "listOrgDomains"
|
||||
},
|
||||
|
||||
Site: {
|
||||
@@ -65,16 +68,9 @@ function getActionsCategories(root: boolean) {
|
||||
[t('actionGetRole')]: "getRole",
|
||||
[t('actionListRole')]: "listRoles",
|
||||
[t('actionUpdateRole')]: "updateRole",
|
||||
[t('actionListAllowedRoleResources')]: "listRoleResources"
|
||||
},
|
||||
|
||||
User: {
|
||||
[t('actionInviteUser')]: "inviteUser",
|
||||
[t('actionRemoveUser')]: "removeUser",
|
||||
[t('actionListUsers')]: "listUsers",
|
||||
[t('actionListAllowedRoleResources')]: "listRoleResources",
|
||||
[t('actionAddUserRole')]: "addUserRole"
|
||||
},
|
||||
|
||||
"Access Token": {
|
||||
[t('actionGenerateAccessToken')]: "generateAccessToken",
|
||||
[t('actionDeleteAccessToken')]: "deleteAcessToken",
|
||||
@@ -114,6 +110,11 @@ function getActionsCategories(root: boolean) {
|
||||
[t('actionListIdpOrgs')]: "listIdpOrgs",
|
||||
[t('actionUpdateIdpOrg')]: "updateIdpOrg"
|
||||
};
|
||||
|
||||
actionsByCategory["User"] = {
|
||||
[t('actionUpdateUser')]: "updateUser",
|
||||
[t('actionGetUser')]: "getUser"
|
||||
};
|
||||
}
|
||||
|
||||
return actionsByCategory;
|
||||
|
||||
@@ -20,8 +20,8 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import Disable2FaForm from "./Disable2FaForm";
|
||||
import Enable2FaForm from "./Enable2FaForm";
|
||||
import SecurityKeyForm from "./SecurityKeyForm";
|
||||
import Enable2FaDialog from "./Enable2FaDialog";
|
||||
import SupporterStatus from "./SupporterStatus";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import LocaleSwitcher from '@app/components/LocaleSwitcher';
|
||||
@@ -72,7 +72,7 @@ export default function ProfileIcon() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||
<Enable2FaDialog open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
||||
<SecurityKeyForm open={openSecurityKey} setOpen={setOpenSecurityKey} />
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ interface SwitchComponentProps {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
@@ -16,6 +17,7 @@ export function SwitchInput({
|
||||
label,
|
||||
description,
|
||||
disabled,
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
onCheckedChange
|
||||
}: SwitchComponentProps) {
|
||||
@@ -24,6 +26,7 @@ export function SwitchInput({
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
|
||||
327
src/components/TwoFactorSetupForm.tsx
Normal file
327
src/components/TwoFactorSetupForm.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useState, forwardRef, useImperativeHandle, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
LoginResponse,
|
||||
RequestTotpSecretBody,
|
||||
RequestTotpSecretResponse,
|
||||
VerifyTotpBody,
|
||||
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 { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type TwoFactorSetupFormProps = {
|
||||
onComplete?: (email: string, password: string) => void;
|
||||
onCancel?: () => void;
|
||||
isDialog?: boolean;
|
||||
email?: string;
|
||||
password?: string;
|
||||
submitButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
showCancelButton?: boolean;
|
||||
onStepChange?: (step: number) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
};
|
||||
|
||||
const TwoFactorSetupForm = forwardRef<
|
||||
{ handleSubmit: () => void },
|
||||
TwoFactorSetupFormProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
onComplete,
|
||||
onCancel,
|
||||
isDialog = false,
|
||||
email,
|
||||
password: initialPassword,
|
||||
submitButtonText,
|
||||
cancelButtonText,
|
||||
showCancelButton = false,
|
||||
onStepChange,
|
||||
onLoadingChange
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
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();
|
||||
|
||||
// Notify parent of step and loading changes
|
||||
useEffect(() => {
|
||||
onStepChange?.(step);
|
||||
}, [step, onStepChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(loading);
|
||||
}, [loading, onLoadingChange]);
|
||||
|
||||
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: initialPassword || ""
|
||||
}
|
||||
});
|
||||
|
||||
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
|
||||
resolver: zodResolver(confirmSchema),
|
||||
defaultValues: {
|
||||
code: ""
|
||||
}
|
||||
});
|
||||
|
||||
const request2fa = async (values: z.infer<typeof enableSchema>) => {
|
||||
setLoading(true);
|
||||
|
||||
const endpoint = `/auth/2fa/request`;
|
||||
const payload = { email, password: values.password };
|
||||
|
||||
const res = await api
|
||||
.post<
|
||||
AxiosResponse<RequestTotpSecretResponse>
|
||||
>(endpoint, payload)
|
||||
.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>) => {
|
||||
setLoading(true);
|
||||
|
||||
const endpoint = `/auth/2fa/enable`;
|
||||
const payload = {
|
||||
email,
|
||||
password: enableForm.getValues().password,
|
||||
code: values.code
|
||||
};
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<VerifyTotpResponse>>(endpoint, payload)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
title: t("otpErrorEnable"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("otpErrorEnableDescription")
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.data.data.valid) {
|
||||
setBackupCodes(res.data.data.backupCodes || []);
|
||||
await api
|
||||
.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
||||
email,
|
||||
password: enableForm.getValues().password,
|
||||
code: values.code
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
setStep(3);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (step === 1) {
|
||||
enableForm.handleSubmit(request2fa)();
|
||||
} else if (step === 2) {
|
||||
confirmForm.handleSubmit(confirm2fa)();
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = (email: string, password: string) => {
|
||||
if (onComplete) {
|
||||
onComplete(email, password);
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleSubmit
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{step === 1 && (
|
||||
<Form {...enableForm}>
|
||||
<form
|
||||
onSubmit={enableForm.handleSubmit(request2fa)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={enableForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p>{t("otpSetupScanQr")}</p>
|
||||
<div className="h-[250px] mx-auto flex items-center justify-center">
|
||||
<QRCodeCanvas value={secretUri} size={200} />
|
||||
</div>
|
||||
<div className="max-w-md mx-auto">
|
||||
<CopyTextBox text={secretUri} wrapText={false} />
|
||||
</div>
|
||||
|
||||
<Form {...confirmForm}>
|
||||
<form
|
||||
onSubmit={confirmForm.handleSubmit(confirm2fa)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={confirmForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("otpSetupSecretCode")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="code" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</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>{t("otpSetupSuccessStoreBackupCodes")}</p>
|
||||
|
||||
{backupCodes.length > 0 && (
|
||||
<div className="max-w-md mx-auto">
|
||||
<CopyTextBox text={backupCodes.join("\n")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons - only show when not in dialog */}
|
||||
{!isDialog && (
|
||||
<div className="flex gap-2 justify-end">
|
||||
{showCancelButton && onCancel && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelButtonText || "Cancel"}
|
||||
</Button>
|
||||
)}
|
||||
{(step === 1 || step === 2) && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
className="w-full"
|
||||
>
|
||||
{submitButtonText || t("submit")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleComplete(
|
||||
email!,
|
||||
enableForm.getValues().password
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{t("continueToApplication")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default TwoFactorSetupForm;
|
||||
Reference in New Issue
Block a user