Skip to content

Commit 986f25c

Browse files
sweetmantechRecoup Agentclaude
authored
agent: @U0AJM7X8FBR Admin Codebase - Hide Sensitive Info Toggle
• actual: email (#17) * feat: add HideProvider with sensitive info toggle Adds a global Eye/EyeOff toggle button (fixed top-right) that masks all email fields across the admin dashboard when activated. - New HideProvider context + useHide hook - New maskEmail utility (e.g. "jo***@ex***.com") - HideToggle component wired into global Providers - privyLoginsColumns, sandboxesColumns, AccountReposList all respect isHidden Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: hide toggle only when authenticated Prevents the toggle from overlapping the sign-in button on the login page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: move hide toggle into header, simplify to icon only - HideToggle is now icon-only (no button background, no label) - Moved from fixed position in Providers to LoginButton header (next to email and sign out) - Extracted EmailCell to its own component file (SRP) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: shared root layout, fix PR review comments - AppHeader with LoginButton + HideToggle in root layout (all pages) - Removed duplicate header from HomePage - Fixed title tooltips to use masked email when hidden - Extracted AccountEmailCell from sandboxesColumns (SRP) - Extracted LastSeenCell from privyLoginsColumns (SRP) - Moved maskEmail to lib/hide/maskEmail.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review comments - HideToggle: added aria-label, aria-pressed, type=button - LoginButton: mask header email when hidden - DRY: created useDisplayEmail hook, used by EmailCell, AccountEmailCell, AccountReposList, LoginButton - LastSeenCell: use == null instead of falsy check for timestamp 0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: move HideToggle from LoginButton to AppHeader (SRP) LoginButton should only handle login/logout. The visibility toggle is a header-level concern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: move components to subdirectories, extract AccountRepoLink - AppHeader → components/Header/AppHeader.tsx - HideToggle → components/Header/HideToggle.tsx - ApiDocsLink → components/ApiDocs/ApiDocsLink.tsx - Extracted AccountRepoLink from AccountReposList (SRP) - Updated all imports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add blink animation to hide toggle On click: eye closes (scaleY 0), icon swaps, eye opens (scaleY 1). 150ms transition for a natural blink effect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: center HomeContent vertically on the page Made <main> a flex column container so child flex-1 items-center can properly fill the remaining height. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: smooth blink open animation Keep scaleY(0) when icon swaps, then animate to scaleY(1) on the next frame so the opening is a smooth transition instead of a flash. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Recoup Agent <agent@recoupable.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 02228e5 commit 986f25c

21 files changed

Lines changed: 247 additions & 54 deletions

app/layout.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from "next";
22
import { Geist, Geist_Mono } from "next/font/google";
33
import Providers from "@/providers/Providers";
4+
import AppHeader from "@/components/Header/AppHeader";
45
import "./globals.css";
56

67
const geistSans = Geist({
@@ -32,7 +33,14 @@ export default function RootLayout({
3233
<body
3334
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
3435
>
35-
<Providers>{children}</Providers>
36+
<Providers>
37+
<div className="flex min-h-screen flex-col">
38+
<AppHeader />
39+
<main className="flex flex-1 flex-col">
40+
{children}
41+
</main>
42+
</div>
43+
</Providers>
3644
</body>
3745
</html>
3846
);

components/Header/AppHeader.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import LoginButton from "@/components/Login/LoginButton";
2+
import { HideToggle } from "@/components/Header/HideToggle";
3+
4+
export default function AppHeader() {
5+
return (
6+
<header className="flex items-center justify-between border-b px-6 py-4">
7+
<h1 className="text-lg font-semibold">Recoup Admin</h1>
8+
<div className="flex items-center gap-3">
9+
<LoginButton />
10+
<HideToggle />
11+
</div>
12+
</header>
13+
);
14+
}

components/Header/HideToggle.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import { useState, useCallback, useRef } from "react";
4+
import { Eye, EyeOff } from "lucide-react";
5+
import { useHide } from "@/providers/HideProvider";
6+
7+
const BLINK_MS = 150;
8+
9+
export function HideToggle() {
10+
const { isHidden, toggle } = useHide();
11+
const [phase, setPhase] = useState<"idle" | "closing" | "opening">("idle");
12+
const iconRef = useRef<SVGSVGElement>(null);
13+
14+
const handleClick = useCallback(() => {
15+
if (phase !== "idle") return;
16+
17+
// Phase 1: close the eye
18+
setPhase("closing");
19+
20+
setTimeout(() => {
21+
// Phase 2: swap icon while still closed, then open
22+
toggle();
23+
setPhase("opening");
24+
25+
// Wait for next frame so the browser registers scaleY(0) before animating to scaleY(1)
26+
requestAnimationFrame(() => {
27+
requestAnimationFrame(() => {
28+
setPhase("idle");
29+
});
30+
});
31+
}, BLINK_MS);
32+
}, [toggle, phase]);
33+
34+
const Icon = isHidden ? EyeOff : Eye;
35+
36+
const scaleY = phase === "closing" || phase === "opening" ? 0 : 1;
37+
38+
return (
39+
<button
40+
type="button"
41+
onClick={handleClick}
42+
aria-label={isHidden ? "Show sensitive info" : "Hide sensitive info"}
43+
aria-pressed={isHidden}
44+
title={isHidden ? "Show sensitive info" : "Hide sensitive info"}
45+
className="text-muted-foreground hover:text-foreground transition-colors"
46+
>
47+
<Icon
48+
ref={iconRef}
49+
className="h-4 w-4"
50+
style={{
51+
transform: `scaleY(${scaleY})`,
52+
transition: `transform ${BLINK_MS}ms ease-in-out`,
53+
}}
54+
/>
55+
</button>
56+
);
57+
}

components/Home/AdminDashboard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import NavButton from "@/components/Home/NavButton";
2-
import ApiDocsLink from "@/components/ApiDocsLink";
2+
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
33

44
export default function AdminDashboard() {
55
return (

components/Home/HomePage.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
import LoginButton from "@/components/Login/LoginButton";
21
import HomeContent from "@/components/Home/HomeContent";
32

43
export default function HomePage() {
54
return (
6-
<div className="flex min-h-screen flex-col">
7-
<header className="flex items-center justify-between border-b px-6 py-4">
8-
<h1 className="text-lg font-semibold">Recoup Admin</h1>
9-
<LoginButton />
10-
</header>
11-
<main className="flex flex-1 items-center justify-center">
12-
<HomeContent />
13-
</main>
5+
<div className="flex flex-1 items-center justify-center">
6+
<HomeContent />
147
</div>
158
);
169
}

components/Login/LoginButton.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import { usePrivy } from "@privy-io/react-auth";
44
import { Button } from "@/components/ui/button";
5+
import { useDisplayEmail } from "@/lib/hide/useDisplayEmail";
56
import LoginButtonSkeleton from "./LoginButtonSkeleton";
67

78
export default function LoginButton() {
89
const { login, logout, authenticated, ready, user } = usePrivy();
10+
const displayEmail = useDisplayEmail(user?.email?.address ?? null);
911

1012
if (!ready) {
1113
return <LoginButtonSkeleton />;
@@ -15,7 +17,7 @@ export default function LoginButton() {
1517
return (
1618
<div className="flex items-center gap-3">
1719
<span className="text-sm text-muted-foreground">
18-
{user?.email?.address ?? "Signed in"}
20+
{displayEmail ?? "Signed in"}
1921
</span>
2022
<Button variant="ghost" onClick={logout}>
2123
Sign Out
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"use client";
2+
3+
import { useDisplayEmail } from "@/lib/hide/useDisplayEmail";
4+
5+
interface EmailCellProps {
6+
getValue: () => string | null;
7+
}
8+
9+
export default function EmailCell({ getValue }: EmailCellProps) {
10+
const displayEmail = useDisplayEmail(getValue());
11+
if (!displayEmail) return <span className="text-gray-400 italic">No email</span>;
12+
return <span>{displayEmail}</span>;
13+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
interface LastSeenCellProps {
2+
getValue: () => number | null;
3+
}
4+
5+
export default function LastSeenCell({ getValue }: LastSeenCellProps) {
6+
const ts = getValue();
7+
if (ts == null) return <span className="text-gray-400 italic">Never</span>;
8+
return <span>{new Date(ts * 1000).toLocaleString()}</span>;
9+
}

components/PrivyLogins/PrivyLoginsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState } from "react";
44
import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";
5-
import ApiDocsLink from "@/components/ApiDocsLink";
5+
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
66
import { usePrivyLogins } from "@/hooks/usePrivyLogins";
77
import PrivyLoginsTable from "@/components/PrivyLogins/PrivyLoginsTable";
88
import PrivyPeriodSelector from "@/components/PrivyLogins/PrivyPeriodSelector";

0 commit comments

Comments
 (0)