Request #
Let a user be signed in to several accounts at once and switch between them without signing out and back in.
Clicking the avatar in the header opens a menu with:
- Each signed-in account (avatar and
@handle, with a check on the active one). Clicking one switches to it. - Add account: starts the normal OAuth sign-in and adds the new account to the list.
- View profile: the avatar links to
/u/<handle>today, so keep that as a menu item. - Sign out @handle: signs out only the active account and switches to the next one. With none left, it goes to
/.
Remove the Sign out button from /settings/account. "Revoke sessions" stays there.
Switching accounts redirects to the new account's profile (/u/<handle>). This is simpler than working out whether the current page still makes sense for the new account.
Context #
From https://www.youtube.com/watch?v=5MnT-ZgbBIc, where the user built an automation that adds conference attendees to a Bluesky list:
Sign-out only exists on /settings/account. The user had to hunt for it to switch from their personal account to the AT Science account (about 1:28). People who run automations for an org account plus their own will switch often.
Details: docs/brainstorms/2026-10-17-graze-conference-automation-feedback.md (F7).
Most of what's needed is already in place:
- OAuth sessions are stored server-side per DID, and web sign-out (
app/routes/auth/signout.ts) only clears the cookie. Automations keep firing for an account that isn't the active one. - The
airglow_sessioncookie holds one signed DID (lib/auth/middleware.ts, set inapp/routes/auth/callback.tsx).
Suggested approach (open to change) #
- Cookie: keep
airglow_sessionas the active DID. Add a signedairglow_accountscookie with the list of signed-in DIDs, capped at around 5.getSessionUseronly accepts the active DID if it's in that list. - Callback: add the new DID to the list and make it active, instead of replacing the cookie.
- Add account:
/auth/logincurrently sends signed-in users away. Add a parameter like?add=1that skips that redirect and doesn't prefill the current handle. - Switch:
POST /auth/switchwith adidthat must be in the list. Redirect to the new account's profile. - Sign out:
POST /auth/signoutremoves only the active DID from the list and activates the next one. Keep thesign_outaudit event, and add anaccount_switchedevent. - Menu: the header is server-rendered, so a native
<details>/<summary>orpopovermenu works without a new island. It must be keyboard-accessible and close on Escape or an outside click. - Lost sessions: an account in the list whose OAuth session is gone (
isOAuthSessionMissing) shows "Sign in again" instead of switching straight to it.
Edge cases #
- Stale tabs: the cookie is shared across tabs. A form rendered for account A in one tab, submitted after switching to B in another, would run as B. This is the same "which account am I writing to" risk as #12. Mutating requests should send the DID they were rendered for, and the server should reject a mismatch with a clear "you switched accounts, reload" message.
- Account cap: when the list is full, "Add account" says so instead of silently dropping an account.
Acceptance #
- Sign in as A, add B, then switch back and forth from the header without re-authenticating.
- Signing out of B leaves A signed in and active. Signing out of the last account leads to
/. /settings/accounthas no Sign out button.- Automations for the inactive account keep firing.
- Tests cover the account list (add, switch, remove, an unlisted DID is rejected),
/auth/login?add=1, and the stale-tab mismatch.
Queued: the loop will work on this version of the issue.
loop:queued cid=bafyreifsmm7pyhq3yczm6urclxq34jugezsgpdm5oreiubxwlvuwqobbke