feat(auth): add readonly review access

This commit is contained in:
Haitao Pan 2026-03-16 09:26:04 +08:00
parent 0fab89e0bd
commit 6c56ea1ba3
13 changed files with 1207 additions and 772 deletions

View File

@ -1,9 +1,51 @@
# Release Process
## Purpose
This page tracks release summaries for published versions of `console.svc.plus`.
- TODO: Add content specific to Release Process.
## Current Release
### v0.2
Release tag: `v0.2`
Release branch: `release/v0.2`
Published commit: `0fab89e`
#### Highlights
- Introduced the new XWorkmate workspace with a denser assistant layout, cleaner shell, and improved entry flow.
- Added the OpenClaw assistant workspace and pairing bridge, including configurable origin override and more stable pairing fallback behavior.
- Unified navigation and AI entry points with a persistent assistant sidebar and refined panel routing.
- Added the latest blog shortcuts on the homepage and improved guest and registration messaging.
- Expanded docs with bilingual structure updates, stronger OIDC guidance, and setup/readme cleanup.
- Fixed build stability issues, including `next-mdx-remote` vulnerability-related build failures and Yarn dependency metadata alignment.
#### New Features
- Launched the XWorkmate workspace and polished its workspace entry and layout.
- Added OpenClaw assistant integration, pairing bridge support, integration probe API, and integration defaults handling.
- Added XScopeHub MCP visibility on the services page.
- Displayed the latest 7 blog article titles in homepage shortcuts.
#### Improvements
- Split observability into a tri-view workspace and refined panel assistant routing.
- Unified navigation structure and persistent AI sidebar behavior.
- Improved login and registration flows by using server-resolved account service URLs.
- Consolidated demo and experience account handling around `sandbox@svc.plus`.
- Added vault-backed token lookup for integrations.
#### Docs And Setup
- Added bilingual docs coverage and restructured the docs entry points.
- Rewrote the OIDC authentication guide with fuller setup instructions.
- Updated setup guidance and simplified README structure.
#### Build And Dependency Fixes
- Updated and aligned `next-mdx-remote` usage for secure builds.
- Removed conflicting npm lockfile state and aligned Yarn dependency metadata for reproducible builds.
## Notes
- TODO: Link to related documents in this section.
- GitHub Release: `https://github.com/cloud-neutral-toolkit/console.svc.plus/releases/tag/v0.2`
- Related docs: `docs/README.md`, `docs/en/README.md`, `docs/zh/README.md`

View File

@ -2,10 +2,52 @@
> English: `../../governance/release-process.md`
## 目的
本页用于记录 `console.svc.plus` 已发布版本的发布说明与变更摘要。
- TODO: 补充中文内容。
## 当前版本
### v0.2
发布标签:`v0.2`
发布分支:`release/v0.2`
发布提交:`0fab89e`
#### 亮点
- 引入新的 XWorkmate 工作区,助手布局更紧凑,工作区外壳更统一,入口流程也更顺滑。
- 新增 OpenClaw assistant workspace 与 pairing bridge支持可配置的 origin override并改进了配对失败时的回退行为。
- 统一导航与 AI 入口,加入持久化 assistant sidebar并梳理 panel 路由。
- 首页增加最新博客快捷入口,同时优化游客模式与注册引导文案。
- 双语文档结构继续补齐OIDC 接入文档和安装说明也更完整。
- 修复构建稳定性问题,包括 `next-mdx-remote` 相关的漏洞拦截构建错误,以及 Yarn 依赖元数据对齐问题。
#### 新特性
- 上线 XWorkmate 工作区,并完善其入口与界面布局。
- 增加 OpenClaw assistant 集成、pairing bridge、integration probe API以及 integration defaults 处理能力。
- 在服务页加入 XScopeHub MCP 服务可见性。
- 首页快捷区展示最新 7 篇博客文章标题。
#### 体验改进
- 将 observability 工作区拆分为 tri-view并优化 panel 助手路由。
- 统一导航结构与持久化 AI sidebar 行为。
- 登录与注册流程改为使用服务端解析后的 account service URL。
- 体验账号与演示账号统一收敛到 `sandbox@svc.plus`
- 为集成配置增加基于 vault 的 token 查询能力。
#### 文档与安装
- 增补双语文档覆盖,并整理文档入口结构。
- 重写 OIDC 认证接入文档,补充更完整的配置说明。
- 更新安装指导并精简 README 结构。
#### 构建与依赖修复
- 对齐并升级 `next-mdx-remote` 使用方式,确保构建安全。
- 移除冲突的 npm 锁文件状态,并整理 Yarn 依赖元数据,提升构建可复现性。
## 备注
- TODO: 链接到本章节相关文档。
- GitHub Release`https://github.com/cloud-neutral-toolkit/console.svc.plus/releases/tag/v0.2`
- 相关文档:`docs/README.md`、`docs/en/README.md`、`docs/zh/README.md`

View File

