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>
54
apps/mobile/.env.example
Normal 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
@@ -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],
|
||||
],
|
||||
});
|
||||
8
apps/mobile/babel.config.js
Normal 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
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
11
apps/mobile/eslint.config.mjs
Normal 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,
|
||||
];
|
||||
67
apps/mobile/metro.config.js
Normal 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
@@ -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:"
|
||||
}
|
||||
}
|
||||
BIN
apps/mobile/public/images/icon/android/adaptive.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/mobile/public/images/icon/android/monochrome.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/mobile/public/images/icon/ios.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
apps/mobile/public/images/setup/1/dark.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
apps/mobile/public/images/setup/1/light.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
apps/mobile/public/images/setup/2/dark.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
apps/mobile/public/images/setup/2/light.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
apps/mobile/public/images/setup/3/dark.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
apps/mobile/public/images/setup/3/light.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
apps/mobile/public/images/splash/splash.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
14
apps/mobile/src/app/(setup)/_layout.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
apps/mobile/src/app/(setup)/auth/_layout.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
apps/mobile/src/app/(setup)/auth/error.tsx
Normal 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;
|
||||
99
apps/mobile/src/app/(setup)/auth/join.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
apps/mobile/src/app/(setup)/auth/login.tsx
Normal 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;
|
||||
20
apps/mobile/src/app/(setup)/auth/password/forgot.tsx
Normal 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;
|
||||
24
apps/mobile/src/app/(setup)/auth/password/update.tsx
Normal 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;
|
||||
49
apps/mobile/src/app/(setup)/auth/register.tsx
Normal 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;
|
||||
143
apps/mobile/src/app/(setup)/steps/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/mobile/src/app/(setup)/steps/final.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
113
apps/mobile/src/app/(setup)/steps/required.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
apps/mobile/src/app/(setup)/steps/skip.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
apps/mobile/src/app/(setup)/steps/start.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
107
apps/mobile/src/app/(setup)/welcome.tsx
Normal 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;
|
||||
32
apps/mobile/src/app/+not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/mobile/src/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
apps/mobile/src/app/dashboard/(user)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
apps/mobile/src/app/dashboard/(user)/ai.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
apps/mobile/src/app/dashboard/(user)/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/mobile/src/app/dashboard/(user)/settings/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
172
apps/mobile/src/app/dashboard/(user)/settings/account/email.tsx
Normal 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;
|
||||
131
apps/mobile/src/app/dashboard/(user)/settings/account/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
31
apps/mobile/src/app/dashboard/(user)/settings/billing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
113
apps/mobile/src/app/dashboard/(user)/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/mobile/src/app/dashboard/_layout.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
83
apps/mobile/src/app/dashboard/organization/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/mobile/src/app/dashboard/organization/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/mobile/src/app/dashboard/organization/members.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
106
apps/mobile/src/app/dashboard/organization/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
29
apps/mobile/src/app/index.tsx
Normal 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} />;
|
||||
}
|
||||
15
apps/mobile/src/assets/styles/globals.css
Normal 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";
|
||||
}
|
||||
10
apps/mobile/src/config/app.ts
Normal 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;
|
||||
21
apps/mobile/src/config/auth.ts
Normal 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;
|
||||
62
apps/mobile/src/config/paths.ts
Normal 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 };
|
||||
21
apps/mobile/src/lib/api/index.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
23
apps/mobile/src/lib/api/utils.ts
Normal 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`;
|
||||
};
|
||||
26
apps/mobile/src/lib/auth/index.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
28
apps/mobile/src/lib/polyfills.ts
Normal 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 {};
|
||||
28
apps/mobile/src/lib/providers/analytics.tsx
Normal 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>;
|
||||
};
|
||||
43
apps/mobile/src/lib/providers/i18n.tsx
Normal 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";
|
||||
25
apps/mobile/src/lib/providers/monitoring.tsx
Normal 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;
|
||||
};
|
||||
52
apps/mobile/src/lib/providers/providers.tsx
Normal 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";
|
||||
44
apps/mobile/src/lib/providers/theme.tsx
Normal 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";
|
||||
49
apps/mobile/src/lib/query.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
apps/mobile/src/modules/auth/form/anonymous.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import { useAuthFormStore } from "./store";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
interface AnonymousLoginProps {
|
||||
readonly redirectTo?: Route;
|
||||
}
|
||||
|
||||
export const AnonymousLogin = ({
|
||||
redirectTo = pathsConfig.index,
|
||||
}: AnonymousLoginProps) => {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.anonymous,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.ANONYMOUS);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.navigate(redirectTo);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-row gap-2"
|
||||
size="lg"
|
||||
disabled={isSubmitting}
|
||||
onPress={() => signIn.mutate(undefined)}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.ANONYMOUS ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<>
|
||||
<Icons.UserRound className="text-foreground" size={16} />
|
||||
<Text>{t("login.anonymous.cta")}</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
5
apps/mobile/src/modules/auth/form/login/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
|
||||
export const LOGIN_OPTIONS = [AuthProvider.PASSWORD, AuthProvider.MAGIC_LINK];
|
||||
|
||||
export type LoginOption = (typeof LOGIN_OPTIONS)[number];
|
||||
138
apps/mobile/src/modules/auth/form/login/form.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { Suspense, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-mobile/badge";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@turbostarter/ui-mobile/tabs";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { Link } from "~/modules/common/styled";
|
||||
|
||||
import { MagicLinkLoginForm } from "./magic-link";
|
||||
import { PasswordLoginForm } from "./password";
|
||||
|
||||
import type { LoginOption } from "./constants";
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
const LOGIN_OPTIONS_DETAILS = {
|
||||
[AuthProvider.PASSWORD]: {
|
||||
lastUsedMethodId: "email",
|
||||
component: PasswordLoginForm,
|
||||
label: "password",
|
||||
},
|
||||
[AuthProvider.MAGIC_LINK]: {
|
||||
lastUsedMethodId: AuthProvider.MAGIC_LINK,
|
||||
component: MagicLinkLoginForm,
|
||||
label: "login.magicLink.label",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface LoginFormProps {
|
||||
readonly options: LoginOption[];
|
||||
readonly redirectTo?: Route;
|
||||
readonly email?: string;
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
export const LoginForm = ({
|
||||
options,
|
||||
redirectTo,
|
||||
email,
|
||||
onTwoFactorRedirect,
|
||||
}: LoginFormProps) => {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const [mainOption] = options;
|
||||
|
||||
const [value, setValue] = useState(mainOption);
|
||||
|
||||
if (!options.length || !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.length === 1) {
|
||||
const Component = LOGIN_OPTIONS_DETAILS[value].component;
|
||||
return (
|
||||
<Component
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
onValueChange={(val) => setValue(val as LoginOption)}
|
||||
className="flex w-full flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<TabsList className="w-full flex-row">
|
||||
{options.map((provider) => (
|
||||
<TabsTrigger
|
||||
key={provider}
|
||||
value={provider}
|
||||
className="relative grow"
|
||||
>
|
||||
<Text>{t(LOGIN_OPTIONS_DETAILS[provider].label)}</Text>
|
||||
|
||||
{authClient.isLastUsedLoginMethod(
|
||||
LOGIN_OPTIONS_DETAILS[provider].lastUsedMethodId,
|
||||
) && (
|
||||
<Badge className="absolute -top-3 -right-4 shadow-sm">
|
||||
<Text>{t("lastUsed")}</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{options.map((provider) => {
|
||||
const Component = LOGIN_OPTIONS_DETAILS[provider].component;
|
||||
return (
|
||||
<TabsContent key={provider} value={provider} className="w-full">
|
||||
<Suspense>
|
||||
<Component
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoginCta = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
const localParams = useLocalSearchParams();
|
||||
const searchParams = new URLSearchParams(
|
||||
localParams as Record<string, string>,
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="items-center justify-center pt-2">
|
||||
<View className="flex-row">
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("register.alreadyHaveAccount")}
|
||||
</Text>
|
||||
<Link
|
||||
href={`${pathsConfig.setup.auth.login}?${searchParams.toString()}`}
|
||||
className="text-muted-foreground hover:text-primary pl-2 font-sans text-sm underline"
|
||||
>
|
||||
{t("login.cta")}
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
106
apps/mobile/src/modules/auth/form/login/magic-link.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, View } from "react-native";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { magicLinkLoginSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
interface MagicLinkLoginFormProps {
|
||||
readonly redirectTo?: Route;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const MagicLinkLoginForm = memo<MagicLinkLoginFormProps>(
|
||||
({ redirectTo = pathsConfig.index, email }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(magicLinkLoginSchema),
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.magicLink,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.MAGIC_LINK);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
Alert.alert(
|
||||
t("login.magicLink.success.title"),
|
||||
t("login.magicLink.success.description"),
|
||||
);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("email")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
editable={!isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) =>
|
||||
signIn.mutateAsync({
|
||||
...data,
|
||||
callbackURL: redirectTo,
|
||||
}),
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.MAGIC_LINK ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("login.magicLink.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
149
apps/mobile/src/modules/auth/form/login/password.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { passwordLoginSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormCheckbox,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
import { Link } from "~/modules/common/styled";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
interface PasswordLoginFormProps {
|
||||
readonly redirectTo?: Route;
|
||||
readonly email?: string;
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
export const PasswordLoginForm = memo<PasswordLoginFormProps>(
|
||||
({ redirectTo = pathsConfig.index, email, onTwoFactorRedirect }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(passwordLoginSchema),
|
||||
defaultValues: {
|
||||
rememberMe: true,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.email,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.PASSWORD);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: (ctx) => {
|
||||
if ("twoFactorRedirect" in ctx) {
|
||||
return onTwoFactorRedirect?.();
|
||||
}
|
||||
|
||||
router.navigate(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("email")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
editable={!isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<FormLabel nativeID="password">{t("password")}</FormLabel>
|
||||
|
||||
<Link
|
||||
href={pathsConfig.setup.auth.forgotPassword}
|
||||
className="text-muted-foreground self-end font-sans text-sm underline underline-offset-4"
|
||||
>
|
||||
{t("account.password.forgot.label")}
|
||||
</Link>
|
||||
</View>
|
||||
<FormInput
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
editable={!isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rememberMe"
|
||||
render={({ field }) => (
|
||||
<FormCheckbox
|
||||
name="rememberMe"
|
||||
label={t("rememberMe")}
|
||||
disabled={isSubmitting}
|
||||
value={!!field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) => signIn.mutate(data))}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.PASSWORD ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("login.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PasswordLoginForm.displayName = "PasswordLoginForm";
|
||||
96
apps/mobile/src/modules/auth/form/password/forgot.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, View } from "react-native";
|
||||
|
||||
import { forgotPasswordSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { Link } from "~/modules/common/styled";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
export const ForgotPasswordForm = () => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(forgotPasswordSchema),
|
||||
});
|
||||
|
||||
const forgetPassword = useMutation({
|
||||
...auth.mutations.password.forget,
|
||||
onSuccess: () => {
|
||||
Alert.alert(
|
||||
t("account.password.forgot.success.title"),
|
||||
t("account.password.forgot.success.description"),
|
||||
);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("email")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
editable={!form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) =>
|
||||
forgetPassword.mutateAsync({
|
||||
...data,
|
||||
redirectTo: Linking.createURL(
|
||||
pathsConfig.setup.auth.updatePassword,
|
||||
),
|
||||
}),
|
||||
)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("account.password.forgot.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<View className="items-center justify-center pt-2">
|
||||
<Link
|
||||
replace
|
||||
href={pathsConfig.setup.auth.login}
|
||||
className="text-muted-foreground active:text-primary pl-2 font-sans text-sm underline"
|
||||
>
|
||||
{t("account.password.forgot.back")}
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
91
apps/mobile/src/modules/auth/form/password/update.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { updatePasswordSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
interface UpdatePasswordFormProps {
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
export const UpdatePasswordForm = memo<UpdatePasswordFormProps>(({ token }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(updatePasswordSchema),
|
||||
});
|
||||
|
||||
const resetPassword = useMutation({
|
||||
...auth.mutations.password.reset,
|
||||
onSuccess: () => {
|
||||
router.setParams({
|
||||
token: undefined,
|
||||
});
|
||||
router.replace(pathsConfig.setup.auth.login);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("password")}
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
editable={!form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) =>
|
||||
resetPassword.mutateAsync({
|
||||
newPassword: data.password,
|
||||
token,
|
||||
}),
|
||||
)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("account.password.update.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
UpdatePasswordForm.displayName = "UpdatePasswordForm";
|
||||
157
apps/mobile/src/modules/auth/form/register-form.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, View } from "react-native";
|
||||
|
||||
import { AuthProvider, generateName, registerSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormInput,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { Link } from "~/modules/common/styled";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import { useAuthFormStore } from "./store";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
interface RegisterFormProps {
|
||||
readonly redirectTo?: Route;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const RegisterForm = ({
|
||||
redirectTo = pathsConfig.index,
|
||||
email,
|
||||
}: RegisterFormProps) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(registerSchema),
|
||||
defaultValues: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
const signUp = useMutation({
|
||||
...auth.mutations.signUp.email,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.PASSWORD);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
Alert.alert(
|
||||
t("register.success.title"),
|
||||
t("register.success.description"),
|
||||
[
|
||||
{
|
||||
text: t("continue"),
|
||||
onPress: () => {
|
||||
router.navigate(pathsConfig.setup.auth.login);
|
||||
form.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("email")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
editable={!isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("password")}
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
editable={!isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) =>
|
||||
signUp.mutateAsync({
|
||||
...data,
|
||||
name: generateName(data.email),
|
||||
callbackURL: redirectTo,
|
||||
}),
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.PASSWORD ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("register.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export const RegisterCta = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
const localParams = useLocalSearchParams();
|
||||
const searchParams = new URLSearchParams(
|
||||
localParams as Record<string, string>,
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="items-center justify-center pt-2">
|
||||
<View className="flex-row">
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("login.noAccount")}
|
||||
</Text>
|
||||
<Link
|
||||
href={`${pathsConfig.setup.auth.register}?${searchParams.toString()}`}
|
||||
className="text-muted-foreground active:text-primary pl-2 font-sans text-sm underline"
|
||||
>
|
||||
{t("register.cta")}
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
206
apps/mobile/src/modules/auth/form/social-providers.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
GoogleSignin,
|
||||
isCancelledResponse,
|
||||
isSuccessResponse,
|
||||
} from "@react-native-google-signin/google-signin";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import env from "env.config";
|
||||
import * as AppleAuthentication from "expo-apple-authentication";
|
||||
import { router } from "expo-router";
|
||||
import { memo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { SocialProvider as SocialProviderType } from "@turbostarter/auth";
|
||||
import { Trans, useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-mobile/badge";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { authConfig } from "~/config/auth";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
import { isAndroid, isIOS } from "~/utils/device";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import type { AuthProvider } from "@turbostarter/auth";
|
||||
import type { Icon } from "@turbostarter/ui-mobile/icons";
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
interface SocialProvidersProps {
|
||||
readonly providers: SocialProviderType[];
|
||||
readonly redirectTo?: Route;
|
||||
}
|
||||
|
||||
export const SocialIcons: Record<SocialProviderType, Icon> = {
|
||||
[SocialProviderType.GITHUB]: Icons.Github,
|
||||
[SocialProviderType.GOOGLE]: Icons.Google,
|
||||
[SocialProviderType.APPLE]: Icons.Apple,
|
||||
};
|
||||
|
||||
if (
|
||||
authConfig.providers.oAuth.includes(SocialProviderType.GOOGLE) &&
|
||||
isAndroid
|
||||
) {
|
||||
GoogleSignin.configure({
|
||||
webClientId: env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
|
||||
});
|
||||
}
|
||||
|
||||
const SocialProvider = ({
|
||||
provider,
|
||||
onClick,
|
||||
actualProvider,
|
||||
isSubmitting,
|
||||
}: {
|
||||
provider: SocialProviderType;
|
||||
isSubmitting: boolean;
|
||||
onClick: () => void;
|
||||
actualProvider: AuthProvider;
|
||||
}) => {
|
||||
const { t } = useTranslation("common");
|
||||
const Icon = SocialIcons[provider];
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={provider}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="relative w-full flex-row justify-center gap-2.5"
|
||||
onPress={onClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && actualProvider === provider ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<>
|
||||
<View className="size-5">
|
||||
<Icon className="text-foreground" />
|
||||
</View>
|
||||
<Text>
|
||||
<Trans
|
||||
ns="auth"
|
||||
i18nKey="login.social"
|
||||
values={{ provider }}
|
||||
components={{
|
||||
capitalize: <Text className="capitalize" />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authClient.isLastUsedLoginMethod(provider) && (
|
||||
<Badge className="absolute -top-2 -right-3 shadow-sm">
|
||||
<Text>{t("lastUsed")}</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const SocialProviders = memo<SocialProvidersProps>(
|
||||
({ providers, redirectTo = pathsConfig.index }) => {
|
||||
const {
|
||||
provider: actualProvider,
|
||||
setProvider,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
} = useAuthFormStore();
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.social,
|
||||
onMutate: ({ provider }) => {
|
||||
setProvider(provider as SocialProviderType);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
const session = await authClient.getSession({
|
||||
fetchOptions: { throw: true },
|
||||
});
|
||||
|
||||
if (session?.session) {
|
||||
router.navigate(redirectTo);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getParams = async (provider: SocialProviderType) => {
|
||||
const shared = {
|
||||
provider,
|
||||
callbackURL: redirectTo,
|
||||
errorCallbackURL: pathsConfig.setup.auth.error,
|
||||
};
|
||||
|
||||
if (provider === SocialProviderType.APPLE && isIOS) {
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
...shared,
|
||||
...(credential.identityToken
|
||||
? { idToken: { token: credential.identityToken } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === SocialProviderType.GOOGLE && isAndroid) {
|
||||
await GoogleSignin.hasPlayServices();
|
||||
const response = await GoogleSignin.signIn();
|
||||
|
||||
if (isCancelledResponse(response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await GoogleSignin.getTokens();
|
||||
|
||||
return {
|
||||
...shared,
|
||||
...(isSuccessResponse(response)
|
||||
? {
|
||||
idToken: {
|
||||
token: tokens.idToken,
|
||||
accessToken: tokens.accessToken,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
return shared;
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex w-full items-stretch justify-center gap-2">
|
||||
{Object.values(providers).map((provider) => (
|
||||
<SocialProvider
|
||||
key={provider}
|
||||
provider={provider}
|
||||
onClick={async () => {
|
||||
const params = await getParams(provider);
|
||||
if (params) {
|
||||
await signIn.mutateAsync(params);
|
||||
}
|
||||
}}
|
||||
actualProvider={actualProvider}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SocialProviders.displayName = "SocialProviders";
|
||||
15
apps/mobile/src/modules/auth/form/store/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
|
||||
export const useAuthFormStore = create<{
|
||||
provider: AuthProvider;
|
||||
setProvider: (provider: AuthProvider) => void;
|
||||
isSubmitting: boolean;
|
||||
setIsSubmitting: (isSubmitting: boolean) => void;
|
||||
}>((set) => ({
|
||||
provider: AuthProvider.PASSWORD,
|
||||
setProvider: (provider) => set({ provider }),
|
||||
isSubmitting: false,
|
||||
setIsSubmitting: (isSubmitting) => set({ isSubmitting }),
|
||||
}));
|
||||
114
apps/mobile/src/modules/auth/form/two-factor/backup-code.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { backupCodeVerificationSchema, SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormCheckbox,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { CtaProps, FormProps } from ".";
|
||||
|
||||
const BackupCodeForm = memo<FormProps>(({ redirectTo = pathsConfig.index }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(backupCodeVerificationSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
trustDevice: false,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyBackupCode = useMutation({
|
||||
...auth.mutations.twoFactor.backupCodes.verify,
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="flex flex-col gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
autoFocus
|
||||
placeholder={t("login.twoFactor.backupCode.placeholder")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="one-time-code"
|
||||
editable={!form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trustDevice"
|
||||
render={({ field }) => (
|
||||
<FormCheckbox
|
||||
name="trustDevice"
|
||||
label={t("login.twoFactor.trustDevice")}
|
||||
value={field.value ?? false}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit((data) =>
|
||||
verifyBackupCode.mutateAsync(data),
|
||||
)}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("verify")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
const BackupCodeCta = memo<CtaProps>(({ onFactorChange }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
return (
|
||||
<View className="flex items-center justify-center pt-2">
|
||||
<Text
|
||||
onPress={() => onFactorChange(SecondFactor.BACKUP_CODE)}
|
||||
className="text-muted-foreground font-sans-medium cursor-pointer pl-2 text-sm underline underline-offset-4"
|
||||
>
|
||||
{t("login.twoFactor.backupCode.cta")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export { BackupCodeForm, BackupCodeCta };
|
||||
30
apps/mobile/src/modules/auth/form/two-factor/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SecondFactor } from "@turbostarter/auth";
|
||||
|
||||
import { BackupCodeForm, BackupCodeCta } from "./backup-code";
|
||||
import { TotpForm, TotpCta } from "./totp";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
export interface FormProps {
|
||||
readonly redirectTo?: Route;
|
||||
}
|
||||
|
||||
export interface CtaProps {
|
||||
readonly onFactorChange: (factor: SecondFactor) => void;
|
||||
}
|
||||
|
||||
const TwoFactorForm: Record<
|
||||
SecondFactor,
|
||||
(props: FormProps) => React.ReactNode
|
||||
> = {
|
||||
[SecondFactor.TOTP]: TotpForm,
|
||||
[SecondFactor.BACKUP_CODE]: BackupCodeForm,
|
||||
};
|
||||
|
||||
const TwoFactorCta: Record<SecondFactor, (props: CtaProps) => React.ReactNode> =
|
||||
{
|
||||
[SecondFactor.TOTP]: TotpCta,
|
||||
[SecondFactor.BACKUP_CODE]: BackupCodeCta,
|
||||
};
|
||||
|
||||
export { TwoFactorForm, TwoFactorCta };
|
||||
132
apps/mobile/src/modules/auth/form/two-factor/totp.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { otpVerificationSchema, SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormCheckbox,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@turbostarter/ui-mobile/input-otp";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { CtaProps, FormProps } from ".";
|
||||
|
||||
const TotpForm = memo<FormProps>(({ redirectTo = pathsConfig.index }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(otpVerificationSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
trustDevice: false,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTotp = useMutation({
|
||||
...auth.mutations.twoFactor.totp.verify,
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="flex flex-col items-start gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onComplete={() =>
|
||||
form.handleSubmit((data) => verifyTotp.mutateAsync(data))()
|
||||
}
|
||||
render={({ slots }) => (
|
||||
<InputOTPGroup>
|
||||
{slots.map((slot, index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
max={6}
|
||||
{...slot}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
)}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trustDevice"
|
||||
render={({ field }) => (
|
||||
<FormCheckbox
|
||||
name="trustDevice"
|
||||
label={t("login.twoFactor.trustDevice")}
|
||||
value={field.value ?? false}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit((data) => verifyTotp.mutateAsync(data))}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("verify")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
const TotpCta = memo<CtaProps>(({ onFactorChange }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
return (
|
||||
<View className="flex items-center justify-center pt-2">
|
||||
<Text
|
||||
onPress={() => onFactorChange(SecondFactor.TOTP)}
|
||||
className="text-muted-foreground font-sans-medium cursor-pointer pl-2 text-sm underline underline-offset-4"
|
||||
>
|
||||
{t("login.twoFactor.totp.cta")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export { TotpForm, TotpCta };
|
||||
16
apps/mobile/src/modules/auth/layout/base.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ScrollView } from "~/modules/common/styled";
|
||||
import { KeyboardAvoidingView } from "~/modules/common/styled";
|
||||
|
||||
export const AuthLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<KeyboardAvoidingView className="bg-background flex-1" behavior="padding">
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerClassName="gap-5 px-6 pt-4 pb-10"
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
21
apps/mobile/src/modules/auth/layout/divider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
export const AuthDivider = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
return (
|
||||
<View className="relative w-full">
|
||||
<View className="absolute top-1/2 left-0 flex h-2 w-full items-center">
|
||||
<View className="border-input w-full border-t" />
|
||||
</View>
|
||||
<View className="bg-background relative justify-center self-center">
|
||||
<Text className="text-muted-foreground px-4 text-sm">
|
||||
{t("divider")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
20
apps/mobile/src/modules/auth/layout/header.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { memo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
interface AuthHeaderProps {
|
||||
readonly title: React.ReactNode;
|
||||
readonly description: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AuthHeader = memo<AuthHeaderProps>(({ title, description }) => {
|
||||
return (
|
||||
<View className="gap-1">
|
||||
<Text className="font-sans-bold text-3xl tracking-tighter">{title}</Text>
|
||||
<Text className="text-muted-foreground text-sm">{description}</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
AuthHeader.displayName = "AuthHeader";
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@turbostarter/ui-mobile/alert";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
|
||||
export const InvitationDisclaimer = () => {
|
||||
const { t } = useTranslation("organization");
|
||||
|
||||
return (
|
||||
<Alert icon={Icons.MailPlus} variant="primary">
|
||||
<AlertTitle>{t("invitations.disclaimer.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("invitations.disclaimer.description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
187
apps/mobile/src/modules/auth/lib/api.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { authClient } from "~/lib/auth";
|
||||
|
||||
const KEY = "auth";
|
||||
|
||||
const queries = {
|
||||
sessions: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "sessions"],
|
||||
queryFn: () =>
|
||||
authClient.listSessions({
|
||||
fetchOptions: {
|
||||
throw: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "accounts"],
|
||||
queryFn: () => authClient.listAccounts({ fetchOptions: { throw: true } }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
signIn: {
|
||||
email: {
|
||||
mutationKey: [KEY, "signIn", "email"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signIn.email>[0]) =>
|
||||
authClient.signIn.email(params),
|
||||
},
|
||||
magicLink: {
|
||||
mutationKey: [KEY, "signIn", "magicLink"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signIn.magicLink>[0]) =>
|
||||
authClient.signIn.magicLink(params),
|
||||
},
|
||||
anonymous: {
|
||||
mutationKey: [KEY, "signIn", "anonymous"],
|
||||
mutationFn: (
|
||||
params?: Parameters<typeof authClient.signIn.anonymous>[0],
|
||||
) => authClient.signIn.anonymous(params),
|
||||
},
|
||||
social: {
|
||||
mutationKey: [KEY, "signIn", "social"],
|
||||
mutationFn: async (
|
||||
params: Parameters<typeof authClient.signIn.social>[0],
|
||||
) => {
|
||||
await authClient.signIn.social(params);
|
||||
await authClient.getSession();
|
||||
},
|
||||
},
|
||||
},
|
||||
magicLink: {
|
||||
verify: {
|
||||
mutationKey: [KEY, "magicLink", "verify"],
|
||||
mutationFn: (
|
||||
query: Parameters<typeof authClient.magicLink.verify>[0]["query"],
|
||||
) => authClient.magicLink.verify({ query }),
|
||||
},
|
||||
},
|
||||
password: {
|
||||
forget: {
|
||||
mutationKey: [KEY, "password", "forget"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.requestPasswordReset>[0],
|
||||
) => authClient.requestPasswordReset(params),
|
||||
},
|
||||
reset: {
|
||||
mutationKey: [KEY, "password", "update"],
|
||||
mutationFn: (params: Parameters<typeof authClient.resetPassword>[0]) =>
|
||||
authClient.resetPassword(params),
|
||||
},
|
||||
change: {
|
||||
mutationKey: [KEY, "password", "change"],
|
||||
mutationFn: (params: Parameters<typeof authClient.changePassword>[0]) =>
|
||||
authClient.changePassword(params),
|
||||
},
|
||||
},
|
||||
signOut: {
|
||||
mutationKey: [KEY, "signOut"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signOut>[0]) =>
|
||||
authClient.signOut(params),
|
||||
},
|
||||
signUp: {
|
||||
email: {
|
||||
mutationKey: [KEY, "signUp", "email"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signUp.email>[0]) =>
|
||||
authClient.signUp.email(params),
|
||||
},
|
||||
},
|
||||
twoFactor: {
|
||||
enable: {
|
||||
mutationKey: [KEY, "twoFactor", "enable"],
|
||||
mutationFn: (params: Parameters<typeof authClient.twoFactor.enable>[0]) =>
|
||||
authClient.twoFactor.enable({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
disable: {
|
||||
mutationKey: [KEY, "twoFactor", "disable"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.disable>[0],
|
||||
) => authClient.twoFactor.disable(params),
|
||||
},
|
||||
backupCodes: {
|
||||
generate: {
|
||||
mutationKey: [KEY, "twoFactor", "backupCodes", "generate"],
|
||||
mutationFn: (
|
||||
params: Parameters<
|
||||
typeof authClient.twoFactor.generateBackupCodes
|
||||
>[0],
|
||||
) =>
|
||||
authClient.twoFactor.generateBackupCodes({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
verify: {
|
||||
mutationKey: [KEY, "twoFactor", "backupCodes", "verify"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.verifyBackupCode>[0],
|
||||
) => authClient.twoFactor.verifyBackupCode(params),
|
||||
},
|
||||
},
|
||||
totp: {
|
||||
getUri: {
|
||||
mutationKey: [KEY, "twoFactor", "totp", "getUri"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.getTotpUri>[0],
|
||||
) =>
|
||||
authClient.twoFactor.getTotpUri({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
verify: {
|
||||
mutationKey: [KEY, "twoFactor", "totp", "verify"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.verifyTotp>[0],
|
||||
) => authClient.twoFactor.verifyTotp(params),
|
||||
},
|
||||
},
|
||||
},
|
||||
email: {
|
||||
sendVerification: {
|
||||
mutationKey: [KEY, "email", "sendVerification"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.sendVerificationEmail>[0],
|
||||
) => authClient.sendVerificationEmail(params),
|
||||
},
|
||||
change: {
|
||||
mutationKey: [KEY, "email", "change"],
|
||||
mutationFn: (params: Parameters<typeof authClient.changeEmail>[0]) =>
|
||||
authClient.changeEmail(params),
|
||||
},
|
||||
verify: {
|
||||
mutationKey: [KEY, "email", "confirm"],
|
||||
mutationFn: (
|
||||
query: Parameters<typeof authClient.verifyEmail>[0]["query"],
|
||||
) => authClient.verifyEmail({ query }),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
connect: {
|
||||
mutationKey: [KEY, "accounts", "connect"],
|
||||
mutationFn: (params: Parameters<typeof authClient.linkSocial>[0]) =>
|
||||
authClient.linkSocial(params),
|
||||
},
|
||||
disconnect: {
|
||||
mutationKey: [KEY, "accounts", "disconnect"],
|
||||
mutationFn: (params: Parameters<typeof authClient.unlinkAccount>[0]) =>
|
||||
authClient.unlinkAccount(params),
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
revoke: {
|
||||
mutationKey: [KEY, "sessions", "revoke"],
|
||||
mutationFn: (token: string) => authClient.revokeSession({ token }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const auth = {
|
||||
queries,
|
||||
mutations,
|
||||
};
|
||||
130
apps/mobile/src/modules/auth/login.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { memo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { authConfig } from "~/config/auth";
|
||||
import { AnonymousLogin } from "~/modules/auth/form/anonymous";
|
||||
import { LOGIN_OPTIONS } from "~/modules/auth/form/login/constants";
|
||||
import { LoginForm } from "~/modules/auth/form/login/form";
|
||||
import { RegisterCta } from "~/modules/auth/form/register-form";
|
||||
import { SocialProviders } from "~/modules/auth/form/social-providers";
|
||||
import { TwoFactorForm, TwoFactorCta } from "~/modules/auth/form/two-factor";
|
||||
import { AuthLayout } from "~/modules/auth/layout/base";
|
||||
import { AuthDivider } from "~/modules/auth/layout/divider";
|
||||
import { AuthHeader } from "~/modules/auth/layout/header";
|
||||
import { InvitationDisclaimer } from "~/modules/auth/layout/invitation-disclaimer";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
import type { LoginOption } from "~/modules/auth/form/login/constants";
|
||||
|
||||
const LoginStep = {
|
||||
FORM: "form",
|
||||
TWO_FACTOR: "twoFactor",
|
||||
} as const;
|
||||
|
||||
type LoginStep = (typeof LoginStep)[keyof typeof LoginStep];
|
||||
|
||||
interface LoginFlowProps {
|
||||
readonly redirectTo?: Route;
|
||||
readonly invitationId?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const LoginFlow = ({
|
||||
redirectTo,
|
||||
invitationId,
|
||||
email,
|
||||
}: LoginFlowProps) => {
|
||||
const [step, setStep] = useState<LoginStep>(LoginStep.FORM);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
{(() => {
|
||||
switch (step) {
|
||||
case LoginStep.FORM:
|
||||
return (
|
||||
<Login
|
||||
redirectTo={redirectTo}
|
||||
invitationId={invitationId}
|
||||
email={email}
|
||||
onTwoFactorRedirect={() => setStep(LoginStep.TWO_FACTOR)}
|
||||
/>
|
||||
);
|
||||
case LoginStep.TWO_FACTOR:
|
||||
return <TwoFactor redirectTo={redirectTo} />;
|
||||
}
|
||||
})()}
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
interface LoginProps extends LoginFlowProps {
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
const Login = memo<LoginProps>(
|
||||
({ redirectTo, invitationId, email, onTwoFactorRedirect }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
const options = Object.entries(authConfig.providers)
|
||||
.filter(
|
||||
([provider, enabled]) =>
|
||||
enabled && LOGIN_OPTIONS.includes(provider as LoginOption),
|
||||
)
|
||||
.map(([provider]) => provider as LoginOption);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t("login.header.title")}
|
||||
description={t("login.header.description")}
|
||||
/>
|
||||
{invitationId && <InvitationDisclaimer />}
|
||||
|
||||
<SocialProviders
|
||||
providers={authConfig.providers.oAuth}
|
||||
redirectTo={redirectTo}
|
||||
/>
|
||||
{authConfig.providers.oAuth.length > 0 && options.length > 0 && (
|
||||
<AuthDivider />
|
||||
)}
|
||||
|
||||
<View className="gap-2">
|
||||
<LoginForm
|
||||
options={options}
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
/>
|
||||
{authConfig.providers.anonymous && <AnonymousLogin />}
|
||||
</View>
|
||||
|
||||
<RegisterCta />
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const TwoFactor = ({ redirectTo }: LoginFlowProps) => {
|
||||
const [factor, setFactor] = useState<SecondFactor>(SecondFactor.TOTP);
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
const Form = TwoFactorForm[factor];
|
||||
const Cta =
|
||||
factor === SecondFactor.TOTP
|
||||
? TwoFactorCta[SecondFactor.BACKUP_CODE]
|
||||
: TwoFactorCta[SecondFactor.TOTP];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t(`login.twoFactor.${factor}.header.title`)}
|
||||
description={t(`login.twoFactor.${factor}.header.description`)}
|
||||
/>
|
||||
|
||||
<Form redirectTo={redirectTo} />
|
||||
<Cta onFactorChange={setFactor} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
136
apps/mobile/src/modules/auth/verification.tsx
Normal file
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
5
apps/mobile/src/modules/billing/hooks/use-customer.ts
Normal 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);
|
||||
18
apps/mobile/src/modules/billing/lib/api.ts
Normal 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,
|
||||
};
|
||||
379
apps/mobile/src/modules/common/avatar-form.tsx
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
36
apps/mobile/src/modules/common/hooks/use-image-picker.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||