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 index 167689560..96f3baad9 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; -import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange'; +import { exchangeCodeForTokens, buildOAuthParams, fetchUserInfoAvatar, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; @@ -27,9 +27,12 @@ export async function POST(request: NextRequest) { const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId); + const avatarUrl = await fetchUserInfoAvatar(tokens.access_token, serverId).catch(() => undefined); + const response = NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in, + ...(avatarUrl ? { avatar_url: avatarUrl } : {}), }); const cookieStore = await cookies(); diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 97ae3a7e2..5d1ed78f6 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 3df41ca87..87386593f 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -693,6 +693,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 4845191cc..a6afbad26 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -343,6 +343,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 51fe2d213..1260ce16a 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -91,6 +91,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..451a88e0f 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..a46cf8ccd 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..07f3e90be 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..8b95c9ae1 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..8b95c9ae1 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..335a8a0ae 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..cf95a03eb 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..07fe71faf 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..dc857a457 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..5e3d33338 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..fc509ff6f 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 1129a4367..16d445a93 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 8d21a585a..0de65d704 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..16147da44 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..9b79c363a 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..43f949755 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..fc33dd972 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..e7d08b050 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..1c2c773ad 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..e8163d5b0 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..f38d32eb6 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..8bbbaf7e4 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..0a84e6834 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..0e7f8cb66 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..519de8e47 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..ca2636116 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 92a6f8b81..1895d36e3 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -884,7 +884,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()); @@ -920,6 +920,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, @@ -1022,7 +1023,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; @@ -1061,6 +1062,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,