Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c430b0b
feat: refactor profile page and add self-service password reset
evanqua Aug 12, 2026
7d7bddd
feat: let admins delete other users' accounts and data, add Organizat…
evanqua Aug 12, 2026
3605460
feat(dispatch): add vocabulary registry, built-in presets, and transl…
evanqua Aug 12, 2026
8663f9a
feat(dispatch): persist dispatch vocabulary preset selection and shar…
evanqua Aug 12, 2026
eae24a7
feat(dispatch): add Language section to profile Preferences
evanqua Aug 12, 2026
f9d286e
feat(dispatch): wire vocabulary provider into the dispatch page shell
evanqua Aug 12, 2026
c6a3d00
feat(dispatch): wire vocabulary into call-tracking table, cards, and …
evanqua Aug 12, 2026
7ac363e
feat(dispatch): wire vocabulary into team cards
evanqua Aug 12, 2026
41c4d53
feat(dispatch): wire vocabulary into clinic tracking table and cards
evanqua Aug 12, 2026
a32c397
feat(dispatch): wire vocabulary into equipment card and event modals
evanqua Aug 12, 2026
70c1892
fix(dispatch): translate Show Resolved Calls toggles and dispatch navbar
evanqua Aug 12, 2026
65d6b39
feat(dispatch): add create-from-template and delete-own-preset UI
evanqua Aug 12, 2026
4f07ac9
fix(dispatch): sync vocabulary across components; redesign preset UI
evanqua Aug 12, 2026
7855763
fix(profile): cap list heights with minimal scrollbar, clean up prese…
evanqua Aug 12, 2026
b79d82e
fix(venues,profile): scroll event lists with minimal scrollbar, drop …
evanqua Aug 12, 2026
f146992
fix(dispatch): match call rows to clinic rows in sizing and padding
evanqua Aug 12, 2026
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ node scripts/setup-pocketbase.js

5. The app is available at `http://localhost:3000` and the PocketBase admin UI at `http://localhost:8090/_/`.

6. Grant yourself admin access (first time only — needed for Profile > Admin, e.g. managing the certification list):
```bash
PB_URL=http://127.0.0.1:8090 PB_ADMIN_EMAIL=admin@example.com PB_ADMIN_PASSWORD=YourPassword! \
node scripts/setAdminPocketbase.js you@example.com
```
> This is only needed once — after signing in, that user can grant/revoke admin access for others from Profile > Admin > Manage Admins.

