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
14 changes: 13 additions & 1 deletion backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Expand All @@ -14,7 +15,7 @@ import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
import { AuthResponseDto } from './dto/auth-response.dto';
import { AuthResponseDto, AuthUserDto } from './dto/auth-response.dto';
import { Response, CookieOptions } from 'express';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
Expand Down Expand Up @@ -58,6 +59,16 @@ export class AuthController {
return result;
}

@Get('me')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary:
'Return the authenticated user (id, email, role) so the client can restore a session from the HttpOnly cookie without exposing it to JavaScript',
})
async me(@CurrentUser() currentUser: RequestUser): Promise<AuthUserDto> {
return this.authService.me(currentUser);
}

@Post('logout')
@UseGuards(JwtAuthGuard)
@ApiOperation({
Expand Down Expand Up @@ -115,3 +126,4 @@ export class AuthController {
return { id: user.id, role: user.role };
}
}

17 changes: 16 additions & 1 deletion backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { UserRole } from './enums/user-role.enum';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { AuthResponseDto } from './dto/auth-response.dto';
import { AuthResponseDto, AuthUserDto } from './dto/auth-response.dto';
import { RequestUser } from './interfaces/authenticated-request.interface';

/**
* Cost factor every password hash in this service uses (issue #1964). Both
Expand Down Expand Up @@ -164,6 +165,20 @@ export class AuthService {
return this.users.save(user);
}

/**
* Restores the session client-side (issue #1973): the HttpOnly cookie
* cannot be read by the frontend, so `GET /auth/me` is the only way to
* learn who is signed in and with which role. A revoked token never gets
* this far (the guard's verifier rejects it), so a hit means "logged in".
*/
async me(currentUser: RequestUser): Promise<AuthUserDto> {
const user = await this.users.findOne({ where: { id: currentUser.id } });
if (!user) {
throw new NotFoundException('User not found');
}
return { id: user.id, email: user.email, role: user.role };
}

private async bumpTokenVersion(userId: string): Promise<void> {
await this.users.increment({ id: userId }, 'tokenVersion', 1);
}
Expand Down
18 changes: 7 additions & 11 deletions frontend/app/admin/accounts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
SecondaryButton,
inputClass,
} from "@/components/admin/ui";
import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin";
import { RequireAdmin } from "@/components/admin/require-admin";

