App accounts

Some people your app serves have no email and no phone: staff on a shared device, a child, a kiosk. Your own Cloud Function can still give each of them an Arc CMS account, so the member area, the guards and your rules treat them as signed in like anyone else.

What such an account is

Arc CMS sends such a person no email or SMS, since it has neither address. The admins get no "just signed up" alert for it, because your app created it; the user.signed_up event is still sent. The role is always user: an app helper never makes an Arc CMS admin. Your app's own roles go in your claims.

Create an account and sign the person in

How the person proves who they are is your app's choice. The usual one is a PIN (see PINs for your app), checked in your own callable, which then returns a sign-in token:

// functions/src/custom/staff.ts
import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { createAppAccount, isArcAdmin, issueSignInToken } from '../app-kit/index.js';

/** A manager adds a member of staff. */
export const addStaff = onCall(async (request) => {
  if (!isArcAdmin(request.auth?.token)) throw new HttpsError('permission-denied', 'Managers only.');
  const { name, siteId } = request.data ?? {};
  const { uid } = await createAppAccount({ displayName: String(name ?? ''), claims: { site: String(siteId), staffRole: 'front-desk' } });
  return { uid };
});

/** Signs a member of staff in once your own check has passed. */
export const staffSignIn = onCall(async (request) => {
  const uid = String(request.data?.uid ?? '');
  // ... check the person here (a PIN, a badge), and refuse if it fails ...
  return { token: await issueSignInToken(uid) };
});

Add both names to functions/src/custom/public-callables.txt (see Write your own Cloud Functions). In the browser, exchange the token:

import { signInWithCustomToken } from '@angular/fire/auth';

const { token } = (await arcCallable<{ uid: string }, { token: string }>(this.functions, 'custom-staffSignIn')({ uid })).data;
await signInWithCustomToken(this.auth, token);

From then on the person is signed in: the member area and userGuard let them in, and their token carries arccms_uid and your claims from the first sign-in.

Your own claims

mergeAppClaims(uid, patch) adds or changes your claims on any account, an app account or not:

await mergeAppClaims(uid, { staffRole: 'manager', perms: ['approve', 'cancel'] });
await mergeAppClaims(uid, { site: null }); // removes your "site" claim

Firebase cannot merge claims in one step: a write reads the claims, then replaces them all. So Arc CMS's own claims writes and mergeAppClaims() take turns on each account: a write holds a short lease (15 seconds) while it reads, writes and reads back, and another write on the same account waits for it. A write that changes nothing takes no lease. Two writes through Arc CMS keep each other's claims, with one exception: a write still running when its lease runs out can race the next one, and is then read back and redone if its claims are missing.

A program outside Arc CMS that calls setCustomUserClaims on the same accounts takes no lease, and can still overwrite them. Use mergeAppClaims() for all of your claims.

Locked by default

An app account belongs to your app, not to the person using it. So it is locked: the person cannot change it, and Arc CMS's server and rules refuse each try, whatever the browser sends.

It still signs in with your token, reads its own record, gets its claims refreshed and keeps settings that are not its identity, such as a language. Your own functions are not affected: they use the Admin SDK, which the lock does not apply to. A manager renaming someone:

import { db, owner } from '../init.js';

await owner.updateUser(uid, { displayName: name });
await db.collection('users').doc(userDocId).update({ name, modifiedAt: new Date() });

To let one person change their own account like any member, create it with selfService: true:

await createAppAccount({ displayName: 'Anna', selfService: true });

Accounts made before the lock existed have no selfService field, so they are locked too.

The member pages

By default a locked account can open the member pages (/user/... and /account, see Build the member area). Its profile shows the name and photo with nothing to change: no sign-in methods, no password and no delete. To keep locked accounts out of those pages, say so in src/custom/app-accounts.ts:

import type { AppAccountChoice } from '../app/core/app-accounts/app-account-lock';

export const CUSTOM_APP_ACCOUNTS: AppAccountChoice = { memberPages: false };

A locked account that opens one then goes to its home page from src/custom/home.ts, or to the site's home page when that home is itself a member page. The member menu hides those pages for it. Your own pages are not affected: a route with userGuard still lets it in.

Firebase lets a signed-in browser change a few things on its own sign-in account through the Firebase SDK, without asking Arc CMS's server. Refusing them needs Identity Platform's blocking functions, which Arc CMS does not use. Arc CMS hides every control that does them for a locked account, and the users record never follows any of them:

The browser canWhat Arc CMS does
Link a sign-in method: Google, a password or a phoneWhen the person later signs in that way, the server sees a locked account signed in without your token: it takes that way in off the account, ends all its sessions and refuses. Until then that sign-in carries your claims (see below).
Change the sign-in's display name or photo (updateProfile)Nothing, and nothing needs doing: Arc CMS shows the name and photo from the users record, never from the sign-in. The ID token's name and picture do change, so never trust them in your own rules or functions.
Delete the sign-in (auth.currentUser.delete(); a sign-in with your token counts as recent, so Firebase asks for nothing more)Keeps the record and emits user.signInDeleted, so your app decides what happens (below).

A sign-in linked in the browser carries your claims until the server next sees it. So in your own rules, wherever a claim you put on app accounts grants access, add signedInByApp(). It is true only when the session came from your sign-in token, never from a Google, password or phone sign-in linked in the browser:

// firestore.app.rules
match /bookings/{id} {
  allow read: if signedInByApp() && request.auth.token.staffRole == 'front-desk';
}

Your callables check the same with signedInByApp(request) from the app kit:

import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { signedInByApp } from '../app-kit/index.js';

export const approveBooking = onCall(async (request) => {
  if (!signedInByApp(request) || request.auth?.token['staffRole'] !== 'manager') {
    throw new HttpsError('permission-denied', 'Managers only.');
  }
  // ...
});

Leave both out for ordinary members, who sign in with Google, a password or a phone code. The helpers your rules can call are listed in Set security rules and indexes.

When the browser deletes the sign-in

The users record stays, and so do your app's PIN and your data for that uid. Arc CMS emits user.signInDeleted on the event bus, with userId (the Auth uid), data.userDocId and data.locked. It is not user.deleted: the account still exists, without a way in. Your app picks one of two answers:

import { onDocumentCreated } from 'firebase-functions/v2/firestore';
import { arcDocument } from '../arc-config.js';
import { restoreAppSignIn } from '../app-kit/index.js';

export const onStaffSignInDeleted = onDocumentCreated(arcDocument('AppEvents/{id}'), async (event) => {
  const e = event.data?.data();
  if (e?.['type'] !== 'user.signInDeleted' || !e['data']?.['locked']) return;
  const staff = await findStaff(e['userId']); // your own data, keyed by the Auth uid
  await restoreAppSignIn(e['userId'], { site: staff.siteId, staffRole: staff.role });
});

Both are safe to repeat. If the person signs in with your token before your app answers, Firebase makes a new sign-in with that uid. Arc CMS gives it back its own claims at sign-in, but not yours, so your app's access stays off until restoreAppSignIn() runs. Arc CMS's own deletions never emit user.signInDeleted: they delete the record first, and the event is only for a sign-in whose record is still there.

Sessions

revokeSessions(uid) signs the person out on every device: their refresh tokens are revoked, so each session ends at its next token refresh, within the hour. Use it when a member of staff leaves or a device is lost. They can sign in again as usual afterwards.

Delete an account

deleteAppAccount(uid) deletes the person's users record. Arc CMS's account deletion then does what it does for anyone (see Follow the account contract): it ends their access, deletes the sign-in, their PIN and their data under the record, and last emits user.deleted with userId (the Auth uid) and data.userDocId. Clean up your own data from that event:

export const onStaffDeleted = onDocumentCreated(arcDocument('AppEvents/{id}'), async (event) => {
  const e = event.data?.data();
  if (e?.['type'] !== 'user.deleted') return;
  await removeShifts(e['userId']); // your own data, keyed by the Auth uid
});

Empty email and phone never match

Many records with an empty email or phone could match a lookup by an empty value. Every Arc CMS lookup by exact email or phone checks for an empty value first and finds nothing, and a test fails on a new one that does not. Do the same in your own functions: never query where('email', '==', x) without checking that x is not blank.