This commit is contained in:
Leon Morival 2026-04-19 15:02:37 +02:00
parent 6984c1be3d
commit 345641a615
16 changed files with 583 additions and 26 deletions

View File

@ -1,7 +1,107 @@
import { Text,View } from "react-native";
import {Button} from "@/components/common/Button";
import { Redirect, router } from 'expo-router';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { Button } from '@/components/common/Button';
import { Palette } from '@/constants/palette';
import { Radius, Spacing } from '@/constants/styles';
import { useAuth } from '@/providers/AuthProvider';
import { logout } from '@/services/auth';
export default function Home() {
return <View>
<Button title="Button"/>
</View>;
const { initializing, user } = useAuth();
if (initializing) {
return (
<View style={styles.centered}>
<ActivityIndicator color={Palette.black} />
</View>
);
}
if (!user) {
return <Redirect href="/login" />;
}
return (
<View style={styles.container}>
<Text style={styles.eyebrow}>Session active</Text>
<Text style={styles.title}>Bienvenue</Text>
<Text style={styles.subtitle}>
{user.displayName || user.email || user.uid}
</Text>
<View style={styles.card}>
<Text style={styles.label}>UID</Text>
<Text style={styles.value}>{user.uid}</Text>
<Text style={styles.label}>Email</Text>
<Text style={styles.value}>{user.email || 'Aucun email'}</Text>
</View>
<Button onPress={() => void logout()} title="Se deconnecter" />
<Button
onPress={() => router.push('/register')}
style={styles.secondaryButton}
textStyle={styles.secondaryButtonText}
title="Creer un autre compte"
variant="secondary"
/>
</View>
);
}
const styles = StyleSheet.create({
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: Palette.cream,
},
container: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: Spacing.lg,
gap: 20,
backgroundColor: Palette.cream,
},
eyebrow: {
color: Palette.gray,
fontSize: 13,
fontWeight: '700',
letterSpacing: 1.1,
textTransform: 'uppercase',
},
title: {
color: Palette.black,
fontSize: 34,
fontWeight: '800',
},
subtitle: {
color: Palette.gray,
fontSize: 16,
lineHeight: 22,
},
card: {
gap: Spacing.sm,
padding: 18,
borderRadius: Radius.xl,
borderWidth: 1,
borderColor: Palette.cream,
backgroundColor: Palette.white,
},
label: {
color: Palette.gray,
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
},
value: {
color: Palette.black,
fontSize: 15,
},
secondaryButton: {
borderColor: Palette.black,
},
secondaryButtonText: {
color: Palette.black,
},
});

View File

@ -1,9 +1,17 @@
import { Stack } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { AuthProvider } from "@/providers/AuthProvider";
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
<SafeAreaProvider>
<AuthProvider>
<Stack>
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="register" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</AuthProvider>
</SafeAreaProvider>
);
}

14
app/login.tsx Normal file
View File

@ -0,0 +1,14 @@
import { Redirect } from 'expo-router';
import { AuthForm } from '@/components/auth/AuthForm';
import { useAuth } from '@/providers/AuthProvider';
export default function LoginScreen() {
const { initializing, user } = useAuth();
if (!initializing && user) {
return <Redirect href="/(tabs)" />;
}
return <AuthForm mode="login" />;
}

14
app/register.tsx Normal file
View File

@ -0,0 +1,14 @@
import { Redirect } from 'expo-router';
import { AuthForm } from '@/components/auth/AuthForm';
import { useAuth } from '@/providers/AuthProvider';
export default function RegisterScreen() {
const { initializing, user } = useAuth();
if (!initializing && user) {
return <Redirect href="/(tabs)" />;
}
return <AuthForm mode="register" />;
}

View File

@ -6,6 +6,7 @@
"name": "expo-starter",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
@ -509,6 +510,8 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="],
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
"@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="],
@ -1151,6 +1154,8 @@
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
"is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
@ -1283,6 +1288,8 @@
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
"merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"metro": ["metro@0.83.3", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-config": "0.83.3", "metro-core": "0.83.3", "metro-file-map": "0.83.3", "metro-resolver": "0.83.3", "metro-runtime": "0.83.3", "metro-source-map": "0.83.3", "metro-symbolicate": "0.83.3", "metro-transform-plugins": "0.83.3", "metro-transform-worker": "0.83.3", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q=="],

View File

