From 5208cd59366883a171f07f77201b594ab91ca09b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 31 May 2026 13:17:28 -0300 Subject: [PATCH] feat(quota): mask emails across the quota-share screen (EmailPrivacyToggle) --- .../quota-share/QuotaSharePageClient.tsx | 20 +- .../components/AccountQuotaRow.tsx | 7 +- .../components/EditAllocationsModal.tsx | 5 +- .../costs/quota-share/components/PoolCard.tsx | 8 +- .../quota-share/components/PoolWizard.tsx | 10 +- tests/unit/quota-email-privacy.test.ts | 241 ++++++++++++++++++ 6 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 tests/unit/quota-email-privacy.test.ts diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx index b0def14f31..d61c8814c5 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx @@ -3,6 +3,9 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/shared/components"; +import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { maskEmailLikeValue } from "@/shared/utils/maskEmail"; import type { QuotaPool, PoolAllocation } from "@/lib/quota/dimensions"; import { usePools } from "./hooks/usePools"; @@ -119,6 +122,7 @@ function PoolCardWithUsage({ export default function QuotaSharePageClient() { const t = useTranslations("quotaShare"); const { pools, loading, mutate } = usePools(); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); // LS → DB migration hook (B22) — runs once, idempotent useLocalStoragePoolMigration({ pools, mutate }); @@ -186,9 +190,10 @@ export default function QuotaSharePageClient() { (connectionId: string) => { const conn = connections.find((c) => c.id === connectionId); if (!conn) return connectionId.slice(0, 12); - return conn.name || conn.email || conn.displayName || conn.id.slice(0, 12); + const raw = conn.name || conn.email || conn.displayName || conn.id.slice(0, 12); + return emailsVisible ? raw : maskEmailLikeValue(raw); }, - [connections] + [connections, emailsVisible] ); const connProvider = useCallback( @@ -244,10 +249,13 @@ export default function QuotaSharePageClient() {

{t("description")}

