mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-13 07:25:05 +02:00
disable 2fa and end email notifications
This commit is contained in:
parent
ccc2e3358c
commit
cf75be5a6c
14 changed files with 555 additions and 173 deletions
83
server/emails/templates/TwoFactorAuthNotification.tsx
Normal file
83
server/emails/templates/TwoFactorAuthNotification.tsx
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Tailwind
|
||||||
|
} from "@react-email/components";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
email: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
|
||||||
|
const previewText = `Two-Factor Authentication has been ${enabled ? "enabled" : "disabled"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: "#16A34A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="font-sans">
|
||||||
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
|
Two-Factor Authentication{" "}
|
||||||
|
{enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
|
Hi {email || "there"},
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
This email confirms that Two-Factor Authentication
|
||||||
|
has been successfully{" "}
|
||||||
|
{enabled ? "enabled" : "disabled"} on your account.
|
||||||
|
</Text>
|
||||||
|
<Section className="text-center my-6">
|
||||||
|
{enabled ? (
|
||||||
|
<Text className="text-base text-gray-700">
|
||||||
|
With Two-Factor Authentication enabled, your
|
||||||
|
account is now more secure. Please ensure
|
||||||
|
you keep your authentication method safe.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text className="text-base text-gray-700">
|
||||||
|
With Two-Factor Authentication disabled,
|
||||||
|
your account may be less secure. We
|
||||||
|
recommend enabling it to protect your
|
||||||
|
account.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
If you did not make this change, please contact our
|
||||||
|
support team immediately.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-500 mt-6">
|
||||||
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Fossorial
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TwoFactorAuthNotification;
|
|
@ -11,11 +11,16 @@ import { response } from "@server/utils";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import { verifyTotpCode } from "@server/auth/2fa";
|
import { verifyTotpCode } from "@server/auth/2fa";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { sendEmail } from "@server/emails";
|
||||||
|
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
||||||
|
import config from "@server/config";
|
||||||
|
|
||||||
export const disable2faBody = z.object({
|
export const disable2faBody = z
|
||||||
password: z.string(),
|
.object({
|
||||||
code: z.string().optional(),
|
password: z.string(),
|
||||||
}).strict();
|
code: z.string().optional()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export type Disable2faBody = z.infer<typeof disable2faBody>;
|
export type Disable2faBody = z.infer<typeof disable2faBody>;
|
||||||
|
|
||||||
|
@ -26,7 +31,7 @@ export type Disable2faResponse = {
|
||||||
export async function disable2fa(
|
export async function disable2fa(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const parsedBody = disable2faBody.safeParse(req.body);
|
const parsedBody = disable2faBody.safeParse(req.body);
|
||||||
|
|
||||||
|
@ -34,8 +39,8 @@ export async function disable2fa(
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
fromError(parsedBody.error).toString(),
|
fromError(parsedBody.error).toString()
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,8 +57,8 @@ export async function disable2fa(
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"Two-factor authentication is already disabled",
|
"Two-factor authentication is already disabled"
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
|
@ -62,7 +67,7 @@ export async function disable2fa(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Two-factor authentication required",
|
message: "Two-factor authentication required",
|
||||||
status: HttpCode.ACCEPTED,
|
status: HttpCode.ACCEPTED
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -70,15 +75,15 @@ export async function disable2fa(
|
||||||
const validOTP = await verifyTotpCode(
|
const validOTP = await verifyTotpCode(
|
||||||
code,
|
code,
|
||||||
user.twoFactorSecret!,
|
user.twoFactorSecret!,
|
||||||
user.userId,
|
user.userId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!validOTP) {
|
if (!validOTP) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"The two-factor code you entered is incorrect",
|
"The two-factor code you entered is incorrect"
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,22 +96,32 @@ export async function disable2fa(
|
||||||
.delete(twoFactorBackupCodes)
|
.delete(twoFactorBackupCodes)
|
||||||
.where(eq(twoFactorBackupCodes.userId, user.userId));
|
.where(eq(twoFactorBackupCodes.userId, user.userId));
|
||||||
|
|
||||||
// TODO: send email to user confirming two-factor authentication is disabled
|
sendEmail(
|
||||||
|
TwoFactorAuthNotification({
|
||||||
|
email: user.email,
|
||||||
|
enabled: false
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
to: user.email,
|
||||||
|
from: config.email?.no_reply,
|
||||||
|
subject: "Two-factor authentication disabled"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return response<null>(res, {
|
return response<null>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Two-factor authentication disabled",
|
message: "Two-factor authentication disabled",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"Failed to disable two-factor authentication",
|
"Failed to disable two-factor authentication"
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ export type RequestTotpSecretBody = z.infer<typeof requestTotpSecretBody>;
|
||||||
|
|
||||||
export type RequestTotpSecretResponse = {
|
export type RequestTotpSecretResponse = {
|
||||||
secret: string;
|
secret: string;
|
||||||
|
uri: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function requestTotpSecret(
|
export async function requestTotpSecret(
|
||||||
|
@ -75,7 +76,8 @@ export async function requestTotpSecret(
|
||||||
|
|
||||||
return response<RequestTotpSecretResponse>(res, {
|
return response<RequestTotpSecretResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
secret: uri
|
secret,
|
||||||
|
uri
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|
|
@ -11,6 +11,9 @@ import { alphabet, generateRandomString } from "oslo/crypto";
|
||||||
import { hashPassword } from "@server/auth/password";
|
import { hashPassword } from "@server/auth/password";
|
||||||
import { verifyTotpCode } from "@server/auth/2fa";
|
import { verifyTotpCode } from "@server/auth/2fa";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { sendEmail } from "@server/emails";
|
||||||
|
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
||||||
|
import config from "@server/config";
|
||||||
|
|
||||||
export const verifyTotpBody = z
|
export const verifyTotpBody = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -90,8 +93,6 @@ export async function verifyTotp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: send email to user confirming two-factor authentication is enabled
|
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
@ -101,6 +102,18 @@ export async function verifyTotp(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendEmail(
|
||||||
|
TwoFactorAuthNotification({
|
||||||
|
email: user.email,
|
||||||
|
enabled: true
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
to: user.email,
|
||||||
|
from: config.email?.no_reply,
|
||||||
|
subject: "Two-factor authentication enabled"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return response<VerifyTotpResponse>(res, {
|
return response<VerifyTotpResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
valid,
|
valid,
|
||||||
|
|
|
@ -13,7 +13,6 @@ import { UsersDataTable } from "./UsersDataTable";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import InviteUserForm from "./InviteUserForm";
|
import InviteUserForm from "./InviteUserForm";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
@ -21,6 +20,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { formatAxiosError } from "@app/lib/utils";
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
import { createApiClient } from "@app/api";
|
import { createApiClient } from "@app/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
export type UserRow = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -45,7 +45,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const user = useUserContext();
|
const { user, updateUser } = useUserContext();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
|
|
@ -368,7 +368,6 @@ export default function ResetPasswordForm({
|
||||||
index={2}
|
index={2}
|
||||||
/>
|
/>
|
||||||
</InputOTPGroup>
|
</InputOTPGroup>
|
||||||
<InputOTPSeparator />
|
|
||||||
<InputOTPGroup>
|
<InputOTPGroup>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={3}
|
index={3}
|
||||||
|
|
14
src/app/profile/general/page.tsx
Normal file
14
src/app/profile/general/page.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Enable2FaForm from "../../../components/Enable2FaForm";
|
||||||
|
|
||||||
|
export default function ProfileGeneralPage() {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* <Enable2FaForm open={open} setOpen={setOpen} /> */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
DoubleArrowLeftIcon,
|
DoubleArrowLeftIcon,
|
||||||
DoubleArrowRightIcon,
|
DoubleArrowRightIcon
|
||||||
} from "@radix-ui/react-icons";
|
} from "@radix-ui/react-icons";
|
||||||
import { Table } from "@tanstack/react-table";
|
import { Table } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ import {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
|
|
||||||
interface DataTablePaginationProps<TData> {
|
interface DataTablePaginationProps<TData> {
|
||||||
|
@ -20,38 +20,34 @@ interface DataTablePaginationProps<TData> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTablePagination<TData>({
|
export function DataTablePagination<TData>({
|
||||||
table,
|
table
|
||||||
}: DataTablePaginationProps<TData>) {
|
}: DataTablePaginationProps<TData>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end px-2">
|
<div className="flex items-center justify-between text-muted-foreground">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">Rows per page</p>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={table.getState().pagination.pageSize}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 30, 40, 50, 100, 200].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<p className="text-sm font-medium">Rows per page</p>
|
|
||||||
<Select
|
|
||||||
value={`${table.getState().pagination.pageSize}`}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
table.setPageSize(Number(value));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
table.getState().pagination.pageSize
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent side="top">
|
|
||||||
{[10, 20, 30, 40, 50, 100, 200].map((pageSize) => (
|
|
||||||
<SelectItem
|
|
||||||
key={pageSize}
|
|
||||||
value={`${pageSize}`}
|
|
||||||
>
|
|
||||||
{pageSize}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
{table.getPageCount()}
|
{table.getPageCount()}
|
||||||
|
|
227
src/components/Disable2FaForm.tsx
Normal file
227
src/components/Disable2FaForm.tsx
Normal file
|
@ -0,0 +1,227 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { createApiClient } from "@app/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { Disable2faBody, Disable2faResponse } 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 { useToast } from "@app/hooks/useToast";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
||||||
|
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
|
const disableSchema = z.object({
|
||||||
|
password: z.string().min(1, { message: "Password is required" }),
|
||||||
|
code: z.string().min(1, { message: "Code is required" })
|
||||||
|
});
|
||||||
|
|
||||||
|
type Disable2FaProps = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (val: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<"password" | "success">("password");
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { user, updateUser } = useUserContext();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const disableForm = useForm<z.infer<typeof disableSchema>>({
|
||||||
|
resolver: zodResolver(disableSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: "",
|
||||||
|
code: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const request2fa = async (values: z.infer<typeof disableSchema>) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post<AxiosResponse<Disable2faResponse>>(`/auth/2fa/disable`, {
|
||||||
|
password: values.password,
|
||||||
|
code: values.code
|
||||||
|
} as Disable2faBody)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
title: "Unable to disable 2FA",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred while disabling 2FA"
|
||||||
|
),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
// toast({
|
||||||
|
// title: "Two-factor disabled",
|
||||||
|
// description:
|
||||||
|
// "Two-factor authentication has been disabled for your account"
|
||||||
|
// });
|
||||||
|
updateUser({ twoFactorEnabled: false });
|
||||||
|
setStep("success");
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
Disable Two-factor Authentication
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
Disable two-factor authentication for your account
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
{step === "password" && (
|
||||||
|
<Form {...disableForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={disableForm.handleSubmit(request2fa)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={disableForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={disableForm.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Authenticator Code
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
{...field}
|
||||||
|
pattern={
|
||||||
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={0}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={1}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={2}
|
||||||
|
/>
|
||||||
|
</InputOTPGroup>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={3}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={4}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={5}
|
||||||
|
/>
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "success" && (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<CheckCircle2
|
||||||
|
className="mx-auto text-green-500"
|
||||||
|
size={48}
|
||||||
|
/>
|
||||||
|
<p className="font-semibold text-lg">
|
||||||
|
Two-Factor Authentication Disabled
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Two-factor authentication has been disabled for
|
||||||
|
your account. You can enable it again at any
|
||||||
|
time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
{step === "password" && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="form"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
|
@ -39,7 +39,7 @@ import { useToast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/utils";
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import { userUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
const enableSchema = z.object({
|
const enableSchema = z.object({
|
||||||
password: z.string().min(1, { message: "Password is required" })
|
password: z.string().min(1, { message: "Password is required" })
|
||||||
|
@ -57,6 +57,7 @@ type Enable2FaProps = {
|
||||||
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [secretKey, setSecretKey] = useState("");
|
const [secretKey, setSecretKey] = useState("");
|
||||||
|
const [secretUri, setSecretUri] = useState("");
|
||||||
const [verificationCode, setVerificationCode] = useState("");
|
const [verificationCode, setVerificationCode] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
@ -65,7 +66,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { user, updateUser } = userUserContext();
|
const { user, updateUser } = useUserContext();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
@ -106,6 +107,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
|
|
||||||
if (res && res.data.data.secret) {
|
if (res && res.data.data.secret) {
|
||||||
setSecretKey(res.data.data.secret);
|
setSecretKey(res.data.data.secret);
|
||||||
|
setSecretUri(res.data.data.uri);
|
||||||
setStep(2);
|
setStep(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,7 +134,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
|
|
||||||
if (res && res.data.data.valid) {
|
if (res && res.data.data.valid) {
|
||||||
setBackupCodes(res.data.data.backupCodes || []);
|
setBackupCodes(res.data.data.backupCodes || []);
|
||||||
updateUser({ twoFactorEnabled: true })
|
updateUser({ twoFactorEnabled: true });
|
||||||
setStep(3);
|
setStep(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,11 +205,11 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p>
|
<p>
|
||||||
scan this qr code with your authenticator app or
|
Scan this QR code with your authenticator app or
|
||||||
enter the secret key manually:
|
enter the secret key manually:
|
||||||
</p>
|
</p>
|
||||||
<div classname="w-64 h-64 mx-auto flex items-center justify-center">
|
<div className="w-64 h-64 mx-auto flex items-center justify-center">
|
||||||
<qrcodesvg value={secretkey} size={256} />
|
<QRCodeSVG value={secretUri} size={256} />
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
<CopyTextBox
|
<CopyTextBox
|
||||||
|
@ -231,7 +233,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Verification Code
|
Authenticator Code
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
@ -43,7 +43,8 @@ import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Enable2FaForm from "./Enable2FaForm";
|
import Enable2FaForm from "./Enable2FaForm";
|
||||||
import { userUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
import Disable2FaForm from "./Disable2FaForm";
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
|
@ -54,7 +55,7 @@ export function Header({ orgId, orgs }: HeaderProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { setTheme, theme } = useTheme();
|
const { setTheme, theme } = useTheme();
|
||||||
|
|
||||||
const { user, updateUser } = userUserContext();
|
const { user, updateUser } = useUserContext();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
|
const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
|
||||||
|
@ -62,6 +63,7 @@ export function Header({ orgId, orgs }: HeaderProps) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
||||||
|
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
@ -93,6 +95,7 @@ export function Header({ orgId, orgs }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||||
|
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
@ -133,7 +136,9 @@ export function Header({ orgId, orgs }: HeaderProps) {
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{user.twoFactorEnabled && (
|
{user.twoFactorEnabled && (
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setOpenDisable2fa(true)}
|
||||||
|
>
|
||||||
<span>Disable Two-factor</span>
|
<span>Disable Two-factor</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -103,8 +103,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
|
|
||||||
const data = res.data.data;
|
const data = res.data.data;
|
||||||
|
|
||||||
console.log(data);
|
|
||||||
|
|
||||||
if (data?.codeRequested) {
|
if (data?.codeRequested) {
|
||||||
setMfaRequested(true);
|
setMfaRequested(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
@ -136,6 +134,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
@ -182,93 +181,120 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
<LockIcon className="w-4 h-4 mr-2" />
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mfaRequested && (
|
{mfaRequested && (
|
||||||
<Form {...mfaForm}>
|
<>
|
||||||
<form
|
<div className="text-center">
|
||||||
onSubmit={mfaForm.handleSubmit(onSubmit)}
|
<h3 className="text-lg font-medium">
|
||||||
className="space-y-4"
|
Two-Factor Authentication
|
||||||
>
|
</h3>
|
||||||
<FormField
|
<p className="text-sm text-muted-foreground">
|
||||||
control={mfaForm.control}
|
Enter the code from your authenticator app.
|
||||||
name="code"
|
</p>
|
||||||
render={({ field }) => (
|
</div>
|
||||||
<FormItem>
|
<Form {...mfaForm}>
|
||||||
<FormLabel>Authenticator Code</FormLabel>
|
<form
|
||||||
<FormControl>
|
onSubmit={mfaForm.handleSubmit(onSubmit)}
|
||||||
<div className="flex justify-center">
|
className="space-y-4"
|
||||||
<InputOTP
|
id="form"
|
||||||
maxLength={6}
|
>
|
||||||
{...field}
|
<FormField
|
||||||
pattern={
|
control={mfaForm.control}
|
||||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
name="code"
|
||||||
}
|
render={({ field }) => (
|
||||||
>
|
<FormItem>
|
||||||
<InputOTPGroup>
|
<FormControl>
|
||||||
<InputOTPSlot index={0} />
|
<div className="flex justify-center">
|
||||||
<InputOTPSlot index={1} />
|
<InputOTP
|
||||||
<InputOTPSlot index={2} />
|
maxLength={6}
|
||||||
</InputOTPGroup>
|
{...field}
|
||||||
<InputOTPSeparator />
|
pattern={
|
||||||
<InputOTPGroup>
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
<InputOTPSlot index={3} />
|
}
|
||||||
<InputOTPSlot index={4} />
|
>
|
||||||
<InputOTPSlot index={5} />
|
<InputOTPGroup>
|
||||||
</InputOTPGroup>
|
<InputOTPSlot
|
||||||
</InputOTP>
|
index={0}
|
||||||
</div>
|
/>
|
||||||
</FormControl>
|
<InputOTPSlot
|
||||||
<FormMessage />
|
index={1}
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
<InputOTPSlot
|
||||||
/>
|
index={2}
|
||||||
{error && (
|
/>
|
||||||
<Alert variant="destructive">
|
</InputOTPGroup>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<InputOTPGroup>
|
||||||
</Alert>
|
<InputOTPSlot
|
||||||
)}
|
index={3}
|
||||||
|
/>
|
||||||
<div className="space-y-4">
|
<InputOTPSlot
|
||||||
<Button
|
index={4}
|
||||||
type="submit"
|
/>
|
||||||
className="w-full"
|
<InputOTPSlot
|
||||||
loading={loading}
|
index={5}
|
||||||
>
|
/>
|
||||||
<LockIcon className="w-4 h-4 mr-2" />
|
</InputOTPGroup>
|
||||||
Submit Code
|
</InputOTP>
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
</FormControl>
|
||||||
type="button"
|
<FormMessage />
|
||||||
className="w-full"
|
</FormItem>
|
||||||
variant="outline"
|
)}
|
||||||
onClick={() => {
|
/>
|
||||||
setMfaRequested(false);
|
</form>
|
||||||
mfaForm.reset();
|
</Form>
|
||||||
}}
|
</>
|
||||||
>
|
|
||||||
Back to Login
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mfaRequested && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="form"
|
||||||
|
className="w-full"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Submit Code
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!mfaRequested && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="form"
|
||||||
|
className="w-full"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<LockIcon className="w-4 h-4 mr-2" />
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mfaRequested && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setMfaRequested(false);
|
||||||
|
mfaForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Login
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,18 +10,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
const [showPassword, setShowPassword] = React.useState(false);
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
const togglePasswordVisibility = () => setShowPassword(!showPassword);
|
const togglePasswordVisibility = () => setShowPassword(!showPassword);
|
||||||
|
|
||||||
console.log("type", type);
|
return type === "password" ? (
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={
|
type={showPassword ? "text" : "password"}
|
||||||
type === "password"
|
|
||||||
? showPassword
|
|
||||||
? "text"
|
|
||||||
: "password"
|
|
||||||
: type
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
|
@ -29,22 +21,30 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{type === "password" && (
|
<div className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-gray-400">
|
||||||
<div className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-gray-400">
|
{showPassword ? (
|
||||||
{showPassword ? (
|
<EyeOff
|
||||||
<EyeOff
|
className="h-4 w-4"
|
||||||
className="h-4 w-4"
|
onClick={togglePasswordVisibility}
|
||||||
onClick={togglePasswordVisibility}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<Eye
|
||||||
<Eye
|
className="h-4 w-4"
|
||||||
className="h-4 w-4"
|
onClick={togglePasswordVisibility}
|
||||||
onClick={togglePasswordVisibility}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import UserContext from "@app/contexts/userContext";
|
import UserContext from "@app/contexts/userContext";
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
|
|
||||||
export function userUserContext() {
|
export function useUserContext() {
|
||||||
const context = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error("useUserContext must be used within a UserProvider");
|
throw new Error("useUserContext must be used within a UserProvider");
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue