1# multi-account experience 2 3**status:** design draft 4**issue:** [#583](https://github.com/zzstoatzz/plyr.fm/issues/583) 5**date:** 2026-01-03 6 7## problem 8 9users with multiple ATProto identities (personal account, artist alias, band account) cannot easily switch between them. the current flow: 10 111. click logout 122. session destroyed, cookie cleared 133. navigate to login 144. enter new handle 155. redirected to PDS - but PDS auto-approves if "remember this account" was checked 166. no way to force account selection or fresh login 17 18the PDS remembers the client and auto-signs in, making multi-account workflows frustrating. 19 20## ATProto OAuth prompt parameter 21 22the AT Protocol OAuth spec supports a `prompt` parameter with three modes: 23 24| value | behavior | 25|-------|----------| 26| `login` | forces re-authentication, ignoring remembered session | 27| `select_account` | shows account selection instead of auto-selecting | 28| `consent` | forces consent screen even if previously approved | 29 30**prerequisite:** our atproto SDK fork needs to accept `prompt` in `start_authorization()`. 31 32**status:** PR opened ([zzstoatzz/atproto#8](https://github.com/zzstoatzz/atproto/pull/8)) 33 34```python 35# new signature 36async def start_authorization( 37 self, 38 handle_or_did: str, 39 prompt: Literal["login", "select_account", "consent", "none"] | None = None 40) -> tuple[str, str] 41``` 42 43## design options 44 45### option A: session stack (recommended) 46 47store multiple sessions server-side, switch by rotating which one is "active." 48 49**how it works:** 50 511. user logs in with account A - session created, cookie set 522. user clicks "add account" - redirected with `prompt=login` 533. user logs in with account B - second session created 544. both sessions stored in database, linked by a "session group" or stored as array in encrypted cookie 555. user menu shows both accounts, click to switch active 56 57**session storage approaches:** 58 59| approach | pros | cons | 60|----------|------|------| 61| **encrypted cookie array** | no db schema change, stateless switching | cookie size limits (~4KB), complex encryption | 62| **session groups table** | clean relational model, unlimited accounts | db schema migration, additional queries | 63| **localStorage + session_id** | simple to implement | XSS-vulnerable, breaks HttpOnly security model | 64 65**recommendation:** session groups table - maintains security model, clean data relationships. 66 67**schema sketch:** 68 69```sql 70-- new table: links multiple sessions as a group 71CREATE TABLE session_groups ( 72 group_id UUID PRIMARY KEY, 73 created_at TIMESTAMP DEFAULT NOW() 74); 75 76-- modify user_sessions to reference group 77ALTER TABLE user_sessions ADD COLUMN group_id UUID REFERENCES session_groups(group_id); 78ALTER TABLE user_sessions ADD COLUMN is_active BOOLEAN DEFAULT true; 79 80-- index for fast group lookups 81CREATE INDEX idx_sessions_group ON user_sessions(group_id); 82``` 83 84**backend changes:** 85 86- `POST /auth/add-account` - starts OAuth with `prompt=login`, links to existing session group 87- `POST /auth/switch-account` - sets `is_active=false` on current, `is_active=true` on target 88- `GET /auth/me` - returns active session info + list of other accounts in group 89- cookie still holds single `session_id` - backend looks up group from it 90 91**frontend changes:** 92 93- user menu shows current account + "add account" option 94- if multiple accounts in group, show account switcher 95- clicking different account calls `/auth/switch-account` 96 97### option B: browser-managed (simpler, less ideal) 98 99don't store multiple sessions - just make switching easier. 100 101**how it works:** 102 1031. "switch account" button triggers OAuth with `prompt=select_account` 1042. current session destroyed before redirect 1053. PDS shows account picker 1064. user picks account, new session created 107 108**pros:** minimal backend changes, no schema migration 109**cons:** loses previous session entirely, can't "quick switch" back 110 111### option C: parallel windows (no code changes) 112 113educate users to use private/incognito windows for different accounts. 114 115**pros:** zero implementation effort 116**cons:** poor UX, not a real solution 117 118## UX flows 119 120### when logged in (single account) 121 122``` 123┌─────────────────────────────┐ 124│ @artist.bsky.social ▼ │ 125├─────────────────────────────┤ 126│ ⬚ portal │ 127│ ⚙ settings │ 128│ ───────────────────────── │ 129│ + add account │ ← new 130│ ───────────────────────── │ 131│ ⎋ logout │ 132└─────────────────────────────┘ 133``` 134 135### when logged in (multiple accounts) 136 137``` 138┌─────────────────────────────┐ 139│ @artist.bsky.social ▼ │ ← active account 140├─────────────────────────────┤ 141│ ⬚ portal │ 142│ ⚙ settings │ 143│ ───────────────────────── │ 144│ ○ @personal.bsky.social │ ← switch to this 145│ ○ @band.music │ ← switch to this 146│ + add account │ 147│ ───────────────────────── │ 148│ ⎋ logout │ ← logs out active only 149│ ⎋ logout all │ ← clears entire group 150└─────────────────────────────┘ 151``` 152 153### logout behavior 154 155**question:** what should "logout" do with multiple accounts? 156 157| option | behavior | 158|--------|----------| 159| **logout active only** | removes current session, auto-switches to next account in group | 160| **logout all** | destroys entire session group, back to login page | 161 162**recommendation:** default to logout active, provide "logout all" as separate option. 163 164### edge cases 165 1661. **session expires for one account** - remove from group, notify if it was active 1672. **scope upgrade needed** - only affects the active session, not others in group 1683. **cross-tab sync** - BroadcastChannel already exists; extend to broadcast account switches 1694. **queue state** - queue is global, not per-account (music keeps playing during switch) 1705. **mobile (ProfileMenu)** - same UX, adapted for touch 171 172## implementation phases 173 174### phase 1: prompt parameter support 175 1761. fork update: add `prompt` param to `start_authorization()` 1772. backend: pass prompt to SDK in `/auth/start` 1783. frontend: "sign in with different account" uses `prompt=login` 179 180**outcome:** users can force re-auth, but still single-session. 181 182### phase 2: session groups 183 1841. database migration for session groups 1852. `/auth/add-account` endpoint 1863. `/auth/switch-account` endpoint 1874. modify `/auth/me` to return account list 1885. frontend account switcher UI 189 190**outcome:** full multi-account experience. 191 192### phase 3: polish 193 1941. account avatars in switcher 1952. keyboard shortcut for quick-switch (Cmd+Shift+A?) 1963. "switch to" option in artist page when viewing own other account 1974. notification badge per account (future) 198 199## security considerations 200 201- **no localStorage for session IDs** - maintains HttpOnly security model 202- **session group isolation** - groups are per-browser, not per-user (different devices = different groups) 203- **cookie still single value** - one active session_id, backend resolves group membership 204- **logout clears cookie regardless** - even with session groups, logout destroys the cookie 205 206## open questions 207 2081. **should we limit accounts per group?** (suggest: 5 max) 2092. **what about developer tokens?** - probably exclude from session groups, they're standalone 2103. **how to handle account picker on login page?** - show known accounts if cookie exists but session expired? 2114. **mobile app (future)** - will need equivalent session group storage in secure keychain 212 213## bluesky implementation study 214 215studied bluesky's open-source client ([social-app](https://github.com/bluesky-social/social-app)) to inform our design. 216 217### their architecture 218 219**persistence layer** (`src/state/persisted/`): 220- uses AsyncStorage with single key `'BSKY_STORAGE'` 221- `Schema` type defines all persisted state 222- `currentAccount` stores only DID (lightweight reference) 223- full account data lives in `accounts[]` array 224 225**session state shape:** 226```typescript 227interface SessionState { 228 accounts: SessionAccount[] // all accounts, even expired ones 229 currentAccount: SessionAccount // active account reference (DID only) 230 hasSession: boolean 231} 232 233interface SessionAccount { 234 did: string 235 handle: string 236 service: string // PDS URL 237 accessJwt?: string // may be empty/expired 238 refreshJwt?: string // may be empty/expired 239 email?: string 240 emailConfirmed?: boolean 241 emailAuthFactor?: boolean 242 pdsUrl?: string 243 active?: boolean 244 status?: 'takendown' | 'suspended' | 'deactivated' 245} 246``` 247 248**reducer actions** (`src/state/session/reducer.ts`): 249```typescript 250type Action = 251 | { type: 'switched-to-account'; agent: BskyAgent; did: string } 252 | { type: 'removed-account'; did: string } 253 | { type: 'logged-out-current-account' } 254 | { type: 'logged-out-every-account' } 255 | { type: 'synced-accounts'; accounts: SessionAccount[]; currentAccount?: SessionAccount } 256 | { type: 'received-agent-event'; event: SessionEvent } 257 | { type: 'partial-refresh-session'; patch: Partial<SessionAccount> } 258``` 259 260**key files:** 261- `src/state/persisted/schema.ts` - persistence schema with account fields 262- `src/state/session/reducer.ts` - state transitions 263- `src/state/session/agent.ts` - agent creation and token refresh 264- `src/components/dialogs/SwitchAccount.tsx` - switcher UI 265- `src/lib/hooks/useAccountSwitcher.ts` - switching logic 266- `src/components/AccountList.tsx` - account list rendering 267 268### their UX patterns 269 2701. **account list items:** 271 - 48x48 avatar 272 - display name + @handle 273 - green checkmark (not chevron) on current account 274 - "logged out" italic label for expired sessions 275 2762. **switching flow:** 277 - if tokens valid: `resumeSession()` silently 278 - if tokens expired: show login form for that specific account 279 - race condition protection via `pendingDid` state 280 2813. **logout distinction:** 282 - `logoutCurrentAccount`: clears tokens, account stays in list 283 - `logoutEveryAccount`: clears everything, back to login 284 2854. **cross-tab sync:** 286 - `synced-accounts` action handles changes from other tabs 287 - `needsPersist` flag prevents sync cycles 288 289### what we can adopt 290 291| pattern | bluesky | plyr.fm adaptation | 292|---------|---------|-------------------| 293| account list with avatars | 48x48 + name + handle | same, with our design tokens | 294| checkmark on active | green circle-check | use `var(--success)` | 295| expired session label | "logged out" italic | same | 296| logout vs logout-all | two distinct actions | same approach | 297| token-based resume | client-side jwt check | server-side via session group | 298| cross-tab sync | BroadcastChannel | extend existing player sync | 299 300### key difference 301 302bluesky stores tokens client-side (react native app). we store sessions server-side (HttpOnly cookies). our session group approach achieves the same UX with better web security. 303 304### token refresh strategy 305 306bluesky's `createAgentAndResume()` flow: 3071. check if `refreshJwt` exists and is not expired 3082. if valid: restore session, attempt background refresh 3093. if expired: show login form for that account (no silent refresh possible) 310 311for plyr.fm, our server-side sessions handle refresh differently: 312- refresh happens server-side via atproto SDK 313- client never sees tokens 314- "expired" means session row deleted or OAuth refresh failed 315 316### account list UI details 317 318from `AccountList.tsx`: 319- uses `useProfilesQuery` to batch-fetch profile data for all accounts 320- `isJwtExpired()` helper determines "logged out" state 321- `pendingDid` disables interaction during switch (`pointerEvents: 'none'`) 322- profiles fetched by DID array, matched back to accounts 323 324## references 325 326- [ATProto OAuth spec](https://github.com/bluesky-social/atproto/blob/main/packages/oauth/oauth-provider/src/oauth-provider.ts) - prompt parameter handling 327- [bluesky social-app](https://github.com/bluesky-social/social-app) - multi-account reference implementation 328 - [persisted/schema.ts](https://github.com/bluesky-social/social-app/blob/main/src/state/persisted/schema.ts) - account schema 329 - [session/reducer.ts](https://github.com/bluesky-social/social-app/blob/main/src/state/session/reducer.ts) - state machine 330 - [AccountList.tsx](https://github.com/bluesky-social/social-app/blob/main/src/components/AccountList.tsx) - UI component 331- [issue #583](https://github.com/zzstoatzz/plyr.fm/issues/583) - original feature request 332- [PRs #578, #582](https://github.com/zzstoatzz/plyr.fm/pull/578) - confidential OAuth client context 333- [atproto SDK fork](https://github.com/zzstoatzz/atproto) - prompt parameter support (merged)