Просмотр исходного кода

Keep portal sign-in links working when a mail scanner opens them first (#583)

* Keep portal sign-in links working when a mail scanner opens them first

Opening the emailed link no longer spends it. The link leads to a page
with one button, and pressing the button is what signs the customer in.

* Sign in only as the customer the confirm page names
Bernt Christian Egeland 15 часов назад
Родитель
Сommit
56457ed459

+ 3 - 0
messages/de/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Anmelden",
     "useDifferentPhone": "Andere Nummer verwenden",
     "invalidCode": "Ungültiger oder abgelaufener Code",
+    "confirmTitle": "Bei {workshop} anmelden",
+    "confirmDescription": "Sie melden sich als {email} an. Klicken Sie auf die Schaltfläche, um fortzufahren.",
+    "confirmButton": "Weiter zur Anmeldung",
     "notFound": "Nicht gefunden",
     "portalNotExist": "Dieses Portal existiert nicht.",
     "portalNotAvailable": "Portal nicht verfügbar",

+ 3 - 0
messages/en/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Sign in",
     "useDifferentPhone": "Use a different number",
     "invalidCode": "Invalid or expired code",
+    "confirmTitle": "Sign in to {workshop}",
+    "confirmDescription": "You are signing in as {email}. Press the button to continue.",
+    "confirmButton": "Continue to sign in",
     "notFound": "Not Found",
     "portalNotExist": "This portal does not exist.",
     "portalNotAvailable": "Portal Not Available",

+ 3 - 0
messages/es/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Iniciar sesión",
     "useDifferentPhone": "Usar otro número",
     "invalidCode": "Código no válido o caducado",
+    "confirmTitle": "Iniciar sesión en {workshop}",
+    "confirmDescription": "Vas a iniciar sesión como {email}. Pulsa el botón para continuar.",
+    "confirmButton": "Continuar e iniciar sesión",
     "notFound": "No encontrado",
     "portalNotExist": "Este portal no existe.",
     "portalNotAvailable": "Portal no disponible",

+ 3 - 0
messages/fr/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Se connecter",
     "useDifferentPhone": "Utiliser un autre numéro",
     "invalidCode": "Code invalide ou expiré",
+    "confirmTitle": "Se connecter à {workshop}",
+    "confirmDescription": "Vous vous connectez en tant que {email}. Appuyez sur le bouton pour continuer.",
+    "confirmButton": "Continuer vers la connexion",
     "notFound": "Introuvable",
     "portalNotExist": "Ce portail n'existe pas.",
     "portalNotAvailable": "Portail indisponible",

+ 3 - 0
messages/it/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Accedi",
     "useDifferentPhone": "Usa un altro numero",
     "invalidCode": "Codice non valido o scaduto",
+    "confirmTitle": "Accedi a {workshop}",
+    "confirmDescription": "Stai accedendo come {email}. Premi il pulsante per continuare.",
+    "confirmButton": "Continua e accedi",
     "notFound": "Non Trovato",
     "portalNotExist": "Questo portale non esiste.",
     "portalNotAvailable": "Portale Non Disponibile",

+ 3 - 0
messages/lt/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Prisijungti",
     "useDifferentPhone": "Naudoti kitą numerį",
     "invalidCode": "Neteisingas arba pasibaigęs kodas",
+    "confirmTitle": "Prisijungti prie {workshop}",
+    "confirmDescription": "Jungiatės kaip {email}. Paspauskite mygtuką, kad tęstumėte.",
+    "confirmButton": "Tęsti prisijungimą",
     "notFound": "Nerasta",
     "portalNotExist": "Šis portalas neegzistuoja.",
     "portalNotAvailable": "Portalas nepasiekiamas",

+ 3 - 0
messages/nb/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Logg inn",
     "useDifferentPhone": "Bruk et annet nummer",
     "invalidCode": "Ugyldig eller utløpt kode",
+    "confirmTitle": "Logg inn hos {workshop}",
+    "confirmDescription": "Du logger inn som {email}. Trykk på knappen for å fortsette.",
+    "confirmButton": "Fortsett til innlogging",
     "notFound": "Ikke funnet",
     "portalNotExist": "Denne portalen finnes ikke.",
     "portalNotAvailable": "Portal ikke tilgjengelig",

+ 3 - 0
messages/nl/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Aanmelden",
     "useDifferentPhone": "Een ander nummer gebruiken",
     "invalidCode": "Ongeldige of verlopen code",
+    "confirmTitle": "Inloggen bij {workshop}",
+    "confirmDescription": "U logt in als {email}. Druk op de knop om door te gaan.",
+    "confirmButton": "Doorgaan met inloggen",
     "notFound": "Niet gevonden",
     "portalNotExist": "Dit portaal bestaat niet.",
     "portalNotAvailable": "Portaal niet beschikbaar",

+ 3 - 0
messages/pl/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Zaloguj się",
     "useDifferentPhone": "Użyj innego numeru",
     "invalidCode": "Nieprawidłowy lub wygasły kod",
+    "confirmTitle": "Zaloguj się do {workshop}",
+    "confirmDescription": "Logujesz się jako {email}. Naciśnij przycisk, aby kontynuować.",
+    "confirmButton": "Przejdź do logowania",
     "notFound": "Nie znaleziono",
     "portalNotExist": "Ten portal nie istnieje.",
     "portalNotAvailable": "Portal niedostępny",

+ 3 - 0
messages/pt-BR/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Entrar",
     "useDifferentPhone": "Usar outro número",
     "invalidCode": "Código inválido ou expirado",
+    "confirmTitle": "Entrar em {workshop}",
+    "confirmDescription": "Você está entrando como {email}. Pressione o botão para continuar.",
+    "confirmButton": "Continuar para entrar",
     "notFound": "Não Encontrado",
     "portalNotExist": "Este portal não existe.",
     "portalNotAvailable": "Portal Indisponível",

+ 3 - 0
messages/pt-PT/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Entrar",
     "useDifferentPhone": "Usar outro número",
     "invalidCode": "Código inválido ou expirado",
+    "confirmTitle": "Iniciar sessão em {workshop}",
+    "confirmDescription": "Está a iniciar sessão como {email}. Prima o botão para continuar.",
+    "confirmButton": "Continuar para iniciar sessão",
     "notFound": "Não encontrado",
     "portalNotExist": "Esta área de cliente não existe.",
     "portalNotAvailable": "Área de cliente indisponível",

+ 3 - 0
messages/ru/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Войти",
     "useDifferentPhone": "Использовать другой номер",
     "invalidCode": "Неверный или просроченный код",
+    "confirmTitle": "Вход в {workshop}",
+    "confirmDescription": "Вы входите как {email}. Нажмите кнопку, чтобы продолжить.",
+    "confirmButton": "Продолжить вход",
     "notFound": "Не найдено",
     "portalNotExist": "Этот портал не существует.",
     "portalNotAvailable": "Портал недоступен",

+ 3 - 0
messages/tr/portal.json

@@ -44,6 +44,9 @@
     "verifyCode": "Giriş yap",
     "useDifferentPhone": "Farklı bir numara kullan",
     "invalidCode": "Geçersiz veya süresi dolmuş kod",
+    "confirmTitle": "{workshop} hesabına giriş",
+    "confirmDescription": "{email} olarak giriş yapıyorsunuz. Devam etmek için düğmeye basın.",
+    "confirmButton": "Girişe devam et",
     "notFound": "Bulunamadı",
     "portalNotExist": "Bu portal mevcut değil.",
     "portalNotAvailable": "Portal Kullanılamıyor",

+ 164 - 0
src/__tests__/api/portal-signin-link.test.ts

@@ -0,0 +1,164 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * The emailed portal sign-in link, and who gets to use it.
+ *
+ * Mail services open every link in a message before the reader does. When
+ * opening the link was what signed in, that visit used the link up and the
+ * customer landed on "already used" however fast they clicked. Opening it now
+ * spends nothing; only the button on the page it leads to does.
+ */
+
+vi.mock('server-only', () => ({}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    customerMagicLink: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
+    customer: { findFirst: vi.fn() },
+    customerSession: { create: vi.fn() },
+  },
+}))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+vi.mock('@/lib/portal-slug', () => ({ resolvePortalOrg: vi.fn() }))
+
+import { db } from '@/lib/db'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { GET, POST } from '@/app/(public)/portal/[orgId]/auth/verify/route'
+
+const ORG = 'org-a'
+const TOKEN = 'a'.repeat(64)
+const BASE = `https://shop.example.com/portal/${ORG}/auth`
+const params = { params: Promise.resolve({ orgId: ORG }) }
+
+const link = (overrides: Record<string, unknown> = {}) => ({
+  id: 'link-1',
+  token: TOKEN,
+  email: 'kari@example.com',
+  organizationId: ORG,
+  expiresAt: new Date(Date.now() + 60_000),
+  usedAt: null,
+  ...overrides,
+})
+
+const open = (token = TOKEN) => GET(new Request(`${BASE}/verify?token=${token}`), params)
+
+const press = (cookie: string | null = `customer-signin=${TOKEN}`, shownLink = 'link-1') =>
+  POST(
+    new Request(`${BASE}/verify`, {
+      method: 'POST',
+      headers: cookie ? { cookie } : {},
+      body: new URLSearchParams({ link: shownLink }),
+    }),
+    params
+  )
+
+const cookiesOf = (res: Response) => res.headers.getSetCookie().join('\n')
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
+  vi.mocked(resolvePortalOrg).mockResolvedValue({ id: ORG, name: 'Shop' } as never)
+  vi.mocked(db.customerMagicLink.findUnique).mockResolvedValue(link() as never)
+  vi.mocked(db.customerMagicLink.updateMany).mockResolvedValue({ count: 1 } as never)
+  vi.mocked(db.customer.findFirst).mockResolvedValue({ id: 'cust-1' } as never)
+})
+
+describe('opening the sign-in link', () => {
+  it('leads to the page with the button and leaves the link unspent', async () => {
+    const res = await open()
+
+    expect(res.headers.get('location')).toBe(`${BASE}/confirm`)
+    expect(db.customerMagicLink.updateMany).not.toHaveBeenCalled()
+    expect(db.customerMagicLink.update).not.toHaveBeenCalled()
+    expect(db.customerSession.create).not.toHaveBeenCalled()
+    expect(cookiesOf(res)).not.toContain('customer-session=')
+  })
+
+  it('still works for the customer after a mail scanner has opened it first', async () => {
+    await open() // the scanner
+    await open() // the customer
+    const res = await press()
+
+    expect(res.headers.get('location')).toBe(`https://shop.example.com/portal/${ORG}/dashboard`)
+    expect(cookiesOf(res)).toContain('customer-session=')
+  })
+
+  it('keeps the token out of the address of the page it leads to', async () => {
+    const res = await open()
+
+    expect(res.headers.get('location')).not.toContain(TOKEN)
+    expect(cookiesOf(res)).toContain(`customer-signin=${TOKEN}`)
+  })
+
+  it.each([
+    ['a link that was already used', { usedAt: new Date() }, 'already been used'],
+    ['a link past its fifteen minutes', { expiresAt: new Date(Date.now() - 1000) }, 'has expired'],
+    ["another workshop's link", { organizationId: 'org-b' }, 'Invalid or expired link'],
+  ])('sends %s back to the login page with the reason', async (_name, overrides, reason) => {
+    vi.mocked(db.customerMagicLink.findUnique).mockResolvedValue(link(overrides) as never)
+
+    const res = await open()
+
+    const location = decodeURIComponent(res.headers.get('location') ?? '')
+    expect(location).toContain(`${BASE}/login?error=`)
+    expect(location).toContain(reason)
+  })
+})
+
+describe('pressing Continue to sign in', () => {
+  it('signs the customer in and spends the link', async () => {
+    const res = await press()
+
+    expect(res.status).toBe(303)
+    expect(res.headers.get('location')).toBe(`https://shop.example.com/portal/${ORG}/dashboard`)
+    expect(db.customerMagicLink.updateMany).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'link-1', usedAt: null } })
+    )
+    expect(db.customerSession.create).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ customerId: 'cust-1', organizationId: ORG }),
+      })
+    )
+    expect(cookiesOf(res)).toContain('customer-session=')
+  })
+
+  it('signs in once when the button is pressed twice at the same moment', async () => {
+    vi.mocked(db.customerMagicLink.updateMany).mockResolvedValue({ count: 0 } as never)
+
+    const res = await press()
+
+    expect(decodeURIComponent(res.headers.get('location') ?? '')).toContain('already been used')
+    expect(db.customerSession.create).not.toHaveBeenCalled()
+  })
+
+  it('shows the page again instead of signing in as whoever opened a link in another tab', async () => {
+    // The page was drawn for one customer; a second link opened since then
+    // replaced the cookie, so the button no longer means what the page says.
+    vi.mocked(db.customerMagicLink.findUnique).mockResolvedValue(
+      link({ id: 'link-2', email: 'ola@example.com' }) as never
+    )
+
+    const res = await press(undefined, 'link-1')
+
+    expect(res.headers.get('location')).toBe(`${BASE}/confirm`)
+    expect(db.customerMagicLink.updateMany).not.toHaveBeenCalled()
+    expect(db.customerSession.create).not.toHaveBeenCalled()
+  })
+
+  it('signs nobody in when the browser never opened a link', async () => {
+    const res = await press(null)
+
+    expect(res.headers.get('location')).toContain(`${BASE}/login?error=`)
+    expect(db.customerSession.create).not.toHaveBeenCalled()
+  })
+
+  it('refuses a link that expired while the page was open', async () => {
+    vi.mocked(db.customerMagicLink.findUnique).mockResolvedValue(
+      link({ expiresAt: new Date(Date.now() - 1000) }) as never
+    )
+
+    const res = await press()
+
+    expect(decodeURIComponent(res.headers.get('location') ?? '')).toContain('has expired')
+    expect(db.customerSession.create).not.toHaveBeenCalled()
+  })
+})

