Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions backend/firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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} {
Expand Down
8 changes: 7 additions & 1 deletion backend/functions/src/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
9 changes: 9 additions & 0 deletions mobile/lib/components/call_number.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
76 changes: 67 additions & 9 deletions mobile/lib/pages/auth_gate.dart
Original file line number Diff line number Diff line change
@@ -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<User?>(
Expand All @@ -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;
Expand All @@ -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<DocumentSnapshot<Map<String, dynamic>>>(
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();
},
);
Expand Down
38 changes: 20 additions & 18 deletions mobile/lib/pages/chat_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────

Expand Down Expand Up @@ -124,25 +126,26 @@ class _ChatPageState extends State<ChatPage> {
);
}

/// 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),
color: const Color(0xFFFFF8E1),
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),
Expand Down Expand Up @@ -173,10 +176,9 @@ class _ChatPageState extends State<ChatPage> {
}
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),
Expand All @@ -196,7 +198,7 @@ class _ChatPageState extends State<ChatPage> {
searchEnabled: canSearch,
onSearchTap: _toggleSearch,
),
if (showUnverifiedNotice) _unverifiedBanner(), // M4
if (showHoursNotice) _hoursBanner(),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
Expand Down
7 changes: 5 additions & 2 deletions mobile/lib/pages/login_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading