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

54
apps/mobile/.env.example Normal file
View File

@@ -0,0 +1,54 @@
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
# Keep this file up-to-date when you add new variables to \`.env\`.
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
# Env variables extracted from shared to be exposed to the client in Expo mobile app
EXPO_PUBLIC_SITE_URL="${URL}"
EXPO_PUBLIC_DEFAULT_LOCALE="${DEFAULT_LOCALE}"
# Theme mode and color
EXPO_PUBLIC_THEME_MODE="system"
EXPO_PUBLIC_THEME_COLOR="orange"
#########################
### Auth config ###
#########################
# Use this variable to enable or disable password-based authentication. If you set this to true, users will be able to sign up and sign in using their email and password. If you set this to false, the form won't be shown.
EXPO_PUBLIC_AUTH_PASSWORD="true"
# Use this variable to enable or disable magic link-based authentication. If you set this to true, users will be able to sign up and sign in using a magic link sent to their email. If you set this to false, the form won't be shown.
EXPO_PUBLIC_AUTH_MAGIC_LINK="false"
# Use this variable to enable or disable anonymous authentication. If you set this to true, users will be able to proceed to your app without "traditional" authentication. If you set this to false, the anonymous login won't be available.
EXPO_PUBLIC_AUTH_ANONYMOUS="true"
# Required for native "Sign in with Google" on Android, you can reuse the GOOGLE_CLIENT_ID from the web environment variables here
EXPO_PUBLIC_GOOGLE_CLIENT_ID="<your-google-client-id>"
#############################
### Monitoring config ###
#############################
# Sentry config - required only if you use Sentry as a monitoring provider
EXPO_PUBLIC_SENTRY_DSN="<your-sentry-dsn>"
EXPO_PUBLIC_SENTRY_ENVIRONMENT="development"
SENTRY_AUTH_TOKEN="<your-sentry-auth-token>" # required for source maps upload
# Posthog config - reused from analytics section below
############################
### Analytics config ###
############################
# Posthog config - required only if you use Posthog as an analytics provider
EXPO_PUBLIC_POSTHOG_KEY="<your-posthog-key>"
EXPO_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
# Mixpanel config - required only if you use Mixpanel as an analytics provider
EXPO_PUBLIC_MIXPANEL_TOKEN="<your-mixpanel-token>"

87
apps/mobile/app.config.ts Normal file
View File

@@ -0,0 +1,87 @@
import type { ConfigContext, ExpoConfig } from "expo/config";
const SPLASH = {
imageWidth: 150,
image: "./public/images/splash/splash.png",
dark: {
image: "./public/images/splash/splash.png",
backgroundColor: "#0D121C",
},
} as const;
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: "TurboStarter",
slug: "turbostarter",
scheme: "turbostarter",
version: "1.1.0",
orientation: "portrait",
icon: "./public/images/icon/ios.png",
userInterfaceStyle: "automatic",
assetBundlePatterns: ["**/*"],
platforms: ["ios", "android"],
updates: {
url: "https://u.expo.dev/a7958179-7450-4e6f-8791-da222215909e",
},
newArchEnabled: true,
ios: {
bundleIdentifier: "com.turbostarter.core",
supportsTablet: true,
usesAppleSignIn: true,
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
},
},
android: {
package: "com.turbostarter.core",
adaptiveIcon: {
monochromeImage: "./public/images/icon/android/monochrome.png",
foregroundImage: "./public/images/icon/android/adaptive.png",
backgroundColor: "#0D121C",
},
edgeToEdgeEnabled: true,
},
extra: {
eas: {
projectId: "a7958179-7450-4e6f-8791-da222215909e",
},
},
experiments: {
tsconfigPaths: true,
typedRoutes: true,
},
runtimeVersion: {
policy: "appVersion",
},
plugins: [
"expo-router",
"expo-font",
"expo-secure-store",
"expo-image-picker",
"expo-web-browser",
"expo-apple-authentication",
"@react-native-google-signin/google-signin",
/* required to enable i18n */
"expo-localization",
[
"expo-build-properties",
{
android: {
/* https://github.com/expo/expo/issues/15761 */
enableProguardInReleaseBuilds: true,
extraProguardRules: "-keep public class com.horcrux.svg.** {*;}",
allowBackup: false,
},
},
],
[
"expo-tracking-transparency",
{
/* 🍎 Describe why you need access to the user's data */
userTrackingPermission:
"This identifier will be used to deliver personalized ads to you.",
},
],
["expo-splash-screen", SPLASH],
],
});

View File

@@ -0,0 +1,8 @@
/** @type {import("@babel/core").ConfigFunction} */
module.exports = (api) => {
api.cache(true);
return {
presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]],
plugins: ["react-native-worklets/plugin"],
};
};

76
apps/mobile/eas.json Normal file
View File

@@ -0,0 +1,76 @@
{
"cli": {
"appVersionSource": "remote",
"version": ">= 4.1.2"
},
"build": {
"base": {
"node": "22.17.0",
"pnpm": "10.25.0",
"ios": {
"resourceClass": "m-medium"
},
"env": {
"EXPO_PUBLIC_DEFAULT_LOCALE": "en",
"EXPO_PUBLIC_AUTH_PASSWORD": "true",
"EXPO_PUBLIC_AUTH_MAGIC_LINK": "true",
"EXPO_PUBLIC_AUTH_ANONYMOUS": "true",
"EXPO_PUBLIC_THEME_MODE": "system",
"EXPO_PUBLIC_THEME_COLOR": "orange"
}
},
// Development profile used for local development
"development": {
"extends": "base",
"developmentClient": true,
"distribution": "internal",
"environment": "development",
"env": {
"APP_ENV": "development"
}
},
// Needed for iOS, as you can't reuse `development` profile there for simulator
"development-simulator": {
"extends": "base",
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
},
"environment": "development",
"env": {
"APP_ENV": "development"
}
},
// Preview profile used for EAS test builds in preview environment
"preview": {
"extends": "base",
"distribution": "internal",
"android": {
"buildType": "apk"
},
"channel": "preview",
"environment": "preview",
"env": {
"APP_ENV": "preview"
}
},
// Production profile used for EAS production builds to be submitted to the stores
"production": {
"extends": "base",
"autoIncrement": true,
"environment": "production",
"channel": "production",
"env": {
"APP_ENV": "production"
}
}
},
"submit": {
"production": {
"ios": {
"ascAppId": "6754278899"
}
}
}
}

55
apps/mobile/env.config.ts Normal file
View File

@@ -0,0 +1,55 @@
import { defineEnv } from "envin";
import * as z from "zod";
import { preset as analytics } from "@turbostarter/analytics-mobile/env";
import { preset as monitoring } from "@turbostarter/monitoring-mobile/env";
import { envConfig } from "@turbostarter/shared/constants";
import { ThemeColor, ThemeMode } from "@turbostarter/ui";
const castStringToBool = z.preprocess((val) => {
if (typeof val === "string") {
if (["1", "true"].includes(val.toLowerCase())) return true;
if (["0", "false"].includes(val.toLowerCase())) return false;
}
return val;
}, z.coerce.boolean());
export default defineEnv({
...envConfig,
extends: [analytics, monitoring],
clientPrefix: "EXPO_PUBLIC_",
client: {
EXPO_PUBLIC_AUTH_PASSWORD: castStringToBool.optional().default(true),
EXPO_PUBLIC_AUTH_MAGIC_LINK: castStringToBool.optional().default(false),
EXPO_PUBLIC_AUTH_ANONYMOUS: castStringToBool.optional().default(true),
EXPO_PUBLIC_GOOGLE_CLIENT_ID: z.string().optional().default(""),
EXPO_PUBLIC_SITE_URL: z.url(),
EXPO_PUBLIC_DEFAULT_LOCALE: z.string().optional().default("en"),
EXPO_PUBLIC_THEME_MODE: z
.enum(ThemeMode)
.optional()
.default(ThemeMode.SYSTEM),
EXPO_PUBLIC_THEME_COLOR: z
.enum(ThemeColor)
.optional()
.default(ThemeColor.ORANGE),
},
env: {
...process.env,
EXPO_PUBLIC_SITE_URL: process.env.EXPO_PUBLIC_SITE_URL,
EXPO_PUBLIC_DEFAULT_LOCALE: process.env.EXPO_PUBLIC_DEFAULT_LOCALE,
EXPO_PUBLIC_THEME_MODE: process.env.EXPO_PUBLIC_THEME_MODE,
EXPO_PUBLIC_THEME_COLOR: process.env.EXPO_PUBLIC_THEME_COLOR,
EXPO_PUBLIC_AUTH_PASSWORD: process.env.EXPO_PUBLIC_AUTH_PASSWORD,
EXPO_PUBLIC_AUTH_MAGIC_LINK: process.env.EXPO_PUBLIC_AUTH_MAGIC_LINK,
EXPO_PUBLIC_AUTH_ANONYMOUS: process.env.EXPO_PUBLIC_AUTH_ANONYMOUS,
EXPO_PUBLIC_GOOGLE_CLIENT_ID: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
EXPO_PUBLIC_POSTHOG_KEY: process.env.EXPO_PUBLIC_POSTHOG_KEY,
EXPO_PUBLIC_POSTHOG_HOST: process.env.EXPO_PUBLIC_POSTHOG_HOST,
EXPO_PUBLIC_SENTRY_DSN: process.env.EXPO_PUBLIC_SENTRY_DSN,
EXPO_PUBLIC_SENTRY_ENVIRONMENT: process.env.EXPO_PUBLIC_SENTRY_ENVIRONMENT,
},
});

View File

@@ -0,0 +1,11 @@
import baseConfig from "@turbostarter/eslint-config/base";
import reactConfig from "@turbostarter/eslint-config/react";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [".expo/**", "expo-plugins/**"],
},
...baseConfig,
...reactConfig,
];

View File

@@ -0,0 +1,67 @@
// Learn more: https://docs.expo.dev/guides/monorepos/
const { getDefaultConfig } = require("expo/metro-config");
const { FileStore } = require("metro-cache");
const { withUniwindConfig } = require("uniwind/metro");
const path = require("path");
const config = withTurborepoManagedCache(
withMonorepoPaths(
withUniwindConfig(getDefaultConfig(__dirname), {
cssEntryFile: "./src/assets/styles/globals.css",
}),
),
);
const { transformer, resolver } = config;
config.transformer = {
...transformer,
babelTransformerPath: require.resolve("react-native-svg-transformer/expo"),
};
config.resolver = {
...resolver,
assetExts: resolver.assetExts.filter((ext) => ext !== "svg"),
sourceExts: [...resolver.sourceExts, "svg"],
};
module.exports = config;
/**
* Add the monorepo paths to the Metro config.
* This allows Metro to resolve modules from the monorepo.
*
* @see https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
* @param {import('expo/metro-config').MetroConfig} config
* @returns {import('expo/metro-config').MetroConfig}
*/
function withMonorepoPaths(config) {
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
// #1 - Watch all files in the monorepo
config.watchFolders = [workspaceRoot];
// #2 - Resolve modules within the project's `node_modules` first, then all monorepo modules
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
return config;
}
/**
* Move the Metro cache to the `.cache/metro` folder.
* If you have any environment variables, you can configure Turborepo to invalidate it when needed.
*
* @see https://turbo.build/repo/docs/reference/configuration#env
* @param {import('expo/metro-config').MetroConfig} config
* @returns {import('expo/metro-config').MetroConfig}
*/
function withTurborepoManagedCache(config) {
config.cacheStores = [
new FileStore({ root: path.join(__dirname, ".cache/metro") }),
];
return config;
}

