feat: authentication (register, login, logout)
This commit is contained in:
parent
90c8c7547f
commit
25f60afb91
@ -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"
|
||||
|
@ -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
|
||||
|
61
app/(pages)/application/_layout.tsx
Normal file
61
app/(pages)/application/_layout.tsx
Normal 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
|
@ -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>
|
||||
)
|
||||
})}
|
38
app/(pages)/application/users/settings.tsx
Normal file
38
app/(pages)/application/users/settings.tsx
Normal 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
|
42
app/(pages)/authentication/_layout.tsx
Normal file
42
app/(pages)/authentication/_layout.tsx
Normal 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
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 {
|
||||
|
12
domain/repositories/Authentication.ts
Normal file
12
domain/repositories/Authentication.ts
Normal 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
|
||||
}
|
57
domain/use-cases/Authentication.ts
Normal file
57
domain/use-cases/Authentication.ts
Normal 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)
|
||||
}
|
||||
}
|
@ -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,
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
@ -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()),
|
||||
|
@ -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
2036
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
151
presentation/presenters/Authentication.ts
Normal file
151
presentation/presenters/Authentication.ts
Normal 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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -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"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
7
presentation/presenters/utils/ZodPresenter.ts
Normal file
7
presentation/presenters/utils/ZodPresenter.ts
Normal 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>
|
||||
},
|
||||
}
|
13
presentation/react/components/TabBarIcon.tsx
Normal file
13
presentation/react/components/TabBarIcon.tsx
Normal 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} />
|
||||
}
|
56
presentation/react/contexts/Authentication.tsx
Normal file
56
presentation/react/contexts/Authentication.tsx
Normal 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
|
||||
}
|
@ -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`.",
|
||||
)
|
||||
|
Reference in New Issue
Block a user