feat(prices): Settings revocation toggle for price_fetching_consent (#159) #168
7 changed files with 418 additions and 0 deletions
|
|
@ -42,6 +42,25 @@ hardcoded fallback in SnapshotEditor wiring. A `// TODO: asset_type from categor
|
||||||
comment marks the injection point. A follow-up issue should add `asset_type TEXT DEFAULT 'stock'`
|
comment marks the injection point. A follow-up issue should add `asset_type TEXT DEFAULT 'stock'`
|
||||||
to `balance_categories` and thread it through SnapshotEditor props.
|
to `balance_categories` and thread it through SnapshotEditor props.
|
||||||
|
|
||||||
|
## Issue #159 — deletePreference added to userPreferenceService (LOW)
|
||||||
|
|
||||||
|
The `userPreferenceService` had no `deletePreference` function. Added a simple DELETE
|
||||||
|
SQL helper alongside `getPreference`/`setPreference`. Revoke must DELETE the row entirely
|
||||||
|
(not set value to null) so that `PriceFetchControl` re-shows the consent modal on next click
|
||||||
|
(it checks `getPreference` returning a truthy value). Setting to null would break that check.
|
||||||
|
|
||||||
|
## Issue #159 — no Switch/Toggle component in shared/ (MEDIUM)
|
||||||
|
|
||||||
|
No reusable Switch or Toggle component exists in `src/components/shared/`. A minimal
|
||||||
|
toggle was built inline inside `PriceFetchConsentToggle` using a styled `<button role="switch">`.
|
||||||
|
This is consistent with the rest of the codebase which uses inline Tailwind patterns rather
|
||||||
|
than an external component library. If a shared toggle is needed later, extract from here.
|
||||||
|
|
||||||
|
## Issue #159 — settings.privacy.title key added (LOW)
|
||||||
|
|
||||||
|
The `settings.privacy` i18n namespace only had `priceFetchConsent.*` keys — no `title`
|
||||||
|
for the section header. Added `settings.privacy.title` in both FR and EN locales.
|
||||||
|
|
||||||
## Issue #156 — rate-limit pacing test strategy (LOW)
|
## Issue #156 — rate-limit pacing test strategy (LOW)
|
||||||
|
|
||||||
The pacing test verifies that setTimeout is called with a positive delay for the 2nd
|
The pacing test verifies that setTimeout is called with a positive delay for the 2nd
|
||||||
|
|
|
||||||
212
src/components/settings/PriceFetchConsentToggle.test.tsx
Normal file
212
src/components/settings/PriceFetchConsentToggle.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
173
src/components/settings/PriceFetchConsentToggle.tsx
Normal file
173
src/components/settings/PriceFetchConsentToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -637,6 +637,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
|
"title": "Privacy",
|
||||||
"priceFetchConsent": {
|
"priceFetchConsent": {
|
||||||
"label": "Price fetching via Maximus",
|
"label": "Price fetching via Maximus",
|
||||||
"description": "Allow Simpl'Résultat to use the Maximus proxy to fetch asset prices. Privacy: your IP is hidden.",
|
"description": "Allow Simpl'Résultat to use the Maximus proxy to fetch asset prices. Privacy: your IP is hidden.",
|
||||||
|
|
|
||||||
|
|
@ -637,6 +637,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
|
"title": "Confidentialité",
|
||||||
"priceFetchConsent": {
|
"priceFetchConsent": {
|
||||||
"label": "Récupération de prix via Maximus",
|
"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.",
|
"description": "Permet à Simpl'Résultat d'utiliser le proxy Maximus pour récupérer les prix d'actifs. Privacy : ton IP est masquée.",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import AccountCard from "../components/settings/AccountCard";
|
||||||
import LogViewerCard from "../components/settings/LogViewerCard";
|
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||||
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
|
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
|
||||||
import CategoriesCard from "../components/settings/CategoriesCard";
|
import CategoriesCard from "../components/settings/CategoriesCard";
|
||||||
|
import { PriceFetchConsentToggle } from "../components/settings/PriceFetchConsentToggle";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
@ -302,6 +303,9 @@ export default function SettingsPage() {
|
||||||
{/* Data management */}
|
{/* Data management */}
|
||||||
<DataManagementCard />
|
<DataManagementCard />
|
||||||
|
|
||||||
|
{/* Privacy — price fetching consent (premium only) */}
|
||||||
|
<PriceFetchConsentToggle />
|
||||||
|
|
||||||
{/* Data safety notice */}
|
{/* Data safety notice */}
|
||||||
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
||||||
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
|
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
export async function getImportFolder(): Promise<string | null> {
|
||||||
return getPreference("import_folder");
|
return getPreference("import_folder");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue