Skip to content
Merged
10 changes: 9 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Providers from "@/providers/Providers";
import AppHeader from "@/components/Header/AppHeader";
import "./globals.css";

const geistSans = Geist({
Expand Down Expand Up @@ -32,7 +33,14 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Providers>{children}</Providers>
<Providers>
<div className="flex min-h-screen flex-col">
<AppHeader />
<main className="flex flex-1 flex-col">
{children}
</main>
</div>
</Providers>
</body>
</html>
);
Expand Down
File renamed without changes.
14 changes: 14 additions & 0 deletions components/Header/AppHeader.tsx
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not store components in the root of components. Store them in component sub-directories.

  • actual: components/AppHeader.tsx
  • required: components/Header/AppHeader.tsx
    If there's any other components outside of a components/[subDirectory] file please fix those too.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import LoginButton from "@/components/Login/LoginButton";
import { HideToggle } from "@/components/Header/HideToggle";

export default function AppHeader() {
return (
<header className="flex items-center justify-between border-b px-6 py-4">
<h1 className="text-lg font-semibold">Recoup Admin</h1>
<div className="flex items-center gap-3">
<LoginButton />
<HideToggle />
</div>
</header>
);
}
57 changes: 57 additions & 0 deletions components/Header/HideToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import { useState, useCallback, useRef } from "react";
import { Eye, EyeOff } from "lucide-react";
import { useHide } from "@/providers/HideProvider";

const BLINK_MS = 150;

export function HideToggle() {
const { isHidden, toggle } = useHide();
const [phase, setPhase] = useState<"idle" | "closing" | "opening">("idle");
const iconRef = useRef<SVGSVGElement>(null);

const handleClick = useCallback(() => {
if (phase !== "idle") return;

// Phase 1: close the eye
setPhase("closing");

setTimeout(() => {
// Phase 2: swap icon while still closed, then open
toggle();
setPhase("opening");

// Wait for next frame so the browser registers scaleY(0) before animating to scaleY(1)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setPhase("idle");
});
});
}, BLINK_MS);
}, [toggle, phase]);

const Icon = isHidden ? EyeOff : Eye;

const scaleY = phase === "closing" || phase === "opening" ? 0 : 1;

return (
<button
type="button"
onClick={handleClick}
aria-label={isHidden ? "Show sensitive info" : "Hide sensitive info"}
aria-pressed={isHidden}
title={isHidden ? "Show sensitive info" : "Hide sensitive info"}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Icon
ref={iconRef}
className="h-4 w-4"
style={{
transform: `scaleY(${scaleY})`,
transition: `transform ${BLINK_MS}ms ease-in-out`,
}}
/>
</button>
);
}
2 changes: 1 addition & 1 deletion components/Home/AdminDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import NavButton from "@/components/Home/NavButton";
import ApiDocsLink from "@/components/ApiDocsLink";
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";