105
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,105 @@
{
"name": "mobile",
"version": "1.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"android": "expo run:android",
"clean": "git clean -xdf .cache .expo .turbo android ios node_modules",
"dev": "expo start --clear",
"dev:android": "expo start --android",
"dev:ios": "expo start --ios",
"format": "prettier --check . --ignore-path ../../.gitignore",
"ios": "expo run:ios",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@turbostarter/prettier-config",
"dependencies": {
"@dev-plugins/react-navigation": "~0.4.0",
"@dev-plugins/react-query": "~0.4.0",
"@expo-google-fonts/geist": "0.4.1",
"@expo-google-fonts/geist-mono": "0.4.1",
"@expo/metro-runtime": "~6.1.2",
"@hookform/resolvers": "5.2.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-google-signin/google-signin": "16.0.0",
"@shopify/react-native-skia": "2.2.12",
"@stardazed/streams-text-encoding": "1.0.2",
"@tanstack/react-query": "catalog:",
"@turbostarter/analytics-mobile": "workspace:*",
"@turbostarter/api": "workspace:*",
"@turbostarter/auth": "workspace:*",
"@turbostarter/billing": "workspace:*",
"@turbostarter/cms": "workspace:*",
"@turbostarter/db": "workspace:*",
"@turbostarter/i18n": "workspace:*",
"@turbostarter/monitoring-mobile": "workspace:*",
"@turbostarter/shared": "workspace:*",
"@turbostarter/ui": "workspace:*",
"@turbostarter/ui-mobile": "workspace:*",
"@ungap/structured-clone": "1.3.0",
"envin": "catalog:",
"expo": "~54.0.27",
"expo-apple-authentication": "~8.0.8",
"expo-application": "~7.0.8",
"expo-auth-session": "~7.0.10",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.11",
"expo-crypto": "~15.0.8",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.10",
"expo-glass-effect": "~0.1.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.9",
"expo-linking": "~8.0.10",
"expo-localization": "~17.0.8",
"expo-navigation-bar": "~5.0.10",
"expo-network": "~8.0.8",
"expo-router": "~6.0.17",
"expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.12",
"expo-status-bar": "~3.0.9",
"expo-store-review": "~9.0.9",
"expo-system-ui": "~6.0.9",
"expo-tracking-transparency": "~6.0.8",
"expo-updates": "~29.0.15",
"expo-web-browser": "~15.0.10",
"metro-react-native-babel-transformer": "0.77.0",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"react-hook-form": "catalog:",
"react-native": "catalog:",
"react-native-gesture-handler": "~2.29.1",
"react-native-keyboard-controller": "1.18.5",
"react-native-marked": "7.0.2",
"react-native-reanimated": "~4.1.5",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.15.1",
"react-native-svg-transformer": "1.5.2",
"react-native-web": "~0.21.2",
"react-native-webview": "13.15.0",
"react-native-worklets": "0.5.1",
"uniwind": "1.2.2",
"victory-native": "41.20.1",
"zod": "catalog:",
"zustand": "5.0.8"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/runtime": "^7.28.4",
"@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*",
"@types/babel__core": "^7.20.5",
"@types/react": "catalog:react19",
"@types/ungap__structured-clone": "1.2.0",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,14 @@
import { Stack } from "expo-router";
export default function SetupLayout() {
return (
<Stack
initialRouteName="welcome"
screenOptions={{
headerShown: false,
animation: "fade",
animationDuration: 200,
}}
/>
);
}

View File

@@ -0,0 +1,24 @@
import { router, Stack } from "expo-router";
import { pathsConfig } from "~/config/paths";
import { BaseHeader } from "~/modules/common/layout/header";
export default function AuthLayout() {
return (
<Stack
screenOptions={{
header: () => (
<BaseHeader
onBack={() =>
router.canGoBack()
? router.back()
: router.replace(pathsConfig.index)
}
/>
),
animation: "fade",
animationDuration: 200,
}}
/>
);
}

View File

@@ -0,0 +1,39 @@
import { useLocalSearchParams } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { Link } from "~/modules/common/styled";
const AuthError = () => {
const { error } = useLocalSearchParams<{ error?: string }>();
const { t } = useTranslation(["auth", "common"]);
return (
<View className="bg-background flex-1 flex-col items-center justify-center gap-4 px-8">
<Icons.CircleX className="text-destructive" strokeWidth={1.2} size={80} />
<Text className="font-sans-semibold text-center text-2xl">
{t("error.general")}
</Text>
{error && (
<Text className="bg-muted rounded-md px-2 py-0.5 font-mono">
{error}
</Text>
)}
<Link
href={pathsConfig.setup.auth.login}
replace
className="text-muted-foreground mt-3 font-sans underline"
>
{t("goToLogin")}
</Link>
</View>
);
};
export default AuthError;

View File

@@ -0,0 +1,99 @@
import { useQuery } from "@tanstack/react-query";
import { Redirect, useLocalSearchParams } from "expo-router";
import { View } from "react-native";
import { handle } from "@turbostarter/api/utils";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api";
import { authClient } from "~/lib/auth";
import { Spinner } from "~/modules/common/spinner";
import { Invitation } from "~/modules/organization/invitations/invitation";
import { InvitationEmailMismatch } from "~/modules/organization/invitations/invitation-email-mismatch";
import { InvitationExpired } from "~/modules/organization/invitations/invitation-expired";
import { organization } from "~/modules/organization/lib/api";
const InvitationCheck = ({
invitationId,
email,
}: {
invitationId: string;
email?: string;
}) => {
const session = authClient.useSession();
const invitation = useQuery(
organization.queries.invitations.get({ id: invitationId }),
);
const invitationOrganization = useQuery({
queryKey: organization.queries.get({
id: invitation.data?.organizationId ?? "",
}).queryKey,
queryFn: () =>
handle(api.organizations[":id"].$get)({
param: { id: invitation.data?.organizationId ?? "" },
}),
enabled: !!invitation.data,
});
if (invitation.isLoading || invitationOrganization.isLoading) {
return <Spinner />;
}
if (invitation.data && invitationOrganization.data?.organization) {
return (
<Invitation
invitation={invitation.data}
organization={invitationOrganization.data.organization}
/>
);
}
if (email && session.data?.user.email !== email) {
return (
<InvitationEmailMismatch invitationId={invitationId} email={email} />
);
}
return <InvitationExpired />;
};
export default function Join() {
const { invitationId, email } = useLocalSearchParams<{
invitationId?: string;
email?: string;
}>();
const session = authClient.useSession();
if (session.isPending) {
return (
<View className="bg-background flex-1">
<Spinner />
</View>
);
}
if (!invitationId) {
return <Redirect href={pathsConfig.index} />;
}
if (!session.data?.user) {
const searchParams = new URLSearchParams();
searchParams.set("invitationId", invitationId);
if (email) searchParams.set("email", email);
searchParams.set(
"redirectTo",
`${pathsConfig.setup.auth.join}?${searchParams.toString()}`,
);
return (
<Redirect
href={`${pathsConfig.setup.auth.login}?${searchParams.toString()}`}
/>
);
}
return (
<View className="bg-background flex-1">
<InvitationCheck invitationId={invitationId} email={email} />
</View>
);
}

View File

@@ -0,0 +1,23 @@
import { useGlobalSearchParams } from "expo-router";
import { LoginFlow } from "~/modules/auth/login";
import type { Route } from "expo-router";
const LoginPage = () => {
const { redirectTo, invitationId, email } = useGlobalSearchParams<{
redirectTo?: Route;
invitationId?: string;
email?: string;
}>();
return (
<LoginFlow
redirectTo={redirectTo}
invitationId={invitationId}
email={email}
/>
);
};
export default LoginPage;

View File

@@ -0,0 +1,20 @@
import { useTranslation } from "@turbostarter/i18n";
import { ForgotPasswordForm } from "~/modules/auth/form/password/forgot";
import { AuthLayout } from "~/modules/auth/layout/base";
import { AuthHeader } from "~/modules/auth/layout/header";
const ForgotPassword = () => {
const { t } = useTranslation("auth");
return (
<AuthLayout>
<AuthHeader
title={t("account.password.forgot.header.title")}
description={t("account.password.forgot.header.description")}
/>
<ForgotPasswordForm />
</AuthLayout>
);
};
export default ForgotPassword;

View File

@@ -0,0 +1,24 @@
import { useLocalSearchParams } from "expo-router";
import { useTranslation } from "@turbostarter/i18n";
import { UpdatePasswordForm } from "~/modules/auth/form/password/update";
import { AuthLayout } from "~/modules/auth/layout/base";
import { AuthHeader } from "~/modules/auth/layout/header";
const UpdatePassword = () => {
const { token } = useLocalSearchParams<{ token?: string }>();
const { t } = useTranslation("auth");
return (
<AuthLayout>
<AuthHeader
title={t("account.password.update.header.title")}
description={t("account.password.update.header.description")}
/>
<UpdatePasswordForm token={token} />
</AuthLayout>
);
};
export default UpdatePassword;

View File

@@ -0,0 +1,49 @@
import { useGlobalSearchParams } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { authConfig } from "~/config/auth";
import { AnonymousLogin } from "~/modules/auth/form/anonymous";
import { LoginCta } from "~/modules/auth/form/login/form";
import { RegisterForm } from "~/modules/auth/form/register-form";
import { SocialProviders } from "~/modules/auth/form/social-providers";
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";
const RegisterPage = () => {
const { t } = useTranslation("auth");
const { redirectTo, invitationId, email } = useGlobalSearchParams<{
redirectTo?: Route;
invitationId?: string;
email?: string;
}>();
return (
<AuthLayout>
<AuthHeader
title={t("register.header.title")}
description={t("register.header.description")}
/>
{invitationId && <InvitationDisclaimer />}
<SocialProviders
providers={authConfig.providers.oAuth}
redirectTo={redirectTo}
/>
{authConfig.providers.oAuth.length > 0 && <AuthDivider />}
<View className="gap-2">
<RegisterForm redirectTo={redirectTo} email={email} />
{authConfig.providers.anonymous && <AnonymousLogin />}
</View>
<LoginCta />
</AuthLayout>
);
};
export default RegisterPage;

View File

@@ -0,0 +1,143 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { router, Slot, usePathname } from "expo-router";
import { useEffect } from "react";
import { Platform, View } from "react-native";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { pathsConfig } from "~/config/paths";
import { SafeAreaView } from "~/modules/common/styled";
const steps = [
pathsConfig.setup.steps.start,
pathsConfig.setup.steps.required,
pathsConfig.setup.steps.skip,
pathsConfig.setup.steps.final,
] as const;
const useSetupStepsStore = create<{
current: number;
setCurrent: (current: number) => void;
}>()(
persist(
(set) => ({
current: 0,
setCurrent: (current) => set({ current }),
}),
{
name: "setup-steps",
storage: createJSONStorage(() => AsyncStorage),
},
),
);
export const useSetupSteps = () => {
const pathname = usePathname();
const { current, setCurrent } = useSetupStepsStore();
const step = steps[current];
useEffect(() => {
const index = steps.findIndex((step) => pathname.startsWith(step));
if (index != -1) {
setCurrent(index);
}
}, [pathname, setCurrent]);
const goNext = () => {
const next = steps[current + 1];
if (!next) {
setCurrent(-1);
return;
}
router.navigate(next);
};
const goBack = () => {
const previous = steps[current - 1];
if (!previous) {
setCurrent(-1);
return;
}
router.navigate(previous);
};
const reset = () => {
setCurrent(0);
};
return {
current,
steps,
step,
goNext,
goBack,
setCurrent,
reset,
};
};
export default function StepsLayout() {
const { current, goBack, reset } = useSetupSteps();
return (
<SafeAreaView
className="bg-background flex-1 pb-4"
style={{
paddingTop: Platform.select({
ios: 8,
android: 16,
}),
}}
>
<View className="flex-1 gap-2 px-6">
<View className="w-full flex-row items-center justify-between">
<Button
size="icon"
variant="outline"
onPress={() =>
current > 0 ? goBack() : router.replace(pathsConfig.setup.welcome)
}
>
<Icons.ChevronLeft
width={22}
height={22}
className="text-muted-foreground"
/>
</Button>
<View className="flex-row gap-1.5">
{steps.map((_, index) => (
<View
key={index}
className={cn("bg-muted h-2 w-7 rounded-full", {
"bg-primary": index <= current,
})}
/>
))}
</View>
<Button
size="icon"
variant="outline"
onPress={() => {
reset();
router.replace(pathsConfig.setup.welcome);
}}
>
<Icons.X width={20} height={20} className="text-muted-foreground" />
</Button>
</View>
<Slot />
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,45 @@
import { router } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Text } from "@turbostarter/ui-mobile/text";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { pathsConfig } from "~/config/paths";
import { ScrollView } from "~/modules/common/styled";
export default function FinalStep() {
const { t } = useTranslation(["common", "marketing"]);
const { goNext } = useSetupSteps();
return (
<>
<ScrollView
bounces={false}
showsVerticalScrollIndicator={false}
className="pt-4"
>
<View className="items-start gap-1">
<Text className="font-sans-bold text-3xl tracking-tight">
{t("setup.steps.step.final.title")}
</Text>
<Text className="text-muted-foreground leading-snug">
{t("setup.steps.step.final.description")}
</Text>
</View>
</ScrollView>
<Button
className="mt-auto"
size="lg"
onPress={() => {
goNext();
router.replace(pathsConfig.dashboard.user.index);
}}
>
<Text>{t("finish")}</Text>
</Button>
</>
);
}

View File

@@ -0,0 +1,113 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import * as Linking from "expo-linking";
import { useForm } from "react-hook-form";
import { View } from "react-native";
import z from "zod";
import { Trans, useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Form, FormCheckbox, FormField } from "@turbostarter/ui-mobile/form";
import { Text } from "@turbostarter/ui-mobile/text";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { appConfig } from "~/config/app";
import { ScrollView } from "~/modules/common/styled";
export default function RequiredStep() {
const { t } = useTranslation(["common", "marketing"]);
const { goNext } = useSetupSteps();
const form = useForm({
resolver: standardSchemaResolver(
z.object({
data: z.boolean(),
privacy: z.boolean(),
}),
),
defaultValues: {
data: false,
privacy: false,
},
});
const values = form.watch();
return (
<>
<ScrollView
bounces={false}
showsVerticalScrollIndicator={false}
className="pt-4"
>
<View className="items-start gap-6">
<View className="items-start gap-1">
<Text className="font-sans-bold text-3xl tracking-tight">
{t("setup.steps.step.required.title")}
</Text>
<Text className="text-muted-foreground leading-snug">
{t("setup.steps.step.required.description")}
</Text>
</View>
<View className="w-full gap-2">
<Form {...form}>
<FormField
control={form.control}
name="data"
render={({ field }) => (
<FormCheckbox
name="data"
label={t("setup.steps.step.required.fields.data")}
value={!!field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
<FormField
control={form.control}
name="privacy"
render={({ field }) => (
<FormCheckbox
name="privacy"
label={
<Trans
i18nKey="setup.steps.step.required.fields.privacy"
ns="marketing"
components={{
a: (
<Text
onPress={() =>
Linking.openURL(
`${appConfig.url}/legal/privacy-policy`,
)
}
className="font-sans-medium text-primary text-sm underline hover:no-underline"
/>
),
}}
/>
}
value={!!field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</Form>
</View>
</View>
</ScrollView>
<Button
className="mt-auto"
size="lg"
onPress={() => goNext()}
disabled={Object.values(values).some((value) => !value)}
>
<Text>{t("continue")}</Text>
</Button>
</>
);
}

View File

@@ -0,0 +1,40 @@
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Text } from "@turbostarter/ui-mobile/text";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { ScrollView } from "~/modules/common/styled";
export default function SkipStep() {
const { t } = useTranslation(["common", "marketing"]);
const { goNext } = useSetupSteps();
return (
<>
<ScrollView
className="pt-4"
bounces={false}
showsVerticalScrollIndicator={false}
>
<View className="items-start gap-1">
<Text className="font-sans-bold text-3xl tracking-tight">
{t("setup.steps.step.skip.title")}
</Text>
<Text className="text-muted-foreground leading-snug">
{t("setup.steps.step.skip.description")}
</Text>
</View>
</ScrollView>
<View className="mt-auto gap-2">
<Button size="lg" onPress={() => goNext()} variant="ghost">
<Text>{t("skip")}</Text>
</Button>
<Button size="lg" onPress={() => goNext()}>
<Text>{t("continue")}</Text>
</Button>
</View>
</>
);
}

View File

@@ -0,0 +1,36 @@
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Text } from "@turbostarter/ui-mobile/text";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { ScrollView } from "~/modules/common/styled";
export default function StartStep() {
const { t } = useTranslation(["common", "marketing"]);
const { goNext } = useSetupSteps();
return (
<>
<ScrollView
className="pt-4"
bounces={false}
showsVerticalScrollIndicator={false}
>
<View className="items-start gap-1">
<Text className="font-sans-bold text-3xl tracking-tight">
{t("setup.steps.step.start.title")}
</Text>
<Text className="text-muted-foreground leading-snug">
{t("setup.steps.step.start.description")}
</Text>
</View>
</ScrollView>
<Button className="mt-auto" size="lg" onPress={() => goNext()}>
<Text>{t("continue")}</Text>
</Button>
</>
);
}

View File

@@ -0,0 +1,107 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import { Image } from "expo-image";
import { router } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Slider,
SliderList,
SliderListItem,
SliderPaginationDots,
SliderPaginationDot,
} from "@turbostarter/ui-mobile/slider";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { useTheme } from "~/modules/common/hooks/use-theme";
import { SafeAreaView } from "~/modules/common/styled";
import { WIDTH } from "~/utils/device";
import type { ImageSource } from "expo-image";
const images = [
{
light: require("../../../public/images/setup/1/light.png") as ImageSource,
dark: require("../../../public/images/setup/1/dark.png") as ImageSource,
},
{
light: require("../../../public/images/setup/2/light.png") as ImageSource,
dark: require("../../../public/images/setup/2/dark.png") as ImageSource,
},
{
light: require("../../../public/images/setup/3/light.png") as ImageSource,
dark: require("../../../public/images/setup/3/dark.png") as ImageSource,
},
];
const ITEM_WIDTH = WIDTH - 48;
const WelcomePage = () => {
const { resolvedTheme } = useTheme();
const { t } = useTranslation(["common", "marketing", "auth"]);
return (
<View className="bg-background flex-1 px-6 pt-2 pb-4">
<SafeAreaView className="bg-background flex-1 gap-12">
<View className="flex-1 gap-8 pt-2">
<Slider threshold={ITEM_WIDTH}>
<SliderList
data={images}
renderItem={({ item, index }) => (
<SliderListItem
className="flex-1 items-center justify-center"
style={{ width: ITEM_WIDTH }}
index={index}
>
<Image
source={item[resolvedTheme]}
contentFit="contain"
style={{ flex: 1, width: "100%" }}
/>
</SliderListItem>
)}
/>
<SliderPaginationDots>
{images.map((_, index) => (
<SliderPaginationDot key={index} index={index} />
))}
</SliderPaginationDots>
</Slider>
<View className="mx-auto mt-auto max-w-xl gap-3">
<Text className="font-sans-bold text-center text-3xl tracking-tighter sm:text-4xl">
{t("product.title")}
</Text>
<Text className="text-muted-foreground px-6 text-center text-base leading-snug sm:text-lg">
{t("product.description")}
</Text>
</View>
</View>
<View className="mt-auto gap-3">
<Button
size="lg"
onPress={() => router.navigate(pathsConfig.setup.auth.register)}
>
<Text>{t("getStarted")}</Text>
</Button>
<Button
variant="outline"
size="lg"
onPress={() => router.navigate(pathsConfig.setup.auth.login)}
>
<Text>
{t("register.alreadyHaveAccount")} {t("login.cta")}
</Text>
</Button>
</View>
</SafeAreaView>
</View>
);
};
export default WelcomePage;

View File

@@ -0,0 +1,32 @@
import { router } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
export default function NotFound() {
const { t } = useTranslation("common");
return (
<View className="bg-background flex flex-1 items-center justify-center gap-8 px-6">
<View className="items-center justify-center gap-4">
<Text className="font-sans-bold text-center text-4xl">
{t("error.notFound")}
</Text>
<Text className="text-muted-foreground max-w-md text-center">
{t("error.resourceDoesNotExist")}
</Text>
</View>
<Button
onPress={() => router.replace(pathsConfig.index)}
variant="outline"
>
<Text>{t("goBackHome")}</Text>
</Button>
</View>
);
}

View File

@@ -0,0 +1,122 @@
import { useReactNavigationDevTools } from "@dev-plugins/react-navigation";
import {
Geist_400Regular,
Geist_500Medium,
Geist_600SemiBold,
Geist_700Bold,
useFonts,
} from "@expo-google-fonts/geist";
import { GeistMono_400Regular } from "@expo-google-fonts/geist-mono";
import { router, Stack, useNavigationContainerRef } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Text } from "@turbostarter/ui-mobile/text";
import "~/assets/styles/globals.css";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth";
import "~/lib/polyfills";
import { Providers } from "~/lib/providers/providers";
import { useTheme } from "~/modules/common/hooks/use-theme";
import { Updates } from "~/modules/common/updates";
import type { ErrorBoundaryProps } from "expo-router";
void SplashScreen.preventAutoHideAsync();
SplashScreen.setOptions({
duration: 500,
fade: true,
});
const RootNavigator = () => {
const navigationRef = useNavigationContainerRef();
useReactNavigationDevTools(
navigationRef as Parameters<typeof useReactNavigationDevTools>[0],
);
return (
<Providers>
<Updates />
<Stack
screenOptions={{
headerShown: false,
animation: "fade",
animationDuration: 200,
}}
/>
</Providers>
);
};
const useSetupAuth = () => {
const session = authClient.useSession();
const activeOrganization = authClient.useActiveOrganization();
const activeMember = authClient.useActiveMember();
if (session.isPending) {
return false;
}
if (!session.data) {
return true;
}
return !activeOrganization.isPending && !activeMember.isPending;
};
const RootLayout = () => {
useTheme();
const [fontsLoaded] = useFonts({
GeistMono_400Regular,
Geist_400Regular,
Geist_500Medium,
Geist_600SemiBold,
Geist_700Bold,
});
const authLoaded = useSetupAuth();
const loaded = fontsLoaded && authLoaded;
if (loaded) {
SplashScreen.hide();
}
return <RootNavigator />;
};
export default RootLayout;
export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
const { t } = useTranslation("common");
return (
<View className="bg-background flex-1 items-center justify-center gap-8 px-6">
<View className="flex flex-col items-center justify-center gap-4">
<Text className="font-sans-bold text-center text-4xl">
{t("error.general")}
</Text>
<Text className="text-muted-foreground text-center">
{error.message || t("error.apologies")}
</Text>
</View>
<View className="flex-row items-center justify-center gap-4">
<Button onPress={retry}>
<Text>{t("tryAgain")}</Text>
</Button>
<Button
onPress={() => router.replace(pathsConfig.index)}
variant="outline"
>
<Text>{t("goBackHome")}</Text>
</Button>
</View>
</View>
);
}

View File

@@ -0,0 +1,81 @@
import { Tabs } from "expo-router";
import { Easing } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { UserHeader } from "~/modules/common/layout/header";
import { TabBarLabel } from "~/modules/common/styled";
export default function UserLayout() {
const { t } = useTranslation("common");
return (
<Tabs
initialRouteName="index"
screenOptions={{
tabBarStyle: {
paddingTop: 6,
},
animation: "fade",
transitionSpec: {
animation: "timing",
config: {
duration: 200,
easing: Easing.inOut(Easing.ease),
},
},
}}
>
<Tabs.Screen
name="index"
options={{
header: () => <UserHeader />,
title: t("home"),
tabBarIcon: ({ focused }) => (
<Icons.House
size={22}
className={cn("text-muted-foreground", {
"text-primary": focused,
})}
/>
),
tabBarLabel: TabBarLabel,
}}
/>
<Tabs.Screen
name="ai"
options={{
headerShown: false,
title: t("ai"),
tabBarIcon: ({ focused }) => (
<Icons.WandSparkles
size={22}
className={cn("text-muted-foreground", {
"text-primary": focused,
})}
/>
),
tabBarLabel: TabBarLabel,
}}
/>
<Tabs.Screen
name="settings"
options={{
headerShown: false,
title: t("settings"),
tabBarIcon: ({ focused }) => (
<Icons.Settings
size={22}
className={cn("text-muted-foreground", {
"text-primary": focused,
})}
/>
),
tabBarLabel: TabBarLabel,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,176 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { fetch as expoFetch } from "expo/fetch";
import { useState } from "react";
import { View, Keyboard } from "react-native";
import { FlatList } from "react-native-gesture-handler";
import Markdown from "react-native-marked";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "@turbostarter/i18n";
import { logger } from "@turbostarter/shared/logger";
import { cn } from "@turbostarter/ui";
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 { Textarea } from "@turbostarter/ui-mobile/textarea";
import { api } from "~/lib/api";
import { KeyboardAvoidingView, ScrollView } from "~/modules/common/styled";
const EXAMPLES = [
{
icon: Icons.Globe2,
prompt: "ai.prompt.history",
},
{
icon: Icons.GraduationCap,
prompt: "ai.prompt.capitals",
},
{
icon: Icons.Atom,
prompt: "ai.prompt.quantum",
},
{
icon: Icons.Brain,
prompt: "ai.prompt.realWorld",
},
] as const;
export default function AI() {
const { t } = useTranslation("marketing");
const [input, setInput] = useState("");
const insets = useSafeAreaInsets();
const { messages, error, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
fetch: expoFetch as unknown as typeof globalThis.fetch,
api: api.ai.chat.chats.$url().toString(),
}),
onError: (error) => logger.error(error),
});
if (error) {
return (
<View className="bg-background flex-1 px-6">
<Text>{error.message}</Text>
</View>
);
}
const messagesToDisplay = messages.filter((message) =>
["assistant", "user"].includes(message.role),
);
const isLoading = ["submitted", "streaming"].includes(status);
return (
<KeyboardAvoidingView
behavior="padding"
style={{ paddingTop: insets.top }}
className="bg-background relative flex-1 px-6"
>
<ScrollView
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic"
contentContainerClassName="flex pt-4 grow items-start justify-start gap-4 pb-8"
showsVerticalScrollIndicator={false}
bounces={false}
>
{messagesToDisplay.map((message) => (
<View
key={message.id}
className={cn("max-w-full", {
"bg-muted max-w-4/5 self-end rounded-lg px-5 py-2.5":
message.role === "user",
})}
>
{message.parts.map((part, i) => {
switch (part.type) {
case "text":
return message.role === "assistant" ? (
<Markdown
value={part.text.trim()}
flatListProps={{
scrollEnabled: false,
style: {
flexGrow: 0,
},
}}
key={`${message.id}-${i}`}
/>
) : (
<Text key={`${message.id}-${i}`}>{part.text}</Text>
);
}
})}
</View>
))}
{isLoading && (
<View className="mr-auto py-2.5">
<Spin>
<Icons.Loader className="text-muted-foreground" size={18} />
</Spin>
</View>
)}
</ScrollView>
{!messagesToDisplay.length && (
<FlatList
data={EXAMPLES}
contentContainerClassName="gap-2 mt-auto mb-3"
bounces={false}
renderItem={({ item }) => (
<Button
onPress={() => {
Keyboard.dismiss();
void sendMessage({ text: t(item.prompt) });
}}
key={item.prompt}
variant="outline"
className="h-auto grow flex-row justify-start gap-3 py-3 text-left"
>
<item.icon
className="text-muted-foreground shrink-0"
width={20}
height={20}
/>
<Text className="text-muted-foreground">{t(item.prompt)}</Text>
</Button>
)}
/>
)}
<View className="bg-background relative pb-4">
<Textarea
placeholder={t("ai.placeholder")}
value={input}
onSubmitEditing={(e) => {
e.preventDefault();
Keyboard.dismiss();
void sendMessage({ text: input });
setInput("");
}}
onChange={(e) => setInput(e.nativeEvent.text)}
className="min-h-24"
/>
<Button
size="icon"
className="absolute right-2 bottom-6 rounded-full"
disabled={isLoading}
onPress={() => {
Keyboard.dismiss();
void sendMessage({ text: input });
setInput("");
}}
accessibilityLabel={t("ai.cta")}
>
<Icons.ArrowUp className="text-primary-foreground" />
</Button>
</View>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,23 @@
import { View } from "react-native";
import { BuiltWith } from "@turbostarter/ui-mobile/built-with";
import { ScrollView } from "~/modules/common/styled";
import { UserOrganizationInvitationsBanner } from "~/modules/organization/invitations/user/user-organization-invitations";
import { OrganizationPicker } from "~/modules/organization/organization-picker";
export default function Home() {
return (
<ScrollView
className="bg-background"
contentContainerClassName="items-center bg-background grow gap-6 px-6 py-2"
showsVerticalScrollIndicator={false}
>
<UserOrganizationInvitationsBanner />
<OrganizationPicker />
<View className="pt-4 pb-10">
<BuiltWith />
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,31 @@
import { router, Stack } from "expo-router";
import { useTranslation } from "@turbostarter/i18n";
import { BaseHeader } from "~/modules/common/layout/header";
export default function SettingsLayout() {
const { t } = useTranslation("common");
return (
<Stack
initialRouteName="index"
screenOptions={{
animation: "fade",
animationDuration: 200,
}}
>
<Stack.Screen name="general" options={{ headerShown: false }} />
<Stack.Screen name="account" options={{ headerShown: false }} />
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen
name="billing"
options={{
header: () => (
<BaseHeader title={t("billing")} onBack={router.back} />
),
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,31 @@
import { router, Stack } from "expo-router";
import { isKey, useTranslation } from "@turbostarter/i18n";
import { capitalize } from "@turbostarter/shared/utils";
import { BaseHeader } from "~/modules/common/layout/header";
export default function AccountLayout() {
const { t, i18n } = useTranslation("common");
return (
<Stack
screenOptions={({ route }) => {
const name = route.name === "index" ? "account" : route.name;
return {
header: () => (
<BaseHeader
title={isKey(name, i18n, "common") ? t(name) : capitalize(name)}
{...(router.canGoBack() && {
onBack: () => router.back(),
})}
/>
),
animation: "fade",
animationDuration: 200,
};
}}
/>
);
}

View File

@@ -0,0 +1,181 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Alert } from "react-native";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { capitalize } from "@turbostarter/shared/utils";
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 { SocialIcons } from "~/modules/auth/form/social-providers";
import { auth } from "~/modules/auth/lib/api";
import type { SocialProvider } from "@turbostarter/auth";
export default function AccountsScreen() {
const { t, i18n } = useTranslation(["auth", "common"]);
const { data: session } = authClient.useSession();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
...auth.queries.accounts.getAll,
enabled: !!session?.user.id,
});
const accounts = data ?? [];
const socials = accounts.filter((account) =>
authConfig.providers.oAuth.includes(account.providerId),
);
const missing = authConfig.providers.oAuth.filter(
(provider) => !socials.some((social) => social.providerId === provider),
);
const connect = useMutation({
...auth.mutations.accounts.connect,
onSuccess: async () => {
await queryClient.invalidateQueries(auth.queries.accounts.getAll);
},
});
const disconnect = useMutation({
...auth.mutations.accounts.disconnect,
onSuccess: async () => {
await queryClient.invalidateQueries(auth.queries.accounts.getAll);
},
});
const handleDisconnect = (provider: SocialProvider) => {
Alert.alert(
t("account.accounts.disconnect.cta", {
provider: capitalize(provider),
}),
t("account.accounts.disconnect.disclaimer", {
provider: capitalize(provider),
}),
[
{
text: t("cancel"),
style: "cancel",
},
{
text: t("continue"),
onPress: () => disconnect.mutate({ providerId: provider }),
},
],
);
};
return (
<View className="bg-background flex-1 gap-6 p-6">
<Text className="text-muted-foreground font-sans-medium text-base">
{t("account.accounts.description")}
</Text>
{isLoading ? (
<View className="p-6">
<Spin>
<Icons.Loader2 className="text-foreground mx-auto size-6" />
</Spin>
</View>
) : (
<>
{socials.length > 0 && (
<View className="border-border overflow-hidden rounded-lg border">
{socials.map((social) => {
const provider = social.providerId as SocialProvider;
const Icon = SocialIcons[provider];
return (
<View
key={social.accountId}
className="border-border flex-row items-center border-b p-4 last:border-b-0"
>
<Icon className="text-foreground size-8" />
<View className="ml-3 flex-1">
<Text className="font-sans-medium capitalize">
{social.providerId}
</Text>
<Text className="text-muted-foreground text-xs">
{t("account.accounts.connectedAt", {
date: social.updatedAt.toLocaleDateString(
i18n.language,
),
})}
</Text>
</View>
<Button
variant="ghost"
size="icon"
disabled={accounts.length === 1 || disconnect.isPending}
onPress={() => handleDisconnect(provider)}
>
{disconnect.isPending &&
disconnect.variables.providerId === provider ? (
<Spin>
<Icons.Loader2 className="text-foreground size-5" />
</Spin>
) : (
<Icons.Trash className="text-foreground" size={20} />
)}
</Button>
</View>
);
})}
</View>
)}
{missing.length > 0 && (
<View className="border-border gap-3 rounded-lg border border-dashed px-5 py-4">
<Text className="font-sans-medium text-sm">{t("addNew")}</Text>
<View className="bg-border h-px" />
<View className="flex-row flex-wrap gap-2">
{missing.map((provider) => {
const Icon = SocialIcons[provider];
return (
<Button
key={provider}
variant="outline"
disabled={connect.isPending}
onPress={() =>
connect.mutate({
provider,
callbackURL:
pathsConfig.dashboard.user.settings.account
.accounts,
errorCallbackURL: pathsConfig.setup.auth.error,
})
}
className="h-[44px] flex-row items-center gap-2 px-4"
>
{connect.isPending &&
connect.variables.provider === provider ? (
<Spin>
<Icons.Loader2 className="size-5" />
</Spin>
) : (
<Icon className="text-foreground size-5" />
)}
<Text className="capitalize">{provider}</Text>
</Button>
);
})}
</View>
</View>
)}
</>
)}
<Text className="text-muted-foreground -mt-3 max-w-80 text-sm">
{t("account.accounts.info")}
</Text>
</View>
);
}

View File

@@ -0,0 +1,172 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import { useFocusEffect } from "expo-router";
import { useCallback } from "react";
import { useForm } from "react-hook-form";
import { Alert, View } from "react-native";
import { emailSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Badge } from "@turbostarter/ui-mobile/badge";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormField,
FormItem,
FormInput,
FormDescription,
} 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 { authClient } from "~/lib/auth";
import { auth } from "~/modules/auth/lib/api";
import { ScrollView } from "~/modules/common/styled";
const EditEmail = () => {
const { t } = useTranslation(["common", "auth"]);
const { data, refetch } = authClient.useSession();
useFocusEffect(
useCallback(() => {
void refetch();
}, [refetch]),
);
const form = useForm({
resolver: standardSchemaResolver(emailSchema),
defaultValues: {
email: data?.user.email ?? "",
},
});
const sendVerification = useMutation({
...auth.mutations.email.sendVerification,
onSuccess: () => {
Alert.alert(t("message"), t("account.email.confirm.email.sent"));
},
});
const changeEmail = useMutation({
...auth.mutations.email.change,
onSuccess: () => {
Alert.alert(t("message"), t("account.email.change.success"));
},
});
return (
<ScrollView
bounces={false}
contentContainerClassName="bg-background flex-1 p-6"
>
<Form {...form}>
<View className="flex-1 gap-6">
<View className="gap-2">
<View className="flex-row items-center gap-3">
<Badge
className={cn(
data?.user.emailVerified
? "bg-success/15 border-transparent"
: "bg-destructive/15 border-transparent",
)}
>
<Text
className={
data?.user.emailVerified
? "text-success"
: "text-destructive"
}
>
{data?.user.emailVerified ? t("verified") : t("unverified")}
</Text>
</Badge>
{!data?.user.emailVerified && (
<Button
variant="outline"
size="sm"
onPress={() =>
sendVerification.mutateAsync({
email: data?.user.email ?? "",
callbackURL:
pathsConfig.dashboard.user.settings.account.email,
fetchOptions: {
headers: {
"x-url": Linking.createURL(
pathsConfig.dashboard.user.settings.account.email,
),
},
},
})
}
disabled={sendVerification.isPending}
>
<Text>
{sendVerification.isPending
? t("account.email.confirm.loading")
: t("account.email.confirm.cta")}
</Text>
</Button>
)}
</View>
<Text className="text-muted-foreground font-sans-medium text-base">
{t("account.email.change.description")}
</Text>
</View>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormInput
label={t("email")}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
editable={!form.formState.isSubmitting}
{...field}
/>
<FormDescription>
{t("account.email.change.info")}
</FormDescription>
</FormItem>
)}
/>
<Button
className="w-full"
size="lg"
onPress={form.handleSubmit((data) =>
changeEmail.mutateAsync({
newEmail: data.email,
callbackURL: pathsConfig.dashboard.user.settings.account.email,
fetchOptions: {
headers: {
"x-url": Linking.createURL(
pathsConfig.dashboard.user.settings.account.email,
),
},
},
}),
)}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground" />
</Spin>
) : (
<Text>{t("save")}</Text>
)}
</Button>
</View>
</Form>
</ScrollView>
);
};
export default EditEmail;

View File

@@ -0,0 +1,131 @@
import { router } from "expo-router";
import React from "react";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { SettingsTile } from "~/modules/common/settings-tile";
import { ScrollView } from "~/modules/common/styled";
import { DeleteAccount } from "~/modules/user/settings/account/delete-account";
import { Logout } from "~/modules/user/settings/account/logout";
const sections = [
[
() => {
const { t } = useTranslation("common");
return (
<SettingsTile
icon={Icons.IdCard}
onPress={() =>
router.navigate(pathsConfig.dashboard.user.settings.account.name)
}
>
<Text>{t("name")}</Text>
</SettingsTile>
);
},
() => {
const { t } = useTranslation("common");
return (
<SettingsTile
icon={Icons.AtSign}
onPress={() =>
router.navigate(pathsConfig.dashboard.user.settings.account.email)
}
>
<Text>{t("email")}</Text>
</SettingsTile>
);
},
() => {
const { t } = useTranslation(["common", "auth"]);
return (
<SettingsTile
icon={Icons.Workflow}
onPress={() =>
router.navigate(
pathsConfig.dashboard.user.settings.account.accounts,
)
}
>
<Text>{t("account.accounts.title")}</Text>
</SettingsTile>
);
},
() => {
const { t } = useTranslation("auth");
return (
<SettingsTile
icon={Icons.Lock}
onPress={() =>
router.navigate(
pathsConfig.dashboard.user.settings.account.password,
)
}
>
<Text>{t("password")}</Text>
</SettingsTile>
);
},
() => {
const { t } = useTranslation("auth");
return (
<SettingsTile
icon={Icons.ShieldCheck}
onPress={() =>
router.navigate(
pathsConfig.dashboard.user.settings.account.twoFactor,
)
}
>
<Text>{t("account.twoFactor.title")}</Text>
</SettingsTile>
);
},
],
[
() => {
const { t } = useTranslation("auth");
return (
<SettingsTile
icon={Icons.MonitorSmartphone}
onPress={() =>
router.navigate(
pathsConfig.dashboard.user.settings.account.sessions,
)
}
>
<Text>{t("account.sessions.title")}</Text>
</SettingsTile>
);
},
Logout,
],
[DeleteAccount],
];
export default function Account() {
return (
<View className="bg-background flex-1">
<ScrollView
className="bg-background flex-1 py-2"
contentContainerClassName="gap-8"
bounces={false}
>
<View className="gap-6">
{sections.map((section, index) => (
<View key={index}>
{section.map((item, index) => (
<React.Fragment key={index}>{item()}</React.Fragment>
))}
</View>
))}
</View>
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,89 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { useForm } from "react-hook-form";
import { View } from "react-native";
import { updateUserSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormField,
FormItem,
FormInput,
FormDescription,
} 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 { authClient } from "~/lib/auth";
import { user } from "~/modules/user/lib/api";
const EditName = () => {
const { t } = useTranslation(["common", "auth"]);
const session = authClient.useSession();
const form = useForm({
resolver: standardSchemaResolver(updateUserSchema.pick({ name: true })),
defaultValues: {
name: session.data?.user.name ?? "",
},
});
const updateUser = useMutation({
...user.mutations.update,
onSuccess: () => {
router.back();
},
});
return (
<View className="bg-background flex-1 p-6">
<Form {...form}>
<View className="flex-1 gap-6">
<Text className="text-muted-foreground font-sans-medium text-base">
{t("account.name.edit.description")}
</Text>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormInput
{...field}
label={t("name")}
autoCapitalize="words"
autoComplete="name"
editable={!form.formState.isSubmitting}
value={field.value ?? ""}
/>
<FormDescription>{t("account.name.edit.info")}</FormDescription>
</FormItem>
)}
/>
<Button
className="w-full"
size="lg"
onPress={form.handleSubmit((data) => updateUser.mutateAsync(data))}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground" />
</Spin>
) : (
<Text>{t("save")}</Text>
)}
</Button>
</View>
</Form>
</View>
);
};
export default EditName;

View File

@@ -0,0 +1,161 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useForm } from "react-hook-form";
import { Alert, View } from "react-native";
import { changePasswordSchema } from "@turbostarter/auth";
import { Trans, useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormField,
FormInput,
FormItem,
FormDescription,
} 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 { authClient } from "~/lib/auth";
import { auth } from "~/modules/auth/lib/api";
import { Link, ScrollView } from "~/modules/common/styled";
export default function Password() {
const { t } = useTranslation(["common", "auth"]);
const session = authClient.useSession();
const { data: accounts, isLoading } = useQuery({
...auth.queries.accounts.getAll,
enabled: !!session.data?.user.id,
});
const form = useForm({
resolver: standardSchemaResolver(changePasswordSchema),
});
const changePassword = useMutation({
...auth.mutations.password.change,
onSuccess: () => {
Alert.alert(
t("account.password.update.title"),
t("account.password.update.success"),
[
{
text: t("continue"),
onPress: () => {
router.back();
form.reset();
},
},
],
);
},
});
const hasPassword = accounts
?.map((account) => account.providerId)
.includes("credential");
return (
<ScrollView
bounces={false}
contentContainerClassName="bg-background flex-1 p-6"
>
<Form {...form}>
<View className="flex-1 gap-6">
<Text className="text-muted-foreground font-sans-medium text-base">
{t("account.password.update.description")}
</Text>
{isLoading ? (
<View className="bg-muted/50 h-20 animate-pulse" key="loading" />
) : (
<View className="gap-4" key="password">
{hasPassword ? (
<>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormInput
label={t("currentPassword")}
secureTextEntry
autoComplete="current-password"
editable={!form.formState.isSubmitting}
{...field}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormInput
label={t("newPassword")}
secureTextEntry
autoComplete="new-password"
editable={!form.formState.isSubmitting}
{...field}
/>
<FormDescription>
{t("account.password.update.info")}
</FormDescription>
</FormItem>
)}
/>
</>
) : (
<View className="border-border items-center justify-center rounded-lg border border-dashed p-6">
<Text className="text-muted-foreground text-center">
<Trans
i18nKey="account.password.update.noPassword"
ns="auth"
components={{
bold: (
<Link
href={pathsConfig.setup.auth.forgotPassword}
className="font-sans-medium underline hover:no-underline"
/>
),
}}
/>
</Text>
</View>
)}
</View>
)}
{!isLoading && hasPassword && (
<Button
variant="default"
size="lg"
disabled={form.formState.isSubmitting}
onPress={form.handleSubmit((data) =>
changePassword.mutateAsync({
...data,
currentPassword: data.password,
revokeOtherSessions: true,
}),
)}
>
{form.formState.isSubmitting ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground" />
</Spin>
) : (
<Text className="text-primary-foreground">{t("save")}</Text>
)}
</Button>
)}
</View>
</Form>
</ScrollView>
);
}

