feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
62
apps/web/src/modules/auth/form/anonymous.tsx
Normal file
62
apps/web/src/modules/auth/form/anonymous.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import { useAuthFormStore } from "./store";
|
||||
|
||||
interface AnonymousLoginProps {
|
||||
readonly redirectTo?: string;
|
||||
}
|
||||
|
||||
export const AnonymousLogin = ({
|
||||
redirectTo = pathsConfig.dashboard.user.index,
|
||||
}: AnonymousLoginProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("auth");
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.anonymous,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.ANONYMOUS);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
size="lg"
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => signIn.mutate(undefined)}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.ANONYMOUS ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Icons.UserRound className="size-4" />
|
||||
{t("login.anonymous.cta")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
5
apps/web/src/modules/auth/form/login/constants.ts
Normal file
5
apps/web/src/modules/auth/form/login/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
|
||||
export const LOGIN_OPTIONS = [AuthProvider.PASSWORD, AuthProvider.MAGIC_LINK];
|
||||
|
||||
export type LoginOption = (typeof LOGIN_OPTIONS)[number];
|
||||
133
apps/web/src/modules/auth/form/login/form.tsx
Normal file
133
apps/web/src/modules/auth/form/login/form.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@turbostarter/ui-web/tabs";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { MagicLinkLoginForm } from "./magic-link";
|
||||
import { PasswordLoginForm } from "./password";
|
||||
|
||||
import type { LoginOption } from "./constants";
|
||||
|
||||
const LOGIN_OPTIONS_DETAILS = {
|
||||
[AuthProvider.PASSWORD]: {
|
||||
lastUsedMethodId: "email",
|
||||
component: PasswordLoginForm,
|
||||
label: "password",
|
||||
},
|
||||
[AuthProvider.MAGIC_LINK]: {
|
||||
lastUsedMethodId: AuthProvider.MAGIC_LINK,
|
||||
component: MagicLinkLoginForm,
|
||||
label: "login.magicLink.label",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface LoginFormProps {
|
||||
readonly options: LoginOption[];
|
||||
readonly redirectTo?: string;
|
||||
readonly email?: string;
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
export const LoginForm = ({
|
||||
options,
|
||||
redirectTo,
|
||||
email,
|
||||
onTwoFactorRedirect,
|
||||
}: LoginFormProps) => {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const [mainOption] = options;
|
||||
|
||||
if (!options.length || !mainOption) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.length === 1) {
|
||||
const Component = LOGIN_OPTIONS_DETAILS[mainOption].component;
|
||||
return (
|
||||
<Component
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={mainOption}
|
||||
className="flex w-full flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{options.map((provider) => (
|
||||
<TabsTrigger
|
||||
key={provider}
|
||||
value={provider}
|
||||
className="relative w-full"
|
||||
>
|
||||
{t(LOGIN_OPTIONS_DETAILS[provider].label)}
|
||||
|
||||
{authClient.isLastUsedLoginMethod(
|
||||
LOGIN_OPTIONS_DETAILS[provider].lastUsedMethodId,
|
||||
) && (
|
||||
<Badge className="absolute top-0 -right-4 z-10 -translate-y-1/2 shadow-sm">
|
||||
{t("lastUsed")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{options.map((provider) => {
|
||||
const Component = LOGIN_OPTIONS_DETAILS[provider].component;
|
||||
return (
|
||||
<TabsContent key={provider} value={provider} className="mt-4 w-full">
|
||||
<Suspense>
|
||||
<Component
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoginCta = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("register.alreadyHaveAccount")}
|
||||
<TurboLink
|
||||
href={
|
||||
searchParams.size > 0
|
||||
? `${pathsConfig.auth.login}?${searchParams.toString()}`
|
||||
: pathsConfig.auth.login
|
||||
}
|
||||
className="hover:text-primary pl-2 font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("login.cta")}!
|
||||
</TurboLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
138
apps/web/src/modules/auth/form/login/magic-link.tsx
Normal file
138
apps/web/src/modules/auth/form/login/magic-link.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
AuthProvider,
|
||||
generateName,
|
||||
magicLinkLoginSchema,
|
||||
} from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
interface MagicLinkLoginFormProps {
|
||||
readonly redirectTo?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const MagicLinkLoginForm = memo<MagicLinkLoginFormProps>(
|
||||
({ redirectTo = pathsConfig.dashboard.user.index, email }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(magicLinkLoginSchema),
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.magicLink,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.MAGIC_LINK);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{form.formState.isSubmitSuccessful ? (
|
||||
<motion.div
|
||||
className="my-6 flex flex-col items-center justify-center gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key="success"
|
||||
>
|
||||
<Icons.CheckCircle2
|
||||
className="text-success h-20 w-20"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
<h2 className="text-center text-2xl font-semibold tracking-tight">
|
||||
{t("login.magicLink.success.title")}
|
||||
</h2>
|
||||
<p className="text-center">
|
||||
{t("login.magicLink.success.description")}
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
signIn.mutateAsync({
|
||||
email: data.email,
|
||||
name: generateName(data.email),
|
||||
callbackURL: redirectTo,
|
||||
errorCallbackURL: pathsConfig.auth.error,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.MAGIC_LINK ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("login.magicLink.cta")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MagicLinkLoginForm.displayName = "MagicLinkLoginForm";
|
||||
73
apps/web/src/modules/auth/form/login/passkey.tsx
Normal file
73
apps/web/src/modules/auth/form/login/passkey.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
import { useAuthFormStore } from "../store";
|
||||
|
||||
interface PasskeyLoginProps {
|
||||
readonly redirectTo?: string;
|
||||
}
|
||||
|
||||
export const PasskeyLogin = ({
|
||||
redirectTo = pathsConfig.dashboard.user.index,
|
||||
}: PasskeyLoginProps) => {
|
||||
const router = useRouter();
|
||||
const { setProvider, setIsSubmitting, isSubmitting, provider } =
|
||||
useAuthFormStore();
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.passkey,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.PASSKEY);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void auth.mutations.signIn.passkey.mutationFn({ autoFill: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative gap-2"
|
||||
size="lg"
|
||||
onClick={() => signIn.mutate(undefined)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.PASSKEY ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Icons.Key className="size-4" />
|
||||
{t("login.passkey.cta")}
|
||||
</>
|
||||
)}
|
||||
|
||||
{authClient.isLastUsedLoginMethod(AuthProvider.PASSKEY) && (
|
||||
<Badge className="absolute top-0 -right-4 z-10 -translate-y-1/2 shadow-sm">
|
||||
{t("lastUsed")}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
169
apps/web/src/modules/auth/form/login/password.tsx
Normal file
169
apps/web/src/modules/auth/form/login/password.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { AuthProvider, passwordLoginSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Checkbox } from "@turbostarter/ui-web/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input, PasswordInput } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
interface PasswordLoginFormProps {
|
||||
readonly redirectTo?: string;
|
||||
readonly email?: string;
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
export const PasswordLoginForm = memo<PasswordLoginFormProps>(
|
||||
({
|
||||
redirectTo = pathsConfig.dashboard.user.index,
|
||||
email,
|
||||
onTwoFactorRedirect,
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(passwordLoginSchema),
|
||||
defaultValues: {
|
||||
rememberMe: true,
|
||||
email: email ?? "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.email,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.PASSWORD);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: (ctx) => {
|
||||
if ("twoFactorRedirect" in ctx) {
|
||||
return onTwoFactorRedirect?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
signIn.mutateAsync({
|
||||
...data,
|
||||
callbackURL: redirectTo,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common:email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
disabled={isSubmitting}
|
||||
autoComplete="email webauthn"
|
||||
inputMode="email"
|
||||
spellCheck={false}
|
||||
maxLength={254}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex flex-col-reverse gap-2">
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="current-password webauthn"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
<TurboLink
|
||||
href={pathsConfig.auth.forgotPassword}
|
||||
className="text-muted-foreground hover:text-primary text-sm underline underline-offset-4"
|
||||
>
|
||||
{t("account.password.forgot.label")}
|
||||
</TurboLink>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rememberMe"
|
||||
render={({ field }) => (
|
||||
<FormItem className="-mt-2 ml-px flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>{t("rememberMe")}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.PASSWORD ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("login.cta")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PasswordLoginForm.displayName = "PasswordLoginForm";
|
||||
131
apps/web/src/modules/auth/form/password/forgot.tsx
Normal file
131
apps/web/src/modules/auth/form/password/forgot.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { forgotPasswordSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
export const ForgotPasswordForm = () => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(forgotPasswordSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const forgetPassword = useMutation({
|
||||
...auth.mutations.password.forget,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{form.formState.isSubmitSuccessful ? (
|
||||
<motion.div
|
||||
className="mt-6 flex flex-col items-center justify-center gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key="success"
|
||||
>
|
||||
<Icons.CheckCircle2
|
||||
className="text-success h-20 w-20"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
<h2 className="text-center text-2xl font-semibold tracking-tight">
|
||||
{t("account.password.forgot.success.title")}
|
||||
</h2>
|
||||
<p className="text-center">
|
||||
{t("account.password.forgot.success.description")}
|
||||
</p>
|
||||
<TurboLink
|
||||
href={pathsConfig.auth.login}
|
||||
className="text-muted-foreground hover:text-primary -mt-1 text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("login.cta")}
|
||||
</TurboLink>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Form {...form} key="idle">
|
||||
<motion.form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
forgetPassword.mutateAsync({
|
||||
...data,
|
||||
redirectTo: pathsConfig.auth.updatePassword,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
className="space-y-6"
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("account.password.forgot.cta")
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<TurboLink
|
||||
href={pathsConfig.auth.login}
|
||||
className="text-muted-foreground hover:text-primary pl-2 text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("account.password.forgot.back")}
|
||||
</TurboLink>
|
||||
</div>
|
||||
</motion.form>
|
||||
</Form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
99
apps/web/src/modules/auth/form/password/update.tsx
Normal file
99
apps/web/src/modules/auth/form/password/update.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { updatePasswordSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { PasswordInput } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
interface UpdatePasswordFormProps {
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
export const UpdatePasswordForm = memo<UpdatePasswordFormProps>(({ token }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(updatePasswordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const resetPassword = useMutation({
|
||||
...auth.mutations.password.reset,
|
||||
onSuccess: () => {
|
||||
router.replace(pathsConfig.auth.login);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form} key="idle">
|
||||
<motion.form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
resetPassword.mutateAsync({
|
||||
newPassword: data.password,
|
||||
token,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
className="space-y-6"
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("account.password.update.cta")
|
||||
)}
|
||||
</Button>
|
||||
</motion.form>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
UpdatePasswordForm.displayName = "UpdatePasswordForm";
|
||||
177
apps/web/src/modules/auth/form/register-form.tsx
Normal file
177
apps/web/src/modules/auth/form/register-form.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { AuthProvider, registerSchema, generateName } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input, PasswordInput } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import { useAuthFormStore } from "./store";
|
||||
|
||||
interface RegisterFormProps {
|
||||
readonly redirectTo?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const RegisterForm = memo<RegisterFormProps>(
|
||||
({ redirectTo = pathsConfig.dashboard.user.index, email }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(registerSchema),
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const signUp = useMutation({
|
||||
...auth.mutations.signUp.email,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.PASSWORD);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{form.formState.isSubmitSuccessful ? (
|
||||
<motion.div
|
||||
className="my-6 flex flex-col items-center justify-center gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key="success"
|
||||
>
|
||||
<Icons.CheckCircle2
|
||||
className="text-success h-20 w-20"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
<h2 className="text-center text-2xl font-semibold tracking-tight">
|
||||
{t("register.success.title")}
|
||||
</h2>
|
||||
<p className="text-center">{t("register.success.description")}</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Form {...form} key="idle">
|
||||
<motion.form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
signUp.mutateAsync({
|
||||
...data,
|
||||
name: generateName(data.email),
|
||||
callbackURL: redirectTo,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
className="space-y-6"
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
disabled={isSubmitting}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.PASSWORD ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("register.cta")
|
||||
)}
|
||||
</Button>
|
||||
</motion.form>
|
||||
</Form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RegisterForm.displayName = "RegisterForm";
|
||||
|
||||
export const RegisterCta = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("login.noAccount")}
|
||||
<TurboLink
|
||||
href={
|
||||
searchParams.size > 0
|
||||
? `${pathsConfig.auth.register}?${searchParams.toString()}`
|
||||
: pathsConfig.auth.register
|
||||
}
|
||||
className="hover:text-primary pl-2 font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("register.cta")}!
|
||||
</TurboLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
116
apps/web/src/modules/auth/form/social-providers.tsx
Normal file
116
apps/web/src/modules/auth/form/social-providers.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { memo } from "react";
|
||||
|
||||
import { SocialProvider as SocialProviderType } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import type { AuthProvider } from "@turbostarter/auth";
|
||||
import type { Icon } from "@turbostarter/ui-web/icons";
|
||||
|
||||
interface SocialProvidersProps {
|
||||
readonly providers: SocialProviderType[];
|
||||
readonly redirectTo?: string;
|
||||
}
|
||||
|
||||
export const SocialIcons: Record<SocialProviderType, Icon> = {
|
||||
[SocialProviderType.GITHUB]: Icons.Github,
|
||||
[SocialProviderType.GOOGLE]: Icons.Google,
|
||||
[SocialProviderType.APPLE]: Icons.Apple,
|
||||
};
|
||||
|
||||
const SocialProvider = ({
|
||||
provider,
|
||||
isSubmitting,
|
||||
onClick,
|
||||
actualProvider,
|
||||
}: {
|
||||
provider: SocialProviderType;
|
||||
isSubmitting: boolean;
|
||||
onClick: () => void;
|
||||
actualProvider: AuthProvider;
|
||||
}) => {
|
||||
const { t } = useTranslation("common");
|
||||
const Icon = SocialIcons[provider];
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={provider}
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="lg"
|
||||
className="relative grow basis-28 gap-2"
|
||||
disabled={isSubmitting}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isSubmitting && actualProvider === provider ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Icon className="size-5 dark:brightness-125" />
|
||||
<span className="leading-none capitalize">{provider}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authClient.isLastUsedLoginMethod(provider) && (
|
||||
<Badge className="absolute top-0 -right-4 z-10 -translate-y-1/2 shadow-sm">
|
||||
{t("lastUsed")}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const SocialProviders = memo<SocialProvidersProps>(
|
||||
({ providers, redirectTo = pathsConfig.dashboard.user.index }) => {
|
||||
const {
|
||||
provider: actualProvider,
|
||||
setProvider,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
} = useAuthFormStore();
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.social,
|
||||
onMutate: ({ provider }) => {
|
||||
setProvider(provider as SocialProviderType);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.values(providers).map((provider) => (
|
||||
<SocialProvider
|
||||
key={provider}
|
||||
provider={provider}
|
||||
isSubmitting={isSubmitting}
|
||||
onClick={() =>
|
||||
signIn.mutate({
|
||||
provider,
|
||||
callbackURL: redirectTo,
|
||||
errorCallbackURL: pathsConfig.auth.error,
|
||||
})
|
||||
}
|
||||
actualProvider={actualProvider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SocialProviders.displayName = "SocialProviders";
|
||||
15
apps/web/src/modules/auth/form/store/index.ts
Normal file
15
apps/web/src/modules/auth/form/store/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
|
||||
export const useAuthFormStore = create<{
|
||||
provider: AuthProvider;
|
||||
setProvider: (provider: AuthProvider) => void;
|
||||
isSubmitting: boolean;
|
||||
setIsSubmitting: (isSubmitting: boolean) => void;
|
||||
}>((set) => ({
|
||||
provider: AuthProvider.PASSWORD,
|
||||
setProvider: (provider) => set({ provider }),
|
||||
isSubmitting: false,
|
||||
setIsSubmitting: (isSubmitting) => set({ isSubmitting }),
|
||||
}));
|
||||
127
apps/web/src/modules/auth/form/two-factor/backup-code.tsx
Normal file
127
apps/web/src/modules/auth/form/two-factor/backup-code.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { backupCodeVerificationSchema, SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Checkbox } from "@turbostarter/ui-web/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { CtaProps, FormProps } from ".";
|
||||
|
||||
const BackupCodeForm = memo<FormProps>(
|
||||
({ redirectTo = pathsConfig.dashboard.user.index }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(backupCodeVerificationSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
trustDevice: false,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyBackupCode = useMutation({
|
||||
...auth.mutations.twoFactor.backupCodes.verify,
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
verifyBackupCode.mutateAsync(data),
|
||||
)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
autoFocus
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="one-time-code"
|
||||
placeholder={t("login.twoFactor.backupCode.placeholder")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trustDevice"
|
||||
render={({ field }) => (
|
||||
<FormItem className="-mt-2 ml-px flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>{t("login.twoFactor.trustDevice")}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("verify")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const BackupCodeCta = memo<CtaProps>(({ onFactorChange }) => {
|
||||
const { t } = useTranslation(["auth"]);
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<span
|
||||
role="link"
|
||||
onClick={() => onFactorChange(SecondFactor.BACKUP_CODE)}
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer pl-2 text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("login.twoFactor.backupCode.cta")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { BackupCodeForm, BackupCodeCta };
|
||||
28
apps/web/src/modules/auth/form/two-factor/index.tsx
Normal file
28
apps/web/src/modules/auth/form/two-factor/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { SecondFactor } from "@turbostarter/auth";
|
||||
|
||||
import { BackupCodeForm, BackupCodeCta } from "./backup-code";
|
||||
import { TotpForm, TotpCta } from "./totp";
|
||||
|
||||
export interface FormProps {
|
||||
readonly redirectTo?: string;
|
||||
}
|
||||
|
||||
export interface CtaProps {
|
||||
readonly onFactorChange: (factor: SecondFactor) => void;
|
||||
}
|
||||
|
||||
const TwoFactorForm: Record<
|
||||
SecondFactor,
|
||||
(props: FormProps) => React.ReactNode
|
||||
> = {
|
||||
[SecondFactor.TOTP]: TotpForm,
|
||||
[SecondFactor.BACKUP_CODE]: BackupCodeForm,
|
||||
};
|
||||
|
||||
const TwoFactorCta: Record<SecondFactor, (props: CtaProps) => React.ReactNode> =
|
||||
{
|
||||
[SecondFactor.TOTP]: TotpCta,
|
||||
[SecondFactor.BACKUP_CODE]: BackupCodeCta,
|
||||
};
|
||||
|
||||
export { TwoFactorForm, TwoFactorCta };
|
||||
137
apps/web/src/modules/auth/form/two-factor/totp.tsx
Normal file
137
apps/web/src/modules/auth/form/two-factor/totp.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { otpVerificationSchema, SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Checkbox } from "@turbostarter/ui-web/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@turbostarter/ui-web/input-otp";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { CtaProps, FormProps } from ".";
|
||||
|
||||
const TotpForm = memo<FormProps>(
|
||||
({ redirectTo = pathsConfig.dashboard.user.index }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(otpVerificationSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
trustDevice: false,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTotp = useMutation({
|
||||
...auth.mutations.twoFactor.totp.verify,
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => verifyTotp.mutateAsync(data))}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
disabled={form.formState.isSubmitting}
|
||||
onComplete={form.handleSubmit((data) =>
|
||||
verifyTotp.mutateAsync(data),
|
||||
)}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<InputOTPSlot key={index} index={index} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trustDevice"
|
||||
render={({ field }) => (
|
||||
<FormItem className="-mt-2 ml-px flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>{t("login.twoFactor.trustDevice")}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("verify")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const TotpCta = memo<CtaProps>(({ onFactorChange }) => {
|
||||
const { t } = useTranslation(["auth"]);
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<span
|
||||
role="link"
|
||||
onClick={() => onFactorChange(SecondFactor.TOTP)}
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer pl-2 text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("login.twoFactor.totp.cta")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { TotpForm, TotpCta };
|
||||
20
apps/web/src/modules/auth/layout/divider.tsx
Normal file
20
apps/web/src/modules/auth/layout/divider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
export const AuthDivider = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="border-input w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-background text-muted-foreground px-2 leading-tight">
|
||||
{t("divider")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
17
apps/web/src/modules/auth/layout/header.tsx
Normal file
17
apps/web/src/modules/auth/layout/header.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React, { memo } from "react";
|
||||
|
||||
interface AuthHeaderProps {
|
||||
readonly title: React.ReactNode;
|
||||
readonly description: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AuthHeader = memo<AuthHeaderProps>(({ title, description }) => {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tighter">{title}</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AuthHeader.displayName = "AuthHeader";
|
||||
23
apps/web/src/modules/auth/layout/invitation-disclaimer.tsx
Normal file
23
apps/web/src/modules/auth/layout/invitation-disclaimer.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@turbostarter/ui-web/alert";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
export const InvitationDisclaimer = () => {
|
||||
const { t } = useTranslation("organization");
|
||||
|
||||
return (
|
||||
<Alert variant="primary">
|
||||
<Icons.MailPlus />
|
||||
<AlertTitle>{t("invitations.disclaimer.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("invitations.disclaimer.description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
207
apps/web/src/modules/auth/lib/api.ts
Normal file
207
apps/web/src/modules/auth/lib/api.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
const KEY = "auth";
|
||||
|
||||
const queries = {
|
||||
sessions: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "sessions"],
|
||||
queryFn: () =>
|
||||
authClient.listSessions({
|
||||
fetchOptions: {
|
||||
throw: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "accounts"],
|
||||
queryFn: () => authClient.listAccounts({ fetchOptions: { throw: true } }),
|
||||
},
|
||||
},
|
||||
passkeys: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "passkeys"],
|
||||
queryFn: () =>
|
||||
authClient.passkey.listUserPasskeys({ fetchOptions: { throw: true } }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
signIn: {
|
||||
email: {
|
||||
mutationKey: [KEY, "signIn", "email"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signIn.email>[0]) =>
|
||||
authClient.signIn.email(params),
|
||||
},
|
||||
magicLink: {
|
||||
mutationKey: [KEY, "signIn", "magicLink"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signIn.magicLink>[0]) =>
|
||||
authClient.signIn.magicLink(params),
|
||||
},
|
||||
anonymous: {
|
||||
mutationKey: [KEY, "signIn", "anonymous"],
|
||||
mutationFn: (
|
||||
params?: Parameters<typeof authClient.signIn.anonymous>[0],
|
||||
) => authClient.signIn.anonymous(params),
|
||||
},
|
||||
social: {
|
||||
mutationKey: [KEY, "signIn", "social"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signIn.social>[0]) =>
|
||||
authClient.signIn.social(params),
|
||||
},
|
||||
passkey: {
|
||||
mutationKey: [KEY, "signIn", "passkey"],
|
||||
mutationFn: (params?: Parameters<typeof authClient.signIn.passkey>[0]) =>
|
||||
authClient.signIn.passkey(params),
|
||||
},
|
||||
},
|
||||
password: {
|
||||
forget: {
|
||||
mutationKey: [KEY, "password", "forget"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.requestPasswordReset>[0],
|
||||
) => authClient.requestPasswordReset(params),
|
||||
},
|
||||
reset: {
|
||||
mutationKey: [KEY, "password", "update"],
|
||||
mutationFn: (params: Parameters<typeof authClient.resetPassword>[0]) =>
|
||||
authClient.resetPassword(params),
|
||||
},
|
||||
change: {
|
||||
mutationKey: [KEY, "password", "change"],
|
||||
mutationFn: (params: Parameters<typeof authClient.changePassword>[0]) =>
|
||||
authClient.changePassword(params),
|
||||
},
|
||||
},
|
||||
signOut: {
|
||||
mutationKey: [KEY, "signOut"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signOut>[0]) =>
|
||||
authClient.signOut(params),
|
||||
},
|
||||
signUp: {
|
||||
email: {
|
||||
mutationKey: [KEY, "signUp", "email"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signUp.email>[0]) =>
|
||||
authClient.signUp.email(params),
|
||||
},
|
||||
},
|
||||
twoFactor: {
|
||||
enable: {
|
||||
mutationKey: [KEY, "twoFactor", "enable"],
|
||||
mutationFn: (params: Parameters<typeof authClient.twoFactor.enable>[0]) =>
|
||||
authClient.twoFactor.enable({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
disable: {
|
||||
mutationKey: [KEY, "twoFactor", "disable"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.disable>[0],
|
||||
) => authClient.twoFactor.disable(params),
|
||||
},
|
||||
backupCodes: {
|
||||
generate: {
|
||||
mutationKey: [KEY, "twoFactor", "backupCodes", "generate"],
|
||||
mutationFn: (
|
||||
params: Parameters<
|
||||
typeof authClient.twoFactor.generateBackupCodes
|
||||
>[0],
|
||||
) =>
|
||||
authClient.twoFactor.generateBackupCodes({
|
||||
...params,
|
||||
fetchOptions: {
|
||||
throw: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
verify: {
|
||||
mutationKey: [KEY, "twoFactor", "backupCodes", "verify"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.verifyBackupCode>[0],
|
||||
) => authClient.twoFactor.verifyBackupCode(params),
|
||||
},
|
||||
},
|
||||
totp: {
|
||||
getUri: {
|
||||
mutationKey: [KEY, "twoFactor", "totp", "getUri"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.getTotpUri>[0],
|
||||
) =>
|
||||
authClient.twoFactor.getTotpUri({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
verify: {
|
||||
mutationKey: [KEY, "twoFactor", "totp", "verify"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.verifyTotp>[0],
|
||||
) => authClient.twoFactor.verifyTotp(params),
|
||||
},
|
||||
},
|
||||
},
|
||||
email: {
|
||||
sendVerification: {
|
||||
mutationKey: [KEY, "email", "sendVerification"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.sendVerificationEmail>[0],
|
||||
) => authClient.sendVerificationEmail(params),
|
||||
},
|
||||
change: {
|
||||
mutationKey: [KEY, "email", "change"],
|
||||
mutationFn: (params: Parameters<typeof authClient.changeEmail>[0]) =>
|
||||
authClient.changeEmail(params),
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
revoke: {
|
||||
mutationKey: [KEY, "sessions", "revoke"],
|
||||
mutationFn: (token: string) => authClient.revokeSession({ token }),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
connect: {
|
||||
mutationKey: [KEY, "accounts", "connect"],
|
||||
mutationFn: (params: Parameters<typeof authClient.linkSocial>[0]) =>
|
||||
authClient.linkSocial(params),
|
||||
},
|
||||
disconnect: {
|
||||
mutationKey: [KEY, "accounts", "disconnect"],
|
||||
mutationFn: (params: Parameters<typeof authClient.unlinkAccount>[0]) =>
|
||||
authClient.unlinkAccount(params),
|
||||
},
|
||||
},
|
||||
passkeys: {
|
||||
add: {
|
||||
mutationKey: [KEY, "passkeys", "add"],
|
||||
mutationFn: async () => {
|
||||
const response = await authClient.passkey.addPasskey();
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
mutationKey: [KEY, "passkeys", "delete"],
|
||||
mutationFn: async (
|
||||
params: Parameters<typeof authClient.passkey.deletePasskey>[0],
|
||||
) => {
|
||||
const response = await authClient.passkey.deletePasskey(params);
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const auth = {
|
||||
queries,
|
||||
mutations,
|
||||
};
|
||||
134
apps/web/src/modules/auth/login.tsx
Normal file
134
apps/web/src/modules/auth/login.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import { SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { authConfig } from "~/config/auth";
|
||||
|
||||
import { AnonymousLogin } from "./form/anonymous";
|
||||
import { LOGIN_OPTIONS } from "./form/login/constants";
|
||||
import { LoginForm } from "./form/login/form";
|
||||
import { PasskeyLogin } from "./form/login/passkey";
|
||||
import { RegisterCta } from "./form/register-form";
|
||||
import { SocialProviders } from "./form/social-providers";
|
||||
import { TwoFactorForm } from "./form/two-factor";
|
||||
import { TwoFactorCta } from "./form/two-factor";
|
||||
import { AuthDivider } from "./layout/divider";
|
||||
import { AuthHeader } from "./layout/header";
|
||||
import { InvitationDisclaimer } from "./layout/invitation-disclaimer";
|
||||
|
||||
import type { LoginOption } from "./form/login/constants";
|
||||
|
||||
const LoginStep = {
|
||||
FORM: "form",
|
||||
TWO_FACTOR: "twoFactor",
|
||||
} as const;
|
||||
|
||||
type LoginStep = (typeof LoginStep)[keyof typeof LoginStep];
|
||||
|
||||
interface LoginFlowProps {
|
||||
readonly redirectTo?: string;
|
||||
readonly invitationId?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const LoginFlow = ({
|
||||
redirectTo,
|
||||
invitationId,
|
||||
email,
|
||||
}: LoginFlowProps) => {
|
||||
const [step, setStep] = useState<LoginStep>(LoginStep.FORM);
|
||||
|
||||
switch (step) {
|
||||
case LoginStep.FORM:
|
||||
return (
|
||||
<Login
|
||||
redirectTo={redirectTo}
|
||||
invitationId={invitationId}
|
||||
email={email}
|
||||
onTwoFactorRedirect={() => setStep(LoginStep.TWO_FACTOR)}
|
||||
/>
|
||||
);
|
||||
case LoginStep.TWO_FACTOR:
|
||||
return <TwoFactor redirectTo={redirectTo} />;
|
||||
}
|
||||
};
|
||||
|
||||
interface LoginProps extends LoginFlowProps {
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
const Login = memo<LoginProps>(
|
||||
({ redirectTo, invitationId, email, onTwoFactorRedirect }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
const options = Object.entries(authConfig.providers)
|
||||
.filter(
|
||||
([provider, enabled]) =>
|
||||
enabled && LOGIN_OPTIONS.includes(provider as LoginOption),
|
||||
)
|
||||
.map(([provider]) => provider as LoginOption);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t("login.header.title")}
|
||||
description={t("login.header.description")}
|
||||
/>
|
||||
|
||||
{invitationId && <InvitationDisclaimer />}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SocialProviders
|
||||
providers={authConfig.providers.oAuth}
|
||||
redirectTo={redirectTo}
|
||||
/>
|
||||
{authConfig.providers.passkey && (
|
||||
<PasskeyLogin redirectTo={redirectTo} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(authConfig.providers.oAuth.length > 0 ||
|
||||
authConfig.providers.passkey) &&
|
||||
options.length > 0 && <AuthDivider />}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<LoginForm
|
||||
options={options}
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
/>
|
||||
|
||||
{authConfig.providers.anonymous && <AnonymousLogin />}
|
||||
</div>
|
||||
|
||||
<RegisterCta />
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const TwoFactor = memo<LoginFlowProps>(({ redirectTo }) => {
|
||||
const [factor, setFactor] = useState<SecondFactor>(SecondFactor.TOTP);
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
const Form = TwoFactorForm[factor];
|
||||
const Cta =
|
||||
factor === SecondFactor.TOTP
|
||||
? TwoFactorCta[SecondFactor.BACKUP_CODE]
|
||||
: TwoFactorCta[SecondFactor.TOTP];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t(`login.twoFactor.${factor}.header.title`)}
|
||||
description={t(`login.twoFactor.${factor}.header.description`)}
|
||||
/>
|
||||
|
||||
<Form redirectTo={redirectTo} />
|
||||
<Cta onFactorChange={setFactor} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user