atproto explorer pdsls.dev
atproto tool
at main 7.6 kB view raw
1import { Did } from "@atcute/lexicons"; 2import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3import { A } from "@solidjs/router"; 4import { createSignal, For, onMount, Show } from "solid-js"; 5import { createStore, produce } from "solid-js/store"; 6import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7import { Modal } from "../components/modal.jsx"; 8import { Login } from "./login.jsx"; 9import { useOAuthScopeFlow } from "./scope-flow.js"; 10import { ScopeSelector } from "./scope-selector.jsx"; 11import { parseScopeString } from "./scope-utils.js"; 12import { 13 getAvatar, 14 loadHandleForSession, 15 loadSessionsFromStorage, 16 resumeSession, 17 retrieveSession, 18 saveSessionToStorage, 19} from "./session-manager.js"; 20import { agent, sessions, setAgent, setSessions } from "./state.js"; 21 22const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 23 const removeSession = async (did: Did) => { 24 const currentSession = agent()?.sub; 25 try { 26 const session = await getSession(did, { allowStale: true }); 27 const agent = new OAuthUserAgent(session); 28 await agent.signOut(); 29 } catch { 30 deleteStoredSession(did); 31 } 32 setSessions( 33 produce((accs) => { 34 delete accs[did]; 35 }), 36 ); 37 saveSessionToStorage(sessions); 38 if (currentSession === did) setAgent(undefined); 39 }; 40 41 return ( 42 <MenuProvider> 43 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2"> 44 <NavMenu 45 href={`/at://${props.did}`} 46 label={agent()?.sub === props.did ? "Go to repo (g)" : "Go to repo"} 47 icon="lucide--user-round" 48 /> 49 <ActionMenu 50 icon="lucide--settings" 51 label="Edit permissions" 52 onClick={() => props.onEditPermissions(props.did)} 53 /> 54 <ActionMenu 55 icon="lucide--x" 56 label="Remove account" 57 onClick={() => removeSession(props.did)} 58 /> 59 </DropdownMenu> 60 </MenuProvider> 61 ); 62}; 63 64export const AccountManager = () => { 65 const [openManager, setOpenManager] = createSignal(false); 66 const [avatars, setAvatars] = createStore<Record<Did, string>>(); 67 const [showingAddAccount, setShowingAddAccount] = createSignal(false); 68 69 const getThumbnailUrl = (avatarUrl: string) => { 70 return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/"); 71 }; 72 73 const scopeFlow = useOAuthScopeFlow({ 74 beforeRedirect: (account) => resumeSession(account as Did), 75 }); 76 77 const handleAccountClick = async (did: Did) => { 78 try { 79 await resumeSession(did); 80 } catch { 81 scopeFlow.initiate(did); 82 } 83 }; 84 85 onMount(async () => { 86 try { 87 await retrieveSession(); 88 } catch {} 89 90 const storedSessions = loadSessionsFromStorage(); 91 if (storedSessions) { 92 const sessionDids = Object.keys(storedSessions) as Did[]; 93 sessionDids.forEach(async (did) => { 94 await loadHandleForSession(did, storedSessions); 95 }); 96 sessionDids.forEach(async (did) => { 97 const avatar = await getAvatar(did); 98 if (avatar) setAvatars(did, avatar); 99 }); 100 } 101 }); 102 103 return ( 104 <> 105 <Modal 106 open={openManager()} 107 onClose={() => { 108 setOpenManager(false); 109 setShowingAddAccount(false); 110 scopeFlow.cancel(); 111 }} 112 > 113 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-18 left-[50%] w-88 -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 114 <Show when={!scopeFlow.showScopeSelector() && !showingAddAccount()}> 115 <div class="mb-2 px-1 font-semibold"> 116 <span>Manage accounts</span> 117 </div> 118 <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100"> 119 <For each={Object.keys(sessions)}> 120 {(did) => ( 121 <div class="flex w-full items-center justify-between"> 122 <A 123 href={`/at://${did}`} 124 onClick={() => setOpenManager(false)} 125 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 126 > 127 <Show 128 when={avatars[did as Did]} 129 fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>} 130 > 131 <img 132 src={getThumbnailUrl(avatars[did as Did])} 133 class="size-6 rounded-full" 134 /> 135 </Show> 136 </A> 137 <button 138 class="flex grow items-center justify-between gap-1 truncate rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 139 onclick={() => handleAccountClick(did as Did)} 140 > 141 <span class="truncate">{sessions[did]?.handle || did}</span> 142 <Show when={did === agent()?.sub && sessions[did].signedIn}> 143 <span class="iconify lucide--check shrink-0 text-green-500 dark:text-green-400"></span> 144 </Show> 145 <Show when={!sessions[did].signedIn}> 146 <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span> 147 </Show> 148 </button> 149 <AccountDropdown 150 did={did as Did} 151 onEditPermissions={(accountDid) => scopeFlow.initiateWithRedirect(accountDid)} 152 /> 153 </div> 154 )} 155 </For> 156 </div> 157 <button 158 onclick={() => setShowingAddAccount(true)} 159 class="flex w-full items-center justify-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 160 > 161 <span class="iconify lucide--user-plus"></span> 162 <span>Add account</span> 163 </button> 164 </Show> 165 166 <Show when={showingAddAccount() && !scopeFlow.showScopeSelector()}> 167 <Login onCancel={() => setShowingAddAccount(false)} /> 168 </Show> 169 170 <Show when={scopeFlow.showScopeSelector()}> 171 <ScopeSelector 172 initialScopes={parseScopeString( 173 sessions[scopeFlow.pendingAccount()]?.grantedScopes || "", 174 )} 175 onConfirm={scopeFlow.complete} 176 onCancel={() => { 177 scopeFlow.cancel(); 178 setShowingAddAccount(false); 179 }} 180 /> 181 </Show> 182 </div> 183 </Modal> 184 <button 185 onclick={() => setOpenManager(true)} 186 class={`flex items-center rounded-lg ${agent() && avatars[agent()!.sub] ? "p-1.25" : "p-1.5"} hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`} 187 > 188 {agent() && avatars[agent()!.sub] ? 189 <img src={getThumbnailUrl(avatars[agent()!.sub])} class="size-5 rounded-full" /> 190 : <span class="iconify lucide--circle-user-round text-lg"></span>} 191 </button> 192 </> 193 ); 194};