From 233d51b47b43e53ea56a3801692ff68451e5c786 Mon Sep 17 00:00:00 2001 From: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:52:28 +0000 Subject: [PATCH 1/3] feat(auth): surface loopback Mailpit guidance without bypassing magic links Reduce first-time local login hops by pointing at Mailpit and, when the message exists, the newest Auth.js callback. Production and non-loopback inboxes stay closed. The emailed magic link remains the authority. Refs #29 --- CHANGELOG.md | 4 + docs/RUN_LOCAL.md | 5 ++ messages/en.json | 5 +- messages/zh-cn.json | 5 +- src/__tests__/api/local-auth-routes.test.ts | 27 +++++++ src/__tests__/lib/local-auth-guidance.test.ts | 38 ++++++++++ src/app/[locale]/signin/page.tsx | 5 +- .../[locale]/signin/verify-request/page.tsx | 8 +- src/app/api/local-auth/guidance/route.ts | 7 ++ src/app/api/local-auth/magic-link/route.ts | 45 ++++++++++++ src/components/local-sign-in-hint.tsx | 73 +++++++++++++++++++ src/lib/local-auth-guidance.ts | 37 ++++++++++ 12 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/api/local-auth-routes.test.ts create mode 100644 src/__tests__/lib/local-auth-guidance.test.ts create mode 100644 src/app/api/local-auth/guidance/route.ts create mode 100644 src/app/api/local-auth/magic-link/route.ts create mode 100644 src/components/local-sign-in-hint.tsx create mode 100644 src/lib/local-auth-guidance.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3463d8c..6c79843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ All notable changes to `doc` are documented here. ByteFolk-hosted SaaS, guided self-hosting), their identity, cost, and compliance boundaries, the authentication consistency principle across deployments, and the guidance surfaces in `doc init`, README, and `doc doctor`. +- Local-only Mailpit guidance on email sign-in: outside production the sign-in + and verify-request pages link to loopback Mailpit and can open the newest + magic link. The emailed link remains the authority; production and + `DOC_LOCAL_AUTH_HINT=0` never enable this path. ### Fixed diff --git a/docs/RUN_LOCAL.md b/docs/RUN_LOCAL.md index cfc62d9..80dd685 100644 --- a/docs/RUN_LOCAL.md +++ b/docs/RUN_LOCAL.md @@ -56,6 +56,11 @@ checkout has a zero-credential authentication path; do not treat it as a product template. Production deployments should define their own topology and use an external SMTP or identity provider instead of Mailpit. +Outside production, the sign-in page shows a loopback Mailpit hint (default +`http://localhost:8025`, overridable with `DOC_MAILPIT_URL`) and can surface the +newest magic link from Mailpit. This is guidance only: the emailed link remains +the authority. The hint is disabled in production and when `DOC_LOCAL_AUTH_HINT=0`. + Complete the first-document loop: 1. Open and enter any valid email address. diff --git a/messages/en.json b/messages/en.json index e2d3f04..8344afd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -135,7 +135,10 @@ "unavailable": "No sign-in method is configured. Ask the instance operator to configure one.", "emailSubTitle": "Sign in with a secure link sent to your email.", "githubSubTitle": "Sign in with your GitHub account.", - "signInSubtitle": "Sign in to your workspace." + "signInSubtitle": "Sign in to your workspace.", + "localHint": "Local development: open Mailpit at {url} to follow the magic link. The email link remains the sign-in authority.", + "openMailpit": "Open Mailpit", + "openMagicLink": "Open the latest sign-in link" }, "verifyRequest": { "title": "Check your email", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index c3f632e..1f789b0 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -135,7 +135,10 @@ "unavailable": "当前未配置可用的登录方式,请联系实例管理员。", "emailSubTitle": "通过邮件中的安全链接登录", "githubSubTitle": "使用 GitHub 账号登录", - "signInSubtitle": "登录你的文档工作台" + "signInSubtitle": "登录你的文档工作台", + "localHint": "本地开发:打开 Mailpit({url})获取登录链接。邮件魔法链接仍是唯一登录凭证。", + "openMailpit": "打开 Mailpit", + "openMagicLink": "打开最新登录链接" }, "verifyRequest": { "title": "检查你的邮件", diff --git a/src/__tests__/api/local-auth-routes.test.ts b/src/__tests__/api/local-auth-routes.test.ts new file mode 100644 index 0000000..1960b9f --- /dev/null +++ b/src/__tests__/api/local-auth-routes.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { GET as guidance } from '@/app/api/local-auth/guidance/route' +import { GET as magicLink } from '@/app/api/local-auth/magic-link/route' + +describe('local auth convenience routes', () => { + const original = { ...process.env } + + afterEach(() => { + process.env.NODE_ENV = original.NODE_ENV + process.env.DOC_MAILPIT_URL = original.DOC_MAILPIT_URL + process.env.DOC_LOCAL_AUTH_HINT = original.DOC_LOCAL_AUTH_HINT + }) + + it('does not expose Mailpit guidance in production', async () => { + process.env.NODE_ENV = 'production' + process.env.DOC_MAILPIT_URL = 'http://localhost:8025' + const response = await guidance() + await expect(response.json()).resolves.toEqual({ enabled: false, mailpitUrl: null }) + }) + + it('does not invent a magic link when Mailpit is unavailable', async () => { + process.env.NODE_ENV = 'development' + process.env.DOC_MAILPIT_URL = 'http://127.0.0.1:1' + const response = await magicLink(new Request('http://doc.test/api/local-auth/magic-link?email=a@b.c')) + await expect(response.json()).resolves.toEqual({ enabled: true, url: null }) + }) +}) diff --git a/src/__tests__/lib/local-auth-guidance.test.ts b/src/__tests__/lib/local-auth-guidance.test.ts new file mode 100644 index 0000000..496a2ff --- /dev/null +++ b/src/__tests__/lib/local-auth-guidance.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest' +import { extractMagicLink, localAuthGuidance } from '@/lib/local-auth-guidance' + +describe('local auth guidance', () => { + test('stays disabled in production', () => { + expect( + localAuthGuidance({ + NODE_ENV: 'production', + DOC_MAILPIT_URL: 'http://localhost:8025', + DOC_LOCAL_AUTH_HINT: '1', + }) + ).toEqual({ enabled: false, mailpitUrl: null }) + }) + + test('enables outside production when Mailpit is configured', () => { + expect(localAuthGuidance({ NODE_ENV: 'development', DOC_MAILPIT_URL: 'http://127.0.0.1:8025' })).toEqual({ + enabled: true, + mailpitUrl: 'http://127.0.0.1:8025', + }) + }) + + test('does not enable assist against a non-loopback inbox', () => { + expect( + localAuthGuidance({ + NODE_ENV: 'development', + DOC_MAILPIT_URL: 'https://mailpit.example.test', + }) + ).toEqual({ enabled: false, mailpitUrl: null }) + }) + + test('extracts the Auth.js callback from mail HTML', () => { + expect( + extractMagicLink( + 'Sign in' + ) + ).toBe('http://localhost:3100/api/auth/callback/nodemailer?callbackUrl=%2F&token=abc&email=a%40b.c') + }) +}) diff --git a/src/app/[locale]/signin/page.tsx b/src/app/[locale]/signin/page.tsx index 31cf223..da6bae0 100644 --- a/src/app/[locale]/signin/page.tsx +++ b/src/app/[locale]/signin/page.tsx @@ -10,6 +10,7 @@ import { Separator } from '@/components/ui/separator' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' // import { Link } from '@/i18n/routing' import HomeNav from '@/components/home-nav' +import LocalSignInHint from '@/components/local-sign-in-hint' import { useTranslations } from 'next-intl' export default function SignInPage() { @@ -98,7 +99,7 @@ export default function SignInPage() {
- +

