diff --git a/CHANGELOG.md b/CHANGELOG.md index 3463d8c..978d64b 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 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 cfc62d9..98d87b2 100644 --- a/docs/RUN_LOCAL.md +++ b/docs/RUN_LOCAL.md @@ -56,6 +56,12 @@ 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 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: 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..b84bef6 --- /dev/null +++ b/src/__tests__/lib/local-auth-guidance.test.ts @@ -0,0 +1,55 @@ +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 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({ + 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/verify-request/page.tsx b/src/app/[locale]/signin/verify-request/page.tsx index 4e014ea..474cfe5 100644 --- a/src/app/[locale]/signin/verify-request/page.tsx +++ b/src/app/[locale]/signin/verify-request/page.tsx @@ -1,9 +1,19 @@ +'use client' + +import { useEffect, useState } from 'react' import HomeNav from '@/components/home-nav' -import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import LocalSignInHint from '@/components/local-sign-in-hint' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 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 (
@@ -14,6 +24,9 @@ export default function VerifyRequestPage() { {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..8101bf6 --- /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 disabled = env.DOC_LOCAL_AUTH_HINT === '0' + const mailpit = env.DOC_MAILPIT_URL?.trim() || '' + if (production || disabled) { + return { enabled: false, mailpitUrl: null } + } + const mailpitUrl = mailpit || DEFAULT_MAILPIT_URL + const enabled = 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 +}