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