doc workspace

{t('title')} @@ -171,6 +172,8 @@ export default function SignInPage() { )} + + {authError && (

- +

doc workspace

{t('title')} {t('subTitle')}
+ + +
) diff --git a/src/app/api/local-auth/guidance/route.ts b/src/app/api/local-auth/guidance/route.ts new file mode 100644 index 0000000..757c66d --- /dev/null +++ b/src/app/api/local-auth/guidance/route.ts @@ -0,0 +1,7 @@ +import { localAuthGuidance } from '@/lib/local-auth-guidance' + +export const dynamic = 'force-dynamic' + +export async function GET() { + return Response.json(localAuthGuidance()) +} diff --git a/src/app/api/local-auth/magic-link/route.ts b/src/app/api/local-auth/magic-link/route.ts new file mode 100644 index 0000000..44d2a8a --- /dev/null +++ b/src/app/api/local-auth/magic-link/route.ts @@ -0,0 +1,45 @@ +import { extractMagicLink, localAuthGuidance } from '@/lib/local-auth-guidance' + +export const dynamic = 'force-dynamic' + +type MailpitMessageSummary = { + ID?: string + To?: Array<{ Address?: string }> +} + +async function readMailpitMessage(base: string, id: string) { + const response = await fetch(`${base}/api/v1/message/${id}`) + if (!response.ok) return null + const body = (await response.json()) as { HTML?: string; Text?: string } + return extractMagicLink(body.HTML || body.Text || '') +} + +export async function GET(request: Request) { + const guidance = localAuthGuidance() + if (!guidance.enabled || !guidance.mailpitUrl) { + return Response.json({ enabled: false, url: null }) + } + + const email = new URL(request.url).searchParams.get('email')?.trim().toLowerCase() + if (!email) { + return Response.json({ enabled: true, url: null }) + } + + try { + const listResponse = await fetch(`${guidance.mailpitUrl.replace(/\/$/, '')}/api/v1/messages`) + if (!listResponse.ok) { + return Response.json({ enabled: true, url: null }) + } + const payload = (await listResponse.json()) as { messages?: MailpitMessageSummary[] } + const match = (payload.messages || []).find((message) => + (message.To || []).some((recipient) => recipient.Address?.toLowerCase() === email) + ) + if (!match?.ID) { + return Response.json({ enabled: true, url: null }) + } + const url = await readMailpitMessage(guidance.mailpitUrl.replace(/\/$/, ''), match.ID) + return Response.json({ enabled: true, url }) + } catch { + return Response.json({ enabled: true, url: null }) + } +} diff --git a/src/components/local-sign-in-hint.tsx b/src/components/local-sign-in-hint.tsx new file mode 100644 index 0000000..5c2c5a1 --- /dev/null +++ b/src/components/local-sign-in-hint.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' + +type Guidance = { enabled: boolean; mailpitUrl: string | null } + +export default function LocalSignInHint(props: { email?: string }) { + const t = useTranslations('signin') + const [guidance, setGuidance] = useState({ enabled: false, mailpitUrl: null }) + const [magicLink, setMagicLink] = useState(null) + + useEffect(() => { + if (typeof fetch !== 'function') return + let active = true + fetch('/api/local-auth/guidance') + .then((response) => response.json()) + .then((payload: Guidance) => { + if (active) setGuidance(payload) + }) + .catch(() => { + if (active) setGuidance({ enabled: false, mailpitUrl: null }) + }) + return () => { + active = false + } + }, []) + + useEffect(() => { + if (!guidance.enabled || !props.email || typeof fetch !== 'function') { + setMagicLink(null) + return + } + let active = true + const timer = window.setInterval(() => { + fetch(`/api/local-auth/magic-link?email=${encodeURIComponent(props.email || '')}`) + .then((response) => response.json()) + .then((payload: { url?: string | null }) => { + if (active && payload.url) { + setMagicLink(payload.url) + window.clearInterval(timer) + } + }) + .catch(() => {}) + }, 1500) + return () => { + active = false + window.clearInterval(timer) + } + }, [guidance.enabled, props.email]) + + if (!guidance.enabled) return null + + return ( +
+

{t('localHint', { url: guidance.mailpitUrl || 'http://localhost:8025' })}

+ {guidance.mailpitUrl ? ( +

+ + {t('openMailpit')} + +

+ ) : null} + {magicLink ? ( +

+ + {t('openMagicLink')} + +

+ ) : null} +
+ ) +} diff --git a/src/lib/local-auth-guidance.ts b/src/lib/local-auth-guidance.ts new file mode 100644 index 0000000..032ca8c --- /dev/null +++ b/src/lib/local-auth-guidance.ts @@ -0,0 +1,37 @@ +export type AuthEnvironment = Record + +export type LocalAuthGuidance = { + enabled: boolean + mailpitUrl: string | null +} + +const DEFAULT_MAILPIT_URL = 'http://localhost:8025' +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) + +function isLoopbackUrl(value: string) { + try { + return LOOPBACK_HOSTS.has(new URL(value).hostname) + } catch { + return false + } +} + +export function localAuthGuidance(env: AuthEnvironment = process.env): LocalAuthGuidance { + const production = env.NODE_ENV === 'production' + const forced = env.DOC_LOCAL_AUTH_HINT === '1' + const mailpit = env.DOC_MAILPIT_URL?.trim() || '' + if (production) { + return { enabled: false, mailpitUrl: null } + } + const mailpitUrl = mailpit || (forced ? DEFAULT_MAILPIT_URL : '') + const enabled = Boolean(mailpitUrl) && isLoopbackUrl(mailpitUrl) + return { + enabled, + mailpitUrl: enabled ? mailpitUrl : null, + } +} + +export function extractMagicLink(htmlOrText: string): string | null { + const match = htmlOrText.match(/https?:\/\/[^\s"'<>]+\/api\/auth\/callback\/[^\s"'<>]+/i) + return match ? match[0].replace(/&/g, '&') : null +} From 1824ecb7d4ef989754c1937632bfa8d4d2619f87 Mon Sep 17 00:00:00 2001 From: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:47:42 +0000 Subject: [PATCH 2/3] fix(auth): keep sign-in layout and pass email into Mailpit hint Revert the sign-in header alignment so e2e baselines stay valid. Read email from the verify-request query string, and enable loopback Mailpit guidance by default outside production. --- src/__tests__/lib/local-auth-guidance.test.ts | 19 ++++++++++++++++++- src/app/[locale]/signin/page.tsx | 2 +- .../[locale]/signin/verify-request/page.tsx | 13 +++++++++++-- src/lib/local-auth-guidance.ts | 8 ++++---- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/__tests__/lib/local-auth-guidance.test.ts b/src/__tests__/lib/local-auth-guidance.test.ts index 496a2ff..b84bef6 100644 --- a/src/__tests__/lib/local-auth-guidance.test.ts +++ b/src/__tests__/lib/local-auth-guidance.test.ts @@ -12,13 +12,30 @@ describe('local auth guidance', () => { ).toEqual({ enabled: false, mailpitUrl: null }) }) - test('enables outside production when Mailpit is configured', () => { + test('enables outside production on loopback Mailpit by default', () => { + expect(localAuthGuidance({ NODE_ENV: 'development' })).toEqual({ + enabled: true, + mailpitUrl: 'http://localhost:8025', + }) + }) + + test('uses an explicit loopback Mailpit URL', () => { expect(localAuthGuidance({ NODE_ENV: 'development', DOC_MAILPIT_URL: 'http://127.0.0.1:8025' })).toEqual({ enabled: true, mailpitUrl: 'http://127.0.0.1:8025', }) }) + test('can be opted out outside production', () => { + expect( + localAuthGuidance({ + NODE_ENV: 'development', + DOC_LOCAL_AUTH_HINT: '0', + DOC_MAILPIT_URL: 'http://localhost:8025', + }) + ).toEqual({ enabled: false, mailpitUrl: null }) + }) + test('does not enable assist against a non-loopback inbox', () => { expect( localAuthGuidance({ diff --git a/src/app/[locale]/signin/page.tsx b/src/app/[locale]/signin/page.tsx index da6bae0..162cbf0 100644 --- a/src/app/[locale]/signin/page.tsx +++ b/src/app/[locale]/signin/page.tsx @@ -99,7 +99,7 @@ export default function SignInPage() {
- +

doc workspace

{t('title')} diff --git a/src/app/[locale]/signin/verify-request/page.tsx b/src/app/[locale]/signin/verify-request/page.tsx index a93601a..474cfe5 100644 --- a/src/app/[locale]/signin/verify-request/page.tsx +++ b/src/app/[locale]/signin/verify-request/page.tsx @@ -1,3 +1,6 @@ +'use client' + +import { useEffect, useState } from 'react' import HomeNav from '@/components/home-nav' import LocalSignInHint from '@/components/local-sign-in-hint' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -5,18 +8,24 @@ import { useTranslations } from 'next-intl' export default function VerifyRequestPage() { const t = useTranslations('verifyRequest') + const [email, setEmail] = useState('') + + useEffect(() => { + const value = new URLSearchParams(window.location.search).get('email') + if (value) setEmail(value) + }, []) return (
- +

doc workspace

{t('title')} {t('subTitle')}
- +
diff --git a/src/lib/local-auth-guidance.ts b/src/lib/local-auth-guidance.ts index 032ca8c..8101bf6 100644 --- a/src/lib/local-auth-guidance.ts +++ b/src/lib/local-auth-guidance.ts @@ -18,13 +18,13 @@ function isLoopbackUrl(value: string) { export function localAuthGuidance(env: AuthEnvironment = process.env): LocalAuthGuidance { const production = env.NODE_ENV === 'production' - const forced = env.DOC_LOCAL_AUTH_HINT === '1' + const disabled = env.DOC_LOCAL_AUTH_HINT === '0' const mailpit = env.DOC_MAILPIT_URL?.trim() || '' - if (production) { + if (production || disabled) { return { enabled: false, mailpitUrl: null } } - const mailpitUrl = mailpit || (forced ? DEFAULT_MAILPIT_URL : '') - const enabled = Boolean(mailpitUrl) && isLoopbackUrl(mailpitUrl) + const mailpitUrl = mailpit || DEFAULT_MAILPIT_URL + const enabled = isLoopbackUrl(mailpitUrl) return { enabled, mailpitUrl: enabled ? mailpitUrl : null, From 2be1779d3cb90ded7fede324487d3f8f3776a278 Mon Sep 17 00:00:00 2001 From: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:58:02 +0000 Subject: [PATCH 3/3] fix(auth): keep Mailpit hint off the sign-in screenshot surface E2E sign-in baselines run against next dev. Show loopback Mailpit guidance only on verify-request, after the email has been submitted. --- CHANGELOG.md | 6 +++--- docs/RUN_LOCAL.md | 9 +++++---- src/app/[locale]/signin/page.tsx | 3 --- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c79843..978d64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,9 @@ All notable changes to `doc` are documented here. ByteFolk-hosted SaaS, guided self-hosting), their identity, cost, and compliance boundaries, the authentication consistency principle across deployments, and the guidance surfaces in `doc init`, README, and `doc doctor`. -- Local-only Mailpit guidance on email sign-in: outside production the sign-in - and verify-request pages link to loopback Mailpit and can open the newest - magic link. The emailed link remains the authority; production and +- Local-only Mailpit guidance after email sign-in: outside production the + verify-request page links to loopback Mailpit and can open the newest magic + link. The emailed link remains the authority; production and `DOC_LOCAL_AUTH_HINT=0` never enable this path. ### Fixed diff --git a/docs/RUN_LOCAL.md b/docs/RUN_LOCAL.md index 80dd685..98d87b2 100644 --- a/docs/RUN_LOCAL.md +++ b/docs/RUN_LOCAL.md @@ -56,10 +56,11 @@ checkout has a zero-credential authentication path; do not treat it as a product template. Production deployments should define their own topology and use an external SMTP or identity provider instead of Mailpit. -Outside production, the sign-in page shows a loopback Mailpit hint (default -`http://localhost:8025`, overridable with `DOC_MAILPIT_URL`) and can surface the -newest magic link from Mailpit. This is guidance only: the emailed link remains -the authority. The hint is disabled in production and when `DOC_LOCAL_AUTH_HINT=0`. +Outside production, the verify-request page (after submitting an email) shows a +loopback Mailpit hint (default `http://localhost:8025`, overridable with +`DOC_MAILPIT_URL`) and can surface the newest magic link from Mailpit. This is +guidance only: the emailed link remains the authority. The hint is disabled in +production and when `DOC_LOCAL_AUTH_HINT=0`. Complete the first-document loop: diff --git a/src/app/[locale]/signin/page.tsx b/src/app/[locale]/signin/page.tsx index 162cbf0..31cf223 100644 --- a/src/app/[locale]/signin/page.tsx +++ b/src/app/[locale]/signin/page.tsx @@ -10,7 +10,6 @@ import { Separator } from '@/components/ui/separator' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' // import { Link } from '@/i18n/routing' import HomeNav from '@/components/home-nav' -import LocalSignInHint from '@/components/local-sign-in-hint' import { useTranslations } from 'next-intl' export default function SignInPage() { @@ -172,8 +171,6 @@ export default function SignInPage() { )} - - {authError && (