From: Thibaud Moustier Date: Fri, 27 Feb 2026 19:55:35 +0000 (+0100) Subject: "Mobile : Modification fenetre tuto + modification fenetre auth" X-Git-Url: https://git.digitality.be/?a=commitdiff_plain;h=2edc21718da004cab595aecc3701c41c4c0767c3;p=pdw25-26 "Mobile : Modification fenetre tuto + modification fenetre auth" --- diff --git a/Wallette/mobile/package-lock.json b/Wallette/mobile/package-lock.json index fd361d2..6377cc9 100644 --- a/Wallette/mobile/package-lock.json +++ b/Wallette/mobile/package-lock.json @@ -13,6 +13,7 @@ "@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", @@ -4745,6 +4746,18 @@ "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", diff --git a/Wallette/mobile/package.json b/Wallette/mobile/package.json index 0db1739..813958d 100644 --- a/Wallette/mobile/package.json +++ b/Wallette/mobile/package.json @@ -14,6 +14,7 @@ "@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", diff --git a/Wallette/mobile/src/models/AuthUser.ts b/Wallette/mobile/src/models/AuthUser.ts new file mode 100644 index 0000000..1e13ac9 --- /dev/null +++ b/Wallette/mobile/src/models/AuthUser.ts @@ -0,0 +1,14 @@ +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 diff --git a/Wallette/mobile/src/screens/AuthScreen.tsx b/Wallette/mobile/src/screens/AuthScreen.tsx index 8e15dac..8ac9986 100644 --- a/Wallette/mobile/src/screens/AuthScreen.tsx +++ b/Wallette/mobile/src/screens/AuthScreen.tsx @@ -1,44 +1,87 @@ 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("demo@example.com"); + const [mode, setMode] = useState<"login" | "register">("login"); const [error, setError] = useState(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(), }); @@ -48,29 +91,124 @@ export default function AuthScreen({ onAuthenticated }: { onAuthenticated: () => return ( - Connexion + {title} + + {/* Tabs */} + + { + setMode("login"); + setError(null); + }} + > + + Connexion + + + + { + setMode("register"); + setError(null); + }} + > + + Créer un compte + + + - Email + {mode === "login" ? ( + <> + Email ou nom d’utilisateur + - + Mot de passe + - {!!error && {error}} + {!!error && {error}} - - Se connecter - + + Se connecter + + + + (Mode local sans API : destiné au développement / démo.) + + + ) : ( + <> + Email + + + Nom d’utilisateur + + + Nom / Prénom (optionnel) + + + Mot de passe + + + Confirmer + - - Step 4 (sans API) : session locale. Plus tard, auth réelle via serveur. - + {!!error && {error}} + + + Créer le compte + + + + (Sans API : stockage local + hash SHA-256 pour éviter le mot de passe en clair.) + + + )} @@ -78,16 +216,30 @@ export default function AuthScreen({ onAuthenticated }: { onAuthenticated: () => } 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", @@ -98,15 +250,8 @@ const styles = StyleSheet.create({ 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 diff --git a/Wallette/mobile/src/screens/TutorialScreen.tsx b/Wallette/mobile/src/screens/TutorialScreen.tsx index 5ffe6f7..3f5f36c 100644 --- a/Wallette/mobile/src/screens/TutorialScreen.tsx +++ b/Wallette/mobile/src/screens/TutorialScreen.tsx @@ -58,7 +58,6 @@ export default function TutorialScreen({ onDone }: { onDone: () => void }) { const [index, setIndex] = useState(0); const isFirst = index === 0; const isLast = index === slides.length - 1; - const slide = slides[index]; const finish = async () => { @@ -79,10 +78,10 @@ export default function TutorialScreen({ onDone }: { onDone: () => void }) { }; return ( - - - {/* Top bar */} - + + + {/* TOP BAR */} + {index + 1}/{slides.length} @@ -92,8 +91,8 @@ export default function TutorialScreen({ onDone }: { onDone: () => void }) { - {/* Slide content (sans swipe) */} - + {/* CONTENT (on laisse de la place en bas pour la barre fixe) */} + @@ -102,29 +101,34 @@ export default function TutorialScreen({ onDone }: { onDone: () => void }) { {slide.text} - {/* Dots */} - - {slides.map((_, i) => ( - - ))} - - - {/* Bottom buttons */} - - - Précédent - + {/* BOTTOM BAR FIXE */} + + + {slides.map((_, i) => ( + + ))} + - - {isLast ? "Terminer" : "Suivant"} - + + + + Précédent + + + + + + {isLast ? "Terminer" : "Suivant"} + + + @@ -133,12 +137,27 @@ export default function TutorialScreen({ onDone }: { onDone: () => void }) { 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, @@ -161,11 +180,30 @@ const styles = StyleSheet.create({ 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, @@ -177,7 +215,15 @@ const styles = StyleSheet.create({ }, 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 }, diff --git a/Wallette/mobile/src/utils/authUsersStorage.ts b/Wallette/mobile/src/utils/authUsersStorage.ts new file mode 100644 index 0000000..aa45bf5 --- /dev/null +++ b/Wallette/mobile/src/utils/authUsersStorage.ts @@ -0,0 +1,108 @@ +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 { + return await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, input); +} + +export async function hashPassword(password: string): Promise { + const normalized = password.trim(); + return await sha256(`${LOCAL_SALT}:${normalized}`); +} + +export async function loadUsers(): Promise { + 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 { + await AsyncStorage.setItem(KEY, JSON.stringify(users)); +} + +export async function findUserByLogin(login: string): Promise { + 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