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
+327
View File
@@ -0,0 +1,327 @@
# Linkder Design System
**Version 1.0** · derived from the wix.com design language, adapted for Linkder.
This document is the single source of truth for how Linkder looks. Every screen must be
buildable from the tokens and components below. If a screen needs something that is not in
here, add it here first, then build it.
## Provenance and scope
The visual language is adapted from **wix.com** (sampled 2026-08-21): the Madefor typeface
pair, the signature action blue, the navy-black ink, the pill geometry and the generous
whitespace rhythm. Values below were read off the live stylesheet, not eyeballed.
Adopted: typefaces (Wix Madefor Display / Text, published under the SIL Open Font License via
Google Fonts), colour ramps, radius and spacing conventions, component geometry.
**Not** adopted: Wix's logo, wordmark, product names, illustrations or photography. Linkder is
not affiliated with Wix and must never present itself as such.
---
## 1. Principles
1. **Blue means action.** `brand-500` is reserved for things the user can do. It is never
decoration, never a background wash for a whole section, never body text.
2. **Ink is near-black, not grey.** Headings sit at `ink-950` (`#000624`) — black with a blue
cast. Grey text is a deliberate demotion, not a default.
3. **Type carries the design.** Big, tight display type over ornament. Negative tracking on
anything above 24px; zero tracking on body.
4. **Pills for actions, soft rectangles for containers.** A pill is clickable. A rounded
rectangle holds content. Never the reverse.
5. **Whitespace over borders.** Separate with space first, a hairline second, a shadow last.
---
## 2. Colour
All values are sampled from wix.com. Hex is normative.
### 2.1 Brand — action blue
| Token | Hex | Use |
|---|---|---|
| `brand-50` | `#EDF3FF` | Hover fill on ghost / outline controls |
| `brand-100` | `#DCE8FF` | Selected-chip fill, info banner background |
| `brand-200` | `#95B9FF` | Focus ring halo, disabled primary |
| `brand-400` | `#5E97FF` | **Dark-mode** link and accent text |
| `brand-500` | `#116DFF` | **Primary action.** Buttons, links, active states |
| `brand-600` | `#094BCC` | Primary hover / pressed |
| `brand-700` | `#082F7B` | Primary active on dark surfaces |
`brand-500` on white is **4.5:1** — it clears AA for normal text, but only just. Do not tint it
lighter for text. On dark surfaces use `brand-400` (7.0:1 on `ink-950`) instead.
### 2.2 Ink — text and surfaces
| Token | Hex | Use |
|---|---|---|
| `ink-0` | `#FFFFFF` | Card surface, page background (light) |
| `ink-50` | `#F7F8F8` | Alternating section background |
| `ink-100` | `#F0F0F0` | Inset fills, skeletons, track backgrounds |
| `ink-200` | `#E2E2E2` | **Hairline borders** |
| `ink-300` | `#C2C2C2` | Border on dark, disabled border |
| `ink-400` | `#B0B0B0` | **Dark-mode** secondary text |
| `ink-500` | `#8F8F8F` | Placeholder and disabled text **only** (3.2:1 — never body copy) |
| `ink-600` | `#6E6E6E` | **Secondary body text** (5.1:1 on white) |
| `ink-800` | `#212121` | Body text on tinted surfaces |
| `ink-950` | `#000624` | **Headings, primary text, dark surfaces** |
### 2.3 Semantic
| Token | Hex | Meaning |
|---|---|---|
| `go-50` | `#F3F8F0` | Success banner background |
| `go-100` | `#D2E4C7` | Success border |
| `go-400` | `#92B079` | Success on dark |
| `go-600` | `#618741` | **Success** — verified, accepted, right-swipe |
| `go-700` | `#39641D` | Success pressed |
| `sun-50` | `#FFE9DF` | Warning / pending banner background |
| `sun-100` | `#FFBFA1` | Warning border |
| `sun-400` | `#FA854F` | Warning on dark |
| `sun-500` | `#EA6020` | **Warning** — pending review, expiring soon |
| `sun-600` | `#C94001` | Warning pressed |
| `stop-50` | `#FFECEC` | Error banner background |
| `stop-100` | `#FFC9CB` | Error border |
| `stop-400` | `#FF6A70` | Error on dark |
| `stop-500` | `#ED1C24` | **Error / destructive** — rejected, left-swipe |
| `stop-600` | `#C4141B` | Error pressed |
Never use green/red as the *only* signal. Pair with an icon and a word.
### 2.4 Dark mode
Dark mode inverts the ink ramp and lightens the accent. It is not a separate palette.
| Role | Light | Dark |
|---|---|---|
| Page background | `ink-0` | `ink-950` |
| Raised surface (card) | `ink-0` | `#0C1230` |
| Sunken surface | `ink-50` | `#070C22` |
| Primary text | `ink-950` | `ink-0` |
| Secondary text | `ink-600` | `ink-400` |
| Hairline | `ink-200` | `rgb(255 255 255 / .14)` |
| Accent text / link | `brand-500` | `brand-400` |
| Filled primary button | `brand-500` bg, white text | unchanged |
---
## 3. Typography
### 3.1 Families
| Role | Family | Weights |
|---|---|---|
| Display — h1h3, big numbers, buttons | **Wix Madefor Display** | 400800 variable |
| Text — body, labels, inputs, meta | **Wix Madefor Text** | 400800 variable |
Fallback stack: `'Segoe UI', system-ui, -apple-system, sans-serif`.
Load via `next/font/google` with `display: 'swap'`, exposed as CSS variables. Never link a
stylesheet in `<head>` — it costs a render-blocking round trip.
### 3.2 Scale
Display sizes use `clamp()` so one token works from 360px to desktop.
| Token | Size | Line | Tracking | Weight | Family |
|---|---|---|---|---|---|
| `display-xl` | `clamp(2.75rem, 6vw, 4.5rem)` | 1.04 | `-0.03em` | 700 | Display |
| `display-lg` | `clamp(2.25rem, 4.5vw, 3.25rem)` | 1.08 | `-0.025em` | 700 | Display |
| `h1` | `clamp(1.875rem, 3.5vw, 2.5rem)` | 1.12 | `-0.02em` | 700 | Display |
| `h2` | `clamp(1.5rem, 2.5vw, 2rem)` | 1.18 | `-0.02em` | 700 | Display |
| `h3` | `1.375rem` | 1.25 | `-0.015em` | 600 | Display |
| `h4` | `1.125rem` | 1.35 | `-0.01em` | 600 | Display |
| `body-lg` | `1.125rem` | 1.55 | `0` | 400 | Text |
| `body` | `1rem` | 1.6 | `0` | 400 | Text |
| `body-sm` | `0.875rem` | 1.5 | `0` | 400 | Text |
| `meta` | `0.75rem` | 1.45 | `0` | 500 | Text |
| `overline` | `0.75rem` | 1.4 | `0.08em` | 700 | Text, uppercase |
### 3.3 Rules
- **One display element per view.** A page has one `h1`. A card has none.
- Tracking is negative above 24px and exactly zero below it. Never positive except `overline`.
- Body copy maxes at **68 characters** (`max-w-[68ch]`).
- Headlines use `text-balance`; paragraphs use `text-pretty`.
- Prices, counters and table figures use `tabular-nums`.
---
## 4. Space and layout
An **8px grid**. The only legal spacing values are `4 8 12 16 24 32 40 56 72 96 128`. Anything
else is a bug.
| Container | Width | Used for |
|---|---|---|
| `prose` | 680px | Reading, forms, single-column flows |
| `app` | 1080px | Dashboards, lists |
| `wide` | 1280px | Marketing sections |
- Page gutter: **24px** mobile, **32px** ≥768px.
- Vertical rhythm between page sections: **72px** mobile, **96px** desktop.
- Gap between sibling cards in a list: **12px**.
- Label → control: **8px**. Control → helper text: **8px**. Field → field: **24px**.
---
## 5. Radius and elevation
| Token | Value | Applies to |
|---|---|---|
| `radius-pill` | `999px` | **All buttons**, chips, avatars, badges |
| `radius-card` | `18px` | Cards, banners, modals |
| `radius-lg` | `12px` | Inputs, list rows, small containers |
| `radius-md` | `8px` | Tags, thumbnails, inline code |
| `radius-deck` | `28px` | The swipe card only |
Elevation is restrained. Four steps, no more:
| Token | Shadow | Use |
|---|---|---|
| `shadow-none` | — | Default. Most surfaces are flat with a hairline. |
| `shadow-sm` | `0 1px 2px rgb(0 6 36 / .06), 0 1px 3px rgb(0 6 36 / .04)` | Hover on a list row |
| `shadow-md` | `0 4px 12px rgb(0 6 36 / .08), 0 2px 4px rgb(0 6 36 / .04)` | Sticky header, popovers |
| `shadow-lg` | `0 12px 32px rgb(0 6 36 / .12), 0 4px 8px rgb(0 6 36 / .06)` | Deck card, modal |
Shadows are tinted with `#000624`, never pure black.
---
## 6. Components
### 6.1 Button
Always a pill. Always Display family, weight 600. Label is sentence case, never ALL CAPS.
| Variant | Fill | Text | Border | Hover |
|---|---|---|---|---|
| `primary` | `brand-500` | white | none | `brand-600` |
| `dark` | `ink-950` | white | none | `#1A2145` |
| `outline` | transparent | `ink-950` | `1.5px ink-950` | `ink-50` fill |
| `ghost` | transparent | `brand-500` | none | `brand-50` fill |
| `danger` | `stop-500` | white | none | `stop-600` |
| Size | Height | Padding-x | Font |
|---|---|---|---|
| `sm` | 36px | 16px | `body-sm` |
| `md` | 44px | 24px | `body` |
| `lg` | 56px | 32px | `body-lg` |
Control heights are their own scale — **36 / 44 / 56** for buttons, **48** for inputs. They are
exempt from the 8px spacing grid in §4, which governs margins, padding and gaps.
- Minimum touch target is **44px**. `sm` is desktop-only or must sit inside a 44px row.
- Disabled: `opacity: .45`, `cursor: not-allowed`. Never grey out by swapping colour.
- Busy: leading 16px spinner, label unchanged, control disabled. Never replace the label.
- Icon-only buttons are circles and require `aria-label`.
### 6.2 Input, textarea, select
- `radius-lg` (12px), `1.5px ink-200` border, `ink-0` fill, **16px** text (never smaller — iOS
zooms below 16px), padding `12px 16px`, height 48px.
- Focus: border `brand-500` + `0 0 0 3px brand-200`. Never remove the outline without
replacing it.
- Error: border `stop-500`, message below in `body-sm` `stop-500` with `role="alert"`.
- Placeholder is `ink-500` and never carries meaning the label doesn't.
- Every control has a visible `<label>`. Placeholder-as-label is forbidden.
### 6.3 Card
`ink-0` surface, `radius-card`, `1px ink-200` hairline, `shadow-none`. Padding 24px (20px below
640px). Interactive cards raise to `shadow-sm` and shift the border to `brand-500` on hover;
they do not lift or scale.
### 6.4 Chip / selectable tag
Pill, `1.5px` border, `12px 16px` padding, `body-sm`. Unselected: `ink-200` border on
transparent. Selected: `brand-500` border, `brand-100` fill, weight 600, leading check icon.
Selection must never rely on fill alone.
### 6.5 Banner / status card
`radius-card`, 20px padding, 12px icon-to-text gap, tinted surface + matching border:
| Tone | Fill | Border | Icon |
|---|---|---|---|
| `info` | `brand-100` | `brand-200` | `brand-500` |
| `success` | `go-50` | `go-100` | `go-600` |
| `warning` | `sun-50` | `sun-100` | `sun-500` |
| `error` | `stop-50` | `stop-100` | `stop-500` |
Title in `h4`, body in `body-sm` `ink-600`.
### 6.6 Navigation
Sticky top bar, 72px tall, `ink-0` at 88% opacity with `backdrop-blur(12px)`, hairline bottom
border. Wordmark left in Display 700 `-0.02em`. Primary CTA right as a pill. Mobile collapses
links; the CTA stays visible in the bar.
### 6.7 Footer
`ink-950` surface, white text, secondary links `ink-400`, 72px top padding, 40px bottom,
4-up column grid collapsing to 1-up under 640px.
### 6.8 Deck card
The one place that breaks the flat rule. `radius-deck` (28px), `shadow-lg`, full-bleed photo,
bottom scrim `linear-gradient(to top, rgb(0 6 36 / .92), rgb(0 6 36 / .35) 45%, transparent)`.
Overlay stamps: `SEND JOB` in `go-600`, `PASS` in `stop-500`, 4px border, ±12° rotation. Action
buttons are 64px circles, 2px border, `ink-0` fill.
---
## 7. Motion
| Token | Duration | Easing | Use |
|---|---|---|---|
| `motion-fast` | 120ms | `cubic-bezier(.4,0,.2,1)` | Hover, focus, colour |
| `motion-base` | 200ms | `cubic-bezier(.4,0,.2,1)` | Enter / exit, expand |
| `motion-slow` | 320ms | `cubic-bezier(.22,1,.36,1)` | Page and card transitions |
Transition `color`, `background-color`, `border-color`, `opacity`, `transform` — never `all`.
Non-essential animation is wrapped in `@media (prefers-reduced-motion: no-preference)`. The
deck's drag is essential and stays; its spring flattens under reduced motion.
---
## 8. Accessibility floor
- Body text ≥ 4.5:1, large text and UI borders ≥ 3:1. `ink-500` fails body text — placeholder only.
- Visible focus on every interactive element: `0 0 0 3px brand-200` plus a `brand-500` border.
- Touch targets ≥ 44×44px.
- Colour is never the sole carrier of meaning.
- One `h1` per page, headings in order, no level skipped for size.
- Decorative images `alt=""`; icons inside labelled controls `aria-hidden`.
---
## 9. Do / Don't
| Don't | Do |
|---|---|
| Square-cornered buttons | `radius-pill`, always |
| `brand-500` as a section background | White or `ink-50`; blue for the CTA inside it |
| Grey `#8F8F8F` body copy | `ink-600` for secondary text |
| Positive letter-spacing on headings | `-0.02em` and tighter |
| Three shadows on one screen | One raised element per view |
| `transition: all` | Name the properties |
| Placeholder standing in for a label | Visible `<label>` every time |
| Arbitrary values like `py-[13px]` | The 8px scale |
| A second accent colour | Blue is the only accent |
---
## 10. Where this lives in code
| Concern | File |
|---|---|
| Token definitions (`@theme`), base layer, dark mode | `apps/web/src/styles/globals.css` |
| Font loading, chrome wiring | `apps/web/src/app/layout.tsx` |
| Button, Input, Card, Chip, Banner, Field primitives | `apps/web/src/components/ui/` |
| Site header and footer | `apps/web/src/components/chrome/` |
Screens compose primitives. A screen that writes its own `rounded-xl bg-[#...]` has bypassed the
system — fix the primitive instead.
+76
View File
@@ -0,0 +1,76 @@
# Open security findings (M1 auth review)
An adversarial review of the M1 auth, session and authorization code produced 28
confirmed findings (~20 after deduplication). The ones **fixed** are listed first
so nobody re-reports them; the rest are open and are the M1 exit criteria.
## Fixed
| # | Issue | Fix |
|---|---|---|
| 1 | `pro.upsertProfile` computed `requiresReReview` and never applied it — a verified plumber could silently become a verified electrician 40 km away | Demotes a `verified` profile to `pending` in the same transaction, writes an audit row, and now counts a **trade change** as material (it did not before). Needed a new `verified -> pending` edge in `VERIFICATION_GRAPH` |
| 2 | `phoneNumber()` registers `/phone-number/request-password-reset`, `/phone-number/reset-password` and `/sign-in/phone-number` **unconditionally** — not gated on `emailAndPassword.enabled: false`. Together they mint a password credential with no SMS sent to the owner, then accept it forever with no OTP | All three served as 404 via `disabledPaths` (checked in `onRequest`, before rate limiting) |
| 3 | `bearer()` accepted the raw plaintext `sessions.token` column as an `Authorization: Bearer` credential — one leaked DB row is a replayable login | Plugin removed. The mobile client it was for does not exist yet |
| 4 | Phone numbers stored exactly as typed, so `+34600111222` and `0034600111222` are two "unique" accounts — defeating the UNIQUE constraint, bans, and duplicate detection | `phoneNumberValidator` pins E.164 on send-otp and sign-in; `toE164`/`isE164` added to `@linkder/shared` |
| 5 | `NEXT_PUBLIC_APP_URL` fell back to `http://localhost:3000`, which drops `Secure` and the `__Secure-` prefix from the production session cookie | Throws at boot in production |
## Open — must close before M1 ships
**Authorization / attribution**
- `resolveSession` discards `session.impersonatedBy`, so every admin action taken
while impersonating is written to `audit_log` as the **victim**. The `Session`
type has no field for it, so no procedure can tell. Admin impersonation is
enabled (`admin()` plugin) and is currently invisible to the application.
*Fix:* add `impersonatedBy` to `Session`, populate it in `resolveSession`, and
record it on all three `audit_log` insert sites.
- `user.setRole` is a check-then-write across two connections; a concurrent
`job.create` permanently strands the job it was meant to protect.
- `deck.swipe` checks `job.status` outside its transaction — a concurrent
`job.cancel` loses the race and a pending request lands on a cancelled job.
- `deck.undo`'s "already reached the pro" guard is check-then-write: it deletes
the tombstone for a request that really was sent, and reports success.
**Storage / uploads**
- Government ID and insurance scans go to the **public** bucket;
`isPrivateKind` is never called from production code.
- Presigned PUT does not pin `Content-Type`, so a caller can store HTML under an
image key in a public bucket.
- `pro.addMedia`, `pro.addCredential` and `job.create` accept **any** URL the
caller sends, with no binding to an object that caller actually uploaded —
which bypasses the presign design entirely.
**Abuse / spend**
- No per-number cap, destination allowlist, or global ceiling on
`/phone-number/send-otp`. SMS spend is unmetered.
- better-auth rate limiting is per-process in-memory and collapses to one global
bucket behind a proxy chain — 10 req/min then locks out the whole platform.
Needs the Redis-backed `rateLimit.customStorage`, not `secondary-storage`
(which throws unless all five `SecondaryStorage` methods exist).
**Lower severity**
- `user.setEmail` lets any authenticated user claim an arbitrary *unverified*
address, locking the real owner out of Google sign-up and leaking which
addresses are registered.
- Open redirect: the `next` query param on the sign-in form goes straight to
`router.push`.
- `audit_log.ip` trusts the leftmost `X-Forwarded-For` entry verbatim —
attacker-controlled.
- `findPossibleDuplicates` has no call site; the guarantee it was written to
provide never executes.
- `DeckClient` renders a frozen `initialCards` prop and invalidates a query
nothing subscribes to, capping the deck at 20 cards per page load.
- A right swipe after a left swipe creates a live request while the stored swipe
row still records a rejection.
## Note on the OTP-in-plaintext decision
`apps/web/src/lib/auth.ts` argues the plaintext OTP is acceptable partly because
"anyone who can read that table can already read `session.token`". With
`bearer()` removed that argument is weaker than when it was written — a session
token now requires an httpOnly browser cookie to use, an OTP does not. The
decision still stands (300s window, 3 attempts, rate limited) but revisit it if
OTP lifetime or attempt budget ever increases.
+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 { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle'; 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 { nextCookies } from 'better-auth/next-js';
import { db, schema } from '@linkder/db'; import { db, schema } from '@linkder/db';
import { isE164 } from '@linkder/shared';
import { sendVerificationSms } from '@/server/sms'; 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'); 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({ export const auth = betterAuth({
// Passing `schema` explicitly (rather than letting the adapter read // Passing `schema` explicitly (rather than letting the adapter read
// db._.fullSchema) keeps it from forcing our lazy db Proxy open at module // db._.fullSchema) keeps it from forcing our lazy db Proxy open at module
@@ -67,7 +83,39 @@ export const auth = betterAuth({
verification: { modelName: 'verifications' }, verification: { modelName: 'verifications' },
secret, 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: { advanced: {
database: { database: {
@@ -95,6 +143,16 @@ export const auth = betterAuth({
otpLength: 6, otpLength: 6,
expiresIn: 300, expiresIn: 300,
allowedAttempts: 3, 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: { signUpOnVerification: {
/** /**
* better-auth requires a unique, non-null email. Phone-first users do * better-auth requires a unique, non-null email. Phone-first users do
@@ -118,9 +176,18 @@ export const auth = betterAuth({
adminRoles: ['admin'], adminRoles: ['admin'],
}), }),
// Lets a future React Native client authenticate with /**
// `Authorization: Bearer <token>` instead of a cookie. * NOT bearer(): it accepts the raw `sessions.token` value as an
bearer(), * `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. // Must be last — it wraps the handler to set cookies on Next responses.
nextCookies(), nextCookies(),
+5
View File
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client'; import { httpBatchLink } from '@trpc/client';
import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query'; import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query';
import { deserialize, serialize } from 'superjson'; import { deserialize, serialize } from 'superjson';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@linkder/api'; import type { AppRouter } from '@linkder/api';
// Explicit annotation: pnpm's strict node_modules layout means the inferred // Explicit annotation: pnpm's strict node_modules layout means the inferred
@@ -61,3 +62,7 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
</api.Provider> </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'; @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 { @theme {
--color-ink-50: oklch(0.98 0.005 260); /* ── Brand: action blue. §2.1 ───────────────────────────────────────── */
--color-ink-100: oklch(0.95 0.008 260); --color-brand-50: #edf3ff;
--color-ink-200: oklch(0.89 0.012 260); --color-brand-100: #dce8ff;
--color-ink-400: oklch(0.65 0.02 260); --color-brand-200: #95b9ff;
--color-ink-600: oklch(0.45 0.025 260); --color-brand-400: #5e97ff;
--color-ink-800: oklch(0.26 0.03 260); --color-brand-500: #116dff;
--color-ink-950: oklch(0.15 0.03 260); --color-brand-600: #094bcc;
--color-brand-700: #082f7b;
--color-brand-400: oklch(0.72 0.15 25); /* ── Ink: text and surfaces. §2.2 ───────────────────────────────────── */
--color-brand-500: oklch(0.64 0.19 25); --color-ink-0: #ffffff;
--color-brand-600: oklch(0.56 0.2 25); --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); /* ── Semantic. §2.3 ─────────────────────────────────────────────────── */
--color-stop-500: oklch(0.64 0.2 20); --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 { :root {
--bg: var(--color-ink-50); --surface-page: var(--color-ink-0);
--fg: var(--color-ink-950); --surface-raised: var(--color-ink-0);
--card: white; --surface-sunken: var(--color-ink-50);
--muted: var(--color-ink-600); --surface-inset: var(--color-ink-100);
--border: var(--color-ink-200); --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) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: var(--color-ink-950); --surface-page: var(--color-ink-950);
--fg: var(--color-ink-50); --surface-raised: #0c1230;
--card: var(--color-ink-800); --surface-sunken: #070c22;
--muted: var(--color-ink-400); --surface-inset: #141a3a;
--border: color-mix(in oklch, var(--color-ink-400) 25%, transparent); --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, /* `inline` so the utilities resolve the var at use-site and flip with the scheme. */
body { @theme inline {
background: var(--bg); --color-page: var(--surface-page);
color: var(--fg); --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 { @layer base {
-webkit-font-smoothing: antialiased; html {
text-rendering: optimizeLegibility; 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 { .deck-card {
touch-action: none; touch-action: none;
user-select: 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;
}
} }
+50 -6
View File
@@ -67,11 +67,44 @@ export const proRouter = router({
// Editing a live profile sends it back for review — a verified plumber must // Editing a live profile sends it back for review — a verified plumber must
// not be able to quietly become an unverified electrician. // not be able to quietly become an unverified electrician.
const materiallyChanged = //
// The trade list is part of "material": it is the single most important
// thing verification actually checks (the licence is per-trade), so a
// change of trade matters more than a change of radius.
const previousCategoryIds = existing
? (
await ctx.db
.select({ categoryId: schema.proCategories.categoryId })
.from(schema.proCategories)
.where(eq(schema.proCategories.proId, ctx.session.userId))
)
.map((c) => c.categoryId)
.sort()
: [];
const nextCategoryIds = [...input.categoryIds].sort();
const materiallyChanged = Boolean(
existing && existing &&
(existing.serviceRadiusM !== input.serviceRadiusM || (existing.serviceRadiusM !== input.serviceRadiusM ||
existing.baseLocation.lat !== input.location.lat || existing.baseLocation.lat !== input.location.lat ||
existing.baseLocation.lng !== input.location.lng); existing.baseLocation.lng !== input.location.lng ||
previousCategoryIds.length !== nextCategoryIds.length ||
previousCategoryIds.some((id, i) => id !== nextCategoryIds[i])),
);
/**
* Act on it. Computing `materiallyChanged` and returning it to the client
* without changing anything server-side is exactly the bug the comment
* above was written to prevent: the client is free to ignore the flag.
*
* Only a currently-verified pro needs demoting. A draft/pending/rejected
* profile is already not on the deck, and demoting a `suspended` pro to
* pending would quietly undo a moderator's suspension.
*/
const sendBackForReview = materiallyChanged && existing?.verificationStatus === 'verified';
if (sendBackForReview) {
assertTransition('verification', 'verified', 'pending');
}
await ctx.db.transaction(async (tx) => { await ctx.db.transaction(async (tx) => {
const values = { const values = {
@@ -83,6 +116,7 @@ export const proRouter = router({
baseLocation: input.location, baseLocation: input.location,
serviceRadiusM: input.serviceRadiusM, serviceRadiusM: input.serviceRadiusM,
updatedAt: new Date(), updatedAt: new Date(),
...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}),
}; };
await tx await tx
@@ -96,9 +130,19 @@ export const proRouter = router({
await tx.insert(schema.proCategories).values( await tx.insert(schema.proCategories).values(
input.categoryIds.map((categoryId) => ({ proId: ctx.session.userId, categoryId })), input.categoryIds.map((categoryId) => ({ proId: ctx.session.userId, categoryId })),
); );
if (sendBackForReview) {
await tx.insert(schema.auditLog).values({
actorId: ctx.session.userId,
action: 'verification.re_review_required',
entity: 'pro_profile',
entityId: ctx.session.userId,
ip: ctx.ip,
});
}
}); });
return { saved: true, requiresReReview: Boolean(materiallyChanged) }; return { saved: true, requiresReReview: sendBackForReview };
}), }),
/** Attach an uploaded photo. The file itself went straight to R2. */ /** Attach an uploaded photo. The file itself went straight to R2. */
@@ -150,7 +194,7 @@ export const proRouter = router({
.values({ .values({
proId: ctx.session.userId, proId: ctx.session.userId,
kind: input.kind, kind: input.kind,
fileUrl: input.fileUrl, fileKey: input.fileKey,
issuer: input.issuer ?? null, issuer: input.issuer ?? null,
expiresAt: input.expiresAt ?? null, expiresAt: input.expiresAt ?? null,
}) })
@@ -85,7 +85,7 @@ CREATE TABLE "credentials" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pro_id" uuid NOT NULL, "pro_id" uuid NOT NULL,
"kind" "credential_kind" NOT NULL, "kind" "credential_kind" NOT NULL,
"file_url" text NOT NULL, "file_key" text NOT NULL,
"issuer" text, "issuer" text,
"expires_at" timestamp with time zone, "expires_at" timestamp with time zone,
"review_status" "review_status" DEFAULT 'pending' NOT NULL, "review_status" "review_status" DEFAULT 'pending' NOT NULL,
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"id": "d2669c23-7683-48e2-a271-03f7e5ddcbd1", "id": "6b9ea99a-a893-432a-9bea-ab3e145c97a2",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -580,8 +580,8 @@
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"file_url": { "file_key": {
"name": "file_url", "name": "file_key",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
+2 -2
View File
@@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1787250714989, "when": 1787252153406,
"tag": "0000_colossal_masked_marvel", "tag": "0000_material_shadow_king",
"breakpoints": true "breakpoints": true
} }
] ]
+2 -1
View File
@@ -109,7 +109,8 @@ export const credentials = pgTable(
.notNull() .notNull()
.references(() => proProfiles.userId, { onDelete: 'cascade' }), .references(() => proProfiles.userId, { onDelete: 'cascade' }),
kind: credentialKind('kind').notNull(), kind: credentialKind('kind').notNull(),
fileUrl: text('file_url').notNull(), /** R2 object key. Private — resolve with a signed GET, never a public URL. */
fileKey: text('file_key').notNull(),
issuer: text('issuer'), issuer: text('issuer'),
expiresAt: timestamp('expires_at', { withTimezone: true }), expiresAt: timestamp('expires_at', { withTimezone: true }),
reviewStatus: reviewStatus('review_status').notNull().default('pending'), reviewStatus: reviewStatus('review_status').notNull().default('pending'),
+1
View File
@@ -5,3 +5,4 @@ export * from './ranking';
export * from './cancellation'; export * from './cancellation';
export * from './schemas'; export * from './schemas';
export * from './email'; export * from './email';
export * from './phone';
+55
View File
@@ -0,0 +1,55 @@
/**
* Phone identity.
*
* Phone is the primary identity on this platform, which makes its *string form*
* load-bearing: `users.phone` carries a UNIQUE constraint, bans are enforced per
* account, and duplicate detection matches on it. If "+34600111222" and
* "0034 600 111 222" can both be stored, then one handset holds two distinct
* "unique" accounts — which defeats the constraint, lets a banned user return,
* and hides the duplicate from `findPossibleDuplicates`.
*
* So there is exactly one accepted stored form: E.164, no spaces, no separators.
* Normalise on the way in, reject anything that cannot be normalised.
*/
/** E.164: a leading +, a nonzero leading digit, and 815 digits total. */
const E164 = /^\+[1-9]\d{7,14}$/;
export function isE164(value: string): boolean {
return E164.test(value);
}
/**
* Coerce common user input into E.164, or return null if it cannot be done
* unambiguously.
*
* Handles the shapes people actually type: spaces, hyphens, parentheses and dots
* as separators, and a `00` international prefix instead of `+`. It deliberately
* does NOT guess a country code for a bare national number — "600111222" is
* meaningless without knowing the country, and silently assuming one would
* attach a real person's account to the wrong number.
*/
export function toE164(input: string | null | undefined): string | null {
if (!input) return null;
// Strip everything a human might use as a separator.
let s = input.trim().replace(/[\s().-]/g, '');
if (s.length === 0) return null;
// "0034..." is the same as "+34..."
if (s.startsWith('00')) s = `+${s.slice(2)}`;
// A bare national number is ambiguous — refuse rather than guess a country.
if (!s.startsWith('+')) return null;
if (!/^\+\d+$/.test(s)) return null;
return isE164(s) ? s : null;
}
/**
* Last four digits, for display ("••• ••• 222"). Never render a full number
* belonging to someone other than the viewer.
*/
export function phoneLast4(e164: string): string {
return e164.slice(-4);
}
+6 -1
View File
@@ -109,7 +109,12 @@ export type SendMessageInput = z.infer<typeof sendMessageSchema>;
export const credentialSchema = z.object({ export const credentialSchema = z.object({
kind: z.enum(['id', 'licence', 'insurance']), kind: z.enum(['id', 'licence', 'insurance']),
fileUrl: z.string().url(), /**
* An R2 object KEY, not a URL. Credential documents are private — they are
* never served publicly, so there is no URL to store. Admins read them
* through a short-lived signed GET.
*/
fileKey: z.string().min(1).max(500),
issuer: z.string().max(120).optional(), issuer: z.string().max(120).optional(),
expiresAt: z.coerce.date().optional(), expiresAt: z.coerce.date().optional(),
}); });
+5 -1
View File
@@ -99,7 +99,11 @@ const PAYMENT_GRAPH: Graph<PaymentStatus> = {
const VERIFICATION_GRAPH: Graph<VerificationStatus> = { const VERIFICATION_GRAPH: Graph<VerificationStatus> = {
draft: ['pending'], draft: ['pending'],
pending: ['verified', 'rejected'], pending: ['verified', 'rejected'],
verified: ['suspended'], // 'pending' is reachable from 'verified' because a material profile edit
// (trade, base location, service radius) sends a live pro back for re-review.
// Without this edge the re-review in pro.upsertProfile is not representable
// and a verified plumber could silently become a verified electrician.
verified: ['suspended', 'pending'],
rejected: ['pending'], rejected: ['pending'],
suspended: ['verified', 'rejected'], suspended: ['verified', 'rejected'],
}; };
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { isE164, phoneLast4, toE164 } from '../src/phone';
/**
* These are security tests, not formatting tests. `users.phone` is UNIQUE and
* bans are per-account, so any pair of inputs that normalises to two different
* strings for one real handset is a way to hold two accounts and to escape a ban.
*/
describe('toE164', () => {
it('passes through an already-normalised number', () => {
expect(toE164('+34600111222')).toBe('+34600111222');
});
it('collapses every separator style a human types to ONE stored form', () => {
const forms = [
'+34 600 111 222',
'+34-600-111-222',
'+34 (600) 111.222',
' +34600111222 ',
'0034600111222',
'0034 600 111 222',
];
const normalised = new Set(forms.map(toE164));
expect(normalised).toEqual(new Set(['+34600111222']));
});
it('refuses a bare national number rather than guessing a country', () => {
// Guessing would attach one person's account to another person's number.
expect(toE164('600111222')).toBeNull();
expect(toE164('0600111222')).toBeNull();
});
it('rejects junk, empties and letters', () => {
expect(toE164(null)).toBeNull();
expect(toE164(undefined)).toBeNull();
expect(toE164('')).toBeNull();
expect(toE164(' ')).toBeNull();
expect(toE164('+34600ABC222')).toBeNull();
expect(toE164('not a phone')).toBeNull();
});
it('enforces E.164 length and a nonzero country digit', () => {
expect(toE164('+3460011')).toBeNull(); // too short
expect(toE164('+3460011122233344')).toBeNull(); // too long
expect(toE164('+0600111222')).toBeNull(); // country code cannot start with 0
});
});
describe('isE164', () => {
it('accepts only the canonical stored form', () => {
expect(isE164('+34600111222')).toBe(true);
expect(isE164('0034600111222')).toBe(false);
expect(isE164('+34 600 111 222')).toBe(false);
});
});
describe('phoneLast4', () => {
it('returns the last four digits for masked display', () => {
expect(phoneLast4('+34600111222')).toBe('1222');
});
});
+5 -2
View File
@@ -5,7 +5,9 @@
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { ".": "./src/index.ts" }, "exports": {
".": "./src/index.ts"
},
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run" "test": "vitest run"
@@ -17,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^2.1.8" "vitest": "^2.1.8",
"@linkder/shared": "workspace:*"
} }
} }
+28
View File
@@ -148,3 +148,31 @@ describe('configuration', () => {
).rejects.toThrow(/not allowed/i); ).rejects.toThrow(/not allowed/i);
}); });
}); });
describe('key/URL contract with the API', () => {
/**
* Regression: credential uploads return an object KEY (they are private and
* have no public URL), but the credential schema originally demanded
* z.string().url(). The result was that pro onboarding could never be
* completed — every document upload failed validation at the last step.
*
* This asserts the contract in both directions so the two halves cannot drift
* apart again.
*/
it('produces a key that is NOT a URL for private kinds', () => {
const key = buildKey('credential', OWNER, 'application/pdf');
expect(() => new URL(key)).toThrow();
expect(isPrivateKind('credential')).toBe(true);
});
it('accepts that key against the credential schema', async () => {
const { credentialSchema } = await import('@linkder/shared');
const key = buildKey('credential', OWNER, 'application/pdf');
expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: key }).success).toBe(true);
});
it('rejects an empty key rather than storing a dangling reference', async () => {
const { credentialSchema } = await import('@linkder/shared');
expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: '' }).success).toBe(false);
});
});
+3
View File
@@ -199,6 +199,9 @@ importers:
specifier: ^3.24.1 specifier: ^3.24.1
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@linkder/shared':
specifier: workspace:*
version: link:../shared
typescript: typescript:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.9.3 version: 5.9.3