From e94722bd1df9bf1848634e1de8fe7d11dbc80e18 Mon Sep 17 00:00:00 2001 From: Eugene Mutembei <103780583+CodeWithEugene@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:52:21 +0300 Subject: [PATCH 1/3] fix(payout): hold ambiguous transfers, close announce/cancel races, review follow-ups Money path: - executePayout stores the attempt reference at claim time and, when the transfer call throws, asks Paystack what happened before deciding: success confirms, failed/reversed/not_found retries, pending/unknown holds PROCESSING for the sweep. Paystack port gains a not_found status. - announceWinners and both cancel paths use conditional status updates, so a cancel and an announce racing each other can't both win. Security and ops: - Per-action rate-limit keys (register, event-cancel, judge-invite). - Hourly purge of expired RateLimitBucket rows. - /api/health reports 503 when the payment or chain port can't resolve. - CSP connect-src allows the Sentry ingest origin from the public DSN. - Drop the unused direct pg dependency (pg-boss brings its own). SEO and copy: - robots.txt blocks /organizer and /organizer/ without hiding /organizers. - Remove em/en dashes from user-facing strings; Title Case fixes. --- app/(app)/dashboard/winnings/page.tsx | 2 +- app/(auth)/onboarding/choose/page.tsx | 2 +- app/(marketing)/trust/page.tsx | 4 +- app/api/events/[slug]/cancel/route.ts | 2 +- app/api/events/[slug]/register/route.ts | 2 +- app/api/health/route.ts | 28 ++-- app/api/judge/invites/[assignmentId]/route.ts | 2 +- components/admin/event-cancel-form.tsx | 2 +- components/judging/scoring-screen.tsx | 6 +- lib/admin/event-actions.ts | 2 +- lib/events/cancel.ts | 44 ++++-- lib/jobs/handlers.ts | 5 + lib/newsletter/mail-templates.ts | 4 +- lib/notifications/templates/admin.ts | 2 +- lib/orgs/verification-service.ts | 2 +- lib/ports/paystack.ts | 14 +- lib/rate-limit.ts | 15 ++ lib/seo/robots.ts | 5 +- next.config.ts | 21 ++- package.json | 1 - pnpm-lock.yaml | 3 - services/escrow/deposits.ts | 2 +- services/escrow/reconcile.ts | 10 +- services/escrow/refund.ts | 6 +- services/escrow/webhook.ts | 8 +- services/judging/service.ts | 2 +- services/legacy/service.ts | 2 +- services/payout/actions.ts | 2 +- services/payout/payout-alerts.ts | 8 +- services/payout/service.ts | 71 +++++++-- tests/integration/escrow.test.ts | 2 +- tests/integration/event-cancel.test.ts | 23 +++ tests/integration/payout-ambiguous.test.ts | 140 ++++++++++++++++++ tests/integration/payouts.test.ts | 29 ++++ tests/unit/payout-ambiguous.test.ts | 23 +++ tests/unit/rate-limit.test.ts | 17 ++- tests/unit/seo.test.ts | 4 + 37 files changed, 436 insertions(+), 81 deletions(-) create mode 100644 tests/integration/payout-ambiguous.test.ts create mode 100644 tests/unit/payout-ambiguous.test.ts diff --git a/app/(app)/dashboard/winnings/page.tsx b/app/(app)/dashboard/winnings/page.tsx index 087f087..5b31226 100644 --- a/app/(app)/dashboard/winnings/page.tsx +++ b/app/(app)/dashboard/winnings/page.tsx @@ -111,7 +111,7 @@ export default async function WinningsPage() { description="When you win a Prize Verified hackathon, the 50% instant tranche lands here the same day, tracked to the shilling." action={ - + } /> diff --git a/app/(auth)/onboarding/choose/page.tsx b/app/(auth)/onboarding/choose/page.tsx index 45c8758..2032106 100644 --- a/app/(auth)/onboarding/choose/page.tsx +++ b/app/(auth)/onboarding/choose/page.tsx @@ -21,7 +21,7 @@ export default async function OnboardingChoosePage() { Sign In To Choose Your Path - Pick organizer or developer right after you sign in — we'll bring you straight back + Pick organizer or developer right after you sign in. We'll bring you straight back here.
diff --git a/app/(marketing)/trust/page.tsx b/app/(marketing)/trust/page.tsx index c09f09a..a3fa4e6 100644 --- a/app/(marketing)/trust/page.tsx +++ b/app/(marketing)/trust/page.tsx @@ -19,7 +19,7 @@ import { LEDGER_TYPE_LABELS, payloadAmountKes, payloadReference, shortenHash } f export const metadata: Metadata = { title: "The Public Ledger: Every Prize Lock And Payout, Verifiable", description: - "Every hackathon prize pool locked, paid out or refunded on HackVillage is recorded on a public ledger. Verify each entry yourself — radical transparency is the product.", + "Every hackathon prize pool locked, paid out or refunded on HackVillage is recorded on a public ledger. Verify each entry yourself: radical transparency is the product.", alternates: { canonical: "/trust" }, openGraph: pageOpenGraph("/trust"), }; @@ -238,7 +238,7 @@ export default async function TrustPage({ searchParams }: PageProps) { {LEDGER_TYPE_LABELS[entry.type]} - {amount === null ? "—" : formatKes(amount)} + {amount === null ? "None" : formatKes(amount)} {onChain ? ( diff --git a/app/api/events/[slug]/cancel/route.ts b/app/api/events/[slug]/cancel/route.ts index c23ea4e..1ae3a99 100644 --- a/app/api/events/[slug]/cancel/route.ts +++ b/app/api/events/[slug]/cancel/route.ts @@ -16,7 +16,7 @@ export async function POST( const { slug } = await params; const user = await currentUser(); if (user) { - const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000); + const limit = await rateLimit(`event-cancel:${user.id}`, 30, 60 * 60 * 1000); if (!limit.ok) { return NextResponse.json( { error: "Too many attempts. Try again later." }, diff --git a/app/api/events/[slug]/register/route.ts b/app/api/events/[slug]/register/route.ts index 0557a6b..8b68cc0 100644 --- a/app/api/events/[slug]/register/route.ts +++ b/app/api/events/[slug]/register/route.ts @@ -20,7 +20,7 @@ export async function POST( const { slug } = await params; const user = await currentUser(); if (user) { - const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000); + const limit = await rateLimit(`register:${user.id}`, 30, 60 * 60 * 1000); if (!limit.ok) { return NextResponse.json( { error: "Too many attempts. Try again later." }, diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 9dd983a..4a1c4c3 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -8,7 +8,8 @@ import { getPaystackPort } from "@/lib/ports/paystack"; * Liveness/readiness probe (no auth — uptime monitors and load balancers * poll it). Reports component status only: no versions, hosts, or secret * presence beyond the already-public port modes. 200 when the app can serve - * real traffic (database reachable, queue schema present), 503 otherwise. + * real traffic (database reachable, queue schema present, payment and chain + * ports resolvable), 503 otherwise. */ export const dynamic = "force-dynamic"; @@ -43,21 +44,24 @@ async function queueOk(): Promise { } } -export async function GET(): Promise> { - const [db, queue] = await Promise.all([databaseOk(), queueOk()]); - - // Port getters never reach the network (simulation fallbacks are in-process); - // if env validation itself throws, the whole app is down — report honestly. - let paystackMode = "unknown"; - let chainMode = "unknown"; +function portMode(read: () => string): string { try { - paystackMode = getPaystackPort().mode; - chainMode = getChainPort().mode; + return read(); } catch { - // Modes are informational; db/queue decide the status code. + return "unknown"; } +} + +export async function GET(): Promise> { + const [db, queue] = await Promise.all([databaseOk(), queueOk()]); + + // Port getters never reach the network (simulation fallbacks are in-process). + // A getter that throws (e.g. no Paystack key in production) means every + // deposit and payout would fail, so the app is not healthy. + const paystackMode = portMode(() => getPaystackPort().mode); + const chainMode = portMode(() => getChainPort().mode); - const ok = db && queue; + const ok = db && queue && paystackMode !== "unknown" && chainMode !== "unknown"; const body: ComponentHealth = { ok, db: db ? "ok" : "down", diff --git a/app/api/judge/invites/[assignmentId]/route.ts b/app/api/judge/invites/[assignmentId]/route.ts index 5b79973..94ab4a8 100644 --- a/app/api/judge/invites/[assignmentId]/route.ts +++ b/app/api/judge/invites/[assignmentId]/route.ts @@ -22,7 +22,7 @@ export async function POST( } const user = await currentUser(); if (user) { - const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000); + const limit = await rateLimit(`judge-invite:${user.id}`, 30, 60 * 60 * 1000); if (!limit.ok) { return NextResponse.json( { error: "Too many attempts. Try again later." }, diff --git a/components/admin/event-cancel-form.tsx b/components/admin/event-cancel-form.tsx index 7d1ea89..73f6e4e 100644 --- a/components/admin/event-cancel-form.tsx +++ b/components/admin/event-cancel-form.tsx @@ -34,7 +34,7 @@ export function AdminEventCancelForm({ onConfirm={async (formData) => { const reason = String(formData.get("reason") ?? "").trim(); if (reason.length < 4) { - return { error: "Give a reason (at least 4 characters) — the organizer sees it." }; + return { error: "Give a reason (at least 4 characters), since the organizer sees it." }; } return adminCancelEventAction(eventId, reason); }} diff --git a/components/judging/scoring-screen.tsx b/components/judging/scoring-screen.tsx index 83af6c3..d01b825 100644 --- a/components/judging/scoring-screen.tsx +++ b/components/judging/scoring-screen.tsx @@ -107,7 +107,7 @@ export function ScoringScreen({ : "w-8 text-right font-mono text-sm font-bold text-ink" } > - {value ?? "–"} + {value ?? "Not scored"}
@@ -124,8 +124,8 @@ export function ScoringScreen({ {!allScored ? (

- {unscoredCount} {unscoredCount === 1 ? "criterion" : "criteria"} still unscored — - move every slider to enable saving. + {unscoredCount} {unscoredCount === 1 ? "criterion" : "criteria"} still unscored. + Move every slider to enable saving.

) : null} diff --git a/lib/admin/event-actions.ts b/lib/admin/event-actions.ts index cbe9f94..fd73d34 100644 --- a/lib/admin/event-actions.ts +++ b/lib/admin/event-actions.ts @@ -26,7 +26,7 @@ export async function adminCancelEventAction( const parsedReason = z.string().trim().min(4).max(500).safeParse(reason); if (!parsedId.success) return { error: "Unknown hackathon." }; if (!parsedReason.success) { - return { error: "Give a short reason (at least 4 characters) — it's audit-logged." }; + return { error: "Give a short reason (at least 4 characters), since it's audit-logged." }; } try { diff --git a/lib/events/cancel.ts b/lib/events/cancel.ts index 8b62225..e8121d7 100644 --- a/lib/events/cancel.ts +++ b/lib/events/cancel.ts @@ -1,3 +1,4 @@ +import type { EventStatus } from "@prisma/client"; import { prisma } from "@/lib/db"; import { sendNotification } from "@/lib/notifications/send"; import { eventCancelledEmail } from "@/lib/notifications/templates/events"; @@ -60,9 +61,17 @@ export async function cancelEventAsOrganizer(input: { ); } - await prisma.$transaction([ - prisma.event.update({ where: { id: event.id }, data: { status: "CANCELLED" } }), - prisma.auditLog.create({ + await prisma.$transaction(async (tx) => { + // Conditional on the status checked above, so a hackathon that went live + // (vault locked) in the meantime is never cancelled from this path. + const cancelled = await tx.event.updateMany({ + where: { id: event.id, status: { in: ["DRAFT", "PENDING_DEPOSIT"] } }, + data: { status: "CANCELLED" }, + }); + if (cancelled.count === 0) { + throw new EventCancelError("This hackathon changed state. Refresh the page and try again.", "WRONG_STATE"); + } + await tx.auditLog.create({ data: { actorId: input.userId, action: "event.cancelled", @@ -70,13 +79,13 @@ export async function cancelEventAsOrganizer(input: { entityId: event.id, reason: "Cancelled by the organizer.", }, - }), - ]); + }); + }); return { slug: event.slug }; } /** Statuses an admin may still cancel: anything before winners are announced. */ -const ADMIN_CANCELLABLE_STATUSES = new Set([ +const ADMIN_CANCELLABLE_STATUSES = new Set([ "DRAFT", "PENDING_DEPOSIT", "LIVE", @@ -110,14 +119,25 @@ export async function cancelEventAsAdmin(input: { if (!event) throw new EventCancelError("Hackathon not found.", "NOT_FOUND"); if (!ADMIN_CANCELLABLE_STATUSES.has(event.status)) { throw new EventCancelError( - "Hackathons with announced winners can't be cancelled — resolve payouts and disputes instead.", + "Hackathons with announced winners can't be cancelled. Resolve payouts and disputes instead.", "WRONG_STATE" ); } - await prisma.$transaction([ - prisma.event.update({ where: { id: event.id }, data: { status: "CANCELLED" } }), - prisma.auditLog.create({ + await prisma.$transaction(async (tx) => { + // Conditional on the status checked above: if winners were announced in + // the meantime, nothing is cancelled and no refund follows. + const cancelled = await tx.event.updateMany({ + where: { id: event.id, status: { in: [...ADMIN_CANCELLABLE_STATUSES] } }, + data: { status: "CANCELLED" }, + }); + if (cancelled.count === 0) { + throw new EventCancelError( + "This hackathon changed state (winners may have just been announced). Refresh the page.", + "WRONG_STATE" + ); + } + await tx.auditLog.create({ data: { actorId: input.adminId, action: "event.admin-cancelled", @@ -125,8 +145,8 @@ export async function cancelEventAsAdmin(input: { entityId: event.id, reason: input.reason, }, - }), - ]); + }); + }); // The vault refund runs outside the status transaction: refundLockedVault // owns its own state machine + attestation and is idempotent. diff --git a/lib/jobs/handlers.ts b/lib/jobs/handlers.ts index 5ea5e2e..fd02776 100644 --- a/lib/jobs/handlers.ts +++ b/lib/jobs/handlers.ts @@ -99,6 +99,11 @@ export async function registerJobs(): Promise { const { advanceStartedEvents } = await import("@/lib/events/status-jobs"); const advanced = await advanceStartedEvents(); if (advanced > 0) console.log(`[cron] advanced ${advanced} event(s) to IN_PROGRESS`); + + // Housekeeping: expired rate-limit buckets carry no state. + const { purgeExpiredRateLimits } = await import("@/lib/rate-limit"); + const purged = await purgeExpiredRateLimits(); + if (purged > 0) console.log(`[cron] purged ${purged} expired rate-limit bucket(s)`); } }); diff --git a/lib/newsletter/mail-templates.ts b/lib/newsletter/mail-templates.ts index 64c3a5a..7986a55 100644 --- a/lib/newsletter/mail-templates.ts +++ b/lib/newsletter/mail-templates.ts @@ -43,7 +43,7 @@ export function newsletterResubscribeConfirmEmail(confirmUrl: string): EmailTemp preheader: "You (or someone with your address) asked to re-subscribe.", section: { heading: "Confirm Your Re-Subscription", - bodyHtml: html`

We got a new subscription request for this address, but it had unsubscribed before. If that was you, confirm below and you're back on the list. If it wasn't, ignore this email — you'll stay unsubscribed.

`, + bodyHtml: html`

We got a new subscription request for this address, but it had unsubscribed before. If that was you, confirm below and you're back on the list. If it wasn't, ignore this email. You'll stay unsubscribed.

`, ctaUrl: confirmUrl, ctaLabel: "Yes, Re-Subscribe Me", }, @@ -51,7 +51,7 @@ export function newsletterResubscribeConfirmEmail(confirmUrl: string): EmailTemp text: renderText([ "We got a new subscription request for this address, but it had unsubscribed before.", `Confirm here to re-subscribe: ${confirmUrl}`, - "If it wasn't you, ignore this email — you'll stay unsubscribed.", + "If it wasn't you, ignore this email, and you'll stay unsubscribed.", ]), }; } diff --git a/lib/notifications/templates/admin.ts b/lib/notifications/templates/admin.ts index 0648923..a7ff23a 100644 --- a/lib/notifications/templates/admin.ts +++ b/lib/notifications/templates/admin.ts @@ -74,7 +74,7 @@ export function disputeRefundedEmail(eventTitle: string, note: string): EmailTem preheader: "The prize pool goes back to the organizer.", section: { heading: "Dispute Resolved: Refund", - bodyHtml: html`

Your dispute on ${eventTitle} was upheld. The remaining prize pool is refunded to the organizer — no milestone payout will be made.

+ bodyHtml: html`

Your dispute on ${eventTitle} was upheld. The remaining prize pool is refunded to the organizer. No milestone payout will be made.

Note from our team: ${note}

`, }, }), diff --git a/lib/orgs/verification-service.ts b/lib/orgs/verification-service.ts index 8faf108..35a1eae 100644 --- a/lib/orgs/verification-service.ts +++ b/lib/orgs/verification-service.ts @@ -34,7 +34,7 @@ export async function decideKyb(input: { if (!org) throw new KybDecisionError("Organization not found."); if (org.kycStatus !== "PENDING") { throw new KybDecisionError( - "That organization isn't awaiting review — refresh and take the next pending one." + "That organization isn't awaiting review. Refresh and take the next pending one." ); } diff --git a/lib/ports/paystack.ts b/lib/ports/paystack.ts index 79c8a74..2402fa4 100644 --- a/lib/ports/paystack.ts +++ b/lib/ports/paystack.ts @@ -65,9 +65,13 @@ export interface TransferResult { simulated: boolean; } -/** Provider-truth lookup for the stuck-PROCESSING recovery sweep (P4). */ +/** + * Provider-truth lookup for the stuck-PROCESSING recovery sweep (P4). + * "not_found" means Paystack answered and has no record of the transfer (safe + * to retry); "unknown" means the lookup itself failed (never safe to retry). + */ export interface TransferStatusResult { - status: "success" | "failed" | "pending" | "reversed" | "unknown"; + status: "success" | "failed" | "pending" | "reversed" | "not_found" | "unknown"; } /** Maps Paystack transfer statuses onto the platform's recovery vocabulary. */ @@ -240,10 +244,12 @@ export class PaystackLive implements PaystackPort { `${PAYSTACK_BASE}/transfer/verify/${encodeURIComponent(referenceOrCode)}`, `${PAYSTACK_BASE}/transfer/${encodeURIComponent(referenceOrCode)}`, ]; + let notFoundCount = 0; for (const url of endpoints) { const response = await fetch(url, { headers: { Authorization: `Bearer ${this.secretKey}` }, }).catch(() => null); + if (response?.status === 404) notFoundCount += 1; if (!response || !response.ok) continue; const payload = (await response.json().catch(() => null)) as { status?: boolean; @@ -252,7 +258,8 @@ export class PaystackLive implements PaystackPort { if (!payload?.status || !payload.data) continue; return { status: mapTransferStatus(payload.data.status) }; } - return { status: "unknown" }; + // Only a clean 404 from both lookups proves the transfer never existed. + return { status: notFoundCount === endpoints.length ? "not_found" : "unknown" }; } } @@ -316,6 +323,7 @@ export class PaystackSimulation implements PaystackPort { if (reference.includes("-simreverse")) return { status: "reversed" }; if (reference.includes("-simpending")) return { status: "pending" }; if (reference.includes("-simunknown")) return { status: "unknown" }; + if (reference.includes("-simnotfound")) return { status: "not_found" }; return { status: "success" }; } } diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 7014a27..5f04408 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -98,6 +98,21 @@ export async function rateLimit( } } +/** + * Deletes buckets whose window has already ended. An expired row carries no + * state (the next hit resets it to 1), so dropping it is behavior-neutral and + * keeps one-off keys (per-IP sign-in attempts, newsletter emails) from piling + * up forever. Runs from the hourly cron. + */ +export async function purgeExpiredRateLimits(now: number = Date.now()): Promise { + // Raw SQL on purpose: rateLimitInDb writes resetAt through raw parameters, + // which Postgres converts using the session timezone. Comparing the same way + // keeps the purge correct even when the database isn't set to UTC. + return prisma.$executeRaw` + DELETE FROM "RateLimitBucket" WHERE "resetAt" < ${new Date(now)} + `; +} + /** Test helper — clears both the durable buckets and the fallback windows. */ export async function resetRateLimits(): Promise { fallbackWindows.clear(); diff --git a/lib/seo/robots.ts b/lib/seo/robots.ts index cea635d..7170e84 100644 --- a/lib/seo/robots.ts +++ b/lib/seo/robots.ts @@ -12,7 +12,10 @@ import { appUrl } from "@/lib/url"; */ const PRIVATE_PATHS = [ "/dashboard", - "/organizer", + // Exactly /organizer and everything under /organizer/: a bare "/organizer" + // prefix would also block the public /organizers/ trust pages. + "/organizer$", + "/organizer/", "/judge", "/admin", "/settings", diff --git a/next.config.ts b/next.config.ts index 8a0507b..88f5e76 100644 --- a/next.config.ts +++ b/next.config.ts @@ -67,6 +67,7 @@ const HTML_LIMITED_BOTS = new RegExp( * Media lives on a Cloudflare R2 public URL (lib/ports/storage.ts): either * R2_PUBLIC_BASE or the derived pub-.r2.dev host. Only the origin is * allow-listed — paths stay unrestricted so bucket layout can change. + * Headers are computed at build time, so changing R2 env vars needs a redeploy. */ function r2PublicOrigin(): string | null { const explicit = process.env.R2_PUBLIC_BASE; @@ -82,6 +83,21 @@ function r2PublicOrigin(): string | null { } } +/** + * Browser error reports go to the ingest host named in the public DSN + * (https://@oXXX.ingest..sentry.io/). Like the R2 + * origin, it's read at build time: changing either env var needs a redeploy. + */ +function sentryIngestOrigin(): string | null { + const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; + if (!dsn) return null; + try { + return new URL(dsn).origin; + } catch { + return null; + } +} + /** * Baseline security headers applied to every route. CSP note: script-src * keeps 'unsafe-inline' because the App Router streams its RSC payload in @@ -92,6 +108,9 @@ function r2PublicOrigin(): string | null { */ function securityHeaders(): Array<{ key: string; value: string }> { const imgSrc = ["'self'", "data:", "blob:", r2PublicOrigin()].filter(Boolean).join(" "); + const connectSrc = ["'self'", "https://api.paystack.co", sentryIngestOrigin()] + .filter(Boolean) + .join(" "); return [ { key: "Content-Security-Policy", @@ -100,7 +119,7 @@ function securityHeaders(): Array<{ key: string; value: string }> { "script-src 'self' 'unsafe-inline'", `style-src 'self' 'unsafe-inline'`, `img-src ${imgSrc}`, - "connect-src 'self' https://api.paystack.co", + `connect-src ${connectSrc}`, "frame-src https://js.paystack.co https://checkout.paystack.com", "frame-ancestors 'none'", "base-uri 'self'", diff --git a/package.json b/package.json index 2765890..1af3bcc 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "lucide-react": "^0.475.0", "next": "^15.0.0", "next-auth": "^5.0.0-beta.32", - "pg": "^8.23.0", "pg-boss": "^10.1.5", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29d1ba1..ddf3e5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,9 +47,6 @@ importers: next-auth: specifier: ^5.0.0-beta.32 version: 5.0.0-beta.32(next@15.5.26(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) - pg: - specifier: ^8.23.0 - version: 8.23.0 pg-boss: specifier: ^10.1.5 version: 10.4.2 diff --git a/services/escrow/deposits.ts b/services/escrow/deposits.ts index b2c4e7b..5f76134 100644 --- a/services/escrow/deposits.ts +++ b/services/escrow/deposits.ts @@ -210,7 +210,7 @@ export async function recordChargeSuccess(input: { action: "escrow.deposit-overpayment", entity: "Deposit", entityId: deposit.id, - reason: `Deposit ${input.reference} succeeded after the vault was already LOCKED — refund manually via Paystack`, + reason: `Deposit ${input.reference} succeeded after the vault was already LOCKED. Refund manually via Paystack`, meta: { reference: input.reference, eventSlug: event.slug, diff --git a/services/escrow/reconcile.ts b/services/escrow/reconcile.ts index f970a02..a5900af 100644 --- a/services/escrow/reconcile.ts +++ b/services/escrow/reconcile.ts @@ -140,7 +140,7 @@ export async function reconcileLedger(chainOverride?: ChainPort): PromiseThe prize vault for ${input.eventTitle} was marked REFUNDED (was ${input.previousState}). Reason: ${input.reason}. The ledger attestation is queued. Now refund the deposit(s) manually in the Paystack dashboard — references: ${referenceLines}.

`, + heading: "Vault Refund: Manual Step Required", + bodyHtml: html`

The prize vault for ${input.eventTitle} was marked REFUNDED (was ${input.previousState}). Reason: ${input.reason}. The ledger attestation is queued. Now refund the deposit(s) manually in the Paystack dashboard. References: ${referenceLines}.

`, details: [ { label: "Hackathon", value: input.eventSlug }, { label: "Total deposited (gross)", value: `KES ${totalGross.toLocaleString("en-KE")}` }, diff --git a/services/escrow/webhook.ts b/services/escrow/webhook.ts index 6d7e38d..cba70ee 100644 --- a/services/escrow/webhook.ts +++ b/services/escrow/webhook.ts @@ -191,7 +191,7 @@ async function routeVerifiedEvent( action: "payout.reversed-after-success", entity: "Payout", entityId: payout.id, - reason: `Paystack reversed transfer ${reference} AFTER the payout succeeded — funds clawed back provider-side`, + reason: `Paystack reversed transfer ${reference} AFTER the payout succeeded, so funds clawed back provider-side`, meta: { reference, amountKes: payout.amountKes, @@ -349,8 +349,8 @@ function orphanedChargeEmail(input: { html: renderEmail({ preheader: "Real money arrived for a deposit we can no longer credit.", section: { - heading: "Orphaned Charge — Manual Repair", - bodyHtml: html`

Paystack confirmed a charge for ${input.eventTitle}, but the deposit was already ${input.depositStatus} (the organizer paid after the 24h window). The money is NOT lost — verify the charge in Paystack, then either refund it or credit the vault manually.

`, + heading: "Orphaned Charge: Manual Repair", + bodyHtml: html`

Paystack confirmed a charge for ${input.eventTitle}, but the deposit was already ${input.depositStatus} (the organizer paid after the 24h window). The money is NOT lost. Verify the charge in Paystack, then either refund it or credit the vault manually.

`, details: [ { label: "Deposit reference", value: input.reference }, { label: "Hackathon", value: input.eventSlug }, @@ -397,7 +397,7 @@ function clawbackAfterSuccessEmail( text: renderText([ `Paystack reversed transfer ${reference} for ${eventTitle} after success.`, `Payout ${payoutId}, winner @${winnerHandle}, KES ${amountKes}.`, - `The payout stays SUCCEEDED — recover funds manually.`, + `The payout stays SUCCEEDED. Recover funds manually.`, appUrl("/admin/payments"), ]), }; diff --git a/services/judging/service.ts b/services/judging/service.ts index f5cc12d..b8accd9 100644 --- a/services/judging/service.ts +++ b/services/judging/service.ts @@ -218,7 +218,7 @@ export async function openJudging(eventId: string, organizerId: string): Promise }); if (conflicts.length > 0) { throw new JudgingError( - `Judging can't open — these judges are also participants; remove them first: ${conflicts + `Judging can't open: these judges are also participants; remove them first: ${conflicts .map((conflict) => `@${conflict.user.handle}`) .join(", ")}.`, "WRONG_STATE" diff --git a/services/legacy/service.ts b/services/legacy/service.ts index ddf09d5..1c80c38 100644 --- a/services/legacy/service.ts +++ b/services/legacy/service.ts @@ -369,7 +369,7 @@ export async function resolveDispute(input: { }); if (milestonePayout) { throw new LegacyError( - "A milestone payout already exists — refunds after a paid tranche are a manual ops process.", + "A milestone payout already exists. Refunds after a paid tranche are a manual ops process.", "WRONG_STATE" ); } diff --git a/services/payout/actions.ts b/services/payout/actions.ts index ad2f561..c9ad47b 100644 --- a/services/payout/actions.ts +++ b/services/payout/actions.ts @@ -135,7 +135,7 @@ export async function confirmMilestoneAction(winnerId: string): PromiseA transfer success for ${eventTitle} arrived AFTER Paystack had reversed the transfer. The payout stays REVERSED — the money was clawed back. Verify the winner's position with them before any retry.

`, + heading: "Reversed Payout: Success Refused", + bodyHtml: html`

A transfer success for ${eventTitle} arrived AFTER Paystack had reversed the transfer. The payout stays REVERSED. The money was clawed back. Verify the winner's position with them before any retry.

`, details: [ { label: "Payout", value: payoutId }, { label: "Winner", value: `@${winnerHandle}` }, @@ -36,7 +36,7 @@ export function confirmOnReversedAdminEmail( text: renderText([ `A success signal arrived for REVERSED payout ${payoutId} (${eventTitle}).`, `Winner @${winnerHandle}, KES ${amountKes}, reference ${reference}.`, - `The payout stays REVERSED — verify manually before any retry.`, + `The payout stays REVERSED. Verify manually before any retry.`, appUrl("/admin/payments"), ]), }; @@ -55,7 +55,7 @@ export function stuckPayoutAdminEmail(input: { html: renderEmail({ preheader: "A payout has been in flight for over a day without provider truth.", section: { - heading: "Payout Stuck — Manual Check", + heading: "Payout Stuck: Manual Check", bodyHtml: html`

Payout ${input.payoutId} for ${input.eventTitle} has been PROCESSING for over 24 hours and Paystack still reports "${input.providerStatus}". The funds remain locked (fail-closed). Check the transfer in the Paystack dashboard and resolve it by hand.

`, details: [ { label: "Payout", value: input.payoutId }, diff --git a/services/payout/service.ts b/services/payout/service.ts index 9d7ea02..5b28951 100644 --- a/services/payout/service.ts +++ b/services/payout/service.ts @@ -253,10 +253,19 @@ export async function announceWinners(input: AnnounceInput): Promise { }); } - await tx.event.update({ - where: { id: event.id }, + // Conditional on the status checked above: if an admin cancelled (and + // refunded) the hackathon in the meantime, nothing is announced and the + // winners and payouts created in this transaction roll back. + const announced = await tx.event.updateMany({ + where: { id: event.id, status: "JUDGING" }, data: { status: "WINNERS_ANNOUNCED" }, }); + if (announced.count === 0) { + throw new PayoutError( + "This hackathon is no longer in judging, so winners weren't announced. Refresh the page.", + "WRONG_STATE" + ); + } }); } catch (error) { // Unique ({eventId, place}) on Winner — a concurrent/duplicate announce. @@ -355,20 +364,26 @@ export async function executePayout(payoutId: string): Promise { const attempt = payout.attemptCount + 1; const reference = transferReference(payout.id, attempt); + // Resolve the payment port before claiming: if it's unavailable (no key in + // production), the job fails here and nothing is left half-claimed. + const paystack = getPaystackPort(); + // Mark PROCESSING first (claim the attempt) so a concurrent worker or a - // replayed job sees "duplicate" and stands down. + // replayed job sees "duplicate" and stands down. The reference is stored + // with the claim, so even a crash mid-request leaves the sweep a reference + // to check with the provider. const claimed = await prisma.payout.updateMany({ where: { id: payout.id, status: { in: ["QUEUED", "FAILED"] } }, - data: { status: "PROCESSING", attemptCount: attempt }, + data: { status: "PROCESSING", attemptCount: attempt, paystackReference: reference }, }); if (claimed.count === 0) return { outcome: "duplicate" }; try { - const transfer = await getPaystackPort().initiateTransfer({ + const transfer = await paystack.initiateTransfer({ reference, recipientCode: payout.recipientCode, amountKes: payout.amountKes, - reason: `${payout.winner.event.title} — ${payout.tranche.toLowerCase()} prize`, + reason: `${payout.winner.event.title}, ${payout.tranche.toLowerCase()} prize`, }); await prisma.payout.update({ @@ -391,12 +406,46 @@ export async function executePayout(payoutId: string): Promise { } return { outcome: "processing" }; } catch (error) { + // Ambiguous: a timeout or dropped connection can happen after Paystack + // already accepted the transfer. Retrying under a new reference would pay + // twice, so ask the provider what happened to this attempt first. const message = error instanceof Error ? error.message : "Unknown transfer error."; - await handleTransferFailure(payout.id, reference, message); - return { outcome: "failed" }; + const truth = await paystack + .transferStatus(reference) + .catch(() => ({ status: "unknown" as const })); + const next = afterAmbiguousTransferError(truth.status); + if (next === "confirm") { + await confirmTransferSuccess(payout.id, reference); + return { outcome: "succeeded" }; + } + if (next === "retry") { + await handleTransferFailure(payout.id, reference, message); + return { outcome: "failed" }; + } + // Pending or unverifiable: stay PROCESSING (fail closed). The stuck-payout + // sweep re-checks the provider and pages ops if it never resolves. + await prisma.payout.update({ + where: { id: payout.id }, + data: { lastError: `Ambiguous transfer error (${truth.status}): ${message}`.slice(0, 400) }, + }); + console.error(`[payout] ambiguous transfer error payout=${payout.id} ref=${reference} provider=${truth.status}`); + return { outcome: "processing" }; } } +/** + * What to do after the transfer request itself threw. Only a provider answer + * that the attempt is dead (failed, reversed, or never created) allows a retry + * with a new reference; a live or unverifiable one never does. + */ +export function afterAmbiguousTransferError( + status: "success" | "failed" | "pending" | "reversed" | "not_found" | "unknown", +): "confirm" | "retry" | "hold" { + if (status === "success") return "confirm"; + if (status === "failed" || status === "reversed" || status === "not_found") return "retry"; + return "hold"; +} + /** Transfer confirmed → terminal success + vault/ledger advancement. */ export async function confirmTransferSuccess(payoutId: string, reference: string): Promise { const payout = await prisma.payout.findUnique({ @@ -425,7 +474,7 @@ export async function confirmTransferSuccess(payoutId: string, reference: string action: "payout.confirm-on-reversed", entity: "Payout", entityId: payoutId, - reason: `transfer.success arrived for REVERSED payout (ref ${reference}) — refused`, + reason: `transfer.success arrived for REVERSED payout (ref ${reference}), refused`, meta: { reference, eventTitle: payout.winner.event.title }, }, }); @@ -815,7 +864,9 @@ export async function sweepStuckPayouts(): Promise { driven += 1; continue; } - if (truth.status === "failed" || truth.status === "reversed") { + // not_found: Paystack answered and never created this transfer, so a + // fresh attempt can't duplicate it. + if (truth.status === "failed" || truth.status === "reversed" || truth.status === "not_found") { console.warn( `[payout] sweep resolving stuck payout ${payout.id}: provider says ${truth.status}` ); diff --git a/tests/integration/escrow.test.ts b/tests/integration/escrow.test.ts index 5d4eb9c..b32beae 100644 --- a/tests/integration/escrow.test.ts +++ b/tests/integration/escrow.test.ts @@ -288,7 +288,7 @@ describe("escrow deposit flow (integration)", () => { where: { action: "escrow.deposit-overpayment", entityId: second.id }, }); expect(audit).not.toBeNull(); - expect(audit?.reason).toContain("refund manually"); + expect(audit?.reason).toMatch(/refund manually/i); } finally { await prisma.organization.delete({ where: { id: overpaid.org.id } }); } diff --git a/tests/integration/event-cancel.test.ts b/tests/integration/event-cancel.test.ts index a074ac3..6552268 100644 --- a/tests/integration/event-cancel.test.ts +++ b/tests/integration/event-cancel.test.ts @@ -161,6 +161,29 @@ describe("admin cancel", () => { expect(vault.chainState).toBe("REFUNDED"); }); + it("loses the race cleanly when winners are announced after its read", async () => { + const { event } = await createEvent("race", { status: "WINNERS_ANNOUNCED", withVault: "HALF_RELEASED" }); + // Stale read: cancel saw JUDGING, but the announce committed first. + const delegate = prisma.event as unknown as Record; + const realFindUnique = prisma.event.findUnique; + delegate.findUnique = (args: Parameters[0]) => { + delegate.findUnique = realFindUnique; + return realFindUnique(args).then((row) => (row ? { ...row, status: "JUDGING" } : row)); + }; + try { + await expect( + cancelEventAsAdmin({ eventId: event.id, adminId, reason: "race" }) + ).rejects.toMatchObject({ code: "WRONG_STATE" }); + } finally { + delegate.findUnique = realFindUnique; + } + expect((await prisma.event.findUniqueOrThrow({ where: { id: event.id } })).status).toBe( + "WINNERS_ANNOUNCED" + ); + const vault = await prisma.vaultState.findUniqueOrThrow({ where: { eventId: event.id } }); + expect(vault.chainState).toBe("HALF_RELEASED"); + }); + it("cancels a JUDGING event without a vault (no refund)", async () => { const { event } = await createEvent("judging", { status: "JUDGING" }); const result = await cancelEventAsAdmin({ eventId: event.id, adminId, reason: "no show" }); diff --git a/tests/integration/payout-ambiguous.test.ts b/tests/integration/payout-ambiguous.test.ts new file mode 100644 index 0000000..ac3b5c8 --- /dev/null +++ b/tests/integration/payout-ambiguous.test.ts @@ -0,0 +1,140 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { prisma } from "@/lib/db"; +import { getPaystackPort } from "@/lib/ports/paystack"; +import { executePayout } from "@/services/payout/service"; + +/** + * Money-path race and retry fixes: a transfer request that throws (timeout, + * dropped connection) must never be retried under a new reference unless the + * provider confirms the first attempt is dead. + */ + +const KEY = `payamb-int-${Date.now().toString(36)}`; +const DAY = 24 * 60 * 60 * 1000; +const userIds: string[] = []; +let orgId = ""; +let eventId = ""; +let winnerId = ""; + +async function user(label: string) { + const created = await prisma.user.create({ + data: { + email: `${KEY}-${label}@payouts.example.net`, + name: `Payout ${label}`, + handle: `${KEY}-${label}`.slice(-28), + emailVerified: new Date(), + primaryRole: "DEVELOPER", + onboardingCompletedAt: new Date(), + }, + }); + userIds.push(created.id); + return created.id; +} + +async function freshPayout(label: string) { + return prisma.payout.create({ + data: { + winnerId, + tranche: "INSTANT", + amountKes: 10_000, + idempotencyKey: `${winnerId}:${label}`, + recipientCode: "RCP_SIM_AMBIGUOUS", + status: "QUEUED", + }, + }); +} + +beforeAll(async () => { + const owner = await user("owner"); + const leader = await user("leader"); + const org = await prisma.organization.create({ data: { name: "Ambiguous Org", slug: KEY, ownerId: owner } }); + orgId = org.id; + const now = Date.now(); + const event = await prisma.event.create({ + data: { + orgId, + slug: `${KEY}-event`, + title: "Ambiguous Payout Test", + venueType: "ONLINE", + startsAt: new Date(now - 3 * DAY), + endsAt: new Date(now - DAY), + registrationDeadline: new Date(now - 4 * DAY), + status: "WINNERS_ANNOUNCED", + publishedAt: new Date(), + prizeVerifiedAt: new Date(), + }, + }); + eventId = event.id; + const team = await prisma.team.create({ + data: { eventId, name: "Ambiguous Team", leaderId: leader, inviteCode: KEY.slice(-10) }, + }); + const winner = await prisma.winner.create({ + data: { eventId, teamId: team.id, place: 1, userId: leader, amountKes: 20_000 }, + }); + winnerId = winner.id; +}); + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + await prisma.payout.deleteMany({ where: { winnerId } }); + await prisma.winner.deleteMany({ where: { eventId } }); + await prisma.team.deleteMany({ where: { eventId } }); + await prisma.event.deleteMany({ where: { id: eventId } }); + await prisma.organization.deleteMany({ where: { id: orgId } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); +}); + +describe("executePayout after the transfer request throws", () => { + it("confirms instead of paying again when the provider says the first attempt succeeded", async () => { + const payout = await freshPayout("a"); + const port = getPaystackPort(); + const initiate = vi.spyOn(port, "initiateTransfer").mockRejectedValue(new Error("socket hang up")); + vi.spyOn(port, "transferStatus").mockResolvedValue({ status: "success" }); + + const result = await executePayout(payout.id); + + expect(result.outcome).toBe("succeeded"); + expect(initiate).toHaveBeenCalledTimes(1); + const after = await prisma.payout.findUniqueOrThrow({ where: { id: payout.id } }); + expect(after.status).toBe("SUCCEEDED"); + expect(after.paystackReference).toBe(`trf-${payout.id.toLowerCase()}-1`); + }); + + it("fails into the normal retry path when the provider has no record of the attempt", async () => { + const payout = await freshPayout("c"); + const port = getPaystackPort(); + vi.spyOn(port, "initiateTransfer").mockRejectedValue(new Error("ECONNRESET")); + vi.spyOn(port, "transferStatus").mockResolvedValue({ status: "not_found" }); + + const result = await executePayout(payout.id); + + expect(result.outcome).toBe("failed"); + const after = await prisma.payout.findUniqueOrThrow({ where: { id: payout.id } }); + expect(after.status).not.toBe("PROCESSING"); + expect(after.status).not.toBe("SUCCEEDED"); + expect(after.attemptCount).toBe(1); + }); + + it("holds the payout when the provider can't be asked, instead of retrying blind", async () => { + const payout = await freshPayout("b"); + const port = getPaystackPort(); + vi.spyOn(port, "initiateTransfer").mockRejectedValue(new Error("ETIMEDOUT")); + vi.spyOn(port, "transferStatus").mockResolvedValue({ status: "unknown" }); + + const result = await executePayout(payout.id); + + expect(result.outcome).toBe("processing"); + const after = await prisma.payout.findUniqueOrThrow({ where: { id: payout.id } }); + expect(after.status).toBe("PROCESSING"); + expect(after.attemptCount).toBe(1); + expect(after.paystackReference).toBe(`trf-${payout.id.toLowerCase()}-1`); + expect(after.lastError).toContain("Ambiguous transfer error (unknown)"); + // A replayed job must stand down rather than start a second transfer. + expect((await executePayout(payout.id)).outcome).toBe("duplicate"); + }); +}); diff --git a/tests/integration/payouts.test.ts b/tests/integration/payouts.test.ts index d597ff0..9ad82d5 100644 --- a/tests/integration/payouts.test.ts +++ b/tests/integration/payouts.test.ts @@ -227,6 +227,35 @@ describe("payout engine (integration)", () => { ).rejects.toMatchObject({ code: "WRONG_STATE" }); }); + it("announces nothing when the hackathon leaves judging after the read (cancel race)", async () => { + // An admin cancel commits between announce's read and its write. + const delegate = prisma.event as unknown as Record; + const realFindUnique = prisma.event.findUnique; + delegate.findUnique = async (args: Parameters[0]) => { + delegate.findUnique = realFindUnique; + const row = await realFindUnique(args); + await prisma.event.update({ where: { id: world.eventId }, data: { status: "CANCELLED" } }); + return row; + }; + try { + await expect( + announceWinners({ + eventId: world.eventId, + organizerId: world.organizerId, + placements: [ + { place: 1, teamId: world.teamIds[0] }, + { place: 2, teamId: world.teamIds[1] }, + ], + }) + ).rejects.toMatchObject({ code: "WRONG_STATE" }); + } finally { + delegate.findUnique = realFindUnique; + await prisma.event.update({ where: { id: world.eventId }, data: { status: "JUDGING" } }); + } + expect(await prisma.winner.count({ where: { eventId: world.eventId } })).toBe(0); + expect(await prisma.payout.count({ where: { winner: { eventId: world.eventId } } })).toBe(0); + }); + it("announces winners: creates winners, milestones, instant payout rows atomically", async () => { await announceWinners({ eventId: world.eventId, diff --git a/tests/unit/payout-ambiguous.test.ts b/tests/unit/payout-ambiguous.test.ts new file mode 100644 index 0000000..fe3b616 --- /dev/null +++ b/tests/unit/payout-ambiguous.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { PaystackSimulation } from "@/lib/ports/paystack"; +import { afterAmbiguousTransferError } from "@/services/payout/service"; + +describe("afterAmbiguousTransferError", () => { + it("only retries when the provider says the attempt is dead", () => { + expect(afterAmbiguousTransferError("success")).toBe("confirm"); + expect(afterAmbiguousTransferError("failed")).toBe("retry"); + expect(afterAmbiguousTransferError("reversed")).toBe("retry"); + expect(afterAmbiguousTransferError("not_found")).toBe("retry"); + expect(afterAmbiguousTransferError("pending")).toBe("hold"); + expect(afterAmbiguousTransferError("unknown")).toBe("hold"); + }); +}); + +describe("simulated transferStatus", () => { + it("reports a transfer Paystack never created as not_found, distinct from unknown", async () => { + const port = new PaystackSimulation(); + await expect(port.transferStatus("trf-x-simnotfound-1")).resolves.toEqual({ status: "not_found" }); + await expect(port.transferStatus("trf-x-simunknown-1")).resolves.toEqual({ status: "unknown" }); + }); +}); diff --git a/tests/unit/rate-limit.test.ts b/tests/unit/rate-limit.test.ts index 099e5ed..d05beed 100644 --- a/tests/unit/rate-limit.test.ts +++ b/tests/unit/rate-limit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; -import { rateLimit, resetRateLimits } from "@/lib/rate-limit"; +import { purgeExpiredRateLimits, rateLimit, resetRateLimits } from "@/lib/rate-limit"; /** True when the durable bucket table is reachable (CI/dev DATABASE_URL). */ async function dbAvailable(): Promise { @@ -65,4 +65,19 @@ describe("rateLimit", () => { expect(result.ok).toBe(true); await resetRateLimits(); }); + + it("purgeExpiredRateLimits drops only buckets whose window has ended", async () => { + if (!(await dbAvailable())) return; + await resetRateLimits(); + const now = Date.now(); + await rateLimit("db-expired", 5, 1000, now - 10_000); // window ended 9s ago + await rateLimit("db-live", 5, 60_000, now); + expect(await purgeExpiredRateLimits(now)).toBe(1); + const keys = (await prisma.rateLimitBucket.findMany({ select: { key: true } })).map((b) => b.key); + expect(keys).toEqual(["db-live"]); + // The live bucket keeps its count. + expect((await rateLimit("db-live", 2, 60_000, now)).ok).toBe(true); + expect((await rateLimit("db-live", 2, 60_000, now)).ok).toBe(false); + await resetRateLimits(); + }); }); diff --git a/tests/unit/seo.test.ts b/tests/unit/seo.test.ts index 4f93cf8..f9eb5e8 100644 --- a/tests/unit/seo.test.ts +++ b/tests/unit/seo.test.ts @@ -126,5 +126,9 @@ describe("robots.txt", () => { expect(text).toMatch(/^User-Agent: GPTBot$/m); expect(text).toMatch(/^Sitemap: .+\/sitemap\.xml$/m); expect(text).not.toMatch(/^Host:/m); + // Private organizer pages are blocked, public /organizers/ pages aren't. + expect(text).toContain("Disallow: /organizer$"); + expect(text).toContain("Disallow: /organizer/"); + expect(text).not.toMatch(/^Disallow: \/organizer$/m); }); }); From 69fc30d432cc05d084149fe52a6c4033ad25f178 Mon Sep 17 00:00:00 2001 From: Eugene Mutembei <103780583+CodeWithEugene@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:20:30 +0300 Subject: [PATCH 2/3] style(ui): square auth form cards onto the ruled frame, center sign-in and sign-up headings --- app/(auth)/layout.tsx | 2 +- app/globals.css | 30 +++++++++++++++++++++--------- components/auth/signin-form.tsx | 4 ++-- components/auth/signup-form.tsx | 4 ++-- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index 5965775..19278f4 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -37,7 +37,7 @@ export default function AuthLayout({ children }: { children: React.ReactNode }) -
{children}
+
{children}
diff --git a/app/globals.css b/app/globals.css index 0fa5e5c..19a20d3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1227,6 +1227,9 @@ /* Auth pages: full height photo on the left half, form on the right. */ .auth-shell { --auth-corner: 1rem; + /* Where the form column's vertical hairlines sit; the top bar and the + form card line up on the same rules. */ + --auth-rule-inset: clamp(12px, 4vw, 56px); display: grid; min-height: 100dvh; background: var(--color-paper); @@ -1244,7 +1247,7 @@ align-items: center; justify-content: center; gap: 2rem; - padding: 5.5rem 1rem 3rem; + padding: 5.5rem 0 3rem; background: linear-gradient( 180deg, color-mix(in srgb, var(--color-brand) 7%, var(--color-paper)), @@ -1254,7 +1257,7 @@ .auth-main::before { content: ""; position: absolute; - inset: 0 clamp(12px, 4vw, 56px); + inset: 0 var(--auth-rule-inset); z-index: -1; border-inline: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); pointer-events: none; @@ -1262,8 +1265,8 @@ .auth-topbar { position: absolute; top: 0; - right: clamp(12px, 4vw, 56px); - left: clamp(12px, 4vw, 56px); + right: var(--auth-rule-inset); + left: var(--auth-rule-inset); display: flex; height: var(--site-header-height); align-items: center; @@ -1310,7 +1313,7 @@ z-index: 1; grid-column: 2; grid-row: 1; - padding: 6rem 2rem 3rem; + padding: 6rem 0 3rem; border-radius: var(--auth-corner) 0 0 var(--auth-corner); } .auth-visual { @@ -4120,14 +4123,23 @@ html:not([data-scrolled]) body:has(.lp-hero) .site-header { } /* - * Auth pages: the forms render the shared Card, whose shadow utility would - * outrank layered styles. Unlayered on purpose, scoped to the auth form - * slot, to give them the site's hairline card instead. + * Auth pages: the forms render the shared Card, whose radius and shadow + * utilities would outrank layered styles. Unlayered on purpose, scoped to the + * auth form slot, to turn them into a ruled band instead: square, flat, and + * spanning the column so its top and bottom rules meet the vertical ones. */ +.auth-form-slot { + align-self: stretch; + margin-inline: var(--auth-rule-inset); +} .auth-form-slot > .rounded-card, .auth-form-slot .rounded-card.shadow-card { + max-width: none; + margin-inline: 0; + padding: 2.5rem clamp(1.25rem, 5vw, 4rem); + border-radius: 0; border-color: color-mix(in srgb, var(--color-ink) 10%, transparent); - box-shadow: 0 1px 2px rgb(0 0 70 / 0.04); + box-shadow: none; } .auth-form-slot h1 { font-weight: 500; diff --git a/components/auth/signin-form.tsx b/components/auth/signin-form.tsx index 406d2ed..42b2818 100644 --- a/components/auth/signin-form.tsx +++ b/components/auth/signin-form.tsx @@ -25,8 +25,8 @@ export function SignInForm({ return ( -

Welcome Back

-

Sign in to your HackVillage account.

+

Welcome Back

+

Sign in to your HackVillage account.

{notice ? : null} {errorNotice ? : null} diff --git a/components/auth/signup-form.tsx b/components/auth/signup-form.tsx index e134102..c7ab2bc 100644 --- a/components/auth/signup-form.tsx +++ b/components/auth/signup-form.tsx @@ -40,8 +40,8 @@ export function SignUpForm({ return ( -

Create Your Account

-

+

Create Your Account

+

{role === "ORGANIZER" ? "Start with your own account. Next, you'll set up your organization." : "One account, many roles: you can add organizer or judge access later."} From 63d9240540142377a7b2f04e39ac895c9f53a64a Mon Sep 17 00:00:00 2001 From: Eugene Mutembei <103780583+CodeWithEugene@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:29:41 +0300 Subject: [PATCH 3/3] feat(auth): add a show/hide toggle to every password field --- app/globals.css | 6 ++++ components/auth/reset-password-form.tsx | 6 ++-- components/auth/signin-form.tsx | 4 +-- components/auth/signup-form.tsx | 6 ++-- components/settings/account-settings.tsx | 7 ++--- components/ui/password-input.tsx | 35 ++++++++++++++++++++++++ 6 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 components/ui/password-input.tsx diff --git a/app/globals.css b/app/globals.css index 19a20d3..7ebc94b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4145,3 +4145,9 @@ html:not([data-scrolled]) body:has(.lp-hero) .site-header { font-weight: 500; letter-spacing: -0.02em; } + +/* PasswordInput renders its own show/hide toggle; hide Edge's built-in one. */ +input::-ms-reveal, +input::-ms-clear { + display: none; +} diff --git a/components/auth/reset-password-form.tsx b/components/auth/reset-password-form.tsx index 867a06f..31bb078 100644 --- a/components/auth/reset-password-form.tsx +++ b/components/auth/reset-password-form.tsx @@ -5,7 +5,8 @@ import { useActionState } from "react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { FormError, Input, Label } from "@/components/ui/input"; +import { FormError, Label } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; import { resetPasswordAction, type AuthActionState } from "@/lib/auth/actions"; export function ResetPasswordForm({ token }: { token: string }) { @@ -34,10 +35,9 @@ export function ResetPasswordForm({ token }: { token: string }) {

-
- diff --git a/components/auth/signup-form.tsx b/components/auth/signup-form.tsx index c7ab2bc..dc9e12e 100644 --- a/components/auth/signup-form.tsx +++ b/components/auth/signup-form.tsx @@ -8,6 +8,7 @@ import { OAuthButtons } from "@/components/auth/oauth-buttons"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { FormError, Input, Label } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; import { signUpAction, type AuthActionState } from "@/lib/auth/actions"; import type { SignUpRole } from "@/lib/auth/signup-links"; @@ -95,7 +96,7 @@ export function SignUpForm({ {role === "DEVELOPER" ? (
- -
-
- , "type"> & { + ref?: Ref; +}; + +/** Password field with a show/hide toggle. The toggle never submits the form. */ +export function PasswordInput({ className, ...props }: PasswordInputProps) { + const [visible, setVisible] = useState(false); + const label = visible ? "Hide password" : "Show password"; + + return ( +
+ + +
+ ); +}