Improve AskAI fallback and secure admin APIs (#522)
This commit is contained in:
parent
2b4760adfa
commit
2b560384b5
73
ui/homepage/app/api/admin/settings/route.ts
Normal file
73
ui/homepage/app/api/admin/settings/route.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceBaseUrl } from '@lib/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = `${getAccountServiceBaseUrl()}/api/auth`
|
||||
|
||||
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 (!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 (!userHasRole(user, WRITE_ROLES)) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
return proxyAccountRequest(request, `${ACCOUNT_API_BASE}/admin/settings`, 'POST', session.token)
|
||||
}
|
||||
|
||||
67
ui/homepage/app/api/admin/users/[userId]/role/route.ts
Normal file
67
ui/homepage/app/api/admin/users/[userId]/role/route.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceBaseUrl } from '@lib/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = `${getAccountServiceBaseUrl()}/api/auth`
|
||||
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 (!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 })
|
||||
}
|
||||
|
||||
43
ui/homepage/app/api/admin/users/metrics/route.ts
Normal file
43
ui/homepage/app/api/admin/users/metrics/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceBaseUrl } from '@lib/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = `${getAccountServiceBaseUrl()}/api/auth`
|
||||
|
||||
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 (!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 })
|
||||
}
|
||||
|
||||
61
ui/homepage/app/api/users/route.ts
Normal file
61
ui/homepage/app/api/users/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { getInternalServerServiceBaseUrl } from '@lib/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 (!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 })
|
||||
}
|
||||
|
||||
@ -168,39 +168,68 @@ export function AskAIDialog({
|
||||
}
|
||||
|
||||
try {
|
||||
let { answer, retrieved } = await streamChat(
|
||||
'/api/rag/query',
|
||||
{ question: normalized, history },
|
||||
updateAI
|
||||
)
|
||||
let ragAnswer = ''
|
||||
let ragRetrieved: any[] = []
|
||||
let ragError: any = null
|
||||
|
||||
if (!answer || retrieved.length === 0) {
|
||||
console.warn(
|
||||
!answer
|
||||
? 'RAG query returned empty answer, falling back to /api/askai'
|
||||
: 'RAG query returned no relevant chunks, falling back to /api/askai'
|
||||
try {
|
||||
const result = await streamChat(
|
||||
'/api/rag/query',
|
||||
{ question: normalized, history },
|
||||
updateAI
|
||||
)
|
||||
ragAnswer = result?.answer ?? ''
|
||||
ragRetrieved = Array.isArray(result?.retrieved) ? result.retrieved : []
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError') {
|
||||
throw err
|
||||
}
|
||||
ragError = err
|
||||
}
|
||||
|
||||
if (ragError) {
|
||||
console.warn('RAG query failed, falling back to /api/askai', ragError)
|
||||
}
|
||||
|
||||
let answer = ragAnswer
|
||||
let retrieved = ragRetrieved
|
||||
|
||||
if (!answer || retrieved.length === 0 || ragError) {
|
||||
if (!ragError && !answer) {
|
||||
console.warn('RAG query returned empty answer, falling back to /api/askai')
|
||||
} else if (!ragError && retrieved.length === 0) {
|
||||
console.warn('RAG query returned no relevant chunks, falling back to /api/askai')
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await streamChat(
|
||||
'/api/askai',
|
||||
{ question: normalized, history },
|
||||
updateAI
|
||||
)
|
||||
if (result.answer) {
|
||||
if (result?.answer) {
|
||||
answer = result.answer
|
||||
}
|
||||
if (result.retrieved && result.retrieved.length > 0) {
|
||||
if (Array.isArray(result?.retrieved) && result.retrieved.length > 0) {
|
||||
retrieved = result.retrieved
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fallback /api/askai failed', err)
|
||||
// ignore, fallback handled below
|
||||
} catch (fallbackError: any) {
|
||||
if (fallbackError?.name === 'AbortError') {
|
||||
throw fallbackError
|
||||
}
|
||||
console.error('Fallback /api/askai failed', fallbackError)
|
||||
if (!answer) {
|
||||
updateAI('Sorry, I could not find an answer at this time.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!answer) {
|
||||
answer = 'Sorry, I could not find an answer at this time.'
|
||||
updateAI(answer)
|
||||
} else if (retrieved.length === 0) {
|
||||
return
|
||||
}
|
||||
if (retrieved.length === 0) {
|
||||
answer +=
|
||||
'\n\n_Note: No relevant documents were found; this answer may be inaccurate._'
|
||||
updateAI(answer)
|
||||
|
||||
247
ui/homepage/server/account/session.ts
Normal file
247
ui/homepage/server/account/session.ts
Normal file
@ -0,0 +1,247 @@
|
||||
'use server'
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
import { SESSION_COOKIE_NAME } from '@lib/authGateway'
|
||||
import { getAccountServiceBaseUrl } from '@lib/serviceConfig'
|
||||
|
||||
const ACCOUNT_SERVICE_BASE = getAccountServiceBaseUrl()
|
||||
const ACCOUNT_API_BASE = `${ACCOUNT_SERVICE_BASE}/api/auth`
|
||||
|
||||
export type AccountUserRole = 'guest' | 'user' | 'operator' | 'admin'
|
||||
|
||||
export type AccountTenantMembership = {
|
||||
id: string
|
||||
name?: string
|
||||
role?: AccountUserRole
|
||||
}
|
||||
|
||||
export type AccountSessionUser = {
|
||||
id: string
|
||||
uuid: string
|
||||
email: string
|
||||
name?: string
|
||||
username?: string
|
||||
role: AccountUserRole
|
||||
groups: string[]
|
||||
permissions: string[]
|
||||
tenantId?: string
|
||||
tenants?: AccountTenantMembership[]
|
||||
}
|
||||
|
||||
export type AccountSessionResult = {
|
||||
token?: string
|
||||
user: AccountSessionUser | null
|
||||
}
|
||||
|
||||
type RawAccountTenant = {
|
||||
id?: unknown
|
||||
name?: unknown
|
||||
role?: unknown
|
||||
}
|
||||
|
||||
type RawAccountUser = {
|
||||
id?: unknown
|
||||
uuid?: unknown
|
||||
email?: unknown
|
||||
name?: unknown
|
||||
username?: unknown
|
||||
role?: unknown
|
||||
groups?: unknown
|
||||
permissions?: unknown
|
||||
tenantId?: unknown
|
||||
tenants?: unknown
|
||||
}
|
||||
|
||||
type AccountSessionResponse = {
|
||||
user?: RawAccountUser | null
|
||||
}
|
||||
|
||||
const KNOWN_ROLE_MAP: Record<string, AccountUserRole> = {
|
||||
admin: 'admin',
|
||||
administrator: 'admin',
|
||||
operator: 'operator',
|
||||
ops: 'operator',
|
||||
user: 'user',
|
||||
member: 'user',
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown): AccountUserRole {
|
||||
if (typeof value !== 'string') {
|
||||
return 'guest'
|
||||
}
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return 'guest'
|
||||
}
|
||||
return KNOWN_ROLE_MAP[normalized] ?? 'guest'
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
function normalizeStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
const result: string[] = []
|
||||
for (const entry of value) {
|
||||
const normalized = normalizeString(entry)
|
||||
if (normalized) {
|
||||
result.push(normalized)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeTenants(value: unknown): AccountTenantMembership[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
const normalized: AccountTenantMembership[] = []
|
||||
for (const tenant of value) {
|
||||
if (!tenant || typeof tenant !== 'object') {
|
||||
continue
|
||||
}
|
||||
const raw = tenant as RawAccountTenant
|
||||
const identifier = normalizeString(raw.id)
|
||||
if (!identifier) {
|
||||
continue
|
||||
}
|
||||
const entry: AccountTenantMembership = { id: identifier }
|
||||
const name = normalizeString(raw.name)
|
||||
if (name) {
|
||||
entry.name = name
|
||||
}
|
||||
const role = normalizeRole(raw.role)
|
||||
if (role !== 'guest') {
|
||||
entry.role = role
|
||||
}
|
||||
normalized.push(entry)
|
||||
}
|
||||
return normalized.length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
function buildUser(raw: RawAccountUser | null | undefined): AccountSessionUser | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null
|
||||
}
|
||||
const identifier = normalizeString(raw.uuid) ?? normalizeString(raw.id)
|
||||
const email = normalizeString(raw.email)
|
||||
if (!identifier || !email) {
|
||||
return null
|
||||
}
|
||||
const name = normalizeString(raw.name)
|
||||
const username = normalizeString(raw.username) ?? name
|
||||
const role = normalizeRole(raw.role)
|
||||
const groups = normalizeStringList(raw.groups)
|
||||
const permissions = normalizeStringList(raw.permissions)
|
||||
const tenantId = normalizeString(raw.tenantId)
|
||||
const tenants = normalizeTenants(raw.tenants)
|
||||
|
||||
return {
|
||||
id: identifier,
|
||||
uuid: identifier,
|
||||
email,
|
||||
name: name ?? undefined,
|
||||
username: username ?? undefined,
|
||||
role,
|
||||
groups,
|
||||
permissions,
|
||||
tenantId: tenantId ?? undefined,
|
||||
tenants,
|
||||
}
|
||||
}
|
||||
|
||||
function extractBearer(value: string | null): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
const prefix = 'Bearer '
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return trimmed.slice(prefix.length).trim() || undefined
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function resolveTokenFromRequest(request?: NextRequest): string | undefined {
|
||||
if (request) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const authToken = extractBearer(authHeader)
|
||||
if (authToken) {
|
||||
return authToken
|
||||
}
|
||||
const sessionHeader = request.headers.get('x-account-session')
|
||||
if (sessionHeader && sessionHeader.trim().length > 0) {
|
||||
return sessionHeader.trim()
|
||||
}
|
||||
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value
|
||||
if (cookieToken && cookieToken.trim().length > 0) {
|
||||
return cookieToken.trim()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const cookieStore = cookies()
|
||||
const cookieToken = cookieStore.get(SESSION_COOKIE_NAME)?.value
|
||||
if (cookieToken && cookieToken.trim().length > 0) {
|
||||
return cookieToken.trim()
|
||||
}
|
||||
} catch (error) {
|
||||
// Accessing cookies() outside a request context throws; ignore and fall through.
|
||||
console.warn('Failed to read session cookie from request context', error)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function userHasRole(user: AccountSessionUser | null, roles: AccountUserRole[]): boolean {
|
||||
if (!user || roles.length === 0) {
|
||||
return false
|
||||
}
|
||||
return roles.includes(user.role)
|
||||
}
|
||||
|
||||
export async function getAccountSession(request?: NextRequest): Promise<AccountSessionResult> {
|
||||
const token = resolveTokenFromRequest(request)
|
||||
if (!token) {
|
||||
return { token: undefined, user: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/session`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return { token, user: null }
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as AccountSessionResponse | null
|
||||
if (!payload?.user) {
|
||||
return { token, user: null }
|
||||
}
|
||||
|
||||
const user = buildUser(payload.user)
|
||||
return { token, user }
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve account session', error)
|
||||
return { token, user: null }
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,7 +20,8 @@
|
||||
"@components/*": ["components/*"],
|
||||
"@i18n/*": ["i18n/*"],
|
||||
"@lib/*": ["lib/*"],
|
||||
"@types/*": ["types/*"]
|
||||
"@types/*": ["types/*"],
|
||||
"@server/*": ["server/*"]
|
||||
},
|
||||
"types": ["node"],
|
||||
"plugins": [{ "name": "next" }]
|
||||
@ -30,6 +31,7 @@
|
||||
"components",
|
||||
"i18n",
|
||||
"lib",
|
||||
"server",
|
||||
"types",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user