diff --git a/backend/firestore.rules b/backend/firestore.rules index 4dd4fb8..4a1563c 100644 --- a/backend/firestore.rules +++ b/backend/firestore.rules @@ -31,13 +31,12 @@ service cloud.firestore { request.resource.data.fcmToken is string; } - function isOwnEmailVerificationMirrorUpdate() { - return request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['isVerified', 'updatedAt']) && - request.auth.token.email_verified == true && - request.resource.data.isVerified == true && - request.resource.data.updatedAt == request.time; - } + // NOTE: `isVerified` means "approved by CancerLINC client services" and is + // the flag that gates app access. It is written ONLY by staff (the web + // console's accept/reject actions) and by admin Cloud Functions — never by + // the patient. Email verification is tracked separately by Firebase Auth's + // own `emailVerified` token claim and is deliberately NOT mirrored here; + // allowing that self-write previously let any patient approve themselves. function checklistAllowedKeys() { return [ @@ -93,8 +92,7 @@ service cloud.firestore { match /users/{uid} { allow read: if isCurrentUser(uid); allow create, delete: if false; - allow update: if isCurrentUser(uid) && - (isFcmTokenOnlyUpdate() || isOwnEmailVerificationMirrorUpdate()); + allow update: if isCurrentUser(uid) && isFcmTokenOnlyUpdate(); } match /chats/{chatId} { diff --git a/backend/functions/src/shared/index.ts b/backend/functions/src/shared/index.ts index 043e193..f28132a 100644 --- a/backend/functions/src/shared/index.ts +++ b/backend/functions/src/shared/index.ts @@ -264,7 +264,13 @@ export const onAuthUserCreated = functions.auth.user().onCreate(async (user) => profilePhotoUrl: user.photoURL ?? "", role: "patient", status: "follow-up", - isVerified: user.emailVerified, + // New patients start unapproved and stay gated out of the app until + // client services accepts them in the staff console. This is approval + // state, NOT email verification — see firestore.rules users/{uid}. + // Only runs when the doc doesn't already exist, so patients created + // before this gate keep whatever isVerified they already had. + isVerified: false, + isBanned: false, createdAt: serverTimestamp(), updatedAt: serverTimestamp(), lastContactTimestamp: serverTimestamp(), diff --git a/mobile/lib/components/call_number.dart b/mobile/lib/components/call_number.dart index 507d7a1..595d9aa 100644 --- a/mobile/lib/components/call_number.dart +++ b/mobile/lib/components/call_number.dart @@ -7,6 +7,15 @@ import 'package:url_launcher/url_launcher.dart'; /// CancerLINC support line, shown to blocked patients in the chat page. const String cancerLincSupportPhone = '804-562-0371'; +/// When the Social Worker team is reachable. Shown as a notice on the chat +/// page so patients know when to expect a reply. Deliberately static copy +/// rather than a computed "open now / closed now" indicator: that needs real +/// US Eastern DST handling, which `DateTime` can't do without a timezone +/// database, and would be wrong for half the year if approximated. +const String socialWorkerHours = + 'Social workers are available 9:00 a.m. to 5:00 p.m. Eastern Time, ' + 'Monday through Friday, excluding holidays.'; + class CallButton extends StatelessWidget { final String phoneNumber; diff --git a/mobile/lib/pages/auth_gate.dart b/mobile/lib/pages/auth_gate.dart index 675b597..3c19c04 100644 --- a/mobile/lib/pages/auth_gate.dart +++ b/mobile/lib/pages/auth_gate.dart @@ -1,18 +1,35 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:cancerlinc/services/auth.dart'; import 'package:cancerlinc/pages/login_page.dart'; +import 'package:cancerlinc/pages/pending_verification.dart'; import 'package:cancerlinc/components/bottom_bar.dart'; -/// Routes the app between [LoginPage] and [BottomBar] based solely on -/// Firebase Auth state and the email-verified flag. +/// Routes the app between [LoginPage], [PendingVerificationScreen] and +/// [BottomBar]. A patient reaches the main app only when all three hold: /// -/// This widget deliberately does NOT consult isVerified (social-worker -/// approval) or isBanned — those checks belong to the pages that need them -/// (e.g. ChatPage). Any authenticated, email-verified user reaches [BottomBar]. +/// 1. signed in, +/// 2. email verified (Firebase Auth's own `emailVerified` claim), and +/// 3. approved by CancerLINC client services (`users/{uid}.isVerified`). +/// +/// `isVerified` is approval state, written only by staff — it is NOT a mirror +/// of email verification. The two are tracked separately on purpose; see the +/// note on users/{uid} in firestore.rules. +/// +/// This is the ONLY place the approval check lives, so every route into the +/// app must funnel through here rather than pushing [BottomBar] directly. +/// +/// The approval check subscribes to the user document rather than reading it +/// once, so a patient approved (or declined) by staff mid-session moves to the +/// right screen without needing to restart the app. class AuthGate extends StatelessWidget { const AuthGate({super.key}); + static const Widget _loading = Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + @override Widget build(BuildContext context) { return StreamBuilder( @@ -21,9 +38,7 @@ class AuthGate extends StatelessWidget { // While the auth stream is resolving, show a neutral loading screen // to prevent a login-page flash on cold start or hot restart. if (snapshot.connectionState == ConnectionState.waiting) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return _loading; } final user = snapshot.data; @@ -33,7 +48,50 @@ class AuthGate extends StatelessWidget { return const LoginPage(); } - // Authenticated and email-verified → main app. + return _ApprovalGate(uid: user.uid); + }, + ); + } +} + +/// Holds the main app behind the client-services approval flag on the +/// patient's own user document. +class _ApprovalGate extends StatelessWidget { + final String uid; + + const _ApprovalGate({required this.uid}); + + @override + Widget build(BuildContext context) { + return StreamBuilder>>( + stream: FirebaseFirestore.instance + .collection('users') + .doc(uid) + .snapshots(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return AuthGate._loading; + } + + // If the document can't be read we cannot confirm approval, so fail + // closed rather than letting an unapproved patient through. The same + // applies before the doc exists at all: onAuthUserCreated writes it + // asynchronously just after signup, and this rebuilds once it lands. + final data = snapshot.data?.data(); + if (data == null) { + return const PendingVerificationScreen(); + } + + // Declined patients get their own copy — telling someone client + // services has already turned down to "wait for a call" would be a lie. + if (data['isBanned'] == true) { + return const PendingVerificationScreen(isDenied: true); + } + + if (data['isVerified'] != true) { + return const PendingVerificationScreen(); + } + return const BottomBar(); }, ); diff --git a/mobile/lib/pages/chat_page.dart b/mobile/lib/pages/chat_page.dart index 8f5866e..d3ad1b4 100644 --- a/mobile/lib/pages/chat_page.dart +++ b/mobile/lib/pages/chat_page.dart @@ -11,13 +11,15 @@ import 'package:cancerlinc/services/notification_service.dart'; import 'package:cancerlinc/components/call_number.dart'; import 'package:cancerlinc/components/search_panel.dart'; -/// FEATURE FLAG (Ticket M4) — show a non-blocking "pending verification" -/// notice to socially-unverified (non-banned) patients. They can STILL chat; -/// the banner just tells them verification grants increased priority. -/// To hide the banner: set this to `false`. -/// To fully remove M4 later: set false (or delete this const, the -/// `_unverifiedBanner()` widget, and its use in `build()` marked "// M4"). -const bool kShowUnverifiedChatNotice = true; +/// FEATURE FLAG — show the Social Worker availability notice at the top of +/// the chat. Set to `false` to hide it; to remove the feature entirely delete +/// this const, the `_hoursBanner()` widget, and its use in `build()`. +/// +/// This replaces the old M4 "pending verification" notice. That banner told +/// unverified patients they'd get increased priority once verified, which is +/// now unreachable copy: unverified patients are held at [AuthGate] and never +/// reach the chat at all. +const bool kShowSocialWorkerHoursNotice = true; // ── Message group data class ─────────────────────────────────────────────────── @@ -124,9 +126,10 @@ class _ChatPageState extends State { ); } - /// M4: non-blocking banner for unverified (non-banned) patients. They can - /// still chat; this only explains that verification grants increased priority. - Widget _unverifiedBanner() { + /// Non-blocking notice telling patients when the Social Worker team is + /// reachable, so an after-hours message doesn't read as being ignored. + /// Patients can still send messages at any time. + Widget _hoursBanner() { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), @@ -134,15 +137,15 @@ class _ChatPageState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.info_outline, size: 20, color: Color(0xFF8D6E63)), + const Icon(Icons.schedule, size: 20, color: Color(0xFF8D6E63)), const SizedBox(width: 8), Expanded( child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ const Text( - "Your account isn't verified yet — you'll have increased " - 'priority once verified. Questions? Call CancerLINC at ', + '$socialWorkerHours Messages sent outside those hours will ' + 'be answered the next working day. If this is urgent, call ', style: TextStyle(fontSize: 13, color: Color(0xFF5D4037)), ), CallButton(phoneNumber: cancerLincSupportPhone), @@ -173,10 +176,9 @@ class _ChatPageState extends State { } final blocked = blockedMessage != null; - // M4: unverified (non-banned) patients keep full chat access but see a - // non-blocking notice that verification grants increased priority. - final showUnverifiedNotice = - !_loading && !blocked && !_isVerified && kShowUnverifiedChatNotice; + // Availability notice for everyone who can actually use the chat. + final showHoursNotice = + !_loading && !blocked && kShowSocialWorkerHoursNotice; final canSearch = !_loading && !blocked && _chatId != null; // If the state we're searchable in disappears (chat blocked, unloaded), @@ -196,7 +198,7 @@ class _ChatPageState extends State { searchEnabled: canSearch, onSearchTap: _toggleSearch, ), - if (showUnverifiedNotice) _unverifiedBanner(), // M4 + if (showHoursNotice) _hoursBanner(), Expanded( child: _loading ? const Center(child: CircularProgressIndicator()) diff --git a/mobile/lib/pages/login_page.dart b/mobile/lib/pages/login_page.dart index 67c8c70..aa2e492 100644 --- a/mobile/lib/pages/login_page.dart +++ b/mobile/lib/pages/login_page.dart @@ -3,7 +3,7 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:cancerlinc/pages/forgot_password.dart'; import 'package:cancerlinc/pages/create_account.dart'; -import 'package:cancerlinc/components/bottom_bar.dart'; +import 'package:cancerlinc/pages/auth_gate.dart'; import 'package:cancerlinc/services/auth.dart'; class LoginPage extends StatefulWidget { @@ -198,7 +198,10 @@ class LoginPage extends StatefulWidget { if (mounted) { Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => const BottomBar()), + // Route through AuthGate, not straight to + // BottomBar: the client-services approval check + // lives there and must not be bypassed. + MaterialPageRoute(builder: (context) => const AuthGate()), ); } } on FirebaseAuthException { diff --git a/mobile/lib/pages/pending_verification.dart b/mobile/lib/pages/pending_verification.dart new file mode 100644 index 0000000..fa0caae --- /dev/null +++ b/mobile/lib/pages/pending_verification.dart @@ -0,0 +1,221 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter/material.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:cancerlinc/components/call_number.dart'; +import 'package:cancerlinc/pages/login_page.dart'; + +/// Shown to authenticated patients who have not been approved by CancerLINC +/// client services, in place of the main app. [AuthGate] decides when this +/// appears; see the approval-gate notes there. +/// +/// [isDenied] distinguishes the two non-approved states, because they need +/// very different copy: a patient still awaiting review is told to expect a +/// call, while a patient client services has already declined must not be, +/// since no call is coming. +class PendingVerificationScreen extends StatefulWidget { + final bool isDenied; + + const PendingVerificationScreen({super.key, this.isDenied = false}); + + @override + State createState() => + _PendingVerificationScreenState(); +} + +class _PendingVerificationScreenState extends State { + bool _checking = false; + + Future _signOut() async { + // Guarded because this is also reached from _refreshStatus's error path, + // which may run after the gate has already swapped this screen out. + if (!mounted) return; + final navigator = Navigator.of(context); + await FirebaseAuth.instance.signOut(); + if (!mounted) return; + navigator.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginPage()), + (route) => false, + ); + } + + /// Re-checks account status on demand. + /// + /// [AuthGate] already listens to the user document, so an approval normally + /// lands here on its own. This button covers the cases that listener can't: + /// a dropped or backgrounded socket, a reply served from the offline cache, + /// and account-level changes (disabled or deleted) that live in Firebase + /// Auth rather than in Firestore. It also gives a waiting patient something + /// to do besides relaunch the app. + Future _refreshStatus() async { + if (_checking) return; + setState(() => _checking = true); + + final messenger = ScaffoldMessenger.of(context); + try { + final user = FirebaseAuth.instance.currentUser; + if (user == null) return; // AuthGate routes back to login on its own. + + // Surfaces account-level changes; throws if the account was disabled + // or deleted while the patient sat on this screen. + await user.reload(); + + // Read from the server, not the cache, so "check again" can't report a + // stale local copy. This also refreshes the cache, which re-fires + // AuthGate's listener — so on approval the gate moves the patient into + // the app without this screen having to navigate anywhere itself. + final snapshot = await FirebaseFirestore.instance + .collection('users') + .doc(user.uid) + .get(const GetOptions(source: Source.server)); + + final data = snapshot.data(); + final approved = data?['isVerified'] == true && data?['isBanned'] != true; + + if (!mounted || approved) return; + messenger.showSnackBar( + const SnackBar( + content: Text( + "You're still awaiting review. We'll let you know as soon as " + 'client services has been in touch.', + ), + ), + ); + } on FirebaseAuthException { + // reload() rejects a disabled or deleted account — don't strand the + // patient on a screen promising a call that isn't coming. + await _signOut(); + } catch (_) { + if (!mounted) return; + messenger.showSnackBar( + const SnackBar( + content: Text( + "Couldn't check your status just now. Please check your " + 'connection and try again.', + ), + ), + ); + } finally { + if (mounted) setState(() => _checking = false); + } + } + + @override + Widget build(BuildContext context) { + final isDenied = widget.isDenied; + final title = isDenied ? 'Account Not Approved' : 'Thanks for Your Interest'; + final body = isDenied + ? "We weren't able to approve your account for app access. If you " + 'think this is a mistake, or if you are a CancerLINC client, ' + 'please reach out to us at' + : "Your account is being reviewed. Once client services contacts " + "you, you'll have full access to the app. Thank you for your " + 'interest in CancerLINC.\n\nQuestions? Call us at'; + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isDenied ? Icons.info_outline : Icons.schedule, + size: 56, + color: const Color(0xFF43474F), + ), + const SizedBox(height: 24), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: Color(0xFF43474F), + ), + ), + const SizedBox(height: 16), + Text( + body, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15, + height: 1.5, + color: Colors.black54, + ), + ), + const SizedBox(height: 8), + const CallButton(phoneNumber: cancerLincSupportPhone), + if (!isDenied) ...[ + const SizedBox(height: 24), + Text( + socialWorkerHours, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Colors.black38), + ), + ], + const SizedBox(height: 40), + + // Only offered while a decision is still pending. A declined + // patient re-checking would just re-read the same "no", and + // AuthGate's listener already moves them if staff reverse it. + if (!isDenied) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SizedBox( + width: double.infinity, + height: 46, + child: OutlinedButton( + onPressed: _checking ? null : _refreshStatus, + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + overlayColor: Colors.black, + splashFactory: InkRipple.splashFactory, + ), + child: _checking + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF43474F), + ), + ) + : const Text( + 'CHECK AGAIN', + style: TextStyle(color: Color(0xFF43474F)), + ), + ), + ), + ), + + SizedBox( + width: double.infinity, + height: 46, + child: ElevatedButton( + onPressed: _checking ? null : _signOut, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF43474F), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + overlayColor: Colors.white, + splashFactory: InkRipple.splashFactory, + ), + child: const Text( + 'RETURN TO LOGIN', + style: TextStyle(color: Colors.white), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/verify_email.dart b/mobile/lib/pages/verify_email.dart index 410791c..f1c5e36 100644 --- a/mobile/lib/pages/verify_email.dart +++ b/mobile/lib/pages/verify_email.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; -import 'package:cancerlinc/components/bottom_bar.dart'; +import 'package:cancerlinc/pages/auth_gate.dart'; class VerifyEmail extends StatefulWidget { final String email; @@ -30,14 +30,6 @@ class _VerifyEmailState extends State { return false; } - Future _mirrorEmailVerification(User user) async { - await user.getIdToken(true); - await FirebaseFirestore.instance.collection('users').doc(user.uid).update({ - 'isVerified': true, - 'updatedAt': FieldValue.serverTimestamp(), - }); - } - @override void initState() { super.initState(); @@ -50,11 +42,15 @@ class _VerifyEmailState extends State { if (user?.emailVerified == true) { _isCompletingSignup = true; _timer?.cancel(); + // Wait for onAuthUserCreated to write users/{uid} so AuthGate can + // read the approval flag instead of briefly failing closed on a + // missing document. await _waitForUserDocument(user!.uid); - await _mirrorEmailVerification(user); if (mounted) { + // AuthGate, not BottomBar: a freshly email-verified patient still + // needs client-services approval before entering the app. Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute(builder: (_) => const BottomBar()), + MaterialPageRoute(builder: (_) => const AuthGate()), (route) => false, ); } diff --git a/mobile/lib/services/auth.dart b/mobile/lib/services/auth.dart index 237dbb9..06df5a5 100644 --- a/mobile/lib/services/auth.dart +++ b/mobile/lib/services/auth.dart @@ -9,16 +9,10 @@ class AuthService { Stream authStateChanges() => _auth.authStateChanges(); Future signIn(String email, String password) async { - final credential = await _auth.signInWithEmailAndPassword( + return _auth.signInWithEmailAndPassword( email: email, password: password, ); - final user = credential.user; - if (user?.emailVerified == true) { - await _mirrorEmailVerification(user!); - } - - return credential; } Future register( @@ -70,12 +64,4 @@ class AuthService { Future sendEmailVerification() async { await _auth.currentUser?.sendEmailVerification(); } - - Future _mirrorEmailVerification(User user) async { - await user.getIdToken(true); - await FirebaseFirestore.instance.collection('users').doc(user.uid).update({ - 'isVerified': true, - 'updatedAt': FieldValue.serverTimestamp(), - }); - } }