@ -1,63 +1,88 @@
export const dynamic = 'force-dynamic'
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from 'next/server'
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'
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import {
getAccountSession,
userHasPermission,
userHasRole,
} from "@server/account/session";
import type { AccountUserRole } from "@server/account/session";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const REQUIRED_ROLES: AccountUserRole[] = ["admin", "operator"];
const WRITE_PERMISSIONS = ["admin.blacklist.write"];
type ErrorPayload = {
error: string
}
error: string;
};
type RouteParams = {
params: Promise<{
email: string
}>
}
email: string;
}>;
};
function resolveEmail(param?: string): string | null {
if (!param) {
return null
return null;
}
const trimmed = param.trim()
return trimmed.length > 0 ? trimmed : null
const trimmed = param.trim();
return trimmed.length > 0 ? trimmed : null;
}
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const session = await getAccountSession(request)
const user = session.user
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
if (
!(
(await userHasRole(user, REQUIRED_ROLES)) ||
(await userHasPermission(user, WRITE_PERMISSIONS))
)
) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
const { email: emailParam } = await params
const email = resolveEmail(emailParam)
const { email: emailParam } = await params;
const email = resolveEmail(emailParam);
if (!email) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_email' }, { status: 400 })
return NextResponse.json<ErrorPayload>(
{ error: "invalid_email" },
{ status: 400 },
);
}
const response = await fetch(`${ACCOUNT_API_BASE}/admin/blacklist/${encodeURIComponent(email)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
const response = await fetch(
`${ACCOUNT_API_BASE}/admin/blacklist/${encodeURIComponent(email)}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${session.token}`,
Accept: "application/json",
},
cache: "no-store",
},
cache: 'no-store',
})
);
const payload = await response.json().catch(() => null)
const payload = await response.json().catch(() => null);
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
return NextResponse.json<ErrorPayload>(
{ error: "invalid_response" },
{ status: 502 },
);
}
return NextResponse.json(payload, { status: response.status })
return NextResponse.json(payload, { status: response.status });
}

View File

@ -1,76 +1,108 @@
export const dynamic = 'force-dynamic'
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from 'next/server'
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'
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import {
getAccountSession,
userHasPermission,
userHasRole,
userHasRoleOrPermission,
} from "@server/account/session";
import type { AccountUserRole } from "@server/account/session";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const REQUIRED_ROLES: AccountUserRole[] = ["admin", "operator"];
const READ_PERMISSIONS = ["admin.blacklist.read"];
const WRITE_PERMISSIONS = ["admin.blacklist.write"];
type ErrorPayload = {
error: string
}
error: string;
};
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
if (
!(await userHasRoleOrPermission(user, REQUIRED_ROLES, READ_PERMISSIONS))
) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
const response = await fetch(`${ACCOUNT_API_BASE}/admin/blacklist`, {
method: 'GET',
method: "GET",
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
Accept: "application/json",
},
cache: 'no-store',
})
cache: "no-store",
});
const payload = await response.json().catch(() => null)
const payload = await response.json().catch(() => null);
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
return NextResponse.json<ErrorPayload>(
{ error: "invalid_response" },
{ status: 502 },
);
}
return NextResponse.json(payload, { status: response.status })
return NextResponse.json(payload, { status: response.status });
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
if (
!(
(await userHasRole(user, REQUIRED_ROLES)) ||
(await userHasPermission(user, WRITE_PERMISSIONS))
)
) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
const body = await request.text()
const body = await request.text();
const response = await fetch(`${ACCOUNT_API_BASE}/admin/blacklist`, {
method: 'POST',
method: "POST",
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
'Content-Type': request.headers.get('content-type') ?? 'application/json',
Accept: "application/json",
"Content-Type": request.headers.get("content-type") ?? "application/json",
},
body,
cache: 'no-store',
})
cache: "no-store",
});
const payload = await response.json().catch(() => null)
const payload = await response.json().catch(() => null);
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
return NextResponse.json<ErrorPayload>(
{ error: "invalid_response" },
{ status: 502 },
);
}
return NextResponse.json(payload, { status: response.status })
return NextResponse.json(payload, { status: response.status });
}

View File

@ -1,92 +1,120 @@
export const dynamic = 'force-dynamic'
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from 'next/server'
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'
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import {
getAccountSession,
userHasPermission,
userHasRole,
userHasRoleOrPermission,
} 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']
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const READ_ROLES: AccountUserRole[] = ["admin", "operator"];
const WRITE_ROLES: AccountUserRole[] = ["admin"];
const READ_PERMISSIONS = ["admin.settings.read"];
const WRITE_PERMISSIONS = ["admin.settings.write"];
type ErrorPayload = {
error: string
}
error: string;
};
async function proxyRequest(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
const { pathname, search } = new URL(request.url);
// Map /api/admin/sandbox/... to backend /admin/sandbox/...
const segments = pathname.replace(/^\/api\/admin\/sandbox/, "");
const targetUrl = `${ACCOUNT_API_BASE}/admin/sandbox${segments}${search}`;
const method = request.method;
const isWrite = method !== "GET" && method !== "HEAD";
if (isWrite) {
if (
!(
(await userHasRole(user, WRITE_ROLES)) ||
(await userHasPermission(user, WRITE_PERMISSIONS))
)
) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
} else {
if (!(await userHasRoleOrPermission(user, READ_ROLES, READ_PERMISSIONS))) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
}
const headers = new Headers({
Authorization: `Bearer ${session.token}`,
Accept: "application/json",
});
let body: string | undefined;
if (isWrite) {
body = await request.text();
const contentType =
request.headers.get("content-type") ?? "application/json";
headers.set("Content-Type", contentType);
}
try {
const response = await fetch(targetUrl, {
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 },
);
}
const { pathname, search } = new URL(request.url)
// Map /api/admin/sandbox/... to backend /admin/sandbox/...
const segments = pathname.replace(/^\/api\/admin\/sandbox/, '')
const targetUrl = `${ACCOUNT_API_BASE}/admin/sandbox${segments}${search}`
const method = request.method
const isWrite = method !== 'GET' && method !== 'HEAD'
if (isWrite) {
if (!(await userHasRole(user, WRITE_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
} else {
if (!(await userHasRole(user, READ_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
}
const headers = new Headers({
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
})
let body: string | undefined
if (isWrite) {
body = await request.text()
const contentType = request.headers.get('content-type') ?? 'application/json'
headers.set('Content-Type', contentType)
}
try {
const response = await fetch(targetUrl, {
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 })
} catch (err: any) {
return NextResponse.json<ErrorPayload>({ error: err.message }, { status: 500 })
}
return NextResponse.json(payload, { status: response.status });
} catch (err: any) {
return NextResponse.json<ErrorPayload>(
{ error: err.message },
{ status: 500 },
);
}
}
export async function GET(request: NextRequest) {
return proxyRequest(request)
return proxyRequest(request);
}
export async function POST(request: NextRequest) {
return proxyRequest(request)
return proxyRequest(request);
}
export async function PUT(request: NextRequest) {
return proxyRequest(request)
return proxyRequest(request);
}
export async function PATCH(request: NextRequest) {
return proxyRequest(request)
return proxyRequest(request);
}
export async function DELETE(request: NextRequest) {
return proxyRequest(request)
return proxyRequest(request);
}

View File

@ -1,75 +1,116 @@
export const dynamic = 'force-dynamic'
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from 'next/server'
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'
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import {
getAccountSession,
userHasPermission,
userHasRole,
userHasRoleOrPermission,
} from "@server/account/session";
import type { AccountUserRole } from "@server/account/session";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const READ_ROLES: AccountUserRole[] = ['admin', 'operator']
const WRITE_ROLES: AccountUserRole[] = ['admin']
const READ_ROLES: AccountUserRole[] = ["admin", "operator"];
const WRITE_ROLES: AccountUserRole[] = ["admin"];
const READ_PERMISSIONS = ["admin.settings.read"];
type ErrorPayload = {
error: string
}
error: string;
};
async function proxyAccountRequest(request: NextRequest, endpoint: string, method: string, token: string) {
async function proxyAccountRequest(
request: NextRequest,
endpoint: string,
method: string,
token: string,
) {
const headers = new Headers({
Authorization: `Bearer ${token}`,
Accept: 'application/json',
})
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)
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',
})
cache: "no-store",
});
const payload = await response.json().catch(() => null)
const payload = await response.json().catch(() => null);
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
return NextResponse.json<ErrorPayload>(
{ error: "invalid_response" },
{ status: 502 },
);
}
return NextResponse.json(payload, { status: response.status })
return NextResponse.json(payload, { status: response.status });
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (!(await userHasRole(user, READ_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
if (!(await userHasRoleOrPermission(user, READ_ROLES, READ_PERMISSIONS))) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
return proxyAccountRequest(request, `${ACCOUNT_API_BASE}/admin/settings`, 'GET', session.token)
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
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (!(await userHasRole(user, WRITE_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
if (
!(
(await userHasRole(user, WRITE_ROLES)) ||
(await userHasPermission(user, ["admin.settings.write"]))
)
) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
return proxyAccountRequest(request, `${ACCOUNT_API_BASE}/admin/settings`, 'POST', session.token)
return proxyAccountRequest(
request,
`${ACCOUNT_API_BASE}/admin/settings`,
"POST",
session.token,
);
}

View File

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

View File

@ -1,72 +1,87 @@
export const dynamic = 'force-dynamic'
export const dynamic = "force-dynamic";
import { NextResponse } from 'next/server'
import { NextResponse } from "next/server";
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import {
getAccountSession,
userHasRoleOrPermission,
} from "@server/account/session";
import type { AccountUserRole } from "@server/account/session";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const USERS_ENDPOINT = `${ACCOUNT_API_BASE}/users`
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const USERS_ENDPOINT = `${ACCOUNT_API_BASE}/users`;
const ALLOWED_ROLES: AccountUserRole[] = ['admin', 'operator']
const ALLOWED_ROLES: AccountUserRole[] = ["admin", "operator"];
const READ_PERMISSIONS = ["admin.users.list.read"];
type ErrorPayload = {
error: string
}
error: string;
};
type PermissionAwareHeaders = {
'X-User-Role': string
'X-User-Permissions'?: string
'X-Service-Token'?: string
}
"X-User-Role": string;
"X-User-Permissions"?: string;
"X-Service-Token"?: string;
};
function buildForwardHeaders(role: string, permissions: string[]): PermissionAwareHeaders {
function buildForwardHeaders(
role: string,
permissions: string[],
): PermissionAwareHeaders {
const headers: PermissionAwareHeaders = {
'X-User-Role': role,
}
"X-User-Role": role,
};
if (permissions.length > 0) {
headers['X-User-Permissions'] = permissions.join(',')
headers["X-User-Permissions"] = permissions.join(",");
}
// Add internal service token for service-to-service authentication
const serviceToken = process.env.INTERNAL_SERVICE_TOKEN
const serviceToken = process.env.INTERNAL_SERVICE_TOKEN;
if (serviceToken && serviceToken.trim().length > 0) {
headers['X-Service-Token'] = serviceToken.trim()
headers["X-Service-Token"] = serviceToken.trim();
}
return headers
return headers;
}
export async function GET() {
const session = await getAccountSession()
const user = session.user
const session = await getAccountSession();
const user = session.user;
if (!user) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (!(await userHasRole(user, ALLOWED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
if (!(await userHasRoleOrPermission(user, ALLOWED_ROLES, READ_PERMISSIONS))) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
const headers = new Headers({
Accept: 'application/json',
Accept: "application/json",
Authorization: `Bearer ${session.token}`,
...buildForwardHeaders(user.role, user.permissions),
})
});
const response = await fetch(USERS_ENDPOINT, {
method: 'GET',
method: "GET",
headers,
cache: 'no-store',
})
cache: "no-store",
});
const payload = await response.json().catch(() => null)
const payload = await response.json().catch(() => null);
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
return NextResponse.json<ErrorPayload>(
{ error: "invalid_response" },
{ status: 502 },
);
}
return NextResponse.json(payload, { status: response.status })
return NextResponse.json(payload, { status: response.status });
}

View File

@ -1,89 +1,137 @@
import { useMemo } from 'react'
import { useMemo } from "react";
import { useUserStore } from './userStore'
import type { SessionUser, TenantMembership, UserRole } from './userStore'
import { useUserStore } from "./userStore";
import type { SessionUser, TenantMembership, UserRole } from "./userStore";
type AccessReason = 'unauthenticated' | 'forbidden'
type AccessReason = "unauthenticated" | "forbidden";
export type AccessDecision = {
allowed: boolean
reason?: AccessReason
userRole: UserRole
userTenants?: TenantMembership[]
tenantId?: string
}
allowed: boolean;
reason?: AccessReason;
userRole: UserRole;
userTenants?: TenantMembership[];
tenantId?: string;
};
export type AccessRule = {
requireLogin?: boolean
allowGuests?: boolean
roles?: UserRole[]
permissions?: string[]
}
requireLogin?: boolean;
allowGuests?: boolean;
roles?: UserRole[];
permissions?: string[];
};
const EVERYONE_ROLES: UserRole[] = ['guest', 'user', 'operator', 'admin']
const EVERYONE_ROLES: UserRole[] = ["guest", "user", "operator", "admin"];
function normalizeRoles(roles?: UserRole[]): UserRole[] | undefined {
if (!roles || roles.length === 0) {
return undefined
return undefined;
}
const known = new Set<UserRole>()
const known = new Set<UserRole>();
for (const role of roles) {
if (EVERYONE_ROLES.includes(role)) {
known.add(role)
known.add(role);
}
}
return known.size ? Array.from(known) : undefined
return known.size ? Array.from(known) : undefined;
}
function normalizePermissions(permissions?: string[]): string[] | undefined {
if (!permissions || permissions.length === 0) {
return undefined
return undefined;
}
const known = new Set<string>()
const known = new Set<string>();
for (const permission of permissions) {
const trimmed = permission.trim()
const trimmed = permission.trim();
if (trimmed.length > 0) {
known.add(trimmed)
known.add(trimmed);
}
}
return known.size ? Array.from(known) : undefined
return known.size ? Array.from(known) : undefined;
}
export function resolveAccess(user: SessionUser, rule?: AccessRule): AccessDecision {
const normalizedRule = rule ?? {}
const normalizedRoles = normalizeRoles(normalizedRule.roles)
const normalizedPermissions = normalizePermissions(normalizedRule.permissions)
export function resolveAccess(
user: SessionUser,
rule?: AccessRule,
): AccessDecision {
const normalizedRule = rule ?? {};
const normalizedRoles = normalizeRoles(normalizedRule.roles);
const normalizedPermissions = normalizePermissions(
normalizedRule.permissions,
);
const role: UserRole = user?.role ?? 'guest'
const isAuthenticated = Boolean(user)
const role: UserRole = user?.role ?? "guest";
const isAuthenticated = Boolean(user);
const allowGuests =
normalizedRule.allowGuests ?? (!normalizedRoles || normalizedRoles.includes('guest'))
normalizedRule.allowGuests ??
(!normalizedRoles || normalizedRoles.includes("guest"));
const requiresLogin =
normalizedRule.requireLogin ??
(!allowGuests ||
Boolean(normalizedPermissions && normalizedPermissions.length > 0) ||
Boolean(normalizedRoles && !normalizedRoles.includes('guest')))
Boolean(normalizedRoles && !normalizedRoles.includes("guest")));
if (!isAuthenticated && requiresLogin) {
if (allowGuests) {
// Guests explicitly allowed to pass through.
} else {
return { allowed: false, reason: 'unauthenticated', userRole: role }
return { allowed: false, reason: "unauthenticated", userRole: role };
}
}
if (normalizedRoles && !normalizedRoles.includes(role)) {
const userPermissions = new Set(user?.permissions ?? []);
const roleAllowed = normalizedRoles
? normalizedRoles.includes(role)
: undefined;
const permissionAllowed = normalizedPermissions
? normalizedPermissions.every(
(permission) =>
userPermissions.has(permission) || userPermissions.has("*"),
)
: undefined;
if (
normalizedRoles &&
normalizedPermissions &&
normalizedRoles.length > 0 &&
normalizedPermissions.length > 0
) {
if (!roleAllowed && !permissionAllowed) {
if (!isAuthenticated && allowGuests) {
return { allowed: false, reason: "unauthenticated", userRole: role };
}
return {
allowed: false,
reason: isAuthenticated ? "forbidden" : "unauthenticated",
userRole: role,
};
}
} else if (normalizedRoles && !roleAllowed) {
if (!isAuthenticated && allowGuests) {
return { allowed: false, reason: 'unauthenticated', userRole: role }
return { allowed: false, reason: "unauthenticated", userRole: role };
}
return { allowed: false, reason: isAuthenticated ? 'forbidden' : 'unauthenticated', userRole: role }
return {
allowed: false,
reason: isAuthenticated ? "forbidden" : "unauthenticated",
userRole: role,
};
}
if (normalizedPermissions && normalizedPermissions.length > 0) {
const userPermissions = new Set(user?.permissions ?? [])
const missing = normalizedPermissions.some((permission) => !userPermissions.has(permission))
if (
!normalizedRoles &&
normalizedPermissions &&
normalizedPermissions.length > 0
) {
const userPermissions = new Set(user?.permissions ?? []);
const missing = normalizedPermissions.some(
(permission) =>
!userPermissions.has(permission) && !userPermissions.has("*"),
);
if (missing) {
return { allowed: false, reason: isAuthenticated ? 'forbidden' : 'unauthenticated', userRole: role }
return {
allowed: false,
reason: isAuthenticated ? "forbidden" : "unauthenticated",
userRole: role,
};
}
}
@ -92,17 +140,17 @@ export function resolveAccess(user: SessionUser, rule?: AccessRule): AccessDecis
userRole: role,
userTenants: user?.tenants,
tenantId: user?.tenantId,
}
};
}
export function useAccess(rule?: AccessRule) {
const user = useUserStore((state) => state.user)
const isLoading = useUserStore((state) => state.isLoading)
const user = useUserStore((state) => state.user);
const isLoading = useUserStore((state) => state.isLoading);
const decision = useMemo(() => resolveAccess(user, rule), [user, rule])
const decision = useMemo(() => resolveAccess(user, rule), [user, rule]);
return {
...decision,
isLoading,
}
};
}

View File

@ -1,66 +1,70 @@
import { Activity, Database, Key, Rocket, Settings } from 'lucide-react'
import { Activity, Database, Key, Rocket, Settings } from "lucide-react";
import type { DashboardExtension } from '../../types'
import type { DashboardExtension } from "../../types";
export const infraExtension: DashboardExtension = {
id: 'builtin.infra',
meta: {
title: '基础设施管理',
description: '云基础设施、部署、资源与可观测性管理。',
version: '1.0.0',
author: 'Cloud-Neutral',
keywords: ['infrastructure', 'deployments', 'resources', 'observability'],
id: "builtin.infra",
meta: {
title: "基础设施管理",
description: "云基础设施、部署、资源与可观测性管理。",
version: "1.0.0",
author: "Cloud-Neutral",
keywords: ["infrastructure", "deployments", "resources", "observability"],
},
routes: [
{
id: "deployments",
path: "/panel/deployments",
label: "Deployments",
description: "部署任务与运行状态",
icon: Rocket,
loader: () => import("./routes/placeholder"),
guard: { requireLogin: true },
sidebar: { section: "infra", order: 0 },
},
routes: [
{
id: 'deployments',
path: '/panel/deployments',
label: 'Deployments',
description: '部署任务与运行状态',
icon: Rocket,
loader: () => import('./routes/placeholder'),
guard: { requireLogin: true },
sidebar: { section: 'infra', order: 0 },
},
{
id: 'resources',
path: '/panel/resources',
label: 'Resources',
description: '云资源与数据库实例',
icon: Database,
loader: () => import('./routes/placeholder'),
guard: { requireLogin: true },
sidebar: { section: 'infra', order: 1 },
},
{
id: 'apiKeys',
path: '/panel/api-keys',
label: 'API Keys',
description: '接口密钥与访问凭证',
icon: Key,
loader: () => import('./routes/placeholder'),
guard: { requireLogin: true },
sidebar: { section: 'infra', order: 2 },
},
{
id: 'logs',
path: '/panel/observability',
label: 'Observability',
description: '监控、日志与 AI 分析',
icon: Activity,
loader: () => import('./routes/placeholder'),
guard: { requireLogin: true },
sidebar: { section: 'infra', order: 3 },
},
{
id: 'settings',
path: '/panel/settings',
label: 'Settings',
description: '全局系统配置',
icon: Settings,
loader: () => import('./routes/placeholder'),
guard: { requireLogin: true, roles: ['admin', 'operator'] },
sidebar: { section: 'preferences', order: 99 },
},
],
}
{
id: "resources",
path: "/panel/resources",
label: "Resources",
description: "云资源与数据库实例",
icon: Database,
loader: () => import("./routes/placeholder"),
guard: { requireLogin: true },
sidebar: { section: "infra", order: 1 },
},
{
id: "apiKeys",
path: "/panel/api-keys",
label: "API Keys",
description: "接口密钥与访问凭证",
icon: Key,
loader: () => import("./routes/placeholder"),
guard: { requireLogin: true },
sidebar: { section: "infra", order: 2 },
},
{
id: "logs",
path: "/panel/observability",
label: "Observability",
description: "监控、日志与 AI 分析",
icon: Activity,
loader: () => import("./routes/placeholder"),
guard: { requireLogin: true },
sidebar: { section: "infra", order: 3 },
},
{
id: "settings",
path: "/panel/settings",
label: "Settings",
description: "全局系统配置",
icon: Settings,
loader: () => import("./routes/placeholder"),
guard: {
requireLogin: true,
roles: ["admin", "operator"],
permissions: ["admin.settings.read"],
},
sidebar: { section: "preferences", order: 99 },
},
],
};

View File

@ -1,133 +1,152 @@
import { Code, CreditCard, Home, Palette, Server, Settings, Shield, User } from 'lucide-react'
import {
Code,
CreditCard,
Home,
Palette,
Server,
Settings,
Shield,
User,
} from "lucide-react";
import type { DashboardExtension } from '../../types'
import type { DashboardExtension } from "../../types";
export const userCenterExtension: DashboardExtension = {
id: 'builtin.user-center',
id: "builtin.user-center",
meta: {
title: '用户中心',
description: '核心控制台能力,包括账户、管理与观测功能。',
version: '1.0.0',
author: 'Cloud-Neutral',
keywords: ['dashboard', 'accounts', 'management'],
title: "用户中心",
description: "核心控制台能力,包括账户、管理与观测功能。",
version: "1.0.0",
author: "Cloud-Neutral",
keywords: ["dashboard", "accounts", "management"],
},
routes: [
{
id: 'dashboard',
path: '/panel',
label: 'Dashboard',
description: '专属于你的信息总览',
id: "dashboard",
path: "/panel",
label: "Dashboard",
description: "专属于你的信息总览",
icon: Home,
loader: () => import('./routes/home'),
match: 'startsWith',
loader: () => import("./routes/home"),
match: "startsWith",
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: 'workspace', order: 0 },
redirect: { unauthenticated: "/login" },
sidebar: { section: "workspace", order: 0 },
},
{
id: 'agents',
path: '/panel/agent',
label: 'Agents',
description: '管理运行节点',
id: "agents",
path: "/panel/agent",
label: "Agents",
description: "管理运行节点",
icon: Server,
loader: () => import('./routes/agent'),
loader: () => import("./routes/agent"),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: 'productivity', order: 10 },
redirect: { unauthenticated: "/login" },
sidebar: { section: "productivity", order: 10 },
featureFlag: {
id: 'user-center.agent',
title: 'Agent 节点管理',
description: '启用运行节点管理页面。',
envVar: 'NEXT_PUBLIC_FEATURE_AGENT_MODULE',
id: "user-center.agent",
title: "Agent 节点管理",
description: "启用运行节点管理页面。",
envVar: "NEXT_PUBLIC_FEATURE_AGENT_MODULE",
defaultEnabled: true,
},
},
{
id: 'apis',
path: '/panel/api',
label: 'Integrations',
description: '统一管理 OpenClaw、Vault 与 AI Gateway',
id: "apis",
path: "/panel/api",
label: "Integrations",
description: "统一管理 OpenClaw、Vault 与 AI Gateway",
icon: Code,
loader: () => import('./routes/api'),
loader: () => import("./routes/api"),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: 'productivity', order: 11 },
redirect: { unauthenticated: "/login" },
sidebar: { section: "productivity", order: 11 },
featureFlag: {
id: 'user-center.api',
title: '接口集成',
description: '启用 OpenClaw、Vault 与 APISIX AI Gateway 集成页面。',
envVar: 'NEXT_PUBLIC_FEATURE_API_MODULE',
id: "user-center.api",
title: "接口集成",
description: "启用 OpenClaw、Vault 与 APISIX AI Gateway 集成页面。",
envVar: "NEXT_PUBLIC_FEATURE_API_MODULE",
defaultEnabled: true,
},
},
{
id: 'accounts',
path: '/panel/account',
label: 'Accounts',
description: '目录与多因素设置',
id: "accounts",
path: "/panel/account",
label: "Accounts",
description: "目录与多因素设置",
icon: User,
loader: () => import('./routes/account'),
loader: () => import("./routes/account"),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: 'management', order: 20 },
redirect: { unauthenticated: "/login" },
sidebar: { section: "management", order: 20 },
},
{
id: 'subscription',
path: '/panel/subscription',
label: 'Subscription',
description: '订阅方案与计费规则',
id: "subscription",
path: "/panel/subscription",
label: "Subscription",
description: "订阅方案与计费规则",
icon: CreditCard,
loader: () => import('./routes/subscription'),
loader: () => import("./routes/subscription"),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: 'management', order: 21 },
redirect: { unauthenticated: "/login" },
sidebar: { section: "management", order: 21 },
featureFlag: {
id: 'user-center.subscription',
title: '订阅与计费',
description: '启用订阅与计费配置页面。',
envVar: 'NEXT_PUBLIC_FEATURE_SUBSCRIPTION_MODULE',
id: "user-center.subscription",
title: "订阅与计费",
description: "启用订阅与计费配置页面。",
envVar: "NEXT_PUBLIC_FEATURE_SUBSCRIPTION_MODULE",
defaultEnabled: true,
},
},
{
id: 'ldp',
path: '/panel/ldp',
label: 'LDP',
description: '低时延身份平面',
id: "ldp",
path: "/panel/ldp",
label: "LDP",
description: "低时延身份平面",
icon: Shield,
loader: () => import('./routes/ldp'),
loader: () => import("./routes/ldp"),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: 'management', order: 22 },
redirect: { unauthenticated: "/login" },
sidebar: { section: "management", order: 22 },
featureFlag: {
id: 'user-center.ldp',
title: 'LDP 管理',
description: '启用低时延身份平面管理模块。',
envVar: 'NEXT_PUBLIC_FEATURE_LDP_MODULE',
id: "user-center.ldp",
title: "LDP 管理",
description: "启用低时延身份平面管理模块。",
envVar: "NEXT_PUBLIC_FEATURE_LDP_MODULE",
defaultEnabled: false,
},
},
{
id: 'appearance',
path: '/panel/appearance',
label: 'Appearance',
description: '个性化主题设置',
id: "appearance",
path: "/panel/appearance",
label: "Appearance",
description: "个性化主题设置",
icon: Palette,
loader: () => import('./routes/theme'),
loader: () => import("./routes/theme"),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: 'preferences', order: 30 },
redirect: { unauthenticated: "/login" },
sidebar: { section: "preferences", order: 30 },
},
{
path: '/panel/management',
label: 'Management',
description: '集中化的权限矩阵与用户编排',
path: "/panel/management",
label: "Management",
description: "集中化的权限矩阵与用户编排",
icon: Settings,
loader: () => import('./routes/management'),
guard: { requireLogin: true, roles: ['admin', 'operator'] },
match: 'startsWith',
redirect: { unauthenticated: '/login', forbidden: '/panel' },
sidebar: { section: 'admin', order: 99, hidden: true },
loader: () => import("./routes/management"),
guard: {
requireLogin: true,
roles: ["admin", "operator"],
permissions: [
"admin.settings.read",
"admin.users.metrics.read",
"admin.users.list.read",
"admin.agents.status.read",
"admin.blacklist.read",
],
},
match: "startsWith",
redirect: { unauthenticated: "/login", forbidden: "/panel" },
sidebar: { section: "admin", order: 99, hidden: true },
},
],
}
};

View File

@ -1,348 +1,430 @@
'use client'
"use client";
import { useCallback, useEffect, useMemo, useState } from 'react'
import useSWR from 'swr'
import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import Card from '../components/Card'
import TrendChart, { type MetricsSeries } from '../management/components/TrendChart'
import OverviewCards, { type MetricsOverview } from '../management/components/OverviewCards'
import Card from "../components/Card";
import TrendChart, {
type MetricsSeries,
} from "../management/components/TrendChart";
import OverviewCards, {
type MetricsOverview,
} from "../management/components/OverviewCards";
import PermissionMatrixEditor, {
type PermissionMatrix,
} from '../management/components/PermissionMatrixEditor'
} from "../management/components/PermissionMatrixEditor";
import UserGroupManagement, {
type ManagedUser,
type CreateManagedUserInput,
} from '../management/components/UserGroupManagement'
import SandboxNodeBindingPanel from '../management/components/SandboxNodeBindingPanel'
import RootAssumeSandboxPanel from '../management/components/RootAssumeSandboxPanel'
import { EmailBlacklist } from '../management/components/EmailBlacklist'
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import { resolveAccess } from '@lib/accessControl'
import { useUserStore } from '@lib/userStore'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
} from "../management/components/UserGroupManagement";
import SandboxNodeBindingPanel from "../management/components/SandboxNodeBindingPanel";
import RootAssumeSandboxPanel from "../management/components/RootAssumeSandboxPanel";
import { EmailBlacklist } from "../management/components/EmailBlacklist";
import Breadcrumbs from "@/app/panel/components/Breadcrumbs";
import { resolveAccess } from "@lib/accessControl";
import { useUserStore } from "@lib/userStore";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
type UserMetricsResponse = {
overview: MetricsOverview
series: MetricsSeries
}
overview: MetricsOverview;
series: MetricsSeries;
};
type AdminSettingsResponse = {
version: number
matrix: PermissionMatrix
}
version: number;
matrix: PermissionMatrix;
};
type ApiError = {
error?: string
message?: string
matrix?: PermissionMatrix
version?: number
}
error?: string;
message?: string;
matrix?: PermissionMatrix;
version?: number;
};
async function jsonFetcher<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
async function jsonFetcher<T>(
input: RequestInfo,
init?: RequestInit,
): Promise<T> {
const response = await fetch(input, {
...init,
credentials: 'include',
credentials: "include",
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init?.headers),
Accept: "application/json",
"Content-Type": "application/json",
...(init?.headers instanceof Headers
? Object.fromEntries(init.headers.entries())
: init?.headers),
},
cache: 'no-store',
})
cache: "no-store",
});
if (!response.ok) {
let payload: ApiError | undefined
let payload: ApiError | undefined;
try {
payload = (await response.json()) as ApiError
payload = (await response.json()) as ApiError;
} catch (error) {
// Ignore JSON parse errors; fall back to status text below.
}
const message = payload?.error ?? payload?.message ?? response.statusText
throw new Error(message || '请求失败')
const message = payload?.error ?? payload?.message ?? response.statusText;
throw new Error(message || "请求失败");
}
return (await response.json()) as T
return (await response.json()) as T;
}
export default function UserCenterManagementRoute() {
const { language } = useLanguage()
const t = translations[language].userCenter
const user = useUserStore((state) => state.user)
const isUserLoading = useUserStore((state) => state.isLoading)
const accessDecision = useMemo(() => resolveAccess(user, { requireLogin: true, roles: ['admin', 'operator'] }), [user])
const canAccess = accessDecision.allowed
const canEditPermissions = Boolean(user?.isAdmin)
const canEditRoles = Boolean(user?.isAdmin)
const canCreateCustomUser = Boolean(user?.isAdmin && user?.email?.trim().toLowerCase() === 'admin@svc.plus')
const { language } = useLanguage();
const t = translations[language].userCenter;
const user = useUserStore((state) => state.user);
const isUserLoading = useUserStore((state) => state.isLoading);
const accessDecision = useMemo(
() =>
resolveAccess(user, {
requireLogin: true,
roles: ["admin", "operator"],
permissions: [
"admin.settings.read",
"admin.users.metrics.read",
"admin.users.list.read",
"admin.agents.status.read",
"admin.blacklist.read",
],
}),
[user],
);
const canAccess = accessDecision.allowed;
const canEditPermissions = Boolean(user?.isAdmin);
const canEditRoles = Boolean(user?.isAdmin);
const canCreateCustomUser = Boolean(
user?.isAdmin && user?.email?.trim().toLowerCase() === "admin@svc.plus",
);
const [matrixDraft, setMatrixDraft] = useState<PermissionMatrix>({})
const [matrixVersion, setMatrixVersion] = useState<number>(0)
const [matrixDirty, setMatrixDirty] = useState(false)
const [matrixSaving, setMatrixSaving] = useState(false)
const [matrixStatus, setMatrixStatus] = useState<string | undefined>()
const [matrixError, setMatrixError] = useState<string | undefined>()
const [roleUpdateMessage, setRoleUpdateMessage] = useState<string | undefined>()
const [pendingRoleUpdates, setPendingRoleUpdates] = useState<Set<string>>(new Set())
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false)
const [matrixDraft, setMatrixDraft] = useState<PermissionMatrix>({});
const [matrixVersion, setMatrixVersion] = useState<number>(0);
const [matrixDirty, setMatrixDirty] = useState(false);
const [matrixSaving, setMatrixSaving] = useState(false);
const [matrixStatus, setMatrixStatus] = useState<string | undefined>();
const [matrixError, setMatrixError] = useState<string | undefined>();
const [roleUpdateMessage, setRoleUpdateMessage] = useState<
string | undefined
>();
const [pendingRoleUpdates, setPendingRoleUpdates] = useState<Set<string>>(
new Set(),
);
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
const metricsSWR = useSWR<UserMetricsResponse>(canAccess ? '/api/admin/users/metrics' : null, jsonFetcher, {
revalidateOnFocus: false,
})
const settingsSWR = useSWR<AdminSettingsResponse>(canAccess ? '/api/admin/settings' : null, jsonFetcher, {
revalidateOnFocus: false,
})
const usersSWR = useSWR<ManagedUser[]>(canAccess ? '/api/users' : null, jsonFetcher, {
revalidateOnFocus: false,
})
const metricsSWR = useSWR<UserMetricsResponse>(
canAccess ? "/api/admin/users/metrics" : null,
jsonFetcher,
{
revalidateOnFocus: false,
},
);
const settingsSWR = useSWR<AdminSettingsResponse>(
canAccess ? "/api/admin/settings" : null,
jsonFetcher,
{
revalidateOnFocus: false,
},
);
const usersSWR = useSWR<ManagedUser[]>(
canAccess ? "/api/users" : null,
jsonFetcher,
{
revalidateOnFocus: false,
},
);
useEffect(() => {
if (settingsSWR.data?.matrix) {
setMatrixDraft(settingsSWR.data.matrix)
setMatrixVersion(settingsSWR.data.version)
setMatrixDirty(false)
setMatrixError(undefined)
setMatrixDraft(settingsSWR.data.matrix);
setMatrixVersion(settingsSWR.data.version);
setMatrixDirty(false);
setMatrixError(undefined);
}
}, [settingsSWR.data])
}, [settingsSWR.data]);
const lastUpdatedLabel = useMemo(() => {
if (!metricsSWR.data) {
return undefined
return undefined;
}
const now = new Date()
return `更新于 ${now.toLocaleString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}`
}, [metricsSWR.data])
const now = new Date();
return `更新于 ${now.toLocaleString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}`;
}, [metricsSWR.data]);
const handleTogglePermission = useCallback(
(moduleKey: string, role: string, nextValue: boolean) => {
setMatrixDraft((prev) => {
const next: PermissionMatrix = { ...prev }
const normalizedModuleKey = moduleKey.trim()
const normalizedRole = role.trim()
const currentRoleMap = next[normalizedModuleKey] ?? {}
next[normalizedModuleKey] = { ...currentRoleMap, [normalizedRole]: nextValue }
return next
})
setMatrixDirty(true)
setMatrixStatus(undefined)
setMatrixError(undefined)
const next: PermissionMatrix = { ...prev };
const normalizedModuleKey = moduleKey.trim();
const normalizedRole = role.trim();
const currentRoleMap = next[normalizedModuleKey] ?? {};
next[normalizedModuleKey] = {
...currentRoleMap,
[normalizedRole]: nextValue,
};
return next;
});
setMatrixDirty(true);
setMatrixStatus(undefined);
setMatrixError(undefined);
},
[],
)
);
const handleSaveMatrix = useCallback(async () => {
if (!canEditPermissions || !matrixDirty) {
return
return;
}
setMatrixSaving(true)
setMatrixStatus(undefined)
setMatrixError(undefined)
setMatrixSaving(true);
setMatrixStatus(undefined);
setMatrixError(undefined);
try {
const response = await fetch('/api/admin/settings', {
method: 'POST',
credentials: 'include',
const response = await fetch("/api/admin/settings", {
method: "POST",
credentials: "include",
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
version: matrixVersion,
matrix: matrixDraft,
}),
})
});
if (response.ok) {
const payload = (await response.json()) as AdminSettingsResponse
setMatrixDraft(payload.matrix)
setMatrixVersion(payload.version)
setMatrixDirty(false)
setMatrixStatus('已保存')
settingsSWR.mutate(payload, { revalidate: false })
return
const payload = (await response.json()) as AdminSettingsResponse;
setMatrixDraft(payload.matrix);
setMatrixVersion(payload.version);
setMatrixDirty(false);
setMatrixStatus("已保存");
settingsSWR.mutate(payload, { revalidate: false });
return;
}
let payload: ApiError | undefined
let payload: ApiError | undefined;
try {
payload = (await response.json()) as ApiError
payload = (await response.json()) as ApiError;
} catch (error) {
// ignore parsing error
}
if (response.status === 409 && payload?.matrix) {
setMatrixDraft(payload.matrix)
if (typeof payload.version === 'number') {
setMatrixVersion(payload.version)
setMatrixDraft(payload.matrix);
if (typeof payload.version === "number") {
setMatrixVersion(payload.version);
}
setMatrixDirty(false)
setMatrixError(payload.message ?? '配置已被其他人更新,已同步最新版本')
return
setMatrixDirty(false);
setMatrixError(payload.message ?? "配置已被其他人更新,已同步最新版本");
return;
}
const message = payload?.error ?? payload?.message ?? '保存失败'
throw new Error(message)
const message = payload?.error ?? payload?.message ?? "保存失败";
throw new Error(message);
} catch (error) {
setMatrixError(error instanceof Error ? error.message : '保存失败')
setMatrixError(error instanceof Error ? error.message : "保存失败");
} finally {
setMatrixSaving(false)
setMatrixSaving(false);
}
}, [canEditPermissions, matrixDirty, matrixDraft, matrixVersion, settingsSWR])
}, [
canEditPermissions,
matrixDirty,
matrixDraft,
matrixVersion,
settingsSWR,
]);
const markRolePending = useCallback((userId: string, pending: boolean) => {
setPendingRoleUpdates((prev) => {
const next = new Set(prev)
const next = new Set(prev);
if (pending) {
next.add(userId)
next.add(userId);
} else {
next.delete(userId)
next.delete(userId);
}
return next
})
}, [])
return next;
});
}, []);
const handleRoleChange = useCallback(
async (userId: string, role: string) => {
if (!canEditRoles) {
return
return;
}
setRoleUpdateMessage(undefined)
markRolePending(userId, true)
setRoleUpdateMessage(undefined);
markRolePending(userId, true);
try {
await jsonFetcher(`/api/admin/users/${userId}/role`, {
method: 'POST',
credentials: 'include',
method: "POST",
credentials: "include",
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ role }),
})
setRoleUpdateMessage('角色已更新')
usersSWR.mutate()
});
setRoleUpdateMessage("角色已更新");
usersSWR.mutate();
} catch (error) {
setRoleUpdateMessage(error instanceof Error ? error.message : '更新失败')
setRoleUpdateMessage(
error instanceof Error ? error.message : "更新失败",
);
} finally {
markRolePending(userId, false)
markRolePending(userId, false);
}
},
[canEditRoles, markRolePending, usersSWR],
)
);
const handleRoleReset = useCallback(
async (userId: string) => {
if (!canEditRoles) {
return
return;
}
setRoleUpdateMessage(undefined)
markRolePending(userId, true)
setRoleUpdateMessage(undefined);
markRolePending(userId, true);
try {
await jsonFetcher(`/api/admin/users/${userId}/role`, {
method: 'DELETE',
credentials: 'include',
method: "DELETE",
credentials: "include",
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Accept: "application/json",
"Content-Type": "application/json",
},
})
setRoleUpdateMessage('角色已重置')
usersSWR.mutate()
});
setRoleUpdateMessage("角色已重置");
usersSWR.mutate();
} catch (error) {
setRoleUpdateMessage(error instanceof Error ? error.message : '更新失败')
setRoleUpdateMessage(
error instanceof Error ? error.message : "更新失败",
);
} finally {
markRolePending(userId, false)
markRolePending(userId, false);
}
},
[canEditRoles, markRolePending, usersSWR],
)
);
const handlePauseUser = useCallback(async (userId: string) => {
try {
await jsonFetcher(`/api/admin/users/${userId}/pause`, { method: 'POST' })
usersSWR.mutate()
} catch (error) {
alert(error instanceof Error ? error.message : '操作失败')
}
}, [usersSWR])
const handlePauseUser = useCallback(
async (userId: string) => {
try {
await jsonFetcher(`/api/admin/users/${userId}/pause`, {
method: "POST",
});
usersSWR.mutate();
} catch (error) {
alert(error instanceof Error ? error.message : "操作失败");
}
},
[usersSWR],
);
const handleResumeUser = useCallback(async (userId: string) => {
try {
await jsonFetcher(`/api/admin/users/${userId}/resume`, { method: 'POST' })
usersSWR.mutate()
} catch (error) {
alert(error instanceof Error ? error.message : '操作失败')
}
}, [usersSWR])
const handleResumeUser = useCallback(
async (userId: string) => {
try {
await jsonFetcher(`/api/admin/users/${userId}/resume`, {
method: "POST",
});
usersSWR.mutate();
} catch (error) {
alert(error instanceof Error ? error.message : "操作失败");
}
},
[usersSWR],
);
const handleDeleteUser = useCallback(async (userId: string) => {
try {
await jsonFetcher(`/api/admin/users/${userId}`, { method: 'DELETE' })
usersSWR.mutate()
} catch (error) {
alert(error instanceof Error ? error.message : '操作失败')
}
}, [usersSWR])
const handleDeleteUser = useCallback(
async (userId: string) => {
try {
await jsonFetcher(`/api/admin/users/${userId}`, { method: "DELETE" });
usersSWR.mutate();
} catch (error) {
alert(error instanceof Error ? error.message : "操作失败");
}
},
[usersSWR],
);
const handleRenewUuid = useCallback(async (userId: string) => {
const days = prompt('设置过期天数 (0 为永久):', '0')
if (days === null) return
try {
await jsonFetcher(`/api/admin/users/${userId}/renew-uuid`, {
method: 'POST',
body: JSON.stringify({ expires_in_days: parseInt(days) || 0 }),
})
alert('UUID 已重置')
usersSWR.mutate()
} catch (error) {
alert(error instanceof Error ? error.message : '操作失败')
}
}, [usersSWR])
const handleRenewUuid = useCallback(
async (userId: string) => {
const days = prompt("设置过期天数 (0 为永久):", "0");
if (days === null) return;
try {
await jsonFetcher(`/api/admin/users/${userId}/renew-uuid`, {
method: "POST",
body: JSON.stringify({ expires_in_days: parseInt(days) || 0 }),
});
alert("UUID 已重置");
usersSWR.mutate();
} catch (error) {
alert(error instanceof Error ? error.message : "操作失败");
}
},
[usersSWR],
);
const handleCreateCustomUser = useCallback(async (input: CreateManagedUserInput) => {
if (!canCreateCustomUser) {
throw new Error('仅 root 管理员可创建自定义 UUID 用户')
}
const handleCreateCustomUser = useCallback(
async (input: CreateManagedUserInput) => {
if (!canCreateCustomUser) {
throw new Error("仅 root 管理员可创建自定义 UUID 用户");
}
await jsonFetcher('/api/admin/users', {
method: 'POST',
body: JSON.stringify({
email: input.email,
uuid: input.uuid,
groups: input.groups,
}),
})
await jsonFetcher("/api/admin/users", {
method: "POST",
body: JSON.stringify({
email: input.email,
uuid: input.uuid,
groups: input.groups,
}),
});
await usersSWR.mutate()
}, [canCreateCustomUser, usersSWR])
await usersSWR.mutate();
},
[canCreateCustomUser, usersSWR],
);
const matrixPending = matrixSaving || isUserLoading
const metricsLoading = metricsSWR.isLoading
const settingsLoading = settingsSWR.isLoading
const usersLoading = usersSWR.isLoading
const matrixPending = matrixSaving || isUserLoading;
const metricsLoading = metricsSWR.isLoading;
const settingsLoading = settingsSWR.isLoading;
const usersLoading = usersSWR.isLoading;
if (!canAccess) {
return (
<Card>
<h1 className="text-2xl font-semibold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-600">访</p>
<p className="mt-2 text-sm text-gray-600">
访
</p>
</Card>
)
);
}
return (
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: t.items.dashboard, href: '/panel' },
{ label: translations[language].nav.account.management, href: '/panel/management' },
{ label: t.items.dashboard, href: "/panel" },
{
label: translations[language].nav.account.management,
href: "/panel/management",
},
]}
/>
<OverviewCards overview={metricsSWR.data?.overview} isLoading={metricsLoading} lastUpdatedLabel={lastUpdatedLabel} />
<OverviewCards
overview={metricsSWR.data?.overview}
isLoading={metricsLoading}
lastUpdatedLabel={lastUpdatedLabel}
/>
<TrendChart series={metricsSWR.data?.series} isLoading={metricsLoading} />
<PermissionMatrixEditor
matrix={matrixDraft}
roles={['user', 'admin', 'operator']}
roles={["user", "admin", "operator"]}
isLoading={settingsLoading}
isSaving={matrixPending}
hasChanges={matrixDirty}
@ -372,7 +454,10 @@ export default function UserCenterManagementRoute() {
<SandboxNodeBindingPanel />
</>
) : null}
<EmailBlacklist isOpen={isBlacklistOpen} onClose={() => setIsBlacklistOpen(false)} />
<EmailBlacklist
isOpen={isBlacklistOpen}
onClose={() => setIsBlacklistOpen(false)}
/>
</div>
)
);
}

View File

@ -1,152 +1,156 @@
'use server'
"use server";
import { cookies } from 'next/headers'
import type { NextRequest } from 'next/server'
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { SESSION_COOKIE_NAME } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { SESSION_COOKIE_NAME } from "@lib/authGateway";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
export type AccountUserRole = 'guest' | 'user' | 'operator' | 'admin'
export type AccountUserRole = "guest" | "user" | "operator" | "admin";
export type AccountTenantMembership = {
id: string
name?: string
role?: AccountUserRole
}
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[]
}
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
}
token?: string;
user: AccountSessionUser | null;
};
type RawAccountTenant = {
id?: unknown
name?: unknown
role?: unknown
}
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
}
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
}
user?: RawAccountUser | null;
};
const KNOWN_ROLE_MAP: Record<string, AccountUserRole> = {
root: 'admin',
super_admin: 'admin',
readonly: 'user',
read_only: 'user',
admin: 'admin',
administrator: 'admin',
operator: 'operator',
ops: 'operator',
user: 'user',
member: 'user',
}
root: "admin",
super_admin: "admin",
readonly: "user",
read_only: "user",
admin: "admin",
administrator: "admin",
operator: "operator",
ops: "operator",
user: "user",
member: "user",
};
function normalizeRole(value: unknown): AccountUserRole {
if (typeof value !== 'string') {
return 'guest'
if (typeof value !== "string") {
return "guest";
}
const normalized = value.trim().toLowerCase()
const normalized = value.trim().toLowerCase();
if (!normalized) {
return 'guest'
return "guest";
}
return KNOWN_ROLE_MAP[normalized] ?? 'guest'
return KNOWN_ROLE_MAP[normalized] ?? "guest";
}
function normalizeString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
return [];
}
const result: string[] = []
const result: string[] = [];
for (const entry of value) {
const normalized = normalizeString(entry)
const normalized = normalizeString(entry);
if (normalized) {
result.push(normalized)
result.push(normalized);
}
}
return result
return result;
}
function normalizeTenants(value: unknown): AccountTenantMembership[] | undefined {
function normalizeTenants(
value: unknown,
): AccountTenantMembership[] | undefined {
if (!Array.isArray(value)) {
return undefined
return undefined;
}
const normalized: AccountTenantMembership[] = []
const normalized: AccountTenantMembership[] = [];
for (const tenant of value) {
if (!tenant || typeof tenant !== 'object') {
continue
if (!tenant || typeof tenant !== "object") {
continue;
}
const raw = tenant as RawAccountTenant
const identifier = normalizeString(raw.id)
const raw = tenant as RawAccountTenant;
const identifier = normalizeString(raw.id);
if (!identifier) {
continue
continue;
}
const entry: AccountTenantMembership = { id: identifier }
const name = normalizeString(raw.name)
const entry: AccountTenantMembership = { id: identifier };
const name = normalizeString(raw.name);
if (name) {
entry.name = name
entry.name = name;
}
const role = normalizeRole(raw.role)
if (role !== 'guest') {
entry.role = role
const role = normalizeRole(raw.role);
if (role !== "guest") {
entry.role = role;
}
normalized.push(entry)
normalized.push(entry);
}
return normalized.length > 0 ? normalized : undefined
return normalized.length > 0 ? normalized : undefined;
}
function buildUser(raw: RawAccountUser | null | undefined): AccountSessionUser | null {
if (!raw || typeof raw !== 'object') {
return null
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)
const identifier = normalizeString(raw.uuid) ?? normalizeString(raw.id);
const email = normalizeString(raw.email);
if (!identifier || !email) {
return null
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)
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,
@ -159,94 +163,132 @@ function buildUser(raw: RawAccountUser | null | undefined): AccountSessionUser |
permissions,
tenantId: tenantId ?? undefined,
tenants,
}
};
}
function extractBearer(value: string | null): string | undefined {
if (!value) {
return undefined
return undefined;
}
const trimmed = value.trim()
const trimmed = value.trim();
if (!trimmed) {
return undefined
return undefined;
}
const prefix = 'Bearer '
const prefix = "Bearer ";
if (trimmed.startsWith(prefix)) {
return trimmed.slice(prefix.length).trim() || undefined
return trimmed.slice(prefix.length).trim() || undefined;
}
return trimmed
return trimmed;
}
async function resolveTokenFromRequest(request?: NextRequest): Promise<string | undefined> {
async function resolveTokenFromRequest(
request?: NextRequest,
): Promise<string | undefined> {
if (request) {
const authHeader = request.headers.get('authorization')
const authToken = extractBearer(authHeader)
const authHeader = request.headers.get("authorization");
const authToken = extractBearer(authHeader);
if (authToken) {
return authToken
return authToken;
}
const sessionHeader = request.headers.get('x-account-session')
const sessionHeader = request.headers.get("x-account-session");
if (sessionHeader && sessionHeader.trim().length > 0) {
return sessionHeader.trim()
return sessionHeader.trim();
}
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
if (cookieToken && cookieToken.trim().length > 0) {
return cookieToken.trim()
return cookieToken.trim();
}
}
try {
const cookieStore = await cookies()
const cookieToken = cookieStore.get(SESSION_COOKIE_NAME)?.value
const cookieStore = await cookies();
const cookieToken = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (cookieToken && cookieToken.trim().length > 0) {
return cookieToken.trim()
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)
console.warn("Failed to read session cookie from request context", error);
}
return undefined
return undefined;
}
export async function userHasRole(
user: AccountSessionUser | null,
roles: AccountUserRole[]
roles: AccountUserRole[],
): Promise<boolean> {
if (!user || roles.length === 0) {
return false
return false;
}
return roles.includes(user.role)
return roles.includes(user.role);
}
export async function getAccountSession(request?: NextRequest): Promise<AccountSessionResult> {
const token = await resolveTokenFromRequest(request)
export async function userHasPermission(
user: AccountSessionUser | null,
permissions: string[],
): Promise<boolean> {
if (!user || permissions.length === 0) {
return false;
}
const userPermissions = new Set(
user.permissions.map((permission) => permission.trim()),
);
if (userPermissions.has("*")) {
return true;
}
return permissions.every((permission) =>
userPermissions.has(permission.trim()),
);
}
export async function userHasRoleOrPermission(
user: AccountSessionUser | null,
roles: AccountUserRole[],
permissions: string[],
): Promise<boolean> {
if (!user) {
return false;
}
if (await userHasRole(user, roles)) {
return true;
}
return userHasPermission(user, permissions);
}
export async function getAccountSession(
request?: NextRequest,
): Promise<AccountSessionResult> {
const token = await resolveTokenFromRequest(request);
if (!token) {
return { token: undefined, user: null }
return { token: undefined, user: null };
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/session`, {
method: 'GET',
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
Accept: "application/json",
},
cache: 'no-store',
})
cache: "no-store",
});
if (!response.ok) {
return { token, user: null }
return { token, user: null };
}
const payload = (await response.json().catch(() => null)) as AccountSessionResponse | null
const payload = (await response
.json()
.catch(() => null)) as AccountSessionResponse | null;
if (!payload?.user) {
return { token, user: null }
return { token, user: null };
}
const user = buildUser(payload.user)
return { token, user }
const user = buildUser(payload.user);
return { token, user };
} catch (error) {
console.error('Failed to resolve account session', error)
return { token, user: null }
console.error("Failed to resolve account session", error);
return { token, user: null };
}
}