refactor(dashboard-fresh): remove Next.js migrated code, keep Fresh standard structure

Removed migrated Next.js code that conflicts with Fresh/Deno project structure:

Deleted:
- app/(auth)/login/ - Next.js login pages and components
- app/(auth)/register/ - Next.js registration pages
- app/(auth)/email-verification/ - Next.js email verification pages
- app/api/auth/ - Next.js API routes (login, register, mfa, session, verify-email)
- app/api/admin/ - Next.js admin API routes
- app/api/mail/ - Next.js mail API routes
- app/api/agent/, app/api/askai/, app/api/rag/, app/api/task/, app/api/users/ - Other Next.js API routes

The Fresh project now uses the correct structure:
- routes/login.tsx - Login page (uses islands/LoginForm.tsx)
- routes/api/auth/login.ts - Login API with multi-step MFA support
- islands/LoginForm.tsx - Client-side login form component

This eliminates the duplicate login implementations that were causing
mfaToken verification failures and ensures clean separation between
Fresh routes and client islands.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Haitao Pan 2025-11-05 15:04:03 +08:00
parent 9a5b95cfb2
commit b035192cd2
38 changed files with 0 additions and 3795 deletions

View File

@ -1,323 +0,0 @@
'use client'
import {
ChangeEvent,
FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { AuthLayout } from '@components/auth/AuthLayout'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
const VERIFICATION_CODE_LENGTH = 6
const RESEND_COOLDOWN_SECONDS = 60
const EMAIL_QUERY_KEYS = ['email', 'address', 'identifier', 'account'] as const
type AlertState = { type: 'error' | 'success' | 'info'; message: string }
export default function EmailVerificationContent() {
const { language } = useLanguage()
const t = translations[language].auth.emailVerification
const router = useRouter()
const searchParams = useSearchParams()
const redirectTimeoutRef = useRef<number | null>(null)
const email = useMemo(() => {
for (const key of EMAIL_QUERY_KEYS) {
const value = searchParams.get(key)
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim().toLowerCase()
}
}
return ''
}, [searchParams])
const statusParam = searchParams.get('status')
const errorParam = searchParams.get('error')
const descriptionEmail = email || t.emailFallback || ''
const description = useMemo(() => {
if (!t.description.includes('{{email}}')) {
return t.description
}
return t.description.replace('{{email}}', descriptionEmail)
}, [descriptionEmail, t.description])
const initialAlert = useMemo<AlertState | null>(() => {
if (statusParam === 'sent') {
return { type: 'info', message: t.alerts.verificationSent }
}
if (statusParam === 'resent') {
return {
type: 'success',
message: t.alerts.verificationResent ?? t.alerts.verificationSent,
}
}
if (errorParam) {
const normalized = errorParam
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
const errorMap: Record<string, string> = {
missing_verification: t.alerts.codeRequired,
verification_failed: t.alerts.verificationFailed,
invalid_code: t.alerts.verificationFailed,
invalid_email: t.alerts.missingEmail,
code_required: t.alerts.codeRequired,
}
const message = errorMap[normalized] ?? t.alerts.genericError
return { type: normalized === 'already_verified' ? 'success' : 'error', message }
}
if (!email) {
return { type: 'info', message: t.alerts.missingEmail }
}
return null
}, [email, errorParam, statusParam, t.alerts])
const [alert, setAlert] = useState<AlertState | null>(initialAlert)
const [code, setCode] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isResending, setIsResending] = useState(false)
const [resendCooldown, setResendCooldown] = useState(0)
useEffect(() => {
setAlert(initialAlert)
}, [initialAlert])
useEffect(() => {
if (resendCooldown <= 0) {
return undefined
}
const timeoutId = window.setTimeout(() => {
setResendCooldown(previous => Math.max(previous - 1, 0))
}, 1000)
return () => {
window.clearTimeout(timeoutId)
}
}, [resendCooldown])
useEffect(() => {
return () => {
if (redirectTimeoutRef.current !== null) {
window.clearTimeout(redirectTimeoutRef.current)
}
}
}, [])
const handleCodeChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const digitsOnly = event.target.value.replace(/\D/g, '').slice(0, VERIFICATION_CODE_LENGTH)
setCode(digitsOnly)
}, [])
const hasEmail = email.length > 0
const isSubmitDisabled =
isSubmitting || !hasEmail || code.length !== VERIFICATION_CODE_LENGTH
const isResendDisabled = isResending || resendCooldown > 0 || !hasEmail
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmitting) {
return
}
if (!hasEmail) {
setAlert({ type: 'error', message: t.alerts.missingEmail })
return
}
if (code.length !== VERIFICATION_CODE_LENGTH) {
setAlert({ type: 'error', message: t.alerts.codeRequired })
return
}
setIsSubmitting(true)
setAlert(null)
try {
const response = await fetch('/api/auth/verify-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, code }),
})
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean
error?: string | null
}
if (!response.ok || payload?.success !== true) {
const errorCode = typeof payload?.error === 'string' ? payload.error : 'verification_failed'
const normalized = errorCode
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
if (normalized === 'already_verified') {
const message = t.alerts.verificationReady ?? t.alerts.verificationSent
setAlert({ type: 'success', message })
redirectTimeoutRef.current = window.setTimeout(() => {
router.push('/login?registered=1')
}, 1200)
return
}
const errorMap: Record<string, string> = {
missing_verification: t.alerts.codeRequired,
invalid_code: t.alerts.verificationFailed,
verification_failed: t.alerts.verificationFailed,
invalid_email: t.alerts.missingEmail,
code_expired: t.alerts.verificationFailed,
}
const message = errorMap[normalized] ?? t.alerts.genericError
setAlert({ type: 'error', message })
return
}
const successMessage = t.alerts.verificationReady ?? t.alerts.verificationSent
setAlert({ type: 'success', message: successMessage })
setCode('')
redirectTimeoutRef.current = window.setTimeout(() => {
router.push('/login?registered=1')
}, 1200)
} catch (error) {
console.error('Email verification request failed', error)
setAlert({ type: 'error', message: t.alerts.genericError })
} finally {
setIsSubmitting(false)
}
}, [code, email, hasEmail, isSubmitting, router, t.alerts])
const handleResend = useCallback(async () => {
if (isResending || !hasEmail) {
if (!hasEmail) {
setAlert({ type: 'error', message: t.alerts.missingEmail })
}
return
}
setIsResending(true)
try {
const response = await fetch('/api/auth/verify-email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
})
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean
error?: string | null
}
if (!response.ok || payload?.success !== true) {
const errorCode = typeof payload?.error === 'string' ? payload.error : 'verification_failed'
const normalized = errorCode
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
if (normalized === 'already_verified') {
const message = t.alerts.verificationReady ?? t.alerts.verificationSent
setAlert({ type: 'success', message })
redirectTimeoutRef.current = window.setTimeout(() => {
router.push('/login?registered=1')
}, 1200)
return
}
const errorMap: Record<string, string> = {
invalid_email: t.alerts.missingEmail,
verification_failed: t.alerts.verificationFailed,
rate_limited: t.alerts.genericError,
}
const message = errorMap[normalized] ?? t.alerts.genericError
setAlert({ type: 'error', message })
return
}
const successMessage = t.alerts.verificationResent ?? t.alerts.verificationSent
setAlert({ type: 'success', message: successMessage })
setResendCooldown(RESEND_COOLDOWN_SECONDS)
} catch (error) {
console.error('Email verification resend failed', error)
setAlert({ type: 'error', message: t.alerts.genericError })
} finally {
setIsResending(false)
}
}, [email, hasEmail, isResending, router, t.alerts])
const resendLabel = isResending
? t.resend.resending ?? t.resend.label
: resendCooldown > 0
? `${t.resend.label} (${resendCooldown}s)`
: t.resend.label
return (
<AuthLayout
mode="register"
badge={t.badge}
title={t.title}
description={description}
alert={alert}
switchAction={{ text: t.switchAction.text, linkLabel: t.switchAction.link, href: '/login' }}
footnote={t.footnote}
bottomNote={t.bottomNote}
>
<form className="space-y-5" onSubmit={handleSubmit} noValidate>
<div className="space-y-2">
<label htmlFor="verification-code" className="text-sm font-medium text-slate-600">
{t.form.codeLabel}
</label>
<input
id="verification-code"
name="code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder={t.form.codePlaceholder}
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
value={code}
onChange={handleCodeChange}
disabled={isSubmitting || !hasEmail}
aria-describedby="verification-code-help"
/>
{t.form.helper ? (
<p id="verification-code-help" className="text-xs text-slate-500">
{t.form.helper}
</p>
) : null}
</div>
<button
type="submit"
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
disabled={isSubmitDisabled}
>
{isSubmitting ? t.form.submitting ?? t.form.submit : t.form.submit}
</button>
</form>
<button
type="button"
onClick={handleResend}
className="inline-flex w-full items-center justify-center rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isResendDisabled}
>
{resendLabel}
</button>
</AuthLayout>
)
}

View File

@ -1,24 +0,0 @@
export const dynamic = 'force-dynamic'
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { isFeatureEnabled } from '@lib/featureToggles'
import EmailVerificationContent from './EmailVerificationContent'
function EmailVerificationPageFallback() {
return <div className="flex min-h-screen flex-col bg-slate-50" />
}
export default function EmailVerificationPage() {
if (!isFeatureEnabled('globalNavigation', '/email-verification')) {
notFound()
}
return (
<Suspense fallback={<EmailVerificationPageFallback />}>
<EmailVerificationContent />
</Suspense>
)
}

View File

@ -1,13 +0,0 @@
import type { ReactNode } from 'react'
import { AppShellBypass } from '@lib/appShellBypass'
export default function AuthPagesLayout({ children }: { children: ReactNode }) {
return (
<AppShellBypass>
<div className="flex min-h-screen flex-col bg-slate-50">
{children}
</div>
</AppShellBypass>
)
}

View File

