diff --git a/projects/developer/src/lib/api/DeveloperAccount.ts b/projects/developer/src/lib/api/DeveloperAccount.ts index 4eb77611..1d70d386 100644 --- a/projects/developer/src/lib/api/DeveloperAccount.ts +++ b/projects/developer/src/lib/api/DeveloperAccount.ts @@ -3,5 +3,6 @@ export type DeveloperAccount = { username: string; expiresAt: number; isExpired: boolean; + hasSessionError: boolean; source: 'developer-oauth'; }; diff --git a/projects/developer/src/lib/api/accountRequest.ts b/projects/developer/src/lib/api/accountRequest.ts index 2528aab3..58ef5662 100644 --- a/projects/developer/src/lib/api/accountRequest.ts +++ b/projects/developer/src/lib/api/accountRequest.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { markRejectedSession } from '$lib/auth/markRejectedSession.ts'; import { accessToken } from '$lib/auth/accessToken.ts'; import { developerErrorMessage } from './developerErrorMessage.ts'; import { traktHeaders } from './traktHeaders.ts'; @@ -33,12 +34,16 @@ export async function accountRequest({ cache: 'no-store', redirect: 'error', }); + if (response.status === 401) { + await markRejectedSession({ slot, accessToken: token }); + } if (!response.ok) { const localized = developerErrorMessage(await errorCode(response)); const messages: Record = { 400: 'The request could not be accepted. Check your details and try again.', - 401: 'Your session has expired. Refresh your account or sign in again.', + 401: + 'Your session is no longer valid. Sign in again from the account menu.', 403: 'This account cannot perform this action. Check your app limit and your GitHub account connection.', 404: 'This app is no longer available. Reload your apps.', diff --git a/projects/developer/src/lib/api/developerErrorMessage.ts b/projects/developer/src/lib/api/developerErrorMessage.ts index ccc5f5cd..62a72f5d 100644 --- a/projects/developer/src/lib/api/developerErrorMessage.ts +++ b/projects/developer/src/lib/api/developerErrorMessage.ts @@ -1,6 +1,6 @@ const DEVELOPER_ERROR_MESSAGES = { authentication_required: - 'Your session has expired. Refresh your account or sign in again.', + 'Your session is no longer valid. Sign in again from the account menu.', invalid_request_body: 'The request could not be accepted. Check your details and try again.', invalid_application_id: 'This app could not be found. Reload your apps.', diff --git a/projects/developer/src/lib/api/executeApiRequest.spec.ts b/projects/developer/src/lib/api/executeApiRequest.spec.ts index 563391f8..f9e1b6fc 100644 --- a/projects/developer/src/lib/api/executeApiRequest.spec.ts +++ b/projects/developer/src/lib/api/executeApiRequest.spec.ts @@ -28,6 +28,17 @@ beforeEach(() => vi.mocked(accessToken).mockResolvedValue('token-value')); afterEach(() => vi.unstubAllGlobals()); describe('execute api request', () => { + it('does not send a request when token refresh fails', async () => { + const fetch = respondWith('[]'); + vi.mocked(accessToken).mockRejectedValueOnce( + new Error('Refresh unavailable'), + ); + + await expect(executeApiRequest({ ...BASE, accountSlot: 1 })) + .rejects.toThrow('Refresh unavailable'); + expect(fetch).not.toHaveBeenCalled(); + }); + it('sends the public client id and api version', async () => { const fetch = respondWith('[]'); await executeApiRequest(BASE); diff --git a/projects/developer/src/lib/api/executeApiRequest.ts b/projects/developer/src/lib/api/executeApiRequest.ts index 19c262e6..269721c8 100644 --- a/projects/developer/src/lib/api/executeApiRequest.ts +++ b/projects/developer/src/lib/api/executeApiRequest.ts @@ -1,4 +1,5 @@ import { PUBLIC_TRAKT_CLIENT_ID } from '$env/static/public'; +import { markRejectedSession } from '$lib/auth/markRejectedSession.ts'; import { accessToken } from '$lib/auth/accessToken.ts'; import type { ApiExecutionRequest } from './ApiExecutionRequest.ts'; import type { ApiExecutionResponse } from './ApiExecutionResponse.ts'; @@ -104,6 +105,10 @@ export async function executeApiRequest( throw new Error('The Trakt API request could not be completed.'); } + if (response.status === 401 && slot !== null && token) { + await markRejectedSession({ slot, accessToken: token }); + } + const text = await response.text(); const size = new TextEncoder().encode(text).length; diff --git a/projects/developer/src/lib/api/mutateAccount.spec.ts b/projects/developer/src/lib/api/mutateAccount.spec.ts deleted file mode 100644 index fb9c0c58..00000000 --- a/projects/developer/src/lib/api/mutateAccount.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { mutateAccount } from './mutateAccount.ts'; -import { refreshAccount } from '$lib/auth/refreshAccount.ts'; -import { signOutAccount } from '$lib/auth/signOutAccount.ts'; - -vi.mock('$lib/auth/refreshAccount.ts', () => ({ - refreshAccount: vi.fn(), -})); -vi.mock('$lib/auth/signOutAccount.ts', () => ({ - signOutAccount: vi.fn(), -})); - -beforeEach(() => vi.clearAllMocks()); - -describe('account actions', () => { - it('refreshes the selected account', async () => { - await mutateAccount(3, 'POST'); - expect(refreshAccount).toHaveBeenCalledWith(3); - expect(signOutAccount).not.toHaveBeenCalled(); - }); - - it('logs out the selected account', async () => { - await mutateAccount(3, 'DELETE'); - expect(signOutAccount).toHaveBeenCalledWith(3); - expect(refreshAccount).not.toHaveBeenCalled(); - }); - - it('reports refresh failure without exposing the cause', async () => { - vi.mocked(refreshAccount).mockRejectedValue( - new Error('refresh_token=private'), - ); - await expect(mutateAccount(0, 'POST')).rejects.toThrow( - 'Could not refresh access token. Try again or reconnect your account.', - ); - }); - - it('reports failed logout instead of treating it as success', async () => { - vi.mocked(signOutAccount).mockRejectedValue(new Error('denied')); - await expect(mutateAccount(0, 'DELETE')).rejects.toThrow( - 'Could not log out. Try again.', - ); - }); -}); diff --git a/projects/developer/src/lib/api/mutateAccount.ts b/projects/developer/src/lib/api/mutateAccount.ts deleted file mode 100644 index b2de106e..00000000 --- a/projects/developer/src/lib/api/mutateAccount.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { refreshAccount } from '$lib/auth/refreshAccount.ts'; -import { signOutAccount } from '$lib/auth/signOutAccount.ts'; - -export async function mutateAccount( - slot: number, - method: 'POST' | 'DELETE', -): Promise { - const isRefresh = method === 'POST'; - - try { - await (isRefresh ? refreshAccount(slot) : signOutAccount(slot)); - } catch { - throw new Error( - `Could not ${isRefresh ? 'refresh access token' : 'log out'}. Try again${ - isRefresh ? ' or reconnect your account' : '' - }.`, - ); - } -} diff --git a/projects/developer/src/lib/auth/AUTH_REQUEST_TIMEOUT_SECONDS.ts b/projects/developer/src/lib/auth/AUTH_REQUEST_TIMEOUT_SECONDS.ts new file mode 100644 index 00000000..00add4f7 --- /dev/null +++ b/projects/developer/src/lib/auth/AUTH_REQUEST_TIMEOUT_SECONDS.ts @@ -0,0 +1 @@ +export const AUTH_REQUEST_TIMEOUT_SECONDS = 15; diff --git a/projects/developer/src/lib/auth/accessToken.ts b/projects/developer/src/lib/auth/accessToken.ts index b5db7547..e6c9c9be 100644 --- a/projects/developer/src/lib/auth/accessToken.ts +++ b/projects/developer/src/lib/auth/accessToken.ts @@ -1,6 +1,11 @@ +import { accountSessionErrors } from './accountSessionErrors.ts'; +import { createAccessTokenProvider } from './createAccessTokenProvider.ts'; import { userManager } from './userManager.ts'; +import { withAccountLock } from './withAccountLock.ts'; -export async function accessToken(slot: number): Promise { - const user = await userManager(slot).getUser().catch(() => null); - return user?.access_token ?? null; -} +export const accessToken = createAccessTokenProvider({ + manager: userManager, + onSessionError: accountSessionErrors.mark, + lock: withAccountLock, + storage: () => globalThis.localStorage, +}); diff --git a/projects/developer/src/lib/auth/accountSessionErrors.spec.ts b/projects/developer/src/lib/auth/accountSessionErrors.spec.ts new file mode 100644 index 00000000..588a471c --- /dev/null +++ b/projects/developer/src/lib/auth/accountSessionErrors.spec.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { accountSessionErrors } from './accountSessionErrors.ts'; + +afterEach(() => { + accountSessionErrors.clear(0); + accountSessionErrors.clear(1); +}); + +describe('account session errors', () => { + it('notifies once per failed account without causing retry loops', () => { + const changed = vi.fn(); + const stop = accountSessionErrors.subscribe(changed); + accountSessionErrors.mark(0); + accountSessionErrors.mark(0); + expect(changed).toHaveBeenCalledTimes(1); + expect(accountSessionErrors.has(0)).toBe(true); + expect(accountSessionErrors.has(1)).toBe(false); + stop(); + }); + + it('clears a recovered session and unsubscribes observers', () => { + const changed = vi.fn(); + const stop = accountSessionErrors.subscribe(changed); + accountSessionErrors.mark(0); + accountSessionErrors.clear(0); + expect(accountSessionErrors.has(0)).toBe(false); + stop(); + accountSessionErrors.mark(1); + expect(changed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/projects/developer/src/lib/auth/accountSessionErrors.ts b/projects/developer/src/lib/auth/accountSessionErrors.ts new file mode 100644 index 00000000..6d9f6b1a --- /dev/null +++ b/projects/developer/src/lib/auth/accountSessionErrors.ts @@ -0,0 +1,21 @@ +const errors = new Set(); +const listeners = new Set<() => void>(); + +export const accountSessionErrors = { + has: (slot: number): boolean => errors.has(slot), + mark(slot: number): void { + if (errors.has(slot)) return; + + errors.add(slot); + listeners.forEach((listener) => listener()); + }, + clear(slot: number): void { + errors.delete(slot); + }, + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, +}; diff --git a/projects/developer/src/lib/auth/authFetch.spec.ts b/projects/developer/src/lib/auth/authFetch.spec.ts new file mode 100644 index 00000000..34cc208d --- /dev/null +++ b/projects/developer/src/lib/auth/authFetch.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { authFetch } from './authFetch.ts'; + +const timedRequest = { + timeoutInSeconds: 15, + signal: new AbortController().signal, +}; + +describe('authentication fetch', () => { + it('buffers timed OIDC responses without replacing their metadata or consuming their body', async () => { + const response = new Response('{"ok":true}', { + headers: { 'content-type': 'application/json' }, + }); + const fetcher = vi.fn().mockResolvedValue(response); + const result = await authFetch(fetcher)( + 'https://auth.trakt.tv/oauth/token', + timedRequest, + ); + expect(result).toBe(response); + expect(result.bodyUsed).toBe(false); + expect(await result.json()).toEqual({ ok: true }); + expect(fetcher).toHaveBeenCalledWith( + 'https://auth.trakt.tv/oauth/token', + timedRequest, + ); + }); + + it.each([ + ['https://api.trakt.tv/users/settings', {}], + ['https://auth.trakt.tv/oauth/token', {}], + ['https://example.com/resource', {}], + ])('leaves unrelated requests unbuffered: %s', async (url, init) => { + const response = new Response('unchanged'); + const clone = vi.spyOn(response, 'clone'); + const fetcher = vi.fn().mockResolvedValue(response); + expect(await authFetch(fetcher)(url, init)).toBe(response); + expect(clone).not.toHaveBeenCalled(); + }); + + it('does not wrap the same fetch more than once', () => { + const wrapped = authFetch(vi.fn()); + expect(authFetch(wrapped)).toBe(wrapped); + }); +}); diff --git a/projects/developer/src/lib/auth/authFetch.ts b/projects/developer/src/lib/auth/authFetch.ts new file mode 100644 index 00000000..76de60cb --- /dev/null +++ b/projects/developer/src/lib/auth/authFetch.ts @@ -0,0 +1,22 @@ +const wrappedFetches = new WeakSet(); + +export function authFetch(fetcher: typeof fetch): typeof fetch { + if (wrappedFetches.has(fetcher)) return fetcher; + + const wrapped: typeof fetch = async (input, init) => { + const response = await fetcher.call(globalThis, input, init); + + // oidc-client-ts passes its timeout through to fetch, but clears the timer + // before reading JSON. Keep that timer alive until the body is buffered. + if ( + init && 'timeoutInSeconds' in init && + typeof init.timeoutInSeconds === 'number' && init.timeoutInSeconds > 0 + ) { + await response.clone().arrayBuffer(); + } + + return response; + }; + wrappedFetches.add(wrapped); + return wrapped; +} diff --git a/projects/developer/src/lib/auth/completeSignIn.ts b/projects/developer/src/lib/auth/completeSignIn.ts index f80fff65..62a74f63 100644 --- a/projects/developer/src/lib/auth/completeSignIn.ts +++ b/projects/developer/src/lib/auth/completeSignIn.ts @@ -1,6 +1,7 @@ import { rememberSlot } from './rememberSlot.ts'; import { takePendingSlot } from './takePendingSlot.ts'; import { storeUsername } from './storeUsername.ts'; +import { withAccountLock } from './withAccountLock.ts'; import { userManager } from './userManager.ts'; export async function completeSignIn(): Promise { @@ -10,7 +11,9 @@ export async function completeSignIn(): Promise { throw new Error('This sign-in could not be matched to an account.'); } - const user = await userManager(slot).signinRedirectCallback(); - await storeUsername(slot, user.access_token); + await withAccountLock(slot, async () => { + const user = await userManager(slot).signinRedirectCallback(); + await storeUsername(slot, user.access_token); + }); rememberSlot(slot); } diff --git a/projects/developer/src/lib/auth/createAccessTokenProvider.spec.ts b/projects/developer/src/lib/auth/createAccessTokenProvider.spec.ts new file mode 100644 index 00000000..124ead16 --- /dev/null +++ b/projects/developer/src/lib/auth/createAccessTokenProvider.spec.ts @@ -0,0 +1,164 @@ +import { User } from 'oidc-client-ts'; +import { describe, expect, it, vi } from 'vitest'; +import { createAccessTokenProvider } from './createAccessTokenProvider.ts'; + +const NOW = 1_700_000_000_000; + +function session(expiresAt = NOW / 1000 + 3600, token = 'fresh') { + return new User({ + access_token: token, + refresh_token: 'refresh', + token_type: 'Bearer', + expires_at: expiresAt, + profile: { + sub: 'account', + iss: 'trakt', + aud: 'portal', + exp: expiresAt, + iat: 0, + }, + }); +} + +function setup() { + const users = new Map([[ + 0, + session(NOW / 1000 - 1, 'expired'), + ]]); + const values = new Map(); + const queues = new Map>(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + }; + const renew = vi.fn(async (slot: number) => { + const user = session(undefined, `fresh-${slot}`); + users.set(slot, user); + return user; + }); + const clock = { value: NOW }; + const onSessionError = vi.fn(); + const dependencies = { + onSessionError, + manager: (slot: number) => ({ + getUser: async () => users.get(slot) ?? null, + signinSilent: () => renew(slot), + }), + lock: (slot: number, operation: () => Promise): Promise => { + const result = (queues.get(slot) ?? Promise.resolve()).catch(() => {}) + .then(operation); + queues.set(slot, result); + return result; + }, + storage: () => storage, + now: () => clock.value, + }; + return { + users, + renew, + clock, + dependencies, + token: createAccessTokenProvider(dependencies), + }; +} + +describe('access token provider', () => { + it('reuses fresh tokens without renewing', async () => { + const { users, renew, token } = setup(); + users.set(0, session()); + await expect(token(0)).resolves.toBe('fresh'); + await expect(token(0)).resolves.toBe('fresh'); + expect(renew).not.toHaveBeenCalled(); + }); + + it.each([NOW / 1000 - 1, NOW / 1000 + 60, undefined])( + 'renews expired, nearly expired, or unknown expiry %s', + async (expiry) => { + const { users, token, renew } = setup(); + const user = session(); + user.expires_at = expiry; + users.set(0, user); + await expect(token(0)).resolves.toBe('fresh-0'); + expect(renew).toHaveBeenCalledTimes(1); + }, + ); + + it('shares one refresh among concurrent callers', async () => { + const { token, renew } = setup(); + const tokens = await Promise.all( + Array.from({ length: 20 }, () => token(0)), + ); + expect(tokens).toEqual(Array(20).fill('fresh-0')); + expect(renew).toHaveBeenCalledTimes(1); + }); + + it('rereads the rotated token when another tab has refreshed', async () => { + const { token, renew, dependencies } = setup(); + const otherTab = createAccessTokenProvider(dependencies); + await expect(Promise.all([token(0), otherTab(0)])).resolves.toEqual([ + 'fresh-0', + 'fresh-0', + ]); + expect(renew).toHaveBeenCalledTimes(1); + }); + + it('allows another account to refresh while the first is waiting', async () => { + const { users, token, renew } = setup(); + users.set(1, session(0)); + let finish: (user: User) => void = () => {}; + renew.mockImplementationOnce(() => + new Promise((resolve) => { + finish = resolve; + }) + ); + const first = token(0); + await expect(token(1)).resolves.toBe('fresh-1'); + finish(session(undefined, 'fresh-0')); + await expect(first).resolves.toBe('fresh-0'); + }); + + it('keeps the session and shares a failure cooldown across tabs before retrying', async () => { + const { token, renew, dependencies, users, clock } = setup(); + renew.mockRejectedValueOnce(new Error('offline')); + await expect(token(0)).rejects.toThrow('Could not refresh'); + expect(dependencies.onSessionError).toHaveBeenCalledWith(0); + const otherTab = createAccessTokenProvider(dependencies); + await expect(otherTab(0)).rejects.toThrow('temporarily unavailable'); + expect(users.get(0)?.access_token).toBe('expired'); + expect(renew).toHaveBeenCalledTimes(1); + clock.value += 30_001; + await expect(otherTab(0)).resolves.toBe('fresh-0'); + expect(renew).toHaveBeenCalledTimes(2); + }); + + it('does not use iframe sign-in when a refresh token is missing', async () => { + const { token, users, renew } = setup(); + const user = session(0); + user.refresh_token = undefined; + users.set(0, user); + await expect(token(0)).rejects.toThrow('Sign in again'); + expect(renew).not.toHaveBeenCalled(); + }); + + it('rejects a refresh response that is already expiring', async () => { + const { token, renew } = setup(); + renew.mockResolvedValueOnce(session(NOW / 1000 + 10)); + await expect(token(0)).rejects.toThrow('Could not refresh'); + }); + + it('does not refresh a session removed while waiting for the lock', async () => { + const { token, users, renew, dependencies } = setup(); + const logout = dependencies.lock(0, async () => { + users.delete(0); + }); + const request = token(0); + await logout; + await expect(request).resolves.toBeNull(); + expect(renew).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/developer/src/lib/auth/createAccessTokenProvider.ts b/projects/developer/src/lib/auth/createAccessTokenProvider.ts new file mode 100644 index 00000000..a9b5199b --- /dev/null +++ b/projects/developer/src/lib/auth/createAccessTokenProvider.ts @@ -0,0 +1,81 @@ +import type { User, UserManager } from 'oidc-client-ts'; +import { AUTH_REQUEST_TIMEOUT_SECONDS } from './AUTH_REQUEST_TIMEOUT_SECONDS.ts'; + +const EXPIRY_MARGIN_SECONDS = 60; +const RETRY_DELAY_MS = 30_000; + +function isFresh(user: User | null, now: number): boolean { + return Boolean( + user?.access_token && Number.isFinite(user.expires_at) && + (user.expires_at ?? 0) > now / 1000 + EXPIRY_MARGIN_SECONDS, + ); +} + +export function createAccessTokenProvider({ + manager, + lock, + storage, + now = Date.now, + onSessionError = () => {}, +}: { + manager: (slot: number) => Pick; + lock: (slot: number, operation: () => Promise) => Promise; + storage: () => Pick; + now?: () => number; + onSessionError?: (slot: number) => void; +}): (slot: number) => Promise { + const pending = new Map>(); + + async function resolveToken(slot: number): Promise { + const account = manager(slot); + const user = await account.getUser(); + if (!user) return null; + if (isFresh(user, now())) return user.access_token; + if (!user.refresh_token) { + onSessionError(slot); + throw new Error( + 'Your session has expired. Sign in again from the account menu.', + ); + } + + const retryKey = `trakt-developer-refresh-retry-${slot}`; + const store = storage(); + const retryAt = Number(store.getItem(retryKey)); + if (retryAt > now()) { + onSessionError(slot); + throw new Error( + 'Account refresh is temporarily unavailable. Try again shortly or sign in again from the account menu.', + ); + } + + store.setItem(retryKey, String(now() + RETRY_DELAY_MS)); + try { + const refreshed = await account.signinSilent({ + silentRequestTimeoutInSeconds: AUTH_REQUEST_TIMEOUT_SECONDS, + }); + if (!refreshed || !isFresh(refreshed, now())) { + throw new Error('Trakt did not return a valid access token.'); + } + + store.removeItem(retryKey); + return refreshed.access_token; + } catch { + onSessionError(slot); + store.setItem(retryKey, String(now() + RETRY_DELAY_MS)); + throw new Error( + 'Could not refresh your account. Try again shortly or sign in again from the account menu.', + ); + } + } + + return (slot) => { + const existing = pending.get(slot); + if (existing) return existing; + + const request = lock(slot, () => resolveToken(slot)).finally(() => { + pending.delete(slot); + }); + pending.set(slot, request); + return request; + }; +} diff --git a/projects/developer/src/lib/auth/listAccounts.spec.ts b/projects/developer/src/lib/auth/listAccounts.spec.ts index bbb2815c..678e71b7 100644 --- a/projects/developer/src/lib/auth/listAccounts.spec.ts +++ b/projects/developer/src/lib/auth/listAccounts.spec.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { accountSessionErrors } from './accountSessionErrors.ts'; import { listAccounts } from './listAccounts.ts'; import { userManager } from './userManager.ts'; @@ -29,6 +30,17 @@ beforeEach(() => { afterEach(() => vi.useRealTimers()); describe('list accounts', () => { + it('distinguishes failed sessions from tokens that can still refresh automatically', async () => { + storedUsers({ 0: NOW_SECONDS - 1, 1: NOW_SECONDS - 1 }); + accountSessionErrors.mark(0); + const accounts = await listAccounts(); + expect(accounts.map((account) => account.hasSessionError)).toEqual([ + true, + false, + ]); + accountSessionErrors.clear(0); + }); + it('returns nothing when no slot holds a session', async () => { storedUsers({}); await expect(listAccounts()).resolves.toEqual([]); diff --git a/projects/developer/src/lib/auth/listAccounts.ts b/projects/developer/src/lib/auth/listAccounts.ts index 0f306453..b21a58b3 100644 --- a/projects/developer/src/lib/auth/listAccounts.ts +++ b/projects/developer/src/lib/auth/listAccounts.ts @@ -1,4 +1,5 @@ import type { DeveloperAccount } from '$lib/api/DeveloperAccount.ts'; +import { accountSessionErrors } from './accountSessionErrors.ts'; import { ACCOUNT_LIMIT } from './ACCOUNT_LIMIT.ts'; import { readUsername } from './readUsername.ts'; import { userManager } from './userManager.ts'; @@ -27,6 +28,7 @@ export async function listAccounts(): Promise< username: readUsername(slot), expiresAt, isExpired: expiresAt <= now, + hasSessionError: accountSessionErrors.has(slot), source: 'developer-oauth' as const, }]; }); diff --git a/projects/developer/src/lib/auth/markRejectedSession.spec.ts b/projects/developer/src/lib/auth/markRejectedSession.spec.ts new file mode 100644 index 00000000..6fa2de39 --- /dev/null +++ b/projects/developer/src/lib/auth/markRejectedSession.spec.ts @@ -0,0 +1,55 @@ +import { User } from 'oidc-client-ts'; +import { describe, expect, it, vi } from 'vitest'; +import { markRejectedSession } from './markRejectedSession.ts'; + +function user(accessToken: string) { + return new User({ + access_token: accessToken, + token_type: 'Bearer', + profile: { sub: 'account', iss: 'trakt', aud: 'portal', exp: 0, iat: 0 }, + }); +} + +describe('rejected sessions', () => { + it.each(['old-token', 'new-token', null])( + 'marks only a rejected token that is still current: %s', + async (currentToken) => { + const mark = vi.fn(); + await markRejectedSession({ + slot: 2, + accessToken: 'old-token', + manager: () => ({ + getUser: async () => currentToken ? user(currentToken) : null, + }), + lock: (_slot, operation) => operation(), + mark, + }); + expect(mark.mock.calls).toEqual( + currentToken === 'old-token' ? [[2]] : [], + ); + }, + ); + + it('waits for an in-flight refresh before comparing tokens', async () => { + let stored = user('old-token'); + let release: () => void = () => {}; + const refreshing = new Promise((resolve) => { + release = resolve; + }); + const mark = vi.fn(); + const rejected = markRejectedSession({ + slot: 0, + accessToken: 'old-token', + manager: () => ({ getUser: async () => stored }), + lock: async (_slot, operation) => { + await refreshing; + return operation(); + }, + mark, + }); + stored = user('new-token'); + release(); + await rejected; + expect(mark).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/developer/src/lib/auth/markRejectedSession.ts b/projects/developer/src/lib/auth/markRejectedSession.ts new file mode 100644 index 00000000..3c5a6f5b --- /dev/null +++ b/projects/developer/src/lib/auth/markRejectedSession.ts @@ -0,0 +1,23 @@ +import type { UserManager } from 'oidc-client-ts'; +import { accountSessionErrors } from './accountSessionErrors.ts'; +import { userManager } from './userManager.ts'; +import { withAccountLock } from './withAccountLock.ts'; + +export async function markRejectedSession({ + slot, + accessToken, + manager = userManager, + lock = withAccountLock, + mark = accountSessionErrors.mark, +}: { + slot: number; + accessToken: string; + manager?: (slot: number) => Pick; + lock?: typeof withAccountLock; + mark?: (slot: number) => void; +}): Promise { + await lock(slot, async () => { + const user = await manager(slot).getUser(); + if (user?.access_token === accessToken) mark(slot); + }); +} diff --git a/projects/developer/src/lib/auth/observeAccountChanges.spec.ts b/projects/developer/src/lib/auth/observeAccountChanges.spec.ts new file mode 100644 index 00000000..b5903a91 --- /dev/null +++ b/projects/developer/src/lib/auth/observeAccountChanges.spec.ts @@ -0,0 +1,107 @@ +import { User, UserManager } from 'oidc-client-ts'; +import { describe, expect, it, vi } from 'vitest'; +import { accountSessionErrors } from './accountSessionErrors.ts'; +import { ACCOUNT_LIMIT } from './ACCOUNT_LIMIT.ts'; +import { observeAccountChanges } from './observeAccountChanges.ts'; + +function setup() { + const events = Array.from( + { length: ACCOUNT_LIMIT }, + () => + new UserManager({ + authority: 'https://auth.trakt.tv', + client_id: 'test', + redirect_uri: 'https://developer.trakt.tv/callback', + automaticSilentRenew: false, + }).events, + ); + const target = new EventTarget(); + const onChange = vi.fn(); + const stop = observeAccountChanges(onChange, { + manager: (slot) => { + const event = events.at(slot); + if (!event) throw new Error('Invalid slot'); + return { events: event }; + }, + target, + }); + return { events, target, onChange, stop }; +} + +function user() { + return new User({ + access_token: 'refreshed', + token_type: 'Bearer', + profile: { sub: 'account', iss: 'trakt', aud: 'portal', exp: 0, iat: 0 }, + }); +} + +describe('observe account changes', () => { + it('updates after a session failure and clears it when the session renews', async () => { + const { events, onChange, stop } = setup(); + accountSessionErrors.mark(0); + expect(onChange).toHaveBeenCalledTimes(1); + expect(accountSessionErrors.has(0)).toBe(true); + await events.at(0)?.load(user()); + expect(accountSessionErrors.has(0)).toBe(false); + expect(onChange).toHaveBeenCalledTimes(2); + stop(); + }); + + it('clears errors when another tab replaces the stored session', () => { + const { target, stop } = setup(); + accountSessionErrors.mark(0); + target.dispatchEvent(Object.assign(new Event('storage'), { + key: 'trakt-developer-account-0.user:https://auth.trakt.tv:client', + })); + expect(accountSessionErrors.has(0)).toBe(false); + stop(); + }); + + it('updates for refreshed sessions and logout in every account slot', async () => { + const { events, onChange, stop } = setup(); + for (const event of events) { + await event.load(user()); + await event.unload(); + } + expect(onChange).toHaveBeenCalledTimes(ACCOUNT_LIMIT * 2); + stop(); + }); + + it('does not notify when account listings read stored users without raising events', async () => { + const { events, onChange, stop } = setup(); + await events.at(0)?.load(user(), false); + expect(onChange).not.toHaveBeenCalled(); + stop(); + }); + + it('updates for cross-tab session changes but ignores cooldown and unrelated writes', () => { + const { target, onChange, stop } = setup(); + for (const key of ['trakt-developer-refresh-retry-0', 'unrelated']) { + target.dispatchEvent(Object.assign(new Event('storage'), { key })); + } + expect(onChange).not.toHaveBeenCalled(); + for ( + const key of [ + 'trakt-developer-account-0.user:https://auth.trakt.tv:client', + 'trakt-developer-username-1', + null, + ] + ) { + target.dispatchEvent(Object.assign(new Event('storage'), { key })); + } + expect(onChange).toHaveBeenCalledTimes(3); + stop(); + }); + + it('removes all subscriptions on teardown', async () => { + const { events, target, onChange, stop } = setup(); + stop(); + for (const event of events) { + await event.load(user()); + await event.unload(); + } + target.dispatchEvent(Object.assign(new Event('storage'), { key: null })); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/developer/src/lib/auth/observeAccountChanges.ts b/projects/developer/src/lib/auth/observeAccountChanges.ts new file mode 100644 index 00000000..7e9ac7bd --- /dev/null +++ b/projects/developer/src/lib/auth/observeAccountChanges.ts @@ -0,0 +1,50 @@ +import type { UserManager } from 'oidc-client-ts'; +import { accountSessionErrors } from './accountSessionErrors.ts'; +import { ACCOUNT_LIMIT } from './ACCOUNT_LIMIT.ts'; +import { userManager } from './userManager.ts'; + +export function observeAccountChanges( + onChange: () => void, + { manager = userManager, target = globalThis }: { + manager?: (slot: number) => Pick; + target?: Pick; + } = {}, +): () => void { + const slots = Array.from({ length: ACCOUNT_LIMIT }, (_, slot) => slot); + const subscriptions = slots.flatMap((slot) => { + const events = manager(slot).events; + const changed = () => { + accountSessionErrors.clear(slot); + onChange(); + }; + + return [events.addUserLoaded(changed), events.addUserUnloaded(changed)]; + }); + const stopObservingErrors = accountSessionErrors.subscribe(onChange); + + function onStorage(event: StorageEvent) { + const sessionSlot = event.key?.match( + /^trakt-developer-account-(\d+)\.user:/, + )?.at(1); + + if (sessionSlot !== undefined) { + accountSessionErrors.clear(Number(sessionSlot)); + } + if (event.key === null) { + slots.forEach(accountSessionErrors.clear); + } + + if ( + event.key === null || sessionSlot !== undefined || + event.key.startsWith('trakt-developer-username-') + ) onChange(); + } + + target.addEventListener('storage', onStorage); + + return () => { + subscriptions.forEach((unsubscribe) => unsubscribe()); + stopObservingErrors(); + target.removeEventListener('storage', onStorage); + }; +} diff --git a/projects/developer/src/lib/auth/refreshAccount.ts b/projects/developer/src/lib/auth/refreshAccount.ts deleted file mode 100644 index f4e2688c..00000000 --- a/projects/developer/src/lib/auth/refreshAccount.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { storeUsername } from './storeUsername.ts'; -import { userManager } from './userManager.ts'; - -export async function refreshAccount(slot: number): Promise { - const user = await userManager(slot).signinSilent(); - - if (!user) throw new Error('Trakt did not return a new access token.'); - - await storeUsername(slot, user.access_token); -} diff --git a/projects/developer/src/lib/auth/resolveUsername.ts b/projects/developer/src/lib/auth/resolveUsername.ts index 06e965f2..110a127a 100644 --- a/projects/developer/src/lib/auth/resolveUsername.ts +++ b/projects/developer/src/lib/auth/resolveUsername.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { AUTH_REQUEST_TIMEOUT_SECONDS } from './AUTH_REQUEST_TIMEOUT_SECONDS.ts'; import { traktHeaders } from '$lib/api/traktHeaders.ts'; import { fallbackUsername } from './fallbackUsername.ts'; @@ -15,6 +16,7 @@ export async function resolveUsername({ }): Promise { const response = await fetch('https://api.trakt.tv/users/settings', { headers: traktHeaders({ accessToken }), + signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT_SECONDS * 1000), }).catch(() => null); if (!response?.ok) return fallbackUsername(slot); diff --git a/projects/developer/src/lib/auth/signInAccount.spec.ts b/projects/developer/src/lib/auth/signInAccount.spec.ts new file mode 100644 index 00000000..e2e24ccd --- /dev/null +++ b/projects/developer/src/lib/auth/signInAccount.spec.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { signInAccount } from './signInAccount.ts'; +import { userManager } from './userManager.ts'; + +vi.mock('./userManager.ts', () => ({ userManager: vi.fn() })); + +afterEach(() => vi.unstubAllGlobals()); + +describe('sign in account', () => { + it.each([ + [0, false, false], + [0, true, true], + [1, false, true], + ])( + 'requests account choice for slot %i with existing session %s', + async (slot, existing, promptLogin) => { + vi.stubGlobal('location', { href: 'https://developer.trakt.tv/apps' }); + vi.stubGlobal('sessionStorage', { setItem: vi.fn() }); + const signinRedirect = vi.fn(); + vi.mocked(userManager).mockReturnValue({ + getUser: async () => existing ? {} : null, + signinRedirect, + } as unknown as ReturnType); + + await signInAccount(slot); + expect(signinRedirect).toHaveBeenCalledWith( + promptLogin ? { extraQueryParams: { prompt: 'login' } } : {}, + ); + }, + ); +}); diff --git a/projects/developer/src/lib/auth/signInAccount.ts b/projects/developer/src/lib/auth/signInAccount.ts index 7c233a13..e9954bdf 100644 --- a/projects/developer/src/lib/auth/signInAccount.ts +++ b/projects/developer/src/lib/auth/signInAccount.ts @@ -3,10 +3,13 @@ import { writePendingSlot } from './writePendingSlot.ts'; import { userManager } from './userManager.ts'; export async function signInAccount(slot: number): Promise { + const manager = userManager(slot); + const promptLogin = slot > 0 || Boolean(await manager.getUser()); + writePendingSlot(slot); rememberSection(); - await userManager(slot).signinRedirect( - slot > 0 ? { extraQueryParams: { prompt: 'login' } } : {}, + await manager.signinRedirect( + promptLogin ? { extraQueryParams: { prompt: 'login' } } : {}, ); } diff --git a/projects/developer/src/lib/auth/signOutAccount.ts b/projects/developer/src/lib/auth/signOutAccount.ts index ad0dff22..e87f2fcf 100644 --- a/projects/developer/src/lib/auth/signOutAccount.ts +++ b/projects/developer/src/lib/auth/signOutAccount.ts @@ -1,12 +1,17 @@ import { clearUsername } from './clearUsername.ts'; +import { withAccountLock } from './withAccountLock.ts'; import { userManager } from './userManager.ts'; export async function signOutAccount(slot: number): Promise { - const manager = userManager(slot); + await withAccountLock(slot, async () => { + const manager = userManager(slot); - // Trakt's revocation endpoint may reject a public client, and the local - // tokens must be cleared either way. - await manager.revokeTokens(['access_token', 'refresh_token']).catch(() => {}); - await manager.removeUser(); - clearUsername(slot); + // Trakt's revocation endpoint may reject a public client, and the local + // tokens must be cleared either way. + await manager.revokeTokens(['access_token', 'refresh_token']).catch( + () => {}, + ); + await manager.removeUser(); + clearUsername(slot); + }); } diff --git a/projects/developer/src/lib/auth/userManager.spec.ts b/projects/developer/src/lib/auth/userManager.spec.ts new file mode 100644 index 00000000..046ee0a8 --- /dev/null +++ b/projects/developer/src/lib/auth/userManager.spec.ts @@ -0,0 +1,147 @@ +import { InMemoryWebStorage, User } from 'oidc-client-ts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AUTH_REQUEST_TIMEOUT_SECONDS } from './AUTH_REQUEST_TIMEOUT_SECONDS.ts'; +import { createAccessTokenProvider } from './createAccessTokenProvider.ts'; +import { userManager } from './userManager.ts'; +import { signOutAccount } from './signOutAccount.ts'; + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('location', { origin: 'https://developer.trakt.tv' }); + vi.stubGlobal('localStorage', new InMemoryWebStorage()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +function storedUser() { + return new User({ + access_token: 'expired', + refresh_token: 'refresh', + expires_at: 1, + token_type: 'Bearer', + profile: { + sub: 'test', + iss: 'https://auth.trakt.tv', + aud: 'test', + exp: 1, + iat: 0, + }, + }); +} + +function stallRequests({ discovery = false, body = false } = {}) { + const signals: AbortSignal[] = []; + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, init: RequestInit) => { + if (!discovery && String(_url).includes('.well-known')) { + return Promise.resolve( + new Response( + JSON.stringify({ + issuer: 'https://auth.trakt.tv', + token_endpoint: 'https://auth.trakt.tv/oauth/token', + revocation_endpoint: 'https://auth.trakt.tv/oauth/revoke', + }), + { headers: { 'content-type': 'application/json' } }, + ), + ); + } + + const signal = init.signal; + if (!signal) throw new Error('Request has no abort signal'); + signals.push(signal); + if (body) { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{')); + signal.addEventListener( + 'abort', + () => controller.error(signal.reason), + { once: true }, + ); + }, + }); + return Promise.resolve( + new Response(stream, { + headers: { 'content-type': 'application/json' }, + }), + ); + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }); + }), + ); + return signals; +} + +describe('authentication request timeouts', () => { + it.each([false, true])( + 'aborts a stalled refresh (body: %s), preserves the session, and releases the account queue', + async (body) => { + const signals = stallRequests({ body }); + const manager = userManager(0); + await manager.storeUser(storedUser()); + let queue: Promise = Promise.resolve(); + const lock = (_slot: number, operation: () => Promise) => { + const result = queue.catch(() => {}).then(operation); + queue = result; + return result; + }; + const token = createAccessTokenProvider({ + manager: () => manager, + lock, + storage: () => globalThis.localStorage, + }); + + const failed = expect(token(0)).rejects.toThrow( + 'sign in again from the account menu', + ); + const queued = lock(0, async () => 'released'); + await vi.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_SECONDS * 1000); + await failed; + await expect(queued).resolves.toBe('released'); + expect(signals).toHaveLength(1); + expect(signals.at(0)?.aborted).toBe(true); + expect((await manager.getUser())?.refresh_token).toBe('refresh'); + }, + ); + + it.each([false, true])( + 'aborts stalled discovery (body: %s) instead of holding the lock indefinitely', + async (body) => { + const signals = stallRequests({ discovery: true, body }); + const manager = userManager(1); + await manager.storeUser(storedUser()); + const failed = expect(manager.signinSilent()).rejects.toThrow(); + await vi.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_SECONDS * 1000); + await failed; + expect(signals.at(0)?.aborted).toBe(true); + }, + ); + + it.each([false, true])( + 'aborts stalled revocation (body: %s) so logout can continue with local removal', + async (body) => { + const signals = stallRequests({ body }); + const manager = userManager(2); + await manager.storeUser(storedUser()); + vi.stubGlobal('navigator', { + locks: { + request: (_name: string, operation: () => Promise) => + operation(), + }, + }); + const logout = signOutAccount(2); + await vi.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_SECONDS * 1000); + await logout; + expect(await manager.getUser()).toBeNull(); + expect(signals.at(0)?.aborted).toBe(true); + }, + ); +}); diff --git a/projects/developer/src/lib/auth/userManager.ts b/projects/developer/src/lib/auth/userManager.ts index 1dd1de26..37e5204c 100644 --- a/projects/developer/src/lib/auth/userManager.ts +++ b/projects/developer/src/lib/auth/userManager.ts @@ -1,11 +1,15 @@ import { PUBLIC_TRAKT_CLIENT_ID } from '$env/static/public'; import { UserManager, WebStorageStateStore } from 'oidc-client-ts'; +import { authFetch } from './authFetch.ts'; +import { AUTH_REQUEST_TIMEOUT_SECONDS } from './AUTH_REQUEST_TIMEOUT_SECONDS.ts'; const AUTHORITY = 'https://auth.trakt.tv'; const SCOPE = 'public openid profile email'; const managers = new Map(); export function userManager(slot: number): UserManager { + globalThis.fetch = authFetch(globalThis.fetch); + const existing = managers.get(slot); if (existing) return existing; @@ -16,6 +20,7 @@ export function userManager(slot: number): UserManager { response_type: 'code', scope: SCOPE, automaticSilentRenew: false, + requestTimeoutInSeconds: AUTH_REQUEST_TIMEOUT_SECONDS, userStore: new WebStorageStateStore({ prefix: `trakt-developer-account-${slot}.`, store: globalThis.localStorage, diff --git a/projects/developer/src/lib/auth/withAccountLock.spec.ts b/projects/developer/src/lib/auth/withAccountLock.spec.ts new file mode 100644 index 00000000..20f0507e --- /dev/null +++ b/projects/developer/src/lib/auth/withAccountLock.spec.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { withAccountLock } from './withAccountLock.ts'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('account lock', () => { + it('uses a distinct lock name for each account', async () => { + const request = vi.fn(async ( + _name: string, + operation: () => Promise, + ) => operation()); + vi.stubGlobal('navigator', { locks: { request } }); + await expect(withAccountLock(0, async () => 'zero')).resolves.toBe('zero'); + await expect(withAccountLock(1, async () => 'one')).resolves.toBe('one'); + expect(request.mock.calls.map(([name]) => name)).toEqual([ + 'trakt-developer-account-0', + 'trakt-developer-account-1', + ]); + }); + + it('fails safely when cross-tab coordination is unavailable', async () => { + vi.stubGlobal('navigator', {}); + const operation = vi.fn(); + await expect(withAccountLock(0, operation)).rejects.toThrow('Web Locks'); + expect(operation).not.toHaveBeenCalled(); + }); + + it('rejects invalid slots before accessing storage', async () => { + const operation = vi.fn(); + await expect(withAccountLock(-1, operation)).rejects.toThrow('unavailable'); + expect(operation).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/developer/src/lib/auth/withAccountLock.ts b/projects/developer/src/lib/auth/withAccountLock.ts new file mode 100644 index 00000000..2d58c026 --- /dev/null +++ b/projects/developer/src/lib/auth/withAccountLock.ts @@ -0,0 +1,21 @@ +import { isAccountSlot } from './isAccountSlot.ts'; + +export async function withAccountLock( + slot: number, + operation: () => Promise, +): Promise { + if (!isAccountSlot(slot)) { + throw new Error('The selected account is unavailable.'); + } + + if (!globalThis.navigator?.locks) { + throw new Error( + 'Safe account access requires a browser with Web Locks support.', + ); + } + + return globalThis.navigator.locks.request( + `trakt-developer-account-${slot}`, + operation, + ); +} diff --git a/projects/developer/src/lib/features/apps/applicationsTransport.spec.ts b/projects/developer/src/lib/features/apps/applicationsTransport.spec.ts index 977ff036..dee43c19 100644 --- a/projects/developer/src/lib/features/apps/applicationsTransport.spec.ts +++ b/projects/developer/src/lib/features/apps/applicationsTransport.spec.ts @@ -17,6 +17,39 @@ afterEach(() => { const input = { name: 'Test', redirect_uri: ['test://callback'], origins: [] }; describe('app management transport', () => { + it('waits for a refreshed token before sending a mutation', async () => { + let finish: (token: string) => void = () => {}; + vi.mocked(accessToken).mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + const fetcher = vi.fn().mockResolvedValue( + new Response(null, { status: 204 }), + ); + vi.stubGlobal('fetch', fetcher); + + const request = deleteApplication(2, 42); + expect(fetcher).not.toHaveBeenCalled(); + finish('refreshed-token'); + await request; + expect(fetcher.mock.calls.at(0)?.[1].headers.get('authorization')) + .toBe('Bearer refreshed-token'); + }); + + it('does not send a mutation when token refresh fails', async () => { + vi.mocked(accessToken).mockRejectedValueOnce( + new Error('Refresh unavailable'), + ); + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + + await expect(deleteApplication(2, 42)).rejects.toThrow( + 'Refresh unavailable', + ); + expect(fetcher).not.toHaveBeenCalled(); + }); + it('never sends an anonymous management request', async () => { vi.mocked(accessToken).mockResolvedValue(null); const fetcher = vi.fn(); diff --git a/projects/developer/src/lib/features/developer/AccountMenu.svelte b/projects/developer/src/lib/features/developer/AccountMenu.svelte index bd45f7aa..3f58cd5b 100644 --- a/projects/developer/src/lib/features/developer/AccountMenu.svelte +++ b/projects/developer/src/lib/features/developer/AccountMenu.svelte @@ -1,9 +1,7 @@ -{#snippet statusDot(slot: number, expiresAt: number)} - {@const status = tokenStatus(slot, expiresAt)} +{#snippet statusDot(expiresAt: number, hasSessionError = false)} + {@const status = tokenStatus(expiresAt, hasSessionError)} {#if connectedAccounts.length > 0} @@ -167,53 +152,32 @@ aria-pressed={account.slot === selectedSlot} onclick={() => onAccount(account.slot)} > - {@render statusDot(account.slot, account.expiresAt)} + {@render statusDot(account.expiresAt, account.hasSessionError)} @{account.username} {#if account.slot === selectedSlot}{/if} + {#if account.hasSessionError} +

+ We couldn’t reconnect this account. Sign in again to continue. +

+ {/if} {#if errors[account.slot]}{/if} @@ -372,6 +336,13 @@ border-color: var(--color-border-strong); } + .session-notice { + margin: 0; + padding-inline: var(--ni-8); + color: var(--color-muted); + font-size: var(--ni-11); + } + .account-error { margin: 0 0 8px; color: var(--color-danger); @@ -407,37 +378,6 @@ padding-inline-start: 8px; } - .token-info { - position: relative; - display: inline-flex; - } - - .token-tooltip { - position: absolute; - z-index: 1; - inset-block-end: calc(100% + var(--ni-8)); - inset-inline-end: 0; - - width: max-content; - max-width: 230px; - padding: 7px 9px; - border: var(--ni-1) solid var(--color-border-strong); - border-radius: var(--radius-control); - - background: var(--color-surface); - color: var(--color-foreground); - box-shadow: var(--shadow-popover); - font-size: var(--ni-11); - - pointer-events: none; - visibility: hidden; - } - - .token-info:hover .token-tooltip, - .token-info:focus-within .token-tooltip { - visibility: visible; - } - .account-actions button { min-width: var(--ni-28); height: var(--ni-28); diff --git a/projects/developer/src/lib/features/developer/AccountMenuProps.ts b/projects/developer/src/lib/features/developer/AccountMenuProps.ts index dde2cb19..45b1627b 100644 --- a/projects/developer/src/lib/features/developer/AccountMenuProps.ts +++ b/projects/developer/src/lib/features/developer/AccountMenuProps.ts @@ -6,5 +6,4 @@ export type AccountMenuProps = { selectedSlot: number | null; onAccount: (slot: number) => void; onLogout: (slot: number) => void; - onAccountsChanged: () => Promise; }; diff --git a/projects/developer/src/lib/features/developer/DeveloperApp.svelte b/projects/developer/src/lib/features/developer/DeveloperApp.svelte index cf0a4121..451b315c 100644 --- a/projects/developer/src/lib/features/developer/DeveloperApp.svelte +++ b/projects/developer/src/lib/features/developer/DeveloperApp.svelte @@ -12,6 +12,7 @@ import GuideReader from "./GuideReader.svelte"; import type { ApiHeader } from "$lib/api/ApiHeader.ts"; import { executeApiRequest } from "$lib/api/executeApiRequest.ts"; + import { observeAccountChanges } from "$lib/auth/observeAccountChanges.ts"; import { fetchAccounts } from "$lib/api/fetchAccounts.ts"; import type { DeveloperAccount } from "$lib/api/DeveloperAccount.ts"; import type { Endpoint } from "$lib/openapi/Endpoint.ts"; @@ -559,12 +560,14 @@ globalThis.addEventListener("keydown", focusSearch); globalThis.addEventListener("focus", refreshAccounts); - globalThis.addEventListener("storage", refreshAccounts); + const stopObservingAccounts = observeAccountChanges(() => { + void refreshAccounts(); + }); void Promise.all([loadCatalog(sharedState), refreshAccounts()]); return () => { globalThis.removeEventListener("keydown", focusSearch); globalThis.removeEventListener("focus", refreshAccounts); - globalThis.removeEventListener("storage", refreshAccounts); + stopObservingAccounts(); }; }); @@ -605,7 +608,6 @@ selectedSlot={selectedAccountSlot} onAccount={setSelectedAccount} onLogout={logOutAccount} - onAccountsChanged={refreshAccounts} /> diff --git a/projects/developer/src/lib/features/developer/formatTokenValidity.spec.ts b/projects/developer/src/lib/features/developer/formatTokenValidity.spec.ts deleted file mode 100644 index 440fd68d..00000000 --- a/projects/developer/src/lib/features/developer/formatTokenValidity.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { formatTokenValidity } from './formatTokenValidity.ts'; - -describe('token validity', () => { - it.each([ - [0, 'Expired'], - [-1, 'Expired'], - [1, '1s'], - [59, '59s'], - [60, '1m'], - [61, '1m 1s'], - [3600, '1h'], - [3661, '1h 1m'], - [86400, '1d'], - [90061, '1d 1h'], - [86401, '1d 1s'], - [Number.NaN, 'Unknown'], - ])('formats %s seconds as %s', (remaining, expected) => { - expect(formatTokenValidity(1000 + remaining, 1000000)).toBe(expected); - }); -}); diff --git a/projects/developer/src/lib/features/developer/formatTokenValidity.ts b/projects/developer/src/lib/features/developer/formatTokenValidity.ts deleted file mode 100644 index e7950d26..00000000 --- a/projects/developer/src/lib/features/developer/formatTokenValidity.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function formatTokenValidity(expiresAt: number, now: number): string { - if (!Number.isFinite(expiresAt)) return 'Unknown'; - let remaining = Math.ceil(expiresAt - now / 1000); - if (remaining <= 0) return 'Expired'; - - const parts: string[] = []; - for ( - const [size, unit] of [[86400, 'd'], [3600, 'h'], [60, 'm'], [ - 1, - 's', - ]] as const - ) { - const amount = Math.floor(remaining / size); - remaining %= size; - if (amount) parts.push(`${amount}${unit}`); - if (parts.length === 2) break; - } - - return parts.join(' '); -}