fix: corrige les dépendances mobile et la qualité du code frontend

Mobile (Expo SDK 53) :
- supprime victory-native et react-native-svg (dépendances mortes, non
  importées) qui empêchaient l'installation (ETARGET sur svg@~16.0.0 +
  conflit de peer reanimated/skia)
- ajoute les peers manquants expo-constants et expo-linking (requis par
  expo-router)
- aligne expo-router, react-native et react-native-screens sur le SDK 53
- ajoute package-lock.json (installs reproductibles)
- expo-doctor 18/18, tsc sans erreur

Frontend (Next.js) :
- EmotionChart : remplace 4 `any` par les types Recharts (ValueType/NameType)
- EditSaisieModal : remplace le setState dans useEffect par l'ajustement
  d'état pendant le rendu (pattern recommandé par React)
- tsc, eslint et build de production sans erreur

Backend : 15 tests passent (Auth, Admin, Tracker, RGPD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Azmog
2026-06-16 17:19:07 +02:00
parent bfd35905c9
commit 2ec2a78469
23 changed files with 9905 additions and 60 deletions
@@ -5,7 +5,9 @@ namespace App\Http\Controllers;
use App\Http\Requests\UpdatePasswordRequest; use App\Http\Requests\UpdatePasswordRequest;
use App\Http\Requests\UpdateProfilRequest; use App\Http\Requests\UpdateProfilRequest;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ProfilController extends Controller class ProfilController extends Controller
{ {
@@ -56,4 +58,68 @@ class ProfilController extends Controller
'message' => 'Compte supprimé avec succès', 'message' => 'Compte supprimé avec succès',
]); ]);
} }
// RGPD — Article 20 : droit à la portabilité des données
public function export(): JsonResponse
{
$utilisateur = auth()->user();
$utilisateur->load(['role', 'tracker.saisies.emotion', 'contactsUrgence']);
$payload = [
'export_genere_le' => now()->toIso8601String(),
'utilisateur' => [
'id' => $utilisateur->id,
'nom' => $utilisateur->nom,
'prenom' => $utilisateur->prenom,
'email' => $utilisateur->email,
'role' => $utilisateur->role->nom ?? null,
'consentement_rgpd' => $utilisateur->consentement_rgpd,
'est_actif' => $utilisateur->est_actif,
'cree_le' => $utilisateur->created_at?->toIso8601String(),
'mis_a_jour_le' => $utilisateur->updated_at?->toIso8601String(),
],
'saisies_tracker' => $utilisateur->tracker
? $utilisateur->tracker->saisies->map(fn ($s) => [
'date_saisie' => $s->date_saisie?->toIso8601String(),
'emotion' => $s->emotion->nom ?? null,
'emotion_parent' => $s->emotion->parent->nom ?? null,
'intensite' => $s->intensite,
'note' => $s->note,
])->values()
: [],
];
return response()->json($payload)
->header('Content-Disposition', 'attachment; filename="cesizen-export-'.$utilisateur->id.'.json"');
}
// RGPD — Article 17 : alternative à la suppression définitive — anonymisation
public function anonymiser(): JsonResponse
{
$utilisateur = auth()->user();
DB::transaction(function () use ($utilisateur) {
$pseudo = 'anonyme_'.Str::random(10);
$utilisateur->update([
'nom' => 'Anonyme',
'prenom' => 'Utilisateur',
'email' => $pseudo.'@anonymise.cesizen.local',
'password' => Hash::make(Str::random(40)),
'est_actif' => false,
'consentement_rgpd' => false,
]);
$utilisateur->contactsUrgence()->delete();
if ($utilisateur->tracker) {
$utilisateur->tracker->saisies()->update(['note' => null]);
}
});
auth()->logout();
return response()->json([
'message' => 'Compte anonymisé. Vos données personnelles ont été effacées et vos saisies dépersonnalisées.',
]);
}
} }
@@ -18,7 +18,7 @@ class SaisieTrackerController extends Controller
return response()->json([]); return response()->json([]);
} }
$query = $tracker->saisies()->with('emotion')->orderBy('date_saisie', 'desc'); $query = $tracker->saisies()->with('emotion.parent')->orderBy('date_saisie', 'desc');
if ($request->filled('date_debut')) { if ($request->filled('date_debut')) {
$query->where('date_saisie', '>=', $request->date_debut); $query->where('date_saisie', '>=', $request->date_debut);
+5 -1
View File
@@ -44,6 +44,10 @@ Route::prefix('v1')->group(function () {
Route::put('profil/password', [ProfilController::class, 'updatePassword']); Route::put('profil/password', [ProfilController::class, 'updatePassword']);
Route::delete('profil', [ProfilController::class, 'destroy']); Route::delete('profil', [ProfilController::class, 'destroy']);
// RGPD
Route::get('profil/export', [ProfilController::class, 'export']);
Route::post('profil/anonymiser', [ProfilController::class, 'anonymiser']);
// Émotions // Émotions
Route::get('emotions', [EmotionController::class, 'index']); Route::get('emotions', [EmotionController::class, 'index']);
@@ -58,7 +62,7 @@ Route::prefix('v1')->group(function () {
}); });
// ── Admin ────────────────────────────────────────────── // ── Admin ──────────────────────────────────────────────
Route::middleware(['jwt.auth', 'role:administrateur'])->prefix('admin')->group(function () { Route::middleware(['jwt.auth', 'role:administrateur', 'throttle:60,1'])->prefix('admin')->group(function () {
// Utilisateurs // Utilisateurs
Route::get('utilisateurs', [AdminUtilisateurController::class, 'index']); Route::get('utilisateurs', [AdminUtilisateurController::class, 'index']);
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature;
use App\Models\Role;
use App\Models\Utilisateur;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Tymon\JWTAuth\Facades\JWTAuth;
class AdminUtilisateurTest extends TestCase
{
use RefreshDatabase;
protected Utilisateur $admin;
protected Utilisateur $membre;
protected string $tokenAdmin;
protected string $tokenMembre;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$roleAdmin = Role::where('nom', 'administrateur')->first();
$roleMembre = Role::where('nom', 'membre')->first();
$this->admin = Utilisateur::create([
'nom' => 'Admin', 'prenom' => 'Super', 'email' => 'admin@test.fr',
'password' => 'Admin123!', 'role_id' => $roleAdmin->id, 'consentement_rgpd' => true,
]);
$this->membre = Utilisateur::create([
'nom' => 'Membre', 'prenom' => 'Lambda', 'email' => 'membre@test.fr',
'password' => 'Password123!', 'role_id' => $roleMembre->id, 'consentement_rgpd' => true,
]);
$this->tokenAdmin = JWTAuth::fromUser($this->admin);
$this->tokenMembre = JWTAuth::fromUser($this->membre);
}
public function test_un_admin_peut_lister_les_utilisateurs(): void
{
$this->withHeaders(['Authorization' => 'Bearer '.$this->tokenAdmin])
->getJson('/api/v1/admin/utilisateurs')
->assertOk();
}
public function test_un_membre_ne_peut_pas_acceder_a_l_admin(): void
{
$this->withHeaders(['Authorization' => 'Bearer '.$this->tokenMembre])
->getJson('/api/v1/admin/utilisateurs')
->assertForbidden();
}
public function test_un_admin_peut_desactiver_un_compte(): void
{
$this->withHeaders(['Authorization' => 'Bearer '.$this->tokenAdmin])
->patchJson('/api/v1/admin/utilisateurs/'.$this->membre->id.'/toggle-active')
->assertOk();
$this->assertFalse($this->membre->fresh()->est_actif);
}
public function test_un_admin_peut_supprimer_un_compte_soft_delete(): void
{
$this->withHeaders(['Authorization' => 'Bearer '.$this->tokenAdmin])
->deleteJson('/api/v1/admin/utilisateurs/'.$this->membre->id)
->assertOk();
$this->assertSoftDeleted('utilisateurs', ['id' => $this->membre->id]);
}
public function test_endpoint_export_rgpd_renvoie_les_donnees_utilisateur(): void
{
$this->withHeaders(['Authorization' => 'Bearer '.$this->tokenMembre])
->getJson('/api/v1/profil/export')
->assertOk()
->assertJsonStructure(['export_genere_le', 'utilisateur', 'saisies_tracker']);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Tests\Feature;
use App\Models\Role;
use App\Models\Utilisateur;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
}
public function test_un_visiteur_peut_creer_un_compte(): void
{
$response = $this->postJson('/api/v1/auth/register', [
'nom' => 'Marzuk',
'prenom' => 'Adam',
'email' => 'adam@test.fr',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'consentement_rgpd' => true,
]);
$response->assertCreated()
->assertJsonStructure(['utilisateur', 'token']);
$this->assertDatabaseHas('utilisateurs', ['email' => 'adam@test.fr']);
}
public function test_un_utilisateur_peut_se_connecter(): void
{
$role = Role::where('nom', 'membre')->first();
Utilisateur::create([
'nom' => 'Test',
'prenom' => 'User',
'email' => 'login@test.fr',
'password' => 'Password123!',
'role_id' => $role->id,
'consentement_rgpd' => true,
]);
$response = $this->postJson('/api/v1/auth/login', [
'email' => 'login@test.fr',
'password' => 'Password123!',
]);
$response->assertOk()
->assertJsonStructure(['utilisateur', 'token']);
}
public function test_un_compte_desactive_ne_peut_pas_se_connecter(): void
{
$role = Role::where('nom', 'membre')->first();
Utilisateur::create([
'nom' => 'Inactif',
'prenom' => 'User',
'email' => 'inactif@test.fr',
'password' => 'Password123!',
'role_id' => $role->id,
'consentement_rgpd' => true,
'est_actif' => false,
]);
$response = $this->postJson('/api/v1/auth/login', [
'email' => 'inactif@test.fr',
'password' => 'Password123!',
]);
$response->assertForbidden();
}
public function test_un_acces_protege_renvoie_401_sans_token(): void
{
$this->getJson('/api/v1/profil')->assertUnauthorized();
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace Tests\Feature;
use App\Models\Emotion;
use App\Models\Role;
use App\Models\Tracker;
use App\Models\Utilisateur;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Tymon\JWTAuth\Facades\JWTAuth;
class TrackerTest extends TestCase
{
use RefreshDatabase;
protected Utilisateur $utilisateur;
protected Emotion $emotion;
protected string $token;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->emotion = Emotion::create([
'nom' => 'Joie',
'couleur' => '#FFD700',
'niveau' => 1,
'est_actif' => true,
]);
$role = Role::where('nom', 'membre')->first();
$this->utilisateur = Utilisateur::create([
'nom' => 'Test',
'prenom' => 'User',
'email' => 'tracker@test.fr',
'password' => 'Password123!',
'role_id' => $role->id,
'consentement_rgpd' => true,
]);
Tracker::create(['utilisateur_id' => $this->utilisateur->id, 'nom' => 'Mon journal']);
$this->token = JWTAuth::fromUser($this->utilisateur);
}
private function authHeaders(): array
{
return ['Authorization' => 'Bearer '.$this->token];
}
public function test_utilisateur_peut_creer_une_saisie(): void
{
$response = $this->withHeaders($this->authHeaders())
->postJson('/api/v1/tracker/saisies', [
'emotion_id' => $this->emotion->id,
'intensite' => 7,
'note' => 'Bonne journée',
'date_saisie' => '2026-05-15',
]);
$response->assertCreated()
->assertJsonPath('saisie.intensite', 7);
$this->assertDatabaseHas('saisie_trackers', ['intensite' => 7]);
}
public function test_intensite_doit_etre_entre_1_et_10(): void
{
$response = $this->withHeaders($this->authHeaders())
->postJson('/api/v1/tracker/saisies', [
'emotion_id' => $this->emotion->id,
'intensite' => 15,
'date_saisie' => '2026-05-15',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['intensite']);
}
public function test_utilisateur_peut_lister_ses_saisies(): void
{
$this->utilisateur->tracker->saisies()->create([
'emotion_id' => $this->emotion->id,
'intensite' => 5,
'date_saisie' => '2026-05-15',
]);
$response = $this->withHeaders($this->authHeaders())
->getJson('/api/v1/tracker/saisies');
$response->assertOk();
}
public function test_utilisateur_peut_supprimer_sa_saisie(): void
{
$saisie = $this->utilisateur->tracker->saisies()->create([
'emotion_id' => $this->emotion->id,
'intensite' => 5,
'date_saisie' => '2026-05-15',
]);
$this->withHeaders($this->authHeaders())
->deleteJson('/api/v1/tracker/saisies/'.$saisie->id)
->assertOk();
$this->assertDatabaseMissing('saisie_trackers', ['id' => $saisie->id]);
}
}
+53 -5
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Plus, Filter } from 'lucide-react'; import { Plus, Filter, Download } from 'lucide-react';
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker'; import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
import { useEmotions } from '@/hooks/useEmotions'; import { useEmotions } from '@/hooks/useEmotions';
import TrackerTimeline from '@/components/features/TrackerTimeline'; import TrackerTimeline from '@/components/features/TrackerTimeline';
@@ -10,9 +10,33 @@ import EditSaisieModal from '@/components/features/EditSaisieModal';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Skeleton from '@/components/ui/Skeleton'; import Skeleton from '@/components/ui/Skeleton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import type { SaisieTracker } from '@/types'; import type { SaisieTracker } from '@/types';
function exportSaisiesCsv(saisies: SaisieTracker[]): void {
const escape = (v: unknown): string => {
const s = v === null || v === undefined ? '' : String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const headers = ['Date', 'Emotion', 'Emotion parente', 'Intensite', 'Note'];
const rows = saisies.map((s) => [
s.date_saisie,
s.emotion?.nom ?? '',
s.emotion?.parent?.nom ?? '',
s.intensite,
s.note ?? '',
]);
const csv = [headers, ...rows].map((r) => r.map(escape).join(',')).join('\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cesizen-saisies-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
export default function JournalPage() { export default function JournalPage() {
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
@@ -21,21 +45,31 @@ export default function JournalPage() {
emotion_id?: number; emotion_id?: number;
}>({}); }>({});
const [editingSaisie, setEditingSaisie] = useState<SaisieTracker | null>(null); const [editingSaisie, setEditingSaisie] = useState<SaisieTracker | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const { data: saisies, isLoading } = useSaisies(filters); const { data: saisies, isLoading } = useSaisies(filters);
const { data: emotions } = useEmotions(); const { data: emotions } = useEmotions();
const deleteSaisie = useDeleteSaisie(); const deleteSaisie = useDeleteSaisie();
const handleDelete = async (id: number) => { const confirmDelete = async () => {
if (!confirm('Supprimer cette saisie ?')) return; if (!deleteId) return;
try { try {
await deleteSaisie.mutateAsync(id); await deleteSaisie.mutateAsync(deleteId);
toast.success('Saisie supprimée'); toast.success('Saisie supprimée');
} catch { } catch {
toast.error('Erreur lors de la suppression'); toast.error('Erreur lors de la suppression');
} }
}; };
const handleExport = () => {
if (!saisies || saisies.length === 0) {
toast.error('Aucune saisie à exporter');
return;
}
exportSaisiesCsv(saisies);
toast.success('Export CSV téléchargé');
};
return ( return (
<div> <div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
@@ -56,6 +90,10 @@ export default function JournalPage() {
<Filter size={16} /> <Filter size={16} />
Filtrer Filtrer
</Button> </Button>
<Button variant="ghost" size="sm" onClick={handleExport} title="Exporter mes saisies (RGPD)">
<Download size={16} />
Export CSV
</Button>
<Link href="/journal/nouvelle-saisie"> <Link href="/journal/nouvelle-saisie">
<Button size="sm"> <Button size="sm">
<Plus size={16} /> <Plus size={16} />
@@ -138,7 +176,7 @@ export default function JournalPage() {
<TrackerTimeline <TrackerTimeline
saisies={saisies || []} saisies={saisies || []}
onEdit={(saisie) => setEditingSaisie(saisie)} onEdit={(saisie) => setEditingSaisie(saisie)}
onDelete={handleDelete} onDelete={(id) => setDeleteId(id)}
/> />
)} )}
@@ -148,6 +186,16 @@ export default function JournalPage() {
isOpen={!!editingSaisie} isOpen={!!editingSaisie}
onClose={() => setEditingSaisie(null)} onClose={() => setEditingSaisie(null)}
/> />
<ConfirmDialog
isOpen={deleteId !== null}
title="Supprimer la saisie"
message="Cette saisie sera définitivement supprimée de votre journal. Cette action est irréversible."
confirmLabel="Supprimer"
destructive
onConfirm={confirmDelete}
onClose={() => setDeleteId(null)}
/>
</div> </div>
); );
} }
@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import type { SaisieTracker } from '@/types'; import type { SaisieTracker } from '@/types';
import { useUpdateSaisie } from '@/hooks/useTracker'; import { useUpdateSaisie } from '@/hooks/useTracker';
import EmotionPicker from '@/components/features/EmotionPicker'; import EmotionPicker from '@/components/features/EmotionPicker';
@@ -22,14 +22,16 @@ export default function EditSaisieModal({ saisie, isOpen, onClose }: EditSaisieM
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [dateSaisie, setDateSaisie] = useState(''); const [dateSaisie, setDateSaisie] = useState('');
useEffect(() => { // Synchronise le formulaire quand une nouvelle saisie est passée : ajustement
if (saisie) { // d'état pendant le rendu (pattern recommandé par React, sans effet).
const [prevSaisieId, setPrevSaisieId] = useState<number | null>(null);
if (saisie && saisie.id !== prevSaisieId) {
setPrevSaisieId(saisie.id);
setEmotionId(saisie.emotion_id); setEmotionId(saisie.emotion_id);
setIntensite(saisie.intensite); setIntensite(saisie.intensite);
setNote(saisie.note || ''); setNote(saisie.note || '');
setDateSaisie(saisie.date_saisie.split('T')[0]); setDateSaisie(saisie.date_saisie.split('T')[0]);
} }
}, [saisie]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!saisie || !emotionId) return; if (!saisie || !emotionId) return;
@@ -16,6 +16,7 @@ import {
Line, Line,
Legend, Legend,
} from 'recharts'; } from 'recharts';
import type { ValueType, NameType } from 'recharts/types/component/DefaultTooltipContent';
import type { RapportData } from '@/types'; import type { RapportData } from '@/types';
interface EmotionChartProps { interface EmotionChartProps {
@@ -48,14 +49,18 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
outerRadius={120} outerRadius={120}
paddingAngle={3} paddingAngle={3}
dataKey="value" dataKey="value"
label={({ name, pourcentage }: any) => `${name} (${pourcentage}%)`} label={({ name, pourcentage }: { name?: string; pourcentage?: number }) =>
`${name} (${pourcentage}%)`
}
> >
{pieData.map((entry, index) => ( {pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} /> <Cell key={`cell-${index}`} fill={entry.color} />
))} ))}
</Pie> </Pie>
<Tooltip <Tooltip
formatter={(value: any, name: any) => [`${value} saisies`, name]} formatter={(value: ValueType | undefined, name: NameType | undefined) =>
[`${value} saisies`, name]
}
contentStyle={{ contentStyle={{
backgroundColor: 'rgba(255,255,255,0.95)', backgroundColor: 'rgba(255,255,255,0.95)',
border: 'none', border: 'none',
@@ -139,7 +144,7 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
borderRadius: '12px', borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)', boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}} }}
formatter={(value: any) => [`${Number(value).toFixed(1)}`, 'Intensité moyenne']} formatter={(value: ValueType | undefined) => [`${Number(value).toFixed(1)}`, 'Intensité moyenne']}
/> />
<Bar dataKey="intensite" fill="#fce117" radius={[6, 6, 0, 0]} /> <Bar dataKey="intensite" fill="#fce117" radius={[6, 6, 0, 0]} />
</BarChart> </BarChart>
@@ -0,0 +1,68 @@
"use client";
import { AlertTriangle } from "lucide-react";
import Modal from "./Modal";
import Button from "./Button";
interface ConfirmDialogProps {
isOpen: boolean;
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onClose: () => void;
}
export default function ConfirmDialog({
isOpen,
title = "Confirmation",
message,
confirmLabel = "Confirmer",
cancelLabel = "Annuler",
destructive = false,
onConfirm,
onClose,
}: ConfirmDialogProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<div className="flex items-start gap-3 mb-6">
<div
className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${
destructive
? "bg-red-50 dark:bg-red-950/40"
: "bg-malachite-50 dark:bg-malachite-950/40"
}`}
>
<AlertTriangle
size={20}
className={
destructive
? "text-red-600 dark:text-red-400"
: "text-malachite-600 dark:text-malachite-400"
}
/>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed pt-1">
{message}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
{cancelLabel}
</Button>
<Button
variant={destructive ? "danger" : "primary"}
onClick={() => {
onConfirm();
onClose();
}}
>
{confirmLabel}
</Button>
</div>
</Modal>
);
}
+1
View File
@@ -23,6 +23,7 @@ export interface Emotion {
icone: string | null; icone: string | null;
niveau: number; niveau: number;
parent_id: number | null; parent_id: number | null;
parent?: Emotion | null;
est_actif: boolean; est_actif: boolean;
enfants?: Emotion[]; enfants?: Emotion[];
} }
+8
View File
@@ -38,9 +38,17 @@ export default function TabLayout() {
name="info" name="info"
options={{ options={{
title: 'Infos', title: 'Infos',
headerShown: false,
tabBarIcon: ({ color, size }) => <Ionicons name="information-circle" size={size} color={color} />, tabBarIcon: ({ color, size }) => <Ionicons name="information-circle" size={size} color={color} />,
}} }}
/> />
<Tabs.Screen
name="urgence"
options={{
title: 'Urgence',
tabBarIcon: ({ color, size }) => <Ionicons name="call" size={size} color={color} />,
}}
/>
<Tabs.Screen <Tabs.Screen
name="profile" name="profile"
options={{ options={{
+61
View File
@@ -0,0 +1,61 @@
import { View, Text, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useFeed } from '@/hooks/useFeeds';
import { Colors } from '@/lib/colors';
export default function FeedDetailScreen() {
const { slug } = useLocalSearchParams<{ slug: string }>();
const { data: feed, isLoading, isError } = useFeed(slug);
if (isLoading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color={Colors.secondary} />
</View>
);
}
if (isError || !feed) {
return (
<View style={styles.centered}>
<Ionicons name="alert-circle-outline" size={48} color={Colors.gray[400]} />
<Text style={styles.errorText}>Article introuvable</Text>
</View>
);
}
const texteNettoye = (feed.contenu ?? '').replace(/<[^>]*>/g, '');
return (
<>
<Stack.Screen options={{ title: feed.titre ?? '' }} />
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.badge}>
<Ionicons name="information-circle" size={14} color={Colors.secondary} />
<Text style={styles.badgeText}>Information santé mentale</Text>
</View>
<Text style={styles.title}>{feed.titre}</Text>
<Text style={styles.date}>
Publié le {new Date(feed.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</Text>
<Text style={styles.body}>{texteNettoye}</Text>
</ScrollView>
</>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white },
content: { padding: 20, paddingBottom: 40 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: Colors.white },
badge: { flexDirection: 'row', alignItems: 'center', gap: 6, alignSelf: 'flex-start', backgroundColor: `${Colors.secondary}15`, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, marginBottom: 12 },
badgeText: { fontSize: 12, fontWeight: '600', color: Colors.secondary },
title: { fontSize: 24, fontWeight: '800', color: Colors.black, marginBottom: 8, lineHeight: 30 },
date: { fontSize: 13, color: Colors.gray[400], marginBottom: 20 },
body: { fontSize: 15, lineHeight: 24, color: Colors.gray[700] ?? '#374151' },
errorText: { marginTop: 12, fontSize: 16, color: Colors.gray[400] },
});
+17
View File
@@ -0,0 +1,17 @@
import { Stack } from 'expo-router';
import { Colors } from '@/lib/colors';
export default function InfoLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: Colors.white },
headerTitleStyle: { fontWeight: '700', color: Colors.black },
headerTintColor: Colors.secondary,
}}
>
<Stack.Screen name="index" options={{ title: 'Infos' }} />
<Stack.Screen name="[slug]" options={{ title: '', headerBackTitle: 'Retour' }} />
</Stack>
);
}
@@ -1,13 +1,18 @@
import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useFeeds } from '@/hooks/useFeeds'; import { useFeeds } from '@/hooks/useFeeds';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
export default function InfoScreen() { export default function InfoListScreen() {
const { data: feeds, isLoading } = useFeeds(); const { data: feeds, isLoading } = useFeeds();
const router = useRouter();
const renderItem = ({ item }: { item: any }) => ( const renderItem = ({ item }: { item: any }) => (
<TouchableOpacity style={styles.card}> <TouchableOpacity
style={styles.card}
onPress={() => router.push(`/(tabs)/info/${item.slug}`)}
>
<View style={styles.cardIcon}> <View style={styles.cardIcon}>
<Ionicons name="document-text" size={24} color={Colors.secondary} /> <Ionicons name="document-text" size={24} color={Colors.secondary} />
</View> </View>
@@ -33,8 +38,8 @@ export default function InfoScreen() {
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
ListHeaderComponent={ ListHeaderComponent={
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.headerTitle}>Sante mentale</Text> <Text style={styles.headerTitle}>Santé mentale</Text>
<Text style={styles.headerSubtitle}>Ressources et informations pour votre bien-etre</Text> <Text style={styles.headerSubtitle}>Ressources et informations pour votre bien-être</Text>
</View> </View>
} }
ListEmptyComponent={ ListEmptyComponent={
+125
View File
@@ -0,0 +1,125 @@
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Linking, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/lib/colors';
type Contact = {
nom: string;
numero: string;
description: string;
disponibilite: string;
};
const CONTACTS: Contact[] = [
{
nom: 'Prévention du suicide',
numero: '3114',
description: 'Numéro national gratuit et confidentiel pour les personnes en détresse psychologique et leur entourage.',
disponibilite: '24h/24 — 7j/7',
},
{
nom: 'SOS Amitié',
numero: '0972394050',
description: 'Écoute et soutien pour les personnes en souffrance, solitude ou détresse morale.',
disponibilite: '24h/24',
},
{
nom: 'Fil Santé Jeunes',
numero: '0800235236',
description: 'Ligne d\'écoute gratuite et anonyme pour les 12-25 ans.',
disponibilite: 'Lun-Dim, 9h-23h',
},
{
nom: 'SAMU',
numero: '15',
description: 'Service d\'aide médicale urgente. En cas d\'urgence vitale.',
disponibilite: '24h/24 — 7j/7',
},
{
nom: 'SOS Médecins',
numero: '3624',
description: 'Consultations médicales à domicile.',
disponibilite: '24h/24 — 7j/7',
},
{
nom: 'Numéro européen d\'urgence',
numero: '112',
description: 'Police, pompiers, SAMU. À utiliser en cas d\'urgence vitale.',
disponibilite: '24h/24 — 7j/7',
},
];
function formatNumero(n: string): string {
if (n.length <= 4) return n;
return n.replace(/(\d{2})(?=\d)/g, '$1 ').trim();
}
export default function UrgenceScreen() {
const appeler = async (numero: string, nom: string) => {
const url = `tel:${numero}`;
const supported = await Linking.canOpenURL(url);
if (supported) {
Linking.openURL(url);
} else {
Alert.alert('Impossible d\'appeler', `Composez manuellement le ${formatNumero(numero)} pour joindre ${nom}.`);
}
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.header}>
<View style={styles.iconBadge}>
<Ionicons name="heart" size={20} color="#DC2626" />
</View>
<Text style={styles.headerTitle}>Vous n'êtes pas seul(e)</Text>
<Text style={styles.headerSubtitle}>
Si vous ou un proche traversez une période difficile, ces services sont là pour vous écouter.
</Text>
</View>
{CONTACTS.map((c) => (
<TouchableOpacity key={c.numero} style={styles.card} onPress={() => appeler(c.numero, c.nom)}>
<View style={styles.cardIcon}>
<Ionicons name="call" size={22} color="#DC2626" />
</View>
<View style={styles.cardContent}>
<Text style={styles.cardName}>{c.nom}</Text>
<Text style={styles.cardNumber}>{formatNumero(c.numero)}</Text>
<Text style={styles.cardDescription}>{c.description}</Text>
<Text style={styles.cardAvailability}>{c.disponibilite}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={Colors.gray[300]} />
</TouchableOpacity>
))}
<View style={styles.warning}>
<Ionicons name="warning" size={18} color="#DC2626" />
<Text style={styles.warningText}>
En cas d'urgence vitale, appelez immédiatement le <Text style={styles.bold}>15</Text> (SAMU) ou le <Text style={styles.bold}>112</Text>.
</Text>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white },
content: { padding: 16, paddingBottom: 32 },
header: { marginBottom: 20, alignItems: 'flex-start' },
iconBadge: { width: 40, height: 40, borderRadius: 999, backgroundColor: '#FEE2E2', justifyContent: 'center', alignItems: 'center', marginBottom: 12 },
headerTitle: { fontSize: 22, fontWeight: '800', color: Colors.black, marginBottom: 6 },
headerSubtitle: { fontSize: 14, color: Colors.gray[500], lineHeight: 20 },
card: {
flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16,
backgroundColor: Colors.gray[50], borderRadius: 16, marginBottom: 10,
borderWidth: 1, borderColor: Colors.gray[100],
},
cardIcon: { width: 44, height: 44, borderRadius: 12, backgroundColor: '#FEE2E2', justifyContent: 'center', alignItems: 'center' },
cardContent: { flex: 1 },
cardName: { fontSize: 14, fontWeight: '700', color: Colors.black, marginBottom: 2 },
cardNumber: { fontSize: 20, fontWeight: '800', color: '#DC2626', marginBottom: 6 },
cardDescription: { fontSize: 12, color: Colors.gray[500], lineHeight: 17 },
cardAvailability: { fontSize: 11, color: Colors.secondary, marginTop: 4, fontWeight: '600' },
warning: { marginTop: 8, padding: 14, borderRadius: 12, backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FECACA', flexDirection: 'row', gap: 10, alignItems: 'flex-start' },
warningText: { flex: 1, fontSize: 13, color: '#B91C1C', lineHeight: 19 },
bold: { fontWeight: '800' },
});
+112
View File
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
import { Link, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/lib/colors';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function ForgotPasswordScreen() {
const router = useRouter();
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = () => {
setError(null);
if (!email.trim()) {
setError('Veuillez renseigner votre adresse email.');
return;
}
if (!EMAIL_REGEX.test(email)) {
setError('Format d\'email invalide.');
return;
}
setSubmitted(true);
};
if (submitted) {
return (
<View style={styles.container}>
<View style={styles.success}>
<View style={styles.successIcon}>
<Ionicons name="mail-open" size={32} color={Colors.secondary} />
</View>
<Text style={styles.successTitle}>Demande enregistrée</Text>
<Text style={styles.successText}>
Si l'adresse <Text style={styles.bold}>{email}</Text> est associée à un compte CESIZen, un email contenant les instructions de réinitialisation sera envoyé.
</Text>
<Text style={styles.successText}>
Si vous ne recevez rien sous 24h, contactez votre administrateur.
</Text>
<TouchableOpacity style={styles.button} onPress={() => router.replace('/auth/login')}>
<Text style={styles.buttonText}>Retour à la connexion</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
<View style={styles.header}>
<Text style={styles.logo}>Mot de passe oublié ?</Text>
<Text style={styles.subtitle}>
Entrez votre adresse email. Si elle est associée à un compte, vous recevrez les instructions pour réinitialiser votre mot de passe.
</Text>
</View>
<View style={styles.form}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={(v) => { setEmail(v); setError(null); }}
placeholder="votre@email.com"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
<TouchableOpacity style={styles.button} onPress={handleSubmit}>
<Text style={styles.buttonText}>Envoyer les instructions</Text>
</TouchableOpacity>
<View style={styles.footer}>
<Link href="/auth/login" asChild>
<TouchableOpacity>
<Text style={styles.link}>Retour à la connexion</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white },
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: 24 },
header: { alignItems: 'center', marginBottom: 32 },
logo: { fontSize: 26, fontWeight: '800', color: Colors.black, textAlign: 'center' },
subtitle: { fontSize: 14, color: Colors.gray[500], marginTop: 10, textAlign: 'center', lineHeight: 20 },
form: { backgroundColor: Colors.gray[50], borderRadius: 24, padding: 24 },
inputGroup: { marginBottom: 16 },
label: { fontSize: 14, fontWeight: '600', color: Colors.gray[700], marginBottom: 6 },
input: { backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.gray[300], borderRadius: 12, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black },
errorText: { color: '#DC2626', marginTop: 6, fontSize: 12 },
button: { backgroundColor: Colors.primary, borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginTop: 8 },
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
footer: { alignItems: 'center', marginTop: 20 },
link: { color: Colors.secondary, fontSize: 14, fontWeight: '600' },
success: { padding: 24, alignItems: 'center', flex: 1, justifyContent: 'center' },
successIcon: { width: 72, height: 72, borderRadius: 999, backgroundColor: `${Colors.secondary}15`, justifyContent: 'center', alignItems: 'center', marginBottom: 20 },
successTitle: { fontSize: 22, fontWeight: '800', color: Colors.black, marginBottom: 12 },
successText: { fontSize: 14, color: Colors.gray[600], textAlign: 'center', lineHeight: 20, marginBottom: 12 },
bold: { fontWeight: '700', color: Colors.black },
});
+8 -1
View File
@@ -70,6 +70,12 @@ export default function LoginScreen() {
<Text style={styles.buttonText}>{loading ? 'Connexion...' : 'Se connecter'}</Text> <Text style={styles.buttonText}>{loading ? 'Connexion...' : 'Se connecter'}</Text>
</TouchableOpacity> </TouchableOpacity>
<Link href="/auth/forgot-password" asChild>
<TouchableOpacity style={styles.forgotLink}>
<Text style={styles.link}>Mot de passe oublié ?</Text>
</TouchableOpacity>
</Link>
<View style={styles.footer}> <View style={styles.footer}>
<Text style={styles.footerText}>Pas encore de compte ? </Text> <Text style={styles.footerText}>Pas encore de compte ? </Text>
<Link href="/auth/register" asChild> <Link href="/auth/register" asChild>
@@ -113,7 +119,8 @@ const styles = StyleSheet.create({
}, },
buttonDisabled: { opacity: 0.6 }, buttonDisabled: { opacity: 0.6 },
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black }, buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 }, forgotLink: { alignItems: 'center', marginTop: 12 },
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 16 },
footerText: { color: Colors.gray[500], fontSize: 14 }, footerText: { color: Colors.gray[500], fontSize: 14 },
link: { color: Colors.secondary, fontSize: 14, fontWeight: '600' }, link: { color: Colors.secondary, fontSize: 14, fontWeight: '600' },
}); });
+62 -20
View File
@@ -1,9 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
import { Link, useRouter } from 'expo-router'; import { Link, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function RegisterScreen() { export default function RegisterScreen() {
const router = useRouter(); const router = useRouter();
const { register } = useAuthStore(); const { register } = useAuthStore();
@@ -12,23 +15,43 @@ export default function RegisterScreen() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [passwordConfirmation, setPasswordConfirmation] = useState(''); const [passwordConfirmation, setPasswordConfirmation] = useState('');
const [consentement, setConsentement] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => {
const e: Record<string, string> = {};
if (!nom.trim()) e.nom = 'Le nom est obligatoire';
if (!prenom.trim()) e.prenom = 'Le prénom est obligatoire';
if (!email.trim()) e.email = 'L\'email est obligatoire';
else if (!EMAIL_REGEX.test(email)) e.email = 'Format d\'email invalide';
if (!password) e.password = 'Le mot de passe est obligatoire';
else if (password.length < 8) e.password = 'Le mot de passe doit faire au moins 8 caractères';
if (password !== passwordConfirmation) e.passwordConfirmation = 'Les mots de passe ne correspondent pas';
if (!consentement) e.consentement = 'Vous devez accepter le traitement de vos données (RGPD)';
setErrors(e);
return Object.keys(e).length === 0;
};
const handleRegister = async () => { const handleRegister = async () => {
if (!nom || !prenom || !email || !password || !passwordConfirmation) { if (!validate()) return;
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
return;
}
if (password !== passwordConfirmation) {
Alert.alert('Erreur', 'Les mots de passe ne correspondent pas');
return;
}
setLoading(true); setLoading(true);
try { try {
await register({ nom, prenom, email, password, password_confirmation: passwordConfirmation }); await register({
nom, prenom, email, password,
password_confirmation: passwordConfirmation,
consentement_rgpd: consentement,
});
router.replace('/(tabs)'); router.replace('/(tabs)');
} catch (error: any) { } catch (error: any) {
Alert.alert('Erreur', error.response?.data?.message || 'Erreur lors de l\'inscription'); const msg = error.response?.data?.message || 'Erreur lors de l\'inscription';
const apiErrors = error.response?.data?.errors;
if (apiErrors) {
const mapped: Record<string, string> = {};
Object.entries(apiErrors).forEach(([k, v]) => { mapped[k] = Array.isArray(v) ? v[0] as string : String(v); });
setErrors(mapped);
}
Alert.alert('Erreur', msg);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -39,7 +62,7 @@ export default function RegisterScreen() {
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.logo}>CESIZen</Text> <Text style={styles.logo}>CESIZen</Text>
<Text style={styles.subtitle}>Creez votre compte</Text> <Text style={styles.subtitle}>Créez votre compte</Text>
</View> </View>
<View style={styles.form}> <View style={styles.form}>
@@ -49,34 +72,50 @@ export default function RegisterScreen() {
<View style={[styles.inputGroup, { flex: 1, marginRight: 8 }]}> <View style={[styles.inputGroup, { flex: 1, marginRight: 8 }]}>
<Text style={styles.label}>Nom</Text> <Text style={styles.label}>Nom</Text>
<TextInput style={styles.input} value={nom} onChangeText={setNom} placeholder="Dupont" /> <TextInput style={styles.input} value={nom} onChangeText={setNom} placeholder="Dupont" />
{errors.nom && <Text style={styles.errorText}>{errors.nom}</Text>}
</View> </View>
<View style={[styles.inputGroup, { flex: 1, marginLeft: 8 }]}> <View style={[styles.inputGroup, { flex: 1, marginLeft: 8 }]}>
<Text style={styles.label}>Prenom</Text> <Text style={styles.label}>Prénom</Text>
<TextInput style={styles.input} value={prenom} onChangeText={setPrenom} placeholder="Jean" /> <TextInput style={styles.input} value={prenom} onChangeText={setPrenom} placeholder="Jean" />
{errors.prenom && <Text style={styles.errorText}>{errors.prenom}</Text>}
</View> </View>
</View> </View>
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text> <Text style={styles.label}>Email</Text>
<TextInput style={styles.input} value={email} onChangeText={setEmail} placeholder="votre@email.com" keyboardType="email-address" autoCapitalize="none" /> <TextInput style={styles.input} value={email} onChangeText={setEmail} placeholder="votre@email.com" keyboardType="email-address" autoCapitalize="none" autoCorrect={false} />
{errors.email && <Text style={styles.errorText}>{errors.email}</Text>}
</View> </View>
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<Text style={styles.label}>Mot de passe</Text> <Text style={styles.label}>Mot de passe (min. 8 caractères)</Text>
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="Mot de passe" secureTextEntry /> <TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="Mot de passe" secureTextEntry />
{errors.password && <Text style={styles.errorText}>{errors.password}</Text>}
</View> </View>
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<Text style={styles.label}>Confirmer le mot de passe</Text> <Text style={styles.label}>Confirmer le mot de passe</Text>
<TextInput style={styles.input} value={passwordConfirmation} onChangeText={setPasswordConfirmation} placeholder="Confirmer" secureTextEntry /> <TextInput style={styles.input} value={passwordConfirmation} onChangeText={setPasswordConfirmation} placeholder="Confirmer" secureTextEntry />
{errors.passwordConfirmation && <Text style={styles.errorText}>{errors.passwordConfirmation}</Text>}
</View> </View>
<TouchableOpacity style={styles.consentRow} onPress={() => setConsentement(!consentement)} activeOpacity={0.7}>
<View style={[styles.checkbox, consentement && styles.checkboxChecked]}>
{consentement && <Ionicons name="checkmark" size={16} color={Colors.white} />}
</View>
<Text style={styles.consentText}>
J'accepte le traitement de mes données de bien-être conformément à la <Text style={styles.consentLink}>politique de confidentialité</Text> (RGPD).
</Text>
</TouchableOpacity>
{errors.consentement && <Text style={styles.errorText}>{errors.consentement}</Text>}
{errors.consentement_rgpd && <Text style={styles.errorText}>{errors.consentement_rgpd}</Text>}
<TouchableOpacity style={[styles.button, loading && styles.buttonDisabled]} onPress={handleRegister} disabled={loading}> <TouchableOpacity style={[styles.button, loading && styles.buttonDisabled]} onPress={handleRegister} disabled={loading}>
<Text style={styles.buttonText}>{loading ? 'Inscription...' : 'S\'inscrire'}</Text> <Text style={styles.buttonText}>{loading ? 'Inscription...' : 'S\'inscrire'}</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.footer}> <View style={styles.footer}>
<Text style={styles.footerText}>Deja un compte ? </Text> <Text style={styles.footerText}>Déjà un compte ? </Text>
<Link href="/auth/login" asChild> <Link href="/auth/login" asChild>
<TouchableOpacity><Text style={styles.link}>Se connecter</Text></TouchableOpacity> <TouchableOpacity><Text style={styles.link}>Se connecter</Text></TouchableOpacity>
</Link> </Link>
@@ -98,11 +137,14 @@ const styles = StyleSheet.create({
row: { flexDirection: 'row' }, row: { flexDirection: 'row' },
inputGroup: { marginBottom: 16 }, inputGroup: { marginBottom: 16 },
label: { fontSize: 14, fontWeight: '600', color: Colors.gray[700], marginBottom: 6 }, label: { fontSize: 14, fontWeight: '600', color: Colors.gray[700], marginBottom: 6 },
input: { input: { backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.gray[300], borderRadius: 12, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black },
backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.gray[300], errorText: { color: '#DC2626', marginTop: 4, fontSize: 12 },
borderRadius: 12, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black, consentRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, marginVertical: 12 },
}, checkbox: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, borderColor: Colors.gray[300], backgroundColor: Colors.white, justifyContent: 'center', alignItems: 'center', marginTop: 1 },
button: { backgroundColor: Colors.primary, borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginTop: 8 }, checkboxChecked: { backgroundColor: Colors.secondary, borderColor: Colors.secondary },
consentText: { flex: 1, fontSize: 13, color: Colors.gray[700], lineHeight: 18 },
consentLink: { color: Colors.secondary, fontWeight: '600' },
button: { backgroundColor: Colors.primary, borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginTop: 12 },
buttonDisabled: { opacity: 0.6 }, buttonDisabled: { opacity: 0.6 },
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black }, buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 }, footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
+18 -1
View File
@@ -24,11 +24,28 @@ api.interceptors.request.use(async (config) => {
return config; return config;
}); });
// Évite d'invalider la session sur les endpoints publics d'auth (login/register/forgot)
function isAuthPublicEndpoint(url?: string): boolean {
if (!url) return false;
return /\/auth\/(login|register|forgot)/.test(url);
}
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
if (error.response?.status === 401) { const status = error.response?.status;
const url: string | undefined = error.config?.url;
if (status === 401 && !isAuthPublicEndpoint(url)) {
await SecureStore.deleteItemAsync('cesizen_token'); await SecureStore.deleteItemAsync('cesizen_token');
// Le AuthGuard de _layout détecte l'absence de token au prochain loadToken
// et redirige vers /auth/login.
try {
const { useAuthStore } = require('./auth-store');
useAuthStore.setState({ user: null, token: null, isAuthenticated: false });
} catch {
// store pas encore monté
}
} }
return Promise.reject(error); return Promise.reject(error);
} }
+1
View File
@@ -15,6 +15,7 @@ interface AuthState {
email: string; email: string;
password: string; password: string;
password_confirmation: string; password_confirmation: string;
consentement_rgpd: boolean;
}) => Promise<void>; }) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
fetchUser: () => Promise<void>; fetchUser: () => Promise<void>;
+8981
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -9,24 +9,24 @@
"web": "expo start --web" "web": "expo start --web"
}, },
"dependencies": { "dependencies": {
"expo": "~53.0.0",
"expo-router": "~5.0.0",
"expo-status-bar": "~2.2.0",
"expo-secure-store": "~14.2.0",
"expo-font": "~13.3.0",
"react": "19.0.0",
"react-native": "0.79.2",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.10.0",
"react-native-reanimated": "~3.17.4",
"react-native-gesture-handler": "~2.24.0",
"@react-navigation/bottom-tabs": "^7.3.0",
"@expo/vector-icons": "^14.0.0", "@expo/vector-icons": "^14.0.0",
"zustand": "^5.0.0", "@react-navigation/bottom-tabs": "^7.3.0",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"axios": "^1.7.0", "axios": "^1.7.0",
"react-native-svg": "~16.0.0", "expo": "~53.0.0",
"victory-native": "^41.0.0" "expo-constants": "~17.1.8",
"expo-font": "~13.3.0",
"expo-linking": "~7.1.7",
"expo-router": "~5.1.11",
"expo-secure-store": "~14.2.0",
"expo-status-bar": "~2.2.0",
"react": "19.0.0",
"react-native": "0.79.6",
"react-native-gesture-handler": "~2.24.0",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.0", "@babel/core": "^7.25.0",