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:
Alejandro Gutiérrez
2026-04-04 21:19:32 +01:00
commit d3163a5bff
1384 changed files with 314925 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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