diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 992bef2ec..9fadc2052 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -27,7 +27,9 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { AppUpdatePrompt } from "@/components/app-update-prompt"; import { OfflineHost } from "@/components/offline/offline-host"; +import { DeviceWipeHost } from "@/components/offline/device-wipe-host"; import { NotificationActionsHost } from "@/components/providers/notification-actions-host"; +import { DesktopOsHost } from "@/components/desktop/desktop-os-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; @@ -37,6 +39,8 @@ import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share"; import { MainWindowOnly } from "@/components/popout/main-window-only"; import { PopoutHost } from "@/components/popout/popout-host"; import { WindowSyncHost } from "@/components/popout/window-sync-host"; +import { DesktopHost } from "@/components/desktop/desktop-host"; +import { WindowMenuBar } from "@/components/layout/window-menu-bar"; import { IS_LITE } from "@/lib/lite"; import { setRequestLocale } from "next-intl/server"; import { locales } from "@/i18n/routing"; @@ -85,10 +89,13 @@ export default async function LocaleLayout({ + {!IS_LITE && } {!IS_LITE && } + {!IS_LITE && } + {!IS_LITE && } {children} @@ -115,6 +122,7 @@ export default async function LocaleLayout({ {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } + {!IS_LITE && } {!IS_LITE && } diff --git a/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx new file mode 100644 index 000000000..de689d054 --- /dev/null +++ b/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge } from '@/lib/desktop/types'; +import { EMPTY_POLICY_LAYERS, resolvePolicies } from '@/lib/desktop/policy-schema'; + +// The device's OS policy on the two ways into SSO: nothing starts towards a refused server. + +vi.mock('next/navigation', () => ({ + useParams: () => ({ locale: 'en' }), + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', +})); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +const configOverrides = vi.hoisted(() => ({ value: {} as Record })); + +vi.mock('@/hooks/use-config', () => ({ + useConfig: () => ({ + appName: 'Webmail', + isLoading: false, + error: null, + jmapServerUrl: 'https://mail.example.org', + jmapServers: [], + oauthEnabled: true, + oauthOnly: true, + oauthClientId: 'webmail', + ...configOverrides.value, + }), +})); + +const { default: LoginPage } = await import('../page'); + +const METADATA = { + issuer: 'https://mail.example.org', + authorization_endpoint: 'https://mail.example.org/authorize', + token_endpoint: 'https://mail.example.org/token', +}; + +function allowOnly(...servers: string[]) { + const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { AllowedJmapServers: servers } }); + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + mode: 'standalone', + policy: { get: () => snapshot, onChange: () => () => {} }, + } as unknown as BulwarkDesktopBridge; +} + +let fetched: string[]; + +beforeEach(() => { + configOverrides.value = {}; + fetched = []; + sessionStorage.clear(); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + fetched.push(url); + if (url.includes('/api/auth/oauth/metadata')) return new Response(JSON.stringify(METADATA), { status: 200 }); + if (url.includes('/api/auth/sso/start')) return new Response(JSON.stringify({ authorize_url: 'about:blank#sso' }), { status: 200 }); + return new Response('{}', { status: 200 }); + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + window.bulwarkDesktop = undefined; +}); + +const ssoButton = () => screen.findByRole('button', { name: /sign_in_sso/ }, { timeout: 10000 }); +const startedPkce = () => sessionStorage.getItem('oauth_code_verifier'); +const startedSso = () => fetched.some((url) => url.includes('/api/auth/sso/start')); + +describe('the SSO button', () => { + it('starts nothing towards a server off the list, and says why', async () => { + allowOnly('https://other.example.org'); + render(); + fireEvent.click(await ssoButton()); + expect(await screen.findByText('server_refused')).toBeInTheDocument(); + expect(startedPkce()).toBeNull(); + }, 30_000); + + it('starts as usual towards a server on the list', async () => { + allowOnly('https://mail.example.org'); + render(); + fireEvent.click(await ssoButton()); + await waitFor(() => expect(startedPkce()).not.toBeNull()); + expect(screen.queryByText('server_refused')).toBeNull(); + }, 30_000); +}); + +describe('automatic SSO', () => { + it('asks the server for nothing when its server is off the list, and says why', async () => { + configOverrides.value = { autoSsoEnabled: true }; + allowOnly('https://other.example.org'); + render(); + expect(await screen.findByText('server_refused', {}, { timeout: 10000 })).toBeInTheDocument(); + expect(startedSso()).toBe(false); + }, 30_000); + + it('starts as usual when its server is on the list', async () => { + configOverrides.value = { autoSsoEnabled: true }; + allowOnly('https://mail.example.org'); + render(); + await waitFor(() => expect(startedSso()).toBe(true), { timeout: 10000 }); + }, 30_000); +}); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index bd29046e7..de5aa6ef1 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -14,6 +14,8 @@ import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; import { useAuthStore, consumeSignedOut, SESSION_EXPIRED_DRAFTS_UNSAVED_KEY } from "@/stores/auth-store"; import { consumeLost } from "@/lib/offline/lost"; +import { consumeWipeNote, onRefusedLoginsChanged, refusedWaits, type RefusedWait, type WipeNote } from "@/lib/devices/wipe-state"; +import { wipeNoteText } from "@/components/offline/device-wipe-host"; import { useAccountStore } from "@/stores/account-store"; import { useThemeStore } from "@/stores/theme-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -198,6 +200,7 @@ function LoginPageContent() { const [desktopWaiting, setDesktopWaiting] = useState(false); const tSessionLimits = useTranslations("session_limits"); const tOffline = useTranslations("offline"); + const tOfflineWipe = useTranslations("offlineWipe"); const tTheme = useTranslations("settings.appearance.theme"); const params = useParams(); const searchParams = useSearchParams(); @@ -302,8 +305,16 @@ function LoginPageContent() { const [sessionEnd, setSessionEnd] = useState(null); // Offline outbox messages a sign-out deleted unsent (#523), named once. const [outboxLost, setOutboxLost] = useState([]); + // Unsent mail a wipe took with it: how many, never which. + const [wipeLost, setWipeLost] = useState(null); + // Unsent mail that waits for a new password: how many and until when, never which. + const [refusedWaiting, setRefusedWaiting] = useState([]); useEffect(() => { setOutboxLost(consumeLost()); + setWipeLost(consumeWipeNote()); + const read = () => setRefusedWaiting(refusedWaits()); + read(); + return onRefusedLoginsChanged(read); }, []); const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false); const [showPassword, setShowPassword] = useState(false); @@ -640,7 +651,7 @@ function LoginPageContent() { const startServerSideSso = useCallback(async () => { // The device's OS policy, as for a password: nothing starts towards a refused server. - if (selectedServer?.url && !jmapServerAllowedByDesktopPolicy(selectedServer.url, desktopPolicy)) { + if (serverUrl && !jmapServerAllowedByDesktopPolicy(serverUrl, desktopPolicy)) { setDesktopPolicyNotice(tDesktopPolicy("server_refused")); return; } @@ -700,7 +711,7 @@ function LoginPageContent() { } catch { setOauthLoading(false); } - }, [params.locale, selectedServer?.id, selectedServer?.url, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]); + }, [params.locale, selectedServer?.id, serverUrl, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]); useEffect(() => { // After signing out, show the login page rather than signing straight @@ -1174,6 +1185,12 @@ function LoginPageContent() { // has to pick which one SSO targets before the button starts the flow, since // the selection drives OAuth discovery and the server_id sent to the callback // (issue #799). + const desktopPolicyAlert = desktopPolicyNotice && ( +

+ {desktopPolicyNotice} +

+ ); + const serverPicker = hasServerList && jmapServers.length > 1 ? (