Compare commits

..

62 Commits

Author SHA1 Message Date
Haitao Pan
017c33d8f4 refactor: rebuild xworkmate bridge workspace 2026-05-29 11:33:39 +08:00
Haitao Pan
76d3d2884f Refine XWorkmate suite marketing layout 2026-05-29 11:19:42 +08:00
Haitao Pan
64940dfc29 Fix Cloudflare DNS workflow zone resolution 2026-05-29 11:10:56 +08:00
Haitao Pan
53c7fc0154 Expand XWorkmate suite marketing content 2026-05-29 11:02:14 +08:00
Haitao Pan
f613ff9c38 Add XWorkmate suite marketing page 2026-05-29 10:55:47 +08:00
Haitao Pan
ad7c76e6e6 fix management dashboard user status 2026-04-24 11:50:30 +08:00
Haitao Pan
081bedd637 Hide TCP VLESS tab by default 2026-04-24 10:00:46 +08:00
Haitao Pan
97e7a4a3bb chore(ci): trigger pipeline for ssh verify wrapper changes 2026-04-13 08:44:40 +08:00
Haitao Pan
239e30472b fix(release): pass verify base url over ssh 2026-04-13 08:38:22 +08:00
Haitao Pan
cf1ce8a4db fix(release): verify frontend release via homepage metadata 2026-04-13 08:31:06 +08:00
Haitao Pan
a0e6da97b1 feat(auth): restrict public routes 2026-04-12 19:30:07 +08:00
Haitao Pan
ddb2a7b627 refactor(auth): remove guest console mode 2026-04-12 19:28:31 +08:00
Haitao Pan
107e9879a6 Validate releases over SSH origin 2026-04-12 19:15:18 +08:00
Haitao Pan
0c6ed2a0c4 Decouple console DNS sync from deploy validation 2026-04-12 19:05:23 +08:00
Haitao Pan
318f407222 Align console release verification with build image ref 2026-04-12 18:54:56 +08:00
Haitao Pan
5f1b59be70 feat: improve public user identity fallbacks 2026-04-12 18:52:08 +08:00
Haitao Pan
c622d0b1d2 fix(ci): pin playbooks deploy revision 2026-04-12 18:51:48 +08:00
Haitao Pan
65a7555e18 ci: reconcile console deploy dns flow 2026-04-12 18:14:14 +08:00
Haitao Pan
d054b35116 Align frontend release contract across www and console domains 2026-04-12 17:55:14 +08:00
Haitao Pan
22e95e5bcb Remove sandbox guest identity exposure 2026-04-12 17:12:28 +08:00
Haitao Pan
37c5788263 remove secondary console domain 2026-04-12 16:41:07 +08:00
Haitao Pan
b8cd175ecc fix: tighten release validation flow 2026-04-12 16:17:50 +08:00
Haitao Pan
c94257e06c Add release traceability guidance and ping metadata 2026-04-12 15:47:24 +08:00
Haitao Pan
68102491e2 Extract console pipeline scripts 2026-04-12 15:42:02 +08:00
Haitao Pan
9c6cc4ade5 fix(ci): align deploy job with bridge workflow 2026-04-11 14:30:10 +08:00
Haitao Pan
9d3ae1e169 fix(ci): provide deploy ssh key 2026-04-11 14:28:54 +08:00
Haitao Pan
03df4c0c2b fix(ci): use public playbooks repo 2026-04-11 13:59:00 +08:00
Haitao Pan
e774ef0794 docs: clarify web console billing boundaries 2026-04-11 13:47:59 +08:00
Haitao Pan
651101c253 Merge branch 'codex/feat/traffic-billing-mvp'
# Conflicts:
#	.github/workflows/pipeline.yaml
2026-04-11 12:21:51 +08:00
Haitao Pan
fe81be2874 Fix agent nodes proxy response headers 2026-04-11 11:59:44 +08:00
Haitao Pan
3d36f8dd30 Fix GitHub Actions policy pins 2026-04-11 11:23:59 +08:00
e83e511406
Codex/feat/traffic billing mvp (#73)
* feat(user-center): show authoritative billing usage details

* fix(user-center): narrow agent node error payload typing

* Refactor frontend release workflow into pipeline stages

* Align console deploy job with playbooks contract

* Fix GitHub Actions env references in pipeline

* Upgrade GitHub Actions runtime-compatible actions

* Simplify deploy playbook invocation

* Finalize deploy workflow env and playbook args

---------

Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
2026-04-11 10:40:32 +08:00
1f0734e7e7
Merge branch 'main' into codex/feat/traffic-billing-mvp 2026-04-11 10:40:20 +08:00
Haitao Pan
1ec1cbf9cc Finalize deploy workflow env and playbook args 2026-04-11 10:31:09 +08:00
Haitao Pan
4a08ff360f Simplify deploy playbook invocation 2026-04-11 10:13:47 +08:00
Haitao Pan
d6d891c809 Upgrade GitHub Actions runtime-compatible actions 2026-04-11 09:22:11 +08:00
1c643cc022
Fix pipeline workflow parse error (#72)
* feat(user-center): show authoritative billing usage details

* fix(user-center): narrow agent node error payload typing

* Refactor frontend release workflow into pipeline stages

* Align console deploy job with playbooks contract

* Fix GitHub Actions env references in pipeline

---------

Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
2026-04-11 09:19:43 +08:00
d5ddfd20bd
Merge branch 'main' into codex/feat/traffic-billing-mvp 2026-04-11 09:14:18 +08:00
Haitao Pan
bb693ce463 Fix GitHub Actions env references in pipeline 2026-04-11 09:11:47 +08:00
f0f6b3ffdb
Codex/feat/traffic billing mvp (#71)
* feat(user-center): show authoritative billing usage details

* fix(user-center): narrow agent node error payload typing

* Refactor frontend release workflow into pipeline stages

* Align console deploy job with playbooks contract

---------

Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
2026-04-11 09:10:33 +08:00
Haitao Pan
b84d0079a6 Align console deploy job with playbooks contract 2026-04-11 09:00:35 +08:00
Haitao Pan
a6fa674ca5 Refactor frontend release workflow into pipeline stages 2026-04-11 08:58:58 +08:00
701d790f97
feat(user-center): show authoritative billing usage details (#70)
* feat(user-center): show authoritative billing usage details

* fix(user-center): narrow agent node error payload typing

---------

Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
2026-04-09 14:05:47 +08:00
Haitao Pan
f10914bbe7 fix(user-center): narrow agent node error payload typing 2026-04-09 14:04:36 +08:00
Haitao Pan
47d132dfd7 feat(user-center): show authoritative billing usage details 2026-04-09 13:50:55 +08:00
Haitao Pan
9cf1c167e8 fix(auth): remove sandbox default session fallback 2026-04-09 09:22:08 +08:00
Haitao Pan
6dfc9454fa fix(console): correct panel node loading 2026-04-08 18:56:18 +08:00
Haitao Pan
2eb72e4aea fix(ci): map cloudflare dns token secret 2026-04-04 16:56:13 +08:00
Haitao Pan
69802dace7 refactor(ci): merge frontend publish workflow 2026-04-02 18:08:43 +08:00
Haitao Pan
fa64d666b8 fix(ci): remove blocked marketplace actions 2026-04-02 18:06:18 +08:00
Haitao Pan
5fae20edb3 fix(ci): use short sha for ghcr image tags 2026-04-02 17:59:59 +08:00
Haitao Pan
484734352d add workflows: build-push-ghcr-images 2026-04-02 17:44:29 +08:00
c894924a57
Merge pull request #68 from x-evor/codex/fix/agent-node-fetch-fallback
fix(user-center): fallback legacy agent node endpoint
2026-04-01 16:22:11 +08:00
Haitao Pan
5c84390b90 fix(console): add account api fallback proxy 2026-04-01 16:20:41 +08:00
Haitao Pan
343a93864f ci(frontend): publish console image to ghcr 2026-04-01 16:20:41 +08:00
Haitao Pan
d8d95a14d3 fix(user-center): fallback legacy agent node endpoint 2026-04-01 16:20:41 +08:00
Haitao Pan
41760a0227 fix(deploy): serve console static assets correctly 2026-04-01 16:20:41 +08:00
Haitao Pan
048665c8fb ci(frontend): publish console image to ghcr 2026-04-01 07:14:15 +08:00
Haitao Pan
f2b08dba84 fix(user-center): fallback legacy agent node endpoint 2026-04-01 07:04:08 +08:00
Haitao Pan
34f03725c3 fix(deploy): serve console static assets correctly 2026-03-31 18:12:12 +08:00
8fa9cd34bf
fix(docker): stop bundling docs content into frontend image (#67)
Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
2026-03-31 14:49:44 +08:00
efe6b988f3
fix(docker): stop bundling docs content into frontend image (#66)
Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
2026-03-31 14:34:08 +08:00
103 changed files with 4281 additions and 3196 deletions

View File

@ -25,6 +25,11 @@ SERVER_SERVICE_URL=https://api.svc.plus
NEXT_PUBLIC_SERVER_SERVICE_URL=https://api.svc.plus
SERVER_SERVICE_INTERNAL_URL=
# XWorkmate bridge runtime
# Read server-side by /api/xworkmate/bridge. Do not expose the token as NEXT_PUBLIC_*.
BRIDGE_SERVER_URL=https://xworkmate-bridge.svc.plus
BRIDGE_AUTH_TOKEN=
# OpenClaw assistant integrations
# Use environment variables to prefill the assistant and integrations page.
# Values are read server-side and are not hardcoded into the UI.

240
.github/workflows/pipeline.yaml vendored Normal file
View File

@ -0,0 +1,240 @@
name: Console Service Pipeline
on:
push:
branches:
- main
paths:
- ".github/workflows/pipeline.yaml"
- "Dockerfile"
- "deploy/single-node/**"
- "package.json"
- "yarn.lock"
- "scripts/github-actions/build-and-push-frontend-image.sh"
- "scripts/github-actions/compute-frontend-release-metadata.sh"
- "scripts/github-actions/render-frontend-build-args.sh"
- "scripts/github-actions/render-frontend-runtime-env.sh"
- "scripts/github-actions/prepare-frontend-build-context.sh"
- "scripts/github-actions/run-console-deploy-playbook.sh"
- "scripts/github-actions/run-cloudflare-svc-plus-dns-playbook.sh"
- "scripts/github-actions/verify-frontend-release-over-ssh.sh"
- "scripts/github-actions/verify-frontend-release.sh"
- "scripts/prebuild.sh"
- "contentlayer.config.ts"
- "next.config.js"
- "next.config.mjs"
- "src/**"
- "public/**"
workflow_dispatch:
inputs:
target_host:
description: Ansible host or alias
required: false
default: "jp-xhttp-contabo.svc.plus"
type: string
run_apply:
description: Apply deployment
required: true
default: true
type: boolean
permissions:
contents: read
packages: write
concurrency:
group: console-pipeline-${{ github.ref_name }}
cancel-in-progress: false
env:
CANONICAL_DOMAIN: www.svc.plus
SERVED_DOMAINS: www.svc.plus,console.svc.plus
APP_BASE_URL: https://www.svc.plus
NEXT_PUBLIC_APP_BASE_URL: https://www.svc.plus
NEXT_PUBLIC_SITE_URL: https://www.svc.plus
RUNTIME_HOSTNAME: www.svc.plus
NEXT_RUNTIME_HOSTNAME: www.svc.plus
NEXT_PUBLIC_RUNTIME_ENVIRONMENT: prod
NEXT_PUBLIC_RUNTIME_REGION: cn
ACCOUNT_SERVICE_URL: https://accounts.svc.plus
CLOUDFLARE_ZONE_TAG: bf3427f83a2c52c8285ab3d741a6ee27
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG: 0973e84ec8872c67c570f8072e92e21b
CLOUDFLARE_ACCOUNT_ID: e71be5efb76a6c54f78f008da4404f00
GHCR_REGISTRY: ghcr.io
GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}
GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN }}
INTERNAL_SERVICE_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_DNS_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }}
jobs:
prep:
name: Prep
runs-on: ubuntu-latest
outputs:
target_host: ${{ steps.inputs.outputs.target_host }}
run_apply: ${{ steps.inputs.outputs.run_apply }}
image_tag: ${{ steps.metadata.outputs.image_tag }}
image_ref: ${{ steps.metadata.outputs.image_ref }}
image_latest_ref: ${{ steps.metadata.outputs.image_latest_ref }}
ghcr_namespace: ${{ steps.metadata.outputs.ghcr_namespace }}
push_latest: ${{ steps.push.outputs.push_latest }}
steps:
- name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Resolve Inputs
id: inputs
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TARGET_HOST: ${{ inputs.target_host }}
INPUT_RUN_APPLY: ${{ inputs.run_apply }}
run: bash scripts/github-actions/resolve-workflow-inputs.sh
- name: Compute Image Metadata
id: metadata
run: |
bash scripts/github-actions/compute-frontend-release-metadata.sh
- name: Resolve Push Latest
id: push
env:
REF: ${{ github.ref }}
run: bash scripts/github-actions/resolve-push-latest.sh
build:
name: Build
runs-on: ubuntu-latest
needs: prep
outputs:
image_ref: ${{ steps.publish.outputs.image_ref }}
image_tag: ${{ steps.publish.outputs.image_tag }}
steps:
- name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set Up Docker Buildx
run: bash scripts/github-actions/setup-docker-buildx.sh
- name: Log In To GHCR
env:
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: bash scripts/github-actions/login-ghcr.sh
- name: Publish Frontend Image
id: publish
env:
IMAGE_REF: ${{ needs.prep.outputs.image_ref }}
IMAGE_TAG: ${{ needs.prep.outputs.image_tag }}
IMAGE_LATEST_REF: ${{ needs.prep.outputs.image_latest_ref }}
PUSH_LATEST: ${{ needs.prep.outputs.push_latest }}
run: bash scripts/github-actions/publish-frontend-image.sh
deploy:
name: Deploy
runs-on: ubuntu-latest
needs:
- prep
- build
env:
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
RUN_APPLY: ${{ needs.prep.outputs.run_apply }}
FRONTEND_IMAGE: ${{ needs.build.outputs.image_ref }}
steps:
- name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Check Out Playbooks Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
# Intentionally pinned: playbooks@main regressed deploy reliability on 2026-04-12.
# Any future bump must pass a full Deploy + Validate run before becoming the default.
repository: x-evor/playbooks
ref: 80c545a95c3b16459f6494ed13d951faac57bfa8
path: playbooks
token: ${{ github.token }}
- name: Set Up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install Ansible
run: |
python -m pip install --upgrade pip
python -m pip install ansible
- name: Configure SSH For Deploy Host
env:
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
run: bash scripts/github-actions/configure-ssh-for-deploy.sh
- name: Run Deploy Playbook
working-directory: playbooks
env:
ANSIBLE_HOST_KEY_CHECKING: "False"
run: bash ../scripts/github-actions/run-console-deploy-playbook.sh
validate:
name: Validate
runs-on: ubuntu-latest
needs:
- prep
- build
- deploy
if: ${{ always() && needs.deploy.result == 'success' }}
env:
EXPECTED_FRONTEND_IMAGE: ${{ needs.build.outputs.image_ref }}
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
steps:
- name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Configure SSH For Validate Host
run: bash scripts/github-actions/configure-ssh-for-deploy.sh
- name: Verify Frontend Release On Host
run: bash scripts/github-actions/verify-frontend-release-over-ssh.sh
update_dns:
name: Update DNS
runs-on: ubuntu-latest
needs:
- prep
- build
- deploy
if: ${{ always() && needs.deploy.result == 'success' }}
continue-on-error: true
env:
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
RUN_APPLY: ${{ needs.prep.outputs.run_apply }}
FRONTEND_IMAGE: ${{ needs.build.outputs.image_ref }}
steps:
- name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Check Out Playbooks Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: x-evor/playbooks
ref: 80c545a95c3b16459f6494ed13d951faac57bfa8
path: playbooks
token: ${{ github.token }}
- name: Set Up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install Ansible
run: |
python -m pip install --upgrade pip
python -m pip install ansible
- name: Update Cloudflare svc.plus DNS
working-directory: playbooks
env:
ANSIBLE_HOST_KEY_CHECKING: "False"
run: bash ../scripts/github-actions/run-cloudflare-svc-plus-dns-playbook.sh

View File

@ -51,7 +51,20 @@ yarn test:e2e path/to/spec.test.ts
---
## 2. Repository Mental Model (Read This First)
## 2. Release Traceability Default Rule
For any change touching CI/CD, image tags, deploy contracts, `/api/ping`, or `validate` behavior:
- Treat `skills/release-traceability/SKILL.md` as the default reference before implementation.
- Prefer release metadata that can be traced from `build` to `deploy` to `validate` without manual injection.
- Keep the published image reference, runtime version, and validation output aligned.
- Do not introduce a deploy path that rebuilds images on the target host.
When in doubt, follow the skill first and keep the release chain fully auditable end to end.
---
## 3. Repository Mental Model (Read This First)
This repository has **three clearly separated layers**:
@ -91,7 +104,7 @@ Used for build-time or runtime wiring only.
---
## 3. Import & Alias Rules (Critical)
## 4. Import & Alias Rules (Critical)
### Dashboard code (src/\*\*)
@ -112,7 +125,7 @@ import { UserCard } from "@/components/UserCard";
---
## 4. TypeScript & Formatting Rules
## 5. TypeScript & Formatting Rules
- Strict mode enabled
- Use `type` for type definitions, `interface` for object shapes
@ -123,7 +136,7 @@ import { UserCard } from "@/components/UserCard";
---
## 5. Naming Conventions
## 6. Naming Conventions
- Components: PascalCase (`UserProfile.tsx`)
- Files: kebab-case for utilities (`user-utils.ts`), PascalCase for components
@ -133,7 +146,7 @@ import { UserCard } from "@/components/UserCard";
---
## 6. Error Handling & Logging
## 7. Error Handling & Logging
- Use try/catch for async operations
- Return Result types or throw errors consistently
@ -142,7 +155,7 @@ import { UserCard } from "@/components/UserCard";
---
## 7. React Patterns
## 8. React Patterns
- Use `'use client'` directive for client components
- Prefer function components with hooks
@ -151,7 +164,7 @@ import { UserCard } from "@/components/UserCard";
---
## 8. Global State Rules (Dashboard Only)
## 9. Global State Rules (Dashboard Only)
✅ Zustand is the **only** allowed global state mechanism
❌ React Context for shared/global state is forbidden
@ -162,7 +175,7 @@ Rule: If state must survive navigation or be shared → it lives in Zustand.
---
## 9. URL-Synchronized State
## 10. URL-Synchronized State
Anything involving:
@ -179,7 +192,7 @@ MUST be handled inside Zustand slices.
---
## 10. Component State Rules
## 11. Component State Rules
Allowed:
@ -194,7 +207,7 @@ Forbidden:
---
## 11. packages/neurapress Rules (Very Important)
## 12. packages/neurapress Rules (Very Important)
packages/neurapress is treated as a vendored internal library.
@ -214,7 +227,7 @@ MUST NOT:
---
## 12. Testing Guidelines
## 13. Testing Guidelines
- Unit tests: Vitest with jsdom environment
- E2E tests: Playwright
@ -224,7 +237,7 @@ MUST NOT:
---
## 13. Environment & Runtime Config
## 14. Environment & Runtime Config
- No new environment variables without approval
- Runtime config must live in: src/config/runtime-service-config\*.yaml
@ -233,13 +246,13 @@ MUST NOT:
---
## 14. Cursor / Copilot Rules
## 15. Cursor / Copilot Rules
- No `.cursor/rules/`, `.cursorrules`, or `.github/copilot-instructions.md` found
---
## 15. TL;DR for AI Agents
## 16. TL;DR for AI Agents
- dashboard = application
- packages = libraries

View File

@ -21,6 +21,10 @@ ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=
ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=
ARG NEXT_PUBLIC_RELEASE_IMAGE=
ARG NEXT_PUBLIC_RELEASE_TAG=
ARG NEXT_PUBLIC_RELEASE_COMMIT=
ARG NEXT_PUBLIC_RELEASE_VERSION=
# -------------------------------------------------------
# Stage 1 — Builder (Turbopack + standalone)
@ -46,6 +50,10 @@ ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO
ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION
ARG NEXT_PUBLIC_RELEASE_IMAGE
ARG NEXT_PUBLIC_RELEASE_TAG
ARG NEXT_PUBLIC_RELEASE_COMMIT
ARG NEXT_PUBLIC_RELEASE_VERSION
ENV NEXT_TELEMETRY_DISABLED=1 \
NEXT_PRIVATE_TURBOPACK=1 \
@ -65,7 +73,11 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO} \
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION} \
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO} \
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION}
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION} \
NEXT_PUBLIC_RELEASE_IMAGE=${NEXT_PUBLIC_RELEASE_IMAGE} \
NEXT_PUBLIC_RELEASE_TAG=${NEXT_PUBLIC_RELEASE_TAG} \
NEXT_PUBLIC_RELEASE_COMMIT=${NEXT_PUBLIC_RELEASE_COMMIT} \
NEXT_PUBLIC_RELEASE_VERSION=${NEXT_PUBLIC_RELEASE_VERSION}
# ---------------------------
# 基础镜像升级到最新

View File

@ -41,22 +41,29 @@ cp .env.example .env
## 主要入口 (Key Routes)
- `/services`:服务导航页,保留现有控制台布局。
- `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 OpenClaw gateway 接入。
- `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 `xworkmate-bridge``/acp/rpc` 接入。
- `/panel/api`:融合设置与集成页,用于配置和探测 OpenClaw Gateway、Vault Server、APISIX AI Gateway。
## AI 助手与集成能力 (Assistant & Integrations)
当前主页 AI 辅助功能已经基于本仓库原生实现,核心行为如下:
- 侧栏助手模式保留现有交互方式,但底层改为对接 OpenClaw gateway
- 侧栏助手模式保留现有交互方式,但 `/xworkmate` 主工作区直接对接 `xworkmate-bridge`
- 最大化助手页面统一收敛到 `/xworkmate`,旧的 `/services/openclaw` 只保留兼容跳转,不再继续使用旧的 control UI 套壳。
- 页面截图通过 assistant chat 附件模式发送,而不是单独的浏览器控制壳。
- `/panel/api` 提供 OpenClaw、Vault、APISIX 三类集成的默认值预填与连通性探测
- 网关地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
- `/panel/api` 仍保留旧集成配置入口;`/xworkmate` 主路径不依赖它
- bridge 地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
## 环境变量 (Environment Variables)
以下变量用于主页 AI 助手和集成页的服务端默认值预填:
以下变量用于 `/xworkmate` 主工作区的服务端 bridge 代理:
| 变量 | 用途 |
| ------------------- | ------------------------------------- |
| `BRIDGE_SERVER_URL` | XWorkmate bridge 服务根地址 |
| `BRIDGE_AUTH_TOKEN` | XWorkmate bridge bearer token服务端 |
以下变量用于旧助手和集成页的服务端默认值预填:
| 变量 | 用途 |
| ----------------------------- | ------------------------------------ |

View File