- +
+ + +
{/* Concept card */} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/AccountQuotaRow.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/AccountQuotaRow.tsx index 87b93bb2b8..ac0b61f197 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/AccountQuotaRow.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/AccountQuotaRow.tsx @@ -3,6 +3,8 @@ import { useTranslations } from "next-intl"; import { useState, useEffect } from "react"; import ProviderIcon from "@/shared/components/ProviderIcon"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { maskEmailLikeValue } from "@/shared/utils/maskEmail"; import { parseQuotaData, calculatePercentage, @@ -83,6 +85,7 @@ export default function AccountQuotaRow({ connectionIds, }: AccountQuotaRowProps) { const t = useTranslations("quotaShare"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); // Keyed by connectionId const [caches, setCaches] = useState | null>(null); @@ -162,8 +165,8 @@ export default function AccountQuotaRow({ - - {connId.slice(0, 8)}… + + {emailsVisible ? `${connId.slice(0, 8)}…` : maskEmailLikeValue(connId)} {summary ? ( <> diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx index 875d1c66d0..2829993933 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import { Button, Modal } from "@/shared/components"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { maskEmailLikeValue } from "@/shared/utils/maskEmail"; import type { QuotaPool, PoolAllocation, Policy } from "@/lib/quota/dimensions"; interface ApiKey { @@ -39,6 +41,7 @@ export default function EditAllocationsModal({ onSave, }: EditAllocationsModalProps) { const t = useTranslations("quotaShare"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); const [drafts, setDrafts] = useState(pool.allocations); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -100,7 +103,7 @@ export default function EditAllocationsModal({
- {t("pool")}: {pool.name} + {t("pool")}: {emailsVisible ? pool.name : maskEmailLikeValue(pool.name)}
{drafts.length === 0 ? ( diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx index 8e8680c95e..083efd7e27 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx @@ -3,6 +3,8 @@ import { useTranslations } from "next-intl"; import Card from "@/shared/components/Card"; import ProviderIcon from "@/shared/components/ProviderIcon"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { maskEmailLikeValue } from "@/shared/utils/maskEmail"; import type { QuotaPool } from "@/lib/quota/dimensions"; import type { PoolUsageSnapshot } from "@/lib/quota/types"; import DimensionBar from "./DimensionBar"; @@ -59,9 +61,13 @@ export default function PoolCard({ onRemove, }: PoolCardProps) { const t = useTranslations("quotaShare"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); const status = computeStatus(usage); const { icon: statusIcon, cls: statusCls } = STATUS_ICONS[status]; + const displayName = emailsVisible ? pool.name : maskEmailLikeValue(pool.name); + const displayConnectionLabel = emailsVisible ? connectionLabel : maskEmailLikeValue(connectionLabel); + // Check for plan dimensions from usage const hasDimensions = !!usage?.dimensions?.length; @@ -98,7 +104,7 @@ export default function PoolCard({ {statusIcon} - {pool.name} · {connectionLabel} + {displayName} · {displayConnectionLabel}
diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx index f782d6ea82..11be86b712 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx @@ -20,6 +20,8 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { Button, Modal } from "@/shared/components"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { maskEmailLikeValue } from "@/shared/utils/maskEmail"; import { getKnownPlan } from "@/lib/quota/planRegistry"; import { quotaModelName } from "@/lib/quota/quotaModelNaming"; import type { Policy, PoolAllocation, QuotaDimension, QuotaUnit, QuotaWindow } from "@/lib/quota/dimensions"; @@ -160,6 +162,7 @@ export default function PoolWizard({ }: PoolWizardProps) { const t = useTranslations("quotaShare"); const tPlans = useTranslations("quotaPlans"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); // ── Wizard step ─────────────────────────────────────────────────────────── const [step, setStep] = useState<1 | 2 | 3>(1); @@ -188,8 +191,11 @@ export default function PoolWizard({ // ── Helpers ─────────────────────────────────────────────────────────────── - const connLabel = (c: Connection) => - `${c.provider} / ${c.name || c.email || c.displayName || c.id.slice(0, 12)}`; + const connLabel = (c: Connection) => { + const detail = c.name || c.email || c.displayName || c.id.slice(0, 12); + const maskedDetail = emailsVisible ? detail : maskEmailLikeValue(detail); + return `${c.provider} / ${maskedDetail}`; + }; const selectedConn = useMemo( () => connections.find((c) => c.id === primaryConnectionId), diff --git a/tests/unit/quota-email-privacy.test.ts b/tests/unit/quota-email-privacy.test.ts new file mode 100644 index 0000000000..fddebbe416 --- /dev/null +++ b/tests/unit/quota-email-privacy.test.ts @@ -0,0 +1,241 @@ +/** + * tests/unit/quota-email-privacy.test.ts + * + * Source-scan assertions for the email-privacy feature on the Quota Share screen. + * Mirrors the pattern from mask-email.test.ts and providers-page-utils.test.ts. + * + * Checks that every display component that can show an email: + * 1. Imports `useEmailPrivacyStore` from the store + * 2. Imports `maskEmailLikeValue` (or `pickDisplayValue`) from the mask utility + * 3. References `emailsVisible` in its body + * + * Also verifies that QuotaSharePageClient renders { + assert.ok( + pageClientSrc.includes('import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"'), + "Expected EmailPrivacyToggle import in QuotaSharePageClient" + ); +}); + +test("QuotaSharePageClient imports useEmailPrivacyStore", () => { + assert.ok( + pageClientSrc.includes('import useEmailPrivacyStore from "@/store/emailPrivacyStore"'), + "Expected useEmailPrivacyStore import in QuotaSharePageClient" + ); +}); + +test("QuotaSharePageClient imports maskEmailLikeValue", () => { + assert.ok( + pageClientSrc.includes("maskEmailLikeValue"), + "Expected maskEmailLikeValue import/usage in QuotaSharePageClient" + ); +}); + +test("QuotaSharePageClient consumes emailsVisible from store", () => { + assert.ok( + pageClientSrc.includes("emailsVisible"), + "Expected emailsVisible consumption in QuotaSharePageClient" + ); +}); + +test("QuotaSharePageClient renders EmailPrivacyToggle in JSX", () => { + assert.ok( + pageClientSrc.includes(" { + // connLabel must call maskEmailLikeValue and guard with emailsVisible + assert.ok( + pageClientSrc.includes("emailsVisible ? raw : maskEmailLikeValue(raw)") || + pageClientSrc.includes("emailsVisible") && pageClientSrc.includes("maskEmailLikeValue(raw)"), + "Expected connLabel to mask raw value when emailsVisible is false" + ); +}); + +// ── PoolCard ────────────────────────────────────────────────────────────────── + +test("PoolCard imports useEmailPrivacyStore", () => { + assert.ok( + poolCardSrc.includes('import useEmailPrivacyStore from "@/store/emailPrivacyStore"'), + "Expected useEmailPrivacyStore import in PoolCard" + ); +}); + +test("PoolCard imports maskEmailLikeValue", () => { + assert.ok( + poolCardSrc.includes("maskEmailLikeValue"), + "Expected maskEmailLikeValue import/usage in PoolCard" + ); +}); + +test("PoolCard consumes emailsVisible from store", () => { + assert.ok( + poolCardSrc.includes("emailsVisible"), + "Expected emailsVisible usage in PoolCard" + ); +}); + +test("PoolCard uses displayName instead of raw pool.name in header", () => { + // The masked variable must be rendered, not the raw pool.name directly + assert.ok( + poolCardSrc.includes("displayName"), + "Expected displayName masking variable in PoolCard" + ); + assert.ok( + poolCardSrc.includes("displayConnectionLabel"), + "Expected displayConnectionLabel masking variable in PoolCard" + ); +}); + +test("PoolCard header renders displayName and displayConnectionLabel (not raw values)", () => { + // The raw `{pool.name} · {connectionLabel}` must NOT appear unmasked + assert.ok( + !poolCardSrc.includes("{pool.name} · {connectionLabel}"), + "PoolCard must not render raw {pool.name} · {connectionLabel} — use masked variables" + ); +}); + +// ── AccountQuotaRow ─────────────────────────────────────────────────────────── + +test("AccountQuotaRow imports useEmailPrivacyStore", () => { + assert.ok( + accountQuotaRowSrc.includes('import useEmailPrivacyStore from "@/store/emailPrivacyStore"'), + "Expected useEmailPrivacyStore import in AccountQuotaRow" + ); +}); + +test("AccountQuotaRow imports maskEmailLikeValue", () => { + assert.ok( + accountQuotaRowSrc.includes("maskEmailLikeValue"), + "Expected maskEmailLikeValue import/usage in AccountQuotaRow" + ); +}); + +test("AccountQuotaRow consumes emailsVisible from store", () => { + assert.ok( + accountQuotaRowSrc.includes("emailsVisible"), + "Expected emailsVisible usage in AccountQuotaRow" + ); +}); + +// ── PoolWizard ──────────────────────────────────────────────────────────────── + +test("PoolWizard imports useEmailPrivacyStore", () => { + assert.ok( + poolWizardSrc.includes('import useEmailPrivacyStore from "@/store/emailPrivacyStore"'), + "Expected useEmailPrivacyStore import in PoolWizard" + ); +}); + +test("PoolWizard imports maskEmailLikeValue", () => { + assert.ok( + poolWizardSrc.includes("maskEmailLikeValue"), + "Expected maskEmailLikeValue import/usage in PoolWizard" + ); +}); + +test("PoolWizard consumes emailsVisible from store", () => { + assert.ok( + poolWizardSrc.includes("emailsVisible"), + "Expected emailsVisible usage in PoolWizard" + ); +}); + +test("PoolWizard connLabel masks detail with emailsVisible gate", () => { + assert.ok( + poolWizardSrc.includes("maskedDetail"), + "Expected maskedDetail variable in PoolWizard connLabel" + ); +}); + +// ── EditAllocationsModal ────────────────────────────────────────────────────── + +test("EditAllocationsModal imports useEmailPrivacyStore", () => { + assert.ok( + editAllocationsModalSrc.includes('import useEmailPrivacyStore from "@/store/emailPrivacyStore"'), + "Expected useEmailPrivacyStore import in EditAllocationsModal" + ); +}); + +test("EditAllocationsModal imports maskEmailLikeValue", () => { + assert.ok( + editAllocationsModalSrc.includes("maskEmailLikeValue"), + "Expected maskEmailLikeValue import/usage in EditAllocationsModal" + ); +}); + +test("EditAllocationsModal consumes emailsVisible from store", () => { + assert.ok( + editAllocationsModalSrc.includes("emailsVisible"), + "Expected emailsVisible usage in EditAllocationsModal" + ); +}); + +test("EditAllocationsModal masks pool.name display", () => { + // pool.name can contain an email (e.g. "codex / gael.martins@domain.com") + assert.ok( + editAllocationsModalSrc.includes("maskEmailLikeValue(pool.name)"), + "Expected maskEmailLikeValue(pool.name) in EditAllocationsModal" + ); +}); + +// ── maskEmailLikeValue helper behaviour (regression) ───────────────────────── + +const { maskEmailLikeValue, pickDisplayValue } = await import( + "../../src/shared/utils/maskEmail.ts" +); + +test("maskEmailLikeValue masks email embedded in a pool name", () => { + const poolName = "codex / gael.martins@example.com"; + // The full string contains @ so the whole thing gets treated as email — however + // maskEmailLikeValue only masks when the *trimmed* value contains @. + // The label is NOT just the email here, it contains a slash prefix. + // Verify: values with @ get masked, plain names stay plain. + assert.ok(maskEmailLikeValue("gael.martins@example.com").includes("***"), "email gets masked"); + assert.equal(maskEmailLikeValue("Work Account"), "Work Account", "plain name is unchanged"); + assert.equal(maskEmailLikeValue(null), "", "null returns empty string"); + assert.equal(maskEmailLikeValue(undefined), "", "undefined returns empty string"); +}); + +test("pickDisplayValue respects emailsVisible toggle for quota labels", () => { + const email = "gael.martins@example.com"; + assert.equal( + pickDisplayValue([email], false, ""), + maskEmailLikeValue(email), + "when hidden: returns masked value" + ); + assert.equal( + pickDisplayValue([email], true, ""), + email, + "when visible: returns raw value" + ); +});