1import { Did } from "@atcute/lexicons";
2import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client";
3import { A } from "@solidjs/router";
4import { createSignal, For, onMount, Show } from "solid-js";
5import { createStore, produce } from "solid-js/store";
6import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx";
7import { Modal } from "../components/modal.jsx";
8import { Login } from "./login.jsx";
9import { useOAuthScopeFlow } from "./scope-flow.js";
10import { ScopeSelector } from "./scope-selector.jsx";
11import { parseScopeString } from "./scope-utils.js";
12import {
13 getAvatar,
14 loadHandleForSession,
15 loadSessionsFromStorage,
16 resumeSession,
17 retrieveSession,
18 saveSessionToStorage,
19} from "./session-manager.js";
20import { agent, sessions, setAgent, setSessions } from "./state.js";
21
22const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => {
23 const removeSession = async (did: Did) => {
24 const currentSession = agent()?.sub;
25 try {
26 const session = await getSession(did, { allowStale: true });
27 const agent = new OAuthUserAgent(session);
28 await agent.signOut();
29 } catch {
30 deleteStoredSession(did);
31 }
32 setSessions(
33 produce((accs) => {
34 delete accs[did];
35 }),
36 );
37 saveSessionToStorage(sessions);
38 if (currentSession === did) setAgent(undefined);
39 };
40
41 return (
42 <MenuProvider>
43 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2">
44 <NavMenu 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
60export 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-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
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};