diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index 06763cb63..95c5d116a 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -4,6 +4,7 @@ import { logger } from '@/lib/logger'; import { decryptPayload } from '@/lib/auth/crypto'; import { exchangeCodeForTokens, + fetchUserInfoAvatar, getRequiredConfig, getTokenEndpoint, } from '@/lib/oauth/token-exchange'; @@ -73,6 +74,8 @@ export async function POST(request: NextRequest) { // Exchange code for tokens const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId); + const avatarUrl = await fetchUserInfoAvatar(tokens.access_token, pendingServerId).catch(() => undefined); + // For the mobile handoff flow the tokens are handed back to the app // verbatim - we deliberately don't write any cookies on the webmail // origin (the mobile browser tab disposes of the session after the @@ -110,12 +113,14 @@ export async function POST(request: NextRequest) { server_url: serverUrl, mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState, + ...(avatarUrl ? { avatar_url: avatarUrl } : {}), }); } return NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in, + ...(avatarUrl ? { avatar_url: avatarUrl } : {}), }); } catch (error) { // Clean up pending cookie on any error diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts deleted file mode 100644 index f173c0b3a..000000000 --- a/app/api/auth/token/route.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { logger } from '@/lib/logger'; -import { - refreshTokenCookieName, - refreshTokenServerCookieName, - accessTokenCookieName, - encodeCachedAccessToken, - decodeCachedAccessToken, -} from '@/lib/oauth/tokens'; -import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint, DEFAULT_CLIENT_ID } from '@/lib/oauth/token-exchange'; -import { getCookieOptions } from '@/lib/oauth/cookie-config'; -import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; - -function getSlot(request: NextRequest): number { - const raw = request.nextUrl.searchParams.get('slot'); - if (raw === null) return 0; - const slot = parseInt(raw, 10); - if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0; - return slot; -} - -type CookieStore = Awaited>; - -/** - * Cache the access token for the slot so a page reload can resume with it. - * - * Scoped to the token's own lifetime - once it expires the cookie is worthless - * and should not linger. A token too large to store is simply not cached. - */ -function cacheAccessToken( - cookieStore: CookieStore, - slot: number, - accessToken: string, - expiresIn: number, -): void { - const name = accessTokenCookieName(slot); - const value = encodeCachedAccessToken(accessToken, expiresIn); - if (!value) { - // Oversized token: drop any stale entry rather than leaving a mismatch. - cookieStore.delete(name); - return; - } - cookieStore.set(name, value, { ...getCookieOptions(), maxAge: expiresIn }); -} - -export async function POST(request: NextRequest) { - try { - const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json(); - - if (!code || !code_verifier || !redirect_uri) { - return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); - } - - const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request); - const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; - - const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId); - - const response = NextResponse.json({ - access_token: tokens.access_token, - expires_in: tokens.expires_in, - }); - - const cookieStore = await cookies(); - if (tokens.refresh_token) { - const cookieName = refreshTokenCookieName(slot); - cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); - } - cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600); - // Persist which server entry minted this refresh token so the PUT/DELETE - // handlers can route the refresh/revocation calls to the right token - // endpoint without the client having to track it across page loads. - const serverCookieName = refreshTokenServerCookieName(slot); - if (serverId) { - cookieStore.set(serverCookieName, serverId, getCookieOptions()); - } else { - cookieStore.delete(serverCookieName); - } - - return response; - } catch (error) { - logger.error('Token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -export async function PUT(request: NextRequest) { - try { - const slot = getSlot(request); - const cookieName = refreshTokenCookieName(slot); - const cookieStore = await cookies(); - const refreshToken = cookieStore.get(cookieName)?.value; - const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null; - - if (!refreshToken) { - cookieStore.delete(accessTokenCookieName(slot)); - return NextResponse.json({ error: 'No refresh token' }, { status: 401 }); - } - - // A session restore calls this to get its token back, not because the - // current one expired. Serving the cached token avoids spending a refresh - // the IdP may legitimately reject: Rauthy stamps refresh tokens with - // nbf = iat + access_token_lifetime - 60, so refreshing early fails with - // "Token is not valid yet" for most of the access token's life (#552). - // - // `force=true` means the caller was told the current token is no good (a - // 401 from JMAP, or a scheduled renewal), so the cache must be skipped. - const force = request.nextUrl.searchParams.get('force') === 'true'; - if (!force) { - const cached = decodeCachedAccessToken(cookieStore.get(accessTokenCookieName(slot))?.value); - if (cached) { - return NextResponse.json({ - access_token: cached.accessToken, - expires_in: cached.expiresIn, - }); - } - } - - // The refresh token may have been minted by the password+TOTP login route, - // which works without a configured OAuth client by falling back to the - // default client id - refreshing must fall back the same way (#873). - const tokenEndpoint = await getTokenEndpoint(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }); - - const params = buildOAuthParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - }, serverId, { fallbackClientId: DEFAULT_CLIENT_ID }); - - const tokenResponse = await fetch(tokenEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }); - - if (!tokenResponse.ok) { - const errorText = await tokenResponse.text(); - logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText }); - // Drop the refresh token only when the server definitively rejected it - // (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping - // the cookie lets the session resume once the server is back. - const status = tokenResponse.status; - if (status === 400 || status === 401 || status === 403) { - cookieStore.delete(cookieName); - cookieStore.delete(refreshTokenServerCookieName(slot)); - cookieStore.delete(accessTokenCookieName(slot)); - return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); - } - return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 }); - } - - const tokens = await tokenResponse.json(); - - if (!tokens.access_token) { - logger.error('Refresh response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) }); - return NextResponse.json({ error: 'Invalid token response' }, { status: 502 }); - } - - if (tokens.refresh_token) { - cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); - } - - const expiresIn = tokens.expires_in || 3600; - cacheAccessToken(cookieStore, slot, tokens.access_token, expiresIn); - - return NextResponse.json({ - access_token: tokens.access_token, - expires_in: expiresIn, - }); - } catch (error) { - logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -export async function DELETE(request: NextRequest) { - try { - const all = request.nextUrl.searchParams.get('all') === 'true'; - - if (all) { - // Revoke and delete all refresh token cookies across every slot. - const cookieStore = await cookies(); - for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) { - const name = refreshTokenCookieName(i); - const serverCookieName = refreshTokenServerCookieName(i); - const token = cookieStore.get(name)?.value; - const slotServerId = cookieStore.get(serverCookieName)?.value || null; - if (token) { - // Best-effort revocation - try { - const metadata = await getMetadata(slotServerId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch(() => null); - if (metadata?.revocation_endpoint) { - const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' }, slotServerId, { fallbackClientId: DEFAULT_CLIENT_ID }); - await fetch(metadata.revocation_endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }).catch(() => {}); - } - } catch { /* best effort */ } - cookieStore.delete(name); - } - cookieStore.delete(serverCookieName); - cookieStore.delete(accessTokenCookieName(i)); - } - return NextResponse.json({ ok: true }); - } - - const slot = getSlot(request); - const cookieName = refreshTokenCookieName(slot); - const cookieStore = await cookies(); - const refreshToken = cookieStore.get(cookieName)?.value; - const slotServerId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null; - const metadata = await getMetadata(slotServerId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => { - logger.warn('Failed to discover OAuth metadata during logout', { - error: err instanceof Error ? err.message : 'Unknown error', - }); - return null; - }); - - if (refreshToken) { - if (metadata?.revocation_endpoint) { - const params = buildOAuthParams({ - token: refreshToken, - token_type_hint: 'refresh_token', - }, slotServerId, { fallbackClientId: DEFAULT_CLIENT_ID }); - - try { - const revocationResponse = await fetch(metadata.revocation_endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }); - if (!revocationResponse.ok) { - logger.warn('Token revocation returned error', { status: revocationResponse.status }); - } - } catch (err) { - logger.error('Token revocation network error', { error: err instanceof Error ? err.message : 'Unknown error' }); - } - } - - cookieStore.delete(cookieName); - } - cookieStore.delete(refreshTokenServerCookieName(slot)); - cookieStore.delete(accessTokenCookieName(slot)); - - let end_session_url: string | undefined; - if (metadata?.end_session_endpoint) { - try { - const parsed = new URL(metadata.end_session_endpoint); - if (parsed.protocol === 'https:') { - end_session_url = metadata.end_session_endpoint; - } else { - logger.warn('Ignoring non-HTTPS end_session_endpoint', { url: metadata.end_session_endpoint }); - } - } catch { - logger.warn('Invalid end_session_endpoint URL', { url: metadata.end_session_endpoint }); - } - } - - return NextResponse.json({ ok: true, ...(end_session_url && { end_session_url }) }); - } catch (error) { - logger.error('Token revocation error', { error: error instanceof Error ? error.message : 'Unknown error' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 6d11707e8..1fcd28855 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -27,6 +27,7 @@ function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: size="sm" className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")} disableFavicon + contactPhotoUri={account.avatarUrl} fallbackColor={account.avatarColor} /> ); diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 44ce83e0c..9f72ae678 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -707,6 +707,7 @@ export function NavigationRail({ email={account.email || account.username} size="sm" disableFavicon + contactPhotoUri={account.avatarUrl} fallbackColor={account.avatarColor} /> {isActive && ( diff --git a/components/protocol/protocol-account-picker.tsx b/components/protocol/protocol-account-picker.tsx index 6fa7401ef..4c2b812a5 100644 --- a/components/protocol/protocol-account-picker.tsx +++ b/components/protocol/protocol-account-picker.tsx @@ -117,6 +117,7 @@ export function ProtocolAccountPicker({ size="md" className="shrink-0" disableFavicon + contactPhotoUri={account.avatarUrl} fallbackColor={account.avatarColor} /> diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index 2571f08c2..b099b54b7 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -346,6 +346,7 @@ function AccountRow({ size="sm" className="w-9 h-9 text-sm" disableFavicon + contactPhotoUri={account.avatarUrl} fallbackColor={account.avatarColor} /> {isActive && ( diff --git a/lib/oauth/discovery.ts b/lib/oauth/discovery.ts index 75c0e2260..1050f2c08 100644 --- a/lib/oauth/discovery.ts +++ b/lib/oauth/discovery.ts @@ -2,6 +2,7 @@ export interface OAuthMetadata { issuer: string; authorization_endpoint: string; token_endpoint: string; + userinfo_endpoint?: string; revocation_endpoint?: string; end_session_endpoint?: string; } @@ -104,6 +105,7 @@ async function attemptDiscovery( const allPublic = await endpointsArePublic([ data.authorization_endpoint, data.token_endpoint, + data.userinfo_endpoint, data.revocation_endpoint, data.end_session_endpoint, ], validate); @@ -115,6 +117,7 @@ async function attemptDiscovery( issuer: data.issuer, authorization_endpoint: data.authorization_endpoint, token_endpoint: data.token_endpoint, + userinfo_endpoint: data.userinfo_endpoint, revocation_endpoint: data.revocation_endpoint, end_session_endpoint: data.end_session_endpoint, }; diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index ba3bdcb93..8ff285143 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -108,6 +108,45 @@ export interface TokenResult { refresh_token?: string; } +export interface UserInfoResult { + picture?: string; + avatar?: string; + avatar_url?: string; +} + +function avatarFromUserInfo(data: unknown): string | undefined { + if (!data || typeof data !== 'object') return undefined; + const record = data as Record; + const value = record.picture || record.avatar || record.avatar_url; + if (typeof value !== 'string') return undefined; + try { + const url = new URL(value); + if (url.protocol !== 'https:') return undefined; + return url.toString(); + } catch { + return undefined; + } +} + +export async function fetchUserInfoAvatar( + accessToken: string, + serverId?: string | null, +): Promise { + const metadata = await getMetadata(serverId); + if (!metadata?.userinfo_endpoint) return undefined; + + const response = await fetch(metadata.userinfo_endpoint, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + logger.warn('Userinfo request failed', { status: response.status }); + return undefined; + } + + return avatarFromUserInfo(await response.json()); +} + export async function exchangeCodeForTokens( code: string, codeVerifier: string, diff --git a/public/apple-touch-icon-120x120.png b/public/apple-touch-icon-120x120.png index ff841ef4b..f33d5f8b5 100644 Binary files a/public/apple-touch-icon-120x120.png and b/public/apple-touch-icon-120x120.png differ diff --git a/public/apple-touch-icon-152x152.png b/public/apple-touch-icon-152x152.png index e7732130c..14336ea9e 100644 Binary files a/public/apple-touch-icon-152x152.png and b/public/apple-touch-icon-152x152.png differ diff --git a/public/apple-touch-icon-167x167.png b/public/apple-touch-icon-167x167.png index 03e902144..1628762cd 100644 Binary files a/public/apple-touch-icon-167x167.png and b/public/apple-touch-icon-167x167.png differ diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png index 6603576b3..b18297be7 100644 Binary files a/public/apple-touch-icon-180x180.png and b/public/apple-touch-icon-180x180.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 6603576b3..b18297be7 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/branding/Bulwark_Favicon.png b/public/branding/Bulwark_Favicon.png index 0a9e1676e..1a1e7ba47 100644 Binary files a/public/branding/Bulwark_Favicon.png and b/public/branding/Bulwark_Favicon.png differ diff --git a/public/branding/Bulwark_Favicon.svg b/public/branding/Bulwark_Favicon.svg index ccf501171..058944e54 100644 --- a/public/branding/Bulwark_Favicon.svg +++ b/public/branding/Bulwark_Favicon.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/public/branding/Bulwark_Icon_App.svg b/public/branding/Bulwark_Icon_App.svg index 4b4fae760..ad9d1a615 100644 --- a/public/branding/Bulwark_Icon_App.svg +++ b/public/branding/Bulwark_Icon_App.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/public/branding/Bulwark_Logo_Color.png b/public/branding/Bulwark_Logo_Color.png index eae2bd511..e8e27f8f6 100644 Binary files a/public/branding/Bulwark_Logo_Color.png and b/public/branding/Bulwark_Logo_Color.png differ diff --git a/public/branding/Bulwark_Logo_Color.svg b/public/branding/Bulwark_Logo_Color.svg index 759a70681..dceec736c 100644 --- a/public/branding/Bulwark_Logo_Color.svg +++ b/public/branding/Bulwark_Logo_Color.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/public/branding/Bulwark_Logo_Dark.png b/public/branding/Bulwark_Logo_Dark.png index 8913c8ef8..60ceb50de 100644 Binary files a/public/branding/Bulwark_Logo_Dark.png and b/public/branding/Bulwark_Logo_Dark.png differ diff --git a/public/branding/Bulwark_Logo_Dark.svg b/public/branding/Bulwark_Logo_Dark.svg index f1c5afc3d..1694e6b80 100644 --- a/public/branding/Bulwark_Logo_Dark.svg +++ b/public/branding/Bulwark_Logo_Dark.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/public/branding/Bulwark_Logo_White.png b/public/branding/Bulwark_Logo_White.png index 896d4967a..cd83e0a9d 100644 Binary files a/public/branding/Bulwark_Logo_White.png and b/public/branding/Bulwark_Logo_White.png differ diff --git a/public/branding/Bulwark_Logo_White.svg b/public/branding/Bulwark_Logo_White.svg index 7d1a5e5b6..99d2cbe5f 100644 --- a/public/branding/Bulwark_Logo_White.svg +++ b/public/branding/Bulwark_Logo_White.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg b/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg index 89d9263eb..a444e64a5 100644 --- a/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg +++ b/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/public/branding/Bulwark_Logo_with_Lettering_Dark_and_Color.png b/public/branding/Bulwark_Logo_with_Lettering_Dark_and_Color.png index 96066b574..73c83ae7b 100644 Binary files a/public/branding/Bulwark_Logo_with_Lettering_Dark_and_Color.png and b/public/branding/Bulwark_Logo_with_Lettering_Dark_and_Color.png differ diff --git a/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.png b/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.png index 7b66beb2e..491c3fcd8 100644 Binary files a/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.png and b/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.png differ diff --git a/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg b/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg index f4a56416d..a8376e13e 100644 --- a/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg +++ b/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/public/icon-192x192.png b/public/icon-192x192.png index f5a709ad8..8c23b2146 100644 Binary files a/public/icon-192x192.png and b/public/icon-192x192.png differ diff --git a/public/icon-512x512.png b/public/icon-512x512.png index 3d5660820..cd77efb5d 100644 Binary files a/public/icon-512x512.png and b/public/icon-512x512.png differ diff --git a/public/icon-maskable-dark-192x192.png b/public/icon-maskable-dark-192x192.png index 4f091837a..08b4addb1 100644 Binary files a/public/icon-maskable-dark-192x192.png and b/public/icon-maskable-dark-192x192.png differ diff --git a/public/icon-maskable-dark-512x512.png b/public/icon-maskable-dark-512x512.png index 7b4d30da2..eca838431 100644 Binary files a/public/icon-maskable-dark-512x512.png and b/public/icon-maskable-dark-512x512.png differ diff --git a/public/icon-maskable-light-192x192.png b/public/icon-maskable-light-192x192.png index 6c878d86c..2d1d9e8f4 100644 Binary files a/public/icon-maskable-light-192x192.png and b/public/icon-maskable-light-192x192.png differ diff --git a/public/icon-maskable-light-512x512.png b/public/icon-maskable-light-512x512.png index 3d269cc32..4ce4e54b9 100644 Binary files a/public/icon-maskable-light-512x512.png and b/public/icon-maskable-light-512x512.png differ diff --git a/public/screenshot-1280x720.png b/public/screenshot-1280x720.png index 424a520d4..de5fa6b95 100644 Binary files a/public/screenshot-1280x720.png and b/public/screenshot-1280x720.png differ diff --git a/public/screenshot-540x720.png b/public/screenshot-540x720.png index 35ebaa39b..5f218004d 100644 Binary files a/public/screenshot-540x720.png and b/public/screenshot-540x720.png differ diff --git a/screenshots/calendar.png b/screenshots/calendar.png index 641002c6b..eda6c061e 100644 Binary files a/screenshots/calendar.png and b/screenshots/calendar.png differ diff --git a/screenshots/contacts.png b/screenshots/contacts.png index 629dcceb1..3f28fbd66 100644 Binary files a/screenshots/contacts.png and b/screenshots/contacts.png differ diff --git a/screenshots/mail-dark.png b/screenshots/mail-dark.png index ce6639c4b..fa7e99f88 100644 Binary files a/screenshots/mail-dark.png and b/screenshots/mail-dark.png differ diff --git a/screenshots/mail-white.png b/screenshots/mail-white.png index be5d508a1..e9cbf9171 100644 Binary files a/screenshots/mail-white.png and b/screenshots/mail-white.png differ diff --git a/screenshots/plugins.png b/screenshots/plugins.png index 1a88d4ee3..b562abdf1 100644 Binary files a/screenshots/plugins.png and b/screenshots/plugins.png differ diff --git a/screenshots/settings.png b/screenshots/settings.png index 9d69ab15e..4d2151048 100644 Binary files a/screenshots/settings.png and b/screenshots/settings.png differ diff --git a/screenshots/theme.png b/screenshots/theme.png index c87c80fbc..e189da69f 100644 Binary files a/screenshots/theme.png and b/screenshots/theme.png differ diff --git a/stores/account-store.ts b/stores/account-store.ts index 49835fe42..4c5b42193 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -30,6 +30,7 @@ export interface AccountEntry { displayName: string; email: string; avatarColor: string; + avatarUrl?: string; /** Timestamp of last successful login */ lastLoginAt: number; /** Whether this account is currently connected */ @@ -83,6 +84,7 @@ export const useAccountStore = create()( errorMessage: undefined, lastLoginAt: entry.lastLoginAt, authMode: entry.authMode, + avatarUrl: entry.avatarUrl, } : a ), diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 19a993b56..ab339a752 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -937,7 +937,7 @@ export const useAuthStore = create()( throw new Error('token_exchange_failed'); } - const { access_token, expires_in } = await tokenRes.json(); + const { access_token, expires_in, avatar_url } = await tokenRes.json(); const refreshFn = get().refreshAccessToken; const client = JMAPClient.withBearer(serverUrl, access_token, '', () => refreshFn()); @@ -973,6 +973,7 @@ export const useAuthStore = create()( rememberMe: true, displayName: primaryIdentity?.name || username, email: primaryIdentity?.email || username, + avatarUrl: typeof avatar_url === 'string' ? avatar_url : undefined, lastLoginAt: Date.now(), isConnected: true, hasError: false, @@ -1076,7 +1077,7 @@ export const useAuthStore = create()( throw new Error(errorData.error || 'token_exchange_failed'); } - const { access_token, expires_in } = await ssoRes.json(); + const { access_token, expires_in, avatar_url } = await ssoRes.json(); const ssoServerUrl = config.jmapServerUrl; @@ -1115,6 +1116,7 @@ export const useAuthStore = create()( rememberMe: true, displayName: primaryIdentity?.name || username, email: primaryIdentity?.email || username, + avatarUrl: typeof avatar_url === 'string' ? avatar_url : undefined, lastLoginAt: Date.now(), isConnected: true, hasError: false,