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() });
+});