feat: turbostarter boilerplate

Production-ready Next.js boilerplate with:
- Runtime env validation (fail-fast on missing vars)
- Feature-gated config (S3, Stripe, email, OAuth)
- Docker + Coolify deployment pipeline
- PostgreSQL + pgvector, MinIO S3, Better Auth
- TypeScript strict mode (no ignoreBuildErrors)
- i18n (en/es), AI modules, billing, monitoring

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-02 17:29:12 +00:00
commit 3527e732d4
1618 changed files with 338230 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { AuthProvider } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { auth } from "../lib/api";
import { useAuthFormStore } from "./store";
import type { Route } from "expo-router";
interface AnonymousLoginProps {
readonly redirectTo?: Route;
}
export const AnonymousLogin = ({
redirectTo = pathsConfig.index,
}: AnonymousLoginProps) => {
const { t } = useTranslation(["auth", "common"]);
const { provider, setProvider, isSubmitting, setIsSubmitting } =
useAuthFormStore();
const signIn = useMutation({
...auth.mutations.signIn.anonymous,
onMutate: () => {
setProvider(AuthProvider.ANONYMOUS);
setIsSubmitting(true);
},
onSuccess: () => {
router.navigate(redirectTo);
},
onSettled: () => {
setIsSubmitting(false);
},
});
return (
<Button
variant="outline"
className="flex-row gap-2"
size="lg"
disabled={isSubmitting}
onPress={() => signIn.mutate(undefined)}
>
{isSubmitting && provider === AuthProvider.ANONYMOUS ? (
<Spin>
<Icons.Loader2 className="text-foreground size-5" />
</Spin>
) : (
<>
<Icons.UserRound className="text-foreground" size={16} />
<Text>{t("login.anonymous.cta")}</Text>
</>
)}
</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,138 @@
import { useLocalSearchParams } from "expo-router";
import { Suspense, useState } from "react";
import { View } from "react-native";
import { AuthProvider } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Badge } from "@turbostarter/ui-mobile/badge";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@turbostarter/ui-mobile/tabs";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth";
import { Link } from "~/modules/common/styled";
import { MagicLinkLoginForm } from "./magic-link";
import { PasswordLoginForm } from "./password";
import type { LoginOption } from "./constants";
import type { Route } from "expo-router";
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?: Route;
readonly email?: string;
readonly onTwoFactorRedirect?: () => void;
}
export const LoginForm = ({
options,
redirectTo,
email,
onTwoFactorRedirect,
}: LoginFormProps) => {
const { t } = useTranslation(["auth", "common"]);
const [mainOption] = options;
const [value, setValue] = useState(mainOption);
if (!options.length || !value) {
return null;
}
if (options.length === 1) {
const Component = LOGIN_OPTIONS_DETAILS[value].component;
return (
<Component
onTwoFactorRedirect={onTwoFactorRedirect}
redirectTo={redirectTo}
email={email}
/>
);
}
return (
<Tabs
value={value}
onValueChange={(val) => setValue(val as LoginOption)}
className="flex w-full flex-col items-center justify-center gap-6"
>
<TabsList className="w-full flex-row">
{options.map((provider) => (
<TabsTrigger
key={provider}
value={provider}
className="relative grow"
>
<Text>{t(LOGIN_OPTIONS_DETAILS[provider].label)}</Text>
{authClient.isLastUsedLoginMethod(
LOGIN_OPTIONS_DETAILS[provider].lastUsedMethodId,
) && (
<Badge className="absolute -top-3 -right-4 shadow-sm">
<Text>{t("lastUsed")}</Text>
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
{options.map((provider) => {
const Component = LOGIN_OPTIONS_DETAILS[provider].component;
return (
<TabsContent key={provider} value={provider} className="w-full">
<Suspense>
<Component
onTwoFactorRedirect={onTwoFactorRedirect}
redirectTo={redirectTo}
email={email}
/>
</Suspense>
</TabsContent>
);
})}
</Tabs>
);
};
export const LoginCta = () => {
const { t } = useTranslation("auth");
const localParams = useLocalSearchParams();
const searchParams = new URLSearchParams(
localParams as Record<string, string>,
);
return (
<View className="items-center justify-center pt-2">
<View className="flex-row">
<Text className="text-muted-foreground text-sm">
{t("register.alreadyHaveAccount")}
</Text>
<Link
href={`${pathsConfig.setup.auth.login}?${searchParams.toString()}`}
className="text-muted-foreground hover:text-primary pl-2 font-sans text-sm underline"
>
{t("login.cta")}
</Link>
</View>
</View>
);
};

View File

@@ -0,0 +1,106 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { memo } from "react";
import { useForm } from "react-hook-form";
import { Alert, View } from "react-native";
import { AuthProvider } from "@turbostarter/auth";
import { magicLinkLoginSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormField,
FormInput,
FormItem,
} from "@turbostarter/ui-mobile/form";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { useAuthFormStore } from "~/modules/auth/form/store";
import { auth } from "../../lib/api";
import type { Route } from "expo-router";
interface MagicLinkLoginFormProps {
readonly redirectTo?: Route;
readonly email?: string;
}
export const MagicLinkLoginForm = memo<MagicLinkLoginFormProps>(
({ redirectTo = pathsConfig.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);
},
onSuccess: () => {
Alert.alert(
t("login.magicLink.success.title"),
t("login.magicLink.success.description"),
);
form.reset();
},
});
return (
<Form {...form}>
<View className="gap-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormInput
label={t("email")}
autoCapitalize="none"
autoComplete="email"
editable={!isSubmitting}
{...field}
/>
</FormItem>
)}
/>
<Button
className="w-full"
size="lg"
onPress={form.handleSubmit((data) =>
signIn.mutateAsync({
...data,
callbackURL: redirectTo,
}),
)}
disabled={isSubmitting}
>
{isSubmitting && provider === AuthProvider.MAGIC_LINK ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground size-5" />
</Spin>
) : (
<Text>{t("login.magicLink.cta")}</Text>
)}
</Button>
</View>
</Form>
);
},
);

