mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
feat(quota): mask emails across the quota-share screen (EmailPrivacyToggle)
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
|
||||
241
tests/unit/quota-email-privacy.test.ts
Normal file
241
tests/unit/quota-email-privacy.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user