@ -1,347 +0,0 @@
'use client'
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Github } from 'lucide-react'
import { AuthLayout, AuthLayoutSocialButton } from '@components/auth/AuthLayout'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { WeChatIcon } from '../../components/icons/WeChatIcon'
type LoginContentProps = {
accountServiceBaseUrl: string
children?: ReactNode
}
export default function LoginContent({ accountServiceBaseUrl, children }: LoginContentProps) {
const { language } = useLanguage()
const t = translations[language].auth.login
const alerts = t.alerts
const searchParams = useSearchParams()
const router = useRouter()
useEffect(() => {
const sensitiveKeys = ['username', 'password', 'email']
const hasSensitiveParams = sensitiveKeys.some((key) => searchParams.has(key))
if (!hasSensitiveParams) {
return
}
const sanitized = new URLSearchParams(searchParams.toString())
sensitiveKeys.forEach((key) => sanitized.delete(key))
const queryString = sanitized.toString()
router.replace(queryString ? `/login?${queryString}` : '/login', { scroll: false })
}, [router, searchParams])
const errorParam = searchParams.get('error')
const registeredParam = searchParams.get('registered')
const setupMfaParam = searchParams.get('setupMfa')
const normalize = useCallback(
(value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, ''),
[],
)
const githubAuthUrl = process.env.NEXT_PUBLIC_GITHUB_AUTH_URL || '/api/auth/github'
const wechatAuthUrl = process.env.NEXT_PUBLIC_WECHAT_AUTH_URL || '/api/auth/wechat'
const loginUrl = process.env.NEXT_PUBLIC_LOGIN_URL || `${accountServiceBaseUrl}/api/auth/login`
const loginUrlRef = useRef(loginUrl)
const deriveSameOriginLoginFallback = useCallback((url: string): string | undefined => {
if (typeof window === 'undefined') {
return undefined
}
try {
const currentOrigin = window.location.origin
const parsed = new URL(url, currentOrigin)
if (parsed.origin === currentOrigin) {
const relative = `${parsed.pathname}${parsed.search}${parsed.hash}` || '/api/auth/login'
return relative
}
const localHostnames = new Set(['localhost', '127.0.0.1', '[::1]'])
const parsedHostname = parsed.hostname.toLowerCase()
const browserHostname = window.location.hostname.toLowerCase()
const parsedIsLocal = localHostnames.has(parsedHostname)
const browserIsLocal = localHostnames.has(browserHostname)
if (!browserIsLocal && parsedIsLocal) {
const relative = `${parsed.pathname}${parsed.search}${parsed.hash}` || '/api/auth/login'
return relative
}
if (
window.location.protocol === 'https:' &&
parsed.protocol === 'http:' &&
parsedHostname === browserHostname
) {
parsed.protocol = 'https:'
return parsed.toString()
}
} catch (error) {
console.warn('Failed to derive same-origin login fallback', error)
}
return undefined
}, [])
useEffect(() => {
loginUrlRef.current = loginUrl
}, [loginUrl])
const socialButtonsDisabled = true
const initialAlert = useMemo(() => {
const successMessages: string[] = []
if (registeredParam === '1') {
successMessages.push(alerts.registered)
}
if (setupMfaParam === '1') {
const setupRequiredMessage = alerts.mfa?.setupRequired ?? alerts.genericError
if (setupRequiredMessage) {
successMessages.push(setupRequiredMessage)
}
}
if (successMessages.length > 0) {
return { type: 'success', message: successMessages.join(' ') } as const
}
if (!errorParam) {
return null
}
const normalizedError = normalize(errorParam)
const errorMap: Record<string, string> = {
missing_credentials: alerts.missingCredentials,
email_and_password_are_required: alerts.missingCredentials,
invalid_credentials: alerts.invalidCredentials,
user_not_found: alerts.userNotFound ?? alerts.genericError,
credentials_in_query: alerts.genericError,
invalid_request: alerts.genericError,
}
const message = errorMap[normalizedError] ?? alerts.genericError
return { type: 'error', message } as const
}, [alerts, errorParam, normalize, registeredParam, setupMfaParam])
const [alert, setAlert] = useState(initialAlert)
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
setAlert(initialAlert)
}, [initialAlert])
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmitting) {
return
}
const formData = new FormData(event.currentTarget)
const username = String(formData.get('username') ?? '').trim()
const password = String(formData.get('password') ?? '')
const remember = formData.get('remember') === 'on'
if (!username || !password) {
setAlert({ type: 'error', message: alerts.missingCredentials })
return
}
setIsSubmitting(true)
setAlert(null)
try {
const requestPayload = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
username,
password,
remember,
}),
} as const
let response: Response
let usedUrl = loginUrlRef.current
try {
response = await fetch(usedUrl, requestPayload)
} catch (primaryError) {
const sameOriginFallback = deriveSameOriginLoginFallback(usedUrl)
if (sameOriginFallback && sameOriginFallback !== usedUrl) {
try {
response = await fetch(sameOriginFallback, requestPayload)
loginUrlRef.current = sameOriginFallback
usedUrl = sameOriginFallback
} catch (fallbackError) {
console.error('Primary login request failed, same-origin fallback also failed', fallbackError)
throw fallbackError
}
} else {
const httpsPattern = /^https:/i
if (httpsPattern.test(usedUrl)) {
const insecureUrl = usedUrl.replace(httpsPattern, 'http:')
try {
response = await fetch(insecureUrl, requestPayload)
loginUrlRef.current = insecureUrl
usedUrl = insecureUrl
} catch (fallbackError) {
console.error('Primary login request failed, insecure fallback also failed', fallbackError)
throw fallbackError
}
} else {
throw primaryError
}
}
}
if (!response.ok) {
let errorCode = 'invalid_credentials'
try {
const data = await response.json()
if (typeof data?.error === 'string') {
errorCode = data.error
}
} catch (error) {
console.error('Failed to parse login response', error)
}
const errorMap: Record<string, string> = {
invalid_credentials: alerts.invalidCredentials,
missing_credentials: alerts.missingCredentials,
user_not_found: alerts.userNotFound ?? alerts.genericError,
invalid_request: alerts.genericError,
credentials_in_query: alerts.genericError,
}
setAlert({ type: 'error', message: errorMap[normalize(errorCode)] ?? alerts.genericError })
return
}
const data: { redirectTo?: string } = await response
.json()
.catch(() => ({}))
router.push(data?.redirectTo || '/')
router.refresh()
} catch (error) {
console.error('Failed to submit login request', error)
setAlert({ type: 'error', message: alerts.genericError })
} finally {
setIsSubmitting(false)
}
},
[alerts, deriveSameOriginLoginFallback, isSubmitting, normalize, router],
)
const socialButtons = useMemo<AuthLayoutSocialButton[]>(() => {
return [
{
label: t.social.github,
href: githubAuthUrl,
icon: <Github className="h-5 w-5" aria-hidden />,
disabled: socialButtonsDisabled,
},
{
label: t.social.wechat,
href: wechatAuthUrl,
icon: <WeChatIcon className="h-5 w-5" aria-hidden />,
disabled: socialButtonsDisabled,
},
]
}, [githubAuthUrl, socialButtonsDisabled, t.social.github, t.social.wechat, wechatAuthUrl])
const formContent = useMemo(() => {
if (children) {
return children
}
return (
<form className="space-y-5" method="post" onSubmit={handleSubmit} noValidate>
<div className="space-y-2">
<label htmlFor="login-username" className="text-sm font-medium text-slate-600">
{t.form.email}
</label>
<input
id="login-username"
name="username"
type="text"
autoComplete="username"
placeholder={t.form.emailPlaceholder}
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<label htmlFor="login-password" className="font-medium text-slate-600">
{t.form.password}
</label>
<Link href="#" className="font-medium text-sky-600 hover:text-sky-500">
{t.forgotPassword}
</Link>
</div>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
placeholder={t.form.passwordPlaceholder}
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
required
/>
</div>
<label className="flex items-center gap-3 text-sm text-slate-600">
<input
type="checkbox"
name="remember"
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
/>
{t.form.remember}
</label>
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
>
{isSubmitting ? t.form.submitting ?? t.form.submit : t.form.submit}
</button>
</form>
)
}, [children, handleSubmit, isSubmitting, t])
return (
<AuthLayout
mode="login"
badge={t.badge}
title={t.form.title}
description={t.form.subtitle}
alert={alert}
socialHeading={t.social.title}
socialButtons={socialButtons}
switchAction={{ text: t.registerPrompt.text, linkLabel: t.registerPrompt.link, href: '/register' }}
bottomNote={t.bottomNote}
>
{formContent}
</AuthLayout>
)
}

View File

@ -1,349 +0,0 @@
'use client'
import { FormEvent, useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { useUser } from '@lib/userStore'
export function LoginForm() {
const router = useRouter()
const { language } = useLanguage()
const pageCopy = translations[language].login
const authCopy = translations[language].auth.login
const navCopy = translations[language].nav.account
const { user, login } = useUser()
const userEmail = user?.email ?? ''
const [identifier, setIdentifier] = useState(() => userEmail)
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [remember, setRemember] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [mfaRequirement, setMfaRequirement] = useState<'optional' | 'required'>(() =>
user?.mfaEnabled ? 'required' : 'optional',
)
useEffect(() => {
if (userEmail && identifier.trim().length === 0) {
setIdentifier(userEmail)
}
}, [identifier, userEmail])
useEffect(() => {
setTotpCode('')
}, [identifier])
useEffect(() => {
if (mfaRequirement !== 'required' && totpCode !== '') {
setTotpCode('')
}
}, [mfaRequirement, totpCode])
useEffect(() => {
let isActive = true
const trimmedIdentifier = identifier.trim()
if (!trimmedIdentifier) {
if (isActive) {
setMfaRequirement('optional')
}
return () => {
isActive = false
}
}
const normalizedIdentifier = trimmedIdentifier.toLowerCase()
const controller = new AbortController()
const signal = controller.signal
const timeoutId = window.setTimeout(async () => {
try {
const response = await fetch(
`/api/auth/mfa/status?identifier=${encodeURIComponent(normalizedIdentifier)}`,
{
method: 'GET',
cache: 'no-store',
signal,
},
)
if (!isActive || signal.aborted) {
return
}
if (!response.ok) {
setMfaRequirement('optional')
return
}
const payload = (await response.json().catch(() => ({}))) as {
mfa?: { totpEnabled?: boolean }
}
const requiresMfa = Boolean(payload?.mfa?.totpEnabled)
setMfaRequirement(requiresMfa ? 'required' : 'optional')
} catch (lookupError) {
if ((lookupError as Error)?.name === 'AbortError' || signal.aborted) {
return
}
setMfaRequirement('optional')
}
}, 300)
return () => {
isActive = false
controller.abort()
window.clearTimeout(timeoutId)
}
}, [identifier])
useEffect(() => {
if (user?.mfaEnabled) {
setMfaRequirement('required')
}
}, [user?.mfaEnabled])
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const trimmedIdentifier = identifier.trim()
if (!trimmedIdentifier) {
setError(pageCopy.missingUsername)
return
}
if (!password) {
setError(pageCopy.missingPassword)
return
}
const requiresTotp = mfaRequirement === 'required'
const sanitizedTotp = totpCode.replace(/\D/g, '')
// If TOTP is provided, validate its format (but don't require it)
if (sanitizedTotp && sanitizedTotp.length !== 6) {
setError(
authCopy.alerts.mfa?.invalidFormat ??
authCopy.alerts.mfa?.invalid ??
pageCopy.missingTotp ??
authCopy.alerts.missingCredentials,
)
return
}
setError(null)
setIsSubmitting(true)
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
email: trimmedIdentifier,
password,
totp: sanitizedTotp.length === 6 ? sanitizedTotp : undefined,
remember,
}),
credentials: 'include',
})
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean
error?: string | null
needMfa?: boolean
}
if (payload.needMfa) {
setMfaRequirement('required')
router.replace('/panel/account?setupMfa=1')
router.refresh()
return
}
const isSuccessful = response.ok && (payload.success ?? true)
if (!isSuccessful) {
const messageKey = payload.error ?? 'generic_error'
if (
messageKey === 'mfa_code_required' ||
messageKey === 'invalid_mfa_code' ||
messageKey === 'mfa_required' ||
messageKey === 'mfa_setup_required' ||
messageKey === 'mfa_challenge_failed'
) {
setMfaRequirement('required')
}
switch (messageKey) {
case 'missing_credentials':
setError(authCopy.alerts.missingCredentials)
break
case 'invalid_credentials':
setError(pageCopy.invalidCredentials)
break
case 'user_not_found':
setError(pageCopy.userNotFound)
break
case 'mfa_code_required':
setError(authCopy.alerts.mfa?.missing ?? pageCopy.missingTotp ?? authCopy.alerts.missingCredentials)
break
case 'invalid_mfa_code':
setError(authCopy.alerts.mfa?.invalid ?? pageCopy.genericError)
break
case 'mfa_challenge_failed':
setError(authCopy.alerts.mfa?.challengeFailed ?? pageCopy.genericError)
break
case 'account_service_unreachable':
setError(pageCopy.serviceUnavailable ?? pageCopy.genericError)
break
default:
setError(pageCopy.genericError)
break
}
return
}
await login()
router.replace('/')
router.refresh()
} catch (submitError) {
console.warn('Login failed', submitError)
setError(pageCopy.genericError)
} finally {
setIsSubmitting(false)
}
}
const handleGoHome = () => {
router.replace('/')
router.refresh()
}
const handleLogout = () => {
router.push('/logout')
}
const requiresTotpInput = mfaRequirement === 'required'
const mfaModeLabel = requiresTotpInput
? authCopy.form.mfa.passwordAndTotp
: authCopy.form.mfa.passwordOnly
return (
<>
{user ? (
<div className="space-y-4 rounded-2xl border border-sky-200 bg-sky-50/80 p-5 text-sm text-sky-700">
<p className="text-base font-semibold">
{pageCopy.success.replace('{username}', user.username)}
</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleGoHome}
className="inline-flex items-center justify-center rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500"
>
{pageCopy.goHome}
</button>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center justify-center rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300"
>
{navCopy.logout}
</button>
</div>
</div>
) : null}
{!user ? (
<form method="post" onSubmit={handleSubmit} className="space-y-5" noValidate>
<div className="space-y-2">
<label htmlFor="login-identifier" className="text-sm font-medium text-slate-600">
{authCopy.form.email}
</label>
<input
id="login-identifier"
name="identifier"
type="text"
autoComplete="username"
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={authCopy.form.emailPlaceholder}
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-slate-600">{authCopy.form.mfa.mode}</p>
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
{mfaModeLabel}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<label htmlFor="login-password" className="font-medium text-slate-600">
{authCopy.form.password}
</label>
<Link href="#" className="font-medium text-sky-600 hover:text-sky-500">
{authCopy.forgotPassword}
</Link>
</div>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={authCopy.form.passwordPlaceholder}
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
{requiresTotpInput ? (
<div className="space-y-2">
<label htmlFor="login-totp" className="text-sm font-medium text-slate-600">
{authCopy.form.mfa.codeLabel}
</label>
<input
id="login-totp"
name="totpCode"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={totpCode}
onChange={(event) => {
const digits = event.target.value.replace(/\D/g, '').slice(0, 6)
setTotpCode(digits)
}}
placeholder={authCopy.form.mfa.codePlaceholder}
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
) : null}
<label className="flex items-center gap-3 text-sm text-slate-600">
<input
type="checkbox"
name="remember"
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
checked={remember}
onChange={(event) => setRemember(event.target.checked)}
/>
{authCopy.form.remember}
</label>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
>
{isSubmitting ? `${authCopy.form.submit}` : authCopy.form.submit}
</button>
<p className="text-xs text-slate-500">* {pageCopy.disclaimer}</p>
</form>
) : null}
</>
)
}