7. (Optional) Enable "Forgot password" emails: in the PocketBase admin UI, configure **Settings > Mail settings** with real SMTP credentials, then update the **Collections > users > Options > Email templates > Reset password** action URL to `{APP_URL}/reset-password?token={TOKEN}` (replacing `{APP_URL}` with your app's URL) so the link opens this app instead of PocketBase's own admin UI. Without this, users can't self-serve a forgotten password. `scripts/setup-pocketbase.js` also prints this reminder.

To stop the stack: `docker compose down`. Your data is preserved in `.pb-data/` and will be available on the next `docker compose up`.

**With Docker + Firebase**
Expand Down Expand Up @@ -172,6 +181,21 @@ npm run build && npm start

The app will be available at `http://localhost:3000` and will communicate with PocketBase via the URL you configured.

**6. Grant yourself admin access**

One-time bootstrap step for Profile > Admin (e.g. managing the certification list):

```bash
PB_URL=http://192.168.x.x:8090 PB_ADMIN_EMAIL=admin@example.com PB_ADMIN_PASSWORD=YourPassword! \
node scripts/setAdminPocketbase.js you@example.com
```

After signing in, that user can grant/revoke admin access for others from Profile > Admin > Manage Admins.

**7. (Optional) Enable "Forgot password" emails**

Configure **Settings > Mail settings** in the PocketBase admin UI with real SMTP credentials, then update the **Collections > users > Options > Email templates > Reset password** action URL to `{APP_URL}/reset-password?token={TOKEN}` so the link opens this app instead of PocketBase's own admin UI.

#### Testing

The E2E suite uses Playwright BDD with Firebase emulators. The test runner starts the emulators and a production build of Next.js automatically — no manual server setup required.
Expand Down
17 changes: 17 additions & 0 deletions docs/FIREBASE_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ Use the emulators when developing Firestore rules and client workflows.
- For production consider enabling SSO providers (Google, OIDC) and enforce MFA for admin users.
- Create service accounts for CI and server-side tasks with least privilege.

## Granting admin access

CrowdCAD's app-level admin role (Profile > Admin — manages the certification list and other admins) is separate from Firebase IAM/service accounts above. It's a boolean `isAdmin` field on the user's `users/{uid}` Firestore document.

There's no signup-time or console way to set it, so the first admin on a deployment must be bootstrapped with a script:

```bash
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json \
node scripts/setAdmin.js admin@example.com
```

This requires a service account JSON (Firebase Console > Project Settings > Service Accounts > Generate new private key). Once the first admin signs in, they can grant or revoke admin access for other users from the "Manage Admins" panel in Profile > Admin — the script is only needed once per deployment.

## Forgot-password emails

The "Forgot password?" link on the login screen uses Firebase Auth's built-in `sendPasswordResetEmail` — Firebase sends and delivers the email itself, no SMTP config needed. The only requirement is that your deployed domain (and `localhost` for local dev) is listed under **Authentication > Settings > Authorized domains** in the Firebase Console — this is usually already the case for any domain you're using to sign in, since Firebase Auth requires it for sign-in to work at all.

## CI & production deploys

- Store `FIREBASE_PROJECT` and `FIREBASE_TOKEN` (or use Workload Identity Federation) in your CI secrets.
Expand Down
37 changes: 33 additions & 4 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,51 @@ service cloud.firestore {
}

// Venues: require org membership to read; writes only by org members.
// Admins may also update/delete any venue (needed to wipe a deleted
// user's data from Profile > Admin > Manage Administrator Access).
match /venues/{venueId} {
allow read: if isVenueVisibleToUser(venueId);
allow create: if request.auth != null && request.resource.data.orgId is string && isOrgMember(request.resource.data.orgId);
allow update, delete: if isVenueOwnerOrOrgMember(venueId);
allow update, delete: if isVenueOwnerOrOrgMember(venueId) || isRequestingUserAdmin();
}

// Events: require org membership to read; writes only by org members.
// Admins may also update/delete any event (see venues comment above).
match /events/{eventId} {
allow read: if isEventVisibleToUser(eventId);
allow create: if request.auth != null && request.resource.data.orgId is string && isOrgMember(request.resource.data.orgId);
allow update, delete: if isEventOwnerOrOrgMember(eventId);
allow update, delete: if isEventOwnerOrOrgMember(eventId) || isRequestingUserAdmin();
}

// Users: allow users to read/write their own profile
// Dispatch logs: owner-scoped, with an admin override for the same
// account-deletion flow. (No prior rule existed for this collection —
// added now since admin-initiated deletes need it to actually work.)
match /dispatchLogs/{logId} {
allow read, update, delete: if request.auth != null &&
(resource.data.userId == request.auth.uid || isRequestingUserAdmin());
allow create: if request.auth != null && request.resource.data.userId == request.auth.uid;
}

// Users: allow users to read/write their own profile; admins may also
// read/write any user's doc (needed to grant/revoke isAdmin from the
// Profile > Admin > Manage Admins panel).
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
allow read, write: if request.auth != null &&
(request.auth.uid == userId || isRequestingUserAdmin());
}

// Settings: app-wide admin-managed config (e.g. the certification list
// offered when adding team members). Readable by any authenticated user
// since it's needed during event creation; writable only by admins.
match /settings/{settingId} {
allow read: if request.auth != null;
allow write: if isRequestingUserAdmin();
}

function isRequestingUserAdmin() {
return request.auth != null &&
exists(/databases/$(database)/documents/users/$(request.auth.uid)) &&
get(/databases/$(database)/documents/users/$(request.auth.uid)).data.isAdmin == true;
}

