Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/RUN_LOCAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://localhost:3100> and enter any valid email address.
Expand Down
5 changes: 4 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion messages/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@
"unavailable": "当前未配置可用的登录方式,请联系实例管理员。",
"emailSubTitle": "通过邮件中的安全链接登录",
"githubSubTitle": "使用 GitHub 账号登录",
"signInSubtitle": "登录你的文档工作台"
"signInSubtitle": "登录你的文档工作台",
"localHint": "本地开发:打开 Mailpit({url})获取登录链接。邮件魔法链接仍是唯一登录凭证。",
"openMailpit": "打开 Mailpit",
"openMagicLink": "打开最新登录链接"
},
"verifyRequest": {
"title": "检查你的邮件",
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/api/local-auth-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
55 changes: 55 additions & 0 deletions src/__tests__/lib/local-auth-guidance.test.ts
Original file line number Diff line number Diff line change
@@ -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(
'<a href="http://localhost:3100/api/auth/callback/nodemailer?callbackUrl=%2F&token=abc&email=a%40b.c">Sign in</a>'
)
).toBe('http://localhost:3100/api/auth/callback/nodemailer?callbackUrl=%2F&token=abc&email=a%40b.c')
})
})
15 changes: 14 additions & 1 deletion src/app/[locale]/signin/verify-request/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="doc-grid flex min-h-screen items-center justify-center bg-canvas px-4 py-16">
Expand All @@ -14,6 +24,9 @@ export default function VerifyRequestPage() {
<CardTitle className="text-2xl">{t('title')}</CardTitle>
<CardDescription>{t('subTitle')}</CardDescription>
</CardHeader>
<CardContent>
<LocalSignInHint email={email} />
</CardContent>
</Card>
</main>
)
Expand Down
7 changes: 7 additions & 0 deletions src/app/api/local-auth/guidance/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { localAuthGuidance } from '@/lib/local-auth-guidance'

export const dynamic = 'force-dynamic'

export async function GET() {
return Response.json(localAuthGuidance())
}
45 changes: 45 additions & 0 deletions src/app/api/local-auth/magic-link/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
73 changes: 73 additions & 0 deletions src/components/local-sign-in-hint.tsx
Original file line number Diff line number Diff line change
@@ -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<Guidance>({ enabled: false, mailpitUrl: null })
const [magicLink, setMagicLink] = useState<string | null>(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 (
<div className="rounded-md bg-accent p-3 text-left text-sm text-foreground" role="note">
<p>{t('localHint', { url: guidance.mailpitUrl || 'http://localhost:8025' })}</p>
{guidance.mailpitUrl ? (
<p className="mt-2">
<a className="underline" href={guidance.mailpitUrl} target="_blank" rel="noreferrer">
{t('openMailpit')}
</a>
</p>
) : null}
{magicLink ? (
<p className="mt-2">
<a className="underline" href={magicLink}>
{t('openMagicLink')}
</a>
</p>
) : null}
</div>
)
}
37 changes: 37 additions & 0 deletions src/lib/local-auth-guidance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export type AuthEnvironment = Record<string, string | undefined>

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(/&amp;/g, '&') : null
}
Loading