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>
This commit is contained in:
serfowi
2026-08-21 01:19:32 -04:00
co-authored by Claude Opus 5
parent cebeda7f4c
commit c617bc9687
26 changed files with 2012 additions and 52 deletions
+208
View File
@@ -0,0 +1,208 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import type { RouterOutputs } from '@/lib/trpc';
import { api } from '@/lib/trpc';
import { cn } from '@/lib/utils';
type Categories = RouterOutputs['job']['categories'];
const CITY = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
};
const URGENCIES = [
{ value: 'now', label: 'As soon as possible', hint: 'Pros have 12 hours to respond' },
{ value: 'this_week', label: 'This week', hint: '48 hours to respond' },
{ value: 'flexible', label: "I'm flexible", hint: '48 hours to respond' },
] as const;
export function NewJobForm({ categories }: { categories: Categories }) {
const router = useRouter();
const [categoryId, setCategoryId] = useState('');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [urgency, setUrgency] = useState<(typeof URGENCIES)[number]['value']>('this_week');
const [addressText, setAddressText] = useState('');
const [budgetMin, setBudgetMin] = useState('');
const [budgetMax, setBudgetMax] = useState('');
const [location, setLocation] = useState(CITY);
const [error, setError] = useState<string | null>(null);
const create = api.job.create.useMutation({
// Straight into the deck — the whole point is that posting and browsing are
// one continuous motion, not two separate visits.
onSuccess: (job) => router.push(`/deck/${job.id}`),
onError: (e) => setError(e.message),
});
function submit(event: React.FormEvent) {
event.preventDefault();
setError(null);
const toCents = (v: string) => (v.trim() ? Math.round(Number(v) * 100) : undefined);
const min = toCents(budgetMin);
const max = toCents(budgetMax);
if (min !== undefined && max !== undefined && min > max) {
setError('The minimum budget is above the maximum.');
return;
}
create.mutate({
categoryId,
title,
description,
photos: [],
urgency,
budgetMinCents: min,
budgetMaxCents: max,
location,
addressText,
});
}
return (
<form onSubmit={submit} className="mt-8 flex flex-col gap-5">
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium">Trade</legend>
<div className="mt-1 flex flex-wrap gap-2">
{categories.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCategoryId(c.id)}
className={cn(
'rounded-full border px-4 py-2 text-sm transition',
categoryId === c.id
? 'border-[var(--color-brand-500)] bg-[var(--color-brand-500)]/10 font-medium'
: 'border-[var(--border)]',
)}
>
{c.name}
</button>
))}
</div>
</fieldset>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Short summary</span>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
required
minLength={5}
maxLength={120}
placeholder="Kitchen sink leaking under the cupboard"
className={inputClass}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">What is wrong?</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
required
minLength={20}
maxLength={4000}
rows={5}
placeholder="The more detail you give, the more accurate the quotes you get back."
className={inputClass}
/>
<span className="text-xs text-[var(--muted)]">{description.length}/4000, minimum 20</span>
</label>
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium">How soon?</legend>
<div className="mt-1 flex flex-col gap-2">
{URGENCIES.map((u) => (
<button
key={u.value}
type="button"
onClick={() => setUrgency(u.value)}
className={cn(
'rounded-xl border px-4 py-3 text-left text-sm transition',
urgency === u.value
? 'border-[var(--color-brand-500)] bg-[var(--color-brand-500)]/10'
: 'border-[var(--border)]',
)}
>
<span className="block font-medium">{u.label}</span>
<span className="text-xs text-[var(--muted)]">{u.hint}</span>
</button>
))}
</div>
</fieldset>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Address</span>
<span className="text-xs text-[var(--muted)]">
Only shared with a pro once you have booked them.
</span>
<input
value={addressText}
onChange={(e) => setAddressText(e.target.value)}
required
minLength={3}
maxLength={255}
className={inputClass}
/>
<button
type="button"
onClick={() =>
navigator.geolocation?.getCurrentPosition(
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setError('We could not get your location, so we will search from the centre.'),
)
}
className="self-start text-sm text-[var(--color-brand-500)] underline underline-offset-4"
>
Use my current location
</button>
</label>
<fieldset className="grid grid-cols-2 gap-3">
<legend className="mb-2 text-sm font-medium">Budget (optional)</legend>
<label className="flex flex-col gap-1.5">
<span className="text-xs text-[var(--muted)]">From </span>
<input
value={budgetMin}
onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal"
className={inputClass}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs text-[var(--muted)]">To </span>
<input
value={budgetMax}
onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal"
className={inputClass}
/>
</label>
</fieldset>
{error && (
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
{error}
</p>
)}
<button
type="submit"
disabled={create.isPending || !categoryId}
className="flex items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white transition hover:bg-[var(--color-brand-600)] disabled:opacity-60"
>
{create.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
Find me a pro
</button>
</form>
);
}
const inputClass =
'w-full rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]';
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from 'next/navigation';
import { getApi } from '@/server/caller';
import { NewJobForm } from './form';
export const metadata = { title: 'Post a job' };
export const dynamic = 'force-dynamic';
export default async function NewJobPage() {
const api = await getApi();
let me: Awaited<ReturnType<typeof api.user.me>>;
try {
me = await api.user.me();
} catch {
redirect('/sign-in?next=/jobs/new');
}
if (me.role === 'pro') redirect('/pro');
const categories = await api.job.categories();
return (
<main className="mx-auto max-w-lg px-6 py-10">
<h1 className="text-2xl font-semibold tracking-tight">What needs doing?</h1>
<p className="mt-2 text-sm text-[var(--muted)]">
Describe it once. We will show you verified pros nearby who can take it on.
</p>
<NewJobForm categories={categories} />
</main>
);
}
+69
View File
@@ -0,0 +1,69 @@
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>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { redirect } from 'next/navigation';
import { getApi } from '@/server/caller';
import { OnboardingWizard } from './wizard';
export const metadata = { title: 'Set up your profile' };
export const dynamic = 'force-dynamic';
export default async function ProOnboardingPage() {
const api = await getApi();
let me: Awaited<ReturnType<typeof api.user.me>>;
try {
me = await api.user.me();
} catch {
redirect('/sign-in?next=/pro/onboarding');
}
// A customer account cannot build a pro profile — send them to pick a role.
if (me.role === 'client') redirect('/onboarding');
if (me.role === 'admin') redirect('/admin');
const [categories, profile] = await Promise.all([api.job.categories(), api.pro.me()]);
// Already submitted or approved — nothing to fill in.
if (profile && profile.verificationStatus !== 'draft' && profile.verificationStatus !== 'rejected') {
redirect('/pro');
}
return (
<main className="mx-auto max-w-lg px-6 py-10">
<h1 className="text-2xl font-semibold tracking-tight">Set up your profile</h1>
<p className="mt-2 text-sm text-[var(--muted)]">
We check every pro&rsquo;s ID, licence and insurance before any customer sees them. It
usually takes a day.
</p>
<OnboardingWizard
categories={categories}
initialProfile={profile}
hasContactableEmail={me.hasContactableEmail}
/>
</main>
);
}
+473
View File
@@ -0,0 +1,473 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Check, Loader2, Upload } from 'lucide-react';
import type { RouterOutputs } from '@/lib/trpc';
import { api } from '@/lib/trpc';
import {
DEFAULT_SERVICE_RADIUS_M,
MAX_SERVICE_RADIUS_M,
MIN_SERVICE_RADIUS_M,
} from '@linkder/shared';
import { cn } from '@/lib/utils';
import { uploadFile } from '@/lib/upload';
type Categories = RouterOutputs['job']['categories'];
type Profile = RouterOutputs['pro']['me'];
const CITY = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
};
const STEPS = ['Trade', 'About you', 'Photos', 'Documents'] as const;
export function OnboardingWizard({
categories,
initialProfile,
hasContactableEmail,
}: {
categories: Categories;
initialProfile: Profile;
hasContactableEmail: boolean;
}) {
const router = useRouter();
const [step, setStep] = useState(0);
const [error, setError] = useState<string | null>(null);
const [categoryIds, setCategoryIds] = useState<string[]>(initialProfile?.categoryIds ?? []);
const [headline, setHeadline] = useState(initialProfile?.headline ?? '');
const [bio, setBio] = useState(initialProfile?.bio ?? '');
const [hourlyRate, setHourlyRate] = useState(
initialProfile ? String(initialProfile.hourlyRateCents / 100) : '',
);
const [yearsExperience, setYearsExperience] = useState(
String(initialProfile?.yearsExperience ?? ''),
);
const [radiusKm, setRadiusKm] = useState(
(initialProfile?.serviceRadiusM ?? DEFAULT_SERVICE_RADIUS_M) / 1000,
);
const [location, setLocation] = useState(initialProfile?.baseLocation ?? CITY);
const [email, setEmail] = useState('');
const utils = api.useUtils();
const upsert = api.pro.upsertProfile.useMutation();
const addMedia = api.pro.addMedia.useMutation();
const addCredential = api.pro.addCredential.useMutation();
const setEmailMutation = api.user.setEmail.useMutation();
const submit = api.pro.submitForReview.useMutation({
onSuccess: () => router.push('/pro'),
onError: (e) => setError(e.message),
});
const media = initialProfile?.media ?? [];
const credentials = initialProfile?.credentials ?? [];
async function saveProfile() {
setError(null);
const rateCents = Math.round(Number(hourlyRate) * 100);
if (!Number.isFinite(rateCents) || rateCents <= 0) {
setError('Enter your hourly rate.');
return false;
}
try {
await upsert.mutateAsync({
headline,
bio,
hourlyRateCents: rateCents,
yearsExperience: Number(yearsExperience) || 0,
categoryIds,
location,
serviceRadiusM: Math.round(radiusKm * 1000),
});
await utils.pro.me.invalidate();
return true;
} catch (e) {
setError((e as Error).message);
return false;
}
}
return (
<div className="mt-8">
<ol className="mb-8 flex gap-2" aria-label="Progress">
{STEPS.map((label, i) => (
<li key={label} className="flex-1">
<div
className={cn(
'h-1 rounded-full',
i <= step ? 'bg-[var(--color-brand-500)]' : 'bg-[var(--border)]',
)}
/>
<span
className={cn(
'mt-1.5 block text-xs',
i === step ? 'font-medium' : 'text-[var(--muted)]',
)}
>
{label}
</span>
</li>
))}
</ol>
{step === 0 && (
<Section
title="What do you do?"
hint="Pick every trade you actually take jobs for — you will only be shown matching work."
>
<div className="flex flex-wrap gap-2">
{categories.map((c) => {
const selected = categoryIds.includes(c.id);
return (
<button
key={c.id}
type="button"
onClick={() =>
setCategoryIds((prev) =>
selected ? prev.filter((id) => id !== c.id) : [...prev, c.id].slice(0, 5),
)
}
className={cn(
'rounded-full border px-4 py-2 text-sm transition',
selected
? 'border-[var(--color-brand-500)] bg-[var(--color-brand-500)]/10 font-medium'
: 'border-[var(--border)]',
)}
>
{selected && <Check className="mr-1 inline h-3.5 w-3.5" aria-hidden />}
{c.name}
</button>
);
})}
</div>
<Nav
onNext={() => {
if (categoryIds.length === 0) return setError('Pick at least one trade.');
setError(null);
setStep(1);
}}
/>
</Section>
)}
{step === 1 && (
<Section title="About you" hint="This is what a customer reads on your card.">
<Field label="Headline" hint="e.g. Emergency plumber, 15 years in Barcelona">
<input
value={headline}
onChange={(e) => setHeadline(e.target.value)}
maxLength={100}
className={inputClass}
/>
</Field>
<Field label="About your work">
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={5}
maxLength={2000}
className={inputClass}
/>
<span className="text-xs text-[var(--muted)]">{bio.length}/2000, minimum 30</span>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Hourly rate (€)">
<input
value={hourlyRate}
onChange={(e) => setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal"
className={inputClass}
/>
</Field>
<Field label="Years experience">
<input
value={yearsExperience}
onChange={(e) => setYearsExperience(e.target.value.replace(/\D/g, ''))}
inputMode="numeric"
className={inputClass}
/>
</Field>
</div>
<Field
label={`How far will you travel? ${radiusKm} km`}
hint="You will only be shown jobs inside this radius."
>
<input
type="range"
min={MIN_SERVICE_RADIUS_M / 1000}
max={MAX_SERVICE_RADIUS_M / 1000}
value={radiusKm}
onChange={(e) => setRadiusKm(Number(e.target.value))}
className="w-full"
/>
</Field>
<button
type="button"
onClick={() =>
navigator.geolocation?.getCurrentPosition(
(pos) =>
setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setError('We could not get your location. The city centre will be used.'),
)
}
className="text-sm text-[var(--color-brand-500)] underline underline-offset-4"
>
Use my current location as my base
</button>
<Nav
onBack={() => setStep(0)}
busy={upsert.isPending}
onNext={async () => {
if (await saveProfile()) setStep(2);
}}
/>
</Section>
)}
{step === 2 && (
<Section
title="Photos"
hint="A face and a few examples of your work. This is the single biggest thing customers judge you on."
>
<UploadList
kind="pro_photo"
existing={media.map((m) => m.url)}
onUploaded={async (url) => {
await addMedia.mutateAsync({ url });
await utils.pro.me.invalidate();
router.refresh();
}}
onError={setError}
/>
<Nav onBack={() => setStep(1)} onNext={() => setStep(3)} />
</Section>
)}
{step === 3 && (
<Section
title="Documents"
hint="We check these by hand before your profile goes live. They are never shown to customers."
>
{!hasContactableEmail && (
<Field
label="Email address"
hint="Required for pros — payout statements, tax records and dispute notices go here."
>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
<button
type="button"
onClick={async () => {
try {
await setEmailMutation.mutateAsync({ email });
setError(null);
router.refresh();
} catch (e) {
setError((e as Error).message);
}
}}
className="mt-2 text-sm text-[var(--color-brand-500)] underline underline-offset-4"
>
Save email
</button>
</Field>
)}
{(['id', 'insurance', 'licence'] as const).map((kind) => (
<Field
key={kind}
label={
kind === 'id'
? 'Photo ID (required)'
: kind === 'insurance'
? 'Public liability insurance (required)'
: 'Trade licence (if your trade needs one)'
}
>
<UploadList
kind="credential"
existing={credentials.filter((c) => c.kind === kind).map((c) => c.fileKey)}
onUploaded={async (url) => {
await addCredential.mutateAsync({ kind, fileKey: url });
await utils.pro.me.invalidate();
router.refresh();
}}
onError={setError}
/>
</Field>
))}
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={() => setStep(2)}
className="rounded-xl border border-[var(--border)] px-5 py-3 text-sm"
>
Back
</button>
<button
type="button"
onClick={() => submit.mutate()}
disabled={submit.isPending}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white disabled:opacity-60"
>
{submit.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
Submit for review
</button>
</div>
</Section>
)}
{error && (
<p role="alert" className="mt-4 text-sm text-[var(--color-stop-500)]">
{error}
</p>
)}
</div>
);
}
const inputClass =
'w-full rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]';
function Section({
title,
hint,
children,
}: {
title: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<section className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-medium">{title}</h2>
{hint && <p className="mt-1 text-sm text-[var(--muted)]">{hint}</p>}
</div>
{children}
</section>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
{hint && <span className="text-xs text-[var(--muted)]">{hint}</span>}
{children}
</label>
);
}
function Nav({
onBack,
onNext,
busy,
}: {
onBack?: () => void;
onNext: () => void | Promise<void>;
busy?: boolean;
}) {
return (
<div className="mt-4 flex gap-3">
{onBack && (
<button
type="button"
onClick={onBack}
className="rounded-xl border border-[var(--border)] px-5 py-3 text-sm"
>
Back
</button>
)}
<button
type="button"
onClick={() => void onNext()}
disabled={busy}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
Continue
</button>
</div>
);
}
function UploadList({
kind,
existing,
onUploaded,
onError,
}: {
kind: 'pro_photo' | 'credential';
existing: string[];
onUploaded: (url: string) => Promise<void>;
onError: (message: string) => void;
}) {
const [busy, setBusy] = useState(false);
const presign = api.upload.presign.useMutation();
return (
<div className="flex flex-col gap-2">
{existing.length > 0 && (
<ul className="flex flex-wrap gap-2">
{existing.map((url) => (
<li
key={url}
className="flex items-center gap-1.5 rounded-lg border border-[var(--border)] px-3 py-1.5 text-xs"
>
<Check className="h-3.5 w-3.5 text-[var(--color-go-500)]" aria-hidden />
Uploaded
</li>
))}
</ul>
)}
<label
className={cn(
'flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed',
'border-[var(--border)] px-4 py-6 text-sm text-[var(--muted)] hover:border-[var(--color-brand-500)]',
)}
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<Upload className="h-4 w-4" aria-hidden />
)}
{busy ? 'Uploading…' : 'Choose a file'}
<input
type="file"
className="hidden"
accept={kind === 'credential' ? 'image/*,application/pdf' : 'image/*'}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
setBusy(true);
try {
const url = await uploadFile(file, kind, (input) => presign.mutateAsync(input));
await onUploaded(url);
} catch (e) {
onError((e as Error).message);
} finally {
setBusy(false);
event.target.value = '';
}
}}
/>
</label>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { redirect } from 'next/navigation';
import { Clock, ShieldCheck, XCircle } from 'lucide-react';
import { getApi } from '@/server/caller';
export const metadata = { title: 'Your account' };
export const dynamic = 'force-dynamic';
export default async function ProHomePage() {
const api = await getApi();
let me: Awaited<ReturnType<typeof api.user.me>>;
try {
me = await api.user.me();
} catch {
redirect('/sign-in?next=/pro');
}
if (me.role !== 'pro') redirect('/onboarding');
const profile = await api.pro.me();
if (!profile) redirect('/pro/onboarding');
const status = profile.verificationStatus;
return (
<main className="mx-auto max-w-lg px-6 py-10">
<h1 className="text-2xl font-semibold tracking-tight">{profile.headline}</h1>
{status === 'pending' && (
<StatusCard
icon={<Clock className="h-5 w-5" aria-hidden />}
tone="waiting"
title="We are checking your documents"
body="This usually takes a day. We will text you the moment you are live and jobs start arriving."
/>
)}
{status === 'verified' && (
<StatusCard
icon={<ShieldCheck className="h-5 w-5" aria-hidden />}
tone="good"
title="You are live"
body="Customers nearby can see you now. Keep your response time short — it is the second biggest factor in where you appear."
/>
)}
{status === 'rejected' && (
<StatusCard
icon={<XCircle className="h-5 w-5" aria-hidden />}
tone="bad"
title="We could not approve your account"
body="Check your email for what we need. You can update your documents and submit again."
/>
)}
{status === 'suspended' && (
<StatusCard
icon={<XCircle className="h-5 w-5" aria-hidden />}
tone="bad"
title="Your account is suspended"
body={profile.suspendedReason ?? 'Contact support to sort this out.'}
/>
)}
{status === 'draft' && (
<StatusCard
icon={<Clock className="h-5 w-5" aria-hidden />}
tone="waiting"
title="Your profile is not finished"
body="Finish setting up and submit it for review."
/>
)}
<dl className="mt-8 grid grid-cols-2 gap-4 text-sm">
<Stat label="Jobs completed" value={String(profile.completedJobs)} />
<Stat
label="Rating"
value={profile.ratingAvg ? `${Number(profile.ratingAvg).toFixed(1)} / 5` : 'No reviews yet'}
/>
<Stat label="Travels up to" value={`${Math.round(profile.serviceRadiusM / 1000)} km`} />
<Stat label="Rate" value={`${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} />
</dl>
</main>
);
}
function StatusCard({
icon,
tone,
title,
body,
}: {
icon: React.ReactNode;
tone: 'good' | 'waiting' | 'bad';
title: string;
body: string;
}) {
const toneClass =
tone === 'good'
? 'border-[var(--color-go-500)]/30 bg-[var(--color-go-500)]/10'
: tone === 'bad'
? 'border-[var(--color-stop-500)]/30 bg-[var(--color-stop-500)]/10'
: 'border-[var(--border)] bg-[var(--card)]';
return (
<div className={`mt-6 flex gap-3 rounded-2xl border p-5 ${toneClass}`}>
<span className="mt-0.5 shrink-0">{icon}</span>
<span>
<span className="block font-medium">{title}</span>
<span className="mt-1 block text-sm text-[var(--muted)]">{body}</span>
</span>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-[var(--border)] p-4">
<dt className="text-xs text-[var(--muted)]">{label}</dt>
<dd className="mt-1 font-medium">{value}</dd>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* DESIGN.md §6.1. Always a pill, always the display family.
*
* `buttonClasses` is exported separately because Next's <Link> cannot be wrapped
* without a Slot primitive, and adding Radix for one component is not worth it.
* Anchors take `className={buttonClasses({ variant, size })}`.
*/
export const buttonClasses = cva(
[
'inline-flex items-center justify-center gap-2 rounded-pill font-display font-semibold',
'whitespace-nowrap select-none',
'transition-[color,background-color,border-color,opacity] duration-[120ms] ease-standard',
'disabled:pointer-events-none disabled:opacity-45',
],
{
variants: {
variant: {
primary: 'bg-brand-500 text-white hover:bg-brand-600',
dark: 'bg-ink-950 text-white hover:bg-[#1a2145]',
outline: 'border-[1.5px] border-ink-950 text-strong hover:bg-ink-50 dark:border-hairline',
ghost: 'text-accent hover:bg-accent-soft',
danger: 'bg-stop-500 text-white hover:bg-stop-600',
},
size: {
sm: 'h-9 px-4 text-body-sm',
md: 'h-11 px-6 text-body',
lg: 'h-14 px-8 text-body-lg',
},
block: { true: 'w-full', false: '' },
},
defaultVariants: { variant: 'primary', size: 'md', block: false },
},
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonClasses> & {
/** Shows a leading spinner and disables the control. The label never changes. */
busy?: boolean;
};
export function Button({
className,
variant,
size,
block,
busy = false,
disabled,
children,
...props
}: ButtonProps) {
return (
<button
{...props}
disabled={disabled || busy}
aria-busy={busy || undefined}
className={cn(buttonClasses({ variant, size, block }), className)}
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{children}
</button>
);
}
/** Circular icon-only button. §6.1 — requires an accessible label. */
export function IconButton({
label,
tone = 'neutral',
className,
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
label: string;
tone?: 'neutral' | 'go' | 'stop';
}) {
return (
<button
{...props}
aria-label={label}
title={label}
className={cn(
'flex h-16 w-16 items-center justify-center rounded-pill border-2 bg-raised shadow-md',
'transition-[transform,border-color,color] duration-[120ms] ease-standard',
'hover:scale-105 active:scale-95 motion-reduce:hover:scale-100 motion-reduce:active:scale-100',
tone === 'go' && 'border-go-600 text-go-600',
tone === 'stop' && 'border-stop-500 text-stop-500',
tone === 'neutral' && 'border-hairline text-strong',
className,
)}
>
{children}
</button>
);
}
+72 -5
View File
@@ -1,8 +1,9 @@
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin, bearer, phoneNumber } from 'better-auth/plugins';
import { admin, phoneNumber } from 'better-auth/plugins';
import { nextCookies } from 'better-auth/next-js';
import { db, schema } from '@linkder/db';
import { isE164 } from '@linkder/shared';
import { sendVerificationSms } from '@/server/sms';
/**
@@ -37,6 +38,21 @@ if (!secret && process.env.NODE_ENV === 'production') {
throw new Error('AUTH_SECRET is not set. Generate one with: openssl rand -base64 32');
}
/**
* better-auth derives cookie attributes from baseURL. Falling back to
* http://localhost:3000 in production would therefore drop `Secure` and the
* `__Secure-` cookie prefix from the real session cookie, and send OAuth
* callbacks to localhost. A missing app URL is a deployment error, so say so at
* boot rather than serving downgraded cookies.
*/
const appUrl =
process.env.NEXT_PUBLIC_APP_URL ??
(process.env.NODE_ENV === 'production'
? (() => {
throw new Error('NEXT_PUBLIC_APP_URL is not set. Set it to the public https origin.');
})()
: 'http://localhost:3000');
export const auth = betterAuth({
// Passing `schema` explicitly (rather than letting the adapter read
// db._.fullSchema) keeps it from forcing our lazy db Proxy open at module
@@ -67,7 +83,39 @@ export const auth = betterAuth({
verification: { modelName: 'verifications' },
secret,
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
baseURL: appUrl,
/**
* Endpoints we deliberately serve as 404.
*
* `phoneNumber()` registers a full password-reset pair and a password
* sign-in unconditionally — they are NOT gated on
* `emailAndPassword.enabled`, which is false above. Left live they form a
* complete, silent alternative credential path:
*
* 1. POST /phone-number/request-password-reset writes a plaintext OTP to
* `verifications` and sends NO SMS, because we never configured
* `sendPasswordResetOTP` — so the account owner is never told.
* 2. POST /phone-number/reset-password mints a `credential` account row
* holding a bcrypt password.
* 3. POST /sign-in/phone-number then accepts { phoneNumber, password }
* forever, with no OTP.
*
* The OTP in step 1 is still gated (3 attempts, 300s, rate limited), so this
* is not a remote unauthenticated takeover. What it *is*: a way to convert a
* single momentary OTP compromise — a SIM swap, a glanced-at lock screen —
* into permanent access that survives the victim re-verifying their phone,
* with no notification and no password the victim can see or rotate. This
* platform has exactly one credential: a live OTP. Keep it that way.
*
* `disabledPaths` is checked in the router's onRequest, before rate limiting
* and before the handler, and returns 404.
*/
disabledPaths: [
'/phone-number/request-password-reset',
'/phone-number/reset-password',
'/sign-in/phone-number',
],
advanced: {
database: {
@@ -95,6 +143,16 @@ export const auth = betterAuth({
otpLength: 6,
expiresIn: 300,
allowedAttempts: 3,
/**
* `users.phone` is UNIQUE and bans are per-account, so the stored string
* form is load-bearing: if "+34600111222" and "0034600111222" can both be
* written, one handset holds two "unique" accounts, a ban is escapable by
* retyping, and findPossibleDuplicates cannot see the pair. Callers must
* send E.164 — normalise with toE164() from @linkder/shared before
* calling. This runs on both /phone-number/send-otp and
* /sign-in/phone-number.
*/
phoneNumberValidator: (phone) => isE164(phone),
signUpOnVerification: {
/**
* better-auth requires a unique, non-null email. Phone-first users do
@@ -118,9 +176,18 @@ export const auth = betterAuth({
adminRoles: ['admin'],
}),
// Lets a future React Native client authenticate with
// `Authorization: Bearer <token>` instead of a cookie.
bearer(),
/**
* NOT bearer(): it accepts the raw `sessions.token` value as an
* `Authorization: Bearer` credential. That column is stored in plaintext,
* so with bearer() enabled anything that can read one row out of `sessions`
* — a log line, a backup, a SQL injection, a support screenshot — holds a
* directly replayable login for that session's full lifetime. Cookies at
* least require the httpOnly cookie to be exfiltrated from a browser.
*
* The mobile client this was added for does not exist yet. When it does,
* give it its own signed, short-lived access token rather than handing out
* the session row's primary secret.
*/
// Must be last — it wraps the handler to set cookies on Next responses.
nextCookies(),
+5
View File
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query';
import { deserialize, serialize } from 'superjson';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@linkder/api';
// Explicit annotation: pnpm's strict node_modules layout means the inferred
@@ -61,3 +62,7 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
</api.Provider>
);
}
/** Convenience aliases so components can name API shapes without re-deriving them. */
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;
+47
View File
@@ -0,0 +1,47 @@
import type { UploadKind } from '@linkder/storage';
interface PresignResult {
url: string;
key: string;
publicUrl: string | null;
}
/**
* Ask the server for a presigned PUT, then send the bytes straight to R2.
*
* The file never passes through our server: a 15 MB licence scan would blow the
* request body limit and pay for the bandwidth twice. The server still controls
* the key and pins the content type and length, so the browser cannot choose
* where the object lands.
*/
export async function uploadFile(
file: File,
kind: UploadKind,
presign: (input: {
kind: UploadKind;
contentType: string;
contentLength: number;
}) => Promise<PresignResult>,
): Promise<string> {
const signed = await presign({
kind,
contentType: file.type || 'application/octet-stream',
contentLength: file.size,
});
const response = await fetch(signed.url, {
method: 'PUT',
// Must match exactly what was signed, or R2 rejects the request.
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
if (!response.ok) {
throw new Error(`Upload failed (${response.status}). Try again.`);
}
// A private kind (credentials) has no public URL, so the caller gets the
// object KEY. Callers must store it in a key-typed column — putting it where
// a URL is expected fails validation, which is how this was caught.
return signed.publicUrl ?? signed.key;
}
+225 -30
View File
@@ -1,53 +1,248 @@
@import 'tailwindcss';
/*
* Linkder design tokens — see DESIGN.md at the repo root.
* Hex values are sampled from wix.com and are normative. Do not hand-tune them
* in a component; change them here or add a step to the ramp.
*/
@theme {
--color-ink-50: oklch(0.98 0.005 260);
--color-ink-100: oklch(0.95 0.008 260);
--color-ink-200: oklch(0.89 0.012 260);
--color-ink-400: oklch(0.65 0.02 260);
--color-ink-600: oklch(0.45 0.025 260);
--color-ink-800: oklch(0.26 0.03 260);
--color-ink-950: oklch(0.15 0.03 260);
/* ── Brand: action blue. §2.1 ───────────────────────────────────────── */
--color-brand-50: #edf3ff;
--color-brand-100: #dce8ff;
--color-brand-200: #95b9ff;
--color-brand-400: #5e97ff;
--color-brand-500: #116dff;
--color-brand-600: #094bcc;
--color-brand-700: #082f7b;
--color-brand-400: oklch(0.72 0.15 25);
--color-brand-500: oklch(0.64 0.19 25);
--color-brand-600: oklch(0.56 0.2 25);
/* ── Ink: text and surfaces. §2.2 ───────────────────────────────────── */
--color-ink-0: #ffffff;
--color-ink-50: #f7f8f8;
--color-ink-100: #f0f0f0;
--color-ink-200: #e2e2e2;
--color-ink-300: #c2c2c2;
--color-ink-400: #b0b0b0;
--color-ink-500: #8f8f8f;
--color-ink-600: #6e6e6e;
--color-ink-800: #212121;
--color-ink-950: #000624;
--color-go-500: oklch(0.7 0.17 150);
--color-stop-500: oklch(0.64 0.2 20);
/* ── Semantic. §2.3 ─────────────────────────────────────────────────── */
--color-go-50: #f3f8f0;
--color-go-100: #d2e4c7;
--color-go-400: #92b079;
--color-go-600: #618741;
--color-go-700: #39641d;
--color-sun-50: #ffe9df;
--color-sun-100: #ffbfa1;
--color-sun-400: #fa854f;
--color-sun-500: #ea6020;
--color-sun-600: #c94001;
--color-stop-50: #ffecec;
--color-stop-100: #ffc9cb;
--color-stop-400: #ff6a70;
--color-stop-500: #ed1c24;
--color-stop-600: #c4141b;
/* ── Type. §3 ───────────────────────────────────────────────────────── */
--font-sans: var(--font-madefor-text), 'Segoe UI', system-ui, -apple-system, sans-serif;
--font-display: var(--font-madefor-display), 'Segoe UI', system-ui, -apple-system, sans-serif;
--text-display-xl: clamp(2.75rem, 6vw, 4.5rem);
--text-display-xl--line-height: 1.04;
--text-display-xl--letter-spacing: -0.03em;
--text-display-xl--font-weight: 700;
--text-display-lg: clamp(2.25rem, 4.5vw, 3.25rem);
--text-display-lg--line-height: 1.08;
--text-display-lg--letter-spacing: -0.025em;
--text-display-lg--font-weight: 700;
--text-h1: clamp(1.875rem, 3.5vw, 2.5rem);
--text-h1--line-height: 1.12;
--text-h1--letter-spacing: -0.02em;
--text-h1--font-weight: 700;
--text-h2: clamp(1.5rem, 2.5vw, 2rem);
--text-h2--line-height: 1.18;
--text-h2--letter-spacing: -0.02em;
--text-h2--font-weight: 700;
--text-h3: 1.375rem;
--text-h3--line-height: 1.25;
--text-h3--letter-spacing: -0.015em;
--text-h3--font-weight: 600;
--text-h4: 1.125rem;
--text-h4--line-height: 1.35;
--text-h4--letter-spacing: -0.01em;
--text-h4--font-weight: 600;
--text-body-lg: 1.125rem;
--text-body-lg--line-height: 1.55;
--text-body-lg--letter-spacing: 0em;
--text-body: 1rem;
--text-body--line-height: 1.6;
--text-body--letter-spacing: 0em;
--text-body-sm: 0.875rem;
--text-body-sm--line-height: 1.5;
--text-body-sm--letter-spacing: 0em;
--text-meta: 0.75rem;
--text-meta--line-height: 1.45;
--text-meta--letter-spacing: 0em;
--text-meta--font-weight: 500;
--text-overline: 0.75rem;
--text-overline--line-height: 1.4;
--text-overline--letter-spacing: 0.08em;
--text-overline--font-weight: 700;
/* ── Radius. §5 ─────────────────────────────────────────────────────── */
--radius-md: 8px;
--radius-lg: 12px;
--radius-card: 18px;
--radius-deck: 28px;
--radius-pill: 999px;
/* ── Elevation. §5 — tinted with ink-950, never pure black. ─────────── */
--shadow-sm: 0 1px 2px rgb(0 6 36 / 0.06), 0 1px 3px rgb(0 6 36 / 0.04);
--shadow-md: 0 4px 12px rgb(0 6 36 / 0.08), 0 2px 4px rgb(0 6 36 / 0.04);
--shadow-lg: 0 12px 32px rgb(0 6 36 / 0.12), 0 4px 8px rgb(0 6 36 / 0.06);
/* ── Containers. §4 ─────────────────────────────────────────────────── */
--container-prose: 680px;
--container-app: 1080px;
--container-wide: 1280px;
/* ── Motion. §7 ─────────────────────────────────────────────────────── */
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
}
/*
* Semantic layer. Components reference these, not the ramps directly, so light
* and dark are one set of classes rather than a `dark:` variant on every node.
*/
:root {
--bg: var(--color-ink-50);
--fg: var(--color-ink-950);
--card: white;
--muted: var(--color-ink-600);
--border: var(--color-ink-200);
--surface-page: var(--color-ink-0);
--surface-raised: var(--color-ink-0);
--surface-sunken: var(--color-ink-50);
--surface-inset: var(--color-ink-100);
--text-strong: var(--color-ink-950);
--text-muted: var(--color-ink-600);
--text-faint: var(--color-ink-500);
--hairline: var(--color-ink-200);
--accent: var(--color-brand-500);
--accent-hover: var(--color-brand-600);
--accent-soft: var(--color-brand-50);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: var(--color-ink-950);
--fg: var(--color-ink-50);
--card: var(--color-ink-800);
--muted: var(--color-ink-400);
--border: color-mix(in oklch, var(--color-ink-400) 25%, transparent);
--surface-page: var(--color-ink-950);
--surface-raised: #0c1230;
--surface-sunken: #070c22;
--surface-inset: #141a3a;
--text-strong: var(--color-ink-0);
--text-muted: var(--color-ink-400);
--text-faint: var(--color-ink-500);
--hairline: rgb(255 255 255 / 0.14);
/* brand-500 is only 4.4:1 on ink-950 — lighten the accent, not the fill. */
--accent: var(--color-brand-400);
--accent-hover: var(--color-brand-200);
--accent-soft: rgb(94 151 255 / 0.14);
}
}
html,
body {
background: var(--bg);
color: var(--fg);
/* `inline` so the utilities resolve the var at use-site and flip with the scheme. */
@theme inline {
--color-page: var(--surface-page);
--color-raised: var(--surface-raised);
--color-sunken: var(--surface-sunken);
--color-inset: var(--surface-inset);
--color-strong: var(--text-strong);
--color-muted: var(--text-muted);
--color-faint: var(--text-faint);
--color-hairline: var(--hairline);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-soft: var(--accent-soft);
}
body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
@layer base {
html {
background-color: var(--surface-page);
color: var(--text-strong);
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--font-sans);
font-size: var(--text-body);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* §3.3 — display family and negative tracking are structural, not opt-in. */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display);
color: var(--text-strong);
text-wrap: balance;
}
p {
text-wrap: pretty;
}
/* §8 — one focus treatment, everywhere, and it is always visible. */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-md);
}
::selection {
background-color: var(--color-brand-100);
color: var(--color-ink-950);
}
::placeholder {
color: var(--text-faint);
}
/* Prices and counters must not jitter as they change. §3.3 */
input[inputmode='decimal'],
input[inputmode='numeric'] {
font-variant-numeric: tabular-nums;
}
}
/* The deck is drag-driven; stop the browser from hijacking the gesture. */
/* The deck is a drag surface — stop the browser hijacking the gesture. §6.8 */
.deck-card {
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
/* §7 — nothing non-essential moves when the user has asked for stillness. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}