merge: integrate xworkmate multitenant into stripe pricing console

This commit is contained in:
Haitao Pan 2026-03-17 18:57:44 +08:00
commit 1769c26093
51 changed files with 3418 additions and 1021 deletions

View File

@ -27,3 +27,12 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=
# Root email whitelist for privileged user-creation actions (comma-separated)
# Default: admin@svc.plus
ROOT_EMAIL_WHITELIST=admin@svc.plus
# Stripe public price ids used by /prices, product pages, and /panel/subscription
# These values are safe to expose to the browser. Use Stripe test-mode price ids for local/dev.
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=

View File

@ -70,6 +70,21 @@ cp .env.example .env
更多说明见 `docs/getting-started/installation.md``.env.example`
## Stripe 配置 (Stripe Billing Setup)
`/prices`、产品页和账户中心的购买入口现在统一读取前端公开的 Stripe `price_id`
| 变量 | 用途 |
| -------------------------------------------------- | ------------------- |
| `NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO` | Xstream 按量购买 |
| `NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION` | Xstream 订阅 |
| `NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO` | XScopeHub 按量购买 |
| `NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION` | XScopeHub 订阅 |
| `NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO` | XCloudFlow 按量购买 |
| `NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION` | XCloudFlow 订阅 |
这些值应填写为 Stripe Dashboard 中对应套餐的 `price_...` 标识。联调步骤见 `docs/integrations/stripe-billing.md`
## 核心特性 & 技术栈 (Features & Tech Stack)
核心特性:
@ -107,6 +122,7 @@ yarn typecheck
- OIDC: `docs/integrations/oidc-auth.md`
- Cloudflare Web Analytics: `docs/integrations/cloudflare-web-analytics.md`
- Stripe billing: `docs/integrations/stripe-billing.md`
- Assistant / Integrations env setup: `docs/getting-started/installation.md`
- Chinese installation guide: `docs/zh/getting-started/installation.md`

View File

@ -0,0 +1,51 @@
# Stripe Billing Integration
This console now routes all purchase entry points through Stripe:
- `/prices`
- product detail pages
- `/panel/subscription`
The browser only needs public Stripe `price_id` values. Secret keys stay in `accounts.svc.plus`.
## Required Environment Variables
Set these in `console.svc.plus`:
```bash
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=price_xxx
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=price_xxx
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=price_xxx
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=price_xxx
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=price_xxx
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=price_xxx
```
If a value is missing, the related purchase button stays visible but reports that Stripe pricing is not configured.
## Local Integration Checklist
1. Configure all `NEXT_PUBLIC_STRIPE_PRICE_*` values with Stripe test-mode `price_...` ids.
2. Start `accounts.svc.plus` with Stripe server-side settings.
3. Start this console with `yarn dev`.
4. Sign in with a normal user account.
5. Open `/prices` or `/panel/subscription` and start checkout.
6. Complete a Stripe test payment.
7. Confirm the browser returns to `/panel/subscription?checkout=success...`.
8. Confirm the subscription record appears in the subscription panel.
9. Open "Manage Stripe billing" and confirm the customer portal opens.
## Expected Flow
1. The console calls `/api/auth/stripe/checkout`.
2. The BFF proxies the request to `accounts.svc.plus` using the current account session.
3. `accounts.svc.plus` creates the Stripe Checkout Session.
4. Stripe redirects back to the console.
5. Stripe webhooks update the account service subscription record.
6. The console reads the final state from `/api/auth/subscriptions`.
## Notes
- The console does not store Stripe secret keys.
- Sensitive payment methods such as crypto QR flows are intentionally removed from the purchase UI.
- Use Stripe test mode first; do not validate this flow against live prices until webhook delivery is confirmed.

60
scripts/skills/package_skill.py Executable file
View File

@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Package a skill folder into a distributable .skill archive."""
from __future__ import annotations
import sys
import zipfile
from pathlib import Path
from validate_skill import validate_skill
def should_include(file_path: Path) -> bool:
if "__pycache__" in file_path.parts:
return False
if file_path.suffix == ".pyc":
return False
return True
def package_skill(skill_path: str | Path, output_dir: str | Path | None = None) -> Path:
skill_dir = Path(skill_path).resolve()
if not skill_dir.exists():
raise FileNotFoundError(f"Skill folder not found: {skill_dir}")
if not skill_dir.is_dir():
raise NotADirectoryError(f"Path is not a directory: {skill_dir}")
valid, message = validate_skill(skill_dir)
if not valid:
raise ValueError(message)
destination = Path(output_dir).resolve() if output_dir else Path.cwd()
destination.mkdir(parents=True, exist_ok=True)
output_path = destination / f"{skill_dir.name}.skill"
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as archive:
for file_path in skill_dir.rglob("*"):
if file_path.is_file() and should_include(file_path):
archive.write(file_path, file_path.relative_to(skill_dir.parent))
return output_path
def main() -> int:
if len(sys.argv) < 2 or len(sys.argv) > 3:
print("Usage: package_skill.py <path/to/skill-folder> [output-directory]")
return 1
try:
output_path = package_skill(sys.argv[1], sys.argv[2] if len(sys.argv) == 3 else None)
except Exception as exc: # pragma: no cover - command-line wrapper
print(f"Error: {exc}")
return 1
print(output_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Minimal ClawHub-style skill validation."""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
ALLOWED_PROPERTIES = {"name", "description", "license", "allowed-tools", "metadata"}
def validate_skill(skill_path: str | Path) -> tuple[bool, str]:
skill_dir = Path(skill_path)
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
return False, "SKILL.md not found"
content = skill_md.read_text(encoding="utf-8")
if not content.startswith("---"):
return False, "No YAML frontmatter found"
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not match:
return False, "Invalid frontmatter format"
try:
frontmatter = yaml.safe_load(match.group(1))
except yaml.YAMLError as exc:
return False, f"Invalid YAML in frontmatter: {exc}"
if not isinstance(frontmatter, dict):
return False, "Frontmatter must be a YAML dictionary"
unexpected = set(frontmatter.keys()) - ALLOWED_PROPERTIES
if unexpected:
return (
False,
"Unexpected key(s) in SKILL.md frontmatter: "
+ ", ".join(sorted(unexpected))
+ ". Allowed properties are: "
+ ", ".join(sorted(ALLOWED_PROPERTIES)),
)
for key in ("name", "description"):
if key not in frontmatter:
return False, f"Missing '{key}' in frontmatter"
name = str(frontmatter["name"]).strip()
if not re.fullmatch(r"[a-z0-9-]+", name) or name.startswith("-") or name.endswith("-") or "--" in name:
return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)"
if len(name) > 64:
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters."
description = str(frontmatter["description"]).strip()
if "<" in description or ">" in description:
return False, "Description cannot contain angle brackets (< or >)"
if len(description) > 1024:
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters."
return True, "Skill is valid!"
def main() -> int:
if len(sys.argv) != 2:
print("Usage: validate_skill.py <skill-directory>")
return 1
valid, message = validate_skill(sys.argv[1])
print(message)
return 0 if valid else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,190 @@
---
name: git-history-secret-remediation
description: Use when a user asks to detect secrets in git commit history, clean tracked sensitive data, rewrite history with git-filter-repo, or verify cleanup with gitleaks. Covers gitleaks detect -v, replacement mapping, path removal, ref inventory, history rewrites, force-push planning, and post-cleanup coordination.
license: Internal use only
metadata:
owner: cloud-neutral-toolkit
distribution: clawhub-compatible
package-format: .skill
---
# Git History Secret Remediation
Use this skill when secrets have already been committed and the task is to inspect, scrub, verify, and coordinate git history cleanup.
Core tools:
- `gitleaks detect -v`
- `git filter-repo`
Bundled scripts:
- `scripts/list_git_refs.sh`
- `scripts/run_gitleaks_history_scan.sh`
- `scripts/backup_git_remotes.py`
- `scripts/restore_git_remotes.py`
- `scripts/run_filter_repo_redaction.sh`
- `scripts/run_history_remediation.sh`
## When To Use
Trigger this skill when the user asks to:
- scan commit history for secrets
- run `gitleaks detect -v`
- remove passwords, API keys, tokens, or private keys from git history
- run `git filter-repo`
- clean up old commits after a leak
- rewrite history and force-push the cleaned repository
## Safety Rules
1. Clean current `HEAD` first, then rewrite history.
2. Rotate real leaked credentials out-of-band. History cleanup is not secret rotation.
3. Prefer empty values or angle-bracket placeholders in tracked samples.
4. Do not use fake secret-looking placeholders such as `` when scanners still match them.
5. Treat history rewrite as destructive:
- inventory refs first
- expect force-push
- warn that teammates must reclone or fully scrub old clones
6. Back up `git remote -v` before rewrite and restore it after rewrite or force-push preparation.
## Workflow
### 1. Inventory refs
At repo root:
```bash
bash skills/git-history-secret-remediation/scripts/list_git_refs.sh /path/to/repo
```
This tells you which branches and tags may need to be force-pushed after rewriting.
### 2. Run the history scan
Use the bundled wrapper:
```bash
bash skills/git-history-secret-remediation/scripts/run_gitleaks_history_scan.sh /path/to/repo
```
Behavior:
- auto-detects `config/gitleaks.toml` when present
- otherwise runs `gitleaks detect -v` with tool defaults
Classify findings into:
- current-file leaks still present in `HEAD`
- history-only leaks from deleted or renamed files
### 3. Sanitize current HEAD
Before rewriting history:
- replace real secrets in tracked sample/config files
- prefer:
- `""`
- empty env values
- `<OPENSSH_PRIVATE_KEY_CONTENT>`
- keep real values only in local `.env` or a secret manager
### 4. Build a replace-text file
Create a temporary mapping file, for example:
```text
real-secret-1==>
real-secret-2==>
OPENSSH_PRIVATE_KEY_BEGIN_LINE==><OPENSSH_PRIVATE_KEY_BEGIN_LINE>
OPENSSH_PRIVATE_KEY_END_LINE==><OPENSSH_PRIVATE_KEY_END_LINE>
```
Notes:
- default replacement can be empty
- use explicit placeholders only when file syntax requires visible text
- if an old placeholder also triggers scanners, run a second rewrite replacing it with an empty string
### 5. Remove history-only artifact files when appropriate
If a file exists only as a leak artifact, prefer removing it from history entirely.
Examples:
- `leaks_github.json`
- obsolete docs that embed private-key examples
- scratch backup files that contain real credentials
### 6. Rewrite history
Use the bundled wrapper:
```bash
bash skills/git-history-secret-remediation/scripts/run_filter_repo_redaction.sh \
/path/to/repo \
/tmp/replace-text.txt \
[path-to-remove...]
```
Behavior:
- backs up `git remote -v` metadata before rewriting
- restores remotes after rewriting if needed
- runs `git filter-repo --force --sensitive-data-removal --no-fetch`
- clears `.git/filter-repo/already_ran` when present
- optionally removes listed paths from history with `--invert-paths`
### 6b. Single-command remediation
If you already know the replacement mapping and the paths to purge, use the orchestrator:
```bash
bash skills/git-history-secret-remediation/scripts/run_history_remediation.sh \
/path/to/repo \
/tmp/replace-text.txt \
[path-to-remove...]
```
Behavior:
- inventories refs
- runs a pre-scan
- rewrites history
- restores remotes
- re-runs `gitleaks`
- exits non-zero until the repo scans clean
### 7. Re-run gitleaks
Repeat until:
- real secrets are gone from all commits
- remaining findings, if any, are only deliberate placeholders you explicitly accept
### 8. Push rewritten refs
For normal repos with all relevant local branches:
```bash
git push --force origin --all
git push --force origin --tags
```
If the remote has important branches not present locally:
- create local tracking branches first
- or do the rewrite in a fresh mirror clone and push from there
Do not assume a normal non-bare clone can safely use `git push --mirror`.
### 9. Post-cleanup coordination
Always tell the user to:
- rotate leaked credentials
- purge or invalidate old access where relevant
- have other clones recloned or scrubbed
- notify repo admins if server-side cache or object cleanup is needed
- use the remote backup JSON when reconstructing remotes after force-push in a fresh clone

