Set up your private workspace
Prime Practice is practice-management software for Indian professional firms — Chartered Accountants, Company Secretaries, Advocates and Cost Accountants. Your client data lives in a private database on your own Google account, not on our servers.
This wizard will guide you through five simple steps to create your workspace. Estimated time: 15–20 minutes (mostly waiting for Firebase to provision).
What you'll do:
- Create a free Firebase project on your Google account
- Paste the SDK config into this wizard
- Enable Firestore + paste our security rules
- Enable Authentication + add authorized domains
- Pick your firm's URL slug and submit for approval
Download the one-click setup tool — project, database, security rules, authentication, admin account, registration: all automatic (~5-8 minutes, with just one click in between). It runs on your own computer with your own Google sign-in — the privacy promise stays exactly the same.
⬇ Download Setup Tool (Windows)
Extract the zip and double-click SETUP-PRIMEPRACTICE.bat — everything is explained in README-FIRST.txt inside. If Windows shows a "protected your PC" warning, click More info → Run anyway. On a Mac, or if the tool gives you any trouble? The manual wizard below always works.
Note: All progress is saved as you go. You can close this page and resume later — just visit primepractice.in/setup-wizard again.
Create your free Firebase project
Firebase will host your workspace's database, authentication, and storage. The free tier covers all small & mid-size CA firms comfortably.
- Open console.firebase.google.com in a new tab and sign in with your Google account.
- Click "Add project" (or the giant card at center).
- Enter a project name (e.g., your firm's name). Firebase auto-generates a Project ID — you can edit it for cleanliness if you want.
- Disable Google Analytics when prompted — not needed for the workspace.
- Click Create project and wait ~30 seconds for provisioning.
Firebase's free Spark plan covers everything Prime Practice needs — Firestore (1 GB storage, 50,000 reads/day, 20,000 writes/day), Authentication (unlimited Email/Password users), and Hosting. For a typical small or mid-size CA firm, you will never exceed the free limits.
Do NOT upgrade to Blaze (the paid plan) unless you specifically want premium features in the future. If Firebase asks "Upgrade to Blaze?" anywhere during setup, just say No / close the dialog.
Done? Click Next below — we'll set up the web app + grab the SDK config in the next step.
Connect your Firebase project
Inside Firebase Console, register a Web app and copy its SDK config snippet. Paste it below — we'll validate it works.
- In your Firebase project dashboard, click the </> (Web) icon under "Get started by adding Firebase to your app".
- App nickname:
Prime Practice MT(or anything). Leave "Also set up Firebase Hosting" unchecked. - Click Register app.
- Firebase shows a
const firebaseConfig = { ... }snippet. Copy the entire block (all 6 fields). - Paste it into the textarea below ↓ — the 6 fields auto-fill.
Enable Firestore + apply our security rules
Firestore is the database that stores your clients, tasks, and compliance data. The security rules below ensure only you and your invited staff can access it.
We'll enable the required APIs, deploy security rules, configure Authentication, and add authorized domains for you. One-time access — token is used once and discarded. Saves you ~10 manual clicks.
Full transparency — here's exactly what happens
- Create your Firestore database in asia-south1 (if not already done)
- Deploy our recommended security rules (shown below)
- Enable Email/Password + Google sign-in
- Add
primepractice.into your authorized domains
- Store your Google credentials anywhere on our servers
- Read your Firestore data (zero data access)
- Access any other Google service (Drive, Gmail, etc.)
- Use the token a second time — it's discarded immediately after setup
firebase (manage Firebase resources) + service.management (enable required APIs on your project). Both are Firebase/GCP project-management scopes only — we cannot read your Firestore data, Gmail, Drive, or any other Google service.
- In your Firebase project, go to Build → Firestore Database.
- Click Create database. Location:
asia-south1 (Mumbai). Mode:Production mode. Click Create. - After provisioning (~30 sec), switch to the Rules tab.
- Click the "Copy rules" button below, then paste in the Rules editor (replace everything) and click Publish.
rules_version = '2';
// ═══════════════════════════════════════════════════════════════════
// PRIME PRACTICE — FIRESTORE SECURITY RULES (v12 — CLIENT PORTAL read
// paths restored; v11 = staff + CA digital diary, personal to-dos,
// private notepad and diary attachments)
//
// v12 (2026-08-05) — WHY THIS EXISTS: the portal rules were applied by
// hand in the Console in May 2026 and were never written into THIS file.
// The next publish from here therefore wiped them and every client
// portal died with "Permission denied". They now live here, so no
// future publish can lose them again.
//
// NOTE: v9 was never issued — the number is skipped on purpose so that
// a project still carrying a stale "v9" marker is correctly seen as
// out of date and gets the amber update banner.
// ═══════════════════════════════════════════════════════════════════
// Deploy:
// Firebase Console → Firestore Database → Rules → Paste → Publish
// or:
// firebase deploy --only firestore:rules
// ═══════════════════════════════════════════════════════════════════
service cloud.firestore {
match /databases/{database}/documents {
// ── Helper functions ─────────────────────────────────────────
function isSignedIn() { return request.auth != null; }
function isAdmin(firmId) { return isSignedIn() && request.auth.uid == firmId; }
function isStaff() {
return isSignedIn()
&& request.auth.token.email != null
&& request.auth.token.email.matches('.*@staff[.]local');
}
function staffUsername() { return request.auth.token.email.split('@')[0]; }
function staffDoc() {
return get(/databases/$(database)/documents/staffIndex/$(staffUsername())).data;
}
function isStaffOfFirm(firmId) {
return isStaff()
&& exists(/databases/$(database)/documents/staffIndex/$(staffUsername()))
&& staffDoc().firmId == firmId;
}
function staffHasPerm(firmId, perm) {
return isStaffOfFirm(firmId)
&& staffDoc().permissions != null
&& staffDoc().permissions[perm] == true;
}
function isMemberOfFirm(firmId) { return isAdmin(firmId) || isStaffOfFirm(firmId); }
// ── CLIENT PORTAL (v12 2026-08-05) ───────────────────────────
// A portal user is an ordinary Firebase Auth user on THIS tenant
// project: uid != firmId and no @staff.local email. They are linked
// to one or more client records by `portalUid`; `portalEnabled` is
// the CA's pause switch (portal-admin "Pause access" clears it while
// keeping the link, "Revoke" deletes both fields).
//
// ⚠️ RULE-SHAPE MUST MATCH QUERY-SHAPE. For a list, Firestore checks
// this rule against EVERY row it would return — one failing row
// rejects the whole query, not just that row. So each portal query
// has to carry the same filters the rule tests:
// clients -> .where('portalUid','==',uid)
// .where('portalEnabled','==',true)
// tasks -> .where('clientId','==',<client id>)
// transactions -> .where('clientId','==',<client id>)
// documents -> .where('visibleToClient','==',true)
// Two equality filters need NO composite index, so nothing has to be
// created in any tenant project.
//
// The repeated get() below resolves to the SAME document path.
// Firestore documents that repeated access calls to one document are
// cached and count as a SINGLE access, so a whole portal list query
// costs one extra read, not one per row (limit: 20 per query request).
//
// The `!= ''` guard matters: app-core writes `clientId: client?.id ||
// ''` on tasks, so a client-less task carries an EMPTY STRING, not a
// missing field. `$('')` is an evaluation error, not `false`.
function isPortalLinked(firmId, clientId) {
return isSignedIn()
&& clientId is string
&& clientId != ''
&& exists(/databases/$(database)/documents/firms/$(firmId)/clients/$(clientId))
&& get(/databases/$(database)/documents/firms/$(firmId)/clients/$(clientId)).data.portalUid == request.auth.uid
&& get(/databases/$(database)/documents/firms/$(firmId)/clients/$(clientId)).data.portalEnabled == true;
}
// ── staffIndex (top-level) ───────────────────────────────────
// v27 redesign: key is now mobile number (was username). Logic
// unchanged because the key just needs to match the staff's
// Firebase Auth email prefix ({key}@staff.local).
//
// FIX (Phase 8 Issue P3-3): hijack prevention. An admin can only
// CREATE a new staffIndex doc, OR UPDATE one that already belongs
// to their firm. Cannot overwrite another firm's staffIndex entry.
match /staffIndex/{username} {
// FIX (Phase 17): Allow unauthenticated READ. Required because staff
// mobile login must look up loginEmail BEFORE Firebase Auth sign-in.
// Beta trade-off (similar to staffIndex_email):
// - Data exposed: name, mobile, firmId, permissions, loginEmail
// - Mitigation: in beta, only known users have the app URL
// - Public launch: replace with Cloud Function callable that does
// the lookup server-side (eliminating the open read).
allow read: if true;
// CREATE: admin (uid==firmId) OR a staff with `staffSetup` permission.
// The new doc must declare the SAME firmId — no hijacking other firms.
allow create: if isSignedIn()
&& resource == null
&& request.auth.uid == request.resource.data.firmId;
allow create: if resource == null
&& request.resource.data.firmId is string
&& isStaffOfFirm(request.resource.data.firmId)
&& staffDoc().permissions.staffSetup == true;
// UPDATE: admin OR a `staffSetup` staff of the SAME firm.
allow update: if isSignedIn()
&& resource != null
&& request.auth.uid == resource.data.firmId
&& request.auth.uid == request.resource.data.firmId;
allow update: if resource != null
&& resource.data.firmId == request.resource.data.firmId
&& isStaffOfFirm(resource.data.firmId)
&& staffDoc().permissions.staffSetup == true;
// DELETE: admin OR a `staffSetup` staff of the SAME firm.
allow delete: if isSignedIn() && resource != null && request.auth.uid == resource.data.firmId;
allow delete: if resource != null
&& isStaffOfFirm(resource.data.firmId)
&& staffDoc().permissions.staffSetup == true;
}
// ── staffIndex_email (top-level lookup pointer) ──────────────
// v27: Maps staff email → {mobile, firmId} so staff can also log in
// using their email instead of mobile number.
//
// BETA TRADE-OFF: read is open to anyone (`if true`) because the
// lookup must happen BEFORE login (to translate email → mobile
// before signInWithEmailAndPassword). Acceptable for beta because:
// - Data is minimal (just mobile + firmId for that email)
// - Email enumeration is already possible via Firebase Auth itself
// - Closed beta with 30-40 known users
//
// PUBLIC LAUNCH: Replace with a Cloud Function callable that does
// the lookup server-side, removing this open read.
//
// v2 2026-05-25: writes also allowed for staff with `staffSetup` perm.
match /staffIndex_email/{email} {
allow read: if true;
allow create, update: if isSignedIn() && request.auth.uid == request.resource.data.firmId;
allow create, update: if request.resource.data.firmId is string
&& isStaffOfFirm(request.resource.data.firmId)
&& staffDoc().permissions.staffSetup == true;
allow delete: if isSignedIn() && resource != null && request.auth.uid == resource.data.firmId;
allow delete: if resource != null
&& isStaffOfFirm(resource.data.firmId)
&& staffDoc().permissions.staffSetup == true;
}
// ── firms/{firmId} ───────────────────────────────────────────
match /firms/{firmId} {
allow read: if isMemberOfFirm(firmId);
// PORTAL (v12): the firm doc is NOT opened to portal clients. It
// holds profile.bankDetails[].accountNo/ifsc and
// profile.signatureDataUrl — forgery material. The three harmless
// things the portal actually needs are mirrored into
// settings/portalPublic below.
allow create: if isSignedIn() && request.auth.uid == firmId;
allow update, delete: if isAdmin(firmId);
// ── clients (v2 2026-05-25: canEdit / canDelete sub-permissions) ──
// FIX (Phase 3 audit Issue 1): use `!= true` instead of `== false`
// so clients without the hideFromStaff field (legacy / direct console
// edits / restored backups) are NOT silently hidden from staff.
//
// v2 model (2026-05-25): module-access PLUS sub-permission for writes.
// - `clients` perm → read (gated by hideFromStaff)
// - `clients` + canEdit → create / update
// - `clients` + canDelete → delete
match /clients/{clientId} {
allow read: if isAdmin(firmId);
allow read: if isStaffOfFirm(firmId) && resource.data.hideFromStaff != true;
// PORTAL (v12): a client reads ONLY the records linked to its own
// login, and only while the CA has not paused access. Writes are
// never granted — the portal is read-only by design.
allow read: if isSignedIn()
&& resource.data.portalUid == request.auth.uid
&& resource.data.portalEnabled == true;
allow create, update: if isAdmin(firmId);
allow create, update: if staffHasPerm(firmId, 'clients')
&& staffDoc().permissions.canEdit == true;
allow delete: if isAdmin(firmId);
allow delete: if staffHasPerm(firmId, 'clients')
&& staffDoc().permissions.canDelete == true;
// ── documents shared with the portal (v12) ──
// The CA marks a file visibleToClient; everything else in the
// subcollection stays invisible to the client.
match /documents/{docId} {
allow read, write: if isAdmin(firmId);
allow read: if isPortalLinked(firmId, clientId)
&& resource.data.visibleToClient == true;
}
}
// ── tasks (v3 2026-05-25 evening: peer-to-peer reassignment) ──
// Existing assigned-task update path preserved (any staff can update
// their own task; staffName field still locked from reassignment
// UNLESS they're acting on a pending reassignment request to them).
// NEW v2: a staff with `bulkOps` perm can create + delete tasks.
// NEW v3: target staff can accept (changes staffName to them) or
// reject (keeps staffName), both clear pendingReassignment.
match /tasks/{taskId} {
allow read: if isMemberOfFirm(firmId);
// PORTAL (v12): the client sees only its own work. Read-only.
// NOTE: the tasks/{taskId}/chat subcollection is deliberately NOT
// opened here. Chat shares one subcollection with the CA app and
// holds internal office messages; a rule cannot filter fields out
// of a list, so opening it would expose them. Portal chat stays
// demo-only until the client query carries
// where('internal','==',false) and each tenant has the matching
// composite index.
allow read: if isPortalLinked(firmId, resource.data.clientId);
allow create, delete: if isAdmin(firmId);
allow create, delete: if staffHasPerm(firmId, 'bulkOps');
// v6 SELF-ASSIGN (2026-07-23): when a client calls a staff member
// directly, the staff can create a task FOR THEMSELVES ONLY.
// Guarded: doc must be flagged selfAssigned and staffName must be
// the actor's own staffIndex name — no assigning to others.
allow create: if isStaffOfFirm(firmId)
&& request.resource.data.selfAssigned == true
&& request.resource.data.staffName == staffDoc().name;
// Admin: full update access
allow update: if isAdmin(firmId);
// Staff: update only if task is assigned to them
// (compares stored staffName with their staffIndex.name)
// FIX (Phase 3 audit Issue 2): also lock staffName field so staff
// cannot reassign their own task to another staff member.
allow update: if isStaffOfFirm(firmId)
&& resource.data.staffName == staffDoc().name
&& request.resource.data.staffName == resource.data.staffName;
// bulkOps staff can also update tasks they aren't assigned to
// (used by bulk operations like mass-status-update).
allow update: if staffHasPerm(firmId, 'bulkOps');
// v3 PEER REASSIGN — target staff can ACCEPT a pending request:
// - staffName changes to them
// - pendingReassignment field is cleared (set to null)
allow update: if isStaffOfFirm(firmId)
&& resource.data.pendingReassignment != null
&& resource.data.pendingReassignment.toStaff == staffDoc().name
&& request.resource.data.staffName == staffDoc().name
&& request.resource.data.pendingReassignment == null;
// v3 PEER REASSIGN — target staff can REJECT a pending request:
// - staffName stays unchanged
// - pendingReassignment field is cleared (set to null)
allow update: if isStaffOfFirm(firmId)
&& resource.data.pendingReassignment != null
&& resource.data.pendingReassignment.toStaff == staffDoc().name
&& request.resource.data.staffName == resource.data.staffName
&& request.resource.data.pendingReassignment == null;
// Task chat — both can read & post; staff can only post if task is theirs
match /chat/{messageId} {
allow read: if isMemberOfFirm(firmId);
allow create: if isAdmin(firmId);
allow create: if isStaffOfFirm(firmId)
&& get(/databases/$(database)/documents/firms/$(firmId)/tasks/$(taskId)).data.staffName == staffDoc().name;
allow update, delete: if isAdmin(firmId);
}
}
// ── transactions (v2 2026-05-25: finance perm grants full CRUD) ──
match /transactions/{txId} {
allow read: if isAdmin(firmId);
allow read: if staffHasPerm(firmId, 'finance');
// PORTAL (v12): the client's own statement of account. Read-only.
// Invoice DRAFTS never appear here — a saved draft writes no
// transactions doc at all.
allow read: if isPortalLinked(firmId, resource.data.clientId);
allow write: if isAdmin(firmId);
allow write: if staffHasPerm(firmId, 'finance');
}
// ── expenses (v5 2026-05-25: Out-of-Pocket expenses) ──
// Tracks client-related expenses the firm incurs on the client's
// behalf (stamp paper, notary, gov fees, courier). Same access model
// as transactions: admin OR staff with finance perm.
match /expenses/{expenseId} {
allow read: if isAdmin(firmId);
allow read: if staffHasPerm(firmId, 'finance');
allow write: if isAdmin(firmId);
allow write: if staffHasPerm(firmId, 'finance');
}
// ── staff records (v2 2026-05-25: staffSetup perm grants full CRUD) ──
// Self-read preserved for any signed-in staff (they need their own
// record for visibility flags etc.). Writes now allowed for staff
// with `staffSetup` — they can create / edit / delete other staff.
match /staff/{staffId} {
allow read: if isAdmin(firmId);
allow read: if isStaffOfFirm(firmId)
&& resource.data.username == staffUsername();
allow read: if staffHasPerm(firmId, 'staffSetup');
allow write: if isAdmin(firmId);
allow write: if staffHasPerm(firmId, 'staffSetup');
}
// ── groups (v2 2026-05-25: clients+canEdit / canDelete) ──
// Groups are an extension of Client Master (contact-info grouping).
// Writes follow the same sub-permission model as clients.
match /groups/{groupId} {
allow read: if isAdmin(firmId);
allow read: if staffHasPerm(firmId, 'clients');
allow create, update: if isAdmin(firmId);
allow create, update: if staffHasPerm(firmId, 'clients')
&& staffDoc().permissions.canEdit == true;
allow delete: if isAdmin(firmId);
allow delete: if staffHasPerm(firmId, 'clients')
&& staffDoc().permissions.canDelete == true;
}
// ── compliance master (v2 2026-05-25: compliance perm grants full CRUD) ──
match /compliances/{compId} {
allow read: if isAdmin(firmId);
allow read: if staffHasPerm(firmId, 'compliance') || isStaffOfFirm(firmId);
// ↑ Staff still needs read access for task badges.
// PORTAL (v12): the portal does NOT read this collection — a list
// here would expose the firm's whole service catalogue with SAC
// codes to any signed-in account. The id -> name map it needs is
// mirrored into settings/portalPublic instead.
allow write: if isAdmin(firmId);
allow write: if staffHasPerm(firmId, 'compliance');
}
// ── notifications (v3 2026-05-25: staff can create reassign pings) ──
match /notifications/{notifId} {
allow read: if isMemberOfFirm(firmId);
allow create, delete: if isAdmin(firmId);
// v3 NEW: any staff member of the firm can create notifications
// (needed for reassign_request / reassign_accepted / reassign_rejected
// /reassign_cancelled / reassign_expired pings). Tightened to require
// that the notification's fromStaff matches the actor — prevents
// staff from posting in another staff's name.
allow create: if isStaffOfFirm(firmId)
&& request.resource.data.fromStaff == staffDoc().name;
// Staff can delete their own outgoing notifications (e.g. cancel
// a pending reassign request).
allow delete: if isStaffOfFirm(firmId)
&& resource.data.fromStaff == staffDoc().name;
// Update: admin OR target staff (mark as read)
allow update: if isAdmin(firmId);
allow update: if isStaffOfFirm(firmId)
&& resource.data.targetStaff == staffDoc().name;
}
// ── app updates / changelog ──
match /appUpdates/{docId} {
allow read: if isMemberOfFirm(firmId);
allow write: if isAdmin(firmId);
}
// ── WhatsApp templates ──
match /waTemplates/{templateId} {
allow read: if isMemberOfFirm(firmId);
allow write: if isAdmin(firmId);
}
// ── Email templates ──
match /emailTemplates/{templateId} {
allow read: if isMemberOfFirm(firmId);
allow write: if isAdmin(firmId);
}
// ── auditLog (append-only trail) ──
// FIX (Phase 3 audit Issue 4): audit logs are now FULLY immutable.
// Even admin cannot delete — required for compliance / tamper-proof
// trail. To purge old logs, use a Cloud Function with admin SDK
// (which bypasses these rules) on a retention schedule.
match /auditLog/{logId} {
allow read: if isAdmin(firmId);
allow create: if isMemberOfFirm(firmId);
allow update: if false;
allow delete: if false;
}
// ── recurringSchedules (v23 — admin-only, no staff visibility) ──
match /recurringSchedules/{scheduleId} {
allow read, write: if isAdmin(firmId);
}
// ── caTodos (v6 2026-07-23 — CA's personal to-do list) ──
// Admin-only by default. Items the CA marks showToStaff==true are
// readable by staff (shown as "CA khud karenge" on Task Dashboard).
// NOTE: staff LIST queries MUST carry where('showToStaff','==',true)
// or the whole query is rejected (same pattern as clients.hideFromStaff).
match /caTodos/{todoId} {
allow read: if isAdmin(firmId);
allow read: if isStaffOfFirm(firmId) && resource.data.showToStaff == true;
allow write: if isAdmin(firmId);
}
// ── vaultConfig (v6 — password vault master-PIN metadata) ──
// Holds PBKDF2 salt + AES-GCM verifier ONLY (never any password).
// Vault-permitted staff need read to unlock; only admin manages PIN.
match /vaultConfig/{docId} {
allow read: if isAdmin(firmId) || staffHasPerm(firmId, 'canViewPasswords');
allow write: if isAdmin(firmId);
}
// ── credentials (v6 — client portal logins, AES-GCM encrypted) ──
// loginId/password are stored as ciphertext {iv, ct} — decryptable
// only in-browser with the master PIN. Access: admin OR staff with
// the 'canViewPasswords' permission (set in Staff Management).
// Delete stays admin-only.
match /credentials/{credId} {
allow read, create, update: if isAdmin(firmId) || staffHasPerm(firmId, 'canViewPasswords');
allow delete: if isAdmin(firmId);
}
// ── companyMaster (v6 — MCA company info + directors incl. their
// encrypted MCA logins). Same access model as credentials. ──
match /companyMaster/{docId} {
allow read, create, update: if isAdmin(firmId) || staffHasPerm(firmId, 'canViewPasswords');
allow delete: if isAdmin(firmId);
}
// ── gstStatusCache (v6 — cached PUBLIC GST filing status per client;
// contains no credentials). Any firm member can read/refresh. ──
match /gstStatusCache/{docId} {
allow read, write: if isMemberOfFirm(firmId);
}
// ── staffDiary (v8 2026-07-25 — staff/article daily work diary) ──
// Path: staffDiary/{staffUser}/days/{YYYY-MM-DD} — one doc per staff
// per day, holding an entries[] array. The app addresses these docs
// by explicit path only (never a list query), so NO composite indexes
// are needed on any tenant project.
//
// Access is gated on the {staffUser} PATH SEGMENT matching the
// actor's own auth username. That matters twice over:
// * reading a day that does not exist yet still works (there is no
// `resource` to test on an empty day), and
// * nothing depends on a display NAME matching. v7 compared
// staffName against staffDoc().name and every write failed when
// the two differed by even one character.
//
// The today/yesterday edit policy is enforced in the UI; these rules
// are the tamper-resistant OUTER fence (a 3-day window), deliberately
// one day wider so a save issued a few seconds before local midnight,
// or a slightly skewed client clock, is not spuriously denied.
// Backfilling a week-old day is still impossible, and entryAt can
// never be rewritten once set.
match /staffDiary/{staffUser}/days/{dayId} {
allow read: if isAdmin(firmId);
allow read: if isStaffOfFirm(firmId) && staffUser == staffUsername();
allow create: if isStaffOfFirm(firmId)
&& staffUser == staffUsername()
&& request.resource.data.entryAt is timestamp
&& request.resource.data.entryAt > request.time - duration.value(3, 'd')
&& request.resource.data.entryAt < request.time + duration.value(1, 'd');
allow update: if isStaffOfFirm(firmId)
&& staffUser == staffUsername()
&& request.resource.data.entryAt is timestamp
&& request.resource.data.entryAt > request.time - duration.value(3, 'd')
&& request.resource.data.entryAt < request.time + duration.value(1, 'd')
&& (!('entryAt' in resource.data)
|| request.resource.data.entryAt == resource.data.entryAt);
// Day docs are never deleted by staff (entries are removed via
// update while the day is still editable).
allow delete: if false;
}
// ── staff private notepad (v10) ──
// staffDiary/{staffUser}/notes/{noteId} — the staff member's own
// rough book: mistakes, reminders, things to ask. Deliberately NOT
// surfaced anywhere in the CA-facing UI: no Team Diary, no report,
// no export. Same path-segment gate, and unlike day docs these are
// freely editable and deletable by their owner — a private note is
// not a work record.
match /staffDiary/{staffUser}/notes/{noteId} {
allow read, write: if isStaffOfFirm(firmId)
&& staffUser == staffUsername();
}
// ── diary attachments (v11) ──
// staffDiary/{staffUser}/files/{fileId} — up to 3 small files per work
// entry, held as base64 in their OWN document. They are deliberately
// NOT inside the day document: a day holds many entries and Firestore
// caps a document at 1 MiB, which a single 500 KB attachment would
// already threaten once base64 adds its ~33%.
// Same path-segment gate as days and notes. The CA reads every
// staff attachment through the admin rule (Team Diary shows them),
// and the CA's own diary lives under a reserved staffUser that no
// staff username can ever equal.
match /staffDiary/{staffUser}/files/{fileId} {
allow read: if isAdmin(firmId);
allow read, write: if isStaffOfFirm(firmId)
&& staffUser == staffUsername();
}
// ── staffTodos (v8 — staff personal to-do list) ──
// Gated on the auth-derived `staffUsername` field, NOT on a display
// name (same v7 lesson as above). Staff have full CRUD on their own
// to-dos; the username field is locked so a to-do cannot be moved
// onto someone else. Admin reads all.
// NOTE: staff LIST queries MUST carry
// where('staffUsername','==',their own username) or they are
// rejected (same trap as clients.hideFromStaff).
match /staffTodos/{todoId} {
allow read: if isAdmin(firmId);
allow read, delete: if isStaffOfFirm(firmId)
&& resource.data.staffUsername == staffUsername();
allow create: if isStaffOfFirm(firmId)
&& request.resource.data.staffUsername == staffUsername();
allow update: if isStaffOfFirm(firmId)
&& resource.data.staffUsername == staffUsername()
&& request.resource.data.staffUsername == resource.data.staffUsername;
}
// ── settings/portalPublic (v12 2026-08-05) — portal mirror ────
// The client portal needs three harmless things that otherwise live
// inside documents it must never see:
// upiVpa -> the "Scan to Pay" QR on an invoice
// phone -> the "I've paid, please confirm" WhatsApp
// compliances -> {id: {comp, auth}} so the portal can print
// "GSTR-3B" instead of a raw document id
//
// WHY A MIRROR. On a tenant project "signed in" is NOT a closed set:
// authorizedCAs is world-readable (the app must fetch its own config
// before anyone can log in) and tenant projects allow email/password
// sign-up, so a stranger can create an account on any tenant. Opening
// firms/{firmId} would therefore hand them profile.bankDetails and
// profile.signatureDataUrl; opening /compliances would hand them the
// firm's service catalogue. This one small doc holds neither, and
// nothing in it is not already printed on every invoice.
//
// Written by app-patch-portal-public.js (admin only, on login and
// after a Firm Profile save).
match /settings/portalPublic {
allow read: if isSignedIn();
allow write: if isAdmin(firmId);
}
// Catch-all (admin-only) for any future subcollection
match /{document=**} {
allow read, write: if isAdmin(firmId);
}
}
}
}
Enable Authentication + add authorized domains
This lets your firm's admin and staff sign in. We'll enable two sign-in methods.
- In Firebase Console → Build → Authentication. Click Get started.
- Under the Sign-in method tab, click Email/Password → toggle the first switch ON → Save. (You do not need to enable Google or any other provider — Prime Practice uses only Email/Password.)
- Switch to the Settings tab → Authorized domains section.
- Click Add domain and add both of these (one at a time):
primepractice.inmt.primepractice.in
primepractice.in means your staff can log in to primepractice.in/your-firm.
Pick your URL + submit for approval
Choose the URL slug your firm will use. Submit, and we'll review your setup within 24 hours.
rajesh-co, shantanu.After you submit, your entry will be marked pending. Our team reviews each setup to ensure your Firebase project works correctly. Once approved, you'll receive an email and your workspace will go live at primepractice.in/{slug}.
Submission received!
Your firm admin account has been created in your Firebase project, and your workspace is now in our review queue. We'll email you within 24 hours when activated.
You can already test the URL before approval — the page should load but login will be blocked until our team finishes the security check.