Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
);
};

const closeAfterPrivacyModeChange = useCallback(() => {
setSubDialog(null);
onClose();
}, [onClose]);

return (
<>
<Modal
Expand Down Expand Up @@ -511,6 +516,7 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
<PrivacyStatementDialog
isOpen={subDialog === 'privacy'}
onClose={() => setSubDialog(null)}
onModeChangeComplete={closeAfterPrivacyModeChange}
/>

<UpdateAvailableDialog
Expand Down
89 changes: 16 additions & 73 deletions src/web-ui/src/app/components/Privacy/Privacy.scss
Original file line number Diff line number Diff line change
@@ -1,31 +1,12 @@
.bitfun-privacy-gate {
position: fixed;
inset: 0;
z-index: 10000;
.bitfun-privacy-resource-error {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 320px;
min-height: 480px;
color: var(--color-text-primary);
background: var(--color-bg-primary);
font-family: var(--font-family-sans);
}

.bitfun-privacy-gate--status {
place-content: center;
justify-items: center;
gap: 16px;
padding: 28px;
text-align: center;

h1,
p { margin: 0; }

h1 {
font-size: 18px;
letter-spacing: 0;
}

p {
max-width: 560px;
color: var(--color-text-secondary);
Expand All @@ -36,64 +17,13 @@

.bitfun-privacy-gate__loading-icon { animation: bitfun-privacy-spin 1s linear infinite; }

.bitfun-privacy-gate__header,
.bitfun-privacy-gate__footer {
padding: 18px clamp(20px, 5vw, 64px);
border-color: var(--border-subtle);
background: var(--element-bg-subtle);
}

.bitfun-privacy-gate__header {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 28px 32px;
align-items: center;
gap: 14px;
border-bottom: 1px solid var(--border-subtle);

h1 {
margin: 0 0 3px;
font-size: 20px;
line-height: 1.3;
letter-spacing: 0;
}

p {
margin: 0;
color: var(--color-text-secondary);
font-size: 13px;
}
}

.bitfun-privacy-gate__close {
width: 32px;
height: 32px;
}

.bitfun-privacy-loading {
position: fixed;
inset: auto 16px 16px auto;
z-index: 10001;
color: var(--color-text-muted);
}

.bitfun-privacy-gate__logo {
width: 42px;
height: 42px;
object-fit: contain;
}

.bitfun-privacy-gate__document {
overflow: auto;
padding: 24px clamp(20px, 8vw, 112px);
}

.bitfun-privacy-gate__footer {
display: grid;
gap: 12px;
border-top: 1px solid var(--border-subtle);
}

.bitfun-privacy-gate__metadata,
.bitfun-privacy-dialog__metadata {
display: flex;
flex-wrap: wrap;
Expand Down Expand Up @@ -125,6 +55,21 @@
}
}

.bitfun-privacy-consent-dialog__intro {
padding: 12px 20px;
border-bottom: 1px solid var(--border-subtle);
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.5;
}

.bitfun-privacy-consent-dialog__footer {
display: grid;
gap: 12px;
padding: 14px 20px;
border-top: 1px solid var(--border-subtle);
}

.bitfun-privacy-gate__consent-row {
display: flex;
align-items: center;
Expand Down Expand Up @@ -218,8 +163,6 @@
}

@media (max-width: 620px) {
.bitfun-privacy-gate__header { grid-template-columns: 36px minmax(0, 1fr) 32px; }
.bitfun-privacy-gate__header > svg:not(.lucide-x) { display: none; }
.bitfun-privacy-gate__consent-row {
align-items: stretch;
flex-direction: column;
Expand Down
166 changes: 75 additions & 91 deletions src/web-ui/src/app/components/Privacy/PrivacyGate.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -158,92 +150,84 @@ export const PrivacyGate: React.FC<{ children: React.ReactNode }> = ({ children
return (
<>
{children}
{overlayVisible && resourceError && (
<main
className="bitfun-privacy-gate bitfun-privacy-gate--status"
data-testid="privacy-resource-error"
role="dialog"
aria-modal="true"
aria-labelledby="privacy-resource-error-title"
>
<AlertTriangle size={28} aria-hidden />
<h1 id="privacy-resource-error-title">{copy.loadError}</h1>
<p>{copy.resourceErrorHint}</p>
<div className="bitfun-privacy-gate__actions">
<Button variant="secondary" disabled={submitting} onClick={dismiss}>
{copy.closeAndContinue}
</Button>
<Button disabled={submitting} onClick={() => void loadStatus()}>{copy.retry}</Button>
</div>
</main>
)}
{overlayVisible && choicePanelVisible && !resourceError && status?.policy && (
<main
className="bitfun-privacy-gate"
data-testid="privacy-consent-gate"
role="dialog"
aria-modal="true"
aria-labelledby="privacy-gate-title"
>
<header className="bitfun-privacy-gate__header">
<img src="/Logo-ICON-128.png" alt="" className="bitfun-privacy-gate__logo" />
<div><h1 id="privacy-gate-title">{copy.title}</h1><p>{copy.intro}</p></div>
<ShieldCheck size={28} aria-hidden />
<Button
className="bitfun-privacy-gate__close"
variant="ghost"
iconOnly
aria-label={copy.closeAndContinue}
title={copy.closeAndContinue}
disabled={submitting}
onClick={dismiss}
>
<X size={16} aria-hidden />
</Button>
</header>
<section className="bitfun-privacy-gate__document" aria-label={copy.title}>
<PrivacyDocument content={status.policy.content} />
</section>
<footer className="bitfun-privacy-gate__footer">
<div className="bitfun-privacy-gate__metadata">
<Modal
isOpen={overlayVisible && resourceError}
onClose={dismiss}
title={copy.loadError}
size="medium"
contentClassName="bitfun-privacy-resource-error"
showCloseButton={!submitting}
closeOnOverlayClick={!submitting}
testId="privacy-resource-error"
>
<AlertTriangle size={28} aria-hidden />
<p>{copy.resourceErrorHint}</p>
<div className="bitfun-privacy-gate__actions">
<Button variant="secondary" disabled={submitting} onClick={dismiss}>
{copy.closeAndContinue}
</Button>
<Button disabled={submitting} onClick={() => void loadStatus()}>{copy.retry}</Button>
</div>
</Modal>
<Modal
isOpen={Boolean(
overlayVisible && choicePanelVisible && !resourceError && status?.policy,
)}
onClose={dismiss}
title={copy.title}
size="xlarge"
contentClassName="bitfun-privacy-dialog bitfun-privacy-consent-dialog"
showCloseButton={!submitting}
closeOnOverlayClick={!submitting}
testId="privacy-consent-gate"
>
{status?.policy ? (
<>
<div className="bitfun-privacy-dialog__metadata">
<span>{copy.effective}: {status.policy.effectiveAt.slice(0, 10)}</span>
<span>{copy.updated}: {status.policy.updatedAt.slice(0, 10)}</span>
</div>
{!status.releaseReady && (
<div className="bitfun-privacy-gate__configuration-error">{copy.releaseBlocked}</div>
)}
{mutationError && (
<div className="bitfun-privacy-gate__configuration-error" role="alert">
{copy.saveFailed}
</div>
)}
<div className="bitfun-privacy-gate__consent-row">
{!applyRetryRequired ? (
<Checkbox
checked={checked}
disabled={submitting}
onChange={event => setChecked(event.target.checked)}
label={copy.checkbox}
data-testid="privacy-consent-checkbox"
/>
) : <span />}
<div className="bitfun-privacy-gate__actions">
<Button variant="secondary" disabled={submitting} onClick={() => void handleNotAccepted()}>
{copy.disagree}
</Button>
<Button
disabled={!applyRetryRequired && (!checked || !status.releaseReady)}
isLoading={submitting}
onClick={() => void (applyRetryRequired ? handleApplyRetry() : handleAccept())}
data-testid="privacy-accept"
>
{applyRetryRequired ? copy.retryFullMode : copy.agree}
</Button>
<div className="bitfun-privacy-consent-dialog__intro">{copy.intro}</div>
<div className="bitfun-privacy-dialog__document" aria-label={copy.title}>
<PrivacyDocument content={status.policy.content} />
</div>
<div className="bitfun-privacy-consent-dialog__footer">
{!status.releaseReady && (
<div className="bitfun-privacy-gate__configuration-error">{copy.releaseBlocked}</div>
)}
{mutationError && (
<div className="bitfun-privacy-gate__configuration-error" role="alert">
{copy.saveFailed}
</div>
)}
<div className="bitfun-privacy-gate__consent-row">
{!applyRetryRequired ? (
<Checkbox
checked={checked}
disabled={submitting}
onChange={event => setChecked(event.target.checked)}
label={copy.checkbox}
data-testid="privacy-consent-checkbox"
/>
) : <span />}
<div className="bitfun-privacy-gate__actions">
<Button variant="secondary" disabled={submitting} onClick={() => void handleNotAccepted()}>
{copy.disagree}
</Button>
<Button
disabled={!applyRetryRequired && (!checked || !status.releaseReady)}
isLoading={submitting}
onClick={() => void (applyRetryRequired ? handleApplyRetry() : handleAccept())}
data-testid="privacy-accept"
>
{applyRetryRequired ? copy.retryFullMode : copy.agree}
</Button>
</div>
</div>
</div>
</footer>
</main>
)}
</>
) : null}
</Modal>
{!status && isTauriRuntime() && !loadError && (
<div className="bitfun-privacy-loading" aria-live="polite">
<LoaderCircle className="bitfun-privacy-gate__loading-icon" size={18} aria-hidden />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const log = createLogger('PrivacyStatementDialog');
interface PrivacyStatementDialogProps {
isOpen: boolean;
onClose: () => void;
onModeChangeComplete?: () => void;
variant?: 'about' | 'readonly';
}

Expand All @@ -18,6 +19,7 @@ type OperationError = 'accept_save' | 'apply' | 'withdraw' | 'mark_viewed' | nul
export const PrivacyStatementDialog: React.FC<PrivacyStatementDialogProps> = ({
isOpen,
onClose,
onModeChangeComplete,
variant = 'about',
}) => {
const { t, currentLanguage, formatDate } = useI18n('common');
Expand Down Expand Up @@ -84,6 +86,7 @@ export const PrivacyStatementDialog: React.FC<PrivacyStatementDialogProps> = ({
locale: policy.locale,
});
setChecked(false);
onModeChangeComplete?.();
} catch (error) {
log.warn('Privacy consent could not be saved or applied', error);
try {
Expand All @@ -103,6 +106,7 @@ export const PrivacyStatementDialog: React.FC<PrivacyStatementDialogProps> = ({
setOperationError(null);
try {
await applyCollectionPolicy('full', currentLanguage);
onModeChangeComplete?.();
} catch (error) {
log.warn('Full privacy mode could not be applied', error);
setOperationError('apply');
Expand All @@ -118,6 +122,7 @@ export const PrivacyStatementDialog: React.FC<PrivacyStatementDialogProps> = ({
setOperationError(null);
try {
await enterNotAccepted(policy?.locale ?? currentLanguage);
onModeChangeComplete?.();
} catch (error) {
log.warn('Privacy withdrawal state could not be saved', error);
setOperationError('withdraw');
Expand Down
Loading
Loading