merge: integrate xworkmate multitenant into stripe pricing console
This commit is contained in:
commit
1769c26093
@ -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=
|
||||
|
||||
16
README.md
16
README.md
@ -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`
|
||||
|
||||
|
||||
51
docs/integrations/stripe-billing.md
Normal file
51
docs/integrations/stripe-billing.md
Normal 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
60
scripts/skills/package_skill.py
Executable 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())
|
||||
78
scripts/skills/validate_skill.py
Executable file
78
scripts/skills/validate_skill.py
Executable 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())
|
||||
190
skills/git-history-secret-remediation/SKILL.md
Normal file
190
skills/git-history-secret-remediation/SKILL.md
Normal 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
|
||||
47
skills/git-history-secret-remediation/scripts/backup_git_remotes.py
Executable file
47
skills/git-history-secret-remediation/scripts/backup_git_remotes.py
Executable 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())
|
||||
16
skills/git-history-secret-remediation/scripts/list_git_refs.sh
Executable file
16
skills/git-history-secret-remediation/scripts/list_git_refs.sh
Executable 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
|
||||
53
skills/git-history-secret-remediation/scripts/restore_git_remotes.py
Executable file
53
skills/git-history-secret-remediation/scripts/restore_git_remotes.py
Executable 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())
|
||||
61
skills/git-history-secret-remediation/scripts/run_filter_repo_redaction.sh
Executable file
61
skills/git-history-secret-remediation/scripts/run_filter_repo_redaction.sh
Executable 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
|
||||
32
skills/git-history-secret-remediation/scripts/run_gitleaks_history_scan.sh
Executable file
32
skills/git-history-secret-remediation/scripts/run_gitleaks_history_scan.sh
Executable 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[@]}"
|
||||
)
|
||||
27
skills/git-history-secret-remediation/scripts/run_history_remediation.sh
Executable file
27
skills/git-history-secret-remediation/scripts/run_history_remediation.sh
Executable 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"
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
24
src/app/api/auth/oauth/login/[provider]/route.ts
Normal file
24
src/app/api/auth/oauth/login/[provider]/route.ts
Normal 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 });
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
81
src/app/api/xworkmate/profile/route.ts
Normal file
81
src/app/api/xworkmate/profile/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
56
src/app/xworkmate/admin/page.tsx
Normal file
56
src/app/xworkmate/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/app/xworkmate/integrations/page.tsx
Normal file
53
src/app/xworkmate/integrations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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 />
|
||||
|
||||
524
src/components/xworkmate/XWorkmateProfileEditor.tsx
Normal file
524
src/components/xworkmate/XWorkmateProfileEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
39
src/lib/xworkmate/host.ts
Normal 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}`;
|
||||
}
|
||||
76
src/lib/xworkmate/types.ts
Normal file
76
src/lib/xworkmate/types.ts
Normal 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(":");
|
||||
}
|
||||
61
src/server/account/adminAccess.test.ts
Normal file
61
src/server/account/adminAccess.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
77
src/server/account/adminAccess.ts
Normal file
77
src/server/account/adminAccess.ts
Normal 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 };
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
|
||||
73
src/server/xworkmate/profile.ts
Normal file
73
src/server/xworkmate/profile.ts
Normal 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 };
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user