Skip to content
Merged
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
6 changes: 4 additions & 2 deletions SYSTEM_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ The first release may continue using `admin` in the UI, but server endpoints and
| `events/{eventId}/registrations/{registrationId}` | Participant identity, waiver evidence, payment references, lifecycle | Cloud Functions and admins today; target Cloud Functions only | Restricted PII and financial metadata |
| `products/{productId}` | Merchandise catalog | Admin client today | Public plus internal configuration |
| `orders/{orderId}` | Buyer, shipping, payment, and fulfillment data | Cloud Functions and admins today; target Cloud Functions only | Restricted PII and financial metadata |
| `members/{uid}` | Profile and role mirror | Create-once signup/recovery Functions; self-service name/phone allowlist; server role operations | Confidential |
| `members/{uid}` | Profile and role mirror | Create-once signup/recovery Functions; self-service name-only allowlist while #178 pauses phone collection; server role operations | Confidential |
| `members/{uid}/connections/{provider}` | Non-secret connection metadata | Cloud Functions | Confidential |
| `members/{uid}/secrets/{provider}` | OAuth tokens | Cloud Functions | Restricted secret |
| `promoCodes/{id}` | Intended promotion configuration | Admin only | Confidential; currently not integrated into checkout validation |
Expand Down Expand Up @@ -328,7 +328,7 @@ sequenceDiagram
W->>D: Read caller profile through Firestore Rules
alt read succeeds
D-->>W: Profile
W-->>M: Show profile and name/phone Edit
W-->>M: Show profile and name-only Edit
else read fails
D-->>W: Generic failure
W-->>M: Hide Edit; show retry/sign-out path
Expand All @@ -338,6 +338,8 @@ sequenceDiagram

The callable accepts no UID or profile fields. A missing record receives identity fields from Firebase Auth and `role: unverified`; it never copies a member/admin claim, dues, payment, or discount state. Existing records and claims remain unchanged. This may expose a pre-existing claim/profile mismatch instead of guessing how to repair it; the identity/membership workflow must resolve that mismatch explicitly. App Check is required by the shared boundary only when deployed enforcement is configured, so release evidence must prove that setting before calling App Check live.

DATA-001C1 [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178) pauses optional phone display and collection in My Account. The owner-profile projection omits `phoneNumber`, the client validates and writes only `fullName`, and the Rules source rejects every browser phone mutation while allowing an existing phone value to remain unchanged during a name edit. Firestore still authorizes and transports the owner's complete document at its document-level boundary; #116 retains the future server-projection work for broader administrative reads. There is no migration, deletion, export, Function/Auth change, Google Forms change, or provider action in this slice. The source is not live protection until the exact Rules are deployed and read back before the dependent website revision is published and verified.

The server chooses initial timestamps. The current self-edit path sends a Firestore server timestamp, but the Rules source type-checks rather than independently proves that edit timestamp. Do not describe arbitrary profile edit timestamps as server-authoritative until a coordinated Rules/API issue closes that residual.