View File

@@ -0,0 +1,149 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { memo } from "react";
import { useForm } from "react-hook-form";
import { View } from "react-native";
import { AuthProvider } from "@turbostarter/auth";
import { passwordLoginSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormCheckbox,
FormField,
FormInput,
FormItem,
FormLabel,
} from "@turbostarter/ui-mobile/form";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { useAuthFormStore } from "~/modules/auth/form/store";
import { Link } from "~/modules/common/styled";
import { auth } from "../../lib/api";
import type { Route } from "expo-router";
interface PasswordLoginFormProps {
readonly redirectTo?: Route;
readonly email?: string;
readonly onTwoFactorRedirect?: () => void;
}
export const PasswordLoginForm = memo<PasswordLoginFormProps>(
({ redirectTo = pathsConfig.index, email, onTwoFactorRedirect }) => {
const { t } = useTranslation(["common", "auth"]);
const { provider, setProvider, isSubmitting, setIsSubmitting } =
useAuthFormStore();
const form = useForm({
resolver: standardSchemaResolver(passwordLoginSchema),
defaultValues: {
rememberMe: true,
email,
},
});
const signIn = useMutation({
...auth.mutations.signIn.email,
onMutate: () => {
setProvider(AuthProvider.PASSWORD);
setIsSubmitting(true);
},
onSettled: () => {
setIsSubmitting(false);
},
onSuccess: (ctx) => {
if ("twoFactorRedirect" in ctx) {
return onTwoFactorRedirect?.();
}
router.navigate(redirectTo);
},
});
return (
<Form {...form}>
<View className="gap-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormInput
label={t("email")}
autoCapitalize="none"
autoComplete="email"
editable={!isSubmitting}
{...field}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<View className="flex-row items-center justify-between">
<FormLabel nativeID="password">{t("password")}</FormLabel>
<Link
href={pathsConfig.setup.auth.forgotPassword}
className="text-muted-foreground self-end font-sans text-sm underline underline-offset-4"
>
{t("account.password.forgot.label")}
</Link>
</View>
<FormInput
secureTextEntry
autoComplete="password"
editable={!isSubmitting}
{...field}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="rememberMe"
render={({ field }) => (
<FormCheckbox
name="rememberMe"
label={t("rememberMe")}
disabled={isSubmitting}
value={!!field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
<Button
className="w-full"
size="lg"
onPress={form.handleSubmit((data) => signIn.mutate(data))}
disabled={isSubmitting}
>
{isSubmitting && provider === AuthProvider.PASSWORD ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground" />
</Spin>
) : (
<Text>{t("login.cta")}</Text>
)}
</Button>
</View>
</Form>
);
},
);
PasswordLoginForm.displayName = "PasswordLoginForm";

View File

@@ -0,0 +1,96 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import { useForm } from "react-hook-form";
import { Alert, View } from "react-native";
import { forgotPasswordSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormField,
FormInput,
FormItem,
} from "@turbostarter/ui-mobile/form";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { Link } from "~/modules/common/styled";
import { auth } from "../../lib/api";
export const ForgotPasswordForm = () => {
const { t } = useTranslation(["common", "auth"]);
const form = useForm({
resolver: standardSchemaResolver(forgotPasswordSchema),
});
const forgetPassword = useMutation({
...auth.mutations.password.forget,
onSuccess: () => {
Alert.alert(
t("account.password.forgot.success.title"),
t("account.password.forgot.success.description"),
);
form.reset();
},
});
return (
<Form {...form}>
<View className="gap-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormInput
label={t("email")}
autoCapitalize="none"
autoComplete="email"
editable={!form.formState.isSubmitting}
{...field}
/>
</FormItem>
)}
/>
<Button
className="w-full"
size="lg"
onPress={form.handleSubmit((data) =>
forgetPassword.mutateAsync({
...data,
redirectTo: Linking.createURL(
pathsConfig.setup.auth.updatePassword,
),
}),
)}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground size-5" />
</Spin>
) : (
<Text>{t("account.password.forgot.cta")}</Text>
)}
</Button>
<View className="items-center justify-center pt-2">
<Link
replace
href={pathsConfig.setup.auth.login}
className="text-muted-foreground active:text-primary pl-2 font-sans text-sm underline"
>
{t("account.password.forgot.back")}
</Link>
</View>
</View>
</Form>
);
};