+ 61 - 0
src/app/(public)/portal/[orgId]/auth/confirm/page.tsx

@@ -0,0 +1,61 @@
+import { cookies } from 'next/headers'
+import { redirect } from 'next/navigation'
+import { getTranslations } from 'next-intl/server'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+  checkSignInLink,
+  SIGN_IN_LINK_COOKIE,
+  SIGN_IN_LINK_ERRORS,
+} from '@/features/portal/Lib/signInLink'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * Where the emailed sign-in link lands. The link itself spends nothing; the
+ * button here posts to the verify route, which does. A plain form, so it
+ * works before any script has loaded.
+ */
+export default async function PortalConfirmSignInPage({
+  params,
+}: {
+  params: Promise<{ orgId: string }>
+}) {
+  const { orgId: orgParam } = await params
+  const loginUrl = `/portal/${orgParam}/auth/login`
+  const toLogin = (error: string): never =>
+    redirect(`${loginUrl}?error=${encodeURIComponent(error)}`)
+
+  const org = await resolvePortalOrg(orgParam)
+  if (!org) redirect(loginUrl)
+
+  const token = (await cookies()).get(SIGN_IN_LINK_COOKIE)?.value
+  // No link in this browser: someone came here by address, not from a mail.
+  if (!token) redirect(loginUrl)
+
+  const link = await checkSignInLink(org.id, token)
+  if (!link.ok) return toLogin(SIGN_IN_LINK_ERRORS[link.problem])
+
+  const t = await getTranslations('portal.login')
+
+  return (
+    <div className="flex min-h-svh items-center justify-center bg-muted/30 px-4 py-10">
+      <Card className="w-full max-w-md">
+        <CardHeader className="text-center">
+          <CardTitle>{t('confirmTitle', { workshop: org.name })}</CardTitle>
+          <CardDescription>{t('confirmDescription', { email: link.email })}</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <form method="post" action={`/portal/${orgParam}/auth/verify`}>
+            {/* Which link this page was drawn for; see the verify route. */}
+            <input type="hidden" name="link" value={link.id} />
+            <Button type="submit" size="lg" className="w-full">
+              {t('confirmButton')}
+            </Button>
+          </form>
+        </CardContent>
+      </Card>
+    </div>
+  )
+}