@ -0,0 +1,237 @@
import { Link } from 'expo-router';
import { useMemo, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/common/Button';
import { Palette } from '@/constants/palette';
import { Radius, Spacing } from '@/constants/styles';
import { mapFirebaseAuthError } from '@/lib/firebase-auth-errors';
import { loginWithEmail, registerWithEmail } from '@/services/auth';
type AuthMode = 'login' | 'register';
type AuthFormProps = {
mode: AuthMode;
};
const copyByMode = {
login: {
title: 'Connexion',
submitLabel: 'Se connecter',
switchLabel: "Pas encore de compte ? S'inscrire",
switchHref: '/register' as const,
},
register: {
title: 'Inscription',
submitLabel: "S'inscrire",
switchLabel: 'Déjà un compte ? Se connecter',
switchHref: '/login' as const,
},
} as const;
export function AuthForm({ mode }: AuthFormProps) {
const copy = copyByMode[mode];
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const isFormValid = useMemo(() => {
if (!email.trim() || !password.trim()) {
return false;
}
if (mode === 'register' && !displayName.trim()) {
return false;
}
return true;
}, [displayName, email, mode, password]);
const handleSubmit = async () => {
if (!isFormValid || isSubmitting) {
return;
}
setError(null);
setIsSubmitting(true);
try {
if (mode === 'login') {
await loginWithEmail(email, password);
} else {
await registerWithEmail({
email,
password,
displayName,
});
}
} catch (submitError) {
setError(mapFirebaseAuthError(submitError));
} finally {
setIsSubmitting(false);
}
};
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<View style={styles.container}>
<View style={styles.hero}>
<Text style={styles.title}>{copy.title}</Text>
</View>
<View style={styles.card}>
{mode === 'register' ? (
<View style={styles.field}>
<Text style={styles.label}>Nom</Text>
<TextInput
autoCapitalize="words"
onChangeText={setDisplayName}
placeholder="Pierre"
placeholderTextColor={Palette.black}
style={styles.input}
value={displayName}
/>
</View>
) : null}
<View style={styles.field}>
<Text style={styles.label}>Email</Text>
<TextInput
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
onChangeText={setEmail}
placeholder="pierre@email.com"
placeholderTextColor={Palette.black}
style={styles.input}
value={email}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Mot de passe</Text>
<TextInput
autoCapitalize="none"
autoComplete={mode === 'login' ? 'password' : 'new-password'}
onChangeText={setPassword}
placeholder="••••••••"
placeholderTextColor={Palette.black}
secureTextEntry
style={styles.input}
value={password}
/>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
disabled={!isFormValid || isSubmitting}
onPress={handleSubmit}
style={styles.submitButton}
title={isSubmitting ? 'Chargement...' : copy.submitLabel}
/>
{isSubmitting ? (
<ActivityIndicator color={Palette.black} style={styles.loader} />
) : null}
<Link href={copy.switchHref} style={styles.switchLink}>
{copy.switchLabel}
</Link>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: Palette.cream,
},
keyboardView: {
flex: 1,
},
container: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: Spacing.lg,
gap: Spacing.lg,
backgroundColor: Palette.cream,
},
hero: {
gap: Spacing.sm,
},
title: {
color: Palette.black,
fontSize: 36,
fontWeight: '800',
},
subtitle: {
color: Palette.gray,
fontSize: 16,
lineHeight: 22,
},
card: {
backgroundColor: Palette.white,
borderColor: Palette.cream,
borderRadius: Radius.xl,
borderWidth: 1,
padding: 20,
gap: Spacing.md,
},
field: {
gap: Spacing.sm,
},
label: {
color: Palette.black,
fontSize: 14,
fontWeight: '600',
},
input: {
borderWidth: 1,
borderColor: Palette.gray,
borderRadius: Radius.lg,
backgroundColor: Palette.white,
color: Palette.black,
fontSize: 16,
paddingHorizontal: Spacing.md,
paddingVertical: 14,
},
error: {
color: Palette.danger,
fontSize: 14,
lineHeight: 20,
},
submitButton: {
minHeight: 52,
},
loader: {
marginTop: -4,
},
switchLink: {
color: Palette.black,
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
textDecorationLine: 'underline',
},
});

View File