View File

@ -1,27 +0,0 @@
export const dynamic = 'error'
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { isFeatureEnabled } from '@lib/featureToggles'
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
import { LoginForm } from './LoginForm'
import LoginContent from './LoginContent'
function LoginPageFallback() {
return <div className="flex min-h-screen flex-col bg-slate-50" />
}
export default function LoginPage() {
if (!isFeatureEnabled('globalNavigation', '/login')) {
notFound()
}
const accountServiceBaseUrl = getAccountServiceBaseUrl()
// 统一返回:容器包裹表单,兼容两边改动
return (
<Suspense fallback={<LoginPageFallback />}>
<LoginContent accountServiceBaseUrl={accountServiceBaseUrl}>
<LoginForm />
</LoginContent>
</Suspense>
)
}

View File

@ -1,931 +0,0 @@
'use client'
import Link from 'next/link'
import { Github } from 'lucide-react'
import {
ChangeEvent,
ClipboardEvent,
FormEvent,
KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useId,
} from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { AuthLayout, AuthLayoutSocialButton } from '@components/auth/AuthLayout'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { WeChatIcon } from '../../components/icons/WeChatIcon'
type AlertState = { type: 'error' | 'success' | 'info'; message: string }
const VERIFICATION_CODE_LENGTH = 6
const RESEND_COOLDOWN_SECONDS = 60
const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
const PASSWORD_STRENGTH_PATTERN = /^(?=.*[A-Za-z])(?=.*\d).{8,}$/
export default function RegisterContent() {
const { language } = useLanguage()
const t = translations[language].auth.register
const alerts = t.alerts
const searchParams = useSearchParams()
const router = useRouter()
const githubAuthUrl = process.env.NEXT_PUBLIC_GITHUB_AUTH_URL || '/api/auth/github'
const wechatAuthUrl = process.env.NEXT_PUBLIC_WECHAT_AUTH_URL || '/api/auth/wechat'
const isSocialAuthVisible = false
const socialButtons = useMemo<AuthLayoutSocialButton[]>(() => {
if (!isSocialAuthVisible) {
return []
}
return [
{
label: t.social.github,
href: githubAuthUrl,
icon: <Github className="h-5 w-5" aria-hidden />,
},
{
label: t.social.wechat,
href: wechatAuthUrl,
icon: <WeChatIcon className="h-5 w-5" aria-hidden />,
},
]
}, [githubAuthUrl, isSocialAuthVisible, t.social.github, t.social.wechat, wechatAuthUrl])
useEffect(() => {
const sensitiveKeys = ['username', 'password', 'confirmPassword', 'email']
const hasSensitiveParams = sensitiveKeys.some((key) => searchParams.has(key))
if (!hasSensitiveParams) {
return
}
const sanitized = new URLSearchParams(searchParams.toString())
sensitiveKeys.forEach((key) => sanitized.delete(key))
const queryString = sanitized.toString()
router.replace(queryString ? `/register?${queryString}` : '/register', { scroll: false })
}, [router, searchParams])
const normalize = useCallback(
(value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, ''),
[],
)
const initialAlert = useMemo<AlertState | null>(() => {
const errorParam = searchParams.get('error')
const successParam = searchParams.get('success')
if (successParam === '1') {
return { type: 'success', message: alerts.success }
}
if (!errorParam) {
return null
}
const normalizedError = normalize(errorParam)
const errorMap: Record<string, string> = {
missing_fields: alerts.missingFields,
email_and_password_are_required: alerts.missingFields,
password_mismatch: alerts.passwordMismatch,
user_already_exists: alerts.userExists,
email_must_be_a_valid_address: alerts.invalidEmail,
password_must_be_at_least_8_characters: alerts.weakPassword,
email_already_exists: alerts.userExists,
name_already_exists: alerts.usernameExists ?? alerts.userExists,
invalid_email: alerts.invalidEmail,
password_too_short: alerts.weakPassword,
invalid_name: alerts.invalidName ?? alerts.genericError,
name_required: alerts.invalidName ?? alerts.genericError,
credentials_in_query: alerts.genericError,
}
const message = errorMap[normalizedError] ?? alerts.genericError
return { type: 'error', message }
}, [alerts, normalize, searchParams])
const [alert, setAlert] = useState<AlertState | null>(initialAlert)
const [isSubmitting, setIsSubmitting] = useState(false)
const [codeDigits, setCodeDigits] = useState<string[]>(() => Array(VERIFICATION_CODE_LENGTH).fill(''))
const [hasRequestedCode, setHasRequestedCode] = useState(false)
const [pendingEmail, setPendingEmail] = useState('')
const [pendingPassword, setPendingPassword] = useState('')
const [isResending, setIsResending] = useState(false)
const [resendCooldown, setResendCooldown] = useState(0)
const [isVerified, setIsVerified] = useState(false)
const [formValues, setFormValues] = useState({
email: '',
password: '',
confirmPassword: '',
agreement: false,
})
const [isFormReady, setIsFormReady] = useState(false)
const formRef = useRef<HTMLFormElement | null>(null)
const codeInputRefs = useRef<(HTMLInputElement | null)[]>([])
useEffect(() => {
setAlert(initialAlert)
}, [initialAlert])
useEffect(() => {
setIsFormReady(true)
}, [])
useEffect(() => {
if (resendCooldown <= 0) {
return
}
const timer = window.setInterval(() => {
setResendCooldown((current) => (current > 0 ? current - 1 : 0))
}, 1000)
return () => window.clearInterval(timer)
}, [resendCooldown])
const focusCodeInput = useCallback((index: number) => {
const input = codeInputRefs.current[index]
if (input) {
input.focus()
input.select()
}
}, [])
const resetCodeDigits = useCallback(() => {
setCodeDigits(Array(VERIFICATION_CODE_LENGTH).fill(''))
}, [])
const handleInputChange = useCallback(
(field: 'email' | 'password' | 'confirmPassword') =>
(event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target
setFormValues((previous) => ({ ...previous, [field]: value }))
},
[],
)
const handleAgreementChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setFormValues((previous) => ({ ...previous, agreement: event.target.checked }))
}, [])
const handleCodeChange = useCallback(
(index: number, value: string) => {
const sanitized = value.replace(/\D/g, '')
setCodeDigits((previous) => {
const next = [...previous]
next[index] = sanitized ? sanitized[sanitized.length - 1] ?? '' : ''
return next
})
if (sanitized && index < VERIFICATION_CODE_LENGTH - 1) {
focusCodeInput(index + 1)
}
},
[focusCodeInput],
)
const handleCodeKeyDown = useCallback(
(index: number, event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Backspace' && !codeDigits[index] && index > 0) {
event.preventDefault()
setCodeDigits((previous) => {
const next = [...previous]
next[index - 1] = ''
return next
})
focusCodeInput(index - 1)
return
}
if (event.key === 'ArrowLeft' && index > 0) {
event.preventDefault()
focusCodeInput(index - 1)
return
}
if (event.key === 'ArrowRight' && index < VERIFICATION_CODE_LENGTH - 1) {
event.preventDefault()
focusCodeInput(index + 1)
}
},
[codeDigits, focusCodeInput],
)
const handleCodePaste = useCallback(
(index: number, event: ClipboardEvent<HTMLInputElement>) => {
event.preventDefault()
const clipboardValue = event.clipboardData.getData('text').replace(/\D/g, '')
if (!clipboardValue) {
return
}
const digits = clipboardValue.slice(0, VERIFICATION_CODE_LENGTH - index).split('')
setCodeDigits((previous) => {
const next = [...previous]
digits.forEach((digit, offset) => {
const targetIndex = index + offset
if (targetIndex < VERIFICATION_CODE_LENGTH) {
next[targetIndex] = digit
}
})
return next
})
const lastFilledIndex = Math.min(index + digits.length - 1, VERIFICATION_CODE_LENGTH - 1)
focusCodeInput(lastFilledIndex)
},
[focusCodeInput],
)
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmitting) {
return
}
formRef.current = event.currentTarget
const formData = new FormData(event.currentTarget)
const emailInput = String(formData.get('email') ?? '').trim()
const normalizedEmail = emailInput.toLowerCase()
const password = String(formData.get('password') ?? '')
const confirmPassword = String(formData.get('confirmPassword') ?? '')
const agreementAccepted = formData.get('agreement') === 'on'
const verificationCode = codeDigits.join('')
setFormValues((previous) => ({
...previous,
email: emailInput,
password,
confirmPassword,
agreement: agreementAccepted,
}))
const showError = (message: string) => {
setAlert({ type: 'error', message })
}
const showStatus = (message: string) => {
setAlert({ type: 'info', message })
}
if (!hasRequestedCode) {
if (!emailInput || !EMAIL_PATTERN.test(emailInput)) {
showError(alerts.invalidEmail)
return
}
if (!password || !confirmPassword) {
showError(alerts.missingFields)
return
}
if (!PASSWORD_STRENGTH_PATTERN.test(password)) {
showError(alerts.weakPassword ?? alerts.genericError)
return
}
if (password !== confirmPassword) {
showError(alerts.passwordMismatch)
return
}
if (!agreementAccepted) {
showError(alerts.agreementRequired ?? alerts.missingFields)
return
}
setIsSubmitting(true)
showStatus(
t.form.validation?.submitting ??
t.form.submitting ??
'Submitting registration request…',
)
try {
const response = await fetch('/api/auth/register/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: emailInput }),
})
if (!response.ok) {
let errorCode = 'generic_error'
try {
const data = await response.json()
if (typeof data?.error === 'string') {
errorCode = data.error
}
} catch (error) {
console.error('Failed to parse verification send response', error)
}
const errorMap: Record<string, string> = {
invalid_request: alerts.genericError,
invalid_email: alerts.invalidEmail,
verification_failed: alerts.verificationFailed ?? alerts.genericError,
email_already_exists: alerts.userExists,
account_service_unreachable: alerts.genericError,
}
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
setIsSubmitting(false)
return
}
setPendingEmail(normalizedEmail)
setPendingPassword(password)
setHasRequestedCode(true)
setIsVerified(false)
resetCodeDigits()
focusCodeInput(0)
setResendCooldown(RESEND_COOLDOWN_SECONDS)
const successMessage = alerts.verificationSent ?? alerts.genericError
setAlert({ type: 'success', message: successMessage })
} catch (error) {
console.error('Failed to request verification code', error)
showError(alerts.genericError)
} finally {
setIsSubmitting(false)
}
return
}
const emailForVerification = pendingEmail || normalizedEmail
if (!emailForVerification) {
showError(alerts.invalidEmail)
return
}
if (!isVerified) {
if (verificationCode.length !== VERIFICATION_CODE_LENGTH) {
showError(alerts.codeRequired ?? alerts.invalidCode ?? alerts.missingFields)
return
}
setIsSubmitting(true)
showStatus(
t.form.validation?.verifying ??
t.form.verifying ??
t.form.verifySubmit ??
t.form.submit,
)
try {
const response = await fetch('/api/auth/register/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: emailForVerification, code: verificationCode }),
})
if (!response.ok) {
let errorCode = 'generic_error'
try {
const data = await response.json()
if (typeof data?.error === 'string') {
errorCode = data.error
}
} catch (error) {
console.error('Failed to parse verification response', error)
}
const errorMap: Record<string, string> = {
invalid_request: alerts.genericError,
missing_verification: alerts.codeRequired ?? alerts.missingFields,
invalid_code:
alerts.verificationFailed ?? alerts.invalidCode ?? alerts.genericError,
verification_failed: alerts.verificationFailed ?? alerts.genericError,
account_service_unreachable: alerts.genericError,
}
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
setIsSubmitting(false)
return
}
setIsVerified(true)
const successMessage = alerts.verificationReady ?? alerts.success
setAlert({ type: 'success', message: successMessage })
} catch (error) {
console.error('Failed to verify email', error)
showError(alerts.genericError)
} finally {
setIsSubmitting(false)
}
return
}
if (!pendingPassword) {
showError(alerts.genericError)
return
}
if (verificationCode.length !== VERIFICATION_CODE_LENGTH) {
showError(alerts.codeRequired ?? alerts.invalidCode ?? alerts.genericError)
return
}
setIsSubmitting(true)
showStatus(
t.form.validation?.completing ??
t.form.completing ??
t.form.completeSubmit ??
t.form.submit,
)
try {
const registerResponse = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: emailForVerification,
password: pendingPassword,
confirmPassword: pendingPassword,
code: verificationCode,
}),
})
let registerData: { success?: boolean; error?: string } | null = null
try {
registerData = await registerResponse.json()
} catch (error) {
registerData = null
}
if (!registerResponse.ok || registerData?.success === false) {
const errorCode =
typeof registerData?.error === 'string' ? registerData.error : 'registration_failed'
const errorMap: Record<string, string> = {
invalid_request: alerts.genericError,
missing_credentials: alerts.missingFields,
invalid_email: alerts.invalidEmail,
password_too_short: alerts.weakPassword,
email_already_exists: alerts.userExists,
name_already_exists: alerts.usernameExists ?? alerts.userExists,
invalid_name: alerts.invalidName ?? alerts.genericError,
name_required: alerts.invalidName ?? alerts.genericError,
hash_failure: alerts.genericError,
user_creation_failed: alerts.genericError,
credentials_in_query: alerts.genericError,
verification_required: alerts.codeRequired ?? alerts.genericError,
invalid_code:
alerts.verificationFailed ?? alerts.invalidCode ?? alerts.genericError,
account_service_unreachable: alerts.genericError,
}
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
setIsSubmitting(false)
return
}
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: emailForVerification,
password: pendingPassword,
}),
})
let loginData:
| { success?: boolean; needMfa?: boolean; error?: string; redirectTo?: string }
| null = null
try {
loginData = await loginResponse.json()
} catch (error) {
loginData = null
}
if (!loginResponse.ok || !loginData?.success) {
const errorCode = typeof loginData?.error === 'string' ? loginData.error : 'generic_error'
const errorMap: Record<string, string> = {
invalid_credentials: alerts.genericError,
missing_credentials: alerts.missingFields,
account_service_unreachable: alerts.genericError,
authentication_failed: alerts.genericError,
}
if (loginData?.needMfa) {
router.push('/login?needMfa=1')
router.refresh()
setIsSubmitting(false)
return
}
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
setIsSubmitting(false)
return
}
const successMessage = alerts.registrationComplete ?? alerts.success
setAlert({ type: 'success', message: successMessage })
router.push(loginData?.redirectTo || '/')
router.refresh()
} catch (error) {
console.error('Failed to complete registration', error)
showError(alerts.genericError)
} finally {
setIsSubmitting(false)
}
},
[
alerts,
codeDigits,
focusCodeInput,
hasRequestedCode,
isSubmitting,
isVerified,
normalize,
pendingEmail,
pendingPassword,
resetCodeDigits,
router,
t.form,
],
)
const handleResend = useCallback(async () => {
if (isResending || resendCooldown > 0 || isVerified) {
return
}
const emailFromFormRaw =
pendingEmail ||
(formRef.current ? String(new FormData(formRef.current).get('email') ?? '').trim() : '')
if (!emailFromFormRaw) {
setAlert({ type: 'error', message: alerts.invalidEmail })
return
}
const emailFromForm = emailFromFormRaw.trim()
setIsResending(true)
const resendStatusMessage =
t.form.verificationCodeResending ??
(t.form.verificationCodeResend ? `${t.form.verificationCodeResend}` : 'Resending verification code…')
setAlert({ type: 'info', message: resendStatusMessage })
try {
const response = await fetch('/api/auth/register/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: emailFromForm }),
})
if (!response.ok) {
let errorCode = 'generic_error'
try {
const data = await response.json()
if (typeof data?.error === 'string') {
errorCode = data.error
}
} catch (error) {
console.error('Failed to parse resend response', error)
}
const errorMap: Record<string, string> = {
invalid_request: alerts.genericError,
invalid_email: alerts.invalidEmail,
verification_failed: alerts.verificationFailed ?? alerts.genericError,
already_verified: alerts.verificationFailed ?? alerts.genericError,
account_service_unreachable: alerts.genericError,
email_already_exists: alerts.userExists,
}
setAlert({ type: 'error', message: errorMap[normalize(errorCode)] ?? alerts.genericError })
setIsResending(false)
return
}
setPendingEmail(emailFromForm.toLowerCase())
setHasRequestedCode(true)
setIsVerified(false)
resetCodeDigits()
focusCodeInput(0)
setResendCooldown(RESEND_COOLDOWN_SECONDS)
const message = alerts.verificationResent ?? alerts.verificationSent ?? 'Verification code resent.'
setAlert({ type: 'success', message })
setIsResending(false)
} catch (error) {
console.error('Failed to resend verification code', error)
setAlert({ type: 'error', message: alerts.genericError })
setIsResending(false)
}
}, [
alerts,
focusCodeInput,
isResending,
isVerified,
normalize,
pendingEmail,
resetCodeDigits,
resendCooldown,
t.form.verificationCodeResend,
t.form.verificationCodeResending,
])
const aboveForm = t.uuidNote ? (
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
{t.uuidNote}
</div>
) : null
const isVerificationStep = hasRequestedCode && !isVerified
const submitLabel = isVerified
? isSubmitting
? t.form.completing ?? t.form.completeSubmit ?? t.form.submit
: t.form.completeSubmit ?? t.form.submit
: isVerificationStep
? isSubmitting
? t.form.verifying ?? t.form.verifySubmit ?? t.form.submit
: t.form.verifySubmit ?? t.form.submit
: isSubmitting
? t.form.submitting ?? t.form.submit
: t.form.submit
const resendLabel = isResending
? t.form.verificationCodeResending ?? t.form.verificationCodeResend
: resendCooldown > 0
? `${t.form.verificationCodeResend} (${resendCooldown}s)`
: t.form.verificationCodeResend
const verificationDescriptionId = useId()
const validationHints = t.form.validation
const validationState = useMemo(() => {
const messages: string[] = []
if (!isFormReady && validationHints?.initializing) {
return { disabled: true, messages: [validationHints.initializing] }
}
if (isSubmitting) {
if (isVerified) {
messages.push(
validationHints?.completing ??
t.form.completing ??
t.form.completeSubmit ??
t.form.submit,
)
} else if (isVerificationStep) {
messages.push(
validationHints?.verifying ??
t.form.verifying ??
t.form.verifySubmit ??
t.form.submit,
)
} else {
messages.push(validationHints?.submitting ?? t.form.submitting ?? t.form.submit)
}
return { disabled: true, messages }
}
if (!hasRequestedCode) {
const emailValue = formValues.email.trim()
if (!emailValue) {
messages.push(validationHints?.emailMissing ?? alerts.invalidEmail)
} else if (!EMAIL_PATTERN.test(emailValue)) {
messages.push(validationHints?.emailInvalid ?? alerts.invalidEmail)
}
if (!formValues.password) {
messages.push(validationHints?.passwordMissing ?? alerts.missingFields)
}
if (!formValues.confirmPassword) {
messages.push(validationHints?.confirmPasswordMissing ?? alerts.missingFields)
}
if (formValues.password && !PASSWORD_STRENGTH_PATTERN.test(formValues.password)) {
messages.push(validationHints?.passwordWeak ?? alerts.weakPassword ?? alerts.genericError)
}
if (
formValues.password &&
formValues.confirmPassword &&
formValues.password !== formValues.confirmPassword
) {
messages.push(validationHints?.passwordMismatch ?? alerts.passwordMismatch)
}
if (!formValues.agreement) {
messages.push(
validationHints?.agreementRequired ?? alerts.agreementRequired ?? alerts.missingFields,
)
}
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
}
if (!isVerified) {
if (codeDigits.some((digit) => !digit)) {
messages.push(
validationHints?.codeIncomplete ??
alerts.codeRequired ??
alerts.invalidCode ??
alerts.missingFields,
)
}
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
}
if (codeDigits.some((digit) => !digit)) {
messages.push(
validationHints?.codeIncomplete ??
alerts.codeRequired ??
alerts.invalidCode ??
alerts.missingFields,
)
}
if (!pendingPassword) {
messages.push(validationHints?.passwordUnavailable ?? alerts.genericError)
}
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
}, [
alerts,
codeDigits,
formValues,
hasRequestedCode,
isFormReady,
isSubmitting,
isVerificationStep,
isVerified,
pendingPassword,
t.form.completeSubmit,
t.form.completing,
t.form.submit,
t.form.submitting,
t.form.verifySubmit,
t.form.verifying,
validationHints,
])
const isSubmitDisabled = validationState.disabled
const validationMessages = validationState.messages
return (
<AuthLayout
mode="register"
badge={t.badge}
title={t.form.title}
description={t.form.subtitle}
alert={alert}
socialHeading={t.social.title}
socialButtons={socialButtons}
aboveForm={aboveForm}
switchAction={{ text: t.loginPrompt.text, linkLabel: t.loginPrompt.link, href: '/login' }}
bottomNote={t.bottomNote}
>
<form
ref={formRef}
className="space-y-5"
method="post"
onSubmit={handleSubmit}
noValidate
>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-slate-600">
{t.form.email}
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder={t.form.emailPlaceholder}
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
required
disabled={isVerificationStep}
value={formValues.email}
onChange={handleInputChange('email')}
/>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-slate-600">
{t.form.password}
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
placeholder={t.form.passwordPlaceholder}
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
required={!isVerificationStep}
disabled={isVerificationStep}
value={formValues.password}
onChange={handleInputChange('password')}
/>
</div>
<div className="space-y-2">
<label htmlFor="confirm-password" className="text-sm font-medium text-slate-600">
{t.form.confirmPassword}
</label>
<input
id="confirm-password"
name="confirmPassword"
type="password"
autoComplete="new-password"
placeholder={t.form.confirmPasswordPlaceholder}
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
required={!isVerificationStep}
disabled={isVerificationStep}
value={formValues.confirmPassword}
onChange={handleInputChange('confirmPassword')}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-600" htmlFor="verification-code-0">
{t.form.verificationCodeLabel}
</label>
{t.form.verificationCodeDescription ? (
<p
id={verificationDescriptionId}
className="text-xs text-slate-500"
>
{t.form.verificationCodeDescription}
</p>
) : null}
{hasRequestedCode && !isVerified ? (
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
<strong>10 </strong>
<br />
</div>
) : null}
</div>
<label className="flex items-start gap-3 text-sm text-slate-600">
<input
type="checkbox"
name="agreement"
required={!isVerificationStep}
disabled={isVerificationStep}
className="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
checked={formValues.agreement}
onChange={handleAgreementChange}
/>
<span>
{t.form.agreement}{' '}
<Link href="/docs" className="font-semibold text-sky-600 hover:text-sky-500">
{t.form.terms}
</Link>
</span>
</label>
{validationMessages.length > 0 ? (
<div
className="rounded-2xl border border-slate-200 bg-white/80 px-4 py-3 text-sm text-slate-600"
role="status"
aria-live="polite"
>
<ul className="list-disc space-y-1 pl-5">
{validationMessages.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</div>
) : null}
<button
type="submit"
disabled={isSubmitDisabled}
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
>
{submitLabel}
</button>
</form>
</AuthLayout>
)
}