View File

@@ -0,0 +1,91 @@
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { memo } from "react";
import { useForm } from "react-hook-form";
import { View } from "react-native";
import { updatePasswordSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormField,
FormInput,
FormItem,
} from "@turbostarter/ui-mobile/form";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { auth } from "../../lib/api";
interface UpdatePasswordFormProps {
readonly token?: string;
}
export const UpdatePasswordForm = memo<UpdatePasswordFormProps>(({ token }) => {
const { t } = useTranslation(["common", "auth"]);
const form = useForm({
resolver: standardSchemaResolver(updatePasswordSchema),
});
const resetPassword = useMutation({
...auth.mutations.password.reset,
onSuccess: () => {
router.setParams({
token: undefined,
});
router.replace(pathsConfig.setup.auth.login);
},
});
return (
<Form {...form}>
<View className="gap-6">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormInput
label={t("password")}
secureTextEntry
autoComplete="new-password"
editable={!form.formState.isSubmitting}
{...field}
/>
</FormItem>
)}
/>
<Button
className="w-full"
size="lg"
onPress={form.handleSubmit((data) =>
resetPassword.mutateAsync({
newPassword: data.password,
token,
}),
)}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground" />
</Spin>
) : (
<Text>{t("account.password.update.cta")}</Text>
)}
</Button>
</View>
</Form>
);
});
UpdatePasswordForm.displayName = "UpdatePasswordForm";

