mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4871,6 +4871,8 @@
|
||||
"homeProviderTopologyDesc": "在首页上显示提供商拓扑。",
|
||||
"endpointTokenSaver": "Token Saver",
|
||||
"endpointTokenSaverDesc": "在 Endpoint 页面显示 Token Saver 面板。",
|
||||
"accountEmailVisibility": "账号邮箱可见性",
|
||||
"accountEmailVisibilityDesc": "在提供商、组合、日志、配额和 Playground 页面显示完整账号邮箱。关闭后默认打码显示。",
|
||||
"sidebarVisibilityToggle": "显示侧边栏项目",
|
||||
"enableCache": "启用缓存",
|
||||
"cacheTTL": "缓存生存时间",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user