This commit is contained in:
 Илья Шалонин 2025-10-29 13:43:43 +05:00
commit 8538ab969a
434 changed files with 44051 additions and 0 deletions

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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,
};
}

View 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;
};

View 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;
},
});
};

View 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;
}
});

View 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);