const KINDS: LedgerAccountKind[] = [
"USER",
Expand All @@ -28,27 +28,25 @@ const KINDS: LedgerAccountKind[] = [
];

export default function AccountsPage() {
const token = useAdminToken();

return (
<RequireAdmin>
<AccountsInner token={token as string} />
<AccountsInner />
</RequireAdmin>
);
}

function AccountsInner({ token }: { token: string }) {
function AccountsInner() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);

const accounts = useQuery({
queryKey: ["ledger-accounts"],
queryFn: () => listAccounts(token),
queryFn: () => listAccounts(),
});

const freeze = useMutation({
mutationFn: ({ id, frozen }: { id: string; frozen: boolean }) =>
updateAccount(token, id, { frozen }),
updateAccount(id, { frozen }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ledger-accounts"] });
toast.success("Account policy updated");
Expand All @@ -74,7 +72,7 @@ function AccountsInner({ token }: { token: string }) {
</div>

{showCreate && (
<CreateAccountForm token={token} onDone={() => setShowCreate(false)} />
<CreateAccountForm onDone={() => setShowCreate(false)} />
)}

<Card>
Expand Down Expand Up @@ -176,10 +174,8 @@ function AccountRow({
}

function CreateAccountForm({
token,
onDone,
}: {
token: string;
onDone: () => void;
}) {
const queryClient = useQueryClient();
Expand All @@ -201,7 +197,7 @@ function CreateAccountForm({

const create = useMutation({
mutationFn: () =>
createAccount(token, {
createAccount({
kind: form.kind,
ownerId: form.ownerId || undefined,
currency: form.currency,
Expand Down
7 changes: 0 additions & 7 deletions frontend/app/admin/charge/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
} from "@/components/app-ui";
import { RequireAdmin } from "@/components/admin/require-admin";
import { chargeCredits, type ChargeCreditsResponse } from "@/lib/payments-api";
import Cookies from "js-cookie";
import { useState } from "react";
import { toast } from "sonner";

Expand All @@ -24,15 +23,9 @@ export default function AdminChargePage() {

async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const token = Cookies.get("accessToken");
if (!token) {
toast.error("Sign in as an administrator.");
return;
}
setBusy(true);
try {
const res = await chargeCredits(
token,
{
userId,
amount: Number(amount),
Expand Down
35 changes: 35 additions & 0 deletions frontend/app/admin/forbidden/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Link from "next/link";
import { ShieldAlert } from "lucide-react";

export const metadata = {
title: "Not authorised",
};

/**
* Landing page for member-role tokens that try to open /admin/* routes
* (issue #1973). The middleware redirects non-admin sessions here; the
* backend still enforces authorization on every admin endpoint.
*/
export default function AdminForbiddenPage() {
return (
<main className="mx-auto max-w-lg px-6 py-20">
<div className="flex flex-col items-center gap-4 rounded-lg border border-dashed border-red-300 px-8 py-12 text-center dark:border-red-700">
<ShieldAlert className="h-10 w-10 text-red-400" />
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
Not authorised
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Your account does not have the admin role, so the admin area is
unavailable. If you should have access, ask an administrator to
upgrade your account.
</p>
<Link
href="/wallet"
className="text-sm font-medium text-blue-600 underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Back to your wallet
</Link>
</div>
</main>
);
}
10 changes: 4 additions & 6 deletions frontend/app/admin/integrity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,20 @@ import { CheckCircle2, AlertTriangle } from "lucide-react";
import { checkIntegrity, type LedgerIntegrityReport } from "@/lib/admin-api";
import { Card, CardHeader, Button } from "@/components/admin/ui";
import { IntegrityResults } from "@/components/admin/integrity-results";
import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin";
import { RequireAdmin } from "@/components/admin/require-admin";

export default function IntegrityPage() {
const token = useAdminToken();

return (
<RequireAdmin>
<IntegrityInner token={token as string} />
<IntegrityInner />
</RequireAdmin>
);
}

function IntegrityInner({ token }: { token: string }) {
function IntegrityInner() {
const report = useQuery({
queryKey: ["ledger-integrity"],
queryFn: () => checkIntegrity(token),
queryFn: () => checkIntegrity(),
refetchOnWindowFocus: false,
});

Expand Down
10 changes: 7 additions & 3 deletions frontend/app/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "lucide-react";
import { AdminProviders } from "@/lib/providers";
import { AdminBreadcrumbs } from "@/components/admin/admin-breadcrumbs";
import { UserMenu } from "@/components/auth/user-menu";
import { cn } from "@/lib/utils";

export const metadata: Metadata = {
Expand Down Expand Up @@ -39,9 +40,12 @@ export default function AdminLayout({
<Landmark className="h-5 w-5 text-blue-600" />
ManageHub Admin
</Link>
<Link href="/" className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
Back to site
</Link>
<div className="flex items-center gap-4">
<UserMenu />
<Link href="/" className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
Back to site
</Link>
</div>
</div>
</header>
<div className="mx-auto flex max-w-7xl gap-8 px-6 py-8">
Expand Down
9 changes: 4 additions & 5 deletions frontend/app/admin/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,23 @@ import {
Badge,
StatusBadge,
} from "@/components/admin/ui";
import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin";
import { RequireAdmin } from "@/components/admin/require-admin";

const PAGE_SIZE = 25;

export default function AdminManualReviewPage() {
const token = useAdminToken();
return (
<RequireAdmin>
<ManualReviewInner token={token as string} />
<ManualReviewInner />
</RequireAdmin>
);
}

function ManualReviewInner({ token }: { token: string }) {
function ManualReviewInner() {
const [offset, setOffset] = useState(0);
const { data, isLoading, error } = useQuery({
queryKey: ["manual-review-payments", offset],
queryFn: () => listManualReviewPayments(token, PAGE_SIZE, offset),
queryFn: () => listManualReviewPayments(PAGE_SIZE, offset),
refetchInterval: 30_000,
});

Expand Down
13 changes: 3 additions & 10 deletions frontend/app/admin/refund/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
requestRefund,
type Payment,
} from "@/lib/payments-api";
import Cookies from "js-cookie";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
Expand All @@ -24,14 +23,12 @@ function PaymentRow({ payment }: { payment: Payment }) {
const [amount, setAmount] = useState(String(payment.amount));
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const token = typeof window !== "undefined" ? Cookies.get("accessToken") : null;

async function onRefund(e: React.FormEvent) {
e.preventDefault();
if (!token) return;
setBusy(true);
try {
await requestRefund(token, payment.id, Number(amount), reason);
await requestRefund(payment.id, Number(amount), reason);
toast.success("Refund request submitted");
setReason("");
await queryClient.invalidateQueries({ queryKey: ["manual-review"] });
Expand Down Expand Up @@ -91,11 +88,9 @@ function PaymentRow({ payment }: { payment: Payment }) {
}

export default function AdminRefundPage() {
const token = typeof window !== "undefined" ? Cookies.get("accessToken") : null;
const { data, isLoading, error } = useQuery({
queryKey: ["manual-review"],
queryFn: () => listManualReviewPayments(token!),
enabled: !!token,
queryFn: () => listManualReviewPayments(),
});

return (
Expand All @@ -107,9 +102,7 @@ export default function AdminRefundPage() {
description="Payments awaiting reconciliation or manual review can be refunded."
/>
<div className="space-y-4 px-5 py-4">
{!token ? (
<p className="text-sm text-amber-600">Sign in required.</p>
) : isLoading ? (
isLoading ? (
<p className="text-sm text-gray-500">Loading…</p>
) : error ? (
<p className="text-sm text-red-600">{(error as Error).message}</p>
Expand Down
12 changes: 5 additions & 7 deletions frontend/app/admin/settlements/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
StatusBadge,
Button,
} from "@/components/admin/ui";
import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin";
import { RequireAdmin } from "@/components/admin/require-admin";
import { SettlementVolumeChart } from "@/components/admin/settlement-volume-chart";

const STATUSES: Array<{ value: SettlementBatchStatus | ""; label: string }> = [
Expand All @@ -27,16 +27,14 @@ const STATUSES: Array<{ value: SettlementBatchStatus | ""; label: string }> = [
];

export default function SettlementsPage() {
const token = useAdminToken();

return (
<RequireAdmin>
<SettlementsInner token={token as string} />
<SettlementsInner />
</RequireAdmin>
);
}

function SettlementsInner({ token }: { token: string }) {
function SettlementsInner() {
const queryClient = useQueryClient();
const status =
typeof window !== "undefined"
Expand All @@ -46,11 +44,11 @@ function SettlementsInner({ token }: { token: string }) {
const batches = useQuery({
queryKey: ["settlement-batches", status],
queryFn: () =>
listBatches(token, (status || undefined) as SettlementBatchStatus | undefined),
listBatches((status || undefined) as SettlementBatchStatus | undefined),
});

const run = useMutation({
mutationFn: () => runSettlement(token),
mutationFn: () => runSettlement(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settlement-batches"] });
toast.success("Settlement pass completed");
Expand Down
Loading
Loading