feat: authentication (register, login, logout)

This commit is contained in:
Théo LUDWIG 2024-03-22 23:41:51 +01:00
parent 90c8c7547f
commit 25f60afb91
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
36 changed files with 2088 additions and 1224 deletions

View File

@ -11,6 +11,7 @@ test:
- "develop"
script:
- "npm clean-install"
- "npm run expo:typed-routes"
- 'echo "${CI_COMMIT_MESSAGE}" | npm run lint:commit'
- "npm run lint:prettier"
- "npm run lint:eslint"

View File

@ -1,92 +1,7 @@
import FontAwesome from "@expo/vector-icons/FontAwesome"
import { Tabs } from "expo-router"
import React from "react"
import { Slot } from "expo-router"
/**
* @see https://icons.expo.fyi/
* @param props
* @returns
*/
const TabBarIcon: React.FC<{
name: React.ComponentProps<typeof FontAwesome>["name"]
color: string
}> = (props) => {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />
const Layout: React.FC = () => {
return <Slot />
}
const TabLayout: React.FC = () => {
return (
<Tabs
screenOptions={{
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="home" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/new"
options={{
title: "New Habit",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="plus-square" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/index"
options={{
headerShown: false,
title: "Habits",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="sticky-note" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/history"
options={{
title: "History",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="history" color={color} />
},
}}
/>
<Tabs.Screen
name="users/settings"
options={{
title: "Settings",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="cog" color={color} />
},
}}
/>
<Tabs.Screen
name="authentication/login"
options={{
title: "Login",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="sign-in" color={color} />
},
}}
/>
<Tabs.Screen
name="authentication/register"
options={{
title: "Register",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="user-plus" color={color} />
},
}}
/>
</Tabs>
)
}
export default TabLayout
export default Layout

View File

@ -0,0 +1,61 @@
import { Redirect, Tabs } from "expo-router"
import React from "react"
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => {
const { user } = useAuthentication()
if (user == null) {
return <Redirect href="/(pages)/authentication/login" />
}
return (
<Tabs
screenOptions={{
headerShown: false,
}}
>
<Tabs.Screen
name="habits/index"
options={{
headerShown: false,
title: "Habits",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="sticky-note" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/new"
options={{
title: "New Habit",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="plus-square" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/history"
options={{
title: "History",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="history" color={color} />
},
}}
/>
<Tabs.Screen
name="users/settings"
options={{
title: "Settings",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="cog" color={color} />
},
}}
/>
</Tabs>
)
}
export default TabLayout

View File

@ -13,7 +13,9 @@ const HabitsPage: React.FC = () => {
return (
<View key={habit.id}>
<Text>{habit.name}</Text>
<Text>
{habit.name} ({habit.goal.type})
</Text>
</View>
)
})}

View File

@ -0,0 +1,38 @@
import { StyleSheet, Text } from "react-native"
import { Button } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const SettingsPage: React.FC = () => {
const { logout, authenticationPresenter } = useAuthentication()
const handleLogout = async (): Promise<void> => {
await authenticationPresenter.logout()
}
return (
<SafeAreaView style={styles.container}>
<Text>Settings</Text>
<Button
mode="contained"
onPress={handleLogout}
loading={logout.state === "loading"}
disabled={logout.state === "loading"}
>
Logout
</Button>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
})
export default SettingsPage

View File

@ -0,0 +1,42 @@
import { Redirect, Tabs } from "expo-router"
import React from "react"
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => {
const { user } = useAuthentication()
if (user != null) {
return <Redirect href="/(pages)/application/habits" />
}
return (
<Tabs
screenOptions={{
headerShown: false,
}}
>
<Tabs.Screen
name="login"
options={{
title: "Login",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="sign-in" color={color} />
},
}}
/>
<Tabs.Screen
name="register"
options={{
title: "Register",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="user-plus" color={color} />
},
}}
/>
</Tabs>
)
}
export default TabLayout

View File

@ -1,58 +1,78 @@
import { useState } from "react"
import { Image, StyleSheet } from "react-native"
import {
ActivityIndicator,
Banner,
Button,
HelperText,
TextInput,
} from "react-native-paper"
import { Controller, useForm } from "react-hook-form"
import { StyleSheet } from "react-native"
import { Button, HelperText, TextInput } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import type { UserLoginData } from "@/domain/entities/User"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const LoginPage: React.FC = () => {
const [hasError, _sethasError] = useState<boolean>(true)
const { login, authenticationPresenter } = useAuthentication()
const [errorMessage, _setErrorMessage] = useState<string>("Error message")
const { control, handleSubmit } = useForm<UserLoginData>({
defaultValues: {
email: "",
password: "",
},
})
const [isPerfomingLogin, _setIsPerfomingLogin] = useState<boolean>(true)
const onSubmit = async (data: unknown): Promise<void> => {
await authenticationPresenter.login(data)
}
return (
<SafeAreaView style={styles.container}>
<Banner
visible
actions={[
{
label: "Report this problem",
},
]}
icon={({ size }) => {
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<Image
source={{
uri: "https://avatars3.githubusercontent.com/u/17571969?s=400&v=4",
}}
style={{
width: size,
height: size,
}}
<TextInput
placeholder="Email"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
mode="outlined"
/>
)
}}
>
There was an error while trying to login.
</Banner>
<TextInput label="Email" mode="outlined" style={styles.input} />
<TextInput label="Password" mode="outlined" style={styles.input} />
<HelperText type="error" visible={hasError} style={styles.errorText}>
{errorMessage}
</HelperText>
<Button mode="contained">Login</Button>
<ActivityIndicator
animating={isPerfomingLogin}
color="blue"
size="large"
style={styles.indicator}
name="email"
/>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Password"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
mode="outlined"
secureTextEntry
/>
)
}}
name="password"
/>
<HelperText
type="error"
visible={login.state === "error"}
style={styles.helperText}
>
Invalid credentials.
</HelperText>
<Button
mode="contained"
onPress={handleSubmit(onSubmit)}
loading={login.state === "loading"}
disabled={login.state === "loading"}
>
Login
</Button>
</SafeAreaView>
)
}
@ -67,12 +87,9 @@ const styles = StyleSheet.create({
width: "80%",
marginBottom: 10,
},
errorText: {
marginBottom: 10,
},
indicator: {
marginTop: 10,
marginBottom: 10,
helperText: {
fontSize: 18,
marginVertical: 20,
},
})