View File

@@ -0,0 +1,157 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useForm } from "react-hook-form";
import { Alert, View } from "react-native";
import { AuthProvider, generateName, registerSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormField,
FormItem,
FormInput,
} from "@turbostarter/ui-mobile/form";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { Link } from "~/modules/common/styled";
import { auth } from "../lib/api";
import { useAuthFormStore } from "./store";
import type { Route } from "expo-router";
interface RegisterFormProps {
readonly redirectTo?: Route;
readonly email?: string;
}
export const RegisterForm = ({
redirectTo = pathsConfig.index,
email,
}: RegisterFormProps) => {
const { t } = useTranslation(["common", "auth"]);
const { provider, setProvider, isSubmitting, setIsSubmitting } =
useAuthFormStore();
const form = useForm({
resolver: standardSchemaResolver(registerSchema),
defaultValues: {
email,
},
});
const signUp = useMutation({
...auth.mutations.signUp.email,
onMutate: () => {
setProvider(AuthProvider.PASSWORD);
setIsSubmitting(true);
},
onSettled: () => {
setIsSubmitting(false);
},
onSuccess: () => {
Alert.alert(
t("register.success.title"),
t("register.success.description"),
[
{
text: t("continue"),
onPress: () => {
router.navigate(pathsConfig.setup.auth.login);
form.reset();
},
},
],
);
},
});
return (
<Form {...form}>
<View className="gap-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormInput
label={t("email")}
autoCapitalize="none"
autoComplete="email"
editable={!isSubmitting}
{...field}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormInput
label={t("password")}
secureTextEntry
autoComplete="password"
editable={!isSubmitting}
{...field}
/>
</FormItem>
)}
/>
<Button
className="w-full"
size="lg"
onPress={form.handleSubmit((data) =>
signUp.mutateAsync({
...data,
name: generateName(data.email),
callbackURL: redirectTo,
}),
)}
disabled={isSubmitting}
>
{isSubmitting && provider === AuthProvider.PASSWORD ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground size-5" />
</Spin>
) : (
<Text>{t("register.cta")}</Text>
)}
</Button>
</View>
</Form>
);
};
export const RegisterCta = () => {
const { t } = useTranslation("auth");
const localParams = useLocalSearchParams();
const searchParams = new URLSearchParams(
localParams as Record<string, string>,
);
return (
<View className="items-center justify-center pt-2">
<View className="flex-row">
<Text className="text-muted-foreground text-sm">
{t("login.noAccount")}
</Text>
<Link
href={`${pathsConfig.setup.auth.register}?${searchParams.toString()}`}
className="text-muted-foreground active:text-primary pl-2 font-sans text-sm underline"
>
{t("register.cta")}
</Link>
</View>
</View>
);
};

View File

@@ -0,0 +1,206 @@
import {
GoogleSignin,
isCancelledResponse,
isSuccessResponse,
} from "@react-native-google-signin/google-signin";
import { useMutation } from "@tanstack/react-query";
import env from "env.config";
import * as AppleAuthentication from "expo-apple-authentication";
import { router } from "expo-router";
import { memo } from "react";
import { View } from "react-native";
import { SocialProvider as SocialProviderType } from "@turbostarter/auth";
import { Trans, useTranslation } from "@turbostarter/i18n";
import { Badge } from "@turbostarter/ui-mobile/badge";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { authConfig } from "~/config/auth";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth";
import { useAuthFormStore } from "~/modules/auth/form/store";
import { isAndroid, isIOS } from "~/utils/device";
import { auth } from "../lib/api";
import type { AuthProvider } from "@turbostarter/auth";
import type { Icon } from "@turbostarter/ui-mobile/icons";
import type { Route } from "expo-router";
interface SocialProvidersProps {
readonly providers: SocialProviderType[];
readonly redirectTo?: Route;
}
export const SocialIcons: Record<SocialProviderType, Icon> = {
[SocialProviderType.GITHUB]: Icons.Github,
[SocialProviderType.GOOGLE]: Icons.Google,
[SocialProviderType.APPLE]: Icons.Apple,
};
if (
authConfig.providers.oAuth.includes(SocialProviderType.GOOGLE) &&
isAndroid
) {
GoogleSignin.configure({
webClientId: env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
});
}
const SocialProvider = ({
provider,
onClick,
actualProvider,
isSubmitting,
}: {
provider: SocialProviderType;
isSubmitting: boolean;
onClick: () => void;
actualProvider: AuthProvider;
}) => {
const { t } = useTranslation("common");
const Icon = SocialIcons[provider];
return (
<Button
key={provider}
variant="outline"
size="lg"
className="relative w-full flex-row justify-center gap-2.5"
onPress={onClick}
disabled={isSubmitting}
>
{isSubmitting && actualProvider === provider ? (
<Spin>
<Icons.Loader2 className="text-foreground size-5" />
</Spin>
) : (
<>
<View className="size-5">
<Icon className="text-foreground" />
</View>
<Text>
<Trans
ns="auth"
i18nKey="login.social"
values={{ provider }}
components={{
capitalize: <Text className="capitalize" />,
}}
/>
</Text>
</>
)}
{authClient.isLastUsedLoginMethod(provider) && (
<Badge className="absolute -top-2 -right-3 shadow-sm">
<Text>{t("lastUsed")}</Text>
</Badge>
)}
</Button>
);
};
export const SocialProviders = memo<SocialProvidersProps>(
({ providers, redirectTo = pathsConfig.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);
},
onSuccess: async () => {
const session = await authClient.getSession({
fetchOptions: { throw: true },
});
if (session?.session) {
router.navigate(redirectTo);
}
},
});
const getParams = async (provider: SocialProviderType) => {
const shared = {
provider,
callbackURL: redirectTo,
errorCallbackURL: pathsConfig.setup.auth.error,
};
if (provider === SocialProviderType.APPLE && isIOS) {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
return {
...shared,
...(credential.identityToken
? { idToken: { token: credential.identityToken } }
: {}),
};
}
if (provider === SocialProviderType.GOOGLE && isAndroid) {
await GoogleSignin.hasPlayServices();
const response = await GoogleSignin.signIn();
if (isCancelledResponse(response)) {
return null;
}
const tokens = await GoogleSignin.getTokens();
return {
...shared,
...(isSuccessResponse(response)
? {
idToken: {
token: tokens.idToken,
accessToken: tokens.accessToken,
},
}
: {}),
};
}
return shared;
};
return (
<View className="flex w-full items-stretch justify-center gap-2">
{Object.values(providers).map((provider) => (
<SocialProvider
key={provider}
provider={provider}
onClick={async () => {
const params = await getParams(provider);
if (params) {
await signIn.mutateAsync(params);
}
}}
actualProvider={actualProvider}
isSubmitting={isSubmitting}
/>
))}
</View>
);
},
);
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,114 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { memo } from "react";
import { useForm } from "react-hook-form";
import { View } from "react-native";
import { backupCodeVerificationSchema, SecondFactor } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormCheckbox,
FormField,
FormInput,
FormItem,
} from "@turbostarter/ui-mobile/form";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { auth } from "../../lib/api";
import type { CtaProps, FormProps } from ".";
const BackupCodeForm = memo<FormProps>(({ redirectTo = pathsConfig.index }) => {
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}>
<View className="flex flex-col gap-6">
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormInput
autoFocus
placeholder={t("login.twoFactor.backupCode.placeholder")}
autoCapitalize="none"
autoComplete="one-time-code"
editable={!form.formState.isSubmitting}
{...field}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="trustDevice"
render={({ field }) => (
<FormCheckbox
name="trustDevice"
label={t("login.twoFactor.trustDevice")}
value={field.value ?? false}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
<Button
className="w-full"
size="lg"
disabled={form.formState.isSubmitting}
onPress={form.handleSubmit((data) =>
verifyBackupCode.mutateAsync(data),
)}
>
{form.formState.isSubmitting ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground" />
</Spin>
) : (
<Text>{t("verify")}</Text>
)}
</Button>
</View>
</Form>
);
});
const BackupCodeCta = memo<CtaProps>(({ onFactorChange }) => {
const { t } = useTranslation("auth");
return (
<View className="flex items-center justify-center pt-2">
<Text
onPress={() => onFactorChange(SecondFactor.BACKUP_CODE)}
className="text-muted-foreground font-sans-medium cursor-pointer pl-2 text-sm underline underline-offset-4"
>
{t("login.twoFactor.backupCode.cta")}
</Text>
</View>
);
});
export { BackupCodeForm, BackupCodeCta };

