From 8dd5873f25bf0d5ef277abdfc698a680edfb146d Mon Sep 17 00:00:00 2001 From: Mathew Date: Sat, 18 Jul 2026 13:11:03 +0200 Subject: [PATCH] (Feat) Add google --- android/app/build.gradle.kts | 37 ++- android/app/src/main/AndroidManifest.xml | 9 +- .../fidelite}/MainActivity.kt | 2 +- docs/OAUTH_GOOGLE.md | 65 ++++ lib/main.dart | 12 +- lib/models/client.dart | 2 +- lib/models/mouvement.dart | 2 +- lib/pocketbase_config.dart | 43 +++ lib/screens/auth_gate.dart | 20 +- lib/screens/complete_profile_screen.dart | 4 +- lib/screens/home_client_screen.dart | 4 +- lib/screens/login_screen.dart | 19 +- lib/screens/signup_screen.dart | 63 ++-- lib/screens/welcome_screen.dart | 3 + lib/services/session_client.dart | 129 +++++--- lib/supabase_config.dart | 19 -- lib/widgets/oauth_boutons.dart | 124 +++++++ pubspec.lock | 308 ++++-------------- pubspec.yaml | 5 +- server/pb_hooks/google_native_auth.pb.js | 105 ++++++ 20 files changed, 596 insertions(+), 379 deletions(-) rename android/app/src/main/kotlin/com/{magasin/app_fideliter_client => letreveriennais/fidelite}/MainActivity.kt (69%) create mode 100644 docs/OAUTH_GOOGLE.md create mode 100644 lib/pocketbase_config.dart delete mode 100644 lib/supabase_config.dart create mode 100644 lib/widgets/oauth_boutons.dart create mode 100644 server/pb_hooks/google_native_auth.pb.js diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a446854..b8635f4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,11 +1,22 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Charge les identifiants de la clé de signature release depuis key.properties +// (fichier hors versionnement git). Absent → build debug-signé possible. +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { - namespace = "com.magasin.app_fideliter_client" + namespace = "com.letreveriennais.fidelite" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -15,8 +26,7 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.magasin.app_fideliter_client" + applicationId = "com.letreveriennais.fidelite" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -25,11 +35,26 @@ android { versionName = flutter.versionName } + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Signature avec la clé release (upload-keystore.jks) si key.properties + // est présent, sinon repli sur la clé debug pour un build local. + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index df0a167..3036fe5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,9 @@ - + + + + + + diff --git a/android/app/src/main/kotlin/com/magasin/app_fideliter_client/MainActivity.kt b/android/app/src/main/kotlin/com/letreveriennais/fidelite/MainActivity.kt similarity index 69% rename from android/app/src/main/kotlin/com/magasin/app_fideliter_client/MainActivity.kt rename to android/app/src/main/kotlin/com/letreveriennais/fidelite/MainActivity.kt index 8ca355e..2310872 100644 --- a/android/app/src/main/kotlin/com/magasin/app_fideliter_client/MainActivity.kt +++ b/android/app/src/main/kotlin/com/letreveriennais/fidelite/MainActivity.kt @@ -1,4 +1,4 @@ -package com.magasin.app_fideliter_client +package com.letreveriennais.fidelite import io.flutter.embedding.android.FlutterActivity diff --git a/docs/OAUTH_GOOGLE.md b/docs/OAUTH_GOOGLE.md new file mode 100644 index 0000000..bec9754 --- /dev/null +++ b/docs/OAUTH_GOOGLE.md @@ -0,0 +1,65 @@ +# Activer « Continuer avec Google » + +L'app est déjà codée pour afficher le bouton Google **automatiquement** dès que le +fournisseur est activé côté PocketBase. Il reste uniquement à créer les +identifiants OAuth chez Google (étapes 1→4) puis à les brancher (étape 5). + +PocketBase utilise le **flux web** : Google redirige vers PocketBase, pas +directement vers l'app. On crée donc un client OAuth de type **« Application Web »** +(pas « Android ») — inutile de fournir le nom de package ou l'empreinte SHA-1. + +--- + +## 1. Créer un projet Google Cloud +1. Aller sur https://console.cloud.google.com/ +2. En haut, sélecteur de projet → **Nouveau projet** (ex. « Fideliter »). + +## 2. Configurer l'écran de consentement OAuth +1. Menu → **API et services → Écran de consentement OAuth**. +2. Type d'utilisateur : **Externe** → Créer. +3. Renseigner : nom de l'app (« Ma fidélité »), email d'assistance, logo (facultatif), + email du développeur. +4. **Domaines autorisés** : ajouter `tailb756e1.ts.net`. +5. Étape « Champs d'application » (scopes) : ajouter `.../auth/userinfo.email`, + `.../auth/userinfo.profile` et `openid`. Ce sont des scopes **non sensibles** + → pas de longue procédure de validation Google. +6. Pour la mise en production (Play Store) : renseigner une **URL de politique de + confidentialité** (aussi obligatoire pour le Play Store), puis passer l'app + « En production » (bouton **Publier l'application**). Sinon, tant qu'elle est en + « Test », seuls les comptes ajoutés en « utilisateurs de test » peuvent se connecter. + +## 3. Créer l'identifiant OAuth +1. Menu → **API et services → Identifiants**. +2. **Créer des identifiants → ID client OAuth**. +3. Type d'application : **Application Web**. +4. Nom : « PocketBase Fideliter » (peu importe). +5. **URI de redirection autorisés** → Ajouter EXACTEMENT : + ``` + https://db.tailb756e1.ts.net/api/oauth2-redirect + ``` +6. Créer → Google affiche le **Client ID** et le **Client Secret**. Les copier. + +## 4. (Optionnel) URI de redirection supplémentaire +Rien d'autre à ajouter pour le flux actuel. + +## 5. Brancher dans PocketBase +Deux options : + +**A. Tu me donnes le Client ID + Client Secret** → je les injecte dans PocketBase + (collection `users` → OAuth2 → Google) et le bouton apparaît dans l'app. + +**B. Tu le fais toi-même** dans l'admin PocketBase : + 1. https://db.tailb756e1.ts.net/_/ → connexion superuser. + 2. Collections → `users` → onglet **Options** → **OAuth2** → activer → **+ Add provider → Google**. + 3. Coller Client ID + Client Secret → Enregistrer. + +Dès que c'est fait, le bouton « Continuer avec Google » s'affiche tout seul sur +l'écran d'accueil (l'app lit la liste des fournisseurs activés au démarrage). + +--- + +## Notes Play Store +- Un compte créé via Google arrive sur l'écran **« Finaliser mon profil »** (nom + + téléphone) puis obtient son code de fidélité, comme une inscription classique. +- Le consentement OAuth en production exige une **politique de confidentialité** — + de toute façon requise par le Play Store. Prévois une page web (même simple). diff --git a/lib/main.dart b/lib/main.dart index bda9ad3..f4659bb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:intl/date_symbol_data_local.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; +import 'pocketbase_config.dart'; import 'screens/auth_gate.dart'; -import 'supabase_config.dart'; import 'theme.dart'; Future main() async { @@ -11,11 +10,8 @@ Future main() async { await initializeDateFormatting('fr_FR', null); - if (SupabaseConfig.estConfigure) { - await Supabase.initialize( - url: SupabaseConfig.url, - publishableKey: SupabaseConfig.anonKey, - ); + if (PbConfig.estConfigure) { + await initPocketBase(); } runApp(const MonApp()); @@ -27,7 +23,7 @@ class MonApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Ma fidélité', + title: 'Le Trévériennais', debugShowCheckedModeBanner: false, theme: AppTheme.clair(), darkTheme: AppTheme.sombre(), diff --git a/lib/models/client.dart b/lib/models/client.dart index 69a4373..ca00bda 100644 --- a/lib/models/client.dart +++ b/lib/models/client.dart @@ -1,6 +1,6 @@ /// Le compte fidélité du client connecté. Le [code] est encodé dans son QR. class Client { - final String id; // uuid Supabase + final String id; // id PocketBase final String code; // ex : « FID-3F9K2A » final String nom; final String? prenom; diff --git a/lib/models/mouvement.dart b/lib/models/mouvement.dart index 347d882..c2e5254 100644 --- a/lib/models/mouvement.dart +++ b/lib/models/mouvement.dart @@ -32,7 +32,7 @@ class Mouvement { montantEuros: (map['montant_euros'] as num?)?.toDouble(), points: (map['points'] as num?)?.toDouble() ?? 0, libelle: (map['libelle'] as String?) ?? '', - date: DateTime.tryParse(map['created_at']?.toString() ?? '')?.toLocal() ?? + date: DateTime.tryParse(map['created']?.toString() ?? '')?.toLocal() ?? DateTime.fromMillisecondsSinceEpoch(0), ); } diff --git a/lib/pocketbase_config.dart b/lib/pocketbase_config.dart new file mode 100644 index 0000000..04fc2b9 --- /dev/null +++ b/lib/pocketbase_config.dart @@ -0,0 +1,43 @@ +import 'package:pocketbase/pocketbase.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Configuration de connexion au backend PocketBase (LE MÊME que l'app magasin). +/// +/// L'URL pointe vers l'instance PocketBase auto-hébergée. La session (token +/// d'auth) est persistée localement via [SharedPreferences] pour rester +/// connecté entre deux lancements de l'app. +class PbConfig { + PbConfig._(); + + static const String url = 'https://db.tailb756e1.ts.net'; + + static bool get estConfigure => !url.contains('VOTRE-URL'); +} + +/// ID client OAuth « Web » du projet Google Cloud. Passé à google_sign_in comme +/// `serverClientId` → l'ID token renvoyé a cette audience, que le hook serveur +/// PocketBase (`/api/google-native-auth`) vérifie. +const String googleWebClientId = + '831733439319-45cergb7cf8r728fja4m0kcvu2r7fbj9.apps.googleusercontent.com'; + +/// Client PocketBase global (initialisé une fois via [initPocketBase]). +late final PocketBase pb; + +/// Initialise le client PocketBase avec une auth store persistée sur disque. +/// À appeler au démarrage, avant `runApp`. +Future initPocketBase() async { + final prefs = await SharedPreferences.getInstance(); + final store = AsyncAuthStore( + save: (String data) async => prefs.setString('pb_auth', data), + clear: () async => prefs.remove('pb_auth'), + initial: prefs.getString('pb_auth'), + ); + pb = PocketBase(PbConfig.url, authStore: store); +} + +/// Extrait un message lisible d'une erreur PocketBase (sinon [parDefaut]). +String messageErreurPb(ClientException e, String parDefaut) { + final data = e.response['message']; + if (data is String && data.trim().isNotEmpty) return data; + return parDefaut; +} diff --git a/lib/screens/auth_gate.dart b/lib/screens/auth_gate.dart index 10589d5..dccb95a 100644 --- a/lib/screens/auth_gate.dart +++ b/lib/screens/auth_gate.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:pocketbase/pocketbase.dart'; +import '../pocketbase_config.dart'; import '../services/session_client.dart'; -import '../supabase_config.dart'; import 'complete_profile_screen.dart'; import 'home_client_screen.dart'; import 'welcome_screen.dart'; @@ -13,14 +13,14 @@ class AuthGate extends StatelessWidget { @override Widget build(BuildContext context) { - if (!SupabaseConfig.estConfigure) return const _EcranNonConfigure(); + if (!PbConfig.estConfigure) return const _EcranNonConfigure(); - return StreamBuilder( - stream: supabase.auth.onAuthStateChange, + return StreamBuilder( + stream: pb.authStore.onChange, builder: (context, snapshot) { - final session = supabase.auth.currentSession; - if (session == null) return const WelcomeScreen(); - return _SessionChargee(key: ValueKey(session.user.id)); + if (!pb.authStore.isValid) return const WelcomeScreen(); + final uid = pb.authStore.record?.id ?? ''; + return _SessionChargee(key: ValueKey(uid)); }, ); } @@ -82,11 +82,11 @@ class _EcranNonConfigure extends StatelessWidget { children: [ Icon(Icons.cloud_off, size: 56, color: Colors.grey), SizedBox(height: 16), - Text('Supabase non configuré', + Text('Backend non configuré', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), SizedBox(height: 8), Text( - 'Renseignez l\'URL et la clé anon dans lib/supabase_config.dart.', + 'Renseignez l\'URL PocketBase dans lib/pocketbase_config.dart.', textAlign: TextAlign.center, style: TextStyle(color: Color(0xFF6B6B72)), ), diff --git a/lib/screens/complete_profile_screen.dart b/lib/screens/complete_profile_screen.dart index 353fe58..a1a897c 100644 --- a/lib/screens/complete_profile_screen.dart +++ b/lib/screens/complete_profile_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; +import '../pocketbase_config.dart'; import '../services/session_client.dart'; -import '../supabase_config.dart'; import '../theme.dart'; /// Affiché quand le compte est connecté mais n'a pas encore de fiche fidélité @@ -61,7 +61,7 @@ class _CompleteProfileScreenState extends State { IconButton( tooltip: 'Se déconnecter', icon: const Icon(Icons.logout), - onPressed: () => supabase.auth.signOut(), + onPressed: () => pb.authStore.clear(), ), ], ), diff --git a/lib/screens/home_client_screen.dart b/lib/screens/home_client_screen.dart index 0f3cd55..a9127de 100644 --- a/lib/screens/home_client_screen.dart +++ b/lib/screens/home_client_screen.dart @@ -3,8 +3,8 @@ import 'package:qr_flutter/qr_flutter.dart'; import '../models/mouvement.dart'; import '../models/recompense.dart'; +import '../pocketbase_config.dart'; import '../services/session_client.dart'; -import '../supabase_config.dart'; import '../theme.dart'; import '../utils/format.dart'; @@ -58,7 +58,7 @@ class _HomeClientScreenState extends State ); if (ok == true) { _session.vider(); - await supabase.auth.signOut(); + pb.authStore.clear(); } } diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 412a9d3..c19b57e 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:pocketbase/pocketbase.dart'; -import '../supabase_config.dart'; +import '../pocketbase_config.dart'; /// Connexion d'un client existant (email + mot de passe). class LoginScreen extends StatefulWidget { @@ -33,14 +33,17 @@ class _LoginScreenState extends State { _erreur = null; }); try { - await supabase.auth.signInWithPassword( - email: _emailCtrl.text.trim(), - password: _mdpCtrl.text, - ); + await pb.collection('users').authWithPassword( + _emailCtrl.text.trim(), + _mdpCtrl.text, + ); // L'AuthGate prend le relais automatiquement (pop de cet écran). if (mounted) Navigator.of(context).pop(); - } on AuthException catch (e) { - if (mounted) setState(() => _erreur = e.message); + } on ClientException catch (e) { + if (mounted) { + setState(() => + _erreur = messageErreurPb(e, 'Email ou mot de passe incorrect.')); + } } catch (e) { if (mounted) setState(() => _erreur = 'Erreur : $e'); } finally { diff --git a/lib/screens/signup_screen.dart b/lib/screens/signup_screen.dart index 1136b92..2ddf4dd 100644 --- a/lib/screens/signup_screen.dart +++ b/lib/screens/signup_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:pocketbase/pocketbase.dart'; +import '../pocketbase_config.dart'; import '../services/session_client.dart'; -import '../supabase_config.dart'; import '../theme.dart'; /// Création d'un compte client : email + mot de passe + profil (nom, téléphone). @@ -42,27 +42,29 @@ class _SignupScreenState extends State { _erreur = null; }); try { - final res = await supabase.auth.signUp( - email: _emailCtrl.text.trim(), - password: _mdpCtrl.text, - ); + final email = _emailCtrl.text.trim(); + // Création du compte, puis connexion immédiate (pas de confirmation + // d'email requise). On ne transmet PAS is_staff → compte client par défaut. + await pb.collection('users').create(body: { + 'email': email, + 'password': _mdpCtrl.text, + 'passwordConfirm': _mdpCtrl.text, + }); + await pb.collection('users').authWithPassword(email, _mdpCtrl.text); - if (res.session != null) { - // Connecté directement (confirmation d'email désactivée) → on crée le - // profil fidélité. L'AuthGate basculera ensuite vers l'accueil. - await SessionClient.instance.creerProfil( - nom: _nomCtrl.text, - prenom: _prenomCtrl.text, - telephone: _telCtrl.text, - ); - if (mounted) Navigator.of(context).pop(); - } else { - // Confirmation d'email requise : le profil sera finalisé à la 1re - // connexion (après validation du mail). - if (mounted) await _popupVerifierEmail(); + // Compte connecté → on crée le profil fidélité (nom + téléphone + code). + // L'AuthGate basculera ensuite automatiquement vers l'accueil. + await SessionClient.instance.creerProfil( + nom: _nomCtrl.text, + prenom: _prenomCtrl.text, + telephone: _telCtrl.text, + ); + if (mounted) Navigator.of(context).pop(); + } on ClientException catch (e) { + if (mounted) { + setState(() => _erreur = + messageErreurPb(e, 'Impossible de créer le compte.')); } - } on AuthException catch (e) { - if (mounted) setState(() => _erreur = e.message); } catch (e) { if (mounted) setState(() => _erreur = 'Erreur : $e'); } finally { @@ -70,25 +72,6 @@ class _SignupScreenState extends State { } } - Future _popupVerifierEmail() async { - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Vérifiez votre email'), - content: const Text( - 'Un email de confirmation vous a été envoyé. Validez-le puis ' - 'connectez-vous pour finaliser votre carte de fidélité.'), - actions: [ - FilledButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('OK'), - ), - ], - ), - ); - if (mounted) Navigator.of(context).pop(); // retour à l'accueil - } - @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index 4d34370..d969e62 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../theme.dart'; +import '../widgets/oauth_boutons.dart'; import 'login_screen.dart'; import 'signup_screen.dart'; @@ -57,6 +58,8 @@ class WelcomeScreen extends StatelessWidget { ), child: const Text('J\'ai déjà un compte'), ), + const SizedBox(height: 16), + const OAuthBoutons(), const SizedBox(height: 12), ], ), diff --git a/lib/services/session_client.dart b/lib/services/session_client.dart index 98778ad..288a77c 100644 --- a/lib/services/session_client.dart +++ b/lib/services/session_client.dart @@ -1,9 +1,13 @@ +import 'dart:math'; + import 'package:flutter/foundation.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:pocketbase/pocketbase.dart'; import '../models/client.dart'; import '../models/mouvement.dart'; import '../models/recompense.dart'; -import '../supabase_config.dart'; +import '../pocketbase_config.dart'; /// Données du client connecté (son compte, ses points, son historique) + le /// contexte magasin (nom, récompenses proposées). Source de vérité côté client. @@ -34,47 +38,46 @@ class SessionClient extends ChangeNotifier { _erreur = null; notifyListeners(); try { - final uid = supabase.auth.currentUser?.id; + final uid = pb.authStore.record?.id; if (uid == null) { _monClient = null; return; } - final row = await supabase - .from('clients') - .select() - .eq('user_id', uid) - .maybeSingle(); - _monClient = row == null ? null : Client.fromMap(row); - - // Contexte magasin (lisible par tout compte connecté). - final reglages = await supabase - .from('reglages') - .select('euros_par_point, nom_magasin') - .eq('id', 1) - .maybeSingle(); - if (reglages != null) { - _eurosParPoint = - (reglages['euros_par_point'] as num?)?.toDouble() ?? 0; - _nomMagasin = (reglages['nom_magasin'] as String?) ?? ''; + // Fiche fidélité du compte (une par utilisateur). Absente → profil à créer. + try { + final row = await pb + .collection('clients') + .getFirstListItem('user = "$uid"'); + _monClient = Client.fromMap(row.toJson()); + } on ClientException catch (e) { + if (e.statusCode == 404) { + _monClient = null; + } else { + rethrow; + } } - final recs = await supabase - .from('recompenses') - .select() - .eq('actif', true) - .order('cout_points'); - _recompenses = - (recs as List).map((e) => Recompense.fromMap(e)).toList(); + // Contexte magasin (lisible par tout compte connecté) : ligne unique. + final reglages = await pb.collection('reglages').getList(perPage: 1); + if (reglages.items.isNotEmpty) { + final r = reglages.items.first; + _eurosParPoint = (r.data['euros_par_point'] as num?)?.toDouble() ?? 0; + _nomMagasin = (r.data['nom_magasin'] as String?) ?? ''; + } + + final recs = await pb.collection('recompenses').getFullList( + filter: 'actif = true', + sort: 'cout_points', + ); + _recompenses = recs.map((e) => Recompense.fromMap(e.toJson())).toList(); if (_monClient != null) { - final mvts = await supabase - .from('mouvements') - .select() - .eq('client_id', _monClient!.id) - .order('created_at', ascending: false); - _mouvements = - (mvts as List).map((e) => Mouvement.fromMap(e)).toList(); + final mvts = await pb.collection('mouvements').getFullList( + filter: 'client = "${_monClient!.id}"', + sort: '-created', + ); + _mouvements = mvts.map((e) => Mouvement.fromMap(e.toJson())).toList(); } else { _mouvements = []; } @@ -86,26 +89,72 @@ class SessionClient extends ChangeNotifier { } } - /// Crée le profil fidélité du compte connecté (nom + téléphone). Le code et le - /// QR sont générés côté base. Utilisé à l'inscription / finalisation de profil. + /// Crée le profil fidélité du compte connecté (nom + téléphone). Le code de + /// fidélité (encodé dans le QR) est généré ici, côté client, car le backend + /// n'en fournit pas. Utilisé à l'inscription / finalisation de profil. Future creerProfil({ required String nom, String? prenom, String? telephone, }) async { - final uid = supabase.auth.currentUser?.id; + final uid = pb.authStore.record?.id; if (uid == null) throw Exception('Non connecté'); String? ouNull(String? v) => (v == null || v.trim().isEmpty) ? null : v.trim(); - await supabase.from('clients').insert({ - 'user_id': uid, + await pb.collection('clients').create(body: { + 'user': uid, + 'code': _genererCode(), 'nom': nom.trim(), - 'prenom': ouNull(prenom), - 'telephone': ouNull(telephone), + if (ouNull(prenom) != null) 'prenom': ouNull(prenom), + if (ouNull(telephone) != null) 'telephone': ouNull(telephone), }); await charger(); } + bool _googleInit = false; + + /// Connexion / inscription native avec Google (sélecteur de compte in-app). + /// + /// Récupère l'ID token Google via Credential Manager, l'envoie au hook + /// PocketBase `/api/google-native-auth` qui vérifie le token et renvoie une + /// session PocketBase. Un nouveau compte n'a pas encore de fiche fidélité → + /// l'AuthGate affichera l'écran « Finaliser mon profil ». + Future connexionGoogleNative() async { + if (!_googleInit) { + await GoogleSignIn.instance + .initialize(serverClientId: googleWebClientId); + _googleInit = true; + } + + final account = await GoogleSignIn.instance.authenticate(); + final idToken = account.authentication.idToken; + if (idToken == null) { + throw Exception('Jeton Google introuvable'); + } + + final reponse = await pb.send( + '/api/google-native-auth', + method: 'POST', + body: {'idToken': idToken}, + ); + final map = reponse as Map; + pb.authStore.save( + map['token'] as String, + RecordModel.fromJson(map['record'] as Map), + ); + } + + /// Génère un code de fidélité lisible, ex. « FID-3F9K2A ». + static String _genererCode() { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // sans I,O,0,1 + final rnd = Random.secure(); + final suffixe = List.generate( + 6, + (_) => alphabet[rnd.nextInt(alphabet.length)], + ).join(); + return 'FID-$suffixe'; + } + void vider() { _monClient = null; _mouvements = []; diff --git a/lib/supabase_config.dart b/lib/supabase_config.dart deleted file mode 100644 index 90b2f77..0000000 --- a/lib/supabase_config.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:supabase_flutter/supabase_flutter.dart'; - -/// Configuration de connexion au backend Supabase (LE MÊME que l'app magasin). -/// -/// ⚠️ Renseigner les valeurs de VOTRE projet Supabase : -/// Project Settings → API → « Project URL » et « anon public ». -/// Voir le guide : ../app_fideliter/supabase/SETUP.md -class SupabaseConfig { - SupabaseConfig._(); - - static const String url = 'https://zfhcnzbggpdvgvosrkgp.supabase.co'; - static const String anonKey = 'sb_publishable_4R3-_q1Zy_usliTff01Ilg_jhFlIdPH'; - - static bool get estConfigure => - !url.contains('VOTRE-PROJET') && !anonKey.contains('VOTRE_CLE'); -} - -/// Raccourci vers le client Supabase (une fois [Supabase.initialize] appelé). -SupabaseClient get supabase => Supabase.instance.client; diff --git a/lib/widgets/oauth_boutons.dart b/lib/widgets/oauth_boutons.dart new file mode 100644 index 0000000..83dc794 --- /dev/null +++ b/lib/widgets/oauth_boutons.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../services/session_client.dart'; +import '../theme.dart'; + +/// Logo « G » officiel de Google (SVG des guidelines de marque). Ne pas altérer +/// (couleurs / proportions) sous peine de non-conformité (validation Play Store). +const String _googleGSvg = ''' + + + + + + +'''; + +/// Bouton officiel « Se connecter avec Google » (connexion native, sélecteur de +/// compte in-app), conforme aux guidelines de marque Google. Un compte inconnu +/// est créé côté serveur puis dirigé vers l'écran « Finaliser mon profil ». +class OAuthBoutons extends StatefulWidget { + const OAuthBoutons({super.key}); + + @override + State createState() => _OAuthBoutonsState(); +} + +class _OAuthBoutonsState extends State { + bool _enCours = false; + + Future _google() async { + setState(() => _enCours = true); + try { + await SessionClient.instance.connexionGoogleNative(); + // Succès : l'AuthGate bascule automatiquement (ce widget est démonté). + } catch (e) { + if (mounted) { + setState(() => _enCours = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Connexion Google annulée ou échouée.')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('ou', + style: TextStyle(color: AppTheme.grisTexte, fontSize: 13)), + ), + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: 12), + _BoutonGoogle(enCours: _enCours, onPressed: _enCours ? null : _google), + ], + ); + } +} + +/// Rendu conforme charte Google : fond blanc, bordure #747775, logo G 18dp, +/// libellé Roboto Medium #1F1F1F. +class _BoutonGoogle extends StatelessWidget { + const _BoutonGoogle({required this.enCours, required this.onPressed}); + + final bool enCours; + final VoidCallback? onPressed; + + static const Color _texte = Color(0xFF1F1F1F); + static const Color _bordure = Color(0xFF747775); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onPressed, + child: Ink( + height: 52, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _bordure), + ), + child: Center( + child: enCours + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.string(_googleGSvg, width: 18, height: 18), + const SizedBox(width: 12), + const Text( + 'Se connecter avec Google', + style: TextStyle( + color: _texte, + fontSize: 15, + fontWeight: FontWeight.w500, + fontFamily: 'Roboto', + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index f14da48..a9fe70e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,38 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - app_links: - dependency: transitive - description: - name: app_links - sha256: "9d3c82f634c7f5b5c752f7ee46b67724246043f5e1d5fc1b433dd5b38d780dbe" - url: "https://pub.dev" - source: hosted - version: "7.2.0" - app_links_linux: - dependency: transitive - description: - name: app_links_linux - sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 - url: "https://pub.dev" - source: hosted - version: "1.0.3" - app_links_platform_interface: - dependency: transitive - description: - name: app_links_platform_interface - sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333" - url: "https://pub.dev" - source: hosted - version: "2.0.3" - app_links_web: - dependency: transitive - description: - name: app_links_web - sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 - url: "https://pub.dev" - source: hosted - version: "1.0.4" archive: dependency: transitive description: @@ -105,22 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -129,14 +81,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" - dart_jsonwebtoken: - dependency: transitive - description: - name: dart_jsonwebtoken - sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe - url: "https://pub.dev" - source: hosted - version: "3.4.1" fake_async: dependency: transitive description: @@ -182,6 +126,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -192,30 +144,54 @@ packages: description: flutter source: sdk version: "0.0.0" - functions_client: + google_identity_services_web: dependency: transitive description: - name: functions_client - sha256: "5b17b8dcf5ae1cd6a6428e7a4ca8b569477297a7f158955b5407dda23baeaa5b" + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" url: "https://pub.dev" source: hosted - version: "2.6.3" - gotrue: + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: dependency: transitive description: - name: gotrue - sha256: "54e8abe49c8596234c503f4b64d07022c7913a6475c7a85c1f1eacc1a93af166" + name: google_sign_in_android + sha256: a01d4fd5a0e4eb0c1961434a0289a436abf9225b0933963c8e4e5ed88c26e40b url: "https://pub.dev" source: hosted - version: "2.24.0" - gtk: + version: "7.2.15" + google_sign_in_ios: dependency: transitive description: - name: gtk - sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" http: dependency: transitive description: @@ -256,14 +232,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" - jwt_decode: - dependency: transitive - description: - name: jwt_decode - sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb - url: "https://pub.dev" - source: hosted - version: "0.3.1" leak_tracker: dependency: transitive description: @@ -296,14 +264,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" matcher: dependency: transitive description: @@ -328,22 +288,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - passkeys_platform_interface: - dependency: transitive - description: - name: passkeys_platform_interface - sha256: "9610bd136b3382500390912ddd8517ee99505228b1af7b507b9c907f7e99a47d" - url: "https://pub.dev" - source: hosted - version: "2.8.0" path: dependency: transitive description: @@ -352,6 +296,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider_linux: dependency: transitive description: @@ -400,14 +352,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive + pocketbase: + dependency: "direct main" description: - name: pointycastle - sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + name: pocketbase + sha256: f70353ea9c583fabe4165880e054504c5036c428f59c0fc588d64bb19218df05 url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "0.22.0" posix: dependency: transitive description: @@ -416,14 +368,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" - postgrest: - dependency: transitive - description: - name: postgrest - sha256: "801f6659cffbb6372d38cb4deb0cd892cb0cb38d75e0686ff24f4a724e9f4999" - url: "https://pub.dev" - source: hosted - version: "2.7.4" qr: dependency: transitive description: @@ -440,32 +384,8 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - realtime_client: - dependency: transitive - description: - name: realtime_client - sha256: "3261ba98af41fccfdbb6c66299b5bd6871907e98bb41bce9d55e0fb91287ed85" - url: "https://pub.dev" - source: hosted - version: "2.9.1" - retry: - dependency: transitive - description: - name: retry - sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" - url: "https://pub.dev" - source: hosted - version: "3.1.2" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" shared_preferences: - dependency: transitive + dependency: "direct main" description: name: shared_preferences sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf @@ -541,14 +461,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" - storage_client: - dependency: transitive - description: - name: storage_client - sha256: "2cc5432c312c4a08687bcce795aff377d006e2892097c7f88d57512885bb4b10" - url: "https://pub.dev" - source: hosted - version: "2.5.9" stream_channel: dependency: transitive description: @@ -565,22 +477,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - supabase: - dependency: transitive - description: - name: supabase - sha256: d425df9330ce5f215a0ee708b084899d89031e8a8f3e096f46c38ea37df9fd7d - url: "https://pub.dev" - source: hosted - version: "2.13.3" - supabase_flutter: - dependency: "direct main" - description: - name: supabase_flutter - sha256: "262c7eda45997a308ac0311be82fe8e9134526a18125fe5e0cf0a58e9cd16cb6" - url: "https://pub.dev" - source: hosted - version: "2.15.3" term_glyph: dependency: transitive description: @@ -605,70 +501,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - url_launcher: + vector_graphics: dependency: transitive description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" url: "https://pub.dev" source: hosted - version: "6.3.2" - url_launcher_android: + version: "1.2.2" + vector_graphics_codec: dependency: transitive description: - name: url_launcher_android - sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "6.3.32" - url_launcher_ios: + version: "1.1.13" + vector_graphics_compiler: dependency: transitive description: - name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + name: vector_graphics_compiler + sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3" url: "https://pub.dev" source: hosted - version: "6.4.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.dev" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" + version: "1.2.6" vector_math: dependency: transitive description: @@ -693,22 +549,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" xdg_directories: dependency: transitive description: @@ -733,14 +573,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" - yet_another_json_isolate: - dependency: transitive - description: - name: yet_another_json_isolate - sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9 - url: "https://pub.dev" - source: hosted - version: "2.1.1" sdks: dart: ">=3.12.2 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index bd7710c..faf8038 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,7 +34,10 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - supabase_flutter: ^2.8.0 # backend : auth client + données de fidélité + pocketbase: ^0.22.0 # backend : auth client + données de fidélité + shared_preferences: ^2.3.2 # persistance de la session PocketBase + google_sign_in: ^7.0.0 # connexion native Google (sélecteur de compte in-app) + flutter_svg: ^2.0.10 # rendu du logo G officiel (bouton Google conforme) qr_flutter: ^4.1.0 # affichage du QR code de fidélité du client intl: ^0.20.3 # formatage € et dates (locale fr_FR) diff --git a/server/pb_hooks/google_native_auth.pb.js b/server/pb_hooks/google_native_auth.pb.js new file mode 100644 index 0000000..a8dce73 --- /dev/null +++ b/server/pb_hooks/google_native_auth.pb.js @@ -0,0 +1,105 @@ +/// Connexion native Google (Credential Manager) pour l'app cliente. +/// +/// L'app obtient un « ID token » Google via le sélecteur de compte natif, puis +/// l'envoie ici. On le vérifie auprès de Google, on retrouve (ou on crée) le +/// compte `users`, on s'assure qu'une fiche fidélité `clients` existe (créée +/// automatiquement à partir du nom/prénom Google), et on renvoie une session. +/// +/// Endpoint : POST /api/google-native-auth body: { "idToken": "..." } +/// Réponse : { "token": "", "record": { ...compte... } } +routerAdd("POST", "/api/google-native-auth", (e) => { + // ID client OAuth « Web » (celui passé comme serverClientId côté app). + const WEB_CLIENT_ID = + "831733439319-45cergb7cf8r728fja4m0kcvu2r7fbj9.apps.googleusercontent.com"; + + const body = new DynamicModel({ idToken: "" }); + e.bindBody(body); + const idToken = (body.idToken || "").trim(); + if (!idToken) { + throw new BadRequestError("idToken manquant"); + } + + // Vérification de l'ID token auprès de Google (signature + expiration). + const res = $http.send({ + url: + "https://oauth2.googleapis.com/tokeninfo?id_token=" + + encodeURIComponent(idToken), + method: "GET", + timeout: 15, + }); + if (res.statusCode !== 200) { + throw new BadRequestError("ID token Google invalide"); + } + const p = res.json; + + if (p.aud !== WEB_CLIENT_ID) { + throw new BadRequestError("audience inattendue"); + } + if ( + p.iss !== "accounts.google.com" && + p.iss !== "https://accounts.google.com" + ) { + throw new BadRequestError("issuer inattendu"); + } + const email = (p.email || "").toLowerCase(); + if (!email) { + throw new BadRequestError("email absent du token"); + } + if (String(p.email_verified) !== "true") { + throw new BadRequestError("email Google non verifie"); + } + + // Compte existant ? + let user = null; + try { + user = $app.findFirstRecordByFilter("users", "email = {:email}", { + email: email, + }); + } catch (_) { + user = null; + } + + // Sinon création d'un compte client (is_staff = false). + if (!user) { + const collection = $app.findCollectionByNameOrId("users"); + user = new Record(collection); + user.set("email", email); + user.set("emailVisibility", false); + user.set("verified", true); + user.set("name", p.name || ""); + user.set("is_staff", false); + user.setPassword($security.randomString(30)); + $app.save(user); + } + + // Fiche fidélité déjà présente ? Sinon on la crée à partir du nom/prénom + // Google → l'app cliente arrive directement sur sa carte (pas de saisie). + let hasProfile = false; + try { + $app.findFirstRecordByFilter("clients", "user = {:uid}", { uid: user.id }); + hasProfile = true; + } catch (_) { + hasProfile = false; + } + if (!hasProfile) { + const prenom = p.given_name || ""; + let nom = p.family_name || ""; + if (!nom) { + nom = p.name || prenom || email.split("@")[0]; + } + const code = + "FID-" + + $security.randomStringWithAlphabet(6, "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"); + + const clientsCol = $app.findCollectionByNameOrId("clients"); + const client = new Record(clientsCol); + client.set("user", user.id); + client.set("nom", nom); + client.set("prenom", prenom); + client.set("code", code); + $app.save(client); + } + + const token = user.newAuthToken(); + return e.json(200, { token: token, record: user.publicExport() }); +});