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
2 changes: 1 addition & 1 deletion app/(app)/dashboard/winnings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={
<Link href="/hackathons">
<Button arrow>Find a hackathon</Button>
<Button arrow>Find A Hackathon</Button>
</Link>
}
/>
Expand Down
2 changes: 1 addition & 1 deletion app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
<ThemeSwitcher />
</div>
</div>
<div className="auth-form-slot w-full max-w-md">{children}</div>
<div className="auth-form-slot">{children}</div>
</main>
<AuthVisual />
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/(auth)/onboarding/choose/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default async function OnboardingChoosePage() {
<LogIn aria-hidden className="size-5" /> Sign In To Choose Your Path
</CardTitle>
<CardDescription>
Pick organizer or developer right after you sign in — we&apos;ll bring you straight back
Pick organizer or developer right after you sign in. We&apos;ll bring you straight back
here.
</CardDescription>
<div className="mt-5 flex flex-wrap justify-center gap-3">
Expand Down
4 changes: 2 additions & 2 deletions app/(marketing)/trust/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
};
Expand Down Expand Up @@ -238,7 +238,7 @@ export default async function TrustPage({ searchParams }: PageProps) {
</td>
<td className="px-4 py-3">{LEDGER_TYPE_LABELS[entry.type]}</td>
<td className="px-4 py-3 font-semibold text-ink">
{amount === null ? "—" : formatKes(amount)}
{amount === null ? "None" : formatKes(amount)}
</td>
<td className="px-4 py-3 font-mono text-xs">
{onChain ? (
Expand Down
2 changes: 1 addition & 1 deletion app/api/events/[slug]/cancel/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
2 changes: 1 addition & 1 deletion app/api/events/[slug]/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
28 changes: 16 additions & 12 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -43,21 +44,24 @@ async function queueOk(): Promise<boolean> {
}
}

export async function GET(): Promise<NextResponse<ComponentHealth>> {
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<NextResponse<ComponentHealth>> {
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",
Expand Down
2 changes: 1 addition & 1 deletion app/api/judge/invites/[assignmentId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
36 changes: 27 additions & 9 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)),
Expand All @@ -1254,16 +1257,16 @@
.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;
}
.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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -4120,16 +4123,31 @@ 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;
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;
}
2 changes: 1 addition & 1 deletion components/admin/event-cancel-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}}
Expand Down
6 changes: 3 additions & 3 deletions components/auth/reset-password-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -34,10 +35,9 @@ export function ResetPasswordForm({ token }: { token: string }) {
<input type="hidden" name="token" value={token} />
<div>
<Label htmlFor="password">New password</Label>
<Input
<PasswordInput
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={10}
Expand Down
8 changes: 4 additions & 4 deletions components/auth/signin-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { OAuthButtons } from "@/components/auth/oauth-buttons";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { FormError, FormSuccess, Input, Label } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { signInAction, type AuthActionState } from "@/lib/auth/actions";

export function SignInForm({
Expand All @@ -25,8 +26,8 @@ export function SignInForm({

return (
<Card>
<h1 className="font-display text-2xl font-bold text-ink">Welcome Back</h1>
<p className="mt-1 text-sm text-muted">Sign in to your HackVillage account.</p>
<h1 className="text-center font-display text-2xl font-bold text-ink">Welcome Back</h1>
<p className="mt-1 text-center text-sm text-muted">Sign in to your HackVillage account.</p>

{notice ? <FormSuccess message={notice} /> : null}
{errorNotice ? <FormError message={errorNotice} /> : null}
Expand All @@ -44,10 +45,9 @@ export function SignInForm({
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
<PasswordInput
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
Expand Down
10 changes: 5 additions & 5 deletions components/auth/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -40,8 +41,8 @@ export function SignUpForm({

return (
<Card>
<h1 className="font-display text-2xl font-bold text-ink">Create Your Account</h1>
<p className="mt-1 text-sm text-muted">
<h1 className="text-center font-display text-2xl font-bold text-ink">Create Your Account</h1>
<p className="mt-1 text-center text-sm text-muted">
{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."}
Expand Down Expand Up @@ -95,7 +96,7 @@ export function SignUpForm({
{role === "DEVELOPER" ? (
<div>
<Label htmlFor="handle">Handle (optional)</Label>
<Input
<PasswordInput
id="handle"
name="handle"
placeholder="your public profile address"
Expand All @@ -110,10 +111,9 @@ export function SignUpForm({

<div>
<Label htmlFor="password">Password</Label>
<Input
<PasswordInput
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={10}
Expand Down
6 changes: 3 additions & 3 deletions components/judging/scoring-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function ScoringScreen({
: "w-8 text-right font-mono text-sm font-bold text-ink"
}
>
{value ?? "–"}
{value ?? "Not scored"}
</output>
</div>
</div>
Expand All @@ -124,8 +124,8 @@ export function ScoringScreen({
</Button>
{!allScored ? (
<p className="text-xs font-semibold text-muted">
{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.
</p>
) : null}
</div>
Expand Down
7 changes: 3 additions & 4 deletions components/settings/account-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardTitle } from "@/components/ui/card";
import { FormError, FormSuccess, Input, Label } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import {
changePasswordAction,
deactivateAccountAction,
Expand Down Expand Up @@ -72,20 +73,18 @@ export function AccountSettings({
<form action={changePassword} className="mt-4 space-y-4">
<div>
<Label htmlFor="currentPassword">Current password</Label>
<Input
<PasswordInput
id="currentPassword"
name="currentPassword"
type="password"
autoComplete="current-password"
required
/>
</div>
<div>
<Label htmlFor="newPassword">New password</Label>
<Input
<PasswordInput
id="newPassword"
name="newPassword"
type="password"
autoComplete="new-password"
required
minLength={10}
Expand Down
35 changes: 35 additions & 0 deletions components/ui/password-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { useState, type InputHTMLAttributes, type Ref } from "react";
import { Eye, EyeOff } from "lucide-react";

import { cn } from "@/lib/utils";

import { Input } from "./input";

type PasswordInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
ref?: Ref<HTMLInputElement>;
};

/** 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 (
<div className="relative">
<Input {...props} type={visible ? "text" : "password"} className={cn("pr-11", className)} />
<button
type="button"
onClick={() => setVisible((shown) => !shown)}
aria-label={label}
aria-pressed={visible}
aria-controls={props.id}
title={label}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-control text-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
>
{visible ? <EyeOff aria-hidden className="size-4" /> : <Eye aria-hidden className="size-4" />}
</button>
</div>
);
}
2 changes: 1 addition & 1 deletion lib/admin/event-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading