feat(quota): mask emails across the quota-share screen (EmailPrivacyToggle)

This commit is contained in:
diegosouzapw
2026-05-31 13:17:28 -03:00
parent 99f615c7e0
commit 5208cd5936
6 changed files with 279 additions and 12 deletions

View File

@@ -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() {
</h1>
<p className="text-sm text-text-muted mt-0.5">{t("description")}</p>
</div>
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">add</span>
{t("newPool")}
</Button>
<div className="flex items-center gap-2">
<EmailPrivacyToggle />
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">add</span>
{t("newPool")}
</Button>
</div>
</div>
{/* Concept card */}

View File

@@ -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<Record<string, unknown> | null>(null);
@@ -162,8 +165,8 @@ export default function AccountQuotaRow({
<span className="shrink-0">
<ProviderIcon providerId={prov} size={14} />
</span>
<span className="text-text-muted truncate max-w-[90px]" title={connId}>
{connId.slice(0, 8)}
<span className="text-text-muted truncate max-w-[90px]" title={emailsVisible ? connId : maskEmailLikeValue(connId)}>
{emailsVisible ? `${connId.slice(0, 8)}` : maskEmailLikeValue(connId)}
</span>
{summary ? (
<>

View File

@@ -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<PoolAllocation[]>(pool.allocations);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -100,7 +103,7 @@ export default function EditAllocationsModal({
<Modal isOpen onClose={onClose} title={t("editTitle")} size="lg">
<div className="space-y-3">
<div className="text-xs text-text-muted">
{t("pool")}: <strong className="text-text-main">{pool.name}</strong>
{t("pool")}: <strong className="text-text-main">{emailsVisible ? pool.name : maskEmailLikeValue(pool.name)}</strong>
</div>
{drafts.length === 0 ? (

View File

@@ -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}
</span>
<span className="text-sm font-bold text-text-main truncate">
{pool.name} · {connectionLabel}
{displayName} · {displayConnectionLabel}
</span>
</div>
<div className="text-[11px] text-text-muted">

View File

@@ -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),

View File

@@ -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 <EmailPrivacyToggle and imports the store.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "../../");
function readSrc(relPath: string): string {
return fs.readFileSync(path.join(root, relPath), "utf8");
}
const quotaShareDir = "src/app/(dashboard)/dashboard/costs/quota-share";
// ── File contents loaded once ─────────────────────────────────────────────────
const pageClientSrc = readSrc(`${quotaShareDir}/QuotaSharePageClient.tsx`);
const poolCardSrc = readSrc(`${quotaShareDir}/components/PoolCard.tsx`);
const accountQuotaRowSrc = readSrc(`${quotaShareDir}/components/AccountQuotaRow.tsx`);
const poolWizardSrc = readSrc(`${quotaShareDir}/components/PoolWizard.tsx`);
const editAllocationsModalSrc = readSrc(`${quotaShareDir}/components/EditAllocationsModal.tsx`);
// ── QuotaSharePageClient ──────────────────────────────────────────────────────
test("QuotaSharePageClient imports EmailPrivacyToggle", () => {
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("<EmailPrivacyToggle"),
"Expected <EmailPrivacyToggle in QuotaSharePageClient JSX"
);
});
test("QuotaSharePageClient masks connLabel output with emailsVisible gate", () => {
// 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"
);
});