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,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 — h1–h3, big numbers, buttons | **Wix Madefor Display** | 400–800 variable |
|
||||
| Text — body, labels, inputs, meta | **Wix Madefor Text** | 400–800 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.
|
||||
@@ -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.
|
||||
@@ -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)]';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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’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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { admin, bearer, phoneNumber } from 'better-auth/plugins';
|
||||
import { admin, phoneNumber } from 'better-auth/plugins';
|
||||
import { nextCookies } from 'better-auth/next-js';
|
||||
import { db, schema } from '@linkder/db';
|
||||
import { isE164 } from '@linkder/shared';
|
||||
import { sendVerificationSms } from '@/server/sms';
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,21 @@ if (!secret && process.env.NODE_ENV === 'production') {
|
||||
throw new Error('AUTH_SECRET is not set. Generate one with: openssl rand -base64 32');
|
||||
}
|
||||
|
||||
/**
|
||||
* better-auth derives cookie attributes from baseURL. Falling back to
|
||||
* http://localhost:3000 in production would therefore drop `Secure` and the
|
||||
* `__Secure-` cookie prefix from the real session cookie, and send OAuth
|
||||
* callbacks to localhost. A missing app URL is a deployment error, so say so at
|
||||
* boot rather than serving downgraded cookies.
|
||||
*/
|
||||
const appUrl =
|
||||
process.env.NEXT_PUBLIC_APP_URL ??
|
||||
(process.env.NODE_ENV === 'production'
|
||||
? (() => {
|
||||
throw new Error('NEXT_PUBLIC_APP_URL is not set. Set it to the public https origin.');
|
||||
})()
|
||||
: 'http://localhost:3000');
|
||||
|
||||
export const auth = betterAuth({
|
||||
// Passing `schema` explicitly (rather than letting the adapter read
|
||||
// db._.fullSchema) keeps it from forcing our lazy db Proxy open at module
|
||||
@@ -67,7 +83,39 @@ export const auth = betterAuth({
|
||||
verification: { modelName: 'verifications' },
|
||||
|
||||
secret,
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
|
||||
baseURL: appUrl,
|
||||
|
||||
/**
|
||||
* Endpoints we deliberately serve as 404.
|
||||
*
|
||||
* `phoneNumber()` registers a full password-reset pair and a password
|
||||
* sign-in unconditionally — they are NOT gated on
|
||||
* `emailAndPassword.enabled`, which is false above. Left live they form a
|
||||
* complete, silent alternative credential path:
|
||||
*
|
||||
* 1. POST /phone-number/request-password-reset writes a plaintext OTP to
|
||||
* `verifications` and sends NO SMS, because we never configured
|
||||
* `sendPasswordResetOTP` — so the account owner is never told.
|
||||
* 2. POST /phone-number/reset-password mints a `credential` account row
|
||||
* holding a bcrypt password.
|
||||
* 3. POST /sign-in/phone-number then accepts { phoneNumber, password }
|
||||
* forever, with no OTP.
|
||||
*
|
||||
* The OTP in step 1 is still gated (3 attempts, 300s, rate limited), so this
|
||||
* is not a remote unauthenticated takeover. What it *is*: a way to convert a
|
||||
* single momentary OTP compromise — a SIM swap, a glanced-at lock screen —
|
||||
* into permanent access that survives the victim re-verifying their phone,
|
||||
* with no notification and no password the victim can see or rotate. This
|
||||
* platform has exactly one credential: a live OTP. Keep it that way.
|
||||
*
|
||||
* `disabledPaths` is checked in the router's onRequest, before rate limiting
|
||||
* and before the handler, and returns 404.
|
||||
*/
|
||||
disabledPaths: [
|
||||
'/phone-number/request-password-reset',
|
||||
'/phone-number/reset-password',
|
||||
'/sign-in/phone-number',
|
||||
],
|
||||
|
||||
advanced: {
|
||||
database: {
|
||||
@@ -95,6 +143,16 @@ export const auth = betterAuth({
|
||||
otpLength: 6,
|
||||
expiresIn: 300,
|
||||
allowedAttempts: 3,
|
||||
/**
|
||||
* `users.phone` is UNIQUE and bans are per-account, so the stored string
|
||||
* form is load-bearing: if "+34600111222" and "0034600111222" can both be
|
||||
* written, one handset holds two "unique" accounts, a ban is escapable by
|
||||
* retyping, and findPossibleDuplicates cannot see the pair. Callers must
|
||||
* send E.164 — normalise with toE164() from @linkder/shared before
|
||||
* calling. This runs on both /phone-number/send-otp and
|
||||
* /sign-in/phone-number.
|
||||
*/
|
||||
phoneNumberValidator: (phone) => isE164(phone),
|
||||
signUpOnVerification: {
|
||||
/**
|
||||
* better-auth requires a unique, non-null email. Phone-first users do
|
||||
@@ -118,9 +176,18 @@ export const auth = betterAuth({
|
||||
adminRoles: ['admin'],
|
||||
}),
|
||||
|
||||
// Lets a future React Native client authenticate with
|
||||
// `Authorization: Bearer <token>` instead of a cookie.
|
||||
bearer(),
|
||||
/**
|
||||
* NOT bearer(): it accepts the raw `sessions.token` value as an
|
||||
* `Authorization: Bearer` credential. That column is stored in plaintext,
|
||||
* so with bearer() enabled anything that can read one row out of `sessions`
|
||||
* — a log line, a backup, a SQL injection, a support screenshot — holds a
|
||||
* directly replayable login for that session's full lifetime. Cookies at
|
||||
* least require the httpOnly cookie to be exfiltrated from a browser.
|
||||
*
|
||||
* The mobile client this was added for does not exist yet. When it does,
|
||||
* give it its own signed, short-lived access token rather than handing out
|
||||
* the session row's primary secret.
|
||||
*/
|
||||
|
||||
// Must be last — it wraps the handler to set cookies on Next responses.
|
||||
nextCookies(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query';
|
||||
import { deserialize, serialize } from 'superjson';
|
||||
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
||||
import type { AppRouter } from '@linkder/api';
|
||||
|
||||
// Explicit annotation: pnpm's strict node_modules layout means the inferred
|
||||
@@ -61,3 +62,7 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
|
||||
</api.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convenience aliases so components can name API shapes without re-deriving them. */
|
||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+223
-28
@@ -1,53 +1,248 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/*
|
||||
* Linkder design tokens — see DESIGN.md at the repo root.
|
||||
* Hex values are sampled from wix.com and are normative. Do not hand-tune them
|
||||
* in a component; change them here or add a step to the ramp.
|
||||
*/
|
||||
@theme {
|
||||
--color-ink-50: oklch(0.98 0.005 260);
|
||||
--color-ink-100: oklch(0.95 0.008 260);
|
||||
--color-ink-200: oklch(0.89 0.012 260);
|
||||
--color-ink-400: oklch(0.65 0.02 260);
|
||||
--color-ink-600: oklch(0.45 0.025 260);
|
||||
--color-ink-800: oklch(0.26 0.03 260);
|
||||
--color-ink-950: oklch(0.15 0.03 260);
|
||||
/* ── Brand: action blue. §2.1 ───────────────────────────────────────── */
|
||||
--color-brand-50: #edf3ff;
|
||||
--color-brand-100: #dce8ff;
|
||||
--color-brand-200: #95b9ff;
|
||||
--color-brand-400: #5e97ff;
|
||||
--color-brand-500: #116dff;
|
||||
--color-brand-600: #094bcc;
|
||||
--color-brand-700: #082f7b;
|
||||
|
||||
--color-brand-400: oklch(0.72 0.15 25);
|
||||
--color-brand-500: oklch(0.64 0.19 25);
|
||||
--color-brand-600: oklch(0.56 0.2 25);
|
||||
/* ── Ink: text and surfaces. §2.2 ───────────────────────────────────── */
|
||||
--color-ink-0: #ffffff;
|
||||
--color-ink-50: #f7f8f8;
|
||||
--color-ink-100: #f0f0f0;
|
||||
--color-ink-200: #e2e2e2;
|
||||
--color-ink-300: #c2c2c2;
|
||||
--color-ink-400: #b0b0b0;
|
||||
--color-ink-500: #8f8f8f;
|
||||
--color-ink-600: #6e6e6e;
|
||||
--color-ink-800: #212121;
|
||||
--color-ink-950: #000624;
|
||||
|
||||
--color-go-500: oklch(0.7 0.17 150);
|
||||
--color-stop-500: oklch(0.64 0.2 20);
|
||||
/* ── Semantic. §2.3 ─────────────────────────────────────────────────── */
|
||||
--color-go-50: #f3f8f0;
|
||||
--color-go-100: #d2e4c7;
|
||||
--color-go-400: #92b079;
|
||||
--color-go-600: #618741;
|
||||
--color-go-700: #39641d;
|
||||
|
||||
--color-sun-50: #ffe9df;
|
||||
--color-sun-100: #ffbfa1;
|
||||
--color-sun-400: #fa854f;
|
||||
--color-sun-500: #ea6020;
|
||||
--color-sun-600: #c94001;
|
||||
|
||||
--color-stop-50: #ffecec;
|
||||
--color-stop-100: #ffc9cb;
|
||||
--color-stop-400: #ff6a70;
|
||||
--color-stop-500: #ed1c24;
|
||||
--color-stop-600: #c4141b;
|
||||
|
||||
/* ── Type. §3 ───────────────────────────────────────────────────────── */
|
||||
--font-sans: var(--font-madefor-text), 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
--font-display: var(--font-madefor-display), 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
|
||||
--text-display-xl: clamp(2.75rem, 6vw, 4.5rem);
|
||||
--text-display-xl--line-height: 1.04;
|
||||
--text-display-xl--letter-spacing: -0.03em;
|
||||
--text-display-xl--font-weight: 700;
|
||||
|
||||
--text-display-lg: clamp(2.25rem, 4.5vw, 3.25rem);
|
||||
--text-display-lg--line-height: 1.08;
|
||||
--text-display-lg--letter-spacing: -0.025em;
|
||||
--text-display-lg--font-weight: 700;
|
||||
|
||||
--text-h1: clamp(1.875rem, 3.5vw, 2.5rem);
|
||||
--text-h1--line-height: 1.12;
|
||||
--text-h1--letter-spacing: -0.02em;
|
||||
--text-h1--font-weight: 700;
|
||||
|
||||
--text-h2: clamp(1.5rem, 2.5vw, 2rem);
|
||||
--text-h2--line-height: 1.18;
|
||||
--text-h2--letter-spacing: -0.02em;
|
||||
--text-h2--font-weight: 700;
|
||||
|
||||
--text-h3: 1.375rem;
|
||||
--text-h3--line-height: 1.25;
|
||||
--text-h3--letter-spacing: -0.015em;
|
||||
--text-h3--font-weight: 600;
|
||||
|
||||
--text-h4: 1.125rem;
|
||||
--text-h4--line-height: 1.35;
|
||||
--text-h4--letter-spacing: -0.01em;
|
||||
--text-h4--font-weight: 600;
|
||||
|
||||
--text-body-lg: 1.125rem;
|
||||
--text-body-lg--line-height: 1.55;
|
||||
--text-body-lg--letter-spacing: 0em;
|
||||
|
||||
--text-body: 1rem;
|
||||
--text-body--line-height: 1.6;
|
||||
--text-body--letter-spacing: 0em;
|
||||
|
||||
--text-body-sm: 0.875rem;
|
||||
--text-body-sm--line-height: 1.5;
|
||||
--text-body-sm--letter-spacing: 0em;
|
||||
|
||||
--text-meta: 0.75rem;
|
||||
--text-meta--line-height: 1.45;
|
||||
--text-meta--letter-spacing: 0em;
|
||||
--text-meta--font-weight: 500;
|
||||
|
||||
--text-overline: 0.75rem;
|
||||
--text-overline--line-height: 1.4;
|
||||
--text-overline--letter-spacing: 0.08em;
|
||||
--text-overline--font-weight: 700;
|
||||
|
||||
/* ── Radius. §5 ─────────────────────────────────────────────────────── */
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-card: 18px;
|
||||
--radius-deck: 28px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* ── Elevation. §5 — tinted with ink-950, never pure black. ─────────── */
|
||||
--shadow-sm: 0 1px 2px rgb(0 6 36 / 0.06), 0 1px 3px rgb(0 6 36 / 0.04);
|
||||
--shadow-md: 0 4px 12px rgb(0 6 36 / 0.08), 0 2px 4px rgb(0 6 36 / 0.04);
|
||||
--shadow-lg: 0 12px 32px rgb(0 6 36 / 0.12), 0 4px 8px rgb(0 6 36 / 0.06);
|
||||
|
||||
/* ── Containers. §4 ─────────────────────────────────────────────────── */
|
||||
--container-prose: 680px;
|
||||
--container-app: 1080px;
|
||||
--container-wide: 1280px;
|
||||
|
||||
/* ── Motion. §7 ─────────────────────────────────────────────────────── */
|
||||
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/*
|
||||
* Semantic layer. Components reference these, not the ramps directly, so light
|
||||
* and dark are one set of classes rather than a `dark:` variant on every node.
|
||||
*/
|
||||
:root {
|
||||
--bg: var(--color-ink-50);
|
||||
--fg: var(--color-ink-950);
|
||||
--card: white;
|
||||
--muted: var(--color-ink-600);
|
||||
--border: var(--color-ink-200);
|
||||
--surface-page: var(--color-ink-0);
|
||||
--surface-raised: var(--color-ink-0);
|
||||
--surface-sunken: var(--color-ink-50);
|
||||
--surface-inset: var(--color-ink-100);
|
||||
--text-strong: var(--color-ink-950);
|
||||
--text-muted: var(--color-ink-600);
|
||||
--text-faint: var(--color-ink-500);
|
||||
--hairline: var(--color-ink-200);
|
||||
--accent: var(--color-brand-500);
|
||||
--accent-hover: var(--color-brand-600);
|
||||
--accent-soft: var(--color-brand-50);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: var(--color-ink-950);
|
||||
--fg: var(--color-ink-50);
|
||||
--card: var(--color-ink-800);
|
||||
--muted: var(--color-ink-400);
|
||||
--border: color-mix(in oklch, var(--color-ink-400) 25%, transparent);
|
||||
--surface-page: var(--color-ink-950);
|
||||
--surface-raised: #0c1230;
|
||||
--surface-sunken: #070c22;
|
||||
--surface-inset: #141a3a;
|
||||
--text-strong: var(--color-ink-0);
|
||||
--text-muted: var(--color-ink-400);
|
||||
--text-faint: var(--color-ink-500);
|
||||
--hairline: rgb(255 255 255 / 0.14);
|
||||
/* brand-500 is only 4.4:1 on ink-950 — lighten the accent, not the fill. */
|
||||
--accent: var(--color-brand-400);
|
||||
--accent-hover: var(--color-brand-200);
|
||||
--accent-soft: rgb(94 151 255 / 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
/* `inline` so the utilities resolve the var at use-site and flip with the scheme. */
|
||||
@theme inline {
|
||||
--color-page: var(--surface-page);
|
||||
--color-raised: var(--surface-raised);
|
||||
--color-sunken: var(--surface-sunken);
|
||||
--color-inset: var(--surface-inset);
|
||||
--color-strong: var(--text-strong);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-faint: var(--text-faint);
|
||||
--color-hairline: var(--hairline);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
}
|
||||
|
||||
body {
|
||||
@layer base {
|
||||
html {
|
||||
background-color: var(--surface-page);
|
||||
color: var(--text-strong);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* §3.3 — display family and negative tracking are structural, not opt-in. */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-display);
|
||||
color: var(--text-strong);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* §8 — one focus treatment, everywhere, and it is always visible. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-brand-100);
|
||||
color: var(--color-ink-950);
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Prices and counters must not jitter as they change. §3.3 */
|
||||
input[inputmode='decimal'],
|
||||
input[inputmode='numeric'] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* The deck is drag-driven; stop the browser from hijacking the gesture. */
|
||||
/* The deck is a drag surface — stop the browser hijacking the gesture. §6.8 */
|
||||
.deck-card {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* §7 — nothing non-essential moves when the user has asked for stillness. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,11 +67,44 @@ export const proRouter = router({
|
||||
|
||||
// Editing a live profile sends it back for review — a verified plumber must
|
||||
// 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.serviceRadiusM !== input.serviceRadiusM ||
|
||||
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) => {
|
||||
const values = {
|
||||
@@ -83,6 +116,7 @@ export const proRouter = router({
|
||||
baseLocation: input.location,
|
||||
serviceRadiusM: input.serviceRadiusM,
|
||||
updatedAt: new Date(),
|
||||
...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}),
|
||||
};
|
||||
|
||||
await tx
|
||||
@@ -96,9 +130,19 @@ export const proRouter = router({
|
||||
await tx.insert(schema.proCategories).values(
|
||||
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. */
|
||||
@@ -150,7 +194,7 @@ export const proRouter = router({
|
||||
.values({
|
||||
proId: ctx.session.userId,
|
||||
kind: input.kind,
|
||||
fileUrl: input.fileUrl,
|
||||
fileKey: input.fileKey,
|
||||
issuer: input.issuer ?? null,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
})
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ CREATE TABLE "credentials" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"pro_id" uuid NOT NULL,
|
||||
"kind" "credential_kind" NOT NULL,
|
||||
"file_url" text NOT NULL,
|
||||
"file_key" text NOT NULL,
|
||||
"issuer" text,
|
||||
"expires_at" timestamp with time zone,
|
||||
"review_status" "review_status" DEFAULT 'pending' NOT NULL,
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "d2669c23-7683-48e2-a271-03f7e5ddcbd1",
|
||||
"id": "6b9ea99a-a893-432a-9bea-ab3e145c97a2",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -580,8 +580,8 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_url": {
|
||||
"name": "file_url",
|
||||
"file_key": {
|
||||
"name": "file_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1787250714989,
|
||||
"tag": "0000_colossal_masked_marvel",
|
||||
"when": 1787252153406,
|
||||
"tag": "0000_material_shadow_king",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -109,7 +109,8 @@ export const credentials = pgTable(
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
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'),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
reviewStatus: reviewStatus('review_status').notNull().default('pending'),
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './ranking';
|
||||
export * from './cancellation';
|
||||
export * from './schemas';
|
||||
export * from './email';
|
||||
export * from './phone';
|
||||
|
||||
@@ -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 8–15 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);
|
||||
}
|
||||
@@ -109,7 +109,12 @@ export type SendMessageInput = z.infer<typeof sendMessageSchema>;
|
||||
|
||||
export const credentialSchema = z.object({
|
||||
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(),
|
||||
expiresAt: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
@@ -99,7 +99,11 @@ const PAYMENT_GRAPH: Graph<PaymentStatus> = {
|
||||
const VERIFICATION_GRAPH: Graph<VerificationStatus> = {
|
||||
draft: ['pending'],
|
||||
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'],
|
||||
suspended: ['verified', 'rejected'],
|
||||
};
|
||||
|
||||
@@ -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,7 +5,9 @@
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
@@ -17,6 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.1.8"
|
||||
"vitest": "^2.1.8",
|
||||
"@linkder/shared": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,3 +148,31 @@ describe('configuration', () => {
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+3
@@ -199,6 +199,9 @@ importers:
|
||||
specifier: ^3.24.1
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@linkder/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
Reference in New Issue
Block a user