+ 81 - 55
src/app/(public)/portal/[orgId]/auth/verify/route.ts

@@ -1,80 +1,108 @@
 import { NextResponse } from 'next/server'
 import { randomBytes } from 'crypto'
 import { db } from '@/lib/db'
-import { CUSTOMER_SESSION_COOKIE, CUSTOMER_SESSION_DURATION } from '@/lib/customer-session'
+import {
+  CUSTOMER_SESSION_COOKIE,
+  CUSTOMER_SESSION_DURATION,
+  MAGIC_LINK_DURATION,
+} from '@/lib/customer-session'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { getAppBaseUrl } from '@/lib/app-url'
+import { rateLimit } from '@/lib/rate-limit'
+import {
+  checkSignInLink,
+  redeemSignInLink,
+  SIGN_IN_LINK_COOKIE,
+  SIGN_IN_LINK_ERRORS,
+} from '@/features/portal/Lib/signInLink'
+
+type RouteParams = { params: Promise<{ orgId: string }> }
+
+function readCookie(request: Request, name: string): string | null {
+  const header = request.headers.get('cookie') ?? ''
+  for (const part of header.split(';')) {
+    const [key, ...rest] = part.trim().split('=')
+    if (key === name) return decodeURIComponent(rest.join('='))
+  }
+  return null
+}
+
+/** Back to the login page with a reason, and without a token left behind. */
+function backToLogin(loginUrl: string, error: string, status = 307) {
+  const response = NextResponse.redirect(`${loginUrl}?error=${encodeURIComponent(error)}`, status)
+  response.cookies.delete({ name: SIGN_IN_LINK_COOKIE, path: '/portal' })
+  return response
+}
 
