Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): setup clerk email auth and integrate with api rpc #49

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "@6pm/api",
"version": "0.0.0",
"license": "GPL-3.0",
"main": "index.ts",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
5 changes: 3 additions & 2 deletions apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
"incremental": true,
"composite": true
},
"include": [
"next-env.d.ts",
Expand All @@ -27,4 +28,4 @@
"exclude": [
"node_modules"
]
}
}
3 changes: 3 additions & 0 deletions apps/mobile/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
EXPO_USE_METRO_WORKSPACE_ROOT=1
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=
EXPO_PUBLIC_API_URL=
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,12 @@ import { Tabs } from 'expo-router';
import React from 'react';

import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';

export default function TabLayout() {
const colorScheme = useColorScheme();

return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
// tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
}}>
<Tabs.Screen
Expand Down
47 changes: 47 additions & 0 deletions apps/mobile/app/(app)/(tabs)/explore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/Avatar';
import { Button } from '@/components/Button';
import { useAuth } from '@clerk/clerk-expo';
import { ScrollView, Text, View } from 'react-native';
import { getHonoClient } from '@/lib/client';
import { useQuery } from '@tanstack/react-query';

export default function TabTwoScreen() {
const { signOut } = useAuth();
const { data } = useQuery({
queryKey: ['me'],
queryFn: async () => {
const hc = await getHonoClient()
const res = await hc.v1.auth.me.$get()
if (res.ok) {
return await res.json()
} else {
throw new Error(await res.text())
}
},
})

return (
<ScrollView contentContainerClassName='flex-1 p-4'>
<View className="flex justify-center flex-1 items-center flex-row gap-4">
<Avatar className="h-14 w-14">
<AvatarImage
source={{
uri: 'https://avatars.githubusercontent.com/u/16166195?s=96&v=4',
}}
/>
<AvatarFallback>CG</AvatarFallback>
</Avatar>
<Avatar className="h-14 w-14">
<AvatarImage
source={{
uri: 'https://avatars.githubusercontent.com/u/9253690?s=96&v=4',
}}
/>
<AvatarFallback>SS</AvatarFallback>
</Avatar>
</View>
<Text>{data?.email ? `Logged as ${data.email}` : 'loading...'}</Text>
<Button label="Sign Out" onPress={() => signOut()} />
</ScrollView>
);
}
7 changes: 7 additions & 0 deletions apps/mobile/app/(app)/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Text } from 'react-native';

export default function HomeScreen() {
return (
<Text>Home Screen</Text>
);
}
17 changes: 17 additions & 0 deletions apps/mobile/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Redirect, Stack } from 'expo-router';
import { useAuth } from '@clerk/clerk-expo';
import { Text } from 'react-native';

export default function AuthenticatedLayout() {
const { isLoaded, isSignedIn } = useAuth();

if (!isLoaded) {
return <Text>Loading...</Text>;
}

if (!isSignedIn) {
return <Redirect href={"/login"} />;
}

return <Stack />;
};
19 changes: 19 additions & 0 deletions apps/mobile/app/(auth)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Redirect, Stack } from 'expo-router';
import { useAuth } from '@clerk/clerk-expo';
import { SafeAreaView } from 'react-native';

export default function UnAuthenticatedLayout() {
const { isSignedIn, userId } = useAuth();

console.log("UnAuthenticatedLayout", isSignedIn, userId)

if (isSignedIn) {
return <Redirect href={"/"} />;
}

return (
<SafeAreaView className="flex-1">
<Stack screenOptions={{ headerShown: false }} />
</SafeAreaView>
);
};
12 changes: 12 additions & 0 deletions apps/mobile/app/(auth)/login.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { AuthEmail } from "@/components/auth/auth-email";
import { ScrollView, Text } from "react-native";

export default function LoginScreen() {
return (
<ScrollView className="bg-card p-4" contentContainerClassName="gap-4">
<Text className="text-3xl font-semibold">Manage your expense seamlessly</Text>
<Text className="text-muted-foreground">Let 6pm a good time to spend</Text>
<AuthEmail />
</ScrollView>
)
}
25 changes: 0 additions & 25 deletions apps/mobile/app/(tabs)/explore.tsx

This file was deleted.

7 changes: 0 additions & 7 deletions apps/mobile/app/(tabs)/index.tsx

This file was deleted.

42 changes: 28 additions & 14 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,53 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import { Slot } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import 'react-native-reanimated';
import { ClerkProvider } from '@clerk/clerk-expo';
import { tokenCache } from '@/lib/cache';

