feat(auth): add readonly review access
This commit is contained in:
parent
0fab89e0bd
commit
6c56ea1ba3
@ -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`
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user