View File

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Back up git remote fetch/push URLs to JSON."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def run(repo_path: str, *args: str) -> str:
return subprocess.check_output(["git", "-C", repo_path, *args], text=True).strip()
def main() -> int:
if len(sys.argv) != 3:
print("Usage: backup_git_remotes.py <repo-path> <output-json>", file=sys.stderr)
return 1
repo_path, output_json = sys.argv[1], sys.argv[2]
remotes = run(repo_path, "remote").splitlines()
payload: dict[str, dict[str, list[str]]] = {}
for remote in remotes:
remote = remote.strip()
if not remote:
continue
fetch_urls = run(repo_path, "remote", "get-url", "--all", remote).splitlines()
try:
push_urls = run(repo_path, "remote", "get-url", "--push", "--all", remote).splitlines()
except subprocess.CalledProcessError:
push_urls = fetch_urls
payload[remote] = {
"fetch": [url for url in fetch_urls if url],
"push": [url for url in push_urls if url],
}
output_path = Path(output_json)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(output_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <repo-path>" >&2
exit 1
fi
repo_path=$1
if [[ ! -d "$repo_path/.git" ]]; then
echo "Error: not a git repository: $repo_path" >&2
exit 1
fi
git -C "$repo_path" for-each-ref --format='%(refname)' refs/heads refs/tags refs/remotes/origin

View File

@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Restore git remote fetch/push URLs from JSON."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def git(repo_path: str, *args: str) -> None:
subprocess.check_call(["git", "-C", repo_path, *args])
def main() -> int:
if len(sys.argv) != 3:
print("Usage: restore_git_remotes.py <repo-path> <input-json>", file=sys.stderr)
return 1
repo_path, input_json = sys.argv[1], sys.argv[2]
data = json.loads(Path(input_json).read_text(encoding="utf-8"))
for remote, urls in data.items():
fetch_urls = urls.get("fetch") or []
push_urls = urls.get("push") or []
if not fetch_urls:
continue
existing = subprocess.run(
["git", "-C", repo_path, "remote", "get-url", remote],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if existing.returncode != 0:
git(repo_path, "remote", "add", remote, fetch_urls[0])
else:
git(repo_path, "remote", "set-url", remote, fetch_urls[0])
for url in fetch_urls[1:]:
git(repo_path, "remote", "set-url", "--add", remote, url)
if push_urls:
git(repo_path, "remote", "set-url", "--push", remote, push_urls[0])
for url in push_urls[1:]:
git(repo_path, "remote", "set-url", "--push", "--add", remote, url)
print(input_json)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <repo-path> <replace-text-file> [path-to-remove...]" >&2
exit 1
fi
repo_path=$1
replace_text_file=$2
shift 2
remove_paths=("$@")
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
backup_dir="$repo_path/.git/filter-repo"
remote_backup_json="$backup_dir/remotes.backup.json"
if [[ ! -d "$repo_path/.git" ]]; then
echo "Error: not a git repository: $repo_path" >&2
exit 1
fi
if [[ ! -f "$replace_text_file" ]]; then
echo "Error: replace-text file not found: $replace_text_file" >&2
exit 1
fi
if ! command -v git-filter-repo >/dev/null 2>&1 && ! command -v git >/dev/null 2>&1; then
echo "Error: git-filter-repo is not installed." >&2
exit 1
fi
python3 - "$repo_path" <<'PY'
from pathlib import Path
import sys
marker = Path(sys.argv[1]) / ".git/filter-repo/already_ran"
if marker.exists():
marker.unlink()
PY
python3 "$script_dir/backup_git_remotes.py" "$repo_path" "$remote_backup_json" >/dev/null
cmd=(
git
-C "$repo_path"
filter-repo
--force
--sensitive-data-removal
--no-fetch
--replace-text "$replace_text_file"
)
if [[ ${#remove_paths[@]} -gt 0 ]]; then
for path in "${remove_paths[@]}"; do
cmd+=(--path "$path")
done
cmd+=(--invert-paths)
fi
"${cmd[@]}"
python3 "$script_dir/restore_git_remotes.py" "$repo_path" "$remote_backup_json" >/dev/null

View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "Usage: $0 <repo-path> [gitleaks-config-path]" >&2
exit 1
fi
repo_path=$1
config_path=${2:-}
if [[ ! -d "$repo_path/.git" ]]; then
echo "Error: not a git repository: $repo_path" >&2
exit 1
fi
if ! command -v gitleaks >/dev/null 2>&1; then
echo "Error: gitleaks is not installed or not in PATH." >&2
exit 1
fi
config_args=()
if [[ -n "$config_path" ]]; then
config_args=(--config "$config_path")
elif [[ -f "$repo_path/config/gitleaks.toml" ]]; then
config_args=(--config "$repo_path/config/gitleaks.toml")
fi
(
cd "$repo_path"
gitleaks detect -v "${config_args[@]}"
)

View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <repo-path> <replace-text-file> [path-to-remove...]" >&2
exit 1
fi
repo_path=$1
replace_text_file=$2
shift 2
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "[1/4] Inventory refs"
bash "$script_dir/list_git_refs.sh" "$repo_path"
echo "[2/4] Pre-scan"
if ! bash "$script_dir/run_gitleaks_history_scan.sh" "$repo_path"; then
echo "Pre-scan found leaks. Continuing to remediation..." >&2
fi
echo "[3/4] Rewrite history"
bash "$script_dir/run_filter_repo_redaction.sh" "$repo_path" "$replace_text_file" "$@"
echo "[4/4] Post-scan"
bash "$script_dir/run_gitleaks_history_scan.sh" "$repo_path"

View File

@ -74,16 +74,13 @@ export default function LoginContent({
`${accountServiceBaseUrl}/api/auth/login`;
const socialButtonsDisabled = false;
const githubAuthUrl = `${accountServiceBaseUrl}/api/auth/oauth/login/github`;
const googleAuthUrl = `${accountServiceBaseUrl}/api/auth/oauth/login/google`;
const githubAuthUrl = "/api/auth/oauth/login/github";
const googleAuthUrl = "/api/auth/oauth/login/google";
useEffect(() => {
const publicToken = searchParams.get("public_token");
const userId = searchParams.get("userId");
const email = searchParams.get("email");
const role = searchParams.get("role");
const exchangeCode = searchParams.get("exchange_code");
if (!publicToken || !userId || !email) {
if (!exchangeCode) {
return;
}
@ -101,10 +98,7 @@ export default function LoginContent({
"Content-Type": "application/json",
},
body: JSON.stringify({
publicToken,
userId,
email,
role: role || "user",
exchangeCode,
}),
});

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { usePathname } from "next/navigation";
import { ThemeProvider } from "../components/theme";
import { LanguageProvider } from "../i18n/LanguageProvider";
@ -19,18 +19,44 @@ export function AppProviders({
}) {
const { isOpen, isMinimized, close, toggleOpen } = useMoltbotStore();
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults);
const setScope = useOpenClawConsoleStore((state) => state.setScope);
const pathname = usePathname();
const [isMobileViewport, setIsMobileViewport] = useState(false);
const isOpenClawWorkspace =
pathname.startsWith("/xworkmate") ||
pathname.startsWith("/services/openclaw");
// Always reserve space if open and not minimized, since we only have "Float/Sidebar" mode now
// and user wants it to NEVER cover the homepage.
const reserveSpace = !isOpenClawWorkspace && isOpen && !isMinimized;
const reserveSpace =
!isOpenClawWorkspace && isOpen && !isMinimized && !isMobileViewport;
useEffect(() => {
setScope("global", assistantDefaults);
applyDefaults(assistantDefaults);
}, [applyDefaults, assistantDefaults]);
}, [applyDefaults, assistantDefaults, setScope]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 1023px)");
const syncViewport = () => {
setIsMobileViewport(mediaQuery.matches);
};
syncViewport();
mediaQuery.addEventListener("change", syncViewport);
return () => {
mediaQuery.removeEventListener("change", syncViewport);
};
}, []);
useEffect(() => {
if (isMobileViewport && !isOpenClawWorkspace) {
close();
}
}, [close, isMobileViewport, isOpenClawWorkspace]);
return (
<ThemeProvider>
@ -40,7 +66,7 @@ export function AppProviders({
style={
{
"--assistant-reserve-offset": reserveSpace ? "400px" : "0px",
} as React.CSSProperties
} as CSSProperties
}
className={cn(
"flex-1 flex flex-col relative w-full overflow-hidden transition-[padding] duration-300 ease-in-out",

View File

@ -3,20 +3,18 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
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
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
@ -25,12 +23,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
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({

View File

@ -3,20 +3,18 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
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
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
@ -25,12 +23,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
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 {

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
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', 'operator']
const WRITE_PERMISSIONS = ['admin.users.pause.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
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', 'operator']
const WRITE_PERMISSIONS = ['admin.users.renew_uuid.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
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', 'operator']
const WRITE_PERMISSIONS = ['admin.users.resume.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
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.users.role.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params
@ -78,8 +84,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
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', 'operator']
const DELETE_PERMISSIONS = ['admin.users.delete.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: DELETE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
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.users.role.write']
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
@ -48,10 +50,6 @@ function normalizeGroups(value: unknown): string[] | null {
return Array.from(new Set(result))
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
@ -60,12 +58,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
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 body = (await request.json().catch(() => null)) as CreateUserBody | null

View File

@ -1,123 +1,175 @@
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { applyMfaCookie, applySessionCookie, clearMfaCookie, clearSessionCookie, deriveMaxAgeFromExpires, MFA_COOKIE_NAME } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import {
applyMfaCookie,
applySessionCookie,
clearMfaCookie,
clearSessionCookie,
deriveMaxAgeFromExpires,
MFA_COOKIE_NAME,
} from "@lib/authGateway";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
type LoginPayload = {
email?: string
password?: string
remember?: boolean
totp?: string
code?: string
token?: string
}
email?: string;
password?: string;
remember?: boolean;
totp?: string;
code?: string;
token?: string;
};
type AccountLoginResponse = {
token?: string
expiresAt?: string
error?: string
mfaToken?: string
needMfa?: boolean
mfaEnabled?: boolean
}
token?: string;
expiresAt?: string;
error?: string;
mfaToken?: string;
needMfa?: boolean;
mfaEnabled?: boolean;
};
function normalizeEmail(value: unknown) {
return typeof value === 'string' ? value.trim().toLowerCase() : ''
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function normalizeCode(value: unknown) {
return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : ''
return typeof value === "string" ? value.replace(/\D/g, "").slice(0, 6) : "";
}
export async function POST(request: NextRequest) {
let payload: LoginPayload
let payload: LoginPayload;
try {
payload = (await request.json()) as LoginPayload
payload = (await request.json()) as LoginPayload;
} catch (error) {
console.error('Failed to decode login payload', error)
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
console.error("Failed to decode login payload", error);
return NextResponse.json(
{ success: false, error: "invalid_request", needMfa: false },
{ status: 400 },
);
}
const email = normalizeEmail(payload?.email)
const password = typeof payload?.password === 'string' ? payload.password : ''
const totpCode = normalizeCode(payload?.totp ?? payload?.code)
const remember = Boolean(payload?.remember)
const email = normalizeEmail(payload?.email);
const password =
typeof payload?.password === "string" ? payload.password : "";
const totpCode = normalizeCode(payload?.totp ?? payload?.code);
const remember = Boolean(payload?.remember);
if (!email || !password) {
return NextResponse.json({ success: false, error: 'missing_credentials', needMfa: false }, { status: 400 })
return NextResponse.json(
{ success: false, error: "missing_credentials", needMfa: false },
{ status: 400 },
);
}
try {
const loginBody: Record<string, string> = { email, password }
const loginBody: Record<string, string> = { email, password };
if (totpCode) {
loginBody.totpCode = totpCode
loginBody.totpCode = totpCode;
}
const response = await fetch(`${ACCOUNT_API_BASE}/login`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(loginBody),
cache: 'no-store',
})
cache: "no-store",
});
const data = (await response.json().catch(() => ({}))) as AccountLoginResponse
const data = (await response
.json()
.catch(() => ({}))) as AccountLoginResponse;
if (response.ok && typeof data?.token === 'string' && data.token.length > 0) {
const maxAgeFromBackend = deriveMaxAgeFromExpires(data?.expiresAt)
const effectiveMaxAge = remember ? Math.max(maxAgeFromBackend, 60 * 60 * 24 * 30) : maxAgeFromBackend
const result = NextResponse.json({ success: true, error: null, needMfa: false })
applySessionCookie(result, data.token, effectiveMaxAge)
clearMfaCookie(result)
return result
if (
response.ok &&
typeof data?.token === "string" &&
data.token.length > 0
) {
const maxAgeFromBackend = deriveMaxAgeFromExpires(data?.expiresAt);
const effectiveMaxAge = remember
? Math.max(maxAgeFromBackend, 60 * 60 * 24 * 30)
: maxAgeFromBackend;
const result = NextResponse.json({
success: true,
error: null,
needMfa: false,
});
applySessionCookie(
result,
data.token,
effectiveMaxAge,
request.headers.get("host") ?? undefined,
);
clearMfaCookie(result);
return result;
}
const errorCode = typeof data?.error === 'string' ? data.error : 'authentication_failed'
const needsMfa = Boolean(data?.needMfa || errorCode === 'mfa_required' || errorCode === 'mfa_setup_required')
const errorCode =
typeof data?.error === "string" ? data.error : "authentication_failed";
const needsMfa = Boolean(
data?.needMfa ||
errorCode === "mfa_required" ||
errorCode === "mfa_setup_required",
);
if ((response.status === 401 || response.status === 403 || needsMfa) && typeof data?.mfaToken === 'string') {
const result = NextResponse.json({ success: false, error: errorCode, needMfa: true }, { status: 401 })
applyMfaCookie(result, data.mfaToken)
clearSessionCookie(result)
return result
if (
(response.status === 401 || response.status === 403 || needsMfa) &&
typeof data?.mfaToken === "string"
) {
const result = NextResponse.json(
{ success: false, error: errorCode, needMfa: true },
{ status: 401 },
);
applyMfaCookie(result, data.mfaToken);
clearSessionCookie(result, request.headers.get("host") ?? undefined);
return result;
}
const statusCode = response.status || 401
const result = NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: statusCode })
clearSessionCookie(result)
clearMfaCookie(result)
return result
const statusCode = response.status || 401;
const result = NextResponse.json(
{ success: false, error: errorCode, needMfa: false },
{ status: statusCode },
);
clearSessionCookie(result, request.headers.get("host") ?? undefined);
clearMfaCookie(result);
return result;
} catch (error) {
console.error('Account service login proxy failed', error)
const result = NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: false }, { status: 502 })
clearSessionCookie(result)
clearMfaCookie(result)
return result
console.error("Account service login proxy failed", error);
const result = NextResponse.json(
{ success: false, error: "account_service_unreachable", needMfa: false },
{ status: 502 },
);
clearSessionCookie(result, request.headers.get("host") ?? undefined);
clearMfaCookie(result);
return result;
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed', needMfa: false },
{ success: false, error: "method_not_allowed", needMfa: false },
{
status: 405,
headers: {
Allow: 'POST',
Allow: "POST",
},
},
)
);
}
export async function DELETE() {
const cookieStore = await cookies()
const response = NextResponse.json({ success: true, error: null, needMfa: false })
const cookieStore = await cookies();
const response = NextResponse.json({
success: true,
error: null,
needMfa: false,
});
if (cookieStore.has(MFA_COOKIE_NAME)) {
clearMfaCookie(response)
clearMfaCookie(response);
}
clearSessionCookie(response)
return response
clearSessionCookie(response);
return response;
}

View File

@ -1,54 +1,66 @@
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, clearSessionCookie } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
export async function POST(request: NextRequest) {
void request
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim()
void request;
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim();
if (!token) {
return NextResponse.json({ success: false, error: 'session_required' }, { status: 401 })
return NextResponse.json(
{ success: false, error: "session_required" },
{ status: 401 },
);
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/mfa/disable`, {
method: 'POST',
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
})
cache: "no-store",
});
const data = await response.json().catch(() => ({}))
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'mfa_disable_failed'
const errorCode =
typeof (data as { error?: string })?.error === "string"
? data.error
: "mfa_disable_failed";
if (response.status === 401) {
const result = NextResponse.json({ success: false, error: errorCode })
clearSessionCookie(result)
return result
const result = NextResponse.json({ success: false, error: errorCode });
clearSessionCookie(result, request.headers.get("host") ?? undefined);
return result;
}
return NextResponse.json({ success: false, error: errorCode }, { status: response.status || 400 })
return NextResponse.json(
{ success: false, error: errorCode },
{ status: response.status || 400 },
);
}
return NextResponse.json({ success: true, error: null, data })
return NextResponse.json({ success: true, error: null, data });
} catch (error) {
console.error('Account service MFA disable proxy failed', error)
return NextResponse.json({ success: false, error: 'account_service_unreachable' }, { status: 502 })
console.error("Account service MFA disable proxy failed", error);
return NextResponse.json(
{ success: false, error: "account_service_unreachable" },
{ status: 502 },
);
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed' },
{ success: false, error: "method_not_allowed" },
{
status: 405,
headers: {
Allow: 'POST',
Allow: "POST",
},
},
)
);
}

View File

@ -1,5 +1,5 @@
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import {
applyMfaCookie,
@ -8,107 +8,136 @@ import {
clearSessionCookie,
deriveMaxAgeFromExpires,
MFA_COOKIE_NAME,
} from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
} from "@lib/authGateway";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
type VerifyPayload = {
token?: string
code?: string
totp?: string
}
token?: string;
code?: string;
totp?: string;
};
type AccountVerifyResponse = {
token?: string
expiresAt?: string
mfaToken?: string
error?: string
retryAt?: string
user?: Record<string, unknown> | null
mfa?: Record<string, unknown> | null
}
token?: string;
expiresAt?: string;
mfaToken?: string;
error?: string;
retryAt?: string;
user?: Record<string, unknown> | null;
mfa?: Record<string, unknown> | null;
};
function normalizeString(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
return typeof value === "string" ? value.trim() : "";
}
function normalizeCode(value: unknown) {
return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : ''
return typeof value === "string" ? value.replace(/\D/g, "").slice(0, 6) : "";
}
export async function POST(request: NextRequest) {
const cookieStore = await cookies()
let payload: VerifyPayload
const cookieStore = await cookies();
let payload: VerifyPayload;
try {
payload = (await request.json()) as VerifyPayload
payload = (await request.json()) as VerifyPayload;
} catch (error) {
console.error('Failed to decode MFA verification payload', error)
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: true }, { status: 400 })
console.error("Failed to decode MFA verification payload", error);
return NextResponse.json(
{ success: false, error: "invalid_request", needMfa: true },
{ status: 400 },
);
}
const cookieToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''
const token = normalizeString(payload?.token || cookieToken)
const code = normalizeCode(payload?.code ?? payload?.totp)
const cookieToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? "";
const token = normalizeString(payload?.token || cookieToken);
const code = normalizeCode(payload?.code ?? payload?.totp);
if (!token) {
return NextResponse.json({ success: false, error: 'mfa_token_required', needMfa: true }, { status: 400 })
return NextResponse.json(
{ success: false, error: "mfa_token_required", needMfa: true },
{ status: 400 },
);
}
if (!code) {
return NextResponse.json({ success: false, error: 'mfa_code_required', needMfa: true }, { status: 400 })
return NextResponse.json(
{ success: false, error: "mfa_code_required", needMfa: true },
{ status: 400 },
);
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/mfa/totp/verify`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ token, code }),
cache: 'no-store',
})
cache: "no-store",
});
const data = (await response.json().catch(() => ({}))) as AccountVerifyResponse
const data = (await response
.json()
.catch(() => ({}))) as AccountVerifyResponse;
if (response.ok && typeof data?.token === 'string' && data.token.length > 0) {
const result = NextResponse.json({ success: true, error: null, needMfa: false, data })
applySessionCookie(result, data.token, deriveMaxAgeFromExpires(data?.expiresAt))
clearMfaCookie(result)
return result
if (
response.ok &&
typeof data?.token === "string" &&
data.token.length > 0
) {
const result = NextResponse.json({
success: true,
error: null,
needMfa: false,
data,
});
applySessionCookie(
result,
data.token,
deriveMaxAgeFromExpires(data?.expiresAt),
request.headers.get("host") ?? undefined,
);
clearMfaCookie(result);
return result;
}
const errorCode = typeof data?.error === 'string' ? data.error : 'mfa_verification_failed'
const errorCode =
typeof data?.error === "string" ? data.error : "mfa_verification_failed";
const result = NextResponse.json(
{ success: false, error: errorCode, needMfa: true, data },
{ status: response.status || 400 },
)
);
if (typeof data?.mfaToken === 'string' && data.mfaToken.trim()) {
applyMfaCookie(result, data.mfaToken)
if (typeof data?.mfaToken === "string" && data.mfaToken.trim()) {
applyMfaCookie(result, data.mfaToken);
} else {
applyMfaCookie(result, token)
applyMfaCookie(result, token);
}
clearSessionCookie(result)
return result
clearSessionCookie(result, request.headers.get("host") ?? undefined);
return result;
} catch (error) {
console.error('Account service MFA verification proxy failed', error)
const result = NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: true }, { status: 502 })
applyMfaCookie(result, token)
clearSessionCookie(result)
return result
console.error("Account service MFA verification proxy failed", error);
const result = NextResponse.json(
{ success: false, error: "account_service_unreachable", needMfa: true },
{ status: 502 },
);
applyMfaCookie(result, token);
clearSessionCookie(result, request.headers.get("host") ?? undefined);
return result;
}
}
export function GET() {
return NextResponse.json(
{ success: false, error: 'method_not_allowed', needMfa: true },
{ success: false, error: "method_not_allowed", needMfa: true },
{
status: 405,
headers: {
Allow: 'POST',
Allow: "POST",
},
},
)
);
}

View File

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const ALLOWED_PROVIDERS = new Set(["github", "google"]);
export async function GET(
request: NextRequest,
context: { params: Promise<{ provider: string }> },
) {
const { provider } = await context.params;
const normalizedProvider = provider.trim().toLowerCase();
if (!ALLOWED_PROVIDERS.has(normalizedProvider)) {
return NextResponse.json({ error: "provider_not_found" }, { status: 404 });
}
const target = new URL(
`${ACCOUNT_API_BASE}/oauth/login/${normalizedProvider}`,
);
target.searchParams.set("frontend_url", request.nextUrl.origin);
return NextResponse.redirect(target, { status: 307 });
}

View File

@ -1,116 +1,131 @@
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
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 { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway";
import {
getAccountServiceApiBaseUrl,
getAccountServiceBaseUrl,
} from "@server/serviceConfig";
import {
buildInternalServiceHeaders,
isServiceTokenConfigured,
} from "@server/internalServiceAuth";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const ACCOUNT_BASE = getAccountServiceBaseUrl()
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
mfaEnabled?: boolean
mfaPending?: boolean
id?: string;
uuid?: string;
proxyUuid?: string;
proxyUuidExpiresAt?: string;
name?: string;
username?: string;
email: string;
mfaEnabled?: boolean;
mfaPending?: boolean;
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
totpSecretIssuedAt?: string
totpConfirmedAt?: string
totpLockedUntil?: string
}
role?: string
groups?: string[]
permissions?: string[]
readOnly?: boolean
tenantId?: string
totpEnabled?: boolean;
totpPending?: boolean;
totpSecretIssuedAt?: string;
totpConfirmedAt?: string;
totpLockedUntil?: string;
};
role?: string;
groups?: string[];
permissions?: string[];
readOnly?: boolean;
tenantId?: string;
tenants?: Array<{
id?: string
name?: string
role?: string
}>
}
id?: string;
name?: string;
role?: string;
}>;
};
type SessionResponse = {
user?: AccountUser | null
error?: string
}
user?: AccountUser | null;
error?: string;
};
type SandboxGuestResponse = {
email?: string
proxyUuid?: string
proxyUuidExpiresAt?: string
error?: string
}
email?: string;
proxyUuid?: string;
proxyUuidExpiresAt?: string;
error?: string;
};
function normalizeRole(role: unknown): string {
if (typeof role !== 'string') {
return 'user'
if (typeof role !== "string") {
return "user";
}
const normalized = role.trim().toLowerCase()
const normalized = role.trim().toLowerCase();
if (!normalized) {
return 'user'
return "user";
}
if (normalized === 'root' || normalized === 'super_admin') {
return 'admin'
if (normalized === "root" || normalized === "super_admin") {
return "admin";
}
if (normalized === 'readonly' || normalized === 'read_only') {
return 'user'
if (normalized === "readonly" || normalized === "read_only") {
return "user";
}
return normalized
return normalized;
}
async function fetchSession(token: string) {
async function fetchSession(token: string, requestHost?: string | null) {
try {
const response = await fetch(`${ACCOUNT_API_BASE}/session`, {
headers: {
Authorization: `Bearer ${token}`,
...(requestHost && requestHost.trim().length > 0
? {
"X-Forwarded-Host": requestHost.trim(),
}
: {}),
},
cache: 'no-store',
})
cache: "no-store",
});
const data = (await response.json().catch(() => ({}))) as SessionResponse
return { response, data }
const data = (await response.json().catch(() => ({}))) as SessionResponse;
return { response, data };
} catch (error) {
console.error('Session lookup proxy failed', error)
return { response: null, data: null }
console.error("Session lookup proxy failed", error);
return { response: null, data: null };
}
}
async function fetchSandboxGuest(): Promise<AccountUser | null> {
if (!isServiceTokenConfigured()) {
return null
return null;
}
try {
const response = await fetch(`${ACCOUNT_BASE}/api/internal/sandbox/guest`, {
method: 'GET',
method: "GET",
headers: buildInternalServiceHeaders({
Accept: 'application/json',
Accept: "application/json",
}),
cache: 'no-store',
})
cache: "no-store",
});
if (!response.ok) {
return null
return null;
}
const payload = (await response.json().catch(() => null)) as SandboxGuestResponse | null
const proxyUuid = typeof payload?.proxyUuid === 'string' ? payload.proxyUuid.trim() : ''
const payload = (await response
.json()
.catch(() => null)) as SandboxGuestResponse | null;
const proxyUuid =
typeof payload?.proxyUuid === "string" ? payload.proxyUuid.trim() : "";
if (!proxyUuid) {
return null
return null;
}
const proxyUuidExpiresAt =
typeof payload?.proxyUuidExpiresAt === 'string' && payload.proxyUuidExpiresAt.trim().length > 0
typeof payload?.proxyUuidExpiresAt === "string" &&
payload.proxyUuidExpiresAt.trim().length > 0
? payload.proxyUuidExpiresAt.trim()
: undefined
: undefined;
// Shape this as a pseudo-session user for the Guest/Demo experience.
return {
@ -118,135 +133,162 @@ async function fetchSandboxGuest(): Promise<AccountUser | null> {
uuid: proxyUuid,
proxyUuid,
proxyUuidExpiresAt,
name: 'Guest user',
username: 'guest',
email: 'sandbox@svc.plus',
role: 'guest',
groups: ['guest', 'sandbox'],
permissions: ['read'],
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' }],
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
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
void request;
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
if (!token) {
const sandboxGuest = await fetchSandboxGuest()
return NextResponse.json({ user: sandboxGuest })
const sandboxGuest = await fetchSandboxGuest();
return NextResponse.json({ user: sandboxGuest });
}
const { response, data } = await fetchSession(token)
const requestHost = request.headers.get("host");
const { response, data } = await fetchSession(token, requestHost);
if (!response || !response.ok || !data?.user) {
const res = NextResponse.json({ user: null })
clearSessionCookie(res)
return res
const res = NextResponse.json({ user: null });
clearSessionCookie(res, requestHost ?? undefined);
return res;
}
const rawUser = data.user as AccountUser
const rawUser = data.user as AccountUser;
const identifier =
typeof rawUser.uuid === 'string' && rawUser.uuid.trim().length > 0
typeof rawUser.uuid === "string" && rawUser.uuid.trim().length > 0
? rawUser.uuid.trim()
: typeof rawUser.id === 'string'
: typeof rawUser.id === "string"
? rawUser.id.trim()
: undefined
: undefined;
const rawMfa = rawUser.mfa ?? {}
const derivedMfaEnabled = Boolean(rawUser.mfaEnabled ?? rawMfa.totpEnabled)
const rawMfa = rawUser.mfa ?? {};
const derivedMfaEnabled = Boolean(rawUser.mfaEnabled ?? rawMfa.totpEnabled);
const derivedMfaPendingSource =
typeof rawUser.mfaPending === 'boolean'
typeof rawUser.mfaPending === "boolean"
? rawUser.mfaPending
: typeof rawMfa.totpPending === 'boolean'
: typeof rawMfa.totpPending === "boolean"
? rawMfa.totpPending
: false
const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled
: false;
const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled;
const normalizedRole = normalizeRole(rawUser.role)
const rawRole = typeof rawUser.role === 'string' ? rawUser.role.trim().toLowerCase() : ''
const normalizedRole = normalizeRole(rawUser.role);
const rawRole =
typeof rawUser.role === "string" ? rawUser.role.trim().toLowerCase() : "";
const normalizedGroups = Array.isArray(rawUser.groups)
? rawUser.groups
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
.filter(
(value): value is string =>
typeof value === "string" && value.trim().length > 0,
)
.map((value) => value.trim())
: [];
const normalizedPermissions = Array.isArray(rawUser.permissions)
? rawUser.permissions
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedUsernameLower = String(rawUser.username ?? '').trim().toLowerCase()
const normalizedNameLower = String(rawUser.name ?? '').trim().toLowerCase()
const identifierLower = (identifier ?? '').toLowerCase()
.filter(
(value): value is string =>
typeof value === "string" && value.trim().length > 0,
)
.map((value) => value.trim())
: [];
const normalizedUsernameLower = String(rawUser.username ?? "")
.trim()
.toLowerCase();
const normalizedNameLower = String(rawUser.name ?? "")
.trim()
.toLowerCase();
const identifierLower = (identifier ?? "").toLowerCase();
const normalizedReadOnly =
Boolean(rawUser.readOnly) ||
normalizedGroups.some((group) => group.toLowerCase() === 'readonly role') ||
rawRole === 'readonly' ||
rawRole === 'read_only' ||
String(rawUser.email ?? '').trim().toLowerCase() === 'sandbox@svc.plus'
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
typeof rawUser.proxyUuid === "string" && rawUser.proxyUuid.trim().length > 0
? rawUser.proxyUuid.trim()
: undefined
: undefined;
const normalizedProxyUuidExpiresAt =
typeof rawUser.proxyUuidExpiresAt === 'string' && rawUser.proxyUuidExpiresAt.trim().length > 0
typeof rawUser.proxyUuidExpiresAt === "string" &&
rawUser.proxyUuidExpiresAt.trim().length > 0
? rawUser.proxyUuidExpiresAt.trim()
: undefined
: undefined;
const normalizedTenantId =
typeof rawUser.tenantId === 'string' && rawUser.tenantId.trim().length > 0
typeof rawUser.tenantId === "string" && rawUser.tenantId.trim().length > 0
? rawUser.tenantId.trim()
: undefined
: undefined;
const normalizedTenants = Array.isArray(rawUser.tenants)
? rawUser.tenants
.map((tenant) => {
if (!tenant || typeof tenant !== 'object') {
return null
}
.map((tenant) => {
if (!tenant || typeof tenant !== "object") {
return null;
}
const identifier =
typeof tenant.id === 'string' && tenant.id.trim().length > 0
? tenant.id.trim()
: undefined
if (!identifier) {
return null
}
const identifier =
typeof tenant.id === "string" && tenant.id.trim().length > 0
? tenant.id.trim()
: undefined;
if (!identifier) {
return null;
}
const normalizedTenant: { id: string; name?: string; role?: string } = {
id: identifier,
}
const normalizedTenant: { id: string; name?: string; role?: string } =
{
id: identifier,
};
if (typeof tenant.name === 'string' && tenant.name.trim().length > 0) {
normalizedTenant.name = tenant.name.trim()
}
if (
typeof tenant.name === "string" &&
tenant.name.trim().length > 0
) {
normalizedTenant.name = tenant.name.trim();
}
if (typeof tenant.role === 'string' && tenant.role.trim().length > 0) {
normalizedTenant.role = tenant.role.trim().toLowerCase()
}
if (
typeof tenant.role === "string" &&
tenant.role.trim().length > 0
) {
normalizedTenant.role = tenant.role.trim().toLowerCase();
}
return normalizedTenant
})
.filter((tenant): tenant is { id: string; name?: string; role?: string } => Boolean(tenant))
: undefined
return normalizedTenant;
})
.filter(
(tenant): tenant is { id: string; name?: string; role?: string } =>
Boolean(tenant),
)
: undefined;
const normalizedMfa = Object.keys(rawMfa).length
? {
...rawMfa,
totpEnabled: Boolean(rawMfa.totpEnabled ?? derivedMfaEnabled),
totpPending: Boolean(rawMfa.totpPending ?? derivedMfaPending),
}
...rawMfa,
totpEnabled: Boolean(rawMfa.totpEnabled ?? derivedMfaEnabled),
totpPending: Boolean(rawMfa.totpPending ?? derivedMfaPending),
}
: {
totpEnabled: derivedMfaEnabled,
totpPending: derivedMfaPending,
}
totpEnabled: derivedMfaEnabled,
totpPending: derivedMfaPending,
};
const normalizedUser = identifier ? { ...rawUser, id: identifier, uuid: identifier } : rawUser
const normalizedUser = identifier
? { ...rawUser, id: identifier, uuid: identifier }
: rawUser;
return NextResponse.json({
user: {
@ -263,24 +305,24 @@ export async function GET(request: NextRequest) {
tenantId: normalizedTenantId,
tenants: normalizedTenants,
},
})
});
}
export async function DELETE(request: NextRequest) {
void request
const cookieStore = await cookies()
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value
void request;
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (token) {
await fetch(`${ACCOUNT_API_BASE}/session`, {
method: 'DELETE',
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
}).catch(() => null)
cache: "no-store",
}).catch(() => null);
}
const response = NextResponse.json({ success: true })
clearSessionCookie(response)
return response
const response = NextResponse.json({ success: true });
clearSessionCookie(response, request.headers.get("host") ?? undefined);
return response;
}

