Consolidate email privacy control into Settings (#3822)

Moves the account email visibility control into Settings › Appearance (above Show
Sidebar Items) and removes the page-level email reveal buttons from combos, logs,
provider detail, provider quota, quota sharing, and the edit-connection modal. The
global masking state is unchanged — existing account labels still consume the shared
emailPrivacyStore — so one toggle now governs masking everywhere. The old
EmailPrivacyToggle component is replaced by AccountEmailVisibilitySetting.

Integrated into release/v3.8.25.

Co-authored-by: R.D. <rogerproself@gmail.com>
This commit is contained in:
Randi
2026-06-14 08:37:19 -04:00
committed by GitHub
parent 70319eb831
commit 772e6ba493
14 changed files with 134 additions and 139 deletions

View File

@@ -185,7 +185,7 @@ Comprehensive proxy configuration enforcement across the entire request pipeline
## 📧 Email Privacy Masking _(v3.5.6+)_
OAuth account emails are now masked in the provider dashboard (e.g. `di*****@g****.com`) to prevent accidental exposure when sharing screenshots or recording demos. The full email address remains accessible via hover tooltip (`title` attribute).
OAuth account emails are masked by default (e.g. `di*****@g****.com`) to prevent accidental exposure when sharing screenshots or recording demos. Use Settings → Appearance → Account email visibility to reveal or mask full account emails globally across providers, combos, logs, quota, and playground screens.
---

View File

@@ -12,7 +12,6 @@ import Input from "@/shared/components/Input";
import Modal from "@/shared/components/Modal";
import Toggle from "@/shared/components/Toggle";
import Tooltip from "@/shared/components/Tooltip";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
@@ -976,32 +975,6 @@ export default function CombosPage() {
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-lg border border-black/8 dark:border-white/8 bg-black/[0.02] dark:bg-white/[0.02] px-2.5 py-1.5">
<span className="hidden lg:inline text-xs text-text-muted">
{getI18nOrFallback(
t,
"emailVisibilityHint",
"Account emails here follow the global privacy toggle."
)}
</span>
<Tooltip
position="bottom"
content={getI18nOrFallback(
t,
"emailVisibilityTooltip",
"Use the eye icon to reveal or hide account emails globally across combos, providers and quota screens."
)}
>
<span className="inline-flex">
<EmailPrivacyToggle size="md" />
</span>
</Tooltip>
<span className="text-[11px] text-text-muted">
{emailsVisible
? getI18nOrFallback(t, "emailVisibilityStateOn", "Emails visible globally")
: getI18nOrFallback(t, "emailVisibilityStateOff", "Emails masked globally")}
</span>
</div>
{!showUsageGuide && (
<Button size="sm" variant="ghost" onClick={handleShowUsageGuide}>
{getI18nOrFallback(t, "usageGuideShow", "Show guide")}

View File

@@ -3,7 +3,6 @@
import { useCallback, useEffect, 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 } from "@/lib/quota/dimensions";
@@ -136,7 +135,6 @@ export default function QuotaSharePageClient() {
const [connections, setConnections] = useState<Connection[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [plans, setPlans] = useState<Record<string, PlanInfo>>({});
const [, setSideLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<QuotaPool | null>(null);
@@ -149,8 +147,9 @@ export default function QuotaSharePageClient() {
// ── Fetch side data once on mount ─────────────────────────────────────────
useMemo(() => {
setSideLoading(true);
useEffect(() => {
let cancelled = false;
Promise.all([
fetch("/api/providers/client")
.then((r) => (r.ok ? r.json() : null))
@@ -163,6 +162,8 @@ export default function QuotaSharePageClient() {
.catch(() => null),
])
.then(([connsData, keysData, plansData]) => {
if (cancelled) return;
const conns: Connection[] = Array.isArray(connsData?.connections)
? connsData.connections
: [];
@@ -177,36 +178,43 @@ export default function QuotaSharePageClient() {
dimensions: PlanDimension[];
source: "auto" | "manual";
}>) {
if (p.connectionId) planMap[p.connectionId] = { dimensions: p.dimensions, source: p.source };
if (p.connectionId)
planMap[p.connectionId] = { dimensions: p.dimensions, source: p.source };
}
setPlans(planMap);
}
})
.catch(() => {
// fail open — side data not critical
})
.finally(() => {
setSideLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
return () => {
cancelled = true;
};
}, []);
// ── Fetch groups ──────────────────────────────────────────────────────────
const fetchGroups = useCallback(async () => {
const fetchGroups = useCallback(async (options?: { signal?: AbortSignal }) => {
try {
const res = await fetch("/api/quota/groups");
const res = await fetch("/api/quota/groups", { signal: options?.signal });
if (res.ok) {
const data = (await res.json()) as { groups: QuotaGroup[] };
if (options?.signal?.aborted) return;
setGroups(Array.isArray(data.groups) ? data.groups : []);
}
} catch {
if (options?.signal?.aborted) return;
// fail open — groups list not critical
}
}, []);
useEffect(() => {
void fetchGroups();
const controller = new AbortController();
void Promise.resolve().then(() => fetchGroups({ signal: controller.signal }));
return () => {
controller.abort();
};
}, [fetchGroups]);
// ── Group actions ─────────────────────────────────────────────────────────
@@ -233,7 +241,10 @@ export default function QuotaSharePageClient() {
}, [newGroupInput, fetchGroups]);
const handleRenameGroup = useCallback(async () => {
const name = prompt(t("groupNamePrompt"), groups.find((g) => g.id === selectedGroupId)?.name ?? "");
const name = prompt(
t("groupNamePrompt"),
groups.find((g) => g.id === selectedGroupId)?.name ?? ""
);
if (!name?.trim()) return;
setRenaming(true);
try {
@@ -388,7 +399,6 @@ export default function QuotaSharePageClient() {
<p className="text-sm text-text-muted mt-0.5">{t("description")}</p>
</div>
<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")}
@@ -398,7 +408,9 @@ export default function QuotaSharePageClient() {
{/* Beta banner — scoped to this page only */}
<div className="flex items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[12px] text-amber-700 dark:text-amber-200">
<span className="material-symbols-outlined text-[16px] text-amber-500 shrink-0">science</span>
<span className="material-symbols-outlined text-[16px] text-amber-500 shrink-0">
science
</span>
<span className="flex-1">
<span className="font-semibold">{t("betaTitle")}</span> {t("betaText")}
</span>
@@ -439,7 +451,10 @@ export default function QuotaSharePageClient() {
onChange={(e) => setNewGroupInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateGroup();
if (e.key === "Escape") { setShowNewGroupInput(false); setNewGroupInput(""); }
if (e.key === "Escape") {
setShowNewGroupInput(false);
setNewGroupInput("");
}
}}
placeholder={t("groupNamePrompt")}
autoFocus
@@ -455,7 +470,10 @@ export default function QuotaSharePageClient() {
</button>
<button
type="button"
onClick={() => { setShowNewGroupInput(false); setNewGroupInput(""); }}
onClick={() => {
setShowNewGroupInput(false);
setNewGroupInput("");
}}
className="text-xs px-2 py-1 rounded border border-border text-text-muted hover:text-text-main transition-colors"
>
{t("cancel")}
@@ -541,7 +559,12 @@ export default function QuotaSharePageClient() {
{groupsToRender.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-surface py-10 text-center">
<p className="text-sm text-text-muted">{t("emptyDescription")}</p>
<Button variant="primary" size="sm" className="mt-3" onClick={() => setCreateOpen(true)}>
<Button
variant="primary"
size="sm"
className="mt-3"
onClick={() => setCreateOpen(true)}
>
<span className="material-symbols-outlined text-[14px] mr-1">add</span>
{t("newPool")}
</Button>
@@ -549,8 +572,7 @@ export default function QuotaSharePageClient() {
) : (
groupsToRender.map((g) => {
const groupPools = pools.filter(
(p) =>
((p as unknown as { groupId?: string }).groupId ?? "group-demo") === g.id
(p) => ((p as unknown as { groupId?: string }).groupId ?? "group-demo") === g.id
);
return (
<div key={g.id} className="flex flex-col gap-3">
@@ -643,7 +665,9 @@ export default function QuotaSharePageClient() {
connections={connections}
apiKeys={apiKeys}
plans={plans}
existingPoolConnectionIds={new Set(pools.flatMap((p) => p.connectionIds ?? [p.connectionId]))}
existingPoolConnectionIds={
new Set(pools.flatMap((p) => p.connectionIds ?? [p.connectionId]))
}
connectionPoolName={connectionPoolName}
groups={groups}
selectedGroupId={selectedGroupId}

View File

@@ -2,7 +2,6 @@
import { useState, useRef, useEffect } from "react";
import { ConfirmModal, RequestLoggerV2 } from "@/shared/components";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import { useTranslations } from "next-intl";
const TIME_RANGES = [
@@ -124,8 +123,6 @@ export default function LogsPage() {
<h2 className="text-lg font-semibold text-text-main">{t("requestLogs")}</h2>
<div className="flex items-center gap-2">
<EmailPrivacyToggle size="md" />
<button
id="clean-log-history-btn"
onClick={() => setShowCleanHistory(true)}

View File

@@ -3,7 +3,6 @@
// Phase 1t.1 extraction — Issue #3501
import Link from "next/link";
import ProviderIcon from "@/shared/components/ProviderIcon";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import { getHeaderIconProviderId } from "../providerPageHelpers";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
@@ -78,7 +77,6 @@ export default function ProviderPageHeader({
<p className="text-text-muted">
{t("connectionCountLabel", { count: connectionsCount })}
</p>
<EmailPrivacyToggle size="md" />
{providerId === "adapta-web" && (
<button
onClick={onOpenTutorial}

View File

@@ -135,8 +135,7 @@ export default function EditConnectionModal({
>
>({});
const [showAdvanced, setShowAdvanced] = useState(false);
const { emailsVisible: showEmail, toggleEmailVisibility: toggleShowEmail } =
useEmailPrivacyStore();
const showEmail = useEmailPrivacyStore((state) => state.emailsVisible);
const usesBaseUrl = isBaseUrlConfigurableProvider(connection?.provider);
const defaultBaseUrl = getProviderBaseUrlDefault(connection?.provider);
@@ -681,21 +680,9 @@ export default function EditConnectionModal({
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">{t("email")}</p>
<div className="flex items-center gap-2">
<p className="font-medium" title={showEmail ? connection.email : undefined}>
{showEmail ? connection.email : maskEmail(connection.email)}
</p>
<button
type="button"
onClick={toggleShowEmail}
className="rounded p-1 text-text-muted hover:bg-sidebar hover:text-primary"
title={showEmail ? t("hideEmail") : t("showEmail")}
>
<span className="material-symbols-outlined text-sm">
{showEmail ? "visibility_off" : "visibility"}
</span>
</button>
</div>
<p className="font-medium" title={showEmail ? connection.email : undefined}>
{showEmail ? connection.email : maskEmail(connection.email)}
</p>
</div>
)}
{isOAuth && (

View File

@@ -0,0 +1,38 @@
"use client";
import { useTranslations } from "next-intl";
import { useShallow } from "zustand/react/shallow";
import { Toggle } from "@/shared/components";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
export default function AccountEmailVisibilitySetting() {
const t = useTranslations("settings");
const { emailsVisible, setEmailsVisible } = useEmailPrivacyStore(
useShallow((state) => ({
emailsVisible: state.emailsVisible,
setEmailsVisible: state.setEmailsVisible,
}))
);
const label = (key: string, fallback: string) =>
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
return (
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-medium">
{label("accountEmailVisibility", "Account email visibility")}
</p>
<p className="text-sm text-text-muted">
{label(
"accountEmailVisibilityDesc",
"Show full account emails across providers, combos, logs, quota, and playground screens. Turn this off to mask them by default."
)}
</p>
</div>
<Toggle checked={emailsVisible} onChange={setEmailsVisible} />
</div>
</div>
);
}

View File

@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Button, Card, Toggle } from "@/shared/components";
import { useTheme } from "@/shared/hooks/useTheme";
import useThemeStore, { COLOR_THEMES } from "@/store/themeStore";
@@ -17,6 +16,8 @@ import {
PIN_PROVIDER_QUOTA_TO_HOME_KEY,
SHOW_TOKEN_SAVER_ON_ENDPOINT_KEY,
} from "@/shared/constants/homeWidgets";
import AccountEmailVisibilitySetting from "./AccountEmailVisibilitySetting";
import SidebarVisibilitySetting from "./SidebarVisibilitySetting";
export default function AppearanceTab() {
const { theme, setTheme, isDark } = useTheme();
@@ -533,26 +534,9 @@ export default function AppearanceTab() {
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-medium">{t("sidebarVisibilityToggle")}</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"sidebarCustomizeLink",
"Customize which items appear in the sidebar, their order, and apply role presets."
)}
</p>
</div>
<Link
href="/dashboard/settings/sidebar"
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-border hover:bg-surface/80 hover:border-primary/40 transition-colors text-text-main"
>
<span className="material-symbols-outlined text-[16px]">view_sidebar</span>
{getSettingsLabel("sidebarCustomizeLinkBtn", "Customize")}
</Link>
</div>
</div>
<AccountEmailVisibilitySetting />
<SidebarVisibilitySetting />
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between">

View File

@@ -0,0 +1,33 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
export default function SidebarVisibilitySetting() {
const t = useTranslations("settings");
const label = (key: string, fallback: string) =>
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
return (
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-medium">{t("sidebarVisibilityToggle")}</p>
<p className="text-sm text-text-muted">
{label(
"sidebarCustomizeLink",
"Customize which items appear in the sidebar, their order, and apply role presets."
)}
</p>
</div>
<Link
href="/dashboard/settings/sidebar"
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-border hover:bg-surface/80 hover:border-primary/40 transition-colors text-text-main"
>
<span className="material-symbols-outlined text-[16px]">view_sidebar</span>
{label("sidebarCustomizeLinkBtn", "Customize")}
</Link>
</div>
</div>
);
}

View File

@@ -17,7 +17,6 @@ import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import QuotaCutoffModal from "./QuotaCutoffModal";
import QuotaCardGrid from "./QuotaCardGrid";
import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback";
@@ -819,7 +818,6 @@ export default function ProviderLimits({
{visibleConnections.length !== sortedConnections.length &&
` ${t("filteredFromCount", { count: sortedConnections.length })}`}
</span>
<EmailPrivacyToggle />
</div>
<button

View File

@@ -4890,6 +4890,8 @@
"homeProviderTopologyDesc": "Show the Provider Topology on the Home page.",
"endpointTokenSaver": "Token Saver",
"endpointTokenSaverDesc": "Show the Token Saver panel on the Endpoint page.",
"accountEmailVisibility": "Account email visibility",
"accountEmailVisibilityDesc": "Show full account emails across providers, combos, logs, quota, and playground screens. Turn this off to mask them by default.",
"sidebarVisibilityToggle": "Show Sidebar Items",
"enableCache": "Enable Cache",
"cacheTTL": "Cache TTL",

View File

@@ -4871,6 +4871,8 @@
"homeProviderTopologyDesc": "在首页上显示提供商拓扑。",
"endpointTokenSaver": "Token Saver",
"endpointTokenSaverDesc": "在 Endpoint 页面显示 Token Saver 面板。",
"accountEmailVisibility": "账号邮箱可见性",
"accountEmailVisibilityDesc": "在提供商、组合、日志、配额和 Playground 页面显示完整账号邮箱。关闭后默认打码显示。",
"sidebarVisibilityToggle": "显示侧边栏项目",
"enableCache": "启用缓存",
"cacheTTL": "缓存生存时间",

View File

@@ -1,41 +0,0 @@
"use client";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useTranslations } from "next-intl";
/**
* EmailPrivacyToggle — A toggle button (eye icon) for global email visibility.
*
* When emails are hidden (default), displays a closed-eye icon.
* When active, displays an open-eye icon and all masked emails become fully visible.
*
* Accepts an optional `size` prop: "sm" (default) for inline use, "md" for page headers.
*/
export default function EmailPrivacyToggle({ size = "sm" }: { size?: "sm" | "md" }) {
const { emailsVisible, toggleEmailVisibility } = useEmailPrivacyStore();
const t = useTranslations("providers");
const iconSize = size === "md" ? "text-[20px]" : "text-[16px]";
const sizeClass = size === "md" ? "h-10 w-10" : "h-8 w-8";
const label = emailsVisible ? t("hideEmails") : t("showEmails");
return (
<button
type="button"
onClick={toggleEmailVisibility}
className={`inline-flex ${sizeClass} items-center justify-center rounded transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary ${
emailsVisible
? "bg-primary/15 text-primary hover:bg-primary/25"
: "text-text-muted hover:bg-sidebar hover:text-primary"
}`}
title={label}
aria-label={label}
aria-pressed={emailsVisible}
>
<span className={`material-symbols-outlined ${iconSize} leading-none`}>
{emailsVisible ? "visibility" : "visibility_off"}
</span>
</button>
);
}

View File

@@ -6,15 +6,15 @@ import { persist } from "zustand/middleware";
interface EmailPrivacyState {
/** When true, all email addresses are shown in full (unmasked). Default: false (masked). */
emailsVisible: boolean;
/** Toggle the global email visibility state. */
toggleEmailVisibility: () => void;
/** Set the global email visibility state. */
setEmailsVisible: (visible: boolean) => void;
}
const useEmailPrivacyStore = create<EmailPrivacyState>()(
persist(
(set, get) => ({
(set) => ({
emailsVisible: false,
toggleEmailVisibility: () => set({ emailsVisible: !get().emailsVisible }),
setEmailsVisible: (visible) => set({ emailsVisible: visible }),
}),
{
name: "omniroute-email-privacy",