Files
linkder/apps/web/src/app/jobs/page.tsx
T
serfowiandClaude Opus 5 c617bc9687 M1 security: close the password backdoor, apply re-review, pin E.164
Acts on an adversarial review of the M1 auth and authorization code.
Five findings fixed; the rest recorded in SECURITY-FINDINGS.md as the
M1 exit criteria rather than left in a tool transcript.

- auth: serve /phone-number/request-password-reset, /phone-number/
  reset-password and /sign-in/phone-number as 404. better-auth's
  phoneNumber() registers all three unconditionally -- they are NOT
  gated on emailAndPassword.enabled:false. Left live they form a
  silent second credential path: request-password-reset stores an OTP
  and sends no SMS (sendPasswordResetOTP was never configured, so the
  owner is never told), reset-password mints a bcrypt credential row,
  and sign-in/phone-number then accepts it forever with no OTP. The
  OTP gate still applies, so this is not remote unauthenticated
  takeover -- it converts one momentary OTP compromise into permanent
  access the victim cannot see or rotate.

- auth: drop bearer(). It accepts the plaintext sessions.token column
  as an Authorization credential, making any single leaked row a
  replayable login. The mobile client it was added for is hypothetical.

- auth: pin E.164 via phoneNumberValidator, and add toE164/isE164 to
  @linkder/shared. phone is UNIQUE and bans are per-account, so
  "+34600111222" and "0034600111222" being separately storable meant
  one handset could hold two accounts and a ban was escapable by
  retyping. 15 tests.

- auth: NEXT_PUBLIC_APP_URL now throws in production instead of
  falling back to localhost, which was silently dropping Secure and
  the __Secure- prefix from the production session cookie.

- pro.upsertProfile: actually apply requiresReReview. It was computed,
  returned to the client and never acted on, so a verified plumber
  could become a verified electrician in another city by ignoring a
  response flag. Now demotes to pending in the same transaction and
  audits it. Trade changes count as material (they did not before) --
  the licence is per-trade. Needed a verified -> pending edge in
  VERIFICATION_GRAPH, which did not exist.

Removed two untracked scratch repro files. The impersonation repro
depended on bearer() for transport and no longer applies as written;
the underlying finding (resolveSession drops impersonatedBy, so admin
actions are audited as the victim) is open and documented.

typecheck, lint, build clean; 127 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 01:19:32 -04:00

70 lines
2.3 KiB
TypeScript

import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ArrowRight, Plus } from 'lucide-react';
import { getApi } from '@/server/caller';
export const metadata = { title: 'Your jobs' };
export const dynamic = 'force-dynamic';
const STATUS_LABEL: Record<string, string> = {
open: 'Looking for pros',
matched: 'Pros interested',
booked: 'Booked',
completed: 'Done',
cancelled: 'Cancelled',
};
export default async function JobsPage() {
const api = await getApi();
let jobs: Awaited<ReturnType<typeof api.job.mine>>;
try {
jobs = await api.job.mine();
} catch {
redirect('/sign-in?next=/jobs');
}
return (
<main className="mx-auto max-w-2xl px-6 py-10">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Your jobs</h1>
<Link
href="/jobs/new"
className="inline-flex items-center gap-1.5 rounded-xl bg-[var(--color-brand-500)] px-4 py-2.5 text-sm font-medium text-white"
>
<Plus className="h-4 w-4" aria-hidden />
Post a job
</Link>
</div>
{jobs.length === 0 ? (
<p className="mt-10 rounded-2xl border border-dashed border-[var(--border)] p-8 text-center text-sm text-[var(--muted)]">
Nothing yet. Post a job and start swiping.
</p>
) : (
<ul className="mt-6 space-y-2">
{jobs.map((job) => (
<li key={job.id}>
<Link
href={`/deck/${job.id}`}
className="group flex items-center justify-between gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 transition hover:border-[var(--color-brand-500)]"
>
<span>
<span className="block font-medium">{job.title}</span>
<span className="block text-sm text-[var(--muted)]">
{STATUS_LABEL[job.status] ?? job.status} · {job.addressText}
</span>
</span>
<ArrowRight
className="h-4 w-4 shrink-0 text-[var(--color-brand-500)] transition group-hover:translate-x-0.5"
aria-hidden
/>
</Link>
</li>
))}
</ul>
)}
</main>
);
}