### 8.1 Paid race registration
Expand Down
45 changes: 43 additions & 2 deletions docs/officers/EVENTS_SHOP_MEMBERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,11 @@ flowchart LR
C -- "No" --> E["Create one pending profile"]
D --> F["Read through normal permissions"]
E --> F
F -- "Success" --> G["Edit name and phone"]
F -- "Success" --> G["Edit name only; phone entry paused"]
F -- "Failure" --> H["Hide Edit and show Try again"]
```

In words: the server preserves an existing profile or creates one pending profile for the signed-in person; editing stays hidden unless the normal read succeeds.
In words: the server preserves an existing profile or creates one pending profile for the signed-in person; editing stays hidden unless the normal read succeeds, and the temporary privacy pause permits name editing only.

Safe officer steps:

Expand All @@ -195,6 +195,47 @@ Safe officer steps:

**Escalation:** membership lead plus identity/security owner. Add the privacy owner if private information appeared. Email landing in spam is a separate delivery problem; do not treat it as proof of this permission failure.

## My Account phone collection pause — SOURCE ONLY, NOT LIVE

**Purpose:** stop My Account from accepting another phone number while the club reviews why it collects phone data, who can access it, how long it is kept, and whether the live Firebase boundary matches the reviewed source.

**Approver:** membership lead plus privacy/platform owner.

**Prerequisites:** claimed issue [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178), a private redacted incident record under #112, the authorized service inventory under #113, made-up test data, and the protected backend-first release path. Do not put a member's number, spam message, screenshot, or provider record in GitHub, email, or AI.

```mermaid
flowchart LR
Account["Member opens My Account"] --> Read["Read this member's profile"]
Read --> Name["Display and edit name"]
Read --> Pause["Do not display or accept phone"]
Name --> Rules["Firebase Rules allow name-only update"]
PhoneAttempt["Browser tries a phone change"] --> Deny["Firebase Rules deny"]
Pause --> Existing["Existing stored value stays unchanged"]
```

In words: My Account shows and edits the member's name, does not display or accept a phone number, and leaves any existing stored value unchanged; the reviewed Rules deny a browser phone change.

Officer steps after every prerequisite has proof:

1. Tell members not to add a phone number in My Account.
2. Do not ask whether a member already has a number stored.
3. Do not copy a number or spam message into an issue, email, screenshot, or AI tool.
4. Keep the Google membership form, event registration, shop, and provider review separate; this source change does not alter them.
5. Ask the platform owner to identify the exact merged website and Rules revisions.
6. Require the reviewed Rules to deploy and pass readback before the website is published.
7. Use one made-up staged profile to prove name-only editing and phone-write denial.
8. Check the exact website revision on `runmprc.com` without opening a real member profile.

**Expected result:** My Account contains no phone value, phone input, or phone browser autocomplete. A name-only change succeeds. A direct non-empty phone change is denied. Existing stored data, membership, payment status, and external forms/providers remain unchanged.

**Stop conditions:** a real member profile, a provider Console change, a database export, a request to inspect or delete stored numbers, skipped/partial Rules deployment, website publication before Rules proof, or a proposal to treat spam timing as proof of a breach.

**Success proof:** exact #178 pull request and merge commit; green synthetic frontend and Rules tests; exact Rules deployment/readback; later website publication record; separate `runmprc.com` revision check; and a dated made-up name-only/phone-denial check. Google, Firebase, Sentry, Stripe, and other provider evidence remains separate and private.

**Undo:** use one reviewed revert or safe roll-forward through the same backend-first gate. Do not restore phone collection until #110 approves its purpose, notice, access, and retention, and #113/#133/#136 prove the intended live boundary.

**Escalation:** membership lead plus privacy/platform owner; use the private incident path under #112 if exposure is suspected.

## Admin screens — NOT AVAILABLE YET

Admin event and product editors exist in source, but their live permissions, backup, preview, and rollback behavior have not been approved. Saving can write directly to production Firestore. Officers must not use these screens as a continuity procedure yet.
Expand Down
7 changes: 4 additions & 3 deletions docs/officers/SYSTEM_MAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,15 @@ flowchart TD
Existing -- "No" --> Pending["Create one pending profile"]
Preserve --> Read["Website reads through normal Firebase permissions"]
Pending --> Read
Read -- "Read succeeds" --> Edit["Member may edit name and phone only"]
Read -- "Read succeeds" --> Edit["Member may edit name only"]
Read -- "Read succeeds" --> Phone["Phone display and entry paused\nexisting value unchanged"]
Read -- "Setup or read fails" --> Safe["Edit stays hidden; Try again or sign out"]
Ensure -. "does not grant, remove, or change" .-> Access["Membership, payment, discount, or admin access"]
```

In words: a signed-in member gets their existing profile unchanged or one new pending profile; the website permits only name and phone editing after the normal permission check succeeds. The repair does not change actual access. If displayed profile status and actual access disagree, stop and escalate.
In words: a signed-in member gets their existing profile unchanged or one new pending profile; the website permits only name editing after the normal permission check succeeds, while phone display and entry stay paused without changing an existing stored value. The repair does not change actual access. If displayed profile status and actual access disagree, stop and escalate.

Issue [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) owns this repair. Source code and local tests do not make it live. The website, server Function, database permissions, and made-up live check must each be proven separately.
Issue [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) owns this repair, and [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178) owns the temporary phone-collection pause. Source code and local tests do not make either live. The website, server Function, database permissions, and made-up live check must each be proven separately.

## Emergency decision

Expand Down
9 changes: 5 additions & 4 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,10 @@ service cloud.firestore {
allow read: if request.auth != null
&& (request.auth.uid == uid || isAdmin());

// Self-service edits are restricted to an explicit field allowlist.
// Self-service edits are restricted to a name-only field allowlist while
// optional phone collection is paused for privacy review. Existing phone
// values remain untouched; browser clients cannot add, replace, or clear
// them through this rule.
// An allowlist (rather than pinning each protected field individually)
// blocks BOTH tampering with managed fields — role, email, createdAt,
// emailVerified, provider — AND injection of arbitrary new fields (e.g.
Expand All @@ -274,14 +277,12 @@ service cloud.firestore {
allow update: if request.auth != null
&& request.auth.uid == uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['fullName', 'phoneNumber', 'updatedAt'])
.hasOnly(['fullName', 'updatedAt'])
&& request.resource.data.diff(resource.data).affectedKeys()
.hasAll(['updatedAt'])
&& (request.resource.data.fullName == null
|| (request.resource.data.fullName is string
&& request.resource.data.fullName.size() <= 200))
&& request.resource.data.phoneNumber is string
&& request.resource.data.phoneNumber.size() <= 40
&& request.resource.data.updatedAt is timestamp;

// Third-party connections (Strava, etc). Users can read *whether*
Expand Down
22 changes: 14 additions & 8 deletions src/pages/account/Account.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ const PROFILE = {
email: USER.email,
fullName: 'Synthetic Member',
role: 'unverified' as const,
phoneNumber: '555-0100',
emailVerified: false,
provider: 'password',
createdAt: null,
Expand Down Expand Up @@ -153,20 +152,27 @@ describe('Account profile recovery', () => {
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
});

test('shows the same name and phone limits as the Firestore rules', async () => {
test('pauses phone display and collection while keeping name editing available', async () => {
const legacyProfile = {
...PROFILE,
phoneNumber: 'synthetic-phone-canary',
};
(getMyProfile as jest.Mock).mockResolvedValue(legacyProfile);
renderAccount();
expect(await screen.findByText('Synthetic Member')).toBeInTheDocument();
expect(screen.queryByText(legacyProfile.phoneNumber)).not.toBeInTheDocument();
expect(screen.getByText(/phone collection is temporarily paused/i))
.toBeInTheDocument();

fireEvent.click(await screen.findByRole('button', { name: 'Edit' }));

expect(screen.getByLabelText('Full name')).toHaveAccessibleDescription(
'Up to 200 characters.',
);
expect(screen.getByLabelText('Phone')).toHaveAccessibleDescription(
'Up to 40 characters.',
);
expect(screen.getByLabelText('Full name')).toHaveAttribute('autocomplete', 'name');
expect(screen.getByLabelText('Phone')).toHaveAttribute('autocomplete', 'tel');
expect(screen.getByLabelText('Full name')).toHaveAttribute('maxLength', '200');
expect(screen.getByLabelText('Phone')).toHaveAttribute('maxLength', '40');
expect(screen.queryByLabelText('Phone')).not.toBeInTheDocument();
expect(document.querySelector('input[autocomplete="tel"]')).toBeNull();
});

test('uses a safe state when the profile read fails after setup', async () => {
Expand Down Expand Up @@ -219,7 +225,7 @@ describe('Account profile recovery', () => {
await waitFor(() => expect(updateMyProfile).toHaveBeenCalledWith(
firestore,
USER.uid,
{ fullName: 'Updated Synthetic Member', phoneNumber: '555-0100' },
{ fullName: 'Updated Synthetic Member' },
));
});

Expand Down
44 changes: 10 additions & 34 deletions src/pages/account/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { Timestamp } from 'firebase/firestore';
import SEO from '../../components/SEO';
import { useServiceLocator } from '../../services/ServiceLocatorContext';
import { useAuth } from '../../services/hooks/useAuth';
import { Member } from '../../types/member';
import {
ensureMyProfile,
getMyProfile,
Expand All @@ -15,6 +14,7 @@ import {
MEMBER_PROFILE_LIMITS,
validateMemberProfileFields,
MyRegistrationsResponse,
MyMemberProfile,
} from '../../services/account/accountService';
import { formatEventDate, formatPrice } from '../../services/events/eventsService';
import StravaSection from './StravaSection';
Expand Down Expand Up @@ -230,15 +230,14 @@ export function AccountContent({
user: NonNullable<ReturnType<typeof useAuth>['user']>;
}) {
const { services } = useServiceLocator();
const [profile, setProfile] = useState<Member | null>(null);
const [profile, setProfile] = useState<MyMemberProfile | null>(null);
const [profileState, setProfileState] = useState<'loading' | 'ready' | 'unavailable'>(
'loading',
);
const [profileLoadAttempt, setProfileLoadAttempt] = useState(0);
const [profileError, setProfileError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [fullName, setFullName] = useState('');
const [phoneNumber, setPhoneNumber] = useState('');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);

Expand Down Expand Up @@ -268,7 +267,6 @@ export function AccountContent({
if (!active) return;
setProfile(nextProfile);
setFullName(nextProfile.fullName || '');
setPhoneNumber(nextProfile.phoneNumber || '');
setProfileState('ready');
} catch {
if (!active) return;
Expand Down Expand Up @@ -303,7 +301,7 @@ export function AccountContent({

async function handleSave() {
if (!services) return;
const validation = validateMemberProfileFields({ fullName, phoneNumber });
const validation = validateMemberProfileFields({ fullName });
if (!validation.valid) {
setSaveError(validation.message);
return;
Expand All @@ -320,7 +318,6 @@ export function AccountContent({
if (!fresh) throw new Error('Profile unavailable after save.');
setProfile(fresh);
setFullName(fresh.fullName || '');
setPhoneNumber(fresh.phoneNumber || '');
setProfileError(null);
setProfileState('ready');
setEditing(false);
Expand Down Expand Up @@ -414,10 +411,6 @@ export function AccountContent({
{profile.emailVerified ? '' : ' (unverified)'}
</dd>
</div>
<div>
<dt className="text-gray-500 text-xs">Phone</dt>
<dd>{profile.phoneNumber || <span className="text-gray-400">not set</span>}</dd>
</div>
<div>
<dt className="text-gray-500 text-xs">Membership</dt>
<dd>{roleLabel(profile.role)}</dd>
Expand All @@ -432,6 +425,13 @@ export function AccountContent({
</div>
</dl>
)}
{profileState === 'ready' && profile && (
<p className="mt-3 text-sm text-gray-600">
Phone collection is temporarily paused while we review how contact
information is handled. This update does not change existing stored
information.
</p>
)}
{editing && (
<div className="space-y-3">
<div>
Expand All @@ -457,29 +457,6 @@ export function AccountContent({
characters.
</span>
</div>
<div>
<label htmlFor="profile-phone" className="block">
<span className="text-sm font-medium">Phone</span>
<input
id="profile-phone"
type="tel"
className="border rounded px-3 py-2 w-full"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
aria-describedby="phone-number-limit"
autoComplete="tel"
disabled={saving}
maxLength={MEMBER_PROFILE_LIMITS.phoneNumber}
/>
</label>
<span id="phone-number-limit" className="text-xs text-gray-500">
Up to
{' '}
{MEMBER_PROFILE_LIMITS.phoneNumber}
{' '}
characters.
</span>
</div>
{saveError && (
<p role="alert" className="text-sm text-red-600">{saveError}</p>
)}
Expand All @@ -497,7 +474,6 @@ export function AccountContent({
onClick={() => {
setEditing(false);
setFullName(profile?.fullName || '');
setPhoneNumber(profile?.phoneNumber || '');
setSaveError(null);
}}
disabled={saving}
Expand Down
Loading
Loading