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:
parent
9a5b95cfb2
commit
b035192cd2
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { POST, GET } from '../../verify-email/route'
|
||||
@ -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
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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: '信息',
|
||||
})
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user