Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/app/admin/(protected)/payroll/[contractId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { requireAdminSession } from "@/features/admin-auth/services/admin-session.guard";
import { PayrollRunDetail } from "@/features/payroll/ui/PayrollRunDetail";
import { AdminTrustlessWorkProvider } from "@/providers/AdminTrustlessWorkProvider";

type AdminPayrollDetailPageProps = {
params: Promise<{ contractId: string }>;
};

export const metadata: Metadata = {
title: "Payroll run",
};

export default async function AdminPayrollDetailPage({
params,
}: AdminPayrollDetailPageProps) {
await requireAdminSession();
const { contractId } = await params;

return (
<AdminTrustlessWorkProvider>
<Suspense fallback={null}>
<PayrollRunDetail contractId={contractId} />
</Suspense>
</AdminTrustlessWorkProvider>
);
}
21 changes: 21 additions & 0 deletions src/app/admin/(protected)/payroll/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { requireAdminSession } from "@/features/admin-auth/services/admin-session.guard";
import { PayrollView } from "@/features/payroll/ui/PayrollView";
import { AdminTrustlessWorkProvider } from "@/providers/AdminTrustlessWorkProvider";

export const metadata: Metadata = {
title: "Payroll",
};

export default async function AdminPayrollPage() {
await requireAdminSession();

return (
<AdminTrustlessWorkProvider>
<Suspense fallback={null}>
<PayrollView />
</Suspense>
</AdminTrustlessWorkProvider>
);
}
77 changes: 77 additions & 0 deletions src/app/api/admin/payroll/core/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { NextRequest } from "next/server";
import { getAdminSession } from "@/features/admin-auth/services/admin-session.guard";
import { createAdminForbiddenResponse } from "@/lib/admin-session-response";
import { proxyCoreResponse, validateSameOrigin } from "@/lib/bff-utils";
import { payrollFetch } from "@/lib/payroll-fetch";

type RouteContext = {
params: Promise<{ path: string[] }>;
};

export async function GET(request: NextRequest, context: RouteContext) {
return handlePayrollCoreProxy(request, context);
}

export async function POST(request: NextRequest, context: RouteContext) {
return handlePayrollCoreProxy(request, context);
}

export async function PUT(request: NextRequest, context: RouteContext) {
return handlePayrollCoreProxy(request, context);
}

export async function PATCH(request: NextRequest, context: RouteContext) {
return handlePayrollCoreProxy(request, context);
}

export async function DELETE(request: NextRequest, context: RouteContext) {
return handlePayrollCoreProxy(request, context);
}

async function handlePayrollCoreProxy(
request: NextRequest,
context: RouteContext,
) {
const csrfError = validateSameOrigin(request);
if (csrfError) {
return csrfError;
}

const session = await getAdminSession();
if (session.status !== "ok") {
return createAdminForbiddenResponse(session.status);
}

const { path } = await context.params;
const corePath = `/${path.join("/")}`;
const search = request.nextUrl.search;

const headers: Record<string, string> = {
"Content-Type": request.headers.get("content-type") ?? "application/json",
};

const platformId = request.headers.get("x-tw-platform");
if (platformId) {
headers["X-TW-Platform"] = platformId;
}

const subjectId = request.headers.get("x-tw-subject");
if (subjectId) {
headers["X-TW-Subject"] = subjectId;
}

const init: RequestInit = {
method: request.method,
headers,
};

if (request.method !== "GET" && request.method !== "HEAD") {
const body = await request.text();
if (body) {
init.body = body;
}
}

const response = await payrollFetch(`${corePath}${search}`, init);
return proxyCoreResponse(response);
}
9 changes: 7 additions & 2 deletions src/components/shared/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ function formatBreadcrumbLabel(
): string {
const decoded = decodeURIComponent(segment);

if (parentSegment === "escrows" && decoded.length > 6) {
if (
(parentSegment === "escrows" || parentSegment === "payroll") &&
decoded.length > 6
) {
return truncateStellarAddress(decoded, 3, 3);
}

Expand All @@ -48,7 +51,9 @@ function getBreadcrumbItems(pathname: string): BreadcrumbItemData[] {
href: `/${segments.slice(0, index + 1).join("/")}`,
label: formatBreadcrumbLabel(segment, segments[index - 1]),
title:
segments[index - 1] === "escrows" && segment.length > 6
(segments[index - 1] === "escrows" ||
segments[index - 1] === "payroll") &&
segment.length > 6
? decodeURIComponent(segment)
: undefined,
}));
Expand Down
20 changes: 16 additions & 4 deletions src/components/shared/ResponsiveCopyField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,22 @@ function getResponsiveValue(
compact: boolean,
maxVisibleChars?: number,
): string {
const truncateTo = (visibleChars: number): string => {
if (value.length <= visibleChars) {
return value;
}

const headChars = Math.ceil(visibleChars * 0.56);
const tailChars = Math.max(4, visibleChars - headChars);

return `${value.slice(0, headChars)}${ELLIPSIS}${value.slice(-tailChars)}`;
};

// Avoid flashing the full address before ResizeObserver measures width.
if (width <= 0) {
if (maxVisibleChars !== undefined) {
return truncateTo(Math.max(maxVisibleChars, MIN_VISIBLE_CHARS));
}
return value;
}

Expand Down Expand Up @@ -59,10 +74,7 @@ function getResponsiveValue(
return value;
}

const headChars = Math.ceil(visibleChars * 0.56);
const tailChars = Math.max(4, visibleChars - headChars);

return `${value.slice(0, headChars)}${ELLIPSIS}${value.slice(-tailChars)}`;
return truncateTo(visibleChars);
}

export const ResponsiveCopyField = ({
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";

const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3! uppercase",
{
variants: {
variant: {
Expand Down
48 changes: 42 additions & 6 deletions src/components/ui/custom-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,40 @@ type RoundedTabLinksProps = {

function useSlidingIndicator(activeValue: string, itemsLength: number) {
const listRef = useRef<HTMLDivElement>(null);
const [indicator, setIndicator] = useState({ left: 0, width: 0 });
const [indicator, setIndicator] = useState({
left: 0,
width: 0,
ready: false,
});

useEffect(() => {
const list = listRef.current;
if (!list) return;
const el = list.querySelector<HTMLElement>(`[data-value="${activeValue}"]`);
if (!el) return;
setIndicator({ left: el.offsetLeft, width: el.offsetWidth });

const update = () => {
const el = list.querySelector<HTMLElement>(
`[data-value="${CSS.escape(activeValue)}"]`,
);
if (!el) return;
const width = el.offsetWidth;
setIndicator({
left: el.offsetLeft,
width,
ready: width > 0,
});
};

update();

const observer = new ResizeObserver(() => {
update();
});
observer.observe(list);
list
.querySelectorAll<HTMLElement>("[data-value]")
.forEach((node) => observer.observe(node));

return () => observer.disconnect();
}, [activeValue, itemsLength]);

return { listRef, indicator };
Expand Down Expand Up @@ -78,7 +104,12 @@ export function RoundedTabs({
>
<span
aria-hidden
className="absolute top-1 bottom-1 rounded-full bg-card shadow-sm ring-1 ring-border transition-all duration-300 ease-out"
className={cn(
"absolute top-1 bottom-1 rounded-full bg-card shadow-sm ring-1 ring-border",
indicator.ready
? "transition-all duration-300 ease-out"
: "opacity-0",
)}
style={{ left: indicator.left, width: indicator.width }}
/>

Expand Down Expand Up @@ -137,7 +168,12 @@ export function RoundedTabLinks({
>
<span
aria-hidden
className="absolute top-1 bottom-1 rounded-full bg-card shadow-sm ring-1 ring-border transition-all duration-300 ease-out"
className={cn(
"absolute top-1 bottom-1 rounded-full bg-card shadow-sm ring-1 ring-border",
indicator.ready
? "transition-all duration-300 ease-out"
: "opacity-0",
)}
style={{ left: indicator.left, width: indicator.width }}
/>

Expand Down
11 changes: 11 additions & 0 deletions src/constants/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DiscIcon,
KeyRoundIcon,
LayoutDashboardIcon,
WalletIcon,
WebhookIcon,
} from "lucide-react";
import {
Expand Down Expand Up @@ -84,4 +85,14 @@ export const ADMIN_NAV_GROUPS: DashboardNavGroup[] = [
},
],
},
{
label: "Operations",
items: [
{
title: "Payroll",
url: "/admin/payroll",
icon: WalletIcon,
},
],
},
];
7 changes: 7 additions & 0 deletions src/constants/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
KeyRoundIcon,
LayoutDashboardIcon,
Settings2Icon,
WalletIcon,
WebhookIcon,
} from "lucide-react";

Expand Down Expand Up @@ -60,6 +61,12 @@ export const ADMIN_PAGES: Record<string, DashboardPageConfig> = {
"Platform growth, revenue by token, and escrow lifecycle insights.",
icon: LayoutDashboardIcon,
},
"/admin/payroll": {
title: "Payroll",
description:
"Create and fund multi-release escrows to pay the Trustless Work team.",
icon: WalletIcon,
},
};

const SECTION_ROOTS = new Set(["/dashboard", "/admin"]);
Expand Down
5 changes: 4 additions & 1 deletion src/features/admin-auth/ui/AdminNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SidebarTrigger } from "@/components/ui/sidebar";
import { Breadcrumb } from "@/components/shared/Breadcrumb";
import { NetworkToggle } from "@/components/shared/NetworkToggle";
import { ToggleTheme } from "@/components/shared/ToggleTheme";
import { WalletButton } from "@/components/tw-blocks/wallet-kit/WalletButtons";

export const AdminNavbar = () => {
return (
Expand All @@ -21,6 +22,7 @@ export const AdminNavbar = () => {
</div>

<div className="hidden shrink-0 items-center gap-2 md:flex">
<WalletButton />
<NetworkToggle />
<ToggleTheme />
</div>
Expand All @@ -30,7 +32,8 @@ export const AdminNavbar = () => {
</div>
</div>

<div className="border-t border-border/60 px-3 py-2 md:hidden">
<div className="flex flex-col gap-2 border-t border-border/60 px-3 py-2 md:hidden">
<WalletButton mobileBar />
<NetworkToggle className="w-full justify-center px-2" />
</div>
</header>
Expand Down
7 changes: 7 additions & 0 deletions src/features/auth/hooks/useSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { UserResponse } from "@/types";
import {
clearClientAuthState,
endIntentionalLogout,
isAdminAuthArea,
isIntentionalLogout,
} from "@/features/auth/lib/logout-client";

Expand All @@ -32,6 +33,12 @@ export function useSession() {
return;
}

// Admin has no SEP-10 session by design; a stored wallet is for payroll
// signing and must not be treated as an expired dashboard login.
if (isAdminAuthArea()) {
return;
}

if (
query.isSuccess &&
query.data === null &&
Expand Down
Loading
Loading