diff --git a/.env.example b/.env.example index 55edf36..ca08874 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index cf842f5..bc7317a 100644 --- a/README.md +++ b/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` diff --git a/docs/integrations/stripe-billing.md b/docs/integrations/stripe-billing.md new file mode 100644 index 0000000..6d6ee01 --- /dev/null +++ b/docs/integrations/stripe-billing.md @@ -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. diff --git a/scripts/skills/package_skill.py b/scripts/skills/package_skill.py new file mode 100755 index 0000000..9a71d90 --- /dev/null +++ b/scripts/skills/package_skill.py @@ -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 [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()) diff --git a/scripts/skills/validate_skill.py b/scripts/skills/validate_skill.py new file mode 100755 index 0000000..b3b5234 --- /dev/null +++ b/scripts/skills/validate_skill.py @@ -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 ") + return 1 + + valid, message = validate_skill(sys.argv[1]) + print(message) + return 0 if valid else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/git-history-secret-remediation/SKILL.md b/skills/git-history-secret-remediation/SKILL.md new file mode 100644 index 0000000..da66131 --- /dev/null +++ b/skills/git-history-secret-remediation/SKILL.md @@ -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 + - `` +- 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_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 diff --git a/skills/git-history-secret-remediation/scripts/backup_git_remotes.py b/skills/git-history-secret-remediation/scripts/backup_git_remotes.py new file mode 100755 index 0000000..beb2718 --- /dev/null +++ b/skills/git-history-secret-remediation/scripts/backup_git_remotes.py @@ -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 ", 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()) diff --git a/skills/git-history-secret-remediation/scripts/list_git_refs.sh b/skills/git-history-secret-remediation/scripts/list_git_refs.sh new file mode 100755 index 0000000..49e773e --- /dev/null +++ b/skills/git-history-secret-remediation/scripts/list_git_refs.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&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 diff --git a/skills/git-history-secret-remediation/scripts/restore_git_remotes.py b/skills/git-history-secret-remediation/scripts/restore_git_remotes.py new file mode 100755 index 0000000..df2fcba --- /dev/null +++ b/skills/git-history-secret-remediation/scripts/restore_git_remotes.py @@ -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 ", 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()) diff --git a/skills/git-history-secret-remediation/scripts/run_filter_repo_redaction.sh b/skills/git-history-secret-remediation/scripts/run_filter_repo_redaction.sh new file mode 100755 index 0000000..cdf94e6 --- /dev/null +++ b/skills/git-history-secret-remediation/scripts/run_filter_repo_redaction.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 [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 diff --git a/skills/git-history-secret-remediation/scripts/run_gitleaks_history_scan.sh b/skills/git-history-secret-remediation/scripts/run_gitleaks_history_scan.sh new file mode 100755 index 0000000..42d8125 --- /dev/null +++ b/skills/git-history-secret-remediation/scripts/run_gitleaks_history_scan.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: $0 [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[@]}" +) diff --git a/skills/git-history-secret-remediation/scripts/run_history_remediation.sh b/skills/git-history-secret-remediation/scripts/run_history_remediation.sh new file mode 100755 index 0000000..fcb4af4 --- /dev/null +++ b/skills/git-history-secret-remediation/scripts/run_history_remediation.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 [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" diff --git a/src/app/(auth)/login/LoginContent.tsx b/src/app/(auth)/login/LoginContent.tsx index e68ace4..8133c5c 100644 --- a/src/app/(auth)/login/LoginContent.tsx +++ b/src/app/(auth)/login/LoginContent.tsx @@ -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, }), }); diff --git a/src/app/AppProviders.tsx b/src/app/AppProviders.tsx index f09ced8..eb5275c 100644 --- a/src/app/AppProviders.tsx +++ b/src/app/AppProviders.tsx @@ -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 ( @@ -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", diff --git a/src/app/api/admin/sandbox/bind/route.ts b/src/app/api/admin/sandbox/bind/route.ts index 377979f..cdc58da 100644 --- a/src/app/api/admin/sandbox/bind/route.ts +++ b/src/app/api/admin/sandbox/bind/route.ts @@ -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({ error: 'unauthenticated' }, { status: 401 }) } - if (!(await userHasRole(user, REQUIRED_ROLES))) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }) - } - - if (!isAllowedRootEmail(user.email)) { - return NextResponse.json({ error: 'root_only' }, { status: 403 }) + const access = await evaluateAccountAdminAccess(user, { + roles: REQUIRED_ROLES, + permissions: WRITE_PERMISSIONS, + rootOnly: true, + }) + if (!access.allowed) { + return NextResponse.json({ error: access.reason ?? 'forbidden' }, { status: 403 }) } const headers = new Headers({ diff --git a/src/app/api/admin/sandbox/binding/route.ts b/src/app/api/admin/sandbox/binding/route.ts index 3ab4e5e..d4722c6 100644 --- a/src/app/api/admin/sandbox/binding/route.ts +++ b/src/app/api/admin/sandbox/binding/route.ts @@ -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({ error: 'unauthenticated' }, { status: 401 }) } - if (!(await userHasRole(user, REQUIRED_ROLES))) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }) - } - - if (!isAllowedRootEmail(user.email)) { - return NextResponse.json({ error: 'root_only' }, { status: 403 }) + const access = await evaluateAccountAdminAccess(user, { + roles: REQUIRED_ROLES, + permissions: READ_PERMISSIONS, + rootOnly: true, + }) + if (!access.allowed) { + return NextResponse.json({ error: access.reason ?? 'forbidden' }, { status: 403 }) } try { diff --git a/src/app/api/admin/users/[userId]/pause/route.ts b/src/app/api/admin/users/[userId]/pause/route.ts index 0cb4e5a..b96ce4f 100644 --- a/src/app/api/admin/users/[userId]/pause/route.ts +++ b/src/app/api/admin/users/[userId]/pause/route.ts @@ -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({ error: 'unauthenticated' }, { status: 401 }) } - if (!(await userHasRole(user, REQUIRED_ROLES))) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }) + const access = await evaluateAccountAdminAccess(user, { + roles: REQUIRED_ROLES, + permissions: WRITE_PERMISSIONS, + }) + if (!access.allowed) { + return NextResponse.json({ error: access.reason ?? 'forbidden' }, { status: 403 }) } const { userId: userIdParam } = await params diff --git a/src/app/api/admin/users/[userId]/renew-uuid/route.ts b/src/app/api/admin/users/[userId]/renew-uuid/route.ts index 8d3f02d..4d52496 100644 --- a/src/app/api/admin/users/[userId]/renew-uuid/route.ts +++ b/src/app/api/admin/users/[userId]/renew-uuid/route.ts @@ -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({ error: 'unauthenticated' }, { status: 401 }) } - if (!(await userHasRole(user, REQUIRED_ROLES))) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }) + const access = await evaluateAccountAdminAccess(user, { + roles: REQUIRED_ROLES, + permissions: WRITE_PERMISSIONS, + }) + if (!access.allowed) { + return NextResponse.json({ error: access.reason ?? 'forbidden' }, { status: 403 }) } const { userId: userIdParam } = await params diff --git a/src/app/api/admin/users/[userId]/resume/route.ts b/src/app/api/admin/users/[userId]/resume/route.ts index a82fbc9..9296210 100644 --- a/src/app/api/admin/users/[userId]/resume/route.ts +++ b/src/app/api/admin/users/[userId]/resume/route.ts @@ -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({ error: 'unauthenticated' }, { status: 401 }) } - if (!(await userHasRole(user, REQUIRED_ROLES))) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }) + const access = await evaluateAccountAdminAccess(user, { + roles: REQUIRED_ROLES, + permissions: WRITE_PERMISSIONS, + }) + if (!access.allowed) { + return NextResponse.json({ error: access.reason ?? 'forbidden' }, { status: 403 }) } const { userId: userIdParam } = await params diff --git a/src/app/api/admin/users/[userId]/role/route.ts b/src/app/api/admin/users/[userId]/role/route.ts index c3b9122..cbfe9a6 100644 --- a/src/app/api/admin/users/[userId]/role/route.ts +++ b/src/app/api/admin/users/[userId]/role/route.ts @@ -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({ error: 'unauthenticated' }, { status: 401 }) } - if (!(await userHasRole(user, REQUIRED_ROLES))) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }) + const access = await evaluateAccountAdminAccess(user, { + roles: REQUIRED_ROLES, + permissions: WRITE_PERMISSIONS, + }) + if (!access.allowed) { + return NextResponse.json({ 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({ error: 'unauthenticated' }, { status: 401 }) } - if (!(await userHasRole(user, REQUIRED_ROLES))) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }) + const access = await evaluateAccountAdminAccess(user, { + roles: REQUIRED_ROLES, + permissions: WRITE_PERMISSIONS, + }) + if (!access.allowed) { + return NextResponse.json({ error: access.reason ?? 'forbidden' }, { status: 403 }) } const { userId: userIdParam } = await params diff --git a/src/app/api/admin/users/[userId]/route.ts b/src/app/api/admin/users/[userId]/route.ts index f5c786e..51c8f88 100644 --- a/src/app/api/admin/users/[userId]/route.ts +++ b/src/app/api/admin/users/[userId]/route.ts @@ -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({ error: 'unauthenticated' }, { status: 401 }) } - if (!(await userHasRole(user, REQUIRED_ROLES))) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }) + const access = await evaluateAccountAdminAccess(user, { + roles: REQUIRED_ROLES, + permissions: DELETE_PERMISSIONS, + }) + if (!access.allowed) { + return NextResponse.json({ error: access.reason ?? 'forbidden' }, { status: 403 }) } const { userId: userIdParam } = await params diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 1d3aef1..4bed6e0 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -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({ error: 'unauthenticated' }, { status: 401 }) } - if (!(await userHasRole(user, REQUIRED_ROLES))) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }) - } - - if (!isAllowedRootEmail(user.email)) { - return NextResponse.json({ error: 'root_only' }, { status: 403 }) + const access = await evaluateAccountAdminAccess(user, { + roles: REQUIRED_ROLES, + permissions: WRITE_PERMISSIONS, + rootOnly: true, + }) + if (!access.allowed) { + return NextResponse.json({ error: access.reason ?? 'forbidden' }, { status: 403 }) } const body = (await request.json().catch(() => null)) as CreateUserBody | null diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 08021ba..042553a 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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 = { email, password } + const loginBody: Record = { 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; } diff --git a/src/app/api/auth/mfa/disable/route.ts b/src/app/api/auth/mfa/disable/route.ts index 718bbad..c9606ef 100644 --- a/src/app/api/auth/mfa/disable/route.ts +++ b/src/app/api/auth/mfa/disable/route.ts @@ -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", }, }, - ) + ); } diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index 1cd40d6..a57789b 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -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 | null - mfa?: Record | null -} + token?: string; + expiresAt?: string; + mfaToken?: string; + error?: string; + retryAt?: string; + user?: Record | null; + mfa?: Record | 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", }, }, - ) + ); } diff --git a/src/app/api/auth/oauth/login/[provider]/route.ts b/src/app/api/auth/oauth/login/[provider]/route.ts new file mode 100644 index 0000000..302d1ac --- /dev/null +++ b/src/app/api/auth/oauth/login/[provider]/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 58f39b1..4fbebac 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -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 { 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 { 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; } diff --git a/src/app/api/auth/token/exchange/route.ts b/src/app/api/auth/token/exchange/route.ts index 5ccbc39..45ae789 100644 --- a/src/app/api/auth/token/exchange/route.ts +++ b/src/app/api/auth/token/exchange/route.ts @@ -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 }, + ); + } } diff --git a/src/app/api/sandbox/assume/revert/route.ts b/src/app/api/sandbox/assume/revert/route.ts index 4f1cec0..e9c9d0b 100644 --- a/src/app/api/sandbox/assume/revert/route.ts +++ b/src/app/api/sandbox/assume/revert/route.ts @@ -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 { 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({ error: 'not_assuming' }, { status: 400 }) + return NextResponse.json( + { error: "not_assuming" }, + { status: 400 }, + ); } if (!(await verifyRootToken(rootToken))) { - return NextResponse.json({ error: 'root_token_invalid' }, { status: 403 }) + return NextResponse.json( + { 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; } - diff --git a/src/app/api/sandbox/assume/route.ts b/src/app/api/sandbox/assume/route.ts index a971740..d1594dc 100644 --- a/src/app/api/sandbox/assume/route.ts +++ b/src/app/api/sandbox/assume/route.ts @@ -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({ error: 'unauthenticated' }, { status: 401 }) + return NextResponse.json( + { error: "unauthenticated" }, + { status: 401 }, + ); } - if (!(await userHasRole(user, REQUIRED_ROLES))) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }) - } - - if (!isAllowedRootEmail(user.email)) { - return NextResponse.json({ error: 'root_only' }, { status: 403 }) + const access = await evaluateAccountAdminAccess(user, { + roles: REQUIRED_ROLES, + permissions: WRITE_PERMISSIONS, + rootOnly: true, + }); + if (!access.allowed) { + return NextResponse.json( + { 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({ error: 'invalid_response' }, { status: 502 }) + const payload = (await upstream.json().catch(() => null)) as any; + if (!payload || typeof payload.token !== "string") { + return NextResponse.json( + { 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({ error: 'upstream_unreachable' }, { status: 502 }) + console.error("Failed to assume sandbox", error); + return NextResponse.json( + { error: "upstream_unreachable" }, + { status: 502 }, + ); } } - diff --git a/src/app/api/xworkmate/profile/route.ts b/src/app/api/xworkmate/profile/route.ts new file mode 100644 index 0000000..018f027 --- /dev/null +++ b/src/app/api/xworkmate/profile/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 577df65..2c63f2a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index ffecd06..680a0e1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -67,7 +67,7 @@ export default function HomePage() { const { mode, isOpen } = useMoltbotStore(); return ( -
+
-
+
-
+
@@ -104,43 +104,45 @@ export function HeroSection() { const t = translations[language].marketing.home; return ( -
-
-
+
+
+
{t.hero.eyebrow && ( -

+

{t.hero.eyebrow}

)} -

+

{t.hero.title}

-

{t.hero.subtitle}

+

+ {t.hero.subtitle} +

-
+
{user ? ( -
+
{t.signedIn.replace("{{username}}", user.username)}
) : ( - )} - -

{t.trustedBy}

-
+
@@ -149,8 +151,8 @@ export function HeroSection() {
-
-
+
+
{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 ( -
-
-

+

+
+

{t.nextSteps.title}

- + {t.nextSteps.badge}
@@ -189,9 +191,9 @@ export function NextStepsSection() { return (
-
+
@@ -284,14 +286,19 @@ export function StatsSection() { ]; return ( -
-
+
+
{displayStats.map((stat, index: number) => ( -
-
+
+
{stat.value}
-

{stat.label}

+

+ {stat.label} +

))}
@@ -345,22 +352,22 @@ export function ShortcutsSection() { })); return ( -
-
+
+
-

+

{t.shortcuts.title}

-

{t.shortcuts.subtitle}

+

{t.shortcuts.subtitle}

-
- - -
@@ -372,12 +379,12 @@ export function ShortcutsSection() { -
+
-
+
{item.title}
@@ -403,7 +410,7 @@ type LatestBlogPost = { function LogoPill({ label }: { label: string }) { return ( - +
{label} diff --git a/src/app/prices/page.tsx b/src/app/prices/page.tsx index b01e982..e137af0 100644 --- a/src/app/prices/page.tsx +++ b/src/app/prices/page.tsx @@ -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() {

- + + + {statusMessage ? (

{statusMessage} diff --git a/src/app/xworkmate/admin/page.tsx b/src/app/xworkmate/admin/page.tsx new file mode 100644 index 0000000..dff0a3a --- /dev/null +++ b/src/app/xworkmate/admin/page.tsx @@ -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 ( +

+
+ +
+
+ ); +} diff --git a/src/app/xworkmate/integrations/page.tsx b/src/app/xworkmate/integrations/page.tsx new file mode 100644 index 0000000..ba03674 --- /dev/null +++ b/src/app/xworkmate/integrations/page.tsx @@ -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 ( +
+
+ +
+
+ ); +} diff --git a/src/app/xworkmate/page.tsx b/src/app/xworkmate/page.tsx index c25e9c1..60fbe89 100644 --- a/src/app/xworkmate/page.tsx +++ b/src/app/xworkmate/page.tsx @@ -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 (
}> - +
); diff --git a/src/components/AskAIDialog.tsx b/src/components/AskAIDialog.tsx index 3647a9c..de8dbc0 100644 --- a/src/components/AskAIDialog.tsx +++ b/src/components/AskAIDialog.tsx @@ -50,57 +50,67 @@ export function AskAIDialog({ } return ( -
-
-
-
-

- XWorkmate -

-

- AI Assistant -

+ <> + {open ? ( + + +
-
- - +
+
- -
- -
-
+ ); } diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 3805598..4e02433 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -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 ( -