@ -16,6 +16,12 @@ You are an AI agent working inside this repository.
- Keep changes scoped to the request; avoid unrelated refactors.
- Prefer minimal edits that preserve existing behavior and style.
## Release Traceability Default Rule
- For changes touching CI/CD, image tags, deploy contracts, `/api/ping`, or `validate`, treat `skills/release-traceability/SKILL.md` as the default reference first.
- Keep build output, runtime version, and validate output aligned through the whole release chain.
- Do not add a deploy path that rebuilds images on the target host.
## Repository Constraints (Quick View)
- App layer: src/app/**, src/components/**, src/lib/**, src/state/**, src/modules/\*\*

332
config/feature_flags.yaml Normal file
View File

@ -0,0 +1,332 @@
# Feature flag inventory for the dashboard and public site.
# This file is a human-readable catalog of page-level and module-level flags.
meta:
app: console.svc.plus
scope:
- public-site
- dashboard
- xworkmate
- cloud-iac
- docs
source:
- src/config/feature-toggles.json
- src/modules/extensions/builtin
- src/app
runtime:
current_implementation:
toggles_json: src/config/feature-toggles.json
loader: src/lib/featureToggles.ts
extension_flags: src/lib/featureFlags.ts
notes:
- "feature-toggles.json is the active runtime source for path-based gating."
- "This YAML is an inventory and planning file; it does not currently drive runtime behavior."
sections:
globalNavigation:
enabled: true
description: Top-level navigation and auth entry points.
default_channel: stable
routes:
- path: /
status: enabled
channel: stable
- path: /docs
status: enabled
channel: beta
- path: /blogs
status: enabled
note: Public content listing, not currently toggle-gated in code.
- path: /services
status: enabled
note: Public service directory.
- path: /prices
status: enabled
- path: /support
status: enabled
- path: /about
status: enabled
- path: /login
status: enabled
channel: stable
- path: /register
status: enabled
channel: stable
- path: /panel
status: enabled
channel: stable
- path: /panel/management
status: enabled
note: Shown only to admin/operator users.
- path: /cloud_iac
status: enabled
channel: develop
- path: /download
status: enabled
channel: stable
- path: /insight
status: enabled
channel: develop
- path: /xworkmate
status: enabled
appModules:
enabled: true
description: Path-based module gating used by route handlers and content loaders.
routes:
- path: /docs
status: enabled
uses: src/app/docs/page.tsx
- path: /docs/[collection]
status: enabled
uses: src/app/docs/[collection]/page.tsx
- path: /docs/[collection]/[...slug]
status: enabled
uses: src/app/docs/[collection]/[...slug]/page.tsx
- path: /download
status: enabled
uses: src/app/download/page.tsx
- path: /download/[...segments]
status: enabled
uses: src/app/download/[...segments]/page.tsx
- path: /cloud_iac
status: enabled
uses: src/app/cloud_iac/page.tsx
- path: /cloud_iac/[provider]
status: enabled
uses: src/app/cloud_iac/[provider]/page.tsx
- path: /cloud_iac/[provider]/[service]
status: enabled
uses: src/app/cloud_iac/[provider]/[service]/page.tsx
- path: /insight
status: enabled
uses: src/app/services/insight/page.tsx
- path: /editor
status: enabled
uses: src/app/editor/page.tsx
- path: /editor/wechat
status: enabled
- path: /editor/xiaohongshu
status: enabled
- path: /xworkmate
status: enabled
uses: src/app/xworkmate/page.tsx
- path: /xworkmate/admin
status: enabled
uses: src/app/xworkmate/admin/page.tsx
- path: /xworkmate/integrations
status: enabled
uses: src/app/xworkmate/integrations/page.tsx
cmsExperience:
enabled: true
description: CMS/homepage experience gating.
routes:
- path: /homepage
status: enabled
children:
- path: /homepage/dynamic
status: enabled
extensions:
builtin.user-center:
enabled: true
description: Core dashboard user center and account management extension.
routes:
- path: /panel
id: dashboard
enabled: true
sidebar_section: workspace
- path: /panel/agent
id: agents
enabled: true
env_var: NEXT_PUBLIC_FEATURE_AGENT_MODULE
default_enabled: true
sidebar_section: productivity
- path: /panel/api
id: apis
enabled: true
env_var: NEXT_PUBLIC_FEATURE_API_MODULE
default_enabled: true
sidebar_section: productivity
- path: /panel/account
id: accounts
enabled: true
sidebar_section: management
- path: /panel/subscription
id: subscription
enabled: true
env_var: NEXT_PUBLIC_FEATURE_SUBSCRIPTION_MODULE
default_enabled: true
sidebar_section: management
- path: /panel/ldp
id: ldp
enabled: true
env_var: NEXT_PUBLIC_FEATURE_LDP_MODULE
default_enabled: false
sidebar_section: management
- path: /panel/appearance
id: appearance
enabled: true
sidebar_section: preferences
- path: /panel/management
id: management
enabled: true
roles:
- admin
- operator
permissions:
- admin.settings.read
- admin.users.metrics.read
- admin.users.list.read
- admin.agents.status.read
- admin.blacklist.read
sidebar_hidden: true
builtin.infra:
enabled: true
description: Infrastructure and ops extension.
routes:
- path: /panel/deployments
id: deployments
enabled: true
sidebar_section: infra
- path: /panel/resources
id: resources
enabled: true
sidebar_section: infra
- path: /panel/api-keys
id: apiKeys
enabled: true
sidebar_section: infra
- path: /panel/observability
id: logs
enabled: true
sidebar_section: infra
- path: /panel/settings
id: settings
enabled: true
sidebar_section: preferences
pages:
public:
- path: /
component: src/app/page.tsx
status: enabled
- path: /services
component: src/app/services/page.tsx
status: enabled
- path: /about
component: src/app/about/page.tsx
status: enabled
- path: /prices
component: src/app/prices/page.tsx
status: enabled
- path: /support
component: src/app/support/page.tsx
status: enabled
- path: /support/discussions
component: src/app/support/discussions/page.tsx
status: enabled
- path: /blogs
component: src/app/blogs/page.tsx
status: enabled
- path: /blogs/[...slug]
component: src/app/blogs/[...slug]/page.tsx
status: enabled
- path: /terms
component: src/app/terms/page.tsx
status: enabled
- path: /privacy
component: src/app/privacy/page.tsx
status: enabled
- path: /download
component: src/app/download/page.tsx
status: gated_by_appModules
- path: /download/[...segments]
component: src/app/download/[...segments]/page.tsx
status: gated_by_appModules
- path: /docs
component: src/app/docs/page.tsx
status: gated_by_appModules
- path: /docs/[collection]
component: src/app/docs/[collection]/page.tsx
status: gated_by_appModules
- path: /docs/[collection]/[...slug]
component: src/app/docs/[collection]/[...slug]/page.tsx
status: gated_by_appModules
- path: /cloud_iac
component: src/app/cloud_iac/page.tsx
status: gated_by_appModules
- path: /cloud_iac/[provider]
component: src/app/cloud_iac/[provider]/page.tsx
status: gated_by_appModules
- path: /cloud_iac/[provider]/[service]
component: src/app/cloud_iac/[provider]/[service]/page.tsx
status: gated_by_appModules
- path: /editor
component: src/app/editor/page.tsx
status: redirect_external
- path: /editor/wechat
component: src/app/editor/wechat/page.tsx
status: enabled
- path: /editor/xiaohongshu
component: src/app/editor/xiaohongshu/page.tsx
status: enabled
- path: /xworkmate
component: src/app/xworkmate/page.tsx
status: enabled
- path: /xworkmate/admin
component: src/app/xworkmate/admin/page.tsx
status: enabled
- path: /xworkmate/integrations
component: src/app/xworkmate/integrations/page.tsx
status: enabled
auth:
- path: /login
component: src/app/(auth)/login/page.tsx
status: gated_by_globalNavigation
- path: /register
component: src/app/(auth)/register/page.tsx
status: gated_by_globalNavigation
- path: /email-verification
component: src/app/(auth)/email-verification/page.tsx
status: gated_by_globalNavigation
- path: /logout
component: src/app/logout/page.tsx
status: enabled
panel:
- path: /panel
component: src/app/panel/page.tsx
status: extension_route
- path: /panel/account
component: src/app/panel/account/page.tsx
status: extension_route
- path: /panel/agent
component: src/app/panel/agent/page.tsx
status: extension_route
- path: /panel/api
component: src/app/panel/api/page.tsx
status: extension_route
- path: /panel/appearance
component: src/app/panel/appearance/page.tsx
status: extension_route
- path: /panel/ldp
component: src/app/panel/ldp/page.tsx
status: extension_route
- path: /panel/management
component: src/app/panel/management/page.tsx
status: extension_route
- path: /panel/subscription
component: src/app/panel/subscription/page.tsx
status: extension_route
- path: /panel/[...segments]
component: src/app/panel/[...segments]/page.tsx
status: catch_all_extension
recommendations:
- "If this file is intended to become runtime-configurable, wire it into src/lib/featureToggles.ts and keep feature-toggles.json as the generated artifact."
- "If this file is intended only for documentation, keep it synchronized with feature-toggles.json and extension definitions."

View File

@ -1,22 +1,22 @@
# Compose settings
FRONTEND_IMAGE=ghcr.io/cloud-neutral-toolkit/dashboard:replace-me
PRIMARY_DOMAIN=cn.svc.plus
SECONDARY_DOMAIN=cn.onwalk.net
CANONICAL_DOMAIN=www.svc.plus
SERVED_DOMAINS=www.svc.plus,console.svc.plus
# Frontend runtime
NODE_ENV=production
PORT=3000
RUNTIME_ENV=prod
REGION=cn
APP_BASE_URL=https://cn.svc.plus
NEXT_PUBLIC_APP_BASE_URL=https://cn.svc.plus
NEXT_PUBLIC_SITE_URL=https://cn.svc.plus
NEXT_PUBLIC_LOGIN_URL=https://cn.svc.plus/login
NEXT_PUBLIC_DOCS_BASE_URL=https://cn.svc.plus/docs
APP_BASE_URL=https://www.svc.plus
NEXT_PUBLIC_APP_BASE_URL=https://www.svc.plus
NEXT_PUBLIC_SITE_URL=https://www.svc.plus
NEXT_PUBLIC_LOGIN_URL=https://www.svc.plus/login
NEXT_PUBLIC_DOCS_BASE_URL=https://www.svc.plus/docs
SESSION_COOKIE_SECURE=true
NEXT_PUBLIC_SESSION_COOKIE_SECURE=true
RUNTIME_HOSTNAME=cn.svc.plus
DEPLOYMENT_HOSTNAME=cn.svc.plus
RUNTIME_HOSTNAME=www.svc.plus
DEPLOYMENT_HOSTNAME=www.svc.plus
NEXT_PUBLIC_RUNTIME_ENVIRONMENT=prod
NEXT_PUBLIC_RUNTIME_REGION=cn

View File

@ -1,11 +1,7 @@
{$PRIMARY_DOMAIN}, {$SECONDARY_DOMAIN} {
{$SERVED_DOMAINS} {
encode zstd gzip
@secondary host {$SECONDARY_DOMAIN}
redir @secondary https://{$PRIMARY_DOMAIN}{uri} permanent
@next_static path /_next/static/*
handle @next_static {
handle_path /_next/static/* {
root * /srv
header Cache-Control "public, max-age=31536000, immutable"
file_server

View File

@ -7,9 +7,9 @@ services:
- -c
- |
set -eu
rm -rf /assets/_next /assets/public
mkdir -p /assets/_next/static /assets/public
cp -R /app/dashboard/static/. /assets/_next/static
rm -rf /assets/_next /assets/chunks /assets/public
mkdir -p /assets /assets/public
cp -R /app/dashboard/static/. /assets/
cp -R /app/dashboard/public/. /assets/public
volumes:
- frontend_static:/assets
@ -22,6 +22,8 @@ services:
environment:
NODE_ENV: production
PORT: 3000
volumes:
- frontend_static:/app/dashboard/.next/static:ro
networks:
- frontend
@ -34,8 +36,7 @@ services:
- "80:80"
- "443:443"
environment:
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:?set PRIMARY_DOMAIN in .env.runtime}
SECONDARY_DOMAIN: ${SECONDARY_DOMAIN:?set SECONDARY_DOMAIN in .env.runtime}
SERVED_DOMAINS: ${SERVED_DOMAINS:?set SERVED_DOMAINS in .env.runtime}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- frontend_static:/srv:ro

View File

@ -214,7 +214,7 @@ export default function NotFound() {
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://console.svc.plus'),
metadataBase: new URL('https://www.svc.plus'),
title: {
default: 'Cloud-Neutral | Unified Cloud Native Tools',
template: '%s | Cloud-Neutral',
@ -232,7 +232,7 @@ export const metadata: Metadata = {
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://console.svc.plus',
url: 'https://www.svc.plus',
title: 'Cloud-Neutral | Unified Cloud Native Tools',
description: 'Unified tools for your cloud native stack',
siteName: 'Cloud-Neutral',
@ -274,7 +274,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#6366f1" />
<link rel="canonical" href="https://console.svc.plus" />
<link rel="canonical" href="https://www.svc.plus" />
{/* ... rest of head */}
</head>
{/* ... rest of layout */}
@ -343,7 +343,7 @@ Disallow: /admin/
Disallow: /api/
Disallow: /internal/
Sitemap: https://console.svc.plus/sitemap.xml
Sitemap: https://www.svc.plus/sitemap.xml
```
---
@ -360,8 +360,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Cloud-Neutral',
url: 'https://console.svc.plus',
logo: 'https://console.svc.plus/logo.png',
url: 'https://www.svc.plus',
logo: 'https://www.svc.plus/logo.png',
sameAs: [
'https://twitter.com/cloudneutral',
'https://github.com/x-evor',
@ -372,10 +372,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Cloud-Neutral',
url: 'https://console.svc.plus',
url: 'https://www.svc.plus',
potentialAction: {
'@type': 'SearchAction',
target: 'https://console.svc.plus/search?q={search_term_string}',
target: 'https://www.svc.plus/search?q={search_term_string}',
'query-input': 'required name=search_term_string',
},
}

View File

@ -0,0 +1,96 @@
# console.svc.plus Web Console Architecture
## Scope
`console.svc.plus` is the browser-facing control plane. It is a Next.js App Router application that combines public pages, docs browsing, account/admin panels, and a BFF layer that forwards requests to downstream services. It never reads PostgreSQL or Prometheus directly for billing or usage.
## Architecture
```mermaid
flowchart TB
subgraph Pages["src/app pages"]
Root["/ -> landing page"]
Docs["/docs, /docs/[collection], /docs/[collection]/[...slug]\nDocs reader"]
Auth["/login, /register, /email-verification, /logout\nAuth flows"]
Panel["/panel/*\nUser / admin console"]
Tools["/editor/*, /download/*, /cloud_iac/*, /xworkmate/*\nProduct tools"]
Content["/blogs/*, /services/*, /support/*, /prices, /about, /privacy, /terms, /[slug]"]
Admin["/dashboard/cms\nCMS/admin entry"]
end
subgraph BFF["src/app/api route handlers"]
AuthAPI["/api/auth/*"]
AdminAPI["/api/admin/*"]
AgentAPI["/api/agent-server/[...segments]\n/api/agent/[...segments]"]
RagAPI["/api/rag/query\n/api/askai"]
UtilAPI["/api/users\n/api/ping\n/api/content-meta\n/api/render-markdown\n/api/dl-index/*\n/api/marketing/home-stats\n/api/integrations/*\n/api/moltbot/chat\n/api/openclaw/assistant\n/api/task/[...segments]\n/api/xworkmate/profile"]
GuestAPI["/api/guest/*"]
end
Accounts["accounts.svc.plus"]
Rag["rag-server.svc.plus"]
DocsSvc["docs.svc.plus"]
Grafana["observability.svc.plus / Grafana"]
External["Other upstream services"]
Subscription["/panel/subscription\nUsage / billing panel"]
AuthAPI --> Accounts
AdminAPI --> Accounts
AgentAPI --> Accounts
RagAPI --> Rag
UtilAPI --> DocsSvc
UtilAPI --> External
SandboxAPI --> Accounts
Subscription --> Accounts
Subscription -.-> Grafana
```
## Frontend Routes
| Route family | Path | Purpose |
| --- | --- | --- |
| Home | `/` | Public landing page and site entry |
| Docs | `/docs`, `/docs/[collection]`, `/docs/[collection]/[...slug]` | Documentation reader, sidebar, and TOC |
| Auth | `/login`, `/register`, `/email-verification`, `/logout` | Sign in / sign up / email verification / session cleanup |
| Panel | `/panel`, `/panel/account`, `/panel/agent`, `/panel/api`, `/panel/appearance`, `/panel/ldp`, `/panel/management`, `/panel/subscription`, `/panel/[...segments]` | Signed-in account and admin console |
| Tools | `/editor`, `/editor/wechat`, `/editor/xiaohongshu`, `/download`, `/download/[...segments]`, `/cloud_iac`, `/cloud_iac/[provider]`, `/cloud_iac/[provider]/[service]`, `/xworkmate`, `/xworkmate/admin`, `/xworkmate/integrations` | Product tools and service explorers |
| Content | `/blogs`, `/blogs/[...slug]`, `/services`, `/services/openclaw`, `/services/insight`, `/support`, `/support/discussions`, `/about`, `/prices`, `/privacy`, `/terms`, `/[slug]` | Marketing / informational pages |
| Admin / CMS | `/dashboard/cms` | CMS or content-management entry |
| Error pages | `/404`, `/500` | Static error surfaces |
## BFF / API Routes
| API family | Path | Purpose | Upstream target |
| --- | --- | --- | --- |
| Auth | `/api/auth/login`, `/api/auth/register`, `/api/auth/register/send`, `/api/auth/register/verify`, `/api/auth/verify-email`, `/api/auth/verify-email/send`, `/api/auth/session`, `/api/auth/token/exchange` | Login, registration, token exchange, session lookup | `accounts.svc.plus/api/auth/*` |
| MFA | `/api/auth/mfa/status`, `/api/auth/mfa/setup`, `/api/auth/mfa/verify`, `/api/auth/mfa/disable` | TOTP setup and verification | `accounts.svc.plus/api/auth/*` |
| OAuth / billing | `/api/auth/oauth/login/[provider]`, `/api/auth/stripe/checkout`, `/api/auth/stripe/portal`, `/api/auth/subscriptions`, `/api/auth/subscriptions/cancel` | OAuth redirects and billing actions | `accounts.svc.plus/api/auth/*` |
| Admin | `/api/admin/settings`, `/api/admin/homepage-video`, `/api/admin/users/*`, `/api/admin/blacklist/*` | Account admin operations | `accounts.svc.plus/api/*` |
| Agent bridge | `/api/agent-server/[...segments]`, `/api/agent/[...segments]` | Agent registry/status and legacy alias | `accounts.svc.plus` |
| RAG | `/api/rag/query`, `/api/askai` | Retrieval and answer generation | `rag-server.svc.plus` |
| Guest / demo runtime | `/api/guest/binding` | Guest read-only node resolution for demo access | `accounts.svc.plus/api/sandbox/binding` |
| Content / docs | `/api/content-meta`, `/api/render-markdown`, `/api/blogs/latest`, `/api/dl-index/*` | Docs/content rendering and download manifests | docs / CDN / download service |
| Integrations | `/api/integrations/defaults`, `/api/integrations/probe`, `/api/marketing/home-stats` | Integration defaults, health probes, marketing metrics | config-dependent external services |
| Misc | `/api/ping`, `/api/users`, `/api/xworkmate/profile`, `/api/task/[...segments]`, `/api/openclaw/assistant`, `/api/moltbot/chat`, `/api/render-markdown` | Health, user lookup, profile, task and assistant proxies | `accounts.svc.plus`, internal API, task services |
## Auth and Session Notes
- Browser calls use the session cookie and BFF logic in `src/server/account/session.ts`.
- Service-to-service calls use `INTERNAL_SERVICE_TOKEN` when configured.
- `api/agent-server/[...segments]` keeps caller `Authorization` untouched when an agent token is already present.
- `api/agent-server/[...segments]` forwards the dashboard session token for browser-driven calls.
## Dependencies
- `accounts.svc.plus` for identity, profile, sandbox, billing, and admin actions.
- `accounts.svc.plus` for authoritative usage and billing summaries sourced from PostgreSQL.
- `rag-server.svc.plus` for RAG query and AskAI.
- `docs.svc.plus` for docs content and navigation data.
- `observability.svc.plus` for Grafana dashboards and operational views only.
- CDN / external providers for content, analytics, and integration checks.
## Notes
- Route groups in parentheses, such as `(auth)`, are Next.js organizational folders and do not appear in the public URL.
- The BFF layer is the main place where console-specific auth shaping, cookie management, and upstream proxying happen.
- The subscription panel displays usage and billing data from accounts only and treats Grafana as an embedded observability surface, not a billing source.

View File

@ -3,11 +3,12 @@
## Production Baseline
- Runtime: `Caddy + Docker Compose`
- Deploy host: `47.120.61.35`
- Domains:
- `cn.svc.plus`
- `cn.onwalk.net`
- Frontend release workflow: `.github/workflows/service_release_frontend-deploy.yml`
- Deploy host: `root@jp-xhttp-contabo.svc.plus`
- Public domains:
- `www.svc.plus`
- `console.svc.plus`
- Canonical public origin: `https://www.svc.plus`
- Frontend release workflow: `.github/workflows/pipeline.yaml`
## Operating Model
@ -21,10 +22,16 @@ The stack is static-first:
- The Next.js standalone container serves dynamic HTML, auth endpoints, and API proxy routes. Static assets and hashed CSS/JS files are extracted by the `frontend-assets` helper task, so the runtime no longer needs to compile anything on the single-node host.
- `docs.svc.plus` is the source of truth for rendered docs/blog pages; the browser does not call it directly.
Releases are orchestrated through `.github/workflows/service_release_frontend-deploy.yml`. That workflow clones the knowledge repository, runs the Docker build/push sequence, renders `.env.runtime`, and ships `docker-compose.yml`, `Caddyfile`, and the runtime env file to the host. The control-plane workflow `.github/workflows/service_release_apiserver-deploy.yml` then updates Cloudflare DNS for the release domain (via `scripts/github-actions/update-release-dns.sh`) so `cn.svc.plus` and the redirected alias `cn.onwalk.net` point at the new environment.
Releases are orchestrated through `.github/workflows/service_release_frontend-deploy.yml`. That workflow builds/pushes the image, renders `.env.runtime` including `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL`, and ships `docker-compose.yml`, `Caddyfile`, and the runtime env file to the host. The control-plane workflow `.github/workflows/service_release_apiserver-deploy.yml` then updates Cloudflare DNS for the release domain (via `scripts/github-actions/update-release-dns.sh`) so `cn.svc.plus` and the redirected alias `cn.onwalk.net` point at the new environment.
Releases are orchestrated through `.github/workflows/pipeline.yaml`. That workflow builds/pushes the image, renders `.env.runtime` including `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL`, and ships `docker-compose.yml`, `Caddyfile`, and the runtime env file to the host. The control-plane DNS automation then updates Cloudflare DNS for the release domains (via `scripts/github-actions/update-release-dns.sh`) so both `www.svc.plus` and `console.svc.plus` resolve to the same environment.
This baseline is intentional for the weak-IO single-node host (47.120.61.35). No images are built on the target machine, keeping the deployment lightweight: the host only logs into GHCR, pulls the `dashboard` image, extracts assets into `frontend_static`, and starts `dashboard` plus `caddy` containers via `docker compose`.
The release contract now uses:
- `CANONICAL_DOMAIN=www.svc.plus`
- `SERVED_DOMAINS=www.svc.plus,console.svc.plus`
Validation must pass for both domains. A release is incomplete if either host serves a different runtime version, static asset family, or `dashboardUrl`.
This baseline is intentional for the weak-IO single-node host `root@jp-xhttp-contabo.svc.plus`. No images are built on the target machine, keeping the deployment lightweight: the host only logs into GHCR, pulls the `dashboard` image, extracts assets into `frontend_static`, and starts `dashboard` plus `caddy` containers via `docker compose`.
`docs.svc.plus` is now the dedicated docs/blog service for the frontend delivery path.

View File

@ -1,6 +1,6 @@
# Release Process
This page tracks release summaries for published versions of `console.svc.plus`.
This page tracks release summaries for published versions of the public web console served under `www.svc.plus` and `console.svc.plus`.
## Current Release
@ -31,7 +31,7 @@ Published commit: `0fab89e`
- 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`.
- Guest and demo access must not expose any backing account identity in public UI or session payloads.
- Added vault-backed token lookup for integrations.
#### Docs And Setup
@ -49,3 +49,5 @@ Published commit: `0fab89e`
- GitHub Release: `https://github.com/x-evor/console.svc.plus/releases/tag/v0.2`
- Related docs: `docs/README.md`, `docs/en/README.md`, `docs/zh/README.md`
- Release validation must verify both `www.svc.plus` and `console.svc.plus` against the same `releaseImageRef`, `releaseImageTag`, and `releaseCommit`.
- `www.svc.plus` is the canonical public domain for metadata, sitemap, `dashboardUrl`, and shared links.

View File

@ -48,7 +48,7 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=
### 本地开发
写入 `console.svc.plus/.env.local`
写入当前前端仓库的 `.env.local`
```bash
CLOUDFLARE_API_TOKEN=...
@ -58,7 +58,7 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=...
### 线上部署
把同名变量写入 `console.svc.plus` 的部署环境(例如 Vercel/Cloud Run 的环境变量配置)
把同名变量写入前端部署环境
> 注意:这些变量属于服务端密钥,不要暴露到 `NEXT_PUBLIC_*`
@ -67,7 +67,7 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=...
部署后访问:
```bash
curl -fsSL https://console.svc.plus/api/marketing/home-stats
curl -fsSL https://www.svc.plus/api/marketing/home-stats
```
期望返回中 `visits.daily/weekly/monthly` 为数字(非 `null`)。
@ -77,4 +77,3 @@ curl -fsSL https://console.svc.plus/api/marketing/home-stats
1. token 权限是否包含 Analytics Read
2. Account ID 是否与 siteTag 属于同一账号
3. 环境变量是否已在当前运行实例生效(重启/重新部署后再测)

View File

@ -6,7 +6,7 @@ This guide describes how to configure GitHub and Google OAuth login for the Clou
```
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Browser │ │ console.svc.plus │ │accounts.svc.plus │
│ Browser │ │ www.svc.plus │ │accounts.svc.plus │
│ (User) │ │ (Frontend) │ │ (Backend) │
└──────┬───────┘ └────────┬─────────┘ └────────┬─────────┘
│ 1. Click "Login │ │
@ -40,7 +40,7 @@ This guide describes how to configure GitHub and Google OAuth login for the Clou
- A GitHub account with access to **Settings > Developer Settings**
- A Google account with access to [Google Cloud Console](https://console.cloud.google.com/)
- Running `accounts.svc.plus` and `console.svc.plus` services
- Running `accounts.svc.plus` and the frontend served under `www.svc.plus` / `console.svc.plus`
---
@ -55,7 +55,7 @@ This guide describes how to configure GitHub and Google OAuth login for the Clou
| Field | Value |
|---|---|
| **Application name** | `Cloud Neutral Console` |
| **Homepage URL** | `https://console.svc.plus` |
| **Homepage URL** | `https://www.svc.plus` |
| **Authorization callback URL** | `https://accounts.svc.plus/api/auth/oauth/callback/github` |
| **Enable Device Flow** | ☐ (unchecked) |
@ -119,7 +119,7 @@ No additional GitHub permissions are required.
|---|---|
| **Application type** | `Web application` |
| **Name** | `Cloud Neutral Console` |
| **Authorized JavaScript origins** | `https://console.svc.plus` |
| **Authorized JavaScript origins** | `https://www.svc.plus` |
| **Authorized redirect URIs** | `https://accounts.svc.plus/api/auth/oauth/callback/google` |
4. Click **"Create"**
@ -150,7 +150,7 @@ GOOGLE_CLIENT_SECRET=<your_google_client_secret>
# ── General OAuth ──
OAUTH_REDIRECT_URL=https://accounts.svc.plus/api/auth/oauth/callback
OAUTH_FRONTEND_URL=https://console.svc.plus
OAUTH_FRONTEND_URL=https://www.svc.plus
```
These variables are referenced in `config/account.yaml`:
@ -159,7 +159,7 @@ These variables are referenced in `config/account.yaml`:
auth:
oauth:
redirectUrl: "${OAUTH_REDIRECT_URL}"
frontendUrl: "${OAUTH_FRONTEND_URL:-https://console.svc.plus}"
frontendUrl: "${OAUTH_FRONTEND_URL:-https://www.svc.plus}"
github:
clientId: "${GITHUB_CLIENT_ID}"
clientSecret: "${GITHUB_CLIENT_SECRET}"
@ -172,7 +172,7 @@ auth:
---
## 4. Frontend Configuration (console.svc.plus)
## 4. Frontend Configuration (`www.svc.plus` canonical, `console.svc.plus` secondary)
The frontend resolves the accounts service URL **server-side** via `getAccountServiceBaseUrl()`, which reads:
@ -209,7 +209,7 @@ If not set, the function falls back to a runtime default. **No `NEXT_PUBLIC_*` e
### OAuth login redirects to wrong domain
Check that `OAUTH_FRONTEND_URL` in accounts.svc.plus matches the console domain where users should be redirected after authentication.
Check that `OAUTH_FRONTEND_URL` in accounts.svc.plus matches the canonical public domain where users should be redirected after authentication. The current default is `https://www.svc.plus`.
### Google "Access blocked: This app's request is invalid"

View File

@ -3,10 +3,10 @@
## Scope
- Repository: `console.svc.plus`
- Target host: `root@47.120.61.35`
- Target host: `root@cn-console.svc.plus`
- Public domains:
- `cn.svc.plus`
- `cn.onwalk.net`
- `cn-console.svc.plus`
- `cn-console.onwalk.net`
- Delivery mode: `GitHub Actions + GHCR + Caddy + Docker Compose`
This document defines the deployment baseline for the China-facing frontend node. The source of truth is this upstream repository. The control-plane repository may consume the repo through git submodule, but should not become the primary place where this deployment design lives.
@ -26,7 +26,7 @@ The result should support repeatable releases, quick rollback by image tag, and
### Host constraints
- `47.120.61.35` is a single-node host
- `cn-console.svc.plus` is a single-node host
- deployment user is `root`
- local image build on the host is explicitly disallowed
- IO pressure should be minimized during release
@ -140,16 +140,16 @@ Temporary nature:
Primary domain:
- `cn.svc.plus`
- `cn-console.svc.plus`
Secondary domain:
- `cn.onwalk.net`
- `cn-console.onwalk.net`
Current routing decision:
- Caddy accepts both domains
- requests for `cn.onwalk.net` are redirected permanently to `cn.svc.plus`
- requests for `cn-console.onwalk.net` are redirected permanently to `cn-console.svc.plus`
Reason:
@ -200,7 +200,7 @@ Rollback steps:
1. set `FRONTEND_IMAGE` to a previous known-good tag
2. rerun `frontend-assets`
3. restart `dashboard` and `caddy`
4. verify `cn.svc.plus`
4. verify `cn-console.svc.plus`
This avoids rebuilding and keeps rollback cheap on the weak-IO host.
@ -261,7 +261,7 @@ Mitigation:
### Near term
- populate required GitHub `vars` and `secrets`
- run the workflow against `47.120.61.35`
- run the workflow against `root@cn-console.svc.plus`
- validate DNS, TLS, static assets, login flow, and upstream API proxy behavior
### Later

View File

@ -4,11 +4,11 @@
- Runtime: `console.svc.plus`
- Topology: `Caddy + Docker Compose + GitHub Actions`
- Deploy host: `root@47.120.61.35`
- Deploy host: `root@jp-xhttp-contabo.svc.plus`
- Public domains:
- `https://cn.svc.plus`
- `https://cn.onwalk.net`
- Primary origin: `https://cn.svc.plus`
- `https://www.svc.plus`
- `https://console.svc.plus`
- Canonical public origin: `https://www.svc.plus`
## Current Delivery Model
@ -24,7 +24,7 @@ This is intentionally static-first for the current weak-IO single-node host. Dyn
## Control Plane & DNS Stage
The control repo (`github-org-x-evor`) tracks `console.svc.plus` through `console.svc.plus.code-workspace` and keeps the `subrepos/accounts.svc.plus` pointer in sync via `skills/cross-repo-upstream-submodule-sync`. Releases resolve metadata with that workspace and the `config/single-node-release` manifests. After `.github/workflows/service_release_frontend-deploy.yml` finishes pushing the new image, the control-plane workflow `.github/workflows/service_release_apiserver-deploy.yml` calls `scripts/github-actions/update-release-dns.sh` to update Cloudflare DNS so the new endpoint is reachable under `cn.svc.plus` and `cn.onwalk.net`.
The control repo (`github-org-x-evor`) tracks `console.svc.plus` through `console.svc.plus.code-workspace` and keeps the `subrepos/accounts.svc.plus` pointer in sync via `skills/cross-repo-upstream-submodule-sync`. Releases resolve metadata with that workspace and the `config/single-node-release` manifests. After `.github/workflows/pipeline.yaml` finishes pushing the new image, the control-plane DNS automation calls `scripts/github-actions/update-release-dns.sh` to update Cloudflare DNS so the new endpoint is reachable through the current production host `jp-xhttp-contabo.svc.plus`.
## Runtime Layout
@ -53,7 +53,7 @@ Containers:
Workflow:
```text
.github/workflows/service_release_frontend-deploy.yml
.github/workflows/pipeline.yaml
```
Secrets required:
@ -68,6 +68,8 @@ Secrets required:
Repository/environment variables recommended:
- `CANONICAL_DOMAIN`
- `SERVED_DOMAINS`
- `APP_BASE_URL`
- `NEXT_PUBLIC_APP_BASE_URL`
- `NEXT_PUBLIC_SITE_URL`
@ -94,11 +96,11 @@ Repository/environment variables recommended:
1. GitHub Actions checks out the repo.
2. Docker builds the frontend image with the public `NEXT_PUBLIC_*` values needed at build time.
3. The image is pushed to GHCR.
4. The workflow runs a matrix DNS stage, updating one public domain per job.
5. The workflow renders `.env.runtime`, including docs service runtime endpoints.
4. The workflow updates Cloudflare DNS for the release domain.
5. The workflow renders `.env.runtime`, including the canonical public origin and both served frontend domains.
6. The workflow uploads `docker-compose.yml`, `Caddyfile`, and `.env.runtime` to the host.
7. The host pulls the new image, refreshes the static asset volume, and starts `dashboard + caddy`.
8. The workflow verifies `cn.svc.plus` and `cn.onwalk.net`.
8. The workflow verifies both `www.svc.plus` and `console.svc.plus`, and fails the release if either domain serves a different runtime version.
## Verification Commands
@ -114,7 +116,7 @@ rm -f deploy/single-node/.env.runtime
python3 - <<'PY'
from pathlib import Path
import yaml
yaml.safe_load(Path('.github/workflows/service_release_frontend-deploy.yml').read_text())
yaml.safe_load(Path('.github/workflows/pipeline.yaml').read_text())
print('workflow yaml ok')
PY
```
@ -122,10 +124,11 @@ PY
Remote checks:
```bash
ssh root@47.120.61.35 "cd /opt/console-svc-plus && docker compose --env-file .env.runtime ps"
ssh root@47.120.61.35 "curl -fsSI -H 'Host: cn.svc.plus' http://127.0.0.1/"
curl -fsSIL https://cn.svc.plus
curl -fsSIL https://cn.onwalk.net
ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime ps"
ssh root@jp-xhttp-contabo.svc.plus "curl -fsSI -H 'Host: www.svc.plus' http://127.0.0.1/"
ssh root@jp-xhttp-contabo.svc.plus "curl -fsSI -H 'Host: console.svc.plus' http://127.0.0.1/"
curl -fsSIL https://www.svc.plus
curl -fsSIL https://console.svc.plus
```
## Failure Signatures
@ -134,10 +137,8 @@ curl -fsSIL https://cn.onwalk.net
The workflow token or package visibility is wrong.
- `frontend-assets` fails
The image layout changed and no longer contains `/app/dashboard/static` or `/app/dashboard/public`.
- `cn.svc.plus` returns `502`
- `www.svc.plus` or `console.svc.plus` returns `502`
Caddy is up, but the `dashboard` container failed or is not reachable on port `3000`.
- `cn.onwalk.net` does not redirect
Check the deployed `Caddyfile` and domain DNS.
## Rollback
@ -146,8 +147,8 @@ curl -fsSIL https://cn.onwalk.net
3. Restart the services:
```bash
ssh root@47.120.61.35 "cd /opt/console-svc-plus && docker compose --env-file .env.runtime run --rm frontend-assets"
ssh root@47.120.61.35 "cd /opt/console-svc-plus && docker compose --env-file .env.runtime up -d dashboard caddy"
ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime run --rm frontend-assets"
ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime up -d dashboard caddy"
```
4. Verify `https://cn.svc.plus` again before closing the incident.
4. Verify `https://www.svc.plus` and `https://console.svc.plus` again before closing the incident.

View File

@ -3,11 +3,12 @@
## 生产基线
- 运行拓扑: `Caddy + Docker Compose`
- 目标主机: `47.120.61.35`
- 目标主机: `root@jp-xhttp-contabo.svc.plus`
- 域名:
- `cn.svc.plus`
- `cn.onwalk.net`
- 前端独立发布流水线: `.github/workflows/service_release_frontend-deploy.yml`
- `www.svc.plus`
- `console.svc.plus`
- 公开首选域名: `www.svc.plus`
- 前端独立发布流水线: `.github/workflows/pipeline.yaml`
## 运行方式
@ -21,12 +22,19 @@
- Next.js standalone 容器只承接动态页面、认证接口和代理接口,`frontend-assets` 任务会把所有静态文件(包括哈希后的 CSS/JS拷贝到 `frontend_static`
- `docs.svc.plus` 是 docs/blog 的运行时内容源,浏览器不会直接访问它。
发布由 `.github/workflows/service_release_frontend-deploy.yml` 驱动CI 构建/推送镜像、渲染包含 `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL``.env.runtime`,然后将 `docker-compose.yml`、`Caddyfile` 与运行时环境文件发送到主机。随后控制平面工作流 `.github/workflows/service_release_apiserver-deploy.yml` 通过 `scripts/github-actions/update-release-dns.sh` 更新 Cloudflare DNS使 `cn.svc.plus` 与别名 `cn.onwalk.net` 指向更新后的环境。
发布由 `.github/workflows/pipeline.yaml` 驱动CI 构建/推送镜像、渲染包含 `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL``.env.runtime`,然后将 `docker-compose.yml`、`Caddyfile` 与运行时环境文件发送到主机。随后控制平面 DNS 自动化会通过 `scripts/github-actions/update-release-dns.sh` 更新 Cloudflare DNS使当前生产主机 `jp-xhttp-contabo.svc.plus` 承载更新后的环境。
这是针对弱 IO 单机主机 `47.120.61.35` 的部署权衡:主机不会在本地构建镜像,只需登录 GHCR、拉取 `dashboard` 镜像、解包静态资源到 `frontend_static`,再通过 `docker compose` 启动 `dashboard``caddy`
这是针对弱 IO 单机主机 `root@jp-xhttp-contabo.svc.plus` 的部署权衡:主机不会在本地构建镜像,只需登录 GHCR、拉取 `dashboard` 镜像、解包静态资源到 `frontend_static`,再通过 `docker compose` 启动 `dashboard``caddy`
`docs.svc.plus` 已经是前端 docs/blog 内容的独立服务。
当前发布合同要求:
- `CANONICAL_DOMAIN=www.svc.plus`
- `SERVED_DOMAINS=www.svc.plus,console.svc.plus`
- 流水线必须同时校验两个域名的首页静态资源与 `/api/ping` 版本元数据完全一致
- `dashboardUrl`、canonical、structured data 与 sitemap 默认统一输出 `https://www.svc.plus`
## 相关文档
- `usage/deployment.md`

View File

@ -2,7 +2,7 @@
> English: `../../governance/release-process.md`
本页用于记录 `console.svc.plus` 已发布版本的发布说明与变更摘要。
本页用于记录公开控制台在 `www.svc.plus``console.svc.plus` 下发布版本的说明与变更摘要。
## 当前版本
@ -33,7 +33,7 @@
- 将 observability 工作区拆分为 tri-view并优化 panel 助手路由。
- 统一导航结构与持久化 AI sidebar 行为。
- 登录与注册流程改为使用服务端解析后的 account service URL。
- 体验账号与演示账号统一收敛到 `sandbox@svc.plus`
- 体验与演示模式不得在公开 UI 或会话载荷中暴露其后端承载账号身份
- 为集成配置增加基于 vault 的 token 查询能力。
#### 文档与安装
@ -50,4 +50,6 @@
## 备注
- GitHub Release`https://github.com/x-evor/console.svc.plus/releases/tag/v0.2`
- 发布校验必须同时验证 `www.svc.plus``console.svc.plus``releaseImageRef`、`releaseImageTag`、`releaseCommit` 完全一致。
- `www.svc.plus` 是 metadata、sitemap、`dashboardUrl` 与公开分享链接的首选域名。
- 相关文档:`docs/README.md`、`docs/en/README.md`、`docs/zh/README.md`

55
middleware.ts Normal file
View File

@ -0,0 +1,55 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { SESSION_COOKIE_NAME } from "./src/lib/authGateway";
const PUBLIC_EXACT_PATHS = new Set([
"/",
"/services",
"/login",
"/register",
"/email-verification",
"/logout",
"/404",
"/500",
]);
function isDocsPath(pathname: string): boolean {
return pathname === "/docs" || pathname.startsWith("/docs/");
}
function isPublicPath(pathname: string): boolean {
return PUBLIC_EXACT_PATHS.has(pathname) || isDocsPath(pathname);
}
function buildRedirectTarget(request: NextRequest): string {
const query = request.nextUrl.search;
return `${request.nextUrl.pathname}${query}`;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isPublicPath(pathname)) {
return undefined;
}
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value?.trim();
if (token) {
return undefined;
}
const loginUrl = new URL("/login", request.url);
const redirect = buildRedirectTarget(request);
if (redirect && redirect !== "/login") {
loginUrl.searchParams.set("redirect", redirect);
}
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:css|gif|ico|jpg|jpeg|js|map|png|svg|txt|webp|woff|woff2|xml)$).*)",
],
};

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
require_env() {
local key="$1"
local value="${!key-}"
if [[ -z "${value}" ]]; then
echo "Missing required environment variable: ${key}" >&2
exit 1
fi
}
require_env IMAGE_REF
require_env CANONICAL_DOMAIN
BUILD_ARGS_FILE="$(mktemp)"
trap 'rm -f "${BUILD_ARGS_FILE}"' EXIT
"${SCRIPT_DIR}/render-frontend-build-args.sh" --stdout > "${BUILD_ARGS_FILE}"
build_args=()
while IFS= read -r line; do
if [[ -z "${line}" ]]; then
continue
fi
build_args+=(--build-arg "${line}")
done < "${BUILD_ARGS_FILE}"
tag_args=(--tag "${IMAGE_REF}")
if [[ "${PUSH_LATEST:-false}" == "true" ]]; then
require_env IMAGE_LATEST_REF
tag_args+=(--tag "${IMAGE_LATEST_REF}")
fi
docker buildx build \
--platform "${DOCKER_PLATFORM:-linux/amd64}" \
--file "${REPO_ROOT}/Dockerfile" \
"${tag_args[@]}" \
"${build_args[@]}" \
--push \
"${REPO_ROOT}"

View File

@ -4,7 +4,7 @@ set -euo pipefail
IMAGE_TAG_INPUT="${1-}"
IMAGE_TAG="${IMAGE_TAG_INPUT}"
if [[ -z "${IMAGE_TAG}" ]]; then
IMAGE_TAG="${GITHUB_SHA}"
IMAGE_TAG="${GITHUB_SHA:?GITHUB_SHA is required}"
fi
GHCR_NAMESPACE="${GITHUB_REPOSITORY_OWNER,,}"
@ -18,5 +18,6 @@ fi
{
printf 'ghcr_namespace=%s\n' "${GHCR_NAMESPACE}"
printf 'image_tag=%s\n' "${IMAGE_TAG}"
printf 'image_ref=%s/%s/dashboard:%s\n' "${GHCR_REGISTRY}" "${GHCR_NAMESPACE}" "${IMAGE_TAG}"
printf 'image_ref=%s/%s/console:%s\n' "${GHCR_REGISTRY}" "${GHCR_NAMESPACE}" "${IMAGE_TAG}"
printf 'image_latest_ref=%s/%s/console:latest\n' "${GHCR_REGISTRY}" "${GHCR_NAMESPACE}"
} >> "${GITHUB_OUTPUT}"

View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
target_host="${TARGET_HOST:?TARGET_HOST is required}"
private_key="${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:?SINGLE_NODE_VPS_SSH_PRIVATE_KEY is required}"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "${private_key}" | tr -d '\r' > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "${target_host}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts

View File

@ -21,11 +21,20 @@ require_env SINGLE_NODE_VPS_SSH_PRIVATE_KEY
require_env GHCR_USERNAME
require_env GHCR_PASSWORD
require_env FRONTEND_IMAGE
require_env PRIMARY_DOMAIN
require_env SECONDARY_DOMAIN
require_env CANONICAL_DOMAIN
require_env SERVED_DOMAINS
GHCR_REGISTRY="${GHCR_REGISTRY:-ghcr.io}"
reject_remote_build_configuration() {
local compose_file="$1"
if grep -Eq '^[[:space:]]*(build|dockerfile):' "${compose_file}"; then
echo "Deployment package must reference prebuilt images only; compose build directives are forbidden." >&2
exit 1
fi
}
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "${WORK_DIR}"' EXIT
@ -40,6 +49,8 @@ bash "${SCRIPT_DIR}/render-frontend-runtime-env.sh" "${RUNTIME_ENV_FILE}"
cp "${DEPLOY_SOURCE_DIR}/docker-compose.yml" "${WORK_DIR}/docker-compose.yml"
cp "${DEPLOY_SOURCE_DIR}/Caddyfile" "${WORK_DIR}/Caddyfile"
reject_remote_build_configuration "${WORK_DIR}/docker-compose.yml"
tar -C "${WORK_DIR}" -czf "${RELEASE_ARCHIVE}" \
docker-compose.yml \
Caddyfile \

View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ghcr_token="${GHCR_TOKEN:?GHCR_TOKEN is required}"
ghcr_username="${GHCR_USERNAME:?GHCR_USERNAME is required}"
printf '%s' "${ghcr_token}" | docker login ghcr.io -u "${ghcr_username}" --password-stdin

View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
bash scripts/github-actions/build-and-push-frontend-image.sh
printf 'image_ref=%s\n' "${IMAGE_REF:?IMAGE_REF is required}" >> "${GITHUB_OUTPUT}"
printf 'image_tag=%s\n' "${IMAGE_TAG:?IMAGE_TAG is required}" >> "${GITHUB_OUTPUT}"

View File

@ -13,16 +13,32 @@ require_env() {
}
emit_lines() {
require_env PRIMARY_DOMAIN
require_env CANONICAL_DOMAIN
local canonical_domain="${CANONICAL_DOMAIN}"
local release_image_ref="${IMAGE_REF-}"
local release_image_tag=""
local release_commit=""
local release_version=""
if [[ -n "${release_image_ref}" ]]; then
release_image_tag="$(printf '%s' "${release_image_ref}" | sed -E 's#^.*:([^:@]+)$#\1#')"
release_version="${release_image_tag}"
if [[ "${release_image_tag}" =~ ^sha-([0-9a-f]{7,40})$ ]]; then
release_commit="${BASH_REMATCH[1]}"
elif [[ "${release_image_tag}" =~ ^[0-9a-f]{7,40}$ ]]; then
release_commit="${release_image_tag}"
fi
fi
local primary_domain="${PRIMARY_DOMAIN}"
printf 'NODE_BUILDER_IMAGE=%s\n' "${NODE_BUILDER_IMAGE:-node:22-bookworm}"
printf 'NODE_RUNTIME_IMAGE=%s\n' "${NODE_RUNTIME_IMAGE:-node:22-slim}"
printf 'CONTENTLAYER_BUILD=%s\n' "${CONTENTLAYER_BUILD:-true}"
printf 'NEXT_PUBLIC_APP_BASE_URL=%s\n' "${NEXT_PUBLIC_APP_BASE_URL:-https://${primary_domain}}"
printf 'NEXT_PUBLIC_SITE_URL=%s\n' "${NEXT_PUBLIC_SITE_URL:-https://${primary_domain}}"
printf 'NEXT_PUBLIC_LOGIN_URL=%s\n' "${NEXT_PUBLIC_LOGIN_URL:-https://${primary_domain}/login}"
printf 'NEXT_PUBLIC_DOCS_BASE_URL=%s\n' "${NEXT_PUBLIC_DOCS_BASE_URL:-https://${primary_domain}/docs}"
printf 'NEXT_PUBLIC_APP_BASE_URL=%s\n' "${NEXT_PUBLIC_APP_BASE_URL:-https://${canonical_domain}}"
printf 'NEXT_PUBLIC_SITE_URL=%s\n' "${NEXT_PUBLIC_SITE_URL:-https://${canonical_domain}}"
printf 'NEXT_PUBLIC_LOGIN_URL=%s\n' "${NEXT_PUBLIC_LOGIN_URL:-https://${canonical_domain}/login}"
printf 'NEXT_PUBLIC_DOCS_BASE_URL=%s\n' "${NEXT_PUBLIC_DOCS_BASE_URL:-https://${canonical_domain}/docs}"
printf 'NEXT_PUBLIC_RUNTIME_ENVIRONMENT=%s\n' "${NEXT_PUBLIC_RUNTIME_ENVIRONMENT:-prod}"
printf 'NEXT_PUBLIC_RUNTIME_REGION=%s\n' "${NEXT_PUBLIC_RUNTIME_REGION:-cn}"
printf 'NEXT_PUBLIC_GISCUS_REPO=%s\n' "${NEXT_PUBLIC_GISCUS_REPO:-x-evor/console.svc.plus}"
@ -36,6 +52,10 @@ emit_lines() {
printf 'NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION-}"
printf 'NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO-}"
printf 'NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION-}"
printf 'NEXT_PUBLIC_RELEASE_IMAGE=%s\n' "${release_image_ref}"
printf 'NEXT_PUBLIC_RELEASE_TAG=%s\n' "${release_image_tag}"
printf 'NEXT_PUBLIC_RELEASE_COMMIT=%s\n' "${release_commit}"
printf 'NEXT_PUBLIC_RELEASE_VERSION=%s\n' "${release_version}"
}
if [[ "${MODE}" == "--stdout" ]]; then

View File

@ -22,27 +22,27 @@ require_env() {
}
require_env FRONTEND_IMAGE
require_env PRIMARY_DOMAIN
require_env SECONDARY_DOMAIN
require_env CANONICAL_DOMAIN
require_env SERVED_DOMAINS
append_env FRONTEND_IMAGE "${FRONTEND_IMAGE}"
append_env PRIMARY_DOMAIN "${PRIMARY_DOMAIN}"
append_env SECONDARY_DOMAIN "${SECONDARY_DOMAIN}"
append_env CANONICAL_DOMAIN "${CANONICAL_DOMAIN}"
append_env SERVED_DOMAINS "${SERVED_DOMAINS}"
append_env NODE_ENV "production"
append_env PORT "${PORT:-3000}"
append_env RUNTIME_ENV "${RUNTIME_ENV:-prod}"
append_env REGION "${REGION:-cn}"
append_env APP_BASE_URL "${APP_BASE_URL:-https://${PRIMARY_DOMAIN}}"
append_env NEXT_PUBLIC_APP_BASE_URL "${NEXT_PUBLIC_APP_BASE_URL:-https://${PRIMARY_DOMAIN}}"
append_env NEXT_PUBLIC_SITE_URL "${NEXT_PUBLIC_SITE_URL:-https://${PRIMARY_DOMAIN}}"
append_env NEXT_PUBLIC_LOGIN_URL "${NEXT_PUBLIC_LOGIN_URL:-https://${PRIMARY_DOMAIN}/login}"
append_env NEXT_PUBLIC_DOCS_BASE_URL "${NEXT_PUBLIC_DOCS_BASE_URL:-https://${PRIMARY_DOMAIN}/docs}"
append_env APP_BASE_URL "${APP_BASE_URL:-https://${CANONICAL_DOMAIN}}"
append_env NEXT_PUBLIC_APP_BASE_URL "${NEXT_PUBLIC_APP_BASE_URL:-https://${CANONICAL_DOMAIN}}"
append_env NEXT_PUBLIC_SITE_URL "${NEXT_PUBLIC_SITE_URL:-https://${CANONICAL_DOMAIN}}"
append_env NEXT_PUBLIC_LOGIN_URL "${NEXT_PUBLIC_LOGIN_URL:-https://${CANONICAL_DOMAIN}/login}"
append_env NEXT_PUBLIC_DOCS_BASE_URL "${NEXT_PUBLIC_DOCS_BASE_URL:-https://${CANONICAL_DOMAIN}/docs}"
append_env SESSION_COOKIE_SECURE "${SESSION_COOKIE_SECURE:-true}"
append_env NEXT_PUBLIC_SESSION_COOKIE_SECURE "${NEXT_PUBLIC_SESSION_COOKIE_SECURE:-true}"
append_env RUNTIME_HOSTNAME "${RUNTIME_HOSTNAME:-${PRIMARY_DOMAIN}}"
append_env NEXT_RUNTIME_HOSTNAME "${NEXT_RUNTIME_HOSTNAME:-${PRIMARY_DOMAIN}}"
append_env DEPLOYMENT_HOSTNAME "${DEPLOYMENT_HOSTNAME:-${PRIMARY_DOMAIN}}"
append_env RUNTIME_HOSTNAME "${RUNTIME_HOSTNAME:-${CANONICAL_DOMAIN}}"
append_env NEXT_RUNTIME_HOSTNAME "${NEXT_RUNTIME_HOSTNAME:-${CANONICAL_DOMAIN}}"
append_env DEPLOYMENT_HOSTNAME "${DEPLOYMENT_HOSTNAME-}"
append_env NEXT_PUBLIC_RUNTIME_ENVIRONMENT "${NEXT_PUBLIC_RUNTIME_ENVIRONMENT:-prod}"
append_env NEXT_PUBLIC_RUNTIME_REGION "${NEXT_PUBLIC_RUNTIME_REGION:-cn}"
append_env ACCOUNT_SERVICE_URL "${ACCOUNT_SERVICE_URL:-https://accounts.svc.plus}"

View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
ref="${REF:?REF is required}"
if [[ "${ref}" == "refs/heads/main" ]]; then
echo "push_latest=true" >> "${GITHUB_OUTPUT}"
else
echo "push_latest=false" >> "${GITHUB_OUTPUT}"
fi

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
event_name="${EVENT_NAME:?EVENT_NAME is required}"
input_target_host="${INPUT_TARGET_HOST:-}"
input_run_apply="${INPUT_RUN_APPLY:-}"
if [[ "${event_name}" == "workflow_dispatch" ]]; then
target_host="${input_target_host}"
run_apply="${input_run_apply}"
else
target_host="jp-xhttp-contabo.svc.plus"
run_apply="true"
fi
printf 'target_host=%s\n' "${target_host}" >> "${GITHUB_OUTPUT}"
printf 'run_apply=%s\n' "${run_apply}" >> "${GITHUB_OUTPUT}"

View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
target_host="${TARGET_HOST:?TARGET_HOST is required}"
run_apply="${RUN_APPLY:?RUN_APPLY is required}"
cloudflare_zone_id="${CLOUDFLARE_ZONE_TAG:?CLOUDFLARE_ZONE_TAG is required}"
cloudflare_dns_token="${CLOUDFLARE_DNS_API_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"
if [[ -z "${cloudflare_dns_token}" ]]; then
echo "CLOUDFLARE_DNS_API_TOKEN or CLOUDFLARE_API_TOKEN is required" >&2
exit 1
fi
ansible_args=(
-i inventory.ini
update_cloudflare_svc_plus_dns.yml
-e "{\"cloudflare_dns_source_hosts\":[\"${target_host}\"]}"
-e "cloudflare_dns_zone_id=${cloudflare_zone_id}"
-e "CLOUDFLARE_DNS_API_TOKEN=${cloudflare_dns_token}"
)
if [[ "${run_apply}" != "true" ]]; then
ansible_args=(-C "${ansible_args[@]}")
fi
ansible-playbook "${ansible_args[@]}"

View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
target_host="${TARGET_HOST:?TARGET_HOST is required}"
run_apply="${RUN_APPLY:?RUN_APPLY is required}"
frontend_image="${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}"
ansible_args=(
-i inventory.ini
deploy_console_svc_plus.yml
-D
-l "${target_host}"
-e "FRONTEND_IMAGE=${frontend_image}"
-e "GHCR_USERNAME=${GHCR_USERNAME:?GHCR_USERNAME is required}"
-e "GHCR_PASSWORD=${GHCR_PASSWORD:?GHCR_PASSWORD is required}"
-e "INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required}"
-e "ACCOUNT_SERVICE_URL=${ACCOUNT_SERVICE_URL:?ACCOUNT_SERVICE_URL is required}"
-e "CANONICAL_DOMAIN=${CANONICAL_DOMAIN:?CANONICAL_DOMAIN is required}"
-e "SERVED_DOMAINS=${SERVED_DOMAINS:?SERVED_DOMAINS is required}"
-e "NEXT_PUBLIC_RUNTIME_ENVIRONMENT=${NEXT_PUBLIC_RUNTIME_ENVIRONMENT:?NEXT_PUBLIC_RUNTIME_ENVIRONMENT is required}"
-e "NEXT_PUBLIC_RUNTIME_REGION=${NEXT_PUBLIC_RUNTIME_REGION:?NEXT_PUBLIC_RUNTIME_REGION is required}"
-e "CLOUDFLARE_ZONE_TAG=${CLOUDFLARE_ZONE_TAG:?CLOUDFLARE_ZONE_TAG is required}"
-e "CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=${CLOUDFLARE_WEB_ANALYTICS_SITE_TAG:?CLOUDFLARE_WEB_ANALYTICS_SITE_TAG is required}"
-e "CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required}"
-e "CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required}"
-e "CLOUDFLARE_DNS_API_TOKEN=${CLOUDFLARE_DNS_API_TOKEN:?CLOUDFLARE_DNS_API_TOKEN is required}"
)
if [[ "${run_apply}" != "true" ]]; then
ansible_args=(-C "${ansible_args[@]}")
fi
ansible-playbook "${ansible_args[@]}"

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
docker buildx create --name console-builder --use >/dev/null 2>&1 || docker buildx use console-builder
docker buildx inspect --bootstrap

View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
target_host="${TARGET_HOST:?TARGET_HOST is required}"
canonical_domain="${CANONICAL_DOMAIN:?CANONICAL_DOMAIN is required}"
served_domains="${SERVED_DOMAINS:?SERVED_DOMAINS is required}"
expected_image_ref="${EXPECTED_FRONTEND_IMAGE:?EXPECTED_FRONTEND_IMAGE is required}"
request_base_url="${REQUEST_BASE_URL:-http://127.0.0.1:3000}"
remote_args=(
"$(printf '%q' "${canonical_domain}")"
"$(printf '%q' "${served_domains}")"
"$(printf '%q' "${expected_image_ref}")"
"$(printf '%q' "${request_base_url}")"
)
ssh -o BatchMode=yes "root@${target_host}" "bash -s -- ${remote_args[*]}" \
< scripts/github-actions/verify-frontend-release.sh

View File

@ -1,27 +1,221 @@
#!/usr/bin/env bash
set -euo pipefail
PRIMARY_DOMAIN="${1:?usage: verify-frontend-release.sh <primary-domain> <secondary-domain>}"
SECONDARY_DOMAIN="${2:?usage: verify-frontend-release.sh <primary-domain> <secondary-domain>}"
CANONICAL_DOMAIN="${1:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref> [request-base-url]}"
SERVED_DOMAINS="${2:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref> [request-base-url]}"
EXPECTED_IMAGE_REF="${3:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref> [request-base-url]}"
REQUEST_BASE_URL="${4:-https://${CANONICAL_DOMAIN}}"
primary_url="https://${PRIMARY_DOMAIN}"
secondary_url="https://${SECONDARY_DOMAIN}"
curl_headers=(
-H 'user-agent: Mozilla/5.0 (compatible; console-release-validator/1.0; +https://www.svc.plus)'
-H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
-H 'accept-language: en-US,en;q=0.9'
)
curl -fsSIL "${primary_url}" >/dev/null
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "${value}"
}
secondary_headers="$(curl -fsSIL "${secondary_url}")"
redirect_target="$(printf '%s\n' "${secondary_headers}" | awk 'tolower($1) == "location:" {print $2}' | tail -n 1 | tr -d '\r')"
if [[ "${redirect_target}" != "https://${PRIMARY_DOMAIN}/" && "${redirect_target}" != "https://${PRIMARY_DOMAIN}" ]]; then
echo "Unexpected secondary redirect target: ${redirect_target}" >&2
parse_image_ref() {
local image_ref="$1"
IMAGE_REF="${image_ref}" python3 - <<'PY'
import os
import re
import sys
image_ref = os.environ["IMAGE_REF"].strip()
match = re.search(r":([^:@]+)$", image_ref)
tag = match.group(1) if match else ""
commit = ""
version = tag
if re.fullmatch(r"[0-9a-f]{7,40}", tag, flags=re.IGNORECASE):
commit = tag
else:
prefixed_match = re.fullmatch(r"sha-([0-9a-f]{7,40})", tag, flags=re.IGNORECASE)
if prefixed_match:
commit = prefixed_match.group(1)
if not image_ref or not tag or not commit or not version:
sys.exit(1)
print(image_ref)
print(tag)
print(commit)
print(version)
PY
}
parse_homepage_release_metadata() {
local homepage_html="$1"
HOMEPAGE_HTML="${homepage_html}" python3 - <<'PY'
import os
import re
html = os.environ["HOMEPAGE_HTML"]
def extract_meta(name: str) -> str:
pattern = rf'<meta[^>]+name=["\']{re.escape(name)}["\'][^>]+content=["\']([^"\']*)["\']'
match = re.search(pattern, html, flags=re.IGNORECASE)
return match.group(1).strip() if match else ""
print(extract_meta("svc-plus-release-image"))
print(extract_meta("svc-plus-release-tag"))
print(extract_meta("svc-plus-release-commit"))
print(extract_meta("svc-plus-release-version"))
PY
}
require_http_200() {
local url="$1"
shift
local http_code
http_code="$(curl -sS -o /dev/null -w '%{http_code}' "$@" "${url}")"
if [[ "${http_code}" != "200" ]]; then
echo "Expected HTTP 200 from ${url}, got ${http_code}" >&2
exit 1
fi
}
verify_domain() {
local domain="$1"
local request_base_url="${REQUEST_BASE_URL%/}"
local request_headers=("${curl_headers[@]}" -H "host: ${domain}")
local homepage_html asset_path release_metadata
local actual_image_ref actual_image_tag actual_release_commit actual_release_version
local release_lines
require_http_200 "${request_base_url}" "${request_headers[@]}"
printf 'verified homepage for %s: 200\n' "${domain}" >&2
homepage_html="$(curl -fsSL "${request_headers[@]}" "${request_base_url}")"
asset_path="$(printf '%s' "${homepage_html}" | grep -Eo '/_next/static/[^"'"'"' ]+\.(css|js)' | head -n 1)"
if [[ -z "${asset_path}" ]]; then
echo "Could not find a _next/static asset on ${domain} via ${request_base_url}" >&2
exit 1
fi
require_http_200 "${request_base_url}${asset_path}" "${request_headers[@]}"
printf 'verified static asset for %s: %s%s\n' "${domain}" "${request_base_url}" "${asset_path}" >&2
release_metadata="$(parse_homepage_release_metadata "${homepage_html}")"
mapfile -t release_lines <<< "${release_metadata}"
actual_image_ref="${release_lines[0]-}"
actual_image_tag="${release_lines[1]-}"
actual_release_commit="${release_lines[2]-}"
actual_release_version="${release_lines[3]-}"
if [[ -z "${actual_image_ref}" || -z "${actual_image_tag}" || -z "${actual_release_commit}" || -z "${actual_release_version}" ]]; then
echo "Homepage release metadata is incomplete for ${domain}" >&2
exit 1
fi
if [[ ! "${actual_release_commit}" =~ ^[0-9a-f]{7,40}$ ]]; then
echo "Homepage release commit must contain a commit id for ${domain}, got: ${actual_release_commit}" >&2
exit 1
fi
if [[ "${actual_release_version}" != "${actual_image_tag}" ]]; then
echo "Homepage release version mismatch for ${domain}: expected ${actual_image_tag}, got ${actual_release_version}" >&2
exit 1
fi
if [[ "${actual_release_commit}" != "${EXPECTED_RELEASE_COMMIT}" ]]; then
echo "Homepage release commit mismatch for ${domain}: expected ${EXPECTED_RELEASE_COMMIT}, got ${actual_release_commit}" >&2
exit 1
fi
printf 'verified homepage release image for %s: %s\n' "${domain}" "${actual_image_ref}" >&2
printf 'verified homepage release commit for %s: %s\n' "${domain}" "${actual_release_commit}" >&2
printf 'verified homepage release version for %s: %s\n' "${domain}" "${actual_release_version}" >&2
printf '%s\t%s\t%s\t%s\t%s\n' "${domain}" "${actual_image_ref}" "${actual_image_tag}" "${actual_release_commit}" "${actual_release_version}"
}
mapfile -t expected_release_lines < <(parse_image_ref "${EXPECTED_IMAGE_REF}")
EXPECTED_RELEASE_IMAGE_REF="${expected_release_lines[0]-}"
EXPECTED_RELEASE_IMAGE_TAG="${expected_release_lines[1]-}"
EXPECTED_RELEASE_COMMIT="${expected_release_lines[2]-}"
EXPECTED_RELEASE_VERSION="${expected_release_lines[3]-}"
if [[ -z "${EXPECTED_RELEASE_IMAGE_REF}" || -z "${EXPECTED_RELEASE_IMAGE_TAG}" || -z "${EXPECTED_RELEASE_COMMIT}" || -z "${EXPECTED_RELEASE_VERSION}" ]]; then
echo "Expected image ref is invalid: ${EXPECTED_IMAGE_REF}" >&2
exit 1
fi
homepage_html="$(curl -fsSL "${primary_url}")"
asset_path="$(printf '%s' "${homepage_html}" | grep -Eo '/_next/static/[^"'"'"' ]+\.(css|js)' | head -n 1)"
if [[ -z "${asset_path}" ]]; then
echo "Could not find a _next/static asset on the homepage" >&2
mapfile -t served_domains < <(
printf '%s' "${SERVED_DOMAINS}" | tr ',' '\n' | while IFS= read -r domain; do
domain="$(trim "${domain}")"
if [[ -n "${domain}" ]]; then
printf '%s\n' "${domain}"
fi
done
)
if [[ "${#served_domains[@]}" -eq 0 ]]; then
echo "No served domains were provided." >&2
exit 1
fi
curl -fsSIL "${primary_url}${asset_path}" >/dev/null
printf 'verified static asset: %s%s\n' "${primary_url}" "${asset_path}"
canonical_found=false
declare -a verification_rows=()
for domain in "${served_domains[@]}"; do
if [[ "${domain}" == "${CANONICAL_DOMAIN}" ]]; then
canonical_found=true
fi
verification_rows+=("$(verify_domain "${domain}")")
done
if [[ "${canonical_found}" != "true" ]]; then
echo "Canonical domain ${CANONICAL_DOMAIN} must be included in served domains: ${SERVED_DOMAINS}" >&2
exit 1
fi
reference_image_ref=""
reference_image_tag=""
reference_release_commit=""
reference_release_version=""
for row in "${verification_rows[@]}"; do
IFS=$'\t' read -r domain actual_image_ref actual_image_tag actual_release_commit actual_release_version <<< "${row}"
if [[ "${actual_image_ref}" != "${EXPECTED_RELEASE_IMAGE_REF}" ]]; then
echo "Release image mismatch for ${domain}: expected ${EXPECTED_RELEASE_IMAGE_REF}, got ${actual_image_ref}" >&2
exit 1
fi
if [[ "${actual_image_tag}" != "${EXPECTED_RELEASE_IMAGE_TAG}" ]]; then
echo "Release tag mismatch for ${domain}: expected ${EXPECTED_RELEASE_IMAGE_TAG}, got ${actual_image_tag}" >&2
exit 1
fi
if [[ "${actual_release_commit}" != "${EXPECTED_RELEASE_COMMIT}" ]]; then
echo "Release commit mismatch for ${domain}: expected ${EXPECTED_RELEASE_COMMIT}, got ${actual_release_commit}" >&2
exit 1
fi
if [[ "${actual_release_version}" != "${EXPECTED_RELEASE_VERSION}" ]]; then
echo "Release version mismatch for ${domain}: expected ${EXPECTED_RELEASE_VERSION}, got ${actual_release_version}" >&2
exit 1
fi
if [[ -z "${reference_image_ref}" ]]; then
reference_image_ref="${actual_image_ref}"
reference_image_tag="${actual_image_tag}"
reference_release_commit="${actual_release_commit}"
reference_release_version="${actual_release_version}"
continue
fi
if [[ "${actual_image_ref}" != "${reference_image_ref}" || "${actual_image_tag}" != "${reference_image_tag}" || "${actual_release_commit}" != "${reference_release_commit}" || "${actual_release_version}" != "${reference_release_version}" ]]; then
echo "Release metadata drift detected across served domains." >&2
exit 1
fi
done

View File

@ -0,0 +1,32 @@
---
name: release-traceability
description: Default reference for release-traceability work in this repository. Use when changing CI/CD, image tags, deploy contracts, /api/ping, validate, or any build-to-deploy-to-verify release path.
---
# Release Traceability
## Default Rule
When working on release flow changes in this repository, treat this skill as the first reference.
## Scope
Use for:
- CI/CD orchestration
- Image tag and image reference generation
- Deploy contract changes
- `/api/ping` release metadata
- `validate` release verification
## Required Invariants
- Build output must carry a traceable commit-based version.
- Deploy must consume the published image only.
- Target hosts must not rebuild images.
- `/api/ping` must expose the active release identity.
- `validate` must compare build output, runtime output, and deployed image metadata.
## Working Rule
Prefer the simplest implementation that keeps the release chain auditable from commit to build artifact to runtime verification.

View File

@ -0,0 +1,62 @@
export const dynamic = 'force-dynamic'
import type { NextRequest } from 'next/server'
import { createUpstreamProxyHandler } from '@lib/apiProxy'
import { getAccountSession } from '@server/account/session'
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
const ACCOUNT_PREFIX = '/api/account'
function createHandler() {
const upstreamBaseUrl = getAccountServiceBaseUrl()
return createUpstreamProxyHandler({
upstreamBaseUrl,
upstreamPathPrefix: ACCOUNT_PREFIX,
getAdditionalHeaders: async (request) => {
if (request.headers.get('authorization')) {
return undefined
}
const session = await getAccountSession(request)
if (!session.token) {
return undefined
}
return {
authorization: `Bearer ${session.token}`,
'x-account-session': session.token,
}
},
})
}
const handler = createHandler()
export function GET(request: NextRequest) {
return handler(request)
}
export function POST(request: NextRequest) {
return handler(request)
}
export function PUT(request: NextRequest) {
return handler(request)
}
export function PATCH(request: NextRequest) {
return handler(request)
}
export function DELETE(request: NextRequest) {
return handler(request)
}
export function HEAD(request: NextRequest) {
return handler(request)
}
export function OPTIONS(request: NextRequest) {
return handler(request)
}

View File

@ -0,0 +1,28 @@
export const dynamic = 'force-dynamic'
import type { NextRequest } from 'next/server'
import { createUpstreamProxyHandler } from '@lib/apiProxy'
import { getAccountSession } from '@server/account/session'
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
const ADMIN_COLLECTOR_PREFIX = '/api/admin/collector'
const handler = createUpstreamProxyHandler({
upstreamBaseUrl: getAccountServiceBaseUrl(),
upstreamPathPrefix: ADMIN_COLLECTOR_PREFIX,
getAdditionalHeaders: async (request) => {
const session = await getAccountSession(request)
if (!session.token) {
return undefined
}
return {
authorization: `Bearer ${session.token}`,
'x-account-session': session.token,
}
},
})
export function GET(request: NextRequest) {
return handler(request)
}

View File

@ -1,120 +0,0 @@
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
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 READ_PERMISSIONS = ["admin.settings.read"];
const WRITE_PERMISSIONS = ["admin.settings.write"];
type ErrorPayload = {
error: string;
};
async function proxyRequest(request: NextRequest) {
const session = await getAccountSession(request);
const user = session.user;
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 },
);
}
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);
}
export async function POST(request: NextRequest) {
return proxyRequest(request);
}
export async function PUT(request: NextRequest) {
return proxyRequest(request);
}
export async function PATCH(request: NextRequest) {
return proxyRequest(request);
}
export async function DELETE(request: NextRequest) {
return proxyRequest(request);
}

View File

@ -1,71 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.settings.write']
type ErrorPayload = {
error: string
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const headers = new Headers({
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
})
const body = await request.text()
const contentType = request.headers.get('content-type') ?? 'application/json'
headers.set('Content-Type', contentType)
try {
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, {
method: 'POST',
headers,
body,
cache: 'no-store',
})
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
const text = await response.text().catch(() => '')
return NextResponse.json(
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
{ status: 502 },
)
}
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 (error) {
console.error('Failed to proxy sandbox bind', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -1,64 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const READ_PERMISSIONS = ['admin.settings.read']
type ErrorPayload = {
error: string
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: READ_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/binding`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
},
cache: 'no-store',
})
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
const text = await response.text().catch(() => '')
return NextResponse.json(
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
{ status: 502 },
)
}
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 (error) {
console.error('Failed to proxy sandbox binding', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -0,0 +1,28 @@
export const dynamic = 'force-dynamic'
import type { NextRequest } from 'next/server'
import { createUpstreamProxyHandler } from '@lib/apiProxy'
import { getAccountSession } from '@server/account/session'
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
const ADMIN_SCHEDULER_PREFIX = '/api/admin/scheduler'
const handler = createUpstreamProxyHandler({
upstreamBaseUrl: getAccountServiceBaseUrl(),
upstreamPathPrefix: ADMIN_SCHEDULER_PREFIX,
getAdditionalHeaders: async (request) => {
const session = await getAccountSession(request)
if (!session.token) {
return undefined
}
return {
authorization: `Bearer ${session.token}`,
'x-account-session': session.token,
}
},
})
export function GET(request: NextRequest) {
return handler(request)
}

View File

@ -0,0 +1,28 @@
export const dynamic = 'force-dynamic'
import type { NextRequest } from 'next/server'
import { createUpstreamProxyHandler } from '@lib/apiProxy'
import { getAccountSession } from '@server/account/session'
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
const ADMIN_TRAFFIC_PREFIX = '/api/admin/traffic'
const handler = createUpstreamProxyHandler({
upstreamBaseUrl: getAccountServiceBaseUrl(),
upstreamPathPrefix: ADMIN_TRAFFIC_PREFIX,
getAdditionalHeaders: async (request) => {
const session = await getAccountSession(request)
if (!session.token) {
return undefined
}
return {
authorization: `Bearer ${session.token}`,
'x-account-session': session.token,
}
},
})
export function GET(request: NextRequest) {
return handler(request)
}

View File

@ -0,0 +1,69 @@
// @vitest-environment node
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const cookiesMock = vi.hoisted(() => vi.fn());
const ORIGINAL_ENV = { ...process.env };
vi.mock("next/headers", () => ({
cookies: cookiesMock,
}));
describe("/api/auth/session", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
cookiesMock.mockReset();
process.env = { ...ORIGINAL_ENV };
process.env.ACCOUNT_SERVICE_URL = "https://accounts.svc.plus";
});
afterAll(() => {
vi.unstubAllGlobals();
process.env = ORIGINAL_ENV;
});
it("drops guest sessions instead of exposing them as authenticated users", async () => {
cookiesMock.mockResolvedValue({
get(name: string) {
if (name === "xc_session") {
return { value: "guest-session-token" };
}
return undefined;
},
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
user: {
id: "guest-1",
email: "guest@svc.plus",
role: "guest",
username: "guest",
},
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
),
);
vi.stubGlobal("fetch", fetchMock);
const { GET } = await import("./route");
const request = new NextRequest("https://console.svc.plus/api/auth/session", {
headers: {
host: "console.svc.plus",
},
});
const response = await GET(request);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ user: null });
});
});

View File

@ -2,23 +2,14 @@ import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway";
import {
getAccountServiceApiBaseUrl,
getAccountServiceBaseUrl,
} from "@server/serviceConfig";
import {
buildInternalServiceHeaders,
isServiceTokenConfigured,
} from "@server/internalServiceAuth";
import { resolvePublicUserEmail } from "@lib/publicUserIdentity";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const ACCOUNT_BASE = getAccountServiceBaseUrl();
type AccountUser = {
id?: string;
uuid?: string;
proxyUuid?: string;
proxyUuidExpiresAt?: string;
name?: string;
username?: string;
email: string;
@ -48,20 +39,15 @@ type SessionResponse = {
error?: string;
};
type SandboxGuestResponse = {
email?: string;
proxyUuid?: string;
proxyUuidExpiresAt?: string;
error?: string;
};
type AuthenticatedRole = "user" | "operator" | "admin";
function normalizeRole(role: unknown): string {
function normalizeRole(role: unknown): AuthenticatedRole | null {
if (typeof role !== "string") {
return "user";
return null;
}
const normalized = role.trim().toLowerCase();
if (!normalized) {
return "user";
return null;
}
if (normalized === "root" || normalized === "super_admin") {
return "admin";
@ -69,7 +55,14 @@ function normalizeRole(role: unknown): string {
if (normalized === "readonly" || normalized === "read_only") {
return "user";
}
return normalized;
if (
normalized === "user" ||
normalized === "operator" ||
normalized === "admin"
) {
return normalized;
}
return null;
}
async function fetchSession(token: string, requestHost?: string | null) {
@ -94,69 +87,10 @@ async function fetchSession(token: string, requestHost?: string | null) {
}
}
async function fetchSandboxGuest(): Promise<AccountUser | null> {
if (!isServiceTokenConfigured()) {
return null;
}
try {
const response = await fetch(`${ACCOUNT_BASE}/api/internal/sandbox/guest`, {
method: "GET",
headers: buildInternalServiceHeaders({
Accept: "application/json",
}),
cache: "no-store",
});
if (!response.ok) {
return null;
}
const payload = (await response
.json()
.catch(() => null)) as SandboxGuestResponse | null;
const proxyUuid =
typeof payload?.proxyUuid === "string" ? payload.proxyUuid.trim() : "";
if (!proxyUuid) {
return null;
}
const proxyUuidExpiresAt =
typeof payload?.proxyUuidExpiresAt === "string" &&
payload.proxyUuidExpiresAt.trim().length > 0
? payload.proxyUuidExpiresAt.trim()
: undefined;
// Shape this as a pseudo-session user for the Guest/Demo experience.
return {
id: proxyUuid,
uuid: proxyUuid,
proxyUuid,
proxyUuidExpiresAt,
name: "Guest user",
username: "guest",
email: "sandbox@svc.plus",
role: "guest",
groups: ["guest", "sandbox"],
permissions: ["read"],
readOnly: true,
tenantId: "guest-sandbox",
tenants: [{ id: "guest-sandbox", name: "Guest Sandbox", role: "guest" }],
mfaEnabled: false,
mfaPending: false,
};
} catch (error) {
console.error("Sandbox guest session proxy failed", error);
return null;
}
}
export async function GET(request: NextRequest) {
void request;
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
if (!token) {
const sandboxGuest = await fetchSandboxGuest();
return NextResponse.json({ user: sandboxGuest });
return NextResponse.json({ user: null });
}
const requestHost = request.headers.get("host");
@ -186,6 +120,11 @@ export async function GET(request: NextRequest) {
const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled;
const normalizedRole = normalizeRole(rawUser.role);
if (!normalizedRole) {
const response = NextResponse.json({ user: null });
clearSessionCookie(response, requestHost ?? undefined);
return response;
}
const rawRole =
typeof rawUser.role === "string" ? rawUser.role.trim().toLowerCase() : "";
const normalizedGroups = Array.isArray(rawUser.groups)
@ -215,19 +154,7 @@ export async function GET(request: NextRequest) {
Boolean(rawUser.readOnly) ||
normalizedGroups.some((group) => group.toLowerCase() === "readonly role") ||
rawRole === "readonly" ||
rawRole === "read_only" ||
String(rawUser.email ?? "")
.trim()
.toLowerCase() === "sandbox@svc.plus";
const normalizedProxyUuid =
typeof rawUser.proxyUuid === "string" && rawUser.proxyUuid.trim().length > 0
? rawUser.proxyUuid.trim()
: undefined;
const normalizedProxyUuidExpiresAt =
typeof rawUser.proxyUuidExpiresAt === "string" &&
rawUser.proxyUuidExpiresAt.trim().length > 0
? rawUser.proxyUuidExpiresAt.trim()
: undefined;
rawRole === "read_only";
const normalizedTenantId =
typeof rawUser.tenantId === "string" && rawUser.tenantId.trim().length > 0
@ -289,10 +216,15 @@ export async function GET(request: NextRequest) {
const normalizedUser = identifier
? { ...rawUser, id: identifier, uuid: identifier }
: rawUser;
const publicEmail = resolvePublicUserEmail({
email: normalizedUser.email,
role: normalizedRole,
});
return NextResponse.json({
user: {
...normalizedUser,
email: publicEmail,
mfaEnabled: derivedMfaEnabled,
mfaPending: derivedMfaPending,
mfa: normalizedMfa,
@ -300,8 +232,6 @@ export async function GET(request: NextRequest) {
groups: normalizedGroups,
permissions: normalizedPermissions,
readOnly: normalizedReadOnly,
proxyUuid: normalizedProxyUuid,
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
tenantId: normalizedTenantId,
tenants: normalizedTenants,
},

View File

@ -2,9 +2,43 @@ import { NextResponse } from 'next/server'
import { loadRuntimeConfig } from '@server/runtime-loader'
type TReleaseImageMetadata = {
releaseImageRef: string | null
releaseImageTag: string | null
releaseCommit: string | null
}
function resolveReleaseCommit(releaseImageTag: string | null): string | null {
if (!releaseImageTag) {
return null
}
const normalizedTag = releaseImageTag.trim()
const prefixedShaMatch = normalizedTag.match(/^sha-([0-9a-f]{7,40})$/i)
if (prefixedShaMatch) {
return prefixedShaMatch[1] ?? null
}
return /^[0-9a-f]{7,40}$/i.test(normalizedTag) ? normalizedTag : null
}
function resolveReleaseImageMetadata(frontendImage: string | undefined): TReleaseImageMetadata {
const releaseImageRef = frontendImage?.trim() || null
const releaseImageTagMatch = releaseImageRef?.match(/:([^:@]+)$/)
const releaseImageTag = releaseImageTagMatch?.[1] ?? null
const releaseCommit = resolveReleaseCommit(releaseImageTag)
return {
releaseImageRef,
releaseImageTag,
releaseCommit,
}
}
export async function GET(request: Request) {
const hostnameHeader = request.headers.get('host') ?? undefined
const runtimeConfig = loadRuntimeConfig({ hostname: hostnameHeader })
const releaseMetadata = resolveReleaseImageMetadata(process.env.FRONTEND_IMAGE)
const payload = {
status: 'ok' as const,
@ -14,6 +48,7 @@ export async function GET(request: Request) {
authUrl: runtimeConfig.authUrl,
dashboardUrl: runtimeConfig.dashboardUrl,
logLevel: runtimeConfig.logLevel,
...releaseMetadata,
}
console.info('[runtime-config] /api/ping resolved config snippet', payload)

View File

@ -1,97 +0,0 @@
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
import { applySessionCookie } from "@lib/authGateway";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const ROOT_BACKUP_COOKIE = "xc_session_root";
type ErrorPayload = {
error: string;
};
function secureCookies(): boolean {
if (process.env.NODE_ENV === "production") {
return true;
}
const baseUrl =
process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || "";
return baseUrl.toLowerCase().startsWith("https://");
}
async function verifyRootToken(token: string): Promise<boolean> {
try {
const res = await fetch(`${ACCOUNT_API_BASE}/session`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
cache: "no-store",
});
if (!res.ok) {
return false;
}
const payload = (await res.json().catch(() => null)) as any;
const email =
typeof payload?.user?.email === "string"
? payload.user.email.trim().toLowerCase()
: "";
return email === "admin@svc.plus";
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
const rootToken =
request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? "";
if (!rootToken) {
return NextResponse.json<ErrorPayload>(
{ error: "not_assuming" },
{ status: 400 },
);
}
if (!(await verifyRootToken(rootToken))) {
return NextResponse.json<ErrorPayload>(
{ error: "root_token_invalid" },
{ status: 403 },
);
}
// Best-effort audit log on accounts.svc.plus. (Cookies are owned by console.)
try {
await fetch(`${ACCOUNT_API_BASE}/admin/assume/revert`, {
method: "POST",
headers: {
Authorization: `Bearer ${rootToken}`,
Accept: "application/json",
},
cache: "no-store",
});
} catch (error) {
console.error("Failed to audit assume revert", error);
}
const response = NextResponse.json({ ok: true });
applySessionCookie(
response,
rootToken,
undefined,
request.headers.get("host") ?? undefined,
);
response.cookies.set({
name: ROOT_BACKUP_COOKIE,
value: "",
httpOnly: true,
secure: secureCookies(),
sameSite: "lax",
path: "/",
maxAge: 0,
});
return response;
}

View File

@ -1,119 +0,0 @@
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
import { applySessionCookie, deriveMaxAgeFromExpires } from "@lib/authGateway";
import { evaluateAccountAdminAccess } from "@server/account/adminAccess";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import { getAccountSession } from "@server/account/session";
import type { AccountUserRole } from "@server/account/session";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const REQUIRED_ROLES: AccountUserRole[] = ["admin"];
const WRITE_PERMISSIONS = ["admin.settings.write"];
const ROOT_BACKUP_COOKIE = "xc_session_root";
const SANDBOX_EMAIL = "sandbox@svc.plus";
type ErrorPayload = {
error: string;
};
function secureCookies(): boolean {
if (process.env.NODE_ENV === "production") {
return true;
}
const baseUrl =
process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || "";
return baseUrl.toLowerCase().startsWith("https://");
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
});
if (!access.allowed) {
return NextResponse.json<ErrorPayload>(
{ error: access.reason ?? "forbidden" },
{ status: 403 },
);
}
try {
const upstream = await fetch(`${ACCOUNT_API_BASE}/admin/assume`, {
method: "POST",
headers: {
Authorization: `Bearer ${session.token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ email: SANDBOX_EMAIL }),
cache: "no-store",
});
const contentType = upstream.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
const text = await upstream.text().catch(() => "");
return NextResponse.json(
{
error: "upstream_non_json",
upstreamStatus: upstream.status,
upstreamBody: text.slice(0, 2048),
} as any,
{ status: 502 },
);
}
const payload = (await upstream.json().catch(() => null)) as any;
if (!payload || typeof payload.token !== "string") {
return NextResponse.json<ErrorPayload>(
{ error: "invalid_response" },
{ status: 502 },
);
}
const response = NextResponse.json({ ok: true, assumed: SANDBOX_EMAIL });
// Backup current root session token only if it's NOT already an assumed session.
// Check if the current user is NOT the sandbox user.
if (user.email.toLowerCase() !== SANDBOX_EMAIL) {
response.cookies.set({
name: ROOT_BACKUP_COOKIE,
value: session.token,
httpOnly: true,
secure: secureCookies(),
sameSite: "lax",
path: "/",
maxAge: deriveMaxAgeFromExpires(payload.expiresAt),
});
}
// Switch main session to sandbox token.
applySessionCookie(
response,
payload.token,
deriveMaxAgeFromExpires(payload.expiresAt),
request.headers.get("host") ?? undefined,
);
return response;
} catch (error) {
console.error("Failed to assume sandbox", error);
return NextResponse.json<ErrorPayload>(
{ error: "upstream_unreachable" },
{ status: 502 },
);
}
}

View File

@ -1,11 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
const ROOT_BACKUP_COOKIE = 'xc_session_root'
export async function GET(request: NextRequest) {
const isAssuming = Boolean(request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim())
return NextResponse.json({ isAssuming, target: isAssuming ? 'sandbox@svc.plus' : '' })
}

View File

@ -1,56 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession } from '@server/account/session'
import { buildInternalServiceHeaders, isServiceTokenConfigured } from '@server/internalServiceAuth'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type ErrorPayload = {
error: string
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const canUseInternalToken = isServiceTokenConfigured()
if (!session.token && !canUseInternalToken) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
try {
const headers = session.token
? new Headers({
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
})
: buildInternalServiceHeaders({
Accept: 'application/json',
})
const response = await fetch(`${ACCOUNT_API_BASE}/sandbox/binding`, {
method: 'GET',
headers,
cache: 'no-store',
})
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
const text = await response.text().catch(() => '')
return NextResponse.json(
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
{ status: 502 },
)
}
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 (error) {
console.error('Failed to proxy sandbox binding (public)', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -0,0 +1,76 @@
import type { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const DEFAULT_BRIDGE_SERVER_URL = "https://xworkmate-bridge.svc.plus";
function bridgeServerUrl(): string {
return (
process.env.BRIDGE_SERVER_URL?.trim().replace(/\/+$/, "") ||
DEFAULT_BRIDGE_SERVER_URL
);
}
function bridgeAuthToken(): string {
return process.env.BRIDGE_AUTH_TOKEN?.trim() ?? "";
}
function jsonError(message: string, status = 500): Response {
return Response.json({ error: { message } }, { status });
}
function bridgeHeaders(): HeadersInit {
const token = bridgeAuthToken();
return {
Accept: "application/json",
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function GET(request: NextRequest): Promise<Response> {
const action = request.nextUrl.searchParams.get("action") ?? "ping";
if (action !== "ping") {
return jsonError("Unsupported xworkmate bridge action.", 400);
}
const response = await fetch(`${bridgeServerUrl()}/api/ping`, {
cache: "no-store",
headers: bridgeHeaders(),
});
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"Content-Type":
response.headers.get("content-type") ?? "application/json",
},
});
}
export async function POST(request: NextRequest): Promise<Response> {
let payload: unknown;
try {
payload = await request.json();
} catch {
return jsonError("Invalid JSON body.", 400);
}
const response = await fetch(`${bridgeServerUrl()}/acp/rpc`, {
method: "POST",
cache: "no-store",
headers: bridgeHeaders(),
body: JSON.stringify(payload),
});
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"Content-Type":
response.headers.get("content-type") ?? "application/json",
},
});
}

View File

@ -7,6 +7,7 @@ import type { Metadata } from 'next'
import Script from 'next/script'
import { Analytics } from '@vercel/analytics/react'
import { AppProviders } from './AppProviders'
import { resolveWebReleaseMetadata } from '@/lib/webReleaseMetadata'
import { getConsoleIntegrationDefaults } from '@/server/consoleIntegrations'
const DEFAULT_TITLE = 'Cloud-Neutral Console | Unified Cloud Native Tools'
@ -15,7 +16,7 @@ const DEFAULT_DESCRIPTION =
const DEFAULT_OG_IMAGE = '/icons/webchat.jpg'
export const metadata: Metadata = {
metadataBase: new URL('https://console.svc.plus'),
metadataBase: new URL('https://www.svc.plus'),
title: {
default: DEFAULT_TITLE,
template: '%s | Cloud-Neutral',
@ -76,12 +77,17 @@ const GA_ID = 'G-T4VM8G4Q42'
export default function RootLayout({ children }: { children: React.ReactNode }) {
const assistantDefaults = getConsoleIntegrationDefaults()
const releaseMetadata = resolveWebReleaseMetadata()
return (
<html {...htmlAttributes}>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#6366f1" />
{releaseMetadata.image ? <meta name="svc-plus-release-image" content={releaseMetadata.image} /> : null}
{releaseMetadata.tag ? <meta name="svc-plus-release-tag" content={releaseMetadata.tag} /> : null}
{releaseMetadata.commit ? <meta name="svc-plus-release-commit" content={releaseMetadata.commit} /> : null}
{releaseMetadata.version ? <meta name="svc-plus-release-version" content={releaseMetadata.version} /> : null}
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
rel="stylesheet"
@ -93,8 +99,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Cloud-Neutral',
url: 'https://console.svc.plus',
logo: 'https://console.svc.plus/icons/cloudnative_32.png',
url: 'https://www.svc.plus',
logo: 'https://www.svc.plus/icons/cloudnative_32.png',
description: DEFAULT_DESCRIPTION,
}).replace(/</g, '\\u003c'),
}}
@ -106,7 +112,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Cloud-Neutral',
url: 'https://console.svc.plus',
url: 'https://www.svc.plus',
description: DEFAULT_DESCRIPTION,
}).replace(/</g, '\\u003c'),
}}

View File

@ -1,20 +1,15 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { hasPublicUserEmail } from "@lib/publicUserIdentity";
import { useUserStore } from "@lib/userStore";
import type { UserRole } from "@lib/userStore";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
const ROLE_BADGES: Record<UserRole, { label: string; className: string }> = {
guest: {
label: "Guest",
className: "bg-[var(--color-badge-muted)] text-[var(--color-text-subtle)]",
},
user: {
label: "User",
className:
@ -56,149 +51,28 @@ export default function Header({
isCollapsed,
}: HeaderProps) {
const { language } = useLanguage();
const router = useRouter();
const navCopy = translations[language].nav.account;
const user = useUserStore((state) => state.user);
const isLoading = useUserStore((state) => state.isLoading);
const role: UserRole = user?.role ?? "guest";
const role: UserRole = user?.role ?? "user";
const badge = ROLE_BADGES[role];
const shouldRenderPublicEmail = hasPublicUserEmail({
email: user?.email,
role,
});
const accountLabel =
user?.name ?? user?.username ?? user?.email ?? "Guest user";
user?.name ??
user?.username ??
(shouldRenderPublicEmail ? user?.email : undefined) ??
navCopy.title;
const accountInitial = resolveAccountInitial(accountLabel);
const statusBadge = isLoading ? "Syncing" : badge.label;
const badgeClasses = isLoading
? "bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-70"
: badge.className;
const isRoot = useMemo(() => {
const email = user?.email?.trim().toLowerCase() ?? "";
return email === "admin@svc.plus" && role === "admin";
}, [role, user?.email]);
const [assumeStatus, setAssumeStatus] = useState<{
isAssuming: boolean;
target?: string;
}>({
isAssuming: false,
});
const [assumeBusy, setAssumeBusy] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch("/api/sandbox/assume/status", {
method: "GET",
cache: "no-store",
});
const payload = (await res.json().catch(() => null)) as any;
if (cancelled) return;
setAssumeStatus({
isAssuming: Boolean(payload?.isAssuming),
target:
typeof payload?.target === "string" ? payload.target : undefined,
});
} catch {
if (cancelled) return;
setAssumeStatus({ isAssuming: false });
}
})();
return () => {
cancelled = true;
};
}, []);
const handleAssumeSandbox = async () => {
if (!isRoot || assumeBusy) return;
try {
setAssumeBusy(true);
const res = await fetch("/api/sandbox/assume", {
method: "POST",
cache: "no-store",
credentials: "include",
});
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as any;
throw new Error(
(payload && (payload.message || payload.error)) ||
`Assume failed (${res.status})`,
);
}
router.refresh();
// Ensure server-rendered parts reflect the new cookie immediately.
window.location.reload();
} finally {
setAssumeBusy(false);
}
};
const handleRevertAssume = async () => {
if (assumeBusy) return;
try {
setAssumeBusy(true);
const res = await fetch("/api/sandbox/assume/revert", {
method: "POST",
cache: "no-store",
credentials: "include",
});
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as any;
throw new Error(
(payload && (payload.message || payload.error)) ||
`Revert failed (${res.status})`,
);
}
router.refresh();
window.location.reload();
} finally {
setAssumeBusy(false);
}
};
return (
<header className="sticky top-0 z-30 overflow-hidden border-b border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] text-[var(--color-text)] shadow-[var(--shadow-soft)] backdrop-blur-xl transition-colors">
{assumeStatus.isAssuming ? (
<div className="flex items-center justify-between gap-3 px-4 py-2 text-xs md:px-5">
<div className="rounded-[8px] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-1.5 text-[var(--color-warning-foreground)]">
{language === "zh"
? `当前处于 Assume: ${assumeStatus.target || "sandbox@svc.plus"}(只读视角)`
: `Assuming: ${assumeStatus.target || "sandbox@svc.plus"} (read-only view)`}
</div>
<button
type="button"
onClick={() => void handleRevertAssume()}
disabled={assumeBusy}
className="tactile-button tactile-button-subtle min-h-8 px-3 text-[var(--color-warning-foreground)] disabled:opacity-60"
>
{assumeBusy
? language === "zh"
? "处理中…"
: "Working…"
: language === "zh"
? "退出 Sandbox"
: "Exit Sandbox"}
</button>
</div>
) : (
<div className="flex items-center justify-end gap-2 px-4 py-2 text-xs md:px-5">
{isRoot ? (
<button
type="button"
onClick={() => void handleAssumeSandbox()}
disabled={assumeBusy || isLoading}
className="tactile-button tactile-button-soft min-h-8 border border-[color:var(--color-primary-border)] px-3 text-[var(--color-primary)] disabled:opacity-60"
>
{assumeBusy
? language === "zh"
? "处理中…"
: "Working…"
: language === "zh"
? "切换到 Sandbox"
: "Assume Sandbox"}
</button>
) : null}
</div>
)}
<div className="flex items-center justify-between px-4 py-2.5 md:px-5">
<div className="flex items-center gap-4">
<button
@ -251,10 +125,7 @@ export default function Header({
<span className="text-[13px] font-semibold text-[var(--color-text)]">
{accountLabel}
</span>
<span>
{user?.email ??
(isLoading ? "Checking session…" : "Not signed in")}
</span>
{shouldRenderPublicEmail ? <span>{user?.email}</span> : null}
</div>
</div>
</div>

View File

@ -214,8 +214,8 @@ export default function ServicesPage() {
key: "xworkmate",
name: "XWorkmate",
description: isChinese
? "在线版 XWorkmate 工作区,底层由 OpenClaw gateway 驱动。"
: "Online XWorkmate workspace powered by the OpenClaw gateway.",
? "在线版 XWorkmate 工作区,底层由 xworkmate-bridge 驱动。"
: "Online XWorkmate workspace powered by xworkmate-bridge.",
href: "/xworkmate",
icon: Command,
},

View File

@ -3,7 +3,7 @@ import type { MetadataRoute } from 'next'
import { getBlogList } from '@/lib/docsServiceClient'
import { PRODUCT_LIST } from '@/modules/products/registry'
const baseUrl = 'https://console.svc.plus'
const baseUrl = 'https://www.svc.plus'
export const dynamic = 'force-dynamic'
export const revalidate = 3600 // Revalidate every hour

View File

@ -0,0 +1,21 @@
import type { Metadata } from "next";
import Footer from "@/components/Footer";
import UnifiedNavigation from "@/components/UnifiedNavigation";
import XWorkmateSuiteMarketing from "@/components/marketing/XWorkmateSuiteMarketing";
export const metadata: Metadata = {
title: "XWorkmate 产品矩阵 | Cloud-Neutral Console",
description:
"面向个人 AI 工作流的 XWorkmate App、Bridge、OpenClaw multi-session plugins 与 workspace core skills 产品矩阵。",
};
export default function XWorkmateSuitePage() {
return (
<div className="min-h-screen bg-background text-text">
<UnifiedNavigation />
<XWorkmateSuiteMarketing />
<Footer />
</div>
);
}

View File

@ -4,19 +4,17 @@ import { Suspense } from "react";
import { XWorkmateLoading } from "@/app/xworkmate/XWorkmateLoading";
import { XWorkmateWorkspaceRoute } from "@/components/xworkmate/XWorkmateWorkspaceRoute";
import { getConsoleIntegrationDefaults } from "@/server/consoleIntegrations";
export const metadata = {
title: "XWorkmate",
description: "Online XWorkmate workspace powered by OpenClaw gateway",
description: "Online XWorkmate workspace powered by xworkmate-bridge",
};
export default function XWorkmatePage() {
const defaults = getConsoleIntegrationDefaults();
return (
<div className="h-[calc(100vh-var(--app-shell-nav-offset))] w-full">
<Suspense fallback={<XWorkmateLoading />}>
<XWorkmateWorkspaceRoute defaults={defaults} />
<XWorkmateWorkspaceRoute />
</Suspense>
</div>
);

View File

@ -20,6 +20,7 @@ import { AskAIButton } from "./AskAIButton";
import ReleaseChannelSelector, {
ReleaseChannel,
} from "./ReleaseChannelSelector";
import { hasPublicUserEmail } from "@lib/publicUserIdentity";
import { useUserStore } from "@lib/userStore";
// import SearchComponent from './search'
@ -57,9 +58,13 @@ export default function Navbar() {
const user = useUserStore((state) => state.user);
const nav = translations[language].nav;
const accountCopy = nav.account;
const shouldRenderPublicEmail = hasPublicUserEmail({
email: user?.email,
role: user?.role,
});
const accountInitial =
user?.username?.charAt(0)?.toUpperCase() ??
user?.email?.charAt(0)?.toUpperCase() ??
(shouldRenderPublicEmail ? user?.email?.charAt(0)?.toUpperCase() : null) ??
"?";
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
@ -534,7 +539,9 @@ export default function Navbar() {
<p className="text-sm font-semibold text-text">
{user.username}
</p>
<p className="text-xs text-text-muted">{user.email}</p>
{shouldRenderPublicEmail ? (
<p className="text-xs text-text-muted">{user.email}</p>
) : null}
</div>
<div className="py-1 text-sm text-text">
<Link
@ -648,9 +655,11 @@ export default function Navbar() {
<p className="truncate text-sm font-semibold">
{user.username}
</p>
<p className="truncate text-xs text-text-muted">
{user.email}
</p>
{shouldRenderPublicEmail ? (
<p className="truncate text-xs text-text-muted">
{user.email}
</p>
) : null}
</div>
</div>
</div>

View File

@ -9,6 +9,7 @@ import { translations } from "../i18n/translations";
import LanguageToggle from "./LanguageToggle";
// import { AskAIButton } from "./AskAIButton";
import ReleaseChannelSelector from "./ReleaseChannelSelector";
import { hasPublicUserEmail } from "@lib/publicUserIdentity";
import { useUserStore } from "@lib/userStore";
import { useMoltbotStore } from "@lib/moltbotStore";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
@ -40,9 +41,13 @@ export default function UnifiedNavigation() {
const user = useUserStore((state) => state.user);
const { toggleOpen } = useMoltbotStore();
const nav = translations[language].nav;
const shouldRenderPublicEmail = hasPublicUserEmail({
email: user?.email,
role: user?.role,
});
const accountInitial =
user?.username?.charAt(0)?.toUpperCase() ??
user?.email?.charAt(0)?.toUpperCase() ??
(shouldRenderPublicEmail ? user?.email?.charAt(0)?.toUpperCase() : null) ??
"?";
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
@ -356,9 +361,11 @@ export default function UnifiedNavigation() {
<p className="text-sm font-semibold text-text leading-none mb-1.5">
{user.username}
</p>
<p className="text-[12px] text-text-muted leading-none break-all">
{user.email}
</p>
{shouldRenderPublicEmail ? (
<p className="text-[12px] text-text-muted leading-none break-all">
{user.email}
</p>
) : null}
</div>
<div className="space-y-0.5">

View File

@ -0,0 +1,340 @@
import Image from "next/image";
import Link from "next/link";
import {
AppWindow,
ArrowRight,
Bot,
Boxes,
CheckCircle2,
Cloud,
Code2,
FolderArchive,
GitBranch,
Layers3,
Plug,
PlayCircle,
ServerCog,
ShieldCheck,
Sparkles,
TerminalSquare,
} from "lucide-react";
type SuiteProduct = {
name: string;
repo: string;
href: string;
role: string;
description: string;
icon: typeof AppWindow;
};
const suiteProducts: SuiteProduct[] = [
{
name: "xworkmate-app",
repo: "x-evor/xworkmate-app",
href: "https://github.com/x-evor/xworkmate-app.git",
role: "桌面工作台",
description:
"承载任务线程、执行模式、工作区文件和产物预览,让用户在 macOS 客户端内完成 AI Agent 工作流。",
icon: AppWindow,
},
{
name: "xworkmate-bridge",
repo: "x-evor/xworkmate-bridge",
href: "https://github.com/x-evor/xworkmate-bridge",
role: "网关与运行时连接",
description:
"连接你的桌面工作区与远程 OpenClaw 运行环境,让长任务可以继续跑、可恢复、可回到同一条线程。",
icon: ServerCog,
},
{
name: "openclaw-multi-session-plugins",
repo: "x-evor/openclaw-multi-session-plugins",
href: "https://github.com/x-evor/openclaw-multi-session-plugins",
role: "多会话执行插件",
description:
"把 OpenClaw 扩展成可并行处理任务的执行环境,支撑多线程、归档任务和产物落盘。",
icon: Plug,
},
{
name: "xworkspace-core-skills",
repo: "x-evor/xworkspace-core-skills",
href: "https://github.com/x-evor/xworkspace-core-skills",
role: "核心技能包",
description:
"沉淀常用写作、编程、图片、视频和工作区处理方式,让个人任务从聊天走向稳定产物。",
icon: Boxes,
},
];
const outcomes = [
"把 App Store 产品页里的能力描述落到可感知的网站入口",
"把桌面端、网关、插件、技能包讲成一个人的 AI 工作台",
"突出任务不断线、文件有归档、产物能预览的日常使用价值",
];
const deliverySteps = [
{
title: "1. Desktop workspace",
body: "用户从 XWorkmate App 发起任务,保留上下文、线程、归档和产物目录。",
icon: TerminalSquare,
},
{
title: "2. Bridge contract",
body: "Bridge 负责身份、端点、版本和工作区转发,不把运行时状态散落到本地配置。",
icon: Cloud,
},
{
title: "3. OpenClaw sessions",
body: "多会话插件把任务拆到可恢复的执行线程,支持代码、文档、图片和视频产物。",
icon: GitBranch,
},
{
title: "4. Workspace skills",
body: "核心技能包把常见任务变成可复用工作方式,让写作、代码、图片和视频制作更稳定。",
icon: ShieldCheck,
},
];
export default function XWorkmateSuiteMarketing() {
return (
<main className="relative overflow-hidden bg-background text-text">
<section className="mx-auto grid w-full max-w-7xl gap-8 px-4 pb-10 pt-6 sm:px-6 lg:grid-cols-[0.94fr_1.06fr] lg:px-8 lg:pb-14 lg:pt-10">
<div className="flex flex-col justify-center">
<div className="inline-flex w-fit items-center gap-2 rounded-[8px] border border-surface-border bg-white/88 px-3 py-1.5 text-xs font-semibold text-text-muted shadow-sm">
<Sparkles className="h-3.5 w-3.5 text-primary" aria-hidden="true" />
XWorkmate Suite
</div>
<h1 className="mt-5 max-w-3xl text-[2.7rem] font-semibold leading-[0.98] tracking-normal text-slate-950 sm:text-[4.2rem]">
XWorkmate
</h1>
<p className="mt-5 max-w-2xl text-base leading-7 text-text-muted sm:text-lg">
AI
</p>
<div className="mt-7 flex flex-col gap-3 sm:flex-row">
<Link
href="/download"
className="tactile-button tactile-button-primary h-11 px-5"
>
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href="https://github.com/x-evor/xworkmate-app.git"
className="tactile-button tactile-button-soft h-11 px-5"
>
</Link>
</div>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{outcomes.map((item) => (
<div
key={item}
className="rounded-[8px] border border-surface-border bg-white/86 p-3 text-sm leading-5 text-text-muted shadow-sm"
>
<CheckCircle2
className="mb-2 h-4 w-4 text-primary"
aria-hidden="true"
/>
{item}
</div>
))}
</div>
</div>
<div className="relative">
<div className="overflow-hidden rounded-[16px] border border-surface-border bg-white shadow-[var(--shadow-lg)]">
<Image
src="/marketing/xworkmate-suite-hero.png"
alt="XWorkmate 产品矩阵营销页面视觉稿"
width={1536}
height={864}
priority
className="h-full w-full object-cover"
/>
</div>
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-4 pb-8 pt-16 sm:px-6 sm:pt-20 lg:px-8 lg:pt-24">
<div className="mb-5 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase text-text-subtle">
Product matrix
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-normal text-slate-950">
AI
</h2>
</div>
<p className="max-w-xl text-sm leading-6 text-text-muted">
线
AI
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{suiteProducts.map(
({ name, repo, href, role, description, icon: Icon }) => (
<article
key={name}
className="flex min-h-[270px] flex-col rounded-[8px] border border-surface-border bg-white/92 p-5 shadow-[var(--shadow-soft)]"
>
<div className="flex items-center justify-between gap-3">
<span className="rounded-[8px] border border-primary-border bg-primary-muted p-2 text-primary">
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<span className="rounded-[8px] bg-background-muted px-2.5 py-1 text-xs font-semibold text-text-muted">
{role}
</span>
</div>
<h3 className="mt-5 text-lg font-semibold tracking-normal text-slate-950">
{name}
</h3>
<p className="mt-3 flex-1 text-sm leading-6 text-text-muted">
{description}
</p>
<Link
href={href}
className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-hover"
>
{repo}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</article>
),
)}
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="grid gap-5 rounded-[16px] border border-surface-border bg-white/92 p-5 shadow-[var(--shadow-md)] lg:grid-cols-[1.05fr_0.95fr] lg:p-7">
<div className="flex min-h-[320px] flex-col justify-between rounded-[12px] border border-surface-border bg-slate-950 p-5 text-white">
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2 rounded-[8px] bg-white/10 px-3 py-1.5 text-xs font-semibold text-white/78">
<PlayCircle className="h-4 w-4" aria-hidden="true" />
Demo video
</span>
<span className="rounded-[8px] border border-white/10 px-2.5 py-1 text-xs text-white/56">
Coming soon
</span>
</div>
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full border border-white/15 bg-white/10">
<PlayCircle className="h-9 w-9" aria-hidden="true" />
</div>
<div>
<h2 className="text-2xl font-semibold tracking-normal text-white">
Demo
</h2>
<p className="mt-3 max-w-xl text-sm leading-6 text-white/68">
XWorkmate Bridge OpenClaw
</p>
</div>
</div>
<div className="flex flex-col justify-center">
<p className="text-xs font-semibold uppercase text-text-subtle">
What to show
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-normal text-slate-950">
</h2>
<div className="mt-5 grid gap-3">
{[
"一个任务线程持续推进:补充上下文、恢复执行、继续产出。",
"产物面板展示文件、图片、视频和文档,而不是只停留在聊天气泡。",
"本地桌面体验和远程运行环境自然衔接,长任务不用守在窗口前。",
].map((item) => (
<div
key={item}
className="flex gap-3 rounded-[8px] border border-surface-border bg-background/80 p-3 text-sm leading-6 text-text-muted"
>
<CheckCircle2
className="mt-0.5 h-4 w-4 shrink-0 text-primary"
aria-hidden="true"
/>
{item}
</div>
))}
</div>
</div>
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="rounded-[16px] border border-surface-border bg-white/92 p-5 shadow-[var(--shadow-md)] lg:p-7">
<div className="grid gap-6 lg:grid-cols-[0.85fr_1.15fr]">
<div>
<div className="inline-flex items-center gap-2 rounded-[8px] border border-surface-border bg-background-muted px-3 py-1.5 text-xs font-semibold text-text-muted">
<Layers3 className="h-4 w-4" aria-hidden="true" />
Delivery architecture
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-normal text-slate-950">
</h2>
<p className="mt-3 text-sm leading-6 text-text-muted">
使线Bridge
OpenClaw Core Skills
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{deliverySteps.map(({ title, body, icon: Icon }) => (
<article
key={title}
className="rounded-[8px] border border-surface-border bg-background/80 p-4"
>
<Icon className="h-5 w-5 text-primary" aria-hidden="true" />
<h3 className="mt-3 text-base font-semibold tracking-normal text-slate-950">
{title}
</h3>
<p className="mt-2 text-sm leading-6 text-text-muted">
{body}
</p>
</article>
))}
</div>
</div>
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-4 pb-14 pt-8 sm:px-6 lg:px-8">
<div className="grid gap-4 lg:grid-cols-[1fr_1fr_1fr]">
<div className="rounded-[8px] border border-surface-border bg-white/92 p-5 shadow-sm">
<Bot className="h-5 w-5 text-primary" aria-hidden="true" />
<h2 className="mt-3 text-lg font-semibold tracking-normal text-slate-950">
</h2>
<p className="mt-2 text-sm leading-6 text-text-muted">
线 AI
使
</p>
</div>
<div className="rounded-[8px] border border-surface-border bg-white/92 p-5 shadow-sm">
<Code2 className="h-5 w-5 text-primary" aria-hidden="true" />
<h2 className="mt-3 text-lg font-semibold tracking-normal text-slate-950">
</h2>
<p className="mt-2 text-sm leading-6 text-text-muted">
</p>
</div>
<div className="rounded-[8px] border border-surface-border bg-white/92 p-5 shadow-sm">
<FolderArchive
className="h-5 w-5 text-primary"
aria-hidden="true"
/>
<h2 className="mt-3 text-lg font-semibold tracking-normal text-slate-950">
</h2>
<p className="mt-2 text-sm leading-6 text-text-muted">
</p>
</div>
</div>
</section>
</main>
);
}

View File

@ -93,7 +93,6 @@ type PersistedPairingRequiredLookup = {
const PAIRING_REQUIRED_SESSION_STORAGE_KEY = "openclaw:pairing-required-state";
const PAIRING_REQUIRED_STATE_TTL_MS = 1000 * 60 * 60 * 12;
const PAIRING_REQUIRED_GUEST_TTL_MS = 1000 * 60 * 60;
export type OpenClawAssistantViewState = {
connectionState: ConnectionState;
@ -436,7 +435,6 @@ export function OpenClawAssistantPane({
const [errorMessage, setErrorMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const [isCapturing, setIsCapturing] = useState(false);
const [guestSessionExpired, setGuestSessionExpired] = useState(false);
const defaultsLoaded = useOpenClawConsoleStore(
(state) => state.defaultsLoaded,
@ -482,9 +480,7 @@ export function OpenClawAssistantPane({
const minimalPage = variant === "page";
const pairingPersistenceUserId =
sessionUser?.uuid?.trim() || sessionUser?.id?.trim() || "anonymous";
const pairingPersistenceTtlMs = sessionUser?.isGuest
? PAIRING_REQUIRED_GUEST_TTL_MS
: PAIRING_REQUIRED_STATE_TTL_MS;
const pairingPersistenceTtlMs = PAIRING_REQUIRED_STATE_TTL_MS;
const pairingPersistenceScope = buildPairingPersistenceScope({
openclawUrl,
openclawOrigin,
@ -572,13 +568,6 @@ export function OpenClawAssistantPane({
"当前没有可用的 OpenClaw 地址。先到融合设置填写 gateway / vault / APISIX再回来启动 XWorkmate。",
"No OpenClaw endpoint is available yet. Configure gateway, vault, and APISIX first, then return to XWorkmate.",
),
guestSessionExpired: pickCopy(
isChinese,
"演示模式已超过 1 小时。请注册或登录后继续使用助手。",
"Demo mode has exceeded 1 hour. Register or sign in to continue using the assistant.",
),
login: pickCopy(isChinese, "登录", "Sign in"),
register: pickCopy(isChinese, "注册", "Register"),
openIntegrations: pickCopy(
isChinese,
"打开接口集成",
@ -724,7 +713,6 @@ export function OpenClawAssistantPane({
return;
}
lastPairingRequiredSignatureRef.current = signature;
setGuestSessionExpired(false);
persistPairingRequiredState({
signature,
errorMessage: formattedMessage,
@ -1059,23 +1047,14 @@ export function OpenClawAssistantPane({
if (persisted.state) {
lastPairingRequiredSignatureRef.current = persisted.state.signature;
lastConnectPairingSignatureRef.current = persisted.state.signature;
setGuestSessionExpired(false);
setConnectionState("error");
setErrorMessage(persisted.state.errorMessage);
return;
}
if (sessionUser?.isGuest && persisted.expired) {
lastPairingRequiredSignatureRef.current = null;
lastConnectPairingSignatureRef.current = "guest-session-expired";
setGuestSessionExpired(true);
setConnectionState("error");
setErrorMessage(copy.guestSessionExpired);
}
}, [copy.guestSessionExpired, pairingPersistenceScope, sessionUser?.isGuest]);
}, [pairingPersistenceScope]);
useEffect(() => {
if (!defaultsLoaded || bootstrappedRef.current || guestSessionExpired) {
if (!defaultsLoaded || bootstrappedRef.current) {
return;
}
@ -1088,7 +1067,6 @@ export function OpenClawAssistantPane({
}, [
connectGateway,
defaultsLoaded,
guestSessionExpired,
initialSessionKey,
openclawUrl,
]);
@ -1096,7 +1074,6 @@ export function OpenClawAssistantPane({
useEffect(() => {
lastConnectPairingSignatureRef.current = null;
lastPairingRequiredSignatureRef.current = null;
setGuestSessionExpired(false);
}, [
openclawOrigin,
openclawToken,
@ -1553,24 +1530,6 @@ export function OpenClawAssistantPane({
<div className="whitespace-pre-wrap rounded-[var(--radius-lg)] border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/40 px-3 py-2 text-sm text-[var(--color-danger-foreground)]">
{errorMessage}
</div>
{guestSessionExpired ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => router.push("/login")}
className="tactile-button tactile-button-primary px-3 text-xs"
>
{copy.login}
</button>
<button
type="button"
onClick={() => router.push("/register")}
className="tactile-button tactile-button-soft px-3 text-xs text-[var(--color-text)]"
>
{copy.register}
</button>
</div>
) : null}
</div>
) : null}

View File

@ -1,120 +1,63 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage";
const pushMock = vi.fn();
const assistantPaneMock = vi.fn();
const mockStore = {
setScope: vi.fn(),
applyDefaults: vi.fn(),
setSelectedSessionKey: vi.fn(),
selectedSessionKey: "",
openclawUrl: "",
vaultUrl: "",
apisixUrl: "",
};
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
vi.mock("@/i18n/LanguageProvider", () => ({
useLanguage: () => ({
language: "zh",
}),
}));
vi.mock("@/state/openclawConsoleStore", () => ({
useOpenClawConsoleStore: (selector: (state: typeof mockStore) => unknown) =>
selector(mockStore),
}));
vi.mock("@/components/openclaw/OpenClawAssistantPane", () => ({
OpenClawAssistantPane: (props: { integrationsHref?: string }) => {
assistantPaneMock(props);
return (
<div data-testid="assistant-pane">
assistant-pane:{props.integrationsHref ?? "missing"}
</div>
);
},
}));
const emptyDefaults: IntegrationDefaults = {
openclawUrl: "",
openclawOrigin: "",
openclawTokenConfigured: false,
vaultUrl: "",
vaultNamespace: "",
vaultTokenConfigured: false,
vaultSecretPath: "",
vaultSecretKey: "",
apisixUrl: "",
apisixTokenConfigured: false,
};
describe("XWorkmateWorkspacePage", () => {
beforeEach(() => {
pushMock.mockReset();
assistantPaneMock.mockReset();
mockStore.setScope.mockReset();
mockStore.applyDefaults.mockReset();
mockStore.setSelectedSessionKey.mockReset();
mockStore.selectedSessionKey = "";
mockStore.openclawUrl = "";
mockStore.vaultUrl = "";
mockStore.apisixUrl = "";
});
vi.restoreAllMocks();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("action=ping")) {
return Response.json({
status: "ok",
version: "test-version",
});
}
it("renders the desktop-style AI Gateway empty state and routes to xworkmate integrations", () => {
render(
<XWorkmateWorkspacePage
defaults={emptyDefaults}
profile={null}
scopeKey="test-scope"
/>,
);
expect(screen.getByText("先配置 AI Gateway")).toBeInTheDocument();
expect(
screen.getByText(
/请先在 Settings -> AI Gateway 中配置地址、API Key 和默认模型/,
),
).toBeInTheDocument();
fireEvent.click(
screen.getAllByRole("button", { name: "配置 AI Gateway" })[0],
);
expect(pushMock).toHaveBeenCalledWith("/xworkmate/integrations");
});
it("renders the assistant pane when a gateway target is available", () => {
const connectedDefaults: IntegrationDefaults = {
...emptyDefaults,
openclawUrl: "wss://gateway.example.com",
openclawTokenConfigured: true,
};
mockStore.openclawUrl = "wss://gateway.example.com";
render(
<XWorkmateWorkspacePage
defaults={connectedDefaults}
profile={null}
scopeKey="test-scope"
/>,
);
expect(screen.getByTestId("assistant-pane")).toBeInTheDocument();
expect(assistantPaneMock).toHaveBeenCalledWith(
expect.objectContaining({
integrationsHref: "/xworkmate/integrations",
return Response.json({
jsonrpc: "2.0",
id: "test",
result: {
success: true,
output: "bridge task ok",
artifacts: [{ name: "result.pdf" }],
remoteWorkingDirectory: "/tmp/xworkmate",
},
});
}),
);
});
it("renders the bridge workspace shell from the screenshot flow", async () => {
render(<XWorkmateWorkspacePage />);
expect(screen.getByText("XWorkmate")).toBeInTheDocument();
expect(screen.getByPlaceholderText("搜索任务")).toBeInTheDocument();
expect(screen.getByText("开始对话或运行任务")).toBeInTheDocument();
expect(
screen.getByText("已连接 · xworkmate-bridge.svc.plus"),
).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/Bridge: connected/)).toBeInTheDocument();
});
});
it("submits a prompt through the bridge proxy and renders the result", async () => {
render(<XWorkmateWorkspacePage />);
fireEvent.change(screen.getByPlaceholderText(/输入需求/), {
target: { value: "请只回复 ok" },
});
fireEvent.click(screen.getByRole("button", { name: "提交" }));
await waitFor(() => {
expect(screen.getAllByText("bridge task ok").length).toBeGreaterThan(0);
expect(screen.getByText("/tmp/xworkmate")).toBeInTheDocument();
expect(screen.getByText("result.pdf")).toBeInTheDocument();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,100 +1,17 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import { useUserStore } from "@/lib/userStore";
import { normalizeXWorkmateHost } from "@/lib/xworkmate/host";
import {
buildXWorkmateScopeKey,
toXWorkmateIntegrationDefaults,
type XWorkmateProfileResponse,
} from "@/lib/xworkmate/types";
import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage";
type XWorkmateWorkspaceRouteProps = {
defaults: IntegrationDefaults;
};
async function fetchProfile(): Promise<XWorkmateProfileResponse | null> {
const response = await fetch("/api/xworkmate/profile", {
credentials: "include",
cache: "no-store",
headers: {
Accept: "application/json",
},
});
if (response.status === 401) {
return null;
}
if (!response.ok) {
throw new Error(`xworkmate_profile_failed:${response.status}`);
}
return (await response.json()) as XWorkmateProfileResponse;
}
export function XWorkmateWorkspaceRoute({
defaults,
}: XWorkmateWorkspaceRouteProps): React.ReactNode {
export function XWorkmateWorkspaceRoute(): ReactNode {
const searchParams = useSearchParams();
const sessionUser = useUserStore((state) => state.user);
const [profile, setProfile] = useState<XWorkmateProfileResponse | null>(null);
const requestHost = useMemo(() => {
if (typeof window === "undefined") {
return "";
}
return normalizeXWorkmateHost(window.location.host);
}, []);
useEffect(() => {
let cancelled = false;
async function loadProfile() {
try {
const nextProfile = await fetchProfile();
if (!cancelled) {
setProfile(nextProfile);
}
} catch (error) {
console.error("Failed to load xworkmate profile", error);
if (!cancelled) {
setProfile(null);
}
}
}
void loadProfile();
return () => {
cancelled = true;
};
}, []);
const resolvedDefaults = profile
? toXWorkmateIntegrationDefaults(profile)
: defaults;
const scopeKey = buildXWorkmateScopeKey(
profile,
sessionUser?.id ?? sessionUser?.uuid ?? null,
requestHost,
);
const initialPrompt = searchParams.get("prompt") ?? "";
const initialSessionKey = searchParams.get("sessionKey") ?? "";
return (
<XWorkmateWorkspacePage
defaults={resolvedDefaults}
profile={profile}
initialPrompt={initialPrompt}
initialSessionKey={initialSessionKey}
requestHost={requestHost}
scopeKey={scopeKey}
initialPrompt={searchParams.get("prompt") ?? ""}
initialSessionKey={searchParams.get("sessionKey") ?? ""}
/>
);
}

View File

@ -1,6 +1,6 @@
# Base runtime configuration shared by all environments.
apiBaseUrl: https://rag-server-svc-plus-266500572462.asia-northeast1.run.app
authUrl: https://accounts.svc.plus
dashboardUrl: https://console.svc.plus
dashboardUrl: https://www.svc.plus
docsServiceUrl: https://docs.svc.plus
logLevel: info

View File

@ -4,10 +4,10 @@ regions:
cn:
apiBaseUrl: https://rag-server-svc-plus-266500572462.asia-northeast1.run.app
authUrl: https://accounts.svc.plus
dashboardUrl: https://console.svc.plus
dashboardUrl: https://www.svc.plus
docsServiceUrl: https://docs.svc.plus
global:
apiBaseUrl: https://rag-server-svc-plus-266500572462.asia-northeast1.run.app
authUrl: https://accounts.svc.plus
dashboardUrl: https://console.svc.plus
dashboardUrl: https://www.svc.plus
docsServiceUrl: https://docs.svc.plus

View File

@ -544,7 +544,6 @@ export type Translation = {
title: string
register: string
login: string
demo: string
welcome: string
logout: string
userCenter: string
@ -668,7 +667,6 @@ export const translations: Record<'en' | 'zh', Translation> = {
title: 'Account',
register: 'Register',
login: 'Login',
demo: 'Guest user演示模式',
welcome: 'Welcome, {username}',
logout: 'Sign out',
userCenter: 'User Center',
@ -711,7 +709,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
userNotFound: 'We could not find an account with that username.',
genericError: 'We could not sign you in. Please try again later.',
serviceUnavailable: 'The account service is temporarily unavailable. Please try again shortly.',
disclaimer: 'This Guest user演示模式 login keeps your username in memory only to personalize navigation while you browse.',
disclaimer: 'Your session stays on this device only and is used solely to keep the console signed in while you browse.',
},
termsTitle: 'Terms of Service',
termsPoints: [
@ -1482,7 +1480,6 @@ export const translations: Record<'en' | 'zh', Translation> = {
title: '账户',
register: '注册',
login: '登录',
demo: 'Guest user演示模式',
welcome: '欢迎,{username}',
logout: '退出登录',
userCenter: '用户中心',
@ -1525,7 +1522,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
userNotFound: '未找到该用户名对应的账户。',
genericError: '登录失败,请稍后再试。',
serviceUnavailable: '账户服务暂时不可用,请稍后再试。',
disclaimer: '此 Guest user演示模式 登录仅会在浏览期间保留用户名,以便展示个性化的导航体验。',
disclaimer: '登录态仅保存在当前设备,用于在浏览期间保持控制台会话。',
},
termsTitle: '服务条款',
termsPoints: [

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { resolveAccess } from "@lib/accessControl";
describe("accessControl", () => {
it("blocks unauthenticated access when login is required", () => {
expect(
resolveAccess(null, {
requireLogin: true,
}),
).toMatchObject({
allowed: false,
reason: "unauthenticated",
});
});
it("allows anonymous access only when guests are explicitly allowed", () => {
expect(
resolveAccess(null, {
allowGuests: true,
}),
).toMatchObject({
allowed: true,
});
});
});

View File

@ -8,7 +8,7 @@ type AccessReason = "unauthenticated" | "forbidden";
export type AccessDecision = {
allowed: boolean;
reason?: AccessReason;
userRole: UserRole;
userRole: UserRole | null;
userTenants?: TenantMembership[];
tenantId?: string;
};
@ -20,7 +20,7 @@ export type AccessRule = {
permissions?: string[];
};
const EVERYONE_ROLES: UserRole[] = ["guest", "user", "operator", "admin"];
const KNOWN_ROLES: UserRole[] = ["user", "operator", "admin"];
function normalizeRoles(roles?: UserRole[]): UserRole[] | undefined {
if (!roles || roles.length === 0) {
@ -28,7 +28,7 @@ function normalizeRoles(roles?: UserRole[]): UserRole[] | undefined {
}
const known = new Set<UserRole>();
for (const role of roles) {
if (EVERYONE_ROLES.includes(role)) {
if (KNOWN_ROLES.includes(role)) {
known.add(role);
}
}
@ -59,25 +59,27 @@ export function resolveAccess(
normalizedRule.permissions,
);
const role: UserRole = user?.role ?? "guest";
const isAuthenticated = Boolean(user);
const allowGuests =
normalizedRule.allowGuests ??
(!normalizedRoles || normalizedRoles.includes("guest"));
const requiresLogin =
normalizedRule.requireLogin ??
(!allowGuests ||
Boolean(normalizedPermissions && normalizedPermissions.length > 0) ||
Boolean(normalizedRoles && !normalizedRoles.includes("guest")));
(!normalizedRule.requireLogin &&
!normalizedRoles &&
!normalizedPermissions);
const requiresLogin = Boolean(normalizedRule.requireLogin);
if (!isAuthenticated && requiresLogin) {
if (allowGuests) {
// Guests explicitly allowed to pass through.
} else {
return { allowed: false, reason: "unauthenticated", userRole: role };
if (!user) {
if (
requiresLogin ||
!allowGuests ||
Boolean(normalizedRoles?.length) ||
Boolean(normalizedPermissions?.length)
) {
return { allowed: false, reason: "unauthenticated", userRole: null };
}
return { allowed: true, userRole: null };
}
const role: UserRole = user.role;
const userPermissions = new Set(user?.permissions ?? []);
const roleAllowed = normalizedRoles
? normalizedRoles.includes(role)
@ -96,22 +98,16 @@ export function resolveAccess(
normalizedPermissions.length > 0
) {
if (!roleAllowed && !permissionAllowed) {
if (!isAuthenticated && allowGuests) {
return { allowed: false, reason: "unauthenticated", userRole: role };
}
return {
allowed: false,
reason: isAuthenticated ? "forbidden" : "unauthenticated",
reason: "forbidden",
userRole: role,
};
}
} else if (normalizedRoles && !roleAllowed) {
if (!isAuthenticated && allowGuests) {
return { allowed: false, reason: "unauthenticated", userRole: role };
}
return {
allowed: false,
reason: isAuthenticated ? "forbidden" : "unauthenticated",
reason: "forbidden",
userRole: role,
};
}
@ -129,7 +125,7 @@ export function resolveAccess(
if (missing) {
return {
allowed: false,
reason: isAuthenticated ? "forbidden" : "unauthenticated",
reason: "forbidden",
userRole: role,
};
}

52
src/lib/apiProxy.test.ts Normal file
View File

@ -0,0 +1,52 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NextRequest } from 'next/server'
import { proxyRequestToUpstream } from './apiProxy'
describe('proxyRequestToUpstream', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('strips stale encoding headers when the upstream body is already decoded', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([{ name: 'JP', address: 'jp-xhttp.svc.plus' }]), {
status: 200,
headers: {
'Content-Encoding': 'gzip',
'Content-Length': '405',
'Content-Type': 'application/json; charset=utf-8',
},
}),
)
vi.stubGlobal('fetch', fetchMock)
const response = await proxyRequestToUpstream(
new NextRequest('https://console.svc.plus/api/agent/nodes', {
headers: {
host: 'console.svc.plus',
},
}),
{
upstreamBaseUrl: 'https://accounts.svc.plus',
upstreamPathPrefix: '/api/agent',
},
)
expect(fetchMock).toHaveBeenCalledWith(
'https://accounts.svc.plus/api/agent/nodes',
expect.objectContaining({
cache: 'no-store',
method: 'GET',
redirect: 'manual',
}),
)
expect(response.status).toBe(200)
expect(response.headers.get('content-encoding')).toBeNull()
expect(response.headers.get('content-length')).toBeNull()
expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8')
await expect(response.json()).resolves.toEqual([{ name: 'JP', address: 'jp-xhttp.svc.plus' }])
})
})

View File

@ -14,6 +14,14 @@ const DEFAULT_FORWARD_HEADERS = [
'x-trace-id',
] as const
const RESPONSE_HEADERS_TO_STRIP = new Set([
'connection',
'content-encoding',
'content-length',
'keep-alive',
'transfer-encoding',
])
const BODYLESS_METHODS = new Set(['GET', 'HEAD'])
type ProxyOptions = {
@ -107,7 +115,8 @@ export async function proxyRequestToUpstream(request: NextRequest, options: Prox
const responseHeaders = new Headers()
upstreamResponse.headers.forEach((value, key) => {
if (key.toLowerCase() === 'set-cookie') {
const normalizedKey = key.toLowerCase()
if (normalizedKey === 'set-cookie' || RESPONSE_HEADERS_TO_STRIP.has(normalizedKey)) {
return
}
responseHeaders.set(key, value)

View File

@ -19,6 +19,7 @@ import {
Info,
CreditCard,
LifeBuoy,
PackageOpen,
} from "lucide-react";
export type ReleaseChannel = "stable" | "beta" | "develop";
@ -79,6 +80,14 @@ export const createNavConfig = (
active: (pathname) => pathname?.startsWith("/xworkmate"),
showOn: "both",
},
{
key: "xworkmateSuite",
label: isChinese ? "产品矩阵" : "Suite",
href: "/xworkmate-suite",
icon: PackageOpen,
active: (pathname) => pathname?.startsWith("/xworkmate-suite"),
showOn: "both",
},
{
key: "docs",
label: isChinese ? "文档" : "Docs",

View File

@ -90,6 +90,7 @@ export type IntegrationDefaults = {
vaultSecretKey: string
apisixUrl: string
apisixTokenConfigured: boolean
externalServices?: string[]
}
export function normalizeMainSessionKey(value?: string | null): string {

View File

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
hasPublicUserEmail,
resolvePublicUserEmail,
} from "@lib/publicUserIdentity";
describe("publicUserIdentity", () => {
it("returns the public email value when present", () => {
expect(
resolvePublicUserEmail({
email: "admin@svc.plus",
role: "admin",
}),
).toBe("admin@svc.plus");
});
it("normalizes empty public emails", () => {
expect(
resolvePublicUserEmail({
email: " ",
}),
).toBe("");
});
it("detects whether a public email should be rendered", () => {
expect(hasPublicUserEmail({ email: "" })).toBe(false);
expect(
hasPublicUserEmail({
email: "admin@svc.plus",
role: "admin",
}),
).toBe(true);
});
});

View File

@ -0,0 +1,23 @@
function normalizeText(value?: string | null): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizeRole(value?: string | null): string {
return normalizeText(value).toLowerCase();
}
export function resolvePublicUserEmail(input: {
email?: string | null;
role?: string | null;
}): string {
void normalizeRole(input.role);
return normalizeText(input.email);
}
export function hasPublicUserEmail(input: {
email?: string | null;
role?: string | null;
}): boolean {
void normalizeRole(input.role);
return normalizeText(input.email).length > 0;
}

View File

@ -1,8 +1,9 @@
'use client'
import { create } from 'zustand'
import { resolvePublicUserEmail } from '@lib/publicUserIdentity'
export type UserRole = 'guest' | 'user' | 'operator' | 'admin'
export type UserRole = 'user' | 'operator' | 'admin'
export type TenantMembership = {
id: string
@ -13,8 +14,6 @@ export type TenantMembership = {
type User = {
id: string
uuid: string
proxyUuid?: string
proxyUuidExpiresAt?: string
email: string
name?: string
username: string
@ -23,7 +22,6 @@ type User = {
role: UserRole
groups: string[]
permissions: string[]
isGuest: boolean
isUser: boolean
isOperator: boolean
isAdmin: boolean
@ -65,113 +63,17 @@ const KNOWN_ROLE_MAP: Record<string, UserRole> = {
member: 'user',
}
const GUEST_SESSION_STORAGE_KEY = 'xcontrol.guest.session'
const GUEST_SESSION_TTL_MS = 60 * 60 * 1000
const GUEST_SANDBOX_TENANT_ID = 'guest-sandbox'
const GUEST_SANDBOX_TENANT_NAME = 'Guest Sandbox'
type GuestSession = {
uuid: string
issuedAt: number
}
function normalizeRole(input?: string | null): UserRole {
if (!input || typeof input !== 'string') {
return 'guest'
return 'user'
}
const normalized = input.trim().toLowerCase()
if (!normalized) {
return 'guest'
return 'user'
}
return KNOWN_ROLE_MAP[normalized] ?? 'guest'
}
function createUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
function readGuestSession(): GuestSession | null {
if (typeof window === 'undefined') {
return null
}
const raw = window.sessionStorage.getItem(GUEST_SESSION_STORAGE_KEY)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as GuestSession
if (
typeof parsed?.uuid === 'string' &&
parsed.uuid.trim().length > 0 &&
typeof parsed?.issuedAt === 'number' &&
Number.isFinite(parsed.issuedAt)
) {
return {
uuid: parsed.uuid.trim(),
issuedAt: parsed.issuedAt,
}
}
} catch (error) {
console.warn('Failed to parse guest session payload', error)
}
return null
}
function writeGuestSession(session: GuestSession) {
if (typeof window === 'undefined') {
return
}
window.sessionStorage.setItem(GUEST_SESSION_STORAGE_KEY, JSON.stringify(session))
}
function resolveGuestUUID(now = Date.now()): string {
const existing = readGuestSession()
if (!existing || now - existing.issuedAt >= GUEST_SESSION_TTL_MS) {
const next: GuestSession = { uuid: createUUID(), issuedAt: now }
writeGuestSession(next)
return next.uuid
}
return existing.uuid
}
function buildGuestUser(): User {
const identifier = resolveGuestUUID()
return {
id: identifier,
uuid: identifier,
email: 'sandbox@svc.plus',
name: 'Guest user',
username: 'guest',
mfaEnabled: false,
mfaPending: false,
mfa: {
totpEnabled: false,
totpPending: false,
},
role: 'guest',
groups: ['guest', 'sandbox'],
permissions: ['read'],
isGuest: true,
isUser: false,
isOperator: false,
isAdmin: false,
isReadOnly: true,
tenantId: GUEST_SANDBOX_TENANT_ID,
tenants: [
{
id: GUEST_SANDBOX_TENANT_ID,
name: GUEST_SANDBOX_TENANT_NAME,
role: 'guest',
},
],
}
return KNOWN_ROLE_MAP[normalized] ?? 'user'
}
async function fetchSessionUser(): Promise<User | null> {
@ -185,7 +87,7 @@ async function fetchSessionUser(): Promise<User | null> {
})
if (!response.ok) {
return buildGuestUser()
return null
}
const payload = (await response.json()) as {
@ -200,8 +102,6 @@ async function fetchSessionUser(): Promise<User | null> {
role?: string
groups?: string[]
permissions?: string[]
proxyUuid?: string
proxyUuidExpiresAt?: string
readOnly?: boolean
tenantId?: string
tenants?: TenantMembership[]
@ -217,7 +117,7 @@ async function fetchSessionUser(): Promise<User | null> {
const sessionUser = payload?.user
if (!sessionUser) {
return buildGuestUser()
return null
}
const { id, uuid, email, name, username, mfaEnabled, mfa, mfaPending, role, groups, permissions } = sessionUser
@ -229,7 +129,7 @@ async function fetchSessionUser(): Promise<User | null> {
: ''
if (!identifier) {
return buildGuestUser()
return null
}
const normalizedName = typeof name === 'string' && name.trim().length > 0 ? name.trim() : undefined
const normalizedUsername =
@ -247,6 +147,10 @@ async function fetchSessionUser(): Promise<User | null> {
}
const normalizedRole = normalizeRole(role)
const publicEmail = resolvePublicUserEmail({
email,
role: normalizedRole,
})
const rawRole = typeof role === 'string' ? role.trim().toLowerCase() : ''
const normalizedGroups = Array.isArray(groups)
? groups
@ -258,21 +162,11 @@ async function fetchSessionUser(): Promise<User | null> {
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedEmail = typeof email === 'string' ? email.trim().toLowerCase() : ''
const inferredReadOnly =
rawRole === 'readonly' ||
rawRole === 'read_only' ||
normalizedEmail === 'sandbox@svc.plus' ||
normalizedGroups.some((value) => value.toLowerCase() === 'readonly role')
const normalizedReadOnly = Boolean(sessionUser.readOnly) || inferredReadOnly
const normalizedProxyUuid =
typeof sessionUser.proxyUuid === 'string' && sessionUser.proxyUuid.trim().length > 0
? sessionUser.proxyUuid.trim()
: undefined
const normalizedProxyUuidExpiresAt =
typeof sessionUser.proxyUuidExpiresAt === 'string' && sessionUser.proxyUuidExpiresAt.trim().length > 0
? sessionUser.proxyUuidExpiresAt.trim()
: undefined
const normalizedTenantId =
typeof sessionUser.tenantId === 'string' && sessionUser.tenantId.trim().length > 0
@ -309,43 +203,28 @@ async function fetchSessionUser(): Promise<User | null> {
.filter((tenant): tenant is TenantMembership => Boolean(tenant))
: undefined
const effectiveTenantId = normalizedRole === 'guest' ? GUEST_SANDBOX_TENANT_ID : normalizedTenantId
const effectiveTenants =
normalizedRole === 'guest'
? [
{
id: GUEST_SANDBOX_TENANT_ID,
name: GUEST_SANDBOX_TENANT_NAME,
role: 'guest' as UserRole,
},
]
: normalizedTenants
return {
id: identifier,
uuid: identifier,
proxyUuid: normalizedProxyUuid,
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
email,
email: publicEmail,
name: normalizedName,
username: normalizedUsername ?? email,
username: normalizedUsername ?? publicEmail,
mfaEnabled: Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfaPending: Boolean(mfaPending ?? mfa?.totpPending) && !Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfa: normalizedMfa,
role: normalizedRole,
groups: normalizedGroups,
permissions: normalizedPermissions,
isGuest: normalizedRole === 'guest',
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',
isReadOnly: normalizedRole === 'guest' ? true : normalizedReadOnly,
tenantId: effectiveTenantId,
tenants: effectiveTenants,
isReadOnly: normalizedReadOnly,
tenantId: normalizedTenantId,
tenants: normalizedTenants,
}
} catch (error) {
console.warn('Failed to resolve user session', error)
return buildGuestUser()
return null
}
}

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { resolveWebReleaseMetadata } from "@lib/webReleaseMetadata";
describe("webReleaseMetadata", () => {
it("returns trimmed public release metadata", () => {
expect(
resolveWebReleaseMetadata({
NEXT_PUBLIC_RELEASE_IMAGE: " ghcr.io/x-evor/console:abc123 ",
NEXT_PUBLIC_RELEASE_TAG: " abc123 ",
NEXT_PUBLIC_RELEASE_COMMIT: " abc123 ",
NEXT_PUBLIC_RELEASE_VERSION: " sha-abc123 ",
}),
).toEqual({
image: "ghcr.io/x-evor/console:abc123",
tag: "abc123",
commit: "abc123",
version: "sha-abc123",
});
});
it("normalizes empty public release metadata to null", () => {
expect(
resolveWebReleaseMetadata({
NEXT_PUBLIC_RELEASE_IMAGE: " ",
NEXT_PUBLIC_RELEASE_TAG: "",
NEXT_PUBLIC_RELEASE_COMMIT: undefined,
NEXT_PUBLIC_RELEASE_VERSION: " ",
}),
).toEqual({
image: null,
tag: null,
commit: null,
version: null,
});
});
});

View File

@ -0,0 +1,22 @@
export type TWebReleaseMetadata = {
image: string | null
tag: string | null
commit: string | null
version: string | null
}
type TReleaseMetadataEnv = Record<string, string | undefined>
function normalizeReleaseValue(value: string | undefined): string | null {
const normalizedValue = value?.trim()
return normalizedValue ? normalizedValue : null
}
export function resolveWebReleaseMetadata(env: TReleaseMetadataEnv = process.env): TWebReleaseMetadata {
return {
image: normalizeReleaseValue(env.NEXT_PUBLIC_RELEASE_IMAGE),
tag: normalizeReleaseValue(env.NEXT_PUBLIC_RELEASE_TAG),
commit: normalizeReleaseValue(env.NEXT_PUBLIC_RELEASE_COMMIT),
version: normalizeReleaseValue(env.NEXT_PUBLIC_RELEASE_VERSION),
}
}

57
src/middleware.test.ts Normal file
View File

@ -0,0 +1,57 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { middleware } from "../middleware";
describe("middleware public route policy", () => {
it("keeps homepage public", () => {
const response = middleware(new NextRequest("https://console.svc.plus/"));
expect(response).toBeUndefined();
});
it("keeps docs routes public", () => {
const response = middleware(
new NextRequest("https://console.svc.plus/docs/getting-started"),
);
expect(response).toBeUndefined();
});
it("keeps only the top-level services page public", () => {
const publicResponse = middleware(
new NextRequest("https://console.svc.plus/services"),
);
const protectedResponse = middleware(
new NextRequest("https://console.svc.plus/services/openclaw"),
);
expect(publicResponse).toBeUndefined();
expect(protectedResponse?.status).toBe(307);
expect(protectedResponse?.headers.get("location")).toContain(
"/login?redirect=%2Fservices%2Fopenclaw",
);
});
it("redirects protected pages to login when no session cookie exists", () => {
const response = middleware(
new NextRequest("https://console.svc.plus/panel?tab=agent"),
);
expect(response?.status).toBe(307);
expect(response?.headers.get("location")).toContain(
"/login?redirect=%2Fpanel%3Ftab%3Dagent",
);
});
it("allows protected pages when a session cookie exists", () => {
const request = new NextRequest("https://console.svc.plus/support");
request.cookies.set("xc_session", "token");
const response = middleware(request);
expect(response).toBeUndefined();
});
});

View File

@ -5,6 +5,7 @@ import useSWR from "swr";
import { openStripePortal } from "@components/billing/stripe-client";
import Card from "../components/Card";
import { fetchAccountBillingSummary, fetchAccountPolicy, fetchAccountUsageSummary } from "../lib/fetchAccountUsage";
const fetcher = (url: string) =>
fetch(url, {
@ -48,6 +49,9 @@ export default function SubscriptionPanel() {
"/api/auth/subscriptions",
fetcher,
);
const { data: usageSummary } = useSWR("account-usage-summary", fetchAccountUsageSummary);
const { data: billingSummary } = useSWR("account-billing-summary", fetchAccountBillingSummary);
const { data: accountPolicy } = useSWR("account-policy", fetchAccountPolicy);
const [submitting, setSubmitting] = useState<string | null>(null);
const [portalLoading, setPortalLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -124,6 +128,97 @@ export default function SubscriptionPanel() {
{error ? <p className="mt-3 text-sm text-red-600">{error}</p> : null}
{usageSummary ? (
<div className="mt-4 grid gap-3 md:grid-cols-3">
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
Authoritative Usage
</p>
<p className="mt-2 text-2xl font-semibold text-[var(--color-heading)]">
{usageSummary.totalBytes.toLocaleString()} B
</p>
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
accounts.svc.plus
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{usageSummary.sourceOfTruth || "—"}
</p>
</div>
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
Balance / Quota
</p>
<p className="mt-2 text-2xl font-semibold text-[var(--color-heading)]">
{typeof usageSummary.currentBalance === "number"
? usageSummary.currentBalance.toFixed(2)
: "—"}
</p>
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
{typeof usageSummary.remainingIncludedQuota === "number"
? `${usageSummary.remainingIncludedQuota.toLocaleString()} B`
: "—"}
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{usageSummary.billingProfile?.packageName || billingSummary?.billingProfile?.packageName || "default"}
{usageSummary.billingProfile?.pricingRuleVersion || billingSummary?.billingProfile?.pricingRuleVersion || "—"}
</p>
</div>
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
Policy / Sync
</p>
<p className="mt-2 text-base font-semibold text-[var(--color-heading)]">
{accountPolicy?.preferredStrategy || "—"}
</p>
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
{usageSummary.syncDelaySeconds ?? 0} {" "}
{accountPolicy?.eligibleNodeGroups?.join(", ") || "—"}
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{usageSummary.arrears ? "欠费" : "正常"} / {usageSummary.throttleState || "—"} / {usageSummary.suspendState || "—"}
</p>
</div>
</div>
) : null}
{billingSummary?.ledger?.length ? (
<div className="mt-4 rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-heading)]">Recent Billing Ledger</h3>
<p className="text-xs text-[var(--color-text-subtle)]">
accounts.svc.plus
</p>
</div>
<p className="text-xs text-[var(--color-text-subtle)]">
{billingSummary.sourceOfTruth || "—"}
</p>
</div>
<div className="mt-3 space-y-2">
{billingSummary.ledger.slice(0, 5).map((entry) => (
<div
key={entry.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[color:var(--color-surface-border)] px-3 py-2 text-sm"
>
<div>
<p className="font-medium text-[var(--color-text)]">{entry.entryType}</p>
<p className="text-xs text-[var(--color-text-subtle)]">
{entry.pricingRuleVersion || "—"} · {entry.bucketStart ? formatDate(entry.bucketStart) : "—"}
</p>
</div>
<div className="text-right">
<p className="font-medium text-[var(--color-text)]">{entry.ratedBytes.toLocaleString()} B</p>
<p className="text-xs text-[var(--color-text-subtle)]">
{typeof entry.amountDelta === "number" ? entry.amountDelta.toFixed(2) : "—"} / {" "}
{typeof entry.balanceAfter === "number" ? entry.balanceAfter.toFixed(2) : "—"}
</p>
</div>
</div>
))}
</div>
</div>
) : null}
{isLoading ? (
<p className="mt-4 text-sm text-[var(--color-text-subtle)]">

View File

@ -0,0 +1,90 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import SubscriptionPanel from '../SubscriptionPanel'
vi.mock('swr', () => ({
default: vi.fn((key: string) => {
if (key === '/api/auth/subscriptions') {
return {
data: { subscriptions: [] },
isLoading: false,
mutate: vi.fn(),
}
}
if (key === 'account-usage-summary') {
return {
data: {
totalBytes: 384,
currentBalance: 87.5,
remainingIncludedQuota: 2048,
syncDelaySeconds: 12,
arrears: false,
sourceOfTruth: 'postgresql',
billingProfile: {
packageName: 'starter',
pricingRuleVersion: 'pricing-v1',
},
},
}
}
if (key === 'account-billing-summary') {
return {
data: {
sourceOfTruth: 'postgresql',
billingProfile: {
packageName: 'starter',
pricingRuleVersion: 'pricing-v1',
},
ledger: [
{
id: 'ledger-1',
entryType: 'traffic_charge',
ratedBytes: 50,
amountDelta: -12.5,
balanceAfter: 75,
pricingRuleVersion: 'pricing-v1',
bucketStart: '2026-04-08T10:30:00Z',
},
],
},
}
}
if (key === 'account-policy') {
return {
data: {
preferredStrategy: 'ewma',
eligibleNodeGroups: ['hk-premium'],
},
}
}
return { data: undefined, isLoading: false, mutate: vi.fn() }
}),
}))
vi.mock('@components/billing/stripe-client', () => ({
openStripePortal: vi.fn(),
}))
vi.mock('../../lib/fetchAccountUsage', () => ({
fetchAccountUsageSummary: vi.fn(),
fetchAccountBillingSummary: vi.fn(),
fetchAccountPolicy: vi.fn(),
}))
describe('SubscriptionPanel', () => {
it('renders accounts-backed source-of-truth usage metadata', () => {
render(<SubscriptionPanel />)
expect(screen.getByText('Authoritative Usage')).toBeInTheDocument()
expect(screen.getByText('统计由 accounts.svc.plus 汇总,非本地客户端计数。')).toBeInTheDocument()
expect(screen.getAllByText('数据源postgresql')).toHaveLength(2)
expect(screen.getByText('384 B')).toBeInTheDocument()
expect(screen.getByText(/统计延迟约 12 秒/)).toBeInTheDocument()
expect(screen.getByText(/策略组 hk-premium/)).toBeInTheDocument()
expect(screen.getByText((content) => content.includes('套餐') && content.includes('starter') && content.includes('pricing-v1'))).toBeInTheDocument()
expect(screen.getByText('Recent Billing Ledger')).toBeInTheDocument()
expect(screen.getByText(/traffic_charge/)).toBeInTheDocument()
})
})

View File

@ -1,40 +1,18 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Copy } from 'lucide-react'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { hasPublicUserEmail } from '@lib/publicUserIdentity'
import { useUserStore } from '@lib/userStore'
import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding'
import Card from './Card'
import VlessQrCard from './VlessQrCard'
function resolveDisplayName(
user: {
name?: string
username: string
email: string
} | null,
) {
if (!user) {
return '访客'
}
if (user.name && user.name.trim().length > 0) {
return user.name.trim()
}
if (user.username && user.username.trim().length > 0) {
return user.username.trim()
}
return user.email
}
type UserOverviewProps = {
hideMfaMainPrompt?: boolean
}
@ -45,32 +23,18 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
const copy = translations[language].userCenter.overview
const mfaCopy = translations[language].userCenter.mfa
const user = useUserStore((state) => state.user)
const isLoading = useUserStore((state) => state.isLoading)
const refresh = useUserStore((state) => state.refresh)
const logout = useUserStore((state) => state.logout)
const [copied, setCopied] = useState(false)
const [sandboxBoundNodeAddress, setSandboxBoundNodeAddress] = useState<string | null>(null)
const shouldRenderPublicEmail = hasPublicUserEmail({
email: user?.email,
role: user?.role,
})
const displayName = useMemo(() => resolveDisplayName(user), [user])
const uuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? '—'
const vlessUuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? null
const uuid = user?.uuid ?? user?.id ?? '—'
const vlessUuid = user?.uuid ?? user?.id ?? null
const username = user?.username ?? '—'
const email = user?.email ?? '—'
const email = shouldRenderPublicEmail ? user?.email : '—'
const docsUrl = mfaCopy.actions.docsUrl
const normalizedEmail = user?.email?.toLowerCase() ?? ''
const isGuestSandboxReadOnly = Boolean(
user?.isReadOnly && (normalizedEmail === 'sandbox@svc.plus'),
)
const guestUuidExpiresAtText = useMemo(() => {
if (!isGuestSandboxReadOnly || !user?.proxyUuidExpiresAt) {
return null
}
const date = new Date(user.proxyUuidExpiresAt)
if (Number.isNaN(date.getTime())) {
return null
}
return date.toLocaleString()
}, [isGuestSandboxReadOnly, user?.proxyUuidExpiresAt])
const mfaStatusLabel = useMemo(() => {
if (user?.mfaEnabled) {
@ -86,7 +50,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
const shouldShowMfaMainPrompt = !hideMfaMainPrompt && !user?.isReadOnly
const handleCopy = useCallback(async () => {
const identifier = user?.proxyUuid ?? user?.uuid ?? user?.id
const identifier = user?.uuid ?? user?.id
if (!identifier) {
return
}
@ -110,7 +74,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
} catch (error) {
console.warn('Failed to copy UUID', error)
}
}, [user?.id, user?.proxyUuid, user?.uuid])
}, [user?.id, user?.uuid])
const handleGoToSetup = useCallback(() => {
router.push('/panel/account?setupMfa=1')
@ -122,66 +86,10 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
router.refresh()
}, [logout, router])
useEffect(() => {
if (!isGuestSandboxReadOnly) {
setSandboxBoundNodeAddress(null)
return
}
let cancelled = false
void (async () => {
const binding = await fetchSandboxNodeBinding()
if (cancelled) {
return
}
setSandboxBoundNodeAddress(binding?.address ?? null)
})()
return () => {
cancelled = true
}
}, [isGuestSandboxReadOnly])
useEffect(() => {
if (!isGuestSandboxReadOnly || !user?.proxyUuidExpiresAt) {
return
}
const expiresAt = new Date(user.proxyUuidExpiresAt).getTime()
if (!Number.isFinite(expiresAt)) {
return
}
const delay = Math.max(1000, expiresAt - Date.now() + 1500)
const timer = window.setTimeout(() => {
void refresh()
}, delay)
return () => {
window.clearTimeout(timer)
}
}, [isGuestSandboxReadOnly, refresh, user?.proxyUuidExpiresAt])
return (
<div className="space-y-6 text-[var(--color-text)] transition-colors">
<div>
<p className="text-sm text-[var(--color-text-subtle)] opacity-90">{copy.uuidNote}</p>
{isGuestSandboxReadOnly ? (
<p className="mt-2 rounded-[var(--radius-md)] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-2 text-xs text-[var(--color-warning-foreground)]">
{language === 'zh'
? (
<>
Guest user使 VLESS UUID 1 {guestUuidExpiresAtText ? `(下次刷新约 ${guestUuidExpiresAtText}` : ''}
<Link href="/register" className="ml-1 text-[var(--color-primary)] hover:underline">
访 &rarr;
</Link>
</>
)
: (
<>
Guest user (demo mode) runs in read-only mode: browse safely and use the VLESS QR code, but no configuration changes are allowed. UUID rotates every hour{guestUuidExpiresAtText ? ` (next refresh around ${guestUuidExpiresAtText})` : ''}.
<Link href="/register" className="ml-1 text-[var(--color-primary)] hover:underline">
Register for persistent access &rarr;
</Link>
</>
)}
</p>
) : null}
</div>
{shouldShowMfaMainPrompt && requiresSetup ? (
@ -239,8 +147,6 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
<VlessQrCard
uuid={vlessUuid}
copy={copy.cards.vless}
allowSandboxFallbackNode={isGuestSandboxReadOnly}
boundNodeAddress={sandboxBoundNodeAddress}
/>
<Card>

View File

@ -14,28 +14,7 @@ import {
VlessNode,
VlessTransport,
} from '../lib/vless'
async function fetcher(url: string): Promise<VlessNode[]> {
const res = await fetch(url, { credentials: 'include', cache: 'no-store' })
const payload = await res.json().catch(() => null)
if (!res.ok) {
const message =
(payload && typeof payload.message === 'string' && payload.message) ||
(payload && typeof payload.error === 'string' && payload.error) ||
`Request failed (${res.status})`
throw new Error(message)
}
if (Array.isArray(payload)) {
return payload as VlessNode[]
}
if (payload && Array.isArray((payload as { nodes?: unknown }).nodes)) {
return (payload as { nodes: VlessNode[] }).nodes
}
return []
}
import { fetchAgentNodes } from '../lib/fetchAgentNodes'
export type VlessQrCopy = {
label: string
@ -54,18 +33,39 @@ export type VlessQrCopy = {
interface VlessQrCardProps {
uuid: string | null | undefined
copy: VlessQrCopy
allowSandboxFallbackNode?: boolean
boundNodeAddress?: string | null
defaultTransport?: VlessTransport
visibleTransports?: VlessTransport[]
}
const DEFAULT_TRANSPORT: VlessTransport = 'xhttp'
const DEFAULT_VISIBLE_TRANSPORTS: VlessTransport[] = ['xhttp']
function normalizeVisibleTransports(transports?: VlessTransport[]): VlessTransport[] {
const visible = (transports ?? DEFAULT_VISIBLE_TRANSPORTS).filter(
(transport, index, values) => values.indexOf(transport) === index,
)
return visible.length > 0 ? visible : DEFAULT_VISIBLE_TRANSPORTS
}
function resolveInitialTransport(defaultTransport: VlessTransport | undefined, visibleTransports: VlessTransport[]): VlessTransport {
if (defaultTransport && visibleTransports.includes(defaultTransport)) {
return defaultTransport
}
return visibleTransports[0] ?? DEFAULT_TRANSPORT
}
export default function VlessQrCard({
uuid,
copy,
allowSandboxFallbackNode = false,
boundNodeAddress,
defaultTransport,
visibleTransports,
}: VlessQrCardProps) {
const { data: allNodes, error: nodesError } = useSWR<VlessNode[]>('/api/agent-server/v1/nodes', fetcher)
const { data: allNodes, error: nodesError } = useSWR<VlessNode[]>('user-center-agent-nodes', fetchAgentNodes)
const transportOptions = useMemo(() => normalizeVisibleTransports(visibleTransports), [visibleTransports])
const nodes = useMemo(() => {
return (allNodes ?? []).filter((node) => {
@ -73,18 +73,14 @@ export default function VlessQrCard({
const address = (node.address || '').trim()
if (!address || address === '*') return false
if (allowSandboxFallbackNode) {
// In sandbox mode, allow internal agents so the user can see their bound node
// even if it belongs to the shared token bucket.
return true
}
// Skip the redundant Internal Agents (Shared Token) node
return !(name.includes('internal agents') && name.includes('shared token'))
})
}, [allNodes, allowSandboxFallbackNode])
}, [allNodes])
const [selectedNode, setSelectedNode] = useState<VlessNode | null>(null)
const [preferredTransport, setPreferredTransport] = useState<VlessTransport>('tcp')
const [preferredTransport, setPreferredTransport] = useState<VlessTransport>(() =>
resolveInitialTransport(defaultTransport, transportOptions),
)
const [isSelectorOpen, setIsSelectorOpen] = useState(false)
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
@ -92,38 +88,24 @@ export default function VlessQrCard({
const [generationError, setGenerationError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
setPreferredTransport((currentTransport) => {
if (transportOptions.includes(currentTransport)) {
return currentTransport
}
return resolveInitialTransport(defaultTransport, transportOptions)
})
}, [defaultTransport, transportOptions])
const rawNode = useMemo(() => {
if (selectedNode) return selectedNode
// 1. Try to use the node bound by root management (search in all nodes, including filtered ones)
if (boundNodeAddress) {
const matched = (allNodes ?? []).find((node) => node.address === boundNodeAddress)
if (matched) {
return matched
}
// If we are in sandbox mode and API failed or node not found in list, create a synthetic fallback
if (allowSandboxFallbackNode) {
return {
name: 'Sandbox Node',
address: boundNodeAddress,
port: 443,
transport: 'tcp',
security: 'tls',
flow: 'xtls-rprx-vision',
// These templates are needed for URI generation if the API missed them
uri_scheme_tcp: 'vless://${UUID}@${DOMAIN}:${PORT}?encryption=none&flow=${FLOW}&security=tls&sni=${SNI}&fp=${FP}&type=tcp#${TAG}',
uri_scheme_xhttp: 'vless://${UUID}@${DOMAIN}:${PORT}?encryption=none&security=tls&sni=${SNI}&fp=${FP}&type=xhttp&mode=${MODE}&path=${PATH}#${TAG}',
} as VlessNode
}
}
// 2. Default to the first visible (non-filtered) node
// Default to the first visible (non-filtered) node.
if (nodes && nodes[0]) return nodes[0]
// 3. No fallback node
return undefined
}, [allNodes, allowSandboxFallbackNode, boundNodeAddress, nodes, selectedNode])
}, [nodes, selectedNode])
const effectiveNode = useMemo((): VlessNode | undefined => {
if (!rawNode) return undefined
@ -293,7 +275,7 @@ export default function VlessQrCard({
</div>
<div className="flex gap-2">
{(['tcp', 'xhttp'] as const).map((transport) => (
{transportOptions.map((transport) => (
<button
key={transport}
type="button"
@ -317,10 +299,7 @@ export default function VlessQrCard({
<div className="rounded-md border border-[color:var(--color-warning-border)] bg-[var(--color-warning-muted)] p-3 text-xs text-[var(--color-warning-foreground)]">
<p className="font-semibold"> </p>
<p className="mt-1">
{allowSandboxFallbackNode
? '演示模式账号未发现有效的节点映射。请确认后端已完成 Sandbox 节点绑定逻辑。'
: `无法从服务器获取代理节点列表${nodesError ? `${nodesError.message}` : ''}。请检查 API 接口是否正常。`
}
{`无法从服务器获取代理节点列表${nodesError ? `${nodesError.message}` : ''}。请检查 API 接口是否正常。`}
</p>
</div>
) : !effectiveNode ? (

View File

@ -0,0 +1,53 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import VlessQrCard, { type VlessQrCopy } from '../VlessQrCard'
vi.mock('next/image', () => ({
default: () => null,
}))
vi.mock('qrcode', () => ({
toDataURL: vi.fn(() => Promise.resolve('data:image/png;base64,test')),
}))
vi.mock('swr', () => ({
default: vi.fn(() => ({
data: [
{
name: 'JP-XHTTP.SVC.PLUS',
address: 'jp-xhttp.svc.plus',
port: 443,
xhttp_port: 443,
tcp_port: 1443,
uri_scheme_xhttp: 'vless://${UUID}@${DOMAIN}:443?type=xhttp&path=${PATH}#${TAG}',
uri_scheme_tcp: 'vless://${UUID}@${DOMAIN}:1443?type=tcp&flow=${FLOW}#${TAG}',
},
],
error: undefined,
})),
}))
const copy: VlessQrCopy = {
label: 'VLESS QR',
description: 'Scan to import VLESS config.',
linkLabel: 'VLESS link',
linkHelper: 'Copy link helper',
copyLink: 'Copy link',
copied: 'Copied',
downloadQr: 'Download QR',
generating: 'Generating',
error: 'Failed to generate',
missingUuid: 'Missing UUID',
qrAlt: 'VLESS QR code',
}
describe('VlessQrCard', () => {
it('only renders the XHTTP transport tab by default', () => {
render(<VlessQrCard uuid="11111111-1111-4111-8111-111111111111" copy={copy} />)
expect(screen.getByRole('button', { name: /xhttp/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /tcp/i })).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchAccountBillingSummary, fetchAccountPolicy, fetchAccountUsageSummary } from './fetchAccountUsage'
describe('fetchAccountUsage', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('loads the authoritative usage summary from the account api', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ accountUuid: 'acct-1', totalBytes: 384, sourceOfTruth: 'postgresql' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
await expect(fetchAccountUsageSummary()).resolves.toEqual({
accountUuid: 'acct-1',
totalBytes: 384,
sourceOfTruth: 'postgresql',
})
})
it('loads the authoritative policy snapshot from the account api', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ accountUuid: 'acct-1', preferredStrategy: 'ewma' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
await expect(fetchAccountPolicy()).resolves.toEqual({
accountUuid: 'acct-1',
preferredStrategy: 'ewma',
})
})
it('loads the authoritative billing summary from the account api', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(
JSON.stringify({
accountUuid: 'acct-1',
sourceOfTruth: 'postgresql',
billingProfile: { packageName: 'starter', pricingRuleVersion: 'pricing-v1' },
ledger: [{ id: 'ledger-1', entryType: 'traffic_charge', ratedBytes: 50, amountDelta: -12.5, balanceAfter: 87.5 }],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
)
await expect(fetchAccountBillingSummary()).resolves.toEqual({
accountUuid: 'acct-1',
sourceOfTruth: 'postgresql',
billingProfile: { packageName: 'starter', pricingRuleVersion: 'pricing-v1' },
ledger: [{ id: 'ledger-1', entryType: 'traffic_charge', ratedBytes: 50, amountDelta: -12.5, balanceAfter: 87.5 }],
})
})
})

View File

@ -0,0 +1,103 @@
'use client'
type AccountUsageError = Error & {
status?: number
}
export type AccountUsageSummary = {
accountUuid: string
totalBytes: number
sourceOfTruth?: string
uplinkBytes?: number
downlinkBytes?: number
currentBalance?: number
remainingIncludedQuota?: number
syncDelaySeconds?: number
suspendState?: string
throttleState?: string
arrears?: boolean
billingProfile?: AccountBillingProfile
}
export type AccountPolicy = {
accountUuid: string
preferredStrategy: string
eligibleNodeGroups?: string[]
authState?: string
degradeMode?: string
}
export type AccountBillingProfile = {
packageName?: string
includedQuotaBytes?: number
basePricePerByte?: number
regionMultiplier?: number
lineMultiplier?: number
pricingRuleVersion?: string
}
export type BillingLedgerEntry = {
id: string
entryType: string
ratedBytes: number
amountDelta: number
balanceAfter: number
pricingRuleVersion?: string
bucketStart?: string
bucketEnd?: string
createdAt?: string
}
export type AccountBillingSummary = {
accountUuid: string
sourceOfTruth?: string
quotaState?: {
currentBalance?: number
remainingIncludedQuota?: number
arrears?: boolean
throttleState?: string
suspendState?: string
}
billingProfile?: AccountBillingProfile
ledger?: BillingLedgerEntry[]
}
function toError(payload: unknown, status: number): AccountUsageError {
const message =
payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string'
? payload.message
: payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string'
? payload.error
: `Request failed (${status})`
const error = new Error(message) as AccountUsageError
error.status = status
return error
}
async function requestJSON<T>(url: string): Promise<T> {
const response = await fetch(url, {
credentials: 'include',
cache: 'no-store',
headers: {
Accept: 'application/json',
},
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw toError(payload, response.status)
}
return payload as T
}
export function fetchAccountUsageSummary(): Promise<AccountUsageSummary> {
return requestJSON<AccountUsageSummary>('/api/account/usage/summary')
}
export function fetchAccountPolicy(): Promise<AccountPolicy> {
return requestJSON<AccountPolicy>('/api/account/policy')
}
export function fetchAccountBillingSummary(): Promise<AccountBillingSummary> {
return requestJSON<AccountBillingSummary>('/api/account/billing/summary')
}

View File

@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchAgentNodes } from './fetchAgentNodes'
describe('fetchAgentNodes', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('uses the primary endpoint when it returns a node array', async () => {
const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify([{ name: 'JP', address: 'jp-xhttp.svc.plus' }]), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
}),
)
await expect(fetchAgentNodes()).resolves.toEqual([{ name: 'JP', address: 'jp-xhttp.svc.plus' }])
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/sync/config?since_version=0',
expect.objectContaining({
cache: 'no-store',
credentials: 'include',
}),
)
})
it('falls back to the legacy endpoint when the primary route is unavailable', async () => {
const fetchMock = vi
.spyOn(global, 'fetch')
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'not_found' }), {
status: 404,
headers: {
'Content-Type': 'application/json',
},
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify([{ name: 'US', address: 'us-xhttp.svc.plus' }]), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
}),
)
await expect(fetchAgentNodes()).resolves.toEqual([{ name: 'US', address: 'us-xhttp.svc.plus' }])
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/agent/nodes')
})
it('falls back when the primary route returns an unexpected success payload', async () => {
const fetchMock = vi
.spyOn(global, 'fetch')
.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ nodes: [{ name: 'HK', address: 'hk-xhttp.svc.plus' }] }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
}),
)
await expect(fetchAgentNodes()).resolves.toEqual([{ name: 'HK', address: 'hk-xhttp.svc.plus' }])
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/agent/nodes')
})
it('preserves non-fallback errors from the primary endpoint', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'invalid_session' }), {
status: 401,
headers: {
'Content-Type': 'application/json',
},
}),
)
await expect(fetchAgentNodes()).rejects.toThrow('invalid_session')
})
})

View File

@ -0,0 +1,92 @@
'use client'
import type { VlessNode } from './vless'
const PRIMARY_ENDPOINT = '/api/auth/sync/config?since_version=0'
const FALLBACK_ENDPOINT = '/api/agent/nodes'
type AgentNodePayload =
| {
nodes?: unknown
profiles?: unknown
message?: unknown
error?: unknown
}
| VlessNode[]
type AgentNodesError = Error & {
status?: number
}
function isAgentNodeErrorPayload(
payload: AgentNodePayload | null,
): payload is Exclude<AgentNodePayload, VlessNode[]> {
return !!payload && !Array.isArray(payload)
}
function extractMessage(payload: AgentNodePayload | null, status: number): string {
if (isAgentNodeErrorPayload(payload) && typeof payload.message === 'string' && payload.message.trim().length > 0) {
return payload.message
}
if (isAgentNodeErrorPayload(payload) && typeof payload.error === 'string' && payload.error.trim().length > 0) {
return payload.error
}
return `Request failed (${status})`
}
async function requestAgentNodes(url: string): Promise<VlessNode[]> {
const response = await fetch(url, {
credentials: 'include',
cache: 'no-store',
headers: {
Accept: 'application/json',
},
})
const payload = (await response.json().catch(() => null)) as AgentNodePayload | null
if (!response.ok) {
const error = new Error(extractMessage(Array.isArray(payload) ? null : payload, response.status)) as AgentNodesError
error.status = response.status
throw error
}
if (Array.isArray(payload)) {
return payload as VlessNode[]
}
if (payload && Array.isArray((payload as { nodes?: unknown }).nodes)) {
return (payload as { nodes: VlessNode[] }).nodes
}
if (payload && Array.isArray((payload as { profiles?: unknown }).profiles)) {
return (payload as { profiles: VlessNode[] }).profiles
}
throw new Error('unexpected_agent_nodes_payload')
}
function shouldFallback(error: unknown): boolean {
if (!(error instanceof Error)) {
return true
}
if ((error as AgentNodesError).status && [404, 405, 502].includes((error as AgentNodesError).status as number)) {
return true
}
return [
'unexpected_agent_nodes_payload',
'upstream_unreachable',
].includes(error.message)
}
export async function fetchAgentNodes(): Promise<VlessNode[]> {
try {
return await requestAgentNodes(PRIMARY_ENDPOINT)
} catch (error) {
if (!shouldFallback(error)) {
throw error
}
}
return requestAgentNodes(FALLBACK_ENDPOINT)
}

View File

@ -1,35 +0,0 @@
'use client'
export type SandboxNodeBinding = {
address: string
name?: string
updatedAt: number
updatedBy?: string
}
export async function fetchSandboxNodeBinding(): Promise<SandboxNodeBinding | null> {
try {
const response = await fetch('/api/sandbox/binding', { method: 'GET', cache: 'no-store' })
if (!response.ok) {
return null
}
const payload = (await response.json().catch(() => null)) as any
if (!payload || typeof payload.address !== 'string') {
return null
}
const address = payload.address.trim()
if (!address) {
return null
}
return {
address,
name: typeof payload.name === 'string' && payload.name.trim().length > 0 ? payload.name.trim() : undefined,
updatedAt: typeof payload.updatedAt === 'number' ? payload.updatedAt : Date.now(),
updatedBy:
typeof payload.updatedBy === 'string' && payload.updatedBy.trim().length > 0 ? payload.updatedBy.trim() : undefined,
}
} catch (error) {
console.warn('Failed to fetch sandbox node binding', error)
return null
}
}

View File

@ -1,80 +1,122 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import OverviewCards from '../components/OverviewCards'
import TrendChart from '../components/TrendChart'
import PermissionMatrixEditor from '../components/PermissionMatrixEditor'
import UserGroupManagement from '../components/UserGroupManagement'
import OverviewCards from "../components/OverviewCards";
import TrendChart from "../components/TrendChart";
import PermissionMatrixEditor from "../components/PermissionMatrixEditor";
import UserGroupManagement from "../components/UserGroupManagement";
describe('Management dashboard components', () => {
it('renders loading state for overview cards', () => {
const { container } = render(<OverviewCards isLoading />)
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
})
describe("Management dashboard components", () => {
it("renders loading state for overview cards", () => {
const { container } = render(<OverviewCards isLoading />);
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument();
});
it('supports switching trend granularity', () => {
it("supports switching trend granularity", () => {
const series = {
daily: [
{ period: '2025-03-01', total: 120, active: 80, subscribed: 40 },
{ period: '2025-03-02', total: 140, active: 90, subscribed: 50 },
{ period: "2025-03-01", total: 120, active: 80, subscribed: 40 },
{ period: "2025-03-02", total: 140, active: 90, subscribed: 50 },
],
weekly: [
{ period: '2025-W09', total: 900, active: 600, subscribed: 320 },
{ period: "2025-W09", total: 900, active: 600, subscribed: 320 },
],
}
};
render(<TrendChart series={series} />)
render(<TrendChart series={series} />);
expect(screen.getByText('2025-03-01')).toBeInTheDocument()
expect(screen.queryByText("2025-03-01")).not.toBeVisible();
const weeklyButton = screen.getByRole('button', { name: '按周' })
fireEvent.click(weeklyButton)
const detailsButton = screen.getByRole("button", { name: "展开明细" });
expect(detailsButton).toHaveAttribute("aria-expanded", "false");
fireEvent.click(detailsButton);
expect(screen.getByText('2025-W09')).toBeInTheDocument()
})
expect(screen.getByText("2025-03-01")).toBeVisible();
expect(detailsButton).toHaveAttribute("aria-expanded", "true");
it('disables permission matrix editing when read only', () => {
const weeklyButton = screen.getByRole("button", { name: "按周" });
fireEvent.click(weeklyButton);
expect(screen.getByText("2025-W09")).toBeVisible();
});
it("disables permission matrix editing when read only", () => {
const matrix = {
registration: { admin: true, operator: false, user: false },
}
};
render(
<PermissionMatrixEditor
matrix={matrix}
roles={['admin', 'operator', 'user']}
roles={["admin", "operator", "user"]}
canEdit={false}
/>,
)
);
for (const checkbox of screen.getAllByRole('checkbox')) {
expect(checkbox).toBeDisabled()
for (const checkbox of screen.getAllByRole("checkbox")) {
expect(checkbox).toBeDisabled();
}
expect(screen.queryByRole('button', { name: /保存/ })).not.toBeInTheDocument()
})
expect(
screen.queryByRole("button", { name: /保存/ }),
).not.toBeInTheDocument();
});
it('flags pending role updates in user group management', () => {
const handleRoleChange = vi.fn()
it("flags pending role updates in user group management", () => {
const handleRoleChange = vi.fn();
const users = [
{ id: '1', email: 'admin@example.com', role: 'admin', active: true },
{ id: '2', email: 'operator@example.com', role: 'operator', active: false },
]
{
id: "1",
email: "admin@example.com",
username: "admin",
role: "admin",
active: true,
},
{
id: "2",
email: "operator@example.com",
role: "operator",
active: false,
},
];
render(
<UserGroupManagement
users={users}
canEditRoles
pendingUserIds={new Set(['1'])}
pendingUserIds={new Set(["1"])}
onRoleChange={handleRoleChange}
/>,
)
);
const pendingSelect = screen.getAllByRole('combobox')[0]
expect(pendingSelect).toBeDisabled()
expect(screen.getByText('更新中…')).toBeInTheDocument()
const pendingSelect = screen.getAllByRole("combobox")[0];
expect(pendingSelect).toBeDisabled();
expect(screen.getByText("更新中…")).toBeInTheDocument();
const editableSelect = screen.getAllByRole('combobox')[1]
fireEvent.change(editableSelect, { target: { value: 'admin' } })
expect(handleRoleChange).toHaveBeenCalledWith('2', 'admin')
})
})
const editableSelect = screen.getAllByRole("combobox")[1];
fireEvent.change(editableSelect, { target: { value: "admin" } });
expect(handleRoleChange).toHaveBeenCalledWith("2", "admin");
});
it("shows usernames and treats missing active flags as enabled", () => {
render(
<UserGroupManagement
users={[
{
id: "1",
email: "default-active@example.com",
username: "defaultActive",
role: "user",
},
]}
canEditRoles
/>,
);
expect(screen.getByText("用户名")).toBeInTheDocument();
expect(screen.getByText("defaultActive")).toBeInTheDocument();
expect(screen.getByText("活跃")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "暂停" })).toBeInTheDocument();
expect(screen.queryByText("已暂停")).not.toBeInTheDocument();
});
});

View File

@ -1,201 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Card from '../../components/Card'
import { useUserStore } from '@lib/userStore'
type AssumeStatus = {
isAssuming: boolean
target?: string
}
const SANDBOX_EMAIL = 'sandbox@svc.plus'
async function fetchAssumeStatus(): Promise<AssumeStatus> {
const res = await fetch('/api/sandbox/assume/status', { method: 'GET', cache: 'no-store', credentials: 'include' })
const payload = (await res.json().catch(() => null)) as any
return {
isAssuming: Boolean(payload?.isAssuming),
target: typeof payload?.target === 'string' ? payload.target : undefined,
}
}
export default function RootAssumeSandboxPanel() {
const user = useUserStore((state) => state.user)
const isRoot = useMemo(() => {
const email = user?.email?.trim().toLowerCase() ?? ''
return email === 'admin@svc.plus' && Boolean(user?.isAdmin)
}, [user?.email, user?.isAdmin])
const [status, setStatus] = useState<AssumeStatus>({ isAssuming: false })
const [isLoading, setIsLoading] = useState(true)
const [isBusy, setIsBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
// Future-proof: allowlist targets can be extended later.
const [draftTarget, setDraftTarget] = useState<string>(SANDBOX_EMAIL)
// Two-step confirmation (select -> confirm apply).
const [pendingConfirm, setPendingConfirm] = useState(false)
useEffect(() => {
let cancelled = false
void (async () => {
try {
setIsLoading(true)
const next = await fetchAssumeStatus()
if (cancelled) return
setStatus(next)
} catch (error) {
if (cancelled) return
setStatus({ isAssuming: false })
} finally {
if (cancelled) return
setIsLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
const handleAssume = async () => {
if (!isRoot || isBusy) return
try {
setIsBusy(true)
setMessage(null)
const res = await fetch('/api/sandbox/assume', { method: 'POST', cache: 'no-store', credentials: 'include' })
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as any
throw new Error((payload && (payload.message || payload.error)) || `Assume failed (${res.status})`)
}
setMessage('已切换到 Sandbox 视角(只读)')
// Hard refresh ensures the new xc_session cookie is used everywhere immediately.
window.location.reload()
} catch (error) {
setMessage(`错误:${error instanceof Error ? error.message : '切换失败'}`)
} finally {
setIsBusy(false)
}
}
const handleRevert = async () => {
if (!isRoot || isBusy) return
try {
setIsBusy(true)
setMessage(null)
const res = await fetch('/api/sandbox/assume/revert', { method: 'POST', cache: 'no-store', credentials: 'include' })
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as any
throw new Error((payload && (payload.message || payload.error)) || `Revert failed (${res.status})`)
}
setMessage('已退出 Sandbox恢复 Root 会话')
window.location.reload()
} catch (error) {
setMessage(`错误:${error instanceof Error ? error.message : '退出失败'}`)
} finally {
setIsBusy(false)
}
}
const confirmLabel = status.isAssuming ? '确认退出' : '确认切换'
const primaryDisabled = !isRoot || isBusy || isLoading
return (
<Card>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">Root Assume </h2>
<p className="text-sm text-gray-600">
Root Sandbox Sandbox allowlist
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<label className="flex flex-1 flex-col gap-2 text-sm font-medium text-gray-700">
<select
value={draftTarget}
disabled={primaryDisabled || status.isAssuming}
onChange={(e) => {
setDraftTarget(e.target.value)
setPendingConfirm(false)
setMessage(null)
}}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
>
<option value={SANDBOX_EMAIL}>sandbox@svc.plus</option>
</select>
</label>
<div className="flex gap-2">
{pendingConfirm ? (
<>
<button
type="button"
onClick={() => {
if (status.isAssuming) {
void handleRevert()
} else {
void handleAssume()
}
}}
disabled={primaryDisabled || (!status.isAssuming && draftTarget !== SANDBOX_EMAIL)}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500"
>
{isBusy ? '处理中…' : confirmLabel}
</button>
<button
type="button"
onClick={() => setPendingConfirm(false)}
disabled={isBusy}
className="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
>
</button>
</>
) : (
<button
type="button"
onClick={() => {
setPendingConfirm(true)
setMessage(null)
}}
disabled={primaryDisabled}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500"
>
{status.isAssuming ? '退出 Sandbox' : '切换到 Sandbox'}
</button>
)}
</div>
</div>
<div className="space-y-1 rounded-md bg-gray-50 p-3">
{status.isAssuming ? (
<div className="flex items-center gap-2 text-xs text-gray-700">
<div className="h-2 w-2 rounded-full bg-amber-500" />
Assume<span className="font-bold">{status.target || SANDBOX_EMAIL}</span>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-gray-500">
<div className="h-2 w-2 rounded-full bg-gray-300" />
Assume
</div>
)}
<p className="pl-4 text-[10px] text-gray-400">
Sandbox Assume root `xc_session_root` host-only httpOnly
</p>
</div>
{message ? (
<p className={`text-xs font-medium ${message.startsWith('错误') ? 'text-red-600' : 'text-green-600'}`}>
{message}
</p>
) : null}
</div>
</Card>
)
}

View File

@ -1,176 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import useSWR from 'swr'
import Card from '../../components/Card'
import type { VlessNode } from '../../lib/vless'
async function fetcher(url: string): Promise<VlessNode[]> {
const response = await fetch(url, {
credentials: 'include',
cache: 'no-store',
headers: {
Accept: 'application/json',
},
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
const message =
(payload && typeof payload.message === 'string' && payload.message) ||
(payload && typeof payload.error === 'string' && payload.error) ||
`Request failed (${response.status})`
throw new Error(message)
}
if (Array.isArray(payload)) {
return payload as VlessNode[]
}
if (payload && Array.isArray((payload as { nodes?: unknown }).nodes)) {
return (payload as { nodes: VlessNode[] }).nodes
}
return []
}
export default function SandboxNodeBindingPanel() {
const { data: nodes, error, isLoading } = useSWR<VlessNode[]>('/api/agent-server/v1/nodes', fetcher, {
revalidateOnFocus: false,
})
const [message, setMessage] = useState<string | null>(null)
const [activeBinding, setActiveBinding] = useState<{ address: string; updatedAt?: number } | null>(null)
const [draftAddress, setDraftAddress] = useState<string>('')
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
// Initial load from server to stay in sync
fetch('/api/admin/sandbox/binding')
.then(res => res.json())
.then(data => {
if (data && typeof data.address === 'string') {
setDraftAddress(data.address)
setActiveBinding({
address: data.address,
updatedAt: typeof data.updatedAt === 'number' ? data.updatedAt : undefined,
})
}
})
.catch(err => console.error('Failed to fetch binding from server', err))
}, [])
const isChanged = useMemo(() => {
return (activeBinding?.address ?? '') !== draftAddress
}, [activeBinding?.address, draftAddress])
const handleApply = async (rawAddress: string) => {
const address = rawAddress.trim()
try {
setIsSaving(true)
setMessage(null)
const response = await fetch('/api/admin/sandbox/bind', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ address }),
})
if (!response.ok) {
const payload = await response.json().catch(() => ({}))
throw new Error(payload.message || `Failed to save binding (${response.status})`)
}
if (!address) {
setActiveBinding({ address: '', updatedAt: Date.now() })
setMessage('已成功清空绑定节点 (已同步至服务器)')
} else {
const node = nodes?.find((item) => item.address === address)
setActiveBinding({ address, updatedAt: Date.now() })
setMessage(`应用成功:已绑定至 ${node?.name || address} (已同步至服务器)`)
}
// Refresh local state if needed (though we already updated it)
} catch (err: any) {
setMessage(`错误:${err.message}`)
} finally {
setIsSaving(false)
}
}
const currentActive = activeBinding?.address ? activeBinding : null
return (
<Card>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">Root Sandbox Node </h2>
<p className="text-sm text-gray-600">Sandbox@svc.plus 使</p>
</div>
<div className="flex items-end gap-3">
<label className="flex flex-1 flex-col gap-2 text-sm font-medium text-gray-700">
<select
value={draftAddress}
disabled={isLoading || !nodes}
onChange={(e) => {
const next = e.target.value
setDraftAddress(next)
// 两段式:先选择,再点“确认应用”提交到服务器
setMessage(null)
}}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
>
<option value=""></option>
{(nodes ?? [])
.filter((node) => node.address !== '*')
.map((node) => (
<option key={node.address} value={node.address}>
{node.name} ({node.address})
</option>
))}
</select>
</label>
<button
onClick={() => void handleApply(draftAddress)}
disabled={!isChanged || isSaving}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${isChanged
? 'bg-purple-600 text-white hover:bg-purple-700 shadow-sm'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
{isSaving ? '保存中…' : '确认应用'}
</button>
</div>
<div className="space-y-1 rounded-md bg-gray-50 p-3">
{currentActive ? (
<div className="flex items-center gap-2 text-xs text-gray-700">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="font-bold">{currentActive.address}</span>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-gray-500">
<div className="h-2 w-2 rounded-full bg-gray-300" />
</div>
)}
{currentActive?.updatedAt ? (
<p className="pl-4 text-[10px] text-gray-400">
{new Date(currentActive.updatedAt).toLocaleString()}
</p>
) : null}
</div>
{error && <p className="text-xs text-red-600"> {error.message}</p>}
{message && (
<p className={`text-xs font-medium ${message.startsWith('错误') ? 'text-red-600' : 'text-green-600'}`}>
{message}
</p>
)}
</div>
</Card>
)
}

View File

@ -1,54 +1,55 @@
'use client'
"use client";
import { useMemo, useState } from 'react'
import Card from '../../components/Card'
import { useMemo, useState } from "react";
import Card from "../../components/Card";
export type MetricsPoint = {
period: string
total: number
active: number
subscribed: number
}
period: string;
total: number;
active: number;
subscribed: number;
};
export type MetricsSeries = {
daily: MetricsPoint[]
weekly: MetricsPoint[]
}
daily: MetricsPoint[];
weekly: MetricsPoint[];
};
type TrendChartProps = {
series?: MetricsSeries
isLoading?: boolean
}
series?: MetricsSeries;
isLoading?: boolean;
};
type Granularity = 'daily' | 'weekly'
type Granularity = "daily" | "weekly";
function buildSparkline(points: MetricsPoint[]) {
if (!points || points.length === 0) {
return ''
return "";
}
const totals = points.map((point) => point.total)
const maxValue = Math.max(...totals, 1)
const lastIndex = totals.length - 1 || 1
const totals = points.map((point) => point.total);
const maxValue = Math.max(...totals, 1);
const lastIndex = totals.length - 1 || 1;
return totals
.map((value, index) => {
const x = (index / lastIndex) * 100
const y = 100 - (value / maxValue) * 100
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`
const x = (index / lastIndex) * 100;
const y = 100 - (value / maxValue) * 100;
return `${index === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ')
.join(" ");
}
export function TrendChart({ series, isLoading = false }: TrendChartProps) {
const [granularity, setGranularity] = useState<Granularity>('daily')
const [granularity, setGranularity] = useState<Granularity>("daily");
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const points = useMemo(() => {
if (!series) {
return [] as MetricsPoint[]
return [] as MetricsPoint[];
}
return granularity === 'daily' ? series.daily : series.weekly
}, [granularity, series])
return granularity === "daily" ? series.daily : series.weekly;
}, [granularity, series]);
const sparklinePath = useMemo(() => buildSparkline(points), [points])
const sparklinePath = useMemo(() => buildSparkline(points), [points]);
return (
<Card>
@ -56,13 +57,15 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500"></p>
<p className="text-sm text-gray-500">
</p>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white/80 p-1 text-xs shadow-sm">
{(
[
{ key: 'daily', label: '按日' },
{ key: 'weekly', label: '按周' },
{ key: "daily", label: "按日" },
{ key: "weekly", label: "按周" },
] as Array<{ key: Granularity; label: string }>
).map((option) => (
<button
@ -70,8 +73,8 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
type="button"
className={`rounded-full px-3 py-1 font-medium transition ${
granularity === option.key
? 'bg-purple-600 text-white shadow'
: 'text-gray-600 hover:bg-purple-50'
? "bg-purple-600 text-white shadow"
: "text-gray-600 hover:bg-purple-50"
}`}
onClick={() => setGranularity(option.key)}
aria-pressed={granularity === option.key}
@ -82,12 +85,20 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
</div>
</header>
<div className="flex flex-col gap-4" aria-busy={isLoading} aria-live="polite">
<div
className="flex flex-col gap-4"
aria-busy={isLoading}
aria-live="polite"
>
<div className="relative h-32 w-full overflow-hidden rounded-xl border border-purple-100 bg-gradient-to-br from-purple-50 via-white to-indigo-50">
{isLoading ? (
<div className="absolute inset-0 animate-pulse bg-gradient-to-r from-transparent via-purple-100/60 to-transparent" />
) : sparklinePath ? (
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-full w-full text-purple-500">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="h-full w-full text-purple-500"
>
<path
d={`${sparklinePath}`}
fill="none"
@ -98,11 +109,27 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
/>
</svg>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-400"></div>
<div className="flex h-full items-center justify-center text-sm text-gray-400">
</div>
)}
</div>
<div className="overflow-x-auto">
<button
type="button"
className="inline-flex w-fit items-center rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm transition hover:border-purple-200 hover:bg-purple-50 hover:text-purple-700"
onClick={() => setIsDetailsOpen((current) => !current)}
aria-expanded={isDetailsOpen}
aria-controls="management-trend-details"
>
{isDetailsOpen ? "收起明细" : "展开明细"}
</button>
<div
id="management-trend-details"
className="overflow-x-auto"
hidden={!isDetailsOpen}
>
<table className="min-w-full table-fixed divide-y divide-gray-200 text-left text-sm">
<thead className="bg-gray-50/80 text-xs uppercase tracking-wide text-gray-500">
<tr>
@ -131,11 +158,22 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
</tr>
))
: points.map((point) => (
<tr key={`${granularity}-${point.period}`} className="transition hover:bg-purple-50/50">
<td className="px-3 py-2 font-medium text-gray-700">{point.period}</td>
<td className="px-3 py-2 text-gray-900">{point.total}</td>
<td className="px-3 py-2 text-gray-900">{point.active}</td>
<td className="px-3 py-2 text-gray-900">{point.subscribed}</td>
<tr
key={`${granularity}-${point.period}`}
className="transition hover:bg-purple-50/50"
>
<td className="px-3 py-2 font-medium text-gray-700">
{point.period}
</td>
<td className="px-3 py-2 text-gray-900">
{point.total}
</td>
<td className="px-3 py-2 text-gray-900">
{point.active}
</td>
<td className="px-3 py-2 text-gray-900">
{point.subscribed}
</td>
</tr>
))}
</tbody>
@ -144,7 +182,7 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
</div>
</div>
</Card>
)
);
}
export default TrendChart
export default TrendChart;

View File

@ -1,55 +1,70 @@
'use client'
"use client";
import { type FormEvent, useMemo, useState } from 'react'
import Card from '../../components/Card'
import { type FormEvent, useMemo, useState } from "react";
import Card from "../../components/Card";
export type ManagedUser = {
id: string
email: string
role?: string
groups?: string[]
active?: boolean
created_at?: string
}
id: string;
email: string;
username?: string;
name?: string;
role?: string;
groups?: string[];
active?: boolean;
created_at?: string;
};
export type CreateManagedUserInput = {
email: string
uuid: string
groups: string[]
}
email: string;
uuid: string;
groups: string[];
};
type UserGroupManagementProps = {
users?: ManagedUser[]
isLoading?: boolean
pendingUserIds?: Set<string>
canEditRoles: boolean
canCreateCustomUser?: boolean
onRoleChange?: (userId: string, role: string) => void
onInvite?: () => void
onImport?: () => void
onPauseUser?: (userId: string) => void
onResumeUser?: (userId: string) => void
onDeleteUser?: (userId: string) => void
onRenewUuid?: (userId: string) => void
onManageBlacklist?: () => void
onCreateCustomUser?: (input: CreateManagedUserInput) => Promise<void> | void
}
users?: ManagedUser[];
isLoading?: boolean;
pendingUserIds?: Set<string>;
canEditRoles: boolean;
canCreateCustomUser?: boolean;
onRoleChange?: (userId: string, role: string) => void;
onInvite?: () => void;
onImport?: () => void;
onPauseUser?: (userId: string) => void;
onResumeUser?: (userId: string) => void;
onDeleteUser?: (userId: string) => void;
onRenewUuid?: (userId: string) => void;
onManageBlacklist?: () => void;
onCreateCustomUser?: (input: CreateManagedUserInput) => Promise<void> | void;
};
const ROLE_OPTIONS = [
{ value: 'admin', label: '管理员' },
{ value: 'operator', label: '运营者' },
{ value: 'user', label: '用户' },
]
{ value: "admin", label: "管理员" },
{ value: "operator", label: "运营者" },
{ value: "user", label: "用户" },
];
function parseGroupList(input: string): string[] {
const values = input
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.filter((entry) => entry.length > 0);
return Array.from(new Set(values))
return Array.from(new Set(values));
}
function getUserDisplayName(user: ManagedUser): string {
const username = user.username?.trim();
if (username) {
return username;
}
const name = user.name?.trim();
if (name) {
return name;
}
return "—";
}
export function UserGroupManagement({
users,
@ -67,53 +82,56 @@ export function UserGroupManagement({
onManageBlacklist,
onCreateCustomUser,
}: UserGroupManagementProps) {
const data = useMemo(() => users ?? [], [users])
const pendingSet = pendingUserIds ?? new Set<string>()
const data = useMemo(() => users ?? [], [users]);
const pendingSet = pendingUserIds ?? new Set<string>();
const [customEmail, setCustomEmail] = useState('')
const [customUuid, setCustomUuid] = useState('')
const [customGroups, setCustomGroups] = useState('')
const [isCreating, setIsCreating] = useState(false)
const [createMessage, setCreateMessage] = useState<string | undefined>()
const [createError, setCreateError] = useState<string | undefined>()
const [customEmail, setCustomEmail] = useState("");
const [customUuid, setCustomUuid] = useState("");
const [customGroups, setCustomGroups] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [createMessage, setCreateMessage] = useState<string | undefined>();
const [createError, setCreateError] = useState<string | undefined>();
const parsedCustomGroups = useMemo(() => parseGroupList(customGroups), [customGroups])
const parsedCustomGroups = useMemo(
() => parseGroupList(customGroups),
[customGroups],
);
const handleCreateCustomUser = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
event.preventDefault();
if (!onCreateCustomUser) {
return
return;
}
const email = customEmail.trim()
const uuid = customUuid.trim()
const email = customEmail.trim();
const uuid = customUuid.trim();
if (!email || !uuid) {
setCreateError('请填写邮箱与 UUID')
return
setCreateError("请填写邮箱与 UUID");
return;
}
const groups = parsedCustomGroups
const groups = parsedCustomGroups;
if (groups.length === 0) {
setCreateError('请至少填写一个用户组')
return
setCreateError("请至少填写一个用户组");
return;
}
setIsCreating(true)
setCreateError(undefined)
setCreateMessage(undefined)
setIsCreating(true);
setCreateError(undefined);
setCreateMessage(undefined);
try {
await onCreateCustomUser({ email, uuid, groups })
setCreateMessage('用户创建成功')
setCustomEmail('')
setCustomUuid('')
setCustomGroups('')
await onCreateCustomUser({ email, uuid, groups });
setCreateMessage("用户创建成功");
setCustomEmail("");
setCustomUuid("");
setCustomGroups("");
} catch (error) {
setCreateError(error instanceof Error ? error.message : '创建失败')
setCreateError(error instanceof Error ? error.message : "创建失败");
} finally {
setIsCreating(false)
setIsCreating(false);
}
}
};
return (
<Card>
@ -121,7 +139,9 @@ export function UserGroupManagement({
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500"></p>
<p className="text-sm text-gray-500">
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
@ -149,9 +169,16 @@ export function UserGroupManagement({
</header>
{canCreateCustomUser ? (
<form onSubmit={handleCreateCustomUser} className="rounded-xl border border-purple-100 bg-purple-50/60 p-4">
<h3 className="text-sm font-semibold text-purple-800">Root UUID </h3>
<p className="mt-1 text-xs text-purple-700"></p>
<form
onSubmit={handleCreateCustomUser}
className="rounded-xl border border-purple-100 bg-purple-50/60 p-4"
>
<h3 className="text-sm font-semibold text-purple-800">
Root UUID
</h3>
<p className="mt-1 text-xs text-purple-700">
</p>
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1 text-xs text-gray-600">
@ -190,19 +217,28 @@ export function UserGroupManagement({
disabled={isCreating}
className="inline-flex items-center rounded-full bg-purple-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-purple-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreating ? '创建中…' : '创建用户'}
{isCreating ? "创建中…" : "创建用户"}
</button>
{createMessage ? <span className="text-xs text-green-700">{createMessage}</span> : null}
{createError ? <span className="text-xs text-red-600">{createError}</span> : null}
{createMessage ? (
<span className="text-xs text-green-700">{createMessage}</span>
) : null}
{createError ? (
<span className="text-xs text-red-600">{createError}</span>
) : null}
</div>
</form>
) : null}
<div className="overflow-x-auto" aria-busy={isLoading} aria-live="polite">
<div
className="overflow-x-auto"
aria-busy={isLoading}
aria-live="polite"
>
<table className="min-w-full divide-y divide-gray-200 text-left text-sm">
<thead className="bg-gray-50/80 text-xs uppercase tracking-wide text-gray-500">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
@ -212,99 +248,123 @@ export function UserGroupManagement({
<tbody className="divide-y divide-gray-100 bg-white/80">
{isLoading
? Array.from({ length: 5 }).map((_, index) => (
<tr key={index} className="animate-pulse">
<td className="px-4 py-3">
<span className="inline-block h-4 w-48 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-24 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-32 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-16 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-24 rounded bg-gray-200" />
</td>
</tr>
))
: data.map((user) => {
const role = user.role ?? 'user'
const isPending = pendingSet.has(user.id)
return (
<tr key={user.id} className="transition hover:bg-purple-50/50">
<td className="px-4 py-3 text-sm font-medium text-gray-800">{user.email}</td>
<tr key={index} className="animate-pulse">
<td className="px-4 py-3">
<select
className="w-40 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
value={role}
disabled={!canEditRoles || isPending}
onChange={(event) => onRoleChange?.(user.id, event.target.value)}
>
{ROLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{isPending ? <p className="mt-1 text-xs text-purple-500"></p> : null}
</td>
<td className="px-4 py-3 text-gray-600">{user.groups?.join('、') || '—'}</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${user.active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}
>
{user.active ? '活跃' : '已暂停'}
</span>
<span className="inline-block h-4 w-48 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
{user.active ? (
<button
onClick={() => onPauseUser?.(user.id)}
className="text-xs text-orange-600 hover:text-orange-700"
>
</button>
) : (
<button
onClick={() => onResumeUser?.(user.id)}
className="text-xs text-green-600 hover:text-green-700"
>
</button>
)}
<button
onClick={() => onRenewUuid?.(user.id)}
className="text-xs text-blue-600 hover:text-blue-700"
>
UUID
</button>
<button
onClick={() => {
if (confirm('确定要删除该用户吗?此操作不可逆。')) {
onDeleteUser?.(user.id)
}
}}
className="text-xs text-red-600 hover:text-red-700"
>
</button>
</div>
<span className="inline-block h-4 w-28 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-24 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-32 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-16 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-24 rounded bg-gray-200" />
</td>
</tr>
)
})}
))
: data.map((user) => {
const role = user.role ?? "user";
const isPending = pendingSet.has(user.id);
const isActive = user.active !== false;
return (
<tr
key={user.id}
className="transition hover:bg-purple-50/50"
>
<td className="px-4 py-3 text-sm font-medium text-gray-800">
{user.email}
</td>
<td className="px-4 py-3 text-gray-700">
{getUserDisplayName(user)}
</td>
<td className="px-4 py-3">
<select
className="w-40 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
value={role}
disabled={!canEditRoles || isPending}
onChange={(event) =>
onRoleChange?.(user.id, event.target.value)
}
>
{ROLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{isPending ? (
<p className="mt-1 text-xs text-purple-500">
</p>
) : null}
</td>
<td className="px-4 py-3 text-gray-600">
{user.groups?.join("、") || "—"}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${isActive ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"}`}
>
{isActive ? "活跃" : "已暂停"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
{isActive ? (
<button
onClick={() => onPauseUser?.(user.id)}
className="text-xs text-orange-600 hover:text-orange-700"
>
</button>
) : (
<button
onClick={() => onResumeUser?.(user.id)}
className="text-xs text-green-600 hover:text-green-700"
>
</button>
)}
<button
onClick={() => onRenewUuid?.(user.id)}
className="text-xs text-blue-600 hover:text-blue-700"
>
UUID
</button>
<button
onClick={() => {
if (
confirm("确定要删除该用户吗?此操作不可逆。")
) {
onDeleteUser?.(user.id);
}
}}
className="text-xs text-red-600 hover:text-red-700"
>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{!isLoading && data.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-500"></div>
<div className="py-6 text-center text-sm text-gray-500">
</div>
) : null}
</div>
</div>
</Card>
)
);
}
export default UserGroupManagement
export default UserGroupManagement;

View File

@ -1,14 +1,13 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import useSWR from 'swr'
import { Server, MapPin, Plus, ExternalLink, RefreshCw } from 'lucide-react'
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { useUserStore } from '@lib/userStore'
import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding'
import { fetchAgentNodes } from '../lib/fetchAgentNodes'
interface VlessNode {
@ -35,103 +34,17 @@ function isDisplayableNode(node: VlessNode): boolean {
return true
}
async function fetcher(url: string): Promise<VlessNode[]> {
const res = await fetch(url, { credentials: 'include', cache: 'no-store' })
const payload = await res.json().catch(() => null)
if (!res.ok) {
const message =
(payload && typeof payload.message === 'string' && payload.message) ||
(payload && typeof payload.error === 'string' && payload.error) ||
`Request failed (${res.status})`
throw new Error(message)
}
if (Array.isArray(payload)) {
return payload as VlessNode[]
}
if (payload && Array.isArray((payload as { nodes?: unknown }).nodes)) {
return (payload as { nodes: VlessNode[] }).nodes
}
return []
}
export default function UserCenterAgentRoute() {
const { language } = useLanguage()
const t = translations[language].userCenter
const user = useUserStore((state) => state.user)
const { data: nodes, error, isLoading, mutate } = useSWR<VlessNode[]>('/api/agent-server/v1/nodes', fetcher)
const [boundNode, setBoundNode] = useState<VlessNode | null>(null)
const normalizedEmail = user?.email?.toLowerCase() ?? ''
const isGuestSandboxReadOnly = Boolean(
user?.isReadOnly && (normalizedEmail === 'sandbox@svc.plus'),
)
const { data: nodes, error, isLoading, mutate } = useSWR<VlessNode[]>('user-center-agent-nodes', fetchAgentNodes)
const visibleNodes = useMemo(() => {
return (nodes ?? []).filter((node) => {
if (isGuestSandboxReadOnly) {
// In sandbox mode, allow internal agents so the user can see their bound node
// even if it belongs to the shared token bucket.
const address = (node.address || '').trim()
return address && address !== '*'
}
return isDisplayableNode(node)
})
}, [nodes, isGuestSandboxReadOnly])
const [boundAddress, setBoundAddress] = useState<string | null>(null)
useEffect(() => {
if (!isGuestSandboxReadOnly) {
setBoundNode(null)
setBoundAddress(null)
return
}
let cancelled = false
void (async () => {
const binding = await fetchSandboxNodeBinding()
if (cancelled) {
return
}
setBoundAddress(binding?.address ?? null)
if (!binding?.address) {
setBoundNode(null)
return
}
setBoundNode({
name: binding.name || 'Sandbox Node',
address: binding.address,
port: 443,
transport: 'tcp',
security: 'tls',
} as any)
})()
return () => {
cancelled = true
}
}, [isGuestSandboxReadOnly])
return (nodes ?? []).filter((node) => isDisplayableNode(node))
}, [nodes])
const effectiveNodes = useMemo(() => {
// Default behavior: show all displayable nodes.
const base = visibleNodes.length > 0 ? [...visibleNodes] : []
// Guest sandbox behavior: if root has bound a preferred node, ensure it is first,
// but still show all regions/nodes to keep the demo experience useful.
if (isGuestSandboxReadOnly && normalizedEmail && boundAddress) {
const matched = nodes?.find((n) => n.address === boundAddress)
const preferred = matched ?? boundNode ?? null
if (preferred) {
const rest = base.filter((n) => n.address !== preferred.address)
return [preferred, ...rest]
}
}
// Fallback if no nodes were returned by the API but we are in sandbox mode
if (isGuestSandboxReadOnly && boundNode && base.length === 0) {
return [boundNode]
}
return base
}, [isGuestSandboxReadOnly, nodes, visibleNodes, normalizedEmail, boundAddress, boundNode])
return visibleNodes.length > 0 ? [...visibleNodes] : []
}, [visibleNodes])
const groupedNodes = useMemo(() => {
const groups: Record<string, VlessNode[]> = {
@ -188,7 +101,7 @@ export default function UserCenterAgentRoute() {
<div className="grid gap-6">
{error && !(isGuestSandboxReadOnly && boundNode) && (
{error && (
<div className="rounded-xl border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/30 px-4 py-3 text-sm text-[var(--color-danger-foreground)]">
{language === 'zh'
? `节点列表加载失败:${error.message}`

View File

@ -17,8 +17,6 @@ import UserGroupManagement, {
type ManagedUser,
type CreateManagedUserInput,
} from "../management/components/UserGroupManagement";
import SandboxNodeBindingPanel from "../management/components/SandboxNodeBindingPanel";
import RootAssumeSandboxPanel from "../management/components/RootAssumeSandboxPanel";
import HomepageVideoSettingsPanel from "../management/components/HomepageVideoSettingsPanel";
import { EmailBlacklist } from "../management/components/EmailBlacklist";
import Breadcrumbs from "@/app/panel/components/Breadcrumbs";
@ -518,12 +516,6 @@ export default function UserCenterManagementRoute() {
onCreateCustomUser={handleCreateCustomUser}
onManageBlacklist={() => setIsBlacklistOpen(true)}
/>
{canCreateCustomUser ? (
<>
<RootAssumeSandboxPanel />
<SandboxNodeBindingPanel />
</>
) : null}
<EmailBlacklist
isOpen={isBlacklistOpen}
onClose={() => setIsBlacklistOpen(false)}

View File

@ -8,7 +8,7 @@ import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
export type AccountUserRole = "guest" | "user" | "operator" | "admin";
export type AccountUserRole = "user" | "operator" | "admin";
export type AccountTenantMembership = {
id: string;
@ -70,15 +70,15 @@ const KNOWN_ROLE_MAP: Record<string, AccountUserRole> = {
member: "user",
};
function normalizeRole(value: unknown): AccountUserRole {
function normalizeRole(value: unknown): AccountUserRole | null {
if (typeof value !== "string") {
return "guest";
return null;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return "guest";
return null;
}
return KNOWN_ROLE_MAP[normalized] ?? "guest";
return KNOWN_ROLE_MAP[normalized] ?? null;
}
function normalizeString(value: unknown): string | undefined {
@ -125,7 +125,7 @@ function normalizeTenants(
entry.name = name;
}
const role = normalizeRole(raw.role);
if (role !== "guest") {
if (role) {
entry.role = role;
}
normalized.push(entry);
@ -147,6 +147,9 @@ function buildUser(
const name = normalizeString(raw.name);
const username = normalizeString(raw.username) ?? name;
const role = normalizeRole(raw.role);
if (!role) {
return null;
}
const groups = normalizeStringList(raw.groups);
const permissions = normalizeStringList(raw.permissions);
const tenantId = normalizeString(raw.tenantId);

Some files were not shown because too many files have changed in this diff Show More