first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
135
apps/web/modules/saas/auth/components/ForgotPasswordForm.tsx
Normal file
135
apps/web/modules/saas/auth/components/ForgotPasswordForm.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { AlertTriangleIcon, ArrowLeftIcon, MailboxIcon } from "lucide-react";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const t = useTranslations();
|
||||
const { getAuthErrorMessage } = useAuthErrorMessages();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ email }) => {
|
||||
try {
|
||||
const redirectTo = new URL(
|
||||
"/auth/reset-password",
|
||||
window.location.origin,
|
||||
).toString();
|
||||
|
||||
const { error } = await authClient.forgetPassword({
|
||||
email,
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("auth.forgotPassword.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-6 text-foreground/60">
|
||||
{t("auth.forgotPassword.message")}{" "}
|
||||
</p>
|
||||
|
||||
{form.formState.isSubmitSuccessful ? (
|
||||
<Alert variant="success">
|
||||
<MailboxIcon />
|
||||
<AlertTitle>
|
||||
{t("auth.forgotPassword.hints.linkSent.title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.forgotPassword.hints.linkSent.message")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{form.formState.errors.root && (
|
||||
<Alert variant="error">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.forgotPassword.email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button loading={form.formState.isSubmitting}>
|
||||
{t("auth.forgotPassword.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Link href="/auth/login">
|
||||
<ArrowLeftIcon className="mr-1 inline size-4 align-middle" />
|
||||
{t("auth.forgotPassword.backToSignin")}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
357
apps/web/modules/saas/auth/components/LoginForm.tsx
Normal file
357
apps/web/modules/saas/auth/components/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
|
||||
import { sessionQueryKey } from "@saas/auth/lib/api";
|
||||
import { OrganizationInvitationAlert } from "@saas/organizations/components/OrganizationInvitationAlert";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
KeyIcon,
|
||||
MailboxIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { withQuery } from "ufo";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type OAuthProvider,
|
||||
oAuthProviders,
|
||||
} from "../constants/oauth-providers";
|
||||
import { useSession } from "../hooks/use-session";
|
||||
import { LoginModeSwitch } from "./LoginModeSwitch";
|
||||
import { SocialSigninButton } from "./SocialSigninButton";
|
||||
|
||||
const formSchema = z.union([
|
||||
z.object({
|
||||
mode: z.literal("magic-link"),
|
||||
email: z.string().email(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal("password"),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function LoginForm() {
|
||||
const t = useTranslations();
|
||||
const { getAuthErrorMessage } = useAuthErrorMessages();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, loaded: sessionLoaded } = useSession();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const invitationId = searchParams.get("invitationId");
|
||||
const email = searchParams.get("email");
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
password: "",
|
||||
mode: config.auth.enablePasswordLogin ? "password" : "magic-link",
|
||||
},
|
||||
});
|
||||
|
||||
const redirectPath = invitationId
|
||||
? `/app/organization-invitation/${invitationId}`
|
||||
: (redirectTo ?? config.auth.redirectAfterSignIn);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionLoaded && user) {
|
||||
router.replace(redirectPath);
|
||||
}
|
||||
}, [user, sessionLoaded]);
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = async (values) => {
|
||||
try {
|
||||
if (values.mode === "password") {
|
||||
const { data, error } = await authClient.signIn.email({
|
||||
...values,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if ((data as any).twoFactorRedirect) {
|
||||
router.replace(
|
||||
withQuery(
|
||||
"/auth/verify",
|
||||
Object.fromEntries(searchParams.entries()),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: sessionQueryKey,
|
||||
});
|
||||
|
||||
router.replace(redirectPath);
|
||||
} else {
|
||||
const { error } = await authClient.signIn.magicLink({
|
||||
...values,
|
||||
callbackURL: redirectPath,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const signInWithPasskey = async () => {
|
||||
try {
|
||||
await authClient.signIn.passkey();
|
||||
|
||||
router.replace(redirectPath);
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const signinMode = form.watch("mode");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("auth.login.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-6 text-foreground/60">
|
||||
{t("auth.login.subtitle")}
|
||||
</p>
|
||||
|
||||
{form.formState.isSubmitSuccessful &&
|
||||
signinMode === "magic-link" ? (
|
||||
<Alert variant="success">
|
||||
<MailboxIcon />
|
||||
<AlertTitle>
|
||||
{t("auth.login.hints.linkSent.title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.login.hints.linkSent.message")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{invitationId && (
|
||||
<OrganizationInvitationAlert className="mb-6" />
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
{config.auth.enableMagicLink &&
|
||||
config.auth.enablePasswordLogin && (
|
||||
<LoginModeSwitch
|
||||
activeMode={signinMode}
|
||||
onChange={(mode) =>
|
||||
form.setValue(
|
||||
"mode",
|
||||
mode as typeof signinMode,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.formState.isSubmitted &&
|
||||
form.formState.errors.root?.message && (
|
||||
<Alert variant="error">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.signup.email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{config.auth.enablePasswordLogin &&
|
||||
signinMode === "password" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex justify-between gap-4">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"auth.signup.password",
|
||||
)}
|
||||
</FormLabel>
|
||||
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="text-foreground/60 text-xs"
|
||||
>
|
||||
{t(
|
||||
"auth.login.forgotPassword",
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={
|
||||
showPassword
|
||||
? "text"
|
||||
: "password"
|
||||
}
|
||||
className="pr-10"
|
||||
{...field}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowPassword(
|
||||
!showPassword,
|
||||
)
|
||||
}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-4 text-primary text-xl"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="size-4" />
|
||||
) : (
|
||||
<EyeIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{signinMode === "magic-link"
|
||||
? t("auth.login.sendMagicLink")
|
||||
: t("auth.login.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{(config.auth.enablePasskeys ||
|
||||
(config.auth.enableSignup &&
|
||||
config.auth.enableSocialLogin)) && (
|
||||
<>
|
||||
<div className="relative my-6 h-4">
|
||||
<hr className="relative top-2" />
|
||||
<p className="-translate-x-1/2 absolute top-0 left-1/2 mx-auto inline-block h-4 bg-card px-2 text-center font-medium text-foreground/60 text-sm leading-tight">
|
||||
{t("auth.login.continueWith")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2">
|
||||
{config.auth.enableSignup &&
|
||||
config.auth.enableSocialLogin &&
|
||||
Object.keys(oAuthProviders).map(
|
||||
(providerId) => (
|
||||
<SocialSigninButton
|
||||
key={providerId}
|
||||
provider={
|
||||
providerId as OAuthProvider
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{config.auth.enablePasskeys && (
|
||||
<Button
|
||||
variant="light"
|
||||
className="w-full sm:col-span-2"
|
||||
onClick={() => signInWithPasskey()}
|
||||
>
|
||||
<KeyIcon className="mr-1.5 size-4 text-primary" />
|
||||
{t("auth.login.loginWithPasskey")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.auth.enableSignup && (
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-foreground/60">
|
||||
{t("auth.login.dontHaveAnAccount")}{" "}
|
||||
</span>
|
||||
<Link
|
||||
href={withQuery(
|
||||
"/auth/signup",
|
||||
Object.fromEntries(searchParams.entries()),
|
||||
)}
|
||||
>
|
||||
{t("auth.login.createAnAccount")}
|
||||
<ArrowRightIcon className="ml-1 inline size-4 align-middle" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/web/modules/saas/auth/components/LoginModeSwitch.tsx
Normal file
28
apps/web/modules/saas/auth/components/LoginModeSwitch.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function LoginModeSwitch({
|
||||
activeMode,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
activeMode: "password" | "magic-link";
|
||||
onChange: (mode: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<Tabs value={activeMode} onValueChange={onChange} className={className}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="password" className="flex-1">
|
||||
{t("auth.login.modes.password")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="magic-link" className="flex-1">
|
||||
{t("auth.login.modes.magicLink")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
169
apps/web/modules/saas/auth/components/OtpForm.tsx
Normal file
169
apps/web/modules/saas/auth/components/OtpForm.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Alert, AlertTitle } from "@ui/components/alert";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { AlertTriangleIcon, ArrowLeftIcon } from "lucide-react";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from "@ui/components/input-otp";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().min(6).max(6),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function OtpForm() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { getAuthErrorMessage } = useAuthErrorMessages();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const invitationId = searchParams.get("invitationId");
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
const redirectPath = invitationId
|
||||
? `/app/organization-invitation/${invitationId}`
|
||||
: (redirectTo ?? config.auth.redirectAfterSignIn);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ code }) => {
|
||||
try {
|
||||
const { error } = await authClient.twoFactor.verifyTotp({
|
||||
code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
router.replace(redirectPath);
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("auth.verify.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-4 text-foreground/60">
|
||||
{t("auth.verify.message")}
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{form.formState.errors.root && (
|
||||
<Alert variant="error">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("auth.verify.code")}</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
autoComplete="one-time-code"
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={0}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={1}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={2}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator className="opacity-40" />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={3}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={4}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={5}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button loading={form.formState.isSubmitting}>
|
||||
{t("auth.verify.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Link href="/auth/login">
|
||||
<ArrowLeftIcon className="mr-1 inline size-4 align-middle" />
|
||||
{t("auth.verify.backToSignin")}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
139
apps/web/modules/saas/auth/components/ResetPasswordForm.tsx
Normal file
139
apps/web/modules/saas/auth/components/ResetPasswordForm.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Alert, AlertTitle } from "@ui/components/alert";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { AlertTriangleIcon, ArrowLeftIcon, MailboxIcon } from "lucide-react";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import { PasswordInput } from "@ui/components/password-input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function ResetPasswordForm() {
|
||||
const t = useTranslations();
|
||||
const { user } = useSession();
|
||||
const router = useRouter();
|
||||
const { getAuthErrorMessage } = useAuthErrorMessages();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ password }) => {
|
||||
try {
|
||||
const { error } = await authClient.resetPassword({
|
||||
token: token ?? undefined,
|
||||
newPassword: password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
router.push(config.auth.redirectAfterSignIn);
|
||||
}
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("auth.resetPassword.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-6 text-foreground/60">
|
||||
{t("auth.resetPassword.message")}{" "}
|
||||
</p>
|
||||
|
||||
{form.formState.isSubmitSuccessful ? (
|
||||
<Alert variant="success">
|
||||
<MailboxIcon />
|
||||
<AlertTitle>
|
||||
{t("auth.resetPassword.hints.success")}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{form.formState.errors.root && (
|
||||
<Alert variant="error">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.resetPassword.newPassword")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button loading={form.formState.isSubmitting}>
|
||||
{t("auth.resetPassword.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Link href="/auth/login">
|
||||
<ArrowLeftIcon className="mr-1 inline size-4 align-middle" />
|
||||
{t("auth.resetPassword.backToSignin")}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
apps/web/modules/saas/auth/components/SessionProvider.tsx
Normal file
51
apps/web/modules/saas/auth/components/SessionProvider.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { sessionQueryKey, useSessionQuery } from "@saas/auth/lib/api";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { SessionContext } from "../lib/session-context";
|
||||
|
||||
export function SessionProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: session } = useSessionQuery();
|
||||
const [loaded, setLoaded] = useState(!!session);
|
||||
|
||||
useEffect(() => {
|
||||
if (session && !loaded) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider
|
||||
value={{
|
||||
loaded,
|
||||
session: session?.session ?? null,
|
||||
user: session?.user ?? null,
|
||||
reloadSession: async () => {
|
||||
const { data: newSession, error } =
|
||||
await authClient.getSession({
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
error.message || "Failed to fetch session",
|
||||
);
|
||||
}
|
||||
|
||||
queryClient.setQueryData(sessionQueryKey, () => newSession);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
282
apps/web/modules/saas/auth/components/SignupForm.tsx
Normal file
282
apps/web/modules/saas/auth/components/SignupForm.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
|
||||
import { OrganizationInvitationAlert } from "@saas/organizations/components/OrganizationInvitationAlert";
|
||||
import { useFormErrors } from "@shared/hooks/form-errors";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
MailboxIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { withQuery } from "ufo";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type OAuthProvider,
|
||||
oAuthProviders,
|
||||
} from "../constants/oauth-providers";
|
||||
import { SocialSigninButton } from "./SocialSigninButton";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function SignupForm({ prefillEmail }: { prefillEmail?: string }) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { zodErrorMap } = useFormErrors();
|
||||
const { getAuthErrorMessage } = useAuthErrorMessages();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const invitationId = searchParams.get("invitationId");
|
||||
const email = searchParams.get("email");
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema, {
|
||||
errorMap: zodErrorMap,
|
||||
}),
|
||||
values: {
|
||||
name: "",
|
||||
email: prefillEmail ?? email ?? "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const invitationOnlyMode = !config.auth.enableSignup && invitationId;
|
||||
|
||||
const redirectPath = invitationId
|
||||
? `/app/organization-invitation/${invitationId}`
|
||||
: (redirectTo ?? config.auth.redirectAfterSignIn);
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = async ({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
}) => {
|
||||
try {
|
||||
const { error } = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
callbackURL: redirectPath,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (invitationOnlyMode) {
|
||||
const { error } =
|
||||
await authClient.organization.acceptInvitation({
|
||||
invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
router.push(config.auth.redirectAfterSignIn);
|
||||
}
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("auth.signup.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-6 text-foreground/60">
|
||||
{t("auth.signup.message")}
|
||||
</p>
|
||||
|
||||
{form.formState.isSubmitSuccessful && !invitationOnlyMode ? (
|
||||
<Alert variant="success">
|
||||
<MailboxIcon />
|
||||
<AlertTitle>
|
||||
{t("auth.signup.hints.verifyEmail")}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{invitationId && (
|
||||
<OrganizationInvitationAlert className="mb-6" />
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
{form.formState.isSubmitted &&
|
||||
form.formState.errors.root && (
|
||||
<Alert variant="error">
|
||||
<AlertTriangleIcon />
|
||||
<AlertDescription>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.signup.name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.signup.email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="email"
|
||||
readOnly={!!prefillEmail}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.signup.password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={
|
||||
showPassword
|
||||
? "text"
|
||||
: "password"
|
||||
}
|
||||
className="pr-10"
|
||||
{...field}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowPassword(
|
||||
!showPassword,
|
||||
)
|
||||
}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-4 text-primary text-xl"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="size-4" />
|
||||
) : (
|
||||
<EyeIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button loading={form.formState.isSubmitting}>
|
||||
{t("auth.signup.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{config.auth.enableSignup &&
|
||||
config.auth.enableSocialLogin && (
|
||||
<>
|
||||
<div className="relative my-6 h-4">
|
||||
<hr className="relative top-2" />
|
||||
<p className="-translate-x-1/2 absolute top-0 left-1/2 mx-auto inline-block h-4 bg-card px-2 text-center font-medium text-foreground/60 text-sm leading-tight">
|
||||
{t("auth.login.continueWith")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2">
|
||||
{Object.keys(oAuthProviders).map(
|
||||
(providerId) => (
|
||||
<SocialSigninButton
|
||||
key={providerId}
|
||||
provider={
|
||||
providerId as OAuthProvider
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-foreground/60">
|
||||
{t("auth.signup.alreadyHaveAccount")}{" "}
|
||||
</span>
|
||||
<Link
|
||||
href={withQuery(
|
||||
"/auth/login",
|
||||
Object.fromEntries(searchParams.entries()),
|
||||
)}
|
||||
>
|
||||
{t("auth.signup.signIn")}
|
||||
<ArrowRightIcon className="ml-1 inline size-4 align-middle" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
apps/web/modules/saas/auth/components/SocialSigninButton.tsx
Normal file
47
apps/web/modules/saas/auth/components/SocialSigninButton.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { parseAsString } from "nuqs";
|
||||
import { oAuthProviders } from "../constants/oauth-providers";
|
||||
|
||||
export function SocialSigninButton({
|
||||
provider,
|
||||
className,
|
||||
}: {
|
||||
provider: keyof typeof oAuthProviders;
|
||||
className?: string;
|
||||
}) {
|
||||
const [invitationId] = useQueryState("invitationId", parseAsString);
|
||||
const providerData = oAuthProviders[provider];
|
||||
|
||||
const redirectPath = invitationId
|
||||
? `/app/organization-invitation/${invitationId}`
|
||||
: config.auth.redirectAfterSignIn;
|
||||
|
||||
const onSignin = () => {
|
||||
const callbackURL = new URL(redirectPath, window.location.origin);
|
||||
authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: callbackURL.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => onSignin()}
|
||||
variant="light"
|
||||
type="button"
|
||||
className={className}
|
||||
>
|
||||
{providerData.icon && (
|
||||
<i className="mr-2 text-primary">
|
||||
<providerData.icon className="size-4" />
|
||||
</i>
|
||||
)}
|
||||
{providerData.name}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
37
apps/web/modules/saas/auth/constants/oauth-providers.tsx
Normal file
37
apps/web/modules/saas/auth/constants/oauth-providers.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { JSXElementConstructor } from "react";
|
||||
|
||||
type IconProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const oAuthProviders = {
|
||||
vk: {
|
||||
name: "ВКонтакте",
|
||||
icon: ({ ...props }: IconProps) => (
|
||||
<svg width="20" height="20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M0 48C0 25.3726 0 14.0589 7.02944 7.02944C14.0589 0 25.3726 0 48 0H52C74.6274 0 85.9411 0 92.9706 7.02944C100 14.0589 100 25.3726 100 48V52C100 74.6274 100 85.9411 92.9706 92.9706C85.9411 100 74.6274 100 52 100H48C25.3726 100 14.0589 100 7.02944 92.9706C0 85.9411 0 74.6274 0 52V48Z" fill="#0077FF"/>
|
||||
<path d="M53.2083 72.042C30.4167 72.042 17.4168 56.417 16.8751 30.417H28.2917C28.6667 49.5003 37.0833 57.5836 43.7499 59.2503V30.417H54.5002V46.8752C61.0836 46.1669 67.9994 38.667 70.3328 30.417H81.0831C79.2914 40.5837 71.7914 48.0836 66.458 51.1669C71.7914 53.6669 80.3335 60.2086 83.5835 72.042H71.7498C69.2081 64.1253 62.8752 58.0003 54.5002 57.1669V72.042H53.2083Z" fill="white"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
github: {
|
||||
name: "Github",
|
||||
icon: ({ ...props }: IconProps) => (
|
||||
<svg viewBox="0 0 496 512" {...props}>
|
||||
<title>Github</title>
|
||||
<path
|
||||
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
icon: JSXElementConstructor<React.SVGProps<SVGSVGElement>>;
|
||||
}
|
||||
>;
|
||||
|
||||
export type OAuthProvider = keyof typeof oAuthProviders;
|
||||
50
apps/web/modules/saas/auth/hooks/errors-messages.ts
Normal file
50
apps/web/modules/saas/auth/hooks/errors-messages.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { AuthClientErrorCodes } from "@repo/auth/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function useAuthErrorMessages() {
|
||||
const t = useTranslations();
|
||||
|
||||
const authErrorMessages: Partial<AuthClientErrorCodes> = {
|
||||
INVALID_EMAIL_OR_PASSWORD: t("auth.errors.invalidEmailOrPassword"),
|
||||
USER_NOT_FOUND: t("auth.errors.userNotFound"),
|
||||
FAILED_TO_CREATE_USER: t("auth.errors.failedToCreateUser"),
|
||||
FAILED_TO_CREATE_SESSION: t("auth.errors.failedToCreateSession"),
|
||||
FAILED_TO_UPDATE_USER: t("auth.errors.failedToUpdateUser"),
|
||||
FAILED_TO_GET_SESSION: t("auth.errors.failedToGetSession"),
|
||||
INVALID_PASSWORD: t("auth.errors.invalidPassword"),
|
||||
INVALID_EMAIL: t("auth.errors.invalidEmail"),
|
||||
INVALID_TOKEN: t("auth.errors.invalidToken"),
|
||||
CREDENTIAL_ACCOUNT_NOT_FOUND: t(
|
||||
"auth.errors.credentialAccountNotFound",
|
||||
),
|
||||
EMAIL_CAN_NOT_BE_UPDATED: t("auth.errors.emailCanNotBeUpdated"),
|
||||
EMAIL_NOT_VERIFIED: t("auth.errors.emailNotVerified"),
|
||||
FAILED_TO_GET_USER_INFO: t("auth.errors.failedToGetUserInfo"),
|
||||
ID_TOKEN_NOT_SUPPORTED: t("auth.errors.idTokenNotSupported"),
|
||||
PASSWORD_TOO_LONG: t("auth.errors.passwordTooLong"),
|
||||
PASSWORD_TOO_SHORT: t("auth.errors.passwordTooShort"),
|
||||
PROVIDER_NOT_FOUND: t("auth.errors.providerNotFound"),
|
||||
SOCIAL_ACCOUNT_ALREADY_LINKED: t(
|
||||
"auth.errors.socialAccountAlreadyLinked",
|
||||
),
|
||||
USER_EMAIL_NOT_FOUND: t("auth.errors.userEmailNotFound"),
|
||||
USER_ALREADY_EXISTS: t("auth.errors.userAlreadyExists"),
|
||||
INVALID_INVITATION: t("auth.errors.invalidInvitation"),
|
||||
SESSION_EXPIRED: t("auth.errors.sessionExpired"),
|
||||
FAILED_TO_UNLINK_LAST_ACCOUNT: t(
|
||||
"auth.errors.failedToUnlinkLastAccount",
|
||||
),
|
||||
ACCOUNT_NOT_FOUND: t("auth.errors.accountNotFound"),
|
||||
};
|
||||
|
||||
const getAuthErrorMessage = (errorCode: string | undefined) => {
|
||||
return (
|
||||
authErrorMessages[errorCode as keyof typeof authErrorMessages] ||
|
||||
t("auth.errors.unknown")
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
getAuthErrorMessage,
|
||||
};
|
||||
}
|
||||
12
apps/web/modules/saas/auth/hooks/use-session.ts
Normal file
12
apps/web/modules/saas/auth/hooks/use-session.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { useContext } from "react";
|
||||
import { SessionContext } from "../lib/session-context";
|
||||
|
||||
export const useSession = () => {
|
||||
const sessionContext = useContext(SessionContext);
|
||||
|
||||
if (sessionContext === undefined) {
|
||||
throw new Error("useSession must be used within SessionProvider");
|
||||
}
|
||||
|
||||
return sessionContext;
|
||||
};
|
||||
60
apps/web/modules/saas/auth/lib/api.ts
Normal file
60
apps/web/modules/saas/auth/lib/api.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const sessionQueryKey = ["user", "session"] as const;
|
||||
|
||||
export const useSessionQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: sessionQueryKey,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.getSession({
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to fetch session");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
enabled: config.ui.saas.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const userAccountQueryKey = ["user", "accounts"] as const;
|
||||
export const useUserAccountsQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: userAccountQueryKey,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.listAccounts();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const userPasskeyQueryKey = ["user", "passkeys"] as const;
|
||||
export const useUserPasskeysQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: userPasskeyQueryKey,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.passkey.listUserPasskeys();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
75
apps/web/modules/saas/auth/lib/server.ts
Normal file
75
apps/web/modules/saas/auth/lib/server.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import "server-only";
|
||||
import { auth } from "@repo/auth";
|
||||
import { getInvitationById } from "@repo/database";
|
||||
import { headers } from "next/headers";
|
||||
import { cache } from "react";
|
||||
|
||||
export const getSession = cache(async () => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
export const getActiveOrganization = cache(async (slug: string) => {
|
||||
try {
|
||||
const activeOrganization = await auth.api.getFullOrganization({
|
||||
query: {
|
||||
organizationSlug: slug,
|
||||
},
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return activeOrganization;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export const getOrganizationList = cache(async () => {
|
||||
try {
|
||||
const organizationList = await auth.api.listOrganizations({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return organizationList;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
export const getUserAccounts = cache(async () => {
|
||||
try {
|
||||
const userAccounts = await auth.api.listUserAccounts({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return userAccounts;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
export const getUserPasskeys = cache(async () => {
|
||||
try {
|
||||
const userPasskeys = await auth.api.listPasskeys({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return userPasskeys;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
export const getInvitation = cache(async (id: string) => {
|
||||
try {
|
||||
return await getInvitationById(id);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
12
apps/web/modules/saas/auth/lib/session-context.ts
Normal file
12
apps/web/modules/saas/auth/lib/session-context.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { Session } from "@repo/auth";
|
||||
import React from "react";
|
||||
|
||||
export const SessionContext = React.createContext<
|
||||
| {
|
||||
session: Session["session"] | null;
|
||||
user: Session["user"] | null;
|
||||
loaded: boolean;
|
||||
reloadSession: () => Promise<void>;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
Loading…
Add table
Add a link
Reference in a new issue