View File

@@ -0,0 +1,30 @@
import { SecondFactor } from "@turbostarter/auth";
import { BackupCodeForm, BackupCodeCta } from "./backup-code";
import { TotpForm, TotpCta } from "./totp";
import type { Route } from "expo-router";
export interface FormProps {
readonly redirectTo?: Route;
}
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,132 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { memo } from "react";
import { useForm } from "react-hook-form";
import { View } from "react-native";
import { otpVerificationSchema, SecondFactor } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormCheckbox,
FormField,
FormItem,
FormMessage,
} from "@turbostarter/ui-mobile/form";
import { Icons } from "@turbostarter/ui-mobile/icons";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@turbostarter/ui-mobile/input-otp";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { auth } from "../../lib/api";
import type { CtaProps, FormProps } from ".";
const TotpForm = memo<FormProps>(({ redirectTo = pathsConfig.index }) => {
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}>
<View className="flex flex-col items-start gap-6">
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<InputOTP
maxLength={6}
autoFocus
value={field.value}
onChange={field.onChange}
onComplete={() =>
form.handleSubmit((data) => verifyTotp.mutateAsync(data))()
}
render={({ slots }) => (
<InputOTPGroup>
{slots.map((slot, index) => (
<InputOTPSlot
key={index}
index={index}
max={6}
{...slot}
/>
))}
</InputOTPGroup>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="trustDevice"
render={({ field }) => (
<FormCheckbox
name="trustDevice"
label={t("login.twoFactor.trustDevice")}
value={field.value ?? false}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
<Button
className="w-full"
size="lg"
disabled={form.formState.isSubmitting}
onPress={form.handleSubmit((data) => verifyTotp.mutateAsync(data))}
>
{form.formState.isSubmitting ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground" />
</Spin>
) : (
<Text>{t("verify")}</Text>
)}
</Button>
</View>
</Form>
);
});
const TotpCta = memo<CtaProps>(({ onFactorChange }) => {
const { t } = useTranslation("auth");
return (
<View className="flex items-center justify-center pt-2">
<Text
onPress={() => onFactorChange(SecondFactor.TOTP)}
className="text-muted-foreground font-sans-medium cursor-pointer pl-2 text-sm underline underline-offset-4"
>
{t("login.twoFactor.totp.cta")}
</Text>
</View>
);
});
export { TotpForm, TotpCta };

View File

@@ -0,0 +1,16 @@
import { ScrollView } from "~/modules/common/styled";
import { KeyboardAvoidingView } from "~/modules/common/styled";
export const AuthLayout = ({ children }: { children: React.ReactNode }) => {
return (
<KeyboardAvoidingView className="bg-background flex-1" behavior="padding">
<ScrollView
bounces={false}
showsVerticalScrollIndicator={false}
contentContainerClassName="gap-5 px-6 pt-4 pb-10"
>
{children}
</ScrollView>
</KeyboardAvoidingView>
);
};

View File

@@ -0,0 +1,21 @@
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Text } from "@turbostarter/ui-mobile/text";
export const AuthDivider = () => {
const { t } = useTranslation("auth");
return (
<View className="relative w-full">
<View className="absolute top-1/2 left-0 flex h-2 w-full items-center">
<View className="border-input w-full border-t" />
</View>
<View className="bg-background relative justify-center self-center">
<Text className="text-muted-foreground px-4 text-sm">
{t("divider")}
</Text>
</View>
</View>
);
};

