]> git.digitality.be Git - pdw25-26/commitdiff
"Mobile : Modification fenetre tuto + modification fenetre auth"
authorThibaud Moustier <thibaudmoustier0@gmail.com>
Fri, 27 Feb 2026 19:55:35 +0000 (20:55 +0100)
committerThibaud Moustier <thibaudmoustier0@gmail.com>
Fri, 27 Feb 2026 19:55:35 +0000 (20:55 +0100)
Wallette/mobile/package-lock.json
Wallette/mobile/package.json
Wallette/mobile/src/models/AuthUser.ts [new file with mode: 0644]
Wallette/mobile/src/screens/AuthScreen.tsx
Wallette/mobile/src/screens/TutorialScreen.tsx
Wallette/mobile/src/utils/authUsersStorage.ts [new file with mode: 0644]

index fd361d289845da2662cb77af19fe9f45703b1cc8..6377cc91b295926e087aa4190b13fe103d5d5b55 100644 (file)
@@ -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",
         "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",
index 0db1739d0bb07caebe29b336277c1a21de80481c..813958d9d5e1e46ee372f1a125bd0b261b9b0e92 100644 (file)
@@ -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 (file)
index 0000000..1e13ac9
--- /dev/null
@@ -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
index 8e15dac9348e0dbdd561fc5517377d3813537560..8ac998653cf639a4185778fd385440232115d168 100644 (file)
@@ -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<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(),
     });
 
@@ -48,29 +91,124 @@ export default function AuthScreen({ onAuthenticated }: { onAuthenticated: () =>
   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>
@@ -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
index 5ffe6f7346dd65985feca42908a06d925e5f4483..3f5f36cc6ad50c0a37d73e2ccabb4637027711fa 100644 (file)
@@ -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 (
-    <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>
@@ -92,8 +91,8 @@ export default function TutorialScreen({ onDone }: { onDone: () => void }) {
           </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>
@@ -102,29 +101,34 @@ export default function TutorialScreen({ onDone }: { onDone: () => void }) {
           <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>
@@ -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 (file)
index 0000000..aa45bf5
--- /dev/null
@@ -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<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