@ -7,6 +7,7 @@ import {
TextStyle,
} from "react-native";
import {Palette} from "@/constants/palette";
import { Radius, Spacing } from "@/constants/styles";
type ButtonVariant = "primary" | "secondary";
@ -24,7 +25,7 @@ const variantStyles = {
primary: {
container: {
backgroundColor: Palette.black,
borderColor: "transparent",
borderColor: Palette.transparent,
},
text: {
color: Palette.white,
@ -32,7 +33,7 @@ const variantStyles = {
},
secondary: {
container: {
backgroundColor: "transparent",
backgroundColor: Palette.transparent,
borderColor: Palette.black,
},
text: {
@ -80,8 +81,8 @@ export default Button;
const styles = StyleSheet.create({
base: {
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 12,
paddingHorizontal: Spacing.md,
borderRadius: Radius.md,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
@ -92,4 +93,4 @@ const styles = StyleSheet.create({
disabled: {
opacity: 0.5,
},
});
});

View File

@ -1,4 +1,8 @@
export const Palette = {
white: '#FFF',
black: '#000'
};
black: '#000',
transparent: 'transparent',
cream: '#f5efe6',
gray: '#5a5148',
danger: '#9f2d2d',
};

View File

@ -0,0 +1,17 @@
export const Spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 40,
} as const;
export const Radius = {
sm: 8,
md: 12,
lg: 16,
xl: 24,
pill: 999,
} as const;

View File

@ -0,0 +1,21 @@
const firebaseAuthErrorMessages: Record<string, string> = {
'auth/email-already-in-use': 'Cet email est deja utilise.',
'auth/invalid-credential': 'Email ou mot de passe incorrect.',
'auth/invalid-email': "L'adresse email est invalide.",
'auth/weak-password': 'Le mot de passe doit contenir au moins 6 caracteres.',
};
const defaultFirebaseAuthErrorMessage =
"Une erreur est survenue. Verifie ta configuration Firebase.";
export function mapFirebaseAuthError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
for (const [code, mappedMessage] of Object.entries(firebaseAuthErrorMessages)) {
if (message.includes(code)) {
return mappedMessage;
}
}
return defaultFirebaseAuthErrorMessage;
}

View File

@ -1,7 +1,18 @@
import { getApps, initializeApp } from
'firebase/app';
import { getAuth } from 'firebase/auth';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getApps, initializeApp } from 'firebase/app';
import { getAuth, type Auth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { Platform } from 'react-native';
import type { FirebaseApp } from 'firebase/app';
type ReactNativeAuthModule = {
getAuth(app?: FirebaseApp): Auth;
initializeAuth(
app: FirebaseApp,
deps?: { persistence?: unknown }
): Auth;
getReactNativePersistence(storage: typeof AsyncStorage): unknown;
};
const firebaseConfig = {
apiKey:
@ -14,11 +25,24 @@ const firebaseConfig = {
process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId:
process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
};
const app = getApps().length ? getApps()[0] :
initializeApp(firebaseConfig);
const isAppInitialized = getApps().length > 0;
const app = isAppInitialized ? getApps()[0] : initializeApp(firebaseConfig);
const reactNativeAuth =
Platform.OS === 'web'
? null
: (require('@firebase/auth') as ReactNativeAuthModule);
export const auth = getAuth(app);
export const db = getFirestore(app);
const auth =
Platform.OS === 'web'
? getAuth(app)
: isAppInitialized
? (reactNativeAuth as ReactNativeAuthModule).getAuth(app)
: (reactNativeAuth as ReactNativeAuthModule).initializeAuth(app, {
persistence: (reactNativeAuth as ReactNativeAuthModule).getReactNativePersistence(AsyncStorage),
});
export { auth };
export const db = getFirestore(app);

View File

@ -3,15 +3,19 @@ import {
doc,
type DocumentData,
type FirestoreDataConverter,
type PartialWithFieldValue,
type QueryDocumentSnapshot,
type SetOptions,
type SnapshotOptions,
type WithFieldValue,
} from 'firebase/firestore';
import { db } from '@/lib/firebase';
import type { UserDocument } from '@/types/firestore';
const converter = <T extends DocumentData>(): FirestoreDataConverter<T> => ({
toFirestore: (value: T) => value,
toFirestore: (value: WithFieldValue<T> | PartialWithFieldValue<T>, options?: SetOptions) =>
value as DocumentData,
fromFirestore: (
snapshot: QueryDocumentSnapshot,
options: SnapshotOptions

View File

@ -12,6 +12,7 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",

View File

@ -0,0 +1,52 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from 'react';
import { onAuthStateChanged, type User } from 'firebase/auth';
import { auth } from '@/lib/firebase';
type AuthContextValue = {
user: User | null;
initializing: boolean;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: PropsWithChildren) {
const [user, setUser] = useState<User | null>(null);
const [initializing, setInitializing] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (nextUser) => {
setUser(nextUser);
setInitializing(false);
});
return unsubscribe;
}, []);
const value = useMemo(
() => ({
user,
initializing,
}),
[initializing, user]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

53
services/auth.ts Normal file
View File

@ -0,0 +1,53 @@
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
updateProfile,
} from 'firebase/auth';
import { serverTimestamp, setDoc } from 'firebase/firestore';
import { auth } from '@/lib/firebase';
import { userDoc } from '@/lib/firestore';
type RegisterParams = {
email: string;
password: string;
displayName: string;
};
export async function loginWithEmail(email: string, password: string) {
return signInWithEmailAndPassword(auth, email.trim(), password);
}
export async function registerWithEmail({
email,
password,
displayName,
}: RegisterParams) {
const credential = await createUserWithEmailAndPassword(
auth,
email.trim(),
password
);
const normalizedDisplayName = displayName.trim();
await updateProfile(credential.user, {
displayName: normalizedDisplayName,
});
await setDoc(userDoc(credential.user.uid), {
uid: credential.user.uid,
email: credential.user.email,
displayName: normalizedDisplayName,
photoURL: credential.user.photoURL,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
});
return credential;
}
export async function logout() {
return signOut(auth);
}

View File

@ -5,6 +5,6 @@ export type UserDocument = {
email: string | null;
displayName: string | null;
photoURL: string | null;
createdAt: Timestamp | null;
updatedAt: Timestamp | null;
createdAt: Timestamp;
updatedAt: Timestamp;
};