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
43let 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 try {
322 await api.deleteSession(state.session.accessJwt);
323 } catch {
324 // Ignore errors on logout
325 }
326 }
327 state.session = null;
328 saveSession(null);
329}
330
331export async function switchAccount(did: string): Promise<void> {
332 const account = state.savedAccounts.find((a) => a.did === did);
333 if (!account) {
334 throw new Error("Account not found");
335 }
336 state.loading = true;
337 state.error = null;
338 try {
339 const session = await api.getSession(account.accessJwt);
340 state.session = {
341 ...session,
342 accessJwt: account.accessJwt,
343 refreshJwt: account.refreshJwt,
344 };
345 saveSession(state.session);
346 addOrUpdateSavedAccount(state.session);
347 } catch (e) {
348 if (e instanceof ApiError && e.status === 401) {
349 try {
350 const tokens = await refreshOAuthToken(account.refreshJwt);
351 const sessionInfo = await api.getSession(tokens.access_token);
352 const session: Session = {
353 ...sessionInfo,
354 accessJwt: tokens.access_token,
355 refreshJwt: tokens.refresh_token || account.refreshJwt,
356 };
357 state.session = session;
358 saveSession(session);
359 addOrUpdateSavedAccount(session);
360 } catch {
361 removeSavedAccount(did);
362 state.error = "Session expired. Please log in again.";
363 throw new Error("Session expired");
364 }
365 } else {
366 state.error = "Failed to switch account";
367 throw e;
368 }
369 } finally {
370 state.loading = false;
371 }
372}
373
374export function forgetAccount(did: string): void {
375 removeSavedAccount(did);
376}
377
378export function getAuthState() {
379 return state;
380}
381
382export async function refreshSession(): Promise<void> {
383 if (!state.session) return;
384 try {
385 const sessionInfo = await api.getSession(state.session.accessJwt);
386 state.session = {
387 ...sessionInfo,
388 accessJwt: state.session.accessJwt,
389 refreshJwt: state.session.refreshJwt,
390 };
391 saveSession(state.session);
392 addOrUpdateSavedAccount(state.session);
393 } catch (e) {
394 console.error("Failed to refresh session:", e);
395 }
396}
397
398export function getToken(): string | null {
399 return state.session?.accessJwt ?? null;
400}
401
402export async function getValidToken(): Promise<string | null> {
403 if (!state.session) return null;
404 try {
405 await api.getSession(state.session.accessJwt);
406 return state.session.accessJwt;
407 } catch (e) {
408 if (e instanceof ApiError && e.status === 401) {
409 try {
410 const tokens = await refreshOAuthToken(state.session.refreshJwt);
411 const sessionInfo = await api.getSession(tokens.access_token);
412 const session: Session = {
413 ...sessionInfo,
414 accessJwt: tokens.access_token,
415 refreshJwt: tokens.refresh_token || state.session.refreshJwt,
416 };
417 state.session = session;
418 saveSession(session);
419 addOrUpdateSavedAccount(session);
420 return session.accessJwt;
421 } catch {
422 return null;
423 }
424 }
425 return null;
426 }
427}
428
429export function isAuthenticated(): boolean {
430 return state.session !== null;
431}
432
433export function _testSetState(
434 newState: {
435 session: Session | null;
436 loading: boolean;
437 error: string | null;
438 savedAccounts?: SavedAccount[];
439 },
440) {
441 state.session = newState.session;
442 state.loading = newState.loading;
443 state.error = newState.error;
444 state.savedAccounts = newState.savedAccounts ?? [];
445}
446
447export function _testReset() {
448 state.session = null;
449 state.loading = true;
450 state.error = null;
451 state.savedAccounts = [];
452 localStorage.removeItem(STORAGE_KEY);
453 localStorage.removeItem(ACCOUNTS_KEY);
454}