this repo has no description
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: preserve remembered accounts in sign-out menu; restore OAuth scope label

Account menu:
- When the session is cleared (e.g. after /oauth/add-account redirects
to the sign-in page) but the device still has remembered accounts,
the account menu now shows a signed-out dropdown instead of a plain
"Sign in" link. The dropdown lists all previously-authenticated
accounts as one-click switch targets, so the user's personal account
stays visible and accessible throughout the project account sign-in.
- Fully signed-out devices with no remembered accounts are unchanged.

OAuth scope / permissions text:
- Prepend com.atmosphereaccount.registry.fullPermissions to the scope
string. This named permission set (title: "Atmosphere Account",
detail: "Manage your Atmosphere explore profile, reviews, and
updates.") is what the PDS consent screen uses to render the branded
label rather than raw NSID strings. The explicit repo:* scopes are
kept alongside it so sign-in never fails on PDSes that cannot resolve
the DNS-backed permission set.

Made-with: Cursor

+122 -21
+3
assets/styles.css
··· 4200 4200 background: rgba(255, 255, 255, 0.82); 4201 4201 box-shadow: 0 10px 24px rgba(14, 20, 40, 0.12); 4202 4202 } 4203 + .account-menu-trigger--signed-out { 4204 + opacity: 0.7; 4205 + } 4203 4206 4204 4207 .account-menu-trigger:focus-visible { 4205 4208 outline: 2px solid rgba(80, 130, 220, 0.6);
+2
i18n/messages/en.tsx
··· 37 37 account: { 38 38 menuLabel: "Account menu", 39 39 signedInAs: "Signed in as", 40 + signedOut: "Signed out", 40 41 signIn: "Sign in", 41 42 signInHint: "Sign in with your Atmosphere account to publish a profile.", 42 43 manageProfile: "Manage profile", ··· 45 46 signOut: "Sign out", 46 47 avatarAlt: "Account", 47 48 switchHeading: "Switch account", 49 + yourAccounts: "Your accounts", 48 50 switchTo: (handle: string): string => `Switch to @${handle}`, 49 51 addAccount: "Add another account", 50 52 forget: "Forget",
+102 -5
islands/AccountMenu.tsx
··· 46 46 }: Props, 47 47 ) { 48 48 const t = useT().nav.account; 49 + const accounts = rememberedAccounts ?? []; 49 50 50 - /** Signed out: a plain text link in the same slot the dropdown 51 - * occupies when authenticated. No glass pill — that styling is 52 - * reserved for the Explore CTA above and the avatar trigger that 53 - * appears post-sign-in. */ 54 51 if (!user) { 52 + /** 53 + * Signed out with remembered accounts — show the dropdown in a 54 + * "signed out" state so the user can see (and one-click switch 55 + * back to) any account they previously authenticated with on this 56 + * device. This is the state you land in after /oauth/add-account 57 + * clears the active session while preserving the cookie. 58 + */ 59 + if (accounts.length > 0) { 60 + return <SignedOutMenu rememberedAccounts={accounts} />; 61 + } 62 + /** Fully signed out, no remembered accounts — plain link. */ 55 63 return ( 56 64 <a 57 65 href="/explore/create" ··· 68 76 accountType={accountType ?? null} 69 77 avatarUrl={avatarUrl ?? null} 70 78 publicProfileHandle={publicProfileHandle ?? null} 71 - rememberedAccounts={rememberedAccounts ?? []} 79 + rememberedAccounts={accounts} 72 80 /> 81 + ); 82 + } 83 + 84 + /** Shown when the session is cleared but the device still has remembered 85 + * accounts (e.g. after /oauth/add-account redirects to the sign-in page). 86 + * Gives the user a quick way to switch back without having to type their 87 + * handle again. */ 88 + function SignedOutMenu( 89 + { rememberedAccounts }: { rememberedAccounts: RememberedAccount[] }, 90 + ) { 91 + const t = useT().nav.account; 92 + const open = useSignal(false); 93 + const wrapRef = useRef<HTMLDivElement | null>(null); 94 + const triggerRef = useRef<HTMLButtonElement | null>(null); 95 + 96 + useEffect(() => { 97 + function onPointerDown(e: PointerEvent) { 98 + if (!wrapRef.current) return; 99 + const node = e.target; 100 + if (node instanceof Node && !wrapRef.current.contains(node)) { 101 + open.value = false; 102 + } 103 + } 104 + function onKey(e: KeyboardEvent) { 105 + if (e.key === "Escape" && open.value) { 106 + open.value = false; 107 + triggerRef.current?.focus(); 108 + } 109 + } 110 + document.addEventListener("pointerdown", onPointerDown); 111 + document.addEventListener("keydown", onKey); 112 + return () => { 113 + document.removeEventListener("pointerdown", onPointerDown); 114 + document.removeEventListener("keydown", onKey); 115 + }; 116 + }, []); 117 + 118 + return ( 119 + <div class="account-menu" ref={wrapRef}> 120 + <button 121 + ref={triggerRef} 122 + type="button" 123 + class="account-menu-trigger account-menu-trigger--signed-out" 124 + aria-haspopup="menu" 125 + aria-expanded={open.value} 126 + aria-label={t.menuLabel} 127 + onClick={() => { 128 + open.value = !open.value; 129 + }} 130 + > 131 + <span class="account-menu-avatar" aria-hidden="true"> 132 + <span class="account-menu-avatar-initial">?</span> 133 + </span> 134 + <span class="account-menu-chevron" aria-hidden="true">▾</span> 135 + </button> 136 + 137 + {open.value && ( 138 + <div class="account-menu-popup glass" role="menu"> 139 + <div class="account-menu-header"> 140 + <span class="account-menu-header-label"> 141 + {t.signedOut} 142 + </span> 143 + </div> 144 + <div class="account-menu-divider" aria-hidden="true" /> 145 + <div class="account-menu-section-label">{t.yourAccounts}</div> 146 + {rememberedAccounts.map((account) => ( 147 + <SwitchRow 148 + key={account.did} 149 + account={account} 150 + forgetLabel={t.forget} 151 + switchLabel={t.switchTo(account.handle)} 152 + forgetConfirm={t.forgetConfirm(account.handle)} 153 + /> 154 + ))} 155 + <div class="account-menu-divider" aria-hidden="true" /> 156 + <a 157 + href="/explore/create" 158 + class="account-menu-item account-menu-item-add" 159 + role="menuitem" 160 + onClick={() => { 161 + open.value = false; 162 + }} 163 + > 164 + <span class="account-menu-add-glyph" aria-hidden="true">+</span> 165 + {t.signIn} 166 + </a> 167 + </div> 168 + )} 169 + </div> 73 170 ); 74 171 } 75 172
+10 -13
lib/oauth.ts
··· 36 36 } from "./env.ts"; 37 37 38 38 /** 39 - * Minimum-permission scope. 39 + * Scope requested during PAR. 40 40 * 41 - * Composed of explicit granular scopes: 42 - * - `atproto` - identity 43 - * - `repo:com.atmosphereaccount.registry.profile` - profile writes 44 - * - `repo:com.atmosphereaccount.registry.review` - review writes 45 - * - `repo:com.atmosphereaccount.registry.update` - update writes 46 - * - `blob:image/*` - avatar + 47 - * SVG icon 48 - * uploads 41 + * `com.atmosphereaccount.registry.fullPermissions` is a named permission set 42 + * (lexicon type: permission-set, title: "Atmosphere Account") that maps to 43 + * exactly the three direct repo scopes listed below. Including it as the 44 + * first scope causes the PDS consent screen to display the branded 45 + * "Atmosphere Account" label and description instead of raw NSID strings. 49 46 * 50 - * We keep `com.atmosphereaccount.registry.fullPermissions` published as a 51 - * human-friendly permission set, but request direct repo scopes here so login 52 - * does not depend on every PDS correctly resolving DNS-backed permission sets. 47 + * The explicit `repo:*` scopes are kept alongside it so that PDSes unable to 48 + * resolve the DNS-backed permission set still grant the correct permissions, 49 + * and sign-in never fails solely because of DNS. 53 50 * 54 51 * MUST stay in sync with `routes/oauth/client-metadata.json.ts`. 55 52 */ 56 53 const DEFAULT_SCOPE = 57 - "atproto repo:com.atmosphereaccount.registry.profile repo:com.atmosphereaccount.registry.review repo:com.atmosphereaccount.registry.update blob:image/*"; 54 + "atproto com.atmosphereaccount.registry.fullPermissions repo:com.atmosphereaccount.registry.profile repo:com.atmosphereaccount.registry.review repo:com.atmosphereaccount.registry.update blob:image/*"; 58 55 const STATE_TTL_MS = 10 * 60 * 1000; 59 56 const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60 * 1000; 60 57
+5 -3
routes/oauth/client-metadata.json.ts
··· 25 25 redirect_uris: [redirectUri()], 26 26 /** 27 27 * MUST stay in sync with `DEFAULT_SCOPE` in `lib/oauth.ts`. 28 - * Direct repo scopes avoid depending on every PDS correctly resolving 29 - * DNS-backed permission sets during login. 28 + * 29 + * The named permission set comes first so PDS consent screens show the 30 + * branded "Atmosphere Account" label. The direct `repo:*` scopes follow 31 + * as a fallback grant for PDSes that cannot resolve the DNS-backed set. 30 32 */ 31 33 scope: 32 - "atproto repo:com.atmosphereaccount.registry.profile repo:com.atmosphereaccount.registry.review repo:com.atmosphereaccount.registry.update blob:image/*", 34 + "atproto com.atmosphereaccount.registry.fullPermissions repo:com.atmosphereaccount.registry.profile repo:com.atmosphereaccount.registry.review repo:com.atmosphereaccount.registry.update blob:image/*", 33 35 dpop_bound_access_tokens: true, 34 36 token_endpoint_auth_method: "private_key_jwt", 35 37 token_endpoint_auth_signing_alg: "ES256",