View File

@ -1,26 +0,0 @@
export const dynamic = 'force-dynamic'
export const revalidate = 0
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { isFeatureEnabled } from '@lib/featureToggles'
import RegisterContent from './RegisterContent'
function RegisterPageFallback() {
return <div className="flex min-h-screen flex-col bg-slate-50" />
}
export default function RegisterPage() {
if (!isFeatureEnabled('globalNavigation', '/register')) {
notFound()
}
return (
<Suspense fallback={<RegisterPageFallback />}>
<RegisterContent />
</Suspense>
)
}

View File

@ -1,75 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const READ_ROLES: AccountUserRole[] = ['admin', 'operator']
const WRITE_ROLES: AccountUserRole[] = ['admin']
type ErrorPayload = {
error: string
}
async function proxyAccountRequest(request: NextRequest, endpoint: string, method: string, token: string) {
const headers = new Headers({
Authorization: `Bearer ${token}`,
Accept: 'application/json',
})
let body: string | undefined
if (method !== 'GET' && method !== 'HEAD') {
body = await request.text()
const contentType = request.headers.get('content-type') ?? 'application/json'
headers.set('Content-Type', contentType)
}
const response = await fetch(endpoint, {
method,
headers,
body,
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, READ_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
return proxyAccountRequest(request, `${ACCOUNT_API_BASE}/admin/settings`, 'GET', session.token)
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, WRITE_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
return proxyAccountRequest(request, `${ACCOUNT_API_BASE}/admin/settings`, 'POST', session.token)
}

View File

@ -1,69 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
type ErrorPayload = {
error: string
}
type RouteParams = {
params: {
userId: string
}
}
function resolveUserId(param?: string): string | null {
if (!param) {
return null
}
const trimmed = param.trim()
return trimmed.length > 0 ? trimmed : null
}
export async function POST(request: NextRequest, { params }: RouteParams) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
const userId = resolveUserId(params?.userId)
if (!userId) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_user' }, { status: 400 })
}
const body = await request.text()
const headers = new Headers({
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
})
const contentType = request.headers.get('content-type') ?? 'application/json'
headers.set('Content-Type', contentType)
const response = await fetch(`${ACCOUNT_API_BASE}/admin/users/${encodeURIComponent(userId)}/role`, {
method: 'POST',
headers,
body,
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}

View File

@ -1,45 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const ALLOWED_ROLES: AccountUserRole[] = ['admin', 'operator']
type MetricsErrorPayload = {
error: string
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<MetricsErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, ALLOWED_ROLES))) {
return NextResponse.json<MetricsErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
const response = await fetch(`${ACCOUNT_API_BASE}/admin/users/metrics`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
},
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<MetricsErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}

View File

@ -1,46 +0,0 @@
export const dynamic = 'force-dynamic'
import type { NextRequest } from 'next/server'
import { createUpstreamProxyHandler } from '@lib/apiProxy'
import { getInternalServerServiceBaseUrl } from '@server/serviceConfig'
const AGENT_PREFIX = '/api/agent'
function createHandler() {
const upstreamBaseUrl = getInternalServerServiceBaseUrl()
return createUpstreamProxyHandler({
upstreamBaseUrl,
upstreamPathPrefix: AGENT_PREFIX,
})
}
const handler = createHandler()
export function GET(request: NextRequest) {
return handler(request)
}
export function POST(request: NextRequest) {
return handler(request)
}
export function PUT(request: NextRequest) {
return handler(request)
}
export function PATCH(request: NextRequest) {
return handler(request)
}
export function DELETE(request: NextRequest) {
return handler(request)
}
export function HEAD(request: NextRequest) {
return handler(request)
}
export function OPTIONS(request: NextRequest) {
return handler(request)
}

View File

@ -1,42 +0,0 @@
import { getInternalServerServiceBaseUrl } from '@server/serviceConfig'
const FORWARDED_HEADERS = ['authorization', 'cookie', 'x-account-session'] as const
function buildForwardHeaders(req: Request) {
const headers = new Headers({ 'Content-Type': 'application/json' })
for (const name of FORWARDED_HEADERS) {
const value = req.headers.get(name)
if (value) {
headers.set(name, value)
}
}
return headers
}
export async function POST(req: Request) {
try {
const { question, history } = await req.json()
const apiBase = getInternalServerServiceBaseUrl()
const response = await fetch(`${apiBase}/api/askai`, {
method: 'POST',
headers: buildForwardHeaders(req),
body: JSON.stringify({ question, history }),
credentials: 'include'
})
const data = await response.json().catch(() => null)
if (data === null) {
return Response.json({ error: 'Invalid response from server' }, {
status: response.status
})
}
return Response.json(data, { status: response.status })
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return Response.json({ error: message }, { status: 500 })
}
}

View File

@ -1,123 +0,0 @@
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { applyMfaCookie, applySessionCookie, clearMfaCookie, clearSessionCookie, deriveMaxAgeFromExpires, MFA_COOKIE_NAME } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type LoginPayload = {
email?: string
password?: string
remember?: boolean
totp?: string
code?: string
token?: string
}
type AccountLoginResponse = {
token?: string
expiresAt?: string
error?: string
mfaToken?: string
needMfa?: boolean
mfaEnabled?: boolean
}
function normalizeEmail(value: unknown) {
return typeof value === 'string' ? value.trim().toLowerCase() : ''
}
function normalizeCode(value: unknown) {
return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : ''
}
export async function POST(request: NextRequest) {
let payload: LoginPayload
try {
payload = (await request.json()) as LoginPayload
} catch (error) {
console.error('Failed to decode login payload', error)
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
}
const email = normalizeEmail(payload?.email)
const password = typeof payload?.password === 'string' ? payload.password : ''
const totpCode = normalizeCode(payload?.totp ?? payload?.code)
const remember = Boolean(payload?.remember)
if (!email || !password) {
return NextResponse.json({ success: false, error: 'missing_credentials', needMfa: false }, { status: 400 })
}
try {
const loginBody: Record<string, string> = { email, password }
if (totpCode) {
loginBody.totpCode = totpCode
}
const response = await fetch(`${ACCOUNT_API_BASE}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(loginBody),
cache: 'no-store',
})
const data = (await response.json().catch(() => ({}))) as AccountLoginResponse
if (response.ok && typeof data?.token === 'string' && data.token.length > 0) {
const maxAgeFromBackend = deriveMaxAgeFromExpires(data?.expiresAt)
const effectiveMaxAge = remember ? Math.max(maxAgeFromBackend, 60 * 60 * 24 * 30) : maxAgeFromBackend
const result = NextResponse.json({ success: true, error: null, needMfa: false })
applySessionCookie(result, data.token, effectiveMaxAge)
clearMfaCookie(result)
return result
}
const errorCode = typeof data?.error === 'string' ? data.error : 'authentication_failed'
const needsMfa = Boolean(data?.needMfa || errorCode === 'mfa_required' || errorCode === 'mfa_setup_required')
if ((response.status === 401 || response.status === 403 || needsMfa) && typeof data?.mfaToken === 'string') {
const result = NextResponse.json({ success: false, error: errorCode, needMfa: true }, { status: 401 })
applyMfaCookie(result, data.mfaToken)
clearSessionCookie(result)
return result
}
const statusCode = response.status || 401
const result = NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: statusCode })
clearSessionCookie(result)
clearMfaCookie(result)
return result
} catch (error) {
console.error('Account service login proxy failed', error)
const result = NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: false }, { status: 502 })
clearSessionCookie(result)
clearMfaCookie(result)
return result
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed', needMfa: false },
{
status: 405,
headers: {
Allow: 'POST',
},
},
)
}
export function DELETE() {
const cookieStore = cookies()
const response = NextResponse.json({ success: true, error: null, needMfa: false })
if (cookieStore.has(MFA_COOKIE_NAME)) {
clearMfaCookie(response)
}
clearSessionCookie(response)
return response
}

View File

@ -1,54 +0,0 @@
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { SESSION_COOKIE_NAME, clearSessionCookie } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
export async function POST(request: NextRequest) {
void request
const token = cookies().get(SESSION_COOKIE_NAME)?.value?.trim()
if (!token) {
return NextResponse.json({ success: false, error: 'session_required' }, { status: 401 })
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/mfa/disable`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'mfa_disable_failed'
if (response.status === 401) {
const result = NextResponse.json({ success: false, error: errorCode })
clearSessionCookie(result)
return result
}
return NextResponse.json({ success: false, error: errorCode }, { status: response.status || 400 })
}
return NextResponse.json({ success: true, error: null, data })
} catch (error) {
console.error('Account service MFA disable proxy failed', error)
return NextResponse.json({ success: false, error: 'account_service_unreachable' }, { status: 502 })
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed' },
{
status: 405,
headers: {
Allow: 'POST',
},
},
)
}

View File

@ -1,99 +0,0 @@
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { applyMfaCookie, MFA_COOKIE_NAME, SESSION_COOKIE_NAME } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
// This Next.js route proxies MFA provisioning requests to the account service.
// The UI calls /api/auth/mfa/setup, which in turn forwards to the Go backend
// at /api/auth/mfa/totp/provision, keeping browser credentials opaque to the
// external service and letting us manage cookies centrally.
type SetupPayload = {
token?: string
issuer?: string
account?: string
}
function normalizeString(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
export async function POST(request: NextRequest) {
const cookieStore = cookies()
let payload: SetupPayload
try {
payload = (await request.json()) as SetupPayload
} catch (error) {
console.error('Failed to decode MFA setup payload', error)
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: true }, { status: 400 })
}
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? ''
const cookieToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''
const token = normalizeString(payload?.token || cookieToken)
if (!token && !sessionToken) {
return NextResponse.json({ success: false, error: 'mfa_token_required', needMfa: true }, { status: 400 })
}
const issuer = normalizeString(payload?.issuer)
const account = normalizeString(payload?.account)
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (sessionToken) {
headers.Authorization = `Bearer ${sessionToken}`
}
const body: Record<string, string> = {}
if (token) {
body.token = token
}
if (issuer) {
body.issuer = issuer
}
if (account) {
body.account = account
}
const response = await fetch(`${ACCOUNT_API_BASE}/mfa/totp/provision`, {
method: 'POST',
headers,
body: JSON.stringify(body),
cache: 'no-store',
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'mfa_setup_failed'
return NextResponse.json({ success: false, error: errorCode, needMfa: true }, { status: response.status || 400 })
}
const result = NextResponse.json({ success: true, error: null, needMfa: true, data })
const nextToken = normalizeString((data as { mfaToken?: string })?.mfaToken || token || cookieToken)
if (nextToken) {
applyMfaCookie(result, nextToken)
}
return result
} catch (error) {
console.error('Account service MFA setup proxy failed', error)
return NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: true }, { status: 502 })
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed', needMfa: true },
{
status: 405,
headers: {
Allow: 'POST',
},
},
)
}

View File

@ -1,49 +0,0 @@
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { MFA_COOKIE_NAME, SESSION_COOKIE_NAME } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
export async function GET(request: NextRequest) {
const cookieStore = cookies()
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? ''
const storedMfaToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''
const url = new URL(request.url)
const queryToken = String(url.searchParams.get('token') ?? '').trim()
const token = queryToken || storedMfaToken
const identifier = String(
url.searchParams.get('identifier') ?? url.searchParams.get('email') ?? '',
).trim()
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (sessionToken) {
headers.Authorization = `Bearer ${sessionToken}`
}
const params = new URLSearchParams()
if (token) {
params.set('token', token)
}
if (identifier) {
params.set('identifier', identifier.toLowerCase())
}
const endpointParams = params.toString()
const endpoint = endpointParams
? `${ACCOUNT_API_BASE}/mfa/status?${endpointParams}`
: `${ACCOUNT_API_BASE}/mfa/status`
const response = await fetch(endpoint, {
method: 'GET',
headers,
cache: 'no-store',
})
const payload = await response.json().catch(() => ({}))
return NextResponse.json(payload, { status: response.status })
}

View File

@ -1,114 +0,0 @@
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import {
applyMfaCookie,
applySessionCookie,
clearMfaCookie,
clearSessionCookie,
deriveMaxAgeFromExpires,
MFA_COOKIE_NAME,
} from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type VerifyPayload = {
token?: string
code?: string
totp?: string
}
type AccountVerifyResponse = {
token?: string
expiresAt?: string
mfaToken?: string
error?: string
retryAt?: string
user?: Record<string, unknown> | null
mfa?: Record<string, unknown> | null
}
function normalizeString(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
function normalizeCode(value: unknown) {
return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : ''
}
export async function POST(request: NextRequest) {
const cookieStore = cookies()
let payload: VerifyPayload
try {
payload = (await request.json()) as VerifyPayload
} catch (error) {
console.error('Failed to decode MFA verification payload', error)
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: true }, { status: 400 })
}
const cookieToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''
const token = normalizeString(payload?.token || cookieToken)
const code = normalizeCode(payload?.code ?? payload?.totp)
if (!token) {
return NextResponse.json({ success: false, error: 'mfa_token_required', needMfa: true }, { status: 400 })
}
if (!code) {
return NextResponse.json({ success: false, error: 'mfa_code_required', needMfa: true }, { status: 400 })
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/mfa/totp/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token, code }),
cache: 'no-store',
})
const data = (await response.json().catch(() => ({}))) as AccountVerifyResponse
if (response.ok && typeof data?.token === 'string' && data.token.length > 0) {
const result = NextResponse.json({ success: true, error: null, needMfa: false, data })
applySessionCookie(result, data.token, deriveMaxAgeFromExpires(data?.expiresAt))
clearMfaCookie(result)
return result
}
const errorCode = typeof data?.error === 'string' ? data.error : 'mfa_verification_failed'
const result = NextResponse.json(
{ success: false, error: errorCode, needMfa: true, data },
{ status: response.status || 400 },
)
if (typeof data?.mfaToken === 'string' && data.mfaToken.trim()) {
applyMfaCookie(result, data.mfaToken)
} else {
applyMfaCookie(result, token)
}
clearSessionCookie(result)
return result
} catch (error) {
console.error('Account service MFA verification proxy failed', error)
const result = NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: true }, { status: 502 })
applyMfaCookie(result, token)
clearSessionCookie(result)
return result
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed', needMfa: true },
{
status: 405,
headers: {
Allow: 'POST',
},
},
)
}

View File

@ -1,94 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type RegistrationPayload = {
name?: string
email?: string
password?: string
confirmPassword?: string
code?: string
}
function normalizeEmail(value: unknown) {
return typeof value === 'string' ? value.trim().toLowerCase() : ''
}
function normalizeString(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
export async function POST(request: NextRequest) {
let payload: RegistrationPayload
try {
payload = (await request.json()) as RegistrationPayload
} catch (error) {
console.error('Failed to decode registration payload', error)
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
}
const email = normalizeEmail(payload?.email)
const password = typeof payload?.password === 'string' ? payload.password : ''
const confirmPassword =
typeof payload?.confirmPassword === 'string' ? payload.confirmPassword : payload?.password ?? ''
const name = normalizeString(payload?.name)
const code = normalizeString(payload?.code)
if (!email || !password) {
return NextResponse.json({ success: false, error: 'missing_credentials', needMfa: false }, { status: 400 })
}
if (password !== confirmPassword) {
return NextResponse.json({ success: false, error: 'password_mismatch', needMfa: false }, { status: 400 })
}
if (!code) {
return NextResponse.json({ success: false, error: 'verification_required', needMfa: false }, { status: 400 })
}
const body = {
email,
password,
code,
...(name ? { name } : {}),
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
cache: 'no-store',
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'registration_failed'
return NextResponse.json(
{ success: false, error: errorCode, needMfa: false },
{ status: response.status || 400 },
)
}
return NextResponse.json({ success: true, error: null, needMfa: false })
} catch (error) {
console.error('Account service registration proxy failed', error)
return NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: false }, { status: 502 })
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed', needMfa: false },
{
status: 405,
headers: {
Allow: 'POST',
},
},
)
}

View File

@ -1,65 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type SendPayload = {
email?: string
}
function normalizeEmail(value: unknown) {
return typeof value === 'string' ? value.trim().toLowerCase() : ''
}
export async function POST(request: NextRequest) {
let payload: SendPayload
try {
payload = (await request.json()) as SendPayload
} catch (error) {
console.error('Failed to decode registration send payload', error)
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
}
const email = normalizeEmail(payload?.email)
if (!email) {
return NextResponse.json({ success: false, error: 'invalid_email', needMfa: false }, { status: 400 })
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/register/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
cache: 'no-store',
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'verification_failed'
return NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: response.status || 400 })
}
return NextResponse.json({ success: true, error: null, needMfa: false })
} catch (error) {
console.error('Account service registration send proxy failed', error)
return NextResponse.json(
{ success: false, error: 'account_service_unreachable', needMfa: false },
{ status: 502 },
)
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed', needMfa: false },
{
status: 405,
headers: {
Allow: 'POST',
},
},
)
}

View File

@ -1 +0,0 @@
export { POST, GET } from '../../verify-email/route'

View File