View File

@ -1,82 +1,115 @@
import { useState } from "react"
import { Image, StyleSheet } from "react-native"
import { Banner, Button, HelperText, TextInput } from "react-native-paper"
import { Controller, useForm } from "react-hook-form"
import { StyleSheet } from "react-native"
import { Button, HelperText, TextInput } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { useMemo } from "react"
import type { UserRegisterData } from "@/domain/entities/User"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const RegisterPage: React.FC = () => {
const regexEmail = /^[\w.-]+@[\d.A-Za-z-]+\.[A-Za-z]{2,4}$/
const { register, authenticationPresenter } = useAuthentication()
const [password, setPassword] = useState<string>("")
const [isPasswordCorrect, setIsPasswordCorrect] = useState<boolean>(true)
const [isEmailValid, setIsEmailValid] = useState<boolean>(true)
const { control, handleSubmit } = useForm<UserRegisterData>({
defaultValues: {
displayName: "",
email: "",
password: "",
},
})
const onSubmit = async (data: unknown): Promise<void> => {
await authenticationPresenter.register(data)
}
const helperMessage = useMemo(() => {
if (register.state === "error") {
if (register.errorsFields.includes("displayName")) {
return "Display Name is required."
}
if (register.errorsFields.includes("email")) {
return "Invalid email."
}
if (register.errorsFields.includes("password")) {
return "Password must be at least 6 characters."
}
return "Invalid credentials."
}
// if (register.state === "success") {
// return "Success! Please verify your email."
// }
return ""
}, [register.errorsFields, register.state])
return (
<SafeAreaView style={styles.container}>
<Banner
visible
actions={[
{
label: "Report this problem",
onPress: () => {
return console.log("Pressed")
},
},
]}
icon={({ size }) => {
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<Image
source={{
uri: "https://avatars3.githubusercontent.com/u/17571969?s=400&v=4",
}}
style={{
width: size,
height: size,
}}
<TextInput
placeholder="Display Name"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
mode="outlined"
/>
)
}}
name="displayName"
/>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Email"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
mode="outlined"
/>
)
}}
name="email"
/>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Password"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
mode="outlined"
secureTextEntry
/>
)
}}
name="password"
/>
<HelperText
type={register.state === "error" ? "error" : "info"}
visible={register.state === "error" || register.state === "success"}
style={styles.helperText}
>
There was an error while trying to register.
</Banner>
<TextInput label="Pseudo" mode="outlined" style={styles.input} />
<TextInput
label="Email"
mode="outlined"
style={styles.input}
onChangeText={(text) => {
setIsEmailValid(regexEmail.test(text))
}}
/>
{isEmailValid ? null : (
<HelperText type="error" visible style={styles.errorText}>
Email address is invalid!
</HelperText>
)}
<TextInput
label="Password"
mode="outlined"
style={styles.input}
onChangeText={(text) => {
setPassword(text)
console.log(text)
}}
/>
<TextInput
label="Confirm password"
mode="outlined"
style={styles.input}
onChangeText={(text) => {
setIsPasswordCorrect(text === password)
}}
/>
<HelperText type="error" visible style={styles.errorText}>
Error message
{helperMessage}
</HelperText>
<Button
mode="contained"
onPress={() => {
return console.log(isPasswordCorrect ? "Pressed" : "Error")
}}
onPress={handleSubmit(onSubmit)}
loading={register.state === "loading"}
disabled={register.state === "loading"}
>
Register
</Button>
@ -92,10 +125,11 @@ const styles = StyleSheet.create({
},
input: {
width: "80%",
margin: 10,
marginBottom: 10,
},
errorText: {
margin: 10,
helperText: {
fontSize: 18,
marginVertical: 20,
},
})

