Compare commits
62 Commits
codex/fix/
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
017c33d8f4 | ||
|
|
76d3d2884f | ||
|
|
64940dfc29 | ||
|
|
53c7fc0154 | ||
|
|
f613ff9c38 | ||
|
|
ad7c76e6e6 | ||
|
|
081bedd637 | ||
|
|
97e7a4a3bb | ||
|
|
239e30472b | ||
|
|
cf1ce8a4db | ||
|
|
a0e6da97b1 | ||
|
|
ddb2a7b627 | ||
|
|
107e9879a6 | ||
|
|
0c6ed2a0c4 | ||
|
|
318f407222 | ||
|
|
5f1b59be70 | ||
|
|
c622d0b1d2 | ||
|
|
65a7555e18 | ||
|
|
d054b35116 | ||
|
|
22e95e5bcb | ||
|
|
37c5788263 | ||
|
|
b8cd175ecc | ||
|
|
c94257e06c | ||
|
|
68102491e2 | ||
|
|
9c6cc4ade5 | ||
|
|
9d3ae1e169 | ||
|
|
03df4c0c2b | ||
|
|
e774ef0794 | ||
|
|
651101c253 | ||
|
|
fe81be2874 | ||
|
|
3d36f8dd30 | ||
| e83e511406 | |||
| 1f0734e7e7 | |||
|
|
1ec1cbf9cc | ||
|
|
4a08ff360f | ||
|
|
d6d891c809 | ||
| 1c643cc022 | |||
| d5ddfd20bd | |||
|
|
bb693ce463 | ||
| f0f6b3ffdb | |||
|
|
b84d0079a6 | ||
|
|
a6fa674ca5 | ||
| 701d790f97 | |||
|
|
f10914bbe7 | ||
|
|
47d132dfd7 | ||
|
|
9cf1c167e8 | ||
|
|
6dfc9454fa | ||
|
|
2eb72e4aea | ||
|
|
69802dace7 | ||
|
|
fa64d666b8 | ||
|
|
5fae20edb3 | ||
|
|
484734352d | ||
| c894924a57 | |||
|
|
5c84390b90 | ||
|
|
343a93864f | ||
|
|
d8d95a14d3 | ||
|
|
41760a0227 | ||
|
|
048665c8fb | ||
|
|
f2b08dba84 | ||
|
|
34f03725c3 | ||
| 8fa9cd34bf | |||
| efe6b988f3 |
@ -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
240
.github/workflows/pipeline.yaml
vendored
Normal 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
|
||||
41
AGENTS.md
41
AGENTS.md
@ -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
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@ -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}
|
||||
|
||||
# ---------------------------
|
||||
# 基础镜像升级到最新
|
||||
|
||||
17
README.md
17
README.md
@ -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,服务端 |
|
||||
|
||||
以下变量用于旧助手和集成页的服务端默认值预填:
|
||||
|
||||
| 变量 | 用途 |
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
|
||||
6
agent.md
6
agent.md
@ -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
332
config/feature_flags.yaml
Normal 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."
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
96
docs/architecture/web-console/overview.md
Normal file
96
docs/architecture/web-console/overview.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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. 环境变量是否已在当前运行实例生效(重启/重新部署后再测)
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
55
middleware.ts
Normal 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
2
next-env.d.ts
vendored
@ -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.
|
||||
|
||||
BIN
public/marketing/xworkmate-suite-hero.png
Normal file
BIN
public/marketing/xworkmate-suite-hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
44
scripts/github-actions/build-and-push-frontend-image.sh
Executable file
44
scripts/github-actions/build-and-push-frontend-image.sh
Executable 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}"
|
||||
@ -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}"
|
||||
|
||||
12
scripts/github-actions/configure-ssh-for-deploy.sh
Normal file
12
scripts/github-actions/configure-ssh-for-deploy.sh
Normal 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
|
||||
@ -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 \
|
||||
|
||||
7
scripts/github-actions/login-ghcr.sh
Normal file
7
scripts/github-actions/login-ghcr.sh
Normal 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
|
||||
7
scripts/github-actions/publish-frontend-image.sh
Normal file
7
scripts/github-actions/publish-frontend-image.sh
Normal 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}"
|
||||
@ -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
|
||||
|
||||
@ -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}"
|
||||
|
||||
10
scripts/github-actions/resolve-push-latest.sh
Normal file
10
scripts/github-actions/resolve-push-latest.sh
Normal 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
|
||||
17
scripts/github-actions/resolve-workflow-inputs.sh
Normal file
17
scripts/github-actions/resolve-workflow-inputs.sh
Normal 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}"
|
||||
26
scripts/github-actions/run-cloudflare-svc-plus-dns-playbook.sh
Executable file
26
scripts/github-actions/run-cloudflare-svc-plus-dns-playbook.sh
Executable 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[@]}"
|
||||
33
scripts/github-actions/run-console-deploy-playbook.sh
Normal file
33
scripts/github-actions/run-console-deploy-playbook.sh
Normal 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[@]}"
|
||||
5
scripts/github-actions/setup-docker-buildx.sh
Normal file
5
scripts/github-actions/setup-docker-buildx.sh
Normal 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
|
||||
18
scripts/github-actions/verify-frontend-release-over-ssh.sh
Normal file
18
scripts/github-actions/verify-frontend-release-over-ssh.sh
Normal 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
|
||||
@ -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
|
||||
|
||||
32
skills/release-traceability/SKILL.md
Normal file
32
skills/release-traceability/SKILL.md
Normal 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.
|
||||
62
src/app/api/account/[...segments]/route.ts
Normal file
62
src/app/api/account/[...segments]/route.ts
Normal 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)
|
||||
}
|
||||
28
src/app/api/admin/collector/[...segments]/route.ts
Normal file
28
src/app/api/admin/collector/[...segments]/route.ts
Normal 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)
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
28
src/app/api/admin/scheduler/[...segments]/route.ts
Normal file
28
src/app/api/admin/scheduler/[...segments]/route.ts
Normal 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)
|
||||
}
|
||||
28
src/app/api/admin/traffic/[...segments]/route.ts
Normal file
28
src/app/api/admin/traffic/[...segments]/route.ts
Normal 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)
|
||||
}
|
||||
69
src/app/api/auth/session/route.test.ts
Normal file
69
src/app/api/auth/session/route.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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' : '' })
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
76
src/app/api/xworkmate/bridge/route.ts
Normal file
76
src/app/api/xworkmate/bridge/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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'),
|
||||
}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
21
src/app/xworkmate-suite/page.tsx
Normal file
21
src/app/xworkmate-suite/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
340
src/components/marketing/XWorkmateSuiteMarketing.tsx
Normal file
340
src/components/marketing/XWorkmateSuiteMarketing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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
@ -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") ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: [
|
||||
|
||||
26
src/lib/accessControl.test.ts
Normal file
26
src/lib/accessControl.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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
52
src/lib/apiProxy.test.ts
Normal 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' }])
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -90,6 +90,7 @@ export type IntegrationDefaults = {
|
||||
vaultSecretKey: string
|
||||
apisixUrl: string
|
||||
apisixTokenConfigured: boolean
|
||||
externalServices?: string[]
|
||||
}
|
||||
|
||||
export function normalizeMainSessionKey(value?: string | null): string {
|
||||
|
||||
35
src/lib/publicUserIdentity.test.ts
Normal file
35
src/lib/publicUserIdentity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
src/lib/publicUserIdentity.ts
Normal file
23
src/lib/publicUserIdentity.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
src/lib/webReleaseMetadata.test.ts
Normal file
37
src/lib/webReleaseMetadata.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
22
src/lib/webReleaseMetadata.ts
Normal file
22
src/lib/webReleaseMetadata.ts
Normal 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
57
src/middleware.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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)]">
|
||||
加载订阅中…
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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">
|
||||
立即注册账号以获得持久访问权限 →
|
||||
</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 →
|
||||
</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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}`
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user