diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx index 03a243a2c2..ad0e8c1665 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx @@ -214,6 +214,11 @@ export const AboutDialog: React.FC = ({ ); }; + const closeAfterPrivacyModeChange = useCallback(() => { + setSubDialog(null); + onClose(); + }, [onClose]); + return ( <> = ({ setSubDialog(null)} + onModeChangeComplete={closeAfterPrivacyModeChange} /> svg:not(.lucide-x) { display: none; } .bitfun-privacy-gate__consent-row { align-items: stretch; flex-direction: column; diff --git a/src/web-ui/src/app/components/Privacy/PrivacyGate.tsx b/src/web-ui/src/app/components/Privacy/PrivacyGate.tsx index 58b3dee127..37db50e99d 100644 --- a/src/web-ui/src/app/components/Privacy/PrivacyGate.tsx +++ b/src/web-ui/src/app/components/Privacy/PrivacyGate.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { AlertTriangle, LoaderCircle, ShieldCheck, X } from 'lucide-react'; -import { Button, Checkbox } from '@/component-library'; +import { AlertTriangle, LoaderCircle } from 'lucide-react'; +import { Button, Checkbox, Modal } from '@/component-library'; import { hideStartupOverlay } from '@/app/startup/startupOverlay'; import { privacyAPI } from '@/infrastructure/api/service-api/PrivacyAPI'; import { isTauriRuntime } from '@/infrastructure/runtime'; @@ -80,17 +80,9 @@ export const PrivacyGate: React.FC<{ children: React.ReactNode }> = ({ children useEffect(() => { if (!overlayVisible) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - dismiss(); - } - }; const handleBack = () => dismiss(); - window.addEventListener('keydown', handleKeyDown); window.addEventListener('popstate', handleBack); return () => { - window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('popstate', handleBack); }; }, [dismiss, overlayVisible]); @@ -158,92 +150,84 @@ export const PrivacyGate: React.FC<{ children: React.ReactNode }> = ({ children return ( <> {children} - {overlayVisible && resourceError && ( -
- -

{copy.loadError}

-

{copy.resourceErrorHint}

-
- - -
-
- )} - {overlayVisible && choicePanelVisible && !resourceError && status?.policy && ( -
-
- -

{copy.title}

{copy.intro}

- - -
-
- -
-
-
+ + +

{copy.resourceErrorHint}

+
+ + +
+
+ + {status?.policy ? ( + <> +
{copy.effective}: {status.policy.effectiveAt.slice(0, 10)} {copy.updated}: {status.policy.updatedAt.slice(0, 10)}
- {!status.releaseReady && ( -
{copy.releaseBlocked}
- )} - {mutationError && ( -
- {copy.saveFailed} -
- )} -
- {!applyRetryRequired ? ( - setChecked(event.target.checked)} - label={copy.checkbox} - data-testid="privacy-consent-checkbox" - /> - ) : } -
- - +
{copy.intro}
+
+ +
+
+ {!status.releaseReady && ( +
{copy.releaseBlocked}
+ )} + {mutationError && ( +
+ {copy.saveFailed} +
+ )} +
+ {!applyRetryRequired ? ( + setChecked(event.target.checked)} + label={copy.checkbox} + data-testid="privacy-consent-checkbox" + /> + ) : } +
+ + +
-
-
- )} + + ) : null} +
{!status && isTauriRuntime() && !loadError && (
diff --git a/src/web-ui/src/app/components/Privacy/PrivacyStatementDialog.tsx b/src/web-ui/src/app/components/Privacy/PrivacyStatementDialog.tsx index 005906bc52..0695d96af3 100644 --- a/src/web-ui/src/app/components/Privacy/PrivacyStatementDialog.tsx +++ b/src/web-ui/src/app/components/Privacy/PrivacyStatementDialog.tsx @@ -10,6 +10,7 @@ const log = createLogger('PrivacyStatementDialog'); interface PrivacyStatementDialogProps { isOpen: boolean; onClose: () => void; + onModeChangeComplete?: () => void; variant?: 'about' | 'readonly'; } @@ -18,6 +19,7 @@ type OperationError = 'accept_save' | 'apply' | 'withdraw' | 'mark_viewed' | nul export const PrivacyStatementDialog: React.FC = ({ isOpen, onClose, + onModeChangeComplete, variant = 'about', }) => { const { t, currentLanguage, formatDate } = useI18n('common'); @@ -84,6 +86,7 @@ export const PrivacyStatementDialog: React.FC = ({ locale: policy.locale, }); setChecked(false); + onModeChangeComplete?.(); } catch (error) { log.warn('Privacy consent could not be saved or applied', error); try { @@ -103,6 +106,7 @@ export const PrivacyStatementDialog: React.FC = ({ setOperationError(null); try { await applyCollectionPolicy('full', currentLanguage); + onModeChangeComplete?.(); } catch (error) { log.warn('Full privacy mode could not be applied', error); setOperationError('apply'); @@ -118,6 +122,7 @@ export const PrivacyStatementDialog: React.FC = ({ setOperationError(null); try { await enterNotAccepted(policy?.locale ?? currentLanguage); + onModeChangeComplete?.(); } catch (error) { log.warn('Privacy withdrawal state could not be saved', error); setOperationError('withdraw'); diff --git a/src/web-ui/src/app/components/Privacy/privacyLifecycleContract.test.ts b/src/web-ui/src/app/components/Privacy/privacyLifecycleContract.test.ts index a489e97649..ddc9cb610b 100644 --- a/src/web-ui/src/app/components/Privacy/privacyLifecycleContract.test.ts +++ b/src/web-ui/src/app/components/Privacy/privacyLifecycleContract.test.ts @@ -22,11 +22,23 @@ describe('OpenHarmony privacy lifecycle contract', () => { expect(gate).toContain('const dismiss = useCallback'); expect(gate).toContain('setDismissed(true)'); - expect(gate).toContain("event.key === 'Escape'"); + expect(gate).toContain('onClose={dismiss}'); expect(gate).toContain('await enterNotAccepted'); expect(gate).not.toContain('quitApp'); }); + it('shows the first consent choice in the same modal size as the managed statement', () => { + const gate = readSource('./PrivacyGate.tsx'); + const dialog = readSource('./PrivacyStatementDialog.tsx'); + + expect(gate).toContain('size="xlarge"'); + expect(gate).toContain( + 'contentClassName="bitfun-privacy-dialog bitfun-privacy-consent-dialog"', + ); + expect(dialog).toContain('size="xlarge"'); + expect(gate).not.toContain('className="bitfun-privacy-gate"'); + }); + it('uses the explicit lifecycle and collection-policy command surface', () => { const api = readSource('../../../infrastructure/api/service-api/PrivacyAPI.ts'); const nativeApi = readSource('../../../../../apps/desktop/src/api/privacy_api.rs'); @@ -93,8 +105,8 @@ describe('OpenHarmony privacy lifecycle contract', () => { it('renders resource failure without an agreement action', () => { const gate = readSource('./PrivacyGate.tsx'); const errorView = gate.slice( - gate.indexOf('data-testid="privacy-resource-error"'), - gate.indexOf('data-testid="privacy-consent-gate"'), + gate.indexOf('testId="privacy-resource-error"'), + gate.indexOf('testId="privacy-consent-gate"'), ); expect(errorView).toContain('copy.closeAndContinue'); diff --git a/src/web-ui/src/app/components/Privacy/privacyPolicyManagementContract.test.ts b/src/web-ui/src/app/components/Privacy/privacyPolicyManagementContract.test.ts index 45239e9890..1ae533ed81 100644 --- a/src/web-ui/src/app/components/Privacy/privacyPolicyManagementContract.test.ts +++ b/src/web-ui/src/app/components/Privacy/privacyPolicyManagementContract.test.ts @@ -38,6 +38,17 @@ describe('OpenHarmony privacy policy management contract', () => { expect(`${native}\n${dialog}`).not.toContain('quitApp'); }); + it('returns to the main page after a managed consent mode change succeeds', () => { + const dialog = readSource('./PrivacyStatementDialog.tsx'); + const about = readSource('../AboutDialog/AboutDialog.tsx'); + + expect(dialog).toContain('onModeChangeComplete?: () => void'); + expect(dialog.match(/onModeChangeComplete\?\.\(\)/g)).toHaveLength(3); + expect(about).toContain('const closeAfterPrivacyModeChange = useCallback'); + expect(about).toContain('setSubDialog(null);\n onClose();'); + expect(about).toContain('onModeChangeComplete={closeAfterPrivacyModeChange}'); + }); + it('uses only the policy timestamp for editorial update state', () => { const service = readSource( '../../../../../crates/services/services-integrations/src/privacy/mod.rs',