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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user