this repo has no description
0
fork

Configure Feed

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

feat(account): remembered accounts switcher and avatar cache bust

- Add HMAC-signed atmo_accounts cookie and rememberedAccounts on session state
- OAuth callback appends signed-in identity; add /oauth/switch, /forget, /add-account
- AccountMenu: switch between remembered accounts, forget, add account; DID-keyed avatar
- buildAccountMenuProps: /api/me/avatar?v=<did> so browser cache does not mix users
- Wire Nav account props across explore and admin pages; i18n + styles

Made-with: Cursor

+767 -119
+111
assets/styles.css
··· 3632 3632 line-height: 1.4; 3633 3633 } 3634 3634 3635 + /* --- Account switcher (remembered accounts on this device) --- */ 3636 + 3637 + .account-menu-section-label { 3638 + padding: 0.6rem 0.7rem 0.3rem; 3639 + font-size: 0.72rem; 3640 + font-weight: 600; 3641 + letter-spacing: 0.04em; 3642 + text-transform: uppercase; 3643 + color: rgba(18, 26, 47, 0.55); 3644 + } 3645 + 3646 + .account-menu-switch-row { 3647 + display: flex; 3648 + align-items: stretch; 3649 + gap: 0.25rem; 3650 + } 3651 + 3652 + .account-menu-switch-form { 3653 + flex: 1; 3654 + display: contents; 3655 + } 3656 + 3657 + .account-menu-switch-btn { 3658 + display: flex; 3659 + align-items: center; 3660 + gap: 0.55rem; 3661 + flex: 1; 3662 + padding: 0.45rem 0.7rem; 3663 + } 3664 + 3665 + .account-menu-switch-btn .account-menu-avatar { 3666 + width: 26px; 3667 + height: 26px; 3668 + } 3669 + 3670 + .account-menu-switch-handle { 3671 + font-weight: 500; 3672 + font-size: 0.85rem; 3673 + white-space: nowrap; 3674 + overflow: hidden; 3675 + text-overflow: ellipsis; 3676 + } 3677 + 3678 + .account-menu-forget-form { 3679 + display: flex; 3680 + align-items: center; 3681 + } 3682 + 3683 + .account-menu-forget-btn { 3684 + background: transparent; 3685 + border: none; 3686 + color: rgba(18, 26, 47, 0.45); 3687 + font-size: 1.1rem; 3688 + line-height: 1; 3689 + width: 24px; 3690 + height: 24px; 3691 + margin-right: 0.35rem; 3692 + border-radius: 6px; 3693 + cursor: pointer; 3694 + transition: background 0.15s ease, color 0.15s ease; 3695 + } 3696 + 3697 + .account-menu-forget-btn:hover, 3698 + .account-menu-forget-btn:focus-visible { 3699 + background: rgba(146, 32, 32, 0.08); 3700 + color: rgba(146, 32, 32, 0.95); 3701 + outline: none; 3702 + } 3703 + 3704 + .account-menu-item-add { 3705 + display: flex; 3706 + align-items: center; 3707 + gap: 0.5rem; 3708 + font-size: 0.85rem; 3709 + color: rgba(18, 26, 47, 0.78); 3710 + } 3711 + 3712 + .account-menu-add-glyph { 3713 + display: inline-flex; 3714 + align-items: center; 3715 + justify-content: center; 3716 + width: 20px; 3717 + height: 20px; 3718 + border-radius: 50%; 3719 + background: rgba(18, 26, 47, 0.08); 3720 + font-size: 0.95rem; 3721 + line-height: 1; 3722 + } 3723 + 3635 3724 /* Dark-phase variants — match the sign-in preview palette so both 3636 3725 * float on the same hero gradients without re-tinting work. */ 3637 3726 .dark-phase .account-menu-trigger { ··· 3687 3776 3688 3777 .dark-phase .account-menu-hint { 3689 3778 color: rgba(255, 255, 255, 0.7); 3779 + } 3780 + 3781 + .dark-phase .account-menu-section-label { 3782 + color: rgba(255, 255, 255, 0.55); 3783 + } 3784 + 3785 + .dark-phase .account-menu-forget-btn { 3786 + color: rgba(255, 255, 255, 0.55); 3787 + } 3788 + 3789 + .dark-phase .account-menu-forget-btn:hover, 3790 + .dark-phase .account-menu-forget-btn:focus-visible { 3791 + background: rgba(244, 163, 163, 0.18); 3792 + color: #f4a3a3; 3793 + } 3794 + 3795 + .dark-phase .account-menu-item-add { 3796 + color: rgba(255, 255, 255, 0.85); 3797 + } 3798 + 3799 + .dark-phase .account-menu-add-glyph { 3800 + background: rgba(255, 255, 255, 0.12); 3690 3801 } 3691 3802 3692 3803 @media (max-width: 480px) {
+6
components/Nav.tsx
··· 15 15 user: { did: string; handle: string } | null; 16 16 avatarUrl?: string | null; 17 17 publicProfileHandle?: string | null; 18 + /** Other accounts that have signed in on this device, used to 19 + * power the in-menu account switcher. Optional — pages that 20 + * don't have access to the per-request value (e.g. the static 21 + * marketing nav) can omit it. */ 22 + rememberedAccounts?: { did: string; handle: string }[]; 18 23 }; 19 24 } 20 25 ··· 44 49 user={account.user} 45 50 avatarUrl={account.avatarUrl ?? null} 46 51 publicProfileHandle={account.publicProfileHandle ?? null} 52 + rememberedAccounts={account.rememberedAccounts ?? []} 47 53 /> 48 54 </div> 49 55 )}
+6
i18n/messages/en.tsx
··· 43 43 viewProfile: "View profile", 44 44 signOut: "Sign out", 45 45 avatarAlt: "Account", 46 + switchHeading: "Switch account", 47 + switchTo: (handle: string): string => `Switch to @${handle}`, 48 + addAccount: "Add another account", 49 + forget: "Forget", 50 + forgetConfirm: (handle: string): string => 51 + `Forget @${handle} on this device? You'll need to sign in again to switch back.`, 46 52 }, 47 53 }, 48 54
+160 -22
islands/AccountMenu.tsx
··· 2 2 import { useEffect, useRef } from "preact/hooks"; 3 3 import { useT } from "../i18n/mod.ts"; 4 4 5 + interface RememberedAccount { 6 + did: string; 7 + handle: string; 8 + } 9 + 5 10 interface Props { 6 11 /** null when signed out — drives whether the menu shows sign-in or 7 12 * sign-out + manage actions. */ ··· 9 14 /** 10 15 * Server-resolved avatar URL (typically /api/me/avatar). Falls back 11 16 * to a handle-initial pill if the image 404s or fails to load. 17 + * 18 + * The route handler is responsible for cache-busting per-DID so 19 + * switching accounts doesn't show the previous user's portrait — 20 + * see e.g. `routes/explore.tsx` which appends `?v=<did>`. 12 21 */ 13 22 avatarUrl?: string | null; 14 23 /** ··· 17 26 * see. Otherwise we just show "Manage profile". 18 27 */ 19 28 publicProfileHandle?: string | null; 29 + /** 30 + * Accounts that have completed OAuth on this device. Drives the 31 + * switcher list inside the menu — accounts other than the current 32 + * one render as one-click switch buttons that POST to /oauth/switch. 33 + * Defaults to an empty list (renders nothing) when omitted. 34 + */ 35 + rememberedAccounts?: RememberedAccount[]; 20 36 } 21 37 22 38 export default function AccountMenu( 23 - { user, avatarUrl, publicProfileHandle }: Props, 39 + { 40 + user, 41 + avatarUrl, 42 + publicProfileHandle, 43 + rememberedAccounts, 44 + }: Props, 24 45 ) { 25 46 const t = useT().nav.account; 26 47 ··· 44 65 user={user} 45 66 avatarUrl={avatarUrl ?? null} 46 67 publicProfileHandle={publicProfileHandle ?? null} 68 + rememberedAccounts={rememberedAccounts ?? []} 47 69 /> 48 70 ); 49 71 } ··· 52 74 user: { did: string; handle: string }; 53 75 avatarUrl: string | null; 54 76 publicProfileHandle: string | null; 77 + rememberedAccounts: RememberedAccount[]; 55 78 } 56 79 57 80 function SignedInMenu( 58 - { user, avatarUrl, publicProfileHandle }: SignedInMenuProps, 81 + { user, avatarUrl, publicProfileHandle, rememberedAccounts }: 82 + SignedInMenuProps, 59 83 ) { 60 84 const t = useT().nav.account; 61 85 const open = useSignal(false); 62 - const avatarFailed = useSignal(false); 63 86 64 87 const wrapRef = useRef<HTMLDivElement | null>(null); 65 88 const triggerRef = useRef<HTMLButtonElement | null>(null); ··· 86 109 }; 87 110 }, []); 88 111 89 - /** First letter of the handle for the fallback avatar. For DIDs 90 - * (e.g. "did:plc:abc") fall back to "?". */ 91 - const initial = user.handle?.[0]?.toUpperCase() ?? "?"; 92 - const showImage = !!avatarUrl && !avatarFailed.value; 112 + const others = rememberedAccounts.filter((a) => a.did !== user.did); 93 113 94 114 return ( 95 115 <div class="account-menu" ref={wrapRef}> ··· 104 124 open.value = !open.value; 105 125 }} 106 126 > 107 - <span class="account-menu-avatar" aria-hidden="true"> 108 - {showImage 109 - ? ( 110 - <img 111 - src={avatarUrl!} 112 - alt="" 113 - loading="eager" 114 - decoding="async" 115 - onError={() => { 116 - avatarFailed.value = true; 117 - }} 118 - /> 119 - ) 120 - : <span class="account-menu-avatar-initial">{initial}</span>} 121 - </span> 127 + <Avatar 128 + /** Re-key on `user.did` so when the user switches accounts 129 + * Preact actually unmounts the previous <img> instead of 130 + * reusing the DOM node (which would have caused the cached 131 + * pixels for the previous account to flash before the new 132 + * source loaded). */ 133 + key={user.did} 134 + url={avatarUrl} 135 + handle={user.handle} 136 + /> 122 137 <span class="account-menu-chevron" aria-hidden="true">▾</span> 123 138 </button> 124 139 ··· 168 183 {t.signOut} 169 184 </button> 170 185 </form> 186 + 187 + { 188 + /* Always render the switcher section so users can add a 189 + * second account even when only one is remembered — the 190 + * list of switch rows just collapses to empty in that case. */ 191 + } 192 + <div class="account-menu-divider" aria-hidden="true" /> 193 + <div class="account-menu-section-label"> 194 + {t.switchHeading} 195 + </div> 196 + {others.map((account) => ( 197 + <SwitchRow 198 + key={account.did} 199 + account={account} 200 + forgetLabel={t.forget} 201 + switchLabel={t.switchTo(account.handle)} 202 + forgetConfirm={t.forgetConfirm(account.handle)} 203 + /> 204 + ))} 205 + { 206 + /* POST so the server can clear the live session and route 207 + * the browser to /explore/create even when the user is 208 + * currently signed in (a normal /explore/create GET would 209 + * redirect them back to /explore/manage). */ 210 + } 211 + <form 212 + method="POST" 213 + action="/oauth/add-account" 214 + class="account-menu-form" 215 + > 216 + <button 217 + type="submit" 218 + class="account-menu-item account-menu-item-add" 219 + role="menuitem" 220 + > 221 + <span class="account-menu-add-glyph" aria-hidden="true">+</span> 222 + {t.addAccount} 223 + </button> 224 + </form> 171 225 </div> 172 226 )} 173 227 </div> 174 228 ); 175 229 } 230 + 231 + interface SwitchRowProps { 232 + account: RememberedAccount; 233 + switchLabel: string; 234 + forgetLabel: string; 235 + forgetConfirm: string; 236 + } 237 + 238 + function SwitchRow( 239 + { account, switchLabel, forgetLabel, forgetConfirm }: SwitchRowProps, 240 + ) { 241 + return ( 242 + <div class="account-menu-switch-row" role="none"> 243 + <form 244 + method="POST" 245 + action="/oauth/switch" 246 + class="account-menu-switch-form" 247 + > 248 + <input type="hidden" name="did" value={account.did} /> 249 + <button 250 + type="submit" 251 + class="account-menu-item account-menu-switch-btn" 252 + role="menuitem" 253 + aria-label={switchLabel} 254 + title={switchLabel} 255 + > 256 + <Avatar 257 + url={`/api/registry/avatar/${encodeURIComponent(account.did)}`} 258 + handle={account.handle} 259 + /> 260 + <span class="account-menu-switch-handle">@{account.handle}</span> 261 + </button> 262 + </form> 263 + <form 264 + method="POST" 265 + action="/oauth/forget" 266 + class="account-menu-forget-form" 267 + onSubmit={(e) => { 268 + if (!globalThis.confirm(forgetConfirm)) { 269 + e.preventDefault(); 270 + } 271 + }} 272 + > 273 + <input type="hidden" name="did" value={account.did} /> 274 + <button 275 + type="submit" 276 + class="account-menu-forget-btn" 277 + aria-label={`${forgetLabel} @${account.handle}`} 278 + title={forgetLabel} 279 + > 280 + × 281 + </button> 282 + </form> 283 + </div> 284 + ); 285 + } 286 + 287 + interface AvatarProps { 288 + url: string | null; 289 + handle: string; 290 + } 291 + 292 + function Avatar({ url, handle }: AvatarProps) { 293 + const failed = useSignal(false); 294 + const initial = handle?.[0]?.toUpperCase() ?? "?"; 295 + const showImage = !!url && !failed.value; 296 + return ( 297 + <span class="account-menu-avatar" aria-hidden="true"> 298 + {showImage 299 + ? ( 300 + <img 301 + src={url!} 302 + alt="" 303 + loading="eager" 304 + decoding="async" 305 + onError={() => { 306 + failed.value = true; 307 + }} 308 + /> 309 + ) 310 + : <span class="account-menu-avatar-initial">{initial}</span>} 311 + </span> 312 + ); 313 + }
+34
lib/account-menu-props.ts
··· 1 + /** 2 + * Tiny helper that assembles the props every signed-in `Nav account` 3 + * needs: the live user, a DID-versioned avatar URL (so the browser 4 + * doesn't reuse the previous account's portrait after switching), 5 + * the public profile handle for deep-linking, and the per-device 6 + * remembered-accounts list that powers the in-menu switcher. 7 + * 8 + * The avatar URL deliberately includes the DID as a query param — 9 + * `/api/me/avatar` resolves identity from the session cookie, so the 10 + * URL string would otherwise collide across accounts and the browser 11 + * would happily serve its `private, max-age=300` cache from the 12 + * previous user. 13 + */ 14 + import type { State } from "../utils.ts"; 15 + 16 + interface AccountMenuProps { 17 + user: { did: string; handle: string } | null; 18 + avatarUrl: string | null; 19 + publicProfileHandle: string | null; 20 + rememberedAccounts: { did: string; handle: string }[]; 21 + } 22 + 23 + export function buildAccountMenuProps( 24 + state: Pick<State, "user" | "rememberedAccounts">, 25 + publicProfileHandle: string | null = null, 26 + ): AccountMenuProps { 27 + const user = state.user; 28 + return { 29 + user: user ? { did: user.did, handle: user.handle } : null, 30 + avatarUrl: user ? `/api/me/avatar?v=${encodeURIComponent(user.did)}` : null, 31 + publicProfileHandle, 32 + rememberedAccounts: state.rememberedAccounts ?? [], 33 + }; 34 + }
+167
lib/remembered-accounts.ts
··· 1 + /** 2 + * Per-device "remembered accounts" cookie. Powers the in-menu account 3 + * switcher: every successful OAuth callback appends the (did, handle) 4 + * pair to a long-lived HMAC-signed cookie so subsequent visits can 5 + * offer one-click switching between accounts the user has signed in 6 + * with on this browser. 7 + * 8 + * The cookie carries no secrets — just identifiers — but is signed so 9 + * a tampered payload can't trick the server into surfacing accounts 10 + * the user never authenticated as. Authority for actually switching 11 + * still flows through the OAuth session row keyed by DID; the cookie 12 + * is only used to drive UI and to gate which DIDs the switch handler 13 + * is willing to act on without bouncing through PAR again. 14 + * 15 + * Cookie format: `<base64url(JSON)>.<hmac>` 16 + * Cookie name: `atmo_accounts` 17 + */ 18 + import { hmacSign, hmacVerify } from "./jose.ts"; 19 + import { IS_DEV, SESSION_SECRET } from "./env.ts"; 20 + 21 + export interface RememberedAccount { 22 + did: string; 23 + handle: string; 24 + } 25 + 26 + const COOKIE_NAME = "atmo_accounts"; 27 + const COOKIE_MAX_AGE_SEC = 60 * 60 * 24 * 365; // 1 year 28 + const MAX_ACCOUNTS = 8; 29 + 30 + /** Read + verify the remembered-accounts cookie from a request. Returns 31 + * an empty list when the cookie is missing, malformed, expired, or 32 + * has a bad signature. */ 33 + export async function readRememberedAccounts( 34 + req: Request, 35 + ): Promise<RememberedAccount[]> { 36 + const raw = readCookieValue(req.headers.get("cookie")); 37 + if (!raw) return []; 38 + return await parseSignedValue(raw); 39 + } 40 + 41 + /** Same as `readRememberedAccounts` but takes the raw cookie header 42 + * directly. Used by routes that need to compute the next cookie value 43 + * off the existing one (e.g. callback / switch / forget). */ 44 + export async function readRememberedAccountsFromHeader( 45 + cookieHeader: string | null, 46 + ): Promise<RememberedAccount[]> { 47 + const raw = readCookieValue(cookieHeader); 48 + if (!raw) return []; 49 + return await parseSignedValue(raw); 50 + } 51 + 52 + /** Build a Set-Cookie header that adds `account` to the remembered 53 + * list (or refreshes its position) and returns the new cookie. The 54 + * most-recently-active account is sorted to the front, capped at 55 + * `MAX_ACCOUNTS`. */ 56 + export async function addRememberedAccountCookie( 57 + current: RememberedAccount[], 58 + account: RememberedAccount, 59 + ): Promise<string> { 60 + const filtered = current.filter((a) => a.did !== account.did); 61 + const next = [account, ...filtered].slice(0, MAX_ACCOUNTS); 62 + return await buildCookie(next); 63 + } 64 + 65 + /** Build a Set-Cookie header that removes `did` from the remembered 66 + * list. If the resulting list is empty, the cookie is cleared 67 + * outright instead of being signed-empty. */ 68 + export async function removeRememberedAccountCookie( 69 + current: RememberedAccount[], 70 + did: string, 71 + ): Promise<string> { 72 + const next = current.filter((a) => a.did !== did); 73 + if (next.length === 0) return clearRememberedAccountsCookie(); 74 + return await buildCookie(next); 75 + } 76 + 77 + /** Set-Cookie value that clears the cookie. Sent on logout flows 78 + * that should also wipe device memory (we don't currently use this 79 + * on the standard sign-out — only when explicitly forgetting all 80 + * accounts). */ 81 + export function clearRememberedAccountsCookie(): string { 82 + const flags = ["Path=/", "Max-Age=0", "HttpOnly", "SameSite=Lax"]; 83 + if (!IS_DEV) flags.push("Secure"); 84 + return `${COOKIE_NAME}=; ${flags.join("; ")}`; 85 + } 86 + 87 + /* ---------------- internals ---------------- */ 88 + 89 + function readCookieValue(cookieHeader: string | null): string | null { 90 + if (!cookieHeader) return null; 91 + const target = cookieHeader.split(";").map((c) => c.trim()).find((c) => 92 + c.startsWith(`${COOKIE_NAME}=`) 93 + ); 94 + if (!target) return null; 95 + return decodeURIComponent(target.slice(COOKIE_NAME.length + 1)); 96 + } 97 + 98 + async function parseSignedValue(value: string): Promise<RememberedAccount[]> { 99 + const dot = value.lastIndexOf("."); 100 + if (dot < 0) return []; 101 + const payload = value.slice(0, dot); 102 + const sig = value.slice(dot + 1); 103 + if (!payload || !sig) return []; 104 + 105 + const ok = await hmacVerify(SESSION_SECRET, payload, sig).catch(() => false); 106 + if (!ok) return []; 107 + 108 + let parsed: unknown; 109 + try { 110 + const json = new TextDecoder().decode(b64uDecodeBytes(payload)); 111 + parsed = JSON.parse(json); 112 + } catch { 113 + return []; 114 + } 115 + if (!Array.isArray(parsed)) return []; 116 + const out: RememberedAccount[] = []; 117 + const seen = new Set<string>(); 118 + for (const item of parsed) { 119 + if (!item || typeof item !== "object") continue; 120 + const did = (item as Record<string, unknown>).did; 121 + const handle = (item as Record<string, unknown>).handle; 122 + if (typeof did !== "string" || typeof handle !== "string") continue; 123 + if (!did.startsWith("did:")) continue; 124 + if (seen.has(did)) continue; 125 + seen.add(did); 126 + out.push({ did, handle }); 127 + if (out.length >= MAX_ACCOUNTS) break; 128 + } 129 + return out; 130 + } 131 + 132 + async function buildCookie(accounts: RememberedAccount[]): Promise<string> { 133 + const json = JSON.stringify( 134 + accounts.map((a) => ({ did: a.did, handle: a.handle })), 135 + ); 136 + const payload = b64uEncodeBytes(new TextEncoder().encode(json)); 137 + const sig = await hmacSign(SESSION_SECRET, payload); 138 + const value = `${payload}.${sig}`; 139 + const flags = [ 140 + "Path=/", 141 + `Max-Age=${COOKIE_MAX_AGE_SEC}`, 142 + "HttpOnly", 143 + "SameSite=Lax", 144 + ]; 145 + if (!IS_DEV) flags.push("Secure"); 146 + return `${COOKIE_NAME}=${encodeURIComponent(value)}; ${flags.join("; ")}`; 147 + } 148 + 149 + /* Local base64url helpers — kept here (rather than importing from 150 + * jose) to keep this module dependency-light. */ 151 + function b64uEncodeBytes(bytes: Uint8Array): string { 152 + let binary = ""; 153 + for (const b of bytes) binary += String.fromCharCode(b); 154 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 155 + /=+$/, 156 + "", 157 + ); 158 + } 159 + 160 + function b64uDecodeBytes(input: string): Uint8Array { 161 + const padded = input.replace(/-/g, "+").replace(/_/g, "/"); 162 + const pad = padded.length % 4 === 0 ? "" : "====".slice(padded.length % 4); 163 + const binary = atob(padded + pad); 164 + const bytes = new Uint8Array(binary.length); 165 + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); 166 + return bytes; 167 + }
+20
lib/session.ts
··· 9 9 import { withDb } from "./db.ts"; 10 10 import { hmacSign, hmacVerify, randomB64u } from "./jose.ts"; 11 11 import { IS_DEV, SESSION_SECRET } from "./env.ts"; 12 + import { readRememberedAccounts } from "./remembered-accounts.ts"; 12 13 13 14 export interface SessionUser { 14 15 did: string; ··· 30 31 }); 31 32 const sig = await hmacSign(SESSION_SECRET, sid); 32 33 return `${sid}.${sig}`; 34 + } 35 + 36 + /** 37 + * Read the active session user from a request without going through 38 + * the middleware. Useful for endpoints that run before/around the 39 + * normal middleware chain (e.g. /oauth/forget needs to know whether 40 + * to clear the session cookie even though it doesn't have a fresh 41 + * `ctx.state`). 42 + */ 43 + export async function peekSessionUser( 44 + req: Request, 45 + ): Promise<SessionUser | null> { 46 + return await readSessionCookie(req); 33 47 } 34 48 35 49 async function readSessionCookie(req: Request): Promise<SessionUser | null> { ··· 109 123 } catch (err) { 110 124 if (IS_DEV) console.warn("session read failed:", err); 111 125 ctx.state.user = null; 126 + } 127 + try { 128 + ctx.state.rememberedAccounts = await readRememberedAccounts(ctx.req); 129 + } catch (err) { 130 + if (IS_DEV) console.warn("remembered accounts read failed:", err); 131 + ctx.state.rememberedAccounts = []; 112 132 } 113 133 return await ctx.next(); 114 134 });
+7 -10
routes/admin/featured.tsx
··· 19 19 type ProfilePickerRow, 20 20 type ProfileRow, 21 21 } from "../../lib/registry.ts"; 22 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 22 23 23 24 export const handler = define.handlers({ 24 25 async GET(ctx) { ··· 32 33 })); 33 34 return ctx.render( 34 35 <AdminFeaturedPage 35 - user={ctx.state.user!} 36 + account={buildAccountMenuProps(ctx.state)} 36 37 candidates={candidates} 37 38 initial={initial} 38 39 locale={ctx.state.locale} ··· 42 43 }); 43 44 44 45 interface PageProps { 45 - user: { did: string; handle: string }; 46 + account: ReturnType<typeof buildAccountMenuProps>; 46 47 candidates: FeaturedCandidate[]; 47 48 initial: FeaturedEntryDraft[]; 48 49 locale: Locale; 49 50 } 50 51 51 - function AdminFeaturedPage({ user, candidates, initial, locale }: PageProps) { 52 + function AdminFeaturedPage( 53 + { account, candidates, initial, locale }: PageProps, 54 + ) { 52 55 const t = getMessages(locale).admin; 53 56 return ( 54 57 <div id="page-top"> 55 58 <GlassClouds /> 56 59 <div class="content-layer"> 57 - <Nav 58 - account={{ 59 - user: { did: user.did, handle: user.handle }, 60 - avatarUrl: "/api/me/avatar", 61 - publicProfileHandle: null, 62 - }} 63 - /> 60 + <Nav account={account} /> 64 61 <section class="admin-section"> 65 62 <div class="container" style={{ maxWidth: "1080px" }}> 66 63 <p>
+5 -10
routes/admin/icon-access.tsx
··· 17 17 listGrantedIconAccess, 18 18 listPendingIconAccess, 19 19 } from "../../lib/registry.ts"; 20 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 20 21 21 22 export const handler = define.handlers({ 22 23 async GET(ctx) { ··· 26 27 ]); 27 28 return ctx.render( 28 29 <Page 29 - user={ctx.state.user!} 30 + account={buildAccountMenuProps(ctx.state)} 30 31 pending={pending} 31 32 granted={granted} 32 33 locale={ctx.state.locale} ··· 36 37 }); 37 38 38 39 interface PageProps { 39 - user: { did: string; handle: string }; 40 + account: ReturnType<typeof buildAccountMenuProps>; 40 41 pending: IconAccessRequestRow[]; 41 42 granted: GrantedIconAccessRow[]; 42 43 locale: Locale; 43 44 } 44 45 45 - function Page({ user, pending, granted, locale }: PageProps) { 46 + function Page({ account, pending, granted, locale }: PageProps) { 46 47 const t = getMessages(locale).admin; 47 48 const ti = t.iconAccess; 48 49 return ( 49 50 <div id="page-top"> 50 51 <GlassClouds /> 51 52 <div class="content-layer"> 52 - <Nav 53 - account={{ 54 - user: { did: user.did, handle: user.handle }, 55 - avatarUrl: "/api/me/avatar", 56 - publicProfileHandle: null, 57 - }} 58 - /> 53 + <Nav account={account} /> 59 54 <section class="admin-section"> 60 55 <div class="container" style={{ maxWidth: "920px" }}> 61 56 <p>
+6 -10
routes/admin/index.tsx
··· 13 13 countTakenDownProfiles, 14 14 } from "../../lib/registry.ts"; 15 15 import { countOpenReports } from "../../lib/reports.ts"; 16 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 16 17 17 18 export const handler = define.handlers({ 18 19 async GET(ctx) { ··· 23 24 ]); 24 25 return ctx.render( 25 26 <AdminHome 26 - user={ctx.state.user!} 27 + account={buildAccountMenuProps(ctx.state)} 27 28 iconAccessRequests={iconAccessRequests} 28 29 openReports={openReports} 29 30 takenDown={takenDown} ··· 34 35 }); 35 36 36 37 interface AdminHomeProps { 37 - user: { did: string; handle: string }; 38 + account: ReturnType<typeof buildAccountMenuProps>; 38 39 iconAccessRequests: number; 39 40 openReports: number; 40 41 takenDown: number; ··· 42 43 } 43 44 44 45 function AdminHome( 45 - { user, iconAccessRequests, openReports, takenDown, locale }: AdminHomeProps, 46 + { account, iconAccessRequests, openReports, takenDown, locale }: 47 + AdminHomeProps, 46 48 ) { 47 49 const t = getMessages(locale).admin; 48 50 return ( 49 51 <div id="page-top"> 50 52 <GlassClouds /> 51 53 <div class="content-layer"> 52 - <Nav 53 - account={{ 54 - user: { did: user.did, handle: user.handle }, 55 - avatarUrl: "/api/me/avatar", 56 - publicProfileHandle: null, 57 - }} 58 - /> 54 + <Nav account={account} /> 59 55 <section class="admin-section"> 60 56 <div class="container" style={{ maxWidth: "920px" }}> 61 57 <header class="admin-header">
+5 -10
routes/admin/reports.tsx
··· 11 11 import type { Locale } from "../../i18n/mod.ts"; 12 12 import { getProfileByDid } from "../../lib/registry.ts"; 13 13 import { listOpenReports, type ReportRow } from "../../lib/reports.ts"; 14 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 14 15 15 16 interface ReportWithHandle extends ReportRow { 16 17 targetHandle: string; ··· 32 33 ); 33 34 return ctx.render( 34 35 <AdminReportsPage 35 - user={ctx.state.user!} 36 + account={buildAccountMenuProps(ctx.state)} 36 37 reports={enriched} 37 38 locale={ctx.state.locale} 38 39 />, ··· 41 42 }); 42 43 43 44 interface PageProps { 44 - user: { did: string; handle: string }; 45 + account: ReturnType<typeof buildAccountMenuProps>; 45 46 reports: ReportWithHandle[]; 46 47 locale: Locale; 47 48 } 48 49 49 - function AdminReportsPage({ user, reports, locale }: PageProps) { 50 + function AdminReportsPage({ account, reports, locale }: PageProps) { 50 51 const t = getMessages(locale).admin; 51 52 return ( 52 53 <div id="page-top"> 53 54 <GlassClouds /> 54 55 <div class="content-layer"> 55 - <Nav 56 - account={{ 57 - user: { did: user.did, handle: user.handle }, 58 - avatarUrl: "/api/me/avatar", 59 - publicProfileHandle: null, 60 - }} 61 - /> 56 + <Nav account={account} /> 62 57 <section class="admin-section"> 63 58 <div class="container" style={{ maxWidth: "920px" }}> 64 59 <p>
+5 -10
routes/admin/takedowns.tsx
··· 14 14 listTakenDownProfiles, 15 15 type TakenDownProfileRow, 16 16 } from "../../lib/registry.ts"; 17 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 17 18 18 19 export const handler = define.handlers({ 19 20 async GET(ctx) { ··· 22 23 ); 23 24 return ctx.render( 24 25 <AdminTakedownsPage 25 - user={ctx.state.user!} 26 + account={buildAccountMenuProps(ctx.state)} 26 27 rows={rows} 27 28 locale={ctx.state.locale} 28 29 />, ··· 31 32 }); 32 33 33 34 interface PageProps { 34 - user: { did: string; handle: string }; 35 + account: ReturnType<typeof buildAccountMenuProps>; 35 36 rows: TakenDownProfileRow[]; 36 37 locale: Locale; 37 38 } 38 39 39 - function AdminTakedownsPage({ user, rows, locale }: PageProps) { 40 + function AdminTakedownsPage({ account, rows, locale }: PageProps) { 40 41 const t = getMessages(locale).admin; 41 42 return ( 42 43 <div id="page-top"> 43 44 <GlassClouds /> 44 45 <div class="content-layer"> 45 - <Nav 46 - account={{ 47 - user: { did: user.did, handle: user.handle }, 48 - avatarUrl: "/api/me/avatar", 49 - publicProfileHandle: null, 50 - }} 51 - /> 46 + <Nav account={account} /> 52 47 <section class="admin-section"> 53 48 <div class="container" style={{ maxWidth: "920px" }}> 54 49 <p>
+6 -10
routes/explore.tsx
··· 14 14 searchProfiles, 15 15 } from "../lib/registry.ts"; 16 16 import { CATEGORIES } from "../lib/lexicons.ts"; 17 + import { buildAccountMenuProps } from "../lib/account-menu-props.ts"; 17 18 18 19 interface ExploreData { 19 20 query: string; ··· 25 26 profiles: ProfileRow[]; 26 27 featured: ProfileRow[]; 27 28 signedIn: boolean; 28 - account: { 29 - user: { did: string; handle: string } | null; 30 - avatarUrl: string | null; 31 - publicProfileHandle: string | null; 32 - }; 29 + account: ReturnType<typeof buildAccountMenuProps>; 33 30 } 34 31 35 32 export const handler = define.handlers({ ··· 80 77 profiles: search.profiles, 81 78 featured, 82 79 signedIn: !!user, 83 - account: { 84 - user: user ? { did: user.did, handle: user.handle } : null, 85 - avatarUrl: user ? "/api/me/avatar" : null, 86 - publicProfileHandle: ownerProfile?.handle ?? null, 87 - }, 80 + account: buildAccountMenuProps( 81 + ctx.state, 82 + ownerProfile?.handle ?? null, 83 + ), 88 84 }; 89 85 return ctx.render(<ExplorePage data={data} locale={ctx.state.locale} />); 90 86 },
+8 -14
routes/explore/[handle].tsx
··· 13 13 type ProfileRow, 14 14 } from "../../lib/registry.ts"; 15 15 import { accountProviderName } from "../../lib/account-providers.ts"; 16 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 16 17 17 18 export const handler = define.handlers({ 18 19 async GET(ctx) { ··· 32 33 <ProfileDetailPage 33 34 profile={profile} 34 35 signedInUser={user ? { did: user.did, handle: user.handle } : null} 36 + account={buildAccountMenuProps(ctx.state, ownerProfile?.handle ?? null)} 35 37 ownerHandle={ownerProfile?.handle ?? null} 36 38 locale={ctx.state.locale} 37 39 />, ··· 43 45 interface DetailProps { 44 46 profile: ProfileRow | null; 45 47 signedInUser: { did: string; handle: string } | null; 48 + account: ReturnType<typeof buildAccountMenuProps>; 46 49 ownerHandle: string | null; 47 50 locale: Locale; 48 51 } 49 52 50 53 function ProfileDetailPage( 51 - { profile, signedInUser, ownerHandle, locale }: DetailProps, 54 + { profile, signedInUser, account, ownerHandle: _ownerHandle, locale }: 55 + DetailProps, 52 56 ) { 53 57 const messages = getMessages(locale); 54 58 const t = messages.explore; ··· 57 61 <NotFound 58 62 locale={locale} 59 63 signedInUser={signedInUser} 60 - ownerHandle={ownerHandle} 64 + account={account} 61 65 /> 62 66 ); 63 67 } 64 68 const isOwner = signedInUser?.did === profile.did; 65 - const account = { 66 - user: signedInUser, 67 - avatarUrl: signedInUser ? "/api/me/avatar" : null, 68 - publicProfileHandle: ownerHandle, 69 - }; 70 69 const lastUpdated = new Date(profile.indexedAt).toISOString().slice(0, 10); 71 70 /** PDS hosts are usually per-shard (e.g. shimeji.us-east.host.bsky.network) 72 71 * which isn't useful in UI. Collapse known umbrella PDSes to their ··· 137 136 } 138 137 139 138 function NotFound( 140 - { locale, signedInUser, ownerHandle }: { 139 + { locale, signedInUser: _signedInUser, account }: { 141 140 locale: Locale; 142 141 signedInUser: { did: string; handle: string } | null; 143 - ownerHandle: string | null; 142 + account: ReturnType<typeof buildAccountMenuProps>; 144 143 }, 145 144 ) { 146 145 const t = getMessages(locale).explore.detail; 147 - const account = { 148 - user: signedInUser, 149 - avatarUrl: signedInUser ? "/api/me/avatar" : null, 150 - publicProfileHandle: ownerHandle, 151 - }; 152 146 return ( 153 147 <div id="page-top"> 154 148 <GlassClouds />
+8 -7
routes/explore/create.tsx
··· 5 5 import SignInForm from "../../islands/SignInForm.tsx"; 6 6 import { getMessages } from "../../i18n/mod.ts"; 7 7 import { isOAuthConfigured } from "../../lib/oauth.ts"; 8 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 8 9 9 10 export default define.page(function ExploreCreate(ctx) { 10 11 const t = getMessages(ctx.state.locale).explore; ··· 17 18 }) as unknown as preact.JSX.Element; 18 19 } 19 20 21 + /** user is null here (we redirect when signed in), but the menu can 22 + * still surface remembered accounts — that's the "switch to a 23 + * previously signed-in account" affordance for visitors who hit 24 + * this page from a deep link with cleared session cookies. */ 25 + const account = buildAccountMenuProps(ctx.state); 26 + 20 27 return ( 21 28 <div id="page-top"> 22 29 <GlassClouds /> 23 30 <div class="content-layer"> 24 - { 25 - /* user is null here (we redirect when signed in), so the menu 26 - * shows the "Sign in" entry — useful if someone lands on this 27 - * page from a deep link and wants the same affordance as the 28 - * rest of the explore section. */ 29 - } 30 - <Nav account={{ user: null }} /> 31 + <Nav account={account} /> 31 32 <section class="explore-create" style={{ paddingTop: "8rem" }}> 32 33 <div class="container" style={{ maxWidth: "640px" }}> 33 34 <p class="text-eyebrow">{t.create.eyebrow}</p>
+7 -8
routes/explore/manage.tsx
··· 7 7 import { getProfileByDid } from "../../lib/registry.ts"; 8 8 import { loadSession } from "../../lib/oauth.ts"; 9 9 import { getBskyProfile } from "../../lib/pds.ts"; 10 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 10 11 11 12 /** 12 13 * Build the deterministic public Bluesky CDN URL for a user's avatar ··· 116 117 } 117 118 : null; 118 119 120 + const publicProfileHandle = takedown ? null : existing?.handle ?? null; 119 121 return ctx.render( 120 122 <ManagePage 121 123 user={user} 124 + account={buildAccountMenuProps(ctx.state, publicProfileHandle)} 122 125 initial={initial} 123 126 initialAvatarUrl={initialAvatarUrl} 124 127 initialPublished={!!existing && !takedown} 125 - publicProfileHandle={takedown ? null : existing?.handle ?? null} 128 + publicProfileHandle={publicProfileHandle} 126 129 takedown={takedown} 127 130 t={t} 128 131 />, ··· 132 135 133 136 interface ManagePageProps { 134 137 user: { did: string; handle: string }; 138 + account: ReturnType<typeof buildAccountMenuProps>; 135 139 initial: Parameters<typeof CreateProfileForm>[0]["initial"]; 136 140 initialAvatarUrl: string | null; 137 141 initialPublished: boolean; ··· 144 148 function ManagePage( 145 149 { 146 150 user, 151 + account, 147 152 initial, 148 153 initialAvatarUrl, 149 154 initialPublished, ··· 158 163 <div id="page-top"> 159 164 <GlassClouds /> 160 165 <div class="content-layer"> 161 - <Nav 162 - account={{ 163 - user: { did: user.did, handle: user.handle }, 164 - avatarUrl: "/api/me/avatar", 165 - publicProfileHandle, 166 - }} 167 - /> 166 + <Nav account={account} /> 168 167 <section class="explore-manage" style={{ paddingTop: "8rem" }}> 169 168 <div class="container" style={{ maxWidth: "920px" }}> 170 169 <div class="manage-header">
+25
routes/oauth/add-account.ts
··· 1 + /** 2 + * "Add another account" entry point for the AccountMenu switcher. 3 + * 4 + * Clears the active app session (so /explore/create renders its 5 + * sign-in form instead of bouncing back to /explore/manage) but 6 + * leaves both the OAuth refresh tokens and the remembered-accounts 7 + * cookie intact. After the new sign-in completes the callback will 8 + * append the new identity to the list and the user can switch back 9 + * and forth from the menu. 10 + */ 11 + import { define } from "../../utils.ts"; 12 + import { clearSessionCookie, destroySession } from "../../lib/session.ts"; 13 + 14 + async function handle(ctx: { req: Request }): Promise<Response> { 15 + await destroySession(ctx.req).catch(() => {}); 16 + return new Response(null, { 17 + status: 303, 18 + headers: { 19 + location: "/explore/create", 20 + "set-cookie": clearSessionCookie(), 21 + }, 22 + }); 23 + } 24 + 25 + export const handler = define.handlers({ GET: handle, POST: handle });
+25 -8
routes/oauth/callback.ts
··· 1 1 /** 2 2 * OAuth redirect target. Exchanges the authorization code for tokens, 3 3 * persists the session, and bounces the user into /explore/manage. 4 + * 5 + * Also appends the freshly-authenticated account to the per-device 6 + * `atmo_accounts` cookie so the AccountMenu switcher can offer 7 + * one-click sign-in for any account that has previously authorised 8 + * on this browser. 4 9 */ 5 10 import { define } from "../../utils.ts"; 6 11 import { completeCallback, isOAuthConfigured } from "../../lib/oauth.ts"; 7 12 import { buildSessionCookie, createSession } from "../../lib/session.ts"; 13 + import { 14 + addRememberedAccountCookie, 15 + readRememberedAccountsFromHeader, 16 + } from "../../lib/remembered-accounts.ts"; 8 17 9 18 export const handler = define.handlers({ 10 19 async GET(ctx) { ··· 23 32 } 24 33 try { 25 34 const result = await completeCallback({ state, code, iss }); 26 - const cookieValue = await createSession({ 35 + const sessionCookie = buildSessionCookie( 36 + await createSession({ did: result.did, handle: result.handle }), 37 + ); 38 + 39 + /** Append to the per-device remembered list so the next visit 40 + * can offer this account in the switcher even if the active 41 + * session cookie has been cleared. */ 42 + const remembered = await readRememberedAccountsFromHeader( 43 + ctx.req.headers.get("cookie"), 44 + ); 45 + const rememberedCookie = await addRememberedAccountCookie(remembered, { 27 46 did: result.did, 28 47 handle: result.handle, 29 48 }); 30 - return new Response(null, { 31 - status: 303, 32 - headers: { 33 - location: "/explore/manage", 34 - "set-cookie": buildSessionCookie(cookieValue), 35 - }, 36 - }); 49 + 50 + const headers = new Headers({ location: "/explore/manage" }); 51 + headers.append("set-cookie", sessionCookie); 52 + headers.append("set-cookie", rememberedCookie); 53 + return new Response(null, { status: 303, headers }); 37 54 } catch (err) { 38 55 const message = err instanceof Error ? err.message : String(err); 39 56 return new Response(`callback failed: ${message}`, { status: 400 });
+64
routes/oauth/forget.ts
··· 1 + /** 2 + * Forget a remembered account. Removes it from the per-device 3 + * `atmo_accounts` cookie and deletes the server-side OAuth session 4 + * row so the refresh token can no longer be used. If the account 5 + * being forgotten happens to be the currently active one, the app 6 + * session cookie is cleared as well so the user is signed out. 7 + */ 8 + import { define } from "../../utils.ts"; 9 + import { deleteSession } from "../../lib/oauth.ts"; 10 + import { 11 + clearSessionCookie, 12 + destroySession, 13 + peekSessionUser, 14 + } from "../../lib/session.ts"; 15 + import { 16 + readRememberedAccountsFromHeader, 17 + removeRememberedAccountCookie, 18 + } from "../../lib/remembered-accounts.ts"; 19 + 20 + async function readDid(req: Request): Promise<string | null> { 21 + const ct = (req.headers.get("content-type") ?? "").toLowerCase(); 22 + if (ct.includes("application/json")) { 23 + const body = await req.json().catch(() => null) as 24 + | { did?: string } 25 + | null; 26 + return body?.did?.trim() ?? null; 27 + } 28 + const form = await req.formData().catch(() => null); 29 + if (!form) return null; 30 + const v = form.get("did"); 31 + return typeof v === "string" ? v.trim() : null; 32 + } 33 + 34 + async function handle(ctx: { req: Request }): Promise<Response> { 35 + const did = await readDid(ctx.req); 36 + if (!did) return new Response("missing did", { status: 400 }); 37 + 38 + const remembered = await readRememberedAccountsFromHeader( 39 + ctx.req.headers.get("cookie"), 40 + ); 41 + 42 + /** Best-effort revoke of the server-side OAuth session row. We 43 + * don't surface the error if the row was already gone — the 44 + * user's intent is "remove this from my list" either way. */ 45 + await deleteSession(did).catch(() => {}); 46 + 47 + const headers = new Headers({ location: "/explore" }); 48 + headers.append( 49 + "set-cookie", 50 + await removeRememberedAccountCookie(remembered, did), 51 + ); 52 + 53 + /** If they're forgetting the account they're currently signed in 54 + * as, clear the live app session too. */ 55 + const sessionUser = await peekSessionUser(ctx.req).catch(() => null); 56 + if (sessionUser?.did === did) { 57 + await destroySession(ctx.req).catch(() => {}); 58 + headers.append("set-cookie", clearSessionCookie()); 59 + } 60 + 61 + return new Response(null, { status: 303, headers }); 62 + } 63 + 64 + export const handler = define.handlers({ POST: handle });
+87
routes/oauth/switch.ts
··· 1 + /** 2 + * One-click account switcher. Activates a different remembered account 3 + * by minting a fresh app session bound to its DID. The OAuth refresh 4 + * token (already stored server-side from a previous callback) is 5 + * exchanged for a new access token before the session is created, so 6 + * if the refresh has expired we transparently fall back to /oauth/login 7 + * for that handle. 8 + * 9 + * Accepts the target DID via either: 10 + * - POST form body (form-urlencoded `did=…`) — used by the menu form 11 + * - POST JSON body (`{ "did": "…" }`) — used by JS callers 12 + * 13 + * The switch is gated on the DID being present in the device's 14 + * remembered-accounts cookie. That keeps random DIDs from being 15 + * promoted into a session even if the OAuth row exists from another 16 + * browser. 17 + */ 18 + import { define } from "../../utils.ts"; 19 + import { getValidSession } from "../../lib/oauth.ts"; 20 + import { 21 + buildSessionCookie, 22 + createSession, 23 + destroySession, 24 + } from "../../lib/session.ts"; 25 + import { readRememberedAccountsFromHeader } from "../../lib/remembered-accounts.ts"; 26 + 27 + async function readDid(req: Request): Promise<string | null> { 28 + const ct = (req.headers.get("content-type") ?? "").toLowerCase(); 29 + if (ct.includes("application/json")) { 30 + const body = await req.json().catch(() => null) as 31 + | { did?: string } 32 + | null; 33 + return body?.did?.trim() ?? null; 34 + } 35 + const form = await req.formData().catch(() => null); 36 + if (!form) return null; 37 + const v = form.get("did"); 38 + return typeof v === "string" ? v.trim() : null; 39 + } 40 + 41 + async function handle(ctx: { req: Request }): Promise<Response> { 42 + const did = await readDid(ctx.req); 43 + if (!did) return new Response("missing did", { status: 400 }); 44 + 45 + const remembered = await readRememberedAccountsFromHeader( 46 + ctx.req.headers.get("cookie"), 47 + ); 48 + const target = remembered.find((a) => a.did === did); 49 + if (!target) { 50 + return new Response("account not remembered on this device", { 51 + status: 403, 52 + }); 53 + } 54 + 55 + /** Try refreshing the OAuth tokens for this DID. If anything goes 56 + * wrong (revoked refresh token, server-side row evicted, PDS 57 + * unreachable) bounce to /oauth/login with a login_hint so the 58 + * user gets a one-step re-auth instead of a confusing error. */ 59 + const oauthSession = await getValidSession(did).catch(() => null); 60 + if (!oauthSession) { 61 + return new Response(null, { 62 + status: 303, 63 + headers: { 64 + location: `/oauth/login?handle=${encodeURIComponent(target.handle)}`, 65 + }, 66 + }); 67 + } 68 + 69 + /** Drop the previous app session row (if any) so we don't leak 70 + * rows in the table — the cookie itself is overwritten below. */ 71 + await destroySession(ctx.req).catch(() => {}); 72 + 73 + const cookieValue = await createSession({ 74 + did: oauthSession.did, 75 + handle: oauthSession.handle, 76 + }); 77 + 78 + return new Response(null, { 79 + status: 303, 80 + headers: { 81 + location: "/explore/manage", 82 + "set-cookie": buildSessionCookie(cookieValue), 83 + }, 84 + }); 85 + } 86 + 87 + export const handler = define.handlers({ POST: handle });
+5
utils.ts
··· 1 1 import { createDefine } from "fresh"; 2 2 import type { Locale } from "./i18n/locales.ts"; 3 + import type { RememberedAccount } from "./lib/remembered-accounts.ts"; 3 4 4 5 export interface SessionUser { 5 6 did: string; ··· 11 12 locale: Locale; 12 13 /** Logged-in registry account, or null when signed out. Set by sessionMiddleware. */ 13 14 user: SessionUser | null; 15 + /** Accounts that have completed OAuth on this device, in 16 + * most-recently-used order. Populated by sessionMiddleware so 17 + * routes can hand the list to AccountMenu for the switcher. */ 18 + rememberedAccounts: RememberedAccount[]; 14 19 // deno-lint-ignore no-explicit-any 15 20 [key: string]: any; 16 21 }