"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.13.0",
"expo": "~54.0.33",
+ "expo-crypto": "~15.0.8",
"expo-notifications": "~0.32.16",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "*"
}
},
+ "node_modules/expo-crypto": {
+ "version": "15.0.8",
+ "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz",
+ "integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-font": {
"version": "14.0.11",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.13.0",
"expo": "~54.0.33",
+ "expo-crypto": "~15.0.8",
"expo-notifications": "~0.32.16",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
--- /dev/null
+export interface AuthUser {
+ userId: string;
+
+ email: string;
+ username: string;
+
+ // Optionnel : nom/prénom ou nom affiché
+ displayName?: string;
+
+ // On ne stocke JAMAIS le mot de passe en clair
+ passwordHash: string;
+
+ createdAtMs: number;
+}
\ No newline at end of file
import { View, Text, StyleSheet, TextInput, TouchableOpacity } from "react-native";
-import { useMemo, useState } from "react";
import { SafeAreaView } from "react-native-safe-area-context";
+import { useMemo, useState } from "react";
import { ui } from "../components/ui/uiStyles";
import { saveSession } from "../utils/sessionStorage";
+import { createUser, verifyLogin } from "../utils/authUsersStorage";
/**
- * AuthScreen (Step 4 - sans API)
- * ------------------------------
- * Simule un login local :
- * - Email -> userId stable
- * - Session stockée en AsyncStorage
+ * AuthScreen (Step 4 - local, sans API)
+ * ------------------------------------
+ * Mode Connexion + Mode Création de compte.
+ * - Identifiant: email OU username
+ * - Mot de passe obligatoire
*
- * Plus tard : on remplace par une auth REST réelle.
+ * Plus tard : on remplacera createUser/verifyLogin par des appels REST.
*/
export default function AuthScreen({ onAuthenticated }: { onAuthenticated: () => void }) {
- const [email, setEmail] = useState<string>("demo@example.com");
+ const [mode, setMode] = useState<"login" | "register">("login");
const [error, setError] = useState<string | null>(null);
- const normalizedEmail = useMemo(() => email.trim().toLowerCase(), [email]);
+ // Connexion
+ const [login, setLogin] = useState("demo@example.com");
+ const [password, setPassword] = useState("");
+
+ // Register
+ const [email, setEmail] = useState("demo@example.com");
+ const [username, setUsername] = useState("demo");
+ const [displayName, setDisplayName] = useState("");
+ const [password2, setPassword2] = useState("");
- const isValidEmail = useMemo(() => {
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
- }, [normalizedEmail]);
+ const isValidEmail = (val: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val.trim().toLowerCase());
+ const isValidUsername = (val: string) => /^[a-zA-Z0-9_]{3,20}$/.test(val.trim());
- const makeUserId = (mail: string) => `user_${mail.replace(/[^a-z0-9]/g, "_")}`;
+ const title = useMemo(() => (mode === "login" ? "Connexion" : "Créer un compte"), [mode]);
const handleLogin = async () => {
setError(null);
- if (!isValidEmail) {
- setError("Email invalide.");
- return;
- }
+ const l = login.trim();
+ const p = password;
- const userId = makeUserId(normalizedEmail);
+ if (!l) return setError("Veuillez entrer un email ou un nom d’utilisateur.");
+ if (!p || p.length < 6) return setError("Mot de passe invalide (min 6).");
+
+ const res = await verifyLogin({ login: l, password: p });
+ if (!res.ok) return setError(res.message);
await saveSession({
- userId,
- email: normalizedEmail,
+ userId: res.user.userId,
+ email: res.user.email,
+ createdAtMs: Date.now(),
+ });
+
+ onAuthenticated();
+ };
+
+ const handleRegister = async () => {
+ setError(null);
+
+ const e = email.trim().toLowerCase();
+ const u = username.trim();
+ const d = displayName.trim();
+ const p1 = password;
+ const p2 = password2;
+
+ if (!isValidEmail(e)) return setError("Email invalide.");
+ if (!isValidUsername(u)) return setError("Username invalide (3-20, lettres/chiffres/_).");
+ if (!p1 || p1.length < 6) return setError("Mot de passe trop court (min 6).");
+ if (p1 !== p2) return setError("Les mots de passe ne correspondent pas.");
+
+ const res = await createUser({
+ email: e,
+ username: u,
+ displayName: d || undefined,
+ password: p1,
+ });
+
+ if (!res.ok) return setError(res.message);
+
+ // auto-login après création
+ await saveSession({
+ userId: res.user.userId,
+ email: res.user.email,
createdAtMs: Date.now(),
});
return (
<SafeAreaView style={styles.safeArea}>
<View style={ui.container}>
- <Text style={styles.title}>Connexion</Text>
+ <Text style={styles.title}>{title}</Text>
+
+ {/* Tabs */}
+ <View style={styles.tabsRow}>
+ <TouchableOpacity
+ style={[styles.tab, mode === "login" && styles.tabActive]}
+ onPress={() => {
+ setMode("login");
+ setError(null);
+ }}
+ >
+ <Text style={[styles.tabText, mode === "login" && styles.tabTextActive]}>
+ Connexion
+ </Text>
+ </TouchableOpacity>
+
+ <TouchableOpacity
+ style={[styles.tab, mode === "register" && styles.tabActive]}
+ onPress={() => {
+ setMode("register");
+ setError(null);
+ }}
+ >
+ <Text style={[styles.tabText, mode === "register" && styles.tabTextActive]}>
+ Créer un compte
+ </Text>
+ </TouchableOpacity>
+ </View>
<View style={ui.card}>
- <Text style={ui.title}>Email</Text>
+ {mode === "login" ? (
+ <>
+ <Text style={ui.muted}>Email ou nom d’utilisateur</Text>
+ <TextInput
+ value={login}
+ onChangeText={setLogin}
+ autoCapitalize="none"
+ placeholder="ex: demo@example.com ou demo"
+ style={styles.input}
+ />
- <TextInput
- value={email}
- onChangeText={setEmail}
- autoCapitalize="none"
- keyboardType="email-address"
- placeholder="ex: demo@example.com"
- style={styles.input}
- />
+ <Text style={[ui.muted, { marginTop: 10 }]}>Mot de passe</Text>
+ <TextInput
+ value={password}
+ onChangeText={setPassword}
+ secureTextEntry
+ placeholder="min 6 caractères"
+ style={styles.input}
+ />
- {!!error && <Text style={styles.errorText}>{error}</Text>}
+ {!!error && <Text style={styles.errorText}>{error}</Text>}
- <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleLogin}>
- <Text style={ui.buttonText}>Se connecter</Text>
- </TouchableOpacity>
+ <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleLogin}>
+ <Text style={ui.buttonText}>Se connecter</Text>
+ </TouchableOpacity>
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>
+ (Mode local sans API : destiné au développement / démo.)
+ </Text>
+ </>
+ ) : (
+ <>
+ <Text style={ui.muted}>Email</Text>
+ <TextInput
+ value={email}
+ onChangeText={setEmail}
+ autoCapitalize="none"
+ keyboardType="email-address"
+ placeholder="ex: demo@example.com"
+ style={styles.input}
+ />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Nom d’utilisateur</Text>
+ <TextInput
+ value={username}
+ onChangeText={setUsername}
+ autoCapitalize="none"
+ placeholder="ex: demo_123"
+ style={styles.input}
+ />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Nom / Prénom (optionnel)</Text>
+ <TextInput
+ value={displayName}
+ onChangeText={setDisplayName}
+ placeholder="ex: Thibaud M."
+ style={styles.input}
+ />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Mot de passe</Text>
+ <TextInput
+ value={password}
+ onChangeText={setPassword}
+ secureTextEntry
+ placeholder="min 6 caractères"
+ style={styles.input}
+ />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Confirmer</Text>
+ <TextInput
+ value={password2}
+ onChangeText={setPassword2}
+ secureTextEntry
+ placeholder="retaper le mot de passe"
+ style={styles.input}
+ />
- <Text style={[ui.muted, { marginTop: 10 }]}>
- Step 4 (sans API) : session locale. Plus tard, auth réelle via serveur.
- </Text>
+ {!!error && <Text style={styles.errorText}>{error}</Text>}
+
+ <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleRegister}>
+ <Text style={ui.buttonText}>Créer le compte</Text>
+ </TouchableOpacity>
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>
+ (Sans API : stockage local + hash SHA-256 pour éviter le mot de passe en clair.)
+ </Text>
+ </>
+ )}
</View>
</View>
</SafeAreaView>
}
const styles = StyleSheet.create({
- safeArea: {
+ safeArea: { flex: 1, backgroundColor: ui.screen.backgroundColor },
+
+ title: { fontSize: 22, fontWeight: "900", marginBottom: 12, color: "#0f172a" },
+
+ tabsRow: {
+ flexDirection: "row",
+ gap: 10,
+ marginBottom: 12,
+ },
+ tab: {
flex: 1,
- backgroundColor: ui.screen.backgroundColor,
+ paddingVertical: 10,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: "#e5e7eb",
+ backgroundColor: "#fff",
+ alignItems: "center",
},
- title: {
- fontSize: 22,
- fontWeight: "900",
- marginBottom: 12,
- color: "#0f172a",
+ tabActive: {
+ borderColor: "#0f172a",
},
+ tabText: { fontWeight: "900", color: "#0f172a", opacity: 0.7 },
+ tabTextActive: { opacity: 1 },
+
input: {
borderWidth: 1,
borderColor: "#e5e7eb",
backgroundColor: "#fff",
color: "#0f172a",
},
- fullButton: {
- flexGrow: 0,
- flexBasis: "auto",
- width: "100%",
- marginTop: 12,
- },
- errorText: {
- marginTop: 10,
- color: "#dc2626",
- fontWeight: "900",
- },
+
+ fullButton: { flexGrow: 0, flexBasis: "auto", width: "100%", marginTop: 12 },
+
+ errorText: { marginTop: 10, color: "#dc2626", fontWeight: "900" },
});
\ No newline at end of file
const [index, setIndex] = useState(0);
const isFirst = index === 0;
const isLast = index === slides.length - 1;
-
const slide = slides[index];
const finish = async () => {
};
return (
- <SafeAreaView style={styles.safeArea}>
- <View style={[ui.container, { flex: 1 }]}>
- {/* Top bar */}
- <View style={styles.topRow}>
+ <SafeAreaView style={styles.safeArea} edges={["top", "bottom"]}>
+ <View style={styles.page}>
+ {/* TOP BAR */}
+ <View style={[styles.topBar, ui.container]}>
<Text style={styles.small}>
{index + 1}/{slides.length}
</Text>
</TouchableOpacity>
</View>
- {/* Slide content (sans swipe) */}
- <View style={styles.slide}>
+ {/* CONTENT (on laisse de la place en bas pour la barre fixe) */}
+ <View style={[styles.content, ui.container]}>
<View style={styles.iconWrap}>
<Ionicons name={slide.icon} size={38} color="#0f172a" style={{ opacity: 0.85 }} />
</View>
<Text style={styles.text}>{slide.text}</Text>
</View>
- {/* Dots */}
- <View style={styles.dotsRow}>
- {slides.map((_, i) => (
- <View key={i} style={[styles.dot, i === index && styles.dotActive]} />
- ))}
- </View>
-
- {/* Bottom buttons */}
- <View style={styles.bottomRow}>
- <TouchableOpacity
- style={[styles.secondaryBtn, isFirst && styles.disabledBtn]}
- onPress={prev}
- disabled={isFirst}
- >
- <Text style={[styles.secondaryText, isFirst && styles.disabledText]}>Précédent</Text>
- </TouchableOpacity>
+ {/* BOTTOM BAR FIXE */}
+ <View style={styles.bottomFixed}>
+ <View style={styles.dotsRow}>
+ {slides.map((_, i) => (
+ <View key={i} style={[styles.dot, i === index && styles.dotActive]} />
+ ))}
+ </View>
- <TouchableOpacity
- style={[ui.button, styles.primaryBtn]}
- onPress={isLast ? finish : next}
- >
- <Text style={ui.buttonText}>{isLast ? "Terminer" : "Suivant"}</Text>
- </TouchableOpacity>
+ <View style={styles.bottomRow}>
+ <TouchableOpacity
+ style={[styles.secondaryBtn, isFirst && styles.disabledBtn]}
+ onPress={prev}
+ disabled={isFirst}
+ >
+ <Text style={[styles.secondaryText, isFirst && styles.disabledText]}>
+ Précédent
+ </Text>
+ </TouchableOpacity>
+
+ <TouchableOpacity
+ style={[styles.primaryBtn]}
+ onPress={isLast ? finish : next}
+ >
+ <Text style={styles.primaryText}>
+ {isLast ? "Terminer" : "Suivant"}
+ </Text>
+ </TouchableOpacity>
+ </View>
</View>
</View>
</SafeAreaView>
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: ui.screen.backgroundColor },
+ page: { flex: 1 },
+
+ topBar: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingTop: 6,
+ paddingBottom: 8,
+ },
- topRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
small: { fontWeight: "900", color: "#0f172a", opacity: 0.6 },
skip: { fontWeight: "900", color: "#0f172a", opacity: 0.75 },
- slide: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 22 },
+ // On réserve de la place pour la barre fixe
+ content: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ paddingBottom: 140, // ✅ réserve l’espace pour les boutons fixes
+ },
+
iconWrap: {
width: 76,
height: 76,
lineHeight: 20,
},
- dotsRow: { flexDirection: "row", justifyContent: "center", gap: 8, marginBottom: 12, marginTop: 6 },
+ // ✅ barre du bas fixée
+ bottomFixed: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ bottom: 0,
+ paddingHorizontal: 16,
+ paddingTop: 10,
+ paddingBottom: 16,
+ backgroundColor: ui.screen.backgroundColor,
+ borderTopWidth: 1,
+ borderTopColor: "#e5e7eb",
+ },
+
+ dotsRow: {
+ flexDirection: "row",
+ justifyContent: "center",
+ gap: 8,
+ marginBottom: 10,
+ },
dot: { width: 8, height: 8, borderRadius: 8, backgroundColor: "#0f172a", opacity: 0.15 },
dotActive: { opacity: 0.55 },
- bottomRow: { flexDirection: "row", gap: 10, alignItems: "center", paddingBottom: 8 },
+ bottomRow: { flexDirection: "row", gap: 10, alignItems: "center" },
secondaryBtn: {
paddingVertical: 12,
},
secondaryText: { fontWeight: "900", color: "#0f172a", opacity: 0.8 },
- primaryBtn: { flex: 1, flexGrow: 0, flexBasis: "auto" },
+ primaryBtn: {
+ flex: 1,
+ borderRadius: 12,
+ paddingVertical: 12,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "#16a34a", // ✅ un vert "crypto friendly"
+ },
+ primaryText: { color: "#fff", fontWeight: "900" },
disabledBtn: { opacity: 0.45 },
disabledText: { opacity: 0.6 },
--- /dev/null
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import * as Crypto from "expo-crypto";
+import type { AuthUser } from "../models/AuthUser";
+
+const KEY = "authUsers";
+
+/**
+ * ⚠️ Salt local (demo)
+ * -------------------
+ * Pour un vrai système, le salt doit être par user et géré côté serveur + bcrypt/argon2.
+ * Ici : on fait une version éducative + défendable (pas de mdp en clair).
+ */
+const LOCAL_SALT = "wall-e-tte_local_salt_v1";
+
+async function sha256(input: string): Promise<string> {
+ return await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, input);
+}
+
+export async function hashPassword(password: string): Promise<string> {
+ const normalized = password.trim();
+ return await sha256(`${LOCAL_SALT}:${normalized}`);
+}
+
+export async function loadUsers(): Promise<AuthUser[]> {
+ const raw = await AsyncStorage.getItem(KEY);
+ if (!raw) return [];
+
+ try {
+ const parsed = JSON.parse(raw) as AuthUser[];
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+async function saveUsers(users: AuthUser[]): Promise<void> {
+ await AsyncStorage.setItem(KEY, JSON.stringify(users));
+}
+
+export async function findUserByLogin(login: string): Promise<AuthUser | null> {
+ const users = await loadUsers();
+ const key = login.trim().toLowerCase();
+
+ const found = users.find(
+ (u) => u.email.toLowerCase() === key || u.username.toLowerCase() === key
+ );
+
+ return found ?? null;
+}
+
+/**
+ * Crée un utilisateur local (Register).
+ * - Vérifie unicité email + username
+ * - Hash le mot de passe
+ */
+export async function createUser(params: {
+ email: string;
+ username: string;
+ displayName?: string;
+ password: string;
+}): Promise<{ ok: true; user: AuthUser } | { ok: false; message: string }> {
+ const email = params.email.trim().toLowerCase();
+ const username = params.username.trim().toLowerCase();
+ const displayName = (params.displayName ?? "").trim();
+
+ const users = await loadUsers();
+
+ if (users.some((u) => u.email.toLowerCase() === email)) {
+ return { ok: false, message: "Cet email est déjà utilisé." };
+ }
+ if (users.some((u) => u.username.toLowerCase() === username)) {
+ return { ok: false, message: "Ce nom d’utilisateur est déjà utilisé." };
+ }
+
+ const passwordHash = await hashPassword(params.password);
+
+ const user: AuthUser = {
+ userId: `user_${username}`, // stable et simple pour le local
+ email,
+ username,
+ displayName: displayName || undefined,
+ passwordHash,
+ createdAtMs: Date.now(),
+ };
+
+ await saveUsers([...users, user]);
+ return { ok: true, user };
+}
+
+/**
+ * Vérifie mot de passe pour un login (email OU username).
+ */
+export async function verifyLogin(params: {
+ login: string;
+ password: string;
+}): Promise<{ ok: true; user: AuthUser } | { ok: false; message: string }> {
+ const user = await findUserByLogin(params.login);
+
+ if (!user) return { ok: false, message: "Compte introuvable." };
+
+ const hash = await hashPassword(params.password);
+
+ if (hash !== user.passwordHash) {
+ return { ok: false, message: "Mot de passe incorrect." };
+ }
+
+ return { ok: true, user };
+}
\ No newline at end of file