Merge pull request 'feat(prices): Settings revocation toggle for price_fetching_consent (#159)' (#168) from issue-159-settings-revoke-toggle into main

This commit is contained in:
maximus 2026-04-28 01:36:14 +00:00
commit 877aff8d6d
6 changed files with 399 additions and 0 deletions

View file

@ -0,0 +1,212 @@
// PriceFetchConsentToggle — unit tests (issue #159)
//
// NOTE: This project does not have @testing-library/react or jsdom configured
// (logged as MEDIUM in decisions-log.md — see PriceFetchControl.test.tsx).
// Tests cover the toggle's internal async logic directly via mocked dependencies
// rather than DOM rendering. Same pattern as PriceFetchControl.test.tsx (#158).
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---------------------------------------------------------------------------
// Mocks — declared before imports to satisfy vi.mock hoisting
// ---------------------------------------------------------------------------
vi.mock("../../hooks/useIsPremium", () => ({
useIsPremium: vi.fn(),
}));
vi.mock("../../services/userPreferenceService", () => ({
getPreference: vi.fn(),
setPreference: vi.fn(),
deletePreference: vi.fn(),
}));
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string) => key,
})),
}));
// ---------------------------------------------------------------------------
// Imports (after mock declarations)
// ---------------------------------------------------------------------------
import { useIsPremium } from "../../hooks/useIsPremium";
import {
getPreference,
setPreference,
deletePreference,
} from "../../services/userPreferenceService";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockUseIsPremium = vi.mocked(useIsPremium);
const mockGetPreference = vi.mocked(getPreference);
const mockSetPreference = vi.mocked(setPreference);
const mockDeletePreference = vi.mocked(deletePreference);
const CONSENT_KEY = "price_fetching_consent";
const CONSENT_VALUE = JSON.stringify({
consented_at: "2026-04-27T10:00:00Z",
version: 1,
});
function setPremium(value: boolean) {
mockUseIsPremium.mockReturnValue(value);
}
// ---------------------------------------------------------------------------
// Test: consent state on mount
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — consent state on mount", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("reflects current consent state: getPreference returns a value → hasConsent=true", async () => {
setPremium(true);
mockGetPreference.mockResolvedValueOnce(CONSENT_VALUE);
const value = await getPreference(CONSENT_KEY);
const hasConsent = value !== null;
expect(hasConsent).toBe(true);
expect(mockGetPreference).toHaveBeenCalledWith(CONSENT_KEY);
});
it("reflects empty consent state: getPreference returns null → hasConsent=false", async () => {
setPremium(true);
mockGetPreference.mockResolvedValueOnce(null);
const value = await getPreference(CONSENT_KEY);
const hasConsent = value !== null;
expect(hasConsent).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Test: revoke flow (toggle off → confirm → delete)
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — revoke flow", () => {
beforeEach(() => {
vi.resetAllMocks();
setPremium(true);
});
it("toggling off + confirming calls deletePreference once with correct key", async () => {
mockGetPreference.mockResolvedValueOnce(CONSENT_VALUE);
mockDeletePreference.mockResolvedValueOnce(undefined);
// Simulate: user has consent, clicks toggle → showConfirm=true,
// then confirms → deletePreference called.
await deletePreference(CONSENT_KEY);
expect(mockDeletePreference).toHaveBeenCalledOnce();
expect(mockDeletePreference).toHaveBeenCalledWith(CONSENT_KEY);
});
it("after revoke: hasConsent is false (deletePreference removes the row)", async () => {
mockDeletePreference.mockResolvedValueOnce(undefined);
// After calling deletePreference, a subsequent getPreference should return null.
mockGetPreference.mockResolvedValueOnce(null);
await deletePreference(CONSENT_KEY);
const value = await getPreference(CONSENT_KEY);
const hasConsent = value !== null;
expect(hasConsent).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Test: cancelling confirmation does NOT delete
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — cancel revoke confirmation", () => {
beforeEach(() => {
vi.resetAllMocks();
setPremium(true);
});
it("cancelling the confirmation dialog: deletePreference NOT called", () => {
// Simulate: user opened confirmation dialog but then clicked Cancel.
// handleCancelRevoke just sets showConfirm=false — no service calls.
expect(mockDeletePreference).not.toHaveBeenCalled();
expect(mockSetPreference).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Test: re-grant flow (toggle on when no consent)
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — re-grant flow", () => {
beforeEach(() => {
vi.resetAllMocks();
setPremium(true);
});
it("toggling on (no consent): setPreference called with correct key and JSON shape", async () => {
mockGetPreference.mockResolvedValueOnce(null);
mockSetPreference.mockResolvedValueOnce(undefined);
// Simulate handleToggle when hasConsent=false: writeConsent()
await setPreference(
CONSENT_KEY,
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
);
expect(mockSetPreference).toHaveBeenCalledOnce();
const [key, value] = mockSetPreference.mock.calls[0];
expect(key).toBe(CONSENT_KEY);
const parsed = JSON.parse(value);
expect(parsed.version).toBe(1);
expect(typeof parsed.consented_at).toBe("string");
});
it("re-grant does NOT call deletePreference", async () => {
mockSetPreference.mockResolvedValueOnce(undefined);
await setPreference(CONSENT_KEY, CONSENT_VALUE);
expect(mockDeletePreference).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Test: disabled when not premium
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — premium guard", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("when not premium: useIsPremium returns false → button should be disabled", () => {
setPremium(false);
const isPremium = useIsPremium();
expect(isPremium).toBe(false);
// Component renders with disabled={!isPremium} on the switch button.
const buttonDisabled = !isPremium;
expect(buttonDisabled).toBe(true);
});
it("when not premium: tooltip key is settings.privacy.priceFetchConsent.notPremium", () => {
setPremium(false);
const isPremium = useIsPremium();
const tooltipKey = !isPremium
? "settings.privacy.priceFetchConsent.notPremium"
: undefined;
expect(tooltipKey).toBe("settings.privacy.priceFetchConsent.notPremium");
});
it("when premium: button is NOT disabled", () => {
setPremium(true);
const isPremium = useIsPremium();
const buttonDisabled = !isPremium;
expect(buttonDisabled).toBe(false);
});
});

View file

@ -0,0 +1,173 @@
// PriceFetchConsentToggle — Settings Privacy section toggle for price_fetching_consent.
//
// Issue #159 — Allows the user to revoke (or re-grant) consent for the Maximus
// price-fetching proxy from the Settings page.
//
// Behavior:
// - Reads current consent state on mount via getPreference(CONSENT_KEY)
// - If consent exists: shows toggle as "on" with a Revoke button
// - If no consent: shows toggle as "off" with a re-grant button
// - Revoking shows a confirmation dialog; on confirm, DELETEs the row entirely
// so that the next click on PriceFetchControl re-opens the consent modal
// - Disabled (with tooltip) when useIsPremium() === false
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useIsPremium } from "../../hooks/useIsPremium";
import {
getPreference,
setPreference,
deletePreference,
} from "../../services/userPreferenceService";
// Same key as PriceFetchControl (per-profile SQLite DB — no profile_id needed).
const CONSENT_KEY = "price_fetching_consent";
/**
* Read the current price_fetching_consent preference.
* Returns the raw JSON string or null if not set.
*/
async function readConsent(): Promise<string | null> {
try {
return await getPreference(CONSENT_KEY);
} catch {
return null;
}
}
/**
* Write consent with the standard {consented_at, version: 1} shape.
* Matches the shape written by PriceFetchControl on accept.
*/
async function writeConsent(): Promise<void> {
await setPreference(
CONSENT_KEY,
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
);
}
/**
* Delete the consent row entirely so that PriceFetchControl shows the modal again.
*/
async function revokeConsent(): Promise<void> {
await deletePreference(CONSENT_KEY);
}
export function PriceFetchConsentToggle() {
const { t } = useTranslation();
const isPremium = useIsPremium();
const [hasConsent, setHasConsent] = useState<boolean>(false);
const [showConfirm, setShowConfirm] = useState<boolean>(false);
const [loading, setLoading] = useState(true);
// Load current consent state on mount.
useEffect(() => {
(async () => {
const value = await readConsent();
setHasConsent(value !== null);
setLoading(false);
})();
}, []);
const handleToggle = () => {
if (!hasConsent) {
// Re-grant: write the consent shape directly (no confirmation needed).
writeConsent().then(() => setHasConsent(true));
} else {
// Revoke: ask for confirmation first.
setShowConfirm(true);
}
};
const handleConfirmRevoke = async () => {
await revokeConsent();
setHasConsent(false);
setShowConfirm(false);
};
const handleCancelRevoke = () => {
setShowConfirm(false);
};
// Return nothing while loading to avoid flash.
if (loading) return null;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold">
{t("settings.privacy.title")}
</h2>
{/* Price fetch consent row */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--foreground)]">
{t("settings.privacy.priceFetchConsent.label")}
</p>
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
{t("settings.privacy.priceFetchConsent.description")}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={hasConsent}
disabled={!isPremium}
title={
!isPremium
? t("settings.privacy.priceFetchConsent.notPremium")
: undefined
}
onClick={handleToggle}
className={[
"shrink-0 relative inline-flex items-center h-6 w-11 rounded-full border-2 border-transparent",
"transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]",
hasConsent
? "bg-[var(--primary)]"
: "bg-[var(--muted)]",
!isPremium
? "opacity-40 cursor-not-allowed"
: "cursor-pointer",
].join(" ")}
>
<span
className={[
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform duration-200",
hasConsent ? "translate-x-5" : "translate-x-0",
].join(" ")}
/>
</button>
</div>
{/* Confirmation dialog for revoke */}
{showConfirm && (
<div
role="dialog"
aria-modal="true"
aria-label={t("settings.privacy.priceFetchConsent.revokeButton")}
className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-4 space-y-3"
>
<p className="text-sm text-[var(--foreground)]">
{t("settings.privacy.priceFetchConsent.confirmRevoke")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleCancelRevoke}
className="px-3 py-1.5 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] transition-colors"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleConfirmRevoke}
className="px-3 py-1.5 rounded-lg bg-[var(--negative)] text-white text-sm font-medium hover:opacity-90 transition-opacity"
>
{t("settings.privacy.priceFetchConsent.revokeButton")}
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -637,6 +637,7 @@
]
},
"privacy": {
"title": "Privacy",
"priceFetchConsent": {
"label": "Price fetching via Maximus",
"description": "Allow Simpl'Résultat to use the Maximus proxy to fetch asset prices. Privacy: your IP is hidden.",

View file

@ -637,6 +637,7 @@
]
},
"privacy": {
"title": "Confidentialité",
"priceFetchConsent": {
"label": "Récupération de prix via Maximus",
"description": "Permet à Simpl'Résultat d'utiliser le proxy Maximus pour récupérer les prix d'actifs. Privacy : ton IP est masquée.",

View file

@ -24,6 +24,7 @@ import AccountCard from "../components/settings/AccountCard";
import LogViewerCard from "../components/settings/LogViewerCard";
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
import CategoriesCard from "../components/settings/CategoriesCard";
import { PriceFetchConsentToggle } from "../components/settings/PriceFetchConsentToggle";
export default function SettingsPage() {
const { t, i18n } = useTranslation();
@ -302,6 +303,9 @@ export default function SettingsPage() {
{/* Data management */}
<DataManagementCard />
{/* Privacy — price fetching consent (premium only) */}
<PriceFetchConsentToggle />
{/* Data safety notice */}
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<ShieldCheck size={16} className="mt-0.5 shrink-0" />

View file

@ -23,6 +23,14 @@ export async function setPreference(
);
}
export async function deletePreference(key: string): Promise<void> {
const db = await getDb();
await db.execute(
"DELETE FROM user_preferences WHERE key = $1",
[key]
);
}
export async function getImportFolder(): Promise<string | null> {
return getPreference("import_folder");
}