import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/useColorScheme';

import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import "../global.css"
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/lib/client';

// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();

export const unstable_settings = {
initialRouteName: '(app)',
};

export default function RootLayout() {
const colorScheme = useColorScheme();
const [loaded] = useFonts({
const [fontLoaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});

useEffect(() => {
if (loaded) {
if (fontLoaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
}, [fontLoaded]);

if (!loaded) {
if (!fontLoaded) {
return null;
}

return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
tokenCache={tokenCache}
>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<SafeAreaProvider>
<Slot />
</SafeAreaProvider>
</ThemeProvider>
</ClerkProvider>
</QueryClientProvider>
);
}
114 changes: 114 additions & 0 deletions apps/mobile/components/auth/auth-email.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useSignIn, useSignUp } from "@clerk/clerk-expo"
import { Input } from "../Input"
import { Button } from "../Button"
import { useState } from "react";
import type { EmailCodeFactor } from '@clerk/types';
import { getHonoClient } from "@/lib/client";

export function AuthEmail() {
const [emailAddress, setEmailAddress] = useState("");
const [code, setCode] = useState("");
const [verifying, setVerifying] = useState(false);
const [mode, setMode] = useState<"signUp" | "signIn">("signUp");

const { isLoaded: isSignUpLoaded, signUp, setActive: setActiveSignUp } = useSignUp()
const { isLoaded: isSignInLoaded, signIn, setActive: setActiveSignIn } = useSignIn()

if (!isSignUpLoaded || !isSignInLoaded) {
return null
}

const onContinue = async () => {
try {
await signUp.create({
emailAddress,
})
await signUp.prepareEmailAddressVerification()
setVerifying(true);
} catch (err: any) {
if (err?.errors?.[0]?.code === 'form_identifier_exists') {
// If the email address already exists, try to sign in instead
setMode('signIn')
try {
const { supportedFirstFactors } = await signIn.create({
identifier: emailAddress,
})

const emailCodeFactor = supportedFirstFactors.find(i => i.strategy === 'email_code')
if (emailCodeFactor) {
await signIn.prepareFirstFactor({
strategy: 'email_code',
emailAddressId: (emailCodeFactor as EmailCodeFactor).emailAddressId,
})
setVerifying(true);
}
} catch (err: any) {
console.log('error', JSON.stringify(err, null, 2))
}
} else {
console.log('error', JSON.stringify(err, null, 2))
}
}
}

const onVerify = async () => {
try {
if (mode === 'signUp') {
const signUpAttempt = await signUp.attemptEmailAddressVerification({ code })
if (signUpAttempt.status === 'complete') {
await setActiveSignUp({ session: signUpAttempt.createdSessionId });
console.log('signed up')
// create user
const hc = await getHonoClient()
await hc.v1.users.$post({
json: {
email: emailAddress,
name: "***"
}
})
} else {
console.error(signUpAttempt);
}
} else {
const signInAttempt = await signIn.attemptFirstFactor({ strategy: 'email_code', code })
if (signInAttempt.status === 'complete') {
await setActiveSignIn({ session: signInAttempt.createdSessionId });
console.log('signed in')
} else {
console.error(signInAttempt);
}
}
} catch (err: any) {
console.log('error', JSON.stringify(err, null, 2))
}
}

if (verifying) {
return (
<>
<Input
placeholder="Enter the code"
keyboardType="number-pad"
value={code}
onChangeText={setCode}
autoFocus
/>
<Button label="Verify" onPress={onVerify} />
</>
)
}

return (
<>
<Input
placeholder="Enter your email address"
keyboardType="email-address"
autoCapitalize="none"
autoFocus
value={emailAddress}
onChangeText={setEmailAddress}
/>
<Button label="Continue" onPress={onContinue} />
</>
)
}
25 changes: 25 additions & 0 deletions apps/mobile/lib/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import { TokenCache } from "@clerk/clerk-expo/dist/cache";

const createTokenCache = (): TokenCache => {
return {
getToken: async (key: string) => {
try {
return await SecureStore.getItemAsync(key);
} catch (error) {
console.error("secure store get item error: ", error);
await SecureStore.deleteItemAsync(key);
return null;
}
},
saveToken: (key: string, token: string) => {
return SecureStore.setItemAsync(key, token);
},
};
};

// SecureStore is not supported on the web
// https://github.com/expo/expo/issues/7744#issuecomment-611093485
export const tokenCache =
Platform.OS !== "web" ? createTokenCache() : undefined;
Loading