View File

@ -1,20 +1,15 @@
import { StyleSheet, Text } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import { Redirect } from "expo-router"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const HomePage: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<Text>Home Page</Text>
</SafeAreaView>
)
const { user } = useAuthentication()
if (user == null) {
return <Redirect href="/(pages)/authentication/login" />
}
return <Redirect href="/(pages)/application/habits" />
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
})
export default HomePage

View File

@ -1,20 +0,0 @@
import { StyleSheet, Text } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
const SettingsPage: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<Text>Settings</Text>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
})
export default SettingsPage

View File

@ -1,17 +1,17 @@
import { useFonts } from "expo-font"
import { Stack } from "expo-router"
import * as SplashScreen from "expo-splash-screen"
import { useEffect } from "react"
import {
MD3LightTheme as DefaultTheme,
PaperProvider,
} from "react-native-paper"
import { StatusBar } from "expo-status-bar"
import { useEffect } from "react"
import CanterburyFont from "../presentation/assets/fonts/Canterbury.ttf"
import GeoramFont from "../presentation/assets/fonts/Georama-Black.ttf"
import SpaceMonoFont from "../presentation/assets/fonts/SpaceMono-Regular.ttf"
import { HabitsTrackerProvider } from "@/presentation/react/contexts/HabitsTracker"
import {
AuthenticationProvider,
useAuthentication,
} from "@/presentation/react/contexts/Authentication"
export { ErrorBoundary } from "expo-router"
@ -23,54 +23,52 @@ SplashScreen.preventAutoHideAsync().catch((error) => {
console.error(error)
})
const RootLayout: React.FC = () => {
const [loaded, error] = useFonts({
Georama: GeoramFont,
SpaceMono: SpaceMonoFont,
Canterbury: CanterburyFont,
})
const StackLayout: React.FC = () => {
const { hasLoaded } = useAuthentication()
useEffect(() => {
if (error != null) {
throw error
}
}, [error])
useEffect(() => {
if (loaded) {
if (!hasLoaded) {
SplashScreen.hideAsync().catch((error) => {
console.error(error)
})
}
}, [loaded])
}, [hasLoaded])
if (!loaded) {
return null
if (hasLoaded) {
return <></>
}
return (
<HabitsTrackerProvider>
<PaperProvider
theme={{
...DefaultTheme,
colors: {
...DefaultTheme.colors,
primary: "#f57c00",
secondary: "#fbc02d",
},
}}
>
<Stack
screenOptions={{
headerShown: false,
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(pages)" />
</Stack>
)
}
const RootLayout: React.FC = () => {
return (
<AuthenticationProvider>
<HabitsTrackerProvider>
<PaperProvider
theme={{
...DefaultTheme,
colors: {
...DefaultTheme.colors,
primary: "#f57c00",
secondary: "#fbc02d",
},
}}
>
<Stack.Screen name="(pages)" />
</Stack>
<StackLayout />
<StatusBar style="dark" />
</PaperProvider>
</HabitsTrackerProvider>
<StatusBar style="dark" />
</PaperProvider>
</HabitsTrackerProvider>
</AuthenticationProvider>
)
}

View File

@ -10,8 +10,7 @@ On représente ainsi les données sous la forme suivante:
- Le nom de la table est écrit en **gras**.
- Les champs sont listés en dessous du nom de la table.
- Les clés primaires sont <u>soulignées</u> et placées au début de la liste des champs.
- Les clés étrangères sont préfixées par un dièse (#), et placées après les clés primaires. Les clés étrangères sont
suivies entre parenthèses du nom de la table suivi d'une flèche (->) et du nom du champ de la table référencée.
- Les clés étrangères sont préfixées par un dièse (#), et placées après les clés primaires. Les clés étrangères sont suivies entre parenthèses du nom de la table suivi d'une flèche (->) et du nom du champ de la table référencée.
## Modèle

View File

@ -8,10 +8,13 @@ export const UserSchema = EntitySchema.extend({
})
export const UserRegisterSchema = UserSchema.extend({
password: z.string().min(2),
password: z.string().min(6),
}).omit({ id: true })
export type UserRegisterData = z.infer<typeof UserRegisterSchema>
export const UserLoginSchema = UserRegisterSchema.omit({ displayName: true })
export type UserLoginData = z.infer<typeof UserLoginSchema>
export type UserData = z.infer<typeof UserSchema>
export class User extends Entity implements UserData {

View File

@ -0,0 +1,12 @@
import type { User, UserLoginData, UserRegisterData } from "../entities/User"
export interface AuthenticationRepository {
register: (data: UserRegisterData) => Promise<User>
login: (data: UserLoginData) => Promise<User>
logout: () => Promise<void>
getUser: () => Promise<User | null>
onUserStateChange: (
callback: (user: User | null) => void | Promise<void>,
) => void
}

View File

@ -0,0 +1,57 @@
import {
UserRegisterSchema,
type User,
UserLoginSchema,
} from "../entities/User"
import type { AuthenticationRepository } from "../repositories/Authentication"
export interface AuthenticationUseCaseDependencyOptions {
authenticationRepository: AuthenticationRepository
}
export class AuthenticationUseCase
implements AuthenticationUseCaseDependencyOptions
{
public authenticationRepository: AuthenticationRepository
public constructor(options: AuthenticationUseCaseDependencyOptions) {
this.authenticationRepository = options.authenticationRepository
}
/**
* Register a new user.
* @throws {ZodError} if the data is invalid.
* @throws {Error} if user already exists.
* @param data
* @returns
*/
public async register(data: unknown): Promise<User> {
const userData = await UserRegisterSchema.parseAsync(data)
return await this.authenticationRepository.register(userData)
}
/**
* Login a user.
* @throws {ZodError} if the data is invalid.
* @throws {Error} if invalid credentials.
* @param data
* @returns
*/
public async login(data: unknown): Promise<User> {
const userData = await UserLoginSchema.parseAsync(data)
return await this.authenticationRepository.login(userData)
}
public async logout(): Promise<void> {
return await this.authenticationRepository.logout()
}
public getUser: AuthenticationRepository["getUser"] = async (...args) => {
return await this.authenticationRepository.getUser(...args)
}
public onUserStateChange: AuthenticationRepository["onUserStateChange"] =
async (...args) => {
return this.authenticationRepository.onUserStateChange(...args)
}
}

View File

@ -1,12 +1,18 @@
import { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
import { RetrieveHabitsTrackerUseCase } from "../domain/use-cases/RetrieveHabitsTracker"
import { HabitsTrackerPresenter } from "../presentation/presenters/HabitsTracker"
import { AuthenticationSupabaseRepository } from "./repositories/supabase/lib/AuthenticationRepository"
import { GetHabitProgressHistorySupabaseRepository } from "./repositories/supabase/lib/GetHabitProgressHistory"
import { GetHabitsByUserIdSupabaseRepository } from "./repositories/supabase/lib/GetHabitsByUserId"
import { supabaseClient } from "./repositories/supabase/supabase"
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
/**
* Repositories
*/
const authenticationRepository = new AuthenticationSupabaseRepository({
supabaseClient,
})
const getHabitProgressesRepository =
new GetHabitProgressHistorySupabaseRepository({
supabaseClient,
@ -18,6 +24,9 @@ const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
/**
* Use Cases
*/
const authenticationUseCase = new AuthenticationUseCase({
authenticationRepository,
})
const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
getHabitProgressHistoryRepository: getHabitProgressesRepository,
getHabitsByUserIdRepository,
@ -26,6 +35,9 @@ const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
/**
* Presenters
*/
export const authenticationPresenter = new AuthenticationPresenter({
authenticationUseCase,
})
export const habitsTrackerPresenter = new HabitsTrackerPresenter({
retrieveHabitsTrackerUseCase,
})

View File

@ -0,0 +1,86 @@
import type { Session } from "@supabase/supabase-js"
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
import { SupabaseRepository } from "./_SupabaseRepository"
import { User } from "@/domain/entities/User"
export class AuthenticationSupabaseRepository
extends SupabaseRepository
implements AuthenticationRepository
{
private readonly getUserFromSession = (session: Session | null): User => {
if (session == null || session?.user?.email == null) {
throw new Error("Session is null.")
}
const user = new User({
id: session.user.id,
displayName: session.user.user_metadata["display_name"],
email: session.user.email,
})
return user
}
public register: AuthenticationRepository["register"] = async (data) => {
const { displayName, email, password } = data
const {
data: { session },
error,
} = await this.supabaseClient.auth.signUp({
email,
password,
options: {
data: { display_name: displayName },
},
})
if (error != null) {
throw new Error(error.message)
}
const user = this.getUserFromSession(session)
return user
}
public login: AuthenticationRepository["login"] = async (data) => {
const { email, password } = data
const {
data: { session },
error,
} = await this.supabaseClient.auth.signInWithPassword({
email,
password,
})
if (error != null) {
throw new Error(error.message)
}
const user = this.getUserFromSession(session)
return user
}
public logout: AuthenticationRepository["logout"] = async () => {
await this.supabaseClient.auth.signOut()
}
public getUser: AuthenticationRepository["getUser"] = async () => {
try {
const {
data: { session },
} = await this.supabaseClient.auth.getSession()
const user = this.getUserFromSession(session)
return user
} catch {
return null
}
}
public onUserStateChange: AuthenticationRepository["onUserStateChange"] = (
callback,
) => {
this.supabaseClient.auth.onAuthStateChange(async (_, session) => {
try {
const user = this.getUserFromSession(session)
await callback(user)
} catch {
await callback(null)
}
})
}
}

View File

@ -11,7 +11,9 @@ export class GetHabitProgressHistorySupabaseRepository
extends SupabaseRepository
implements GetHabitProgressHistoryRepository
{
execute: GetHabitProgressHistoryRepository["execute"] = async (options) => {
public execute: GetHabitProgressHistoryRepository["execute"] = async (
options,
) => {
const { habit } = options
const { data, error } = await this.supabaseClient
.from("habits_progresses")

View File

@ -8,14 +8,12 @@ export class GetHabitsByUserIdSupabaseRepository
extends SupabaseRepository
implements GetHabitsByUserIdRepository
{
// execute: GetHabitsByUserIdRepository["execute"] = async (options) => {
// const { userId } = options
// const { data, error } = await this.supabaseClient
// .from("habits")
// .select("*")
// .eq("user_id", userId)
execute: GetHabitsByUserIdRepository["execute"] = async () => {
const { data, error } = await this.supabaseClient.from("habits").select("*")
public execute: GetHabitsByUserIdRepository["execute"] = async (options) => {
const { userId } = options
const { data, error } = await this.supabaseClient
.from("habits")
.select("*")
.eq("user_id", userId)
if (error != null) {
throw new Error(error.message)
}

View File

@ -10,7 +10,7 @@ CREATE TABLE "public"."habits" (
"goal_frequency" goal_frequency NOT NULL,
"goal_target" bigint,
"goal_target_unit" text,
"user_id" uuid NOT NULL DEFAULT gen_random_uuid()
"user_id" uuid NOT NULL DEFAULT auth.uid()
);
ALTER TABLE
@ -190,115 +190,85 @@ GRANT
UPDATE
ON TABLE "public"."habits_progresses" TO "service_role";
CREATE policy "Enable delete for users based on user_id" ON "public"."habits" AS permissive FOR DELETE TO public USING ((auth.uid() = user_id));
CREATE policy "Enable delete for users based on user_id" ON "public"."habits" AS permissive FOR DELETE TO authenticated USING ((auth.uid() = user_id));
CREATE policy "Enable insert for users based on user_id" ON "public"."habits" AS permissive FOR
INSERT
TO public WITH CHECK ((auth.uid() = user_id));
TO authenticated WITH CHECK ((auth.uid() = user_id));
CREATE policy "Enable select for users based on user_id" ON "public"."habits" AS permissive FOR
SELECT
TO public USING ((auth.uid() = user_id));
TO authenticated USING ((auth.uid() = user_id));
CREATE policy "Enable update for users based on user_id" ON "public"."habits" AS permissive FOR
UPDATE
TO public USING ((auth.uid() = user_id)) WITH CHECK ((auth.uid() = user_id));
TO authenticated USING ((auth.uid() = user_id)) WITH CHECK ((auth.uid() = user_id));
CREATE policy "Enable delete for users based on user_id" ON "public"."habits_progresses" AS permissive FOR DELETE TO public USING (
CREATE policy "Enable delete for users based on user_id" ON "public"."habits_progresses" AS permissive FOR DELETE TO authenticated USING (
(
EXISTS (
auth.uid() IN (
SELECT
1
habits.user_id
FROM
(
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
habits
WHERE
(
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
(habits_progresses.habit_id = habits.id)
)
)
);
CREATE policy "Enable insert for users based on user_id" ON "public"."habits_progresses" AS permissive FOR
INSERT
TO public WITH CHECK (
TO authenticated WITH CHECK (
(
EXISTS (
auth.uid() IN (
SELECT
1
habits.user_id
FROM
(
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
habits
WHERE
(
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
(habits_progresses.habit_id = habits.id)
)
)
);
CREATE policy "Enable select for users based on user_id" ON "public"."habits_progresses" AS permissive FOR
SELECT
TO public USING (
TO authenticated USING (
(
EXISTS (
auth.uid() IN (
SELECT
1
habits.user_id
FROM
(
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
habits
WHERE
(
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
(habits_progresses.habit_id = habits.id)
)
)
);
CREATE policy "Enable update for users based on user_id" ON "public"."habits_progresses" AS permissive FOR
UPDATE
TO public USING (
TO authenticated USING (
(
EXISTS (
auth.uid() IN (
SELECT
1
habits.user_id
FROM
(
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
habits
WHERE
(
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
(habits_progresses.habit_id = habits.id)
)
)
) WITH CHECK (
(
EXISTS (
auth.uid() IN (
SELECT
1
habits.user_id
FROM
(
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
habits
WHERE
(
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
(habits_progresses.habit_id = habits.id)
)
)
);

View File

@ -55,7 +55,7 @@ VALUES
NULL,
NULL,
'{"provider": "email", "providers": ["email"]}',
'{}',
'{"display_name": "Test"}',
NULL,
timezone('utc' :: text, NOW()),
timezone('utc' :: text, NOW()),
@ -88,7 +88,7 @@ VALUES
(
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'{"sub": "ab054ee9-fbb4-473e-942b-bbf4415f4bef", "email": "test@test.com", "display_name": "Test"}',
'{"sub": "ab054ee9-fbb4-473e-942b-bbf4415f4bef", "email": "test@test.com"}',
'email',
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
timezone('utc' :: text, NOW()),

View File

@ -1,4 +1,7 @@
import { createClient } from "@supabase/supabase-js"
import { AppState, Platform } from "react-native"
import "react-native-url-polyfill/auto"
import AsyncStorage from "@react-native-async-storage/async-storage"
import type { Database } from "./supabase-types"
@ -8,4 +11,26 @@ const SUPABASE_ANON_KEY = process.env["EXPO_PUBLIC_SUPABASE_ANON_KEY"] ?? ""
export const supabaseClient = createClient<Database>(
SUPABASE_URL,
SUPABASE_ANON_KEY,
{
auth: {
// https://github.com/supabase/supabase-js/issues/870
...(Platform.OS !== "web" ? { storage: AsyncStorage } : {}),
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
},
)
/**
* Tells Supabase Auth to continuously refresh the session automatically if the app is in the foreground.
* When this is added, you will continue to receive `onAuthStateChange` events with the `TOKEN_REFRESH` or `SIGNED_OUT` event if the user's session is terminated.
* This should only be registered once.
*/
AppState.addEventListener("change", async (state) => {
if (state === "active") {
await supabaseClient.auth.startAutoRefresh()
} else {
await supabaseClient.auth.stopAutoRefresh()
}
})

2036
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"expo:typed-routes": "expo customize tsconfig.json",
"lint:commit": "commitlint",
"lint:prettier": "prettier . --check",
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
@ -20,9 +21,10 @@
"dependencies": {
"@expo/vector-icons": "14.0.0",
"@hookform/resolvers": "3.3.4",
"@react-native-async-storage/async-storage": "1.21.0",
"@react-navigation/native": "6.1.16",
"@supabase/supabase-js": "2.39.8",
"expo": "50.0.13",
"expo": "50.0.14",
"expo-font": "11.10.3",
"expo-linking": "6.2.2",
"expo-router": "3.4.8",
@ -34,28 +36,30 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.51.1",
"react-native": "0.73.5",
"react-native": "0.73.6",
"react-native-calendars": "1.1304.1",
"react-native-elements": "3.4.3",
"react-native-paper": "5.12.3",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0",
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.0.3",
"react-native-web": "0.19.10",
"zod": "3.22.4"
},
"devDependencies": {
"@babel/core": "7.24.0",
"@commitlint/cli": "19.2.0",
"@babel/core": "7.24.3",
"@commitlint/cli": "19.1.0",
"@commitlint/config-conventional": "19.1.0",
"@testing-library/react-native": "12.4.3",
"@testing-library/react-native": "12.4.4",
"@total-typescript/ts-reset": "0.5.1",
"@tsconfig/strictest": "2.0.3",
"@types/jest": "29.5.12",
"@types/node": "20.11.28",
"@types/react": "18.2.66",
"@types/node": "20.11.30",
"@types/react": "18.2.69",
"@types/react-test-renderer": "18.0.7",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"@typescript-eslint/eslint-plugin": "7.3.1",
"@typescript-eslint/parser": "7.3.1",
"eslint": "8.57.0",
"eslint-config-conventions": "14.1.0",
"eslint-config-prettier": "9.1.0",
@ -72,7 +76,7 @@
"jest-junit": "16.0.0",
"lint-staged": "15.2.2",
"react-test-renderer": "18.2.0",
"supabase": "1.148.6",
"typescript": "5.4.2"
"supabase": "1.150.0",
"typescript": "5.4.3"
}
}

View File

@ -0,0 +1,151 @@
import { ZodError } from "zod"
import type {
User,
UserLoginData,
UserRegisterData,
} from "@/domain/entities/User"
import type { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
import type { ErrorGlobal, FetchState } from "./_Presenter"
import { Presenter } from "./_Presenter"
import { zodPresenter } from "./utils/ZodPresenter"
export interface AuthenticationPresenterState {
user: User | null
/**
* `true` if the initial authentication state has been loaded.
*/
hasLoaded: boolean
register: {
state: FetchState
errorsFields: Array<keyof UserRegisterData>
errorGlobal: ErrorGlobal
}
login: {
state: FetchState
errorsFields: Array<keyof UserLoginData>
errorGlobal: ErrorGlobal
}
logout: {
state: FetchState
}
}
export interface AuthenticationPresenterOptions {
authenticationUseCase: AuthenticationUseCase
}
export class AuthenticationPresenter
extends Presenter<AuthenticationPresenterState>
implements AuthenticationPresenterOptions
{
public authenticationUseCase: AuthenticationUseCase
public constructor(options: AuthenticationPresenterOptions) {
const { authenticationUseCase } = options
super({
user: null,
hasLoaded: true,
register: {
state: "idle",
errorsFields: [],
errorGlobal: null,
},
login: {
state: "idle",
errorsFields: [],
errorGlobal: null,
},
logout: {
state: "idle",
},
})
this.authenticationUseCase = authenticationUseCase
}
public async register(data: unknown): Promise<void> {
try {
this.setState((state) => {
state.register.state = "loading"
state.register.errorsFields = []
state.register.errorGlobal = null
})
const user = await this.authenticationUseCase.register(data)
this.setState((state) => {
state.register.state = "success"
state.user = user
})
} catch (error) {
this.setState((state) => {
state.register.state = "error"
if (error instanceof ZodError) {
state.register.errorsFields =
zodPresenter.getErrorsFieldsFromZodError<UserRegisterData>(error)
} else {
state.register.errorGlobal = "unknown"
}
})
}
}
public async login(data: unknown): Promise<void> {
try {
this.setState((state) => {
state.login.state = "loading"
state.login.errorsFields = []
state.login.errorGlobal = null
})
const user = await this.authenticationUseCase.login(data)
this.setState((state) => {
state.login.state = "success"
state.user = user
})
} catch (error) {
this.setState((state) => {
state.login.state = "error"
if (error instanceof ZodError) {
state.login.errorsFields =
zodPresenter.getErrorsFieldsFromZodError<UserLoginData>(error)
} else {
state.login.errorGlobal = "unknown"
}
})
}
}
public async logout(): Promise<void> {
try {
this.setState((state) => {
state.logout.state = "loading"
})
await this.authenticationUseCase.logout()
this.setState((state) => {
state.logout.state = "success"
state.user = null
})
} catch (error) {
this.setState((state) => {
state.user = null
state.logout.state = "error"
})
}
}
public async initialAuthStateListener(): Promise<void> {
const user = await this.authenticationUseCase.getUser()
this.setState((state) => {
state.user = user
state.hasLoaded = false
})
this.authenticationUseCase.onUserStateChange((user) => {
this.setState((state) => {
state.user = user
})
})
}
}

View File

@ -1,4 +1,5 @@
import { HabitsTracker } from "@/domain/entities/HabitsTracker"
import type { FetchState } from "./_Presenter"
import { Presenter } from "./_Presenter"
import type {
RetrieveHabitsTrackerUseCase,
@ -7,6 +8,10 @@ import type {
export interface HabitsTrackerPresenterState {
habitsTracker: HabitsTracker
retrieveHabitsTracker: {
state: FetchState
}
}
export interface HabitsTrackerPresenterOptions {
@ -22,17 +27,28 @@ export class HabitsTrackerPresenter
public constructor(options: HabitsTrackerPresenterOptions) {
const { retrieveHabitsTrackerUseCase } = options
const habitsTracker = HabitsTracker.default()
super({ habitsTracker })
super({ habitsTracker, retrieveHabitsTracker: { state: "idle" } })
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
}
public async retrieveHabitsTracker(
options: RetrieveHabitsTrackerUseCaseOptions,
): Promise<void> {
const habitsTracker =
await this.retrieveHabitsTrackerUseCase.execute(options)
this.setState((state) => {
state.habitsTracker = habitsTracker
state.retrieveHabitsTracker.state = "loading"
state.habitsTracker = HabitsTracker.default()
})
try {
const habitsTracker =
await this.retrieveHabitsTrackerUseCase.execute(options)
this.setState((state) => {
state.habitsTracker = habitsTracker
state.retrieveHabitsTracker.state = "success"
})
} catch (error) {
this.setState((state) => {
state.retrieveHabitsTracker.state = "error"
})
}
}
}

View File

@ -1,21 +1,26 @@
import { produce } from "immer"
type Listener<S> = (state: S) => void
type Listener<State> = (state: State) => void
type SetStateAction<S> = (state: S) => void
type SetStateAction<State> = (state: State) => void
export abstract class Presenter<S> {
private _state: S
private readonly _initialState: S
private readonly _listeners: Array<Listener<S>>
export const FETCH_STATES = ["idle", "loading", "error", "success"] as const
export type FetchState = (typeof FETCH_STATES)[number]
public constructor(initialState: S) {
export type ErrorGlobal = "unknown" | null
export abstract class Presenter<State> {
private _state: State
private readonly _initialState: State
private readonly _listeners: Array<Listener<State>>
public constructor(initialState: State) {
this._state = initialState
this._initialState = initialState
this._listeners = []
}
public get initialState(): S {
public get initialState(): State {
return this._initialState
}
@ -24,17 +29,17 @@ export abstract class Presenter<S> {
* @param action A function that receives the current state and can update it by mutating it.
* @returns The new state.
*/
public setState(action: SetStateAction<S>): S {
public setState(action: SetStateAction<State>): State {
this._state = produce(this._state, action)
this.notifyListeners()
return this._state
}
public subscribe(listener: Listener<S>): void {
public subscribe(listener: Listener<State>): void {
this._listeners.push(listener)
}
public unsubscribe(listener: Listener<S>): void {
public unsubscribe(listener: Listener<State>): void {
const listenerIndex = this._listeners.indexOf(listener)
const listenerFound = listenerIndex !== -1
if (listenerFound) {

View File

@ -0,0 +1,7 @@
import type { ZodError } from "zod"
export const zodPresenter = {
getErrorsFieldsFromZodError: <T>(error: ZodError<T>): Array<keyof T> => {
return Object.keys(error.format()) as Array<keyof T>
},
}

View File

@ -0,0 +1,13 @@
import FontAwesome from "@expo/vector-icons/FontAwesome"
/**
* @see https://icons.expo.fyi/
* @param props
* @returns
*/
export const TabBarIcon: React.FC<{
name: React.ComponentProps<typeof FontAwesome>["name"]
color: string
}> = (props) => {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />
}

View File

@ -0,0 +1,56 @@
import { createContext, useContext, useEffect } from "react"
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
import type {
AuthenticationPresenter,
AuthenticationPresenterState,
} from "@/presentation/presenters/Authentication"
import { authenticationPresenter } from "@/infrastructure"
export interface AuthenticationContextValue
extends AuthenticationPresenterState {
authenticationPresenter: AuthenticationPresenter
}
const defaultContextValue = {} as AuthenticationContextValue
const AuthenticationContext =
createContext<AuthenticationContextValue>(defaultContextValue)
interface AuthenticationProviderProps extends React.PropsWithChildren {}
export const AuthenticationProvider: React.FC<AuthenticationProviderProps> = (
props,
) => {
const { children } = props
useEffect(() => {
authenticationPresenter.initialAuthStateListener().catch((error) => {
console.error(error)
})
}, [])
const authenticationPresenterState = usePresenterState(
authenticationPresenter,
)
return (
<AuthenticationContext.Provider
value={{
...authenticationPresenterState,
authenticationPresenter,
}}
>
{children}
</AuthenticationContext.Provider>
)
}
export const useAuthentication = (): AuthenticationContextValue => {
const context = useContext(AuthenticationContext)
if (context === defaultContextValue) {
throw new Error(
"`useAuthentication` must be used within a `AuthenticationProvider`.",
)
}
return context
}

View File

@ -6,15 +6,15 @@ import type {
} from "@/presentation/presenters/HabitsTracker"
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
import { habitsTrackerPresenter } from "@/infrastructure"
import { useAuthentication } from "./Authentication"
export interface HabitsTrackerContextValue extends HabitsTrackerPresenterState {
habitsTrackerPresenter: HabitsTrackerPresenter
}
const defaultHabitsTrackerContextValue = {} as HabitsTrackerContextValue
const HabitsTrackerContext = createContext<HabitsTrackerContextValue>(
defaultHabitsTrackerContextValue,
)
const defaultContextValue = {} as HabitsTrackerContextValue
const HabitsTrackerContext =
createContext<HabitsTrackerContextValue>(defaultContextValue)
interface HabitsTrackerProviderProps extends React.PropsWithChildren {}
@ -23,13 +23,18 @@ export const HabitsTrackerProvider: React.FC<HabitsTrackerProviderProps> = (
) => {
const { children } = props
const { user } = useAuthentication()
useEffect(() => {
if (user == null) {
return
}
habitsTrackerPresenter
.retrieveHabitsTracker({ userId: "1" })
.retrieveHabitsTracker({ userId: user.id })
.catch((error) => {
console.error(error)
})
}, [])
}, [user])
const habitsTrackerPresenterState = usePresenterState(habitsTrackerPresenter)
@ -47,7 +52,7 @@ export const HabitsTrackerProvider: React.FC<HabitsTrackerProviderProps> = (
export const useHabitsTracker = (): HabitsTrackerContextValue => {
const context = useContext(HabitsTrackerContext)
if (context === defaultHabitsTrackerContextValue) {
if (context === defaultContextValue) {
throw new Error(
"`useHabitsTracker` must be used within a `HabitsTrackerProvider`.",
)