feat: multi-account experience (#707)

* docs: add multi-account experience design spec

research document exploring multi-account UX for users with multiple
ATProto identities. covers OAuth prompt parameter support, session
groups architecture, and phased implementation plan.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add bluesky implementation study to multi-account spec

studied bluesky's open-source social-app to inform our design:
- session state patterns (accounts array, currentAccount reference)
- UX patterns (avatars, checkmarks, "logged out" labels)
- logout distinction (active only vs all)
- cross-tab sync approach

also updated prerequisite section with link to SDK fork PR #8.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: update atproto SDK with prompt parameter support

updates to d4830f4 which adds PromptType and prompt parameter
to start_authorization() for multi-account flows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: expand bluesky implementation study with detailed patterns

- added persistence layer details (AsyncStorage, schema structure)
- documented full SessionAccount interface with all fields
- listed reducer action types for state transitions
- added token refresh strategy comparison
- documented AccountList UI implementation details
- expanded references with direct links to key source files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: implement multi-account experience

backend:
- add session groups (group_id, is_active, avatar_url columns)
- add pending_add_accounts table for OAuth flow tracking
- add /auth/add-account/start endpoint with prompt=login
- add /auth/switch-account endpoint to switch active session
- add /auth/logout-all endpoint to clear all linked accounts
- update /auth/me to return linked_accounts list
- update callback to handle add-account flow

frontend:
- add LinkedAccount type and update User type
- update UserMenu with account switcher dropdown
- update ProfileMenu with accounts sub-menu for mobile
- show avatars, handles, and switch/add/logout options

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add-account flow now prompts for new handle

the add-account endpoint was incorrectly using the current user's handle,
which locked the OAuth flow to the same account. now:

- backend: /auth/add-account/start requires a handle in the request body
- backend: validates that handle differs from current account
- frontend: shows inline handle input before starting OAuth flow
- frontend: same UX in UserMenu (desktop) and ProfileMenu (mobile)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: stop propagation on add account click

the click outside handler was closing the menu before the input could
appear because the button was being removed from DOM, causing
menuRef.contains(event.target) to return false.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: deactivate other sessions when adding new account

when a new account is added to a session group, only the new session
should be marked as active. the frontend filters for !is_active to show
switchable accounts, so both being active meant neither showed up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: filter other accounts by DID, not is_active flag

the cookie determines which session is active - we don't need a separate
is_active flag. just filter out the current user's DID to show other
accounts in the switch list.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove unnecessary is_active tracking

the cookie determines which session is active - tracking is_active
separately was redundant complexity.

- removed deactivate_other_sessions_in_group function
- simplified switch_active_account to just validate and return session_id
- switch-account endpoint now checks DID instead of is_active flag

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove is_active from API response and types

is_active was never needed - the cookie determines the active session,
and we filter by DID in the frontend. removed from:
- LinkedAccountResponse model
- LinkedAccount TypeScript interface
- handleSwitchAccount checks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: accounts submenu with avatars and logout-all fix

- backend: look up artist avatars in /auth/me for fresh data
- UserMenu: consolidate accounts into collapsible submenu
- ProfileMenu: show current user avatar
- fix: use window.location.href for logout-all to clear state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: improve multi-account UX

- use invalidateAll() instead of page reload when switching accounts
(prevents "log in" flash during account switch)
- add logout prompt for multi-account users to stay logged in as
another account instead of fully logging out
- add switch_to param to /logout for atomic logout + switch
- fix add-account validation to check ALL accounts in session group
- fix dropdown width (220px) to prevent horizontal expansion
- use HandleAutocomplete in add-account forms
- fix a11y warning in portal page (label → span)
- remove unused .desktop-nav CSS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: prevent logout button click from closing menu before prompt shows

when clicking logout with multiple accounts, the DOM update that shows
the logout prompt was removing the logout button from the DOM before
the click-outside handler checked containment, causing the menu to close.

added event.stopPropagation() to handleLogoutClick in both UserMenu and
ProfileMenu to prevent the click from bubbling to the document listener.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: convert logout prompt to centered modal

- Create global LogoutState class using Svelte 5 runes
- Add LogoutModal component rendered at root layout level
- Update UserMenu and ProfileMenu to use global logout state
- Modal escapes header's backdrop-filter containing block
- Fix click-outside race condition with stopPropagation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add multi-account session management tests

covers session groups, account switching, removal, and pending add-account flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove is_active and avatar_url from session model

these columns were adding complexity without value:
- is_active: cookie is the source of truth for active session
- avatar_url: /auth/me already fetches fresh from Artist table

simplifies remove_account_from_group to just return first remaining session.
removes unused update_session_avatar function.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* migrate: drop is_active and avatar_url from user_sessions

these columns were added to dev database in earlier development
but the model was simplified before merge. this migration aligns
the database schema with the final model.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: make column drop migration idempotent

the columns were only added to dev database during development.
staging and prod never had them. this migration now checks if
columns exist before attempting to drop them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub bfcac3f1 35ba4e17

+34
backend/alembic/versions/2026_01_03_183351_3e972db238c6_add_session_groups_for_multi_account.py
··· 1 + """add session groups for multi-account 2 + 3 + Revision ID: 3e972db238c6 4 + Revises: 15472c0b3bb4 5 + Create Date: 2026-01-03 18:33:51.017398 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "3e972db238c6" 17 + down_revision: str | Sequence[str] | None = "15472c0b3bb4" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Add session groups for multi-account support.""" 24 + op.add_column( 25 + "user_sessions", 26 + sa.Column("group_id", sa.String(64), nullable=True), 27 + ) 28 + op.create_index("ix_user_sessions_group_id", "user_sessions", ["group_id"]) 29 + 30 + 31 + def downgrade() -> None: 32 + """Remove session groups.""" 33 + op.drop_index("ix_user_sessions_group_id", table_name="user_sessions") 34 + op.drop_column("user_sessions", "group_id")
+56
backend/alembic/versions/2026_01_03_184157_5d2522e9f7e9_add_pending_add_accounts_table.py
··· 1 + """add pending add accounts table 2 + 3 + Revision ID: 5d2522e9f7e9 4 + Revises: 3e972db238c6 5 + Create Date: 2026-01-03 18:41:57.612374 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "5d2522e9f7e9" 17 + down_revision: str | Sequence[str] | None = "3e972db238c6" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Add pending_add_accounts table for multi-account OAuth flow tracking.""" 24 + op.create_table( 25 + "pending_add_accounts", 26 + sa.Column("state", sa.String(length=64), nullable=False), 27 + sa.Column("group_id", sa.String(length=64), nullable=False), 28 + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), 29 + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), 30 + sa.PrimaryKeyConstraint("state"), 31 + ) 32 + op.create_index( 33 + "ix_pending_add_accounts_state", "pending_add_accounts", ["state"], unique=False 34 + ) 35 + op.create_index( 36 + "ix_pending_add_accounts_group_id", 37 + "pending_add_accounts", 38 + ["group_id"], 39 + unique=False, 40 + ) 41 + op.create_index( 42 + "ix_pending_add_accounts_created_at", 43 + "pending_add_accounts", 44 + ["created_at"], 45 + unique=False, 46 + ) 47 + 48 + 49 + def downgrade() -> None: 50 + """Remove pending_add_accounts table.""" 51 + op.drop_index( 52 + "ix_pending_add_accounts_created_at", table_name="pending_add_accounts" 53 + ) 54 + op.drop_index("ix_pending_add_accounts_group_id", table_name="pending_add_accounts") 55 + op.drop_index("ix_pending_add_accounts_state", table_name="pending_add_accounts") 56 + op.drop_table("pending_add_accounts")
+63
backend/alembic/versions/2026_01_05_010511_732d7de222b0_drop_is_active_and_avatar_url_from_user_.py
··· 1 + """drop is_active and avatar_url from user_sessions 2 + 3 + Revision ID: 732d7de222b0 4 + Revises: 5d2522e9f7e9 5 + Create Date: 2026-01-05 01:05:11.606841 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "732d7de222b0" 17 + down_revision: str | Sequence[str] | None = "5d2522e9f7e9" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def _column_exists(table: str, column: str) -> bool: 23 + """check if column exists in table.""" 24 + conn = op.get_bind() 25 + result = conn.execute( 26 + sa.text( 27 + "SELECT 1 FROM information_schema.columns " 28 + "WHERE table_name = :table AND column_name = :column" 29 + ), 30 + {"table": table, "column": column}, 31 + ) 32 + return result.fetchone() is not None 33 + 34 + 35 + def upgrade() -> None: 36 + """Remove unused session columns if they exist. 37 + 38 + These columns were added during development but removed before merge. 39 + On fresh databases they won't exist, on dev they need to be dropped. 40 + """ 41 + if _column_exists("user_sessions", "is_active"): 42 + op.drop_column("user_sessions", "is_active") 43 + if _column_exists("user_sessions", "avatar_url"): 44 + op.drop_column("user_sessions", "avatar_url") 45 + 46 + 47 + def downgrade() -> None: 48 + """Restore session columns.""" 49 + if not _column_exists("user_sessions", "avatar_url"): 50 + op.add_column( 51 + "user_sessions", 52 + sa.Column("avatar_url", sa.VARCHAR(length=500), nullable=True), 53 + ) 54 + if not _column_exists("user_sessions", "is_active"): 55 + op.add_column( 56 + "user_sessions", 57 + sa.Column( 58 + "is_active", 59 + sa.BOOLEAN(), 60 + server_default=sa.text("true"), 61 + nullable=False, 62 + ), 63 + )
+18
backend/src/backend/_internal/__init__.py
··· 2 2 3 3 from backend._internal.auth import ( 4 4 DeveloperToken, 5 + LinkedAccount, 6 + PendingAddAccountData, 5 7 PendingDevTokenData, 6 8 PendingScopeUpgradeData, 7 9 Session, ··· 9 11 consume_exchange_token, 10 12 create_exchange_token, 11 13 create_session, 14 + delete_pending_add_account, 12 15 delete_pending_dev_token, 13 16 delete_pending_scope_upgrade, 14 17 delete_session, 15 18 get_oauth_client, 16 19 get_optional_session, 20 + get_or_create_group_id, 21 + get_pending_add_account, 17 22 get_pending_dev_token, 18 23 get_pending_scope_upgrade, 19 24 get_session, 25 + get_session_group, 20 26 handle_oauth_callback, 21 27 list_developer_tokens, 28 + remove_account_from_group, 22 29 require_artist_profile, 23 30 require_auth, 24 31 revoke_developer_token, 32 + save_pending_add_account, 25 33 save_pending_dev_token, 26 34 save_pending_scope_upgrade, 27 35 start_oauth_flow, 28 36 start_oauth_flow_with_scopes, 37 + switch_active_account, 29 38 update_session_tokens, 30 39 ) 31 40 from backend._internal.constellation import get_like_count_safe ··· 36 45 37 46 __all__ = [ 38 47 "DeveloperToken", 48 + "LinkedAccount", 49 + "PendingAddAccountData", 39 50 "PendingDevTokenData", 40 51 "PendingScopeUpgradeData", 41 52 "Session", ··· 43 54 "consume_exchange_token", 44 55 "create_exchange_token", 45 56 "create_session", 57 + "delete_pending_add_account", 46 58 "delete_pending_dev_token", 47 59 "delete_pending_scope_upgrade", 48 60 "delete_session", 49 61 "get_like_count_safe", 50 62 "get_oauth_client", 51 63 "get_optional_session", 64 + "get_or_create_group_id", 65 + "get_pending_add_account", 52 66 "get_pending_dev_token", 53 67 "get_pending_scope_upgrade", 54 68 "get_session", 69 + "get_session_group", 55 70 "get_supported_artists", 56 71 "handle_oauth_callback", 57 72 "list_developer_tokens", 58 73 "notification_service", 59 74 "now_playing_service", 60 75 "queue_service", 76 + "remove_account_from_group", 61 77 "require_artist_profile", 62 78 "require_auth", 63 79 "revoke_developer_token", 80 + "save_pending_add_account", 64 81 "save_pending_dev_token", 65 82 "save_pending_scope_upgrade", 66 83 "start_oauth_flow", 67 84 "start_oauth_flow_with_scopes", 85 + "switch_active_account", 68 86 "update_session_tokens", 69 87 "validate_supporter", 70 88 ]
+226 -7
backend/src/backend/_internal/auth.py
··· 7 7 from datetime import UTC, datetime, timedelta 8 8 from typing import Annotated, Any 9 9 10 - from atproto_oauth import OAuthClient 10 + from atproto_oauth import OAuthClient, PromptType 11 11 from atproto_oauth.stores.memory import MemorySessionStore 12 12 from cryptography.fernet import Fernet 13 13 from cryptography.hazmat.primitives.asymmetric import ec ··· 240 240 expires_in_days: int = 14, 241 241 is_developer_token: bool = False, 242 242 token_name: str | None = None, 243 + group_id: str | None = None, 243 244 ) -> str: 244 245 """create a new session for authenticated user with encrypted OAuth data. 245 246 ··· 250 251 expires_in_days: session expiration in days (default 14, use 0 for no expiration) 251 252 is_developer_token: whether this is a developer token (for listing/revocation) 252 253 token_name: optional name for the token (only for developer tokens) 254 + group_id: optional session group ID for multi-account support 253 255 """ 254 256 session_id = secrets.token_urlsafe(32) 255 257 256 - # encrypt sensitive OAuth session data before storing 257 258 encrypted_data = _encrypt_data(json.dumps(oauth_session)) 258 259 259 - # store in database with expiration 260 260 expires_at = ( 261 261 datetime.now(UTC) + timedelta(days=expires_in_days) 262 262 if expires_in_days > 0 ··· 272 272 expires_at=expires_at, 273 273 is_developer_token=is_developer_token, 274 274 token_name=token_name, 275 + group_id=group_id, 275 276 ) 276 277 db.add(user_session) 277 278 await db.commit() ··· 348 349 return pref is True 349 350 350 351 351 - async def start_oauth_flow(handle: str) -> tuple[str, str]: 352 + async def start_oauth_flow( 353 + handle: str, prompt: PromptType | None = None 354 + ) -> tuple[str, str]: 352 355 """start OAuth flow and return (auth_url, state). 353 356 354 357 uses extended scope if user has enabled teal.fm scrobbling. 358 + 359 + args: 360 + handle: user's ATProto handle 361 + prompt: optional OAuth prompt parameter (login, select_account, consent, none) 355 362 """ 356 363 from backend._internal.atproto.handles import resolve_handle 357 364 ··· 369 376 client = get_oauth_client(include_teal=False) 370 377 logger.info(f"starting OAuth for {handle} (resolution failed, using base)") 371 378 372 - auth_url, state = await client.start_authorization(handle) 379 + auth_url, state = await client.start_authorization(handle, prompt=prompt) 373 380 return auth_url, state 374 381 except Exception as e: 375 382 raise HTTPException( ··· 379 386 380 387 381 388 async def start_oauth_flow_with_scopes( 382 - handle: str, include_teal: bool 389 + handle: str, include_teal: bool, prompt: PromptType | None = None 383 390 ) -> tuple[str, str]: 384 391 """start OAuth flow with explicit scope selection. 385 392 386 393 unlike start_oauth_flow which checks user preferences, this explicitly 387 394 requests the specified scopes. used for scope upgrade flows. 395 + 396 + args: 397 + handle: user's ATProto handle 398 + include_teal: whether to include teal.fm scopes 399 + prompt: optional OAuth prompt parameter (login, select_account, consent, none) 388 400 """ 389 401 try: 390 402 client = get_oauth_client(include_teal=include_teal) 391 403 logger.info(f"starting scope upgrade OAuth for {handle} (teal={include_teal})") 392 - auth_url, state = await client.start_authorization(handle) 404 + auth_url, state = await client.start_authorization(handle, prompt=prompt) 393 405 return auth_url, state 394 406 except Exception as e: 395 407 raise HTTPException( ··· 817 829 if pending := result.scalar_one_or_none(): 818 830 await db.delete(pending) 819 831 await db.commit() 832 + 833 + 834 + # multi-account session group helpers 835 + 836 + 837 + @dataclass 838 + class LinkedAccount: 839 + """account info for account switcher UI.""" 840 + 841 + did: str 842 + handle: str 843 + session_id: str 844 + 845 + 846 + async def get_session_group(session_id: str) -> list[LinkedAccount]: 847 + """get all accounts in the same session group. 848 + 849 + returns empty list if session has no group_id (single account). 850 + """ 851 + async with db_session() as db: 852 + result = await db.execute( 853 + select(UserSession.group_id).where(UserSession.session_id == session_id) 854 + ) 855 + group_id = result.scalar_one_or_none() 856 + 857 + if not group_id: 858 + return [] 859 + 860 + result = await db.execute( 861 + select(UserSession).where( 862 + UserSession.group_id == group_id, 863 + UserSession.is_developer_token == False, # noqa: E712 864 + ) 865 + ) 866 + sessions = result.scalars().all() 867 + 868 + accounts = [] 869 + for session in sessions: 870 + if session.expires_at and datetime.now(UTC) > session.expires_at: 871 + continue 872 + 873 + accounts.append( 874 + LinkedAccount( 875 + did=session.did, 876 + handle=session.handle, 877 + session_id=session.session_id, 878 + ) 879 + ) 880 + 881 + return accounts 882 + 883 + 884 + async def get_or_create_group_id(session_id: str) -> str: 885 + """get existing group_id or create one for this session. 886 + 887 + used when adding a second account to create a group. 888 + """ 889 + async with db_session() as db: 890 + result = await db.execute( 891 + select(UserSession).where(UserSession.session_id == session_id) 892 + ) 893 + session = result.scalar_one_or_none() 894 + 895 + if not session: 896 + raise HTTPException(status_code=404, detail="session not found") 897 + 898 + if session.group_id: 899 + return session.group_id 900 + 901 + # create new group_id for this session 902 + group_id = secrets.token_urlsafe(32) 903 + session.group_id = group_id 904 + await db.commit() 905 + 906 + return group_id 907 + 908 + 909 + async def switch_active_account(current_session_id: str, target_session_id: str) -> str: 910 + """switch to a different account within a session group. 911 + 912 + validates that the target session exists, is in the same group, and isn't expired. 913 + returns the target session_id (caller updates the cookie). 914 + """ 915 + async with db_session() as db: 916 + # get current session to find group_id 917 + result = await db.execute( 918 + select(UserSession).where(UserSession.session_id == current_session_id) 919 + ) 920 + current_session = result.scalar_one_or_none() 921 + 922 + if not current_session or not current_session.group_id: 923 + raise HTTPException(status_code=400, detail="no session group found") 924 + 925 + # verify target session is in the same group 926 + result = await db.execute( 927 + select(UserSession).where(UserSession.session_id == target_session_id) 928 + ) 929 + target_session = result.scalar_one_or_none() 930 + 931 + if not target_session: 932 + raise HTTPException(status_code=404, detail="target session not found") 933 + 934 + if target_session.group_id != current_session.group_id: 935 + raise HTTPException( 936 + status_code=403, detail="target session not in same group" 937 + ) 938 + 939 + # check if target session is expired 940 + if target_session.expires_at and datetime.now(UTC) > target_session.expires_at: 941 + raise HTTPException(status_code=401, detail="target session expired") 942 + 943 + return target_session_id 944 + 945 + 946 + async def remove_account_from_group(session_id: str) -> str | None: 947 + """remove a session from its group and delete it. 948 + 949 + returns session_id of another account in the group, or None if last account. 950 + """ 951 + async with db_session() as db: 952 + result = await db.execute( 953 + select(UserSession).where(UserSession.session_id == session_id) 954 + ) 955 + session = result.scalar_one_or_none() 956 + 957 + if not session: 958 + return None 959 + 960 + group_id = session.group_id 961 + 962 + await db.delete(session) 963 + await db.commit() 964 + 965 + if not group_id: 966 + return None 967 + 968 + result = await db.execute( 969 + select(UserSession).where( 970 + UserSession.group_id == group_id, 971 + UserSession.is_developer_token == False, # noqa: E712 972 + ) 973 + ) 974 + remaining = result.scalars().first() 975 + 976 + return remaining.session_id if remaining else None 977 + 978 + 979 + # pending add account flow helpers 980 + 981 + 982 + @dataclass 983 + class PendingAddAccountData: 984 + """metadata for a pending add-account OAuth flow.""" 985 + 986 + state: str 987 + group_id: str 988 + 989 + 990 + async def save_pending_add_account(state: str, group_id: str) -> None: 991 + """save pending add-account metadata keyed by OAuth state.""" 992 + from backend.models import PendingAddAccount 993 + 994 + async with db_session() as db: 995 + pending = PendingAddAccount( 996 + state=state, 997 + group_id=group_id, 998 + ) 999 + db.add(pending) 1000 + await db.commit() 1001 + 1002 + 1003 + async def get_pending_add_account(state: str) -> PendingAddAccountData | None: 1004 + """get pending add-account metadata by OAuth state.""" 1005 + from backend.models import PendingAddAccount 1006 + 1007 + async with db_session() as db: 1008 + result = await db.execute( 1009 + select(PendingAddAccount).where(PendingAddAccount.state == state) 1010 + ) 1011 + pending = result.scalar_one_or_none() 1012 + 1013 + if not pending: 1014 + return None 1015 + 1016 + # check if expired 1017 + if datetime.now(UTC) > pending.expires_at: 1018 + await db.delete(pending) 1019 + await db.commit() 1020 + return None 1021 + 1022 + return PendingAddAccountData( 1023 + state=pending.state, 1024 + group_id=pending.group_id, 1025 + ) 1026 + 1027 + 1028 + async def delete_pending_add_account(state: str) -> None: 1029 + """delete pending add-account metadata after use.""" 1030 + from backend.models import PendingAddAccount 1031 + 1032 + async with db_session() as db: 1033 + result = await db.execute( 1034 + select(PendingAddAccount).where(PendingAddAccount.state == state) 1035 + ) 1036 + if pending := result.scalar_one_or_none(): 1037 + await db.delete(pending) 1038 + await db.commit()
+270 -4
backend/src/backend/api/auth.py
··· 6 6 from fastapi import APIRouter, Depends, HTTPException, Query, Request 7 7 from fastapi.responses import JSONResponse, RedirectResponse 8 8 from pydantic import BaseModel, field_validator 9 + from sqlalchemy import select 9 10 from starlette.responses import Response 10 11 11 12 from backend._internal import ( ··· 14 15 consume_exchange_token, 15 16 create_exchange_token, 16 17 create_session, 18 + delete_pending_add_account, 17 19 delete_pending_dev_token, 18 20 delete_pending_scope_upgrade, 19 21 delete_session, 22 + get_or_create_group_id, 23 + get_pending_add_account, 20 24 get_pending_dev_token, 21 25 get_pending_scope_upgrade, 26 + get_session_group, 22 27 handle_oauth_callback, 23 28 list_developer_tokens, 24 29 require_auth, 25 30 revoke_developer_token, 31 + save_pending_add_account, 26 32 save_pending_dev_token, 27 33 save_pending_scope_upgrade, 28 34 start_oauth_flow, 29 35 start_oauth_flow_with_scopes, 36 + switch_active_account, 30 37 ) 31 38 from backend._internal.background_tasks import schedule_atproto_sync 32 39 from backend.config import settings 40 + from backend.models import Artist, get_db 33 41 from backend.utilities.rate_limit import limiter 34 42 35 43 logger = logging.getLogger(__name__) ··· 37 45 router = APIRouter(prefix="/auth", tags=["auth"]) 38 46 39 47 48 + class LinkedAccountResponse(BaseModel): 49 + """account info for account switcher UI.""" 50 + 51 + did: str 52 + handle: str 53 + avatar_url: str | None 54 + 55 + 40 56 class CurrentUserResponse(BaseModel): 41 57 """response model for current user endpoint.""" 42 58 43 59 did: str 44 60 handle: str 61 + linked_accounts: list[LinkedAccountResponse] = [] 45 62 46 63 47 64 class DeveloperTokenInfo(BaseModel): ··· 84 101 returns exchange token in URL which frontend will exchange for session_id. 85 102 exchange token is short-lived (60s) and one-time use for security. 86 103 87 - handles three flow types based on pending state: 104 + handles four flow types based on pending state: 88 105 1. developer token flow - creates dev token session, redirects with dev_token=true 89 106 2. scope upgrade flow - replaces old session with new one, redirects to settings 90 - 3. regular login flow - creates session, redirects to portal or profile setup 107 + 3. add account flow - creates session in existing group, redirects to portal 108 + 4. regular login flow - creates session, redirects to portal or profile setup 91 109 """ 92 110 did, handle, oauth_session = await handle_oauth_callback(code, state, iss) 93 111 ··· 154 172 status_code=303, 155 173 ) 156 174 175 + # check if this is an add-account OAuth flow 176 + pending_add_account = await get_pending_add_account(state) 177 + 178 + if pending_add_account: 179 + # create session linked to the existing group 180 + session_id = await create_session( 181 + did=did, 182 + handle=handle, 183 + oauth_session=oauth_session, 184 + group_id=pending_add_account.group_id, 185 + ) 186 + 187 + # clean up pending record 188 + await delete_pending_add_account(state) 189 + 190 + # create exchange token 191 + exchange_token = await create_exchange_token(session_id) 192 + 193 + # schedule ATProto sync 194 + await schedule_atproto_sync(session_id, did) 195 + 196 + return RedirectResponse( 197 + url=f"{settings.frontend.url}/portal?exchange_token={exchange_token}&account_added=true", 198 + status_code=303, 199 + ) 200 + 157 201 # regular login flow 158 202 session_id = await create_session(did, handle, oauth_session) 159 203 ··· 242 286 @router.post("/logout") 243 287 async def logout( 244 288 session: Session = Depends(require_auth), 289 + switch_to: Annotated[ 290 + str | None, Query(description="DID to switch to after logout") 291 + ] = None, 245 292 ) -> JSONResponse: 246 - """logout current user.""" 293 + """logout current user. 294 + 295 + if switch_to is provided and valid, deletes current session and switches 296 + to the specified account. otherwise, fully logs out. 297 + """ 298 + if switch_to: 299 + # validate target is in same group 300 + linked = await get_session_group(session.session_id) 301 + target = next((a for a in linked if a.did == switch_to), None) 302 + 303 + if not target: 304 + raise HTTPException( 305 + status_code=400, detail="target account not in session group" 306 + ) 307 + 308 + if target.did == session.did: 309 + raise HTTPException( 310 + status_code=400, detail="cannot switch to current account" 311 + ) 312 + 313 + # delete current session 314 + await delete_session(session.session_id) 315 + 316 + # set cookie to target session 317 + response = JSONResponse( 318 + content={"switched_to": {"did": target.did, "handle": target.handle}} 319 + ) 320 + 321 + if settings.frontend.url: 322 + is_localhost = settings.frontend.url.startswith("http://localhost") 323 + response.set_cookie( 324 + key="session_id", 325 + value=target.session_id, 326 + httponly=True, 327 + secure=not is_localhost, 328 + samesite="lax", 329 + max_age=14 * 24 * 60 * 60, 330 + ) 331 + 332 + return response 333 + 334 + # no switch_to - full logout 247 335 await delete_session(session.session_id) 248 336 response = JSONResponse(content={"message": "logged out successfully"}) 249 337 ··· 263 351 @router.get("/me") 264 352 async def get_current_user( 265 353 session: Session = Depends(require_auth), 354 + db=Depends(get_db), 266 355 ) -> CurrentUserResponse: 267 - """get current authenticated user.""" 356 + """get current authenticated user with linked accounts.""" 357 + # get all accounts in the session group 358 + linked = await get_session_group(session.session_id) 359 + 360 + # look up artist profiles to get fresh avatars 361 + dids = [account.did for account in linked] 362 + avatar_map: dict[str, str | None] = {} 363 + if dids: 364 + result = await db.execute(select(Artist).where(Artist.did.in_(dids))) 365 + for artist in result.scalars().all(): 366 + avatar_map[artist.did] = artist.avatar_url 367 + 268 368 return CurrentUserResponse( 269 369 did=session.did, 270 370 handle=session.handle, 371 + linked_accounts=[ 372 + LinkedAccountResponse( 373 + did=account.did, 374 + handle=account.handle, 375 + avatar_url=avatar_map.get(account.did), 376 + ) 377 + for account in linked 378 + ], 271 379 ) 272 380 273 381 ··· 422 530 ) 423 531 424 532 return ScopeUpgradeStartResponse(auth_url=auth_url) 533 + 534 + 535 + # multi-account endpoints 536 + 537 + 538 + class AddAccountStartRequest(BaseModel): 539 + """request model for starting add-account flow.""" 540 + 541 + handle: str 542 + 543 + 544 + class AddAccountStartResponse(BaseModel): 545 + """response model with OAuth authorization URL for adding account.""" 546 + 547 + auth_url: str 548 + 549 + 550 + @router.post("/add-account/start") 551 + @limiter.limit(settings.rate_limit.auth_limit) 552 + async def start_add_account_flow( 553 + request: Request, 554 + body: AddAccountStartRequest, 555 + session: Session = Depends(require_auth), 556 + ) -> AddAccountStartResponse: 557 + """start OAuth flow to add another account to the session group. 558 + 559 + the user must provide the handle of the account they want to add. 560 + this initiates a new OAuth authorization flow with prompt=login to force 561 + fresh authentication. the new account will be linked to the same session 562 + group as the current account, enabling quick switching between accounts. 563 + 564 + returns the authorization URL that the frontend should redirect to. 565 + """ 566 + # check if the handle is already in the session group 567 + linked_accounts = await get_session_group(session.session_id) 568 + for account in linked_accounts: 569 + if body.handle.lower() == account.handle.lower(): 570 + raise HTTPException( 571 + status_code=400, 572 + detail="you're already logged into this account", 573 + ) 574 + 575 + # get or create a group_id for the current session 576 + group_id = await get_or_create_group_id(session.session_id) 577 + 578 + # start OAuth flow with the NEW handle and prompt=login to force fresh auth 579 + auth_url, state = await start_oauth_flow(body.handle, prompt="login") 580 + 581 + # save pending add-account metadata keyed by state 582 + await save_pending_add_account(state=state, group_id=group_id) 583 + 584 + return AddAccountStartResponse(auth_url=auth_url) 585 + 586 + 587 + class SwitchAccountRequest(BaseModel): 588 + """request model for switching to a different account.""" 589 + 590 + target_did: str 591 + 592 + 593 + class SwitchAccountResponse(BaseModel): 594 + """response model after switching accounts.""" 595 + 596 + did: str 597 + handle: str 598 + session_id: str 599 + 600 + 601 + @router.post("/switch-account") 602 + async def switch_account( 603 + body: SwitchAccountRequest, 604 + response: Response, 605 + session: Session = Depends(require_auth), 606 + ) -> SwitchAccountResponse: 607 + """switch to a different account in the session group. 608 + 609 + switches the active account within the session group. the cookie is updated 610 + to point to the new session, and the old session is marked inactive. 611 + 612 + returns the new active account's info. 613 + """ 614 + # get all accounts in the group 615 + linked = await get_session_group(session.session_id) 616 + 617 + if not linked: 618 + raise HTTPException( 619 + status_code=400, 620 + detail="no linked accounts - use add-account to link accounts first", 621 + ) 622 + 623 + # find the target session 624 + target = next((a for a in linked if a.did == body.target_did), None) 625 + if not target: 626 + raise HTTPException( 627 + status_code=404, 628 + detail="target account not found in session group", 629 + ) 630 + 631 + if target.did == session.did: 632 + raise HTTPException( 633 + status_code=400, 634 + detail="already logged in as this account", 635 + ) 636 + 637 + # switch the active account 638 + new_session_id = await switch_active_account(session.session_id, target.session_id) 639 + 640 + # update the cookie to point to the new session 641 + if settings.frontend.url: 642 + is_localhost = settings.frontend.url.startswith("http://localhost") 643 + 644 + response.set_cookie( 645 + key="session_id", 646 + value=new_session_id, 647 + httponly=True, 648 + secure=not is_localhost, 649 + samesite="lax", 650 + max_age=14 * 24 * 60 * 60, 651 + ) 652 + 653 + return SwitchAccountResponse( 654 + did=target.did, 655 + handle=target.handle, 656 + session_id=new_session_id, 657 + ) 658 + 659 + 660 + @router.post("/logout-all") 661 + async def logout_all( 662 + session: Session = Depends(require_auth), 663 + ) -> JSONResponse: 664 + """logout all accounts in the session group. 665 + 666 + removes all sessions in the group and clears the cookie. 667 + """ 668 + # get all accounts in the group 669 + linked = await get_session_group(session.session_id) 670 + 671 + # delete all sessions (or just this one if not in a group) 672 + if linked: 673 + for account in linked: 674 + await delete_session(account.session_id) 675 + else: 676 + await delete_session(session.session_id) 677 + 678 + response = JSONResponse(content={"message": "all accounts logged out"}) 679 + 680 + if settings.frontend.url: 681 + is_localhost = settings.frontend.url.startswith("http://localhost") 682 + 683 + response.delete_cookie( 684 + key="session_id", 685 + httponly=True, 686 + secure=not is_localhost, 687 + samesite="lax", 688 + ) 689 + 690 + return response
+2
backend/src/backend/models/__init__.py
··· 8 8 from backend.models.exchange_token import ExchangeToken 9 9 from backend.models.job import Job 10 10 from backend.models.oauth_state import OAuthStateModel 11 + from backend.models.pending_add_account import PendingAddAccount 11 12 from backend.models.pending_dev_token import PendingDevToken 12 13 from backend.models.pending_scope_upgrade import PendingScopeUpgrade 13 14 from backend.models.playlist import Playlist ··· 28 29 "ExchangeToken", 29 30 "Job", 30 31 "OAuthStateModel", 32 + "PendingAddAccount", 31 33 "PendingDevToken", 32 34 "PendingScopeUpgrade", 33 35 "Playlist",
+35
backend/src/backend/models/pending_add_account.py
··· 1 + """pending add account model for multi-account OAuth flow metadata.""" 2 + 3 + from datetime import UTC, datetime, timedelta 4 + 5 + from sqlalchemy import DateTime, String 6 + from sqlalchemy.orm import Mapped, mapped_column 7 + 8 + from backend.models.database import Base 9 + 10 + 11 + class PendingAddAccount(Base): 12 + """temporary record linking OAuth state to add-account metadata. 13 + 14 + when a user initiates OAuth to add another account to their session group, 15 + we store the group_id here, keyed by the OAuth state. 16 + the callback checks this table to link the new session to the existing group. 17 + 18 + records expire after 10 minutes (matching OAuth state TTL). 19 + """ 20 + 21 + __tablename__ = "pending_add_accounts" 22 + 23 + state: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) 24 + group_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) 25 + created_at: Mapped[datetime] = mapped_column( 26 + DateTime(timezone=True), 27 + default=lambda: datetime.now(UTC), 28 + nullable=False, 29 + index=True, 30 + ) 31 + expires_at: Mapped[datetime] = mapped_column( 32 + DateTime(timezone=True), 33 + default=lambda: datetime.now(UTC) + timedelta(minutes=10), 34 + nullable=False, 35 + )
+2
backend/src/backend/models/session.py
··· 32 32 Boolean, default=False, nullable=False, server_default="false" 33 33 ) 34 34 token_name: Mapped[str | None] = mapped_column(String(100), nullable=True) 35 + # multi-account session group 36 + group_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+168
backend/tests/test_auth.py
··· 4 4 from datetime import UTC, datetime, timedelta 5 5 from unittest.mock import patch 6 6 7 + import pytest 8 + from fastapi import HTTPException 7 9 from sqlalchemy import select 8 10 from sqlalchemy.ext.asyncio import AsyncSession 9 11 ··· 391 393 assert public_key["use"] == "sig" 392 394 # should preserve kid from original JWK 393 395 assert public_key["kid"] == "test-key-id" 396 + 397 + 398 + # multi-account tests 399 + 400 + 401 + async def test_get_or_create_group_id_creates_new(db_session: AsyncSession): 402 + """verify group_id is created when session has none.""" 403 + from backend._internal.auth import get_or_create_group_id 404 + 405 + session_id = await create_session( 406 + "did:plc:group1", "group1.bsky.social", {"access_token": "t1"} 407 + ) 408 + 409 + group_id = await get_or_create_group_id(session_id) 410 + assert group_id is not None 411 + 412 + # calling again returns same group_id 413 + assert await get_or_create_group_id(session_id) == group_id 414 + 415 + 416 + async def test_get_session_group_empty_without_group(db_session: AsyncSession): 417 + """verify get_session_group returns empty list for ungrouped session.""" 418 + from backend._internal.auth import get_session_group 419 + 420 + session_id = await create_session( 421 + "did:plc:solo", "solo.bsky.social", {"access_token": "t1"} 422 + ) 423 + 424 + accounts = await get_session_group(session_id) 425 + assert accounts == [] 426 + 427 + 428 + async def test_get_session_group_returns_linked_accounts(db_session: AsyncSession): 429 + """verify get_session_group returns all accounts in group.""" 430 + from backend._internal.auth import get_or_create_group_id, get_session_group 431 + 432 + session1 = await create_session( 433 + "did:plc:user1", "user1.bsky.social", {"access_token": "t1"} 434 + ) 435 + session2 = await create_session( 436 + "did:plc:user2", "user2.bsky.social", {"access_token": "t2"} 437 + ) 438 + 439 + # link sessions to same group 440 + group_id = await get_or_create_group_id(session1) 441 + 442 + result = await db_session.execute( 443 + select(UserSession).where(UserSession.session_id == session2) 444 + ) 445 + s2 = result.scalar_one() 446 + s2.group_id = group_id 447 + await db_session.commit() 448 + 449 + accounts = await get_session_group(session1) 450 + assert len(accounts) == 2 451 + dids = {a.did for a in accounts} 452 + assert dids == {"did:plc:user1", "did:plc:user2"} 453 + 454 + 455 + async def test_switch_active_account_validates_group(db_session: AsyncSession): 456 + """verify switch_active_account rejects sessions not in same group.""" 457 + from backend._internal.auth import get_or_create_group_id, switch_active_account 458 + 459 + session1 = await create_session( 460 + "did:plc:s1", "s1.bsky.social", {"access_token": "t1"} 461 + ) 462 + session2 = await create_session( 463 + "did:plc:s2", "s2.bsky.social", {"access_token": "t2"} 464 + ) 465 + 466 + await get_or_create_group_id(session1) 467 + 468 + # session2 not in group - should fail 469 + with pytest.raises(HTTPException) as exc_info: 470 + await switch_active_account(session1, session2) 471 + assert isinstance(exc_info.value, HTTPException) 472 + assert exc_info.value.status_code == 403 473 + 474 + 475 + async def test_switch_active_account_success(db_session: AsyncSession): 476 + """verify switch_active_account works for same-group sessions.""" 477 + from backend._internal.auth import get_or_create_group_id, switch_active_account 478 + 479 + session1 = await create_session( 480 + "did:plc:sw1", "sw1.bsky.social", {"access_token": "t1"} 481 + ) 482 + session2 = await create_session( 483 + "did:plc:sw2", "sw2.bsky.social", {"access_token": "t2"} 484 + ) 485 + 486 + group_id = await get_or_create_group_id(session1) 487 + 488 + result = await db_session.execute( 489 + select(UserSession).where(UserSession.session_id == session2) 490 + ) 491 + s2 = result.scalar_one() 492 + s2.group_id = group_id 493 + await db_session.commit() 494 + 495 + target = await switch_active_account(session1, session2) 496 + assert target == session2 497 + 498 + 499 + async def test_remove_account_from_group_last_account(db_session: AsyncSession): 500 + """verify remove_account_from_group returns None when last account removed.""" 501 + from backend._internal.auth import remove_account_from_group 502 + 503 + session_id = await create_session( 504 + "did:plc:last", "last.bsky.social", {"access_token": "t1"} 505 + ) 506 + 507 + result = await remove_account_from_group(session_id) 508 + assert result is None 509 + 510 + # session should be deleted 511 + assert await get_session(session_id) is None 512 + 513 + 514 + async def test_remove_account_from_group_returns_next(db_session: AsyncSession): 515 + """verify remove_account_from_group returns next session when others remain.""" 516 + from backend._internal.auth import get_or_create_group_id, remove_account_from_group 517 + 518 + session1 = await create_session( 519 + "did:plc:rem1", "rem1.bsky.social", {"access_token": "t1"} 520 + ) 521 + session2 = await create_session( 522 + "did:plc:rem2", "rem2.bsky.social", {"access_token": "t2"} 523 + ) 524 + 525 + group_id = await get_or_create_group_id(session1) 526 + 527 + result = await db_session.execute( 528 + select(UserSession).where(UserSession.session_id == session2) 529 + ) 530 + s2 = result.scalar_one() 531 + s2.group_id = group_id 532 + await db_session.commit() 533 + 534 + next_session = await remove_account_from_group(session1) 535 + assert next_session == session2 536 + 537 + # session1 deleted, session2 remains 538 + assert await get_session(session1) is None 539 + assert await get_session(session2) is not None 540 + 541 + 542 + async def test_pending_add_account_crud(db_session: AsyncSession): 543 + """verify pending add account save/get/delete cycle.""" 544 + from backend._internal.auth import ( 545 + delete_pending_add_account, 546 + get_pending_add_account, 547 + save_pending_add_account, 548 + ) 549 + 550 + state = "test-oauth-state-123" 551 + group_id = "test-group-id-456" 552 + 553 + await save_pending_add_account(state, group_id) 554 + 555 + pending = await get_pending_add_account(state) 556 + assert pending is not None 557 + assert pending.state == state 558 + assert pending.group_id == group_id 559 + 560 + await delete_pending_add_account(state) 561 + assert await get_pending_add_account(state) is None
+2 -2
backend/uv.lock
··· 287 287 288 288 [[package]] 289 289 name = "atproto" 290 - version = "0.0.1.dev471" 291 - source = { git = "https://github.com/zzstoatzz/atproto?rev=main#0de1a49c6592ae2c3193948bda1b3a0861316cb5" } 290 + version = "0.0.1.dev472" 291 + source = { git = "https://github.com/zzstoatzz/atproto?rev=main#d4830f4afb878eb61dda676cf7a8a440fbe1f7ee" } 292 292 dependencies = [ 293 293 { name = "click" }, 294 294 { name = "cryptography" },
+333
docs/research/2026-01-03-multi-account-experience.md
··· 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 + 9 + users with multiple ATProto identities (personal account, artist alias, band account) cannot easily switch between them. the current flow: 10 + 11 + 1. click logout 12 + 2. session destroyed, cookie cleared 13 + 3. navigate to login 14 + 4. enter new handle 15 + 5. redirected to PDS - but PDS auto-approves if "remember this account" was checked 16 + 6. no way to force account selection or fresh login 17 + 18 + the PDS remembers the client and auto-signs in, making multi-account workflows frustrating. 19 + 20 + ## ATProto OAuth prompt parameter 21 + 22 + the 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 36 + async 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 + 47 + store multiple sessions server-side, switch by rotating which one is "active." 48 + 49 + **how it works:** 50 + 51 + 1. user logs in with account A - session created, cookie set 52 + 2. user clicks "add account" - redirected with `prompt=login` 53 + 3. user logs in with account B - second session created 54 + 4. both sessions stored in database, linked by a "session group" or stored as array in encrypted cookie 55 + 5. 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 71 + CREATE TABLE session_groups ( 72 + group_id UUID PRIMARY KEY, 73 + created_at TIMESTAMP DEFAULT NOW() 74 + ); 75 + 76 + -- modify user_sessions to reference group 77 + ALTER TABLE user_sessions ADD COLUMN group_id UUID REFERENCES session_groups(group_id); 78 + ALTER TABLE user_sessions ADD COLUMN is_active BOOLEAN DEFAULT true; 79 + 80 + -- index for fast group lookups 81 + CREATE 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 + 99 + don't store multiple sessions - just make switching easier. 100 + 101 + **how it works:** 102 + 103 + 1. "switch account" button triggers OAuth with `prompt=select_account` 104 + 2. current session destroyed before redirect 105 + 3. PDS shows account picker 106 + 4. 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 + 113 + educate 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 + 166 + 1. **session expires for one account** - remove from group, notify if it was active 167 + 2. **scope upgrade needed** - only affects the active session, not others in group 168 + 3. **cross-tab sync** - BroadcastChannel already exists; extend to broadcast account switches 169 + 4. **queue state** - queue is global, not per-account (music keeps playing during switch) 170 + 5. **mobile (ProfileMenu)** - same UX, adapted for touch 171 + 172 + ## implementation phases 173 + 174 + ### phase 1: prompt parameter support 175 + 176 + 1. fork update: add `prompt` param to `start_authorization()` 177 + 2. backend: pass prompt to SDK in `/auth/start` 178 + 3. 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 + 184 + 1. database migration for session groups 185 + 2. `/auth/add-account` endpoint 186 + 3. `/auth/switch-account` endpoint 187 + 4. modify `/auth/me` to return account list 188 + 5. frontend account switcher UI 189 + 190 + **outcome:** full multi-account experience. 191 + 192 + ### phase 3: polish 193 + 194 + 1. account avatars in switcher 195 + 2. keyboard shortcut for quick-switch (Cmd+Shift+A?) 196 + 3. "switch to" option in artist page when viewing own other account 197 + 4. 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 + 208 + 1. **should we limit accounts per group?** (suggest: 5 max) 209 + 2. **what about developer tokens?** - probably exclude from session groups, they're standalone 210 + 3. **how to handle account picker on login page?** - show known accounts if cookie exists but session expired? 211 + 4. **mobile app (future)** - will need equivalent session group storage in secure keychain 212 + 213 + ## bluesky implementation study 214 + 215 + studied 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 227 + interface SessionState { 228 + accounts: SessionAccount[] // all accounts, even expired ones 229 + currentAccount: SessionAccount // active account reference (DID only) 230 + hasSession: boolean 231 + } 232 + 233 + interface 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 250 + type 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 + 270 + 1. **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 + 276 + 2. **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 + 281 + 3. **logout distinction:** 282 + - `logoutCurrentAccount`: clears tokens, account stays in list 283 + - `logoutEveryAccount`: clears everything, back to login 284 + 285 + 4. **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 + 302 + bluesky 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 + 306 + bluesky's `createAgentAndResume()` flow: 307 + 1. check if `refreshJwt` exists and is not expired 308 + 2. if valid: restore session, attempt background refresh 309 + 3. if expired: show login form for that account (no silent refresh possible) 310 + 311 + for 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 + 318 + from `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)
+5 -2
frontend/src/lib/components/HandleAutocomplete.svelte
··· 49 49 searchTimeout = setTimeout(searchHandles, 300); 50 50 } 51 51 52 - function selectHandle(result: HandleResult) { 52 + function selectHandle(event: MouseEvent, result: HandleResult) { 53 + // stop propagation to prevent click-outside handlers from firing 54 + // after we remove the result elements from the DOM 55 + event.stopPropagation(); 53 56 value = result.handle; 54 57 onSelect(result.handle); 55 58 results = []; ··· 97 100 <button 98 101 type="button" 99 102 class="result-item" 100 - onclick={() => selectHandle(result)} 103 + onclick={(e) => selectHandle(e, result)} 101 104 > 102 105 {#if result.avatar_url} 103 106 <SensitiveImage src={result.avatar_url} compact>
-6
frontend/src/lib/components/Header.svelte
··· 208 208 gap: 0.5rem; 209 209 } 210 210 211 - .desktop-nav { 212 - display: flex; 213 - align-items: center; 214 - gap: 1rem; 215 - } 216 - 217 211 .brand { 218 212 text-decoration: none; 219 213 color: inherit;
+209
frontend/src/lib/components/LogoutModal.svelte
··· 1 + <script lang="ts"> 2 + import { logout } from '$lib/logout.svelte'; 3 + 4 + function handleBackdropClick(event: MouseEvent) { 5 + if (event.target === event.currentTarget) { 6 + logout.close(); 7 + } 8 + } 9 + </script> 10 + 11 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 12 + <div 13 + class="logout-backdrop" 14 + class:open={logout.isOpen} 15 + role="presentation" 16 + onclick={handleBackdropClick} 17 + > 18 + <div class="logout-modal" role="dialog" aria-modal="true" aria-label="logout options"> 19 + <div class="logout-modal-header">stay logged in?</div> 20 + <p class="logout-modal-subtext">you're logging out of @{logout.user?.handle}</p> 21 + <div class="logout-modal-accounts"> 22 + {#each logout.otherAccounts as account} 23 + <button class="logout-modal-account" onclick={() => logout.logoutAndSwitch(account)}> 24 + {#if account.avatar_url} 25 + <img src={account.avatar_url} alt="" class="logout-modal-avatar" /> 26 + {:else} 27 + <div class="logout-modal-avatar placeholder"> 28 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 29 + <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path> 30 + <circle cx="12" cy="7" r="4"></circle> 31 + </svg> 32 + </div> 33 + {/if} 34 + <span class="logout-modal-account-text">switch to @{account.handle}</span> 35 + </button> 36 + {/each} 37 + </div> 38 + <div class="logout-modal-actions"> 39 + <button class="logout-modal-logout" onclick={() => logout.logoutAll()}> 40 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 41 + <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 42 + <polyline points="16 17 21 12 16 7"></polyline> 43 + <line x1="21" y1="12" x2="9" y2="12"></line> 44 + </svg> 45 + <span>logout completely</span> 46 + </button> 47 + <button class="logout-modal-cancel" onclick={() => logout.close()}> 48 + cancel 49 + </button> 50 + </div> 51 + </div> 52 + </div> 53 + 54 + <style> 55 + .logout-backdrop { 56 + position: fixed; 57 + inset: 0; 58 + background: color-mix(in srgb, var(--bg-primary) 60%, transparent); 59 + backdrop-filter: blur(4px); 60 + -webkit-backdrop-filter: blur(4px); 61 + z-index: 9999; 62 + display: flex; 63 + align-items: center; 64 + justify-content: center; 65 + opacity: 0; 66 + pointer-events: none; 67 + transition: opacity 0.15s; 68 + } 69 + 70 + .logout-backdrop.open { 71 + opacity: 1; 72 + pointer-events: auto; 73 + } 74 + 75 + .logout-modal { 76 + width: 100%; 77 + max-width: 400px; 78 + background: color-mix(in srgb, var(--bg-secondary) 95%, transparent); 79 + backdrop-filter: blur(20px) saturate(180%); 80 + -webkit-backdrop-filter: blur(20px) saturate(180%); 81 + border: 1px solid var(--border-subtle); 82 + border-radius: var(--radius-xl); 83 + box-shadow: 84 + 0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent), 85 + 0 0 1px var(--border-subtle) inset; 86 + padding: 1.5rem; 87 + margin: 0 1rem; 88 + } 89 + 90 + .logout-modal-header { 91 + font-size: var(--text-xl); 92 + font-weight: 600; 93 + color: var(--text-primary); 94 + text-align: center; 95 + margin-bottom: 0.25rem; 96 + } 97 + 98 + .logout-modal-subtext { 99 + font-size: var(--text-sm); 100 + color: var(--text-tertiary); 101 + text-align: center; 102 + margin: 0 0 1.25rem; 103 + } 104 + 105 + .logout-modal-accounts { 106 + display: flex; 107 + flex-direction: column; 108 + gap: 0.5rem; 109 + margin-bottom: 1rem; 110 + } 111 + 112 + .logout-modal-account { 113 + display: flex; 114 + align-items: center; 115 + gap: 0.75rem; 116 + width: 100%; 117 + padding: 0.875rem 1rem; 118 + background: var(--bg-secondary); 119 + border: 1px solid var(--border-default); 120 + border-radius: var(--radius-base); 121 + color: var(--text-primary); 122 + font-family: inherit; 123 + font-size: var(--text-base); 124 + cursor: pointer; 125 + transition: all 0.15s; 126 + } 127 + 128 + .logout-modal-account:hover { 129 + border-color: var(--accent); 130 + background: var(--bg-tertiary); 131 + } 132 + 133 + .logout-modal-account:hover .logout-modal-account-text { 134 + color: var(--accent); 135 + } 136 + 137 + .logout-modal-avatar { 138 + width: 36px; 139 + height: 36px; 140 + border-radius: 50%; 141 + object-fit: cover; 142 + flex-shrink: 0; 143 + } 144 + 145 + .logout-modal-avatar.placeholder { 146 + display: flex; 147 + align-items: center; 148 + justify-content: center; 149 + background: var(--bg-tertiary); 150 + color: var(--text-tertiary); 151 + } 152 + 153 + .logout-modal-account-text { 154 + flex: 1; 155 + text-align: left; 156 + overflow: hidden; 157 + text-overflow: ellipsis; 158 + white-space: nowrap; 159 + } 160 + 161 + .logout-modal-actions { 162 + display: flex; 163 + flex-direction: column; 164 + gap: 0.5rem; 165 + } 166 + 167 + .logout-modal-logout { 168 + display: flex; 169 + align-items: center; 170 + justify-content: center; 171 + gap: 0.5rem; 172 + width: 100%; 173 + padding: 0.75rem 1rem; 174 + background: transparent; 175 + border: 1px solid var(--border-default); 176 + border-radius: var(--radius-base); 177 + color: var(--text-secondary); 178 + font-family: inherit; 179 + font-size: var(--text-base); 180 + cursor: pointer; 181 + transition: all 0.15s; 182 + } 183 + 184 + .logout-modal-logout:hover { 185 + border-color: var(--error); 186 + color: var(--error); 187 + background: color-mix(in srgb, var(--error) 10%, transparent); 188 + } 189 + 190 + .logout-modal-logout:hover svg { 191 + color: var(--error); 192 + } 193 + 194 + .logout-modal-cancel { 195 + width: 100%; 196 + padding: 0.625rem; 197 + background: transparent; 198 + border: none; 199 + color: var(--text-tertiary); 200 + font-family: inherit; 201 + font-size: var(--text-sm); 202 + cursor: pointer; 203 + transition: color 0.15s; 204 + } 205 + 206 + .logout-modal-cancel:hover { 207 + color: var(--text-primary); 208 + } 209 + </style>
+428 -9
frontend/src/lib/components/ProfileMenu.svelte
··· 2 2 import { portal } from 'svelte-portal'; 3 3 import { onMount } from 'svelte'; 4 4 import { page } from '$app/stores'; 5 + import { invalidateAll } from '$app/navigation'; 5 6 import { queue } from '$lib/queue.svelte'; 6 7 import { preferences, type Theme } from '$lib/preferences.svelte'; 7 - import type { User } from '$lib/types'; 8 + import { API_URL } from '$lib/config'; 9 + import type { User, LinkedAccount } from '$lib/types'; 10 + import HandleAutocomplete from './HandleAutocomplete.svelte'; 11 + import { logout } from '$lib/logout.svelte'; 8 12 9 13 interface Props { 10 14 user: User | null; ··· 16 20 let isOnUpload = $derived($page.url.pathname === '/upload'); 17 21 let showMenu = $state(false); 18 22 let showSettings = $state(false); 23 + let showAccounts = $state(false); 24 + let switching = $state(false); 25 + let showAddAccountForm = $state(false); 26 + let newHandle = $state(''); 27 + let addAccountError = $state(''); 28 + let addingAccount = $state(false); 19 29 20 30 const presetColors = [ 21 31 { name: 'blue', value: '#6a9fff' }, ··· 36 46 let autoAdvance = $derived(preferences.autoAdvance); 37 47 let currentTheme = $derived(preferences.theme); 38 48 49 + // derive linked accounts (excluding current user) 50 + const otherAccounts = $derived( 51 + user?.linked_accounts?.filter((a) => a.did !== user?.did) ?? [] 52 + ); 53 + const hasMultipleAccounts = $derived(otherAccounts.length > 0); 54 + // get current user's avatar from linked accounts 55 + const currentUserAvatar = $derived( 56 + user?.linked_accounts?.find((a) => a.did === user?.did)?.avatar_url 57 + ); 58 + 39 59 $effect(() => { 40 60 if (currentColor) { 41 61 applyColorLocally(currentColor); ··· 56 76 function toggleMenu() { 57 77 showMenu = !showMenu; 58 78 if (!showMenu) { 59 - showSettings = false; 79 + resetSubmenus(); 60 80 } 61 81 } 62 82 83 + function resetSubmenus() { 84 + showSettings = false; 85 + showAccounts = false; 86 + showAddAccountForm = false; 87 + newHandle = ''; 88 + addAccountError = ''; 89 + } 90 + 63 91 function closeMenu() { 64 92 showMenu = false; 65 - showSettings = false; 93 + resetSubmenus(); 66 94 } 67 95 68 96 function applyColorLocally(color: string) { ··· 101 129 preferences.setTheme(theme); 102 130 } 103 131 104 - async function handleLogout() { 132 + function handleLogoutClick(event: MouseEvent) { 133 + event.stopPropagation(); 134 + if (hasMultipleAccounts) { 135 + closeMenu(); 136 + logout.open(user, otherAccounts, logoutAll, logoutAndSwitch); 137 + } else { 138 + performLogout(); 139 + } 140 + } 141 + 142 + async function performLogout() { 105 143 closeMenu(); 106 144 await onLogout(); 107 145 } 146 + 147 + async function logoutAndSwitch(account: LinkedAccount) { 148 + closeMenu(); 149 + try { 150 + const response = await fetch(`${API_URL}/auth/logout?switch_to=${encodeURIComponent(account.did)}`, { 151 + method: 'POST', 152 + credentials: 'include' 153 + }); 154 + if (response.ok) { 155 + await invalidateAll(); 156 + } else { 157 + console.error('logout with switch failed'); 158 + } 159 + } catch (e) { 160 + console.error('logout with switch failed:', e); 161 + } 162 + } 163 + 164 + async function logoutAll() { 165 + closeMenu(); 166 + try { 167 + await fetch(`${API_URL}/auth/logout-all`, { 168 + method: 'POST', 169 + credentials: 'include' 170 + }); 171 + window.location.href = '/'; 172 + } catch (e) { 173 + console.error('logout all failed:', e); 174 + } 175 + } 176 + 177 + async function handleSwitchAccount(account: LinkedAccount) { 178 + if (switching) return; 179 + 180 + switching = true; 181 + closeMenu(); 182 + try { 183 + await fetch(`${API_URL}/auth/switch-account`, { 184 + method: 'POST', 185 + credentials: 'include', 186 + headers: { 'Content-Type': 'application/json' }, 187 + body: JSON.stringify({ target_did: account.did }) 188 + }); 189 + await invalidateAll(); 190 + } catch (e) { 191 + console.error('switch account failed:', e); 192 + } finally { 193 + switching = false; 194 + } 195 + } 196 + 197 + function showAddAccount(event: MouseEvent) { 198 + event.stopPropagation(); 199 + showAddAccountForm = true; 200 + addAccountError = ''; 201 + } 202 + 203 + function hideAddAccount() { 204 + showAddAccountForm = false; 205 + newHandle = ''; 206 + addAccountError = ''; 207 + } 208 + 209 + function handleSelectHandle(handle: string) { 210 + newHandle = handle; 211 + // immediately submit when selecting from autocomplete 212 + submitAddAccount(); 213 + } 214 + 215 + function handleFormSubmit(event: SubmitEvent) { 216 + event.preventDefault(); 217 + submitAddAccount(); 218 + } 219 + 220 + async function submitAddAccount() { 221 + const handle = newHandle.trim(); 222 + if (!handle) { 223 + addAccountError = 'enter a handle'; 224 + return; 225 + } 226 + 227 + addingAccount = true; 228 + addAccountError = ''; 229 + 230 + try { 231 + const response = await fetch(`${API_URL}/auth/add-account/start`, { 232 + method: 'POST', 233 + credentials: 'include', 234 + headers: { 'Content-Type': 'application/json' }, 235 + body: JSON.stringify({ handle }) 236 + }); 237 + if (response.ok) { 238 + const data: { auth_url: string } = await response.json(); 239 + window.location.href = data.auth_url; 240 + } else { 241 + const err = await response.json().catch(() => ({ detail: 'failed to add account' })); 242 + addAccountError = err.detail || 'failed to add account'; 243 + addingAccount = false; 244 + } 245 + } catch (e) { 246 + console.error('add account failed:', e); 247 + addAccountError = 'network error'; 248 + addingAccount = false; 249 + } 250 + } 108 251 </script> 109 252 110 253 <div class="profile-menu"> ··· 122 265 <div class="menu-backdrop" use:portal={'body'} onclick={closeMenu}></div> 123 266 <div class="menu-popover" use:portal={'body'}> 124 267 <div class="menu-header"> 125 - <span>{showSettings ? 'settings' : 'menu'}</span> 268 + <span>{showSettings ? 'settings' : showAccounts ? 'accounts' : 'menu'}</span> 126 269 <button class="close-btn" onclick={closeMenu} aria-label="close"> 127 270 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 128 271 <line x1="18" y1="6" x2="6" y2="18"></line> ··· 131 274 </button> 132 275 </div> 133 276 134 - {#if !showSettings} 277 + {#if !showSettings && !showAccounts} 135 278 <nav class="menu-items"> 136 279 {#if !isOnPortal} 137 280 <a href="/portal" class="menu-item" onclick={closeMenu}> ··· 176 319 </svg> 177 320 </button> 178 321 179 - <button class="menu-item logout" onclick={handleLogout}> 322 + <button class="menu-item" onclick={() => showAccounts = true}> 323 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 324 + <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path> 325 + <circle cx="9" cy="7" r="4"></circle> 326 + <line x1="19" y1="8" x2="19" y2="14"></line> 327 + <line x1="22" y1="11" x2="16" y2="11"></line> 328 + </svg> 329 + <div class="item-content"> 330 + <span class="item-title">accounts</span> 331 + <span class="item-subtitle">{hasMultipleAccounts ? `${otherAccounts.length + 1} linked` : 'add another'}</span> 332 + </div> 333 + <svg class="chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 334 + <polyline points="9 18 15 12 9 6"></polyline> 335 + </svg> 336 + </button> 337 + 338 + <button class="menu-item logout" onclick={handleLogoutClick}> 180 339 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 181 340 <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 182 341 <polyline points="16 17 21 12 16 7"></polyline> ··· 187 346 </div> 188 347 </button> 189 348 </nav> 190 - {:else} 349 + {:else if showSettings} 191 350 <button class="back-btn" onclick={() => showSettings = false}> 192 351 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 193 352 <polyline points="15 18 9 12 15 6"></polyline> ··· 258 417 all settings → 259 418 </a> 260 419 </div> 420 + {:else if showAccounts} 421 + <button class="back-btn" onclick={() => showAccounts = false}> 422 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 423 + <polyline points="15 18 9 12 15 6"></polyline> 424 + </svg> 425 + <span>back</span> 426 + </button> 427 + 428 + <div class="accounts-content"> 429 + <!-- current account --> 430 + <div class="account-item current"> 431 + {#if currentUserAvatar} 432 + <img src={currentUserAvatar} alt="" class="account-avatar" /> 433 + {:else} 434 + <div class="account-avatar placeholder"> 435 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 436 + <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path> 437 + <circle cx="12" cy="7" r="4"></circle> 438 + </svg> 439 + </div> 440 + {/if} 441 + <div class="account-info"> 442 + <span class="account-handle">@{user?.handle}</span> 443 + <span class="account-badge">active</span> 444 + </div> 445 + <svg class="check-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--success, #4ade80)" stroke-width="2"> 446 + <polyline points="20 6 9 17 4 12"></polyline> 447 + </svg> 448 + </div> 449 + 450 + {#if hasMultipleAccounts} 451 + {#each otherAccounts as account} 452 + <button 453 + class="account-item" 454 + onclick={() => handleSwitchAccount(account)} 455 + disabled={switching} 456 + > 457 + {#if account.avatar_url} 458 + <img src={account.avatar_url} alt="" class="account-avatar" /> 459 + {:else} 460 + <div class="account-avatar placeholder"> 461 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 462 + <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path> 463 + <circle cx="12" cy="7" r="4"></circle> 464 + </svg> 465 + </div> 466 + {/if} 467 + <div class="account-info"> 468 + <span class="account-handle">@{account.handle}</span> 469 + </div> 470 + </button> 471 + {/each} 472 + {/if} 473 + 474 + {#if showAddAccountForm} 475 + <!-- add account form with back button --> 476 + <button class="back-btn" onclick={hideAddAccount}> 477 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 478 + <polyline points="15 18 9 12 15 6"></polyline> 479 + </svg> 480 + <span>back</span> 481 + </button> 482 + <form class="add-account-form" onsubmit={handleFormSubmit}> 483 + <HandleAutocomplete 484 + bind:value={newHandle} 485 + onSelect={handleSelectHandle} 486 + placeholder="handle.bsky.social" 487 + disabled={addingAccount} 488 + /> 489 + <button 490 + type="submit" 491 + class="add-account-btn" 492 + disabled={addingAccount || !newHandle.trim()} 493 + > 494 + {#if addingAccount} 495 + adding... 496 + {:else} 497 + add account 498 + {/if} 499 + </button> 500 + {#if addAccountError} 501 + <div class="add-account-error">{addAccountError}</div> 502 + {/if} 503 + </form> 504 + {:else} 505 + <button class="menu-item add-account" onclick={showAddAccount}> 506 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 507 + <line x1="12" y1="5" x2="12" y2="19"></line> 508 + <line x1="5" y1="12" x2="19" y2="12"></line> 509 + </svg> 510 + <div class="item-content"> 511 + <span class="item-title">add account</span> 512 + </div> 513 + </button> 514 + {/if} 515 + </div> 261 516 {/if} 262 517 </div> 263 518 {/if} ··· 391 646 transform: scale(0.98); 392 647 } 393 648 649 + .menu-item:disabled { 650 + opacity: 0.5; 651 + cursor: not-allowed; 652 + } 653 + 394 654 .menu-item svg:first-child { 395 655 flex-shrink: 0; 396 656 color: var(--text-secondary); ··· 454 714 background: var(--bg-hover); 455 715 } 456 716 457 - .settings-content { 717 + .settings-content, 718 + .accounts-content { 458 719 padding: 0.75rem 1.25rem 1.25rem; 459 720 display: flex; 460 721 flex-direction: column; 461 722 gap: 1.25rem; 462 723 } 463 724 725 + .accounts-content { 726 + gap: 0.5rem; 727 + padding: 0.5rem; 728 + } 729 + 730 + .account-item { 731 + display: flex; 732 + align-items: center; 733 + gap: 0.75rem; 734 + padding: 0.75rem 1rem; 735 + background: transparent; 736 + border: none; 737 + border-radius: var(--radius-lg); 738 + width: 100%; 739 + text-align: left; 740 + cursor: pointer; 741 + transition: all 0.15s; 742 + font-family: inherit; 743 + -webkit-tap-highlight-color: transparent; 744 + } 745 + 746 + .account-item:hover { 747 + background: var(--bg-hover); 748 + } 749 + 750 + .account-item:disabled { 751 + opacity: 0.5; 752 + cursor: not-allowed; 753 + } 754 + 755 + .account-item.current { 756 + cursor: default; 757 + background: color-mix(in srgb, var(--success, #4ade80) 10%, transparent); 758 + } 759 + 760 + .account-avatar { 761 + width: 36px; 762 + height: 36px; 763 + border-radius: 50%; 764 + object-fit: cover; 765 + flex-shrink: 0; 766 + } 767 + 768 + .account-avatar.placeholder { 769 + display: flex; 770 + align-items: center; 771 + justify-content: center; 772 + background: var(--bg-tertiary); 773 + color: var(--text-tertiary); 774 + } 775 + 776 + .account-info { 777 + display: flex; 778 + flex-direction: column; 779 + gap: 0.15rem; 780 + flex: 1; 781 + min-width: 0; 782 + } 783 + 784 + .account-handle { 785 + font-size: var(--text-base); 786 + color: var(--text-primary); 787 + overflow: hidden; 788 + text-overflow: ellipsis; 789 + white-space: nowrap; 790 + } 791 + 792 + .account-badge { 793 + font-size: var(--text-xs); 794 + color: var(--success, #4ade80); 795 + text-transform: uppercase; 796 + letter-spacing: 0.05em; 797 + } 798 + 799 + .check-icon { 800 + flex-shrink: 0; 801 + } 802 + 803 + .add-account { 804 + margin-top: 0.5rem; 805 + border-top: 1px solid var(--border-subtle); 806 + border-radius: 0; 807 + padding-top: 1rem; 808 + } 809 + 464 810 .settings-section { 465 811 display: flex; 466 812 flex-direction: column; ··· 665 1011 top: calc(50% - var(--player-height, 0px) / 2); 666 1012 max-height: calc(100vh - var(--player-height, 0px) - 3rem - env(safe-area-inset-bottom, 0px)); 667 1013 } 1014 + } 1015 + 1016 + /* add account form styles */ 1017 + .add-account-form { 1018 + display: flex; 1019 + flex-direction: column; 1020 + gap: 0.75rem; 1021 + padding: 0.5rem 1rem 1rem; 1022 + } 1023 + 1024 + .add-account-form :global(.handle-autocomplete) { 1025 + width: 100%; 1026 + } 1027 + 1028 + .add-account-form :global(.handle-autocomplete .input-wrapper input) { 1029 + padding: 0.625rem 0.75rem; 1030 + font-size: 16px; /* prevents zoom on iOS */ 1031 + background: var(--bg-tertiary); 1032 + } 1033 + 1034 + .add-account-form :global(.handle-autocomplete .results) { 1035 + max-height: 150px; 1036 + z-index: 200; 1037 + } 1038 + 1039 + .add-account-form :global(.handle-autocomplete .result-item) { 1040 + padding: 0.5rem 0.75rem; 1041 + gap: 0.5rem; 1042 + } 1043 + 1044 + .add-account-form :global(.handle-autocomplete .avatar), 1045 + .add-account-form :global(.handle-autocomplete .avatar-placeholder) { 1046 + width: 28px; 1047 + height: 28px; 1048 + } 1049 + 1050 + .add-account-form :global(.handle-autocomplete .display-name) { 1051 + font-size: var(--text-sm); 1052 + } 1053 + 1054 + .add-account-form :global(.handle-autocomplete .handle) { 1055 + font-size: var(--text-xs); 1056 + } 1057 + 1058 + .add-account-btn { 1059 + display: flex; 1060 + align-items: center; 1061 + justify-content: center; 1062 + gap: 0.5rem; 1063 + padding: 0.75rem 1rem; 1064 + background: var(--accent); 1065 + border: none; 1066 + border-radius: var(--radius-lg); 1067 + color: white; 1068 + font-family: inherit; 1069 + font-size: var(--text-base); 1070 + font-weight: 500; 1071 + cursor: pointer; 1072 + transition: opacity 0.15s; 1073 + } 1074 + 1075 + .add-account-btn:hover:not(:disabled) { 1076 + opacity: 0.9; 1077 + } 1078 + 1079 + .add-account-btn:disabled { 1080 + opacity: 0.5; 1081 + cursor: not-allowed; 1082 + } 1083 + 1084 + .add-account-error { 1085 + color: var(--error); 1086 + font-size: var(--text-sm); 668 1087 } 669 1088 </style>
+445 -27
frontend/src/lib/components/UserMenu.svelte
··· 1 1 <script lang="ts"> 2 - import type { User } from '$lib/types'; 2 + import type { User, LinkedAccount } from '$lib/types'; 3 + import { API_URL } from '$lib/config'; 4 + import { invalidateAll } from '$app/navigation'; 5 + import HandleAutocomplete from './HandleAutocomplete.svelte'; 6 + import { logout } from '$lib/logout.svelte'; 3 7 4 8 interface Props { 5 9 user: User | null; ··· 9 13 let { user, onLogout }: Props = $props(); 10 14 let showMenu = $state(false); 11 15 let menuRef = $state<HTMLDivElement | null>(null); 16 + let switching = $state(false); 17 + let showAccountsSubmenu = $state(false); 18 + let showAddAccountForm = $state(false); 19 + let newHandle = $state(''); 20 + let addAccountError = $state(''); 21 + let addingAccount = $state(false); 12 22 13 23 function toggleMenu() { 14 24 showMenu = !showMenu; 25 + if (!showMenu) { 26 + resetSubmenus(); 27 + } 28 + } 29 + 30 + function resetSubmenus() { 31 + showAccountsSubmenu = false; 32 + showAddAccountForm = false; 33 + newHandle = ''; 34 + addAccountError = ''; 15 35 } 16 36 17 37 function closeMenu() { 18 38 showMenu = false; 39 + resetSubmenus(); 19 40 } 20 41 21 - async function handleLogout() { 42 + function toggleAccountsSubmenu(event: MouseEvent) { 43 + event.stopPropagation(); 44 + showAccountsSubmenu = !showAccountsSubmenu; 45 + if (!showAccountsSubmenu) { 46 + showAddAccountForm = false; 47 + newHandle = ''; 48 + addAccountError = ''; 49 + } 50 + } 51 + 52 + function handleLogoutClick(event: MouseEvent) { 53 + event.stopPropagation(); 54 + if (hasMultipleAccounts) { 55 + // close menu and show global logout modal 56 + closeMenu(); 57 + logout.open(user, otherAccounts, logoutAll, logoutAndSwitch); 58 + } else { 59 + // single account - just logout 60 + performLogout(); 61 + } 62 + } 63 + 64 + async function performLogout() { 22 65 closeMenu(); 23 66 await onLogout(); 24 67 } 25 68 69 + async function logoutAndSwitch(account: LinkedAccount) { 70 + closeMenu(); 71 + try { 72 + const response = await fetch(`${API_URL}/auth/logout?switch_to=${encodeURIComponent(account.did)}`, { 73 + method: 'POST', 74 + credentials: 'include' 75 + }); 76 + if (response.ok) { 77 + await invalidateAll(); 78 + } else { 79 + console.error('logout with switch failed'); 80 + } 81 + } catch (e) { 82 + console.error('logout with switch failed:', e); 83 + } 84 + } 85 + 86 + async function logoutAll() { 87 + closeMenu(); 88 + try { 89 + await fetch(`${API_URL}/auth/logout-all`, { 90 + method: 'POST', 91 + credentials: 'include' 92 + }); 93 + window.location.href = '/'; 94 + } catch (e) { 95 + console.error('logout all failed:', e); 96 + } 97 + } 98 + 99 + async function handleSwitchAccount(account: LinkedAccount) { 100 + if (switching) return; 101 + 102 + switching = true; 103 + closeMenu(); 104 + try { 105 + await fetch(`${API_URL}/auth/switch-account`, { 106 + method: 'POST', 107 + credentials: 'include', 108 + headers: { 'Content-Type': 'application/json' }, 109 + body: JSON.stringify({ target_did: account.did }) 110 + }); 111 + await invalidateAll(); 112 + } catch (e) { 113 + console.error('switch account failed:', e); 114 + } finally { 115 + switching = false; 116 + } 117 + } 118 + 119 + function showAddAccount(event: MouseEvent) { 120 + event.stopPropagation(); 121 + showAddAccountForm = true; 122 + addAccountError = ''; 123 + } 124 + 125 + function hideAddAccount() { 126 + showAddAccountForm = false; 127 + newHandle = ''; 128 + addAccountError = ''; 129 + } 130 + 131 + function handleSelectHandle(handle: string) { 132 + newHandle = handle; 133 + // immediately submit when selecting from autocomplete 134 + submitAddAccount(); 135 + } 136 + 137 + function handleFormSubmit(event: SubmitEvent) { 138 + event.preventDefault(); 139 + submitAddAccount(); 140 + } 141 + 142 + async function submitAddAccount() { 143 + const handle = newHandle.trim(); 144 + if (!handle) { 145 + addAccountError = 'enter a handle'; 146 + return; 147 + } 148 + 149 + addingAccount = true; 150 + addAccountError = ''; 151 + 152 + try { 153 + const response = await fetch(`${API_URL}/auth/add-account/start`, { 154 + method: 'POST', 155 + credentials: 'include', 156 + headers: { 'Content-Type': 'application/json' }, 157 + body: JSON.stringify({ handle }) 158 + }); 159 + if (response.ok) { 160 + const data: { auth_url: string } = await response.json(); 161 + window.location.href = data.auth_url; 162 + } else { 163 + const err = await response.json().catch(() => ({ detail: 'failed to add account' })); 164 + addAccountError = err.detail || 'failed to add account'; 165 + addingAccount = false; 166 + } 167 + } catch (e) { 168 + console.error('add account failed:', e); 169 + addAccountError = 'network error'; 170 + addingAccount = false; 171 + } 172 + } 173 + 26 174 function handleClickOutside(event: MouseEvent) { 27 175 if (menuRef && !menuRef.contains(event.target as Node)) { 28 176 closeMenu(); ··· 35 183 return () => document.removeEventListener('click', handleClickOutside); 36 184 } 37 185 }); 186 + 187 + // derive linked accounts (excluding current user) 188 + const otherAccounts = $derived( 189 + user?.linked_accounts?.filter((a) => a.did !== user?.did) ?? [] 190 + ); 191 + const hasMultipleAccounts = $derived(otherAccounts.length > 0); 38 192 </script> 39 193 40 194 <div class="user-menu" bind:this={menuRef}> ··· 59 213 {#if showMenu} 60 214 <div class="dropdown"> 61 215 <a href="/portal" class="dropdown-item" onclick={closeMenu}> 62 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 63 - <rect x="3" y="3" width="7" height="7"></rect> 64 - <rect x="14" y="3" width="7" height="7"></rect> 65 - <rect x="14" y="14" width="7" height="7"></rect> 66 - <rect x="3" y="14" width="7" height="7"></rect> 67 - </svg> 68 - <span>portal</span> 69 - </a> 70 - <a href="/settings" class="dropdown-item" onclick={closeMenu}> 71 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 72 - <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path> 73 - <circle cx="12" cy="12" r="3"></circle> 74 - </svg> 75 - <span>settings</span> 76 - </a> 77 - <div class="dropdown-divider"></div> 78 - <button class="dropdown-item logout" onclick={handleLogout}> 79 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 80 - <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 81 - <polyline points="16 17 21 12 16 7"></polyline> 82 - <line x1="21" y1="12" x2="9" y2="12"></line> 83 - </svg> 84 - <span>logout</span> 85 - </button> 216 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 217 + <rect x="3" y="3" width="7" height="7"></rect> 218 + <rect x="14" y="3" width="7" height="7"></rect> 219 + <rect x="14" y="14" width="7" height="7"></rect> 220 + <rect x="3" y="14" width="7" height="7"></rect> 221 + </svg> 222 + <span>portal</span> 223 + </a> 224 + <a href="/settings" class="dropdown-item" onclick={closeMenu}> 225 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 226 + <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path> 227 + <circle cx="12" cy="12" r="3"></circle> 228 + </svg> 229 + <span>settings</span> 230 + </a> 231 + 232 + <div class="dropdown-divider"></div> 233 + 234 + <!-- accounts submenu --> 235 + <div class="submenu-container"> 236 + <button class="dropdown-item has-submenu" onclick={toggleAccountsSubmenu}> 237 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 238 + <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path> 239 + <circle cx="9" cy="7" r="4"></circle> 240 + <path d="M22 21v-2a4 4 0 0 0-3-3.87"></path> 241 + <path d="M16 3.13a4 4 0 0 1 0 7.75"></path> 242 + </svg> 243 + <span>accounts</span> 244 + <svg 245 + class="submenu-chevron" 246 + class:open={showAccountsSubmenu} 247 + width="12" 248 + height="12" 249 + viewBox="0 0 24 24" 250 + fill="none" 251 + stroke="currentColor" 252 + stroke-width="2" 253 + stroke-linecap="round" 254 + stroke-linejoin="round" 255 + > 256 + <polyline points="6 9 12 15 18 9"></polyline> 257 + </svg> 258 + </button> 259 + 260 + {#if showAccountsSubmenu} 261 + <div class="submenu"> 262 + {#if showAddAccountForm} 263 + <!-- add account form --> 264 + <button class="back-button" onclick={hideAddAccount}> 265 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 266 + <path d="M15 18l-6-6 6-6"/> 267 + </svg> 268 + <span>back</span> 269 + </button> 270 + <form class="add-account-form" onsubmit={handleFormSubmit}> 271 + <HandleAutocomplete 272 + bind:value={newHandle} 273 + onSelect={handleSelectHandle} 274 + placeholder="handle.bsky.social" 275 + disabled={addingAccount} 276 + /> 277 + <button 278 + type="submit" 279 + class="add-account-btn" 280 + disabled={addingAccount || !newHandle.trim()} 281 + > 282 + {#if addingAccount} 283 + adding... 284 + {:else} 285 + add account 286 + {/if} 287 + </button> 288 + {#if addAccountError} 289 + <div class="add-account-error">{addAccountError}</div> 290 + {/if} 291 + </form> 292 + {:else} 293 + {#each otherAccounts as account} 294 + <button 295 + class="dropdown-item account-item" 296 + onclick={() => handleSwitchAccount(account)} 297 + disabled={switching} 298 + > 299 + {#if account.avatar_url} 300 + <img src={account.avatar_url} alt="" class="account-avatar" /> 301 + {:else} 302 + <div class="account-avatar placeholder"> 303 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 304 + <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path> 305 + <circle cx="12" cy="7" r="4"></circle> 306 + </svg> 307 + </div> 308 + {/if} 309 + <span class="account-handle">@{account.handle}</span> 310 + </button> 311 + {/each} 312 + 313 + <button class="dropdown-item add-account-trigger" onclick={showAddAccount}> 314 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 315 + <line x1="12" y1="5" x2="12" y2="19"></line> 316 + <line x1="5" y1="12" x2="19" y2="12"></line> 317 + </svg> 318 + <span>add account</span> 319 + </button> 320 + {/if} 321 + </div> 322 + {/if} 323 + </div> 324 + 325 + <div class="dropdown-divider"></div> 326 + 327 + <button class="dropdown-item logout" onclick={handleLogoutClick}> 328 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 329 + <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 330 + <polyline points="16 17 21 12 16 7"></polyline> 331 + <line x1="21" y1="12" x2="9" y2="12"></line> 332 + </svg> 333 + <span>logout</span> 334 + </button> 86 335 </div> 87 336 {/if} 88 337 </div> 338 + 89 339 90 340 <style> 91 341 .user-menu { ··· 133 383 position: absolute; 134 384 top: calc(100% + 0.5rem); 135 385 right: 0; 136 - min-width: 180px; 386 + width: 220px; 137 387 background: var(--bg-secondary); 138 388 border: 1px solid var(--border-default); 139 389 border-radius: var(--radius-md); ··· 175 425 background: var(--bg-hover); 176 426 } 177 427 428 + .dropdown-item:disabled { 429 + opacity: 0.5; 430 + cursor: not-allowed; 431 + } 432 + 178 433 .dropdown-item svg { 179 434 flex-shrink: 0; 180 435 color: var(--text-secondary); ··· 185 440 color: var(--accent); 186 441 } 187 442 443 + .account-item { 444 + padding: 0.5rem 1rem; 445 + } 446 + 447 + .account-avatar { 448 + width: 24px; 449 + height: 24px; 450 + border-radius: 50%; 451 + object-fit: cover; 452 + } 453 + 454 + .account-avatar.placeholder { 455 + display: flex; 456 + align-items: center; 457 + justify-content: center; 458 + background: var(--bg-tertiary); 459 + color: var(--text-tertiary); 460 + } 461 + 462 + .account-handle { 463 + font-size: var(--text-sm); 464 + color: var(--text-secondary); 465 + overflow: hidden; 466 + text-overflow: ellipsis; 467 + white-space: nowrap; 468 + min-width: 0; 469 + flex: 1; 470 + } 471 + 472 + .account-item:hover .account-handle { 473 + color: var(--accent); 474 + } 475 + 188 476 .dropdown-item.logout:hover { 189 477 background: color-mix(in srgb, var(--error) 10%, transparent); 190 478 } ··· 201 489 height: 1px; 202 490 background: var(--border-subtle); 203 491 margin: 0.25rem 0; 492 + } 493 + 494 + .submenu-container { 495 + position: relative; 496 + } 497 + 498 + .has-submenu { 499 + justify-content: flex-start; 500 + } 501 + 502 + .has-submenu span { 503 + flex: 1; 504 + } 505 + 506 + .submenu-chevron { 507 + flex-shrink: 0; 508 + margin-left: auto; 509 + transition: transform 0.15s; 510 + color: var(--text-tertiary); 511 + } 512 + 513 + .submenu-chevron.open { 514 + transform: rotate(180deg); 515 + } 516 + 517 + .submenu { 518 + background: var(--bg-tertiary); 519 + border-top: 1px solid var(--border-subtle); 520 + animation: submenuIn 0.12s ease-out; 521 + } 522 + 523 + @keyframes submenuIn { 524 + from { 525 + opacity: 0; 526 + } 527 + to { 528 + opacity: 1; 529 + } 530 + } 531 + 532 + .submenu .dropdown-item { 533 + padding-left: 1.5rem; 534 + } 535 + 536 + .submenu .account-item { 537 + padding-left: 1.5rem; 538 + } 539 + 540 + /* back button - matches AddToMenu pattern */ 541 + .back-button { 542 + display: flex; 543 + align-items: center; 544 + gap: 0.5rem; 545 + width: 100%; 546 + padding: 0.75rem 1rem; 547 + background: transparent; 548 + border: none; 549 + border-bottom: 1px solid var(--border-subtle); 550 + color: var(--text-secondary); 551 + font-size: var(--text-sm); 552 + font-family: inherit; 553 + cursor: pointer; 554 + transition: background 0.15s; 555 + } 556 + 557 + .back-button:hover { 558 + background: var(--bg-hover); 559 + color: var(--accent); 560 + } 561 + 562 + /* add account form - matches AddToMenu create-form pattern */ 563 + .add-account-form { 564 + display: flex; 565 + flex-direction: column; 566 + gap: 0.75rem; 567 + padding: 1rem; 568 + } 569 + 570 + .add-account-form :global(.handle-autocomplete) { 571 + width: 100%; 572 + } 573 + 574 + .add-account-form :global(.handle-autocomplete .input-wrapper input) { 575 + padding: 0.625rem 0.75rem; 576 + font-size: var(--text-base); 577 + background: var(--bg-secondary); 578 + } 579 + 580 + .add-account-form :global(.handle-autocomplete .results) { 581 + max-height: 180px; 582 + } 583 + 584 + .add-account-btn { 585 + display: flex; 586 + align-items: center; 587 + justify-content: center; 588 + gap: 0.5rem; 589 + padding: 0.625rem 1rem; 590 + background: var(--accent); 591 + border: none; 592 + border-radius: var(--radius-base); 593 + color: white; 594 + font-family: inherit; 595 + font-size: var(--text-base); 596 + font-weight: 500; 597 + cursor: pointer; 598 + transition: opacity 0.15s; 599 + } 600 + 601 + .add-account-btn:hover:not(:disabled) { 602 + opacity: 0.9; 603 + } 604 + 605 + .add-account-btn:disabled { 606 + opacity: 0.5; 607 + cursor: not-allowed; 608 + } 609 + 610 + .add-account-error { 611 + color: var(--error); 612 + font-size: var(--text-sm); 613 + } 614 + 615 + .add-account-trigger { 616 + color: var(--accent); 617 + border-top: 1px solid var(--border-subtle); 618 + } 619 + 620 + .add-account-trigger svg { 621 + color: var(--accent); 204 622 } 205 623 </style>
+43
frontend/src/lib/logout.svelte.ts
··· 1 + // global logout modal state using Svelte 5 runes 2 + import type { User, LinkedAccount } from '$lib/types'; 3 + 4 + class LogoutState { 5 + isOpen = $state(false); 6 + user: User | null = $state(null); 7 + otherAccounts: LinkedAccount[] = $state([]); 8 + onLogoutAll: (() => Promise<void>) | null = null; 9 + onLogoutAndSwitch: ((account: LinkedAccount) => Promise<void>) | null = null; 10 + 11 + open( 12 + user: User | null, 13 + otherAccounts: LinkedAccount[], 14 + onLogoutAll: () => Promise<void>, 15 + onLogoutAndSwitch: (account: LinkedAccount) => Promise<void> 16 + ) { 17 + this.user = user; 18 + this.otherAccounts = otherAccounts; 19 + this.onLogoutAll = onLogoutAll; 20 + this.onLogoutAndSwitch = onLogoutAndSwitch; 21 + this.isOpen = true; 22 + } 23 + 24 + close() { 25 + this.isOpen = false; 26 + } 27 + 28 + async logoutAll() { 29 + if (this.onLogoutAll) { 30 + this.close(); 31 + await this.onLogoutAll(); 32 + } 33 + } 34 + 35 + async logoutAndSwitch(account: LinkedAccount) { 36 + if (this.onLogoutAndSwitch) { 37 + this.close(); 38 + await this.onLogoutAndSwitch(account); 39 + } 40 + } 41 + } 42 + 43 + export const logout = new LogoutState();
+7
frontend/src/lib/types.ts
··· 59 59 gated?: boolean; // true if track is gated AND viewer lacks access 60 60 } 61 61 62 + export interface LinkedAccount { 63 + did: string; 64 + handle: string; 65 + avatar_url: string | null; 66 + } 67 + 62 68 export interface User { 63 69 did: string; 64 70 handle: string; 71 + linked_accounts: LinkedAccount[]; 65 72 } 66 73 67 74 export interface Artist {
+2
frontend/src/routes/+layout.svelte
··· 9 9 import Toast from '$lib/components/Toast.svelte'; 10 10 import Queue from '$lib/components/Queue.svelte'; 11 11 import SearchModal from '$lib/components/SearchModal.svelte'; 12 + import LogoutModal from '$lib/components/LogoutModal.svelte'; 12 13 import { onMount, onDestroy } from 'svelte'; 13 14 import { page } from '$app/stores'; 14 15 import { afterNavigate } from '$app/navigation'; ··· 414 415 {/if} 415 416 <Toast /> 416 417 <SearchModal /> 418 + <LogoutModal /> 417 419 418 420 <style> 419 421 :global(*),
+1 -1
frontend/src/routes/portal/+page.svelte
··· 753 753 </div> 754 754 {#if atprotofansEligible || track.support_gate} 755 755 <div class="edit-field-group"> 756 - <label class="edit-label">supporter access</label> 756 + <span class="edit-label">supporter access</span> 757 757 <label class="toggle-row"> 758 758 <input 759 759 type="checkbox"