View File

@ -1,48 +1,74 @@
import { NextRequest, NextResponse } from 'next/server'
import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { NextRequest, NextResponse } from "next/server";
import { applySessionCookie, deriveMaxAgeFromExpires } from "@lib/authGateway";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
export async function POST(request: NextRequest) {
try {
const payload = await request.json()
const { publicToken, userId, email, role } = payload
try {
const payload = await request.json();
const { exchangeCode } = payload;
if (!publicToken || !userId || !email) {
return NextResponse.json({ success: false, error: 'invalid_request' }, { status: 400 })
}
const response = await fetch(`${ACCOUNT_API_BASE}/token/exchange`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
public_token: publicToken,
user_id: userId,
email,
roles: role,
}),
cache: 'no-store',
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return NextResponse.json({ success: false, error: errorData.error || 'exchange_failed' }, { status: response.status })
}
const data = await response.json()
const { access_token, expires_in } = data
const result = NextResponse.json({ success: true })
// If backend returns expires_in (seconds), use it; otherwise derive from expiresAt if it exists
const maxAge = typeof expires_in === 'number' ? expires_in : deriveMaxAgeFromExpires(data.expiresAt)
applySessionCookie(result, access_token, maxAge)
return result
} catch (error) {
console.error('Token exchange proxy failed', error)
return NextResponse.json({ success: false, error: 'internal_error' }, { status: 500 })
if (!exchangeCode || typeof exchangeCode !== "string") {
return NextResponse.json(
{ success: false, error: "invalid_request" },
{ status: 400 },
);
}
const response = await fetch(`${ACCOUNT_API_BASE}/token/exchange`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
exchange_code: exchangeCode,
}),
cache: "no-store",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return NextResponse.json(
{ success: false, error: errorData.error || "exchange_failed" },
{ status: response.status },
);
}
const data = await response.json();
const sessionToken =
typeof data.token === "string" && data.token.trim().length > 0
? data.token.trim()
: typeof data.access_token === "string" &&
data.access_token.trim().length > 0
? data.access_token.trim()
: "";
if (!sessionToken) {
return NextResponse.json(
{ success: false, error: "invalid_response" },
{ status: 502 },
);
}
const result = NextResponse.json({ success: true });
const maxAge =
typeof data.expires_in === "number"
? data.expires_in
: deriveMaxAgeFromExpires(data.expiresAt);
applySessionCookie(
result,
sessionToken,
maxAge,
request.headers.get("host") ?? undefined,
);
return result;
} catch (error) {
console.error("Token exchange proxy failed", error);
return NextResponse.json(
{ success: false, error: "internal_error" },
{ status: 500 },
);
}
}

View File

@ -1,82 +1,97 @@
export const dynamic = 'force-dynamic'
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from 'next/server'
import { NextRequest, NextResponse } from "next/server";
import { applySessionCookie } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { applySessionCookie } from "@lib/authGateway";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const ROOT_BACKUP_COOKIE = 'xc_session_root'
const ROOT_BACKUP_COOKIE = "xc_session_root";
type ErrorPayload = {
error: string
}
error: string;
};
function secureCookies(): boolean {
if (process.env.NODE_ENV === 'production') {
return true
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://')
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',
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
Accept: "application/json",
},
cache: 'no-store',
})
cache: "no-store",
});
if (!res.ok) {
return false
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'
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
return false;
}
}
export async function POST(request: NextRequest) {
const rootToken = request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? ''
const rootToken =
request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? "";
if (!rootToken) {
return NextResponse.json<ErrorPayload>({ error: 'not_assuming' }, { status: 400 })
return NextResponse.json<ErrorPayload>(
{ error: "not_assuming" },
{ status: 400 },
);
}
if (!(await verifyRootToken(rootToken))) {
return NextResponse.json<ErrorPayload>({ error: 'root_token_invalid' }, { status: 403 })
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',
method: "POST",
headers: {
Authorization: `Bearer ${rootToken}`,
Accept: 'application/json',
Accept: "application/json",
},
cache: 'no-store',
})
cache: "no-store",
});
} catch (error) {
console.error('Failed to audit assume revert', error)
console.error("Failed to audit assume revert", error);
}
const response = NextResponse.json({ ok: true })
applySessionCookie(response, rootToken)
const response = NextResponse.json({ ok: true });
applySessionCookie(
response,
rootToken,
undefined,
request.headers.get("host") ?? undefined,
);
response.cookies.set({
name: ROOT_BACKUP_COOKIE,
value: '',
value: "",
httpOnly: true,
secure: secureCookies(),
sameSite: 'lax',
path: '/',
sameSite: "lax",
path: "/",
maxAge: 0,
})
return response
});
return response;
}

View File

@ -1,77 +1,90 @@
export const dynamic = 'force-dynamic'
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from 'next/server'
import { NextRequest, NextResponse } from "next/server";
import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
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 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'
const ROOT_BACKUP_COOKIE = "xc_session_root";
const SANDBOX_EMAIL = "sandbox@svc.plus";
type ErrorPayload = {
error: string
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
error: string;
};
function secureCookies(): boolean {
if (process.env.NODE_ENV === 'production') {
return true
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://')
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
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
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',
method: "POST",
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ email: SANDBOX_EMAIL }),
cache: 'no-store',
})
cache: "no-store",
});
const contentType = upstream.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
const text = await upstream.text().catch(() => '')
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,
{
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 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 })
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.
@ -81,19 +94,26 @@ export async function POST(request: NextRequest) {
value: session.token,
httpOnly: true,
secure: secureCookies(),
sameSite: 'lax',
path: '/',
sameSite: "lax",
path: "/",
maxAge: deriveMaxAgeFromExpires(payload.expiresAt),
})
});
}
// Switch main session to sandbox token.
applySessionCookie(response, payload.token, deriveMaxAgeFromExpires(payload.expiresAt))
applySessionCookie(
response,
payload.token,
deriveMaxAgeFromExpires(payload.expiresAt),
request.headers.get("host") ?? undefined,
);
return response
return response;
} catch (error) {
console.error('Failed to assume sandbox', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
console.error("Failed to assume sandbox", error);
return NextResponse.json<ErrorPayload>(
{ error: "upstream_unreachable" },
{ status: 502 },
);
}
}

View File

