Account switcher — frontend implementation guide (master / sub-account)
Audience: Fangate webapp and mobile client teams
Backend status: On main (directional linking; run php artisan migrate on each environment)
Webapp status: Implemented in production — link/switch/aggregate flows use account_group and the linked-account APIs. Use the migration checklist only for remaining edge cases or new clients.
Related: API overview · Auth API · Wallet aggregate · Webapp logout fix
1. What changed and why
Before (token-only switcher)
- Client stored multiple Sanctum
apitokens locally (e.g.fangate-webapp-managed-sessions). - Whoever had tokens in storage could show every account in the switcher and call
POST /api/dashboard/summary/aggregatewithadditional_tokens. - No server-side relationship — security and UX depended entirely on the browser.
After (backend-owned master / sub-account)
- Relationships are stored in
account_group_members(master_user_id→child_user_id), directional (same sub-account may link under multiple masters). - Login returns
account_groupdescribing what the current user may see. linked_accountslists only sub-accounts you linked; sub-accounts never receive another master’s siblings.- Aggregate KPIs use persisted links for
master/hybrid, not client-supplied tokens.
Your job: Drive the switcher UI, storage, and API calls from account_group, not from “every token we ever stored.”
2. Roles and rules
account_group.role | Switcher shows | Can link / unlink | Can open sub-account session | Aggregate scope |
|---|---|---|---|---|
standalone | Current user only (until links exist) | Yes (POST link) | N/A until links exist | Self only |
master | Self + linked_accounts[] | Yes | Yes (POST .../session) | Self + all sub-accounts you linked |
sub_account | Self only (+ optional master metadata) | Yes (becomes hybrid when you link others) | Only for accounts you linked | Self only until you have own links |
hybrid | Self + your linked_accounts[] | Yes | Yes for your links | Self + your linked sub-accounts |
Important
- Master (direction) is whoever has rows as
master_user_idfor that link. - The same user may be a sub-account under master A and also link their own sub-accounts (
hybrid). - API field names still use
child_user_idfor the linked account id — this is not affiliatemaster_slave.
3. account_group response shape
Returned on:
POST /api/loginPOST /api/registerGET /api/user- Each row of
POST /api/login/batch→data.sessions[].account_group
type AccountGroupRole = "standalone" | "master" | "sub_account" | "hybrid";
type AccountGroupMasterSummary = {
user_id: number;
email: string;
display_name: string;
currency_id: number;
currency_code: string | null; // e.g. "EUR"
};
type LinkedAccountSummary = {
id: number; // membership row id
child_user_id: number;
email: string;
display_name: string;
currency_id: number;
currency_code: string | null;
linked_at: string | null; // ISO 8601
};
type AccountGroup = {
role: AccountGroupRole;
master: AccountGroupMasterSummary | null; // set when role === "sub_account"
linked_accounts: LinkedAccountSummary[]; // non-empty when role === "master" | "hybrid"
};Example — master login
{
"success": true,
"data": {
"user": { "id": 10, "email": "agency@example.com" },
"token": "1|…",
"account_group": {
"role": "master",
"master": null,
"linked_accounts": [
{
"id": 3,
"child_user_id": 42,
"email": "creator@example.com",
"display_name": "Jane Creator",
"currency_id": 1,
"currency_code": "EUR",
"linked_at": "2026-05-18T12:00:00+00:00"
}
]
}
}
}Example — sub-account login
{
"account_group": {
"role": "sub_account",
"master": {
"user_id": 10,
"email": "agency@example.com",
"display_name": "Agency Admin",
"currency_id": 1,
"currency_code": "EUR"
},
"linked_accounts": []
}
}Do not infer sibling accounts from local storage when role === "sub_account".
4. API endpoints (frontend contract)
Base URL: dev https://fangate.co/api · prod https://fangate.info/api
| Action | Method | Path | Notes |
|---|---|---|---|
| Login | POST | /login | Read data.account_group |
| Refresh group | GET | /user/linked-accounts | Returns { account_group } |
| Link sub-account | POST | /user/linked-accounts | Body: { "email", "password" } |
| Unlink sub-account | DELETE | /user/linked-accounts/{childUserId} | Removes your link to that user |
| Switch to sub-account | POST | /user/linked-accounts/{childUserId}/session | Returns { user, token } when you linked them |
| Aggregate KPIs | POST | /dashboard/summary/aggregate | Master: no body required; uses DB links |
| Single KPIs | GET | /dashboard/summary | Per active bearer token |
| Logout | POST | /logout | Revokes current token only |
Auth header for protected routes: Authorization: Bearer <active api token>
Link child (add account to group)
POST /api/user/linked-accounts
Authorization: Bearer <master token>
Content-Type: application/json
{ "email": "creator@example.com", "password": "their-password" }Success includes updated account_group and linked_account row.
Common 422 messages:
Maximum number of linked accounts reached.(max 10 per linking account)You cannot link your own account.Wrong email or password
Switch active account (master → child)
Prefer this over keeping child passwords in the client:
POST /api/user/linked-accounts/42/session
Authorization: Bearer <master token>Response:
{
"data": {
"user": { "id": 42, "email": "creator@example.com" },
"token": "99|plainTextSanctumToken"
}
}Store the returned token as the active session for API calls. Optionally keep the master token in storage for “switch back” without re-login.
Aggregate dashboard (agency view)
POST /api/dashboard/summary/aggregate
Authorization: Bearer <master token>
Content-Type: application/json
{}- Backend merges master + all linked children from the database.
additional_tokensis ignored for masters (do not send sibling tokens).- Child accounts get 403 if they send
additional_tokens.
Use currency_unified and accounts[].wallet_balance as documented in Wallet.
5. Recommended client architecture
5.1 State model
Extend (or replace) ManagedCreatorSession with server truth:
type ClientAccountState = {
activeUserId: number;
activeToken: string;
activeUser: FangateUser;
accountGroup: AccountGroup;
// Optional: cache master token when acting as child
masterSession?: { userId: number; token: string };
// Tokens keyed by userId — only users the backend allows
sessionsByUserId: Record<number, {
token: string;
user: FangateUser;
accountGroup: AccountGroup;
}>;
};5.2 Building the switcher list
function getSwitcherEntries(state: ClientAccountState): SwitcherEntry[] {
const { accountGroup, activeUser } = state;
if (accountGroup.role === "sub_account") {
return [{ userId: activeUser.id!, label: displayName(activeUser), isActive: true }];
}
if (accountGroup.role === "master") {
const self = { userId: activeUser.id!, label: displayName(activeUser), isMaster: true };
const children = accountGroup.linked_accounts.map((la) => ({
userId: la.child_user_id,
label: la.display_name || la.email,
isChild: true,
}));
return [self, ...children];
}
// standalone
return [{ userId: activeUser.id!, label: displayName(activeUser) }];
}Never merge in every entry from sessionsByUserId if account_group does not include them.
5.3 Add account flow (replace password-only batch as source of truth)
- User is logged in as master (or standalone).
- Show “Add account” → collect child email + password (or OAuth if added later).
POST /api/user/linked-accounts.- On success, update
accountGroupfrom response; optionally callPOST .../sessionto cache child token. - Refresh UI switcher from
linked_accounts.
POST /api/login/batch may still be used for bulk sign-in, but it does not create master/child links. You must call link to persist the group.
5.4 Switch account flow
| From | To | Action |
|---|---|---|
| Master | Child | POST /user/linked-accounts/{childUserId}/session → set active token |
| Master | Self | Use stored master token |
| Child | Self only | No switcher entries — hide switcher or show single account |
| Child | Master | Not supported via API (child has no access to group) |
5.5 Remove account flow
DELETE /api/user/linked-accounts/{childUserId}with master bearer.- Remove child from local
sessionsByUserId. - If active account was removed child, fall back to master token.
6. Migration checklist (from old switcher)
- [ ] Parse and store
account_groupon every login / register /GET /api/user. - [ ] Replace switcher list source:
account_groupfirst, not all local tokens. - [ ] “Add account” →
POST /user/linked-accounts(not only batch login). - [ ] “Switch account” (master) →
POST .../sessionor stored master token. - [ ] Remove reliance on
additional_tokensfor aggregate when user is master. - [ ] Hide other masters’ linked lists when
role === "sub_account". - [ ] Do not show
account_group.masteras a switchable sibling for sub-accounts. - [ ] Support
hybrid: show switcher for ownlinked_accountsonly. - [ ] Fix logout: webapp logout doc —
removeManagedSession(activeUserId), notclearAllSessions()on every logout. - [ ] On app start:
GET /api/userorGET /api/user/linked-accountsto refreshaccount_groupafter deploy.
7. UI / product guidelines
Master / agency UI
- Show combined dashboard via
POST /dashboard/summary/aggregatewith master token. - Per-account wallet/history: still
GET /api/walletwith that account’s token (after session switch). - “Remove from agency” →
DELETE /user/linked-accounts/{id}.
Child UI
- Single-account experience.
- Optional read-only line: “Managed by {master.display_name}” using
account_group.master. - No “add account”, no sibling list, no aggregate across accounts.
Standalone UI
- Same as today until first link; after first successful link,
rolebecomesmasterand switcher expands.
8. Security do’s and don’ts
| Do | Don’t |
|---|---|
Trust account_group from the server after login | Show every token in localStorage in the switcher |
| Use HTTPS only | Log additional_tokens or passwords |
| Refresh group after link/unlink | Let child UI call link/unlink/session endpoints |
| Store tokens securely (httpOnly cookie where possible) | Assume batch login creates master/child links |
9. Error handling
| HTTP | Scenario | UX |
|---|---|---|
| 403 | Child calls session / aggregate with extra tokens | Hide action; show single-account mode |
| 422 | Link failed (wrong password, already linked, max 10) | Show errors_message from API wrapper |
| 404 | Unlink or session for unknown child | Refresh GET /user/linked-accounts |
| 503 | Aggregate: wallet missing | Retry or per-account fallback |
Standard envelope: { success, errors_message, data }.
10. Testing on development
- Deploy backend + run migrations on
fangate.co. - Master path
- Login as user A →
role: standalone. - Link user B →
role: master,linked_accountslength 1. POST /dashboard/summary/aggregate→ 2 entries indata.accounts.POST .../B/session→ receive sub-account token; callGET /api/useras B →role: sub_account, emptylinked_accounts.
- Login as user A →
- Child path
- Login as B directly → no siblings in response.
- Aggregate with
additional_tokens→ 403.
- Unlink
DELETEas A → B disappears fromlinked_accounts.
11. Webapp file touchpoints (reference)
Likely files to update in webapp:
| Area | Files |
|---|---|
| Types / session storage | src/lib/auth.ts, src/lib/auth-cookie.ts |
| Logout | src/lib/auth-session-actions.ts |
| Switcher UI | src/components/dashboard/dashboard-shell.tsx, account switcher components |
| Settings | src/app/settings/page.tsx (or equivalent) |
| API client | Login handler, dashboard data hooks |
12. Questions for backend
Contact backend if you need:
- Auto-linking children on
POST /login/batch(not in current scope). - Child-visible “leave agency” self-unlink.
- Role flags beyond master/child/standalone.
Changelog
| Date | Change |
|---|---|
| 2026-05-18 | Initial master/sub-account implementation guide |
| 2026-05-28 | Directional multi-master linking; sub_account / hybrid roles |