View File

@@ -0,0 +1,130 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Fragment } from "react/jsx-runtime";
import { Alert, View } from "react-native";
import { FlatList, RefreshControl } from "react-native-gesture-handler";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Skeleton } from "@turbostarter/ui-mobile/skeleton";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth";
import { auth } from "~/modules/auth/lib/api";
export default function Sessions() {
const { t } = useTranslation(["common", "auth"]);
const session = authClient.useSession();
const signOut = useMutation({
...auth.mutations.signOut,
onSuccess: () => {
router.replace(pathsConfig.index);
},
});
const {
data: sessions,
isLoading,
refetch,
isRefetching,
} = useQuery({
...auth.queries.sessions.getAll,
enabled: !!session.data?.user.id,
});
const revoke = useMutation({
...auth.mutations.sessions.revoke,
onSuccess: async (_, token) => {
Alert.alert(t("account.sessions.revoke.success"));
await refetch();
if (token === session.data?.session.token) {
await signOut.mutateAsync(undefined);
}
},
});
return (
<View className="bg-background flex-1 gap-6 p-6">
<Text className="text-muted-foreground font-sans-medium">
{t("account.sessions.description")}
</Text>
<View className="border-border flex-1 rounded-md border">
<FlatList
data={sessions}
contentContainerClassName={cn({
"flex-1": !sessions?.length,
"items-center justify-center": !sessions?.length && !isLoading,
})}
renderItem={({ item }) => (
<View className="flex-row justify-between gap-3 px-4 py-3">
<View className="flex-1">
<Text className="font-sans-medium text-sm" numberOfLines={1}>
{item.ipAddress}
</Text>
<Text
className="text-muted-foreground text-sm"
numberOfLines={1}
>
{item.userAgent}
</Text>
</View>
<Button
size="icon"
variant="ghost"
disabled={revoke.isPending && revoke.variables === item.token}
onPress={() => revoke.mutate(item.token)}
>
{revoke.isPending && revoke.variables === item.token ? (
<Spin>
<Icons.Loader2 size={16} className="text-foreground" />
</Spin>
) : (
<Icons.Trash className="text-foreground" size={16} />
)}
</Button>
</View>
)}
ItemSeparatorComponent={() => <View className="bg-border h-px" />}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={() => refetch()}
tintColorClassName="accent-primary"
colorsClassName="accent-primary"
/>
}
ListEmptyComponent={
isLoading ? (
<View className="w-full items-start">
{Array.from({ length: 15 }).map((_, index, arr) => (
<Fragment key={index}>
<View className="flex-row items-center justify-between gap-3 px-4 py-3">
<View className="gap-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-64" />
</View>
<Skeleton className="h-10 w-10" />
</View>
{index !== arr.length - 1 && (
<View className="bg-border h-px" />
)}
</Fragment>
))}
</View>
) : (
<Text>{t("noResults")}</Text>
)
}
/>
</View>
</View>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { useBottomSheet } from "@turbostarter/ui-mobile/bottom-sheet";
import { Switch } from "@turbostarter/ui-mobile/switch";
import { Text } from "@turbostarter/ui-mobile/text";
import { BackupCodesTile } from "~/modules/user/settings/account/two-factor/backup-codes/backup-codes";
import { useBackupCodes } from "~/modules/user/settings/account/two-factor/backup-codes/use-backup-codes";
import { RequirePassword } from "~/modules/user/settings/account/two-factor/require-password";
import {
TotpTile,
TotpSheet,
} from "~/modules/user/settings/account/two-factor/totp/totp";
import { useTotp } from "~/modules/user/settings/account/two-factor/totp/use-totp";
import { useTwoFactor } from "~/modules/user/settings/account/two-factor/use-two-factor";
import type { PasswordPayload } from "@turbostarter/auth";
export default function TwoFactor() {
const { t } = useTranslation(["common", "auth"]);
const { ref: totpSheetRef } = useBottomSheet();
const { setUri } = useTotp();
const { setCodes } = useBackupCodes();
const { enabled, enable, disable } = useTwoFactor();
const onEnable = useCallback(
async (data: PasswordPayload) => {
const response = await enable.mutateAsync(data);
setUri(response.totpURI);
setCodes(response.backupCodes);
totpSheetRef.current?.present();
},
[enable, setUri, setCodes, totpSheetRef],
);
const onDisable = useCallback(
async (data: PasswordPayload) => {
await disable.mutateAsync(data);
},
[disable],
);
return (
<View className="bg-background flex-1 p-6">
<View className="flex-row items-start justify-between gap-8">
<View className="flex-1">
<Text className="text-muted-foreground font-sans-medium text-base">
{t("account.twoFactor.description")}
</Text>
</View>
<TwoFactorSwitch onSubmit={enabled ? onDisable : onEnable} />
</View>
<View className="mt-6 gap-1">
<TotpTile />
<BackupCodesTile />
</View>
<TotpSheet ref={totpSheetRef} />
</View>
);
}
const TwoFactorSwitch = ({
onSubmit,
}: {
onSubmit: (data: PasswordPayload) => Promise<void>;
}) => {
const { t } = useTranslation(["common", "auth"]);
const { enabled } = useTwoFactor();
const key = useMemo(() => {
return enabled ? "disable" : "enable";
}, [enabled]);
return (
<RequirePassword
onConfirm={onSubmit}
title={t(`account.twoFactor.${key}.title`)}
description={t(`account.twoFactor.${key}.description`)}
cta={t(key)}
>
<Switch
checked={enabled}
onCheckedChange={() => {
// Switch is controlled by the RequirePassword component
// The actual toggling happens in the onConfirm callback
}}
/>
</RequirePassword>
);
};

View File

@@ -0,0 +1,31 @@
import * as Linking from "expo-linking";
import { Pressable } from "react-native";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Text } from "@turbostarter/ui-mobile/text";
export default function Billing() {
const { t } = useTranslation(["common", "marketing"]);
return (
<View className="bg-background flex flex-1 items-center justify-center px-6">
<View className="items-center gap-6 text-center">
<Text className="font-sans-bold mt-4 text-3xl tracking-tight">
{t("workInProgress.title")}
</Text>
<Text className="text-muted-foreground text-center text-pretty">
{t("workInProgress.description", { feature: t("billing") })}
</Text>
<Pressable
onPress={() =>
Linking.openURL("https://github.com/orgs/turbostarter/projects/1")
}
className="mt-6"
>
<Text className="text-primary underline">{t("seeRoadmap")}</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -0,0 +1,33 @@
import { router, Stack } from "expo-router";
import { useTranslation } from "@turbostarter/i18n";
import { isKey } from "@turbostarter/i18n";
import { capitalize } from "@turbostarter/shared/utils";
import { BaseHeader } from "~/modules/common/layout/header";
export default function GeneralLayout() {
const { t, i18n } = useTranslation("common");
return (
<Stack
initialRouteName="index"
screenOptions={({ route }) => {
const name = route.name === "index" ? "general" : route.name;
return {
header: () => (
<BaseHeader
title={isKey(name, i18n, "common") ? t(name) : capitalize(name)}
{...(router.canGoBack() && {
onBack: () => router.back(),
})}
/>
),
animation: "fade",
animationDuration: 200,
};
}}
/>
);
}

View File

@@ -0,0 +1,50 @@
import { router } from "expo-router";
import { checkForUpdateAsync, useUpdates } from "expo-updates";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { SettingsTile } from "~/modules/common/settings-tile";
import { I18nSettings } from "~/modules/user/settings/i18n";
import { ThemeSettings } from "~/modules/user/settings/theme";
const CheckForUpdates = () => {
const { t } = useTranslation("common");
const { isChecking } = useUpdates();
return (
<SettingsTile
icon={Icons.RefreshCw}
onPress={checkForUpdateAsync}
loading={isChecking}
disabled={isChecking}
>
<Text className="mr-auto">{t("checkForUpdates")}</Text>
</SettingsTile>
);
};
export default function General() {
const { t } = useTranslation("common");
return (
<View className="bg-background flex-1 py-2">
<CheckForUpdates />
<SettingsTile
icon={Icons.Bell}
onPress={() =>
router.navigate(
pathsConfig.dashboard.user.settings.general.notifications,
)
}
>
<Text className="mr-auto">{t("notifications")}</Text>
</SettingsTile>
<ThemeSettings />
<I18nSettings />
</View>
);
}

View File

@@ -0,0 +1,31 @@
import * as Linking from "expo-linking";
import { Pressable } from "react-native";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Text } from "@turbostarter/ui-mobile/text";
export default function Notifications() {
const { t } = useTranslation(["common", "marketing"]);
return (
<View className="bg-background flex flex-1 items-center justify-center px-6">
<View className="items-center gap-6 text-center">
<Text className="font-sans-bold mt-4 text-3xl tracking-tight">
{t("workInProgress.title")}
</Text>
<Text className="text-muted-foreground text-center text-pretty">
{t("workInProgress.description", { feature: t("notifications") })}
</Text>
<Pressable
onPress={() =>
Linking.openURL("https://github.com/orgs/turbostarter/projects/1")
}
className="mt-6"
>
<Text className="text-primary underline">{t("seeRoadmap")}</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -0,0 +1,113 @@
import Constants from "expo-constants";
import * as Linking from "expo-linking";
import { router } from "expo-router";
import * as StoreReview from "expo-store-review";
import { Share, View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth";
import { SettingsTile } from "~/modules/common/settings-tile";
import { SafeAreaView, ScrollView } from "~/modules/common/styled";
import { AccountInfo } from "~/modules/user/settings/account/account-info";
import type { Session } from "@turbostarter/auth";
const getSections = (session?: Session | null) =>
[
[
{
title: "general",
icon: Icons.Settings,
onPress: () =>
router.navigate(pathsConfig.dashboard.user.settings.general.index),
visible: true,
},
{
title: "account",
icon: Icons.UserRound,
onPress: () =>
router.navigate(pathsConfig.dashboard.user.settings.account.index),
visible: !!session?.session,
},
{
title: "billing",
icon: Icons.Wallet,
onPress: () =>
router.navigate(pathsConfig.dashboard.user.settings.billing),
visible: !!session?.session,
},
],
[
{
title: "rate",
icon: Icons.ThumbsUp,
onPress: async () => {
const available = await StoreReview.hasAction();
if (available) {
return await StoreReview.requestReview();
}
return Share.share({
title: Constants.expoConfig?.name,
message: appConfig.url,
});
},
visible: true,
},
{
title: "share",
icon: Icons.Share2,
onPress: () =>
Share.share({
title: Constants.expoConfig?.name,
message: appConfig.url,
}),
visible: true,
},
{
title: "privacy",
icon: Icons.Lock,
onPress: () => Linking.openURL(`${appConfig.url}/legal/privacy-policy`),
visible: true,
},
],
] as const;
export default function Settings() {
const session = authClient.useSession();
const { t } = useTranslation("common");
const sections = getSections(session.data);
return (
<SafeAreaView className="bg-background flex-1">
<ScrollView
className="bg-background flex-1"
contentContainerClassName="gap-8 py-6"
bounces={false}
>
<AccountInfo />
<View className="gap-6">
{sections.map((section, index) => (
<View key={index}>
{section
.filter((item) => item.visible)
.map((item) => (
<SettingsTile {...item} key={item.title}>
<Text>{t(item.title)}</Text>
</SettingsTile>
))}
</View>
))}
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,27 @@
import { Redirect, Stack } from "expo-router";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth";
import { Spinner } from "~/modules/common/spinner";
export default function DashboardLayout() {
const session = authClient.useSession();
if (session.isPending) {
return <Spinner modal={false} />;
}
if (!session.data) {
return <Redirect href={pathsConfig.index} />;
}
return (
<Stack
screenOptions={{
animation: "fade",
animationDuration: 200,
headerShown: false,
}}
/>
);
}

View File

@@ -0,0 +1,83 @@
import { Tabs } from "expo-router";
import { Easing } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { UserHeader } from "~/modules/common/layout/header";
import { TabBarLabel } from "~/modules/common/styled";
export default function OrganizationLayout() {
const { t } = useTranslation("common");
return (
<Tabs
initialRouteName="index"
screenOptions={{
tabBarStyle: {
paddingTop: 6,
},
animation: "fade",
transitionSpec: {
animation: "timing",
config: {
duration: 200,
easing: Easing.inOut(Easing.ease),
},
},
}}
>
<Tabs.Screen
name="index"
options={{
header: () => <UserHeader />,
title: t("home"),
tabBarIcon: ({ focused }) => (
<Icons.House
size={22}
className={cn("text-muted-foreground", {
"text-primary": focused,
})}
/>
),
tabBarLabel: TabBarLabel,
}}
/>
<Tabs.Screen
name="settings"
options={{
headerShown: false,
title: t("settings"),
tabBarIcon: ({ focused }) => (
<Icons.Settings
size={22}
className={cn("text-muted-foreground", {
"text-primary": focused,
})}
/>
),
tabBarLabel: TabBarLabel,
}}
/>
<Tabs.Screen
name="members"
options={{
headerShown: false,
title: t("members"),
tabBarIcon: ({ focused }) => (
<Icons.UsersRound
size={22}
className={cn("text-muted-foreground", {
"text-primary": focused,
})}
/>
),
tabBarLabel: TabBarLabel,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,26 @@
import { View } from "react-native";
import { BuiltWith } from "@turbostarter/ui-mobile/built-with";
import { ScrollView } from "~/modules/common/styled";
import { AreaChart } from "~/modules/home/charts/area";
import { BarChart } from "~/modules/home/charts/bar";
import { PieChart } from "~/modules/home/charts/pie";
export default function Home() {
return (
<ScrollView
className="bg-background"
contentContainerClassName="gap-4 items-center bg-background px-6 py-2"
showsVerticalScrollIndicator={false}
>
<BarChart />
<PieChart />
<AreaChart />
<View className="pt-4 pb-10">
<BuiltWith />
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,65 @@
import { useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@turbostarter/ui-mobile/tabs";
import { Text } from "@turbostarter/ui-mobile/text";
import { authClient } from "~/lib/auth";
import { SafeAreaView } from "~/modules/common/styled";
import { InvitationsList } from "~/modules/organization/invitations/list/invitations-list";
import { toMemberRole } from "~/modules/organization/lib/utils";
import { InviteMemberBottomSheet } from "~/modules/organization/members/invite-member";
import { MembersList } from "~/modules/organization/members/list/members-list";
export default function Members() {
const { t } = useTranslation(["common", "organization"]);
const [tab, setTab] = useState("members");
const activeMember = authClient.useActiveMember();
const hasInvitePermission = authClient.organization.checkRolePermission({
permission: {
invitation: ["create"],
},
role: toMemberRole(activeMember.data?.role),
});
return (
<SafeAreaView
className="bg-background flex-1 gap-4 p-6"
edges={["top", "left", "right"]}
>
<Tabs value={tab} onValueChange={setTab} className="flex-1">
<TabsList className="w-full">
<TabsTrigger value="members" className="grow">
<Text>{t("members.title")}</Text>
</TabsTrigger>
<TabsTrigger value="invitations" className="grow">
<Text>{t("invitations.title")}</Text>
</TabsTrigger>
</TabsList>
<TabsContent value="members" className="flex-1">
<MembersList />
</TabsContent>
<TabsContent value="invitations" className="flex-1">
<InvitationsList />
</TabsContent>
</Tabs>
<InviteMemberBottomSheet>
<Button disabled={!hasInvitePermission} size="lg">
<Icons.UserRoundPlus size={20} className="text-primary-foreground" />
<Text>{t("invite")}</Text>
</Button>
</InviteMemberBottomSheet>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,16 @@
import { Stack } from "expo-router";
export default function SettingsLayout() {
return (
<Stack
initialRouteName="index"
screenOptions={{
headerShown: false,
animation: "fade",
animationDuration: 200,
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
</Stack>
);
}

View File

@@ -0,0 +1,106 @@
import Constants from "expo-constants";
import * as Linking from "expo-linking";
import { router } from "expo-router";
import * as StoreReview from "expo-store-review";
import { Share, View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths";
import { SettingsTile } from "~/modules/common/settings-tile";
import { SafeAreaView, ScrollView } from "~/modules/common/styled";
import { organization } from "~/modules/organization/lib/api";
import { OrganizationInfo } from "~/modules/organization/settings/organization-info";
const sections = [
[
{
title: "organization",
icon: Icons.Building,
onPress: () =>
router.navigate(
pathsConfig.dashboard.organization.settings.organization.index,
),
visible: true,
},
],
[
{
title: "account",
icon: Icons.UserRound,
onPress: () => {
void organization.mutations.setActive.mutationFn({
organizationId: null,
});
router.navigate(pathsConfig.dashboard.user.settings.index);
},
visible: true,
},
],
[
{
title: "rate",
icon: Icons.ThumbsUp,
onPress: async () => {
const available = await StoreReview.hasAction();
if (available) {
return await StoreReview.requestReview();
}
return Share.share({
title: Constants.expoConfig?.name,
message: appConfig.url,
});
},
visible: true,
},
{
title: "share",
icon: Icons.Share2,
onPress: () =>
Share.share({
title: Constants.expoConfig?.name,
message: appConfig.url,
}),
visible: true,
},
{
title: "privacy",
icon: Icons.Lock,
onPress: () => Linking.openURL(`${appConfig.url}/legal/privacy-policy`),
visible: true,
},
],
] as const;
export default function Settings() {
const { t } = useTranslation("common");
return (
<SafeAreaView className="bg-background flex-1">
<ScrollView
className="bg-background flex-1"
contentContainerClassName="gap-8 py-6"
bounces={false}
>
<OrganizationInfo />
<View className="gap-6">
{sections.map((section, index) => (
<View key={index}>
{section.map((item) => (
<SettingsTile {...item} key={item.title}>
<Text>{t(item.title)}</Text>
</SettingsTile>
))}
</View>
))}
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,33 @@
import { router, Stack } from "expo-router";
import { useTranslation } from "@turbostarter/i18n";
import { isKey } from "@turbostarter/i18n";
import { capitalize } from "@turbostarter/shared/utils";
import { BaseHeader } from "~/modules/common/layout/header";
export default function GeneralLayout() {
const { t, i18n } = useTranslation("common");
return (
<Stack
initialRouteName="index"
screenOptions={({ route }) => {
const name = route.name === "index" ? "organization" : route.name;
return {
header: () => (
<BaseHeader
title={isKey(name, i18n, "common") ? t(name) : capitalize(name)}
{...(router.canGoBack() && {
onBack: () => router.back(),
})}
/>
),
animation: "fade",
animationDuration: 200,
};
}}
/>
);
}

View File

@@ -0,0 +1,44 @@
import { router } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth";
import { SettingsTile } from "~/modules/common/settings-tile";
import { toMemberRole } from "~/modules/organization/lib/utils";
import { DeleteOrganization } from "~/modules/organization/settings/delete-organization";
import { LeaveOrganization } from "~/modules/organization/settings/leave-organization";
export default function Organization() {
const { t } = useTranslation("common");
const { data: activeMember } = authClient.useActiveMember();
const hasUpdatePermission = authClient.organization.checkRolePermission({
permission: {
organization: ["update"],
},
role: toMemberRole(activeMember?.role),
});
return (
<View className="bg-background flex-1 gap-6 py-2">
{hasUpdatePermission && (
<SettingsTile
icon={Icons.IdCard}
onPress={() =>
router.navigate(
pathsConfig.dashboard.organization.settings.organization.name,
)
}
>
<Text>{t("name")}</Text>
</SettingsTile>
)}
<LeaveOrganization />
<DeleteOrganization />
</View>
);
}

View File

@@ -0,0 +1,111 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { useForm } from "react-hook-form";
import { View } from "react-native";
import { updateOrganizationSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormField,
FormItem,
FormInput,
FormDescription,
} 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 { authClient } from "~/lib/auth";
import { organization } from "~/modules/organization/lib/api";
import { toMemberRole } from "~/modules/organization/lib/utils";
const EditName = () => {
const { t } = useTranslation(["common", "organization"]);
const { data: activeOrganization, refetch } =
authClient.useActiveOrganization();
const { data: activeMember } = authClient.useActiveMember();
const form = useForm({
resolver: standardSchemaResolver(
updateOrganizationSchema.pick({ name: true }),
),
defaultValues: {
name: activeOrganization?.name,
},
});
const hasUpdatePermission = authClient.organization.checkRolePermission({
permission: {
organization: ["update"],
},
role: toMemberRole(activeMember?.role),
});
const updateOrganization = useMutation({
...organization.mutations.update,
onSuccess: async () => {
await refetch();
router.back();
},
});
if (!activeOrganization || !hasUpdatePermission) {
return null;
}
return (
<View className="bg-background flex-1 p-6">
<Form {...form}>
<View className="flex-1 gap-6">
<Text className="text-muted-foreground font-sans-medium">
{t("name.edit.description")}
</Text>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormInput
{...field}
label={t("common:name")}
autoCapitalize="words"
autoComplete="name"
editable={!form.formState.isSubmitting}
value={field.value ?? ""}
/>
<FormDescription>{t("name.edit.info")}</FormDescription>
</FormItem>
)}
/>
<Button
className="w-full"
size="lg"
onPress={form.handleSubmit((data) =>
updateOrganization.mutateAsync({
data,
organizationId: activeOrganization.id,
}),
)}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground" />
</Spin>
) : (
<Text>{t("save")}</Text>
)}
</Button>
</View>
</Form>
</View>
);
};
export default EditName;

View File

@@ -0,0 +1,29 @@
import { Redirect } from "expo-router";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth";
import { Spinner } from "~/modules/common/spinner";
export default function Index() {
const { data, isPending } = authClient.useSession();
const { step } = useSetupSteps();
if (isPending) {
return <Spinner modal={false} />;
}
if (!data) {
return <Redirect href={pathsConfig.setup.welcome} />;
}
if (step) {
return <Redirect href={step} />;
}
if (data.session.activeOrganizationId) {
return <Redirect href={pathsConfig.dashboard.organization.index} />;
}
return <Redirect href={pathsConfig.dashboard.user.index} />;
}

View File

@@ -0,0 +1,15 @@
@import "@turbostarter/ui/globals.css";
@import "uniwind";
@source "../../app";
@source "../../modules";
@source "../../../../../packages/ui/shared/";
@source "../../../../../packages/ui/mobile/";
@theme inline {
--font-sans: "Geist_400Regular";
--font-sans-medium: "Geist_500Medium";
--font-sans-semibold: "Geist_600SemiBold";
--font-sans-bold: "Geist_700Bold";
--font-mono: "GeistMono_400Regular";
}

View File

@@ -0,0 +1,10 @@
import env from "env.config";
export const appConfig = {
locale: env.EXPO_PUBLIC_DEFAULT_LOCALE,
url: env.EXPO_PUBLIC_SITE_URL,
theme: {
mode: env.EXPO_PUBLIC_THEME_MODE,
color: env.EXPO_PUBLIC_THEME_COLOR,
},
} as const;

View File

@@ -0,0 +1,21 @@
import env from "env.config";
import { Platform } from "react-native";
import { SocialProvider, authConfigSchema } from "@turbostarter/auth";
import type { AuthConfig } from "@turbostarter/auth";
export const authConfig = authConfigSchema.parse({
providers: {
password: env.EXPO_PUBLIC_AUTH_PASSWORD,
magicLink: env.EXPO_PUBLIC_AUTH_MAGIC_LINK,
anonymous: env.EXPO_PUBLIC_AUTH_ANONYMOUS,
oAuth: [
Platform.select({
android: SocialProvider.GOOGLE,
ios: SocialProvider.APPLE,
}),
SocialProvider.GITHUB,
],
},
}) satisfies AuthConfig;

View File

@@ -0,0 +1,62 @@
const STEPS_PREFIX = "/steps";
const DASHBOARD_PREFIX = "/dashboard";
const ORGANIZATION_DASHBOARD_PREFIX = "/dashboard/organization";
const AUTH_PREFIX = `/auth`;
const pathsConfig = {
index: "/",
setup: {
welcome: "/welcome",
auth: {
login: `${AUTH_PREFIX}/login`,
register: `${AUTH_PREFIX}/register`,
forgotPassword: `${AUTH_PREFIX}/password/forgot`,
updatePassword: `${AUTH_PREFIX}/password/update`,
error: `${AUTH_PREFIX}/error`,
join: `${AUTH_PREFIX}/join`,
},
steps: {
start: `${STEPS_PREFIX}/start`,
required: `${STEPS_PREFIX}/required`,
skip: `${STEPS_PREFIX}/skip`,
final: `${STEPS_PREFIX}/final`,
},
},
dashboard: {
user: {
index: DASHBOARD_PREFIX,
ai: `${DASHBOARD_PREFIX}/ai`,
settings: {
index: `${DASHBOARD_PREFIX}/settings`,
general: {
index: `${DASHBOARD_PREFIX}/settings/general`,
notifications: `${DASHBOARD_PREFIX}/settings/general/notifications`,
},
account: {
index: `${DASHBOARD_PREFIX}/settings/account`,
name: `${DASHBOARD_PREFIX}/settings/account/name`,
email: `${DASHBOARD_PREFIX}/settings/account/email`,
password: `${DASHBOARD_PREFIX}/settings/account/password`,
accounts: `${DASHBOARD_PREFIX}/settings/account/accounts`,
twoFactor: `${DASHBOARD_PREFIX}/settings/account/two-factor`,
sessions: `${DASHBOARD_PREFIX}/settings/account/sessions`,
},
billing: `${DASHBOARD_PREFIX}/settings/billing`,
},
},
organization: {
index: ORGANIZATION_DASHBOARD_PREFIX,
settings: {
index: `${ORGANIZATION_DASHBOARD_PREFIX}/settings`,
organization: {
index: `${ORGANIZATION_DASHBOARD_PREFIX}/settings/organization`,
name: `${ORGANIZATION_DASHBOARD_PREFIX}/settings/organization/name`,
},
},
members: `${ORGANIZATION_DASHBOARD_PREFIX}/members`,
},
},
} as const;
export { pathsConfig, AUTH_PREFIX, STEPS_PREFIX, DASHBOARD_PREFIX };

View File

@@ -0,0 +1,21 @@
import { hc } from "hono/client";
import { config } from "@turbostarter/i18n";
import { authClient } from "~/lib/auth";
import { useI18nConfig } from "~/lib/providers/i18n";
import { getBaseUrl } from "./utils";
import type { AppRouter } from "@turbostarter/api";
export const { api } = hc<AppRouter>(getBaseUrl(), {
headers: () => ({
cookie: `${config.cookie}=${useI18nConfig.getState().config.locale};${authClient.getCookie()}`,
"x-client-platform": "mobile",
}),
init: {
/* https://github.com/better-auth/better-auth/issues/2970 */
credentials: "omit",
},
});

View File

@@ -0,0 +1,23 @@
import env from "env.config";
import Constants from "expo-constants";
import { logger } from "@turbostarter/shared/logger";
export const getBaseUrl = () => {
/**
* Gets the IP address of your host-machine. If it cannot automatically find it,
* you'll have to manually set it. NOTE: Port 3000 should work for most but confirm
* you don't have anything else running on it, or you'd have to change it.
*
* **NOTE**: This is only for development. In production, you'll want to set the
* baseUrl to your production API URL.
*/
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(":")[0];
if (!localhost) {
logger.warn("Failed to get localhost. Pointing to production server...");
return env.EXPO_PUBLIC_SITE_URL;
}
return `http://${localhost}:3000`;
};

View File

@@ -0,0 +1,26 @@
import * as SecureStore from "expo-secure-store";
import { createClient } from "@turbostarter/auth/client/mobile";
import { config } from "@turbostarter/i18n";
import { getBaseUrl } from "~/lib/api/utils";
import { useI18nConfig } from "~/lib/providers/i18n";
export const authClient = createClient({
baseURL: getBaseUrl(),
disableDefaultFetchPlugins: true,
mobile: {
storage: SecureStore,
cookiePrefix: "turbostarter",
},
lastLoginMethod: {
storage: SecureStore,
},
fetchOptions: {
headers: {
Cookie: `${config.cookie}=${useI18nConfig.getState().config.locale}`,
"x-client-platform": "mobile",
},
throw: true,
},
});

View File

@@ -0,0 +1,28 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import structuredClone from "@ungap/structured-clone";
import { Platform } from "react-native";
if (Platform.OS !== "web") {
const setupPolyfills = async () => {
const { polyfillGlobal } = await import(
// @ts-expect-error - polyfillGlobal is not typed
"react-native/Libraries/Utilities/PolyfillFunctions"
);
const { TextEncoderStream, TextDecoderStream } = await import(
"@stardazed/streams-text-encoding"
);
if (!("structuredClone" in global)) {
polyfillGlobal("structuredClone", () => structuredClone);
}
polyfillGlobal("TextEncoderStream", () => TextEncoderStream);
polyfillGlobal("TextDecoderStream", () => TextDecoderStream);
};
void setupPolyfills();
}
export {};

View File

@@ -0,0 +1,28 @@
import { useEffect } from "react";
import { identify, Provider, reset } from "@turbostarter/analytics-mobile";
import { authClient } from "~/lib/auth";
export const AnalyticsProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const session = authClient.useSession();
useEffect(() => {
if (session.isPending) {
return;
}
if (session.data?.user) {
const { id, email, name } = session.data.user;
identify(id, { email, name });
} else {
reset();
}
}, [session]);
return <Provider>{children}</Provider>;
};

View File

@@ -0,0 +1,43 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { getLocales } from "expo-localization";
import { memo } from "react";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { config, I18nProvider as I18nClientProvider } from "@turbostarter/i18n";
import { appConfig } from "~/config/app";
export const useI18nConfig = create<{
config: {
locale?: string;
};
setConfig: (config: { locale?: string }) => void;
}>()(
persist(
(set) => ({
config: { locale: getLocales()[0]?.languageCode ?? config.defaultLocale },
setConfig: (config) => set({ config }),
}),
{
name: "i18n-config",
storage: createJSONStorage(() => AsyncStorage),
},
),
);
interface I18nProviderProps {
readonly children: React.ReactNode;
}
export const I18nProvider = memo<I18nProviderProps>(({ children }) => {
const config = useI18nConfig((state) => state.config);
return (
<I18nClientProvider locale={config.locale} defaultLocale={appConfig.locale}>
{children}
</I18nClientProvider>
);
});
I18nProvider.displayName = "I18nProvider";

View File

@@ -0,0 +1,25 @@
import { useEffect } from "react";
import { initialize, identify } from "@turbostarter/monitoring-mobile";
import { authClient } from "~/lib/auth";
initialize();
export const MonitoringProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const session = authClient.useSession();
useEffect(() => {
if (session.isPending) {
return;
}
identify(session.data?.user ?? null);
}, [session]);
return children;
};

View File

@@ -0,0 +1,52 @@
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalHost } from "@rn-primitives/portal";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
import { memo } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { I18nProvider } from "~/lib/providers/i18n";
import { ThemeProvider } from "~/lib/providers/theme";
import { QueryClientProvider } from "~/lib/query";
import { Verification } from "~/modules/auth/verification";
import { AnalyticsProvider } from "./analytics";
import { MonitoringProvider } from "./monitoring";
dayjs.extend(duration);
dayjs.extend(relativeTime);
interface ProvidersProps {
readonly children: React.ReactNode;
}
export const Providers = memo<ProvidersProps>(({ children }) => {
return (
<GestureHandlerRootView>
<QueryClientProvider>
<I18nProvider>
<SafeAreaProvider>
<ThemeProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<MonitoringProvider>
<AnalyticsProvider>
{children}
<Verification />
<PortalHost />
</AnalyticsProvider>
</MonitoringProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</ThemeProvider>
</SafeAreaProvider>
</I18nProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
});
Providers.displayName = "Providers";

View File

@@ -0,0 +1,44 @@
import {
DarkTheme,
DefaultTheme,
ThemeProvider as NavigationThemeProvider,
} from "@react-navigation/native";
import * as NavigationBar from "expo-navigation-bar";
import { memo } from "react";
import { StatusBar, View } from "react-native";
import { ThemeMode } from "@turbostarter/ui";
import { useTheme } from "~/modules/common/hooks/use-theme";
import { isAndroid } from "~/utils/device";
interface ThemeProviderProps {
readonly children: React.ReactNode;
}
export const ThemeProvider = memo<ThemeProviderProps>(({ children }) => {
const { resolvedTheme } = useTheme();
if (isAndroid) {
void NavigationBar.setButtonStyleAsync(
resolvedTheme === ThemeMode.DARK ? ThemeMode.LIGHT : ThemeMode.DARK,
);
}
return (
<NavigationThemeProvider
value={resolvedTheme === ThemeMode.DARK ? DarkTheme : DefaultTheme}
>
<View className="bg-background flex-1">{children}</View>
<StatusBar
barStyle={
resolvedTheme === ThemeMode.DARK ? "light-content" : "dark-content"
}
translucent
backgroundColor="transparent"
/>
</NavigationThemeProvider>
);
});
ThemeProvider.displayName = "ThemeProvider";

View File

@@ -0,0 +1,49 @@
import { useReactQueryDevTools } from "@dev-plugins/react-query";
import {
QueryClient,
QueryClientProvider as TanstackQueryClientProvider,
} from "@tanstack/react-query";
import { onlineManager } from "@tanstack/react-query";
import * as Network from "expo-network";
import { useState } from "react";
import { Alert } from "react-native";
import { logger } from "@turbostarter/shared/logger";
import { useRefetchOnAppFocus } from "~/modules/common/hooks/use-refetch-on-app-focus";
onlineManager.setEventListener((setOnline) => {
const eventSubscription = Network.addNetworkStateListener((state) => {
setOnline(!!state.isConnected);
});
return () => eventSubscription.remove();
});
export function QueryClientProvider(props: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error: Error | { error: Error }) => {
if ("error" in error) {
error = error.error;
}
logger.error(error);
Alert.alert(error.message);
},
},
},
}),
);
useRefetchOnAppFocus();
useReactQueryDevTools(queryClient);
return (
<TanstackQueryClientProvider client={queryClient}>
{props.children}
</TanstackQueryClientProvider>
);
}

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

View File

@@ -0,0 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { billing } from "~/modules/billing/lib/api";
export const useCustomer = () => useQuery(billing.queries.customer.get);

View File

@@ -0,0 +1,18 @@
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api";
const KEY = "billing";
const queries = {
customer: {
get: {
queryKey: [KEY, "customer"],
queryFn: () => handle(api.billing.customer.$get)(),
},
},
};
export const billing = {
queries,
};

View File

@@ -0,0 +1,379 @@
import { useMutation, useMutationState } from "@tanstack/react-query";
import { createContext, useContext, useState } from "react";
import { FormProvider, useForm, useFormContext } from "react-hook-form";
import { Alert, Text, View } from "react-native";
import * as z from "zod";
import { handle } from "@turbostarter/api/utils";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-mobile/avatar";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { api } from "~/lib/api";
import { useImagePicker } from "~/modules/common/hooks/use-image-picker";
import type { ImagePickerAsset } from "expo-image-picker";
interface AvatarFormProps {
readonly id: string;
readonly image?: string | null;
readonly update: (image: string | null) => Promise<unknown>;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
];
const mutations = {
upload: {
mutationKey: ["avatar", "upload"] as const,
mutationFn: async ({
avatar,
id,
image,
update,
}: AvatarFormProps & { avatar?: ImagePickerAsset }) => {
if (!avatar) throw new Error("No file selected");
const guessedExtensionFromMime = avatar.mimeType?.split("/").pop();
const guessedExtensionFromUri = avatar.uri.split(".").pop();
const extension =
guessedExtensionFromMime ?? guessedExtensionFromUri ?? "jpg";
const uuid = String(Date.now());
const path = `avatars/${id}-${uuid}.${extension}`;
const blob = await fetch(avatar.uri).then((r) => r.blob());
const { url: uploadUrl } = await handle(api.storage.upload.$get)({
query: { path },
});
const response = await fetch(uploadUrl, {
method: "PUT",
body: blob,
headers: {
"Content-Type": avatar.mimeType ?? "",
},
});
if (!response.ok) {
throw new Error();
}
const { url: publicUrl } = await handle(api.storage.public.$get)({
query: { path },
});
await update(publicUrl);
return { publicUrl, oldImage: image };
},
},
remove: {
mutationKey: ["avatar", "remove"] as const,
mutationFn: async ({ image, update }: Omit<AvatarFormProps, "id">) => {
const path = image?.split("/").pop();
if (!path) return;
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
query: { path: `avatars/${path}` },
});
await update(null);
void fetch(deleteUrl, { method: "DELETE" });
},
},
} as const;
const useAvatarFormSchema = () => {
const assetSchema = z.object({
uri: z.string().min(1),
mimeType: z.string().optional(),
fileSize: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
});
return z.object({
avatar: assetSchema
.refine((file) => (file.fileSize ?? MAX_FILE_SIZE) <= MAX_FILE_SIZE, {
message: "error.tooBig.file.inclusive",
path: ["avatar"],
})
.refine(
(file) =>
!file.mimeType || ACCEPTED_IMAGE_TYPES.includes(file.mimeType),
{
message: "error.file.type",
path: ["avatar"],
},
),
});
};
interface AvatarFormContextValue extends AvatarFormProps {
previewUrl: string | null;
setPreviewUrl: (previewUrl: string | null) => void;
}
const AvatarFormContext = createContext<AvatarFormContextValue | null>(null);
const useAvatarFormContext = () => {
const context = useContext(AvatarFormContext);
if (!context) {
throw new Error("useAvatarFormContext must be used within a AvatarForm!");
}
return context;
};
const AvatarForm = ({
id,
image,
update,
children,
}: AvatarFormProps & { children: React.ReactNode }) => {
const [previewUrl, setPreviewUrl] = useState(image ?? null);
const _avatarSchema = useAvatarFormSchema();
const form = useForm<z.infer<typeof _avatarSchema>>();
return (
<AvatarFormContext.Provider
value={{ id, image, update, previewUrl, setPreviewUrl }}
>
<FormProvider {...form}>{children}</FormProvider>
</AvatarFormContext.Provider>
);
};
const AvatarFormUploadButton = ({
className,
onUpload,
disabled,
...props
}: React.ComponentProps<typeof Button> & { onUpload?: () => void }) => {
const { t } = useTranslation(["common", "validation"]);
const { pick } = useImagePicker();
const { image, setPreviewUrl, id, update } = useAvatarFormContext();
const avatarSchema = useAvatarFormSchema();
const { setError, clearErrors } =
useFormContext<z.infer<typeof avatarSchema>>();
const upload = useMutation({
...mutations.upload,
onError: (error) => {
setPreviewUrl(image ?? null);
Alert.alert(
t("common:error.title"),
error.message || t("common:error.general"),
);
},
onSuccess: async ({ publicUrl, oldImage }) => {
clearErrors();
setPreviewUrl(publicUrl);
if (oldImage) {
const oldPath = oldImage.split("/").pop();
if (oldPath) {
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
query: { path: `avatars/${oldPath}` },
});
void fetch(deleteUrl, { method: "DELETE" });
}
}
onUpload?.();
},
});
const [removeStatus] = useMutationState({
filters: { mutationKey: mutations.remove.mutationKey },
select: (mutation) => mutation.state.status,
});
const handlePick = async () => {
const asset = await pick();
if (!asset) {
return;
}
const result = avatarSchema.safeParse({ avatar: asset });
if (!result.success) {
const firstIssue = result.error.issues[0];
const firstMsg = firstIssue?.message ?? "";
setError("avatar", { message: firstMsg });
Alert.alert(t("common:error.title"), firstMsg);
return;
}
setPreviewUrl(asset.uri);
upload.mutate({
avatar: asset,
id,
image,
update,
});
};
return (
<Button
hitSlop={4}
variant="outline"
size="icon"
className={cn(
"dark:bg-background active:bg-muted absolute -right-2 -bottom-2.5 rounded-full",
className,
)}
onPress={handlePick}
disabled={disabled ?? (upload.isPending || removeStatus === "pending")}
{...props}
>
<Icons.Pencil size={14} className="text-foreground" />
</Button>
);
};
const AvatarFormPreview = ({
className,
fallback,
...props
}: React.ComponentProps<typeof Avatar> & { fallback?: React.ReactNode }) => {
const { previewUrl } = useAvatarFormContext();
const _avatarSchema = useAvatarFormSchema();
const { formState } = useFormContext<z.infer<typeof _avatarSchema>>();
const mutationStatuses = useMutationState({
filters: {
predicate: (mutation) =>
mutation.options.mutationKey === mutations.upload.mutationKey ||
mutation.options.mutationKey === mutations.remove.mutationKey,
},
select: (mutation) => mutation.state.status,
});
const hasError =
Boolean(formState.errors.avatar) ||
mutationStatuses.some((s) => s === "error");
return (
<View className="relative">
<Avatar
className={cn(
"size-26",
hasError ? "ring-destructive ring-2 ring-offset-2" : "",
className,
)}
{...props}
>
{previewUrl && <AvatarImage source={{ uri: previewUrl }} />}
{mutationStatuses.some((status) => status === "pending") && (
<>
<View className="bg-background absolute inset-0 rounded-full opacity-50" />
<View className="absolute inset-0 items-center justify-center rounded-full">
<Spin>
<Icons.Loader2 className="text-muted-foreground" size={28} />
</Spin>
</View>
</>
)}
<AvatarFallback>
{fallback ?? (
<Icons.UserRound
width={50}
height={50}
className="text-foreground"
/>
)}
</AvatarFallback>
</Avatar>
</View>
);
};
const AvatarFormRemoveButton = ({
className,
onRemove,
...props
}: React.ComponentProps<typeof Button> & { onRemove?: () => void }) => {
const { image, update, previewUrl, setPreviewUrl } = useAvatarFormContext();
const { clearErrors } = useFormContext();
const [uploadStatus] = useMutationState({
filters: {
mutationKey: mutations.upload.mutationKey,
},
select: (mutation) => mutation.state.status,
});
const remove = useMutation({
...mutations.remove,
onMutate: () => {
setPreviewUrl(null);
},
onSuccess: () => {
setPreviewUrl(null);
onRemove?.();
},
});
if (!previewUrl || uploadStatus === "pending") {
return null;
}
return (
<Button
variant="outline"
size="icon"
className={cn(
"dark:bg-background active:bg-muted absolute -top-2 -right-2 rounded-full",
className,
)}
disabled={remove.isPending}
onPress={() => {
clearErrors();
remove.mutate({ image, update });
}}
{...props}
>
<Icons.X size={16} strokeWidth={2} className="text-foreground" />
</Button>
);
};
const AvatarFormErrorMessage = ({
className,
...props
}: React.ComponentProps<typeof Text>) => {
const _avatarSchema = useAvatarFormSchema();
const { formState } = useFormContext<z.infer<typeof _avatarSchema>>();
if (!formState.errors.avatar) {
return null;
}
return (
<Text className={cn("text-destructive text-xs", className)} {...props}>
{formState.errors.avatar.message}
</Text>
);
};
export {
AvatarForm,
AvatarFormPreview,
AvatarFormRemoveButton,
AvatarFormUploadButton,
AvatarFormErrorMessage,
};

View File

@@ -0,0 +1,21 @@
import * as Clipboard from "expo-clipboard";
import { useCallback, useState } from "react";
import { logger } from "@turbostarter/shared/logger";
export const useCopyToClipboard = () => {
const [copiedText, setCopiedText] = useState<string | null>(null);
const copy = useCallback(async (text: string): Promise<boolean> => {
try {
await Clipboard.setStringAsync(text);
setCopiedText(text);
return true;
} catch (error) {
logger.error("Failed to copy to clipboard:", error);
return false;
}
}, []);
return [copiedText, copy] as const;
};

View File

@@ -0,0 +1,36 @@
import * as ExpoImagePicker from "expo-image-picker";
import { useCallback } from "react";
import { logger } from "@turbostarter/shared/logger";
export const useImagePicker = () => {
const pick = useCallback(async () => {
try {
const result = await ExpoImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
quality: 0.6,
});
if (result.canceled) {
return;
}
const pendingResult = await ExpoImagePicker.getPendingResultAsync();
const image =
result.assets[0] ??
(pendingResult && "assets" in pendingResult
? pendingResult.assets?.[0]
: null);
return image;
} catch (e) {
logger.error("Error on image pick: ", e);
}
}, []);
return {
pick,
};
};

Some files were not shown because too many files have changed in this diff Show More