View File

@@ -0,0 +1,20 @@
import { memo } from "react";
import { View } from "react-native";
import { Text } from "@turbostarter/ui-mobile/text";
interface AuthHeaderProps {
readonly title: React.ReactNode;
readonly description: React.ReactNode;
}
export const AuthHeader = memo<AuthHeaderProps>(({ title, description }) => {
return (
<View className="gap-1">
<Text className="font-sans-bold text-3xl tracking-tighter">{title}</Text>
<Text className="text-muted-foreground text-sm">{description}</Text>
</View>
);
});
AuthHeader.displayName = "AuthHeader";

View File

@@ -0,0 +1,22 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@turbostarter/ui-mobile/alert";
import { Icons } from "@turbostarter/ui-mobile/icons";
export const InvitationDisclaimer = () => {
const { t } = useTranslation("organization");
return (
<Alert icon={Icons.MailPlus} variant="primary">
<AlertTitle>{t("invitations.disclaimer.title")}</AlertTitle>
<AlertDescription>
{t("invitations.disclaimer.description")}
</AlertDescription>
</Alert>
);
};

View File

@@ -0,0 +1,187 @@
import { authClient } from "~/lib/auth";
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 } }),
},
},
};
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: async (
params: Parameters<typeof authClient.signIn.social>[0],
) => {
await authClient.signIn.social(params);
await authClient.getSession();
},
},
},
magicLink: {
verify: {
mutationKey: [KEY, "magicLink", "verify"],
mutationFn: (
query: Parameters<typeof authClient.magicLink.verify>[0]["query"],
) => authClient.magicLink.verify({ query }),
},
},
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),
},
verify: {
mutationKey: [KEY, "email", "confirm"],
mutationFn: (
query: Parameters<typeof authClient.verifyEmail>[0]["query"],
) => authClient.verifyEmail({ query }),
},
},
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),
},
},
sessions: {
revoke: {
mutationKey: [KEY, "sessions", "revoke"],
mutationFn: (token: string) => authClient.revokeSession({ token }),
},
},
};
export const auth = {
queries,
mutations,
};

View File