@ -1,184 +0,0 @@
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { SESSION_COOKIE_NAME, clearSessionCookie } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type AccountUser = {
id?: string
uuid?: string
name?: string
username?: string
email: string
mfaEnabled?: boolean
mfaPending?: boolean
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
totpSecretIssuedAt?: string
totpConfirmedAt?: string
totpLockedUntil?: string
}
role?: string
groups?: string[]
permissions?: string[]
tenantId?: string
tenants?: Array<{
id?: string
name?: string
role?: string
}>
}
type SessionResponse = {
user?: AccountUser | null
error?: string
}
async function fetchSession(token: string) {
try {
const response = await fetch(`${ACCOUNT_API_BASE}/session`, {
headers: {
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
})
const data = (await response.json().catch(() => ({}))) as SessionResponse
return { response, data }
} catch (error) {
console.error('Session lookup proxy failed', error)
return { response: null, data: null }
}
}
export async function GET(request: NextRequest) {
void request
const token = cookies().get(SESSION_COOKIE_NAME)?.value
if (!token) {
return NextResponse.json({ user: null })
}
const { response, data } = await fetchSession(token)
if (!response || !response.ok || !data?.user) {
const res = NextResponse.json({ user: null })
clearSessionCookie(res)
return res
}
const rawUser = data.user as AccountUser
const identifier =
typeof rawUser.uuid === 'string' && rawUser.uuid.trim().length > 0
? rawUser.uuid.trim()
: typeof rawUser.id === 'string'
? rawUser.id.trim()
: undefined
const rawMfa = rawUser.mfa ?? {}
const derivedMfaEnabled = Boolean(rawUser.mfaEnabled ?? rawMfa.totpEnabled)
const derivedMfaPendingSource =
typeof rawUser.mfaPending === 'boolean'
? rawUser.mfaPending
: typeof rawMfa.totpPending === 'boolean'
? rawMfa.totpPending
: false
const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled
const normalizedRole =
typeof rawUser.role === 'string' && rawUser.role.trim().length > 0
? rawUser.role.trim().toLowerCase()
: 'user'
const normalizedGroups = Array.isArray(rawUser.groups)
? rawUser.groups
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedPermissions = Array.isArray(rawUser.permissions)
? rawUser.permissions
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedTenantId =
typeof rawUser.tenantId === 'string' && rawUser.tenantId.trim().length > 0
? rawUser.tenantId.trim()
: undefined
const normalizedTenants = Array.isArray(rawUser.tenants)
? rawUser.tenants
.map((tenant) => {
if (!tenant || typeof tenant !== 'object') {
return null
}
const identifier =
typeof tenant.id === 'string' && tenant.id.trim().length > 0
? tenant.id.trim()
: undefined
if (!identifier) {
return null
}
const normalizedTenant: { id: string; name?: string; role?: string } = {
id: identifier,
}
if (typeof tenant.name === 'string' && tenant.name.trim().length > 0) {
normalizedTenant.name = tenant.name.trim()
}
if (typeof tenant.role === 'string' && tenant.role.trim().length > 0) {
normalizedTenant.role = tenant.role.trim().toLowerCase()
}
return normalizedTenant
})
.filter((tenant): tenant is { id: string; name?: string; role?: string } => Boolean(tenant))
: undefined
const normalizedMfa = Object.keys(rawMfa).length
? {
...rawMfa,
totpEnabled: Boolean(rawMfa.totpEnabled ?? derivedMfaEnabled),
totpPending: Boolean(rawMfa.totpPending ?? derivedMfaPending),
}
: {
totpEnabled: derivedMfaEnabled,
totpPending: derivedMfaPending,
}
const normalizedUser = identifier ? { ...rawUser, id: identifier, uuid: identifier } : rawUser
return NextResponse.json({
user: {
...normalizedUser,
mfaEnabled: derivedMfaEnabled,
mfaPending: derivedMfaPending,
mfa: normalizedMfa,
role: normalizedRole,
groups: normalizedGroups,
permissions: normalizedPermissions,
tenantId: normalizedTenantId,
tenants: normalizedTenants,
},
})
}
export async function DELETE(request: NextRequest) {
void request
const cookieStore = cookies()
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value
if (token) {
await fetch(`${ACCOUNT_API_BASE}/session`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
}).catch(() => null)
}
const response = NextResponse.json({ success: true })
clearSessionCookie(response)
return response
}

View File

@ -1,69 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type VerifyPayload = {
email?: string
code?: string
}
function normalizeEmail(value: unknown) {
return typeof value === 'string' ? value.trim().toLowerCase() : ''
}
function normalizeCode(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
export async function POST(request: NextRequest) {
let payload: VerifyPayload
try {
payload = (await request.json()) as VerifyPayload
} catch (error) {
console.error('Failed to decode verification payload', error)
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
}
const email = normalizeEmail(payload?.email)
const code = normalizeCode(payload?.code)
if (!email || !code) {
return NextResponse.json({ success: false, error: 'missing_verification', needMfa: false }, { status: 400 })
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/register/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, code }),
cache: 'no-store',
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'verification_failed'
return NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: response.status || 400 })
}
return NextResponse.json({ success: true, error: null, needMfa: false })
} catch (error) {
console.error('Account service verification proxy failed', error)
return NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: false }, { status: 502 })
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed', needMfa: false },
{
status: 405,
headers: {
Allow: 'POST',
},
},
)
}

View File

@ -1,65 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type SendPayload = {
email?: string
}
function normalizeEmail(value: unknown) {
return typeof value === 'string' ? value.trim().toLowerCase() : ''
}
export async function POST(request: NextRequest) {
let payload: SendPayload
try {
payload = (await request.json()) as SendPayload
} catch (error) {
console.error('Failed to decode verification send payload', error)
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
}
const email = normalizeEmail(payload?.email)
if (!email) {
return NextResponse.json({ success: false, error: 'invalid_email', needMfa: false }, { status: 400 })
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/register/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
cache: 'no-store',
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'verification_failed'
return NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: response.status || 400 })
}
return NextResponse.json({ success: true, error: null, needMfa: false })
} catch (error) {
console.error('Account service verification send proxy failed', error)
return NextResponse.json(
{ success: false, error: 'account_service_unreachable', needMfa: false },
{ status: 502 },
)
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed', needMfa: false },
{
status: 405,
headers: {
Allow: 'POST',
},
},
)
}

View File

@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { ContentNotFoundError, getContentCommitMeta } from '../../../api/content-meta'
export const runtime = 'nodejs'
export async function GET(request: NextRequest) {
const path = request.nextUrl.searchParams.get('path')
if (!path) {
return NextResponse.json({ error: 'Missing path parameter' }, { status: 400 })
}
try {
const result = await getContentCommitMeta(path)
return NextResponse.json(result, { status: 200 })
} catch (error) {
if (error instanceof ContentNotFoundError) {
return NextResponse.json({ error: 'Content file not found' }, { status: 404 })
}
console.error('Failed to load content metadata:', error)
return NextResponse.json({ error: 'Failed to load metadata' }, { status: 500 })
}
}

View File

@ -1,15 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMessage, resolveTenantId } from '../../mockData'
export async function POST(request: NextRequest) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const body = (await request.json()) as { messageId: string }
const message = getMessage(tenantId, body.messageId)
if (!message) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const labels = Array.from(new Set([...message.labels, 'AI-Reviewed']))
return NextResponse.json({ labels })
}

View File

@ -1,17 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMessage, resolveTenantId } from '../../mockData'
export async function POST(request: NextRequest) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const body = (await request.json()) as { messageId: string; style?: string; language?: string }
const message = body?.messageId ? getMessage(tenantId, body.messageId) : null
const base = message?.aiInsights?.suggestions ?? [
'收到,我们将安排同事跟进。',
'感谢提醒,我们将及时回复。',
'请告知是否需要更多信息。',
]
return NextResponse.json({ suggestions: base })
}

View File

@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMessage, resolveTenantId } from '../../mockData'
export async function POST(request: NextRequest) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const body = (await request.json()) as { messageId?: string; raw?: string }
if (!body.messageId && !body.raw) {
return NextResponse.json({ error: 'messageId or raw is required' }, { status: 400 })
}
if (body.messageId) {
const message = getMessage(tenantId, body.messageId)
if (!message) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (message.aiInsights) {
return NextResponse.json(message.aiInsights)
}
}
return NextResponse.json({
summary: '示例摘要:邮件内容将提炼为关键句子。',
bullets: ['示例要点一', '示例要点二'],
actions: ['示例行动一'],
tone: '信息',
})
}

View File

@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getInbox, resolveTenantId } from '../mockData'
export async function GET(request: NextRequest) {
const tenantHeader = request.headers.get('x-tenant-id')
const tenantQuery = request.nextUrl.searchParams.get('tenantId')
const tenantId = resolveTenantId(tenantHeader ?? tenantQuery)
const inbox = getInbox(tenantId)
const label = request.nextUrl.searchParams.get('label')
const query = request.nextUrl.searchParams.get('q')?.toLowerCase().trim()
let filtered = inbox.messages
if (label === 'unread') {
filtered = filtered.filter((item) => item.unread)
} else if (label === 'starred') {
filtered = filtered.filter((item) => item.starred)
} else if (label && label !== 'important') {
filtered = filtered.filter((item) => item.labels.includes(label))
}
if (query) {
filtered = filtered.filter((item) =>
[item.subject, item.snippet, item.from.email, item.from.name]
.filter(Boolean)
.some((field) => field!.toLowerCase().includes(query)),
)
}
return NextResponse.json({
...inbox,
messages: filtered,
})
}

View File

@ -1,21 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMessage, resolveTenantId } from '../../mockData'
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const message = getMessage(tenantId, params.id)
if (!message) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(message)
}
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const message = getMessage(tenantId, params.id)
if (!message) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
}

View File