-export async function GET(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
+/**
+ * The address in the email. Opening it signs nobody in and spends nothing:
+ * see signInLink.ts for why. It checks the link, remembers the token in a
+ * cookie and sends the browser to the page with the button.
+ */
+export async function GET(request: Request, { params }: RouteParams) {
   const { orgId: orgParam } = await params
-  const { searchParams } = new URL(request.url)
-  const token = searchParams.get('token')
+  const token = new URL(request.url).searchParams.get('token')
   // Nothing configured: the address that reached us is the one to send them back to.
   const baseUrl = getAppBaseUrl(new URL(request.url).origin)
-
   const loginUrl = `${baseUrl}/portal/${orgParam}/auth/login`
 
-  if (!token) {
-    return NextResponse.redirect(
-      `${loginUrl}?error=${encodeURIComponent('Missing verification token.')}`
-    )
-  }
+  if (!token) return backToLogin(loginUrl, 'Missing verification token.')
 
-  // Resolve slug/id to real org
   const org = await resolvePortalOrg(orgParam)
-  if (!org) {
-    return NextResponse.redirect(
-      `${loginUrl}?error=${encodeURIComponent('Invalid or expired link.')}`
-    )
-  }
+  if (!org) return backToLogin(loginUrl, SIGN_IN_LINK_ERRORS.invalid)
 
-  const orgId = org.id
+  const link = await checkSignInLink(org.id, token)
+  if (!link.ok) return backToLogin(loginUrl, SIGN_IN_LINK_ERRORS[link.problem])
 
-  // Look up magic link
-  const magicLink = await db.customerMagicLink.findUnique({
-    where: { token },
+  const response = NextResponse.redirect(`${baseUrl}/portal/${orgParam}/auth/confirm`)
+  response.cookies.set(SIGN_IN_LINK_COOKIE, token, {
+    httpOnly: true,
+    secure: process.env.NODE_ENV === 'production',
+    sameSite: 'lax',
+    path: '/portal',
+    maxAge: MAGIC_LINK_DURATION / 1000,
   })
+  return response
+}
 
-  if (!magicLink || magicLink.organizationId !== orgId) {
-    return NextResponse.redirect(
-      `${loginUrl}?error=${encodeURIComponent('Invalid or expired link.')}`
-    )
-  }
+/** The button on the confirm page. This is what spends the link and signs in. */
+export async function POST(request: Request, { params }: RouteParams) {
+  const rateLimitResponse = rateLimit(request, { limit: 10, windowMs: 60_000, anonymous: true })
+  if (rateLimitResponse) return rateLimitResponse
 
-  if (magicLink.usedAt) {
-    return NextResponse.redirect(
-      `${loginUrl}?error=${encodeURIComponent('This link has already been used. Please request a new one.')}`
-    )
-  }
+  const { orgId: orgParam } = await params
+  const baseUrl = getAppBaseUrl(new URL(request.url).origin)
+  const loginUrl = `${baseUrl}/portal/${orgParam}/auth/login`
+  // 303 throughout: the browser must follow a form post with a GET.
+  const fail = (error: string) => backToLogin(loginUrl, error, 303)
+
+  const token = readCookie(request, SIGN_IN_LINK_COOKIE)
+  if (!token) return fail(SIGN_IN_LINK_ERRORS.invalid)
+
+  const org = await resolvePortalOrg(orgParam)
+  if (!org) return fail(SIGN_IN_LINK_ERRORS.invalid)
+  const orgId = org.id
 
-  if (new Date() > magicLink.expiresAt) {
-    return NextResponse.redirect(
-      `${loginUrl}?error=${encodeURIComponent('This link has expired. Please request a new one.')}`
-    )
+  const link = await checkSignInLink(orgId, token)
+  if (!link.ok) return fail(SIGN_IN_LINK_ERRORS[link.problem])
+
+  // The page names who is about to sign in, and the cookie is shared by every
+  // tab. If a second link was opened since that page was drawn, the cookie now
+  // holds a different link than the one the button was shown for: show the
+  // page again, with the right name on it, rather than sign in as someone else.
+  const form = await request.formData().catch(() => null)
+  if (form?.get('link') !== link.id) {
+    return NextResponse.redirect(`${baseUrl}/portal/${orgParam}/auth/confirm`, 303)
   }
 
-  // Mark magic link as used
-  await db.customerMagicLink.update({
-    where: { id: magicLink.id },
-    data: { usedAt: new Date() },
-  })
+  if (!(await redeemSignInLink(link.id))) return fail(SIGN_IN_LINK_ERRORS.used)
 
-  // Find customer
   const customer = await db.customer.findFirst({
-    where: {
-      email: magicLink.email,
-      organizationId: orgId,
-    },
+    where: { email: link.email, organizationId: orgId },
     select: { id: true },
   })
+  if (!customer) return fail('Customer account not found.')
 
-  if (!customer) {
-    return NextResponse.redirect(
-      `${loginUrl}?error=${encodeURIComponent('Customer account not found.')}`
-    )
-  }
-
-  // Create session
   const sessionToken = randomBytes(32).toString('hex')
   await db.customerSession.create({
     data: {
@@ -85,9 +113,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ orgI
     },
   })
 
-  // Set cookie and redirect to dashboard
-  const response = NextResponse.redirect(`${baseUrl}/portal/${orgParam}/dashboard`)
-
+  const response = NextResponse.redirect(`${baseUrl}/portal/${orgParam}/dashboard`, 303)
   response.cookies.set(CUSTOMER_SESSION_COOKIE, sessionToken, {
     httpOnly: true,
     secure: process.env.NODE_ENV === 'production',
@@ -95,6 +121,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ orgI
     path: '/portal',
     maxAge: CUSTOMER_SESSION_DURATION / 1000,
   })
-
+  response.cookies.delete({ name: SIGN_IN_LINK_COOKIE, path: '/portal' })
   return response
 }

+ 58 - 0
src/features/portal/Lib/signInLink.ts

@@ -0,0 +1,58 @@
+import 'server-only'
+
+import { db } from '@/lib/db'
+
+/**
+ * The sign-in link a customer gets by email, between the click and the
+ * session.
+ *
+ * Opening the link must not spend it. Mail services open every link in a
+ * message before the reader does (Defender Safe Links, Gmail's scanners, a
+ * phone's preview), and a link that signs in on a plain GET is used up by
+ * that visit: the customer then lands on "already used" every time. So the
+ * GET only remembers the token in a cookie and shows a page with one button,
+ * and the button's POST is what redeems it. A scanner does not press buttons.
+ *
+ * The cookie rather than the page address carries the token, so a token that
+ * still works is never in a URL the page's analytics or a Referer can see.
+ */
+export const SIGN_IN_LINK_COOKIE = 'customer-signin'
+
+export type SignInLinkProblem = 'invalid' | 'used' | 'expired'
+
+/** What the login page says for each way a link can be no good. */
+export const SIGN_IN_LINK_ERRORS: Record<SignInLinkProblem, string> = {
+  invalid: 'Invalid or expired link.',
+  used: 'This link has already been used. Please request a new one.',
+  expired: 'This link has expired. Please request a new one.',
+}
+
+export type SignInLinkState =
+  | { ok: true; id: string; email: string }
+  | { ok: false; problem: SignInLinkProblem }
+
+/** Whether a token can still sign someone in to this workshop. Reads only. */
+export async function checkSignInLink(
+  organizationId: string,
+  token: string
+): Promise<SignInLinkState> {
+  const link = await db.customerMagicLink.findUnique({ where: { token } })
+
+  if (!link || link.organizationId !== organizationId) return { ok: false, problem: 'invalid' }
+  if (link.usedAt) return { ok: false, problem: 'used' }
+  if (new Date() > link.expiresAt) return { ok: false, problem: 'expired' }
+
+  return { ok: true, id: link.id, email: link.email }
+}
+
+/**
+ * Spend a link. False when someone else spent it first: two presses of the
+ * button, or the same mail open on two devices, yield one session.
+ */
+export async function redeemSignInLink(id: string): Promise<boolean> {
+  const { count } = await db.customerMagicLink.updateMany({
+    where: { id, usedAt: null },
+    data: { usedAt: new Date() },
+  })
+  return count === 1
+}