// Fallback deny
Expand Down
7 changes: 7 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export default defineConfig({
webServer: [
{
command: 'npx firebase emulators:start --only auth,firestore,storage --project demo-crowdcad',
// The emulator hub's port — lets Playwright detect an already-running
// instance and skip re-spawning it. Without this, Playwright always
// launches a fresh `firebase emulators:start`, which either races the
// `next build` step below (proceeding before the emulator is actually
// ready) or, if one is already running, fails to bind its ports and
// can take the healthy instance down with it.
port: 4400,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'pipe',
Expand Down
46 changes: 46 additions & 0 deletions scripts/setAdmin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/*
Grants (or revokes) admin access for a CrowdCAD user, identified by email, by
setting `isAdmin` on their `users/{uid}` Firestore document.

This is a one-time bootstrap step for the *first* admin on a deployment —
once at least one admin exists, further admins can be granted/revoked from
the "Manage Admins" panel in Profile > Admin.

Usage:
node scripts/setAdmin.js <email> # grant admin
node scripts/setAdmin.js <email> --revoke # revoke admin

Ensure you have a service account JSON and set `GOOGLE_APPLICATION_CREDENTIALS`.
*/

const admin = require('firebase-admin');

if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) {
console.error('Set GOOGLE_APPLICATION_CREDENTIALS to a service account JSON path before running.');
process.exit(1);
}

const email = process.argv[2];
const revoke = process.argv.includes('--revoke');

if (!email) {
console.error('Usage: node scripts/setAdmin.js <email> [--revoke]');
process.exit(1);
}

admin.initializeApp();

async function run() {
const userRecord = await admin.auth().getUserByEmail(email);
await admin.firestore().collection('users').doc(userRecord.uid).set(
{ isAdmin: !revoke },
{ merge: true },
);
console.log(`${revoke ? 'Revoked' : 'Granted'} admin access for ${email} (uid: ${userRecord.uid})`);
}

run().catch((err) => {
console.error(err);
process.exit(1);
});
95 changes: 95 additions & 0 deletions scripts/setAdminPocketbase.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/*
Grants (or revokes) admin access for a CrowdCAD user, identified by email, by
setting `isAdmin` on their record in the PocketBase `users` collection.

This is a one-time bootstrap step for the *first* admin on a deployment —
once at least one admin exists, further admins can be granted/revoked from
the "Manage Admins" panel in Profile > Admin.

Prerequisites:
- PocketBase is running and reachable at PB_URL
- `node scripts/setup-pocketbase.js` has been run (adds the `isAdmin` field)

Usage:
PB_URL=http://192.168.x.x:8090 \
PB_ADMIN_EMAIL=admin@example.com \
PB_ADMIN_PASSWORD=YourPassword! \
node scripts/setAdminPocketbase.js <email-of-user-to-promote> [--revoke]

All PB_* env vars can also be placed in a .env.local file.
*/

try {
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env.local') });
} catch {
// dotenv not available — rely on env vars being set externally
}

const PB_URL = (process.env.PB_URL ?? 'http://127.0.0.1:8090').replace(/\/$/, '');
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;

const targetEmail = process.argv[2];
const revoke = process.argv.includes('--revoke');

if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.error('Error: PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD must be set.');
process.exit(1);
}
if (!targetEmail) {
console.error('Usage: node scripts/setAdminPocketbase.js <email> [--revoke]');
process.exit(1);
}

async function pbFetch(apiPath, options = {}) {
return fetch(`${PB_URL}${apiPath}`, options);
}

async function getAdminToken() {
const res = await pbFetch('/api/collections/_superusers/auth-with-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Superadmin authentication failed: ${res.status} — ${body}`);
}
const { token } = await res.json();
return token;
}

async function main() {
const token = await getAdminToken();
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };

const filter = encodeURIComponent(`email = "${targetEmail}"`);
const listRes = await pbFetch(`/api/collections/users/records?filter=${filter}`, { headers });
if (!listRes.ok) {
const body = await listRes.text();
throw new Error(`Failed to look up user '${targetEmail}': ${listRes.status} — ${body}`);
}
const { items } = await listRes.json();
if (!items || items.length === 0) {
throw new Error(`No user found with email '${targetEmail}'`);
}
const user = items[0];

const patchRes = await pbFetch(`/api/collections/users/records/${user.id}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ isAdmin: !revoke }),
});
if (!patchRes.ok) {
const body = await patchRes.text();
throw new Error(`Failed to update user '${targetEmail}': ${patchRes.status} — ${body}`);
}

console.log(`${revoke ? 'Revoked' : 'Granted'} admin access for ${targetEmail} (id: ${user.id})`);
}

main().catch((err) => {
console.error(err.message);
process.exit(1);
});
Loading
Loading