@ -1,208 +0,0 @@
import type { MailInboxResponse, MailListMessage, MailMessageDetail, NamespacePolicy } from '@lib/mail/types'
type TenantMailData = {
inbox: MailListMessage[]
messages: Record<string, MailMessageDetail>
namespace: NamespacePolicy
}
const now = Date.now()
const baseMessages: MailListMessage[] = [
{
id: 'msg-1001',
subject: '【故障通报】核心链路延迟恢复通知',
snippet: '生产集群延迟已恢复至正常指标,详见行动项。',
from: { name: 'SRE 值班', email: 'sre@svc.plus' },
to: [{ name: 'Ops 团队', email: 'ops@tenant.io' }],
date: new Date(now - 5 * 60 * 1000).toISOString(),
unread: true,
starred: true,
labels: ['Incident', 'Priority'],
hasAttachments: true,
aiSummary: {
preview: '延迟恢复,需确认追踪指标。',
tone: '紧急',
},
},
{
id: 'msg-1002',
subject: '月度账单与消耗对账单',
snippet: '附件包含 5 月份资源使用与费用明细,请于本周内确认。',
from: { name: 'Finance Robot', email: 'billing@svc.plus' },
to: [{ name: 'Finance', email: 'finance@tenant.io' }],
date: new Date(now - 2 * 60 * 60 * 1000).toISOString(),
unread: false,
labels: ['Billing'],
hasAttachments: true,
aiSummary: {
preview: '账单结算提醒,需核对折扣。',
tone: '正式',
},
},
{
id: 'msg-1003',
subject: 'AI 助手联调会议记录',
snippet: '会议纪要包含下一步联调行动项与 SLA 讨论。',
from: { name: '产品经理', email: 'pm@svc.plus' },
to: [{ name: 'AI 团队', email: 'ai@tenant.io' }],
date: new Date(now - 5 * 60 * 60 * 1000).toISOString(),
unread: false,
labels: ['Product'],
aiSummary: {
preview: '提炼三条关键任务。',
tone: '合作',
},
},
{
id: 'msg-1004',
subject: '【提醒】IAM 权限矩阵变更审批',
snippet: '审批单待确认,涉及新的只读角色授权,请于 24 小时内处理。',
from: { name: 'Access Bot', email: 'iam@svc.plus' },
to: [{ name: 'Security', email: 'sec@tenant.io' }],
date: new Date(now - 12 * 60 * 60 * 1000).toISOString(),
unread: true,
labels: ['Security'],
aiSummary: {
preview: '审批截止前需确认。',
tone: '提醒',
},
},
]
const detailMap: Record<string, MailMessageDetail> = {
'msg-1001': {
...baseMessages[0],
text: '生产链路延迟恢复。请确认后续监控指标与复盘会议安排。',
html: '<p>生产链路延迟已恢复。</p><ul><li>核对 Prometheus 延迟指标</li><li>更新状态页面</li><li>准备 18:00 复盘会议</li></ul>',
attachments: [
{
id: 'att-1',
fileName: 'incident-report.pdf',
contentType: 'application/pdf',
size: 234567,
downloadUrl: '#',
},
],
aiInsights: {
summary: '生产链路延迟恢复,需跟进指标及复盘会议。',
bullets: ['Prometheus 延迟恢复', '状态页面需更新', '18:00 复盘会议'],
actions: ['确认状态页', '同步客户邮件', '准备复盘材料'],
tone: '紧急',
suggestions: [
'感谢通知,已安排团队核查 Prometheus 指标。',
'收到,我们将于 18:00 准备复盘材料。',
'请同步可能影响的客户列表,方便统一公告。',
],
},
},
'msg-1002': {
...baseMessages[1],
text: '随信附上 5 月份账单,包含折扣与超额费用明细,请在本周内完成对账。',
attachments: [
{
id: 'att-2',
fileName: 'may-usage.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 54567,
downloadUrl: '#',
},
],
aiInsights: {
summary: '账单需要财务团队在本周内确认。',
bullets: ['包含折扣明细', '有部分资源超额', '需在周五前回复'],
actions: ['核对折扣', '确认超额原因', '回邮确认'],
tone: '正式',
suggestions: ['已收悉,我们将在周四前完成对账并回复。'],
},
},
'msg-1003': {
...baseMessages[2],
html: '<p>联调会议要点:</p><ol><li>六月上线 Beta需补充监控指标</li><li>AI 模型回退策略需评审</li><li>下一次联调会议安排在周五上午</li></ol>',
aiInsights: {
summary: '会议聚焦上线计划、模型回退与下次会议时间。',
bullets: ['六月 Beta 上线', '确认模型回退策略', '周五上午继续联调'],
actions: ['同步监控指标清单', '准备回退方案文档', '发送会议邀请'],
tone: '合作',
},
},
'msg-1004': {
...baseMessages[3],
text: 'IAM 角色矩阵变更涉及新建只读角色,需要安全团队审批。',
aiInsights: {
summary: '安全团队需在 24 小时内确认新角色审批。',
bullets: ['新增只读角色', '审批截止 24 小时内', '需评估权限边界'],
actions: ['审阅角色权限', '评估风险', '确认审批或驳回'],
tone: '提醒',
},
},
}
const TENANT_DATA: Record<string, TenantMailData> = {
'tenant-alpha': {
inbox: baseMessages,
messages: detailMap,
namespace: {
model: 'gpt-4o-mini',
temperature: 0.3,
maxTokens: 2048,
rateLimitPerMinute: 60,
vectorIndex: 's3://tenant-alpha-mail',
policy: '{"blockedKeywords": ["NDA", "秘密"]}',
updatedAt: new Date(now - 3600 * 1000).toISOString(),
},
},
default: {
inbox: baseMessages,
messages: detailMap,
namespace: {
model: 'gpt-4o-mini',
temperature: 0.5,
maxTokens: 2048,
rateLimitPerMinute: 30,
vectorIndex: 's3://default-mail',
policy: '{"allowExternal": true}',
updatedAt: new Date(now - 7200 * 1000).toISOString(),
},
},
}
export function resolveTenantId(raw: string | null | undefined) {
if (!raw) {
return 'default'
}
return TENANT_DATA[raw] ? raw : 'default'
}
export function getInbox(tenantId: string): MailInboxResponse {
const data = TENANT_DATA[tenantId] ?? TENANT_DATA.default
return {
messages: data.inbox,
labels: [
{ id: 'Incident', name: 'Incident', color: '#f97316', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Incident')).length },
{ id: 'Billing', name: 'Billing', color: '#2563eb', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Billing')).length },
{ id: 'Security', name: 'Security', color: '#7c3aed', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Security')).length },
{ id: 'Product', name: 'Product', color: '#0f766e', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Product')).length },
],
unreadCount: data.inbox.filter((item) => item.unread).length,
nextCursor: null,
}
}
export function getMessage(tenantId: string, id: string): MailMessageDetail | null {
const data = TENANT_DATA[tenantId] ?? TENANT_DATA.default
return data.messages[id] ?? null
}
export function getNamespace(tenantId: string): NamespacePolicy {
const data = TENANT_DATA[tenantId] ?? TENANT_DATA.default
return data.namespace
}
export function updateNamespace(tenantId: string, patch: Partial<NamespacePolicy>): NamespacePolicy {
const key = TENANT_DATA[tenantId] ? tenantId : 'default'
const current = TENANT_DATA[key].namespace
const next = { ...current, ...patch, updatedAt: new Date().toISOString() }
TENANT_DATA[key].namespace = next
return next
}

View File

@ -1,14 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getNamespace, resolveTenantId, updateNamespace } from '../mockData'
export async function GET(request: NextRequest) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
return NextResponse.json(getNamespace(tenantId))
}
export async function PUT(request: NextRequest) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const patch = (await request.json()) as Record<string, unknown>
return NextResponse.json(updateNamespace(tenantId, patch))
}

View File

@ -1,9 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import type { ComposePayload } from '@lib/mail/types'
export async function POST(request: NextRequest) {
const payload = (await request.json()) as ComposePayload
void payload
return NextResponse.json({ success: true })
}

View File

@ -1,22 +0,0 @@
import { NextResponse } from 'next/server'
import { loadRuntimeConfig } from '@server/runtime-loader'
export async function GET(request: Request) {
const hostnameHeader = request.headers.get('host') ?? undefined
const runtimeConfig = loadRuntimeConfig({ hostname: hostnameHeader })
const payload = {
status: 'ok' as const,
environment: runtimeConfig.environment,
region: runtimeConfig.region,
apiBaseUrl: runtimeConfig.apiBaseUrl,
authUrl: runtimeConfig.authUrl,
dashboardUrl: runtimeConfig.dashboardUrl,
logLevel: runtimeConfig.logLevel,
}
console.info('[runtime-config] /api/ping resolved config snippet', payload)
return NextResponse.json(payload)
}

View File

@ -1,37 +0,0 @@
import { getInternalServerServiceBaseUrl } from '@server/serviceConfig'
const FORWARDED_HEADERS = ['authorization', 'cookie', 'x-account-session'] as const
function buildForwardHeaders(req: Request) {
const headers = new Headers({ 'Content-Type': 'application/json' })
for (const name of FORWARDED_HEADERS) {
const value = req.headers.get(name)
if (value) {
headers.set(name, value)
}
}
return headers
}
export async function POST(req: Request) {
try {
const { question, history } = await req.json()
const apiBase = getInternalServerServiceBaseUrl()
const response = await fetch(`${apiBase}/api/rag/query`, {
method: 'POST',
headers: buildForwardHeaders(req),
body: JSON.stringify({ question, history }),
credentials: 'include'
})
const data = await response.json().catch(() => null)
return Response.json(data ?? { error: 'Invalid response from server' }, {
status: response.status
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return Response.json({ error: message }, { status: 500 })
}
}

View File

@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { ContentNotFoundError, renderMarkdownFile } from '../../../api/render-markdown'
export const runtime = 'nodejs'
export async function GET(request: NextRequest) {
const path = request.nextUrl.searchParams.get('path')
if (!path) {
return NextResponse.json({ error: 'Missing path parameter' }, { status: 400 })
}
try {
const result = await renderMarkdownFile(path)
return NextResponse.json(result, { status: 200 })
} catch (error) {
if (error instanceof ContentNotFoundError) {
return NextResponse.json({ error: 'Markdown file not found' }, { status: 404 })
}
console.error('Failed to render markdown:', error)
return NextResponse.json({ error: 'Failed to render markdown' }, { status: 500 })
}
}

View File

@ -1,46 +0,0 @@
export const dynamic = 'force-dynamic'
import type { NextRequest } from 'next/server'
import { createUpstreamProxyHandler } from '@lib/apiProxy'
import { getInternalServerServiceBaseUrl } from '@server/serviceConfig'
const TASK_PREFIX = '/api/task'
function createHandler() {
const upstreamBaseUrl = getInternalServerServiceBaseUrl()
return createUpstreamProxyHandler({
upstreamBaseUrl,
upstreamPathPrefix: TASK_PREFIX,
})
}
const handler = createHandler()
export function GET(request: NextRequest) {
return handler(request)
}
export function POST(request: NextRequest) {
return handler(request)
}
export function PUT(request: NextRequest) {
return handler(request)
}
export function PATCH(request: NextRequest) {
return handler(request)
}
export function DELETE(request: NextRequest) {
return handler(request)
}
export function HEAD(request: NextRequest) {
return handler(request)
}
export function OPTIONS(request: NextRequest) {
return handler(request)
}

View File

@ -1,63 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
import { getInternalServerServiceBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const SERVER_API_BASE = getInternalServerServiceBaseUrl()
const SERVER_USERS_ENDPOINT = `${SERVER_API_BASE}/api/users`
const ALLOWED_ROLES: AccountUserRole[] = ['admin', 'operator']
type ErrorPayload = {
error: string
}
type PermissionAwareHeaders = {
'X-User-Role': string
'X-User-Permissions'?: string
}
function buildForwardHeaders(role: string, permissions: string[]): PermissionAwareHeaders {
const headers: PermissionAwareHeaders = {
'X-User-Role': role,
}
if (permissions.length > 0) {
headers['X-User-Permissions'] = permissions.join(',')
}
return headers
}
export async function GET() {
const session = await getAccountSession()
const user = session.user
if (!user) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, ALLOWED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
const headers = new Headers({
Accept: 'application/json',
...buildForwardHeaders(user.role, user.permissions),
})
const response = await fetch(SERVER_USERS_ENDPOINT, {
method: 'GET',
headers,
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}