+1
-1
public/oauth-client-metadata.json
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
-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
+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
+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
+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">