@ -0,0 +1,81 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME } from "@/lib/authGateway";
import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
function buildProxyHeaders(
token: string,
requestHost?: string | null,
): HeadersInit {
return {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...(requestHost && requestHost.trim().length > 0
? {
"X-Forwarded-Host": requestHost.trim(),
}
: {}),
};
}
export async function GET(request: NextRequest) {
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim();
if (!token) {
return NextResponse.json(
{ error: "session_token_required" },
{ status: 401 },
);
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/xworkmate/profile`, {
method: "GET",
headers: buildProxyHeaders(token, request.headers.get("host")),
cache: "no-store",
});
const payload = await response.json().catch(() => ({}));
return NextResponse.json(payload, { status: response.status });
} catch (error) {
console.error("xworkmate profile proxy failed", error);
return NextResponse.json(
{ error: "account_service_unreachable" },
{ status: 502 },
);
}
}
export async function PUT(request: NextRequest) {
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim();
if (!token) {
return NextResponse.json(
{ error: "session_token_required" },
{ status: 401 },
);
}
const rawBody = await request.text();
try {
const response = await fetch(`${ACCOUNT_API_BASE}/xworkmate/profile`, {
method: "PUT",
headers: {
...buildProxyHeaders(token, request.headers.get("host")),
"Content-Type": "application/json",
},
body: rawBody,
cache: "no-store",
});
const payload = await response.json().catch(() => ({}));
return NextResponse.json(payload, { status: response.status });
} catch (error) {
console.error("xworkmate profile update proxy failed", error);
return NextResponse.json(
{ error: "account_service_unreachable" },
{ status: 502 },
);
}
}

View File

@ -1,13 +1,13 @@
@import 'react-grid-layout/css/styles.css';
@import 'react-resizable/css/styles.css';
@import "react-grid-layout/css/styles.css";
@import "react-resizable/css/styles.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-geist-sans: 'Geist', sans-serif;
--font-geist-mono: 'Geist Mono', monospace;
--font-geist-sans: "Geist", sans-serif;
--font-geist-mono: "Geist Mono", monospace;
--app-shell-nav-offset: 5.5rem;
/* Light theme defaults */
@ -59,7 +59,8 @@
--gradient-primary-from: #3366ff;
--gradient-primary-to: #254edb;
--shadow-sm: 0 1px 2px rgba(17, 24, 39, 0.06), 0 1px 3px rgba(17, 24, 39, 0.04);
--shadow-sm:
0 1px 2px rgba(17, 24, 39, 0.06), 0 1px 3px rgba(17, 24, 39, 0.04);
--shadow-md: 0 10px 24px rgba(17, 24, 39, 0.08);
--radius-lg: 0.875rem;
@ -91,7 +92,9 @@ body {
color: var(--color-text);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
transition: background-color 150ms ease, color 150ms ease;
transition:
background-color 150ms ease,
color 150ms ease;
}
button,
@ -145,3 +148,34 @@ button {
background: rgba(51, 102, 255, 0.18);
}
}
@media (max-width: 1023px) {
.mobile-home-shell {
--color-background: #fbfaf7;
--color-background-muted: #f4f1eb;
--color-surface: #fffdfa;
--color-surface-elevated: rgba(255, 253, 250, 0.96);
--color-surface-translucent: rgba(255, 253, 250, 0.92);
--color-surface-muted: #f1ece5;
--color-surface-hover: #f6f1ea;
--color-surface-border: rgba(15, 23, 42, 0.1);
--color-surface-border-strong: rgba(15, 23, 42, 0.16);
--color-text: #171717;
--color-heading: #0f172a;
--color-text-muted: #525866;
--color-text-subtle: #747b88;
--color-primary: #111827;
--color-primary-hover: #1f2937;
--color-primary-muted: #f1ece5;
--color-primary-border: rgba(15, 23, 42, 0.12);
--color-accent: #2563eb;
--color-accent-muted: #e5edff;
--color-accent-foreground: #1d4ed8;
--gradient-app-from: #fffdf8;
--gradient-app-via: #f8f4ee;
--gradient-app-to: #fbfaf7;
--shadow-sm:
0 1px 2px rgba(15, 23, 42, 0.05), 0 6px 18px rgba(15, 23, 42, 0.04);
--shadow-md: 0 18px 45px rgba(15, 23, 42, 0.08);
}
}

View File

@ -67,7 +67,7 @@ export default function HomePage() {
const { mode, isOpen } = useMoltbotStore();
return (
<div className="min-h-screen bg-background text-text transition-colors duration-150 flex flex-col">
<div className="mobile-home-shell min-h-screen bg-background text-text transition-colors duration-150 flex flex-col overflow-x-hidden">
<UnifiedNavigation />
<div
@ -77,12 +77,12 @@ export default function HomePage() {
)}
>
<div className="flex-1 overflow-y-auto relative">
<div className="relative mx-auto max-w-6xl px-6 pb-20">
<div className="relative mx-auto max-w-6xl px-4 pb-16 sm:px-6 sm:pb-20">
<div
className="absolute inset-0 bg-gradient-app-from opacity-20 pointer-events-none"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(37,78,219,0.08),transparent_28%),radial-gradient(circle_at_bottom_right,rgba(15,23,42,0.05),transparent_32%),linear-gradient(180deg,rgba(255,255,255,0.82),transparent_58%)]"
aria-hidden
/>
<main className="relative space-y-12 pt-10">
<main className="relative space-y-8 pt-6 sm:space-y-12 sm:pt-10">
<HeroSection />
<NextStepsSection />
<StatsSection />
@ -104,43 +104,45 @@ export function HeroSection() {
const t = translations[language].marketing.home;
return (
<section className="grid gap-12 lg:grid-cols-[0.9fr_1.1fr]">
<div className="flex flex-col justify-center space-y-8">
<div className="space-y-4">
<section className="grid gap-8 lg:grid-cols-[0.9fr_1.1fr] lg:gap-12">
<div className="flex flex-col justify-center space-y-6 sm:space-y-8">
<div className="space-y-3 sm:space-y-4">
{t.hero.eyebrow && (
<p className="font-semibold uppercase tracking-wider text-text-subtle">
<p className="font-semibold uppercase tracking-[0.28em] text-text-subtle">
{t.hero.eyebrow}
</p>
)}
<h1 className="text-xl font-bold tracking-tight text-heading sm:text-3xl">
<h1 className="max-w-[12ch] text-[2.22rem] font-semibold leading-[0.92] tracking-[-0.075em] text-heading sm:max-w-none sm:text-3xl lg:text-[3.35rem]">
{t.hero.title}
</h1>
<p className="text-base text-text-muted">{t.hero.subtitle}</p>
<p className="max-w-xl text-[1.02rem] leading-7 text-text-muted">
{t.hero.subtitle}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
{user ? (
<div className="flex items-center gap-2 rounded-full border border-success/30 bg-success/10 px-4 py-1.5 text-sm font-medium text-success">
<div className="flex items-center justify-center gap-2 rounded-full border border-success/30 bg-success/10 px-4 py-2 text-sm font-medium text-success sm:justify-start sm:py-1.5">
<div className="h-2 w-2 rounded-full bg-success animate-pulse" />
{t.signedIn.replace("{{username}}", user.username)}
</div>
) : (
<button className="flex items-center gap-2 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-primary-hover">
<button className="flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3 text-sm font-semibold text-white transition hover:bg-primary-hover sm:py-2.5">
<PlusCircle className="h-4 w-4" />
{t.heroButtons.create}
</button>
)}
<button className="flex items-center gap-2 rounded-full border border-surface-border bg-surface px-6 py-2.5 text-sm font-semibold text-text transition hover:bg-surface-hover">
<button className="flex items-center justify-center gap-2 rounded-full border border-surface-border bg-surface/90 px-6 py-3 text-sm font-semibold text-text transition hover:bg-surface-hover sm:py-2.5">
<Play className="h-4 w-4" />
{t.heroButtons.playground}
</button>
<button className="flex items-center gap-2 rounded-full border border-surface-border bg-surface px-6 py-2.5 text-sm font-semibold text-text transition hover:bg-surface-hover">
<button className="flex items-center justify-center gap-2 rounded-full border border-surface-border bg-surface/90 px-6 py-3 text-sm font-semibold text-text transition hover:bg-surface-hover sm:py-2.5">
<BookOpen className="h-4 w-4" />
{t.heroButtons.tutorials}
</button>
</div>
<div className="flex flex-col gap-3 text-sm">
<p className="text-text-muted">{t.trustedBy}</p>
<div className="flex gap-2 flex-wrap">
<div className="flex flex-wrap gap-2">
<LogoPill label="Next.js" />
<LogoPill label="Go" />
<LogoPill label="Vercel" />
@ -149,8 +151,8 @@ export function HeroSection() {
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 relative">
<div className="flex flex-col gap-3 sm:gap-4">
<div className="relative flex flex-col gap-3 sm:gap-4">
{t.heroCards.map((card) => {
const Icon = getIcon(card.title, PlusCircle);
return (
@ -174,12 +176,12 @@ export function NextStepsSection() {
const t = translations[language].marketing.home;
return (
<section className="space-y-4">
<header className="flex items-center gap-3 text-sm text-text-muted">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-text-subtle">
<section className="space-y-4 rounded-[1.75rem] border border-surface-border/70 bg-white/70 p-5 shadow-[0_16px_45px_rgba(15,23,42,0.05)] lg:rounded-none lg:border-transparent lg:bg-transparent lg:p-0 lg:shadow-none">
<header className="flex flex-col gap-2 text-sm text-text-muted sm:flex-row sm:items-center sm:gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-text-subtle">
{t.nextSteps.title}
</p>
<span className="rounded-full bg-surface-muted px-3 py-1 text-xs font-semibold text-primary">
<span className="w-fit rounded-full bg-surface-muted px-3 py-1 text-xs font-semibold text-primary">
{t.nextSteps.badge}
</span>
</header>
@ -189,9 +191,9 @@ export function NextStepsSection() {
return (
<div
key={index}
className="flex items-start gap-3 rounded-xl border border-surface-border bg-surface p-4 shadow-lg shadow-shadow-sm"
className="flex items-start gap-3 rounded-[1.4rem] border border-surface-border bg-surface/92 p-4 shadow-lg shadow-shadow-sm"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/15 text-primary">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary/12 text-primary">
<Icon className="h-5 w-5" aria-hidden />
</div>
<div className="space-y-2">
@ -284,14 +286,19 @@ export function StatsSection() {
];
return (
<section className="rounded-2xl border border-surface-border bg-gradient-to-r from-surface-muted via-surface/0 to-surface-muted p-6 shadow-inner shadow-shadow-sm">
<div className="grid gap-6 grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
<section className="overflow-hidden rounded-[1.9rem] border border-surface-border/70 bg-[linear-gradient(135deg,rgba(255,255,255,0.92),rgba(243,244,246,0.88))] p-5 shadow-[0_18px_40px_rgba(15,23,42,0.05)] sm:p-6">
<div className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-3 lg:grid-cols-5">
{displayStats.map((stat, index: number) => (
<div key={index} className="space-y-1 text-center md:text-left">
<div className="text-3xl font-semibold text-heading">
<div
key={index}
className="space-y-1 text-left even:text-right md:text-left"
>
<div className="text-[2rem] font-semibold tracking-[-0.06em] text-heading sm:text-3xl">
{stat.value}
</div>
<p className="text-sm text-text-muted">{stat.label}</p>
<p className="max-w-[9rem] text-sm text-text-muted even:ml-auto md:max-w-none">
{stat.label}
</p>
</div>
))}
</div>
@ -345,22 +352,22 @@ export function ShortcutsSection() {
}));
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<section className="space-y-4 rounded-[1.75rem] border border-surface-border/70 bg-white/70 p-5 shadow-[0_16px_45px_rgba(15,23,42,0.05)] lg:rounded-none lg:border-transparent lg:bg-transparent lg:p-0 lg:shadow-none">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-text-subtle">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-text-subtle">
{t.shortcuts.title}
</p>
<p className="text-sm text-text-muted">{t.shortcuts.subtitle}</p>
<p className="mt-1 text-sm text-text-muted">{t.shortcuts.subtitle}</p>
</div>
<div className="flex gap-2 text-xs font-semibold text-primary">
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">
<div className="flex flex-wrap gap-2 text-xs font-semibold text-primary">
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-2 transition hover:bg-surface-hover">
{t.shortcuts.buttons.start}
</button>
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-2 transition hover:bg-surface-hover">
{t.shortcuts.buttons.docs}
</button>
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-2 transition hover:bg-surface-hover">
{t.shortcuts.buttons.guides}
</button>
</div>
@ -372,12 +379,12 @@ export function ShortcutsSection() {
<a
key={index}
href={item.href}
className="group flex items-start gap-3 rounded-xl border border-surface-border bg-surface p-4 transition hover:-translate-y-[1px] hover:border-primary/50 hover:bg-surface-hover"
className="group flex items-start gap-3 rounded-[1.4rem] border border-surface-border bg-surface/92 p-4 transition hover:-translate-y-[1px] hover:border-primary/50 hover:bg-surface-hover"
>
<div className="mt-1 flex h-10 w-10 items-center justify-center rounded-full bg-primary/15 text-primary">
<div className="mt-1 flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary/12 text-primary">
<Icon className="h-5 w-5" aria-hidden />
</div>
<div className="space-y-1">
<div className="min-w-0 space-y-1">
<div className="text-sm font-semibold text-heading">
{item.title}
</div>
@ -403,7 +410,7 @@ type LatestBlogPost = {
function LogoPill({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-2 rounded-full border border-surface-border bg-surface-muted px-3 py-1 text-xs font-semibold text-text">
<span className="inline-flex items-center gap-2 rounded-full border border-surface-border bg-surface/88 px-3.5 py-1.5 text-xs font-semibold text-text shadow-[0_8px_22px_rgba(15,23,42,0.04)]">
<div className="h-2 w-2 rounded-full bg-success" />
{label}
</span>

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useState, Suspense } from "react";
import Link from "next/link";
import { Check, Shield } from "lucide-react";
@ -163,7 +163,9 @@ export default function PricesPage() {
</p>
</div>
<CheckoutStatusBanner className="mx-auto mb-6 max-w-3xl" />
<Suspense fallback={null}>
<CheckoutStatusBanner className="mx-auto mb-6 max-w-3xl" />
</Suspense>
{statusMessage ? (
<p className="mx-auto mb-6 max-w-3xl rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{statusMessage}

View File

@ -0,0 +1,56 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { XWorkmateProfileEditor } from "@/components/xworkmate/XWorkmateProfileEditor";
import {
buildSharedXWorkmateUrl,
isLegacyConsoleXWorkmateHost,
isSharedXWorkmateHost,
normalizeXWorkmateHost,
} from "@/lib/xworkmate/host";
import { buildXWorkmateScopeKey } from "@/lib/xworkmate/types";
import { getXWorkmateSessionContext } from "@/server/xworkmate/profile";
export const metadata = {
title: "XWorkmate Shared Integrations",
description: "Manage the shared XWorkmate integrations profile",
};
export default async function XWorkmateAdminPage() {
const requestHeaders = await headers();
const requestHost = normalizeXWorkmateHost(
requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"),
);
if (isLegacyConsoleXWorkmateHost(requestHost)) {
redirect(buildSharedXWorkmateUrl("/xworkmate/admin"));
}
const { user, profile } = await getXWorkmateSessionContext(requestHost);
if (!profile) {
redirect("/xworkmate");
}
if (!isSharedXWorkmateHost(requestHost)) {
redirect("/xworkmate/integrations");
}
if (
profile.profileScope !== "tenant-shared" ||
!profile.canEditIntegrations
) {
redirect("/xworkmate");
}
const scopeKey = buildXWorkmateScopeKey(profile, user?.id, requestHost);
return (
<div className="min-h-[calc(100vh-var(--app-shell-nav-offset))] bg-[linear-gradient(180deg,#f4f7fd_0%,#f6f8fb_32%,#f3f5f8_100%)] px-4 py-5 md:px-6">
<div className="mx-auto max-w-6xl">
<XWorkmateProfileEditor
payload={profile}
scopeKey={scopeKey}
workspaceHref="/xworkmate"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,53 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { XWorkmateProfileEditor } from "@/components/xworkmate/XWorkmateProfileEditor";
import {
buildSharedXWorkmateUrl,
isLegacyConsoleXWorkmateHost,
isSharedXWorkmateHost,
normalizeXWorkmateHost,
} from "@/lib/xworkmate/host";
import { buildXWorkmateScopeKey } from "@/lib/xworkmate/types";
import { getXWorkmateSessionContext } from "@/server/xworkmate/profile";
export const metadata = {
title: "XWorkmate Personal Integrations",
description: "Manage the personal XWorkmate integrations profile",
};
export default async function XWorkmateIntegrationsPage() {
const requestHeaders = await headers();
const requestHost = normalizeXWorkmateHost(
requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"),
);
if (isLegacyConsoleXWorkmateHost(requestHost)) {
redirect(buildSharedXWorkmateUrl("/xworkmate/integrations"));
}
const { user, profile } = await getXWorkmateSessionContext(requestHost);
if (!profile) {
redirect("/xworkmate");
}
if (isSharedXWorkmateHost(requestHost)) {
redirect(profile.canEditIntegrations ? "/xworkmate/admin" : "/xworkmate");
}
if (profile.profileScope !== "user-private") {
redirect("/xworkmate");
}
const scopeKey = buildXWorkmateScopeKey(profile, user?.id, requestHost);
return (
<div className="min-h-[calc(100vh-var(--app-shell-nav-offset))] bg-[linear-gradient(180deg,#f4f7fd_0%,#f6f8fb_32%,#f3f5f8_100%)] px-4 py-5 md:px-6">
<div className="mx-auto max-w-6xl">
<XWorkmateProfileEditor
payload={profile}
scopeKey={scopeKey}
workspaceHref="/xworkmate"
/>
</div>
</div>
);
}

View File

@ -1,21 +1,51 @@
import { Suspense } from "react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { XWorkmateLoading } from "@/app/xworkmate/XWorkmateLoading";
import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage";
import {
buildSharedXWorkmateUrl,
isLegacyConsoleXWorkmateHost,
normalizeXWorkmateHost,
} from "@/lib/xworkmate/host";
import {
buildXWorkmateScopeKey,
toXWorkmateIntegrationDefaults,
} from "@/lib/xworkmate/types";
import { getConsoleIntegrationDefaults } from "@/server/consoleIntegrations";
import { getXWorkmateSessionContext } from "@/server/xworkmate/profile";
export const metadata = {
title: "XWorkmate",
description: "Online XWorkmate workspace powered by OpenClaw gateway",
};
export default function XWorkmatePage() {
const defaults = getConsoleIntegrationDefaults();
export default async function XWorkmatePage() {
const requestHeaders = await headers();
const requestHost = normalizeXWorkmateHost(
requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"),
);
if (isLegacyConsoleXWorkmateHost(requestHost)) {
redirect(buildSharedXWorkmateUrl("/xworkmate"));
}
const { user, profile } = await getXWorkmateSessionContext(requestHost);
const defaults = profile
? toXWorkmateIntegrationDefaults(profile)
: getConsoleIntegrationDefaults();
const scopeKey = buildXWorkmateScopeKey(profile, user?.id, requestHost);
return (
<div className="h-[calc(100vh-var(--app-shell-nav-offset))] w-full">
<Suspense fallback={<XWorkmateLoading />}>
<XWorkmateWorkspacePage defaults={defaults} />
<XWorkmateWorkspacePage
defaults={defaults}
profile={profile}
scopeKey={scopeKey}
requestHost={requestHost}
/>
</Suspense>
</div>
);

View File

@ -50,57 +50,67 @@ export function AskAIDialog({
}
return (
<div
className={cn(
"fixed bottom-0 right-0 z-[40] border-l border-[color:var(--color-surface-border)] bg-[var(--color-background)]/95 shadow-xl backdrop-blur",
)}
style={{
width: "400px",
top: "var(--app-shell-nav-offset, 64px)",
height: "calc(100vh - var(--app-shell-nav-offset, 64px))",
display: open ? "block" : "none",
}}
>
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between gap-3 border-b border-[color:var(--color-surface-border)] px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-subtle)]">
XWorkmate
</p>
<h2 className="text-sm font-semibold text-[var(--color-heading)]">
AI Assistant
</h2>
<>
{open ? (
<button
type="button"
aria-label="Close assistant"
onClick={onMinimize}
className="fixed inset-0 z-[35] bg-black/18 backdrop-blur-sm md:hidden"
/>
) : null}
<div
className={cn(
"fixed bottom-0 left-0 right-0 z-[40] overflow-hidden border border-[color:var(--color-surface-border)] bg-[var(--color-background)]/96 shadow-2xl backdrop-blur transition-transform duration-300 ease-out md:left-auto md:right-0 md:w-[400px] md:border-l md:border-t-0 md:rounded-none",
open
? "translate-y-0 md:translate-x-0"
: "translate-y-full md:translate-x-full",
"rounded-t-[1.75rem] md:rounded-none",
"top-[calc(var(--app-shell-nav-offset,64px)+0.75rem)] h-[calc(100vh-var(--app-shell-nav-offset,64px)-0.75rem)] md:top-[var(--app-shell-nav-offset,64px)] md:h-[calc(100vh-var(--app-shell-nav-offset,64px))]",
)}
>
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between gap-3 border-b border-[color:var(--color-surface-border)] px-4 py-3 md:px-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--color-text-subtle)]">
XWorkmate
</p>
<h2 className="text-sm font-semibold text-[var(--color-heading)]">
AI Assistant
</h2>
</div>
<div className="flex items-center gap-1 text-[var(--color-text-subtle)]">
<button
type="button"
onClick={handleMaximize}
className="rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
title="Open workspace"
>
<Maximize2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={onMinimize}
className="rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
title="Close sidebar"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex items-center gap-1 text-[var(--color-text-subtle)]">
<button
type="button"
onClick={handleMaximize}
className="rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
title="Open workspace"
>
<Maximize2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={onMinimize}
className="rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
title="Close sidebar"
>
<X className="h-4 w-4" />
</button>
<div className="min-h-0 flex-1">
<OpenClawAssistantPane
defaults={resolvedDefaults}
initialQuestion={initialQuestion?.text}
initialQuestionKey={initialQuestion?.key}
variant="sidebar"
/>
</div>
</div>
<div className="min-h-0 flex-1">
<OpenClawAssistantPane
defaults={resolvedDefaults}
initialQuestion={initialQuestion?.text}
initialQuestionKey={initialQuestion?.key}
variant="sidebar"
/>
</div>
</div>
</div>
</>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { Github, Linkedin, Moon, Sun, Twitter } from "lucide-react";
import Link from 'next/link';
import Link from "next/link";
import { useLanguage } from "../i18n/LanguageProvider";
import { useThemeStore } from "@components/theme";
@ -36,16 +36,25 @@ export default function Footer() {
};
return (
<footer className="mt-12 flex flex-col items-center justify-center gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-4 text-sm text-slate-300">
<footer className="mt-12 flex flex-col items-center justify-center gap-4 rounded-[1.75rem] border border-surface-border bg-surface/88 px-6 py-4 text-sm text-text-muted shadow-[0_18px_40px_rgba(15,23,42,0.05)] lg:rounded-2xl lg:border-white/10 lg:bg-white/5 lg:text-slate-300 lg:shadow-none">
<div className="flex w-full flex-col items-center gap-4 sm:flex-row sm:justify-between">
<div className="flex gap-4 order-2 sm:order-1">
<Link href="/terms" className="hover:text-white transition-colors">
<Link
href="/terms"
className="transition-colors hover:text-text lg:hover:text-white"
>
{isChinese ? "服务条款" : "Terms of Service"}
</Link>
<Link href="/privacy" className="hover:text-white transition-colors">
<Link
href="/privacy"
className="transition-colors hover:text-text lg:hover:text-white"
>
{isChinese ? "隐私政策" : "Privacy Policy"}
</Link>
<Link href="/support" className="hover:text-white transition-colors">
<Link
href="/support"
className="transition-colors hover:text-text lg:hover:text-white"
>
{isChinese ? "联系我们" : "Contact Us"}
</Link>
</div>
@ -55,7 +64,7 @@ export default function Footer() {
<a
key={label}
href={href}
className="flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white transition hover:border-indigo-400/50 hover:text-indigo-100"
className="flex h-9 w-9 items-center justify-center rounded-full border border-surface-border bg-surface-muted text-text transition hover:border-surface-border-strong hover:text-text lg:border-white/10 lg:bg-white/5 lg:text-white lg:hover:border-indigo-400/50 lg:hover:text-indigo-100"
>
<Icon className="h-4 w-4" aria-hidden />
<span className="sr-only">{label}</span>
@ -69,7 +78,7 @@ export default function Footer() {
onClick={handleViewToggle}
aria-label={viewToggleLabel}
title={viewToggleLabel}
className="group flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white transition hover:border-indigo-400/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
className="group flex h-10 w-10 items-center justify-center rounded-full border border-surface-border bg-surface-muted text-text transition hover:border-surface-border-strong focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 lg:border-white/10 lg:bg-white/5 lg:text-white lg:hover:border-indigo-400/50"
>
<span className="material-symbols-outlined text-xl">
{view === "classic" ? "view_quilt" : "view_cozy"}
@ -81,21 +90,21 @@ export default function Footer() {
aria-pressed={isDark}
aria-label={toggleLabel}
title={toggleLabel}
className="group relative flex h-10 w-20 items-center rounded-full border border-white/10 bg-white/5 px-2 text-white transition hover:border-indigo-400/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
className="group relative flex h-10 w-20 items-center rounded-full border border-surface-border bg-surface-muted px-2 text-text transition hover:border-surface-border-strong focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 lg:border-white/10 lg:bg-white/5 lg:text-white lg:hover:border-indigo-400/50"
>
<span className="relative z-10 flex w-full items-center justify-between text-slate-300">
<span className="relative z-10 flex w-full items-center justify-between text-text-subtle lg:text-slate-300">
<Moon
className={`h-4 w-4 transition-colors ${isDark ? "text-indigo-100" : "text-slate-500"}`}
className={`h-4 w-4 transition-colors ${isDark ? "text-text" : "text-text-subtle lg:text-slate-500"}`}
aria-hidden
/>
<Sun
className={`h-4 w-4 transition-colors ${isDark ? "text-slate-500" : "text-amber-300"}`}
className={`h-4 w-4 transition-colors ${isDark ? "text-text-subtle lg:text-slate-500" : "text-amber-500 lg:text-amber-300"}`}
aria-hidden
/>
</span>
<span
aria-hidden
className={`absolute inset-y-1 left-1 h-8 w-8 rounded-full bg-white/90 shadow-sm transition-transform duration-300 ease-out ${isDark ? "translate-x-0" : "translate-x-10"}`}
className={`absolute inset-y-1 left-1 h-8 w-8 rounded-full bg-background shadow-sm transition-transform duration-300 ease-out lg:bg-white/90 ${isDark ? "translate-x-0" : "translate-x-10"}`}
/>
</button>
</div>

View File

@ -59,23 +59,25 @@ export function HeroCard({
onClick={hasGuide ? openGuide : undefined}
onKeyDown={handleCardKeyDown}
className={cn(
"group relative flex items-start gap-4 rounded-2xl border border-surface-border bg-surface p-6 transition-all duration-300",
"group relative flex items-start gap-4 overflow-hidden rounded-[1.6rem] border border-surface-border bg-white/88 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.05)] transition-all duration-300 sm:rounded-2xl sm:p-6",
hasGuide
? "cursor-pointer hover:border-primary/50 hover:bg-surface-hover"
: "hover:border-primary/50 hover:bg-surface-hover",
showGuide ? "border-primary/50 shadow-lg" : "",
)}
>
<div className="mt-1 rounded-full border border-surface-border bg-surface-muted p-2 group-hover:border-primary/50 group-hover:text-primary">
<div className="mt-1 rounded-full border border-surface-border bg-surface-muted p-2.5 group-hover:border-primary/50 group-hover:text-primary">
<Icon className="h-5 w-5" />
</div>
<div className="flex w-full items-start justify-between gap-4">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="space-y-1">
<h3 className="font-semibold text-heading">{title}</h3>
<p className="text-sm text-text-muted">{description}</p>
<h3 className="text-base font-semibold tracking-[-0.03em] text-heading">
{title}
</h3>
<p className="text-sm leading-6 text-text-muted">{description}</p>
</div>
{hasGuide ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
<span className="inline-flex w-fit shrink-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
<ArrowRight className="h-3.5 w-3.5" />
</span>
@ -86,13 +88,16 @@ export function HeroCard({
{guide ? (
<div
className={cn(
"fixed top-0 right-0 z-[100] h-full w-[400px] transform border-l border-surface-border bg-surface shadow-2xl transition-transform duration-300 ease-in-out",
showGuide ? "translate-x-0" : "translate-x-full",
"fixed bottom-3 left-3 right-3 z-[100] transform overflow-hidden rounded-[1.75rem] border border-surface-border bg-surface shadow-2xl transition-transform duration-300 ease-in-out md:bottom-0 md:left-auto md:right-0 md:w-[400px] md:rounded-none md:border-l md:border-t-0",
"top-[calc(var(--app-shell-nav-offset,64px)+0.75rem)] h-[calc(100vh-var(--app-shell-nav-offset,64px)-0.75rem)] md:top-[var(--app-shell-nav-offset,64px)] md:h-[calc(100vh-var(--app-shell-nav-offset,64px))]",
showGuide
? "translate-y-0 md:translate-x-0"
: "translate-y-full md:translate-x-full",
)}
>
<div className="flex h-full flex-col overflow-y-auto p-8">
<div className="mb-8 flex items-center justify-between">
<h4 className="flex items-center gap-3 text-xl font-bold text-heading">
<div className="flex h-full flex-col overflow-y-auto p-5 sm:p-8">
<div className="mb-6 flex items-center justify-between sm:mb-8">
<h4 className="flex items-center gap-3 text-lg font-bold text-heading sm:text-xl">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
@ -109,7 +114,7 @@ export function HeroCard({
</button>
</div>
<div className="flex-1 space-y-8">
<div className="flex-1 space-y-6 sm:space-y-8">
{guide.steps.map((step, idx) => (
<div key={idx} className="group/step relative pl-8">
{idx !== guide.steps.length - 1 ? (

View File

@ -4,7 +4,7 @@ import Image from "next/image";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useLanguage } from "../i18n/LanguageProvider";
import { Menu, X, Sun, Moon, Monitor, Plus, BarChart2 } from "lucide-react";
import { Menu, X } from "lucide-react";
import { translations } from "../i18n/translations";
import LanguageToggle from "./LanguageToggle";
// import { AskAIButton } from "./AskAIButton";
@ -36,11 +36,10 @@ export default function UnifiedNavigation() {
"stable",
]);
const navRef = useRef<HTMLElement | null>(null);
const { language } = useLanguage();
const { language, setLanguage } = useLanguage();
const user = useUserStore((state) => state.user);
const { setIsOpen, setMode, toggleOpen } = useMoltbotStore();
const { toggleOpen } = useMoltbotStore();
const nav = translations[language].nav;
const accountCopy = nav.account;
const accountInitial =
user?.username?.charAt(0)?.toUpperCase() ??
user?.email?.charAt(0)?.toUpperCase() ??
@ -113,6 +112,22 @@ export default function UnifiedNavigation() {
setAccountMenuOpen(false);
}, [user]);
useEffect(() => {
setMenuOpen(false);
}, [pathname]);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
document.body.style.overflow = menuOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [menuOpen]);
useEffect(() => {
if (typeof window === "undefined") {
return;
@ -164,16 +179,29 @@ export default function UnifiedNavigation() {
const filteredMainNav = filterNavItems(mainNav, user);
const filteredSecondaryNav = filterNavItems(secondaryNav, user);
const mobilePrimaryNav = [...filteredMainNav, ...filteredSecondaryNav].filter(
(item) => item.showOn !== "desktop",
);
const mobileQuickLinks = mobilePrimaryNav.filter((item) =>
["chat", "console", "docs", "services"].includes(item.key),
);
const mobileMenuNav = mobilePrimaryNav.filter((item) => item.key !== "home");
const primaryAccountAction = user
? (accountNav.find((item) => item.key !== "logout") ?? accountNav[0])
: (accountNav.find((item) => item.key === "login") ?? accountNav[0]);
const secondaryAccountAction = user
? accountNav.find((item) => item.key === "logout")
: accountNav.find((item) => item.key === "register");
const isHiddenRoute = pathname
? [
"/login",
"/register",
"/xstream",
"/xcloudflow",
"/xscopehub",
"/blogs",
].some((prefix) => pathname.startsWith(prefix))
"/login",
"/register",
"/xstream",
"/xcloudflow",
"/xscopehub",
"/blogs",
].some((prefix) => pathname.startsWith(prefix))
: false;
if (isHiddenRoute) {
@ -197,10 +225,27 @@ export default function UnifiedNavigation() {
}}
className="sticky top-0 z-50 w-full border-b border-surface-border bg-background/95 text-text backdrop-blur transition-colors duration-150"
>
<div className="lg:hidden flex items-center justify-between px-4 py-3 bg-background">
<div className="flex items-center justify-between border-b border-surface-border/70 bg-background px-5 pb-3 pt-[max(0.875rem,env(safe-area-inset-top))] lg:hidden">
<Link
href="/"
className="flex items-center gap-2"
onClick={() => setMenuOpen(false)}
>
<Image
src="/icons/cloudnative_32.png"
alt="logo"
width={24}
height={24}
className="h-6 w-6"
unoptimized
/>
<span className="text-[1.05rem] font-semibold tracking-tight text-text">
Cloud-Neutral
</span>
</Link>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-2 -ml-2 rounded-xl bg-surface-muted hover:bg-surface-hover text-text transition-colors"
className="rounded-[1.15rem] bg-surface-muted p-3 text-text transition-colors hover:bg-surface-hover"
aria-label="Toggle menu"
>
{menuOpen ? (
@ -209,7 +254,6 @@ export default function UnifiedNavigation() {
<Menu className="w-5 h-5" />
)}
</button>
<div className="w-10" />
</div>
<div className="hidden lg:block mx-auto w-full max-w-7xl px-6 sm:px-8">
@ -219,33 +263,35 @@ export default function UnifiedNavigation() {
{filteredMainNav.map((item) => {
const active = isActive(item);
if (item.showOn === "mobile") return null;
if (item.key === 'chat') {
if (item.key === "chat") {
return (
<button
key={item.key}
onClick={() => {
toggleOpen();
}}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${
active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="text-[13px] tracking-tight">
{getLabel(item.label, language)}
</span>
</button>
)
);
}
return (
<Link
key={item.key}
href={item.href}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${
active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="text-[13px] tracking-tight">
@ -261,10 +307,11 @@ export default function UnifiedNavigation() {
<Link
key={item.key}
href={item.href}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${
active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="text-[13px] tracking-tight">
@ -285,7 +332,10 @@ export default function UnifiedNavigation() {
onToggle={toggleChannel}
variant="icon"
/>
<DropdownMenu.Root open={accountMenuOpen} onOpenChange={setAccountMenuOpen}>
<DropdownMenu.Root
open={accountMenuOpen}
onOpenChange={setAccountMenuOpen}
>
<DropdownMenu.Trigger asChild>
<button
type="button"
@ -320,14 +370,17 @@ export default function UnifiedNavigation() {
>
<Link
href={item.href}
className={`flex h-[38px] flex-row-reverse items-center justify-between gap-3 px-3 rounded-lg text-[13px] font-medium transition-all group select-none ${item.key === 'logout'
? "text-rose-500 hover:bg-rose-500/10 hover:text-rose-600 focus:bg-rose-500/10 focus:text-rose-600"
: "text-text-muted hover:bg-primary/10 hover:text-primary focus:bg-primary/10 focus:text-primary"
}`}
className={`flex h-[38px] flex-row-reverse items-center justify-between gap-3 px-3 rounded-lg text-[13px] font-medium transition-all group select-none ${
item.key === "logout"
? "text-rose-500 hover:bg-rose-500/10 hover:text-rose-600 focus:bg-rose-500/10 focus:text-rose-600"
: "text-text-muted hover:bg-primary/10 hover:text-primary focus:bg-primary/10 focus:text-primary"
}`}
onClick={() => setAccountMenuOpen(false)}
>
{item.icon && (
<item.icon className={`w-4 h-4 shrink-0 opacity-60 group-hover:opacity-100 transition-opacity ${item.key === 'logout' ? 'text-rose-500' : 'text-current'}`} />
<item.icon
className={`w-4 h-4 shrink-0 opacity-60 group-hover:opacity-100 transition-opacity ${item.key === "logout" ? "text-rose-500" : "text-current"}`}
/>
)}
<span className="flex-1 text-right">
{typeof item.label === "function"
@ -373,68 +426,63 @@ export default function UnifiedNavigation() {
</div>
</div>
</div>
</nav>
{menuOpen && (
<div className="fixed inset-0 z-[60] lg:hidden">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute inset-y-0 left-0 w-80 max-w-[85vw] bg-background shadow-2xl transition-transform duration-300 ease-in-out">
<div className="flex h-full flex-col overflow-y-auto border-r border-surface-border">
<div className="flex items-center justify-between border-b border-surface-border p-4">
<Link
href="/"
className="flex items-center gap-2"
onClick={() => setMenuOpen(false)}
{menuOpen && (
<div className="fixed inset-0 z-[60] lg:hidden">
<button
type="button"
aria-label={isChinese ? "关闭菜单" : "Close menu"}
className="absolute inset-0 bg-white/72 backdrop-blur-[2px] transition-opacity"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute inset-0 bg-background transition-transform duration-300 ease-in-out">
<div className="flex h-full flex-col overflow-y-auto px-5 pb-[calc(env(safe-area-inset-bottom)+2rem)] pt-[max(1rem,env(safe-area-inset-top))] min-[430px]:pb-[calc(env(safe-area-inset-bottom)+2.25rem)]">
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-2"
onClick={() => setMenuOpen(false)}
>
<Image
src="/icons/cloudnative_32.png"
alt="logo"
width={24}
height={24}
className="h-6 w-6"
unoptimized
/>
<span className="text-[1.7rem] font-semibold tracking-[-0.05em] text-text">
Cloud-Neutral
</span>
</Link>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setLanguage(language === "zh" ? "en" : "zh")}
className="inline-flex h-10 min-w-10 items-center justify-center rounded-full border border-surface-border bg-surface-muted/75 px-3 text-xs font-semibold uppercase tracking-[0.18em] text-text shadow-sm transition hover:bg-surface-hover"
aria-label={
isChinese ? "切换到英文" : "Switch language to Chinese"
}
>
<Image
src="/icons/cloudnative_32.png"
alt="logo"
width={24}
height={24}
className="h-6 w-6"
unoptimized
/>
<span className="text-lg font-bold tracking-tight">
Cloud-Neutral
</span>
</Link>
{language === "zh" ? "EN" : "中"}
</button>
<button
onClick={() => setMenuOpen(false)}
className="rounded-lg p-2 text-text-muted hover:bg-surface-muted transition-colors"
className="rounded-full border border-surface-border bg-surface-muted/75 p-2.5 text-text shadow-sm transition-colors hover:bg-surface-hover"
aria-label={isChinese ? "关闭菜单" : "Close menu"}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{user && (
<div className="border-b border-surface-border bg-surface-muted/30 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-primary to-accent text-sm font-semibold text-white">
{accountInitial}
</div>
<div className="flex-1 overflow-hidden">
<p className="truncate text-sm font-semibold">
{user.username}
</p>
<p className="truncate text-xs text-text-muted">
{user.email}
</p>
</div>
</div>
</div>
)}
<div className="flex-1 p-4">
<p className="px-2 text-[10px] font-bold uppercase tracking-widest text-text-muted opacity-50 mb-2">
{isChinese ? "主导航" : "Main Navigation"}
</p>
<div className="space-y-1 mb-6">
{filteredMainNav.map((item) => {
<div className="flex flex-1 flex-col justify-between pt-8">
<div className="relative min-h-0 flex-1">
<div className="max-w-[13rem] space-y-2.5 min-[430px]:max-w-[13.5rem]">
{mobileMenuNav.map((item) => {
const active = isActive(item);
if (item.showOn === "desktop") return null;
if (item.key === 'chat') {
if (item.key === "chat") {
return (
<button
key={item.key}
@ -442,122 +490,104 @@ export default function UnifiedNavigation() {
toggleOpen();
setMenuOpen(false);
}}
className={`flex w-full items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`block w-full py-1 text-left text-[1.95rem] font-semibold tracking-[-0.055em] transition-colors min-[430px]:text-[2rem] ${
active
? "text-text"
: "text-text hover:text-primary"
}`}
>
{item.icon && (
<item.icon className="mr-3 h-5 w-5 opacity-70" />
)}
<span>
{getLabel(item.label, language)}
</span>
{getLabel(item.label, language)}
</button>
)
);
}
return (
<Link
key={item.key}
href={item.href}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`block py-1 text-[1.95rem] font-semibold tracking-[-0.055em] transition-colors min-[430px]:text-[2rem] ${
active
? "text-text"
: "text-text hover:text-primary"
}`}
onClick={() => setMenuOpen(false)}
>
{item.icon && (
<item.icon className="mr-3 h-5 w-5 opacity-70" />
)}
<span>
{getLabel(item.label, language)}
</span>
{getLabel(item.label, language)}
</Link>
);
})}
</div>
{filteredSecondaryNav.length > 0 && (
<>
<p className="px-2 text-[10px] font-bold uppercase tracking-widest text-text-muted opacity-50 mb-2">
{isChinese ? "其他" : "Other"}
</p>
<div className="space-y-1 mb-6">
{filteredSecondaryNav.map((item) => {
const active = isActive(item);
if (item.showOn === "desktop") return null;
return (
<Link
key={item.key}
href={item.href}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
{item.icon && (
<item.icon className="mr-3 h-5 w-5 opacity-70" />
)}
<span>
{mobileQuickLinks.length > 0 ? (
<div className="pointer-events-none absolute right-0 top-[60%] flex -translate-y-1/2 justify-end min-[390px]:top-[59%] min-[430px]:top-[58%]">
<div className="pointer-events-auto w-[min(10.75rem,45vw)] rounded-[1.75rem] bg-surface-muted/82 p-4 shadow-[0_18px_40px_rgba(15,23,42,0.08)]">
<div className="space-y-2.5">
{mobileQuickLinks.map((item) =>
item.key === "chat" ? (
<button
key={item.key}
onClick={() => {
toggleOpen();
setMenuOpen(false);
}}
className="block text-left text-[1.08rem] font-medium tracking-[-0.03em] text-text transition hover:text-primary"
>
{getLabel(item.label, language)}
</span>
</Link>
);
})}
</button>
) : (
<Link
key={item.key}
href={item.href}
onClick={() => setMenuOpen(false)}
className="block text-[1.08rem] font-medium tracking-[-0.03em] text-text transition hover:text-primary"
>
{getLabel(item.label, language)}
</Link>
),
)}
</div>
</div>
</>
)}
<div className="mt-8 space-y-3 px-2">
<p className="px-2 text-[10px] font-bold uppercase tracking-widest text-text-muted opacity-50 mb-2">
{isChinese ? "账户" : "Account"}
</p>
{accountNav.map((item) => (
<Link
key={item.key}
href={item.href}
className={`flex w-full items-center justify-center rounded-xl py-3 text-sm font-bold transition ${item.key === "logout"
? "bg-rose-500/10 text-rose-600 shadow-sm hover:bg-rose-500/20"
: "border border-surface-border bg-surface-muted/50 dark:bg-surface-muted/30 hover:bg-surface-hover"
}`}
onClick={() => setMenuOpen(false)}
>
{typeof item.label === "function"
? item.label(language)
: item.label}
</Link>
))}
</div>
</div>
) : null}
</div>
<div className="border-t border-surface-border p-4 space-y-4">
<div className="flex flex-col gap-3">
<p className="px-2 text-[10px] font-bold uppercase tracking-widest text-text-muted opacity-50">
{isChinese ? "设置" : "Settings"}
</p>
<div className="flex items-center justify-between rounded-xl bg-surface-muted/50 p-2">
<span className="ml-2 text-xs font-medium text-text-muted">
{isChinese ? "界面语言" : "Language"}
<div className="flex items-center justify-between gap-5 pt-8">
<div className="min-w-0">
{secondaryAccountAction ? (
<Link
href={secondaryAccountAction.href}
onClick={() => setMenuOpen(false)}
className="text-sm font-medium text-text-muted transition hover:text-text"
>
{typeof secondaryAccountAction.label === "function"
? secondaryAccountAction.label(language)
: secondaryAccountAction.label}
</Link>
) : (
<span className="text-sm text-text-muted/60">
{isChinese ? "导航" : "Menu"}
</span>
<LanguageToggle />
</div>
<div className="flex flex-col gap-2 rounded-xl bg-surface-muted/50 p-2">
<span className="ml-2 text-xs font-medium text-text-muted mb-1">
{isChinese ? "发布频道" : "Channels"}
</span>
<ReleaseChannelSelector
selected={selectedChannels}
onToggle={toggleChannel}
/>
</div>
)}
</div>
{primaryAccountAction ? (
<div className="flex shrink-0 flex-col items-end gap-2">
<Link
href={primaryAccountAction.href}
onClick={() => setMenuOpen(false)}
className="inline-flex min-h-[3.1rem] min-w-[7rem] items-center justify-center rounded-full bg-surface-muted px-6 text-[1.05rem] font-semibold text-text shadow-sm transition hover:bg-surface-hover"
>
{typeof primaryAccountAction.label === "function"
? primaryAccountAction.label(language)
: primaryAccountAction.label}
</Link>
</div>
) : null}
</div>
</div>
</div>
</div>
)}
</nav>
</div>
)}
{/* <div className="hidden lg:block">
<AskAIButton />

View File

@ -0,0 +1,524 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { CheckCircle2, Loader2, RefreshCw, ShieldCheck } from "lucide-react";
import Link from "next/link";
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import type { XWorkmateProfileResponse } from "@/lib/xworkmate/types";
import { toXWorkmateIntegrationDefaults } from "@/lib/xworkmate/types";
import { useOpenClawConsoleStore } from "@/state/openclawConsoleStore";
type ProbeTarget = "openclaw" | "vault" | "apisix";
type ProbeState = {
ok: boolean;
status?: number;
error?: string;
body?: string;
};
type XWorkmateProfileEditorProps = {
payload: XWorkmateProfileResponse;
scopeKey: string;
workspaceHref: string;
};
function StatusBadge({ ok, label }: { ok: boolean; label: string }) {
return (
<span
className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold ${
ok
? "bg-emerald-500/10 text-emerald-600"
: "bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)]"
}`}
>
<span
className={`h-2 w-2 rounded-full ${ok ? "bg-emerald-500" : "bg-[var(--color-text-subtle)]/50"}`}
/>
{label}
</span>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-2 text-sm">
<div className="space-y-1">
<span className="font-medium text-[var(--color-text)]">{label}</span>
{hint ? (
<p className="text-xs text-[var(--color-text-subtle)]">{hint}</p>
) : null}
</div>
{children}
</label>
);
}
function inputClassName(type: "input" | "textarea" = "input"): string {
return [
"w-full rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text)] outline-none transition",
"focus:border-[color:var(--color-primary)] focus:ring-2 focus:ring-[color:var(--color-primary-muted)]",
type === "textarea" ? "min-h-[120px] resize-y" : "",
]
.filter(Boolean)
.join(" ");
}
export function XWorkmateProfileEditor({
payload,
scopeKey,
workspaceHref,
}: XWorkmateProfileEditorProps) {
const defaults = useMemo<IntegrationDefaults>(
() => toXWorkmateIntegrationDefaults(payload),
[payload],
);
const setScope = useOpenClawConsoleStore((state) => state.setScope);
const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl);
const openclawOrigin = useOpenClawConsoleStore(
(state) => state.openclawOrigin,
);
const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken);
const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl);
const vaultNamespace = useOpenClawConsoleStore(
(state) => state.vaultNamespace,
);
const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken);
const vaultSecretPath = useOpenClawConsoleStore(
(state) => state.vaultSecretPath,
);
const vaultSecretKey = useOpenClawConsoleStore(
(state) => state.vaultSecretKey,
);
const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl);
const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken);
const setOpenclawUrl = useOpenClawConsoleStore(
(state) => state.setOpenclawUrl,
);
const setOpenclawOrigin = useOpenClawConsoleStore(
(state) => state.setOpenclawOrigin,
);
const setOpenclawToken = useOpenClawConsoleStore(
(state) => state.setOpenclawToken,
);
const setVaultUrl = useOpenClawConsoleStore((state) => state.setVaultUrl);
const setVaultNamespace = useOpenClawConsoleStore(
(state) => state.setVaultNamespace,
);
const setVaultToken = useOpenClawConsoleStore((state) => state.setVaultToken);
const setVaultSecretPath = useOpenClawConsoleStore(
(state) => state.setVaultSecretPath,
);
const setVaultSecretKey = useOpenClawConsoleStore(
(state) => state.setVaultSecretKey,
);
const setApisixUrl = useOpenClawConsoleStore((state) => state.setApisixUrl);
const setApisixToken = useOpenClawConsoleStore(
(state) => state.setApisixToken,
);
const [saving, setSaving] = useState(false);
const [loadingTarget, setLoadingTarget] = useState<ProbeTarget | null>(null);
const [saveState, setSaveState] = useState<string>("");
const [probeResults, setProbeResults] = useState<
Record<ProbeTarget, ProbeState>
>({
openclaw: { ok: false },
vault: { ok: false },
apisix: { ok: false },
});
useEffect(() => {
setScope(scopeKey, defaults);
}, [defaults, scopeKey, setScope]);
const summary = useMemo(
() => [
{
key: "openclaw",
label: "OpenClaw",
configured: Boolean(openclawUrl.trim()),
tokenConfigured:
payload.tokenConfigured.openclaw ||
Boolean(vaultSecretPath.trim()) ||
Boolean(openclawToken.trim()),
},
{
key: "vault",
label: "Vault",
configured: Boolean(vaultUrl.trim()),
tokenConfigured:
payload.tokenConfigured.vault || Boolean(vaultToken.trim()),
},
{
key: "apisix",
label: "APISIX",
configured: Boolean(apisixUrl.trim()),
tokenConfigured:
payload.tokenConfigured.apisix || Boolean(apisixToken.trim()),
},
],
[
apisixToken,
apisixUrl,
openclawToken,
openclawUrl,
payload.tokenConfigured.apisix,
payload.tokenConfigured.openclaw,
payload.tokenConfigured.vault,
vaultSecretPath,
vaultToken,
vaultUrl,
],
);
async function probe(target: ProbeTarget) {
setLoadingTarget(target);
try {
const response = await fetch("/api/integrations/probe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
target,
gatewayUrl: openclawUrl,
gatewayOrigin: openclawOrigin,
gatewayToken: openclawToken,
vaultUrl,
vaultNamespace,
vaultToken,
vaultSecretPath,
vaultSecretKey,
apisixUrl,
apisixToken,
}),
});
const payload = (await response.json().catch(() => ({}))) as ProbeState;
setProbeResults((current) => ({
...current,
[target]: {
ok: Boolean(response.ok && payload.ok),
status: payload.status ?? response.status,
error: payload.error,
body: typeof payload.body === "string" ? payload.body : "",
},
}));
} finally {
setLoadingTarget(null);
}
}
async function saveProfile() {
setSaving(true);
setSaveState("");
try {
const response = await fetch("/api/xworkmate/profile", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
profile: {
openclawUrl,
openclawOrigin,
vaultUrl,
vaultNamespace,
vaultSecretPath,
vaultSecretKey,
apisixUrl,
},
}),
});
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(payload.error ?? "save_failed");
}
setSaveState("已保存配置。临时 token 仍只保留在当前浏览器会话。");
} catch (error) {
console.error("Failed to save xworkmate profile", error);
setSaveState("保存失败,请检查权限或服务连接。");
} finally {
setSaving(false);
}
}
return (
<div className="space-y-5">
<div className="rounded-[28px] border border-[color:var(--color-surface-border)] bg-white/96 px-6 py-5 shadow-[0_18px_50px_rgba(15,23,42,0.06)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<StatusBadge
ok={payload.canEditIntegrations}
label={
payload.profileScope === "tenant-shared"
? "共享版配置"
: "个人独享配置"
}
/>
<StatusBadge
ok={payload.membershipRole === "admin"}
label={`角色 · ${payload.membershipRole}`}
/>
<StatusBadge
ok={summary.some((item) => item.configured)}
label={`${payload.tenant.name} · ${payload.tenant.domain}`}
/>
</div>
<div>
<h1 className="text-[24px] font-semibold tracking-[-0.03em] text-black">
{payload.profileScope === "tenant-shared"
? "共享集成配置"
: "我的集成配置"}
</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-text-subtle)]">
{payload.profileScope === "tenant-shared"
? "这组配置对 svc.plus/xworkmate 的共享工作台生效,只有管理员可编辑。"
: "这组配置只对当前租户域名下的你自己生效,不影响其他成员。"}
</p>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Link
href={workspaceHref}
className="inline-flex h-11 items-center rounded-[14px] border border-[color:var(--color-surface-border)] bg-white px-5 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)]"
>
</Link>
<button
type="button"
onClick={saveProfile}
disabled={saving || !payload.canEditIntegrations}
className="inline-flex h-11 items-center gap-2 rounded-[14px] bg-[var(--color-primary)] px-5 text-sm font-semibold text-white shadow-[0_10px_24px_rgba(51,102,255,0.28)] transition hover:bg-[var(--color-primary-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</button>
</div>
</div>
{saveState ? (
<p className="mt-4 text-sm text-[var(--color-text-subtle)]">
{saveState}
</p>
) : null}
</div>
<div className="grid gap-4 lg:grid-cols-3">
{summary.map((item) => (
<div
key={item.key}
className="rounded-[22px] border border-[color:var(--color-surface-border)] bg-white/92 p-5 shadow-[var(--shadow-sm)]"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-base font-semibold text-[var(--color-heading)]">
{item.label}
</p>
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
{item.configured ? "已填写连接信息" : "等待配置"}
</p>
</div>
<ShieldCheck className="h-5 w-5 text-[var(--color-primary)]" />
</div>
<div className="mt-4 flex flex-wrap gap-2">
<StatusBadge ok={item.configured} label="地址" />
<StatusBadge ok={item.tokenConfigured} label="凭证" />
</div>
</div>
))}
</div>
<div className="grid gap-5 xl:grid-cols-2">
<div className="space-y-4 rounded-[24px] border border-[color:var(--color-surface-border)] bg-white/96 p-5 shadow-[var(--shadow-sm)]">
<Field label="OpenClaw WebSocket URL">
<input
value={openclawUrl}
onChange={(event) => setOpenclawUrl(event.target.value)}
placeholder="wss://openclaw.svc.plus"
className={inputClassName()}
/>
</Field>
<Field
label="OpenClaw Origin"
hint="留空时允许前端按当前页面 origin 发送。"
>
<input
value={openclawOrigin}
onChange={(event) => setOpenclawOrigin(event.target.value)}
placeholder={`https://${payload.tenant.domain}`}
className={inputClassName()}
/>
</Field>
<Field
label="OpenClaw Token"
hint="仅保留在当前浏览器会话,不会持久化到服务端。"
>
<input
type="password"
value={openclawToken}
onChange={(event) => setOpenclawToken(event.target.value)}
placeholder="Session token only"
className={inputClassName()}
/>
</Field>
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--color-heading)]">
OpenClaw
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{probeResults.openclaw.error || "检查网关连接和会话 token。"}
</p>
</div>
<button
type="button"
onClick={() => probe("openclaw")}
className="inline-flex h-10 items-center gap-2 rounded-[12px] border border-[color:var(--color-surface-border)] bg-white px-4 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)]"
>
{loadingTarget === "openclaw" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="space-y-4 rounded-[24px] border border-[color:var(--color-surface-border)] bg-white/96 p-5 shadow-[var(--shadow-sm)]">
<Field label="Vault URL">
<input
value={vaultUrl}
onChange={(event) => setVaultUrl(event.target.value)}
placeholder="https://vault.svc.plus"
className={inputClassName()}
/>
</Field>
<Field label="Vault Namespace">
<input
value={vaultNamespace}
onChange={(event) => setVaultNamespace(event.target.value)}
placeholder="admin"
className={inputClassName()}
/>
</Field>
<Field
label="Vault Token"
hint="仅用于当前浏览器会话内探测或读取引用。"
>
<input
type="password"
value={vaultToken}
onChange={(event) => setVaultToken(event.target.value)}
placeholder="Session token only"
className={inputClassName()}
/>
</Field>
<Field label="Vault Secret Path">
<input
value={vaultSecretPath}
onChange={(event) => setVaultSecretPath(event.target.value)}
placeholder="kv/openclaw"
className={inputClassName()}
/>
</Field>
<Field label="Vault Secret Key">
<input
value={vaultSecretKey}
onChange={(event) => setVaultSecretKey(event.target.value)}
placeholder="token"
className={inputClassName()}
/>
</Field>
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--color-heading)]">
Vault
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{probeResults.vault.error ||
"验证 Vault 地址、namespace 与 token。"}
</p>
</div>
<button
type="button"
onClick={() => probe("vault")}
className="inline-flex h-10 items-center gap-2 rounded-[12px] border border-[color:var(--color-surface-border)] bg-white px-4 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)]"
>
{loadingTarget === "vault" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
<div className="rounded-[24px] border border-[color:var(--color-surface-border)] bg-white/96 p-5 shadow-[var(--shadow-sm)]">
<div className="grid gap-4 xl:grid-cols-[1.35fr_0.65fr]">
<Field label="APISIX URL">
<input
value={apisixUrl}
onChange={(event) => setApisixUrl(event.target.value)}
placeholder="https://ai-gateway.svc.plus"
className={inputClassName()}
/>
</Field>
<Field label="APISIX Token" hint="同样只保留在当前浏览器 session。">
<input
type="password"
value={apisixToken}
onChange={(event) => setApisixToken(event.target.value)}
placeholder="Session token only"
className={inputClassName()}
/>
</Field>
</div>
<div className="mt-4 flex items-center justify-between gap-3 rounded-[18px] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--color-heading)]">
APISIX
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{probeResults.apisix.error ||
"验证 AI Gateway 地址和临时 token。"}
</p>
</div>
<button
type="button"
onClick={() => probe("apisix")}
className="inline-flex h-10 items-center gap-2 rounded-[12px] border border-[color:var(--color-surface-border)] bg-white px-4 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)]"
>
{loadingTarget === "apisix" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -18,13 +18,13 @@ import {
Shield,
Sparkles,
UserCircle2,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useLanguage } from "@/i18n/LanguageProvider";
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import type { XWorkmateProfileResponse } from "@/lib/xworkmate/types";
import { cn } from "@/lib/utils";
import { IntegrationsConsole } from "@/modules/extensions/builtin/user-center/components/IntegrationsConsole";
import { useOpenClawConsoleStore } from "@/state/openclawConsoleStore";
type WorkspaceDestination =
@ -165,7 +165,10 @@ function createSections(isChinese: boolean): SectionDefinition[] {
icon: Sparkles,
tabs: [
{ key: "installed", label: pickCopy(isChinese, "已安装", "Installed") },
{ key: "recommended", label: pickCopy(isChinese, "推荐", "Recommended") },
{
key: "recommended",
label: pickCopy(isChinese, "推荐", "Recommended"),
},
{ key: "clawhub", label: "ClawHub" },
],
cards: [
@ -276,11 +279,18 @@ function createSections(isChinese: boolean): SectionDefinition[] {
tabs: [
{ key: "skills", label: pickCopy(isChinese, "技能", "Skills") },
{ key: "templates", label: pickCopy(isChinese, "模板", "Templates") },
{ key: "connectors", label: pickCopy(isChinese, "连接器", "Connectors") },
{
key: "connectors",
label: pickCopy(isChinese, "连接器", "Connectors"),
},
],
cards: [
{
title: pickCopy(isChinese, "模板与连接器", "Templates and Connectors"),
title: pickCopy(
isChinese,
"模板与连接器",
"Templates and Connectors",
),
description: pickCopy(
isChinese,
"ClawHub 不再只是技能列表,而是统一承接扩展分发。",
@ -355,7 +365,10 @@ function createSections(isChinese: boolean): SectionDefinition[] {
{ key: "general", label: pickCopy(isChinese, "通用", "General") },
{ key: "workspace", label: pickCopy(isChinese, "工作区", "Workspace") },
{ key: "gateway", label: pickCopy(isChinese, "集成", "Integrations") },
{ key: "diagnostics", label: pickCopy(isChinese, "诊断", "Diagnostics") },
{
key: "diagnostics",
label: pickCopy(isChinese, "诊断", "Diagnostics"),
},
],
cards: [
{
@ -489,6 +502,10 @@ function AssistantHome({
prompt,
onPromptChange,
onOpenConnections,
primaryActionLabel,
secondaryActionLabel,
connectionHint,
actionDisabled,
}: {
isChinese: boolean;
tabs: SectionTab[];
@ -497,6 +514,10 @@ function AssistantHome({
prompt: string;
onPromptChange: (value: string) => void;
onOpenConnections: () => void;
primaryActionLabel: string;
secondaryActionLabel: string;
connectionHint?: string;
actionDisabled?: boolean;
}) {
return (
<>
@ -506,7 +527,9 @@ function AssistantHome({
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-[var(--color-text-subtle)]">
<DesktopChip label={pickCopy(isChinese, "主页", "Home")} />
<ChevronRight className="h-4 w-4" />
<DesktopChip label={pickCopy(isChinese, "默认任务", "Default Task")} />
<DesktopChip
label={pickCopy(isChinese, "默认任务", "Default Task")}
/>
</div>
<h1 className="mt-4 text-[20px] font-semibold tracking-[-0.03em] text-black">
{pickCopy(isChinese, "默认任务", "Default Task")}
@ -520,7 +543,11 @@ function AssistantHome({
</p>
<div className="mt-5 flex flex-wrap gap-3">
{tabs.map((tab, index) => (
<DesktopChip key={tab.key} label={tab.label} active={index === 0} />
<DesktopChip
key={tab.key}
label={tab.label}
active={index === 0}
/>
))}
</div>
</div>
@ -545,22 +572,29 @@ function AssistantHome({
"Connect first to start chatting, create tasks, and view results in the current conversation.",
)}
</p>
{connectionHint ? (
<p className="mt-3 text-sm leading-6 text-[var(--color-text-subtle)]">
{connectionHint}
</p>
) : null}
<div className="mt-6 flex flex-wrap gap-3">
<button
type="button"
onClick={onOpenConnections}
className="inline-flex h-11 items-center gap-2 rounded-[14px] bg-[var(--color-primary)] px-5 text-sm font-semibold text-white shadow-[0_10px_24px_rgba(51,102,255,0.28)] transition hover:bg-[var(--color-primary-hover)]"
disabled={actionDisabled}
className="inline-flex h-11 items-center gap-2 rounded-[14px] bg-[var(--color-primary)] px-5 text-sm font-semibold text-white shadow-[0_10px_24px_rgba(51,102,255,0.28)] transition hover:bg-[var(--color-primary-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className="h-4 w-4" />
{pickCopy(isChinese, "重新连接", "Reconnect")}
{primaryActionLabel}
</button>
<button
type="button"
onClick={onOpenConnections}
className="inline-flex h-11 items-center gap-2 rounded-[14px] border border-[color:var(--color-surface-border)] bg-white px-5 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)]"
disabled={actionDisabled}
className="inline-flex h-11 items-center gap-2 rounded-[14px] border border-[color:var(--color-surface-border)] bg-white px-5 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
<Settings2 className="h-4 w-4" />
{pickCopy(isChinese, "编辑连接", "Edit Connection")}
{secondaryActionLabel}
</button>
</div>
</div>
@ -581,7 +615,9 @@ function AssistantHome({
<div className="mt-4 flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap gap-3">
<ToolbarChip label={pickCopy(isChinese, "远程", "Remote")} />
<ToolbarChip label={pickCopy(isChinese, "默认权限", "Default Access")} />
<ToolbarChip
label={pickCopy(isChinese, "默认权限", "Default Access")}
/>
<ToolbarChip label="z-ai/glm5" active />
<ToolbarChip label={pickCopy(isChinese, "问答", "Ask")} />
<ToolbarChip label={pickCopy(isChinese, "高", "High")} />
@ -589,10 +625,11 @@ function AssistantHome({
<button
type="button"
onClick={onOpenConnections}
className="inline-flex h-11 items-center justify-center gap-2 self-end rounded-[14px] bg-[var(--color-primary)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--color-primary-hover)]"
disabled={actionDisabled}
className="inline-flex h-11 items-center justify-center gap-2 self-end rounded-[14px] bg-[var(--color-primary)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--color-primary-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className="h-4 w-4" />
{pickCopy(isChinese, "重连", "Reconnect")}
{primaryActionLabel}
</button>
</div>
</div>
@ -625,12 +662,20 @@ function SectionOverview({
</p>
<div className="mt-5 flex flex-wrap gap-3">
{section.tabs.map((tab, index) => (
<DesktopChip key={tab.key} label={tab.label} active={index === 0} />
<DesktopChip
key={tab.key}
label={tab.label}
active={index === 0}
/>
))}
</div>
</div>
<div className="inline-flex h-fit items-center rounded-full border border-[color:var(--color-surface-border)] bg-white px-4 py-2 text-sm font-semibold text-[var(--color-text-subtle)]">
{pickCopy(isChinese, "已对齐最新桌面结构", "Aligned with latest desktop IA")}
{pickCopy(
isChinese,
"已对齐最新桌面结构",
"Aligned with latest desktop IA",
)}
</div>
</div>
</div>
@ -650,24 +695,32 @@ function SectionOverview({
export function XWorkmateWorkspacePage({
defaults,
profile,
scopeKey,
requestHost,
}: {
defaults: IntegrationDefaults;
profile?: XWorkmateProfileResponse | null;
scopeKey: string;
requestHost?: string;
}) {
const { language } = useLanguage();
const isChinese = language === "zh";
const router = useRouter();
const [activeSection, setActiveSection] =
useState<WorkspaceDestination>("assistant");
const [composerValue, setComposerValue] = useState("");
const [showConnections, setShowConnections] = useState(false);
const setScope = useOpenClawConsoleStore((state) => state.setScope);
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults);
const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl);
const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl);
const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl);
useEffect(() => {
setScope(scopeKey, defaults);
applyDefaults(defaults);
}, [applyDefaults, defaults]);
}, [applyDefaults, defaults, scopeKey, setScope]);
const sections = useMemo(() => createSections(isChinese), [isChinese]);
const activeDefinition =
@ -678,9 +731,11 @@ export function XWorkmateWorkspacePage({
pickCopy(isChinese, "未连接目标", "No target"),
);
const connected = Boolean(openclawEndpoint.trim());
const configuredCount = [openclawEndpoint, vaultUrl || defaults.vaultUrl, apisixUrl || defaults.apisixUrl].filter(
(item) => item.trim().length > 0,
).length;
const configuredCount = [
openclawEndpoint,
vaultUrl || defaults.vaultUrl,
apisixUrl || defaults.apisixUrl,
].filter((item) => item.trim().length > 0).length;
const primarySections = sections.filter((section) =>
["assistant", "tasks", "skills"].includes(section.key),
@ -694,6 +749,51 @@ export function XWorkmateWorkspacePage({
const footerSections = sections.filter((section) =>
["settings", "account"].includes(section.key),
);
const integrationRoute =
profile?.profileScope === "tenant-shared"
? "/xworkmate/admin"
: "/xworkmate/integrations";
const canEditIntegrations = Boolean(profile?.canEditIntegrations);
const profileModeLabel =
profile?.profileScope === "tenant-shared"
? pickCopy(isChinese, "共享配置", "Shared Profile")
: pickCopy(isChinese, "个人配置", "Personal Profile");
const connectionHint = profile
? profile.profileScope === "tenant-shared" && !profile.canEditIntegrations
? pickCopy(
isChinese,
"当前是共享版工作台。只有管理员能修改连接配置,普通成员可直接使用已发布能力。",
"This is the shared workspace. Only administrators can change integrations, while members can use the published workspace.",
)
: profile.profileScope === "tenant-shared"
? pickCopy(
isChinese,
"你正在维护共享版连接配置,保存后会影响 svc.plus/xworkmate 的共享工作台。",
"You are editing the shared integrations profile for svc.plus/xworkmate.",
)
: pickCopy(
isChinese,
"你正在使用租户独享工作台,连接配置只对当前用户生效。",
"You are using a tenant-private workspace, and the profile only affects the current member.",
)
: pickCopy(
isChinese,
"未检测到租户配置,当前仍会回退到浏览器会话内的默认连接。",
"No tenant profile was resolved yet, so the workspace falls back to browser-session defaults.",
);
const primaryActionLabel = canEditIntegrations
? pickCopy(isChinese, "打开配置页", "Open Config")
: pickCopy(isChinese, "查看状态", "View Status");
const secondaryActionLabel = canEditIntegrations
? pickCopy(isChinese, "管理连接", "Manage Integrations")
: pickCopy(isChinese, "等待管理员配置", "Await Admin Setup");
const openConnections = () => {
if (!canEditIntegrations) {
return;
}
router.push(integrationRoute);
};
return (
<div className="relative h-full overflow-hidden bg-[linear-gradient(180deg,#f4f7fd_0%,#f6f8fb_32%,#f3f5f8_100%)] text-[var(--color-text)]">
@ -763,6 +863,28 @@ export function XWorkmateWorkspacePage({
<main className="flex min-h-0 flex-1 flex-col rounded-[30px] border border-white/75 bg-[rgba(255,255,255,0.54)] p-3 shadow-[0_24px_64px_rgba(15,23,42,0.07)] backdrop-blur">
<div className="min-h-0 flex-1 rounded-[28px] border border-white/80 bg-[rgba(248,250,252,0.78)] p-3">
<div className="mx-auto flex h-full max-w-[1680px] min-h-0 flex-col">
{profile ? (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-[22px] border border-[color:var(--color-surface-border)] bg-white/90 px-5 py-4 text-sm text-[var(--color-text-subtle)] shadow-[var(--shadow-sm)]">
<Shield className="h-4 w-4 text-[var(--color-primary)]" />
<span>
{profile.edition === "shared_public"
? pickCopy(isChinese, "共享版", "Shared Edition")
: pickCopy(isChinese, "租户独享版", "Tenant Edition")}
</span>
<span>·</span>
<span>{profile.tenant.name}</span>
<span>·</span>
<span>{profile.membershipRole}</span>
<span>·</span>
<span>{profileModeLabel}</span>
{requestHost ? (
<>
<span>·</span>
<span>{requestHost}</span>
</>
) : null}
</div>
) : null}
{activeSection === "assistant" ? (
<AssistantHome
isChinese={isChinese}
@ -771,10 +893,17 @@ export function XWorkmateWorkspacePage({
connected={connected}
prompt={composerValue}
onPromptChange={setComposerValue}
onOpenConnections={() => setShowConnections(true)}
onOpenConnections={openConnections}
primaryActionLabel={primaryActionLabel}
secondaryActionLabel={secondaryActionLabel}
connectionHint={connectionHint}
actionDisabled={!canEditIntegrations}
/>
) : (
<SectionOverview isChinese={isChinese} section={activeDefinition} />
<SectionOverview
isChinese={isChinese}
section={activeDefinition}
/>
)}
</div>
</div>
@ -787,42 +916,6 @@ export function XWorkmateWorkspacePage({
? `${pickCopy(isChinese, "在线网关", "Gateway Online")} · ${configuredCount}/3`
: `${pickCopy(isChinese, "集成概况", "Integrations")} · ${configuredCount}/3`}
</div>
{showConnections ? (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-[rgba(15,23,42,0.24)] p-6 backdrop-blur-[2px]">
<div className="max-h-[calc(100vh-64px)] w-full max-w-[1080px] overflow-auto rounded-[28px] border border-white/80 bg-[linear-gradient(180deg,#fbfdff_0%,#f6f8fc_100%)] p-5 shadow-[0_32px_80px_rgba(15,23,42,0.20)]">
<div className="mb-4 flex items-center justify-between gap-4 rounded-[20px] border border-[color:var(--color-surface-border)] bg-white/92 px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--color-heading)]">
{pickCopy(isChinese, "编辑 Gateway 连接", "Edit Gateway Connections")}
</p>
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
{pickCopy(
isChinese,
"沿用当前在线版的配置、探测和会话级覆盖逻辑。",
"Reuse the current web configuration, probe, and session override flow.",
)}
</p>
</div>
<button
type="button"
aria-label="Close"
onClick={() => setShowConnections(false)}
className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-[color:var(--color-surface-border)] bg-white text-[var(--color-text-subtle)] transition hover:text-[var(--color-heading)]"
>
<X className="h-4.5 w-4.5" />
</button>
</div>
<IntegrationsConsole
defaults={defaults}
onOpenAssistant={() => {
setActiveSection("assistant");
setShowConnections(false);
}}
/>
</div>
</div>
) : null}
</div>
);
}

View File

@ -1,90 +1,127 @@
import { NextResponse } from 'next/server'
import { NextResponse } from "next/server";
export const SESSION_COOKIE_NAME = 'xc_session'
export const MFA_COOKIE_NAME = 'xc_mfa_challenge'
export const SESSION_COOKIE_NAME = "xc_session";
export const MFA_COOKIE_NAME = "xc_mfa_challenge";
const SESSION_DEFAULT_MAX_AGE = 60 * 60 * 24 // 24 hours
const MFA_DEFAULT_MAX_AGE = 60 * 10 // 10 minutes
const SESSION_DEFAULT_MAX_AGE = 60 * 60 * 24; // 24 hours
const MFA_DEFAULT_MAX_AGE = 60 * 10; // 10 minutes
function readEnvValue(key: string): string | undefined {
const value = process.env[key]
if (typeof value !== 'string') {
return undefined
const value = process.env[key];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function parseBoolean(value: string | undefined): boolean | undefined {
if (!value) {
return undefined
return undefined;
}
const normalized = value.trim().toLowerCase()
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return undefined
return undefined;
}
function shouldUseSecureCookies(): boolean {
const explicit =
parseBoolean(readEnvValue('SESSION_COOKIE_SECURE')) ??
parseBoolean(readEnvValue('NEXT_PUBLIC_SESSION_COOKIE_SECURE'))
parseBoolean(readEnvValue("SESSION_COOKIE_SECURE")) ??
parseBoolean(readEnvValue("NEXT_PUBLIC_SESSION_COOKIE_SECURE"));
if (explicit !== undefined) {
return explicit
return explicit;
}
if (process.env.NODE_ENV === 'production') {
return true
if (process.env.NODE_ENV === "production") {
return true;
}
const baseUrl =
readEnvValue('NEXT_PUBLIC_APP_BASE_URL') ??
readEnvValue('APP_BASE_URL') ??
readEnvValue('NEXT_PUBLIC_SITE_URL')
readEnvValue("NEXT_PUBLIC_APP_BASE_URL") ??
readEnvValue("APP_BASE_URL") ??
readEnvValue("NEXT_PUBLIC_SITE_URL");
if (typeof baseUrl === 'string' && baseUrl.toLowerCase().startsWith('https://')) {
return true
if (
typeof baseUrl === "string" &&
baseUrl.toLowerCase().startsWith("https://")
) {
return true;
}
return false
return false;
}
const secureCookieBase = {
httpOnly: true,
secure: shouldUseSecureCookies(),
sameSite: 'lax' as const, // Change to lax to support cross-subdomain
path: '/',
}
sameSite: "lax" as const, // Change to lax to support cross-subdomain
path: "/",
};
/**
* Resolves the cookie domain based on the current environment.
* If running on a .svc.plus subdomain, returns '.svc.plus' to allow SSO.
*/
function resolveCookieDomain(): string | undefined {
if (typeof window !== 'undefined') {
const host = window.location.hostname
if (host.endsWith('.svc.plus')) {
return '.svc.plus'
function normalizeHostname(value?: string | null): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return undefined;
}
const withoutProtocol = trimmed.replace(/^https?:\/\//, "");
const withoutPath = withoutProtocol.split("/")[0] ?? "";
const withoutPort = withoutPath.replace(/:\d+$/, "");
return withoutPort || undefined;
}
function resolveCookieDomain(requestHost?: string): string | undefined {
const normalizedRequestHost = normalizeHostname(requestHost);
if (normalizedRequestHost) {
if (
normalizedRequestHost === "svc.plus" ||
normalizedRequestHost.endsWith(".svc.plus")
) {
return ".svc.plus";
}
return undefined;
}
if (typeof window !== "undefined") {
const host = window.location.hostname;
if (host.endsWith(".svc.plus")) {
return ".svc.plus";
}
}
// For server-side, check headers or environment
const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || ''
if (baseUrl.includes('.svc.plus')) {
return '.svc.plus'
const baseUrl =
process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || "";
if (baseUrl.includes(".svc.plus")) {
return ".svc.plus";
}
return undefined
return undefined;
}
export function applySessionCookie(response: NextResponse, token: string, maxAge?: number) {
const resolvedMaxAge = Number.isFinite(maxAge) && maxAge && maxAge > 0 ? Math.floor(maxAge) : SESSION_DEFAULT_MAX_AGE
const domain = resolveCookieDomain()
export function applySessionCookie(
response: NextResponse,
token: string,
maxAge?: number,
requestHost?: string,
) {
const resolvedMaxAge =
Number.isFinite(maxAge) && maxAge && maxAge > 0
? Math.floor(maxAge)
: SESSION_DEFAULT_MAX_AGE;
const domain = resolveCookieDomain(requestHost);
response.cookies.set({
name: SESSION_COOKIE_NAME,
@ -92,71 +129,84 @@ export function applySessionCookie(response: NextResponse, token: string, maxAge
...secureCookieBase,
maxAge: resolvedMaxAge,
...(domain ? { domain } : {}),
})
});
}
export function clearSessionCookie(response: NextResponse) {
const domain = resolveCookieDomain()
export function clearSessionCookie(
response: NextResponse,
requestHost?: string,
) {
const domain = resolveCookieDomain(requestHost);
// Always clear the host-only cookie.
response.cookies.set({
name: SESSION_COOKIE_NAME,
value: '',
value: "",
...secureCookieBase,
maxAge: 0,
})
});
// Also clear the domain-scoped cookie if we can resolve the domain.
if (domain) {
response.cookies.set({
name: SESSION_COOKIE_NAME,
value: '',
value: "",
...secureCookieBase,
maxAge: 0,
domain,
})
});
}
}
export function applyMfaCookie(response: NextResponse, token: string, maxAge?: number) {
const resolvedMaxAge = Number.isFinite(maxAge) && maxAge && maxAge > 0 ? Math.floor(maxAge) : MFA_DEFAULT_MAX_AGE
export function applyMfaCookie(
response: NextResponse,
token: string,
maxAge?: number,
) {
const resolvedMaxAge =
Number.isFinite(maxAge) && maxAge && maxAge > 0
? Math.floor(maxAge)
: MFA_DEFAULT_MAX_AGE;
response.cookies.set({
name: MFA_COOKIE_NAME,
value: token,
...secureCookieBase,
maxAge: resolvedMaxAge,
})
});
}
export function clearMfaCookie(response: NextResponse) {
// Clear host-only
response.cookies.set({
name: MFA_COOKIE_NAME,
value: '',
value: "",
...secureCookieBase,
maxAge: 0,
})
});
// Clear domain-scoped if resolved
const domain = resolveCookieDomain()
const domain = resolveCookieDomain();
if (domain) {
response.cookies.set({
name: MFA_COOKIE_NAME,
value: '',
value: "",
...secureCookieBase,
maxAge: 0,
domain,
})
});
}
}
export function deriveMaxAgeFromExpires(expiresAt?: string | number | Date | null, fallback = SESSION_DEFAULT_MAX_AGE) {
export function deriveMaxAgeFromExpires(
expiresAt?: string | number | Date | null,
fallback = SESSION_DEFAULT_MAX_AGE,
) {
if (!expiresAt) {
return fallback
return fallback;
}
const date = expiresAt instanceof Date ? expiresAt : new Date(expiresAt)
const msUntilExpiry = date.getTime() - Date.now()
const date = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
const msUntilExpiry = date.getTime() - Date.now();
if (!Number.isFinite(msUntilExpiry) || msUntilExpiry <= 0) {
return fallback
return fallback;
}
return Math.floor(msUntilExpiry / 1000)
return Math.floor(msUntilExpiry / 1000);
}

39
src/lib/xworkmate/host.ts Normal file
View File

@ -0,0 +1,39 @@
const SHARED_HOSTS = new Set([
"svc.plus",
"www.svc.plus",
"console.svc.plus",
"localhost",
"127.0.0.1",
"[::1]",
]);
export function normalizeXWorkmateHost(value?: string | null): string {
const trimmed = String(value ?? "")
.trim()
.toLowerCase();
if (!trimmed) {
return "";
}
const withoutProtocol = trimmed.replace(/^https?:\/\//, "");
const withoutPath = withoutProtocol.split("/")[0] ?? "";
const withoutPort = withoutPath.replace(/:\d+$/, "");
return withoutPort.replace(/\.+$/, "");
}
export function isSharedXWorkmateHost(host?: string | null): boolean {
const normalized = normalizeXWorkmateHost(host);
if (!normalized) {
return true;
}
return SHARED_HOSTS.has(normalized);
}
export function isLegacyConsoleXWorkmateHost(host?: string | null): boolean {
return normalizeXWorkmateHost(host) === "console.svc.plus";
}
export function buildSharedXWorkmateUrl(pathname: string): string {
const normalizedPath = pathname.startsWith("/") ? pathname : `/${pathname}`;
return `https://svc.plus${normalizedPath}`;
}

View File

@ -0,0 +1,76 @@
import type { IntegrationDefaults } from "@/lib/openclaw/types";
export type XWorkmateEdition = "shared_public" | "tenant_private";
export type XWorkmateProfileScope = "tenant-shared" | "user-private";
export type XWorkmateMembershipRole = "admin" | "user";
export type XWorkmateProfile = {
openclawUrl: string;
openclawOrigin: string;
vaultUrl: string;
vaultNamespace: string;
vaultSecretPath: string;
vaultSecretKey: string;
apisixUrl: string;
};
export type XWorkmateProfileResponse = {
edition: XWorkmateEdition;
tenant: {
id: string;
name: string;
domain: string;
};
membershipRole: XWorkmateMembershipRole;
profileScope: XWorkmateProfileScope;
canEditIntegrations: boolean;
canManageTenant: boolean;
profile: XWorkmateProfile;
tokenConfigured: {
openclaw: boolean;
vault: boolean;
apisix: boolean;
};
};
export function toXWorkmateIntegrationDefaults(
payload: XWorkmateProfileResponse | null | undefined,
): IntegrationDefaults {
return {
openclawUrl: payload?.profile.openclawUrl ?? "",
openclawOrigin: payload?.profile.openclawOrigin ?? "",
openclawTokenConfigured: Boolean(payload?.tokenConfigured.openclaw),
vaultUrl: payload?.profile.vaultUrl ?? "",
vaultNamespace: payload?.profile.vaultNamespace ?? "",
vaultTokenConfigured: Boolean(payload?.tokenConfigured.vault),
vaultSecretPath: payload?.profile.vaultSecretPath ?? "",
vaultSecretKey: payload?.profile.vaultSecretKey ?? "",
apisixUrl: payload?.profile.apisixUrl ?? "",
apisixTokenConfigured: Boolean(payload?.tokenConfigured.apisix),
};
}
export function buildXWorkmateScopeKey(
payload: XWorkmateProfileResponse | null | undefined,
userId?: string | null,
host?: string | null,
): string {
const normalizedHost =
String(host ?? "")
.trim()
.toLowerCase() || "shared";
const normalizedTenant = payload?.tenant.id?.trim() || "anonymous";
const normalizedScope = payload?.profileScope?.trim() || "guest";
const normalizedUser =
payload?.profileScope === "tenant-shared"
? "shared"
: String(userId ?? "").trim() || "anonymous";
return [
"xworkmate",
normalizedHost,
normalizedTenant,
normalizedUser,
normalizedScope,
].join(":");
}

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
evaluateAccountAdminAccess,
isPlatformRootEmail,
} from "@server/account/adminAccess";
import type { AccountSessionUser } from "@server/account/session";
function buildUser(overrides: Partial<AccountSessionUser> = {}): AccountSessionUser {
return {
id: "user-1",
uuid: "user-1",
email: "user@example.com",
role: "user",
groups: [],
permissions: [],
...overrides,
};
}
describe("adminAccess", () => {
it("allows platform admin roles without an explicit permission", async () => {
const decision = await evaluateAccountAdminAccess(buildUser({ role: "admin" }), {
roles: ["admin", "operator"],
permissions: ["admin.users.list.read"],
});
expect(decision).toEqual({ allowed: true });
});
it("allows permission-scoped operators without requiring the admin role", async () => {
const decision = await evaluateAccountAdminAccess(buildUser({
role: "operator",
permissions: ["admin.users.pause.write"],
}), {
roles: ["admin", "operator"],
permissions: ["admin.users.pause.write"],
});
expect(decision).toEqual({ allowed: true });
});
it("enforces root-only routes after role and permission checks pass", async () => {
const decision = await evaluateAccountAdminAccess(buildUser({
role: "admin",
permissions: ["admin.settings.write"],
}), {
roles: ["admin"],
permissions: ["admin.settings.write"],
rootOnly: true,
});
expect(decision).toEqual({ allowed: false, reason: "root_only" });
});
it("recognizes the shared platform root email", () => {
expect(isPlatformRootEmail("admin@svc.plus")).toBe(true);
expect(isPlatformRootEmail("ADMIN@svc.plus")).toBe(true);
expect(isPlatformRootEmail("user@example.com")).toBe(false);
});
});

View File

@ -0,0 +1,77 @@
import type {
AccountSessionUser,
AccountUserRole,
} from "@server/account/session";
export const PLATFORM_ROOT_EMAIL = "admin@svc.plus";
type AccountAdminAccessRule = {
roles?: AccountUserRole[];
permissions?: string[];
rootOnly?: boolean;
};
type AccountAdminAccessDecision = {
allowed: boolean;
reason?: "forbidden" | "root_only";
};
export function isPlatformRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === PLATFORM_ROOT_EMAIL;
}
function hasRole(
user: AccountSessionUser,
roles: AccountUserRole[],
): boolean {
return roles.includes(user.role);
}
function hasPermission(
user: AccountSessionUser,
permissions: string[],
): boolean {
if (permissions.length === 0) {
return false;
}
const normalizedPermissions = new Set(
user.permissions.map((permission) => permission.trim()),
);
if (normalizedPermissions.has("*")) {
return true;
}
return permissions.every((permission) =>
normalizedPermissions.has(permission.trim()),
);
}
export async function evaluateAccountAdminAccess(
user: AccountSessionUser | null,
rule: AccountAdminAccessRule,
): Promise<AccountAdminAccessDecision> {
if (!user) {
return { allowed: false, reason: "forbidden" };
}
const roles = rule.roles ?? [];
const permissions = rule.permissions ?? [];
let allowed = false;
if (roles.length > 0 && permissions.length > 0) {
allowed = hasRole(user, roles) || hasPermission(user, permissions);
} else if (roles.length > 0) {
allowed = hasRole(user, roles);
} else if (permissions.length > 0) {
allowed = hasPermission(user, permissions);
}
if (!allowed) {
return { allowed: false, reason: "forbidden" };
}
if (rule.rootOnly && !isPlatformRootEmail(user.email)) {
return { allowed: false, reason: "root_only" };
}
return { allowed: true };
}

