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:
66
apps/mobile/src/modules/auth/form/anonymous.tsx
Normal file
66
apps/mobile/src/modules/auth/form/anonymous.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
5
apps/mobile/src/modules/auth/form/login/constants.ts
Normal file
5
apps/mobile/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];
|
||||
138
apps/mobile/src/modules/auth/form/login/form.tsx
Normal file
138
apps/mobile/src/modules/auth/form/login/form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
106
apps/mobile/src/modules/auth/form/login/magic-link.tsx
Normal file
106
apps/mobile/src/modules/auth/form/login/magic-link.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
149
apps/mobile/src/modules/auth/form/login/password.tsx
Normal file
149
apps/mobile/src/modules/auth/form/login/password.tsx
Normal 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";
|
||||
96
apps/mobile/src/modules/auth/form/password/forgot.tsx
Normal file
96
apps/mobile/src/modules/auth/form/password/forgot.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
91
apps/mobile/src/modules/auth/form/password/update.tsx
Normal file
91
apps/mobile/src/modules/auth/form/password/update.tsx
Normal 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";
|
||||
157
apps/mobile/src/modules/auth/form/register-form.tsx
Normal file
157
apps/mobile/src/modules/auth/form/register-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
206
apps/mobile/src/modules/auth/form/social-providers.tsx
Normal file
206
apps/mobile/src/modules/auth/form/social-providers.tsx
Normal 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";
|
||||
15
apps/mobile/src/modules/auth/form/store/index.ts
Normal file
15
apps/mobile/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 }),
|
||||
}));
|
||||
114
apps/mobile/src/modules/auth/form/two-factor/backup-code.tsx
Normal file
114
apps/mobile/src/modules/auth/form/two-factor/backup-code.tsx
Normal 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 };
|
||||
30
apps/mobile/src/modules/auth/form/two-factor/index.tsx
Normal file
30
apps/mobile/src/modules/auth/form/two-factor/index.tsx
Normal 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 };
|
||||
132
apps/mobile/src/modules/auth/form/two-factor/totp.tsx
Normal file
132
apps/mobile/src/modules/auth/form/two-factor/totp.tsx
Normal 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 };
|
||||
16
apps/mobile/src/modules/auth/layout/base.tsx
Normal file
16
apps/mobile/src/modules/auth/layout/base.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
apps/mobile/src/modules/auth/layout/divider.tsx
Normal file
21
apps/mobile/src/modules/auth/layout/divider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
apps/mobile/src/modules/auth/layout/header.tsx
Normal file
20
apps/mobile/src/modules/auth/layout/header.tsx
Normal 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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
187
apps/mobile/src/modules/auth/lib/api.ts
Normal file
187
apps/mobile/src/modules/auth/lib/api.ts
Normal 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,
|
||||
};
|
||||
130
apps/mobile/src/modules/auth/login.tsx
Normal file
130
apps/mobile/src/modules/auth/login.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
136
apps/mobile/src/modules/auth/verification.tsx
Normal file
136
apps/mobile/src/modules/auth/verification.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user