atproto explorer pdsls.dev
atproto tool

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

+1 -1
public/oauth-client-metadata.json
···
"client_uri": "https://pdsls.dev",
"logo_uri": "https://pdsls.dev/favicon.ico",
"redirect_uris": ["https://pdsls.dev/"],
-
"scope": "atproto transition:generic",
+
"scope": "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
+190
src/auth/account.tsx
···
+
import { Did } from "@atcute/lexicons";
+
import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client";
+
import { A } from "@solidjs/router";
+
import { createSignal, For, onMount, Show } from "solid-js";
+
import { createStore, produce } from "solid-js/store";
+
import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx";
+
import { Modal } from "../components/modal.jsx";
+
import { Login } from "./login.jsx";
+
import { useOAuthScopeFlow } from "./scope-flow.js";
+
import { ScopeSelector } from "./scope-selector.jsx";
+
import { parseScopeString } from "./scope-utils.js";
+
import {
+
getAvatar,
+
loadHandleForSession,
+
loadSessionsFromStorage,
+
resumeSession,
+
retrieveSession,
+
saveSessionToStorage,
+
} from "./session-manager.js";
+
import { agent, sessions, setAgent, setSessions } from "./state.js";
+
+
const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => {
+
const removeSession = async (did: Did) => {
+
const currentSession = agent()?.sub;
+
try {
+
const session = await getSession(did, { allowStale: true });
+
const agent = new OAuthUserAgent(session);
+
await agent.signOut();
+
} catch {
+
deleteStoredSession(did);
+
}
+
setSessions(
+
produce((accs) => {
+
delete accs[did];
+
}),
+
);
+
saveSessionToStorage(sessions);
+
if (currentSession === did) setAgent(undefined);
+
};
+
+
return (
+
<MenuProvider>
+
<DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2">
+
<NavMenu href={`/at://${props.did}`} label="Go to repo" icon="lucide--user-round" />
+
<ActionMenu
+
icon="lucide--settings"
+
label="Edit permissions"
+
onClick={() => props.onEditPermissions(props.did)}
+
/>
+
<ActionMenu
+
icon="lucide--x"
+
label="Remove account"
+
onClick={() => removeSession(props.did)}
+
/>
+
</DropdownMenu>
+
</MenuProvider>
+
);
+
};
+
+
export const AccountManager = () => {
+
const [openManager, setOpenManager] = createSignal(false);
+
const [avatars, setAvatars] = createStore<Record<Did, string>>();
+
const [showingAddAccount, setShowingAddAccount] = createSignal(false);
+
+
const getThumbnailUrl = (avatarUrl: string) => {
+
return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/");
+
};
+
+
const scopeFlow = useOAuthScopeFlow({
+
beforeRedirect: (account) => resumeSession(account as Did),
+
});
+
+
const handleAccountClick = async (did: Did) => {
+
try {
+
await resumeSession(did);
+
} catch {
+
scopeFlow.initiate(did);
+
}
+
};
+
+
onMount(async () => {
+
try {
+
await retrieveSession();
+
} catch {}
+
+
const storedSessions = loadSessionsFromStorage();
+
if (storedSessions) {
+
const sessionDids = Object.keys(storedSessions) as Did[];
+
sessionDids.forEach(async (did) => {
+
await loadHandleForSession(did, storedSessions);
+
});
+
sessionDids.forEach(async (did) => {
+
const avatar = await getAvatar(did);
+
if (avatar) setAvatars(did, avatar);
+
});
+
}
+
});
+
+
return (
+
<>
+
<Modal
+
open={openManager()}
+
onClose={() => {
+
setOpenManager(false);
+
setShowingAddAccount(false);
+
scopeFlow.cancel();
+
}}
+
>
+
<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">
+
<Show when={!scopeFlow.showScopeSelector() && !showingAddAccount()}>
+
<div class="mb-2 px-1 font-semibold">
+
<span>Manage accounts</span>
+
</div>
+
<div class="mb-3 max-h-80 overflow-y-auto md:max-h-100">
+
<For each={Object.keys(sessions)}>
+
{(did) => (
+
<div class="flex w-full items-center justify-between">
+
<A
+
href={`/at://${did}`}
+
onClick={() => setOpenManager(false)}
+
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"
+
>
+
<Show
+
when={avatars[did as Did]}
+
fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>}
+
>
+
<img
+
src={getThumbnailUrl(avatars[did as Did])}
+
class="size-6 rounded-full"
+
/>
+
</Show>
+
</A>
+
<button
+
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"
+
onclick={() => handleAccountClick(did as Did)}
+
>
+
<span class="truncate">{sessions[did]?.handle || did}</span>
+
<Show when={did === agent()?.sub && sessions[did].signedIn}>
+
<span class="iconify lucide--check shrink-0 text-green-500 dark:text-green-400"></span>
+
</Show>
+
<Show when={!sessions[did].signedIn}>
+
<span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span>
+
</Show>
+
</button>
+
<AccountDropdown
+
did={did as Did}
+
onEditPermissions={(accountDid) => scopeFlow.initiateWithRedirect(accountDid)}
+
/>
+
</div>
+
)}
+
</For>
+
</div>
+
<button
+
onclick={() => setShowingAddAccount(true)}
+
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"
+
>
+
<span class="iconify lucide--user-plus"></span>
+
<span>Add account</span>
+
</button>
+
</Show>
+
+
<Show when={showingAddAccount() && !scopeFlow.showScopeSelector()}>
+
<Login onCancel={() => setShowingAddAccount(false)} />
+
</Show>
+
+
<Show when={scopeFlow.showScopeSelector()}>
+
<ScopeSelector
+
initialScopes={parseScopeString(
+
sessions[scopeFlow.pendingAccount()]?.grantedScopes || "",
+
)}
+
onConfirm={scopeFlow.complete}
+
onCancel={() => {
+
scopeFlow.cancel();
+
setShowingAddAccount(false);
+
}}
+
/>
+
</Show>
+
</div>
+
</Modal>
+
<button
+
onclick={() => setOpenManager(true)}
+
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`}
+
>
+
{agent() && avatars[agent()!.sub] ?
+
<img src={getThumbnailUrl(avatars[agent()!.sub])} class="size-5 rounded-full" />
+
: <span class="iconify lucide--circle-user-round text-lg"></span>}
+
</button>
+
</>
+
);
+
};
+88
src/auth/login.tsx
···
+
import { createSignal, Show } from "solid-js";
+
import "./oauth-config";
+
import { useOAuthScopeFlow } from "./scope-flow";
+
import { ScopeSelector } from "./scope-selector";
+
+
interface LoginProps {
+
onCancel?: () => void;
+
}
+
+
export const Login = (props: LoginProps) => {
+
const [notice, setNotice] = createSignal("");
+
const [loginInput, setLoginInput] = createSignal("");
+
+
const scopeFlow = useOAuthScopeFlow({
+
onError: (e) => setNotice(`${e}`),
+
onRedirecting: () => {
+
setNotice(`Contacting your data server...`);
+
setTimeout(() => setNotice(`Redirecting...`), 0);
+
},
+
});
+
+
const initiateLogin = (handle: string) => {
+
setNotice("");
+
scopeFlow.initiate(handle);
+
};
+
+
const handleCancel = () => {
+
scopeFlow.cancel();
+
setLoginInput("");
+
setNotice("");
+
props.onCancel?.();
+
};
+
+
return (
+
<div class="flex flex-col gap-y-2 px-1">
+
<Show when={!scopeFlow.showScopeSelector()}>
+
<Show when={props.onCancel}>
+
<div class="mb-1 flex items-center gap-2">
+
<button
+
onclick={handleCancel}
+
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"
+
>
+
<span class="iconify lucide--arrow-left"></span>
+
</button>
+
<div class="font-semibold">Add account</div>
+
</div>
+
</Show>
+
<form onsubmit={(e) => e.preventDefault()}>
+
<label for="username" class="hidden">
+
Add account
+
</label>
+
<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">
+
<label
+
for="username"
+
class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400"
+
></label>
+
<input
+
type="text"
+
spellcheck={false}
+
placeholder="user.bsky.social"
+
id="username"
+
name="username"
+
autocomplete="username"
+
autofocus
+
aria-label="Your AT Protocol handle"
+
class="grow py-1 select-none placeholder:text-sm focus:outline-none"
+
onInput={(e) => setLoginInput(e.currentTarget.value)}
+
/>
+
<button
+
onclick={() => initiateLogin(loginInput())}
+
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"
+
>
+
<span class="iconify lucide--log-in"></span>
+
</button>
+
</div>
+
</form>
+
</Show>
+
+
<Show when={scopeFlow.showScopeSelector()}>
+
<ScopeSelector onConfirm={scopeFlow.complete} onCancel={handleCancel} />
+
</Show>
+
+
<Show when={notice()}>
+
<div class="text-sm">{notice()}</div>
+
</Show>
+
</div>
+
);
+
};
+13
src/auth/oauth-config.ts
···
+
import { configureOAuth, defaultIdentityResolver } from "@atcute/oauth-browser-client";
+
import { didDocumentResolver, handleResolver } from "../utils/api";
+
+
configureOAuth({
+
metadata: {
+
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
+
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
+
},
+
identityResolver: defaultIdentityResolver({
+
handleResolver: handleResolver,
+
didDocumentResolver: didDocumentResolver,
+
}),
+
});
+77
src/auth/scope-flow.ts
···
+
import { isDid, isHandle } from "@atcute/lexicons/syntax";
+
import { createAuthorizationUrl } from "@atcute/oauth-browser-client";
+
import { createSignal } from "solid-js";
+
+
interface UseOAuthScopeFlowOptions {
+
onError?: (error: unknown) => void;
+
onRedirecting?: () => void;
+
beforeRedirect?: (account: string) => Promise<void>;
+
}
+
+
export const useOAuthScopeFlow = (options: UseOAuthScopeFlowOptions = {}) => {
+
const [showScopeSelector, setShowScopeSelector] = createSignal(false);
+
const [pendingAccount, setPendingAccount] = createSignal("");
+
const [shouldForceRedirect, setShouldForceRedirect] = createSignal(false);
+
+
const initiate = (account: string) => {
+
if (!account) return;
+
setPendingAccount(account);
+
setShouldForceRedirect(false);
+
setShowScopeSelector(true);
+
};
+
+
const initiateWithRedirect = (account: string) => {
+
if (!account) return;
+
setPendingAccount(account);
+
setShouldForceRedirect(true);
+
setShowScopeSelector(true);
+
};
+
+
const complete = async (scopeString: string, scopeIds: string) => {
+
try {
+
const account = pendingAccount();
+
+
if (options.beforeRedirect && !shouldForceRedirect()) {
+
try {
+
await options.beforeRedirect(account);
+
setShowScopeSelector(false);
+
return;
+
} catch {}
+
}
+
+
localStorage.setItem("pendingScopes", scopeIds);
+
+
options.onRedirecting?.();
+
+
const authUrl = await createAuthorizationUrl({
+
scope: scopeString,
+
target:
+
isHandle(account) || isDid(account) ?
+
{ type: "account", identifier: account }
+
: { type: "pds", serviceUrl: account },
+
});
+
+
await new Promise((resolve) => setTimeout(resolve, 250));
+
location.assign(authUrl);
+
} catch (e) {
+
console.error(e);
+
options.onError?.(e);
+
setShowScopeSelector(false);
+
}
+
};
+
+
const cancel = () => {
+
setShowScopeSelector(false);
+
setPendingAccount("");
+
setShouldForceRedirect(false);
+
};
+
+
return {
+
showScopeSelector,
+
pendingAccount,
+
initiate,
+
initiateWithRedirect,
+
complete,
+
cancel,
+
};
+
};
+88
src/auth/scope-selector.tsx
···
+
import { createSignal, For } from "solid-js";
+
import { buildScopeString, GRANULAR_SCOPES, scopeIdsToString } from "./scope-utils";
+
+
interface ScopeSelectorProps {
+
onConfirm: (scopeString: string, scopeIds: string) => void;
+
onCancel: () => void;
+
initialScopes?: Set<string>;
+
}
+
+
export const ScopeSelector = (props: ScopeSelectorProps) => {
+
const [selectedScopes, setSelectedScopes] = createSignal<Set<string>>(
+
props.initialScopes || new Set(["create", "update", "delete", "blob"]),
+
);
+
+
const isBlobDisabled = () => {
+
const scopes = selectedScopes();
+
return !scopes.has("create") && !scopes.has("update");
+
};
+
+
const toggleScope = (scopeId: string) => {
+
setSelectedScopes((prev) => {
+
const newSet = new Set(prev);
+
if (newSet.has(scopeId)) {
+
newSet.delete(scopeId);
+
if (
+
(scopeId === "create" || scopeId === "update") &&
+
!newSet.has("create") &&
+
!newSet.has("update")
+
) {
+
newSet.delete("blob");
+
}
+
} else {
+
newSet.add(scopeId);
+
}
+
return newSet;
+
});
+
};
+
+
const handleConfirm = () => {
+
const scopes = selectedScopes();
+
const scopeString = buildScopeString(scopes);
+
const scopeIds = scopeIdsToString(scopes);
+
props.onConfirm(scopeString, scopeIds);
+
};
+
+
return (
+
<div class="flex flex-col gap-y-2">
+
<div class="mb-1 flex items-center gap-2">
+
<button
+
onclick={props.onCancel}
+
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"
+
>
+
<span class="iconify lucide--arrow-left"></span>
+
</button>
+
<div class="font-semibold">Select permissions</div>
+
</div>
+
<div class="flex flex-col gap-y-2 px-1">
+
<For each={GRANULAR_SCOPES}>
+
{(scope) => (
+
<div
+
class="flex items-center gap-2"
+
classList={{ "opacity-50": scope.id === "blob" && isBlobDisabled() }}
+
>
+
<input
+
id={`scope-${scope.id}`}
+
type="checkbox"
+
checked={selectedScopes().has(scope.id)}
+
disabled={scope.id === "blob" && isBlobDisabled()}
+
onChange={() => toggleScope(scope.id)}
+
/>
+
<label for={`scope-${scope.id}`} class="flex grow items-center gap-2 select-none">
+
<span>{scope.label}</span>
+
</label>
+
</div>
+
)}
+
</For>
+
</div>
+
<div class="mt-2 flex gap-2">
+
<button
+
onclick={handleConfirm}
+
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"
+
>
+
Continue
+
</button>
+
</div>
+
</div>
+
);
+
};
+53
src/auth/scope-utils.ts
···
+
import { agent, sessions } from "./state";
+
+
export const GRANULAR_SCOPES = [
+
{
+
id: "create",
+
scope: "repo:*?action=create",
+
label: "Create records",
+
},
+
{
+
id: "update",
+
scope: "repo:*?action=update",
+
label: "Update records",
+
},
+
{
+
id: "delete",
+
scope: "repo:*?action=delete",
+
label: "Delete records",
+
},
+
{
+
id: "blob",
+
scope: "blob:*/*",
+
label: "Upload blobs",
+
},
+
];
+
+
export const BASE_SCOPES = ["atproto"];
+
+
export const buildScopeString = (selected: Set<string>): string => {
+
const granular = GRANULAR_SCOPES.filter((s) => selected.has(s.id)).map((s) => s.scope);
+
return [...BASE_SCOPES, ...granular].join(" ");
+
};
+
+
export const scopeIdsToString = (scopeIds: Set<string>): string => {
+
return ["atproto", ...Array.from(scopeIds)].join(",");
+
};
+
+
export const parseScopeString = (scopeIdsString: string): Set<string> => {
+
if (!scopeIdsString) return new Set();
+
const ids = scopeIdsString.split(",").filter(Boolean);
+
return new Set(ids.filter((id) => id !== "atproto"));
+
};
+
+
export const hasScope = (grantedScopes: string | undefined, scopeId: string): boolean => {
+
if (!grantedScopes) return false;
+
return grantedScopes.split(",").includes(scopeId);
+
};
+
+
export const hasUserScope = (scopeId: string): boolean => {
+
if (!agent()) return false;
+
const grantedScopes = sessions[agent()!.sub]?.grantedScopes;
+
if (!grantedScopes) return true;
+
return hasScope(grantedScopes, scopeId);
+
};
+95
src/auth/session-manager.ts
···
+
import { Client, CredentialManager } from "@atcute/client";
+
import { Did } from "@atcute/lexicons";
+
import {
+
finalizeAuthorization,
+
getSession,
+
OAuthUserAgent,
+
type Session,
+
} from "@atcute/oauth-browser-client";
+
import { resolveDidDoc } from "../utils/api";
+
import { Sessions, setAgent, setSessions } from "./state";
+
+
export const saveSessionToStorage = (sessions: Sessions) => {
+
localStorage.setItem("sessions", JSON.stringify(sessions));
+
};
+
+
export const loadSessionsFromStorage = (): Sessions | null => {
+
const localSessions = localStorage.getItem("sessions");
+
return localSessions ? JSON.parse(localSessions) : null;
+
};
+
+
export const getAvatar = async (did: Did): Promise<string | undefined> => {
+
const rpc = new Client({
+
handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
+
});
+
const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } });
+
if (res.ok) {
+
return res.data.avatar;
+
}
+
return undefined;
+
};
+
+
export const loadHandleForSession = async (did: Did, storedSessions: Sessions) => {
+
const doc = await resolveDidDoc(did);
+
const alias = doc.alsoKnownAs?.find((alias) => alias.startsWith("at://"));
+
if (alias) {
+
setSessions(did, {
+
signedIn: storedSessions[did].signedIn,
+
handle: alias.replace("at://", ""),
+
grantedScopes: storedSessions[did].grantedScopes,
+
});
+
}
+
};
+
+
export const retrieveSession = async (): Promise<void> => {
+
const init = async (): Promise<Session | undefined> => {
+
const params = new URLSearchParams(location.hash.slice(1));
+
+
if (params.has("state") && (params.has("code") || params.has("error"))) {
+
history.replaceState(null, "", location.pathname + location.search);
+
+
const auth = await finalizeAuthorization(params);
+
const did = auth.session.info.sub;
+
+
localStorage.setItem("lastSignedIn", did);
+
+
const grantedScopes = localStorage.getItem("pendingScopes") || "atproto";
+
localStorage.removeItem("pendingScopes");
+
+
const sessions = loadSessionsFromStorage();
+
const newSessions: Sessions = sessions || {};
+
newSessions[did] = { signedIn: true, grantedScopes };
+
saveSessionToStorage(newSessions);
+
return auth.session;
+
} else {
+
const lastSignedIn = localStorage.getItem("lastSignedIn");
+
+
if (lastSignedIn) {
+
const sessions = loadSessionsFromStorage();
+
const newSessions: Sessions = sessions || {};
+
try {
+
const session = await getSession(lastSignedIn as Did);
+
const rpc = new Client({ handler: new OAuthUserAgent(session) });
+
const res = await rpc.get("com.atproto.server.getSession");
+
newSessions[lastSignedIn].signedIn = true;
+
saveSessionToStorage(newSessions);
+
if (!res.ok) throw res.data.error;
+
return session;
+
} catch (err) {
+
newSessions[lastSignedIn].signedIn = false;
+
saveSessionToStorage(newSessions);
+
throw err;
+
}
+
}
+
}
+
};
+
+
const session = await init();
+
+
if (session) setAgent(new OAuthUserAgent(session));
+
};
+
+
export const resumeSession = async (did: Did): Promise<void> => {
+
localStorage.setItem("lastSignedIn", did);
+
await retrieveSession();
+
};
+14
src/auth/state.ts
···
+
import { OAuthUserAgent } from "@atcute/oauth-browser-client";
+
import { createSignal } from "solid-js";
+
import { createStore } from "solid-js/store";
+
+
export type Account = {
+
signedIn: boolean;
+
handle?: string;
+
grantedScopes?: string;
+
};
+
+
export type Sessions = Record<string, Account>;
+
+
export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>();
+
export const [sessions, setSessions] = createStore<Sessions>();
-170
src/components/account.tsx
···
-
import { Client, CredentialManager } from "@atcute/client";
-
import { Did } from "@atcute/lexicons";
-
import {
-
createAuthorizationUrl,
-
deleteStoredSession,
-
getSession,
-
OAuthUserAgent,
-
} from "@atcute/oauth-browser-client";
-
import { A } from "@solidjs/router";
-
import { createSignal, For, onMount, Show } from "solid-js";
-
import { createStore, produce } from "solid-js/store";
-
import { resolveDidDoc } from "../utils/api.js";
-
import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "./dropdown.jsx";
-
import { agent, Login, retrieveSession, Sessions, setAgent } from "./login.jsx";
-
import { Modal } from "./modal.jsx";
-
-
export const [sessions, setSessions] = createStore<Sessions>();
-
-
const AccountDropdown = (props: { did: Did }) => {
-
const removeSession = async (did: Did) => {
-
const currentSession = agent()?.sub;
-
try {
-
const session = await getSession(did, { allowStale: true });
-
const agent = new OAuthUserAgent(session);
-
await agent.signOut();
-
} catch {
-
deleteStoredSession(did);
-
}
-
setSessions(
-
produce((accs) => {
-
delete accs[did];
-
}),
-
);
-
localStorage.setItem("sessions", JSON.stringify(sessions));
-
if (currentSession === did) setAgent(undefined);
-
};
-
-
return (
-
<MenuProvider>
-
<DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2">
-
<NavMenu href={`/at://${props.did}`} label="Go to repo" icon="lucide--user-round" />
-
<ActionMenu
-
icon="lucide--x"
-
label="Remove account"
-
onClick={() => removeSession(props.did)}
-
/>
-
</DropdownMenu>
-
</MenuProvider>
-
);
-
};
-
-
export const AccountManager = () => {
-
const [openManager, setOpenManager] = createSignal(false);
-
const [avatars, setAvatars] = createStore<Record<Did, string>>();
-
-
onMount(async () => {
-
try {
-
await retrieveSession();
-
} catch {}
-
-
const localSessions = localStorage.getItem("sessions");
-
if (localSessions) {
-
const storedSessions: Sessions = JSON.parse(localSessions);
-
const sessionDids = Object.keys(storedSessions) as Did[];
-
sessionDids.forEach(async (did) => {
-
const doc = await resolveDidDoc(did);
-
const alias = doc.alsoKnownAs?.find((alias) => alias.startsWith("at://"));
-
if (alias) {
-
setSessions(did, {
-
signedIn: storedSessions[did].signedIn,
-
handle: alias.replace("at://", ""),
-
});
-
}
-
});
-
sessionDids.forEach(async (did) => {
-
const avatar = await getAvatar(did);
-
if (avatar) setAvatars(did, avatar);
-
});
-
}
-
});
-
-
const resumeSession = async (did: Did) => {
-
try {
-
localStorage.setItem("lastSignedIn", did);
-
await retrieveSession();
-
} catch {
-
const authUrl = await createAuthorizationUrl({
-
scope: import.meta.env.VITE_OAUTH_SCOPE,
-
target: { type: "account", identifier: did },
-
});
-
-
await new Promise((resolve) => setTimeout(resolve, 250));
-
-
location.assign(authUrl);
-
}
-
};
-
-
const getAvatar = async (did: Did) => {
-
const rpc = new Client({
-
handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
-
});
-
const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } });
-
if (res.ok) {
-
return res.data.avatar;
-
}
-
return undefined;
-
};
-
-
return (
-
<>
-
<Modal open={openManager()} onClose={() => setOpenManager(false)}>
-
<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">
-
<div class="mb-2 px-1 font-semibold">
-
<span>Manage accounts</span>
-
</div>
-
<div class="mb-3 max-h-80 overflow-y-auto md:max-h-100">
-
<For each={Object.keys(sessions)}>
-
{(did) => (
-
<div class="flex w-full items-center justify-between">
-
<A
-
href={`/at://${did}`}
-
onClick={() => setOpenManager(false)}
-
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"
-
>
-
<Show
-
when={avatars[did as Did]}
-
fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>}
-
>
-
<img
-
src={avatars[did as Did].replace("img/avatar/", "img/avatar_thumbnail/")}
-
class="size-6 rounded-full"
-
/>
-
</Show>
-
</A>
-
<button
-
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"
-
onclick={() => resumeSession(did as Did)}
-
>
-
<span class="truncate">
-
{sessions[did]?.handle ? sessions[did].handle : did}
-
</span>
-
<Show when={did === agent()?.sub && sessions[did].signedIn}>
-
<span class="iconify lucide--check shrink-0 text-green-500 dark:text-green-400"></span>
-
</Show>
-
<Show when={!sessions[did].signedIn}>
-
<span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span>
-
</Show>
-
</button>
-
<AccountDropdown did={did as Did} />
-
</div>
-
)}
-
</For>
-
</div>
-
<Login />
-
</div>
-
</Modal>
-
<button
-
onclick={() => setOpenManager(true)}
-
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`}
-
>
-
{agent() && avatars[agent()!.sub] ?
-
<img
-
src={avatars[agent()!.sub].replace("img/avatar/", "img/avatar_thumbnail/")}
-
class="size-5 rounded-full"
-
/>
-
: <span class="iconify lucide--circle-user-round text-lg"></span>}
-
</button>
-
</>
-
);
-
};
+12 -10
src/components/create.tsx
···
import { remove } from "@mary/exif-rm";
import { useNavigate, useParams } from "@solidjs/router";
import { createEffect, createSignal, For, lazy, onCleanup, Show, Suspense } from "solid-js";
-
import { agent } from "../components/login.jsx";
-
import { sessions } from "./account.jsx";
+
import { hasUserScope } from "../auth/scope-utils";
+
import { agent, sessions } from "../auth/state";
import { Button } from "./button.jsx";
import { Modal } from "./modal.jsx";
import { addNotification, removeNotification } from "./notification.jsx";
···
</button>
<Show when={openInsertMenu()}>
<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">
-
<MenuItem
-
icon="lucide--upload"
-
label="Upload blob"
-
onClick={() => {
-
setOpenInsertMenu(false);
-
blobInput.click();
-
}}
-
/>
+
<Show when={hasUserScope("blob")}>
+
<MenuItem
+
icon="lucide--upload"
+
label="Upload blob"
+
onClick={() => {
+
setOpenInsertMenu(false);
+
blobInput.click();
+
}}
+
/>
+
</Show>
<MenuItem
icon="lucide--clock"
label="Insert timestamp"
-143
src/components/login.tsx
···
-
import { Client } from "@atcute/client";
-
import { Did } from "@atcute/lexicons";
-
import { isDid, isHandle } from "@atcute/lexicons/syntax";
-
import {
-
configureOAuth,
-
createAuthorizationUrl,
-
defaultIdentityResolver,
-
finalizeAuthorization,
-
getSession,
-
OAuthUserAgent,
-
type Session,
-
} from "@atcute/oauth-browser-client";
-
import { createSignal, Show } from "solid-js";
-
import { didDocumentResolver, handleResolver } from "../utils/api";
-
-
configureOAuth({
-
metadata: {
-
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
-
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
-
},
-
identityResolver: defaultIdentityResolver({
-
handleResolver: handleResolver,
-
didDocumentResolver: didDocumentResolver,
-
}),
-
});
-
-
export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>();
-
-
type Account = {
-
signedIn: boolean;
-
handle?: string;
-
};
-
-
export type Sessions = Record<string, Account>;
-
-
const Login = () => {
-
const [notice, setNotice] = createSignal("");
-
const [loginInput, setLoginInput] = createSignal("");
-
-
const login = async (handle: string) => {
-
try {
-
setNotice("");
-
if (!handle) return;
-
setNotice(`Contacting your data server...`);
-
const authUrl = await createAuthorizationUrl({
-
scope: import.meta.env.VITE_OAUTH_SCOPE,
-
target:
-
isHandle(handle) || isDid(handle) ?
-
{ type: "account", identifier: handle }
-
: { type: "pds", serviceUrl: handle },
-
});
-
-
setNotice(`Redirecting...`);
-
await new Promise((resolve) => setTimeout(resolve, 250));
-
-
location.assign(authUrl);
-
} catch (e) {
-
console.error(e);
-
setNotice(`${e}`);
-
}
-
};
-
-
return (
-
<form class="flex flex-col gap-y-2 px-1" onsubmit={(e) => e.preventDefault()}>
-
<label for="username" class="hidden">
-
Add account
-
</label>
-
<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">
-
<label
-
for="username"
-
class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400"
-
></label>
-
<input
-
type="text"
-
spellcheck={false}
-
placeholder="user.bsky.social"
-
id="username"
-
name="username"
-
autocomplete="username"
-
aria-label="Your AT Protocol handle"
-
class="grow py-1 select-none placeholder:text-sm focus:outline-none"
-
onInput={(e) => setLoginInput(e.currentTarget.value)}
-
/>
-
<button
-
onclick={() => login(loginInput())}
-
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"
-
>
-
<span class="iconify lucide--log-in"></span>
-
</button>
-
</div>
-
<Show when={notice()}>
-
<div class="text-sm">{notice()}</div>
-
</Show>
-
</form>
-
);
-
};
-
-
const retrieveSession = async () => {
-
const init = async (): Promise<Session | undefined> => {
-
const params = new URLSearchParams(location.hash.slice(1));
-
-
if (params.has("state") && (params.has("code") || params.has("error"))) {
-
history.replaceState(null, "", location.pathname + location.search);
-
-
const auth = await finalizeAuthorization(params);
-
const did = auth.session.info.sub;
-
-
localStorage.setItem("lastSignedIn", did);
-
-
const sessions = localStorage.getItem("sessions");
-
const newSessions: Sessions = sessions ? JSON.parse(sessions) : { [did]: {} };
-
newSessions[did] = { signedIn: true };
-
localStorage.setItem("sessions", JSON.stringify(newSessions));
-
return auth.session;
-
} else {
-
const lastSignedIn = localStorage.getItem("lastSignedIn");
-
-
if (lastSignedIn) {
-
const sessions = localStorage.getItem("sessions");
-
const newSessions: Sessions = sessions ? JSON.parse(sessions) : {};
-
try {
-
const session = await getSession(lastSignedIn as Did);
-
const rpc = new Client({ handler: new OAuthUserAgent(session) });
-
const res = await rpc.get("com.atproto.server.getSession");
-
newSessions[lastSignedIn].signedIn = true;
-
localStorage.setItem("sessions", JSON.stringify(newSessions));
-
if (!res.ok) throw res.data.error;
-
return session;
-
} catch (err) {
-
newSessions[lastSignedIn].signedIn = false;
-
localStorage.setItem("sessions", JSON.stringify(newSessions));
-
throw err;
-
}
-
}
-
}
-
};
-
-
const session = await init();
-
-
if (session) setAgent(new OAuthUserAgent(session));
-
};
-
-
export { Login, retrieveSession };
+3 -3
src/layout.tsx
···
import { Meta, MetaProvider } from "@solidjs/meta";
import { A, RouteSectionProps, useLocation, useNavigate } from "@solidjs/router";
import { createEffect, ErrorBoundary, onMount, Show, Suspense } from "solid-js";
-
import { AccountManager } from "./components/account.jsx";
+
import { AccountManager } from "./auth/account.jsx";
+
import { hasUserScope } from "./auth/scope-utils";
import { RecordEditor } from "./components/create.jsx";
import { DropdownMenu, MenuProvider, MenuSeparator, NavMenu } from "./components/dropdown.jsx";
-
import { agent } from "./components/login.jsx";
import { NavBar } from "./components/navbar.jsx";
import { NotificationContainer } from "./components/notification.jsx";
import { Search, SearchButton, showSearch } from "./components/search.jsx";
···
<Show when={location.pathname !== "/"}>
<SearchButton />
</Show>
-
<Show when={agent()}>
+
<Show when={hasUserScope("create")}>
<RecordEditor create={true} />
</Show>
<AccountManager />
+3 -2
src/views/collection.tsx
···
import { A, useParams } from "@solidjs/router";
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js";
import { createStore } from "solid-js/store";
+
import { hasUserScope } from "../auth/scope-utils";
+
import { agent } from "../auth/state";
import { Button } from "../components/button.jsx";
import { JSONType, JSONValue } from "../components/json.jsx";
-
import { agent } from "../components/login.jsx";
import { Modal } from "../components/modal.jsx";
import { addNotification, removeNotification } from "../components/notification.jsx";
import { StickyOverlay } from "../components/sticky.jsx";
···
<StickyOverlay>
<div class="flex w-full flex-col gap-2">
<div class="flex items-center gap-1">
-
<Show when={agent() && agent()?.sub === did}>
+
<Show when={agent() && agent()?.sub === did && hasUserScope("delete")}>
<div class="flex items-center">
<Tooltip
text={batchDelete() ? "Cancel" : "Delete"}
+28 -23
src/views/record.tsx
···
import { verifyRecord } from "@atcute/repo";
import { A, useLocation, useNavigate, useParams } from "@solidjs/router";
import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js";
+
import { hasUserScope } from "../auth/scope-utils";
+
import { agent } from "../auth/state";
import { Backlinks } from "../components/backlinks.jsx";
import { Button } from "../components/button.jsx";
import { RecordEditor, setPlaceholder } from "../components/create.jsx";
···
} from "../components/dropdown.jsx";
import { JSONValue } from "../components/json.jsx";
import { LexiconSchemaView } from "../components/lexicon-schema.jsx";
-
import { agent } from "../components/login.jsx";
import { Modal } from "../components/modal.jsx";
import { pds } from "../components/navbar.jsx";
import { addNotification, removeNotification } from "../components/notification.jsx";
···
</div>
<div class="flex gap-0.5">
<Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}>
-
<RecordEditor create={false} record={record()?.value} refetch={refetch} />
-
<Tooltip text="Delete">
-
<button
-
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"
-
onclick={() => setOpenDelete(true)}
-
>
-
<span class="iconify lucide--trash-2"></span>
-
</button>
-
</Tooltip>
-
<Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
-
<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">
-
<h2 class="mb-2 font-semibold">Delete this record?</h2>
-
<div class="flex justify-end gap-2">
-
<Button onClick={() => setOpenDelete(false)}>Cancel</Button>
-
<Button
-
onClick={deleteRecord}
-
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"
-
>
-
Delete
-
</Button>
+
<Show when={hasUserScope("update")}>
+
<RecordEditor create={false} record={record()?.value} refetch={refetch} />
+
</Show>
+
<Show when={hasUserScope("delete")}>
+
<Tooltip text="Delete">
+
<button
+
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"
+
onclick={() => setOpenDelete(true)}
+
>
+
<span class="iconify lucide--trash-2"></span>
+
</button>
+
</Tooltip>
+
<Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
+
<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">
+
<h2 class="mb-2 font-semibold">Delete this record?</h2>
+
<div class="flex justify-end gap-2">
+
<Button onClick={() => setOpenDelete(false)}>Cancel</Button>
+
<Button
+
onClick={deleteRecord}
+
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"
+
>
+
Delete
+
</Button>
+
</div>
</div>
-
</div>
-
</Modal>
+
</Modal>
+
</Show>
</Show>
<MenuProvider>
<DropdownMenu icon="lucide--ellipsis-vertical" buttonClass="rounded-sm p-1.5">