this repo has no description
1import {
2 api,
3 ApiError,
4 type CreateAccountParams,
5 type CreateAccountResult,
6 type Session,
7 setTokenRefreshCallback,
8} from "./api";
9import {
10 checkForOAuthCallback,
11 clearOAuthCallbackParams,
12 handleOAuthCallback,
13 refreshOAuthToken,
14 startOAuthLogin,
15} from "./oauth";
16import { setLocale, type SupportedLocale } from "./i18n";
17
18function applyLocaleFromSession(
19 sessionInfo: { preferredLocale?: string | null },
20) {
21 if (sessionInfo.preferredLocale) {
22 setLocale(sessionInfo.preferredLocale as SupportedLocale);
23 }
24}
25
26const STORAGE_KEY = "tranquil_pds_session";
27const ACCOUNTS_KEY = "tranquil_pds_accounts";
28
29export interface SavedAccount {
30 did: string;
31 handle: string;
32 accessJwt: string;
33 refreshJwt: string;
34}
35
36interface AuthState {
37 session: Session | null;
38 loading: boolean;
39 error: string | null;
40 savedAccounts: SavedAccount[];
41}
42
43const state = $state<AuthState>({
44 session: null,
45 loading: true,
46 error: null,
47 savedAccounts: [],
48});
49
50function saveSession(session: Session | null) {
51 if (session) {
52 localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
53 } else {
54 localStorage.removeItem(STORAGE_KEY);
55 }
56}
57
58function loadSession(): Session | null {
59 const stored = localStorage.getItem(STORAGE_KEY);
60 if (stored) {
61 try {
62 return JSON.parse(stored);
63 } catch {
64 return null;
65 }
66 }
67 return null;
68}
69
70function loadSavedAccounts(): SavedAccount[] {
71 const stored = localStorage.getItem(ACCOUNTS_KEY);
72 if (stored) {
73 try {
74 return JSON.parse(stored);
75 } catch {
76 return [];
77 }
78 }
79 return [];
80}
81
82function saveSavedAccounts(accounts: SavedAccount[]) {
83 localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
84}
85
86function addOrUpdateSavedAccount(session: Session) {
87 const accounts = loadSavedAccounts();
88 const existing = accounts.findIndex((a) => a.did === session.did);
89 const savedAccount: SavedAccount = {
90 did: session.did,
91 handle: session.handle,
92 accessJwt: session.accessJwt,
93 refreshJwt: session.refreshJwt,
94 };
95 if (existing >= 0) {
96 accounts[existing] = savedAccount;
97 } else {
98 accounts.push(savedAccount);
99 }
100 saveSavedAccounts(accounts);
101 state.savedAccounts = accounts;
102}
103
104function removeSavedAccount(did: string) {
105 const accounts = loadSavedAccounts().filter((a) => a.did !== did);
106 saveSavedAccounts(accounts);
107 state.savedAccounts = accounts;
108}
109
110async function tryRefreshToken(): Promise<string | null> {
111 if (!state.session) return null;
112 try {
113 const tokens = await refreshOAuthToken(state.session.refreshJwt);
114 const sessionInfo = await api.getSession(tokens.access_token);
115 const session: Session = {
116 ...sessionInfo,
117 accessJwt: tokens.access_token,
118 refreshJwt: tokens.refresh_token || state.session.refreshJwt,
119 };
120 state.session = session;
121 saveSession(session);
122 addOrUpdateSavedAccount(session);
123 return session.accessJwt;
124 } catch {
125 return null;
126 }
127}
128
129export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
130 setTokenRefreshCallback(tryRefreshToken);
131 state.loading = true;
132 state.error = null;
133 state.savedAccounts = loadSavedAccounts();
134
135 const oauthCallback = checkForOAuthCallback();
136 if (oauthCallback) {
137 clearOAuthCallbackParams();
138 try {
139 const tokens = await handleOAuthCallback(
140 oauthCallback.code,
141 oauthCallback.state,
142 );
143 const sessionInfo = await api.getSession(tokens.access_token);
144 const session: Session = {
145 ...sessionInfo,
146 accessJwt: tokens.access_token,
147 refreshJwt: tokens.refresh_token || "",
148 };
149 state.session = session;
150 saveSession(session);
151 addOrUpdateSavedAccount(session);
152 applyLocaleFromSession(sessionInfo);
153 state.loading = false;
154 return { oauthLoginCompleted: true };
155 } catch (e) {
156 state.error = e instanceof Error ? e.message : "OAuth login failed";
157 state.loading = false;
158 return { oauthLoginCompleted: false };
159 }
160 }
161
162 const stored = loadSession();
163 if (stored) {
164 try {
165 const sessionInfo = await api.getSession(stored.accessJwt);
166 state.session = {
167 ...sessionInfo,
168 accessJwt: stored.accessJwt,
169 refreshJwt: stored.refreshJwt,
170 };
171 addOrUpdateSavedAccount(state.session);
172 applyLocaleFromSession(sessionInfo);
173 } catch (e) {
174 if (e instanceof ApiError && e.status === 401) {
175 try {
176 const tokens = await refreshOAuthToken(stored.refreshJwt);
177 const sessionInfo = await api.getSession(tokens.access_token);
178 const session: Session = {
179 ...sessionInfo,
180 accessJwt: tokens.access_token,
181 refreshJwt: tokens.refresh_token || stored.refreshJwt,
182 };
183 state.session = session;
184 saveSession(session);
185 addOrUpdateSavedAccount(session);
186 applyLocaleFromSession(sessionInfo);
187 } catch (refreshError) {
188 console.error("Token refresh failed during init:", refreshError);
189 saveSession(null);
190 state.session = null;
191 }
192 } else {
193 console.error("Non-401 error during getSession:", e);
194 saveSession(null);
195 state.session = null;
196 }
197 }
198 }
199 state.loading = false;
200 return { oauthLoginCompleted: false };
201}
202
203export async function login(
204 identifier: string,
205 password: string,
206): Promise<void> {
207 state.loading = true;
208 state.error = null;
209 try {
210 const session = await api.createSession(identifier, password);
211 state.session = session;
212 saveSession(session);
213 addOrUpdateSavedAccount(session);
214 } catch (e) {
215 if (e instanceof ApiError) {
216 state.error = e.message;
217 } else {
218 state.error = "Login failed";
219 }
220 throw e;
221 } finally {
222 state.loading = false;
223 }
224}
225
226export async function loginWithOAuth(): Promise<void> {
227 state.loading = true;
228 state.error = null;
229 try {
230 await startOAuthLogin();
231 } catch (e) {
232 state.loading = false;
233 state.error = e instanceof Error
234 ? e.message
235 : "Failed to start OAuth login";
236 throw e;
237 }
238}
239
240export async function register(
241 params: CreateAccountParams,
242): Promise<CreateAccountResult> {
243 try {
244 const result = await api.createAccount(params);
245 return result;
246 } catch (e) {
247 if (e instanceof ApiError) {
248 state.error = e.message;
249 } else {
250 state.error = "Registration failed";
251 }
252 throw e;
253 }
254}
255
256export async function confirmSignup(
257 did: string,
258 verificationCode: string,
259): Promise<void> {
260 state.loading = true;
261 state.error = null;
262 try {
263 const result = await api.confirmSignup(did, verificationCode);
264 const session: Session = {
265 did: result.did,
266 handle: result.handle,
267 accessJwt: result.accessJwt,
268 refreshJwt: result.refreshJwt,
269 email: result.email,
270 emailConfirmed: result.emailConfirmed,
271 preferredChannel: result.preferredChannel,
272 preferredChannelVerified: result.preferredChannelVerified,
273 };
274 state.session = session;
275 saveSession(session);
276 addOrUpdateSavedAccount(session);
277 } catch (e) {
278 if (e instanceof ApiError) {
279 state.error = e.message;
280 } else {
281 state.error = "Verification failed";
282 }
283 throw e;
284 } finally {
285 state.loading = false;
286 }
287}
288
289export async function resendVerification(did: string): Promise<void> {
290 try {
291 await api.resendVerification(did);
292 } catch (e) {
293 if (e instanceof ApiError) {
294 throw e;
295 }
296 throw new Error("Failed to resend verification code");
297 }
298}
299
300export function setSession(
301 session: {
302 did: string;
303 handle: string;
304 accessJwt: string;
305 refreshJwt: string;
306 },
307): void {
308 const newSession: Session = {
309 did: session.did,
310 handle: session.handle,
311 accessJwt: session.accessJwt,
312 refreshJwt: session.refreshJwt,
313 };
314 state.session = newSession;
315 saveSession(newSession);
316 addOrUpdateSavedAccount(newSession);
317}
318
319export async function logout(): Promise<void> {
320 if (state.session) {
321 const did = state.session.did;
322 const refreshToken = state.session.refreshJwt;
323 try {
324 await fetch("/oauth/revoke", {
325 method: "POST",
326 headers: { "Content-Type": "application/x-www-form-urlencoded" },
327 body: new URLSearchParams({ token: refreshToken }),
328 });
329 } catch {
330 // Ignore errors on logout
331 }
332 removeSavedAccount(did);
333 }
334 state.session = null;
335 saveSession(null);
336}
337
338export async function switchAccount(did: string): Promise<void> {
339 const account = state.savedAccounts.find((a) => a.did === did);
340 if (!account) {
341 throw new Error("Account not found");
342 }
343 state.loading = true;
344 state.error = null;
345 try {
346 const session = await api.getSession(account.accessJwt);
347 state.session = {
348 ...session,
349 accessJwt: account.accessJwt,
350 refreshJwt: account.refreshJwt,
351 };
352 saveSession(state.session);
353 addOrUpdateSavedAccount(state.session);
354 } catch (e) {
355 if (e instanceof ApiError && e.status === 401) {
356 try {
357 const tokens = await refreshOAuthToken(account.refreshJwt);
358 const sessionInfo = await api.getSession(tokens.access_token);
359 const session: Session = {
360 ...sessionInfo,
361 accessJwt: tokens.access_token,
362 refreshJwt: tokens.refresh_token || account.refreshJwt,
363 };
364 state.session = session;
365 saveSession(session);
366 addOrUpdateSavedAccount(session);
367 } catch {
368 removeSavedAccount(did);
369 state.error = "Session expired. Please log in again.";
370 throw new Error("Session expired");
371 }
372 } else {
373 state.error = "Failed to switch account";
374 throw e;
375 }
376 } finally {
377 state.loading = false;
378 }
379}
380
381export function forgetAccount(did: string): void {
382 removeSavedAccount(did);
383}
384
385export function getAuthState() {
386 return state;
387}
388
389export async function refreshSession(): Promise<void> {
390 if (!state.session) return;
391 try {
392 const sessionInfo = await api.getSession(state.session.accessJwt);
393 state.session = {
394 ...sessionInfo,
395 accessJwt: state.session.accessJwt,
396 refreshJwt: state.session.refreshJwt,
397 };
398 saveSession(state.session);
399 addOrUpdateSavedAccount(state.session);
400 } catch (e) {
401 console.error("Failed to refresh session:", e);
402 }
403}
404
405export function getToken(): string | null {
406 return state.session?.accessJwt ?? null;
407}
408
409export async function getValidToken(): Promise<string | null> {
410 if (!state.session) return null;
411 try {
412 await api.getSession(state.session.accessJwt);
413 return state.session.accessJwt;
414 } catch (e) {
415 if (e instanceof ApiError && e.status === 401) {
416 try {
417 const tokens = await refreshOAuthToken(state.session.refreshJwt);
418 const sessionInfo = await api.getSession(tokens.access_token);
419 const session: Session = {
420 ...sessionInfo,
421 accessJwt: tokens.access_token,
422 refreshJwt: tokens.refresh_token || state.session.refreshJwt,
423 };
424 state.session = session;
425 saveSession(session);
426 addOrUpdateSavedAccount(session);
427 return session.accessJwt;
428 } catch {
429 return null;
430 }
431 }
432 return null;
433 }
434}
435
436export function isAuthenticated(): boolean {
437 return state.session !== null;
438}
439
440export function _testSetState(
441 newState: {
442 session: Session | null;
443 loading: boolean;
444 error: string | null;
445 savedAccounts?: SavedAccount[];
446 },
447) {
448 state.session = newState.session;
449 state.loading = newState.loading;
450 state.error = newState.error;
451 state.savedAccounts = newState.savedAccounts ?? [];
452}
453
454export function _testResetState() {
455 state.session = null;
456 state.loading = true;
457 state.error = null;
458 state.savedAccounts = [];
459}
460
461export function _testReset() {
462 _testResetState();
463 localStorage.removeItem(STORAGE_KEY);
464 localStorage.removeItem(ACCOUNTS_KEY);
465}