diff --git a/docs/develop/account-web-sign-in-migration.md b/docs/develop/account-web-sign-in-migration.md new file mode 100644 index 0000000000..7c0525c4b3 --- /dev/null +++ b/docs/develop/account-web-sign-in-migration.md @@ -0,0 +1,454 @@ +# 第一阶段前端迁移方案 + +本文档覆盖第一阶段的两条并行工作流: + +1. `Account Web`:把 Casdoor 中的 `XBuilderLoginPage` 迁移到 Builder 仓库,并接入新的 XBuilder Account API。 +2. `spx-gui`:把主站从当前 Casdoor 接入切到新的 Account OAuth 流程。 + +## 总体范围 + +### 包含 + +- `account.xbuilder.com/sign-in` 登录页迁移 +- 第三方身份登录入口 +- 用户名密码登录(仅管理员托管密码) +- 基于已有 account session 的“继续使用当前账号” +- “切换其它账号登录” +- 主站 OAuth `PAR / authorize / token / revoke` 对接 +- 承接 Bearer token 的产品 API / backend 侧继续通过 introspection 校验 access token + +### 不包含 + +- prompt page +- hosted interaction(绑定确认、身份冲突、补充信息) +- provider link / unlink +- 公开注册 +- 全局切账号 + +## 1. `Account Web` sign-in 登录页迁移 + +### 1.1 目标 + +- 承载 `account.xbuilder.com/sign-in` +- 替代 Casdoor 中的 `XBuilderLoginPage` +- 支持: + - 第三方身份登录 + - 用户名密码登录 + - 已有 account session 的继续登录 + - 切换其它账号登录 +- 登录完成后继续 `GET /account/oauth/authorize` + +### 1.2 页面状态机 + +```mermaid +stateDiagram-v2 + [*] --> Bootstrapping + + Bootstrapping --> ExistingSession: GET /account/session = 200 + Bootstrapping --> SignedOut: GET /account/session = 401 + Bootstrapping --> Error: init failed + + ExistingSession --> Authorizing: continue with current account + ExistingSession --> SwitchingAccount: switch account + SwitchingAccount --> SignedOut: DELETE /account/session = 204 + SwitchingAccount --> Error: delete failed + + SignedOut --> ProviderRedirecting: click provider + SignedOut --> PasswordSubmitting: submit username/password + + PasswordSubmitting --> Authorizing: POST /account/session = 201 + PasswordSubmitting --> Error: sign-in failed + + ProviderRedirecting --> CallbackHandling: provider callback + CallbackHandling --> ExistingSession: callback created/reused account session + CallbackHandling --> Error: provider callback failed + + Authorizing --> AppCallbackRedirect: GET /account/oauth/authorize = 302 + Authorizing --> Error: authorize failed + + Error --> SignedOut: retry +``` + +### 1.3 接口调用时序图 + +#### 1.3.1 已有 session,继续当前账号 + +```mermaid +sequenceDiagram + participant Browser as Browser + participant AccountWeb as Account Web + participant AccountAPI as Account API + participant App as App callback + + Browser->>AccountWeb: Open /sign-in?clientID&requestURI + AccountWeb->>AccountAPI: GET /account/session + AccountAPI-->>AccountWeb: 200 CurrentAccountSession + AccountWeb-->>Browser: Render current user card + Browser->>AccountWeb: Click continue + AccountWeb->>AccountAPI: GET /account/oauth/authorize?client_id&request_uri + AccountAPI-->>Browser: 302 redirect to app callback with code,state + Browser->>App: Deliver code,state +``` + +#### 1.3.2 切换其它账号后重新登录 + +```mermaid +sequenceDiagram + participant Browser as Browser + participant AccountWeb as Account Web + participant AccountAPI as Account API + participant Provider as Identity Provider + participant App as App callback + + Browser->>AccountWeb: Open /sign-in?clientID&requestURI + AccountWeb->>AccountAPI: GET /account/session + AccountAPI-->>AccountWeb: 200 CurrentAccountSession + Browser->>AccountWeb: Click switch account + AccountWeb->>AccountAPI: DELETE /account/session + AccountAPI-->>AccountWeb: 204 + AccountWeb->>AccountAPI: GET /account/identity-providers + AccountAPI-->>AccountWeb: providers + Browser->>AccountWeb: Click provider + AccountWeb->>AccountAPI: GET /account/identity-providers/{provider}/authorize + AccountAPI-->>Browser: 302 redirect to provider + Provider-->>AccountAPI: callback + AccountAPI-->>Browser: 302 back to hosted sign-in / continue flow + AccountWeb->>AccountAPI: GET /account/oauth/authorize?client_id&request_uri + AccountAPI-->>Browser: 302 redirect to app callback with code,state + Browser->>App: Deliver code,state +``` + +#### 1.3.3 用户名密码登录 + +```mermaid +sequenceDiagram + participant Browser as Browser + participant AccountWeb as Account Web + participant AccountAPI as Account API + participant App as App callback + + Browser->>AccountWeb: Open /sign-in?clientID&requestURI + AccountWeb->>AccountAPI: GET /account/session + AccountAPI-->>AccountWeb: 401 + AccountWeb->>AccountAPI: GET /account/identity-providers + AccountAPI-->>AccountWeb: providers + Browser->>AccountWeb: Submit username/password + AccountWeb->>AccountAPI: POST /account/session + AccountAPI-->>AccountWeb: 201 CurrentAccountSession + Set-Cookie + AccountWeb->>AccountAPI: GET /account/oauth/authorize?client_id&request_uri + AccountAPI-->>Browser: 302 redirect to app callback with code,state + Browser->>App: Deliver code,state +``` + +### 1.4 当前目录结构 + +实现完成后对新增文件做了目录整理,`Account Web` 和主站入口各自独立在 `apps/` 下。 + +```text +spx-gui/ +├── index.html # 主站 HTML 入口 +├── account.html # Account Web 独立 HTML 入口 +├── vite.config.ts # 主站构建配置 +├── vite.config.account.ts # Account Web 构建配置 +└── src/ + ├── apps/ + │ ├── account/ # Account Web 入口 + │ │ ├── main.ts + │ │ ├── App.vue + │ │ ├── router.ts + │ │ └── pages/ + │ │ └── sign-in.vue + │ └── xbuilder/ # 主站入口 + │ ├── main.ts + │ ├── App.vue + │ ├── router.ts + │ └── pages/ + │ ├── community/ # 首页 / 探索 / 搜索 / 用户 / 项目 + │ ├── editor/ + │ ├── tutorials/ + │ ├── docs/ + │ ├── 404/ + │ └── sign-in/ # callback.vue / token.vue + ├── apis/ + │ ├── account-session.ts # Account Web 登录页 session API + │ └── account-oauth.ts # 主站 OAuth 客户端 + ├── utils/ + │ └── account/ + │ └── sign-in.ts # Account Web 登录页工具 + ├── components/ + │ └── sign-in/ # Account Web 登录页 UI 组件 + │ ├── assets/logo.svg + │ ├── AccountSessionSection.vue + │ ├── LoginForm.vue + │ ├── LoginOptionsSection.vue + │ ├── PasswordLoginSection/ + │ ├── ProviderLoginButton/ + │ ├── XBuilderLoginPagePc/ + │ └── XBuilderLoginPageMobile/ + ├── stores/user/ + │ └── signed-in.ts # 主站 OAuth 登录态 + └── setup.ts # 共享初始化(app 无关) +``` + +### 1.5 当前实现涉及的文件 + +#### 已新增 + +- `spx-gui/account.html` + - `Account Web` 独立 HTML 入口(与 `index.html` 平级) +- `spx-gui/src/apps/account/main.ts` + - `Account Web` 独立入口 +- `spx-gui/src/apps/account/App.vue` + - `Account Web` 根组件 +- `spx-gui/src/apps/account/router.ts` + - `Account Web` 路由 +- `spx-gui/src/apps/account/pages/sign-in.vue` + - 登录页,承载状态机 +- `spx-gui/src/apis/account-session.ts` + - 封装 `GET/DELETE /account/session`、`GET /account/identity-providers`、`POST /account/session` +- `spx-gui/src/utils/account/sign-in.ts` + - 解析 `client_id`、`request_uri`,拼接同源 `/api/oauth/authorize` 和 provider authorize URL,并维护 pending authorization 标记 +- `spx-gui/src/components/sign-in/AccountSessionSection.vue` + - 展示当前账号、继续登录、切换账号 +- `spx-gui/src/components/sign-in/LoginOptionsSection.vue` + - provider 按钮列表和用户名密码入口 +- `spx-gui/src/components/sign-in/PasswordLoginSection/PasswordLoginSection.vue` + - 用户名密码登录表单 +- `spx-gui/src/components/sign-in/LoginForm.vue` + - 登录页状态机与 API 调用编排 +- `spx-gui/vite.config.account.ts` + - `Account Web` 独立构建配置、同源 `/api` 代理和 Vercel rewrite + +#### 已改造 + +- `spx-gui/src/utils/env.ts` + - 增加主站 OAuth 和 `Account Web` 需要的环境变量 +- `spx-gui/src/apis/account-oauth.ts` + - 封装 `POST /account/oauth/par`、`POST /account/oauth/token`、`POST /account/oauth/revoke` +- `spx-gui/src/stores/user/signed-in.ts` + - 主站切到新的 Account OAuth 流程 +- `spx-gui/src/apps/xbuilder/pages/sign-in/callback.vue` + - callback 页改为处理 authorization code exchange +- `spx-gui/src/apps/xbuilder/router.ts` + - `requiresSignIn` 场景改为走新的 `initiateSignIn()` + +### 1.6 接口清单 + +#### `Account Web` 直接调用 + +- `GET /account/session` +- `DELETE /account/session` +- `GET /account/identity-providers` +- `POST /account/session` +- `GET /account/oauth/authorize` + +#### 浏览器跳转链路使用 + +- `GET /account/identity-providers/{provider}/authorize` +- `GET /account/identity-providers/{provider}/callback` +- `POST /account/identity-providers/{provider}/callback` + +### 1.7 页面交互约定 + +#### 已有有效 session + +登录页优先展示当前账号卡片: + +- 主按钮:继续使用当前账号 +- 次按钮:切换其它账号登录 + +#### 切换其它账号登录 + +第一阶段只清除 `Account Web` 的当前 `account session`: + +- 调 `DELETE /account/session` +- 不要求同步退出其它应用 +- 不做全局切账号 + +### 1.8 实现注意点 + +- 不读取浏览器 cookie;统一通过 `GET /account/session` 判断当前登录态 +- `continue` 直接进入 `GET /account/oauth/authorize` +- `switch account` 必须先 `DELETE /account/session`,避免仍复用旧 session +- 第一阶段不复刻 Casdoor 的 `prompt page` / `forget` / `signup` 分支 + +## 2. `spx-gui` 主站 OAuth 接入 + +### 2.1 目标 + +这一部分覆盖 `xbuilder.com` 主站如何从当前 Casdoor 接入切到新的 Account OAuth 流程。默认按 `Account-issued token model` 落地。 + +### 2.2 主链路 + +```mermaid +sequenceDiagram + participant UI as spx-gui frontend + participant Account as Account API + participant SignIn as Account Web + participant ProductBackend as Product API + + UI->>Account: POST /account/oauth/par + Account-->>UI: request_uri + UI->>Account: GET /account/oauth/authorize?client_id&request_uri + Account-->>SignIn: 302 /sign-in?clientID&requestURI (if sign-in required) + SignIn->>Account: Complete sign-in / authorize + Account-->>UI: Redirect to /sign-in/callback?code&state + UI->>Account: POST /account/oauth/token + Account-->>UI: access_token + refresh_token + UI->>Product: Bearer access token + ProductBackend->>Account: POST /account/oauth/introspect + Account-->>Product: active, sub, client_id, scope +``` + +### 2.3 前端状态调整 + +- 不再使用 `casdoor-js-sdk` +- 不再从 access token decode 用户名 +- OAuth 一次性中间态短期保存在 `sessionStorage`: + - `state` + - `codeVerifier` + - `returnTo` +- 当前用户身份以 `/user` 或后端返回的 canonical user 为准,不以 token payload 为准 + +### 2.4 当前落地的文件改造 + +#### 重点改造 + +- `spx-gui/src/stores/user/signed-in.ts` + - 移除 `casdoor-js-sdk` + - 增加 `createAuthorizationRequest()`:生成 PKCE、state,调用 `POST /account/oauth/par`,并生成 `GET /account/oauth/authorize` 跳转地址 + - 增加 `completeAuthorizationCodeSignIn()`:在 callback 页用 `code + code_verifier` 调 `POST /account/oauth/token` + - 增加 `refreshAccessToken()`:用 `refresh_token` 调 `POST /account/oauth/token` + - 增加 `revokeTokens()`:调用 `POST /account/oauth/revoke` + +- `spx-gui/src/apps/xbuilder/pages/sign-in/callback.vue` + - 从只做 Casdoor `exchangeForAccessToken()` + - 改为:读取 `code/state` → 校验 state → 读取 PKCE verifier → 调 token exchange → 恢复 `returnTo` + +- `spx-gui/src/utils/env.ts` + - 增加主站在新账号系统里的应用标识 `VITE_ACCOUNT_OAUTH_CLIENT_ID` + - 主站在 `PAR` 之后直接跳转 `GET /account/oauth/authorize`,不自行拼接 `Account Web` 地址 + - `Account Web` 自身走同源 `/api/*` facade,不额外暴露独立的 Account API base URL + - `Account Web` 本地环境联调可通过 `VITE_ACCOUNT_WEB_CDP_CALLBACK_ORIGIN` 启用 Chrome DevTools Protocol callback redirect,免去本地 hosts 与 HTTPS 证书配置 + +#### 小范围联动 + +- `spx-gui/src/apps/xbuilder/router.ts` + - 保留 `/sign-in/callback` + - `requiresSignIn` 场景改为走新的 `initiateSignIn()` + +- `spx-gui/src/apps/xbuilder/pages/sign-in/token.vue` + - 当前是 JWT token 手动粘贴登录页 + - 若不再需要调试入口,可标记废弃或后续删除 + +- `spx-gui/package.json` + - 已移除 `casdoor-js-sdk` + - 已移除 `jwt-decode` + +> **目录调整**:实现完成后将主站入口(`main.ts`、`App.vue`、`router.ts`、`pages/`)移入 `src/apps/xbuilder/`,与 Account Web 的 `src/apps/account/` 平级。`setup.ts` 同步解耦了对 router 的硬依赖,改为由各 app 入口自行提供。详见 [1.4 当前目录结构](#14-当前目录结构)。 + +### 2.5 推荐实现拆分 + +#### 2.5.1 发起登录 + +在 `signed-in.ts` 提供统一入口: + +- 生成 `state` +- 生成 PKCE `code_verifier` / `code_challenge` +- 调 `POST /account/oauth/par` +- 将 `state`、`code_verifier`、`returnTo` 保存到 `sessionStorage` +- 浏览器跳转到 `GET /account/oauth/authorize?client_id=...&request_uri=...` +- 若需要 hosted sign-in,则由 `/account/oauth/authorize` 再 `302` 到 `account.xbuilder.com/sign-in` + +#### 2.5.2 callback 页收尾 + +`/sign-in/callback` 页面负责: + +- 读取 `code`、`state` +- 校验本地保存的 `state` +- 读取 `code_verifier` +- 调 `POST /account/oauth/token` +- 保存 `access_token` / `refresh_token` +- 清理一次性 OAuth 中间状态 +- `window.location.replace(returnTo ?? '/')` + +#### 2.5.3 token 刷新 + +- access token 接近过期时主动刷新 +- 同一运行环境合并并发刷新 +- 刷新失败时清空本地 token;后续再次访问受保护页面时重新进入 hosted sign-in + +#### 2.5.4 退出登录 + +主站退出第一阶段建议至少做两步: + +- 清理本地 `access_token` / `refresh_token` +- 如需撤销当前 grant 下 token,再调 `POST /account/oauth/revoke` + +是否同时退出 `account session`,由产品交互决定;第一阶段不强制联动 `Account Web`。 + +### 2.6 与当前实现的对应关系 + +| 当前文件 / 逻辑 | 第一阶段替换为 | +| - | - | +| `casdoorSdk.signin_redirect()` | `POST /account/oauth/par` + 跳转 `GET /account/oauth/authorize` | +| `casdoorSdk.exchangeForAccessToken()` | `POST /account/oauth/token` | +| `casdoorSdk.refreshAccessToken()` | `POST /account/oauth/token` with `grant_type=refresh_token` | +| JWT decode username | 删除;改为依赖 `/user` | +| Casdoor env | Account OAuth env | + +### 2.7 验收清单 + +- 未登录访问受保护页面会先进入 `GET /account/oauth/authorize`,并在需要时跳到新的 hosted sign-in +- callback 能成功换取 token 并恢复原页面 +- access token 可自动刷新 +- 刷新失败时会清理本地登录态,后续受保护页面访问会重新进入 hosted sign-in +- 承接前端 Bearer token 的产品 API / backend 可以接受新 token,并通过 introspection 校验 +- 前端不再依赖 Casdoor SDK / Casdoor JWT 用户名解析 + +## 3. 任务拆分 checklist + +### 3.1 `Account Web` + +- [x] 在 `spx-gui` 中增加 `Account Web` 独立构建入口 +- [x] 新增登录页,适配 PC / mobile +- [x] 登录页支持“继续使用当前账号”/“切换其它账号登录” +- [x] 登录页支持第三方身份登录 +- [x] 登录页支持用户名密码登录 +- [x] 对接 `GET /account/session` +- [x] 对接 `DELETE /account/session` +- [x] 对接 `GET /account/identity-providers` +- [x] 对接 `POST /account/session` +- [x] 对接 `GET /account/oauth/authorize` +- [x] 对接 `GET /account/identity-providers/{provider}/authorize` + +### 3.2 `spx-gui` + +- [x] 在 `spx-gui/src/stores/user/signed-in.ts` 移除 `casdoor-js-sdk` +- [x] 在 `spx-gui/src/stores/user/signed-in.ts` 移除 JWT decode 用户名逻辑 +- [x] 实现 `createAuthorizationRequest()` +- [x] 实现 `completeAuthorizationCodeSignIn()` +- [x] 实现 `refreshAccessToken()` +- [x] 实现 `revokeTokens()` +- [x] 用 `POST /account/oauth/par` + `GET /account/oauth/authorize` 发起登录 +- [x] 用 `POST /account/oauth/token` 完成 code exchange +- [x] 用 `POST /account/oauth/token` 完成 refresh token exchange +- [x] 在 `/sign-in/callback` 页面接入新 callback 收尾逻辑 +- [x] 在 `spx-gui/src/apps/xbuilder/router.ts` 中让 `requiresSignIn` 走新的 `initiateSignIn()` +- [x] 更新 `env.ts`,移除 `VITE_CASDOOR_*` +- [ ] 评估并处理 `src/apps/xbuilder/pages/sign-in/token.vue` +- [x] 移除 `package.json` 中的 `casdoor-js-sdk` +- [x] 移除 `package.json` 中的 `jwt-decode` + +### 3.3 联调与验收 + +- [ ] 验证未登录进入受保护页面会先进入 `/account/oauth/authorize`,并在需要时跳到 Account Web +- [ ] 验证已有 account session 时可直接 continue +- [ ] 验证 switch account 只影响 `Account Web` 当前 session +- [ ] 验证用户名密码登录可完成 OAuth authorize +- [ ] 验证第三方身份登录可完成 OAuth authorize +- [ ] 验证 `/sign-in/callback` 可恢复 `returnTo` +- [ ] 验证 access token 自动刷新 +- [ ] 验证刷新失败会重新进入 hosted sign-in +- [ ] 验证承接前端 Bearer token 的产品 API / backend 已接入 `/account/oauth/introspect` +- [ ] 验证前端运行时不再依赖 Casdoor SDK / Casdoor JWT diff --git a/spx-gui/.env b/spx-gui/.env index 4393234404..a26c1c62f7 100644 --- a/spx-gui/.env +++ b/spx-gui/.env @@ -36,13 +36,11 @@ VITE_USERCONTENT_BUCKET="" # NOTE: Must be synchronized with `KODO_BUCKET_REGION` in spx-backend `.env` file. VITE_USERCONTENT_UPLOAD_BASE_URL="" -# Casdoor configuration. +# Account OAuth configuration for the main-site hosted sign-in flow. # -# Required. -VITE_CASDOOR_ENDPOINT="" -VITE_CASDOOR_CLIENT_ID="" -VITE_CASDOOR_APP_NAME="" -VITE_CASDOOR_ORGANIZATION_NAME="" +# Required when enabling the new Account OAuth flow. +VITE_ACCOUNT_OAUTH_CLIENT_ID="" + # Feature flags, all boolean (true/false), with default values set here. # diff --git a/spx-gui/.env.production b/spx-gui/.env.production index 2f9e0647a9..b49914146b 100644 --- a/spx-gui/.env.production +++ b/spx-gui/.env.production @@ -13,9 +13,7 @@ VITE_USERCONTENT_BASE_URL="https://builder-usercontent.gopluscdn.com" VITE_USERCONTENT_BUCKET="goplus-builder-usercontent" VITE_USERCONTENT_UPLOAD_BASE_URL="https://upload-na0.qiniup.com" -VITE_CASDOOR_ENDPOINT="https://acc.xbuilder.com" -VITE_CASDOOR_CLIENT_ID="4ff910257e9cdd89b6b8" -VITE_CASDOOR_APP_NAME="goplus-builder" -VITE_CASDOOR_ORGANIZATION_NAME="GoPlus" +# TODO: Update to the correct production clientID when it's ready. +VITE_ACCOUNT_OAUTH_CLIENT_ID="2" VITE_SENTRY_DSN="https://0d463740215eb87f7e06f6572d64c93e@o4509472134987776.ingest.us.sentry.io/4509472136167424" diff --git a/spx-gui/.env.staging b/spx-gui/.env.staging index 623d6bf5f2..869f76d5d9 100644 --- a/spx-gui/.env.staging +++ b/spx-gui/.env.staging @@ -1,16 +1,15 @@ # Config for env staging -VITE_API_BASE_URL="https://goplus-builder-api.qiniu.io" -VITE_VERCEL_PROXIED_API_BASE_URL="https://goplus-builder-api.qiniu.io" +# VITE_API_BASE_URL="https://goplus-builder-api.qiniu.io" +# VITE_VERCEL_PROXIED_API_BASE_URL="https://goplus-builder-api.qiniu.io" +VITE_API_BASE_URL="https://goplus-builder-api-test-acc.qiniu.io" +VITE_VERCEL_PROXIED_API_BASE_URL="https://goplus-builder-api-test-acc.qiniu.io" VITE_USERCONTENT_BASE_URL="https://xbuilder-usercontent-test.gopluscdn.com" VITE_USERCONTENT_BUCKET="xbuilder-usercontent-test" VITE_USERCONTENT_UPLOAD_BASE_URL="https://upload.qiniup.com" -VITE_CASDOOR_ENDPOINT="https://goplus-casdoor.qiniu.io" -VITE_CASDOOR_CLIENT_ID="389313df51ffd2093b2f" -VITE_CASDOOR_APP_NAME="goplus-builder" -VITE_CASDOOR_ORGANIZATION_NAME="GoPlus" +VITE_ACCOUNT_OAUTH_CLIENT_ID="2" VITE_SENTRY_DSN="https://0d463740215eb87f7e06f6572d64c93e@o4509472134987776.ingest.us.sentry.io/4509472136167424" diff --git a/spx-gui/Dockerfile.account b/spx-gui/Dockerfile.account new file mode 100644 index 0000000000..80e41860fc --- /dev/null +++ b/spx-gui/Dockerfile.account @@ -0,0 +1,30 @@ +ARG NODE_BASE_IMAGE=node:24.11.1 +ARG NGINX_BASE_IMAGE=nginx:1.27 + +FROM ${NODE_BASE_IMAGE} AS frontend-builder + +WORKDIR /app/spx-gui + +COPY spx-gui/package.json spx-gui/package-lock.json . + +ARG NPM_CONFIG_REGISTRY +RUN npm install --ignore-scripts + +COPY spx-gui/account.html spx-gui/vite.config.account.ts spx-gui/vercel-output-plugin.ts . +COPY spx-gui/tsconfig.json spx-gui/tsconfig.app.json spx-gui/tsconfig.node.json . +COPY spx-gui/.env spx-gui/.env.production spx-gui/.env.staging . +COPY spx-gui/public ./public +COPY spx-gui/src ./src + +ARG NODE_ENV=production +ENV NODE_OPTIONS=--max-old-space-size=4096 +RUN NODE_ENV=${NODE_ENV} npx vite build --config vite.config.account.ts --mode ${NODE_ENV} + +FROM ${NGINX_BASE_IMAGE} + +COPY --from=frontend-builder /app/spx-gui/dist /usr/share/nginx/html +COPY spx-gui/nginx.account.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/spx-gui/account.html b/spx-gui/account.html new file mode 100644 index 0000000000..14a11f855e --- /dev/null +++ b/spx-gui/account.html @@ -0,0 +1,24 @@ + + + + + + XBuilder Account + + + + +
+ + + + diff --git a/spx-gui/deployment.account.yaml b/spx-gui/deployment.account.yaml new file mode 100644 index 0000000000..24a1ac2c4c --- /dev/null +++ b/spx-gui/deployment.account.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: builder-account + namespace: builder + labels: + app: builder-account +spec: + replicas: 1 + selector: + matchLabels: + app: builder-account + template: + metadata: + labels: + app: builder-account + spec: + imagePullSecrets: + - name: ghcr-nighca + containers: + - name: builder-account + image: ghcr.io/nighca/builder-account:test + ports: + - name: http + containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: builder-account + namespace: builder + labels: + app: builder-account +spec: + type: ClusterIP + selector: + app: builder-account + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: builder-account + namespace: builder + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 300m + nginx.ingress.kubernetes.io/proxy-buffering: 'off' + nginx.ingress.kubernetes.io/proxy-http-version: '1.1' + nginx.ingress.kubernetes.io/proxy-request-buffering: 'off' +spec: + rules: + - host: goplus-builder-account.qiniu.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: builder-account + port: + number: 80 diff --git a/spx-gui/eslint.config.js b/spx-gui/eslint.config.js index 2c03bae738..d75375001c 100644 --- a/spx-gui/eslint.config.js +++ b/spx-gui/eslint.config.js @@ -93,7 +93,7 @@ export default defineConfigWithVueTs( { name: 'app/pages-rules', - files: ['src/pages/**/*.vue'], + files: ['src/apps/xbuilder/pages/**/*.vue'], rules: { // Page components will not be used by name in other components' template. // Disable this rule to simplify naming of page components. diff --git a/spx-gui/index.html b/spx-gui/index.html index d4891fce97..d504597a60 100644 --- a/spx-gui/index.html +++ b/spx-gui/index.html @@ -34,6 +34,6 @@
- + diff --git a/spx-gui/nginx.account.conf b/spx-gui/nginx.account.conf new file mode 100644 index 0000000000..53912d4678 --- /dev/null +++ b/spx-gui/nginx.account.conf @@ -0,0 +1,54 @@ +server { + listen 80 default_server; + server_name _; + + client_max_body_size 200M; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_min_length 1024; + gzip_comp_level 6; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml image/x-icon; + + add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always; + add_header 'Cross-Origin-Opener-Policy' 'same-origin' always; + + location /assets/ { + add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always; + add_header 'Cross-Origin-Opener-Policy' 'same-origin' always; + add_header 'Cache-Control' 'public, max-age=31536000, immutable' always; + + root /usr/share/nginx/html; + try_files $uri =404; + } + + location = /api { + proxy_pass http://test-acc-spx-backend/account; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://test-acc-spx-backend/account/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + index index.html; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/spx-gui/package-lock.json b/spx-gui/package-lock.json index 8af7064aa0..dedc6280ac 100644 --- a/spx-gui/package-lock.json +++ b/spx-gui/package-lock.json @@ -32,7 +32,6 @@ "bowser": "^2.14.1", "browserslist": "^4.28.0", "browserslist-to-esbuild": "^2.1.1", - "casdoor-js-sdk": "^0.16.0", "cropperjs": "^2.1.0", "csv-stringify": "^6.5.2", "dayjs": "^1.11.10", @@ -44,7 +43,6 @@ "hast": "^1.0.0", "hast-util-raw": "^9.1.0", "hast-util-sanitize": "^5.0.2", - "jwt-decode": "^4.0.0", "konva": "^9.3.1", "localforage": "^1.10.0", "lodash": "^4.17.21", @@ -3895,15 +3893,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/casdoor-js-sdk": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/casdoor-js-sdk/-/casdoor-js-sdk-0.16.0.tgz", - "integrity": "sha512-K5ZKaSGxzAgLzYGlp444OnhZ0nAZqnvlq5/wPhpQ0HCU0uwk0NIt+KC4LC+qUdnRxaQN6f1LP8wR/xsTZQBWVQ==", - "dependencies": { - "js-pkce": "^1.3.0", - "jwt-decode": "^4.0.0" - } - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -4075,11 +4064,6 @@ "node": ">= 8" } }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -5517,14 +5501,6 @@ "node": ">=14" } }, - "node_modules/js-pkce": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/js-pkce/-/js-pkce-1.4.0.tgz", - "integrity": "sha512-ztPzIgjQ+hY2uvwsTi625Yt/123NODAInGg2Y7aQLlKpNpD16A3WePjKyY2+B8WCoZy1cGG4xC9C68Dt2ZB8iQ==", - "dependencies": { - "crypto-js": "^4.0.0" - } - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -5568,16 +5544,6 @@ "integrity": "sha512-1IynUYEc/HAwxhi3WDpIpxJbZpMCvvrrmZVqvj9EhpvbH8lls7HhdhiByjL7DkAaWlLIzpC0Xc/VPvy/UxLNjA==", "license": "MIT" }, - "node_modules/jwt-decode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", - "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/spx-gui/package.json b/spx-gui/package.json index 898991f330..91f72f805d 100644 --- a/spx-gui/package.json +++ b/spx-gui/package.json @@ -9,6 +9,8 @@ "dev": "vite", "build": "./build-tutorial-books.sh && vue-tsc --build --force && NODE_ENV=production vite build --mode ${NODE_ENV:-production}", "preview": "vite preview", + "dev:account": "vite --config vite.config.account.ts --port 5174", + "build:account": "vue-tsc --build --force && NODE_ENV=production vite build --config vite.config.account.ts --mode ${NODE_ENV:-production}", "type-check": "vue-tsc --build --force", "format-check": "prettier --check ./src", "format": "prettier --write ./src", @@ -39,7 +41,6 @@ "bowser": "^2.14.1", "browserslist": "^4.28.0", "browserslist-to-esbuild": "^2.1.1", - "casdoor-js-sdk": "^0.16.0", "cropperjs": "^2.1.0", "csv-stringify": "^6.5.2", "dayjs": "^1.11.10", @@ -51,7 +52,6 @@ "hast": "^1.0.0", "hast-util-raw": "^9.1.0", "hast-util-sanitize": "^5.0.2", - "jwt-decode": "^4.0.0", "konva": "^9.3.1", "localforage": "^1.10.0", "lodash": "^4.17.21", diff --git a/spx-gui/src/apis/account-oauth.test.ts b/spx-gui/src/apis/account-oauth.test.ts new file mode 100644 index 0000000000..5d0256afcd --- /dev/null +++ b/spx-gui/src/apis/account-oauth.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const postForm = vi.fn() +const Client = vi.fn(function MockClient(this: { postForm: typeof postForm }) { + this.postForm = postForm +}) + +vi.mock('@/apis/common/client', () => ({ + Client +})) + +vi.mock('@/utils/env', () => ({ + accountOAuthClientId: 'client-123', + apiBaseUrl: '/api' +})) + +describe('account oauth apis', () => { + beforeEach(() => { + vi.resetModules() + Client.mockClear() + postForm.mockReset() + window.history.replaceState({}, '', '/') + }) + + it('should create pushed authorization requests with form-encoded oauth params', async () => { + postForm.mockResolvedValueOnce({ request_uri: 'urn:request:1' }) + const { createPushedAuthorizationRequest } = await import('./account-oauth') + + await expect(createPushedAuthorizationRequest({ state: 'state-1', codeChallenge: 'challenge-1' })).resolves.toEqual( + { + request_uri: 'urn:request:1' + } + ) + expect(postForm).toHaveBeenCalledWith('/account/oauth/par', { + client_id: 'client-123', + response_type: 'code', + redirect_uri: 'http://localhost:3000/sign-in/callback', + state: 'state-1', + code_challenge: 'challenge-1', + code_challenge_method: 'S256', + scope: 'account:user:read' + }) + }) + + it('should exchange authorization codes with form-encoded oauth params', async () => { + postForm.mockResolvedValueOnce({ access_token: 'token-1', expires_in: 3600 }) + const { exchangeAuthorizationCode } = await import('./account-oauth') + + await exchangeAuthorizationCode('code-1', 'verifier-1') + + expect(postForm).toHaveBeenCalledWith('/account/oauth/token', { + grant_type: 'authorization_code', + client_id: 'client-123', + code: 'code-1', + redirect_uri: 'http://localhost:3000/sign-in/callback', + code_verifier: 'verifier-1' + }) + }) + + it('should exchange refresh tokens with form-encoded oauth params', async () => { + postForm.mockResolvedValueOnce({ access_token: 'token-1', expires_in: 3600 }) + const { exchangeRefreshToken } = await import('./account-oauth') + + await exchangeRefreshToken('refresh-1') + + expect(postForm).toHaveBeenCalledWith('/account/oauth/token', { + grant_type: 'refresh_token', + client_id: 'client-123', + refresh_token: 'refresh-1' + }) + }) + + it('should revoke oauth tokens with form-encoded oauth params', async () => { + postForm.mockResolvedValueOnce(null) + const { revokeOAuthToken } = await import('./account-oauth') + + await revokeOAuthToken('token-1') + + expect(postForm).toHaveBeenCalledWith('/account/oauth/revoke', { + client_id: 'client-123', + token: 'token-1' + }) + }) + + it('should skip revoke when token is null', async () => { + const { revokeOAuthToken } = await import('./account-oauth') + + await expect(revokeOAuthToken(null)).resolves.toBeUndefined() + expect(postForm).not.toHaveBeenCalled() + }) + + it('should use a dedicated client instance for oauth endpoints', async () => { + await import('./account-oauth') + expect(Client).toHaveBeenCalledTimes(1) + expect(Client).toHaveBeenCalledWith() + }) + + it('should build the authorize URL against the main-site api origin', async () => { + const { getAccountOAuthAuthorizeUrl } = await import('./account-oauth') + + expect(getAccountOAuthAuthorizeUrl('urn:request:1')).toBe( + 'http://localhost:3000/account/oauth/authorize?client_id=client-123&request_uri=urn%3Arequest%3A1' + ) + }) +}) diff --git a/spx-gui/src/apis/account-oauth.ts b/spx-gui/src/apis/account-oauth.ts new file mode 100644 index 0000000000..c7cfad6bc3 --- /dev/null +++ b/spx-gui/src/apis/account-oauth.ts @@ -0,0 +1,78 @@ +/** + * API client for the main xbuilder.com OAuth flow. + * + * These are used by the main site's sign-in flow (stores/user/signed-in.ts + * and pages/sign-in/callback.vue) to interact with the Account OAuth + * endpoints — pushed authorization request (PAR), authorization code + * exchange, token refresh, and token revocation. + * + * This file is for the main site's OAuth client. For the Account Web + * sign-in page session API, see account-session.ts. + */ + +import { Client } from '@/apis/common/client' +import { accountOAuthClientId, apiBaseUrl } from '@/utils/env' + +const client = new Client() + +function getAccountOAuthRedirectUri() { + return `${window.location.origin}/sign-in/callback` +} + +export interface PushedAuthorizationRequestResponse { + request_uri: string + expires_in?: number | null +} + +export function createPushedAuthorizationRequest(params: { state: string; codeChallenge: string }) { + return client.postForm('/account/oauth/par', { + client_id: accountOAuthClientId, + response_type: 'code', + redirect_uri: getAccountOAuthRedirectUri(), + state: params.state, + code_challenge: params.codeChallenge, + code_challenge_method: 'S256', + scope: 'account:user:read' + }) as Promise +} + +export function getAccountOAuthAuthorizeUrl(requestUri: string) { + const url = new URL('/account/oauth/authorize', new URL(apiBaseUrl, window.location.origin)) + url.searchParams.set('client_id', accountOAuthClientId) + url.searchParams.set('request_uri', requestUri) + return url.toString() +} + +export interface AccountOAuthTokenResponse { + access_token: string + expires_in: number + refresh_token?: string | null + token_type?: string + scope?: string +} + +export function exchangeAuthorizationCode(code: string, codeVerifier: string) { + return client.postForm('/account/oauth/token', { + grant_type: 'authorization_code', + client_id: accountOAuthClientId, + code, + redirect_uri: getAccountOAuthRedirectUri(), + code_verifier: codeVerifier + }) as Promise +} + +export function exchangeRefreshToken(refreshToken: string) { + return client.postForm('/account/oauth/token', { + grant_type: 'refresh_token', + client_id: accountOAuthClientId, + refresh_token: refreshToken + }) as Promise +} + +export async function revokeOAuthToken(token: string | null) { + if (token == null) return + await client.postForm('/account/oauth/revoke', { + client_id: accountOAuthClientId, + token + }) +} diff --git a/spx-gui/src/apis/account-session.ts b/spx-gui/src/apis/account-session.ts new file mode 100644 index 0000000000..0a743d8242 --- /dev/null +++ b/spx-gui/src/apis/account-session.ts @@ -0,0 +1,94 @@ +/** + * API client for Account Web sign-in page session management. + * + * These are used by the sign-in page (apps/account/) to interact with + * the Account API for session operations — checking current session, + * signing in with password, listing identity providers, and clearing + * the session on account switch. + * + * This file is specific to the Account Web sign-in flow and is not used by + * the main xbuilder.com OAuth client logic (see account-oauth.ts for that). + */ + +import { Client } from '@/apis/common/client' +import { ApiException, ApiExceptionCode } from '@/apis/common/exception' +import type { OAuthContext } from '@/utils/account/sign-in' + +const client = new Client({ baseUrl: '/api' }) + +export interface AccountUser { + id: string + createdAt: string + updatedAt: string + username: string + displayName: string + avatar: string +} + +export interface CurrentAccountSession { + id: string + createdAt: string + updatedAt: string + lastUsedAt: string + expiresAt: string + current: boolean + user: AccountUser + userAgent?: string | null + ipAddress?: string | null +} + +function isUnauthorizedError(error: unknown) { + return ( + (error instanceof ApiException && error.code === ApiExceptionCode.errorUnauthorized) || + (error instanceof Error && error.message.includes('status 401 for api call:')) + ) +} + +export async function getCurrentAccountSession(): Promise { + try { + return (await client.get('/session')) as CurrentAccountSession + } catch (error) { + if (isUnauthorizedError(error)) return null + throw error + } +} + +export async function deleteCurrentAccountSession(): Promise { + await client.delete('/session') +} + +export interface IdentityProvider { + name: string + displayName: string + enabled: boolean +} + +interface IdentityProviderListResponse { + data: IdentityProvider[] +} + +export async function getIdentityProviders(context?: OAuthContext): Promise { + const response = (await client.get( + '/identity-providers', + context == null + ? undefined + : { + clientID: context.clientId, + requestURI: context.requestUri + } + )) as IdentityProviderListResponse + return response.data.filter((provider) => provider.enabled) +} + +export interface PasswordSignInPayload { + username: string + password: string +} + +export async function createPasswordSession(payload: PasswordSignInPayload): Promise { + return (await client.post('/session', { + method: 'password', + username: payload.username, + password: payload.password + })) as CurrentAccountSession +} diff --git a/spx-gui/src/apis/common/client.test.ts b/spx-gui/src/apis/common/client.test.ts index 447cba80ac..943271147f 100644 --- a/spx-gui/src/apis/common/client.test.ts +++ b/spx-gui/src/apis/common/client.test.ts @@ -95,6 +95,34 @@ describe('Client', () => { }) }) + describe('form requests', () => { + it('should submit application/x-www-form-urlencoded payloads', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + + await client.postForm('/account/oauth/token', { + grant_type: 'refresh_token', + client_id: 'client-1', + refresh_token: 'refresh-1' + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const request = fetchMock.mock.calls[0]![0] as Request + expect(request.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded') + expect(await request.text()).toBe('grant_type=refresh_token&client_id=client-1&refresh_token=refresh-1') + }) + + it('should allow empty successful responses for form-encoded endpoints', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })) + + await expect( + client.postForm('/account/oauth/revoke', { + client_id: 'client-1', + token: 'token-1' + }) + ).resolves.toBeNull() + }) + }) + describe('default fetch binding', () => { it('should call the global fetch with the global receiver when fetchFn is not injected', async () => { const globalFetchMock = vi.fn(function (this: typeof globalThis, _req: RequestInfo | URL, _init?: RequestInit) { @@ -112,4 +140,18 @@ describe('Client', () => { expect(globalFetchMock).toHaveBeenCalledTimes(1) }) }) + + describe('explicit authorization headers', () => { + it('should preserve an explicit Authorization header instead of overwriting it with the token provider', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ username: 'alice' }), { status: 200 })) + client.setTokenProvider(async () => 'shared-token') + + await client.get('/user', undefined, { + headers: new Headers({ Authorization: 'Bearer direct-token' }) + }) + + const req = fetchMock.mock.calls[0]?.[0] as Request + expect(req.headers.get('Authorization')).toBe('Bearer direct-token') + }) + }) }) diff --git a/spx-gui/src/apis/common/client.ts b/spx-gui/src/apis/common/client.ts index 27f2ad19e0..1880310d8a 100644 --- a/spx-gui/src/apis/common/client.ts +++ b/spx-gui/src/apis/common/client.ts @@ -97,12 +97,33 @@ export class Client { options?.signal?.throwIfAborted() const headers = options?.headers ?? new Headers() if (body != null) headers.set('Content-Type', 'application/json') - if (token != null) headers.set('Authorization', `Bearer ${token}`) + if (token != null && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`) if (sentryTraceHeader != null) headers.set('Sentry-Trace', sentryTraceHeader) if (sentryBaggageHeader != null) headers.set('Baggage', sentryBaggageHeader) return new Request(url, { method, headers, body }) } + /** Prepare request object, encoding payload as application/x-www-form-urlencoded */ + private async prepareFormRequest(path: string, payload: QueryParams, options?: RequestOptions): Promise { + const traceData = Sentry.getTraceData() + const sentryTraceHeader = traceData['sentry-trace'] + const sentryBaggageHeader = traceData['baggage'] + const url = this.baseUrl + path + const method = options?.method ?? 'POST' + const body = new URLSearchParams() + Object.entries(payload).forEach(([key, value]) => { + if (value != null) body.append(key, `${value}`) + }) + const token = await this.tokenProvider() + options?.signal?.throwIfAborted() + const headers = options?.headers ?? new Headers() + headers.set('Content-Type', 'application/x-www-form-urlencoded') + if (token != null) headers.set('Authorization', `Bearer ${token}`) + if (sentryTraceHeader != null) headers.set('Sentry-Trace', sentryTraceHeader) + if (sentryBaggageHeader != null) headers.set('Baggage', sentryBaggageHeader) + return new Request(url, { method, headers, body: body.toString() }) + } + /** Perform request object and handle errors */ private async doRequest(req: Request, options?: RequestOptions): Promise { const timeout = options?.timeout ?? this.defaultTimeout @@ -134,6 +155,17 @@ export class Client { return resp.json() } + /** Do a form request, parsing response body as JSON when present */ + private async requestForm(path: string, payload: QueryParams, options?: RequestOptions): Promise { + const req = await this.prepareFormRequest(path, payload, options) + const resp = await this.doRequest(req, options) + if (resp.status === 204) return null + const contentType = resp.headers.get('Content-Type') ?? '' + if (contentType.includes('application/json')) return resp.json() + const text = await resp.text() + return text === '' ? null : text + } + private async requestBinary(path: string, payload: FormData, options?: RequestOptions) { const traceData = Sentry.getTraceData() const sentryTraceHeader = traceData['sentry-trace'] @@ -143,7 +175,7 @@ export class Client { const token = await this.tokenProvider() options?.signal?.throwIfAborted() const headers = options?.headers ?? new Headers() - if (token != null) headers.set('Authorization', `Bearer ${token}`) + if (token != null && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`) if (sentryTraceHeader != null) headers.set('Sentry-Trace', sentryTraceHeader) if (sentryBaggageHeader != null) headers.set('Baggage', sentryBaggageHeader) const req = new Request(url, { method, headers, body: payload }) @@ -197,6 +229,10 @@ export class Client { return this.requestJSON(path, payload, { ...options, method: 'POST' }) } + postForm(path: string, payload: QueryParams, options?: Omit) { + return this.requestForm(path, payload, { ...options, method: 'POST' }) + } + postBinary(path: string, payload: FormData, options?: Omit) { return this.requestBinary(path, payload, { ...options, method: 'POST' }) } diff --git a/spx-gui/src/apis/user.ts b/spx-gui/src/apis/user.ts index a7cda5fa20..f0d21074f8 100644 --- a/spx-gui/src/apis/user.ts +++ b/spx-gui/src/apis/user.ts @@ -48,8 +48,8 @@ export async function isUsernameTaken(username: string) { } } -export function getSignedInUser(): Promise { - return client.get(`/user`) as Promise +export function getSignedInUser(options?: { headers?: Headers }): Promise { + return client.get(`/user`, undefined, options) as Promise } export type UpdateSignedInUserParams = Partial> diff --git a/spx-gui/src/apps/account/App.vue b/spx-gui/src/apps/account/App.vue new file mode 100644 index 0000000000..60eab2f436 --- /dev/null +++ b/spx-gui/src/apps/account/App.vue @@ -0,0 +1,17 @@ + + + diff --git a/spx-gui/src/apps/account/main.ts b/spx-gui/src/apps/account/main.ts new file mode 100644 index 0000000000..0848b9aa51 --- /dev/null +++ b/spx-gui/src/apps/account/main.ts @@ -0,0 +1,38 @@ +import '@/polyfills' +import '@/app.css' + +import { createApp, watchEffect } from 'vue' +import dayjs from 'dayjs' +import localizedFormat from 'dayjs/plugin/localizedFormat' +import relativeTime from 'dayjs/plugin/relativeTime' +import utc from 'dayjs/plugin/utc' +import timezone from 'dayjs/plugin/timezone' + +import { createI18n, normalizeLang } from '@/utils/i18n' +import { defaultLang } from '@/utils/env' + +import App from './App.vue' +import router from './router' + +// TODO: Consider using @/setup's setup() and configureApp() for initialization +// once we determine which parts of the shared bootstrap are needed. +// Currently dayjs, i18n, and router are set up locally because Account Web +// does not need VueKonva, Sentry, VueQuery, VueRadar, or other xbuilder plugins. + +function initDayjs() { + dayjs.extend(localizedFormat) + dayjs.extend(relativeTime) + dayjs.extend(utc) + dayjs.extend(timezone) +} +initDayjs() + +const langLocalStorageKey = 'spx-gui-language' +const lang = normalizeLang(localStorage.getItem(langLocalStorageKey) ?? defaultLang) +const i18n = createI18n({ lang }) + +watchEffect(() => { + localStorage.setItem(langLocalStorageKey, i18n.lang.value) +}) + +createApp(App).use(i18n).use(router).mount('#app') diff --git a/spx-gui/src/apps/account/pages/sign-in.vue b/spx-gui/src/apps/account/pages/sign-in.vue new file mode 100644 index 0000000000..645cd4cdeb --- /dev/null +++ b/spx-gui/src/apps/account/pages/sign-in.vue @@ -0,0 +1,30 @@ + + + diff --git a/spx-gui/src/apps/account/router.ts b/spx-gui/src/apps/account/router.ts new file mode 100644 index 0000000000..422f51e54d --- /dev/null +++ b/spx-gui/src/apps/account/router.ts @@ -0,0 +1,27 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' + +export const accountWebRoutePaths = { + signIn: '/sign-in' +} as const + +const routes: Array = [ + { + path: '/', + redirect: accountWebRoutePaths.signIn + }, + { + path: accountWebRoutePaths.signIn, + component: () => import('@/apps/account/pages/sign-in.vue') + }, + { + path: '/:pathMatch(.*)*', + redirect: accountWebRoutePaths.signIn + } +] + +const router = createRouter({ + history: createWebHistory(''), + routes +}) + +export default router diff --git a/spx-gui/src/App.vue b/spx-gui/src/apps/xbuilder/App.vue similarity index 97% rename from spx-gui/src/App.vue rename to spx-gui/src/apps/xbuilder/App.vue index 7fb441d4b8..2b598411f1 100644 --- a/spx-gui/src/App.vue +++ b/spx-gui/src/apps/xbuilder/App.vue @@ -30,7 +30,7 @@ import { SpotlightUI } from '@/utils/spotlight' import { useI18n } from '@/utils/i18n' import { useInstallRouteLoading } from '@/utils/route-loading' import { isMobile } from '@/utils/ua' -import { getUIConfig } from './setup' +import { getUIConfig } from '@/setup' const MobileReminder = defineAsyncComponent(() => import('@/components/app/device-check/MobileReminder.vue')) diff --git a/spx-gui/src/apps/xbuilder/main.ts b/spx-gui/src/apps/xbuilder/main.ts new file mode 100644 index 0000000000..26e7cce983 --- /dev/null +++ b/spx-gui/src/apps/xbuilder/main.ts @@ -0,0 +1,12 @@ +import '@/polyfills' +import '@/app.css' +import { createApp } from 'vue' +import { setup, configureApp } from '@/setup' +import { initRouter } from './router' +import App from './App.vue' + +setup() +const app = createApp(App) +const router = initRouter(app) +configureApp(app, router) +app.mount('#app') diff --git a/spx-gui/src/pages/404/error.png b/spx-gui/src/apps/xbuilder/pages/404/error.png similarity index 100% rename from spx-gui/src/pages/404/error.png rename to spx-gui/src/apps/xbuilder/pages/404/error.png diff --git a/spx-gui/src/pages/404/index.vue b/spx-gui/src/apps/xbuilder/pages/404/index.vue similarity index 100% rename from spx-gui/src/pages/404/index.vue rename to spx-gui/src/apps/xbuilder/pages/404/index.vue diff --git a/spx-gui/src/pages/community/explore.vue b/spx-gui/src/apps/xbuilder/pages/community/explore.vue similarity index 100% rename from spx-gui/src/pages/community/explore.vue rename to spx-gui/src/apps/xbuilder/pages/community/explore.vue diff --git a/spx-gui/src/pages/community/home.vue b/spx-gui/src/apps/xbuilder/pages/community/home.vue similarity index 98% rename from spx-gui/src/pages/community/home.vue rename to spx-gui/src/apps/xbuilder/pages/community/home.vue index e1229aa813..f8168c1d8f 100644 --- a/spx-gui/src/pages/community/home.vue +++ b/spx-gui/src/apps/xbuilder/pages/community/home.vue @@ -120,7 +120,7 @@ import { computed } from 'vue' import { useQuery } from '@/utils/query' import { usePageTitle } from '@/utils/utils' import { ExploreOrder, ProjectType, exploreProjects, listSignedInUserProjects } from '@/apis/project' -import { getExploreRoute, getUserPageRoute } from '@/router' +import { getExploreRoute, getUserPageRoute } from '../../router' import { isSignedIn, useSignedInUser } from '@/stores/user' import { useResponsive } from '@/components/ui' import ProjectsSection from '@/components/community/ProjectsSection.vue' diff --git a/spx-gui/src/pages/community/index.vue b/spx-gui/src/apps/xbuilder/pages/community/index.vue similarity index 100% rename from spx-gui/src/pages/community/index.vue rename to spx-gui/src/apps/xbuilder/pages/community/index.vue diff --git a/spx-gui/src/pages/community/project.vue b/spx-gui/src/apps/xbuilder/pages/community/project.vue similarity index 99% rename from spx-gui/src/pages/community/project.vue rename to spx-gui/src/apps/xbuilder/pages/community/project.vue index a8ee069f7a..7774c0d810 100644 --- a/spx-gui/src/pages/community/project.vue +++ b/spx-gui/src/apps/xbuilder/pages/community/project.vue @@ -13,7 +13,7 @@ import { ProjectType, listProjects, recordProjectView, stringifyRemixSource, Vis import { listProjectReleases } from '@/apis/project-release' import { SpxProject, type CloudProject } from '@/models/spx/project' import { useSignedInUser, useUser, isSignedIn, initiateSignIn } from '@/stores/user' -import { getOwnProjectEditorRoute, getProjectEditorRoute, getProjectPageRoute, getUserPageRoute } from '@/router' +import { getOwnProjectEditorRoute, getProjectEditorRoute, getProjectPageRoute, getUserPageRoute } from '../../router' import { UIIcon, UILoading, diff --git a/spx-gui/src/pages/community/search.vue b/spx-gui/src/apps/xbuilder/pages/community/search.vue similarity index 100% rename from spx-gui/src/pages/community/search.vue rename to spx-gui/src/apps/xbuilder/pages/community/search.vue diff --git a/spx-gui/src/pages/community/user/followers.vue b/spx-gui/src/apps/xbuilder/pages/community/user/followers.vue similarity index 100% rename from spx-gui/src/pages/community/user/followers.vue rename to spx-gui/src/apps/xbuilder/pages/community/user/followers.vue diff --git a/spx-gui/src/pages/community/user/following.vue b/spx-gui/src/apps/xbuilder/pages/community/user/following.vue similarity index 100% rename from spx-gui/src/pages/community/user/following.vue rename to spx-gui/src/apps/xbuilder/pages/community/user/following.vue diff --git a/spx-gui/src/pages/community/user/index.vue b/spx-gui/src/apps/xbuilder/pages/community/user/index.vue similarity index 100% rename from spx-gui/src/pages/community/user/index.vue rename to spx-gui/src/apps/xbuilder/pages/community/user/index.vue diff --git a/spx-gui/src/pages/community/user/likes.vue b/spx-gui/src/apps/xbuilder/pages/community/user/likes.vue similarity index 100% rename from spx-gui/src/pages/community/user/likes.vue rename to spx-gui/src/apps/xbuilder/pages/community/user/likes.vue diff --git a/spx-gui/src/pages/community/user/overview.vue b/spx-gui/src/apps/xbuilder/pages/community/user/overview.vue similarity index 98% rename from spx-gui/src/pages/community/user/overview.vue rename to spx-gui/src/apps/xbuilder/pages/community/user/overview.vue index 5ec6cfcbaf..006c9b7317 100644 --- a/spx-gui/src/pages/community/user/overview.vue +++ b/spx-gui/src/apps/xbuilder/pages/community/user/overview.vue @@ -1,6 +1,6 @@ diff --git a/spx-gui/src/components/project/ProjectItem.vue b/spx-gui/src/components/project/ProjectItem.vue index 72378913a9..62b9896339 100644 --- a/spx-gui/src/components/project/ProjectItem.vue +++ b/spx-gui/src/components/project/ProjectItem.vue @@ -127,7 +127,7 @@ import { humanizeExactTime, useAsyncComputedLegacy } from '@/utils/utils' -import { getProjectEditorRoute, getProjectPageRoute } from '@/router' +import { getProjectEditorRoute, getProjectPageRoute } from '@/apps/xbuilder/router' import { Visibility, type ProjectData } from '@/apis/project' import stageBgUrl from '@/assets/images/stage-bg.svg' import { createFileWithUniversalUrl, getPublishedContent } from '@/models/common/cloud' diff --git a/spx-gui/src/components/project/ProjectPublishedModal.vue b/spx-gui/src/components/project/ProjectPublishedModal.vue index d7deac7149..40a87aecfe 100644 --- a/spx-gui/src/components/project/ProjectPublishedModal.vue +++ b/spx-gui/src/components/project/ProjectPublishedModal.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { UIButton, UIFormModal, UITextInput, UILink } from '@/components/ui' import { useMessageHandle } from '@/utils/exception' -import { getProjectPageRoute } from '@/router' +import { getProjectPageRoute } from '@/apps/xbuilder/router' import { SpxProject } from '@/models/spx/project' const props = defineProps<{ diff --git a/spx-gui/src/components/project/ProjectSharingLinkModal.vue b/spx-gui/src/components/project/ProjectSharingLinkModal.vue index d7b6cd3788..27da583abd 100644 --- a/spx-gui/src/components/project/ProjectSharingLinkModal.vue +++ b/spx-gui/src/components/project/ProjectSharingLinkModal.vue @@ -37,7 +37,7 @@ import { UIButton, UIFormModal, UITextInput } from '@/components/ui' import { useMessageHandle } from '@/utils/exception' import { computed } from 'vue' -import { getProjectShareRoute } from '@/router' +import { getProjectShareRoute } from '@/apps/xbuilder/router' const props = defineProps<{ visible: boolean diff --git a/spx-gui/src/components/sign-in/AccountSessionSection.vue b/spx-gui/src/components/sign-in/AccountSessionSection.vue new file mode 100644 index 0000000000..6345f13d0e --- /dev/null +++ b/spx-gui/src/components/sign-in/AccountSessionSection.vue @@ -0,0 +1,48 @@ + + + diff --git a/spx-gui/src/components/sign-in/InputWithIcon.vue b/spx-gui/src/components/sign-in/InputWithIcon.vue new file mode 100644 index 0000000000..cfc4627213 --- /dev/null +++ b/spx-gui/src/components/sign-in/InputWithIcon.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/spx-gui/src/components/sign-in/LoginButton.vue b/spx-gui/src/components/sign-in/LoginButton.vue new file mode 100644 index 0000000000..fe4fdab6d1 --- /dev/null +++ b/spx-gui/src/components/sign-in/LoginButton.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/spx-gui/src/components/sign-in/LoginForm.vue b/spx-gui/src/components/sign-in/LoginForm.vue new file mode 100644 index 0000000000..648bf4a74c --- /dev/null +++ b/spx-gui/src/components/sign-in/LoginForm.vue @@ -0,0 +1,173 @@ + + + diff --git a/spx-gui/src/components/sign-in/LoginOptionsSection.vue b/spx-gui/src/components/sign-in/LoginOptionsSection.vue new file mode 100644 index 0000000000..f3cbe96b8b --- /dev/null +++ b/spx-gui/src/components/sign-in/LoginOptionsSection.vue @@ -0,0 +1,35 @@ + + + diff --git a/spx-gui/src/components/sign-in/PasswordLoginSection/PasswordLoginSection.vue b/spx-gui/src/components/sign-in/PasswordLoginSection/PasswordLoginSection.vue new file mode 100644 index 0000000000..1502895ee0 --- /dev/null +++ b/spx-gui/src/components/sign-in/PasswordLoginSection/PasswordLoginSection.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/spx-gui/src/components/sign-in/PasswordLoginSection/eye-off.svg b/spx-gui/src/components/sign-in/PasswordLoginSection/eye-off.svg new file mode 100644 index 0000000000..82732daee4 --- /dev/null +++ b/spx-gui/src/components/sign-in/PasswordLoginSection/eye-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/components/sign-in/PasswordLoginSection/eye.svg b/spx-gui/src/components/sign-in/PasswordLoginSection/eye.svg new file mode 100644 index 0000000000..4e494cce6d --- /dev/null +++ b/spx-gui/src/components/sign-in/PasswordLoginSection/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/components/sign-in/PasswordLoginSection/lock.svg b/spx-gui/src/components/sign-in/PasswordLoginSection/lock.svg new file mode 100644 index 0000000000..55883e527b --- /dev/null +++ b/spx-gui/src/components/sign-in/PasswordLoginSection/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/components/sign-in/PasswordLoginSection/user.svg b/spx-gui/src/components/sign-in/PasswordLoginSection/user.svg new file mode 100644 index 0000000000..80ccda9991 --- /dev/null +++ b/spx-gui/src/components/sign-in/PasswordLoginSection/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/ProviderLoginButton.vue b/spx-gui/src/components/sign-in/ProviderLoginButton/ProviderLoginButton.vue new file mode 100644 index 0000000000..2e374ecb8a --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/ProviderLoginButton.vue @@ -0,0 +1,87 @@ + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/github-colorful.svg b/spx-gui/src/components/sign-in/ProviderLoginButton/github-colorful.svg new file mode 100644 index 0000000000..76a843198b --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/github-colorful.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/github-monochrome.svg b/spx-gui/src/components/sign-in/ProviderLoginButton/github-monochrome.svg new file mode 100644 index 0000000000..67e4dc4ee1 --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/github-monochrome.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/google-colorful.svg b/spx-gui/src/components/sign-in/ProviderLoginButton/google-colorful.svg new file mode 100644 index 0000000000..bad8a6ebea --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/google-colorful.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/google-monochrome.svg b/spx-gui/src/components/sign-in/ProviderLoginButton/google-monochrome.svg new file mode 100644 index 0000000000..f8706cc74b --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/google-monochrome.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/qq-colorful.svg b/spx-gui/src/components/sign-in/ProviderLoginButton/qq-colorful.svg new file mode 100644 index 0000000000..e12d16416b --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/qq-colorful.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/qq-monochrome.svg b/spx-gui/src/components/sign-in/ProviderLoginButton/qq-monochrome.svg new file mode 100644 index 0000000000..d463de44d3 --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/qq-monochrome.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/wechat-colorful.svg b/spx-gui/src/components/sign-in/ProviderLoginButton/wechat-colorful.svg new file mode 100644 index 0000000000..27105300ec --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/wechat-colorful.svg @@ -0,0 +1,4 @@ + + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/wechat-monochrome.svg b/spx-gui/src/components/sign-in/ProviderLoginButton/wechat-monochrome.svg new file mode 100644 index 0000000000..264315c00a --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/wechat-monochrome.svg @@ -0,0 +1,4 @@ + + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/x-colorful.svg b/spx-gui/src/components/sign-in/ProviderLoginButton/x-colorful.svg new file mode 100644 index 0000000000..fa4d8daba8 --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/x-colorful.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/components/sign-in/ProviderLoginButton/x-monochrome.svg b/spx-gui/src/components/sign-in/ProviderLoginButton/x-monochrome.svg new file mode 100644 index 0000000000..59a1fa4979 --- /dev/null +++ b/spx-gui/src/components/sign-in/ProviderLoginButton/x-monochrome.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/components/sign-in/UsernamePasswordLink.vue b/spx-gui/src/components/sign-in/UsernamePasswordLink.vue new file mode 100644 index 0000000000..009cd0bbbf --- /dev/null +++ b/spx-gui/src/components/sign-in/UsernamePasswordLink.vue @@ -0,0 +1,24 @@ + + + diff --git a/spx-gui/src/components/sign-in/XBuilderLoginPageMobile/XBuilderLoginPageMobile.vue b/spx-gui/src/components/sign-in/XBuilderLoginPageMobile/XBuilderLoginPageMobile.vue new file mode 100644 index 0000000000..a303c4ca09 --- /dev/null +++ b/spx-gui/src/components/sign-in/XBuilderLoginPageMobile/XBuilderLoginPageMobile.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/spx-gui/src/components/sign-in/XBuilderLoginPageMobile/bg-mobile.svg b/spx-gui/src/components/sign-in/XBuilderLoginPageMobile/bg-mobile.svg new file mode 100644 index 0000000000..4f6c0f55c4 --- /dev/null +++ b/spx-gui/src/components/sign-in/XBuilderLoginPageMobile/bg-mobile.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/spx-gui/src/components/sign-in/XBuilderLoginPageMobile/illustration-mobile.svg b/spx-gui/src/components/sign-in/XBuilderLoginPageMobile/illustration-mobile.svg new file mode 100644 index 0000000000..c83fde422e --- /dev/null +++ b/spx-gui/src/components/sign-in/XBuilderLoginPageMobile/illustration-mobile.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spx-gui/src/components/sign-in/XBuilderLoginPagePc/XBuilderLoginPagePc.vue b/spx-gui/src/components/sign-in/XBuilderLoginPagePc/XBuilderLoginPagePc.vue new file mode 100644 index 0000000000..7182666f17 --- /dev/null +++ b/spx-gui/src/components/sign-in/XBuilderLoginPagePc/XBuilderLoginPagePc.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/spx-gui/src/components/sign-in/XBuilderLoginPagePc/bg.svg b/spx-gui/src/components/sign-in/XBuilderLoginPagePc/bg.svg new file mode 100644 index 0000000000..674cf7aec2 --- /dev/null +++ b/spx-gui/src/components/sign-in/XBuilderLoginPagePc/bg.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spx-gui/src/components/sign-in/XBuilderLoginPagePc/illustration.svg b/spx-gui/src/components/sign-in/XBuilderLoginPagePc/illustration.svg new file mode 100644 index 0000000000..ed071749cd --- /dev/null +++ b/spx-gui/src/components/sign-in/XBuilderLoginPagePc/illustration.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spx-gui/src/components/sign-in/assets/logo.svg b/spx-gui/src/components/sign-in/assets/logo.svg new file mode 100644 index 0000000000..de7a1b92ce --- /dev/null +++ b/spx-gui/src/components/sign-in/assets/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/spx-gui/src/main.ts b/spx-gui/src/main.ts deleted file mode 100644 index 917aaebb1e..0000000000 --- a/spx-gui/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import './polyfills' -import './app.css' -import { createApp } from 'vue' -import { setup, configureApp } from './setup' -import App from './App.vue' - -setup() -const app = createApp(App) -configureApp(app) -app.mount('#app') diff --git a/spx-gui/src/setup.ts b/spx-gui/src/setup.ts index e92c4a8b9e..08e51c9535 100644 --- a/spx-gui/src/setup.ts +++ b/spx-gui/src/setup.ts @@ -11,7 +11,6 @@ import utc from 'dayjs/plugin/utc' import timezone from 'dayjs/plugin/timezone' import 'dayjs/locale/zh' -import { initRouter } from './router' import { initUserState, ensureAccessToken } from './stores/user' import { client } from './apis/common' import { CustomTransformer } from './components/editor/common/viewer/custom-transformer' @@ -109,8 +108,7 @@ export function setup() { } /** Configure the Vue app by installing plugins, etc. */ -export function configureApp(app: VueApp, routerEnabled = true) { - const router = routerEnabled ? initRouter(app) : undefined +export function configureApp(app: VueApp, router?: Router) { initSentry(app, router) initI18n(app) app.use(VueKonva as any, { diff --git a/spx-gui/src/stores/following.ts b/spx-gui/src/stores/following.ts index 04e9e27265..69598bbf32 100644 --- a/spx-gui/src/stores/following.ts +++ b/spx-gui/src/stores/following.ts @@ -2,7 +2,7 @@ import { computed, toRef, type WatchSource } from 'vue' import { useQueryWithCache, useQueryCache } from '@/utils/query' import { useAction } from '@/utils/exception' import * as apis from '@/apis/user' -import { getUnresolvedSignedInUsername } from './user' +import { useSignedInUser } from './user' function getFollowingQueryKey(signedInUsernameInput: string | null, username: string) { return ['following', signedInUsernameInput, username] @@ -12,11 +12,12 @@ const staleTime = 5 * 60 * 1000 // 5min export function useIsFollowing(username: WatchSource) { const usernameRef = toRef(username) - const queryKey = computed(() => getFollowingQueryKey(getUnresolvedSignedInUsername(), usernameRef.value)) + const signedInUser = useSignedInUser() + const queryKey = computed(() => getFollowingQueryKey(signedInUser.value?.username ?? null, usernameRef.value)) return useQueryWithCache({ queryKey, async queryFn() { - const signedInUsernameInput = getUnresolvedSignedInUsername() + const signedInUsernameInput = signedInUser.value?.username ?? null if (signedInUsernameInput == null || signedInUsernameInput === usernameRef.value) return false return apis.isFollowing(usernameRef.value) }, @@ -27,10 +28,11 @@ export function useIsFollowing(username: WatchSource) { export function useFollow() { const queryCache = useQueryCache() + const signedInUser = useSignedInUser() return useAction( async function follow(username: string) { await apis.follow(username) - const queryKey = getFollowingQueryKey(getUnresolvedSignedInUsername(), username) + const queryKey = getFollowingQueryKey(signedInUser.value?.username ?? null, username) queryCache.invalidateWithOptimisticValue(queryKey, true) }, { en: 'Failed to follow', zh: '关注用户失败' } @@ -39,10 +41,11 @@ export function useFollow() { export function useUnfollow() { const queryCache = useQueryCache() + const signedInUser = useSignedInUser() return useAction( async function unfollow(username: string) { await apis.unfollow(username) - const queryKey = getFollowingQueryKey(getUnresolvedSignedInUsername(), username) + const queryKey = getFollowingQueryKey(signedInUser.value?.username ?? null, username) queryCache.invalidateWithOptimisticValue(queryKey, false) }, { en: 'Failed to unfollow', zh: '取消关注失败' } diff --git a/spx-gui/src/stores/liking.ts b/spx-gui/src/stores/liking.ts index 022d49d1bd..dfe47fb166 100644 --- a/spx-gui/src/stores/liking.ts +++ b/spx-gui/src/stores/liking.ts @@ -2,7 +2,7 @@ import { computed, toRef, type WatchSource } from 'vue' import { useQueryWithCache, useQueryCache } from '@/utils/query' import { useAction } from '@/utils/exception' import * as apis from '@/apis/project' -import { getUnresolvedSignedInUsername, isSignedIn } from './user' +import { isSignedIn, useSignedInUser } from './user' function getLikingQueryKey(signedInUsernameInput: string | null, owner: string, name: string) { return ['liking', signedInUsernameInput, owner, name] @@ -12,9 +12,10 @@ const staleTime = 5 * 60 * 1000 // 5min export function useIsLikingProject(project: WatchSource<{ owner: string; name: string }>) { const projectRef = toRef(project) + const signedInUser = useSignedInUser() const queryKey = computed(() => { const { owner, name } = projectRef.value - return getLikingQueryKey(getUnresolvedSignedInUsername(), owner, name) + return getLikingQueryKey(signedInUser.value?.username ?? null, owner, name) }) return useQueryWithCache({ queryKey, @@ -29,10 +30,11 @@ export function useIsLikingProject(project: WatchSource<{ owner: string; name: s export function useLikeProject() { const queryCache = useQueryCache() + const signedInUser = useSignedInUser() return useAction( async function likeProject(owner: string, name: string) { await apis.likeProject(owner, name) - const queryKey = getLikingQueryKey(getUnresolvedSignedInUsername(), owner, name) + const queryKey = getLikingQueryKey(signedInUser.value?.username ?? null, owner, name) queryCache.invalidateWithOptimisticValue(queryKey, true) }, { en: 'Failed to like', zh: '标记喜欢失败' } @@ -41,10 +43,11 @@ export function useLikeProject() { export function useUnlikeProject() { const queryCache = useQueryCache() + const signedInUser = useSignedInUser() return useAction( async function unlikeProject(owner: string, name: string) { await apis.unlikeProject(owner, name) - const queryKey = getLikingQueryKey(getUnresolvedSignedInUsername(), owner, name) + const queryKey = getLikingQueryKey(signedInUser.value?.username ?? null, owner, name) queryCache.invalidateWithOptimisticValue(queryKey, false) }, { en: 'Failed to unlike', zh: '取消喜欢失败' } diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts new file mode 100644 index 0000000000..faf8a17e8d --- /dev/null +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -0,0 +1,230 @@ +import { ref, shallowRef } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { withSetup } from '@/utils/test' + +const createPushedAuthorizationRequest = vi.fn() +const exchangeAuthorizationCode = vi.fn() +const exchangeRefreshToken = vi.fn() +const revokeOAuthToken = vi.fn() +const getSignedInUser = vi.fn() +const useVueQueryMock = vi.fn() +const pendingAuthorizationStorageKey = 'spx-account-oauth:pending-authorization' + +vi.mock('@/utils/env', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + accountOAuthClientId: 'client-123', + apiBaseUrl: '/api' + } +}) + +vi.mock('@/apis/account-oauth', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createPushedAuthorizationRequest, + exchangeAuthorizationCode, + exchangeRefreshToken, + revokeOAuthToken + } +}) + +vi.mock('@/apis/user', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getSignedInUser + } +}) + +vi.mock('@/utils/query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQueryWithCache: useVueQueryMock + } +}) + +describe('signed-in user store', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + createPushedAuthorizationRequest.mockReset() + exchangeAuthorizationCode.mockReset() + exchangeRefreshToken.mockReset() + revokeOAuthToken.mockReset() + getSignedInUser.mockReset() + useVueQueryMock.mockReset() + useVueQueryMock.mockReturnValue({ + isLoading: ref(false), + data: shallowRef(null), + error: shallowRef(null), + progress: shallowRef({ percentage: 0, timeLeft: null, desc: null }), + refetch: vi.fn() + }) + vi.resetModules() + }) + + afterEach(async () => { + const userStore = await import('./signed-in') + userStore.signOut() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('should change the signed-in user query key across sign-in and sign-out transitions', async () => { + const userStore = await import('./signed-in') + getSignedInUser.mockResolvedValue({ username: 'alice' }) + + withSetup(() => userStore.useSignedInUser()) + expect(useVueQueryMock).toHaveBeenLastCalledWith(expect.objectContaining({ queryKey: expect.any(Object) })) + let queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey + expect(queryKey.value).toEqual(['user', '', 'signed-in']) + + await userStore.signInWithAccessToken('token-a') + withSetup(() => userStore.useSignedInUser()) + queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey + expect(queryKey.value).toEqual(['user', 'alice', 'signed-in']) + + userStore.signOut() + withSetup(() => userStore.useSignedInUser()) + queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey + expect(queryKey.value).toEqual(['user', '', 'signed-in']) + }) + + it('should resolve username from the access token when signing in with a token', async () => { + const userStore = await import('./signed-in') + getSignedInUser.mockResolvedValue({ username: 'alice' }) + + await expect(userStore.signInWithAccessToken('token-a')).resolves.toBeUndefined() + expect(userStore.isSignedIn()).toBe(true) + expect(userStore.getUnresolvedSignedInUsername()).toBe('alice') + expect(getSignedInUser).toHaveBeenCalledWith({ + headers: new Headers({ Authorization: 'Bearer token-a' }) + }) + }) + + it('should keep the guest signed-in user query key when resolving access token for a guest session', async () => { + const userStore = await import('./signed-in') + + withSetup(() => userStore.useSignedInUser()) + let queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey + expect(queryKey.value).toEqual(['user', '', 'signed-in']) + + await expect(userStore.ensureAccessToken()).resolves.toBeNull() + + withSetup(() => userStore.useSignedInUser()) + queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey + expect(queryKey.value).toEqual(['user', '', 'signed-in']) + }) + + it('should complete OAuth callback sign-in and return the stored returnTo', async () => { + const userStore = await import('./signed-in') + getSignedInUser.mockResolvedValue({ username: 'alice' }) + + sessionStorage.setItem( + pendingAuthorizationStorageKey, + JSON.stringify({ state: 'state-a', codeVerifier: 'verifier-a', returnTo: '/editor/demo' }) + ) + exchangeAuthorizationCode.mockResolvedValue({ + access_token: 'token-a', + expires_in: 3600, + refresh_token: 'refresh-a' + }) + + await expect(userStore.completeAuthorizationCodeSignIn('?code=code-a&state=state-a')).resolves.toEqual({ + returnTo: '/editor/demo' + }) + expect(exchangeAuthorizationCode).toHaveBeenCalledWith('code-a', 'verifier-a') + expect(userStore.isSignedIn()).toBe(true) + expect(getSignedInUser).toHaveBeenCalledWith({ + headers: new Headers({ Authorization: 'Bearer token-a' }) + }) + expect(sessionStorage.getItem(pendingAuthorizationStorageKey)).toBe(null) + }) + + it('should build sign-in URLs through the authorize endpoint on the main-site api origin', async () => { + const userStore = await import('./signed-in') + + createPushedAuthorizationRequest.mockResolvedValueOnce({ + request_uri: 'urn:request:1' + }) + + await expect(userStore.createAuthorizationRequest('/editor/demo')).resolves.toEqual({ + requestUri: 'urn:request:1', + signInUrl: 'http://localhost:3000/account/oauth/authorize?client_id=client-123&request_uri=urn%3Arequest%3A1' + }) + expect(sessionStorage.getItem(pendingAuthorizationStorageKey)).toMatch(/"returnTo":"\/editor\/demo"/) + expect(createPushedAuthorizationRequest).toHaveBeenCalledTimes(1) + expect(createPushedAuthorizationRequest).toHaveBeenCalledWith( + expect.objectContaining({ + state: expect.any(String), + codeChallenge: expect.any(String) + }) + ) + }) + + it('should redirect initiateSignIn through the authorize endpoint returned from the main-site api origin', async () => { + const userStore = await import('./signed-in') + + createPushedAuthorizationRequest.mockResolvedValueOnce({ + request_uri: 'urn:request:1' + }) + + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined) + + await userStore.initiateSignIn('/editor/demo') + + expect(assign).toHaveBeenCalledWith( + 'http://localhost:3000/account/oauth/authorize?client_id=client-123&request_uri=urn%3Arequest%3A1' + ) + }) + + it('should clear pending OAuth authorization when callback token exchange fails', async () => { + const userStore = await import('./signed-in') + const exchangeError = new Error('token exchange failed') + + sessionStorage.setItem( + pendingAuthorizationStorageKey, + JSON.stringify({ state: 'state-a', codeVerifier: 'verifier-a', returnTo: '/editor/demo' }) + ) + exchangeAuthorizationCode.mockRejectedValueOnce(exchangeError) + + await expect(userStore.completeAuthorizationCodeSignIn('?code=code-a&state=state-a')).rejects.toBe(exchangeError) + expect(userStore.isSignedIn()).toBe(false) + expect(sessionStorage.getItem(pendingAuthorizationStorageKey)).toBe(null) + }) + + it('should use the rotated refresh token on the next refresh', async () => { + const userStore = await import('./signed-in') + getSignedInUser.mockResolvedValue({ username: 'alice' }) + + sessionStorage.setItem( + pendingAuthorizationStorageKey, + JSON.stringify({ state: 'state-a', codeVerifier: 'verifier-a', returnTo: '/editor/demo' }) + ) + exchangeAuthorizationCode.mockResolvedValueOnce({ + access_token: 'token-a', + expires_in: 0, + refresh_token: 'refresh-a' + }) + + await userStore.completeAuthorizationCodeSignIn('?code=code-a&state=state-a') + + exchangeRefreshToken.mockResolvedValueOnce({ + access_token: 'token-b', + expires_in: 0, + refresh_token: 'refresh-b' + }) + await expect(userStore.refreshAccessToken()).resolves.toBe('token-b') + expect(exchangeRefreshToken).toHaveBeenNthCalledWith(1, 'refresh-a') + exchangeRefreshToken.mockResolvedValueOnce({ + access_token: 'token-c', + expires_in: 3600, + refresh_token: 'refresh-c' + }) + await expect(userStore.refreshAccessToken()).resolves.toBe('token-c') + expect(exchangeRefreshToken).toHaveBeenNthCalledWith(2, 'refresh-b') + }) +}) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index 19d3573a8d..0084cc772c 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -1,29 +1,32 @@ import { reactive, watchEffect, computed } from 'vue' -import Sdk from 'casdoor-js-sdk' -import { casdoorConfig } from '@/utils/env' -import { jwtDecode } from 'jwt-decode' -import { useQueryWithCache, useQueryCache, useQuery, composeQuery } from '@/utils/query' -import { useAction } from '@/utils/exception' +import { + getAccountOAuthAuthorizeUrl, + createPushedAuthorizationRequest, + exchangeAuthorizationCode, + exchangeRefreshToken, + revokeOAuthToken, + type AccountOAuthTokenResponse +} from '@/apis/account-oauth' import * as apis from '@/apis/user' +import { useAction } from '@/utils/exception' +import { composeQuery, useQuery, useQueryCache, useQueryWithCache } from '@/utils/query' import { getUserQueryKey } from './query-keys' export type SignedInUser = apis.SignedInUser -const casdoorAuthRedirectPath = '/sign-in/callback' -const casdoorSdk = new Sdk({ - ...casdoorConfig, - redirectPath: casdoorAuthRedirectPath -}) - const userStateStorageKey = 'spx-user' +const pendingAuthorizationStorageKey = 'spx-account-oauth:pending-authorization' + +interface PendingAuthorization { + state: string + codeVerifier: string + returnTo: string +} + const userState = reactive({ accessToken: null as string | null, accessTokenExpiresAt: null as number | null, refreshToken: null as string | null, - /** - * User name parsed from access token, or null if not available. - * This is only a hint and should not be treated as canonical backend-confirmed identity. - */ username: null as string | null }) @@ -35,50 +38,109 @@ export function initUserState() { watchEffect(() => localStorage.setItem(userStateStorageKey, JSON.stringify(userState))) } -interface TokenResponse { - access_token: string - expires_in: number - refresh_token: string +function encodeBase64Url(bytes: Uint8Array) { + let binary = '' + bytes.forEach((byte) => { + binary += String.fromCharCode(byte) + }) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +function createRandomString(byteLength: number = 32) { + return encodeBase64Url(crypto.getRandomValues(new Uint8Array(byteLength))) +} + +async function createCodeChallenge(codeVerifier: string) { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)) + return encodeBase64Url(new Uint8Array(digest)) } -function decodeUsernameFromAccessToken(accessToken: string): string | null { +function readPendingAuthorization(): PendingAuthorization | null { + const stored = sessionStorage.getItem(pendingAuthorizationStorageKey) + if (stored == null) return null try { - const decoded = jwtDecode<{ name?: unknown }>(accessToken) - if (typeof decoded.name !== 'string' || decoded.name === '') return null - return decoded.name + return JSON.parse(stored) as PendingAuthorization } catch { + sessionStorage.removeItem(pendingAuthorizationStorageKey) return null } } -function handleTokenResponse(resp: TokenResponse) { +function writePendingAuthorization(value: PendingAuthorization | null) { + if (value == null) { + sessionStorage.removeItem(pendingAuthorizationStorageKey) + } else { + sessionStorage.setItem(pendingAuthorizationStorageKey, JSON.stringify(value)) + } +} + +async function fetchSignedInUsernameByAccessToken(accessToken: string) { + const user = await apis.getSignedInUser({ + headers: new Headers({ + Authorization: `Bearer ${accessToken}` + }) + }) + return user.username +} + +async function handleTokenResponse(resp: AccountOAuthTokenResponse) { + const username = await fetchSignedInUsernameByAccessToken(resp.access_token) userState.accessToken = resp.access_token userState.accessTokenExpiresAt = resp.expires_in ? Date.now() + resp.expires_in * 1000 : null - userState.refreshToken = resp.refresh_token - userState.username = decodeUsernameFromAccessToken(resp.access_token) + if ('refresh_token' in resp) userState.refreshToken = resp.refresh_token ?? null + userState.username = username } -export function initiateSignIn( +export async function createAuthorizationRequest( returnTo: string = window.location.pathname + window.location.search + window.location.hash ) { - // Workaround for casdoor-js-sdk not supporting override of `redirectPath` in `signin_redirect`. - const casdoorSdk = new Sdk({ - ...casdoorConfig, - redirectPath: `${casdoorAuthRedirectPath}?returnTo=${encodeURIComponent(returnTo)}` - }) - casdoorSdk.signin_redirect() + const state = createRandomString() + const codeVerifier = createRandomString(48) + const codeChallenge = await createCodeChallenge(codeVerifier) + const response = await createPushedAuthorizationRequest({ state, codeChallenge }) + + writePendingAuthorization({ state, codeVerifier, returnTo }) + return { + requestUri: response.request_uri, + signInUrl: getAccountOAuthAuthorizeUrl(response.request_uri) + } } -export async function completeSignIn() { - const resp = await casdoorSdk.exchangeForAccessToken() - handleTokenResponse(resp) +export async function initiateSignIn( + returnTo: string = window.location.pathname + window.location.search + window.location.hash +) { + const { signInUrl } = await createAuthorizationRequest(returnTo) + window.location.assign(signInUrl) +} + +export async function completeAuthorizationCodeSignIn(search: string = window.location.search) { + const params = new URLSearchParams(search) + const code = params.get('code')?.trim() ?? '' + const state = params.get('state')?.trim() ?? '' + if (code === '' || state === '') throw new Error('Missing OAuth callback parameters') + + const pendingAuthorization = readPendingAuthorization() + if (pendingAuthorization == null) throw new Error('Missing pending OAuth authorization state') + if (pendingAuthorization.state !== state) throw new Error('OAuth state mismatch') + + try { + const resp = await exchangeAuthorizationCode(code, pendingAuthorization.codeVerifier) + await handleTokenResponse(resp) + } finally { + writePendingAuthorization(null) + } + + return { + returnTo: pendingAuthorization.returnTo + } } -export function signInWithAccessToken(accessToken: string) { +export async function signInWithAccessToken(accessToken: string) { + const username = await fetchSignedInUsernameByAccessToken(accessToken) userState.accessToken = accessToken userState.accessTokenExpiresAt = null userState.refreshToken = null - userState.username = decodeUsernameFromAccessToken(accessToken) + userState.username = username } export function signOut() { @@ -86,32 +148,43 @@ export function signOut() { userState.accessTokenExpiresAt = null userState.refreshToken = null userState.username = null + writePendingAuthorization(null) +} + +export async function revokeTokens() { + const accessToken = userState.accessToken + const refreshToken = userState.refreshToken + signOut() + try { + await revokeOAuthToken(accessToken) + } catch (error) { + console.error('failed to revoke access token', error) + } + try { + await revokeOAuthToken(refreshToken) + } catch (error) { + console.error('failed to revoke refresh token', error) + } } const tokenExpiryDelta = 60 * 1000 // 1 minute in milliseconds let tokenRefreshPromise: Promise | null = null -export async function ensureAccessToken(): Promise { - if (isAccessTokenValid()) return userState.accessToken - +export async function refreshAccessToken(): Promise { + const refreshToken = userState.refreshToken + if (refreshToken == null) return null if (tokenRefreshPromise != null) return tokenRefreshPromise - if (userState.refreshToken == null) { - signOut() - return null - } tokenRefreshPromise = (async () => { try { - const resp = await casdoorSdk.refreshAccessToken(userState.refreshToken!) - handleTokenResponse(resp) - } catch (e) { - console.error('failed to refresh access token', e) - throw e + const resp = await exchangeRefreshToken(refreshToken) + await handleTokenResponse(resp) + } catch (error) { + console.error('failed to refresh access token', error) + signOut() + return null } - // Due to casdoor-js-sdk's lack of error handling, we must check if the access token is valid after calling - // `casdoorSdk.refreshAccessToken`. The token might still be invalid if, e.g., the server has already revoked - // the refresh token. We can't do anything but sign out the user in such cases. if (!isAccessTokenValid()) { signOut() return null @@ -119,9 +192,19 @@ export async function ensureAccessToken(): Promise { return userState.accessToken })() + return tokenRefreshPromise.finally(() => (tokenRefreshPromise = null)) } +export async function ensureAccessToken(): Promise { + if (isAccessTokenValid()) return userState.accessToken + if (userState.refreshToken == null) { + signOut() + return null + } + return refreshAccessToken() +} + function isAccessTokenValid(): boolean { return !!( userState.accessToken && @@ -136,25 +219,30 @@ export function isSignedIn(): boolean { /** * Returns the current signed-in username from locally available auth state only. * - * The returned value is unresolved: it comes from the access token, - * and should not be treated as canonical backend-confirmed identity. + * The returned value is unresolved: it comes from local cached state and may lag behind the + * canonical signed-in user returned by the backend. * - * Use this only at boundaries that need a synchronous identity hint, such as cache keys, route - * derivation, or other session-scoping data. Do not use it for behavior-sensitive checks like - * ownership, permissions, or other logic that should depend on canonical backend data. + * Use this only at boundaries that need a synchronous session-scoped identity hint, such as + * temporary route derivation or user-scoped storage. Do not use it for behavior-sensitive checks + * like ownership, permissions, or other logic that should depend on canonical backend data. */ export function getUnresolvedSignedInUsername(): string | null { if (!isSignedIn()) return null - if (userState.username != null) return userState.username - if (userState.accessToken == null) return null - const username = decodeUsernameFromAccessToken(userState.accessToken) - if (username == null) return null - userState.username = username - return username + return userState.username } const signedInUserStaleTime = 60 * 1000 // 1min +/** + * TODO: This query key still depends on `getUnresolvedSignedInUsername()`, which is only a local + * username hint rather than a canonical auth-session identifier. + * + * Current limitations: + * - auth-session changes do not change the key if the unresolved username stays the same + * - different sessions for the same username may therefore reuse the same cache entry + * + * A later cleanup should replace this with a dedicated auth-session-scoping key. + */ function getSignedInUserQueryKey() { return [...getUserQueryKey(getUnresolvedSignedInUsername() ?? ''), 'signed-in'] } @@ -162,7 +250,7 @@ function getSignedInUserQueryKey() { function useSignedInUserQuery() { const queryKey = computed(() => getSignedInUserQueryKey()) return useQueryWithCache({ - queryKey: queryKey, + queryKey, async queryFn() { if (!isSignedIn()) throw new Error('User not signed in') return apis.getSignedInUser() @@ -188,8 +276,8 @@ export type SignedInState = /** * Get the signed-in state, including whether the user is signed in and the signed-in user information if available. * Suitable for scenarios like: - * - callers need to known whether the user is signed in or not - * - callers need to acccess the loading or error state of the signed-in user query + * - callers need to know whether the user is signed in or not + * - callers need to access the loading or error state of the signed-in user query */ export function useSignedInStateQuery() { const signedInUserQuery = useSignedInUserQuery() diff --git a/spx-gui/src/utils/account/sign-in.test.ts b/spx-gui/src/utils/account/sign-in.test.ts new file mode 100644 index 0000000000..540268a02a --- /dev/null +++ b/spx-gui/src/utils/account/sign-in.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + buildIdentityProviderAuthorizeUrl, + buildOAuthAuthorizeUrl, + clearPendingAuthorization, + hasPendingAuthorization, + markPendingAuthorization, + parseOAuthContext, + type OAuthContext +} from './sign-in' + +describe('account-web sign-in utils', () => { + const context: OAuthContext = { + clientId: 'xbuilder', + requestUri: 'urn:example:request:123' + } + + beforeEach(() => { + sessionStorage.clear() + window.history.replaceState({}, '', '/sign-in?clientID=xbuilder&requestURI=urn%3Aexample%3Arequest%3A123') + }) + + it('parses oauth context from the current location', () => { + expect(parseOAuthContext()).toEqual(context) + }) + + it('builds account authorize URLs against a relative facade base URL', () => { + expect(buildOAuthAuthorizeUrl(context)).toBe( + 'http://localhost:3000/api/oauth/authorize?client_id=xbuilder&request_uri=urn%3Aexample%3Arequest%3A123' + ) + expect(buildIdentityProviderAuthorizeUrl('github', context)).toBe( + 'http://localhost:3000/api/identity-providers/github/authorize?clientID=xbuilder&requestURI=urn%3Aexample%3Arequest%3A123' + ) + }) + + it('tracks pending authorization by oauth flow', () => { + expect(hasPendingAuthorization(context)).toBe(false) + markPendingAuthorization(context) + expect(hasPendingAuthorization(context)).toBe(true) + clearPendingAuthorization(context) + expect(hasPendingAuthorization(context)).toBe(false) + }) +}) diff --git a/spx-gui/src/utils/account/sign-in.ts b/spx-gui/src/utils/account/sign-in.ts new file mode 100644 index 0000000000..6eb432788a --- /dev/null +++ b/spx-gui/src/utils/account/sign-in.ts @@ -0,0 +1,71 @@ +/** + * Utilities for the Account Web sign-in page. + * + * These are used by the sign-in page (apps/account/) to parse OAuth context + * from query parameters, build authorize URLs, and track pending + * authorization state in sessionStorage. + * + * This file is specific to the Account Web sign-in flow and is not used by + * the main xbuilder.com OAuth client logic. + */ + +export interface OAuthContext { + clientId: string + requestUri: string +} + +export function parseOAuthContext(search: string = window.location.search): OAuthContext | null { + const params = new URLSearchParams(search) + const clientId = params.get('clientID')?.trim() ?? '' + const requestUri = params.get('requestURI')?.trim() ?? '' + if (clientId === '' || requestUri === '') return null + return { clientId, requestUri } +} + +export function getOAuthError(search: string = window.location.search): string | null { + const params = new URLSearchParams(search) + const error = params.get('error')?.trim() ?? '' + if (error === '') return null + const description = params.get('error_description')?.trim() ?? '' + return description === '' ? error : `${error}: ${description}` +} + +export function buildAccountApiUrl(path: string, query?: Record) { + const url = new URL(path, window.location.origin) + if (query != null) { + Object.entries(query).forEach(([key, value]) => { + url.searchParams.set(key, value) + }) + } + return url.toString() +} + +export function buildOAuthAuthorizeUrl(context: OAuthContext) { + return buildAccountApiUrl('/api/oauth/authorize', { + client_id: context.clientId, + request_uri: context.requestUri + }) +} + +export function buildIdentityProviderAuthorizeUrl(provider: string, context: OAuthContext) { + return buildAccountApiUrl(`/api/identity-providers/${encodeURIComponent(provider)}/authorize`, { + clientID: context.clientId, + requestURI: context.requestUri + }) +} + +function getPendingAuthorizationKey(context: OAuthContext) { + return `account-web:pending-authorization:${context.clientId}:${context.requestUri}` +} + +export function hasPendingAuthorization(context: OAuthContext) { + return sessionStorage.getItem(getPendingAuthorizationKey(context)) === '1' +} + +export function markPendingAuthorization(context: OAuthContext) { + sessionStorage.setItem(getPendingAuthorizationKey(context), '1') +} + +export function clearPendingAuthorization(context: OAuthContext) { + sessionStorage.removeItem(getPendingAuthorizationKey(context)) +} diff --git a/spx-gui/src/utils/env.ts b/spx-gui/src/utils/env.ts index 6496854d7c..1e88c56730 100644 --- a/spx-gui/src/utils/env.ts +++ b/spx-gui/src/utils/env.ts @@ -13,14 +13,6 @@ export const usercontentBaseUrl = import.meta.env.VITE_USERCONTENT_BASE_URL as s export const usercontentBucket = import.meta.env.VITE_USERCONTENT_BUCKET as string export const usercontentUploadBaseUrl = import.meta.env.VITE_USERCONTENT_UPLOAD_BASE_URL as string -// Casdoor configurations -export const casdoorConfig = { - serverUrl: import.meta.env.VITE_CASDOOR_ENDPOINT as string, - clientId: import.meta.env.VITE_CASDOOR_CLIENT_ID as string, - organizationName: import.meta.env.VITE_CASDOOR_ORGANIZATION_NAME as string, - appName: import.meta.env.VITE_CASDOOR_APP_NAME as string -} - /** * If we should disable features that rely on AIGC service. * For now AIGC service is not ready for production. We disable it until it's ready. @@ -55,3 +47,6 @@ export const showTutorialsEntry = import.meta.env.VITE_SHOW_TUTORIALS_ENTRY === /** Default language, e.g. `en`, `zh` */ export const defaultLang = (import.meta.env.VITE_DEFAULT_LANG as string) || 'en' + +// OAuth client id issued for the main-site frontend. +export const accountOAuthClientId = import.meta.env.VITE_ACCOUNT_OAUTH_CLIENT_ID as string diff --git a/spx-gui/src/widgets/spx-runner/index.ts b/spx-gui/src/widgets/spx-runner/index.ts index 9207e934ad..45aa698aca 100644 --- a/spx-gui/src/widgets/spx-runner/index.ts +++ b/spx-gui/src/widgets/spx-runner/index.ts @@ -9,7 +9,7 @@ customElements.define( 'spx-runner', defineCustomElement(spxRunner, { configureApp(app) { - configureApp(app, false /* disable router in widget */) + configureApp(app) } }) ) diff --git a/spx-gui/src/widgets/xgo-code-editor/index.ts b/spx-gui/src/widgets/xgo-code-editor/index.ts index 4b31838af5..ffa4e5f760 100644 --- a/spx-gui/src/widgets/xgo-code-editor/index.ts +++ b/spx-gui/src/widgets/xgo-code-editor/index.ts @@ -17,7 +17,7 @@ customElements.define( 'unstable-xgo-code-editor', defineCustomElement(xgoCodeEditor, { configureApp(app) { - configureApp(app, false /* disable router in widget */) + configureApp(app) } }) ) diff --git a/spx-gui/vite.config.account.ts b/spx-gui/vite.config.account.ts new file mode 100644 index 0000000000..b0c59369cd --- /dev/null +++ b/spx-gui/vite.config.account.ts @@ -0,0 +1,434 @@ +import path from 'node:path' +import { spawn, type ChildProcess } from 'node:child_process' +import crypto from 'node:crypto' +import http from 'node:http' +import net from 'node:net' +import os from 'node:os' +import fs from 'node:fs' +import { Buffer } from 'node:buffer' +import { defineConfig, loadEnv } from 'vite' +import type { Plugin, ViteDevServer } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import browserslistToEsbuild from 'browserslist-to-esbuild' + +import { createVercelOutputPlugin } from './vercel-output-plugin.js' + +const resolve = (dir: string) => path.join(__dirname, dir) +const cdpCallbackOriginEnv = 'VITE_ACCOUNT_WEB_CDP_CALLBACK_ORIGIN' + +interface CDPCommand { + id: number + method: string + params?: Record +} + +interface CDPEvent { + method: string + params?: Record +} + +interface CDPRequestPausedParams { + requestId: string + request: { + url: string + method: string + headers: Record + postData?: string + } +} + +class CDPWebSocket { + private socket: net.Socket + private buffer = Buffer.alloc(0) + private nextID = 1 + private eventListeners = new Map) => void>>() + + private constructor(socket: net.Socket) { + this.socket = socket + socket.on('data', (chunk) => this.handleData(chunk)) + } + + static async connect(rawURL: string) { + const url = new URL(rawURL) + const socket = await connectSocket(url.hostname, Number(url.port || 80)) + const key = crypto.randomBytes(16).toString('base64') + socket.write( + [ + `GET ${url.pathname}${url.search} HTTP/1.1`, + `Host: ${url.host}`, + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Key: ${key}`, + 'Sec-WebSocket-Version: 13', + '', + '' + ].join('\r\n') + ) + + await readHandshake(socket) + return new CDPWebSocket(socket) + } + + on(event: string, listener: (params: Record) => void) { + const listeners = this.eventListeners.get(event) ?? [] + listeners.push(listener) + this.eventListeners.set(event, listeners) + } + + send(method: string, params?: Record) { + const command: CDPCommand = { id: this.nextID++, method, params } + this.socket.write(encodeWebSocketFrame(JSON.stringify(command))) + } + + close() { + this.socket.end() + } + + private handleData(chunk: Buffer) { + this.buffer = Buffer.concat([this.buffer, chunk]) + while (this.buffer.length > 0) { + const frame = decodeWebSocketFrame(this.buffer) + if (frame == null) return + this.buffer = this.buffer.subarray(frame.length) + const message = JSON.parse(frame.payload.toString()) as CDPEvent + if (message.method == null || message.params == null) continue + for (const listener of this.eventListeners.get(message.method) ?? []) listener(message.params) + } + } +} + +function connectSocket(host: string, port: number) { + return new Promise((resolveSocket, reject) => { + const socket = net.connect(port, host) + socket.once('connect', () => resolveSocket(socket)) + socket.once('error', reject) + }) +} + +function readHandshake(socket: net.Socket) { + return new Promise((resolveHandshake, reject) => { + let buffer = Buffer.alloc(0) + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + if (!buffer.includes('\r\n\r\n')) return + socket.off('data', onData) + resolveHandshake() + } + socket.on('data', onData) + socket.once('error', reject) + }) +} + +function encodeWebSocketFrame(payload: string) { + const payloadBuffer = Buffer.from(payload) + const mask = crypto.randomBytes(4) + const headerLength = payloadBuffer.length < 126 ? 6 : 8 + const frame = Buffer.alloc(headerLength + payloadBuffer.length) + frame[0] = 0x81 + if (payloadBuffer.length < 126) { + frame[1] = 0x80 | payloadBuffer.length + mask.copy(frame, 2) + } else { + frame[1] = 0x80 | 126 + frame.writeUInt16BE(payloadBuffer.length, 2) + mask.copy(frame, 4) + } + const maskOffset = headerLength - 4 + for (let i = 0; i < payloadBuffer.length; i++) { + frame[headerLength + i] = payloadBuffer[i] ^ frame[maskOffset + (i % 4)] + } + return frame +} + +function decodeWebSocketFrame(buffer: Buffer): { length: number; payload: Buffer } | null { + if (buffer.length < 2) return null + const baseLength = buffer[1] & 0x7f + let offset = 2 + let payloadLength = baseLength + if (baseLength === 126) { + if (buffer.length < 4) return null + payloadLength = buffer.readUInt16BE(2) + offset = 4 + } else if (baseLength === 127) { + if (buffer.length < 10) return null + payloadLength = Number(buffer.readBigUInt64BE(2)) + offset = 10 + } + const masked = (buffer[1] & 0x80) !== 0 + const maskOffset = offset + if (masked) offset += 4 + const length = offset + payloadLength + if (buffer.length < length) return null + const payload = Buffer.from(buffer.subarray(offset, length)) + if (masked) { + for (let i = 0; i < payload.length; i++) payload[i] ^= buffer[maskOffset + (i % 4)] + } + return { length, payload } +} + +function accountWebCDPCallbackPlugin(callbackOrigin: string | null): Plugin | null { + if (callbackOrigin == null) return null + + return { + name: 'account-cdp-callback-redirect', + apply: 'serve', + configureServer(server) { + let chrome: ChildProcess | null = null + let cdp: CDPWebSocket | null = null + server.httpServer?.once('listening', () => { + void startCDPCallbackRedirect(server, callbackOrigin) + .then((result) => { + chrome = result.chrome + cdp = result.cdp + }) + .catch((error: unknown) => { + server.config.logger.error( + `Failed to start Account Web CDP callback redirect: ${error instanceof Error ? error.message : String(error)}` + ) + }) + }) + server.httpServer?.once('close', () => { + cdp?.close() + chrome?.kill() + }) + } + } +} + +async function startCDPCallbackRedirect(server: ViteDevServer, callbackOrigin: string) { + const devOrigin = server.resolvedUrls?.local[0]?.replace(/\/$/, '') ?? `http://localhost:${server.config.server.port}` + const remoteDebuggingPort = await getFreePort() + const chrome = launchChrome(remoteDebuggingPort, devOrigin) + const target = await waitForCDPTarget(remoteDebuggingPort) + const cdp = await CDPWebSocket.connect(target.webSocketDebuggerUrl) + const callbackURLPattern = `${callbackOrigin.replace(/\/$/, '')}/api/identity-providers/*/callback*` + + cdp.send('Fetch.enable', { + patterns: [{ urlPattern: callbackURLPattern, requestStage: 'Request' }] + }) + cdp.on('Fetch.requestPaused', (params) => { + handlePausedCallbackRequest(cdp, params as unknown as CDPRequestPausedParams, devOrigin) + }) + server.config.logger.info(`Account Web CDP callback redirect enabled for ${callbackURLPattern}`) + return { chrome, cdp } +} + +function launchChrome(remoteDebuggingPort: number, devOrigin: string) { + const executable = findChromeExecutable() + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xbuilder-account-chrome-')) + return spawn( + executable, + [ + `--remote-debugging-port=${remoteDebuggingPort}`, + `--user-data-dir=${userDataDir}`, + '--no-first-run', + '--no-default-browser-check', + devOrigin + ], + { stdio: 'ignore' } + ) +} + +function findChromeExecutable() { + const candidates = [ + process.env.CHROME_PATH ?? null, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + 'google-chrome', + 'chromium', + 'chromium-browser' + ] + const executable = candidates.find( + (candidate) => candidate != null && (candidate.includes('/') ? fs.existsSync(candidate) : true) + ) + if (executable == null) throw new Error('Chrome executable not found') + return executable +} + +function getFreePort() { + return new Promise((resolvePort, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + server.close(() => { + if (address == null || typeof address === 'string') { + reject(new Error('Failed to allocate a remote debugging port')) + return + } + resolvePort(address.port) + }) + }) + }) +} + +async function waitForCDPTarget(port: number) { + for (let i = 0; i < 50; i++) { + const targets = await readJSON>( + `http://127.0.0.1:${port}/json/list` + ).catch(() => null) + const target = targets?.find((item) => item.type === 'page' && item.webSocketDebuggerUrl != null) + if (target != null) return target + await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 100)) + } + throw new Error('Timed out waiting for Chrome DevTools Protocol target') +} + +function readJSON(url: string) { + return new Promise((resolveJSON, reject) => { + http + .get(url, (response) => { + const chunks: Buffer[] = [] + response.on('data', (chunk) => chunks.push(chunk)) + response.on('end', () => { + resolveJSON(JSON.parse(Buffer.concat(chunks).toString()) as T) + }) + }) + .on('error', reject) + }) +} + +function handlePausedCallbackRequest(cdp: CDPWebSocket, params: CDPRequestPausedParams, devOrigin: string) { + const requestURL = new URL(params.request.url) + const provider = requestURL.pathname.match(/\/api\/identity-providers\/([^/]+)\/callback$/)?.[1] + if (provider == null) { + cdp.send('Fetch.continueRequest', { requestId: params.requestId }) + return + } + + const target = new URL(`/api/identity-providers/${provider}/callback`, devOrigin) + for (const [key, value] of requestURL.searchParams) target.searchParams.append(key, value) + if (params.request.method === 'POST' && params.request.postData != null) { + for (const [key, value] of new URLSearchParams(params.request.postData)) target.searchParams.append(key, value) + } + cdp.send('Fetch.fulfillRequest', { + requestId: params.requestId, + responseCode: 302, + responseHeaders: [ + { name: 'Location', value: target.toString() }, + { name: 'Cache-Control', value: 'no-store' } + ] + }) +} + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const vercelProxiedApiBaseURL = + env.VITE_VERCEL_PROXIED_API_BASE_URL == null ? null : env.VITE_VERCEL_PROXIED_API_BASE_URL + const cdpCallbackOrigin = env[cdpCallbackOriginEnv]?.trim() || null + const cdpCallbackPlugin = accountWebCDPCallbackPlugin(cdpCallbackOrigin) + + return { + plugins: [ + ...(cdpCallbackPlugin == null ? [] : [cdpCallbackPlugin]), + { + name: 'account-rename-output-html', + apply: 'build', + closeBundle() { + const from = resolve('dist/account.html') + const to = resolve('dist/index.html') + if (fs.existsSync(from)) fs.renameSync(from, to) + } + }, + { + name: 'account-dev-spa-fallback', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + if (req.url == null) return next() + const pathname = new URL(req.url, 'http://localhost').pathname + // Skip Vite internal requests + if (pathname.startsWith('/@')) return next() + // Skip requests for static assets (have file extensions) + if (pathname.includes('.')) return next() + // Skip API proxy requests + if (pathname.startsWith('/api/')) return next() + // Skip the entry HTML itself + if (pathname === '/account.html') return next() + // SPA fallback: serve account.html for unmatched HTML routes (e.g. /sign-in) + req.url = '/account.html' + next() + }) + } + }, + vue(), + tailwindcss(), + createVercelOutputPlugin({ + headers: [ + { + source: '/(.*)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=300' + }, + { + key: 'Cross-Origin-Embedder-Policy', + value: 'require-corp' + }, + { + key: 'Cross-Origin-Opener-Policy', + value: 'same-origin' + } + ] + }, + { + source: '/assets/(.*)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=31536000, immutable' + } + ] + } + ], + rewrites: [ + { + source: '/api/(.*)', + destination: vercelProxiedApiBaseURL == null ? null : `${vercelProxiedApiBaseURL}/$1` + }, + { + source: '/(.*)', + destination: '/index.html' + } + ] + }) + ], + resolve: { + alias: { + '@': resolve('src'), + '@docs': resolve('../docs') + } + }, + publicDir: false, + build: { + target: browserslistToEsbuild(), + rolldownOptions: { + input: { + 'sign-in': resolve('account.html') + }, + output: { + entryFileNames: 'assets/[name]-[hash].js' + } + } + }, + server: { + headers: { + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Opener-Policy': 'same-origin' + }, + proxy: (() => { + const target = env.VITE_API_BASE_URL + if (!target) return undefined + return { + '/api': { + target, + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api/, '/account') + } + } + })() + } + } +})