export default function AdminDashboard() {
return (
Expand Down
11 changes: 2 additions & 9 deletions components/Home/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import LoginButton from "@/components/Login/LoginButton";
import HomeContent from "@/components/Home/HomeContent";

export default function HomePage() {
return (
<div className="flex min-h-screen flex-col">
<header className="flex items-center justify-between border-b px-6 py-4">
<h1 className="text-lg font-semibold">Recoup Admin</h1>
<LoginButton />
</header>
<main className="flex flex-1 items-center justify-center">
<HomeContent />
</main>
<div className="flex flex-1 items-center justify-center">
<HomeContent />
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After you moved the header, this content was moved up to the top of the page.

  • required: HomeContent is centered vertically on the page.

</div>
);
}
4 changes: 3 additions & 1 deletion components/Login/LoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import { usePrivy } from "@privy-io/react-auth";
import { Button } from "@/components/ui/button";
import { useDisplayEmail } from "@/lib/hide/useDisplayEmail";
import LoginButtonSkeleton from "./LoginButtonSkeleton";

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

if (!ready) {
return <LoginButtonSkeleton />;
Expand All @@ -15,7 +17,7 @@ export default function LoginButton() {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{user?.email?.address ?? "Signed in"}
{displayEmail ?? "Signed in"}
</span>
<Button variant="ghost" onClick={logout}>
Sign Out
Expand Down
13 changes: 13 additions & 0 deletions components/PrivyLogins/EmailCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use client";

import { useDisplayEmail } from "@/lib/hide/useDisplayEmail";

interface EmailCellProps {
getValue: () => string | null;
}

export default function EmailCell({ getValue }: EmailCellProps) {
const displayEmail = useDisplayEmail(getValue());
if (!displayEmail) return <span className="text-gray-400 italic">No email</span>;
return <span>{displayEmail}</span>;
}
9 changes: 9 additions & 0 deletions components/PrivyLogins/LastSeenCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
interface LastSeenCellProps {
getValue: () => number | null;
}

export default function LastSeenCell({ getValue }: LastSeenCellProps) {
const ts = getValue();
if (ts == null) return <span className="text-gray-400 italic">Never</span>;
return <span>{new Date(ts * 1000).toLocaleString()}</span>;
}
2 changes: 1 addition & 1 deletion components/PrivyLogins/PrivyLoginsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState } from "react";
import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";
import ApiDocsLink from "@/components/ApiDocsLink";
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
import { usePrivyLogins } from "@/hooks/usePrivyLogins";
import PrivyLoginsTable from "@/components/PrivyLogins/PrivyLoginsTable";
import PrivyPeriodSelector from "@/components/PrivyLogins/PrivyPeriodSelector";
Expand Down
16 changes: 8 additions & 8 deletions components/PrivyLogins/privyLoginsColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@ import { type ColumnDef } from "@tanstack/react-table";
import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader";
import { getEmail } from "@/lib/privy/getEmail";
import { getLastSeen } from "@/lib/privy/getLastSeen";
import EmailCell from "@/components/PrivyLogins/EmailCell";
import LastSeenCell from "@/components/PrivyLogins/LastSeenCell";
import type { PrivyUser } from "@/types/privy";

export const privyLoginsColumns: ColumnDef<PrivyUser>[] = [
{
id: "email",
accessorFn: (row) => getEmail(row),
header: "Email",
cell: ({ getValue }) => {
const email = getValue<string | null>();
return email ?? <span className="text-gray-400 italic">No email</span>;
},
cell: ({ getValue }) => (
<EmailCell getValue={() => getValue<string | null>()} />
),
},
{
id: "created_at",
Expand All @@ -25,10 +26,9 @@ export const privyLoginsColumns: ColumnDef<PrivyUser>[] = [
id: "last_seen",
accessorFn: (row) => getLastSeen(row),
header: ({ column }) => <SortableHeader column={column} label="Last Seen" />,
cell: ({ getValue }) => {
const ts = getValue<number | null>();
return ts ? new Date(ts * 1000).toLocaleString() : <span className="text-gray-400 italic">Never</span>;
},
cell: ({ getValue }) => (
<LastSeenCell getValue={() => getValue<number | null>()} />
),
sortingFn: "basic",
},
];
21 changes: 21 additions & 0 deletions components/SandboxOrgs/AccountRepoLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import Link from "next/link";
import { useDisplayEmail } from "@/lib/hide/useDisplayEmail";
import type { AccountRepo } from "@/types/sandbox";

export default function AccountRepoLink({ account_id, email }: AccountRepo) {
const displayEmail = useDisplayEmail(email ?? null);
const displayLabel = displayEmail ?? account_id;
return (
<li>
<Link
href={`/accounts/${account_id}`}
className="text-[#345A5D] hover:underline font-medium truncate block max-w-xs"
title={`View task runs for ${displayLabel}`}
>
{displayLabel}
</Link>
</li>
);
}
21 changes: 6 additions & 15 deletions components/SandboxOrgs/AccountReposList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Link from "next/link";
"use client";

import AccountRepoLink from "@/components/SandboxOrgs/AccountRepoLink";
import type { AccountRepo } from "@/types/sandbox";

interface AccountReposListProps {
Expand All @@ -11,20 +13,9 @@ export function AccountReposList({ repos }: AccountReposListProps) {
}
return (
<ul className="space-y-1 text-sm">
{repos.map(({ account_id, email }) => {
const displayLabel = email ?? account_id;
return (
<li key={account_id}>
<Link
href={`/accounts/${account_id}`}
className="text-[#345A5D] hover:underline font-medium truncate block max-w-xs"
title={`View task runs for ${displayLabel}`}
>
{displayLabel}
</Link>
</li>
);
})}
{repos.map((repo) => (
<AccountRepoLink key={repo.account_id} {...repo} />
))}
</ul>
);
}
2 changes: 1 addition & 1 deletion components/SandboxOrgs/SandboxOrgsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import SandboxOrgsTableContainer from "@/components/SandboxOrgs/SandboxOrgsTableContainer";
import ApiDocsLink from "@/components/ApiDocsLink";
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";

export default function SandboxOrgsPage() {
Expand Down
24 changes: 24 additions & 0 deletions components/Sandboxes/AccountEmailCell.tsx
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can we DRY the email display text changes in both this file and components/PrivyLogins/EmailCell.tsx?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also components/SandboxOrgs/AccountReposList.tsx

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import Link from "next/link";
import { useDisplayEmail } from "@/lib/hide/useDisplayEmail";

interface AccountEmailCellProps {
email: string | null;
accountId: string;
}

export default function AccountEmailCell({ email, accountId }: AccountEmailCellProps) {
const displayEmail = useDisplayEmail(email);
return (
<Link
href={`/accounts/${accountId}`}
className="text-[#345A5D] hover:underline font-medium"
title={`View task runs for ${displayEmail ?? accountId}`}
>
{displayEmail ?? (
<span className="font-mono text-xs text-gray-500">{accountId}</span>
)}
</Link>
);
}
2 changes: 1 addition & 1 deletion components/Sandboxes/SandboxesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import SandboxesTableContainer from "@/components/Sandboxes/SandboxesTableContainer";
import ApiDocsLink from "@/components/ApiDocsLink";
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";

export default function SandboxesPage() {
Expand Down
21 changes: 7 additions & 14 deletions components/Sandboxes/sandboxesColumns.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import AccountEmailCell from "@/components/Sandboxes/AccountEmailCell";
import type { AccountSandboxRow } from "@/types/sandbox";

export const sandboxesColumns: ColumnDef<AccountSandboxRow>[] = [
{
accessorKey: "account_email",
header: "Account Email",
cell: ({ row }) => {
const email = row.getValue<string | null>("account_email");
const accountId = row.original.account_id;
return (
<Link
href={`/accounts/${accountId}`}
className="text-[#345A5D] hover:underline font-medium"
title={`View task runs for ${email ?? accountId}`}
>
{email ?? <span className="font-mono text-xs text-gray-500">{accountId}</span>}
</Link>
);
},
cell: ({ row }) => (
<AccountEmailCell
email={row.getValue<string | null>("account_email")}
accountId={row.original.account_id}
/>
),
},
{
accessorKey: "total_sandboxes",
Expand Down
22 changes: 22 additions & 0 deletions lib/hide/maskEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Masks an email address so sensitive info is not exposed.
* e.g. "john.doe@example.com" → "jo***@ex***.com"
*/
export function maskEmail(email: string): string {
const atIndex = email.indexOf("@");
if (atIndex === -1) return "***";

const local = email.slice(0, atIndex);
const domain = email.slice(atIndex + 1);

const maskedLocal = local.slice(0, 2).padEnd(2, "*") + "***";

const dotIndex = domain.lastIndexOf(".");
if (dotIndex === -1) return `${maskedLocal}@***`;

const domainName = domain.slice(0, dotIndex);
const tld = domain.slice(dotIndex);
const maskedDomain = domainName.slice(0, 2).padEnd(2, "*") + "***";

return `${maskedLocal}@${maskedDomain}${tld}`;
}
13 changes: 13 additions & 0 deletions lib/hide/useDisplayEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use client";

import { useHide } from "@/providers/HideProvider";
import { maskEmail } from "@/lib/hide/maskEmail";

/**
* Returns the email formatted for display — masked when hide mode is active.
*/
export function useDisplayEmail(email: string | null): string | null {
const { isHidden } = useHide();
if (!email) return null;
return isHidden ? maskEmail(email) : email;
}
Loading
Loading