atmosphere explorer pdsls.dev
atproto tool typescript

Oauth scopes (#47)

* init

* refactor

* remove re exports

* remove re exports fr

* disable blob perm when no create or update

* refactor perm localstorage

* restyle permission selector

* autofocus login

authored by juli.ee and committed by GitHub e0fa0bbc dfda14be

+1 -1
public/oauth-client-metadata.json
··· 4 4 "client_uri": "https://pdsls.dev", 5 5 "logo_uri": "https://pdsls.dev/favicon.ico", 6 6 "redirect_uris": ["https://pdsls.dev/"], 7 - "scope": "atproto transition:generic", 7 + "scope": "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 8 8 "grant_types": ["authorization_code", "refresh_token"], 9 9 "response_types": ["code"], 10 10 "token_endpoint_auth_method": "none",
+190
src/auth/account.tsx
··· 1 + import { Did } from "@atcute/lexicons"; 2 + import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 + import { A } from "@solidjs/router"; 4 + import { createSignal, For, onMount, Show } from "solid-js"; 5 + import { createStore, produce } from "solid-js/store"; 6 + import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 + import { Modal } from "../components/modal.jsx"; 8 + import { Login } from "./login.jsx"; 9 + import { useOAuthScopeFlow } from "./scope-flow.js"; 10 + import { ScopeSelector } from "./scope-selector.jsx"; 11 + import { parseScopeString } from "./scope-utils.js"; 12 + import { 13 + getAvatar, 14 + loadHandleForSession, 15 + loadSessionsFromStorage, 16 + resumeSession, 17 + retrieveSession, 18 + saveSessionToStorage, 19 + } from "./session-manager.js"; 20 + import { agent, sessions, setAgent, setSessions } from "./state.js"; 21 + 22 + const 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 href={`/at://${props.did}`} label="Go to repo" icon="lucide--user-round" /> 45 + <ActionMenu 46 + icon="lucide--settings" 47 + label="Edit permissions" 48 + onClick={() => props.onEditPermissions(props.did)} 49 + /> 50 + <ActionMenu 51 + icon="lucide--x" 52 + label="Remove account" 53 + onClick={() => removeSession(props.did)} 54 + /> 55 + </DropdownMenu> 56 + </MenuProvider> 57 + ); 58 + }; 59 + 60 + export const AccountManager = () => { 61 + const [openManager, setOpenManager] = createSignal(false); 62 + const [avatars, setAvatars] = createStore<Record<Did, string>>(); 63 + const [showingAddAccount, setShowingAddAccount] = createSignal(false); 64 + 65 + const getThumbnailUrl = (avatarUrl: string) => { 66 + return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/"); 67 + }; 68 + 69 + const scopeFlow = useOAuthScopeFlow({ 70 + beforeRedirect: (account) => resumeSession(account as Did), 71 + }); 72 + 73 + const handleAccountClick = async (did: Did) => { 74 + try { 75 + await resumeSession(did); 76 + } catch { 77 + scopeFlow.initiate(did); 78 + } 79 + }; 80 + 81 + onMount(async () => { 82 + try { 83 + await retrieveSession(); 84 + } catch {} 85 + 86 + const storedSessions = loadSessionsFromStorage(); 87 + if (storedSessions) { 88 + const sessionDids = Object.keys(storedSessions) as Did[]; 89 + sessionDids.forEach(async (did) => { 90 + await loadHandleForSession(did, storedSessions); 91 + }); 92 + sessionDids.forEach(async (did) => { 93 + const avatar = await getAvatar(did); 94 + if (avatar) setAvatars(did, avatar); 95 + }); 96 + } 97 + }); 98 + 99 + return ( 100 + <> 101 + <Modal 102 + open={openManager()} 103 + onClose={() => { 104 + setOpenManager(false); 105 + setShowingAddAccount(false); 106 + scopeFlow.cancel(); 107 + }} 108 + > 109 + <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"> 110 + <Show when={!scopeFlow.showScopeSelector() && !showingAddAccount()}> 111 + <div class="mb-2 px-1 font-semibold"> 112 + <span>Manage accounts</span> 113 + </div> 114 + <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100"> 115 + <For each={Object.keys(sessions)}> 116 + {(did) => ( 117 + <div class="flex w-full items-center justify-between"> 118 + <A 119 + href={`/at://${did}`} 120 + onClick={() => setOpenManager(false)} 121 + 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" 122 + > 123 + <Show 124 + when={avatars[did as Did]} 125 + fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>} 126 + > 127 + <img 128 + src={getThumbnailUrl(avatars[did as Did])} 129 + class="size-6 rounded-full" 130 + /> 131 + </Show> 132 + </A> 133 + <button 134 + 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" 135 + onclick={() => handleAccountClick(did as Did)} 136 + > 137 + <span class="truncate">{sessions[did]?.handle || did}</span> 138 + <Show when={did === agent()?.sub && sessions[did].signedIn}> 139 + <span class="iconify lucide--check shrink-0 text-green-500 dark:text-green-400"></span> 140 + </Show> 141 + <Show when={!sessions[did].signedIn}> 142 + <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span> 143 + </Show> 144 + </button> 145 + <AccountDropdown 146 + did={did as Did} 147 + onEditPermissions={(accountDid) => scopeFlow.initiateWithRedirect(accountDid)} 148 + /> 149 + </div> 150 + )} 151 + </For> 152 + </div> 153 + <button 154 + onclick={() => setShowingAddAccount(true)} 155 + class="flex w-full items-center justify-center gap-2 rounded-md border-[0.5px] border-neutral-300 bg-white px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 156 + > 157 + <span class="iconify lucide--user-plus"></span> 158 + <span>Add account</span> 159 + </button> 160 + </Show> 161 + 162 + <Show when={showingAddAccount() && !scopeFlow.showScopeSelector()}> 163 + <Login onCancel={() => setShowingAddAccount(false)} /> 164 + </Show> 165 + 166 + <Show when={scopeFlow.showScopeSelector()}> 167 + <ScopeSelector 168 + initialScopes={parseScopeString( 169 + sessions[scopeFlow.pendingAccount()]?.grantedScopes || "", 170 + )} 171 + onConfirm={scopeFlow.complete} 172 + onCancel={() => { 173 + scopeFlow.cancel(); 174 + setShowingAddAccount(false); 175 + }} 176 + /> 177 + </Show> 178 + </div> 179 + </Modal> 180 + <button 181 + onclick={() => setOpenManager(true)} 182 + 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`} 183 + > 184 + {agent() && avatars[agent()!.sub] ? 185 + <img src={getThumbnailUrl(avatars[agent()!.sub])} class="size-5 rounded-full" /> 186 + : <span class="iconify lucide--circle-user-round text-lg"></span>} 187 + </button> 188 + </> 189 + ); 190 + };
+88
src/auth/login.tsx
··· 1 + import { createSignal, Show } from "solid-js"; 2 + import "./oauth-config"; 3 + import { useOAuthScopeFlow } from "./scope-flow"; 4 + import { ScopeSelector } from "./scope-selector"; 5 + 6 + interface LoginProps { 7 + onCancel?: () => void; 8 + } 9 + 10 + export const Login = (props: LoginProps) => { 11 + const [notice, setNotice] = createSignal(""); 12 + const [loginInput, setLoginInput] = createSignal(""); 13 + 14 + const scopeFlow = useOAuthScopeFlow({ 15 + onError: (e) => setNotice(`${e}`), 16 + onRedirecting: () => { 17 + setNotice(`Contacting your data server...`); 18 + setTimeout(() => setNotice(`Redirecting...`), 0); 19 + }, 20 + }); 21 + 22 + const initiateLogin = (handle: string) => { 23 + setNotice(""); 24 + scopeFlow.initiate(handle); 25 + }; 26 + 27 + const handleCancel = () => { 28 + scopeFlow.cancel(); 29 + setLoginInput(""); 30 + setNotice(""); 31 + props.onCancel?.(); 32 + }; 33 + 34 + return ( 35 + <div class="flex flex-col gap-y-2 px-1"> 36 + <Show when={!scopeFlow.showScopeSelector()}> 37 + <Show when={props.onCancel}> 38 + <div class="mb-1 flex items-center gap-2"> 39 + <button 40 + onclick={handleCancel} 41 + 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" 42 + > 43 + <span class="iconify lucide--arrow-left"></span> 44 + </button> 45 + <div class="font-semibold">Add account</div> 46 + </div> 47 + </Show> 48 + <form onsubmit={(e) => e.preventDefault()}> 49 + <label for="username" class="hidden"> 50 + Add account 51 + </label> 52 + <div class="dark:bg-dark-100 dark:inset-shadow-dark-200 flex grow items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 inset-shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-600 dark:border-neutral-600 dark:focus-within:outline-neutral-400"> 53 + <label 54 + for="username" 55 + class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400" 56 + ></label> 57 + <input 58 + type="text" 59 + spellcheck={false} 60 + placeholder="user.bsky.social" 61 + id="username" 62 + name="username" 63 + autocomplete="username" 64 + autofocus 65 + aria-label="Your AT Protocol handle" 66 + class="grow py-1 select-none placeholder:text-sm focus:outline-none" 67 + onInput={(e) => setLoginInput(e.currentTarget.value)} 68 + /> 69 + <button 70 + onclick={() => initiateLogin(loginInput())} 71 + class="flex items-center rounded-md p-1 hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 72 + > 73 + <span class="iconify lucide--log-in"></span> 74 + </button> 75 + </div> 76 + </form> 77 + </Show> 78 + 79 + <Show when={scopeFlow.showScopeSelector()}> 80 + <ScopeSelector onConfirm={scopeFlow.complete} onCancel={handleCancel} /> 81 + </Show> 82 + 83 + <Show when={notice()}> 84 + <div class="text-sm">{notice()}</div> 85 + </Show> 86 + </div> 87 + ); 88 + };
+13
src/auth/oauth-config.ts
··· 1 + import { configureOAuth, defaultIdentityResolver } from "@atcute/oauth-browser-client"; 2 + import { didDocumentResolver, handleResolver } from "../utils/api"; 3 + 4 + configureOAuth({ 5 + metadata: { 6 + client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 7 + redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 8 + }, 9 + identityResolver: defaultIdentityResolver({ 10 + handleResolver: handleResolver, 11 + didDocumentResolver: didDocumentResolver, 12 + }), 13 + });
+77
src/auth/scope-flow.ts
··· 1 + import { isDid, isHandle } from "@atcute/lexicons/syntax"; 2 + import { createAuthorizationUrl } from "@atcute/oauth-browser-client"; 3 + import { createSignal } from "solid-js"; 4 + 5 + interface UseOAuthScopeFlowOptions { 6 + onError?: (error: unknown) => void; 7 + onRedirecting?: () => void; 8 + beforeRedirect?: (account: string) => Promise<void>; 9 + } 10 + 11 + export const useOAuthScopeFlow = (options: UseOAuthScopeFlowOptions = {}) => { 12 + const [showScopeSelector, setShowScopeSelector] = createSignal(false); 13 + const [pendingAccount, setPendingAccount] = createSignal(""); 14 + const [shouldForceRedirect, setShouldForceRedirect] = createSignal(false); 15 + 16 + const initiate = (account: string) => { 17 + if (!account) return; 18 + setPendingAccount(account); 19 + setShouldForceRedirect(false); 20 + setShowScopeSelector(true); 21 + }; 22 + 23 + const initiateWithRedirect = (account: string) => { 24 + if (!account) return; 25 + setPendingAccount(account); 26 + setShouldForceRedirect(true); 27 + setShowScopeSelector(true); 28 + }; 29 + 30 + const complete = async (scopeString: string, scopeIds: string) => { 31 + try { 32 + const account = pendingAccount(); 33 + 34 + if (options.beforeRedirect && !shouldForceRedirect()) { 35 + try { 36 + await options.beforeRedirect(account); 37 + setShowScopeSelector(false); 38 + return; 39 + } catch {} 40 + } 41 + 42 + localStorage.setItem("pendingScopes", scopeIds); 43 + 44 + options.onRedirecting?.(); 45 + 46 + const authUrl = await createAuthorizationUrl({ 47 + scope: scopeString, 48 + target: 49 + isHandle(account) || isDid(account) ? 50 + { type: "account", identifier: account } 51 + : { type: "pds", serviceUrl: account }, 52 + }); 53 + 54 + await new Promise((resolve) => setTimeout(resolve, 250)); 55 + location.assign(authUrl); 56 + } catch (e) { 57 + console.error(e); 58 + options.onError?.(e); 59 + setShowScopeSelector(false); 60 + } 61 + }; 62 + 63 + const cancel = () => { 64 + setShowScopeSelector(false); 65 + setPendingAccount(""); 66 + setShouldForceRedirect(false); 67 + }; 68 + 69 + return { 70 + showScopeSelector, 71 + pendingAccount, 72 + initiate, 73 + initiateWithRedirect, 74 + complete, 75 + cancel, 76 + }; 77 + };
+88
src/auth/scope-selector.tsx
··· 1 + import { createSignal, For } from "solid-js"; 2 + import { buildScopeString, GRANULAR_SCOPES, scopeIdsToString } from "./scope-utils"; 3 + 4 + interface ScopeSelectorProps { 5 + onConfirm: (scopeString: string, scopeIds: string) => void; 6 + onCancel: () => void; 7 + initialScopes?: Set<string>; 8 + } 9 + 10 + export const ScopeSelector = (props: ScopeSelectorProps) => { 11 + const [selectedScopes, setSelectedScopes] = createSignal<Set<string>>( 12 + props.initialScopes || new Set(["create", "update", "delete", "blob"]), 13 + ); 14 + 15 + const isBlobDisabled = () => { 16 + const scopes = selectedScopes(); 17 + return !scopes.has("create") && !scopes.has("update"); 18 + }; 19 + 20 + const toggleScope = (scopeId: string) => { 21 + setSelectedScopes((prev) => { 22 + const newSet = new Set(prev); 23 + if (newSet.has(scopeId)) { 24 + newSet.delete(scopeId); 25 + if ( 26 + (scopeId === "create" || scopeId === "update") && 27 + !newSet.has("create") && 28 + !newSet.has("update") 29 + ) { 30 + newSet.delete("blob"); 31 + } 32 + } else { 33 + newSet.add(scopeId); 34 + } 35 + return newSet; 36 + }); 37 + }; 38 + 39 + const handleConfirm = () => { 40 + const scopes = selectedScopes(); 41 + const scopeString = buildScopeString(scopes); 42 + const scopeIds = scopeIdsToString(scopes); 43 + props.onConfirm(scopeString, scopeIds); 44 + }; 45 + 46 + return ( 47 + <div class="flex flex-col gap-y-2"> 48 + <div class="mb-1 flex items-center gap-2"> 49 + <button 50 + onclick={props.onCancel} 51 + 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" 52 + > 53 + <span class="iconify lucide--arrow-left"></span> 54 + </button> 55 + <div class="font-semibold">Select permissions</div> 56 + </div> 57 + <div class="flex flex-col gap-y-2 px-1"> 58 + <For each={GRANULAR_SCOPES}> 59 + {(scope) => ( 60 + <div 61 + class="flex items-center gap-2" 62 + classList={{ "opacity-50": scope.id === "blob" && isBlobDisabled() }} 63 + > 64 + <input 65 + id={`scope-${scope.id}`} 66 + type="checkbox" 67 + checked={selectedScopes().has(scope.id)} 68 + disabled={scope.id === "blob" && isBlobDisabled()} 69 + onChange={() => toggleScope(scope.id)} 70 + /> 71 + <label for={`scope-${scope.id}`} class="flex grow items-center gap-2 select-none"> 72 + <span>{scope.label}</span> 73 + </label> 74 + </div> 75 + )} 76 + </For> 77 + </div> 78 + <div class="mt-2 flex gap-2"> 79 + <button 80 + onclick={handleConfirm} 81 + class="grow rounded-lg border-[0.5px] border-neutral-300 bg-white px-3 py-1.5 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 82 + > 83 + Continue 84 + </button> 85 + </div> 86 + </div> 87 + ); 88 + };
+53
src/auth/scope-utils.ts
··· 1 + import { agent, sessions } from "./state"; 2 + 3 + export const GRANULAR_SCOPES = [ 4 + { 5 + id: "create", 6 + scope: "repo:*?action=create", 7 + label: "Create records", 8 + }, 9 + { 10 + id: "update", 11 + scope: "repo:*?action=update", 12 + label: "Update records", 13 + }, 14 + { 15 + id: "delete", 16 + scope: "repo:*?action=delete", 17 + label: "Delete records", 18 + }, 19 + { 20 + id: "blob", 21 + scope: "blob:*/*", 22 + label: "Upload blobs", 23 + }, 24 + ]; 25 + 26 + export const BASE_SCOPES = ["atproto"]; 27 + 28 + export const buildScopeString = (selected: Set<string>): string => { 29 + const granular = GRANULAR_SCOPES.filter((s) => selected.has(s.id)).map((s) => s.scope); 30 + return [...BASE_SCOPES, ...granular].join(" "); 31 + }; 32 + 33 + export const scopeIdsToString = (scopeIds: Set<string>): string => { 34 + return ["atproto", ...Array.from(scopeIds)].join(","); 35 + }; 36 + 37 + export const parseScopeString = (scopeIdsString: string): Set<string> => { 38 + if (!scopeIdsString) return new Set(); 39 + const ids = scopeIdsString.split(",").filter(Boolean); 40 + return new Set(ids.filter((id) => id !== "atproto")); 41 + }; 42 + 43 + export const hasScope = (grantedScopes: string | undefined, scopeId: string): boolean => { 44 + if (!grantedScopes) return false; 45 + return grantedScopes.split(",").includes(scopeId); 46 + }; 47 + 48 + export const hasUserScope = (scopeId: string): boolean => { 49 + if (!agent()) return false; 50 + const grantedScopes = sessions[agent()!.sub]?.grantedScopes; 51 + if (!grantedScopes) return true; 52 + return hasScope(grantedScopes, scopeId); 53 + };
+95
src/auth/session-manager.ts
··· 1 + import { Client, CredentialManager } from "@atcute/client"; 2 + import { Did } from "@atcute/lexicons"; 3 + import { 4 + finalizeAuthorization, 5 + getSession, 6 + OAuthUserAgent, 7 + type Session, 8 + } from "@atcute/oauth-browser-client"; 9 + import { resolveDidDoc } from "../utils/api"; 10 + import { Sessions, setAgent, setSessions } from "./state"; 11 + 12 + export const saveSessionToStorage = (sessions: Sessions) => { 13 + localStorage.setItem("sessions", JSON.stringify(sessions)); 14 + }; 15 + 16 + export const loadSessionsFromStorage = (): Sessions | null => { 17 + const localSessions = localStorage.getItem("sessions"); 18 + return localSessions ? JSON.parse(localSessions) : null; 19 + }; 20 + 21 + export const getAvatar = async (did: Did): Promise<string | undefined> => { 22 + const rpc = new Client({ 23 + handler: new CredentialManager({ service: "https://public.api.bsky.app" }), 24 + }); 25 + const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } }); 26 + if (res.ok) { 27 + return res.data.avatar; 28 + } 29 + return undefined; 30 + }; 31 + 32 + export const loadHandleForSession = async (did: Did, storedSessions: Sessions) => { 33 + const doc = await resolveDidDoc(did); 34 + const alias = doc.alsoKnownAs?.find((alias) => alias.startsWith("at://")); 35 + if (alias) { 36 + setSessions(did, { 37 + signedIn: storedSessions[did].signedIn, 38 + handle: alias.replace("at://", ""), 39 + grantedScopes: storedSessions[did].grantedScopes, 40 + }); 41 + } 42 + }; 43 + 44 + export const retrieveSession = async (): Promise<void> => { 45 + const init = async (): Promise<Session | undefined> => { 46 + const params = new URLSearchParams(location.hash.slice(1)); 47 + 48 + if (params.has("state") && (params.has("code") || params.has("error"))) { 49 + history.replaceState(null, "", location.pathname + location.search); 50 + 51 + const auth = await finalizeAuthorization(params); 52 + const did = auth.session.info.sub; 53 + 54 + localStorage.setItem("lastSignedIn", did); 55 + 56 + const grantedScopes = localStorage.getItem("pendingScopes") || "atproto"; 57 + localStorage.removeItem("pendingScopes"); 58 + 59 + const sessions = loadSessionsFromStorage(); 60 + const newSessions: Sessions = sessions || {}; 61 + newSessions[did] = { signedIn: true, grantedScopes }; 62 + saveSessionToStorage(newSessions); 63 + return auth.session; 64 + } else { 65 + const lastSignedIn = localStorage.getItem("lastSignedIn"); 66 + 67 + if (lastSignedIn) { 68 + const sessions = loadSessionsFromStorage(); 69 + const newSessions: Sessions = sessions || {}; 70 + try { 71 + const session = await getSession(lastSignedIn as Did); 72 + const rpc = new Client({ handler: new OAuthUserAgent(session) }); 73 + const res = await rpc.get("com.atproto.server.getSession"); 74 + newSessions[lastSignedIn].signedIn = true; 75 + saveSessionToStorage(newSessions); 76 + if (!res.ok) throw res.data.error; 77 + return session; 78 + } catch (err) { 79 + newSessions[lastSignedIn].signedIn = false; 80 + saveSessionToStorage(newSessions); 81 + throw err; 82 + } 83 + } 84 + } 85 + }; 86 + 87 + const session = await init(); 88 + 89 + if (session) setAgent(new OAuthUserAgent(session)); 90 + }; 91 + 92 + export const resumeSession = async (did: Did): Promise<void> => { 93 + localStorage.setItem("lastSignedIn", did); 94 + await retrieveSession(); 95 + };
+14
src/auth/state.ts
··· 1 + import { OAuthUserAgent } from "@atcute/oauth-browser-client"; 2 + import { createSignal } from "solid-js"; 3 + import { createStore } from "solid-js/store"; 4 + 5 + export type Account = { 6 + signedIn: boolean; 7 + handle?: string; 8 + grantedScopes?: string; 9 + }; 10 + 11 + export type Sessions = Record<string, Account>; 12 + 13 + export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 + export const [sessions, setSessions] = createStore<Sessions>();
-170
src/components/account.tsx
··· 1 - import { Client, CredentialManager } from "@atcute/client"; 2 - import { Did } from "@atcute/lexicons"; 3 - import { 4 - createAuthorizationUrl, 5 - deleteStoredSession, 6 - getSession, 7 - OAuthUserAgent, 8 - } from "@atcute/oauth-browser-client"; 9 - import { A } from "@solidjs/router"; 10 - import { createSignal, For, onMount, Show } from "solid-js"; 11 - import { createStore, produce } from "solid-js/store"; 12 - import { resolveDidDoc } from "../utils/api.js"; 13 - import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "./dropdown.jsx"; 14 - import { agent, Login, retrieveSession, Sessions, setAgent } from "./login.jsx"; 15 - import { Modal } from "./modal.jsx"; 16 - 17 - export const [sessions, setSessions] = createStore<Sessions>(); 18 - 19 - const AccountDropdown = (props: { did: Did }) => { 20 - const removeSession = async (did: Did) => { 21 - const currentSession = agent()?.sub; 22 - try { 23 - const session = await getSession(did, { allowStale: true }); 24 - const agent = new OAuthUserAgent(session); 25 - await agent.signOut(); 26 - } catch { 27 - deleteStoredSession(did); 28 - } 29 - setSessions( 30 - produce((accs) => { 31 - delete accs[did]; 32 - }), 33 - ); 34 - localStorage.setItem("sessions", JSON.stringify(sessions)); 35 - if (currentSession === did) setAgent(undefined); 36 - }; 37 - 38 - return ( 39 - <MenuProvider> 40 - <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2"> 41 - <NavMenu href={`/at://${props.did}`} label="Go to repo" icon="lucide--user-round" /> 42 - <ActionMenu 43 - icon="lucide--x" 44 - label="Remove account" 45 - onClick={() => removeSession(props.did)} 46 - /> 47 - </DropdownMenu> 48 - </MenuProvider> 49 - ); 50 - }; 51 - 52 - export const AccountManager = () => { 53 - const [openManager, setOpenManager] = createSignal(false); 54 - const [avatars, setAvatars] = createStore<Record<Did, string>>(); 55 - 56 - onMount(async () => { 57 - try { 58 - await retrieveSession(); 59 - } catch {} 60 - 61 - const localSessions = localStorage.getItem("sessions"); 62 - if (localSessions) { 63 - const storedSessions: Sessions = JSON.parse(localSessions); 64 - const sessionDids = Object.keys(storedSessions) as Did[]; 65 - sessionDids.forEach(async (did) => { 66 - const doc = await resolveDidDoc(did); 67 - const alias = doc.alsoKnownAs?.find((alias) => alias.startsWith("at://")); 68 - if (alias) { 69 - setSessions(did, { 70 - signedIn: storedSessions[did].signedIn, 71 - handle: alias.replace("at://", ""), 72 - }); 73 - } 74 - }); 75 - sessionDids.forEach(async (did) => { 76 - const avatar = await getAvatar(did); 77 - if (avatar) setAvatars(did, avatar); 78 - }); 79 - } 80 - }); 81 - 82 - const resumeSession = async (did: Did) => { 83 - try { 84 - localStorage.setItem("lastSignedIn", did); 85 - await retrieveSession(); 86 - } catch { 87 - const authUrl = await createAuthorizationUrl({ 88 - scope: import.meta.env.VITE_OAUTH_SCOPE, 89 - target: { type: "account", identifier: did }, 90 - }); 91 - 92 - await new Promise((resolve) => setTimeout(resolve, 250)); 93 - 94 - location.assign(authUrl); 95 - } 96 - }; 97 - 98 - const getAvatar = async (did: Did) => { 99 - const rpc = new Client({ 100 - handler: new CredentialManager({ service: "https://public.api.bsky.app" }), 101 - }); 102 - const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } }); 103 - if (res.ok) { 104 - return res.data.avatar; 105 - } 106 - return undefined; 107 - }; 108 - 109 - return ( 110 - <> 111 - <Modal open={openManager()} onClose={() => setOpenManager(false)}> 112 - <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"> 113 - <div class="mb-2 px-1 font-semibold"> 114 - <span>Manage accounts</span> 115 - </div> 116 - <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100"> 117 - <For each={Object.keys(sessions)}> 118 - {(did) => ( 119 - <div class="flex w-full items-center justify-between"> 120 - <A 121 - href={`/at://${did}`} 122 - onClick={() => setOpenManager(false)} 123 - 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" 124 - > 125 - <Show 126 - when={avatars[did as Did]} 127 - fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>} 128 - > 129 - <img 130 - src={avatars[did as Did].replace("img/avatar/", "img/avatar_thumbnail/")} 131 - class="size-6 rounded-full" 132 - /> 133 - </Show> 134 - </A> 135 - <button 136 - 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" 137 - onclick={() => resumeSession(did as Did)} 138 - > 139 - <span class="truncate"> 140 - {sessions[did]?.handle ? sessions[did].handle : did} 141 - </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 did={did as Did} /> 150 - </div> 151 - )} 152 - </For> 153 - </div> 154 - <Login /> 155 - </div> 156 - </Modal> 157 - <button 158 - onclick={() => setOpenManager(true)} 159 - 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`} 160 - > 161 - {agent() && avatars[agent()!.sub] ? 162 - <img 163 - src={avatars[agent()!.sub].replace("img/avatar/", "img/avatar_thumbnail/")} 164 - class="size-5 rounded-full" 165 - /> 166 - : <span class="iconify lucide--circle-user-round text-lg"></span>} 167 - </button> 168 - </> 169 - ); 170 - };
+12 -10
src/components/create.tsx
··· 5 5 import { remove } from "@mary/exif-rm"; 6 6 import { useNavigate, useParams } from "@solidjs/router"; 7 7 import { createEffect, createSignal, For, lazy, onCleanup, Show, Suspense } from "solid-js"; 8 - import { agent } from "../components/login.jsx"; 9 - import { sessions } from "./account.jsx"; 8 + import { hasUserScope } from "../auth/scope-utils"; 9 + import { agent, sessions } from "../auth/state"; 10 10 import { Button } from "./button.jsx"; 11 11 import { Modal } from "./modal.jsx"; 12 12 import { addNotification, removeNotification } from "./notification.jsx"; ··· 436 436 </button> 437 437 <Show when={openInsertMenu()}> 438 438 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute bottom-full left-0 z-10 mb-1 flex w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 shadow-md dark:border-neutral-700"> 439 - <MenuItem 440 - icon="lucide--upload" 441 - label="Upload blob" 442 - onClick={() => { 443 - setOpenInsertMenu(false); 444 - blobInput.click(); 445 - }} 446 - /> 439 + <Show when={hasUserScope("blob")}> 440 + <MenuItem 441 + icon="lucide--upload" 442 + label="Upload blob" 443 + onClick={() => { 444 + setOpenInsertMenu(false); 445 + blobInput.click(); 446 + }} 447 + /> 448 + </Show> 447 449 <MenuItem 448 450 icon="lucide--clock" 449 451 label="Insert timestamp"
-143
src/components/login.tsx
··· 1 - import { Client } from "@atcute/client"; 2 - import { Did } from "@atcute/lexicons"; 3 - import { isDid, isHandle } from "@atcute/lexicons/syntax"; 4 - import { 5 - configureOAuth, 6 - createAuthorizationUrl, 7 - defaultIdentityResolver, 8 - finalizeAuthorization, 9 - getSession, 10 - OAuthUserAgent, 11 - type Session, 12 - } from "@atcute/oauth-browser-client"; 13 - import { createSignal, Show } from "solid-js"; 14 - import { didDocumentResolver, handleResolver } from "../utils/api"; 15 - 16 - configureOAuth({ 17 - metadata: { 18 - client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 19 - redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 20 - }, 21 - identityResolver: defaultIdentityResolver({ 22 - handleResolver: handleResolver, 23 - didDocumentResolver: didDocumentResolver, 24 - }), 25 - }); 26 - 27 - export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 28 - 29 - type Account = { 30 - signedIn: boolean; 31 - handle?: string; 32 - }; 33 - 34 - export type Sessions = Record<string, Account>; 35 - 36 - const Login = () => { 37 - const [notice, setNotice] = createSignal(""); 38 - const [loginInput, setLoginInput] = createSignal(""); 39 - 40 - const login = async (handle: string) => { 41 - try { 42 - setNotice(""); 43 - if (!handle) return; 44 - setNotice(`Contacting your data server...`); 45 - const authUrl = await createAuthorizationUrl({ 46 - scope: import.meta.env.VITE_OAUTH_SCOPE, 47 - target: 48 - isHandle(handle) || isDid(handle) ? 49 - { type: "account", identifier: handle } 50 - : { type: "pds", serviceUrl: handle }, 51 - }); 52 - 53 - setNotice(`Redirecting...`); 54 - await new Promise((resolve) => setTimeout(resolve, 250)); 55 - 56 - location.assign(authUrl); 57 - } catch (e) { 58 - console.error(e); 59 - setNotice(`${e}`); 60 - } 61 - }; 62 - 63 - return ( 64 - <form class="flex flex-col gap-y-2 px-1" onsubmit={(e) => e.preventDefault()}> 65 - <label for="username" class="hidden"> 66 - Add account 67 - </label> 68 - <div class="dark:bg-dark-100 dark:inset-shadow-dark-200 flex grow items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 inset-shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-600 dark:border-neutral-600 dark:focus-within:outline-neutral-400"> 69 - <label 70 - for="username" 71 - class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400" 72 - ></label> 73 - <input 74 - type="text" 75 - spellcheck={false} 76 - placeholder="user.bsky.social" 77 - id="username" 78 - name="username" 79 - autocomplete="username" 80 - aria-label="Your AT Protocol handle" 81 - class="grow py-1 select-none placeholder:text-sm focus:outline-none" 82 - onInput={(e) => setLoginInput(e.currentTarget.value)} 83 - /> 84 - <button 85 - onclick={() => login(loginInput())} 86 - class="flex items-center rounded-md p-1 hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 87 - > 88 - <span class="iconify lucide--log-in"></span> 89 - </button> 90 - </div> 91 - <Show when={notice()}> 92 - <div class="text-sm">{notice()}</div> 93 - </Show> 94 - </form> 95 - ); 96 - }; 97 - 98 - const retrieveSession = async () => { 99 - const init = async (): Promise<Session | undefined> => { 100 - const params = new URLSearchParams(location.hash.slice(1)); 101 - 102 - if (params.has("state") && (params.has("code") || params.has("error"))) { 103 - history.replaceState(null, "", location.pathname + location.search); 104 - 105 - const auth = await finalizeAuthorization(params); 106 - const did = auth.session.info.sub; 107 - 108 - localStorage.setItem("lastSignedIn", did); 109 - 110 - const sessions = localStorage.getItem("sessions"); 111 - const newSessions: Sessions = sessions ? JSON.parse(sessions) : { [did]: {} }; 112 - newSessions[did] = { signedIn: true }; 113 - localStorage.setItem("sessions", JSON.stringify(newSessions)); 114 - return auth.session; 115 - } else { 116 - const lastSignedIn = localStorage.getItem("lastSignedIn"); 117 - 118 - if (lastSignedIn) { 119 - const sessions = localStorage.getItem("sessions"); 120 - const newSessions: Sessions = sessions ? JSON.parse(sessions) : {}; 121 - try { 122 - const session = await getSession(lastSignedIn as Did); 123 - const rpc = new Client({ handler: new OAuthUserAgent(session) }); 124 - const res = await rpc.get("com.atproto.server.getSession"); 125 - newSessions[lastSignedIn].signedIn = true; 126 - localStorage.setItem("sessions", JSON.stringify(newSessions)); 127 - if (!res.ok) throw res.data.error; 128 - return session; 129 - } catch (err) { 130 - newSessions[lastSignedIn].signedIn = false; 131 - localStorage.setItem("sessions", JSON.stringify(newSessions)); 132 - throw err; 133 - } 134 - } 135 - } 136 - }; 137 - 138 - const session = await init(); 139 - 140 - if (session) setAgent(new OAuthUserAgent(session)); 141 - }; 142 - 143 - export { Login, retrieveSession };
+3 -3
src/layout.tsx
··· 2 2 import { Meta, MetaProvider } from "@solidjs/meta"; 3 3 import { A, RouteSectionProps, useLocation, useNavigate } from "@solidjs/router"; 4 4 import { createEffect, ErrorBoundary, onMount, Show, Suspense } from "solid-js"; 5 - import { AccountManager } from "./components/account.jsx"; 5 + import { AccountManager } from "./auth/account.jsx"; 6 + import { hasUserScope } from "./auth/scope-utils"; 6 7 import { RecordEditor } from "./components/create.jsx"; 7 8 import { DropdownMenu, MenuProvider, MenuSeparator, NavMenu } from "./components/dropdown.jsx"; 8 - import { agent } from "./components/login.jsx"; 9 9 import { NavBar } from "./components/navbar.jsx"; 10 10 import { NotificationContainer } from "./components/notification.jsx"; 11 11 import { Search, SearchButton, showSearch } from "./components/search.jsx"; ··· 131 131 <Show when={location.pathname !== "/"}> 132 132 <SearchButton /> 133 133 </Show> 134 - <Show when={agent()}> 134 + <Show when={hasUserScope("create")}> 135 135 <RecordEditor create={true} /> 136 136 </Show> 137 137 <AccountManager />
+3 -2
src/views/collection.tsx
··· 5 5 import { A, useParams } from "@solidjs/router"; 6 6 import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"; 7 7 import { createStore } from "solid-js/store"; 8 + import { hasUserScope } from "../auth/scope-utils"; 9 + import { agent } from "../auth/state"; 8 10 import { Button } from "../components/button.jsx"; 9 11 import { JSONType, JSONValue } from "../components/json.jsx"; 10 - import { agent } from "../components/login.jsx"; 11 12 import { Modal } from "../components/modal.jsx"; 12 13 import { addNotification, removeNotification } from "../components/notification.jsx"; 13 14 import { StickyOverlay } from "../components/sticky.jsx"; ··· 198 199 <StickyOverlay> 199 200 <div class="flex w-full flex-col gap-2"> 200 201 <div class="flex items-center gap-1"> 201 - <Show when={agent() && agent()?.sub === did}> 202 + <Show when={agent() && agent()?.sub === did && hasUserScope("delete")}> 202 203 <div class="flex items-center"> 203 204 <Tooltip 204 205 text={batchDelete() ? "Cancel" : "Delete"}
+28 -23
src/views/record.tsx
··· 8 8 import { verifyRecord } from "@atcute/repo"; 9 9 import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 10 10 import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 11 + import { hasUserScope } from "../auth/scope-utils"; 12 + import { agent } from "../auth/state"; 11 13 import { Backlinks } from "../components/backlinks.jsx"; 12 14 import { Button } from "../components/button.jsx"; 13 15 import { RecordEditor, setPlaceholder } from "../components/create.jsx"; ··· 20 22 } from "../components/dropdown.jsx"; 21 23 import { JSONValue } from "../components/json.jsx"; 22 24 import { LexiconSchemaView } from "../components/lexicon-schema.jsx"; 23 - import { agent } from "../components/login.jsx"; 24 25 import { Modal } from "../components/modal.jsx"; 25 26 import { pds } from "../components/navbar.jsx"; 26 27 import { addNotification, removeNotification } from "../components/notification.jsx"; ··· 389 390 </div> 390 391 <div class="flex gap-0.5"> 391 392 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}> 392 - <RecordEditor create={false} record={record()?.value} refetch={refetch} /> 393 - <Tooltip text="Delete"> 394 - <button 395 - class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 396 - onclick={() => setOpenDelete(true)} 397 - > 398 - <span class="iconify lucide--trash-2"></span> 399 - </button> 400 - </Tooltip> 401 - <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 402 - <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -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"> 403 - <h2 class="mb-2 font-semibold">Delete this record?</h2> 404 - <div class="flex justify-end gap-2"> 405 - <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 406 - <Button 407 - onClick={deleteRecord} 408 - class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400" 409 - > 410 - Delete 411 - </Button> 393 + <Show when={hasUserScope("update")}> 394 + <RecordEditor create={false} record={record()?.value} refetch={refetch} /> 395 + </Show> 396 + <Show when={hasUserScope("delete")}> 397 + <Tooltip text="Delete"> 398 + <button 399 + class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 400 + onclick={() => setOpenDelete(true)} 401 + > 402 + <span class="iconify lucide--trash-2"></span> 403 + </button> 404 + </Tooltip> 405 + <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 406 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -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"> 407 + <h2 class="mb-2 font-semibold">Delete this record?</h2> 408 + <div class="flex justify-end gap-2"> 409 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 410 + <Button 411 + onClick={deleteRecord} 412 + class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400" 413 + > 414 + Delete 415 + </Button> 416 + </div> 412 417 </div> 413 - </div> 414 - </Modal> 418 + </Modal> 419 + </Show> 415 420 </Show> 416 421 <MenuProvider> 417 422 <DropdownMenu icon="lucide--ellipsis-vertical" buttonClass="rounded-sm p-1.5">