View File

@ -214,6 +214,20 @@ async function resolveTokenFromRequest(
return undefined;
}
function resolveForwardedHost(request?: NextRequest): string | undefined {
if (!request) {
return undefined;
}
const hostHeader =
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (!hostHeader) {
return undefined;
}
const trimmed = hostHeader.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export async function userHasRole(
user: AccountSessionUser | null,
roles: AccountUserRole[],
@ -263,6 +277,7 @@ export async function getAccountSession(
if (!token) {
return { token: undefined, user: null };
}
const requestHost = resolveForwardedHost(request);
try {
const response = await fetch(`${ACCOUNT_API_BASE}/session`, {
@ -270,6 +285,11 @@ export async function getAccountSession(
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
...(requestHost
? {
"X-Forwarded-Host": requestHost,
}
: {}),
},
cache: "no-store",
});

View File

@ -0,0 +1,73 @@
import "server-only";
import { cookies } from "next/headers";
import { SESSION_COOKIE_NAME } from "@/lib/authGateway";
import type { AccountSessionUser } from "@/server/account/session";
import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig";
import type { XWorkmateProfileResponse } from "@/lib/xworkmate/types";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
type AccountSessionResponse = {
user?: AccountSessionUser | null;
};
function buildForwardHeaders(
token: string,
host?: string | null,
): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
};
const normalizedHost = String(host ?? "").trim();
if (normalizedHost) {
headers["X-Forwarded-Host"] = normalizedHost;
}
return headers;
}
export async function getXWorkmateSessionContext(
host?: string | null,
): Promise<{
user: AccountSessionUser | null;
profile: XWorkmateProfileResponse | null;
}> {
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim();
if (!token) {
return { user: null, profile: null };
}
const requestHeaders = buildForwardHeaders(token, host);
const [sessionResponse, profileResponse] = await Promise.all([
fetch(`${ACCOUNT_API_BASE}/session`, {
method: "GET",
headers: requestHeaders,
cache: "no-store",
}).catch(() => null),
fetch(`${ACCOUNT_API_BASE}/xworkmate/profile`, {
method: "GET",
headers: requestHeaders,
cache: "no-store",
}).catch(() => null),
]);
let user: AccountSessionUser | null = null;
if (sessionResponse?.ok) {
const payload = (await sessionResponse
.json()
.catch(() => null)) as AccountSessionResponse | null;
user = payload?.user ?? null;
}
let profile: XWorkmateProfileResponse | null = null;
if (profileResponse?.ok) {
profile = (await profileResponse
.json()
.catch(() => null)) as XWorkmateProfileResponse | null;
}
return { user, profile };
}

View File

@ -1,108 +1,264 @@
'use client'
"use client";
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { AssistantMode, IntegrationDefaults, ThinkingLevel } from '@/lib/openclaw/types'
import type {
AssistantMode,
IntegrationDefaults,
ThinkingLevel,
} from "@/lib/openclaw/types";
type OpenClawConsoleState = {
defaultsLoaded: boolean
openclawUrl: string
openclawOrigin: string
openclawToken: string
vaultUrl: string
vaultNamespace: string
vaultToken: string
vaultSecretPath: string
vaultSecretKey: string
apisixUrl: string
apisixToken: string
assistantMode: AssistantMode
thinking: ThinkingLevel
selectedAgentId: string
selectedSessionKey: string
applyDefaults: (defaults: IntegrationDefaults) => void
setOpenclawUrl: (value: string) => void
setOpenclawOrigin: (value: string) => void
setOpenclawToken: (value: string) => void
setVaultUrl: (value: string) => void
setVaultNamespace: (value: string) => void
setVaultToken: (value: string) => void
setVaultSecretPath: (value: string) => void
setVaultSecretKey: (value: string) => void
setApisixUrl: (value: string) => void
setApisixToken: (value: string) => void
setAssistantMode: (value: AssistantMode) => void
setThinking: (value: ThinkingLevel) => void
setSelectedAgentId: (value: string) => void
setSelectedSessionKey: (value: string) => void
type OpenClawScopedSnapshot = {
openclawUrl: string;
openclawOrigin: string;
openclawToken: string;
vaultUrl: string;
vaultNamespace: string;
vaultToken: string;
vaultSecretPath: string;
vaultSecretKey: string;
apisixUrl: string;
apisixToken: string;
assistantMode: AssistantMode;
thinking: ThinkingLevel;
selectedAgentId: string;
selectedSessionKey: string;
};
type OpenClawConsoleState = OpenClawScopedSnapshot & {
defaultsLoaded: boolean;
scopeKey: string;
scopedSessions: Record<string, OpenClawScopedSnapshot>;
applyDefaults: (defaults: IntegrationDefaults) => void;
setScope: (scopeKey: string, defaults?: IntegrationDefaults) => void;
setOpenclawUrl: (value: string) => void;
setOpenclawOrigin: (value: string) => void;
setOpenclawToken: (value: string) => void;
setVaultUrl: (value: string) => void;
setVaultNamespace: (value: string) => void;
setVaultToken: (value: string) => void;
setVaultSecretPath: (value: string) => void;
setVaultSecretKey: (value: string) => void;
setApisixUrl: (value: string) => void;
setApisixToken: (value: string) => void;
setAssistantMode: (value: AssistantMode) => void;
setThinking: (value: ThinkingLevel) => void;
setSelectedAgentId: (value: string) => void;
setSelectedSessionKey: (value: string) => void;
};
const DEFAULT_SCOPE_KEY = "global";
const EMPTY_SCOPE: OpenClawScopedSnapshot = {
openclawUrl: "",
openclawOrigin: "",
openclawToken: "",
vaultUrl: "",
vaultNamespace: "",
vaultToken: "",
vaultSecretPath: "",
vaultSecretKey: "",
apisixUrl: "",
apisixToken: "",
assistantMode: "ask",
thinking: "high",
selectedAgentId: "",
selectedSessionKey: "",
};
function normalizeScopeKey(value: string): string {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : DEFAULT_SCOPE_KEY;
}
function buildScopedDefaults(
defaults?: IntegrationDefaults,
): OpenClawScopedSnapshot {
return {
...EMPTY_SCOPE,
openclawUrl: defaults?.openclawUrl ?? "",
openclawOrigin: defaults?.openclawOrigin ?? "",
vaultUrl: defaults?.vaultUrl ?? "",
vaultNamespace: defaults?.vaultNamespace ?? "",
vaultSecretPath: defaults?.vaultSecretPath ?? "",
vaultSecretKey: defaults?.vaultSecretKey ?? "",
apisixUrl: defaults?.apisixUrl ?? "",
};
}
function mergeScopeSnapshot(
snapshot: OpenClawScopedSnapshot | undefined,
defaults?: IntegrationDefaults,
): OpenClawScopedSnapshot {
const base = buildScopedDefaults(defaults);
if (!snapshot) {
return base;
}
return {
...snapshot,
openclawUrl: snapshot.openclawUrl || base.openclawUrl,
openclawOrigin: snapshot.openclawOrigin || base.openclawOrigin,
vaultUrl: snapshot.vaultUrl || base.vaultUrl,
vaultNamespace: snapshot.vaultNamespace || base.vaultNamespace,
vaultSecretPath: snapshot.vaultSecretPath || base.vaultSecretPath,
vaultSecretKey: snapshot.vaultSecretKey || base.vaultSecretKey,
apisixUrl: snapshot.apisixUrl || base.apisixUrl,
};
}
function snapshotFromState(
state: OpenClawConsoleState,
): OpenClawScopedSnapshot {
return {
openclawUrl: state.openclawUrl,
openclawOrigin: state.openclawOrigin,
openclawToken: state.openclawToken,
vaultUrl: state.vaultUrl,
vaultNamespace: state.vaultNamespace,
vaultToken: state.vaultToken,
vaultSecretPath: state.vaultSecretPath,
vaultSecretKey: state.vaultSecretKey,
apisixUrl: state.apisixUrl,
apisixToken: state.apisixToken,
assistantMode: state.assistantMode,
thinking: state.thinking,
selectedAgentId: state.selectedAgentId,
selectedSessionKey: state.selectedSessionKey,
};
}
export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
persist(
(set, get) => ({
defaultsLoaded: false,
openclawUrl: '',
openclawOrigin: '',
openclawToken: '',
vaultUrl: '',
vaultNamespace: '',
vaultToken: '',
vaultSecretPath: '',
vaultSecretKey: '',
apisixUrl: '',
apisixToken: '',
assistantMode: 'ask',
thinking: 'high',
selectedAgentId: '',
selectedSessionKey: '',
applyDefaults: (defaults) => {
const current = get()
(set, get) => {
const updateScopedSession = (
partial: Partial<OpenClawScopedSnapshot>,
options?: { defaultsLoaded?: boolean },
) => {
const current = get();
const scopeKey = normalizeScopeKey(current.scopeKey);
const currentSnapshot = mergeScopeSnapshot(
current.scopedSessions[scopeKey],
);
const nextSnapshot = {
...currentSnapshot,
...partial,
};
set({
defaultsLoaded: true,
openclawUrl: current.openclawUrl || defaults.openclawUrl,
openclawOrigin: current.openclawOrigin || defaults.openclawOrigin,
vaultUrl: current.vaultUrl || defaults.vaultUrl,
vaultNamespace: current.vaultNamespace || defaults.vaultNamespace,
vaultSecretPath: current.vaultSecretPath || defaults.vaultSecretPath,
vaultSecretKey: current.vaultSecretKey || defaults.vaultSecretKey,
apisixUrl: current.apisixUrl || defaults.apisixUrl,
})
},
setOpenclawUrl: (openclawUrl) => set({ openclawUrl }),
setOpenclawOrigin: (openclawOrigin) => set({ openclawOrigin }),
setOpenclawToken: (openclawToken) => set({ openclawToken }),
setVaultUrl: (vaultUrl) => set({ vaultUrl }),
setVaultNamespace: (vaultNamespace) => set({ vaultNamespace }),
setVaultToken: (vaultToken) => set({ vaultToken }),
setVaultSecretPath: (vaultSecretPath) => set({ vaultSecretPath }),
setVaultSecretKey: (vaultSecretKey) => set({ vaultSecretKey }),
setApisixUrl: (apisixUrl) => set({ apisixUrl }),
setApisixToken: (apisixToken) => set({ apisixToken }),
setAssistantMode: (assistantMode) => set({ assistantMode }),
setThinking: (thinking) => set({ thinking }),
setSelectedAgentId: (selectedAgentId) => set({ selectedAgentId }),
setSelectedSessionKey: (selectedSessionKey) => set({ selectedSessionKey }),
}),
...partial,
defaultsLoaded:
options?.defaultsLoaded !== undefined
? options.defaultsLoaded
: current.defaultsLoaded,
scopedSessions: {
...current.scopedSessions,
[scopeKey]: nextSnapshot,
},
});
};
return {
defaultsLoaded: false,
scopeKey: DEFAULT_SCOPE_KEY,
scopedSessions: {
[DEFAULT_SCOPE_KEY]: EMPTY_SCOPE,
},
...EMPTY_SCOPE,
applyDefaults: (defaults) => {
const current = get();
const scopeKey = normalizeScopeKey(current.scopeKey);
const nextSnapshot = mergeScopeSnapshot(
current.scopedSessions[scopeKey],
defaults,
);
set({
defaultsLoaded: true,
...nextSnapshot,
scopedSessions: {
...current.scopedSessions,
[scopeKey]: nextSnapshot,
},
});
},
setScope: (scopeKey, defaults) => {
const current = get();
const normalizedScopeKey = normalizeScopeKey(scopeKey);
const nextSnapshot = mergeScopeSnapshot(
current.scopedSessions[normalizedScopeKey],
defaults,
);
set({
scopeKey: normalizedScopeKey,
defaultsLoaded: current.defaultsLoaded || Boolean(defaults),
...nextSnapshot,
scopedSessions: {
...current.scopedSessions,
[normalizedScopeKey]: nextSnapshot,
},
});
},
setOpenclawUrl: (openclawUrl) => updateScopedSession({ openclawUrl }),
setOpenclawOrigin: (openclawOrigin) =>
updateScopedSession({ openclawOrigin }),
setOpenclawToken: (openclawToken) =>
updateScopedSession({ openclawToken }),
setVaultUrl: (vaultUrl) => updateScopedSession({ vaultUrl }),
setVaultNamespace: (vaultNamespace) =>
updateScopedSession({ vaultNamespace }),
setVaultToken: (vaultToken) => updateScopedSession({ vaultToken }),
setVaultSecretPath: (vaultSecretPath) =>
updateScopedSession({ vaultSecretPath }),
setVaultSecretKey: (vaultSecretKey) =>
updateScopedSession({ vaultSecretKey }),
setApisixUrl: (apisixUrl) => updateScopedSession({ apisixUrl }),
setApisixToken: (apisixToken) => updateScopedSession({ apisixToken }),
setAssistantMode: (assistantMode) =>
updateScopedSession({ assistantMode }),
setThinking: (thinking) => updateScopedSession({ thinking }),
setSelectedAgentId: (selectedAgentId) =>
updateScopedSession({ selectedAgentId }),
setSelectedSessionKey: (selectedSessionKey) =>
updateScopedSession({ selectedSessionKey }),
};
},
{
name: 'openclaw-console-session',
name: "openclaw-console-session",
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
openclawUrl: state.openclawUrl,
openclawOrigin: state.openclawOrigin,
openclawToken: state.openclawToken,
vaultUrl: state.vaultUrl,
vaultNamespace: state.vaultNamespace,
vaultToken: state.vaultToken,
vaultSecretPath: state.vaultSecretPath,
vaultSecretKey: state.vaultSecretKey,
apisixUrl: state.apisixUrl,
apisixToken: state.apisixToken,
assistantMode: state.assistantMode,
thinking: state.thinking,
selectedAgentId: state.selectedAgentId,
selectedSessionKey: state.selectedSessionKey,
scopeKey: state.scopeKey,
scopedSessions: state.scopedSessions,
defaultsLoaded: state.defaultsLoaded,
...snapshotFromState(state),
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as
| Partial<OpenClawConsoleState>
| undefined;
const mergedState = {
...currentState,
...persisted,
} as OpenClawConsoleState;
const mergedScopeKey = normalizeScopeKey(mergedState.scopeKey);
const hydratedSnapshot = mergeScopeSnapshot(
mergedState.scopedSessions?.[mergedScopeKey] ??
snapshotFromState(mergedState),
);
return {
...mergedState,
scopeKey: mergedScopeKey,
scopedSessions: {
[DEFAULT_SCOPE_KEY]: EMPTY_SCOPE,
...(mergedState.scopedSessions ?? {}),
[mergedScopeKey]: hydratedSnapshot,
},
...hydratedSnapshot,
};
},
},
),
)
);