@@ -0,0 +1,130 @@
import { memo, useState } from "react";
import { View } from "react-native";
import { SecondFactor } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { authConfig } from "~/config/auth";
import { AnonymousLogin } from "~/modules/auth/form/anonymous";
import { LOGIN_OPTIONS } from "~/modules/auth/form/login/constants";
import { LoginForm } from "~/modules/auth/form/login/form";
import { RegisterCta } from "~/modules/auth/form/register-form";
import { SocialProviders } from "~/modules/auth/form/social-providers";
import { TwoFactorForm, TwoFactorCta } from "~/modules/auth/form/two-factor";
import { AuthLayout } from "~/modules/auth/layout/base";
import { AuthDivider } from "~/modules/auth/layout/divider";
import { AuthHeader } from "~/modules/auth/layout/header";
import { InvitationDisclaimer } from "~/modules/auth/layout/invitation-disclaimer";
import type { Route } from "expo-router";
import type { LoginOption } from "~/modules/auth/form/login/constants";
const LoginStep = {
FORM: "form",
TWO_FACTOR: "twoFactor",
} as const;
type LoginStep = (typeof LoginStep)[keyof typeof LoginStep];
interface LoginFlowProps {
readonly redirectTo?: Route;
readonly invitationId?: string;
readonly email?: string;
}
export const LoginFlow = ({
redirectTo,
invitationId,
email,
}: LoginFlowProps) => {
const [step, setStep] = useState<LoginStep>(LoginStep.FORM);
return (
<AuthLayout>
{(() => {
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} />;
}
})()}
</AuthLayout>
);
};
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 />}
<SocialProviders
providers={authConfig.providers.oAuth}
redirectTo={redirectTo}
/>
{authConfig.providers.oAuth.length > 0 && options.length > 0 && (
<AuthDivider />
)}
<View className="gap-2">
<LoginForm
options={options}
redirectTo={redirectTo}
email={email}
onTwoFactorRedirect={onTwoFactorRedirect}
/>
{authConfig.providers.anonymous && <AnonymousLogin />}
</View>
<RegisterCta />
</>
);
},
);
const TwoFactor = ({ redirectTo }: LoginFlowProps) => {
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} />
</>
);
};

View File

@@ -0,0 +1,136 @@
import { useMutation } from "@tanstack/react-query";
import { router, useGlobalSearchParams } from "expo-router";
import { useEffect } from "react";
import { VerificationType } from "@turbostarter/auth";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { pathsConfig } from "~/config/paths";
import { Spinner } from "~/modules/common/spinner";
import { user } from "~/modules/user/lib/api";
import { auth } from "./lib/api";
import type { Route } from "expo-router";
const useVerificationMutations = ({
onSuccess,
onError,
}: {
onSuccess?: () => void;
onError?: () => void;
}) => {
const { reset } = useSetupSteps();
const signOut = useMutation({
...auth.mutations.signOut,
onSuccess: () => {
reset();
},
});
return {
[VerificationType.MAGIC_LINK]: useMutation({
...auth.mutations.magicLink.verify,
onSuccess,
onError,
}),
[VerificationType.CONFIRM_EMAIL]: useMutation({
...auth.mutations.email.verify,
onSuccess,
onError,
}),
[VerificationType.DELETE_ACCOUNT]: useMutation({
...user.mutations.delete,
onSuccess: async () => {
await signOut.mutateAsync(undefined);
onSuccess?.();
},
onError,
}),
};
};
const VerificationController = ({
token,
type,
callbackURL,
redirectTo,
errorCallbackURL,
}: {
token: string;
type: VerificationType;
callbackURL?: Route;
redirectTo?: Route;
errorCallbackURL?: Route;
}) => {
const resetParams = () => {
router.setParams({
token: undefined,
type: undefined,
redirectTo: undefined,
callbackURL: undefined,
errorCallbackURL: undefined,
});
};
const mutations = useVerificationMutations({
onSuccess: () => {
router.navigate(redirectTo ?? callbackURL ?? pathsConfig.index);
resetParams();
},
...(errorCallbackURL
? {
onError: () => {
router.navigate(errorCallbackURL);
resetParams();
},
}
: {}),
});
const { mutate, isPending } = mutations[type];
useEffect(() => {
if (token && !isPending) {
mutate({
token,
});
}
}, [token, isPending, mutate, callbackURL, errorCallbackURL]);
if (isPending) {
return <Spinner />;
}
return null;
};
export const Verification = () => {
const {
token,
type,
callbackURL = pathsConfig.index,
redirectTo,
errorCallbackURL,
} = useGlobalSearchParams<{
token?: string;
type?: VerificationType;
callbackURL?: Route;
redirectTo?: Route;
errorCallbackURL?: Route;
}>();
if (!token || !type) {
return null;
}
return (
<VerificationController
token={token}
type={type}
callbackURL={callbackURL}
redirectTo={redirectTo}
errorCallbackURL={errorCallbackURL}
/>
);
};