diff --git a/.gitignore b/.gitignore index ef56624..c321178 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ coverage.html # 專案編譯產物(根目錄 binary 名稱與 module 相同時) /gateway +# k6 / dev binary 產物 +/bin/ + # ========================= # go-doc / 工具 binary # ========================= @@ -76,4 +79,5 @@ temp/ # E2E 產物(make e2e-full / e2e-up 生成) test/e2e/fixtures/state.json test/e2e/fixtures/gateway.pid +.dev/ .cache/ diff --git a/.golangci.yml b/.golangci.yml index 2efb85b..1e06791 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -134,6 +134,8 @@ linters: paths: - generate/doc-generate - docs/openapi + - frontend + - cmd/k6-seed-admin formatters: enable: @@ -142,6 +144,8 @@ formatters: generated: lax paths: - generate/doc-generate + - frontend + - cmd/k6-seed-admin issues: max-issues-per-linter: 0 diff --git a/AGENTS.md b/AGENTS.md index 6a85302..d6953ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,97 +1,39 @@ # AGENTS.md -給 AI coding agent(Claude / Cursor / Codex / 其他)的專案工作準則。請在開始任務前讀過一遍,並在需要時翻閱對應子文件。 +`template-monorepo` 是基於 [go-zero](https://github.com/zeromicro/go-zero) 的 API Gateway,採用「模組化 Clean Architecture」。 -## 專案簡介 +完整的 AI agent 工作準則請見 **[docs/AGENTS.md](docs/AGENTS.md)**(Cursor / Claude / Codex / 其他 agent 皆適用)。 -`template-monorepo` 是基於 [go-zero](https://github.com/zeromicro/go-zero) 的 API Gateway,採用「**模組化 Clean Architecture**」:每個業務模組(auth / member / notification / permission ...)放在 `internal/model//`,內部分 `domain`(介面 + enum + errors) / `repository`(Mongo / Redis 實作) / `usecase`(原子業務邏輯) / `config`。 +## 一分鐘快速理解 -跨模組編排(例如「發 OTP → 寄信 → 驗碼 → 更新 profile」)一律放在 `internal/logic//`,**usecase 不可呼叫其他 usecase**。 +- **業務模組** 一律放 `internal/model//`,內部分 `domain` / `repository` / `usecase` / `config`。 +- **跨模組編排**(多 usecase 串接)放 `internal/logic//`;**usecase 不可呼叫其他 usecase**。 +- **API** 由 `generate/api/*.api` 定義,`make gen-api` 產生 handler / types,`make gen-doc` 產生 OpenAPI。 +- **錯誤碼** 8 碼 `SSCCCDDD`,全專案唯一在 `internal/library/errors`。 +- **回應永遠用繁體中文。** -## 必讀文件 +## 常用入口 -| 文件 | 何時讀 | +| 文件 | 用途 | |---|---| -| [`generate/api/README.md`](generate/api/README.md) | 新增 / 修改 API 端點、type、文件分組、欄位描述、enum 列舉前 | -| [`generate/doc-generate/README.md`](generate/doc-generate/README.md) | 需要查 go-doc 支援的 tag / `@respdoc` 寫法時 | -| `internal/model//README.md` | 動到該模組的領域邏輯時 | -| `docs/model.md`(若存在) | 全局架構規範 | - -## 標準工作流程 - -### 1. 修改 API(`.api` → handler / types / docs) - -1. 編輯 `generate/api/*.api`(遵守 `generate/api/README.md` 的三條規則:tags 分組 / backtick 行末 `//` 中文 description / `options=A|B|C` enum) -2. `make gen-api` — 重新產生 `internal/handler/`、`internal/logic/`(已存在則不覆蓋)、`internal/types/types.go` -3. `make gen-doc` — 重新產生 `docs/openapi/gateway.yaml`(gitignore,本地驗證用) -4. 實作 / 修改 `internal/logic//_logic.go` 的業務邏輯 -5. `go build ./...` 確保編譯通過 -6. `make lint` / `make test` 視改動範圍跑 - -### 2. 新增 / 修改業務模組 - -- 領域介面與型別放 `internal/model//domain/` -- Mongo / Redis 實作放 `internal/model//repository/` -- 原子 usecase 放 `internal/model//usecase/`(**不可**互相呼叫) -- 多步驟流程編排放 `internal/logic//` -- 模組的對外裝配入口統一在 `internal/model//usecase/module.go`,並從 `internal/svc/service_context.go` 注入 - -### 3. 錯誤碼 - -- 業務碼格式 `SSCCCDDD`(scope * 1_000_000 + category * 1_000 + detail) -- Scope 註冊在 `internal/library/errors/code/types.go`(Facade=10, Auth=28, Member=29, Notification=30, Permission=31) -- 新增 scope 時:同步更新 `gateway.api` 的 `bizCodeEnumDescription` - -### 4. Middleware(go-zero 正規手段) - -**禁止**在 `gateway.go` 用 `server.Use(...)` 全域掛 middleware,**所有** middleware 都透過 `.api` 的 `middleware:` 宣告: - -```go -@server ( - group: auth - prefix: /api/v1/auth - middleware: AuthJWT // 一個 - // middleware: AuthJWT,CasbinRBAC // 多個用逗號 -) -``` - -跑 `make gen-api` 後 `routes.go` 會自動 `rest.WithMiddlewares([]rest.Middleware{serverCtx.AuthJWT}, ...)`。 - -撰寫新 middleware 時: -- 用 **struct + `Handle()` method** 模式(不是 factory function) -- 檔名 = goctl stringx 規則(例 `AuthJWT` → `authjwt_middleware.go`、`CasbinRBAC` → `casbinrbac_middleware.go`) -- 在 `ServiceContext` 加 ` rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc. = middleware.NewMiddleware(...).Handle` -- Actor context 一律用 `internal/library/actor`(`WithActor` / `ActorFromContext`),禁止各 package 自定 `actorKey struct{}`(會造成 context value 進不來) - -詳細範例與分組原則見 [`generate/api/README.md`](generate/api/README.md) "Middleware" 章節。 - -### 4. Redis / Mongo / 設定 - -- 每個模組的設定型別放 `internal/model//config/config.go`,再合入 `internal/config/config.go` 的 `Config` struct -- Redis client 共用 `internal/library/redis/`,需 Pub/Sub 用 `client.PubSubClient()` -- Mongo index 註冊到 `cmd/mongo-index/main.go`(在 `run()` 裡呼叫 `repo.EnsureMongoIndexes`) - -## 通用準則 - -- **回應 traditional Chinese**(繁體中文) -- 程式碼註解只寫「為什麼」、邊界條件、trade-off,**不寫**「import the module / increment counter」這類顯而易見的描述 -- 不主動建立 `*.md` 文件,除非使用者明確要求 -- 改 git config / 強制 push / `rm -rf` 等破壞性操作 **必須**先取得使用者同意 -- 不要在沒被要求時直接 commit;commit 前先 `git status` / `git diff` 確認 -- commit message 用繁中描述「為什麼」改,不是「改了什麼」 +| [docs/AGENTS.md](docs/AGENTS.md) | Agent 工作準則總覽(必讀) | +| [README.md](README.md) | 專案總覽、開發約定、HTTP / 錯誤格式 | +| [docs/model.md](docs/model.md) | `internal/model/{module}` 分層規範 | +| [generate/api/README.md](generate/api/README.md) | `.api` 寫法、`@respdoc`、middleware 宣告 | +| [internal/library/errors/README.md](internal/library/errors/README.md) | 8 碼錯誤碼設計與 HTTP 對照 | +| [docs/identity-member-design.md](docs/identity-member-design.md) | Identity / Member / Permission 跨模組架構 | +| [docs/auth-unified-registration.md](docs/auth-unified-registration.md) | 統一註冊/登入完整時序 | +| [docs/e2e-testing.md](docs/e2e-testing.md) | E2E 測試流程 | ## 指令速查 -| 指令 | 用途 | -|---|---| -| `make gen-api` | `.api` → handler / logic(skip exists)/ types | -| `make gen-doc` | `.api` → `docs/openapi/gateway.yaml` | -| `make gen-mock` | 模組 mock(gomock) | -| `make tools` | 安裝 goctl / goimports / golangci-lint | -| `make fix` | gofmt + goimports + lint --fix + lint | -| `make check` | fix + test(提交前) | -| `make run-dev` | 本機啟動(需 `make deps-up`) | -| `make deps-up` | docker compose Mongo + Redis | -| `make mongo-index` | 建立 / 更新 Mongo 索引 | +```bash +make gen-api # .api → handler / logic(skip exists) / types +make gen-doc # .api → docs/openapi/gateway.yaml +make fix # gofmt + goimports + lint --fix + lint +make check # fix + test(提交前必跑) +make run-dev # 本機啟動(需 make deps-up) +make deps-up # docker compose Mongo + Redis +``` -完整列表跑 `make help`。 +完整列表:`make help`。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f44c07a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md + +Claude Code project memory. 本檔以 `@import` 把專案規範帶入 context;所有規則的單一真實來源(SSOT)在 [docs/AGENTS.md](docs/AGENTS.md)。 + +## 核心規範(必讀) + +@docs/AGENTS.md +@docs/model.md + +## 專案總覽 + +@README.md + +## 子系統設計 + +@docs/identity-member-design.md +@docs/auth-unified-registration.md + +## 子目錄 README(按需查閱,未 @import;需要時用 Read 工具開啟) + +- [`generate/api/README.md`](generate/api/README.md) — `.api` 寫法、middleware 宣告、`@respdoc` +- [`generate/doc-generate/README.md`](generate/doc-generate/README.md) — go-doc tag / `@respdoc` 參考 +- [`internal/library/errors/README.md`](internal/library/errors/README.md) — 8 碼錯誤碼設計 +- [`internal/library/mongo/README.md`](internal/library/mongo/README.md) — Mongo + Redis cache 流程 +- [`internal/library/redis/README.md`](internal/library/redis/README.md) — Redis client 共用 +- [`internal/library/validate/custom/README.md`](internal/library/validate/custom/README.md) — 自訂 validator tag +- [`internal/response/README.md`](internal/response/README.md) — Handler / Logic 分工 +- [`internal/model/auth/README.md`](internal/model/auth/README.md) +- [`internal/model/member/README.md`](internal/model/member/README.md) +- [`internal/model/permission/README.md`](internal/model/permission/README.md) +- [`internal/model/notification/README.md`](internal/model/notification/README.md) +- [`deploy/README.md`](deploy/README.md) — Docker compose / 部署 +- [`etc/README.md`](etc/README.md) — 設定檔說明 + +## 工作原則摘要 + +- **回應一律繁體中文。** +- 改動 Go 程式碼後執行 `make check`(= `make fix` + `make test`)才算完成。 +- 不主動建立 `*.md` 文件,除非使用者明確要求。 +- 不主動 commit;commit 前先 `git status` / `git diff` 確認;commit message 描述「為什麼」。 +- `rm -rf` / 強制 push / 改 git config 等破壞性操作前必須先取得使用者同意。 +- 程式碼註解只寫「為什麼」與邊界條件,不寫「import the module」這種顯而易見的描述。 diff --git a/Makefile b/Makefile index b3dcfc1..649dd40 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,6 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 .DEFAULT_GOAL := help -.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test test-e2e test-e2e-journey e2e-full e2e-casbin e2e-up e2e-down e2e-list fmt lint lint-fix fix check run \ - deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test member-seed setup-dev run-local - help: ## 顯示可用指令 @echo "Gateway Makefile" @echo "" @@ -57,38 +54,6 @@ gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML test: ## 執行測試 $(GO) test ./... - -test-e2e: ## 對已啟動的 Gateway 跑 E2E contract tests(單一 endpoint 驗證;需 state.json) - GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'Test(Auth_|Health|Member|Permission)' - GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestZZZ_AuthTokenRefreshAndLogout' - -test-e2e-journey: ## 對已啟動的 Gateway 跑 E2E user journeys(k6 風格多步流程;需 state.json) - GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestJourney_' - GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestZZZJourney_' - -e2e-full: ## 全新 Docker + index + seed + E2E contract tests + 關閉 - bash scripts/e2e-run.sh - -e2e-journey: ## 全新 Docker + index + seed + E2E user journeys(k6 風格)+ 關閉 - E2E_MODE=journey \ - E2E_TEST_PATTERN='TestJourney_' \ - E2E_TEST_PATTERN_ZZZ='TestZZZJourney_' \ - bash scripts/e2e-run.sh - -e2e-casbin: ## 全新 Docker + Casbin enabled + E2E + 關閉(含 RBAC 403 / reload) - E2E_CASBIN=1 E2E_CONFIG=test/e2e/fixtures/e2e.casbin.yaml \ - E2E_TEST_PATTERN='Test(Auth_|Health|Member|Permission_(Catalog|Me|CasbinRBAC))' \ - bash scripts/e2e-run.sh - -e2e-up: ## 起 Docker + index + seed + Gateway(不跑測試、不 teardown) - bash scripts/e2e-up.sh - -e2e-down: ## 停止 E2E Gateway 並 docker compose down -v - bash scripts/e2e-down.sh - -e2e-list: ## 列出所有 E2E 測試(ID + HTTP path + 中文描述) - @bash scripts/e2e-list.sh - fmt: ## gofmt + goimports(不含 lint) $(GOFMT) -s -w $(GOFILES) @command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1) @@ -103,55 +68,247 @@ fix: fmt lint-fix lint ## 格式化 + 自動修 lint + 再檢查(提交前建 check: fix test ## 提交 / PR 前完整檢查(fmt、lint、test) -run: ## 啟動 Gateway(etc/gateway.yaml,無需 Docker) - $(GO) run gateway.go -f etc/gateway.yaml +# ============================================================ +# Docker compose(本機依賴) +# ============================================================ -setup-dev: ## 建立本機 gateway.dev.yaml(自 example,不會被 git 追蹤) - @test -f etc/gateway.dev.yaml || cp etc/gateway.dev.example.yaml etc/gateway.dev.yaml - @echo ">> etc/gateway.dev.yaml ready (edit locally; not committed)" +DOCKER_COMPOSE ?= docker compose +COMPOSE_FILE := deploy/docker-compose.yml +COMPOSE := $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) -run-dev: setup-dev ## 啟動 Gateway(etc/gateway.dev.yaml,需 make deps-up) - $(GO) run gateway.go -f etc/gateway.dev.yaml +deps-up: ## 起 Mongo + Redis(最小本機依賴) + $(COMPOSE) up -d mongo redis -run-local: run-dev ## 別名:同 run-dev +deps-up-smtp: ## 起 Mongo + Redis + MailHog + $(COMPOSE) --profile smtp up -d mongo redis mailhog -deps-up: ## 啟動本機 Mongo + Redis(docker compose) - docker compose up -d mongo redis +deps-down: ## 停服務(保留 volume) + $(COMPOSE) --profile smtp --profile k6 down -deps-up-smtp: ## 啟動 Mongo + Redis + MailHog(本機 SMTP 測試) - docker compose --profile smtp up -d mongo redis mailhog +deps-down-v: ## 停服務並刪 volume(會清資料) + $(COMPOSE) --profile smtp --profile k6 down -v -deps-down: ## 停止 docker compose 容器(保留 volume) - docker compose --profile smtp down +deps-logs: ## 看 compose log + $(COMPOSE) --profile smtp --profile k6 logs -f -deps-down-v: ## 停止並刪除 volume(清空 Mongo/Redis 資料) - docker compose --profile smtp down -v +# ============================================================ +# k6 測試(test/k6/) +# ============================================================ -deps-logs: ## 查看依賴服務 log - docker compose --profile smtp logs -f +K6 ?= k6 +K6_GATEWAY_CONFIG := etc/gateway.k6.yaml +K6_GATEWAY_BIN := bin/gateway-k6 +K6_DIR := test/k6 +K6_PAT_FILE := deploy/zitadel/machinekey/zitadel-admin-sa.token +K6_ENV_FILE := deploy/zitadel/machinekey/k6.env +ZITADEL_HEALTH_URL := http://localhost:8080/debug/healthz -deps-ps: ## 查看依賴服務狀態 - docker compose --profile smtp ps +# k6 安裝指引(macOS / Linux) +define K6_INSTALL_HINT +k6 not found in PATH. -mongo-index: ## 建立 notification Mongo 索引(需 Mongo 已啟動) - $(GO) run ./cmd/mongo-index -f etc/gateway.dev.yaml +Install: + macOS (Homebrew): brew install k6 + Linux (apt): sudo gpg -k && sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 \ + && echo 'deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main' | sudo tee /etc/apt/sources.list.d/k6.list \ + && sudo apt update && sudo apt install k6 + Other: https://grafana.com/docs/k6/latest/set-up/install-k6/ -notify-test: setup-dev ## 通知測試(METHOD 必填;例: make notify-test METHOD=email-send TO=a@b.com) - @test -n "$(METHOD)" || (echo "usage: make notify-test METHOD=email-send TO=you@example.com" && \ - echo " make notify-test METHOD=sms-send PHONE=0912345678" && \ - echo " make notify-test METHOD=email-send TO=t@e.com MOCK=1" && exit 1) - $(GO) run ./cmd/notify-test -f etc/gateway.dev.yaml -method "$(METHOD)" \ - $(if $(TO),-to "$(TO)",) $(if $(PHONE),-phone "$(PHONE)",) $(if $(MOCK),-mock,) +Or use docker (no install): K6='docker run --rm -i --network host -v $$PWD:/app -w /app grafana/k6:latest' make k6-smoke +endef +export K6_INSTALL_HINT -totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證(Google Authenticator;需 Redis) - $(GO) run ./cmd/totp-test -f etc/gateway.dev.yaml \ - $(if $(TENANT),-tenant "$(TENANT)",) $(if $(UID),-uid "$(UID)",) \ - $(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \ - $(if $(CODE),-code "$(CODE)",) +k6-check: ## 檢查 k6 是否安裝(沒裝會印 install 指引) + @command -v $(K6) >/dev/null 2>&1 || (echo "$$K6_INSTALL_HINT"; exit 1) + @echo "k6: $$($(K6) version 2>&1 | head -1)" -member-seed: setup-dev ## 建立 dev tenant + member(需 Mongo+Redis) - $(GO) run ./cmd/member-seed -f etc/gateway.dev.yaml \ - $(if $(TENANT),-tenant "$(TENANT)",) $(if $(EMAIL),-email "$(EMAIL)",) +k6-up: ## 起 k6 全棧(mongo + redis + mailhog + postgres + zitadel) + $(COMPOSE) --profile k6 up -d mongo redis mailhog postgres zitadel + @echo "ZITADEL bootstrapping (this can take 30–90s the first time)…" + @echo "→ run 'make k6-wait' to block until it is ready" -config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入 - $(GO) test ./internal/config/ -run TestLoadGatewayYAML -v +k6-wait: ## 等 ZITADEL ready + 把 PAT 寫到 deploy/zitadel/machinekey/k6.env + @echo "waiting for ZITADEL at $(ZITADEL_HEALTH_URL)…" + @for i in $$(seq 1 120); do \ + if curl -fsS $(ZITADEL_HEALTH_URL) >/dev/null 2>&1; then \ + echo "zitadel ready ($$i s)"; break; \ + fi; \ + sleep 1; \ + if [ $$i -eq 120 ]; then echo "zitadel did not become ready in 120s"; exit 1; fi; \ + done + @for i in $$(seq 1 30); do \ + if [ -s "$(K6_PAT_FILE)" ]; then break; fi; \ + sleep 1; \ + done + @if [ ! -s "$(K6_PAT_FILE)" ]; then \ + echo "PAT file $(K6_PAT_FILE) missing — check 'docker logs gateway-zitadel'"; \ + exit 1; \ + fi + @PAT=$$(tr -d '\n' < $(K6_PAT_FILE)); \ + printf 'export ZITADEL_SERVICE_TOKEN=%s\nexport BASE_URL=http://localhost:8888\nexport MAILHOG_URL=http://localhost:8025\nexport REDIS_ADDR=localhost:6379\n' "$$PAT" > $(K6_ENV_FILE); \ + echo "wrote $(K6_ENV_FILE)" + @$(MAKE) -s k6-seed-fixtures + @echo "tip: 'source $(K6_ENV_FILE)' to load into your shell" + +# k6-seed-fixtures idempotently upserts the k6-tenant + K6INVITE invite code +# into gateway_k6 so /auth/register resolves successfully. The invite hash is +# sha256("K6INVITE") — see internal/model/auth/domain/const.go HashInviteCode. +# Uses mongosh inside the container; no extra Go binary needed. +K6_TENANT_ID := k6-tenant +K6_INVITE_HASH := e62a291f0dcf88c50c91fdc78e3539c55e1e20ef645c640b1ec778fe4fabb4cb +k6-seed-fixtures: ## upsert k6-tenant + K6INVITE 到 mongo + @docker exec gateway-mongo mongosh --quiet --eval '\ +db = db.getSiblingDB("gateway_k6"); \ +var now = NumberLong(Date.now()); \ +var t = db.tenants.updateOne( \ + {tenant_id: "$(K6_TENANT_ID)"}, \ + {$$set: {tenant_id: "$(K6_TENANT_ID)", slug: "$(K6_TENANT_ID)", name: "k6 Tenant", uid_prefix: "K6", status: "active", update_at: now}, $$setOnInsert: {create_at: now}}, \ + {upsert: true} \ +); \ +var i = db.invite_codes.updateOne( \ + {tenant_id: "$(K6_TENANT_ID)", code_hash: "$(K6_INVITE_HASH)"}, \ + {$$set: {tenant_id: "$(K6_TENANT_ID)", code_hash: "$(K6_INVITE_HASH)", max_uses: NumberLong(1000000), expires_at: NumberLong(0), new_users_only: false, update_at: now}, $$setOnInsert: {used_count: NumberLong(0), create_at: now}}, \ + {upsert: true} \ +); \ +print("tenant matched=" + t.matchedCount + " upserted=" + (t.upsertedId?1:0) + ", invite matched=" + i.matchedCount + " upserted=" + (i.upsertedId?1:0));' && \ + echo "seeded fixtures (tenant=$(K6_TENANT_ID) invite=K6INVITE) into gateway_k6" + +# Back-compat alias +k6-seed-tenant: k6-seed-fixtures ## (alias for k6-seed-fixtures) + +k6-build: ## 建 gateway binary 給 k6 使用 + @mkdir -p $(dir $(K6_GATEWAY_BIN)) + $(GO) build -o $(K6_GATEWAY_BIN) ./gateway.go + +k6-gateway: k6-build ## 前景啟 gateway(吃 etc/gateway.k6.yaml + ZITADEL env) + @if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi + @set -a; . $(K6_ENV_FILE); set +a; \ + $(K6_GATEWAY_BIN) -f $(K6_GATEWAY_CONFIG) + +k6-smoke: k6-check ## 跑 smoke 測試(每個端點一發) + @if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi + @set -a; . $(K6_ENV_FILE); set +a; \ + for f in $(K6_DIR)/smoke/*.js; do \ + echo "==> $$f"; $(K6) run "$$f" || exit 1; \ + done + +k6-journey: k6-check ## 跑 journey 測試(多步驟流程) + @if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi + @set -a; . $(K6_ENV_FILE); set +a; \ + for f in $(K6_DIR)/journeys/*.js; do \ + echo "==> $$f"; $(K6) run "$$f" || exit 1; \ + done + +k6-all: k6-smoke k6-journey ## smoke + journey + +k6-seed-admin: k6-build ## 註冊 k6-admin 並 seed tenant_admin role(rbac journey 用) + @if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi + @mkdir -p bin + $(GO) build -o bin/k6-seed-admin ./cmd/k6-seed-admin + @set -a; . $(K6_ENV_FILE); set +a; \ + ./bin/k6-seed-admin > $(K6_ENV_FILE).admin || (echo "k6-seed-admin failed"; exit 1); \ + cat $(K6_ENV_FILE).admin >> $(K6_ENV_FILE); \ + echo "admin credentials appended to $(K6_ENV_FILE):"; \ + cat $(K6_ENV_FILE).admin + +k6-down: ## 停 k6 stack 並清 volume(會清 Postgres / Mongo / Redis 資料) + $(COMPOSE) --profile k6 down -v + @rm -f $(K6_PAT_FILE) $(K6_ENV_FILE) $(K6_ENV_FILE).admin $(K6_ENV_FILE).tmp deploy/zitadel/machinekey/zitadel-admin-sa.json + @echo "k6 stack stopped, volumes & PAT removed" + +# ============================================================ +# 一鍵本機測試環境(Docker + Gateway + 種子資料) +# ============================================================ + +DEV_DIR := .dev +DEV_GATEWAY_PID := $(DEV_DIR)/gateway.pid +DEV_GATEWAY_LOG := $(DEV_DIR)/gateway.log + +.PHONY: dev-up dev-down dev-status dev-restart-gateway + +dev-up: k6-up k6-wait k6-build ## 一鍵起全套:mongo/redis/mailhog/zitadel + seed + Gateway 背景 + @mkdir -p $(DEV_DIR) + @if [ -f $(DEV_GATEWAY_PID) ] && kill -0 $$(cat $(DEV_GATEWAY_PID)) 2>/dev/null; then \ + echo "gateway already running (pid $$(cat $(DEV_GATEWAY_PID)))"; \ + else \ + set -a; . $(K6_ENV_FILE); set +a; \ + nohup $(K6_GATEWAY_BIN) -f $(K6_GATEWAY_CONFIG) > $(DEV_GATEWAY_LOG) 2>&1 & \ + echo $$! > $(DEV_GATEWAY_PID); \ + echo "gateway starting (pid $$(cat $(DEV_GATEWAY_PID)), log $(DEV_GATEWAY_LOG))…"; \ + for i in $$(seq 1 30); do \ + if curl -fsS http://localhost:8888/api/v1/health >/dev/null 2>&1; then \ + echo "gateway ready ($$i s)"; break; \ + fi; \ + sleep 1; \ + if [ $$i -eq 30 ]; then \ + echo "gateway did not become ready — tail $(DEV_GATEWAY_LOG)"; \ + tail -20 $(DEV_GATEWAY_LOG); exit 1; \ + fi; \ + done; \ + fi + @echo "" + @echo "==========================================" + @echo " 本機測試環境已就緒" + @echo "==========================================" + @echo " Gateway API http://localhost:8888" + @echo " 前端(另開終端) make frontend-dev → http://localhost:5173" + @echo " MailHog 收信 http://localhost:8025 (註冊 OTP 在這裡看)" + @echo " ZITADEL 主控台 http://localhost:8080/ui/console" + @echo "" + @echo " 註冊預設:租戶 k6-tenant · 邀請碼 K6INVITE" + @echo " Email 可填任意地址(例:you@test.com),OTP 不會進真實信箱" + @echo "" + @echo " 管理後台:make dev-seed-admin 後用輸出的帳密登入" + @echo " 關閉環境:make dev-down" + @echo " 查看狀態:make dev-status" + @echo "==========================================" + +dev-seed-admin: k6-seed-admin ## 建立具 tenant_admin 的管理員(dev-up 之後執行) + +dev-down: ## 停 Gateway 背景行程 + k6 docker stack + @if [ -f $(DEV_GATEWAY_PID) ]; then \ + pid=$$(cat $(DEV_GATEWAY_PID)); \ + if kill -0 $$pid 2>/dev/null; then kill $$pid && echo "stopped gateway (pid $$pid)"; fi; \ + rm -f $(DEV_GATEWAY_PID); \ + fi + @$(MAKE) -s k6-down + @rm -rf $(DEV_DIR) + @echo "dev environment stopped" + +dev-status: ## 顯示 docker / gateway / health 狀態 + @echo "=== docker (k6 profile) ===" + @$(COMPOSE) --profile k6 ps 2>/dev/null || true + @echo "" + @echo "=== gateway :8888 ===" + @if [ -f $(DEV_GATEWAY_PID) ] && kill -0 $$(cat $(DEV_GATEWAY_PID)) 2>/dev/null; then \ + echo "running pid $$(cat $(DEV_GATEWAY_PID))"; \ + curl -fsS http://localhost:8888/api/v1/health 2>/dev/null | head -c 200 || echo "(health check failed)"; \ + echo ""; \ + else \ + echo "not running (run: make dev-up)"; \ + fi + +dev-restart-gateway: k6-build ## 只重啟 Gateway(docker 不動) + @if [ -f $(DEV_GATEWAY_PID) ]; then \ + pid=$$(cat $(DEV_GATEWAY_PID)); \ + kill -0 $$pid 2>/dev/null && kill $$pid || true; \ + rm -f $(DEV_GATEWAY_PID); \ + fi + @mkdir -p $(DEV_DIR) + @set -a; . $(K6_ENV_FILE); set +a; \ + nohup $(K6_GATEWAY_BIN) -f $(K6_GATEWAY_CONFIG) > $(DEV_GATEWAY_LOG) 2>&1 & \ + echo $$! > $(DEV_GATEWAY_PID); \ + echo "gateway restarted (pid $$(cat $(DEV_GATEWAY_PID)))" + +# ============================================================ +# Frontend(使用者前台 + 管理後台) +# ============================================================ + +frontend-install: ## 安裝 frontend 依賴 + cd frontend && npm install + +frontend-dev: frontend-install ## 啟動前端 dev server(:5173,proxy /api → :8888) + cd frontend && npm run dev + +frontend-build: frontend-install ## 建置前端靜態檔 → frontend/dist/ + cd frontend && npm run build \ No newline at end of file diff --git a/README.md b/README.md index feea142..9786c6b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Portal API Gateway (PGW) +我# Portal API Gateway (PGW) 基於 [go-zero](https://github.com/zeromicro/go-zero) 的 API Gateway,提供統一 HTTP JSON 回應、8 碼業務錯誤碼,以及由 `.api` 定義驅動的程式碼與 OpenAPI 3.0 文件生成。 @@ -96,11 +96,15 @@ curl -s http://127.0.0.1:8888/api/v1/health | jq | `make run-dev` | 啟動 Gateway(`etc/gateway.dev.yaml`,需 Docker) | | `make config-check` | 驗證 yaml 可載入 | | `make mongo-index` | 建立 notification Mongo 索引 | +| `make dev-up` | **一鍵**本機全套(Docker + ZITADEL + MailHog + Gateway) | +| `make frontend-dev` | 啟動 React 前台 + 管理後台(`frontend/`,:5173) | +| `make dev-down` | 關閉 `dev-up` 啟動的一切 | ## 專案結構 ``` gateway/ +├── frontend/ # Vite + React 使用者前台與管理後台 ├── gateway.go # 程式入口 ├── etc/gateway.yaml # 服務設定(埠號等) ├── generate/ diff --git a/cmd/e2e-seed/main.go b/cmd/e2e-seed/main.go deleted file mode 100644 index 6508df8..0000000 --- a/cmd/e2e-seed/main.go +++ /dev/null @@ -1,245 +0,0 @@ -// Command e2e-seed prepares a fresh E2E tenant, member, permission roles, and JWT tokens. -// -// Usage (usually invoked by scripts/e2e-run.sh): -// -// go run ./cmd/e2e-seed -f test/e2e/fixtures/e2e.yaml -out test/e2e/fixtures/state.json -package main - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "os" - "time" - - "gateway/internal/config" - redislib "gateway/internal/library/redis" - domauth "gateway/internal/model/auth/domain/usecase" - authrepo "gateway/internal/model/auth/repository" - authusecase "gateway/internal/model/auth/usecase" - dommember "gateway/internal/model/member/domain/usecase" - memberusecase "gateway/internal/model/member/usecase" - "gateway/internal/model/permission/domain/enum" - domperm "gateway/internal/model/permission/domain/usecase" - permrepo "gateway/internal/model/permission/repository" - permseed "gateway/internal/model/permission/seed" - permusecase "gateway/internal/model/permission/usecase" - - "github.com/zeromicro/go-zero/core/conf" -) - -const ( - defaultTenantID = "e2e-tenant" - defaultSlug = "e2e" - defaultPrefix = "E2E" - defaultEmail = "e2e-owner@example.com" - defaultNoRoleEmail = "e2e-no-role@example.com" - defaultRoleKey = "tenant_owner" -) - -var ( - configFile = flag.String("f", "test/e2e/fixtures/e2e.yaml", "config file") - outFile = flag.String("out", "test/e2e/fixtures/state.json", "output fixture JSON") - tenantID = flag.String("tenant", defaultTenantID, "tenant_id") - slug = flag.String("slug", defaultSlug, "tenant slug") - uidPrefix = flag.String("prefix", defaultPrefix, "uid prefix") - email = flag.String("email", defaultEmail, "member email") - roleKey = flag.String("role", defaultRoleKey, "system role key to assign") -) - -// State is consumed by test/e2e HTTP tests. -type State struct { - BaseURL string `json:"base_url"` - TenantID string `json:"tenant_id"` - TenantSlug string `json:"tenant_slug"` - UID string `json:"uid"` - Email string `json:"email"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - RoleKey string `json:"role_key"` - NoRoleUID string `json:"no_role_uid"` - NoRoleEmail string `json:"no_role_email"` - NoRoleAccessToken string `json:"no_role_access_token"` - NoRoleRefreshToken string `json:"no_role_refresh_token"` -} - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run() error { - flag.Parse() - - var c config.Config - conf.MustLoad(*configFile, &c) - if c.Mongo.Host == "" || c.Redis.Host == "" { - return fmt.Errorf("e2e-seed: Mongo and Redis are required") - } - if !c.Auth.Defaults().Enabled() { - return fmt.Errorf("e2e-seed: Auth JWT secrets are required") - } - - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - - rds, err := redislib.NewClient(c.Redis) - if err != nil { - return fmt.Errorf("e2e-seed: redis: %w", err) - } - - memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ - Redis: rds, - MongoConf: &c.Mongo, - Config: c.Member, - }) - if err != nil { - return fmt.Errorf("e2e-seed: member module: %w", err) - } - if memberMod.Tenant == nil || memberMod.Lifecycle == nil || memberMod.Profile == nil { - return fmt.Errorf("e2e-seed: member module incomplete (need Mongo)") - } - - if _, err := memberMod.Tenant.Create(ctx, &dommember.CreateTenantRequest{ - TenantID: *tenantID, - Slug: *slug, - Name: "E2E Tenant", - UIDPrefix: *uidPrefix, - }); err != nil { - fmt.Printf("e2e-seed: tenant create skipped (may exist): %v\n", err) - } - - uid, err := ensureMember(ctx, memberMod, *tenantID, *email) - if err != nil { - return err - } - - if err := seedPermissionAndAssignRole(ctx, c, *tenantID, uid, *roleKey); err != nil { - return err - } - - noRoleUID, err := ensureMemberWithZitadel(ctx, memberMod, *tenantID, defaultNoRoleEmail, "e2e-no-role-sub", "E2E No Role") - if err != nil { - return err - } - - tokens := authusecase.MustTokenUseCase(authusecase.TokenUseCaseParam{ - Config: c.Auth, - Revoke: authrepo.NewRedisTokenRevokeStore(rds), - }) - pair, err := issueTokenPair(ctx, tokens, *tenantID, uid) - if err != nil { - return fmt.Errorf("e2e-seed: issue token: %w", err) - } - noRolePair, err := issueTokenPair(ctx, tokens, *tenantID, noRoleUID) - if err != nil { - return fmt.Errorf("e2e-seed: issue no-role token: %w", err) - } - - state := State{ - BaseURL: fmt.Sprintf("http://127.0.0.1:%d", c.Port), - TenantID: *tenantID, - TenantSlug: *slug, - UID: uid, - Email: *email, - AccessToken: pair.AccessToken, - RefreshToken: pair.RefreshToken, - RoleKey: *roleKey, - NoRoleUID: noRoleUID, - NoRoleEmail: defaultNoRoleEmail, - NoRoleAccessToken: noRolePair.AccessToken, - NoRoleRefreshToken: noRolePair.RefreshToken, - } - raw, err := json.MarshalIndent(state, "", " ") - if err != nil { - return fmt.Errorf("e2e-seed: marshal state: %w", err) - } - if err := os.WriteFile(*outFile, raw, 0o600); err != nil { - return fmt.Errorf("e2e-seed: write %s: %w", *outFile, err) - } - - fmt.Printf("e2e-seed: tenant=%s uid=%s role=%s\n", state.TenantID, state.UID, state.RoleKey) - fmt.Printf("e2e-seed: wrote %s (base_url=%s)\n", *outFile, state.BaseURL) - return nil -} - -func issueTokenPair(ctx context.Context, tokens domauth.TokenUseCase, tenantID, uid string) (*domauth.TokenPair, error) { - return tokens.IssuePair(ctx, &domauth.IssuePairRequest{ - TenantID: tenantID, - UID: uid, - }) -} - -func ensureMember(ctx context.Context, mod *memberusecase.Module, tenantID, email string) (string, error) { - return ensureMemberWithZitadel(ctx, mod, tenantID, email, "", "E2E Owner") -} - -func ensureMemberWithZitadel(ctx context.Context, mod *memberusecase.Module, tenantID, email, zitadelUserID, displayName string) (string, error) { - m, err := mod.Lifecycle.CreateUnverified(ctx, &dommember.CreatePlatformMemberRequest{ - TenantID: tenantID, - Email: email, - ZitadelUserID: zitadelUserID, - DisplayName: displayName, - Language: "zh-tw", - }) - if err == nil { - if actErr := mod.Lifecycle.Activate(ctx, tenantID, m.UID); actErr != nil { - return "", fmt.Errorf("e2e-seed: activate member: %w", actErr) - } - return m.UID, nil - } - - // Idempotent re-run: find existing member by listing (dev tenant has one owner). - list, listErr := mod.Profile.List(ctx, &dommember.ListMembersRequest{ - TenantID: tenantID, - Limit: 50, - }) - if listErr != nil { - return "", fmt.Errorf("e2e-seed: create member: %w (list fallback: %v)", err, listErr) - } - for _, item := range list.Items { - if item.ZitadelEmail == email || item.BusinessEmail == email { - return item.UID, nil - } - } - return "", fmt.Errorf("e2e-seed: create member: %w", err) -} - -func seedPermissionAndAssignRole(ctx context.Context, c config.Config, tenantID, uid, roleKey string) error { - perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: &c.Mongo}) - roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: &c.Mongo}) - rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: &c.Mongo}) - - if _, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{ - TenantIDs: []string{tenantID}, - }); err != nil { - return fmt.Errorf("e2e-seed: permission seed: %w", err) - } - - permMod, err := permusecase.NewModuleFromParam(permusecase.FactoryParam{ - MongoConf: &c.Mongo, - Redis: nil, - Config: c.Permission, - }) - if err != nil { - return fmt.Errorf("e2e-seed: permission module: %w", err) - } - - role, err := permMod.Role.GetByKey(ctx, tenantID, roleKey) - if err != nil { - return fmt.Errorf("e2e-seed: get role %q: %w", roleKey, err) - } - if _, err := permMod.UserRole.Assign(ctx, &domperm.AssignParam{ - TenantID: tenantID, - UID: uid, - RoleID: role.ID.Hex(), - Source: enum.RoleSourceManual, - }); err != nil { - // Idempotent re-run when role already assigned. - fmt.Printf("e2e-seed: assign role skipped: %v\n", err) - } - return nil -} diff --git a/cmd/k6-seed-admin/main.go b/cmd/k6-seed-admin/main.go new file mode 100644 index 0000000..a4910a0 --- /dev/null +++ b/cmd/k6-seed-admin/main.go @@ -0,0 +1,400 @@ +// k6-seed-admin bootstraps an admin user for the k6 rbac journey. +// +// Workflow (no external deps beyond gateway + MailHog + Mongo): +// 1. POST /api/v1/auth/register against the local gateway with a fixed +// admin email/password. +// 2. Poll MailHog HTTP API for the 6-digit OTP. +// 3. POST /api/v1/auth/register/confirm to receive a JWT (we don't keep it). +// 4. Connect to Mongo, seed the permission catalog + default system roles for +// the tenant via internal/model/permission/seed.Apply. +// 5. Insert a UserRole linking the new admin UID to the tenant_admin role. +// 6. Print ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_UID env exports to stdout so +// callers can `eval "$(make k6-seed-admin ...)"` or redirect into a file. +// +// Re-running is safe: register is idempotent at the OTP-confirm step (the +// challenge is fresh per call), and seed.Apply / UserRole insert are +// idempotent-by-key. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "time" + + libmongo "gateway/internal/library/mongo" + memberrepo "gateway/internal/model/member/repository" + permdomain "gateway/internal/model/permission/domain" + permentity "gateway/internal/model/permission/domain/entity" + permrepo "gateway/internal/model/permission/repository" + permseed "gateway/internal/model/permission/seed" + + "github.com/redis/go-redis/v9" + "github.com/zeromicro/go-zero/core/logx" + "go.mongodb.org/mongo-driver/v2/bson" +) + +var ( + flagBase = flag.String("base", envOr("BASE_URL", "http://localhost:8888"), "Gateway base URL") + flagMailhog = flag.String("mailhog", envOr("MAILHOG_URL", "http://localhost:8025"), "MailHog HTTP API URL") + flagTenant = flag.String("tenant", envOr("TENANT_SLUG", "k6-tenant"), "Tenant slug") + flagInvite = flag.String("invite", envOr("INVITE_CODE", "K6INVITE"), "Invite code") + // Default email is rotated per-invocation. Re-running seed-admin against + // a stable email would collide with the existing ZITADEL user (28303000 + // email already registered) since ZITADEL state lives outside docker + // volumes that `make k6-down` clears. Override with -email or ADMIN_EMAIL. + flagEmail = flag.String("email", envOr("ADMIN_EMAIL", fmt.Sprintf("k6-admin-%d@k6.local", time.Now().Unix())), "Admin email") + flagPassword = flag.String("password", envOr("ADMIN_PASSWORD", "K6-Admin-Pass-1!"), "Admin password") + flagMongoHost = flag.String("mongo-host", envOr("K6_MONGO_HOST", "127.0.0.1"), "Mongo host") + flagMongoPort = flag.Int("mongo-port", envOrInt("K6_MONGO_PORT", 27017), "Mongo port") + flagMongoDB = flag.String("mongo-db", envOr("K6_MONGO_DB", "gateway_k6"), "Mongo database") + flagTenantID = flag.String("tenant-id", envOr("ADMIN_TENANT_ID", ""), "Override resolved tenant_id (skip lookup)") + flagPollSecs = flag.Int("otp-timeout", 10, "MailHog OTP poll timeout (seconds)") + flagDryRun = flag.Bool("dry-run", false, "Skip Mongo writes; only test register flow") + flagRedisAddr = flag.String("redis-addr", envOr("REDIS_ADDR", "localhost:6379"), "Redis addr (host:port) for casbin reload broadcast") + flagReloadChannel = flag.String("reload-channel", envOr("CASBIN_RELOAD_CHANNEL", "casbin:reload:k6"), "Casbin reload Pub/Sub channel (must match gateway Permission.Reload.Channel)") +) + +func main() { + flag.Parse() + // go-zero's mongo helper logs every query via logx; in a CLI that pipes + // stdout to k6.env that pollutes the env file with JSON log lines. + // Disable logx entirely — we keep our own [k6-seed-admin] stderr logs. + logx.Disable() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + logf("registering admin %s @ %s", *flagEmail, *flagBase) + regResp, err := register(ctx) + if err != nil { + exitf("register: %v", err) + } + logf("challenge_id=%s uid=%s", regResp.ChallengeID, regResp.UID) + + code, err := pollOTP(ctx, *flagEmail, time.Duration(*flagPollSecs)*time.Second) + if err != nil { + exitf("poll OTP: %v", err) + } + logf("OTP=%s", code) + + tokens, err := confirm(ctx, regResp.ChallengeID, code) + if err != nil { + exitf("register/confirm: %v", err) + } + logf("registration confirmed; admin uid=%s access_token=%d chars", regResp.UID, len(tokens.AccessToken)) + + if *flagDryRun { + writeOutput(*flagEmail, *flagPassword, regResp.UID, "", tokens) + return + } + + mongoConf := &libmongo.Conf{ + Schema: "mongodb", + Host: *flagMongoHost, + Port: *flagMongoPort, + Database: *flagMongoDB, + } + + tenantID := *flagTenantID + if tenantID == "" { + t, err := resolveTenantID(ctx, mongoConf, *flagTenant) + if err != nil { + exitf("resolve tenant_id for slug=%s: %v", *flagTenant, err) + } + tenantID = t + } + logf("tenant_id=%s", tenantID) + + if err := seedRoles(ctx, mongoConf, tenantID); err != nil { + exitf("seed roles: %v", err) + } + roleID, err := assignAdmin(ctx, mongoConf, tenantID, regResp.UID) + if err != nil { + exitf("assign tenant_admin: %v", err) + } + logf("tenant_admin role_id=%s assigned", roleID) + + // Casbin lives in process memory inside the gateway and only reloads + // from Mongo when it boots or when something publishes on the reload + // channel. seed-admin runs AFTER the gateway started, so without this + // broadcast the admin's tenant_admin assignment is invisible until a + // restart and the rbac journey 403s on the very first /roles call. + if err := broadcastReload(ctx, *flagRedisAddr, *flagReloadChannel, tenantID); err != nil { + logf("warn: casbin reload broadcast failed (rbac journey may 403 until gateway restart): %v", err) + } else { + logf("casbin policy reload broadcast on %s channel=%s", *flagRedisAddr, *flagReloadChannel) + } + // Pub/Sub is best-effort; give the subscriber a beat to LoadPolicy + // before callers (e.g. make k6-journey) hit /roles. + time.Sleep(500 * time.Millisecond) + + writeOutput(*flagEmail, *flagPassword, regResp.UID, tenantID, tokens) +} + +// broadcastReload publishes a casbin reload event on the same Redis channel +// the gateway subscribes to (see internal/model/permission/usecase +// /rbac_usecase.go::BroadcastReload). Payload shape mirrors that function. +func broadcastReload(ctx context.Context, addr, channel, tenantID string) error { + if addr == "" { + return fmt.Errorf("redis addr empty") + } + if channel == "" { + channel = permdomain.PolicyReloadChannel + } + if tenantID == "" { + tenantID = permdomain.PolicyReloadAllToken + } + rdb := redis.NewClient(&redis.Options{Addr: addr}) + defer func() { _ = rdb.Close() }() + payload, _ := json.Marshal(map[string]any{ + "tenant_id": tenantID, + "ts": time.Now().UnixMilli(), + }) + pubCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + return rdb.Publish(pubCtx, channel, payload).Err() +} + +// ---------- HTTP / API helpers ---------- + +type registerResp struct { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` + UID string `json:"uid"` +} + +type confirmResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + UID string `json:"uid"` + TokenType string `json:"token_type"` +} + +type envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +func register(ctx context.Context) (*registerResp, error) { + body, _ := json.Marshal(map[string]any{ + "tenant_slug": *flagTenant, + "invite_code": *flagInvite, + "email": *flagEmail, + "password": *flagPassword, + "display_name": "k6 admin", + "language": "zh-TW", + "accept_terms_version": "2025-01-01", + "marketing_opt_in": false, + }) + env, err := doJSON(ctx, "POST", *flagBase+"/api/v1/auth/register", body) + if err != nil { + return nil, err + } + var r registerResp + if err := json.Unmarshal(env.Data, &r); err != nil { + return nil, fmt.Errorf("decode register data: %w", err) + } + return &r, nil +} + +func confirm(ctx context.Context, challengeID, code string) (*confirmResp, error) { + body, _ := json.Marshal(map[string]any{ + "tenant_slug": *flagTenant, + "challenge_id": challengeID, + "code": code, + }) + env, err := doJSON(ctx, "POST", *flagBase+"/api/v1/auth/register/confirm", body) + if err != nil { + return nil, err + } + var r confirmResp + if err := json.Unmarshal(env.Data, &r); err != nil { + return nil, fmt.Errorf("decode confirm data: %w", err) + } + return &r, nil +} + +func doJSON(ctx context.Context, method, url string, body []byte) (*envelope, error) { + req, err := http.NewRequestWithContext(ctx, method, url, strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, fmt.Errorf("decode envelope: %w (body=%s)", err, raw) + } + if env.Code != 102000 { + return nil, fmt.Errorf("non-success code=%d message=%s", env.Code, env.Message) + } + return &env, nil +} + +var ( + otpRegex = regexp.MustCompile(`\b(\d{6})\b`) + cssHexRe = regexp.MustCompile(`#[0-9a-fA-F]{6}\b`) + qpSoftLine = regexp.MustCompile(`=\r?\n`) +) + +// extractOTP returns the LAST 6-digit number in the body after stripping +// CSS hex colors (e.g. #059669) and quoted-printable soft line breaks. +// Email bodies render the OTP in a styled span near the bottom; the naive +// "first 6-digit" approach picks up brand colors. +func extractOTP(body string) string { + cleaned := cssHexRe.ReplaceAllString(qpSoftLine.ReplaceAllString(body, ""), "") + matches := otpRegex.FindAllStringSubmatch(cleaned, -1) + if len(matches) == 0 { + return "" + } + return matches[len(matches)-1][1] +} + +type mailhogItem struct { + Created string `json:"Created"` + Content struct { + Body string `json:"Body"` + } `json:"Content"` +} + +type mailhogList struct { + Items []mailhogItem `json:"items"` +} + +func pollOTP(ctx context.Context, email string, timeout time.Duration) (string, error) { + deadline := time.Now().Add(timeout) + url := fmt.Sprintf("%s/api/v2/search?kind=to&query=%s&start=0&limit=5", *flagMailhog, email) + for time.Now().Before(deadline) { + req, _ := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) + resp, err := http.DefaultClient.Do(req) + if err == nil && resp.StatusCode == 200 { + raw, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + var list mailhogList + if json.Unmarshal(raw, &list) == nil { + for _, it := range list.Items { + if code := extractOTP(it.Content.Body); code != "" { + return code, nil + } + } + } + } else if resp != nil { + _ = resp.Body.Close() + } + time.Sleep(300 * time.Millisecond) + } + return "", fmt.Errorf("OTP not seen within %s", timeout) +} + +// ---------- Mongo helpers ---------- + +func resolveTenantID(ctx context.Context, conf *libmongo.Conf, slug string) (string, error) { + repo := memberrepo.NewTenantRepository(memberrepo.TenantRepositoryParam{Conf: conf}) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + t, err := repo.GetBySlug(ctx, slug) + if err == nil && t != nil && t.TenantID != "" { + return t.TenantID, nil + } + time.Sleep(200 * time.Millisecond) + } + // Fallback: treat the slug itself as the tenant id (works for gateways + // that use slug == tenant_id, e.g. dev seed). + return slug, nil +} + +func seedRoles(ctx context.Context, conf *libmongo.Conf, tenantID string) error { + perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: conf}) + roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: conf}) + rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: conf}) + rpt, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{ + TenantIDs: []string{tenantID}, + }) + if err != nil { + return err + } + logf("seed report: catalog=%d roles=%d role_perms=%d", rpt.CatalogUpserted, rpt.RolesUpserted, rpt.RolePermissionSet) + return nil +} + +func assignAdmin(ctx context.Context, conf *libmongo.Conf, tenantID, uid string) (string, error) { + roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: conf}) + role, err := roles.GetByKey(ctx, tenantID, "tenant_admin") + if err != nil || role == nil { + return "", fmt.Errorf("tenant_admin role not found for tenant=%s: %w", tenantID, err) + } + urRepo := permrepo.NewUserRoleRepository(permrepo.UserRoleRepositoryParam{Conf: conf}) + if err := urRepo.Insert(ctx, &permentity.UserRole{ + ID: bson.NewObjectID(), + TenantID: tenantID, + UID: uid, + RoleID: role.ID.Hex(), + }); err != nil { + // duplicate-key is OK (idempotent re-run) + if !strings.Contains(err.Error(), "duplicate") { + return "", err + } + } + return role.ID.Hex(), nil +} + +// ---------- util ---------- + +func writeOutput(email, password, uid, tenantID string, tokens *confirmResp) { + fmt.Printf("export ADMIN_EMAIL=%s\n", email) + fmt.Printf("export ADMIN_PASSWORD=%s\n", password) + fmt.Printf("export ADMIN_UID=%s\n", uid) + if tenantID != "" { + fmt.Printf("export ADMIN_TENANT_ID=%s\n", tenantID) + } + if tokens != nil && tokens.AccessToken != "" { + // k6 journeys (rbac_admin.js) prefer these over POST /auth/login, + // since ZITADEL v2 disables the OAuth password grant by default and + // the gateway's /auth/login → VerifyPassword path then 502s. + fmt.Printf("export ADMIN_ACCESS_TOKEN=%s\n", tokens.AccessToken) + fmt.Printf("export ADMIN_REFRESH_TOKEN=%s\n", tokens.RefreshToken) + } +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func envOrInt(k string, def int) int { + if v := os.Getenv(k); v != "" { + var n int + if _, err := fmt.Sscanf(v, "%d", &n); err == nil { + return n + } + } + return def +} + +func logf(format string, a ...any) { + fmt.Fprintf(os.Stderr, "[k6-seed-admin] "+format+"\n", a...) +} + +func exitf(format string, a ...any) { + logf(format, a...) + os.Exit(1) +} diff --git a/cmd/member-seed/main.go b/cmd/member-seed/main.go deleted file mode 100644 index fbd7f7b..0000000 --- a/cmd/member-seed/main.go +++ /dev/null @@ -1,85 +0,0 @@ -// Command member-seed creates a dev tenant and member for local API testing. -// -// make deps-up && make mongo-index && make member-seed -package main - -import ( - "context" - "flag" - "fmt" - "os" - - "gateway/internal/config" - redislib "gateway/internal/library/redis" - domusecase "gateway/internal/model/member/domain/usecase" - memberusecase "gateway/internal/model/member/usecase" - - "github.com/zeromicro/go-zero/core/conf" -) - -var ( - configFile = flag.String("f", "etc/gateway.dev.yaml", "config file") - tenantID = flag.String("tenant", "dev-tenant", "tenant_id") - slug = flag.String("slug", "dev", "tenant slug") - uidPrefix = flag.String("prefix", "DEV", "uid prefix") - email = flag.String("email", "dev@example.com", "member email") -) - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run() error { - flag.Parse() - var c config.Config - conf.MustLoad(*configFile, &c) - if c.Mongo.Host == "" || c.Redis.Host == "" { - return fmt.Errorf("member-seed: Mongo and Redis are required") - } - - ctx := context.Background() - rds, err := redislib.NewClient(c.Redis) - if err != nil { - return fmt.Errorf("member-seed: redis: %w", err) - } - mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ - Redis: rds, - MongoConf: &c.Mongo, - Config: c.Member, - }) - if err != nil { - return fmt.Errorf("member-seed: module: %w", err) - } - if mod.Tenant == nil || mod.Lifecycle == nil { - return fmt.Errorf("member-seed: tenant/lifecycle not wired (need Mongo)") - } - - if _, err := mod.Tenant.Create(ctx, &domusecase.CreateTenantRequest{ - TenantID: *tenantID, - Slug: *slug, - Name: "Dev Tenant", - UIDPrefix: *uidPrefix, - }); err != nil { - fmt.Printf("tenant create skipped (may exist): %v\n", err) - } - - m, err := mod.Lifecycle.CreateUnverified(ctx, &domusecase.CreatePlatformMemberRequest{ - TenantID: *tenantID, - Email: *email, - DisplayName: "Dev User", - Language: "zh-tw", - }) - if err != nil { - return fmt.Errorf("member-seed: create member: %w", err) - } - if err := mod.Lifecycle.Activate(ctx, *tenantID, m.UID); err != nil { - return fmt.Errorf("member-seed: activate: %w", err) - } - - fmt.Printf("tenant_id=%s uid=%s\n", *tenantID, m.UID) - fmt.Printf("Use headers: X-Tenant-ID=%s X-UID=%s\n", *tenantID, m.UID) - return nil -} diff --git a/cmd/mongo-index/main.go b/cmd/mongo-index/main.go deleted file mode 100644 index f9df59a..0000000 --- a/cmd/mongo-index/main.go +++ /dev/null @@ -1,62 +0,0 @@ -// Command mongo-index ensures Gateway MongoDB indexes exist. -package main - -import ( - "context" - "flag" - "fmt" - "os" - "time" - - "gateway/internal/config" - authrepo "gateway/internal/model/auth/repository" - memberrepo "gateway/internal/model/member/repository" - notifrepo "gateway/internal/model/notification/repository" - permrepo "gateway/internal/model/permission/repository" - - "github.com/zeromicro/go-zero/core/conf" -) - -var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file") - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run() error { - flag.Parse() - - var c config.Config - conf.MustLoad(*configFile, &c) - if c.Mongo.Host == "" { - return fmt.Errorf("mongo-index: Mongo.Host is empty in config") - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - notifRepo := notifrepo.NewNotificationRepository(notifrepo.NotificationRepositoryParam{Conf: &c.Mongo}) - dlqRepo := notifrepo.NewNotificationDLQRepository(notifrepo.NotificationDLQRepositoryParam{Conf: &c.Mongo}) - - if err := notifRepo.Index20260520001UP(ctx); err != nil { - return fmt.Errorf("mongo-index: notifications: %w", err) - } - if err := dlqRepo.Index20260520001UP(ctx); err != nil { - return fmt.Errorf("mongo-index: notification_dlq: %w", err) - } - if err := memberrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil { - return fmt.Errorf("mongo-index: member: %w", err) - } - if err := authrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil { - return fmt.Errorf("mongo-index: auth: %w", err) - } - if err := permrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil { - return fmt.Errorf("mongo-index: permission: %w", err) - } - - fmt.Println("mongo-index: notifications + notification_dlq + member + auth + permission indexes OK") - return nil -} diff --git a/cmd/notify-test/main.go b/cmd/notify-test/main.go deleted file mode 100644 index ddcb0d6..0000000 --- a/cmd/notify-test/main.go +++ /dev/null @@ -1,487 +0,0 @@ -// Command notify-test runs one notification test by -method (point-and-shoot). -// -// make deps-up && make mongo-index -// make notify-test METHOD=email-send TO=you@example.com -// make notify-test METHOD=sms-send PHONE=0912345678 MOCK=1 -package main - -import ( - "context" - "flag" - "fmt" - "os" - "strings" - "time" - - "gateway/internal/config" - redislib "gateway/internal/library/redis" - memberenum "gateway/internal/model/member/domain/enum" - dommember "gateway/internal/model/member/domain/usecase" - memberusecase "gateway/internal/model/member/usecase" - notifconfig "gateway/internal/model/notification/config" - "gateway/internal/model/notification/domain/enum" - domtpl "gateway/internal/model/notification/domain/template" - domusecase "gateway/internal/model/notification/domain/usecase" - notifusecase "gateway/internal/model/notification/usecase" - - "github.com/google/uuid" - "github.com/zeromicro/go-zero/core/conf" -) - -const ( - methodEmailSend = "email-send" - methodEmailEnqueue = "email-enqueue" - methodEmailIdempotency = "email-idempotency" - methodSMSSend = "sms-send" - methodSMSEnqueue = "sms-enqueue" - methodMemberEmail = "member-email" - methodMemberPhone = "member-phone" - methodAdminDLQ = "admin-dlq" -) - -var validMethods = []string{ - methodEmailSend, - methodEmailEnqueue, - methodEmailIdempotency, - methodSMSSend, - methodSMSEnqueue, - methodMemberEmail, - methodMemberPhone, - methodAdminDLQ, -} - -var ( - configFile = flag.String("f", "etc/gateway.dev.yaml", "config file") - method = flag.String("method", "", "test method (required): "+strings.Join(validMethods, ", ")) - toEmail = flag.String("to", "", "recipient email") - phone = flag.String("phone", "", "recipient phone") - tenantID = flag.String("tenant", "notify-test", "tenant_id") - uid = flag.String("uid", "notify-test-uid", "uid") - mockOnly = flag.Bool("mock", false, "force mock email/SMS providers") - pollSec = flag.Int("poll", 45, "max seconds to wait for async delivery (enqueue methods)") -) - -type env struct { - ctx context.Context - tenant string - uid string - to string - phone string - locale string - notifier domusecase.NotifierUseCase - // otp is the atomic primitive; this CLI plays the role of the future - // logic layer and orchestrates OTP.Generate + Notifier.Send inline. - otp dommember.OTPUseCase - admin domusecase.AdminNotifierUseCase -} - -func main() { - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: notify-test -method [options]\n\n") - fmt.Fprintf(os.Stderr, "Methods:\n") - for _, m := range validMethods { - fmt.Fprintf(os.Stderr, " %s\n", m) - } - fmt.Fprintf(os.Stderr, "\nExamples:\n") - fmt.Fprintf(os.Stderr, " notify-test -method email-send -to you@example.com\n") - fmt.Fprintf(os.Stderr, " notify-test -method email-enqueue -to you@example.com\n") - fmt.Fprintf(os.Stderr, " notify-test -method sms-send -phone 0912345678\n") - fmt.Fprintf(os.Stderr, " notify-test -method member-email -to you@example.com\n") - fmt.Fprintf(os.Stderr, " notify-test -method admin-dlq\n") - fmt.Fprintf(os.Stderr, " notify-test -method email-send -to t@e.com -mock\n") - flag.PrintDefaults() - } - flag.Parse() - - code, err := run() - if err != nil { - fmt.Fprintln(os.Stderr, err) - } - if code != 0 { - os.Exit(code) - } -} - -// run wires the requested method and returns (exitCode, error). Deferred -// cleanups inside run always execute before main calls os.Exit. -func run() (int, error) { - m := strings.TrimSpace(*method) - if m == "" { - flag.Usage() - return 2, fmt.Errorf("notify-test: -method is required") - } - if !isValidMethod(m) { - flag.Usage() - return 2, fmt.Errorf("notify-test: unknown method %q", m) - } - if err := validateArgs(m); err != nil { - return 2, fmt.Errorf("notify-test: %w", err) - } - - var c config.Config - conf.MustLoad(*configFile, &c) - if c.Mongo.Host == "" { - return 1, fmt.Errorf("notify-test: Mongo.Host is empty") - } - if c.Redis.Host == "" { - return 1, fmt.Errorf("notify-test: Redis.Host is empty") - } - if c.Notification.Email.From == "" && needsEmailFrom(m) { - return 1, fmt.Errorf("notify-test: Notification.Email.From is empty") - } - if *mockOnly { - forceMock(&c.Notification) - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*pollSec+60)*time.Second) - defer cancel() - - rds, err := redislib.NewClient(c.Redis) - if err != nil { - return 1, fmt.Errorf("notify-test: redis: %w", err) - } - - mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{ - MongoConf: &c.Mongo, - Redis: rds, - Config: c.Notification, - }) - if err != nil { - return 1, fmt.Errorf("notify-test: notification: %w", err) - } - - var otpUC dommember.OTPUseCase - if m == methodMemberEmail || m == methodMemberPhone { - memberMod, memErr := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ - Redis: rds, - Config: c.Member, - }) - if memErr != nil { - return 1, fmt.Errorf("notify-test: member: %w", memErr) - } - otpUC = memberMod.OTP - } - - e := &env{ - ctx: ctx, - tenant: *tenantID, - uid: *uid, - to: *toEmail, - phone: *phone, - locale: c.Notification.DefaultLocale, - notifier: mod.Notifier, - otp: otpUC, - admin: mod.Admin, - } - - if m == methodEmailEnqueue || m == methodSMSEnqueue { - if mod.RetryWorker == nil { - return 1, fmt.Errorf("notify-test: retry worker not configured (need Redis)") - } - workerCtx, stop := context.WithCancel(context.Background()) - go mod.RetryWorker.Run(workerCtx) - defer stop() - } - - fmt.Printf("method=%s email=%s sms=%s\n", m, strings.Join(emailProviders(&c.Notification), ","), strings.Join(smsProviders(&c.Notification), ",")) - - if runErr := runMethod(e, m); runErr != nil { - return 1, fmt.Errorf("FAIL: %w", runErr) - } - fmt.Println("OK") - return 0, nil -} - -func runMethod(e *env, m string) error { - switch m { - case methodEmailSend: - return e.emailSend() - case methodEmailEnqueue: - return e.emailEnqueue() - case methodEmailIdempotency: - return e.emailIdempotency() - case methodSMSSend: - return e.smsSend() - case methodSMSEnqueue: - return e.smsEnqueue() - case methodMemberEmail: - return e.memberEmail() - case methodMemberPhone: - return e.memberPhone() - case methodAdminDLQ: - return e.adminDLQ() - default: - return fmt.Errorf("unhandled method %q", m) - } -} - -func (e *env) emailSend() error { - dto, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ - TenantID: e.tenant, - UID: e.uid, - Channel: enum.ChannelEmail, - Kind: enum.NotifyVerifyEmail, - Target: e.to, - Locale: e.locale, - Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300}, - IdempotencyKey: uuid.NewString(), - DoNotPersistBody: true, - Severity: enum.SeverityInfo, - }) - return reportSent(dto, err) -} - -func (e *env) emailEnqueue() error { - pending, err := e.notifier.Enqueue(e.ctx, &domusecase.SendRequest{ - TenantID: e.tenant, - UID: e.uid, - Channel: enum.ChannelEmail, - Kind: enum.NotifyTenantWelcome, - Target: e.to, - Locale: e.locale, - Data: map[string]any{"tenant_name": "Test Corp"}, - IdempotencyKey: uuid.NewString(), - DoNotPersistBody: false, - Severity: enum.SeverityInfo, - }) - if err != nil { - return err - } - final, err := waitSent(e.ctx, e.notifier, e.tenant, pending.ID, time.Duration(*pollSec)*time.Second) - if err != nil { - return err - } - fmt.Printf("notification_id=%s provider=%s status=%s\n", final.ID, final.Provider, final.Status) - return nil -} - -func (e *env) emailIdempotency() error { - key := uuid.NewString() - first, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ - TenantID: e.tenant, - UID: e.uid, - Channel: enum.ChannelEmail, - Kind: enum.NotifyVerifyEmail, - Target: e.to, - Locale: e.locale, - Data: map[string]any{domtpl.VarCode: "111111", domtpl.VarExpiresIn: 300}, - IdempotencyKey: key, - Severity: enum.SeverityInfo, - }) - if err != nil { - return err - } - second, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ - TenantID: e.tenant, - UID: e.uid, - Channel: enum.ChannelEmail, - Kind: enum.NotifyVerifyEmail, - Target: e.to, - Locale: e.locale, - Data: map[string]any{domtpl.VarCode: "222222", domtpl.VarExpiresIn: 300}, - IdempotencyKey: key, - Severity: enum.SeverityInfo, - }) - if err != nil { - return err - } - if first.ID != second.ID { - return fmt.Errorf("idempotency: expected same id, got %s vs %s", first.ID, second.ID) - } - fmt.Printf("notification_id=%s (replay ok)\n", first.ID) - return nil -} - -func (e *env) smsSend() error { - dto, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ - TenantID: e.tenant, - UID: e.uid, - Channel: enum.ChannelSMS, - Kind: enum.NotifyVerifyPhone, - Target: e.phone, - Locale: e.locale, - Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300}, - IdempotencyKey: uuid.NewString(), - DoNotPersistBody: true, - Severity: enum.SeverityInfo, - }) - return reportSent(dto, err) -} - -func (e *env) smsEnqueue() error { - pending, err := e.notifier.Enqueue(e.ctx, &domusecase.SendRequest{ - TenantID: e.tenant, - UID: e.uid, - Channel: enum.ChannelSMS, - Kind: enum.NotifyVerifyPhone, - Target: e.phone, - Locale: e.locale, - Data: map[string]any{domtpl.VarCode: "654321", domtpl.VarExpiresIn: 300}, - IdempotencyKey: uuid.NewString(), - Severity: enum.SeverityInfo, - }) - if err != nil { - return err - } - final, err := waitSent(e.ctx, e.notifier, e.tenant, pending.ID, time.Duration(*pollSec)*time.Second) - if err != nil { - return err - } - fmt.Printf("notification_id=%s provider=%s\n", final.ID, final.Provider) - return nil -} - -// memberEmail demonstrates the logic-layer orchestration: generate an OTP -// challenge (atomic) and dispatch the verification email through Notifier -// (atomic). usecases never call each other — this driver is what the real -// logic handler will look like. -func (e *env) memberEmail() error { - return e.startMemberVerify(memberenum.OTPPurposeBusinessEmail, enum.ChannelEmail, enum.NotifyVerifyEmail, e.to) -} - -func (e *env) memberPhone() error { - return e.startMemberVerify(memberenum.OTPPurposeBusinessPhone, enum.ChannelSMS, enum.NotifyVerifyPhone, e.phone) -} - -func (e *env) startMemberVerify(purpose memberenum.OTPPurpose, channel enum.Channel, kind enum.NotifyKind, target string) error { - if e.otp == nil { - return fmt.Errorf("member OTP usecase not configured") - } - if target == "" { - return fmt.Errorf("target is empty") - } - dto, code, err := e.otp.Generate(e.ctx, &dommember.GenerateOTPRequest{ - TenantID: e.tenant, - UID: e.uid, - Purpose: purpose, - Target: target, - }) - if err != nil { - return err - } - if _, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ - TenantID: e.tenant, - UID: e.uid, - Channel: channel, - Kind: kind, - Target: target, - Locale: e.locale, - Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn}, - IdempotencyKey: dto.ChallengeID, - DoNotPersistBody: true, - Severity: enum.SeverityInfo, - }); err != nil { - if invErr := e.otp.Invalidate(e.ctx, dto.ChallengeID); invErr != nil { - fmt.Fprintf(os.Stderr, "warn: invalidate otp after send failure: %v\n", invErr) - } - return err - } - fmt.Printf("challenge_id=%s expires_in=%d\n", dto.ChallengeID, dto.ExpiresIn) - return nil -} - -func (e *env) adminDLQ() error { - if e.admin == nil { - return fmt.Errorf("admin notifier not configured") - } - entries, err := e.admin.ListDLQ(e.ctx, e.tenant, 10) - if err != nil { - return err - } - fmt.Printf("dlq_count=%d\n", len(entries)) - return nil -} - -func reportSent(dto *domusecase.NotificationDTO, err error) error { - if err != nil { - return err - } - if dto.Status != enum.NotifyStatusSent { - return fmt.Errorf("status=%s last_error=%s", dto.Status, dto.LastError) - } - fmt.Printf("notification_id=%s provider=%s message_id=%s\n", dto.ID, dto.Provider, dto.ProviderMessageID) - return nil -} - -func waitSent(ctx context.Context, notifier domusecase.NotifierUseCase, tenantID, notificationID string, timeout time.Duration) (*domusecase.NotificationDTO, error) { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - dto, err := notifier.Get(ctx, tenantID, notificationID) - if err != nil { - return nil, err - } - switch dto.Status { - case enum.NotifyStatusSent: - return dto, nil - case enum.NotifyStatusFailed, enum.NotifyStatusDropped: - return dto, fmt.Errorf("status=%s: %s", dto.Status, dto.LastError) - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(500 * time.Millisecond): - } - } - return nil, fmt.Errorf("timeout after %s", timeout) -} - -func validateArgs(m string) error { - switch m { - case methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodMemberEmail: - if *toEmail == "" { - return fmt.Errorf("%s requires -to", m) - } - case methodSMSSend, methodSMSEnqueue, methodMemberPhone: - if *phone == "" { - return fmt.Errorf("%s requires -phone", m) - } - } - return nil -} - -func needsEmailFrom(m string) bool { - switch m { - case methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodMemberEmail: - return true - default: - return false - } -} - -func isValidMethod(m string) bool { - for _, v := range validMethods { - if v == m { - return true - } - } - return false -} - -func forceMock(cfg *notifconfig.Config) { - cfg.Email.SMTP.Enable = false - cfg.Email.SES.Enable = false - cfg.Email.Provider = notifconfig.ProviderMock - cfg.SMS.Mitake.Enable = false - cfg.SMS.Provider = notifconfig.ProviderMock -} - -func emailProviders(cfg *notifconfig.Config) []string { - var out []string - if cfg.Email.SMTP.Enable { - out = append(out, "smtp") - } - if cfg.Email.SES.Enable { - out = append(out, "ses") - } - if len(out) == 0 { - out = append(out, "mock") - } - return out -} - -func smsProviders(cfg *notifconfig.Config) []string { - if cfg.SMS.Mitake.Enable { - return []string{"mitake"} - } - return []string{"mock"} -} diff --git a/cmd/permission-seed/main.go b/cmd/permission-seed/main.go deleted file mode 100644 index 88946eb..0000000 --- a/cmd/permission-seed/main.go +++ /dev/null @@ -1,89 +0,0 @@ -// Command permission-seed upserts the platform-wide permission catalog -// and (optionally) seeds default system roles for one or more tenants. -// -// Usage: -// -// permission-seed -f etc/gateway.dev.yaml # catalog only -// permission-seed -f etc/gateway.dev.yaml -tenant TEN-001 # catalog + tenant roles -// permission-seed -f etc/gateway.dev.yaml -tenant t1,t2 -skip-catalog -// -// The seeder is idempotent: re-running only updates fields that changed -// in the embedded catalog. Default system roles (tenant_owner, etc.) -// always have is_system=true; their permission set is rewritten on each -// run so renaming a catalog entry propagates automatically. -package main - -import ( - "context" - "flag" - "fmt" - "os" - "strings" - "time" - - "gateway/internal/config" - permrepo "gateway/internal/model/permission/repository" - permseed "gateway/internal/model/permission/seed" - - "github.com/zeromicro/go-zero/core/conf" -) - -var ( - configFile = flag.String("f", "etc/gateway.dev.yaml", "config file") - tenantList = flag.String("tenant", "", "comma-separated tenant IDs to seed default system roles into") - skipCatalog = flag.Bool("skip-catalog", false, "skip platform-wide catalog upsert (only seed tenant roles)") -) - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run() error { - flag.Parse() - - var c config.Config - conf.MustLoad(*configFile, &c) - if c.Mongo.Host == "" { - return fmt.Errorf("permission-seed: Mongo.Host is empty in config") - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if err := permrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil { - return fmt.Errorf("permission-seed: ensure indexes: %w", err) - } - - perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: &c.Mongo}) - roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: &c.Mongo}) - rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: &c.Mongo}) - - tenantIDs := splitTenantIDs(*tenantList) - - report, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{ - TenantIDs: tenantIDs, - SkipCatalog: *skipCatalog, - }) - if err != nil { - return fmt.Errorf("permission-seed: apply: %w", err) - } - - fmt.Printf("permission-seed: catalog upserted=%d roles upserted=%d role-permission rows=%d tenants=%v\n", - report.CatalogUpserted, report.RolesUpserted, report.RolePermissionSet, tenantIDs) - return nil -} - -func splitTenantIDs(raw string) []string { - parts := strings.Split(raw, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) - } - } - return out -} diff --git a/cmd/totp-test/main.go b/cmd/totp-test/main.go deleted file mode 100644 index 4aceb95..0000000 --- a/cmd/totp-test/main.go +++ /dev/null @@ -1,330 +0,0 @@ -// Command totp-test runs an interactive TOTP enrollment and verification flow -// against local Redis + in-memory profile (single process). -// -// Prerequisites: -// -// make deps-up -// make setup-dev # ensure Member.TOTP.SecretKEK is set -// -// Usage: -// -// make totp-test -// go run ./cmd/totp-test -f etc/gateway.dev.yaml -package main - -import ( - "bufio" - "context" - "flag" - "fmt" - "net/url" - "os" - "strings" - - "gateway/internal/config" - redislib "gateway/internal/library/redis" - domusecase "gateway/internal/model/member/domain/usecase" - memberusecase "gateway/internal/model/member/usecase" - - "github.com/skip2/go-qrcode" - "github.com/zeromicro/go-zero/core/conf" -) - -const ( - stepFlow = "flow" - stepEnroll = "enroll" - stepConfirm = "confirm" - stepVerify = "verify" - stepStatus = "status" - stepDisable = "disable" -) - -var ( - configFile = flag.String("f", "etc/gateway.dev.yaml", "config file") - stepFlag = flag.String("step", stepFlow, "step: flow, enroll, confirm, verify, status, disable") - tenantID = flag.String("tenant", "totp-test", "tenant_id") - uidFlag = flag.String("uid", "totp-test-uid", "uid") - account = flag.String("account", "totp-test@example.com", "account label shown in Authenticator") - codeFlag = flag.String("code", "", "TOTP code (non-interactive confirm/verify)") - kekFlag = flag.String("kek", "", "override Member.TOTP.SecretKEK (hex or base64)") -) - -func main() { - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: totp-test [options]\n\n") - fmt.Fprintf(os.Stderr, "Interactive TOTP test for Google Authenticator / Authy.\n") - fmt.Fprintf(os.Stderr, "Default step=flow guides enroll → confirm → verify in one process.\n\n") - fmt.Fprintf(os.Stderr, "Examples:\n") - fmt.Fprintf(os.Stderr, " totp-test\n") - fmt.Fprintf(os.Stderr, " totp-test -step status\n") - fmt.Fprintf(os.Stderr, " totp-test -step confirm -code 482913\n") - flag.PrintDefaults() - } - flag.Parse() - - code, err := run() - if err != nil { - fmt.Fprintln(os.Stderr, err) - } - if code != 0 { - os.Exit(code) - } -} - -func run() (int, error) { - step := strings.TrimSpace(*stepFlag) - if step == "" { - step = stepFlow - } - - var c config.Config - conf.MustLoad(*configFile, &c) - if c.Redis.Host == "" { - return 1, fmt.Errorf("totp-test: Redis.Host is empty (run: make deps-up)") - } - - kek := strings.TrimSpace(*kekFlag) - if kek == "" { - kek = strings.TrimSpace(c.Member.TOTP.SecretKEK) - } - if kek == "" { - return 1, fmt.Errorf("totp-test: Member.TOTP.SecretKEK is empty; set it in %s or pass -kek", *configFile) - } - c.Member.TOTP.SecretKEK = kek - - ctx := context.Background() - rds, err := redislib.NewClient(c.Redis) - if err != nil { - return 1, fmt.Errorf("totp-test: redis: %w", err) - } - - mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ - Redis: rds, - Config: c.Member, - }) - if err != nil { - return 1, fmt.Errorf("totp-test: member: %w", err) - } - if mod.TOTP == nil { - return 1, fmt.Errorf("totp-test: TOTP usecase not wired (invalid SecretKEK?)") - } - - env := &session{ - ctx: ctx, - tenant: *tenantID, - uid: *uidFlag, - totp: mod.TOTP, - in: bufio.NewReader(os.Stdin), - } - - switch step { - case stepFlow: - if err := env.runFlow(); err != nil { - return 1, fmt.Errorf("FAIL: %w", err) - } - case stepEnroll: - if err := env.doEnroll(); err != nil { - return 1, fmt.Errorf("FAIL: %w", err) - } - case stepConfirm: - if err := env.doConfirm(strings.TrimSpace(*codeFlag)); err != nil { - return 1, fmt.Errorf("FAIL: %w", err) - } - case stepVerify: - if err := env.doVerify(strings.TrimSpace(*codeFlag)); err != nil { - return 1, fmt.Errorf("FAIL: %w", err) - } - case stepStatus: - if err := env.doStatus(); err != nil { - return 1, fmt.Errorf("FAIL: %w", err) - } - case stepDisable: - if err := env.doDisable(); err != nil { - return 1, fmt.Errorf("FAIL: %w", err) - } - default: - flag.Usage() - return 2, fmt.Errorf("totp-test: unknown step %q", step) - } - - fmt.Println("OK") - return 0, nil -} - -type session struct { - ctx context.Context - tenant string - uid string - totp domusecase.TOTPUseCase - in *bufio.Reader -} - -func (s *session) runFlow() error { - status, err := s.totp.Status(s.ctx, s.tenant, s.uid) - if err != nil { - return err - } - if status.Enrolled { - fmt.Println("Already enrolled for this tenant/uid.") - if err := s.doStatus(); err != nil { - return err - } - fmt.Println() - fmt.Println("Proceeding to verify-only (skip enroll/confirm).") - } else { - if err := s.doEnroll(); err != nil { - return err - } - fmt.Println() - confirmCode, err := s.readCode("Enter the 6-digit code from Google Authenticator to confirm enrollment: ") - if err != nil { - return err - } - if err := s.doConfirm(confirmCode); err != nil { - return err - } - fmt.Println() - fmt.Println("Wait for the Authenticator code to refresh (up to 30s), then verify step-up.") - } - - verifyCode, err := s.readCode("Enter a fresh 6-digit code to verify (step-up): ") - if err != nil { - return err - } - if err := s.doVerify(verifyCode); err != nil { - return err - } - - fmt.Println() - fmt.Println("Testing replay protection with the same code (should fail)...") - if err := s.doVerify(verifyCode); err == nil { - return fmt.Errorf("expected replay failure but verify succeeded") - } - fmt.Println("Replay correctly rejected.") - return nil -} - -func (s *session) doEnroll() error { - start, err := s.totp.StartEnroll(s.ctx, s.tenant, s.uid, *account) - if err != nil { - return err - } - - secret, err := secretFromOtpauthURL(start.OtpauthURL) - if err != nil { - return err - } - - fmt.Println("=== TOTP Enrollment ===") - fmt.Printf("tenant=%s uid=%s\n", s.tenant, s.uid) - fmt.Printf("issuer=%s account=%s digits=%d period=%ds expires_in=%ds\n", - start.Issuer, start.Account, start.Digits, start.PeriodSec, start.ExpiresIn) - fmt.Println() - fmt.Println("Option A — scan QR code with Google Authenticator:") - fmt.Println() - if err := printTerminalQR(start.OtpauthURL); err != nil { - fmt.Fprintf(os.Stderr, "warn: QR render failed: %v\n", err) - } - fmt.Println() - fmt.Println("Option B — enter setup key manually in Google Authenticator:") - fmt.Printf(" Type: Time based\n") - fmt.Printf(" Account: %s\n", start.Account) - fmt.Printf(" Issuer: %s\n", start.Issuer) - fmt.Printf(" Secret key: %s\n", secret) - fmt.Println() - fmt.Println("otpauth URL (for debugging):") - fmt.Println(start.OtpauthURL) - fmt.Println() - fmt.Printf("Complete enrollment within %d seconds.\n", start.ExpiresIn) - return nil -} - -func (s *session) doConfirm(code string) error { - if code == "" { - var err error - code, err = s.readCode("Enter the 6-digit code from Google Authenticator: ") - if err != nil { - return err - } - } - backup, err := s.totp.ConfirmEnroll(s.ctx, s.tenant, s.uid, code) - if err != nil { - return err - } - fmt.Println("Enrollment confirmed.") - fmt.Printf("backup_codes (%d, save these — shown once):\n", len(backup)) - for i, c := range backup { - fmt.Printf(" [%02d] %s\n", i+1, c) - } - return s.doStatus() -} - -func (s *session) doVerify(code string) error { - if code == "" { - var err error - code, err = s.readCode("Enter a 6-digit TOTP code (or backup code): ") - if err != nil { - return err - } - } - if err := s.totp.VerifyCode(s.ctx, s.tenant, s.uid, code); err != nil { - return err - } - fmt.Println("VerifyCode: success") - return nil -} - -func (s *session) doStatus() error { - status, err := s.totp.Status(s.ctx, s.tenant, s.uid) - if err != nil { - return err - } - fmt.Printf("status enrolled=%t backup_codes_remaining=%d", status.Enrolled, status.BackupCodesRemaining) - if status.Enrolled { - fmt.Printf(" enrolled_at=%d", status.EnrolledAt) - } - fmt.Println() - return nil -} - -func (s *session) doDisable() error { - if err := s.totp.Disable(s.ctx, s.tenant, s.uid); err != nil { - return err - } - fmt.Println("TOTP disabled.") - return s.doStatus() -} - -func (s *session) readCode(prompt string) (string, error) { - fmt.Print(prompt) - line, err := s.in.ReadString('\n') - if err != nil { - return "", fmt.Errorf("read code: %w", err) - } - code := strings.TrimSpace(line) - if code == "" { - return "", fmt.Errorf("code is empty") - } - return code, nil -} - -func secretFromOtpauthURL(raw string) (string, error) { - u, err := url.Parse(raw) - if err != nil { - return "", fmt.Errorf("parse otpauth url: %w", err) - } - secret := strings.TrimSpace(u.Query().Get("secret")) - if secret == "" { - return "", fmt.Errorf("otpauth url missing secret parameter") - } - return secret, nil -} - -func printTerminalQR(content string) error { - qr, err := qrcode.New(content, qrcode.Medium) - if err != nil { - return err - } - fmt.Print(qr.ToSmallString(false)) - return nil -} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..ddc0cf6 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,100 @@ +# 本機開發 / k6 測試依賴:MongoDB、Redis、MailHog、Postgres、ZITADEL +# +# 啟動: +# make deps-up → mongo + redis(最小,吃 etc/gateway.dev.yaml) +# make deps-up-smtp → + mailhog(profile smtp) +# make k6-up → mongo + redis + mailhog + postgres + zitadel(吃 etc/gateway.k6.yaml) +# +# ZITADEL admin PAT 會寫到 deploy/zitadel/machinekey/zitadel-admin-sa.token + +services: + mongo: + image: mongo:7 + container_name: gateway-mongo + restart: unless-stopped + ports: + - "27017:27017" + environment: + MONGO_INITDB_DATABASE: gateway + volumes: + - mongo_data:/data/db + - ./mongo/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + redis: + image: redis:7-alpine + container_name: gateway-redis + restart: unless-stopped + ports: + - "6379:6379" + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + mailhog: + profiles: ["smtp", "k6"] + image: mailhog/mailhog:v1.0.1 + container_name: gateway-mailhog + restart: unless-stopped + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI / HTTP API + + # ===== ZITADEL stack(profile k6)===== + postgres: + profiles: ["k6"] + image: postgres:17-alpine + container_name: gateway-postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: zitadel + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres", "-d", "zitadel"] + interval: 5s + timeout: 3s + retries: 20 + + zitadel: + profiles: ["k6"] + image: ghcr.io/zitadel/zitadel:v2.65.0 + container_name: gateway-zitadel + restart: unless-stopped + command: ["start-from-init", "--masterkey", "MasterkeyNeedsToHave32Characters", "--tlsMode", "disabled", "--config", "/etc/zitadel/zitadel.yaml", "--steps", "/etc/zitadel/steps.yaml"] + depends_on: + postgres: + condition: service_healthy + ports: + - "8080:8080" + volumes: + - ./zitadel/zitadel.yaml:/etc/zitadel/zitadel.yaml:ro + - ./zitadel/steps.yaml:/etc/zitadel/steps.yaml:ro + - ./zitadel/machinekey:/machinekey + healthcheck: + # zitadel image lacks wget/curl; use /proc/net/tcp to verify the port + # is in LISTEN state (10 = LISTEN). Cheap, no binary deps. + test: ["CMD-SHELL", "grep -q ':1F90 .* 0A' /proc/net/tcp || exit 1"] + interval: 5s + timeout: 3s + retries: 60 + start_period: 60s + +volumes: + mongo_data: + redis_data: + postgres_data: diff --git a/deploy/zitadel/.gitignore b/deploy/zitadel/.gitignore new file mode 100644 index 0000000..f9eecbe --- /dev/null +++ b/deploy/zitadel/.gitignore @@ -0,0 +1,3 @@ +# ZITADEL bootstrap outputs(PAT / machine key)— 不入 git +machinekey/zitadel-admin-sa.token +machinekey/zitadel-admin-sa.json diff --git a/deploy/zitadel/README.md b/deploy/zitadel/README.md new file mode 100644 index 0000000..67b4e39 --- /dev/null +++ b/deploy/zitadel/README.md @@ -0,0 +1,64 @@ +# ZITADEL(dev / k6) + +本機跑 k6 測試用的 ZITADEL stack(docker-compose `profile: k6`)。 + +## 啟動 + +```bash +make k6-up +``` + +會啟動 mongo / redis / mailhog / postgres / zitadel。 + +ZITADEL 首次啟動會 init Postgres schema 並執行 [steps.yaml](steps.yaml) 預載: +- Instance 名稱:`ZITADEL` +- Org:`GatewayDev` +- Admin 使用者:`zitadel-admin@zitadel.localhost` / `Password1!` +- Service Account:`zitadel-admin-sa`(產生 PAT 寫到 `machinekey/zitadel-admin-sa.token`) + +完成需 30~90 秒,可用 `make k6-wait` 等到 `/debug/healthz` 200。 + +## PAT 取用 + +```bash +cat deploy/zitadel/machinekey/zitadel-admin-sa.token +``` + +把這個值塞進 `etc/gateway.k6.yaml` 的 `Zitadel.ServiceUserToken`,或用環境變數: + +```bash +export ZITADEL_SERVICE_TOKEN=$(cat deploy/zitadel/machinekey/zitadel-admin-sa.token) +``` + +`make k6-gateway` 會自動做這件事。 + +## 密碼登入(`/auth/login`) + +ZITADEL v2 **預設停用** OAuth Resource Owner Password Grant(`unsupported_grant_type`)。 +本 repo 的 Gateway 在**未設定** `OAuthClientID` / `OAuthClientSecret` 時,會改用 **v2 Sessions API**(PAT)驗證密碼,無需額外建立 OIDC App。 + +若要在正式環境使用 ROPG,請自行建立 OIDC Application 並設定: + +```bash +export ZITADEL_OAUTH_CLIENT_ID=... +export ZITADEL_OAUTH_CLIENT_SECRET=... +``` + +## 重設 + +```bash +make k6-down # 停容器(保留 volume) +docker volume rm template-monorepo_postgres_data # 清 ZITADEL 資料 +rm deploy/zitadel/machinekey/zitadel-admin-sa.* # 清 PAT +``` + +## 端點 + +- Console UI:http://localhost:8080/ui/console +- OIDC issuer:http://localhost:8080 +- Management API:http://localhost:8080/management/v1 +- Health:http://localhost:8080/debug/healthz + +## 不可帶上 prod + +`MasterkeyNeedsToHave32Characters` 與 [steps.yaml](steps.yaml) 內的密碼都是固定 dev 值,**只能**本機用。 diff --git a/deploy/zitadel/machinekey/.gitkeep b/deploy/zitadel/machinekey/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/zitadel/machinekey/k6.env b/deploy/zitadel/machinekey/k6.env new file mode 100644 index 0000000..43fdc61 --- /dev/null +++ b/deploy/zitadel/machinekey/k6.env @@ -0,0 +1,4 @@ +export ZITADEL_SERVICE_TOKEN=KK234msGgYozMn56fEzHAjsf-lVm5qyoHCGR10ay1nGYUtN06RIxHtSq90Wtle9cuIVhXWg +export BASE_URL=http://localhost:8888 +export MAILHOG_URL=http://localhost:8025 +export REDIS_ADDR=localhost:6379 diff --git a/deploy/zitadel/steps.yaml b/deploy/zitadel/steps.yaml new file mode 100644 index 0000000..05d559c --- /dev/null +++ b/deploy/zitadel/steps.yaml @@ -0,0 +1,40 @@ +# ZITADEL FirstInstance bootstrap(dev / k6 用,固定憑證) +# 啟動完成後: +# deploy/zitadel/machinekey/zitadel-admin-sa.token ← 給 Gateway 當 ServiceUserToken +# deploy/zitadel/machinekey/zitadel-admin-sa.json ← 給 SDK 用的 JWT key(k6 不會用到) + +FirstInstance: + MachineKeyPath: /machinekey/zitadel-admin-sa.json + PatPath: /machinekey/zitadel-admin-sa.token + + InstanceName: ZITADEL + DefaultLanguage: en + + Org: + Name: GatewayDev + + Human: + UserName: zitadel-admin@zitadel.localhost + FirstName: ZITADEL + LastName: Admin + NickName: admin + DisplayName: Admin + PreferredLanguage: en + Email: + Address: admin@zitadel.localhost + Verified: true + Password: Password1! + PasswordChangeRequired: false + + Machine: + Machine: + Username: zitadel-admin-sa + Name: ServiceAccount + Description: Backend service account for Gateway (dev / k6) + Pat: + ExpirationDate: "2099-01-01T00:00:00Z" + Scopes: + - openid + MachineKey: + Type: 1 + ExpirationDate: "2099-01-01T00:00:00Z" diff --git a/deploy/zitadel/zitadel.yaml b/deploy/zitadel/zitadel.yaml new file mode 100644 index 0000000..79e4e12 --- /dev/null +++ b/deploy/zitadel/zitadel.yaml @@ -0,0 +1,41 @@ +# ZITADEL runtime config(dev / k6 用,TLS 關閉) +# 完整選項:https://zitadel.com/docs/self-hosting/manage/configure +ExternalDomain: localhost +ExternalPort: 8080 +ExternalSecure: false +TLS: + Enabled: false + +Port: 8080 + +Database: + postgres: + Host: postgres + Port: 5432 + Database: zitadel + MaxOpenConns: 20 + MaxIdleConns: 5 + MaxConnLifetime: 30m + MaxConnIdleTime: 5m + User: + Username: postgres + Password: postgres + SSL: + Mode: disable + Admin: + Username: postgres + Password: postgres + SSL: + Mode: disable + +Log: + Level: info + +DefaultInstance: + LoginPolicy: + AllowRegister: true + AllowUsernamePassword: true + AllowExternalIDP: false + ForceMFA: false + HidePasswordReset: false + PasswordlessType: 1 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 3bb4db5..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,52 +0,0 @@ -# 本機開發依賴:MongoDB(notification 持久化)、Redis(冪等/配額/異步重試/member OTP) -# -# 啟動:make deps-up -# 設定:etc/gateway.dev.yaml(搭配 make run-dev) -# 索引:首次啟動由 deploy/mongo/init 建立;既有 volume 可執行 make mongo-index - -services: - mongo: - image: mongo:7 - container_name: gateway-mongo - restart: unless-stopped - ports: - - "27017:27017" - environment: - MONGO_INITDB_DATABASE: gateway - volumes: - - mongo_data:/data/db - - ./deploy/mongo/init:/docker-entrypoint-initdb.d:ro - healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] - interval: 5s - timeout: 5s - retries: 10 - start_period: 10s - - redis: - image: redis:7-alpine - container_name: gateway-redis - restart: unless-stopped - ports: - - "6379:6379" - command: ["redis-server", "--appendonly", "yes"] - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 10 - - mailhog: - profiles: ["smtp"] - image: mailhog/mailhog:v1.0.1 - container_name: gateway-mailhog - restart: unless-stopped - ports: - - "1025:1025" # SMTP - - "8025:8025" # Web UI - -volumes: - mongo_data: - redis_data: diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..5991a35 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,97 @@ +# AGENTS.md + +給 AI coding agent(Claude / Cursor / Codex / 其他)的專案工作準則。請在開始任務前讀過一遍,並在需要時翻閱對應子文件。 + +## 專案簡介 + +`template-monorepo` 是基於 [go-zero](https://github.com/zeromicro/go-zero) 的 API Gateway,採用「**模組化 Clean Architecture**」:每個業務模組(auth / member / notification / permission ...)放在 `internal/model//`,內部分 `domain`(介面 + enum + errors) / `repository`(Mongo / Redis 實作) / `usecase`(原子業務邏輯) / `config`。 + +跨模組編排(例如「發 OTP → 寄信 → 驗碼 → 更新 profile」)一律放在 `internal/logic//`,**usecase 不可呼叫其他 usecase**。 + +## 必讀文件 + +| 文件 | 何時讀 | +|---|---| +| [`generate/api/README.md`](../generate/api/README.md) | 新增 / 修改 API 端點、type、文件分組、欄位描述、enum 列舉前 | +| [`generate/doc-generate/README.md`](../generate/doc-generate/README.md) | 需要查 go-doc 支援的 tag / `@respdoc` 寫法時 | +| `internal/model//README.md` | 動到該模組的領域邏輯時 | +| `docs/model.md`(若存在) | 全局架構規範 | + +## 標準工作流程 + +### 1. 修改 API(`.api` → handler / types / docs) + +1. 編輯 `generate/api/*.api`(遵守 `generate/api/README.md` 的三條規則:tags 分組 / backtick 行末 `//` 中文 description / `options=A|B|C` enum) +2. `make gen-api` — 重新產生 `internal/handler/`、`internal/logic/`(已存在則不覆蓋)、`internal/types/types.go` +3. `make gen-doc` — 重新產生 `docs/openapi/gateway.yaml`(gitignore,本地驗證用) +4. 實作 / 修改 `internal/logic//_logic.go` 的業務邏輯 +5. `go build ./...` 確保編譯通過 +6. `make lint` / `make test` 視改動範圍跑 + +### 2. 新增 / 修改業務模組 + +- 領域介面與型別放 `internal/model//domain/` +- Mongo / Redis 實作放 `internal/model//repository/` +- 原子 usecase 放 `internal/model//usecase/`(**不可**互相呼叫) +- 多步驟流程編排放 `internal/logic//` +- 模組的對外裝配入口統一在 `internal/model//usecase/module.go`,並從 `internal/svc/service_context.go` 注入 + +### 3. 錯誤碼 + +- 業務碼格式 `SSCCCDDD`(scope * 1_000_000 + category * 1_000 + detail) +- Scope 註冊在 `internal/library/errors/code/types.go`(Facade=10, Auth=28, Member=29, Notification=30, Permission=31) +- 新增 scope 時:同步更新 `gateway.api` 的 `bizCodeEnumDescription` + +### 4. Middleware(go-zero 正規手段) + +**禁止**在 `gateway.go` 用 `server.Use(...)` 全域掛 middleware,**所有** middleware 都透過 `.api` 的 `middleware:` 宣告: + +```go +@server ( + group: auth + prefix: /api/v1/auth + middleware: AuthJWT // 一個 + // middleware: AuthJWT,CasbinRBAC // 多個用逗號 +) +``` + +跑 `make gen-api` 後 `routes.go` 會自動 `rest.WithMiddlewares([]rest.Middleware{serverCtx.AuthJWT}, ...)`。 + +撰寫新 middleware 時: +- 用 **struct + `Handle()` method** 模式(不是 factory function) +- 檔名 = goctl stringx 規則(例 `AuthJWT` → `authjwt_middleware.go`、`CasbinRBAC` → `casbinrbac_middleware.go`) +- 在 `ServiceContext` 加 ` rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc. = middleware.NewMiddleware(...).Handle` +- Actor context 一律用 `internal/library/actor`(`WithActor` / `ActorFromContext`),禁止各 package 自定 `actorKey struct{}`(會造成 context value 進不來) + +詳細範例與分組原則見 [`generate/api/README.md`](../generate/api/README.md) "Middleware" 章節。 + +### 4. Redis / Mongo / 設定 + +- 每個模組的設定型別放 `internal/model//config/config.go`,再合入 `internal/config/config.go` 的 `Config` struct +- Redis client 共用 `internal/library/redis/`,需 Pub/Sub 用 `client.PubSubClient()` +- Mongo index 註冊到 `cmd/mongo-index/main.go`(在 `run()` 裡呼叫 `repo.EnsureMongoIndexes`) + +## 通用準則 + +- **回應 traditional Chinese**(繁體中文) +- 程式碼註解只寫「為什麼」、邊界條件、trade-off,**不寫**「import the module / increment counter」這類顯而易見的描述 +- 不主動建立 `*.md` 文件,除非使用者明確要求 +- 改 git config / 強制 push / `rm -rf` 等破壞性操作 **必須**先取得使用者同意 +- 不要在沒被要求時直接 commit;commit 前先 `git status` / `git diff` 確認 +- commit message 用繁中描述「為什麼」改,不是「改了什麼」 + +## 指令速查 + +| 指令 | 用途 | +|---|---| +| `make gen-api` | `.api` → handler / logic(skip exists)/ types | +| `make gen-doc` | `.api` → `docs/openapi/gateway.yaml` | +| `make gen-mock` | 模組 mock(gomock) | +| `make tools` | 安裝 goctl / goimports / golangci-lint | +| `make fix` | gofmt + goimports + lint --fix + lint | +| `make check` | fix + test(提交前) | +| `make run-dev` | 本機啟動(需 `make deps-up`) | +| `make deps-up` | docker compose Mongo + Redis | +| `make mongo-index` | 建立 / 更新 Mongo 索引 | + +完整列表跑 `make help`。 diff --git a/etc/gateway.k6.yaml b/etc/gateway.k6.yaml new file mode 100644 index 0000000..11f7456 --- /dev/null +++ b/etc/gateway.k6.yaml @@ -0,0 +1,116 @@ +# k6 測試專用設定(搭配 make k6-up + make k6-gateway) +# +# 與 dev 差異: +# - Email: SMTP → MailHog(localhost:1025);OTP 由 k6 透過 MailHog HTTP API 撈 +# - SMS : provider=mock,並由 mock_sender 寫到 Redis(key: dev:notification:last:sms:) +# - Permission.Casbin.Enabled: true(rbac journey) +# - Zitadel: 用環境變數帶 PAT / OAuth secret +# +# 啟動: +# export ZITADEL_SERVICE_TOKEN=$(cat deploy/zitadel/machinekey/zitadel-admin-sa.token) +# ./gateway -f etc/gateway.k6.yaml +# (或直接 make k6-gateway) + +Name: gateway +Host: 0.0.0.0 +Port: 8888 + +Mongo: + Schema: mongodb + Host: 127.0.0.1 + Port: 27017 + Database: gateway_k6 + TLS: false + MaxPoolSize: 30 + MinPoolSize: 5 + MaxConnIdleTime: 30m + +Redis: + Host: localhost:6379 + Type: node + +Notification: + DefaultLocale: zh-tw + Email: + Provider: smtp + From: noreply@k6.local + SMTP: + Enable: true + Sort: 1 + Host: localhost + Port: 1025 + Username: "" + Password: "" + SES: + Enable: false + SMS: + Provider: mock + Mitake: + Enable: false + Async: + QueueRedisKey: notification:queue:k6 + Worker: 1 + MaxRetry: 2 + BackoffSeconds: [1, 3] + RatePerTenant: + Email: 1000 + SMS: 1000 + +Member: + OTP: + Length: 6 + TTLSeconds: 300 + MaxAttempts: 10 + ResendCooldownSeconds: 60 + DailyVerifyLimit: 200 + TOTP: + Issuer: CloudEP-k6 + Algorithm: SHA1 + Digits: 6 + PeriodSeconds: 30 + Window: 1 + BackupCodeCount: 10 + BackupCodeLength: 12 + EnrollTTLSeconds: 600 + ReplayTTLSeconds: 90 + SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + Registration: + RequireInviteCode: true + TrustSocialEmailVerified: true + +Auth: + AccessExpire: 900 + RefreshExpire: 604800 + ActiveKID: v1 + AccessSecret: "k6-access-secret-32-bytes-min!!!" + RefreshSecret: "k6-refresh-secret-32-bytes-min!!" + RegistrationSessionTTLSeconds: 600 + +Permission: + Casbin: + Enabled: true + ModelPath: etc/rbac.conf + PolicyAdapter: auto + Cache: + UserRolesTTLSeconds: 10 + RolePermsTTLSeconds: 10 + CatalogTTLSeconds: 60 + Reload: + Channel: casbin:reload:k6 + DebounceMilliseconds: 50 + HeartbeatSeconds: 60 + +# ServiceUserToken / OAuthClientSecret 由環境變數注入: +# ZITADEL_SERVICE_TOKEN, ZITADEL_OAUTH_CLIENT_SECRET, ZITADEL_GOOGLE_CLIENT_SECRET +Zitadel: + Issuer: http://localhost:8080 + APIBase: http://localhost:8080 + ServiceUserToken: "" + DefaultOrgID: "" + OAuthClientID: "" + OAuthClientSecret: "" + GoogleClientID: "" + GoogleClientSecret: "" + GoogleIdPID: "" + JWKSUrl: "" + TimeoutSeconds: 15 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a0d3e21 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# Gateway Frontend + +一般使用者前台 + 簡易管理後台,對應本 repo 的 Gateway API。 + +## 技術 + +- Vite + React + TypeScript + React Router +- 無 UI 框架,表單與頁面導覽即可日常使用 + +## 啟動(推薦一鍵後端) + +```bash +# 終端 1:後端全套(首次約 1–2 分鐘,等 ZITADEL 起來) +make dev-up + +# 終端 2:前端 +make frontend-dev +``` + +開啟 http://localhost:5173 · 註冊 OTP 到 http://localhost:8025(MailHog) + +關閉:`make dev-down` + +## 頁面結構 + +### 使用者前台 + +| 路徑 | 功能 | +|------|------| +| `/login` | 登入 | +| `/register` | 註冊(寄 Email OTP) | +| `/register/confirm` | 輸入 6 碼完成註冊 | +| `/app` | 首頁(個人摘要、驗證狀態、角色) | +| `/app/profile` | 編輯顯示名稱、語系、幣別、電話 | +| `/app/security` | 商業 Email/手機驗證、TOTP 綁定 | + +### 管理後台(需 `tenant_admin` 或 `tenant_owner`) + +| 路徑 | 功能 | +|------|------| +| `/admin` | 總覽、Policy 重載 | +| `/admin/roles` | 角色列表、新增、改名、刪除 | +| `/admin/users` | 依 UID 查詢 / 指派 / 撤銷角色 | + +有管理權限時,使用者前台頂部會出現「管理後台」連結。 + +## 本機預設 + +- 租戶:`k6-tenant` +- 邀請碼:`K6INVITE`(需先 `make k6-seed-fixtures` 或對應 Mongo 資料) +- OTP:開發環境到 MailHog http://localhost:8025 查看 + +### 取得管理員權限 + +```bash +make k6-seed-admin +# 將輸出的 ADMIN_ACCESS_TOKEN 貼到瀏覽器 — 需自行在 localStorage 設 access_token +# 或:用 seed 產生的帳密在 /login 登入(若 ZITADEL password grant 可用) +``` + +較簡單做法:完成一般註冊登入後,在 Mongo 手動指派 `tenant_admin`,或跑 `k6-seed-admin` 用新帳號登入。 + +## 環境變數 + +| 變數 | 說明 | +|------|------| +| `VITE_API_BASE` | API 根網址;留空則走 Vite proxy `/api` → `:8888` | + +## 建置 + +```bash +npm run build # → dist/ +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d9027c4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Gateway API 控制台 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..26641d2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3103 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "qrcode": "^1.5.4", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4035f13 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "qrcode": "^1.5.4", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..7936527 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,533 @@ +:root { + --bg: #f4f6f9; + --surface: #fff; + --border: #e2e8f0; + --text: #1e293b; + --muted: #64748b; + --primary: #2563eb; + --primary-hover: #1d4ed8; + --danger: #dc2626; + --ok: #16a34a; + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; + line-height: 1.5; + color: var(--text); + background: var(--bg); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +a { + color: var(--primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.loading { + padding: 2rem; + color: var(--muted); +} + +/* Auth pages */ +.auth-card { + max-width: 400px; + margin: 4rem auto; + padding: 2rem; + background: var(--surface); + border-radius: 8px; + border: 1px solid var(--border); + box-shadow: 0 1px 3px rgb(0 0 0 / 6%); +} + +.auth-card h1 { + margin: 0 0 1.5rem; + font-size: 1.5rem; +} + +.auth-footer { + margin-top: 1.5rem; + text-align: center; + color: var(--muted); + font-size: 0.9rem; +} + +/* Shell */ +.shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0 1.25rem; + height: 56px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.brand { + font-weight: 700; + color: var(--text); + text-decoration: none; +} + +.nav { + display: flex; + gap: 1rem; + flex: 1; +} + +.nav a { + color: var(--muted); + font-size: 0.9rem; +} + +.nav a:hover, +.nav a.active { + color: var(--primary); +} + +.topbar-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.uid { + font-size: 0.75rem; + color: var(--muted); + font-family: ui-monospace, monospace; +} + +.page { + flex: 1; + padding: 1.5rem 2rem; + max-width: 960px; +} + +/* Admin */ +.admin-shell { + flex-direction: row; +} + +.admin-sidebar { + width: 220px; + background: #1e293b; + color: #e2e8f0; + padding: 1.25rem 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 100vh; +} + +.admin-sidebar .brand { + color: #fff; + margin-bottom: 1rem; +} + +.admin-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} + +.admin-nav a { + color: #94a3b8; + padding: 0.5rem 0.75rem; + border-radius: 4px; +} + +.admin-nav a:hover { + background: #334155; + color: #fff; + text-decoration: none; +} + +.back-link { + color: #94a3b8; + font-size: 0.85rem; + margin-top: auto; +} + +.admin-page { + max-width: none; + flex: 1; +} + +/* Forms */ +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-narrow { + max-width: 420px; +} + +.form label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.85rem; + font-weight: 500; +} + +.form input, +.form select, +.table-input { + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: 6px; + font: inherit; +} + +.form-inline { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} + +.form-inline input { + flex: 1; + min-width: 160px; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; +} + +.form-inline-block { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} + +.form-inline-block input, +.form-inline-block select { + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; +} + +.form-error { + color: var(--danger); + margin: 0; + font-size: 0.9rem; +} + +.form-ok { + color: var(--ok); + margin: 0; + font-size: 0.9rem; +} + +.hint { + color: var(--muted); + font-size: 0.9rem; +} + +/* Buttons */ +.btn-primary { + padding: 0.55rem 1rem; + background: var(--primary); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font: inherit; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); +} + +.btn-primary:disabled { + opacity: 0.6; +} + +.btn-ghost { + padding: 0.4rem 0.75rem; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} + +.btn-link { + background: none; + border: none; + color: var(--primary); + cursor: pointer; + margin-top: 0.75rem; +} + +.btn-danger, +.btn-danger-sm { + background: var(--danger); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + padding: 0.35rem 0.65rem; + font-size: 0.85rem; +} + +.admin-sidebar .btn-ghost { + color: #e2e8f0; + border-color: #475569; +} + +/* Cards */ +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; +} + +.card h3 { + margin: 0 0 0.75rem; + font-size: 1rem; +} + +.card dl { + margin: 0; + font-size: 0.9rem; +} + +.card dt { + color: var(--muted); + margin-top: 0.35rem; +} + +.card dd { + margin: 0; +} + +.tag-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tag { + background: #e0e7ff; + color: #3730a3; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; +} + +.muted { + color: var(--muted); +} + +.section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.25rem; + margin-bottom: 1rem; +} + +.section h2 { + margin: 0 0 0.75rem; + font-size: 1.1rem; +} + +.backup-codes { + background: #f8fafc; + padding: 0.75rem; + border-radius: 4px; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +/* Table */ +.table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border); +} + +.table th, +.table td { + padding: 0.65rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; +} + +.table th { + background: #f8fafc; + font-weight: 600; +} + +.table-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.role-assign-list { + list-style: none; + padding: 0; +} + +.role-assign-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border); +} + +/* TOTP QR */ +.totp-qr-panel { + display: flex; + flex-wrap: wrap; + gap: 1.25rem; + align-items: flex-start; + margin: 1rem 0; + padding: 1rem; + background: #f8fafc; + border: 1px solid var(--border); + border-radius: 8px; +} + +.totp-qr-panel img { + display: block; + border-radius: 4px; + background: #fff; +} + +.totp-qr-meta { + flex: 1; + min-width: 200px; +} + +.totp-manual summary { + cursor: pointer; + font-size: 0.85rem; + color: var(--muted); +} + +.otpauth-url { + display: block; + margin-top: 0.5rem; + font-size: 0.7rem; + word-break: break-all; + color: var(--muted); +} + +/* Admin permissions */ +.admin-links { + list-style: none; + padding: 0; + margin: 0; +} + +.admin-links li { + padding: 0.35rem 0; +} + +.admin-toolbar { + margin: 1rem 0; +} + +.admin-toolbar select { + min-width: 240px; + margin-left: 0.5rem; +} + +.perm-grid { + display: grid; + gap: 1rem; + margin-top: 1rem; +} + +.perm-group { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} + +.perm-group h3 { + margin: 0 0 0.75rem; + font-size: 0.95rem; +} + +.perm-check-list { + list-style: none; + padding: 0; + margin: 0; +} + +.perm-check-list li { + padding: 0.35rem 0; +} + +.perm-check-list label { + display: flex; + align-items: flex-start; + gap: 0.5rem; + cursor: pointer; +} + +.perm-label { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.perm-api { + font-size: 0.8rem; + color: var(--muted); + font-family: ui-monospace, monospace; +} + +@media (max-width: 768px) { + .admin-shell { + flex-direction: column; + } + .admin-sidebar { + width: 100%; + min-height: auto; + flex-direction: row; + flex-wrap: wrap; + } + .admin-nav { + flex-direction: row; + flex-wrap: wrap; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..802a2bc --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,61 @@ +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { AdminRoute } from './components/AdminRoute'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import { AdminLayout } from './layouts/AdminLayout'; +import { UserLayout } from './layouts/UserLayout'; +import { AdminHomePage } from './pages/admin/AdminHomePage'; +import { RolesPage } from './pages/admin/RolesPage'; +import { UserRolesPage } from './pages/admin/UserRolesPage'; +import { RolePermissionsPage } from './pages/admin/RolePermissionsPage'; +import { ConfirmPage } from './pages/user/ConfirmPage'; +import { ForgotPasswordPage } from './pages/user/ForgotPasswordPage'; +import { HomePage } from './pages/user/HomePage'; +import { LoginPage } from './pages/user/LoginPage'; +import { ProfilePage } from './pages/user/ProfilePage'; +import { RegisterPage } from './pages/user/RegisterPage'; +import { SecurityPage } from './pages/user/SecurityPage'; +import './App.css'; + +function RootRedirect() { + const { ready, token } = useAuth(); + if (!ready) return

載入中…

; + return ; +} + +function App() { + return ( + + + + } /> + } /> + } /> + } /> + + }> + }> + } /> + } /> + } /> + + + + }> + }> + } /> + } /> + } /> + } /> + + + + } /> + } /> + + + + ); +} + +export default App; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..e024908 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,166 @@ +import { api, setTokens } from './http'; + +export interface AuthTokenData { + access_token: string; + refresh_token: string; + expires_in: number; + uid: string; + token_type: string; +} + +export interface LoginData extends Partial { + mfa_required?: boolean; + mfa_challenge_id?: string; + mfa_expires_in?: number; +} + +export interface RegisterData { + challenge_id: string; + expires_in: number; + uid: string; +} + +function applyAuthTokens(data: AuthTokenData) { + setTokens(data.access_token, data.refresh_token); + localStorage.setItem('uid', data.uid); +} + +export async function login(tenantSlug: string, email: string, password: string) { + const data = await api('/api/v1/auth/login', { + auth: false, + method: 'POST', + body: JSON.stringify({ tenant_slug: tenantSlug, email, password }), + }); + if (data.mfa_required) { + return data; + } + applyAuthTokens(data as AuthTokenData); + return data; +} + +export async function loginMfaConfirm( + tenantSlug: string, + challengeId: string, + code: string, +) { + const data = await api('/api/v1/auth/login/mfa', { + auth: false, + method: 'POST', + body: JSON.stringify({ + tenant_slug: tenantSlug, + challenge_id: challengeId, + code, + }), + }); + applyAuthTokens(data); + return data; +} + +export async function register(body: { + tenant_slug: string; + invite_code: string; + email: string; + password: string; + display_name?: string; + language?: string; +}) { + return api('/api/v1/auth/register', { + auth: false, + method: 'POST', + body: JSON.stringify({ + ...body, + accept_terms_version: '2025-01-01', + marketing_opt_in: false, + }), + }); +} + +export async function registerConfirm( + tenantSlug: string, + challengeId: string, + code: string, +) { + const data = await api('/api/v1/auth/register/confirm', { + auth: false, + method: 'POST', + body: JSON.stringify({ + tenant_slug: tenantSlug, + challenge_id: challengeId, + code, + }), + }); + setTokens(data.access_token, data.refresh_token); + localStorage.setItem('uid', data.uid); + return data; +} + +export async function registerResend(tenantSlug: string, challengeId: string) { + return api('/api/v1/auth/register/resend', { + auth: false, + method: 'POST', + body: JSON.stringify({ tenant_slug: tenantSlug, challenge_id: challengeId }), + }); +} + +export async function registerResume(tenantSlug: string, email: string) { + return api('/api/v1/auth/register/resume', { + auth: false, + method: 'POST', + body: JSON.stringify({ + tenant_slug: tenantSlug, + email, + }), + }); +} + +export interface PasswordChallengeData { + challenge_id: string; + expires_in: number; +} + +export async function passwordForgot(tenantSlug: string, email: string) { + return api('/api/v1/auth/password/forgot', { + auth: false, + method: 'POST', + body: JSON.stringify({ tenant_slug: tenantSlug, email }), + }); +} + +export async function passwordReset( + tenantSlug: string, + challengeId: string, + code: string, + newPassword: string, +) { + return api<{ ok: boolean }>('/api/v1/auth/password/reset', { + auth: false, + method: 'POST', + body: JSON.stringify({ + tenant_slug: tenantSlug, + challenge_id: challengeId, + code, + new_password: newPassword, + }), + }); +} + +export async function logout() { + try { + await api('/api/v1/auth/logout', { method: 'POST', body: '{}' }); + } finally { + /* always clear local */ + } +} + +export async function refreshToken() { + const refresh = localStorage.getItem('refresh_token'); + if (!refresh) throw new Error('無 refresh token'); + const data = await api('/api/v1/auth/token/refresh', { + auth: false, + method: 'POST', + body: JSON.stringify({ refresh_token: refresh }), + }); + setTokens(data.access_token, data.refresh_token); + localStorage.setItem('uid', data.uid); + return data; +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..cac2eb3 --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,71 @@ +export class ApiError extends Error { + status: number; + code?: number; + body?: unknown; + + constructor(message: string, status: number, code?: number, body?: unknown) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.body = body; + } +} + +export interface Envelope { + code: number; + message: string; + data?: T; + error?: { biz_code?: string }; +} + +function getBaseUrl() { + return (import.meta.env.VITE_API_BASE as string | undefined) ?? ''; +} + +export function getToken(): string { + return localStorage.getItem('access_token') ?? ''; +} + +export function setTokens(access: string, refresh: string) { + localStorage.setItem('access_token', access); + localStorage.setItem('refresh_token', refresh); +} + +export function clearTokens() { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('uid'); + localStorage.removeItem('roles'); +} + +export async function api( + path: string, + init: RequestInit & { auth?: boolean } = {}, +): Promise { + const { auth = true, headers: initHeaders, ...rest } = init; + const headers = new Headers(initHeaders); + headers.set('Accept', 'application/json'); + if (rest.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + if (auth) { + const token = getToken(); + if (token) headers.set('Authorization', `Bearer ${token}`); + } + + const res = await fetch(`${getBaseUrl()}${path}`, { ...rest, headers }); + const text = await res.text(); + let json: Envelope | null = null; + try { + json = text ? (JSON.parse(text) as Envelope) : null; + } catch { + /* non-json */ + } + + if (!res.ok || (json && json.code !== 102000 && json.code !== 0)) { + const msg = json?.message ?? res.statusText ?? '請求失敗'; + throw new ApiError(msg, res.status, json?.code, json); + } + return (json?.data ?? json) as T; +} diff --git a/frontend/src/api/member.ts b/frontend/src/api/member.ts new file mode 100644 index 0000000..5c6ba20 --- /dev/null +++ b/frontend/src/api/member.ts @@ -0,0 +1,111 @@ +import { api } from './http'; + +export interface MemberMe { + tenant_id: string; + uid: string; + origin: string; + zitadel_email?: string; + display_name?: string; + avatar?: string; + phone?: string; + language?: string; + currency?: string; + status: string; + business_email?: string; + business_email_verified: boolean; + business_phone?: string; + business_phone_verified: boolean; + totp_enrolled: boolean; +} + +export interface VerificationStart { + challenge_id: string; + expires_in: number; +} + +export interface TOTPStatus { + enrolled: boolean; + backup_codes_remaining: number; +} + +export interface TOTPEnrollStart { + otpauth_url: string; + issuer: string; + account: string; +} + +export function getMe() { + return api('/api/v1/members/me'); +} + +export function updateMe(body: { + display_name?: string; + language?: string; + currency?: string; + phone?: string; +}) { + return api('/api/v1/members/me', { + method: 'PATCH', + body: JSON.stringify(body), + }); +} + +export function startEmailVerification(target: string) { + return api('/api/v1/members/me/verifications/email/start', { + method: 'POST', + body: JSON.stringify({ target }), + }); +} + +export function confirmEmailVerification(challengeId: string, code: string) { + return api('/api/v1/members/me/verifications/email/confirm', { + method: 'POST', + body: JSON.stringify({ challenge_id: challengeId, code }), + }); +} + +export function startPhoneVerification(target: string) { + return api('/api/v1/members/me/verifications/phone/start', { + method: 'POST', + body: JSON.stringify({ target }), + }); +} + +export function confirmPhoneVerification(challengeId: string, code: string) { + return api('/api/v1/members/me/verifications/phone/confirm', { + method: 'POST', + body: JSON.stringify({ challenge_id: challengeId, code }), + }); +} + +export function getTOTPStatus() { + return api('/api/v1/members/me/totp'); +} + +export function startTOTPEnroll() { + return api('/api/v1/members/me/totp/enroll-start', { + method: 'POST', + body: '{}', + }); +} + +export function confirmTOTPEnroll(code: string) { + return api<{ backup_codes: string[] }>( + '/api/v1/members/me/totp/enroll-confirm', + { method: 'POST', body: JSON.stringify({ code }) }, + ); +} + +export function disableTOTP() { + return api('/api/v1/members/me/totp', { method: 'DELETE' }); +} + +export function changePassword(currentPassword: string, newPassword: string) { + return api<{ ok: boolean }>('/api/v1/members/me/password', { + method: 'POST', + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }); +} diff --git a/frontend/src/api/permission.ts b/frontend/src/api/permission.ts new file mode 100644 index 0000000..10b159d --- /dev/null +++ b/frontend/src/api/permission.ts @@ -0,0 +1,125 @@ +import { api } from './http'; + +export interface MePermissions { + uid: string; + tenant_id: string; + roles: string[]; + permissions: Record; +} + +export interface Role { + id: string; + key: string; + display_name: string; + status: string; + is_system: boolean; +} + +export interface RoleList { + roles: Role[]; +} + +export interface UserRoleList { + user_roles: Array<{ + role_id: string; + role_key: string; + display_name: string; + }>; +} + +export interface PermissionNode { + id: string; + parent?: string; + name: string; + http_methods?: string; + http_path?: string; + status: string; + type: string; + children?: PermissionNode[]; +} + +export interface PermissionCatalog { + tree?: PermissionNode[]; + list?: PermissionNode[]; +} + +export interface RolePermissions { + permissions: PermissionNode[]; +} + +const ADMIN_ROLES = new Set(['tenant_admin', 'tenant_owner']); + +export function isAdminRole(roles: string[]) { + return roles.some((r) => ADMIN_ROLES.has(r)); +} + +export function getMyPermissions() { + return api('/api/v1/permissions/me'); +} + +export function listRoles() { + return api('/api/v1/permissions/roles'); +} + +export function createRole(key: string, displayName: string) { + return api('/api/v1/permissions/roles', { + method: 'POST', + body: JSON.stringify({ key, display_name: displayName, status: 'open' }), + }); +} + +export function updateRole(id: string, displayName: string) { + return api(`/api/v1/permissions/roles/${id}`, { + method: 'PATCH', + body: JSON.stringify({ display_name: displayName }), + }); +} + +export function deleteRole(id: string) { + return api(`/api/v1/permissions/roles/${id}`, { method: 'DELETE' }); +} + +export function listUserRoles(uid: string) { + return api(`/api/v1/permissions/users/${uid}/roles`); +} + +export function assignUserRole(uid: string, roleId: string) { + return api(`/api/v1/permissions/users/${uid}/roles`, { + method: 'POST', + body: JSON.stringify({ role_id: roleId, source: 'manual' }), + }); +} + +export function revokeUserRole(uid: string, roleId: string) { + return api(`/api/v1/permissions/users/${uid}/roles/${roleId}`, { + method: 'DELETE', + }); +} + +export function reloadPolicy(tenantId: string) { + return api('/api/v1/permissions/policy/reload', { + method: 'POST', + body: JSON.stringify({ tenant_id: tenantId }), + }); +} + +export function getPermissionCatalog(opts?: { tree?: boolean; type?: string }) { + const q = new URLSearchParams(); + if (opts?.tree) q.set('tree', 'true'); + if (opts?.type) q.set('type', opts.type); + const qs = q.toString(); + return api( + `/api/v1/permissions/catalog${qs ? `?${qs}` : ''}`, + ); +} + +export function getRolePermissions(roleId: string) { + return api(`/api/v1/permissions/roles/${roleId}/permissions`); +} + +export function replaceRolePermissions(roleId: string, permissionIds: string[]) { + return api(`/api/v1/permissions/roles/${roleId}/permissions`, { + method: 'PUT', + body: JSON.stringify({ permission_ids: permissionIds }), + }); +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/AdminRoute.tsx b/frontend/src/components/AdminRoute.tsx new file mode 100644 index 0000000..7a54806 --- /dev/null +++ b/frontend/src/components/AdminRoute.tsx @@ -0,0 +1,10 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +export function AdminRoute() { + const { ready, token, isAdmin } = useAuth(); + if (!ready) return

載入中…

; + if (!token) return ; + if (!isAdmin) return ; + return ; +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..ba85719 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,9 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +export function ProtectedRoute() { + const { ready, token } = useAuth(); + if (!ready) return

載入中…

; + if (!token) return ; + return ; +} diff --git a/frontend/src/components/TotpQrPanel.tsx b/frontend/src/components/TotpQrPanel.tsx new file mode 100644 index 0000000..265e99e --- /dev/null +++ b/frontend/src/components/TotpQrPanel.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import QRCode from 'qrcode'; + +interface Props { + otpauthUrl: string; + issuer?: string; + account?: string; +} + +export function TotpQrPanel({ otpauthUrl, issuer, account }: Props) { + const [dataUrl, setDataUrl] = useState(''); + const [err, setErr] = useState(''); + + useEffect(() => { + if (!otpauthUrl) { + setDataUrl(''); + return; + } + let cancelled = false; + QRCode.toDataURL(otpauthUrl, { width: 220, margin: 2, errorCorrectionLevel: 'M' }) + .then((url) => { + if (!cancelled) setDataUrl(url); + }) + .catch(() => { + if (!cancelled) setErr('無法產生 QR Code'); + }); + return () => { + cancelled = true; + }; + }, [otpauthUrl]); + + if (!otpauthUrl) return null; + + return ( +
+ {dataUrl ? ( + TOTP QR Code + ) : err ? ( +

{err}

+ ) : ( +

產生 QR Code 中…

+ )} +
+ {issuer && ( +

+ Issuer {issuer} +

+ )} + {account && ( +

+ 帳號 {account} +

+ )} +

+ 用 Google Authenticator、1Password 等 App 掃描 QR,或手動輸入密鑰(進階設定)。 +

+
+ 無法掃描?顯示 otpauth 連結 + {otpauthUrl} +
+
+
+ ); +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..c4f5247 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,5 @@ +/** 本機 k6 / dev 預設值,可在登入頁覆寫 */ +export const DEFAULT_TENANT = 'k6-tenant'; +export const DEFAULT_INVITE = 'K6INVITE'; +/** 與 Gateway Member.OTP.ResendCooldownSeconds 預設一致 */ +export const OTP_RESEND_COOLDOWN_SECONDS = 60; diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..9dcc569 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,96 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { clearTokens, getToken } from '../api/http'; +import * as permApi from '../api/permission'; + +interface AuthState { + ready: boolean; + token: string; + uid: string; + roles: string[]; + isAdmin: boolean; + syncSession: () => void; + refreshRoles: () => Promise; + signOut: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(false); + const [token, setToken] = useState(getToken); + const [uid, setUid] = useState(() => localStorage.getItem('uid') ?? ''); + const [roles, setRoles] = useState(() => { + try { + return JSON.parse(localStorage.getItem('roles') ?? '[]') as string[]; + } catch { + return []; + } + }); + + const syncSession = useCallback(() => { + setToken(getToken()); + setUid(localStorage.getItem('uid') ?? ''); + }, []); + + const refreshRoles = useCallback(async () => { + if (!getToken()) { + setRoles([]); + return; + } + try { + const me = await permApi.getMyPermissions(); + setRoles(me.roles ?? []); + localStorage.setItem('roles', JSON.stringify(me.roles ?? [])); + if (me.uid) { + setUid(me.uid); + localStorage.setItem('uid', me.uid); + } + } catch { + setRoles([]); + } + }, []); + + useEffect(() => { + (async () => { + if (getToken()) await refreshRoles(); + setReady(true); + })(); + }, [refreshRoles]); + + const signOut = useCallback(() => { + clearTokens(); + setToken(''); + setUid(''); + setRoles([]); + }, []); + + const value = useMemo( + () => ({ + ready, + token, + uid, + roles, + isAdmin: permApi.isAdminRole(roles), + syncSession, + refreshRoles, + signOut, + }), + [ready, token, uid, roles, syncSession, refreshRoles, signOut], + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth 需在 AuthProvider 內'); + return ctx; +} diff --git a/frontend/src/hooks/useResendCooldown.ts b/frontend/src/hooks/useResendCooldown.ts new file mode 100644 index 0000000..f8930d1 --- /dev/null +++ b/frontend/src/hooks/useResendCooldown.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** 與 Gateway Member.OTP.ResendCooldownSeconds 預設一致 */ +export const RESEND_COOLDOWN_SECONDS = 60; + +export function useResendCooldown(initialSeconds = 0) { + const [secondsLeft, setSecondsLeft] = useState(initialSeconds); + + useEffect(() => { + if (secondsLeft <= 0) return; + const timer = window.setInterval(() => { + setSecondsLeft((prev) => (prev <= 1 ? 0 : prev - 1)); + }, 1000); + return () => window.clearInterval(timer); + }, [secondsLeft]); + + const startCooldown = useCallback(() => { + setSecondsLeft(RESEND_COOLDOWN_SECONDS); + }, []); + + return { + secondsLeft, + canSend: secondsLeft <= 0, + startCooldown, + }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..904ba9b --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,4 @@ +/* Global reset — layout in App.css */ +body { + margin: 0; +} diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx new file mode 100644 index 0000000..ccfc0a0 --- /dev/null +++ b/frontend/src/layouts/AdminLayout.tsx @@ -0,0 +1,41 @@ +import { Link, Outlet, useNavigate } from 'react-router-dom'; +import * as authApi from '../api/auth'; +import { useAuth } from '../context/AuthContext'; + +export function AdminLayout() { + const { signOut } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + try { + await authApi.logout(); + } catch { + /* ignore */ + } + signOut(); + navigate('/login'); + }; + + return ( +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/layouts/UserLayout.tsx b/frontend/src/layouts/UserLayout.tsx new file mode 100644 index 0000000..00cbc15 --- /dev/null +++ b/frontend/src/layouts/UserLayout.tsx @@ -0,0 +1,43 @@ +import { Link, Outlet, useNavigate } from 'react-router-dom'; +import * as authApi from '../api/auth'; +import { useAuth } from '../context/AuthContext'; + +export function UserLayout() { + const { signOut, isAdmin, uid } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + try { + await authApi.logout(); + } catch { + /* ignore */ + } + signOut(); + navigate('/login'); + }; + + return ( +
+
+ + Gateway + + +
+ {uid} + +
+
+
+ +
+
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/admin/AdminHomePage.tsx b/frontend/src/pages/admin/AdminHomePage.tsx new file mode 100644 index 0000000..0a721e0 --- /dev/null +++ b/frontend/src/pages/admin/AdminHomePage.tsx @@ -0,0 +1,67 @@ +import { Link } from 'react-router-dom'; +import { useState } from 'react'; +import * as permApi from '../../api/permission'; +import { ApiError } from '../../api/http'; +import { useAuth } from '../../context/AuthContext'; + +export function AdminHomePage() { + const { roles, uid } = useAuth(); + const [msg, setMsg] = useState(''); + const [error, setError] = useState(''); + + const reloadPolicy = async () => { + setError(''); + setMsg(''); + try { + const tenant = localStorage.getItem('tenant_slug') ?? 'k6-tenant'; + await permApi.reloadPolicy(tenant); + setMsg('Casbin policy 已重載'); + } catch (e) { + setError(e instanceof ApiError ? e.message : '重載失敗'); + } + }; + + return ( +
+

管理後台

+

以 tenant_admin / tenant_owner 角色登入後可使用。

+
+
+

目前管理員

+

UID: {uid}

+
    + {roles.map((r) => ( +
  • + {r} +
  • + ))} +
+
+
+

權限管理

+
    +
  • + 角色管理 — 新增 / 改名 / 刪除角色 +
  • +
  • + 角色權限 — 勾選 API + 權限 +
  • +
  • + 使用者角色 — 指派 tenant_admin 等 +
  • +
+
+
+

系統維護

+

角色或權限變更後,可手動觸發 policy 重載。

+ + {msg &&

{msg}

} + {error &&

{error}

} +
+
+
+ ); +} diff --git a/frontend/src/pages/admin/RolePermissionsPage.tsx b/frontend/src/pages/admin/RolePermissionsPage.tsx new file mode 100644 index 0000000..a75508c --- /dev/null +++ b/frontend/src/pages/admin/RolePermissionsPage.tsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import * as permApi from '../../api/permission'; +import type { PermissionNode, Role } from '../../api/permission'; +import { ApiError } from '../../api/http'; + +/** 可勾選的權限節點(有實際 API path 的葉節點) */ +function leafPermissions(nodes: PermissionNode[]): PermissionNode[] { + return nodes.filter((n) => n.http_path); +} + +function groupByParent(leaves: PermissionNode[]) { + const groups = new Map(); + for (const p of leaves) { + const key = p.parent || '(root)'; + const arr = groups.get(key) ?? []; + arr.push(p); + groups.set(key, arr); + } + return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)); +} + +export function RolePermissionsPage() { + const [searchParams] = useSearchParams(); + const presetRole = searchParams.get('role') ?? ''; + const [roles, setRoles] = useState([]); + const [roleId, setRoleId] = useState(presetRole); + const [catalog, setCatalog] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [error, setError] = useState(''); + const [msg, setMsg] = useState(''); + const [loading, setLoading] = useState(false); + + const leaves = useMemo(() => leafPermissions(catalog), [catalog]); + const groups = useMemo(() => groupByParent(leaves), [leaves]); + const selectedRole = roles.find((r) => r.id === roleId); + + useEffect(() => { + if (presetRole) setRoleId(presetRole); + }, [presetRole]); + + useEffect(() => { + permApi + .listRoles() + .then((r) => setRoles(r.roles ?? [])) + .catch((e) => setError(e instanceof ApiError ? e.message : '載入角色失敗')); + permApi + .getPermissionCatalog({ type: 'backend_user' }) + .then((c) => setCatalog(c.list ?? [])) + .catch((e) => setError(e instanceof ApiError ? e.message : '載入權限目錄失敗')); + }, []); + + useEffect(() => { + if (!roleId) { + setSelected(new Set()); + return; + } + setError(''); + permApi + .getRolePermissions(roleId) + .then((r) => { + const ids = new Set((r.permissions ?? []).map((p) => p.id)); + setSelected(ids); + }) + .catch((e) => { + setError(e instanceof ApiError ? e.message : '載入角色權限失敗'); + setSelected(new Set()); + }); + }, [roleId]); + + const toggle = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const save = async () => { + if (!roleId) return; + setLoading(true); + setError(''); + setMsg(''); + try { + // 只送葉節點 ID;後端會自動補齊父權限 + const leafIds = leaves.filter((l) => selected.has(l.id)).map((l) => l.id); + await permApi.replaceRolePermissions(roleId, leafIds); + const tenant = localStorage.getItem('tenant_slug') ?? 'k6-tenant'; + await permApi.reloadPolicy(tenant); + setMsg('已儲存並重載 Casbin policy'); + const r = await permApi.getRolePermissions(roleId); + setSelected(new Set((r.permissions ?? []).map((p) => p.id))); + } catch (e) { + setError(e instanceof ApiError ? e.message : '儲存失敗'); + } finally { + setLoading(false); + } + }; + + return ( +
+

角色權限設定

+

+ 為自訂角色勾選 API 權限。儲存後會自動重載 policy。系統內建角色(tenant_admin + 等)權限由種子資料定義,建議只調整非系統角色。 +

+ + {error &&

{error}

} + {msg &&

{msg}

} + +
+ + {roleId && ( + + )} +
+ + {selectedRole?.is_system && ( +

系統角色不建議在此修改;請建立自訂角色(如 support)再指派權限。

+ )} + + {roleId && !selectedRole?.is_system && ( +
+ {groups.map(([parent, items]) => ( +
+

+ {parent} +

+
    + {items.map((p) => ( +
  • + +
  • + ))} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/admin/RolesPage.tsx b/frontend/src/pages/admin/RolesPage.tsx new file mode 100644 index 0000000..29db411 --- /dev/null +++ b/frontend/src/pages/admin/RolesPage.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import * as permApi from '../../api/permission'; +import type { Role } from '../../api/permission'; +import { ApiError } from '../../api/http'; + +export function RolesPage() { + const [roles, setRoles] = useState([]); + const [key, setKey] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const load = () => { + permApi + .listRoles() + .then((r) => setRoles(r.roles ?? [])) + .catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗')); + }; + + useEffect(() => { + load(); + }, []); + + const create = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await permApi.createRole(key, displayName || key); + setKey(''); + setDisplayName(''); + load(); + } catch (e) { + setError(e instanceof ApiError ? e.message : '建立失敗'); + } finally { + setLoading(false); + } + }; + + const remove = async (id: string, isSystem: boolean) => { + if (isSystem) { + alert('系統角色不可刪除'); + return; + } + if (!confirm('確定刪除此角色?')) return; + try { + await permApi.deleteRole(id); + load(); + } catch (e) { + setError(e instanceof ApiError ? e.message : '刪除失敗'); + } + }; + + const rename = async (id: string, name: string) => { + try { + await permApi.updateRole(id, name); + load(); + } catch (e) { + setError(e instanceof ApiError ? e.message : '更新失敗'); + } + }; + + return ( +
+

角色管理

+ {error &&

{error}

} + +
+ setKey(e.target.value)} + required + /> + setDisplayName(e.target.value)} + /> + +
+ + + + + + + + + + + + + {roles.map((r) => ( + + + + + + + + ))} + +
Key顯示名稱系統狀態
+ {r.key} + + { + if (e.target.value !== r.display_name) { + rename(r.id, e.target.value); + } + }} + /> + {r.is_system ? '是' : '否'}{r.status} + {!r.is_system && ( + + 設定權限 + + )} + {!r.is_system && ( + + )} +
+
+ ); +} diff --git a/frontend/src/pages/admin/UserRolesPage.tsx b/frontend/src/pages/admin/UserRolesPage.tsx new file mode 100644 index 0000000..f56bb93 --- /dev/null +++ b/frontend/src/pages/admin/UserRolesPage.tsx @@ -0,0 +1,136 @@ +import { useState, type FormEvent } from 'react'; +import * as permApi from '../../api/permission'; +import type { Role } from '../../api/permission'; +import { ApiError } from '../../api/http'; +import { useAuth } from '../../context/AuthContext'; + +export function UserRolesPage() { + const { uid: myUid } = useAuth(); + const [uid, setUid] = useState(''); + const [roles, setRoles] = useState([]); + const [userRoles, setUserRoles] = useState< + Array<{ role_id: string; role_key: string; display_name: string }> + >([]); + const [assignRoleId, setAssignRoleId] = useState(''); + const [error, setError] = useState(''); + const [msg, setMsg] = useState(''); + + const loadRoleOptions = () => { + permApi.listRoles().then((r) => setRoles(r.roles ?? [])); + }; + + const search = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setMsg(''); + loadRoleOptions(); + try { + const r = await permApi.listUserRoles(uid.trim()); + setUserRoles(r.user_roles ?? []); + setMsg('已載入'); + } catch (e) { + setError(e instanceof ApiError ? e.message : '查詢失敗'); + setUserRoles([]); + } + }; + + const assign = async () => { + if (!assignRoleId) return; + setError(''); + try { + await permApi.assignUserRole(uid.trim(), assignRoleId); + const r = await permApi.listUserRoles(uid.trim()); + setUserRoles(r.user_roles ?? []); + setMsg('已指派角色'); + } catch (e) { + setError(e instanceof ApiError ? e.message : '指派失敗'); + } + }; + + const revoke = async (roleId: string) => { + if (!confirm('撤銷此角色?')) return; + setError(''); + try { + await permApi.revokeUserRole(uid.trim(), roleId); + const r = await permApi.listUserRoles(uid.trim()); + setUserRoles(r.user_roles ?? []); + setMsg('已撤銷'); + } catch (e) { + setError(e instanceof ApiError ? e.message : '撤銷失敗'); + } + }; + + return ( +
+

使用者角色

+

+ 輸入成員 UID(註冊後可在首頁查看)。指派 tenant_admin{' '} + 或 tenant_owner 後,對方重新登入即可進管理後台。 +

+ +
+ setUid(e.target.value)} + required + /> + + {myUid && ( + + )} +
+ + {msg &&

{msg}

} + {error &&

{error}

} + + {userRoles.length > 0 && ( + <> +

目前角色

+
    + {userRoles.map((ur) => ( +
  • + + {ur.display_name} ({ur.role_key}) + + +
  • + ))} +
+ +

指派新角色

+
+ + +
+ + )} +
+ ); +} diff --git a/frontend/src/pages/user/ConfirmPage.tsx b/frontend/src/pages/user/ConfirmPage.tsx new file mode 100644 index 0000000..797ec0a --- /dev/null +++ b/frontend/src/pages/user/ConfirmPage.tsx @@ -0,0 +1,214 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import * as authApi from '../../api/auth'; +import { ApiError } from '../../api/http'; +import { DEFAULT_TENANT, OTP_RESEND_COOLDOWN_SECONDS } from '../../config'; +import { useAuth } from '../../context/AuthContext'; +import { + RESEND_COOLDOWN_SECONDS, + useResendCooldown, +} from '../../hooks/useResendCooldown'; + +type ConfirmStep = 'email' | 'otp'; + +type ConfirmNavState = { + tenant: string; + email: string; + challenge_id: string; +}; + +function resumeErrorMessage(err: unknown): string { + if (!(err instanceof ApiError)) return '無法寄送驗證碼'; + if (err.code === 29301000) { + return '找不到此 Email 的待驗證帳號,請確認租戶與 Email 是否正確'; + } + if (err.code === 28309000) return '此帳號已完成驗證,請直接登入'; + if (err.code === 29604000) { + return `寄送過於頻繁,請稍候 ${OTP_RESEND_COOLDOWN_SECONDS} 秒後再試`; + } + if (err.status === 404) return '驗證服務未就緒,請重啟 Gateway 後再試'; + return err.message; +} + +function cooldownLabel(secondsLeft: number, idle: string) { + if (secondsLeft <= 0) return idle; + return `${idle}(${secondsLeft}s)`; +} + +export function ConfirmPage() { + const navigate = useNavigate(); + const location = useLocation(); + const { syncSession, refreshRoles } = useAuth(); + const { secondsLeft, canSend, startCooldown } = useResendCooldown(); + const [step, setStep] = useState('email'); + const [tenant, setTenant] = useState( + () => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT, + ); + const [challengeId, setChallengeId] = useState(''); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [resendMsg, setResendMsg] = useState(''); + + useEffect(() => { + const state = location.state as ConfirmNavState | null; + if (!state?.challenge_id || !state.email) return; + setTenant(state.tenant); + setEmail(state.email); + setChallengeId(state.challenge_id); + setStep('otp'); + startCooldown(); + }, [location.state, startCooldown]); + + const dispatchCode = async () => { + const data = await authApi.registerResume(tenant, email); + setChallengeId(data.challenge_id); + setStep('otp'); + startCooldown(); + setResendMsg('驗證碼已寄出'); + }; + + const sendCode = async (e: FormEvent) => { + e.preventDefault(); + if (!canSend) return; + setError(''); + setResendMsg(''); + setLoading(true); + try { + localStorage.setItem('tenant_slug', tenant); + await dispatchCode(); + } catch (err) { + if (err instanceof ApiError && err.code === 29604000) { + startCooldown(); + } + setError(resumeErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await authApi.registerConfirm(tenant, challengeId, code); + syncSession(); + await refreshRoles(); + navigate('/app', { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : '驗證失敗'); + } finally { + setLoading(false); + } + }; + + const resend = async () => { + if (!canSend) return; + setResendMsg(''); + setError(''); + setLoading(true); + try { + await dispatchCode(); + setResendMsg('驗證碼已重新寄出'); + } catch (err) { + if (err instanceof ApiError && err.code === 29604000) { + startCooldown(); + } + setResendMsg(resumeErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + const sendDisabled = loading || !canSend; + + if (step === 'email') { + return ( +
+

完成 Email 驗證

+

+ 輸入註冊 Email,若有待驗證帳號將寄送驗證碼({RESEND_COOLDOWN_SECONDS}{' '} + 秒內不可重複寄送)。 +

+
+ + + {error &&

{error}

} + +
+

+ 還沒註冊? 前往註冊 + {' · '} + 登入 +

+
+ ); + } + + return ( +
+

Email 驗證

+

+ 驗證碼已寄至 {email}(開發環境請到 MailHog 查看) +

+
+ + {error &&

{error}

} + +
+ + {resendMsg &&

{resendMsg}

} +

+ + {' · '} + 返回登入 +

+
+ ); +} diff --git a/frontend/src/pages/user/ForgotPasswordPage.tsx b/frontend/src/pages/user/ForgotPasswordPage.tsx new file mode 100644 index 0000000..a3e7467 --- /dev/null +++ b/frontend/src/pages/user/ForgotPasswordPage.tsx @@ -0,0 +1,228 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import * as authApi from '../../api/auth'; +import { ApiError } from '../../api/http'; +import { DEFAULT_TENANT, OTP_RESEND_COOLDOWN_SECONDS } from '../../config'; +import { + useResendCooldown, +} from '../../hooks/useResendCooldown'; + +type ForgotStep = 'email' | 'reset'; + +function forgotErrorMessage(err: unknown): string { + if (!(err instanceof ApiError)) return '無法寄送重設信'; + if (err.code === 28301000) { + return '找不到此 Email 的帳號,請確認租戶與 Email 是否正確'; + } + if (err.code === 28505000) { + return '此帳號由第三方或企業目錄登入,無法在此重設密碼'; + } + if (err.code === 29604000) { + return `寄送過於頻繁,請稍候 ${OTP_RESEND_COOLDOWN_SECONDS} 秒後再試`; + } + if (err.status === 404) return '密碼重設服務未就緒,請重啟 Gateway 後再試'; + return err.message; +} + +function cooldownLabel(secondsLeft: number, idle: string) { + if (secondsLeft <= 0) return idle; + return `${idle}(${secondsLeft}s)`; +} + +export function ForgotPasswordPage() { + const navigate = useNavigate(); + const { secondsLeft, canSend, startCooldown } = useResendCooldown(); + const [step, setStep] = useState('email'); + const [tenant, setTenant] = useState( + () => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT, + ); + const [email, setEmail] = useState(''); + const [challengeId, setChallengeId] = useState(''); + const [code, setCode] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [resendMsg, setResendMsg] = useState(''); + const [loading, setLoading] = useState(false); + + const dispatchCode = async () => { + const data = await authApi.passwordForgot(tenant, email); + setChallengeId(data.challenge_id); + setStep('reset'); + startCooldown(); + setResendMsg('重設驗證碼已寄出'); + }; + + const sendCode = async (e: FormEvent) => { + e.preventDefault(); + if (!canSend) return; + setError(''); + setResendMsg(''); + setLoading(true); + try { + localStorage.setItem('tenant_slug', tenant); + await dispatchCode(); + } catch (err) { + if (err instanceof ApiError && err.code === 29604000) { + startCooldown(); + } + setError(forgotErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + const resend = async () => { + if (!canSend) return; + setResendMsg(''); + setError(''); + setLoading(true); + try { + await dispatchCode(); + setResendMsg('驗證碼已重新寄出'); + } catch (err) { + if (err instanceof ApiError && err.code === 29604000) { + startCooldown(); + } + setResendMsg(forgotErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + const submitReset = async (e: FormEvent) => { + e.preventDefault(); + if (newPassword !== confirmPassword) { + setError('兩次輸入的新密碼不一致'); + return; + } + setError(''); + setLoading(true); + try { + await authApi.passwordReset(tenant, challengeId, code, newPassword); + navigate('/login', { + replace: true, + state: { message: '密碼已重設,請使用新密碼登入' }, + }); + } catch (err) { + setError(err instanceof ApiError ? err.message : '重設失敗'); + } finally { + setLoading(false); + } + }; + + if (step === 'email') { + return ( +
+

忘記密碼

+

+ 僅限平台註冊(Email + 密碼)的帳號。第三方或 LDAP 登入請向管理員洽詢。 +

+
+ + + {error &&

{error}

} + {resendMsg &&

{resendMsg}

} + +
+

+ 返回登入 +

+
+ ); + } + + return ( +
+

重設密碼

+

+ 驗證碼已寄至 {email}(開發環境請至 MailHog :8025 查看)。 +

+
+ + + + {error &&

{error}

} + {resendMsg &&

{resendMsg}

} + + + +
+

+ 返回登入 +

+
+ ); +} diff --git a/frontend/src/pages/user/HomePage.tsx b/frontend/src/pages/user/HomePage.tsx new file mode 100644 index 0000000..23f2856 --- /dev/null +++ b/frontend/src/pages/user/HomePage.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; +import * as memberApi from '../../api/member'; +import { ApiError } from '../../api/http'; +import { useAuth } from '../../context/AuthContext'; + +export function HomePage() { + const { roles, uid } = useAuth(); + const [me, setMe] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + memberApi + .getMe() + .then(setMe) + .catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗')); + }, []); + + if (error) return

{error}

; + if (!me) return

載入個人資料…

; + + return ( +
+

你好,{me.display_name || me.zitadel_email || uid}

+
+
+

帳號

+
+
UID
+
{me.uid}
+
登入 Email
+
{me.zitadel_email ?? '—'}
+
狀態
+
{me.status}
+
+
+
+

驗證狀態

+
+
商業聯絡 Email
+
+ {me.business_email || me.zitadel_email || '未設定'}{' '} + {me.business_email_verified ? '✓ 已驗證' : '未驗證'} +
+
商業手機
+
+ {me.business_phone || '未設定'}{' '} + {me.business_phone_verified ? '✓' : '未驗證'} +
+
雙因素 (TOTP)
+
{me.totp_enrolled ? '已啟用' : '未啟用'}
+
+
+
+

我的角色

+
    + {roles.length === 0 ? ( +
  • 尚無指派角色
  • + ) : ( + roles.map((r) => ( +
  • + {r} +
  • + )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/user/LoginPage.tsx b/frontend/src/pages/user/LoginPage.tsx new file mode 100644 index 0000000..6432cc3 --- /dev/null +++ b/frontend/src/pages/user/LoginPage.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import * as authApi from '../../api/auth'; +import { ApiError } from '../../api/http'; +import { DEFAULT_TENANT } from '../../config'; +import { useAuth } from '../../context/AuthContext'; +import * as permApi from '../../api/permission'; + +export function LoginPage() { + const navigate = useNavigate(); + const location = useLocation(); + const { syncSession, refreshRoles } = useAuth(); + const [tenant, setTenant] = useState( + () => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT, + ); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [mfaChallengeId, setMfaChallengeId] = useState(null); + const [totpCode, setTotpCode] = useState(''); + const [error, setError] = useState(''); + const [info, setInfo] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const state = location.state as { message?: string } | null; + if (state?.message) { + setInfo(state.message); + navigate(location.pathname, { replace: true, state: null }); + } + }, [location.pathname, location.state, navigate]); + + const finishLogin = async () => { + syncSession(); + await refreshRoles(); + const me = await permApi.getMyPermissions(); + const admin = permApi.isAdminRole(me.roles ?? []); + navigate(admin ? '/admin' : '/app'); + }; + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + localStorage.setItem('tenant_slug', tenant); + const result = await authApi.login(tenant, email, password); + if (result.mfa_required) { + if (!result.mfa_challenge_id) { + throw new Error('缺少 MFA challenge'); + } + setMfaChallengeId(result.mfa_challenge_id); + return; + } + await finishLogin(); + } catch (err) { + if (err instanceof ApiError && err.code === 28505000) { + setError('帳號尚未完成 Email 驗證,請先完成註冊驗證。'); + } else { + setError(err instanceof ApiError ? err.message : '登入失敗'); + } + } finally { + setLoading(false); + } + }; + + const submitMfa = async (e: FormEvent) => { + e.preventDefault(); + if (!mfaChallengeId) return; + setError(''); + setLoading(true); + try { + await authApi.loginMfaConfirm(tenant, mfaChallengeId, totpCode); + await finishLogin(); + } catch (err) { + setError(err instanceof ApiError ? err.message : '驗證碼錯誤'); + } finally { + setLoading(false); + } + }; + + const backToPassword = () => { + setMfaChallengeId(null); + setTotpCode(''); + setError(''); + }; + + if (mfaChallengeId) { + return ( +
+

雙因素驗證

+

請輸入驗證器 App 的 6 位數驗證碼,或備援碼。

+
+ + {error &&

{error}

} + + +
+
+ ); + } + + return ( +
+

登入

+
+ + + + {error &&

{error}

} + {info &&

{info}

} + +
+

+ 還沒有帳號? 註冊 + {' · '} + 尚未完成驗證? + {' · '} + 忘記密碼? +

+
+ ); +} diff --git a/frontend/src/pages/user/ProfilePage.tsx b/frontend/src/pages/user/ProfilePage.tsx new file mode 100644 index 0000000..13846ff --- /dev/null +++ b/frontend/src/pages/user/ProfilePage.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import * as memberApi from '../../api/member'; +import { ApiError } from '../../api/http'; + +export function ProfilePage() { + const [displayName, setDisplayName] = useState(''); + const [language, setLanguage] = useState('zh-TW'); + const [currency, setCurrency] = useState('TWD'); + const [phone, setPhone] = useState(''); + const [msg, setMsg] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + memberApi.getMe().then((m) => { + setDisplayName(m.display_name ?? ''); + setLanguage(m.language ?? 'zh-TW'); + setCurrency(m.currency ?? 'TWD'); + setPhone(m.phone ?? ''); + }); + }, []); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setMsg(''); + setLoading(true); + try { + await memberApi.updateMe({ + display_name: displayName, + language, + currency, + phone: phone || undefined, + }); + setMsg('已儲存'); + } catch (err) { + setError(err instanceof ApiError ? err.message : '儲存失敗'); + } finally { + setLoading(false); + } + }; + + return ( +
+

個人資料

+
+ + + + + {error &&

{error}

} + {msg &&

{msg}

} + +
+
+ ); +} diff --git a/frontend/src/pages/user/RegisterPage.tsx b/frontend/src/pages/user/RegisterPage.tsx new file mode 100644 index 0000000..d36ff9e --- /dev/null +++ b/frontend/src/pages/user/RegisterPage.tsx @@ -0,0 +1,99 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import * as authApi from '../../api/auth'; +import { ApiError } from '../../api/http'; +import { DEFAULT_INVITE, DEFAULT_TENANT } from '../../config'; + +export function RegisterPage() { + const navigate = useNavigate(); + const [tenant, setTenant] = useState( + () => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT, + ); + const [invite, setInvite] = useState(DEFAULT_INVITE); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + localStorage.setItem('tenant_slug', tenant); + const data = await authApi.register({ + tenant_slug: tenant, + invite_code: invite, + email, + password, + display_name: displayName || undefined, + language: 'zh-TW', + }); + navigate('/register/confirm', { + state: { tenant, email, challenge_id: data.challenge_id }, + }); + } catch (err) { + if (err instanceof ApiError && err.code === 28303000) { + setError('此 Email 已註冊且已完成驗證,請直接登入'); + } else if (err instanceof ApiError && err.code === 28501000) { + setError('密碼錯誤,請確認後再試'); + } else { + setError(err instanceof ApiError ? err.message : '註冊失敗'); + } + } finally { + setLoading(false); + } + }; + + return ( +
+

註冊

+
+ + + + + + {error &&

{error}

} + +
+

+ 已有帳號? 登入 + {' · '} + 尚未完成驗證? +

+
+ ); +} diff --git a/frontend/src/pages/user/SecurityPage.tsx b/frontend/src/pages/user/SecurityPage.tsx new file mode 100644 index 0000000..b5b3b8d --- /dev/null +++ b/frontend/src/pages/user/SecurityPage.tsx @@ -0,0 +1,322 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import * as memberApi from '../../api/member'; +import { ApiError } from '../../api/http'; +import { TotpQrPanel } from '../../components/TotpQrPanel'; + +export function SecurityPage() { + const [me, setMe] = useState(null); + const [totp, setTotp] = useState(null); + const [enrollInfo, setEnrollInfo] = useState( + null, + ); + const [backupCodes, setBackupCodes] = useState([]); + + const [emailTarget, setEmailTarget] = useState(''); + const [emailChallenge, setEmailChallenge] = useState(''); + const [emailCode, setEmailCode] = useState(''); + + const [phoneTarget, setPhoneTarget] = useState(''); + const [phoneChallenge, setPhoneChallenge] = useState(''); + const [phoneCode, setPhoneCode] = useState(''); + + const [totpCode, setTotpCode] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [msg, setMsg] = useState(''); + const [error, setError] = useState(''); + + const reload = async () => { + const [profile, totpStatus] = await Promise.all([ + memberApi.getMe().catch(() => null), + memberApi.getTOTPStatus().catch(() => null), + ]); + setMe(profile); + setTotp(totpStatus); + if (profile?.zitadel_email && !emailTarget) { + setEmailTarget(profile.zitadel_email); + } + }; + + useEffect(() => { + reload(); + }, []); + + const showErr = (e: unknown) => + setError(e instanceof ApiError ? e.message : '操作失敗'); + + const startEmail = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setMsg(''); + try { + const r = await memberApi.startEmailVerification(emailTarget); + setEmailChallenge(r.challenge_id); + setMsg('Email 驗證碼已寄出'); + } catch (e) { + showErr(e); + } + }; + + const confirmEmail = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + try { + await memberApi.confirmEmailVerification(emailChallenge, emailCode); + setMsg('商業 Email 已驗證'); + await reload(); + } catch (e) { + showErr(e); + } + }; + + const startPhone = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setMsg(''); + try { + const r = await memberApi.startPhoneVerification(phoneTarget); + setPhoneChallenge(r.challenge_id); + setMsg('簡訊驗證碼已送出(開發環境請查 Redis / mock)'); + } catch (e) { + showErr(e); + } + }; + + const confirmPhone = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + try { + await memberApi.confirmPhoneVerification(phoneChallenge, phoneCode); + setMsg('商業手機已驗證'); + } catch (e) { + showErr(e); + } + }; + + const startTotp = async () => { + setError(''); + setEnrollInfo(null); + try { + const r = await memberApi.startTOTPEnroll(); + setEnrollInfo(r); + setMsg('請用 Authenticator 掃描 QR Code,再輸入 6 位驗證碼完成綁定'); + } catch (e) { + showErr(e); + } + }; + + const confirmTotp = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + try { + const r = await memberApi.confirmTOTPEnroll(totpCode); + setBackupCodes(r.backup_codes); + setMsg('TOTP 已啟用,請妥善保存備援碼'); + setEnrollInfo(null); + setTotpCode(''); + await reload(); + } catch (e) { + showErr(e); + } + }; + + const disableTotp = async () => { + if (!confirm('確定停用 TOTP?')) return; + setError(''); + try { + await memberApi.disableTOTP(); + setEnrollInfo(null); + setBackupCodes([]); + await reload(); + setMsg('TOTP 已停用'); + } catch (e) { + showErr(e); + } + }; + + const changePassword = async (e: FormEvent) => { + e.preventDefault(); + if (newPassword !== confirmPassword) { + setError('兩次輸入的新密碼不一致'); + return; + } + setError(''); + setMsg(''); + try { + await memberApi.changePassword(currentPassword, newPassword); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setMsg('密碼已更新'); + } catch (e) { + showErr(e); + } + }; + + const canChangePassword = me?.origin === 'platform_native'; + + return ( +
+

安全設定

+ {msg &&

{msg}

} + {error &&

{error}

} + +
+

登入密碼

+ {canChangePassword ? ( +
+ + + + +
+ ) : ( +

+ 您的帳號由第三方或企業目錄登入,無法在此變更密碼。 +

+ )} +
+ +
+

商業聯絡 Email

+

+ 註冊時驗證的是登入信箱(ZITADEL);此處為業務聯絡信箱(通知、帳務等)。 + 若與註冊信箱相同,完成註冊 OTP 後會自動標為已驗證。 +

+ {me?.business_email_verified ? ( +

+ 已驗證:{me.business_email || me.zitadel_email} +

+ ) : ( + <> + {me?.zitadel_email && ( +

+ 登入信箱:{me.zitadel_email} + {me.business_email && me.business_email !== me.zitadel_email + ? ` · 目前商業信箱:${me.business_email}(未驗證)` + : ''} +

+ )} +
+ setEmailTarget(e.target.value)} + required + /> + +
+ {emailChallenge && ( +
+ setEmailCode(e.target.value)} + maxLength={6} + /> + +
+ )} + + )} +
+ +
+

商業手機驗證

+
+ setPhoneTarget(e.target.value)} + required + /> + +
+ {phoneChallenge && ( +
+ setPhoneCode(e.target.value)} + maxLength={6} + /> + +
+ )} +
+ +
+

雙因素驗證 (TOTP)

+

+ 狀態:{totp?.enrolled ? '已啟用' : '未啟用'} + {totp?.enrolled && + ` · 備援碼剩餘 ${totp.backup_codes_remaining} 組`} +

+ {!totp?.enrolled ? ( + <> + + {enrollInfo && ( + + )} + {enrollInfo && ( +
+ setTotpCode(e.target.value)} + /> + +
+ )} + + ) : ( + + )} + {backupCodes.length > 0 && ( +
{backupCodes.join('\n')}
+ )} +
+
+ ); +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..7f42e5f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..8aef668 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8888', + changeOrigin: true, + }, + }, + }, +}); diff --git a/generate/api/auth.api b/generate/api/auth.api index 7a8db54..cf7fbf4 100644 --- a/generate/api/auth.api +++ b/generate/api/auth.api @@ -29,6 +29,32 @@ type ( ChallengeID string `json:"challenge_id" validate:"required"` // 註冊流程的 OTP challenge ID } + RegisterResumeReq { + TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug + Email string `json:"email" validate:"required,email"` // 註冊 Email + } + + PasswordForgotReq { + TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug + Email string `json:"email" validate:"required,email"` // 登入 Email + } + + PasswordForgotData { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` + } + + PasswordResetReq { + TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug + ChallengeID string `json:"challenge_id" validate:"required"` // 忘記密碼 OTP challenge ID + Code string `json:"code" validate:"required,len=6"` // 6 位數 OTP + NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼 + } + + PasswordResetData { + OK bool `json:"ok"` + } + AuthTokenData { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` @@ -64,6 +90,23 @@ type ( Password string `json:"password" validate:"required,min=8,max=128"` // 密碼(8-128 字元) } + LoginData { + AccessToken string `json:"access_token,optional"` + RefreshToken string `json:"refresh_token,optional"` + ExpiresIn int64 `json:"expires_in,optional"` + UID string `json:"uid,optional"` + TokenType string `json:"token_type,optional"` + MFARequired bool `json:"mfa_required,optional"` + MFAChallengeID string `json:"mfa_challenge_id,optional"` + MFAExpiresIn int `json:"mfa_expires_in,optional"` + } + + LoginMFAConfirmReq { + TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug + ChallengeID string `json:"challenge_id" validate:"required"` // 密碼登入後回傳的 MFA challenge ID + Code string `json:"code" validate:"required,len=6"` // TOTP 或備援碼(6 位數) + } + TokenRefreshReq { RefreshToken string `json:"refresh_token" validate:"required"` // 先前核發的 refresh token } @@ -107,6 +150,12 @@ type ( Data AuthTokenData `json:"data"` } + LoginOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data LoginData `json:"data"` + } + RegisterSocialStartOKStatus { Code int64 `json:"code"` Message string `json:"message"` @@ -124,6 +173,18 @@ type ( Message string `json:"message"` Data LogoutData `json:"data"` } + + PasswordForgotOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data PasswordForgotData `json:"data"` + } + + PasswordResetOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data PasswordResetData `json:"data"` + } ) @server( @@ -229,6 +290,77 @@ service gateway { @handler registerResend post /register/resend (RegisterResendReq) returns (RegisterData) + @doc "恢復未完成註冊(依 Email 重寄 registration OTP)" + /* + @respdoc-200 (RegisterOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant / 待驗證 member 不存在(Member scope) + ) // 資源不存在 + @respdoc-409 ( + 28309000: (APIErrorStatus) 帳號已完成驗證(Auth scope) + ) // 資源狀態衝突 + @respdoc-429 ( + 28604000: (APIErrorStatus) OTP 重送冷卻 + ) // 請求過於頻繁 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ + @handler registerResume + post /register/resume (RegisterResumeReq) returns (RegisterData) + + @doc "忘記密碼:寄送重設 OTP(僅 platform_native 平台帳號)" + /* + @respdoc-200 (PasswordForgotOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 + @respdoc-403 ( + 28505000: (APIErrorStatus) 外部身份帳號不可重設密碼(Auth scope) + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant / member 不存在(Member scope) + ) // 資源不存在 + @respdoc-429 ( + 29604000: (APIErrorStatus) OTP 重送冷卻 + ) // 請求過於頻繁 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ + @handler passwordForgot + post /password/forgot (PasswordForgotReq) returns (PasswordForgotData) + + @doc "忘記密碼:驗證 OTP 並重設密碼(僅 platform_native)" + /* + @respdoc-200 (PasswordResetOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 + @respdoc-403 ( + 28505000: (APIErrorStatus) OTP 無效(Auth scope) + 29505000: (APIErrorStatus) OTP 無效(Member scope) + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant / OTP challenge 不存在(Member scope) + ) // 資源不存在 + @respdoc-502 ( + 28802000: (APIErrorStatus) ZITADEL 第三方錯誤 + ) // 第三方服務錯誤 + */ + @handler passwordReset + post /password/reset (PasswordResetReq) returns (PasswordResetData) + @doc "Social 註冊:建立 session 並回傳 OAuth URL" /* @respdoc-200 (RegisterSocialStartOKStatus) // 成功(code=102000) @@ -299,9 +431,9 @@ service gateway { @handler registerSocialCallback get /register/social/callback (RegisterSocialCallbackReq) returns (AuthTokenData) - @doc "Email + 密碼登入(ZITADEL ROPG → CloudEP JWT)" + @doc "Email + 密碼登入(ZITADEL ROPG → CloudEP JWT;若已啟用 TOTP 則回傳 MFA challenge)" /* - @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000) + @respdoc-200 (LoginOKStatus) // 成功(code=102000);mfa_required=true 時僅含 challenge @respdoc-400 ( 10101000: (APIErrorStatus) 參數格式錯誤 10104000: (APIErrorStatus) 缺少必填欄位 @@ -327,7 +459,33 @@ service gateway { ) // 第三方服務錯誤 */ @handler login - post /login (LoginReq) returns (AuthTokenData) + post /login (LoginReq) returns (LoginData) + + @doc "確認登入 MFA(TOTP / 備援碼)並核發 JWT" + /* + @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 + @respdoc-403 ( + 28505000: (APIErrorStatus) TOTP 無效 / challenge tenant 不符(Auth scope) + 29505000: (APIErrorStatus) OTP 無效(Member scope) + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant 不存在(Member scope) + 28301000: (APIErrorStatus) login mfa challenge 不存在(Auth scope) + ) // 資源不存在 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ + @handler loginMfaConfirm + post /login/mfa (LoginMFAConfirmReq) returns (AuthTokenData) @doc "以 refresh_token 換發新的 access/refresh token" /* diff --git a/generate/api/member.api b/generate/api/member.api index ddb9a07..4b9aada 100644 --- a/generate/api/member.api +++ b/generate/api/member.api @@ -29,6 +29,15 @@ type ( Phone string `json:"phone,optional"` // 聯絡電話 E.164 格式(可選) } + ChangePasswordReq { + CurrentPassword string `json:"current_password" validate:"required,min=8,max=128"` // 目前密碼 + NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼 + } + + ChangePasswordData { + OK bool `json:"ok"` + } + VerificationStartReq { Target string `json:"target"` // 驗證目標:email 地址或 E.164 手機號(依端點而定) } @@ -165,6 +174,29 @@ service gateway { @handler updateMemberMe patch /me (UpdateMemberMeReq) returns (MemberMeData) + @doc "變更登入密碼(僅 platform_native 平台帳號)" + /* + @respdoc-200 (EmptyOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 + @respdoc-401 ( + 29501000: (APIErrorStatus) 目前密碼錯誤 + ) // 未授權 + @respdoc-403 ( + 29505000: (APIErrorStatus) 外部身份帳號不可變更密碼(Member scope) + ) // 禁止存取 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + @respdoc-502 ( + 29802000: (APIErrorStatus) ZITADEL 第三方錯誤 + ) // 第三方服務錯誤 + */ + @handler changePassword + post /me/password (ChangePasswordReq) returns (ChangePasswordData) + @doc "開始業務 email 驗證" /* @respdoc-200 (VerificationStartOKStatus) // 成功(code=102000) diff --git a/internal/handler/auth/login_mfa_confirm_handler.go b/internal/handler/auth/login_mfa_confirm_handler.go new file mode 100644 index 0000000..23dcab7 --- /dev/null +++ b/internal/handler/auth/login_mfa_confirm_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "net/http" + + "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 確認登入 MFA(TOTP / 備援碼)並核發 JWT +func LoginMfaConfirmHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.LoginMFAConfirmReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewLoginMfaConfirmLogic(r.Context(), svcCtx) + data, err := l.LoginMfaConfirm(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/password_forgot_handler.go b/internal/handler/auth/password_forgot_handler.go new file mode 100644 index 0000000..d73a7c2 --- /dev/null +++ b/internal/handler/auth/password_forgot_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "net/http" + + "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 忘記密碼:寄送重設 OTP(僅 platform_native 平台帳號) +func PasswordForgotHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PasswordForgotReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewPasswordForgotLogic(r.Context(), svcCtx) + data, err := l.PasswordForgot(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/password_reset_handler.go b/internal/handler/auth/password_reset_handler.go new file mode 100644 index 0000000..3f68319 --- /dev/null +++ b/internal/handler/auth/password_reset_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "net/http" + + "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 忘記密碼:驗證 OTP 並重設密碼(僅 platform_native) +func PasswordResetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PasswordResetReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewPasswordResetLogic(r.Context(), svcCtx) + data, err := l.PasswordReset(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/register_resume_handler.go b/internal/handler/auth/register_resume_handler.go new file mode 100644 index 0000000..e4d8a5e --- /dev/null +++ b/internal/handler/auth/register_resume_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "net/http" + + "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 恢復未完成註冊(重新寄送 registration OTP) +func RegisterResumeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.RegisterResumeReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewRegisterResumeLogic(r.Context(), svcCtx) + data, err := l.RegisterResume(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/member/change_password_handler.go b/internal/handler/member/change_password_handler.go new file mode 100644 index 0000000..6e981f5 --- /dev/null +++ b/internal/handler/member/change_password_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 變更登入密碼(僅 platform_native 平台帳號) +func ChangePasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ChangePasswordReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := member.NewChangePasswordLogic(r.Context(), svcCtx) + data, err := l.ChangePassword(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 06d3d25..cad6f0e 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -20,11 +20,17 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( []rest.Route{ { - // Email + 密碼登入(ZITADEL ROPG → CloudEP JWT) + // Email + 密碼登入(ZITADEL ROPG → CloudEP JWT;若已啟用 TOTP 則回傳 MFA challenge) Method: http.MethodPost, Path: "/login", Handler: auth.LoginHandler(serverCtx), }, + { + // 確認登入 MFA(TOTP / 備援碼)並核發 JWT + Method: http.MethodPost, + Path: "/login/mfa", + Handler: auth.LoginMfaConfirmHandler(serverCtx), + }, { // Social 登入 OAuth callback Method: http.MethodGet, @@ -37,6 +43,18 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/login/social/start", Handler: auth.LoginSocialStartHandler(serverCtx), }, + { + // 忘記密碼:寄送重設 OTP(僅 platform_native 平台帳號) + Method: http.MethodPost, + Path: "/password/forgot", + Handler: auth.PasswordForgotHandler(serverCtx), + }, + { + // 忘記密碼:驗證 OTP 並重設密碼(僅 platform_native) + Method: http.MethodPost, + Path: "/password/reset", + Handler: auth.PasswordResetHandler(serverCtx), + }, { // Email 註冊(建立 ZITADEL + member,寄 registration OTP) Method: http.MethodPost, @@ -55,6 +73,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/register/resend", Handler: auth.RegisterResendHandler(serverCtx), }, + { + // 恢復未完成註冊(依 Email 重寄 registration OTP) + Method: http.MethodPost, + Path: "/register/resume", + Handler: auth.RegisterResumeHandler(serverCtx), + }, { // Social 註冊 OAuth callback Method: http.MethodGet, @@ -114,6 +138,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/me", Handler: member.UpdateMemberMeHandler(serverCtx), }, + { + // 變更登入密碼(僅 platform_native 平台帳號) + Method: http.MethodPost, + Path: "/me/password", + Handler: member.ChangePasswordHandler(serverCtx), + }, { // TOTP 狀態 Method: http.MethodGet, diff --git a/internal/library/zitadel/client.go b/internal/library/zitadel/client.go index ecb968b..082b9d4 100644 --- a/internal/library/zitadel/client.go +++ b/internal/library/zitadel/client.go @@ -11,6 +11,8 @@ import ( "strings" ) +const fieldPassword = "password" + // Client calls ZITADEL Management API v2 and OAuth token endpoints. type Client struct { conf Conf @@ -131,8 +133,8 @@ func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest "email": req.Email, "isVerified": req.EmailVerified, }, - "password": map[string]any{ - "password": req.Password, + fieldPassword: map[string]any{ + fieldPassword: req.Password, "changeRequired": false, }, } @@ -152,6 +154,28 @@ func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest return &CreateHumanUserResult{UserID: out.UserID}, nil } +// SetUserPassword sets a human user's password via management API (PAT). +// When currentPassword is non-empty, ZITADEL validates the old password first. +func (c *Client) SetUserPassword(ctx context.Context, userID, newPassword, currentPassword string) error { + if c == nil { + return ErrNotConfigured + } + if userID == "" || newPassword == "" { + return fmt.Errorf("zitadel: user id and new password are required") + } + body := map[string]any{ + "newPassword": map[string]any{ + fieldPassword: newPassword, + "changeRequired": false, + }, + } + if strings.TrimSpace(currentPassword) != "" { + body["currentPassword"] = currentPassword + } + endpoint := c.apiBase + "/v2/users/" + url.PathEscape(userID) + "/password" + return c.doJSON(ctx, http.MethodPost, endpoint, c.serviceAuth(), body, http.StatusOK, nil) +} + // DeactivateUser disables login for the user via POST /v2/users/{id}/deactivate. func (c *Client) DeactivateUser(ctx context.Context, userID string) error { if c == nil { @@ -163,28 +187,37 @@ func (c *Client) DeactivateUser(ctx context.Context, userID string) error { return c.doJSON(ctx, http.MethodPost, c.apiBase+"/v2/users/"+url.PathEscape(userID)+"/deactivate", c.serviceAuth(), map[string]any{}, http.StatusOK, nil) } -// TokenResult holds OAuth tokens from a successful password grant. +// TokenResult holds OAuth tokens from a successful password grant, or session-verified +// identity fields when OAuth ROPG is not configured (ZITADEL v2 default). type TokenResult struct { AccessToken string IDToken string ExpiresIn int TokenType string + // Subject and Email are set by session-based verification (no OAuth tokens). + Subject string + Email string } -// VerifyPassword checks credentials using the OAuth2 resource-owner password grant. +// VerifyPassword checks email/password credentials. Uses OAuth2 ROPG when OAuthClientID +// and OAuthClientSecret are configured; otherwise uses ZITADEL v2 Sessions API (PAT). func (c *Client) VerifyPassword(ctx context.Context, username, password string) (*TokenResult, error) { if c == nil { return nil, ErrNotConfigured } - if c.conf.OAuthClientID == "" || c.conf.OAuthClientSecret == "" { - return nil, fmt.Errorf("zitadel: oauth client credentials are required for password verification") + if c.conf.OAuthClientID != "" && c.conf.OAuthClientSecret != "" { + return c.verifyPasswordROPG(ctx, username, password) } + return c.verifyPasswordSession(ctx, username, password) +} + +func (c *Client) verifyPasswordROPG(ctx context.Context, username, password string) (*TokenResult, error) { form := url.Values{} - form.Set("grant_type", "password") + form.Set("grant_type", fieldPassword) form.Set("client_id", c.conf.OAuthClientID) form.Set("client_secret", c.conf.OAuthClientSecret) form.Set("username", username) - form.Set("password", password) + form.Set(fieldPassword, password) form.Set("scope", "openid profile email") return c.postToken(ctx, form) @@ -228,7 +261,11 @@ func (c *Client) doJSON(ctx context.Context, method, endpoint, auth string, body if resp.StatusCode == http.StatusConflict { return ErrUserAlreadyExists } - if resp.StatusCode != wantStatus { + // Accept any 2xx as success. ZITADEL v2 returns 201 for create endpoints + // (e.g. POST /v2/users/human) and 200 for most others; wantStatus is kept + // for caller intent but we don't reject other 2xx responses. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _ = wantStatus return fmt.Errorf("zitadel: %s %s: status %d: %s", method, endpoint, resp.StatusCode, truncateBody(raw)) } if out != nil && len(raw) > 0 { diff --git a/internal/library/zitadel/client_test.go b/internal/library/zitadel/client_test.go index 4b11e7f..e8c6040 100644 --- a/internal/library/zitadel/client_test.go +++ b/internal/library/zitadel/client_test.go @@ -119,6 +119,33 @@ func TestVerifyPassword(t *testing.T) { require.ErrorIs(t, err, zitadel.ErrInvalidCredentials) } +func TestVerifyPasswordSession(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v2/sessions": + _, _ = w.Write([]byte(`{"sessionId":"sess-1"}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/v2/sessions/sess-1": + _, _ = w.Write([]byte(`{"sessionToken":"tok"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v2/sessions/sess-1": + _, _ = w.Write([]byte(`{"session":{"factors":{"user":{"id":"user-42","loginName":"alice@example.com"},"password":{"verifiedAt":"2026-01-01T00:00:00Z"}}}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + c, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL, APIBase: srv.URL, ServiceUserToken: testPAT}) + require.NoError(t, err) + + tok, err := c.VerifyPassword(context.Background(), "alice@example.com", "ok") + require.NoError(t, err) + require.Equal(t, "user-42", tok.Subject) + require.Equal(t, "alice@example.com", tok.Email) + require.Empty(t, tok.AccessToken) +} + func TestNewClientDisabledWhenIssuerEmpty(t *testing.T) { t.Parallel() c, err := zitadel.NewClient(zitadel.Conf{}) diff --git a/internal/library/zitadel/session.go b/internal/library/zitadel/session.go new file mode 100644 index 0000000..e0a30c0 --- /dev/null +++ b/internal/library/zitadel/session.go @@ -0,0 +1,127 @@ +package zitadel + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// verifyPasswordSession checks credentials via ZITADEL v2 Sessions API (PAT-backed). +// Used when OAuth resource-owner password grant is unavailable (default in ZITADEL v2). +func (c *Client) verifyPasswordSession(ctx context.Context, loginName, password string) (*TokenResult, error) { + if c.conf.ServiceUserToken == "" { + return nil, fmt.Errorf("zitadel: service user token is required for session password verification") + } + loginName = strings.TrimSpace(loginName) + if loginName == "" || password == "" { + return nil, ErrInvalidCredentials + } + + var created struct { + SessionID string `json:"sessionId"` + } + if err := c.doSessionJSON(ctx, http.MethodPost, c.apiBase+"/v2/sessions", map[string]any{ + "checks": map[string]any{ + "user": map[string]any{"loginName": loginName}, + }, + }, &created); err != nil { + if isSessionUserNotFound(err) { + return nil, ErrInvalidCredentials + } + return nil, err + } + if created.SessionID == "" { + return nil, fmt.Errorf("zitadel: create session: empty session id") + } + + if err := c.doSessionJSON(ctx, http.MethodPatch, c.apiBase+"/v2/sessions/"+created.SessionID, map[string]any{ + "checks": map[string]any{ + fieldPassword: map[string]any{fieldPassword: password}, + }, + }, nil); err != nil { + if isSessionPasswordInvalid(err) { + return nil, ErrInvalidCredentials + } + return nil, err + } + + var got struct { + Session struct { + Factors struct { + User struct { + ID string `json:"id"` + LoginName string `json:"loginName"` + } `json:"user"` + Password struct { + VerifiedAt string `json:"verifiedAt"` + } `json:"password"` + } `json:"factors"` + } `json:"session"` + } + if err := c.doSessionJSON(ctx, http.MethodGet, c.apiBase+"/v2/sessions/"+created.SessionID, nil, &got); err != nil { + return nil, err + } + if got.Session.Factors.Password.VerifiedAt == "" || got.Session.Factors.User.ID == "" { + return nil, ErrInvalidCredentials + } + return &TokenResult{ + Subject: got.Session.Factors.User.ID, + Email: got.Session.Factors.User.LoginName, + }, nil +} + +func (c *Client) doSessionJSON(ctx context.Context, method, endpoint string, body, out any) error { + var r io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("zitadel: marshal request: %w", err) + } + r = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, r) + if err != nil { + return fmt.Errorf("zitadel: new request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", c.serviceAuth()) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("zitadel: %s %s: %w", method, endpoint, err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("zitadel: read response body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("zitadel: %s %s: status %d: %s", method, endpoint, resp.StatusCode, truncateBody(raw)) + } + if out != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("zitadel: decode response: %w", err) + } + } + return nil +} + +func isSessionUserNotFound(err error) bool { + return err != nil && strings.Contains(err.Error(), "status 404") +} + +func isSessionPasswordInvalid(err error) bool { + if err == nil { + return false + } + s := err.Error() + return strings.Contains(s, "status 400") && strings.Contains(s, "Password is invalid") +} diff --git a/internal/logic/auth/login_helper.go b/internal/logic/auth/login_helper.go index 218511f..2636bd3 100644 --- a/internal/logic/auth/login_helper.go +++ b/internal/logic/auth/login_helper.go @@ -3,6 +3,7 @@ package auth import ( "context" "strings" + "time" errs "gateway/internal/library/errors" "gateway/internal/library/errors/code" @@ -60,6 +61,12 @@ func zitadelIdentityFromToken(ctx context.Context, client *zitadel.Client, tok * if tok == nil { return nil, errb.SvcThirdParty("empty token result") } + if tok.Subject != "" { + return &zitadel.IDTokenClaims{ + Sub: tok.Subject, + Email: tok.Email, + }, nil + } if tok.IDToken != "" { claims, err := zitadel.ParseIDTokenClaims(tok.IDToken) if err != nil { @@ -124,3 +131,89 @@ func isMemberNotFound(err error) bool { e := errs.FromError(err) return e != nil && e.Category() == code.ResNotFound } + +func loginDataFromTokens(tokens *types.AuthTokenData) *types.LoginData { + if tokens == nil { + return nil + } + return &types.LoginData{ + AccessToken: tokens.AccessToken, + RefreshToken: tokens.RefreshToken, + ExpiresIn: tokens.ExpiresIn, + UID: tokens.UID, + TokenType: tokens.TokenType, + } +} + +func beginLoginMFA(ctx context.Context, sc *svc.ServiceContext, tenantID, tenantSlug, uid string) (*types.LoginData, error) { + if sc.AuthLoginMFAChallenge == nil { + return nil, errb.SysNotImplemented("login mfa challenge not configured") + } + if sc.MemberTOTP == nil { + return nil, errb.SysNotImplemented("member TOTP not configured") + } + ttl := time.Duration(sc.Config.Auth.Defaults().RegistrationSessionTTLSeconds) * time.Second + challenge, err := sc.AuthLoginMFAChallenge.Create(ctx, &domauth.CreateLoginMFAChallengeRequest{ + TenantID: tenantID, + TenantSlug: tenantSlug, + UID: uid, + TTL: ttl, + }) + if err != nil { + return nil, err + } + return &types.LoginData{ + MFARequired: true, + MFAChallengeID: challenge.ChallengeID, + MFAExpiresIn: challenge.ExpiresIn, + }, nil +} + +func confirmLoginMFA(ctx context.Context, sc *svc.ServiceContext, tenantSlug, challengeID, totpCode string) (*types.AuthTokenData, error) { + if sc.AuthLoginMFAChallenge == nil { + return nil, errb.SysNotImplemented("login mfa challenge not configured") + } + if sc.MemberTOTP == nil { + return nil, errb.SysNotImplemented("member TOTP not configured") + } + + tenant, err := resolveTenant(ctx, sc, tenantSlug) + if err != nil { + return nil, err + } + + challenge, err := sc.AuthLoginMFAChallenge.Get(ctx, challengeID) + if err != nil { + return nil, err + } + if challenge.TenantID != tenant.TenantID { + return nil, errb.AuthForbidden("login mfa challenge tenant mismatch") + } + if !strings.EqualFold(strings.TrimSpace(challenge.TenantSlug), strings.TrimSpace(tenantSlug)) { + return nil, errb.AuthForbidden("login mfa challenge tenant mismatch") + } + + member, err := sc.MemberProfile.GetByUID(ctx, &dommember.GetMemberRequest{ + TenantID: challenge.TenantID, + UID: challenge.UID, + }) + if err != nil { + return nil, err + } + if err := ensureLoginEligible(member.Status); err != nil { + return nil, err + } + if !member.TOTPEnrolled { + return nil, errb.ResInvalidState("totp not enrolled").WithCause(memberdom.ErrTOTPNotEnrolled) + } + + if err := sc.MemberTOTP.VerifyCode(ctx, challenge.TenantID, challenge.UID, strings.TrimSpace(totpCode)); err != nil { + return nil, err + } + + if err := sc.AuthLoginMFAChallenge.Delete(ctx, challengeID); err != nil { + return nil, err + } + + return issueAuthToken(ctx, sc, challenge.TenantID, challenge.UID) +} diff --git a/internal/logic/auth/login_logic.go b/internal/logic/auth/login_logic.go index 6899cee..0a875a2 100644 --- a/internal/logic/auth/login_logic.go +++ b/internal/logic/auth/login_logic.go @@ -24,7 +24,7 @@ func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic } } -func (l *LoginLogic) Login(req *types.LoginReq) (*types.AuthTokenData, error) { +func (l *LoginLogic) Login(req *types.LoginReq) (*types.LoginData, error) { if err := requireLoginDeps(l.svcCtx); err != nil { return nil, err } @@ -54,5 +54,13 @@ func (l *LoginLogic) Login(req *types.LoginReq) (*types.AuthTokenData, error) { logx.WithContext(l.ctx).Infof("login: zitadel email mismatch for uid=%s", member.UID) } - return issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, member.UID) + if member.TOTPEnrolled { + return beginLoginMFA(l.ctx, l.svcCtx, tenant.TenantID, req.TenantSlug, member.UID) + } + + tokens, err := issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, member.UID) + if err != nil { + return nil, err + } + return loginDataFromTokens(tokens), nil } diff --git a/internal/logic/auth/login_mfa_confirm_logic.go b/internal/logic/auth/login_mfa_confirm_logic.go new file mode 100644 index 0000000..e14075f --- /dev/null +++ b/internal/logic/auth/login_mfa_confirm_logic.go @@ -0,0 +1,38 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "context" + + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LoginMfaConfirmLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// 確認登入 MFA(TOTP / 備援碼)並核發 JWT +func NewLoginMfaConfirmLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginMfaConfirmLogic { + return &LoginMfaConfirmLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *LoginMfaConfirmLogic) LoginMfaConfirm(req *types.LoginMFAConfirmReq) (*types.AuthTokenData, error) { + if err := requireLoginDeps(l.svcCtx); err != nil { + return nil, err + } + if req == nil { + return nil, errb.InputMissingRequired("request body is required") + } + return confirmLoginMFA(l.ctx, l.svcCtx, req.TenantSlug, req.ChallengeID, req.Code) +} diff --git a/internal/logic/auth/password_forgot_logic.go b/internal/logic/auth/password_forgot_logic.go new file mode 100644 index 0000000..e49a811 --- /dev/null +++ b/internal/logic/auth/password_forgot_logic.go @@ -0,0 +1,65 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "context" + + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PasswordForgotLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPasswordForgotLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PasswordForgotLogic { + return &PasswordForgotLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *PasswordForgotLogic) PasswordForgot(req *types.PasswordForgotReq) (*types.PasswordForgotData, error) { + if err := requireRegistrationDeps(l.svcCtx); err != nil { + return nil, err + } + if req == nil { + return nil, errb.InputMissingRequired("request body is required") + } + + tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug) + if err != nil { + return nil, err + } + + email := normalizeLoginEmail(req.Email) + member, err := l.svcCtx.MemberProfile.GetByZitadelEmail(l.ctx, tenant.TenantID, email) + if err != nil { + if isMemberNotFound(err) { + return nil, errb.ResNotFound("member", email) + } + return nil, err + } + if err := ensurePlatformNativePassword(member); err != nil { + return nil, err + } + if err := ensurePasswordResetEligible(member.Status); err != nil { + return nil, err + } + if member.ZitadelUserID == "" { + return nil, errb.ResInvalidState("member has no zitadel identity") + } + + target := email + if member.ZitadelEmail != "" { + target = normalizeLoginEmail(member.ZitadelEmail) + } + return sendPasswordResetOTP(l.ctx, l.svcCtx, tenant.TenantID, member.UID, target) +} diff --git a/internal/logic/auth/password_helper.go b/internal/logic/auth/password_helper.go new file mode 100644 index 0000000..b596cc0 --- /dev/null +++ b/internal/logic/auth/password_helper.go @@ -0,0 +1,43 @@ +package auth + +import ( + memberenum "gateway/internal/model/member/domain/enum" + dommember "gateway/internal/model/member/domain/usecase" +) + +func passwordResetPurpose() memberenum.OTPPurpose { + return memberenum.OTPPurposePasswordReset +} + +func ensurePlatformNativePassword(member *dommember.MemberDTO) error { + if member == nil { + return errb.ResNotFound("member", "") + } + switch member.Origin { + case memberenum.MemberOriginPlatformNative: + return nil + case memberenum.MemberOriginOIDC: + return errb.AuthForbidden("social login accounts cannot change password here") + case memberenum.MemberOriginLDAP: + return errb.AuthForbidden("ldap accounts cannot change password here") + case memberenum.MemberOriginSCIM: + return errb.AuthForbidden("scim provisioned accounts cannot change password here") + default: + return errb.AuthForbidden("account cannot change password here") + } +} + +func ensurePasswordResetEligible(status memberenum.MemberStatus) error { + switch status { + case memberenum.MemberStatusActive: + return nil + case memberenum.MemberStatusUnverified: + return errb.AuthForbidden("account is not verified") + case memberenum.MemberStatusSuspended: + return errb.AuthForbidden("account is suspended") + case memberenum.MemberStatusDeleted: + return errb.ResNotFound("member", "") + default: + return errb.AuthForbidden("account is not allowed to reset password") + } +} diff --git a/internal/logic/auth/password_otp_helper.go b/internal/logic/auth/password_otp_helper.go new file mode 100644 index 0000000..e93afd8 --- /dev/null +++ b/internal/logic/auth/password_otp_helper.go @@ -0,0 +1,61 @@ +package auth + +import ( + "context" + "strings" + "time" + + memberdom "gateway/internal/model/member/domain" + dommember "gateway/internal/model/member/domain/usecase" + notifenum "gateway/internal/model/notification/domain/enum" + notifuc "gateway/internal/model/notification/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" +) + +func sendPasswordResetOTP( + ctx context.Context, + sc *svc.ServiceContext, + tenantID, uid, email string, +) (*types.PasswordForgotData, error) { + cfg := sc.Config.Member.Defaults() + rateKey := memberdom.GetVerifyRateRedisKey(tenantID, uid, string(passwordResetPurpose())) + if err := sc.MemberVerifyRate.AssertResendAllowed(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second); err != nil { + return nil, err + } + + dto, plainCode, err := sc.MemberOTP.Generate(ctx, &dommember.GenerateOTPRequest{ + TenantID: tenantID, + UID: uid, + Purpose: passwordResetPurpose(), + Target: email, + }) + if err != nil { + return nil, err + } + locale := sc.Config.Notification.DefaultLocale + if strings.TrimSpace(locale) == "" { + locale = "en-us" + } + if _, sendErr := sc.Notifier.Send(ctx, ¬ifuc.SendRequest{ + TenantID: tenantID, + UID: uid, + Channel: notifenum.ChannelEmail, + Kind: notifenum.NotifyVerifyEmail, + Target: email, + Locale: locale, + Data: map[string]any{"code": plainCode, "expires_in": dto.ExpiresIn}, + IdempotencyKey: dto.ChallengeID, + DoNotPersistBody: true, + Severity: notifenum.SeverityInfo, + }); sendErr != nil { + if invErr := sc.MemberOTP.Invalidate(ctx, dto.ChallengeID); invErr != nil { + return nil, invErr + } + return nil, sendErr + } + return &types.PasswordForgotData{ + ChallengeID: dto.ChallengeID, + ExpiresIn: dto.ExpiresIn, + }, nil +} diff --git a/internal/logic/auth/password_reset_logic.go b/internal/logic/auth/password_reset_logic.go new file mode 100644 index 0000000..c8deac3 --- /dev/null +++ b/internal/logic/auth/password_reset_logic.go @@ -0,0 +1,89 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "context" + + dommember "gateway/internal/model/member/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PasswordResetLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPasswordResetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PasswordResetLogic { + return &PasswordResetLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *PasswordResetLogic) PasswordReset(req *types.PasswordResetReq) (*types.PasswordResetData, error) { + if err := requireRegistrationDeps(l.svcCtx); err != nil { + return nil, err + } + if l.svcCtx.Zitadel == nil { + return nil, errb.SysNotImplemented("zitadel not configured") + } + if req == nil { + return nil, errb.InputMissingRequired("request body is required") + } + + tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug) + if err != nil { + return nil, err + } + + ch, err := l.svcCtx.MemberOTP.MatchChallenge(l.ctx, &dommember.MatchChallengeRequest{ + ChallengeID: req.ChallengeID, + TenantID: tenant.TenantID, + Purpose: passwordResetPurpose(), + RequireUID: true, + RequireTarget: true, + }) + if err != nil { + return nil, err + } + + member, err := l.svcCtx.MemberProfile.GetByUID(l.ctx, &dommember.GetMemberRequest{ + TenantID: tenant.TenantID, + UID: ch.UID, + }) + if err != nil { + return nil, err + } + if err := ensurePlatformNativePassword(member); err != nil { + return nil, err + } + if err := ensurePasswordResetEligible(member.Status); err != nil { + return nil, err + } + if member.ZitadelUserID == "" { + return nil, errb.ResInvalidState("member has no zitadel identity") + } + + if _, err := l.svcCtx.MemberOTP.Verify(l.ctx, &dommember.VerifyOTPRequest{ + TenantID: tenant.TenantID, + UID: ch.UID, + ChallengeID: req.ChallengeID, + Code: req.Code, + Purpose: passwordResetPurpose(), + }); err != nil { + return nil, err + } + + if err := l.svcCtx.Zitadel.SetUserPassword(l.ctx, member.ZitadelUserID, req.NewPassword, ""); err != nil { + return nil, wrapZitadelErr(err) + } + + return &types.PasswordResetData{OK: true}, nil +} diff --git a/internal/logic/auth/register_confirm_logic.go b/internal/logic/auth/register_confirm_logic.go index 38a7b2a..9e8276a 100644 --- a/internal/logic/auth/register_confirm_logic.go +++ b/internal/logic/auth/register_confirm_logic.go @@ -2,6 +2,7 @@ package auth import ( "context" + "strings" dommember "gateway/internal/model/member/domain/usecase" "gateway/internal/svc" @@ -62,5 +63,13 @@ func (l *RegisterConfirmLogic) RegisterConfirm(req *types.RegisterConfirmReq) (* return nil, err } + // Email 註冊 OTP 已證明使用者擁有該信箱;同步為已驗證的商業聯絡 Email, + // 避免完成註冊後仍要在安全設定重驗同一個地址。 + if email := strings.TrimSpace(strings.ToLower(ch.Target)); email != "" && l.svcCtx.MemberProfile != nil { + if err := l.svcCtx.MemberProfile.SetBusinessEmailVerified(l.ctx, tenant.TenantID, ch.UID, email); err != nil { + return nil, err + } + } + return issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, ch.UID) } diff --git a/internal/logic/auth/register_helper.go b/internal/logic/auth/register_helper.go index fa114f8..c0d2a71 100644 --- a/internal/logic/auth/register_helper.go +++ b/internal/logic/auth/register_helper.go @@ -12,6 +12,9 @@ import ( memberenum "gateway/internal/model/member/domain/enum" dommember "gateway/internal/model/member/domain/usecase" "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) func resolveTenant(ctx context.Context, sc *svc.ServiceContext, slug string) (*dommember.TenantDTO, error) { @@ -85,6 +88,9 @@ func requireRegistrationDeps(sc *svc.ServiceContext) error { if sc.MemberLifecycle == nil { return errb.SysNotImplemented("member lifecycle not configured") } + if sc.MemberProfile == nil { + return errb.SysNotImplemented("member profile not configured") + } if sc.MemberOTP == nil { return errb.SysNotImplemented("member OTP not configured") } @@ -96,3 +102,109 @@ func requireRegistrationDeps(sc *svc.ServiceContext) error { } return nil } + +func resumeRegistration( + ctx context.Context, + sc *svc.ServiceContext, + tenantSlug, email string, +) (*types.RegisterData, error) { + tenant, err := resolveTenant(ctx, sc, tenantSlug) + if err != nil { + return nil, err + } + + email = normalizeLoginEmail(email) + member, err := sc.MemberProfile.GetByZitadelEmail(ctx, tenant.TenantID, email) + if err != nil { + if isMemberNotFound(err) { + return nil, errb.ResNotFound("member", email) + } + return nil, err + } + if member.Status != memberenum.MemberStatusUnverified { + return nil, errb.ResInvalidState("account already verified, please login") + } + + data, err := sendRegistrationOTP(ctx, sc, tenant.TenantID, member.UID, email) + if err != nil { + return nil, err + } + data.UID = member.UID + return data, nil +} + +func recoverPendingRegistration( + ctx context.Context, + sc *svc.ServiceContext, + tenant *dommember.TenantDTO, + req *types.RegisterReq, +) (*types.RegisterData, error) { + if req == nil { + return nil, errb.InputMissingRequired("request body is required") + } + + email := normalizeLoginEmail(req.Email) + tok, err := sc.Zitadel.VerifyPassword(ctx, email, req.Password) + if err != nil { + return nil, errb.AuthUnauthorized("invalid credentials").WithCause(wrapZitadelErr(err)) + } + + identity, err := zitadelIdentityFromToken(ctx, sc.Zitadel, tok) + if err != nil { + return nil, err + } + + memberDTO, err := memberForRegistrationRecovery(ctx, sc, tenant.TenantID, identity.Sub, email, req) + if err != nil { + return nil, err + } + + switch memberDTO.Status { + case memberenum.MemberStatusUnverified: + case memberenum.MemberStatusActive: + return nil, errb.ResAlreadyExist("email already registered, please login") + default: + return nil, errb.ResInvalidState("account cannot complete registration") + } + + data, err := sendRegistrationOTP(ctx, sc, tenant.TenantID, memberDTO.UID, email) + if err != nil { + return nil, err + } + data.UID = memberDTO.UID + return data, nil +} + +func memberForRegistrationRecovery( + ctx context.Context, + sc *svc.ServiceContext, + tenantID, zitadelSub, email string, + req *types.RegisterReq, +) (*dommember.MemberDTO, error) { + if dto, err := sc.MemberProfile.GetByZitadelUserID(ctx, tenantID, zitadelSub); err == nil { + return dto, nil + } else if !isMemberNotFound(err) { + return nil, err + } + + if dto, err := sc.MemberProfile.GetByZitadelEmail(ctx, tenantID, email); err == nil { + return dto, nil + } else if !isMemberNotFound(err) { + return nil, err + } + + memberDTO, err := sc.MemberLifecycle.CreateUnverified(ctx, &dommember.CreatePlatformMemberRequest{ + TenantID: tenantID, + Email: email, + DisplayName: strings.TrimSpace(req.DisplayName), + Language: strings.TrimSpace(req.Language), + ZitadelUserID: zitadelSub, + }) + if err != nil { + return nil, err + } + if err := recordRegistrationMeta(ctx, sc, tenantID, memberDTO.UID, "", req.AcceptTermsVersion, req.MarketingOptIn, authmetaenum.RegistrationChannelEmail); err != nil { + logx.WithContext(ctx).Infof("register recover: registration meta skipped: %v", err) + } + return memberDTO, nil +} diff --git a/internal/logic/auth/register_logic.go b/internal/logic/auth/register_logic.go index 4f8bbc5..06b53dd 100644 --- a/internal/logic/auth/register_logic.go +++ b/internal/logic/auth/register_logic.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "strings" "time" @@ -42,6 +43,21 @@ func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, e return nil, err } + email := normalizeLoginEmail(req.Email) + zResult, err := l.svcCtx.Zitadel.CreateHumanUser(l.ctx, zitadel.CreateHumanUserRequest{ + OrgID: tenant.OrgID, + Email: email, + Password: req.Password, + DisplayName: strings.TrimSpace(req.DisplayName), + Language: strings.TrimSpace(req.Language), + }) + if err != nil { + if errors.Is(err, zitadel.ErrUserAlreadyExists) { + return recoverPendingRegistration(l.ctx, l.svcCtx, tenant, req) + } + return nil, wrapZitadelErr(err) + } + regCfg := l.svcCtx.Config.Member.Defaults().Registration var inviteCodeID string if regCfg.RequireInviteCode { @@ -53,23 +69,14 @@ func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, e Code: req.InviteCode, }) if err != nil { + if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil { + logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after invite failure: %v", deactErr) + } return nil, err } inviteCodeID = consumed.ID } - email := strings.TrimSpace(strings.ToLower(req.Email)) - zResult, err := l.svcCtx.Zitadel.CreateHumanUser(l.ctx, zitadel.CreateHumanUserRequest{ - OrgID: tenant.OrgID, - Email: email, - Password: req.Password, - DisplayName: strings.TrimSpace(req.DisplayName), - Language: strings.TrimSpace(req.Language), - }) - if err != nil { - return nil, wrapZitadelErr(err) - } - memberDTO, err := l.svcCtx.MemberLifecycle.CreateUnverified(l.ctx, &dommember.CreatePlatformMemberRequest{ TenantID: tenant.TenantID, Email: email, diff --git a/internal/logic/auth/register_resume_logic.go b/internal/logic/auth/register_resume_logic.go new file mode 100644 index 0000000..7a879cb --- /dev/null +++ b/internal/logic/auth/register_resume_logic.go @@ -0,0 +1,38 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "context" + + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RegisterResumeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// 恢復未完成註冊(重新寄送 registration OTP) +func NewRegisterResumeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterResumeLogic { + return &RegisterResumeLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RegisterResumeLogic) RegisterResume(req *types.RegisterResumeReq) (*types.RegisterData, error) { + if err := requireRegistrationDeps(l.svcCtx); err != nil { + return nil, err + } + if req == nil { + return nil, errb.InputMissingRequired("request body is required") + } + return resumeRegistration(l.ctx, l.svcCtx, req.TenantSlug, req.Email) +} diff --git a/internal/logic/member/change_password_logic.go b/internal/logic/member/change_password_logic.go new file mode 100644 index 0000000..7b47c9d --- /dev/null +++ b/internal/logic/member/change_password_logic.go @@ -0,0 +1,72 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "context" + "strings" + + domusecase "gateway/internal/model/member/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ChangePasswordLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewChangePasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChangePasswordLogic { + return &ChangePasswordLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) (*types.ChangePasswordData, error) { + actor, err := actorOrErr(l.ctx) + if err != nil { + return nil, err + } + if l.svcCtx.MemberProfile == nil { + return nil, errb.SysNotImplemented("member profile not configured") + } + if l.svcCtx.Zitadel == nil { + return nil, errb.SysNotImplemented("zitadel not configured") + } + if req == nil { + return nil, errb.InputMissingRequired("request body is required") + } + + member, err := l.svcCtx.MemberProfile.GetByUID(l.ctx, &domusecase.GetMemberRequest{ + TenantID: actor.TenantID, + UID: actor.UID, + }) + if err != nil { + return nil, err + } + if err := ensurePlatformNativePassword(member); err != nil { + return nil, err + } + if member.ZitadelUserID == "" { + return nil, errb.ResInvalidState("member has no zitadel identity") + } + + email := strings.TrimSpace(member.ZitadelEmail) + if email == "" { + return nil, errb.ResInvalidState("member has no login email") + } + if _, err := l.svcCtx.Zitadel.VerifyPassword(l.ctx, email, req.CurrentPassword); err != nil { + return nil, errb.AuthUnauthorized("invalid current password") + } + if err := l.svcCtx.Zitadel.SetUserPassword(l.ctx, member.ZitadelUserID, req.NewPassword, req.CurrentPassword); err != nil { + return nil, errb.SvcThirdParty("zitadel password update failed").WithCause(err) + } + + return &types.ChangePasswordData{OK: true}, nil +} diff --git a/internal/logic/member/password_helper.go b/internal/logic/member/password_helper.go new file mode 100644 index 0000000..e0284eb --- /dev/null +++ b/internal/logic/member/password_helper.go @@ -0,0 +1,24 @@ +package member + +import ( + memberenum "gateway/internal/model/member/domain/enum" + domusecase "gateway/internal/model/member/domain/usecase" +) + +func ensurePlatformNativePassword(member *domusecase.MemberDTO) error { + if member == nil { + return errb.ResNotFound("member", "") + } + switch member.Origin { + case memberenum.MemberOriginPlatformNative: + return nil + case memberenum.MemberOriginOIDC: + return errb.AuthForbidden("social login accounts cannot change password here") + case memberenum.MemberOriginLDAP: + return errb.AuthForbidden("ldap accounts cannot change password here") + case memberenum.MemberOriginSCIM: + return errb.AuthForbidden("scim provisioned accounts cannot change password here") + default: + return errb.AuthForbidden("account cannot change password here") + } +} diff --git a/internal/logic/member/verify_helper.go b/internal/logic/member/verify_helper.go index a6f39b1..374d7b1 100644 --- a/internal/logic/member/verify_helper.go +++ b/internal/logic/member/verify_helper.go @@ -13,6 +13,8 @@ import ( notifuc "gateway/internal/model/notification/domain/usecase" "gateway/internal/svc" "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) func startVerification( @@ -76,7 +78,9 @@ func startVerification( } if os.Getenv("GATEWAY_E2E") == "1" && sc.Redis != nil && sc.Redis.Zero() != nil { key := fmt.Sprintf("e2e:otp:%s", dto.ChallengeID) - _ = sc.Redis.Zero().SetexCtx(ctx, key, plainCode, dto.ExpiresIn) + if setErr := sc.Redis.Zero().SetexCtx(ctx, key, plainCode, dto.ExpiresIn); setErr != nil { + logx.WithContext(ctx).Infof("e2e otp mirror skipped: %v", setErr) + } } return &types.VerificationStartData{ ChallengeID: dto.ChallengeID, diff --git a/internal/model/auth/domain/const.go b/internal/model/auth/domain/const.go index 6c161fd..db9a2e3 100644 --- a/internal/model/auth/domain/const.go +++ b/internal/model/auth/domain/const.go @@ -46,6 +46,11 @@ func LoginSessionRedisKey(sessionID string) string { return fmt.Sprintf("auth:login:session:%s", sessionID) } +// LoginMFAChallengeRedisKey returns the Redis key for a password-login MFA challenge. +func LoginMFAChallengeRedisKey(challengeID string) string { + return fmt.Sprintf("auth:login:mfa:%s", challengeID) +} + // NormalizeInviteCode trims and uppercases user input before hashing. func NormalizeInviteCode(code string) string { return strings.ToUpper(strings.TrimSpace(code)) diff --git a/internal/model/auth/domain/errors.go b/internal/model/auth/domain/errors.go index 0e68eb5..a997ac0 100644 --- a/internal/model/auth/domain/errors.go +++ b/internal/model/auth/domain/errors.go @@ -14,4 +14,5 @@ var ( ErrDuplicateRegistrationMeta = fmt.Errorf("auth: duplicate registration metadata") ErrRegistrationSessionNotFound = fmt.Errorf("auth: registration session not found") ErrLoginSessionNotFound = fmt.Errorf("auth: login session not found") + ErrLoginMFAChallengeNotFound = fmt.Errorf("auth: login mfa challenge not found") ) diff --git a/internal/model/auth/domain/repository/login_mfa_challenge.go b/internal/model/auth/domain/repository/login_mfa_challenge.go new file mode 100644 index 0000000..64fcf56 --- /dev/null +++ b/internal/model/auth/domain/repository/login_mfa_challenge.go @@ -0,0 +1,28 @@ +package repository + +import ( + "context" + "time" + + authdomain "gateway/internal/model/auth/domain" +) + +// LoginMFAChallenge holds pending password-login state after credentials pass. +type LoginMFAChallenge struct { + ChallengeID string + TenantID string + TenantSlug string + UID string +} + +// LoginMFAChallengeStore persists short-lived login MFA challenges. +type LoginMFAChallengeStore interface { + Save(ctx context.Context, challenge *LoginMFAChallenge, ttl time.Duration) error + Get(ctx context.Context, challengeID string) (*LoginMFAChallenge, error) + Delete(ctx context.Context, challengeID string) error +} + +// LoginMFAChallengeRedisKey re-exports the Redis key helper for tests. +func LoginMFAChallengeRedisKey(challengeID string) string { + return authdomain.LoginMFAChallengeRedisKey(challengeID) +} diff --git a/internal/model/auth/domain/usecase/login_mfa_challenge.go b/internal/model/auth/domain/usecase/login_mfa_challenge.go new file mode 100644 index 0000000..cd71770 --- /dev/null +++ b/internal/model/auth/domain/usecase/login_mfa_challenge.go @@ -0,0 +1,27 @@ +package usecase + +import ( + "context" + "time" +) + +// CreateLoginMFAChallengeRequest binds tenant/member after password verification. +type CreateLoginMFAChallengeRequest struct { + TenantID string + TenantSlug string + UID string + TTL time.Duration +} + +// LoginMFAChallengeView is returned when login requires TOTP confirmation. +type LoginMFAChallengeView struct { + ChallengeID string + ExpiresIn int +} + +// LoginMFAChallengeUseCase manages password-login MFA challenges. +type LoginMFAChallengeUseCase interface { + Create(ctx context.Context, req *CreateLoginMFAChallengeRequest) (*LoginMFAChallengeView, error) + Get(ctx context.Context, challengeID string) (*CreateLoginMFAChallengeRequest, error) + Delete(ctx context.Context, challengeID string) error +} diff --git a/internal/model/auth/repository/login_mfa_challenge_redis.go b/internal/model/auth/repository/login_mfa_challenge_redis.go new file mode 100644 index 0000000..ab36871 --- /dev/null +++ b/internal/model/auth/repository/login_mfa_challenge_redis.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + redislib "gateway/internal/library/redis" + authdomain "gateway/internal/model/auth/domain" + domrepo "gateway/internal/model/auth/domain/repository" + + "github.com/zeromicro/go-zero/core/stores/redis" +) + +type redisLoginMFAChallengeStore struct { + client *redis.Redis +} + +// NewRedisLoginMFAChallengeStore creates a Redis-backed login MFA challenge store. +func NewRedisLoginMFAChallengeStore(client *redislib.Client) domrepo.LoginMFAChallengeStore { + if client == nil || client.Zero() == nil { + panic("auth: redis client is required for login mfa challenge store") + } + return &redisLoginMFAChallengeStore{client: client.Zero()} +} + +func (s *redisLoginMFAChallengeStore) Save(ctx context.Context, challenge *domrepo.LoginMFAChallenge, ttl time.Duration) error { + if challenge == nil || challenge.ChallengeID == "" { + return fmt.Errorf("auth: login mfa challenge id is required") + } + raw, err := json.Marshal(challenge) + if err != nil { + return fmt.Errorf("auth: marshal login mfa challenge: %w", err) + } + seconds := int(ttl.Seconds()) + if seconds < 1 { + seconds = 1 + } + return s.client.SetexCtx(ctx, authdomain.LoginMFAChallengeRedisKey(challenge.ChallengeID), string(raw), seconds) +} + +func (s *redisLoginMFAChallengeStore) Get(ctx context.Context, challengeID string) (*domrepo.LoginMFAChallenge, error) { + val, err := s.client.GetCtx(ctx, authdomain.LoginMFAChallengeRedisKey(challengeID)) + if errors.Is(err, redis.Nil) { + return nil, authdomain.ErrLoginMFAChallengeNotFound + } + if err != nil { + return nil, err + } + var challenge domrepo.LoginMFAChallenge + if err := json.Unmarshal([]byte(val), &challenge); err != nil { + return nil, fmt.Errorf("auth: unmarshal login mfa challenge: %w", err) + } + return &challenge, nil +} + +func (s *redisLoginMFAChallengeStore) Delete(ctx context.Context, challengeID string) error { + _, err := s.client.DelCtx(ctx, authdomain.LoginMFAChallengeRedisKey(challengeID)) + return err +} + +var _ domrepo.LoginMFAChallengeStore = (*redisLoginMFAChallengeStore)(nil) diff --git a/internal/model/auth/usecase/login_mfa_challenge_usecase.go b/internal/model/auth/usecase/login_mfa_challenge_usecase.go new file mode 100644 index 0000000..c367dca --- /dev/null +++ b/internal/model/auth/usecase/login_mfa_challenge_usecase.go @@ -0,0 +1,84 @@ +package usecase + +import ( + "context" + "errors" + "time" + + authdomain "gateway/internal/model/auth/domain" + domrepo "gateway/internal/model/auth/domain/repository" + domusecase "gateway/internal/model/auth/domain/usecase" + + "github.com/google/uuid" +) + +type loginMFAChallengeUseCase struct { + store domrepo.LoginMFAChallengeStore +} + +// LoginMFAChallengeUseCaseParam wires LoginMFAChallengeUseCase. +type LoginMFAChallengeUseCaseParam struct { + Store domrepo.LoginMFAChallengeStore +} + +// MustLoginMFAChallengeUseCase constructs LoginMFAChallengeUseCase. +func MustLoginMFAChallengeUseCase(param LoginMFAChallengeUseCaseParam) domusecase.LoginMFAChallengeUseCase { + if param.Store == nil { + panic("auth: login mfa challenge store is required") + } + return &loginMFAChallengeUseCase{store: param.Store} +} + +func (uc *loginMFAChallengeUseCase) Create(ctx context.Context, req *domusecase.CreateLoginMFAChallengeRequest) (*domusecase.LoginMFAChallengeView, error) { + if req == nil || req.TenantID == "" || req.TenantSlug == "" || req.UID == "" { + return nil, errb.InputMissingRequired("tenant_id, tenant_slug and uid are required") + } + ttl := req.TTL + if ttl <= 0 { + ttl = 5 * time.Minute + } + challengeID := uuid.NewString() + challenge := &domrepo.LoginMFAChallenge{ + ChallengeID: challengeID, + TenantID: req.TenantID, + TenantSlug: req.TenantSlug, + UID: req.UID, + } + if err := uc.store.Save(ctx, challenge, ttl); err != nil { + return nil, wrapRepoErr(err, "save login mfa challenge failed") + } + return &domusecase.LoginMFAChallengeView{ + ChallengeID: challengeID, + ExpiresIn: int(ttl.Seconds()), + }, nil +} + +func (uc *loginMFAChallengeUseCase) Get(ctx context.Context, challengeID string) (*domusecase.CreateLoginMFAChallengeRequest, error) { + if challengeID == "" { + return nil, errb.InputMissingRequired("challenge_id is required") + } + challenge, err := uc.store.Get(ctx, challengeID) + if err != nil { + if errors.Is(err, authdomain.ErrLoginMFAChallengeNotFound) { + return nil, errb.ResNotFound("login mfa challenge", challengeID).WithCause(err) + } + return nil, wrapRepoErr(err, "read login mfa challenge failed") + } + return &domusecase.CreateLoginMFAChallengeRequest{ + TenantID: challenge.TenantID, + TenantSlug: challenge.TenantSlug, + UID: challenge.UID, + }, nil +} + +func (uc *loginMFAChallengeUseCase) Delete(ctx context.Context, challengeID string) error { + if challengeID == "" { + return errb.InputMissingRequired("challenge_id is required") + } + if err := uc.store.Delete(ctx, challengeID); err != nil { + return wrapRepoErr(err, "delete login mfa challenge failed") + } + return nil +} + +var _ domusecase.LoginMFAChallengeUseCase = (*loginMFAChallengeUseCase)(nil) diff --git a/internal/model/auth/usecase/module.go b/internal/model/auth/usecase/module.go index af12c5f..f0e8003 100644 --- a/internal/model/auth/usecase/module.go +++ b/internal/model/auth/usecase/module.go @@ -16,6 +16,7 @@ type Module struct { RegistrationMeta domusecase.RegistrationMetaUseCase RegistrationSession domusecase.RegistrationSessionUseCase LoginSession domusecase.LoginSessionUseCase + LoginMFAChallenge domusecase.LoginMFAChallengeUseCase Invites domrepo.InviteRepository RegistrationMetaRepo domrepo.RegistrationMetaRepository @@ -47,6 +48,7 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) { regMetaRepo := repository.NewRegistrationMetaRepository(repository.RegistrationMetaRepositoryParam{Conf: param.MongoConf}) sessionStore := repository.NewRedisRegistrationSessionStore(param.Redis) loginStore := repository.NewRedisLoginSessionStore(param.Redis) + loginMFAStore := repository.NewRedisLoginMFAChallengeStore(param.Redis) lock := param.Lock if lock == nil { lock = repository.NewRedisInviteConsumeLock(param.Redis) @@ -68,6 +70,9 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) { LoginSession: MustLoginSessionUseCase(LoginSessionUseCaseParam{ Store: loginStore, }), + LoginMFAChallenge: MustLoginMFAChallengeUseCase(LoginMFAChallengeUseCaseParam{ + Store: loginMFAStore, + }), } return mod, nil } diff --git a/internal/model/member/domain/repository/member.go b/internal/model/member/domain/repository/member.go index 25a14ea..8be823c 100644 --- a/internal/model/member/domain/repository/member.go +++ b/internal/model/member/domain/repository/member.go @@ -29,6 +29,7 @@ type MemberRepository interface { Insert(ctx context.Context, member *entity.Member) error GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*entity.Member, error) + GetByZitadelEmail(ctx context.Context, tenantID, email string) (*entity.Member, error) UpdateProfile(ctx context.Context, tenantID, uid string, update *MemberUpdate) (*entity.Member, error) UpdateStatus(ctx context.Context, tenantID, uid string, status enum.MemberStatus, suspendReason string) error List(ctx context.Context, filter ListMembersFilter) ([]*entity.Member, int64, error) diff --git a/internal/model/member/domain/usecase/profile.go b/internal/model/member/domain/usecase/profile.go index 8568d5f..b59ce4a 100644 --- a/internal/model/member/domain/usecase/profile.go +++ b/internal/model/member/domain/usecase/profile.go @@ -10,6 +10,7 @@ import ( type ProfileUseCase interface { GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error) GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*MemberDTO, error) + GetByZitadelEmail(ctx context.Context, tenantID, email string) (*MemberDTO, error) Update(ctx context.Context, req *UpdateMemberRequest) (*MemberDTO, error) List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error) SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error @@ -54,6 +55,7 @@ type MemberDTO struct { TenantID string `json:"tenant_id"` UID string `json:"uid"` ZitadelEmail string `json:"zitadel_email,omitempty"` + ZitadelUserID string `json:"zitadel_user_id,omitempty"` DisplayName string `json:"display_name,omitempty"` Avatar string `json:"avatar,omitempty"` Phone string `json:"phone,omitempty"` diff --git a/internal/model/member/repository/member_mongo.go b/internal/model/member/repository/member_mongo.go index 788373e..d0305dc 100644 --- a/internal/model/member/repository/member_mongo.go +++ b/internal/model/member/repository/member_mongo.go @@ -3,6 +3,7 @@ package repository import ( "context" "errors" + "strings" "time" libmongo "gateway/internal/library/mongo" @@ -85,6 +86,21 @@ func (r *memberRepository) GetByZitadelUserID(ctx context.Context, tenantID, zit return &doc, nil } +func (r *memberRepository) GetByZitadelEmail(ctx context.Context, tenantID, email string) (*entity.Member, error) { + var doc entity.Member + filter := bson.M{ + member.BSONFieldTenantID: tenantID, + member.BSONFieldZitadelEmail: strings.ToLower(strings.TrimSpace(email)), + } + if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil { + if errors.Is(err, mongodriver.ErrNoDocuments) { + return nil, member.ErrNotFound + } + return nil, err + } + return &doc, nil +} + func (r *memberRepository) UpdateProfile(ctx context.Context, tenantID, uid string, update *domrepo.MemberUpdate) (*entity.Member, error) { set := bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()} if update.DisplayName != nil { @@ -213,6 +229,11 @@ func (r *memberRepository) Index20260520001UP(ctx context.Context) error { []int32{1, 1}, true); err != nil { return err } + if err := r.db.PopulateMultiIndex(ctx, + []string{member.BSONFieldTenantID, member.BSONFieldZitadelEmail}, + []int32{1, 1}, false); err != nil { + return err + } return r.db.PopulateMultiIndex(ctx, []string{member.BSONFieldTenantID, member.BSONFieldMemberStatus, member.BSONFieldCreateAt}, []int32{1, 1, -1}, false) diff --git a/internal/model/member/usecase/mapper.go b/internal/model/member/usecase/mapper.go index bb6dfa0..7cc6287 100644 --- a/internal/model/member/usecase/mapper.go +++ b/internal/model/member/usecase/mapper.go @@ -13,6 +13,7 @@ func memberToDTO(m *entity.Member) *domusecase.MemberDTO { TenantID: m.TenantID, UID: m.UID, ZitadelEmail: m.ZitadelEmail, + ZitadelUserID: m.ZitadelUserID, DisplayName: m.DisplayName, Avatar: m.Avatar, Phone: m.Phone, diff --git a/internal/model/member/usecase/profile_usecase.go b/internal/model/member/usecase/profile_usecase.go index 9ed8583..022c076 100644 --- a/internal/model/member/usecase/profile_usecase.go +++ b/internal/model/member/usecase/profile_usecase.go @@ -3,6 +3,7 @@ package usecase import ( "context" "errors" + "strings" member "gateway/internal/model/member/domain" domrepo "gateway/internal/model/member/domain/repository" @@ -54,6 +55,20 @@ func (uc *profileUseCase) GetByZitadelUserID(ctx context.Context, tenantID, zita return memberToDTO(rec), nil } +func (uc *profileUseCase) GetByZitadelEmail(ctx context.Context, tenantID, email string) (*domusecase.MemberDTO, error) { + if tenantID == "" || strings.TrimSpace(email) == "" { + return nil, errb.InputMissingRequired("tenant_id and email are required") + } + rec, err := uc.members.GetByZitadelEmail(ctx, tenantID, strings.ToLower(strings.TrimSpace(email))) + if err != nil { + if errors.Is(err, member.ErrNotFound) { + return nil, errb.ResNotFound("member", email).WithCause(err) + } + return nil, wrapRepoErr(err, "read member failed") + } + return memberToDTO(rec), nil +} + func (uc *profileUseCase) Update(ctx context.Context, req *domusecase.UpdateMemberRequest) (*domusecase.MemberDTO, error) { if req == nil || req.TenantID == "" || req.UID == "" { return nil, errb.InputMissingRequired("tenant_id and uid are required") diff --git a/internal/model/notification/provider/email/mock_sender.go b/internal/model/notification/provider/email/mock_sender.go index 764a1da..78b9cbd 100644 --- a/internal/model/notification/provider/email/mock_sender.go +++ b/internal/model/notification/provider/email/mock_sender.go @@ -9,6 +9,13 @@ import ( "github.com/zeromicro/go-zero/core/logx" ) +// MockRedisHook is the minimal Redis surface needed to persist the last mock +// email body for dev/k6 inspection. *github.com/zeromicro/go-zero/core/stores/redis.Redis +// satisfies it (SetexCtx). +type MockRedisHook interface { + SetexCtx(ctx context.Context, key, value string, seconds int) error +} + // MockSender records calls and returns configurable results (for tests and local dev). type MockSender struct { name string @@ -19,6 +26,11 @@ type MockSender struct { Err error MessageID string SendHook func(ctx context.Context, msg *Message) (string, error) + + // Optional Redis hook for dev/k6: every successful Send writes the body + // to "dev:notification:last:email:" for each To address. + redis MockRedisHook + redisKeyTTL int } type MockSenderOption func(*MockSender) @@ -39,11 +51,26 @@ func WithMockMessageID(id string) MockSenderOption { return func(m *MockSender) { m.MessageID = id } } +// WithMockRedis mirrors every outbound mock email body into Redis at key +// "dev:notification:last:email:" (one key per To address). +// Primary OTP transport for k6 is still MailHog HTTP API; this hook is a +// fallback so the SMTP-disabled mock mode is also k6-friendly. +func WithMockRedis(r MockRedisHook, ttlSeconds int) MockSenderOption { + return func(m *MockSender) { + m.redis = r + if ttlSeconds <= 0 { + ttlSeconds = 600 + } + m.redisKeyTTL = ttlSeconds + } +} + func NewMockSender(opts ...MockSenderOption) *MockSender { m := &MockSender{ - name: "mock", - sort: 0, - MessageID: "mock-email-id", + name: "mock", + sort: 0, + MessageID: "mock-email-id", + redisKeyTTL: 600, } for _, opt := range opts { opt(m) @@ -54,6 +81,9 @@ func NewMockSender(opts ...MockSenderOption) *MockSender { func (m *MockSender) Name() string { return m.name } func (m *MockSender) Sort() int { return m.sort } +// MockEmailRedisKeyPrefix is the key prefix written by the Redis hook. +const MockEmailRedisKeyPrefix = "dev:notification:last:email:" + func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) { m.mu.Lock() m.calls = append(m.calls, msg) @@ -68,6 +98,17 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) { if msg != nil { logx.Infof("[notification mock email] from=%s to=%s subject=%q body=%s message_id=%s", msg.From, strings.Join(msg.To, ","), msg.Subject, truncateForLog(msg.Body, 500), m.MessageID) + if m.redis != nil { + for _, to := range msg.To { + if to == "" { + continue + } + key := MockEmailRedisKeyPrefix + to + if err := m.redis.SetexCtx(ctx, key, msg.Body, m.redisKeyTTL); err != nil { + logx.Errorf("[notification mock email] redis hook setex %s: %v", key, err) + } + } + } } return m.MessageID, nil } diff --git a/internal/model/notification/provider/email/mock_sender_test.go b/internal/model/notification/provider/email/mock_sender_test.go new file mode 100644 index 0000000..bbe5db8 --- /dev/null +++ b/internal/model/notification/provider/email/mock_sender_test.go @@ -0,0 +1,57 @@ +package email + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type recordingHook struct { + mu sync.Mutex + calls []hookCall +} + +type hookCall struct { + key string + value string + seconds int +} + +func (h *recordingHook) SetexCtx(_ context.Context, key, value string, seconds int) error { + h.mu.Lock() + defer h.mu.Unlock() + h.calls = append(h.calls, hookCall{key: key, value: value, seconds: seconds}) + return nil +} + +func TestMockSender_RedisHookWritesBodyPerRecipient(t *testing.T) { + hook := &recordingHook{} + s := NewMockSender(WithMockRedis(hook, 30)) + + _, err := s.Send(context.Background(), &Message{ + From: "noreply@k6.local", + To: []string{"alice@example.com", "bob@example.com"}, + Subject: "code", + Body: "code is 123456", + }) + require.NoError(t, err) + + hook.mu.Lock() + defer hook.mu.Unlock() + require.Len(t, hook.calls, 2) + assert.Equal(t, MockEmailRedisKeyPrefix+"alice@example.com", hook.calls[0].key) + assert.Equal(t, MockEmailRedisKeyPrefix+"bob@example.com", hook.calls[1].key) + for _, c := range hook.calls { + assert.Equal(t, "code is 123456", c.value) + assert.Equal(t, 30, c.seconds) + } +} + +func TestMockSender_NoHookSkipsRedis(t *testing.T) { + s := NewMockSender() + _, err := s.Send(context.Background(), &Message{To: []string{"x@y"}, Body: "z"}) + require.NoError(t, err) +} diff --git a/internal/model/notification/provider/sms/mock_sender.go b/internal/model/notification/provider/sms/mock_sender.go index 3b078df..44846ba 100644 --- a/internal/model/notification/provider/sms/mock_sender.go +++ b/internal/model/notification/provider/sms/mock_sender.go @@ -8,6 +8,16 @@ import ( "github.com/zeromicro/go-zero/core/logx" ) +// MockRedisHook is the minimal Redis surface needed to persist the last mock +// SMS body for dev/k6 inspection. *github.com/zeromicro/go-zero/core/stores/redis.Redis +// already satisfies it (SetexCtx). +// +// Production SMS senders never receive this hook; it is only wired when +// SMSConfig.Provider=="mock" and a Redis client is available. +type MockRedisHook interface { + SetexCtx(ctx context.Context, key, value string, seconds int) error +} + // MockSender records calls and returns configurable results (for tests and local dev). type MockSender struct { name string @@ -18,6 +28,12 @@ type MockSender struct { Err error MessageID string SendHook func(ctx context.Context, msg *Message) (string, error) + + // Optional Redis hook for dev/k6: on every successful Send, the message + // body is written to "dev:notification:last:sms:" with the given TTL. + // nil hook → behaviour identical to original implementation. + redis MockRedisHook + redisKeyTTL int // seconds, defaults to 600 when redis hook set } type MockSenderOption func(*MockSender) @@ -38,11 +54,25 @@ func WithMockMessageID(id string) MockSenderOption { return func(m *MockSender) { m.MessageID = id } } +// WithMockRedis enables dev/k6 OTP inspection by mirroring every outbound +// mock SMS body into Redis at key "dev:notification:last:sms:". +// ttlSeconds <= 0 → defaults to 600 (10m). +func WithMockRedis(r MockRedisHook, ttlSeconds int) MockSenderOption { + return func(m *MockSender) { + m.redis = r + if ttlSeconds <= 0 { + ttlSeconds = 600 + } + m.redisKeyTTL = ttlSeconds + } +} + func NewMockSender(opts ...MockSenderOption) *MockSender { m := &MockSender{ - name: "mock", - sort: 0, - MessageID: "mock-sms-id", + name: "mock", + sort: 0, + MessageID: "mock-sms-id", + redisKeyTTL: 600, } for _, opt := range opts { opt(m) @@ -53,6 +83,10 @@ func NewMockSender(opts ...MockSenderOption) *MockSender { func (m *MockSender) Name() string { return m.name } func (m *MockSender) Sort() int { return m.sort } +// MockSMSRedisKeyPrefix is the key prefix written by the Redis hook. +// Exposed so k6 / dev tooling can resolve the key for a given phone. +const MockSMSRedisKeyPrefix = "dev:notification:last:sms:" + func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) { m.mu.Lock() m.calls = append(m.calls, msg) @@ -67,6 +101,12 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) { if msg != nil { logx.Infof("[notification mock sms] to=%s recipient=%s body=%q message_id=%s", msg.PhoneNumber, msg.RecipientName, msg.Body, m.MessageID) + if m.redis != nil && msg.PhoneNumber != "" { + key := MockSMSRedisKeyPrefix + msg.PhoneNumber + if err := m.redis.SetexCtx(ctx, key, msg.Body, m.redisKeyTTL); err != nil { + logx.Errorf("[notification mock sms] redis hook setex %s: %v", key, err) + } + } } return m.MessageID, nil } diff --git a/internal/model/notification/provider/sms/mock_sender_test.go b/internal/model/notification/provider/sms/mock_sender_test.go new file mode 100644 index 0000000..5803383 --- /dev/null +++ b/internal/model/notification/provider/sms/mock_sender_test.go @@ -0,0 +1,62 @@ +package sms + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingHook is a stub MockRedisHook for verifying mock SMS Redis writes. +type recordingHook struct { + mu sync.Mutex + calls []hookCall +} + +type hookCall struct { + key string + value string + seconds int +} + +func (h *recordingHook) SetexCtx(_ context.Context, key, value string, seconds int) error { + h.mu.Lock() + defer h.mu.Unlock() + h.calls = append(h.calls, hookCall{key: key, value: value, seconds: seconds}) + return nil +} + +func TestMockSender_RedisHookWritesBody(t *testing.T) { + hook := &recordingHook{} + s := NewMockSender(WithMockRedis(hook, 60)) + + _, err := s.Send(context.Background(), &Message{PhoneNumber: "+886912345678", Body: "your code is 123456"}) + require.NoError(t, err) + + hook.mu.Lock() + defer hook.mu.Unlock() + require.Len(t, hook.calls, 1) + assert.Equal(t, MockSMSRedisKeyPrefix+"+886912345678", hook.calls[0].key) + assert.Equal(t, "your code is 123456", hook.calls[0].value) + assert.Equal(t, 60, hook.calls[0].seconds) +} + +func TestMockSender_RedisHookSkippedWithoutPhone(t *testing.T) { + hook := &recordingHook{} + s := NewMockSender(WithMockRedis(hook, 0)) + + _, err := s.Send(context.Background(), &Message{PhoneNumber: "", Body: "x"}) + require.NoError(t, err) + + hook.mu.Lock() + defer hook.mu.Unlock() + assert.Empty(t, hook.calls) +} + +func TestMockSender_NoHookByDefault(t *testing.T) { + s := NewMockSender() + _, err := s.Send(context.Background(), &Message{PhoneNumber: "+886900000000", Body: "x"}) + require.NoError(t, err) +} diff --git a/internal/model/notification/usecase/factory.go b/internal/model/notification/usecase/factory.go index d47e80d..caa31e2 100644 --- a/internal/model/notification/usecase/factory.go +++ b/internal/model/notification/usecase/factory.go @@ -29,7 +29,7 @@ func NewNotifierUseCaseFromParam(param FactoryParam) (domusecase.NotifierUseCase return mod.Notifier, nil } -func buildEmailChain(cfg notifconfig.Config) (*email.Chain, error) { +func buildEmailChain(cfg notifconfig.Config, rc *redislib.Client) (*email.Chain, error) { senders, err := collectEmailSenders(cfg) if err != nil { return nil, err @@ -37,7 +37,11 @@ func buildEmailChain(cfg notifconfig.Config) (*email.Chain, error) { if len(senders) == 0 { switch cfg.Email.Provider { case "", notifconfig.ProviderMock: - return email.NewChain(email.NewMockSender(email.WithMockName(notifconfig.ProviderMock))), nil + opts := []email.MockSenderOption{email.WithMockName(notifconfig.ProviderMock)} + if r := rc.Zero(); r != nil { + opts = append(opts, email.WithMockRedis(r, 0)) + } + return email.NewChain(email.NewMockSender(opts...)), nil default: return nil, fmt.Errorf("notification: no email senders enabled and provider %q is not mock", cfg.Email.Provider) } @@ -87,7 +91,7 @@ func collectEmailSenders(cfg notifconfig.Config) ([]email.Sender, error) { return senders, nil } -func buildSMSChain(cfg notifconfig.Config) (*sms.Chain, error) { +func buildSMSChain(cfg notifconfig.Config, rc *redislib.Client) (*sms.Chain, error) { senders, err := collectSMSSenders(cfg) if err != nil { return nil, err @@ -95,7 +99,11 @@ func buildSMSChain(cfg notifconfig.Config) (*sms.Chain, error) { if len(senders) == 0 { switch cfg.SMS.Provider { case "", notifconfig.ProviderMock: - return sms.NewChain(sms.NewMockSender(sms.WithMockName(notifconfig.ProviderMock))), nil + opts := []sms.MockSenderOption{sms.WithMockName(notifconfig.ProviderMock)} + if r := rc.Zero(); r != nil { + opts = append(opts, sms.WithMockRedis(r, 0)) + } + return sms.NewChain(sms.NewMockSender(opts...)), nil default: return nil, fmt.Errorf("notification: no sms senders enabled and provider %q is not mock", cfg.SMS.Provider) } diff --git a/internal/model/notification/usecase/factory_test.go b/internal/model/notification/usecase/factory_test.go index 81db839..f3466b8 100644 --- a/internal/model/notification/usecase/factory_test.go +++ b/internal/model/notification/usecase/factory_test.go @@ -12,7 +12,7 @@ import ( func TestBuildEmailChain_MockByDefault(t *testing.T) { chain, err := buildEmailChain(notifconfig.Config{ Email: notifconfig.EmailConfig{Provider: notifconfig.ProviderMock}, - }) + }, nil) require.NoError(t, err) require.NotNil(t, chain) } @@ -34,7 +34,7 @@ func TestBuildEmailChain_SMTPAndSES(t *testing.T) { SecretKey: "secret", }, }, - }) + }, nil) require.NoError(t, err) require.NotNil(t, chain) } @@ -44,7 +44,7 @@ func TestBuildEmailChain_SESRequiresCredentials(t *testing.T) { Email: notifconfig.EmailConfig{ SES: notifconfig.SESProviderSettings{Enable: true, Region: "ap-northeast-1"}, }, - }) + }, nil) assert.Error(t, err) } @@ -58,7 +58,7 @@ func TestBuildSMSChain_Mitake(t *testing.T) { Password: "pass", }, }, - }) + }, nil) require.NoError(t, err) require.NotNil(t, chain) } diff --git a/internal/model/notification/usecase/module.go b/internal/model/notification/usecase/module.go index f966040..8044652 100644 --- a/internal/model/notification/usecase/module.go +++ b/internal/model/notification/usecase/module.go @@ -47,11 +47,11 @@ func NewModuleFromParam(param FactoryParam) (*Module, error) { queue = repository.NewRedisRetryQueue(param.Redis, retryQueueKey(param.Config)) } - emailChain, err := buildEmailChain(param.Config) + emailChain, err := buildEmailChain(param.Config, param.Redis) if err != nil { return nil, err } - smsChain, err := buildSMSChain(param.Config) + smsChain, err := buildSMSChain(param.Config, param.Redis) if err != nil { return nil, err } diff --git a/internal/model/permission/seed/catalog.go b/internal/model/permission/seed/catalog.go index 46116ff..7562f45 100644 --- a/internal/model/permission/seed/catalog.go +++ b/internal/model/permission/seed/catalog.go @@ -67,7 +67,8 @@ var DefaultSystemRoles = []SystemRoleDefinition{ DisplayName: "Tenant Admin", PermissionNames: []string{ "member.admin.list", "member.admin.read", "member.admin.update", "member.admin.status", - "permission.role.read", "permission.role.write", "permission.assign.write", + "permission.role.read", "permission.role.write", "permission.role.modify", + "permission.assign.write", "permission.assign.revoke", "permission.mapping.write", "permission.policy.reload", }, }, diff --git a/internal/model/permission/seed/catalog.json b/internal/model/permission/seed/catalog.json index d27983c..b13adc4 100644 --- a/internal/model/permission/seed/catalog.json +++ b/internal/model/permission/seed/catalog.json @@ -83,26 +83,42 @@ { "name": "permission.role.write", "parent": "permission.role.management", - "http_methods": "POST|PUT|DELETE", - "http_path": "/api/v1/permissions/roles*", + "http_methods": "POST", + "http_path": "/api/v1/permissions/roles", "type": "backend_user", - "description": "管理角色(建立/修改/刪除)" + "description": "建立角色" + }, + { + "name": "permission.role.modify", + "parent": "permission.role.management", + "http_methods": "GET|PUT|PATCH|DELETE", + "http_path": "/api/v1/permissions/roles/*", + "type": "backend_user", + "description": "修改 / 刪除 / 讀取角色 permission 細節" }, { "name": "permission.assign.write", "parent": "permission.role.management", - "http_methods": "POST|DELETE", - "http_path": "/api/v1/permissions/users/*/roles*", + "http_methods": "GET|POST", + "http_path": "/api/v1/permissions/users/*/roles", "type": "backend_user", - "description": "指派 / 撤銷使用者角色" + "description": "查詢 / 指派使用者角色" + }, + { + "name": "permission.assign.revoke", + "parent": "permission.role.management", + "http_methods": "DELETE", + "http_path": "/api/v1/permissions/users/*/roles/*", + "type": "backend_user", + "description": "撤銷使用者單一角色" }, { "name": "permission.mapping.write", "parent": "permission.role.management", - "http_methods": "PUT|DELETE", - "http_path": "/api/v1/permissions/role-mappings*", + "http_methods": "GET|PUT|DELETE", + "http_path": "/api/v1/permissions/role-mappings", "type": "backend_user", - "description": "管理外部角色映射" + "description": "讀取 / 管理外部角色映射" }, { "name": "permission.policy.reload", diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index f6b07e4..ce8fbfd 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -38,6 +38,7 @@ type ServiceContext struct { AuthRegistrationMeta domauth.RegistrationMetaUseCase AuthRegistrationSession domauth.RegistrationSessionUseCase AuthLoginSession domauth.LoginSessionUseCase + AuthLoginMFAChallenge domauth.LoginMFAChallengeUseCase Zitadel *zitadel.Client Notifier domnotif.NotifierUseCase NotificationAdmin domnotif.AdminNotifierUseCase @@ -125,6 +126,7 @@ func NewServiceContext(c config.Config) *ServiceContext { sc.AuthRegistrationMeta = authMod.RegistrationMeta sc.AuthRegistrationSession = authMod.RegistrationSession sc.AuthLoginSession = authMod.LoginSession + sc.AuthLoginMFAChallenge = authMod.LoginMFAChallenge } if rds != nil && rds.Zero() != nil { var mongoConf *libmongo.Conf diff --git a/internal/types/types.go b/internal/types/types.go index 99a30cf..9a8fa61 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -34,6 +34,15 @@ type AuthTokenOKStatus struct { Data AuthTokenData `json:"data"` } +type ChangePasswordData struct { + OK bool `json:"ok"` +} + +type ChangePasswordReq struct { + CurrentPassword string `json:"current_password" validate:"required,min=8,max=128"` // 目前密碼 + NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼 +} + type CreateRoleReq struct { Key string `json:"key" validate:"required,min=2,max=64"` // 角色 key(2-64 字元,不可以 system. / platform_ 開頭) DisplayName string `json:"display_name,optional"` // 角色顯示名稱(給人看的) @@ -69,6 +78,29 @@ type ListUserRolesReq struct { UID string `path:"uid"` // 使用者 UID(path) } +type LoginData struct { + AccessToken string `json:"access_token,optional"` + RefreshToken string `json:"refresh_token,optional"` + ExpiresIn int64 `json:"expires_in,optional"` + UID string `json:"uid,optional"` + TokenType string `json:"token_type,optional"` + MFARequired bool `json:"mfa_required,optional"` + MFAChallengeID string `json:"mfa_challenge_id,optional"` + MFAExpiresIn int `json:"mfa_expires_in,optional"` +} + +type LoginMFAConfirmReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug + ChallengeID string `json:"challenge_id" validate:"required"` // 密碼登入後回傳的 MFA challenge ID + Code string `json:"code" validate:"required,len=6"` // TOTP 或備援碼(6 位數) +} + +type LoginOKStatus struct { + Code int64 `json:"code"` + Message string `json:"message"` + Data LoginData `json:"data"` +} + type LoginReq struct { TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug Email string `json:"email" validate:"required,email"` // 電子郵件 @@ -152,6 +184,39 @@ type MemberMeOKStatus struct { Data MemberMeData `json:"data"` } +type PasswordForgotData struct { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` +} + +type PasswordForgotOKStatus struct { + Code int64 `json:"code"` + Message string `json:"message"` + Data PasswordForgotData `json:"data"` +} + +type PasswordForgotReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug + Email string `json:"email" validate:"required,email"` // 登入 Email +} + +type PasswordResetData struct { + OK bool `json:"ok"` +} + +type PasswordResetOKStatus struct { + Code int64 `json:"code"` + Message string `json:"message"` + Data PasswordResetData `json:"data"` +} + +type PasswordResetReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug + ChallengeID string `json:"challenge_id" validate:"required"` // 忘記密碼 OTP challenge ID + Code string `json:"code" validate:"required,len=6"` // 6 位數 OTP + NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼 +} + type PermissionCatalogData struct { Tree []PermissionNode `json:"tree,omitempty"` List []PermissionNode `json:"list,omitempty"` @@ -239,6 +304,11 @@ type RegisterResendReq struct { ChallengeID string `json:"challenge_id" validate:"required"` // 註冊流程的 OTP challenge ID } +type RegisterResumeReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug + Email string `json:"email" validate:"required,email"` // 註冊 Email +} + type RegisterSocialCallbackReq struct { Code string `form:"code" validate:"required"` // IdP 回傳的 OAuth authorization code State string `form:"state" validate:"required"` // IdP 回傳的 OAuth state(對應 session) diff --git a/k6-seed-admin b/k6-seed-admin new file mode 100755 index 0000000..d3bc928 Binary files /dev/null and b/k6-seed-admin differ diff --git a/scripts/e2e-down.sh b/scripts/e2e-down.sh deleted file mode 100755 index 97d7fd6..0000000 --- a/scripts/e2e-down.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# shellcheck source=scripts/e2e-lib.sh -source "${ROOT}/scripts/e2e-lib.sh" -cd "$ROOT" - -GATEWAY_PORT="${GATEWAY_PORT:-18888}" -PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}" - -e2e_stop_gateway "${GATEWAY_PORT}" "${PID_FILE}" - -if command -v lsof >/dev/null 2>&1 && lsof -ti tcp:"${GATEWAY_PORT}" >/dev/null 2>&1; then - echo "e2e-down: warning — port ${GATEWAY_PORT} still in use" >&2 - lsof -i tcp:"${GATEWAY_PORT}" >&2 || true - exit 1 -fi - -# --profile smtp 涵蓋一般 + mailhog;docker compose 對未掛起的 profile 是 no-op,安全。 -docker compose --profile smtp down -v -e2e_ok "e2e-down OK(gateway stopped, docker cleaned)" diff --git a/scripts/e2e-lib.sh b/scripts/e2e-lib.sh deleted file mode 100644 index 1f64421..0000000 --- a/scripts/e2e-lib.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -# Shared helpers for e2e-run / e2e-up / e2e-down. -# shellcheck disable=SC2034 - -# Colors(非 TTY 自動關掉,CI log 才不會有 ANSI 噪音) -if [[ -t 1 ]]; then - E2E_BOLD=$'\033[1m'; E2E_DIM=$'\033[2m'; E2E_GREEN=$'\033[32m' - E2E_CYAN=$'\033[36m'; E2E_YELLOW=$'\033[33m'; E2E_RESET=$'\033[0m' -else - E2E_BOLD=""; E2E_DIM=""; E2E_GREEN=""; E2E_CYAN=""; E2E_YELLOW=""; E2E_RESET="" -fi - -# e2e_step "1/6" "fresh docker compose" -e2e_step() { - local idx="$1"; shift - printf "\n${E2E_BOLD}${E2E_CYAN}== [%s] %s ==${E2E_RESET}\n" "$idx" "$*" -} - -# e2e_info "啟動 mailhog(http://localhost:8025)" -e2e_info() { printf "${E2E_DIM}>> %s${E2E_RESET}\n" "$*"; } -e2e_ok() { printf "${E2E_GREEN}✔ %s${E2E_RESET}\n" "$*"; } -e2e_warn() { printf "${E2E_YELLOW}! %s${E2E_RESET}\n" "$*"; } - -e2e_root_dir() { - cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -} - -# 把所有 e2e services 列出來(給使用者知道環境準備好了什麼) -e2e_print_services() { - local with_smtp="${1:-}" - echo - printf "${E2E_BOLD}E2E 環境服務${E2E_RESET}\n" - printf " %-12s %-32s %s\n" "MongoDB" "127.0.0.1:27017" "${E2E_DIM}database=gateway_e2e${E2E_RESET}" - printf " %-12s %-32s %s\n" "Redis" "127.0.0.1:6379" "${E2E_DIM}OTP / Casbin policy / blacklist${E2E_RESET}" - printf " %-12s %-32s %s\n" "Gateway" "http://127.0.0.1:18888" "${E2E_DIM}health: /api/v1/health${E2E_RESET}" - if [[ "${with_smtp}" == "1" ]]; then - printf " %-12s %-32s %s\n" "MailHog" "http://127.0.0.1:8025" "${E2E_DIM}SMTP=1025(E2E_WITH_SMTP=1)${E2E_RESET}" - fi - echo -} - -# Stop gateway started for E2E: pid file → port listeners → stale go run orphans. -e2e_stop_gateway() { - local port="${1:-18888}" - local pid_file="${2:-}" - - if [[ -n "${pid_file}" && -f "${pid_file}" ]]; then - local pid - pid="$(cat "${pid_file}")" - if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then - echo ">> stopping gateway pid=${pid}" - kill "${pid}" 2>/dev/null || true - for _ in $(seq 1 10); do - kill -0 "${pid}" 2>/dev/null || break - sleep 0.2 - done - if kill -0 "${pid}" 2>/dev/null; then - kill -9 "${pid}" 2>/dev/null || true - fi - wait "${pid}" 2>/dev/null || true - fi - rm -f "${pid_file}" - fi - - if command -v lsof >/dev/null 2>&1; then - local pids - pids="$(lsof -ti tcp:"${port}" 2>/dev/null | tr '\n' ' ' || true)" - if [[ -n "${pids// /}" ]]; then - echo ">> stopping listener(s) on :${port} (${pids})" - # shellcheck disable=SC2086 - kill ${pids} 2>/dev/null || true - sleep 0.5 - pids="$(lsof -ti tcp:"${port}" 2>/dev/null | tr '\n' ' ' || true)" - if [[ -n "${pids// /}" ]]; then - # shellcheck disable=SC2086 - kill -9 ${pids} 2>/dev/null || true - fi - fi - fi - - # go run leaves a compiled binary under $TMPDIR; kill by e2e config path if still up. - if command -v pgrep >/dev/null 2>&1; then - while IFS= read -r orphan; do - [[ -z "${orphan}" ]] && continue - echo ">> stopping orphan gateway pid=${orphan}" - kill "${orphan}" 2>/dev/null || true - done < <(pgrep -f "gateway(-e2e)? .*${port}|gateway.go -f .*e2e.yaml" 2>/dev/null || true) - fi -} - -# Build a real binary so $! is the server PID (go run only tracks the wrapper). -e2e_start_gateway() { - local root="$1" - local config="$2" - local port="$3" - local pid_file="$4" - - local bin="${root}/.cache/e2e-gateway" - mkdir -p "${root}/.cache" - - e2e_stop_gateway "${port}" "${pid_file}" - - echo ">> building e2e gateway binary" - (cd "${root}" && go build -o "${bin}" gateway.go) - - echo ">> starting gateway on :${port}" - GATEWAY_E2E=1 "${bin}" -f "${config}" & - local pid=$! - echo "${pid}" > "${pid_file}" - echo "${pid}" -} - -e2e_wait_gateway() { - local port="$1" - local url="http://127.0.0.1:${port}/api/v1/health" - for i in $(seq 1 60); do - if curl -sf "${url}" >/dev/null; then - return 0 - fi - sleep 1 - done - echo "timeout waiting for gateway ${url}" >&2 - return 1 -} diff --git a/scripts/e2e-list.sh b/scripts/e2e-list.sh deleted file mode 100755 index 0d9bed3..0000000 --- a/scripts/e2e-list.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env bash -# 列出所有 E2E 測試(從 _test.go 的 e2eStep(...) 呼叫撈)。 -# 對齊 docs/e2e-testing.md 的「測試覆蓋矩陣」;新增 / 修改 e2eStep 後重跑即可。 -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TESTS_DIR="${ROOT}/test/e2e" - -if ! command -v rg >/dev/null 2>&1; then - echo "需要 ripgrep(brew install ripgrep)" >&2 - exit 1 -fi - -# Colors(非 TTY 時關掉) -if [[ -t 1 ]]; then - BOLD=$'\033[1m'; DIM=$'\033[2m'; CYAN=$'\033[36m'; YELLOW=$'\033[33m'; RESET=$'\033[0m' -else - BOLD=""; DIM=""; CYAN=""; YELLOW=""; RESET="" -fi - -echo "${BOLD}Gateway E2E — 自動測試清單${RESET}" -echo "${DIM}(執行:make e2e-full / make e2e-journey / make test-e2e;單測:go test -tags=e2e -run TestXxx)${RESET}" - -# ───────────────────────────────────────────────────────────── -# Section A: Contract tests(單 endpoint,由 e2eStep banner 撈) -# ───────────────────────────────────────────────────────────── -echo -echo "${BOLD}${CYAN}═══ Contract tests(make e2e-full)═══${RESET}" -echo "${DIM}單一 endpoint 驗 HTTP contract;可平行;每個 func 一個測試。${RESET}" - -# 模組分組 -contract_module() { - case "$(basename "$1")" in - health_test.go) echo "Health" ;; - auth_test.go) echo "Auth" ;; - member_test.go) echo "Member" ;; - permission_test.go) echo "Permission" ;; - *) echo "Other" ;; - esac -} - -contract_count=0 -current_module="" -while IFS= read -r line; do - file="${line%%:*}"; rest="${line#*:}" - lineno="${rest%%:*}" - sig="${rest#*:}" - fname="$(printf '%s' "$sig" | sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/')" - - # 只看 contract test 檔(不含 journey_*) - case "$(basename "$file")" in - journey_*.go|journey.go) continue ;; - esac - - mod="$(contract_module "$file")" - if [[ "$mod" != "$current_module" ]]; then - echo - echo " ${BOLD}── $mod ──${RESET}" - current_module="$mod" - fi - - step=$(awk -v start="$lineno" 'NR>=start && NR<=start+5 && /e2eStep\(t,/ { print; exit }' "$file") - if [[ -z "$step" ]]; then - printf " ${YELLOW}? %-40s${RESET} ${DIM}(no e2eStep banner)${RESET}\n" "$fname" - continue - fi - id="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\1/p')" - method="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\2/p')" - path="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\3/p')" - desc="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\4/p')" - - printf " ${BOLD}[%-9s]${RESET} %-7s %-50s %s\n" "$id" "$method" "$path" "$desc" - printf " ${DIM}└─ %s${RESET}\n" "$fname" - contract_count=$((contract_count+1)) -done < <(rg -n --no-heading '^func Test[A-Za-z0-9_]+\(t \*testing\.T\)' "${TESTS_DIR}" -t go) - -# ───────────────────────────────────────────────────────────── -# Section B: Journeys(k6 風格多步驟) -# ───────────────────────────────────────────────────────────── -echo -echo "${BOLD}${CYAN}═══ Journeys(make e2e-journey)═══${RESET}" -echo "${DIM}多步驟 user flow,共享狀態;任一步 fail 自動 skip 後續;用 NewJourney() + j.Step()。${RESET}" - -journey_count=0 -journey_step_total=0 -while IFS= read -r line; do - file="${line%%:*}"; rest="${line#*:}" - lineno="${rest%%:*}" - sig="${rest#*:}" - fname="$(printf '%s' "$sig" | sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/')" - - case "$(basename "$file")" in - journey_*.go) : ;; - *) continue ;; - esac - - # 抓 NewJourney(t, "J-1", "title") - jline=$(awk -v start="$lineno" 'NR>=start && NR<=start+3 && /NewJourney\(t,/ { print; exit }' "$file") - jid="$(printf '%s' "$jline" | sed -nE 's/.*NewJourney\(t, "([^"]*)", *"([^"]*)"\).*/\1/p')" - jtitle="$(printf '%s' "$jline" | sed -nE 's/.*NewJourney\(t, "([^"]*)", *"([^"]*)"\).*/\2/p')" - if [[ -z "$jid" ]]; then - jid="?"; jtitle="(no NewJourney call)" - fi - steps="$(rg -n '\s+j\.(Step|SkipStep)\(' "$file" 2>/dev/null | wc -l | tr -d ' ')" - echo - printf " ${BOLD}[%s] %s${RESET} ${DIM}(%d steps · %s)${RESET}\n" "$jid" "$jtitle" "$steps" "$fname" - - # 列出每個 step 的 id + desc - rg -oN 'j\.(Step|SkipStep)\("[^"]+",\s*"[^"]+"' "$file" 2>/dev/null \ - | sed -nE 's/.*j\.(Step|SkipStep)\("([^"]+)", *"([^"]+)".*/\1|\2|\3/p' \ - | awk -F'|' -v jid="$jid" -v reset="$RESET" -v yellow="$YELLOW" ' - { - kind=$1; sid=$2; desc=$3 - marker = (kind == "SkipStep") ? "⊘" : "▶" - color = (kind == "SkipStep") ? yellow : "" - printf " %s%s [%s.%s]%s %s\n", color, marker, jid, sid, reset, desc - } - ' - - journey_count=$((journey_count+1)) - journey_step_total=$((journey_step_total+steps)) -done < <(rg -n --no-heading '^func Test[A-Za-z0-9_]+\(t \*testing\.T\)' "${TESTS_DIR}" -t go) - -# ───────────────────────────────────────────────────────────── -echo -echo "${DIM}合計:${contract_count} 個 contract tests · ${journey_count} 個 journeys (${journey_step_total} steps)${RESET}" diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh deleted file mode 100755 index 811196d..0000000 --- a/scripts/e2e-run.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# 一鍵 E2E:全新 Docker → index → seed → 起 Gateway → 跑測試 → 關閉並清 volume -# -# 環境變數: -# E2E_KEEP_DOCKER=1 跑完不 docker compose down -v(方便查 Mongo/Redis) -# E2E_WITH_SMTP=1 額外起 MailHog(http://localhost:8025)方便肉眼看寄信 -# E2E_CONFIG=... 預設 test/e2e/fixtures/e2e.yaml -# E2E_TEST_PATTERN 第一輪 go test -run pattern -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# shellcheck source=scripts/e2e-lib.sh -source "${ROOT}/scripts/e2e-lib.sh" -cd "$ROOT" - -E2E_CONFIG="${E2E_CONFIG:-test/e2e/fixtures/e2e.yaml}" -E2E_STATE="${E2E_STATE:-${ROOT}/test/e2e/fixtures/state.json}" -E2E_MODE="${E2E_MODE:-contract}" # contract / journey -E2E_TEST_PATTERN="${E2E_TEST_PATTERN:-Test(Auth_|Health|Member|Permission)}" -E2E_TEST_PATTERN_ZZZ="${E2E_TEST_PATTERN_ZZZ:-TestZZZ_AuthTokenRefreshAndLogout}" -GATEWAY_PORT="${GATEWAY_PORT:-18888}" -PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}" -E2E_WITH_SMTP="${E2E_WITH_SMTP:-}" - -cleanup() { - e2e_stop_gateway "${GATEWAY_PORT}" "${PID_FILE}" - if [[ "${E2E_KEEP_DOCKER:-}" != "1" ]]; then - e2e_info "docker compose down -v(如要保留資料:E2E_KEEP_DOCKER=1)" - docker compose down -v >/dev/null 2>&1 || docker compose --profile smtp down -v >/dev/null 2>&1 || true - else - e2e_warn "E2E_KEEP_DOCKER=1:容器繼續運行" - fi -} -trap cleanup EXIT - -TOTAL_STEPS=6 - -e2e_step "1/${TOTAL_STEPS}" "fresh docker compose(mongo + redis$( [[ "$E2E_WITH_SMTP" == "1" ]] && echo " + mailhog" ))" -docker compose down -v >/dev/null 2>&1 || true -if [[ "$E2E_WITH_SMTP" == "1" ]]; then - docker compose --profile smtp up -d mongo redis mailhog -else - docker compose up -d mongo redis -fi - -e2e_step "2/${TOTAL_STEPS}" "wait for healthcheck" -for i in $(seq 1 60); do - if docker compose ps mongo 2>/dev/null | grep -q "(healthy)" && docker compose ps redis 2>/dev/null | grep -q "(healthy)"; then - e2e_ok "mongo / redis healthy(${i}s)" - break - fi - sleep 1 - if [[ "$i" -eq 60 ]]; then - echo "timeout waiting for docker health" >&2 - docker compose ps >&2 - exit 1 - fi -done - -e2e_step "3/${TOTAL_STEPS}" "建立 Mongo 索引(cmd/mongo-index)" -go run ./cmd/mongo-index -f "${E2E_CONFIG}" -e2e_ok "indexes ready" - -e2e_step "4/${TOTAL_STEPS}" "seed tenant + member + permission + JWT(cmd/e2e-seed)" -rm -f "${E2E_STATE}" -seed_args=(-f "${E2E_CONFIG}" -out "${E2E_STATE}") -if [[ -n "${E2E_ROLE:-}" ]]; then - seed_args+=(-role "${E2E_ROLE}") -fi -go run ./cmd/e2e-seed "${seed_args[@]}" -e2e_ok "state.json written → ${E2E_STATE}" - -e2e_step "5/${TOTAL_STEPS}" "啟動 Gateway(:${GATEWAY_PORT})" -e2e_start_gateway "${ROOT}" "${E2E_CONFIG}" "${GATEWAY_PORT}" "${PID_FILE}" >/dev/null -e2e_wait_gateway "${GATEWAY_PORT}" -e2e_ok "gateway up" - -e2e_print_services "$E2E_WITH_SMTP" - -case "${E2E_MODE}" in - journey) - step6_title="跑 E2E user journeys(每步驟印 ▶ [J-x.y] 中文情境,斷一步停整 journey)" ;; - *) - step6_title="跑 E2E contract tests(每個測試印 ▶ [ID] METHOD path — 中文情境)" ;; -esac - -e2e_step "6/${TOTAL_STEPS}" "${step6_title}" -e2e_info "第一輪:pattern=${E2E_TEST_PATTERN}" -GATEWAY_E2E=1 E2E_STATE_FILE="${E2E_STATE}" E2E_BASE_URL="http://127.0.0.1:${GATEWAY_PORT}" \ - go test -tags=e2e -v -count=1 ./test/e2e/... -run "${E2E_TEST_PATTERN}" - -e2e_info "第二輪:pattern=${E2E_TEST_PATTERN_ZZZ}(會撤銷 JWT,故最後跑)" -GATEWAY_E2E=1 E2E_STATE_FILE="${E2E_STATE}" E2E_BASE_URL="http://127.0.0.1:${GATEWAY_PORT}" \ - go test -tags=e2e -v -count=1 ./test/e2e/... -run "${E2E_TEST_PATTERN_ZZZ}" - -echo -e2e_ok "E2E OK" diff --git a/scripts/e2e-up.sh b/scripts/e2e-up.sh deleted file mode 100755 index b5a8285..0000000 --- a/scripts/e2e-up.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# 啟動 E2E 環境但不跑測試(方便本機除錯) -# -# E2E_WITH_SMTP=1 多起一個 MailHog(http://localhost:8025) -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# shellcheck source=scripts/e2e-lib.sh -source "${ROOT}/scripts/e2e-lib.sh" -cd "$ROOT" - -E2E_CONFIG="${E2E_CONFIG:-test/e2e/fixtures/e2e.yaml}" -E2E_STATE="${E2E_STATE:-${ROOT}/test/e2e/fixtures/state.json}" -GATEWAY_PORT="${GATEWAY_PORT:-18888}" -PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}" -E2E_WITH_SMTP="${E2E_WITH_SMTP:-}" - -e2e_step "1/5" "fresh docker compose" -docker compose down -v >/dev/null 2>&1 || true -if [[ "$E2E_WITH_SMTP" == "1" ]]; then - docker compose --profile smtp up -d mongo redis mailhog -else - docker compose up -d mongo redis -fi - -e2e_step "2/5" "wait for healthcheck" -for i in $(seq 1 60); do - if docker compose ps mongo 2>/dev/null | grep -q "(healthy)" && docker compose ps redis 2>/dev/null | grep -q "(healthy)"; then - e2e_ok "mongo / redis healthy(${i}s)" - break - fi - sleep 1 -done - -e2e_step "3/5" "建立 Mongo 索引" -go run ./cmd/mongo-index -f "${E2E_CONFIG}" -e2e_ok "indexes ready" - -e2e_step "4/5" "seed E2E 資料 + JWT" -go run ./cmd/e2e-seed -f "${E2E_CONFIG}" -out "${E2E_STATE}" -e2e_ok "state.json written" - -e2e_step "5/5" "啟動 Gateway(:${GATEWAY_PORT})" -if [[ -f "${PID_FILE}" ]] && kill -0 "$(cat "${PID_FILE}")" 2>/dev/null && curl -sf "http://127.0.0.1:${GATEWAY_PORT}/api/v1/health" >/dev/null; then - e2e_warn "gateway already running pid=$(cat "${PID_FILE}")" -else - pid="$(e2e_start_gateway "${ROOT}" "${E2E_CONFIG}" "${GATEWAY_PORT}" "${PID_FILE}")" - e2e_ok "gateway started pid=${pid}" -fi -e2e_wait_gateway "${GATEWAY_PORT}" - -e2e_print_services "$E2E_WITH_SMTP" - -echo "${E2E_BOLD}下一步${E2E_RESET}" -echo " ${E2E_DIM}# 列出有哪些 E2E 測試${E2E_RESET}" -echo " make e2e-list" -echo " ${E2E_DIM}# 全部測試(每個會顯示 ▶ [ID] METHOD path — 中文情境)${E2E_RESET}" -echo " make test-e2e" -echo " ${E2E_DIM}# 單一測試${E2E_RESET}" -echo " GATEWAY_E2E=1 go test -tags=e2e -v -count=1 ./test/e2e/ -run TestMember_GetMe" -echo " ${E2E_DIM}# 結束${E2E_RESET}" -echo " make e2e-down" diff --git a/test/e2e/auth_test.go b/test/e2e/auth_test.go deleted file mode 100644 index 7e8896f..0000000 --- a/test/e2e/auth_test.go +++ /dev/null @@ -1,96 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/require" -) - -// TestZZZ_AuthTokenRefreshAndLogout runs last (separate go test invocation). -// It uses an isolated refresh so seed tokens used by member/permission stay valid. -func TestZZZ_AuthTokenRefreshAndLogout(t *testing.T) { - e2eStep(t, "A-10/A-11", "POST", "/api/v1/auth/{token/refresh,logout}", "刷新 token → 用新 access 打 /me → logout → 黑名單後再打 /me=401") - c := isolatedAuthClient(t) - - refreshEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/token/refresh", map[string]string{ - "refresh_token": c.Fixture.RefreshToken, - }, false) - var pair struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - UID string `json:"uid"` - } - require.NoError(t, json.Unmarshal(refreshEnv.Data, &pair)) - require.Equal(t, c.Fixture.UID, pair.UID) - - c.Fixture.AccessToken = pair.AccessToken - c.Fixture.RefreshToken = pair.RefreshToken - - c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) - c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/logout", nil, true) - - resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, true) - require.Equal(t, http.StatusUnauthorized, resp.StatusCode) - require.NotEqual(t, int64(successCode), env.Code) -} - -func TestAuth_MissingBearer_401(t *testing.T) { - e2eStep(t, "A-12", "GET", "/api/v1/members/me", "未帶 Bearer → 401(AuthJWT middleware)") - c := NewClient(t) - resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, false) - require.Equal(t, http.StatusUnauthorized, resp.StatusCode) - require.NotEqual(t, int64(successCode), env.Code) -} - -func TestAuth_PublicValidationErrors(t *testing.T) { - e2eStep(t, "A-13", "POST", "/api/v1/auth/*", "公開 Auth 端點輸入驗證錯誤 → 400 + Facade scope") - c := NewClient(t) - - cases := []struct { - name string - path string - body any - }{ - { - name: "register missing required fields", - path: "/api/v1/auth/register", - body: map[string]any{}, - }, - { - name: "login invalid email and password", - path: "/api/v1/auth/login", - body: map[string]any{ - "tenant_slug": c.Fixture.TenantSlug, - "email": "not-an-email", - "password": "short", - }, - }, - { - name: "token refresh missing token", - path: "/api/v1/auth/token/refresh", - body: map[string]any{}, - }, - { - name: "social login invalid provider", - path: "/api/v1/auth/login/social/start", - body: map[string]any{ - "tenant_slug": c.Fixture.TenantSlug, - "provider": "github", - "redirect_uri": "http://127.0.0.1/callback", - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - env := c.DoExpectHTTP(t, http.MethodPost, tc.path, tc.body, false, http.StatusBadRequest) - require.NotEqual(t, int64(successCode), env.Code) - // Facade scope 10101000 = InputInvalidFormat(gateway parse / validate 進入點) - require.Equal(t, int64(10), env.Code/1_000_000, "expected Facade scope, got code=%d", env.Code) - }) - } -} diff --git a/test/e2e/client.go b/test/e2e/client.go deleted file mode 100644 index 0fd22cc..0000000 --- a/test/e2e/client.go +++ /dev/null @@ -1,144 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "testing" - - "github.com/stretchr/testify/require" -) - -const successCode = 102000 - -// Fixture holds seed output from cmd/e2e-seed. -type Fixture struct { - BaseURL string `json:"base_url"` - TenantID string `json:"tenant_id"` - TenantSlug string `json:"tenant_slug"` - UID string `json:"uid"` - Email string `json:"email"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - RoleKey string `json:"role_key"` - NoRoleUID string `json:"no_role_uid"` - NoRoleEmail string `json:"no_role_email"` - NoRoleAccessToken string `json:"no_role_access_token"` - NoRoleRefreshToken string `json:"no_role_refresh_token"` -} - -// Client is a thin HTTP helper for Gateway E2E tests. -type Client struct { - BaseURL string - HTTP *http.Client - Fixture Fixture -} - -// Envelope matches gateway/types.Status JSON. -type Envelope struct { - Code int64 `json:"code"` - Message string `json:"message"` - Data json.RawMessage `json:"data"` - Error json.RawMessage `json:"error,omitempty"` -} - -// LoadFixture returns the shared bootstrap fixture (refreshed once in TestMain). -func LoadFixture(t *testing.T) Fixture { - t.Helper() - fx := loadFixture() - require.NotEmpty(t, fx.BaseURL) - require.NotEmpty(t, fx.AccessToken) - return fx -} - -// NewClient builds a client from the shared fixture. -func NewClient(t *testing.T) *Client { - t.Helper() - return freshClient(t) -} - -// NewNoRoleClient builds a client for the seeded member that intentionally -// has no role assignment. It is used by Casbin-enabled authorization tests. -func NewNoRoleClient(t *testing.T) *Client { - t.Helper() - c := freshClient(t) - require.NotEmpty(t, c.Fixture.NoRoleUID) - require.NotEmpty(t, c.Fixture.NoRoleAccessToken) - c.Fixture.UID = c.Fixture.NoRoleUID - c.Fixture.Email = c.Fixture.NoRoleEmail - c.Fixture.AccessToken = c.Fixture.NoRoleAccessToken - c.Fixture.RefreshToken = c.Fixture.NoRoleRefreshToken - return c -} - -func (c *Client) URL(path string) string { - return c.BaseURL + path -} - -func (c *Client) Do(t *testing.T, method, path string, body any, auth bool) (*http.Response, Envelope) { - t.Helper() - var r io.Reader - if body != nil { - raw, err := json.Marshal(body) - require.NoError(t, err) - r = bytes.NewReader(raw) - } - req, err := http.NewRequest(method, c.URL(path), r) - require.NoError(t, err) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - if auth { - req.Header.Set("Authorization", "Bearer "+c.Fixture.AccessToken) - } - resp, err := c.HTTP.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - var env Envelope - require.NoError(t, json.Unmarshal(respBody, &env), "body=%s", string(respBody)) - return resp, env -} - -func (c *Client) DoExpectOK(t *testing.T, method, path string, body any, auth bool) Envelope { - t.Helper() - resp, env := c.Do(t, method, path, body, auth) - require.Equal(t, http.StatusOK, resp.StatusCode, "path=%s code=%d body=%s", path, env.Code, string(mustRaw(env))) - require.Equal(t, int64(successCode), env.Code, "path=%s message=%s", path, env.Message) - return env -} - -func (c *Client) DoExpectHTTP(t *testing.T, method, path string, body any, auth bool, httpStatus int) Envelope { - t.Helper() - resp, env := c.Do(t, method, path, body, auth) - require.Equal(t, httpStatus, resp.StatusCode, "path=%s env=%+v", path, env) - return env -} - -func mustRaw(env Envelope) []byte { - if len(env.Data) == 0 { - return []byte(env.Message) - } - return env.Data -} - -// FetchE2EOTP reads OTP plain code stashed by verify_helper when GATEWAY_E2E=1. -func FetchE2EOTP(t *testing.T, challengeID string) string { - t.Helper() - if cli := os.Getenv("REDISCLI"); cli != "" { - out, err := runCmd(cli, "-c", fmt.Sprintf("GET e2e:otp:%s", challengeID)) - require.NoError(t, err) - require.NotEmpty(t, out, "missing e2e otp for challenge %s", challengeID) - return out - } - out, err := runCmd("docker", "exec", "gateway-redis", "redis-cli", "GET", "e2e:otp:"+challengeID) - require.NoError(t, err, "fetch otp via docker exec (is gateway-redis running?)") - require.NotEmpty(t, out, "missing e2e otp for challenge %s", challengeID) - return out -} diff --git a/test/e2e/exec.go b/test/e2e/exec.go deleted file mode 100644 index 00e06f8..0000000 --- a/test/e2e/exec.go +++ /dev/null @@ -1,17 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "os/exec" - "strings" -) - -func runCmd(name string, args ...string) (string, error) { - cmd := exec.Command(name, args...) - out, err := cmd.Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} diff --git a/test/e2e/fixtures/e2e.casbin.yaml b/test/e2e/fixtures/e2e.casbin.yaml deleted file mode 100644 index 6489c36..0000000 --- a/test/e2e/fixtures/e2e.casbin.yaml +++ /dev/null @@ -1,93 +0,0 @@ -# E2E 專用設定(Casbin enabled;make e2e-casbin 使用) -# 固定 Port 18888,避免與本機 dev server (8888) 衝突 - -Name: gateway-e2e-casbin -Host: 0.0.0.0 -Port: 18888 - -Mongo: - Schema: mongodb - Host: 127.0.0.1 - Port: 27017 - Database: gateway_e2e - AuthSource: "" - ReplicaName: "" - TLS: false - MaxPoolSize: 30 - MinPoolSize: 5 - MaxConnIdleTime: 30m - -Redis: - Host: localhost:6379 - Type: node - -Notification: - DefaultLocale: zh-tw - Email: - Provider: mock - From: e2e-noreply@example.com - SMTP: - Enable: false - SMS: - Provider: mock - Mitake: - Enable: false - Async: - Worker: 1 - MaxRetry: 3 - BackoffSeconds: [1, 2, 5] - RatePerTenant: - Email: 1000 - SMS: 500 - -Member: - OTP: - Length: 6 - TTLSeconds: 300 - MaxAttempts: 5 - ResendCooldownSeconds: 1 - DailyVerifyLimit: 100 - TOTP: - Issuer: CloudEP-E2E - Algorithm: SHA1 - Digits: 6 - PeriodSeconds: 30 - Window: 1 - BackupCodeCount: 5 - BackupCodeLength: 12 - EnrollTTLSeconds: 600 - ReplayTTLSeconds: 90 - SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" - Registration: - RequireInviteCode: false - TrustSocialEmailVerified: true - -Auth: - AccessExpire: 900 - RefreshExpire: 604800 - ActiveKID: v1 - AccessSecret: "e2e-access-secret-32-bytes-min!!" - RefreshSecret: "e2e-refresh-secret-32-bytes-min!" - RegistrationSessionTTLSeconds: 600 - -Permission: - Casbin: - Enabled: true - ModelPath: etc/rbac.conf - PolicyAdapter: redis - Cache: - UserRolesTTLSeconds: 60 - RolePermsTTLSeconds: 60 - CatalogTTLSeconds: 120 - Reload: - Channel: casbin:reload:e2e - DebounceMilliseconds: 100 - HeartbeatSeconds: 30 - -Zitadel: - Issuer: "" - ServiceUserToken: "" - DefaultOrgID: "" - OAuthClientID: "" - OAuthClientSecret: "" - TimeoutSeconds: 5 diff --git a/test/e2e/fixtures/e2e.yaml b/test/e2e/fixtures/e2e.yaml deleted file mode 100644 index 3062d2b..0000000 --- a/test/e2e/fixtures/e2e.yaml +++ /dev/null @@ -1,93 +0,0 @@ -# E2E 專用設定(make e2e-full 使用;勿與 gateway.dev.yaml 混用) -# 固定 Port 18888,避免與本機 dev server (8888) 衝突 - -Name: gateway-e2e -Host: 0.0.0.0 -Port: 18888 - -Mongo: - Schema: mongodb - Host: 127.0.0.1 - Port: 27017 - Database: gateway_e2e - AuthSource: "" - ReplicaName: "" - TLS: false - MaxPoolSize: 30 - MinPoolSize: 5 - MaxConnIdleTime: 30m - -Redis: - Host: localhost:6379 - Type: node - -Notification: - DefaultLocale: zh-tw - Email: - Provider: mock - From: e2e-noreply@example.com - SMTP: - Enable: false - SMS: - Provider: mock - Mitake: - Enable: false - Async: - Worker: 1 - MaxRetry: 3 - BackoffSeconds: [1, 2, 5] - RatePerTenant: - Email: 1000 - SMS: 500 - -Member: - OTP: - Length: 6 - TTLSeconds: 300 - MaxAttempts: 5 - ResendCooldownSeconds: 1 - DailyVerifyLimit: 100 - TOTP: - Issuer: CloudEP-E2E - Algorithm: SHA1 - Digits: 6 - PeriodSeconds: 30 - Window: 1 - BackupCodeCount: 5 - BackupCodeLength: 12 - EnrollTTLSeconds: 600 - ReplayTTLSeconds: 90 - SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" - Registration: - RequireInviteCode: false - TrustSocialEmailVerified: true - -Auth: - AccessExpire: 900 - RefreshExpire: 604800 - ActiveKID: v1 - AccessSecret: "e2e-access-secret-32-bytes-min!!" - RefreshSecret: "e2e-refresh-secret-32-bytes-min!" - RegistrationSessionTTLSeconds: 600 - -Permission: - Casbin: - Enabled: false - ModelPath: etc/rbac.conf - PolicyAdapter: auto - Cache: - UserRolesTTLSeconds: 60 - RolePermsTTLSeconds: 60 - CatalogTTLSeconds: 120 - Reload: - Channel: casbin:reload:e2e - DebounceMilliseconds: 100 - HeartbeatSeconds: 30 - -Zitadel: - Issuer: "" - ServiceUserToken: "" - DefaultOrgID: "" - OAuthClientID: "" - OAuthClientSecret: "" - TimeoutSeconds: 5 diff --git a/test/e2e/health_test.go b/test/e2e/health_test.go deleted file mode 100644 index 3e66497..0000000 --- a/test/e2e/health_test.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestHealth_Ping(t *testing.T) { - e2eStep(t, "N-01", "GET", "/api/v1/health", "Ping 回 200 + envelope code=102000") - c := NewClient(t) - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/health", nil, false) - var data map[string]any - require.NoError(t, json.Unmarshal(env.Data, &data)) -} - -func TestHealth_NoAuthRequired(t *testing.T) { - e2eStep(t, "N-02", "GET", "/api/v1/health", "未帶 Bearer 也能通過") - c := NewClient(t) - resp, env := c.Do(t, http.MethodGet, "/api/v1/health", nil, false) - require.Equal(t, http.StatusOK, resp.StatusCode) - require.Equal(t, int64(successCode), env.Code) -} diff --git a/test/e2e/journey.go b/test/e2e/journey.go deleted file mode 100644 index cb3b14f..0000000 --- a/test/e2e/journey.go +++ /dev/null @@ -1,86 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "sync/atomic" - "testing" -) - -// Journey is a k6-style user journey: an ordered sequence of HTTP steps that -// share local state (closures over the parent test). If any step fails the -// remaining steps are auto-skipped, mirroring k6 scenario "abort on fail" -// behaviour so logs are easy to read — you stop at the first broken step. -// -// Use j.Step(id, desc, fn) to add steps; the framework prints: -// -// ▶ [J-1] Tenant Owner 入職第一天 -// ▶ [J-1.1] GET /me 看自己是誰 -// ▶ [J-1.2] PATCH /me 更新 display_name -// ⊘ [J-1.3] skipped (journey aborted) -type Journey struct { - t *testing.T - id string - title string - aborted atomic.Bool - total int - ran int - failed bool -} - -// NewJourney prints the journey banner and returns a builder. -// Call j.Run() at the end to print the final summary. -func NewJourney(t *testing.T, id, title string) *Journey { - t.Helper() - t.Logf("▶ [%s] %s", id, title) - return &Journey{t: t, id: id, title: title} -} - -// Step adds a step. fn receives a sub-test t scoped to this step so testify -// require.* abort just this step (not the whole Test function), letting the -// framework print a clean "aborted" line for subsequent steps. -// -// Pre-existing failures in earlier steps short-circuit the step and emit a -// "⊘ skipped" line. -func (j *Journey) Step(id, desc string, fn func(t *testing.T)) { - j.total++ - stepID := j.id + "." + id - name := stepID + " " + desc - j.t.Run(name, func(t *testing.T) { - if j.aborted.Load() { - t.Skipf("⊘ [%s] skipped — journey aborted at an earlier step", stepID) - return - } - t.Logf(" ▶ [%s] %s", stepID, desc) - j.ran++ - // fn may call require.* which marks t failed and FailNow's. After it - // returns we inspect t.Failed() to decide whether to abort siblings. - fn(t) - if t.Failed() { - j.aborted.Store(true) - j.failed = true - t.Logf("✗ [%s] FAIL — aborting remaining steps", stepID) - } - }) -} - -// SkipStep marks a step as intentionally skipped (e.g. requires ZITADEL). -// The journey is NOT aborted; the next Step still runs. -func (j *Journey) SkipStep(id, desc, reason string) { - j.total++ - stepID := j.id + "." + id - name := stepID + " " + desc - j.t.Run(name, func(t *testing.T) { - t.Skipf("⊘ [%s] %s — %s", stepID, desc, reason) - }) -} - -// Summary prints the final journey result. Always defer this right after -// NewJourney so it runs even on require.* abort. -func (j *Journey) Summary() { - status := "✔" - if j.failed { - status = "✗" - } - j.t.Logf("%s [%s] %s — %d/%d steps executed", status, j.id, j.title, j.ran, j.total) -} diff --git a/test/e2e/journey_owner_test.go b/test/e2e/journey_owner_test.go deleted file mode 100644 index 8821cd5..0000000 --- a/test/e2e/journey_owner_test.go +++ /dev/null @@ -1,162 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/require" -) - -// TestJourney_OwnerOnboarding 模擬 Tenant Owner 入職第一天會走的完整流程: -// 已 login(seed 提供 JWT)→ 看自己 → 更新 profile → 驗證業務 email → 驗證 phone -// → 綁定 TOTP → step-up 驗碼 → 解除 TOTP → logout。 -// -// 是「狀態流」測試:每步都用上一步的結果(challenge_id / display_name / OTP code), -// 任一步 fail 後續自動 skip,便於一眼定位斷點。 -func TestJourney_OwnerOnboarding(t *testing.T) { - j := NewJourney(t, "J-1", "Tenant Owner 入職第一天(已登入後完整 onboarding)") - defer j.Summary() - - c := NewClient(t) - - var ( - emailTarget = "owner-journey@example.com" - phoneTarget = "+886900111222" - emailChallenge string - phoneChallenge string - otpauthURL string - totpDigits int - totpPeriod int - totpCode string - ) - - j.Step("1", "GET /me — 用 seed JWT 確認自己是 tenant_owner 且 status=active", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) - var me struct { - TenantID string `json:"tenant_id"` - UID string `json:"uid"` - Status string `json:"status"` - } - require.NoError(t, json.Unmarshal(env.Data, &me)) - require.Equal(t, c.Fixture.UID, me.UID) - require.Equal(t, "active", me.Status) - }) - - j.Step("2", "PATCH /me — 更新 display_name", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodPatch, "/api/v1/members/me", map[string]string{ - "display_name": "Journey Owner", - }, true) - var me struct { - DisplayName string `json:"display_name"` - } - require.NoError(t, json.Unmarshal(env.Data, &me)) - require.Equal(t, "Journey Owner", me.DisplayName) - }) - - j.Step("3", "POST /me/verifications/email/start — 申請業務 email OTP", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/start", map[string]string{ - "target": emailTarget, - }, true) - var start struct { - ChallengeID string `json:"challenge_id"` - } - require.NoError(t, json.Unmarshal(env.Data, &start)) - require.NotEmpty(t, start.ChallengeID) - emailChallenge = start.ChallengeID - }) - - j.Step("4", "POST /me/verifications/email/confirm — 從 Redis 取碼後驗證", func(t *testing.T) { - require.NotEmpty(t, emailChallenge, "missing email challenge from step 3") - code := FetchE2EOTP(t, emailChallenge) - c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/confirm", map[string]string{ - "challenge_id": emailChallenge, - "code": code, - }, true) - }) - - j.Step("5", "GET /me — 確認 business_email_verified=true", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) - var me struct { - BusinessEmail string `json:"business_email"` - BusinessEmailVerified bool `json:"business_email_verified"` - } - require.NoError(t, json.Unmarshal(env.Data, &me)) - require.Equal(t, emailTarget, me.BusinessEmail) - require.True(t, me.BusinessEmailVerified, "expected email_verified=true after confirm") - }) - - j.Step("6", "POST /me/verifications/phone/start — 申請業務 phone OTP", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/start", map[string]string{ - "target": phoneTarget, - }, true) - var start struct { - ChallengeID string `json:"challenge_id"` - } - require.NoError(t, json.Unmarshal(env.Data, &start)) - phoneChallenge = start.ChallengeID - }) - - j.Step("7", "POST /me/verifications/phone/confirm — 取碼後驗證", func(t *testing.T) { - require.NotEmpty(t, phoneChallenge) - code := FetchE2EOTP(t, phoneChallenge) - c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/confirm", map[string]string{ - "challenge_id": phoneChallenge, - "code": code, - }, true) - }) - - j.Step("8", "POST /me/totp/enroll-start — 開始綁定 TOTP", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-start", nil, true) - var start struct { - OtpauthURL string `json:"otpauth_url"` - Digits int `json:"digits"` - PeriodSec int `json:"period_seconds"` - } - require.NoError(t, json.Unmarshal(env.Data, &start)) - require.NotEmpty(t, start.OtpauthURL) - otpauthURL = start.OtpauthURL - totpDigits = start.Digits - totpPeriod = start.PeriodSec - }) - - j.Step("9", "POST /me/totp/enroll-confirm — 用 otpauth_url 算當下 TOTP code 確認綁定", func(t *testing.T) { - require.NotEmpty(t, otpauthURL) - totpCode = codeFromOtpauthURL(t, otpauthURL, totpDigits, totpPeriod) - env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-confirm", map[string]string{ - "code": totpCode, - }, true) - var confirmed struct { - BackupCodes []string `json:"backup_codes"` - } - require.NoError(t, json.Unmarshal(env.Data, &confirmed)) - require.NotEmpty(t, confirmed.BackupCodes, "enroll-confirm should return backup codes exactly once") - }) - - j.Step("10", "POST /me/totp/verify — step-up 驗碼成功", func(t *testing.T) { - require.NotEmpty(t, totpCode) - c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ - "code": totpCode, - }, true) - }) - - j.Step("11", "POST /me/totp/verify (replay) — 同 code 再打應該 403(重放保護)", func(t *testing.T) { - require.NotEmpty(t, totpCode) - env := c.DoExpectHTTP(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ - "code": totpCode, - }, true, http.StatusForbidden) - require.NotEqual(t, int64(successCode), env.Code) - }) - - j.Step("12", "DELETE /me/totp — 解除綁定", func(t *testing.T) { - c.DoExpectOK(t, http.MethodDelete, "/api/v1/members/me/totp", nil, true) - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) - var st struct { - Enrolled bool `json:"enrolled"` - } - require.NoError(t, json.Unmarshal(env.Data, &st)) - require.False(t, st.Enrolled) - }) -} diff --git a/test/e2e/journey_rbac_test.go b/test/e2e/journey_rbac_test.go deleted file mode 100644 index d9ac1a6..0000000 --- a/test/e2e/journey_rbac_test.go +++ /dev/null @@ -1,147 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "encoding/json" - "fmt" - "net/http" - "testing" - - "github.com/stretchr/testify/require" -) - -// TestJourney_TenantAdminCustomRole 模擬 Tenant Admin 為一個新工種「qa_engineer」 -// 從零建出可用角色的流程: -// 建 Role → 從 catalog 取 perm id → 全量 PUT 權限 → 設 IdP Mapping → 指派給 user -// → 用該 user 視角看 /permissions/me 確認權限生效 → 撤銷 → 刪 mapping → 刪 role -// -// 整段都用 seed owner token;no-role user 用來驗證「被指派後拿到權限」。 -func TestJourney_TenantAdminCustomRole(t *testing.T) { - j := NewJourney(t, "J-2", "Tenant Admin 從零建立 qa_engineer 角色 → 指派 → 驗證 → 撤銷") - defer j.Summary() - - owner := NewClient(t) - noRole := NewNoRoleClient(t) - - var ( - roleKey = "journey_qa_engineer" - externalKey = fmt.Sprintf("journey-qa-group-%s", noRole.Fixture.UID) - roleID string - permissionID string - ) - - // 清掉殘留(前一輪可能 fail 沒走到清理 step) - t.Cleanup(func() { - _, _ = owner.Do(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{ - "external_source": "zitadel", - "external_key": externalKey, - }, true) - if roleID != "" { - _, _ = owner.Do(t, http.MethodDelete, "/api/v1/permissions/users/"+noRole.Fixture.UID+"/roles/"+roleID, nil, true) - _, _ = owner.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+roleID, nil, true) - } - }) - - j.Step("1", "POST /permissions/roles — 建立 qa_engineer 角色", func(t *testing.T) { - env := owner.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{ - "key": roleKey, - "display_name": "QA Engineer", - "status": "open", - }, true) - var role struct { - ID string `json:"id"` - Key string `json:"key"` - } - require.NoError(t, json.Unmarshal(env.Data, &role)) - require.Equal(t, roleKey, role.Key) - require.NotEmpty(t, role.ID) - roleID = role.ID - }) - - j.Step("2", "GET /permissions/catalog — 從平台 catalog 撈第一個 leaf permission id", func(t *testing.T) { - permissionID = firstCatalogPermissionID(t, owner) - require.NotEmpty(t, permissionID) - }) - - j.Step("3", "PUT /permissions/roles/:id/permissions — 把選到的 permission 灌進 role", func(t *testing.T) { - require.NotEmpty(t, roleID) - require.NotEmpty(t, permissionID) - owner.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/roles/"+roleID+"/permissions", map[string][]string{ - "permission_ids": {permissionID}, - }, true) - - // 讀回比對:parent closure 會把祖先一起加進去,所以集合 >= 1 + 必含 permissionID - env := owner.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles/"+roleID+"/permissions", nil, true) - var list struct { - Permissions []struct { - ID string `json:"id"` - } `json:"permissions"` - } - require.NoError(t, json.Unmarshal(env.Data, &list)) - found := false - for _, p := range list.Permissions { - if p.ID == permissionID { - found = true - break - } - } - require.True(t, found) - }) - - j.Step("4", "PUT /permissions/role-mappings — 設定 zitadel:qa-group → qa_engineer 對映", func(t *testing.T) { - env := owner.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/role-mappings", map[string]string{ - "external_source": "zitadel", - "external_key": externalKey, - "internal_role_key": roleKey, - }, true) - var mapping struct { - ExternalKey string `json:"external_key"` - InternalRoleKey string `json:"internal_role_key"` - } - require.NoError(t, json.Unmarshal(env.Data, &mapping)) - require.Equal(t, externalKey, mapping.ExternalKey) - require.Equal(t, roleKey, mapping.InternalRoleKey) - }) - - j.Step("5", "POST /permissions/users/:uid/roles — 把 qa_engineer 手動指派給 no-role user", func(t *testing.T) { - require.NotEmpty(t, roleID) - owner.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/users/"+noRole.Fixture.UID+"/roles", map[string]string{ - "role_id": roleID, - }, true) - }) - - j.Step("6", "GET /permissions/me (no-role user 視角) — 確認新角色 + 權限都拿到了", func(t *testing.T) { - env := noRole.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/me?include_tree=false", nil, true) - var data struct { - Roles []string `json:"roles"` - Permissions map[string]string `json:"permissions"` - } - require.NoError(t, json.Unmarshal(env.Data, &data)) - require.Contains(t, data.Roles, roleKey, "no-role user should now have qa_engineer") - require.NotEmpty(t, data.Permissions, "expected non-empty permissions after role assignment") - }) - - j.Step("7", "DELETE /permissions/users/:uid/roles/:id — 撤銷指派", func(t *testing.T) { - require.NotEmpty(t, roleID) - owner.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/users/"+noRole.Fixture.UID+"/roles/"+roleID, nil, true) - - env := noRole.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/me?include_tree=false", nil, true) - var data struct { - Roles []string `json:"roles"` - } - require.NoError(t, json.Unmarshal(env.Data, &data)) - require.NotContains(t, data.Roles, roleKey) - }) - - j.Step("8", "DELETE /permissions/role-mappings + /roles — 收尾刪 mapping + role", func(t *testing.T) { - owner.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{ - "external_source": "zitadel", - "external_key": externalKey, - }, true) - require.NotEmpty(t, roleID) - owner.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+roleID, nil, true) - // 標記已清,t.Cleanup 那邊就不會再重複打 - roleID = "" - }) -} diff --git a/test/e2e/journey_registration_test.go b/test/e2e/journey_registration_test.go deleted file mode 100644 index fd10c59..0000000 --- a/test/e2e/journey_registration_test.go +++ /dev/null @@ -1,28 +0,0 @@ -//go:build e2e - -package e2e - -import "testing" - -// TestJourney_FullRegistration 模擬使用者第一次接觸系統的完整 onboarding: -// -// POST /register # 建 ZITADEL human user + member draft + 發 OTP 信 -// → POST /register/confirm # 驗 OTP,member 從 unverified 轉 active -// → POST /login # ZITADEL ROPG 拿 id_token,gateway 簽 CloudEP JWT -// → GET /members/me # 用新 JWT 看自己 -// -// 這條 journey 需要真實 ZITADEL;目前 e2e env 只有 mock,故整段標記 skip 並 -// 列出步驟。等 docker-compose 接上 ZITADEL container(或指向 staging)後,把 -// SkipStep 換成 Step + 真的 HTTP call 即可。 -func TestJourney_FullRegistration(t *testing.T) { - j := NewJourney(t, "J-4", "完整註冊 → 登入 → 看自己(需 ZITADEL,目前 stub)") - defer j.Summary() - - const reason = "目前 e2e env 未接 ZITADEL;接上後改成 Step() 即可" - - j.SkipStep("1", "POST /auth/register — 建 ZITADEL human user + member draft + 寄 OTP", reason) - j.SkipStep("2", "GET MailHog API 取 OTP 信件內容(或 mock 直接抓 Redis)", reason) - j.SkipStep("3", "POST /auth/register/confirm — 驗 OTP,member status 由 unverified → active", reason) - j.SkipStep("4", "POST /auth/login — ZITADEL ROPG 拿 id_token,gateway 簽 CloudEP JWT", reason) - j.SkipStep("5", "GET /members/me — 用新 JWT 看自己", reason) -} diff --git a/test/e2e/journey_session_test.go b/test/e2e/journey_session_test.go deleted file mode 100644 index 1d4b7d3..0000000 --- a/test/e2e/journey_session_test.go +++ /dev/null @@ -1,55 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/require" -) - -// TestZZZJourney_SessionLifecycle 走 JWT 的完整生命週期: -// refresh 取得新 pair → 用新 access 打 /me 成功 → logout → 同一 access 再打 = 401 -// -// 與 TestZZZ_AuthTokenRefreshAndLogout 同樣放在 ZZZ 區段最後跑(會撤銷 JWT)。 -// 用 isolatedAuthClient 確保不汙染 member / permission journey 使用的 seed token。 -func TestZZZJourney_SessionLifecycle(t *testing.T) { - j := NewJourney(t, "J-3", "Session 生命週期(refresh → /me → logout → 舊 token 401)") - defer j.Summary() - - c := isolatedAuthClient(t) - - j.Step("1", "POST /auth/token/refresh — 用 isolated refresh token 取得新 access/refresh pair", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/token/refresh", map[string]string{ - "refresh_token": c.Fixture.RefreshToken, - }, false) - var pair struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - UID string `json:"uid"` - } - require.NoError(t, json.Unmarshal(env.Data, &pair)) - require.NotEmpty(t, pair.AccessToken) - require.NotEmpty(t, pair.RefreshToken) - require.Equal(t, c.Fixture.UID, pair.UID) - c.Fixture.AccessToken = pair.AccessToken - c.Fixture.RefreshToken = pair.RefreshToken - }) - - j.Step("2", "GET /members/me — 用 refresh 完拿到的新 access 打 /me 應該成功", func(t *testing.T) { - c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) - }) - - j.Step("3", "POST /auth/logout — 把目前 jti 加入黑名單", func(t *testing.T) { - c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/logout", nil, true) - }) - - j.Step("4", "GET /members/me — 同一 access token 再打應該 401(jti blacklisted)", func(t *testing.T) { - resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, true) - require.Equal(t, http.StatusUnauthorized, resp.StatusCode) - require.NotEqual(t, int64(successCode), env.Code) - }) -} - diff --git a/test/e2e/member_test.go b/test/e2e/member_test.go deleted file mode 100644 index ea91e92..0000000 --- a/test/e2e/member_test.go +++ /dev/null @@ -1,200 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "encoding/json" - "net/http" - "net/url" - "strconv" - "testing" - "time" - - membertotp "gateway/internal/model/member/totp" - - "github.com/stretchr/testify/require" -) - -func TestMember_GetMe(t *testing.T) { - e2eStep(t, "M-01", "GET", "/api/v1/members/me", "讀 profile(tenant/uid/status)") - c := NewClient(t) - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) - var me struct { - TenantID string `json:"tenant_id"` - UID string `json:"uid"` - Status string `json:"status"` - } - require.NoError(t, json.Unmarshal(env.Data, &me)) - require.Equal(t, c.Fixture.TenantID, me.TenantID) - require.Equal(t, c.Fixture.UID, me.UID) - require.Equal(t, "active", me.Status) -} - -func TestMember_UpdateMe(t *testing.T) { - e2eStep(t, "M-02", "PATCH", "/api/v1/members/me", "更新 display_name") - c := NewClient(t) - name := "E2E Updated Name" - env := c.DoExpectOK(t, http.MethodPatch, "/api/v1/members/me", map[string]string{ - "display_name": name, - }, true) - var me struct { - DisplayName string `json:"display_name"` - } - require.NoError(t, json.Unmarshal(env.Data, &me)) - require.Equal(t, name, me.DisplayName) -} - -func TestMember_EmailVerification_FullFlow(t *testing.T) { - e2eStep(t, "M-03/M-04", "POST", "/me/verifications/email/{start,confirm}", "業務 email OTP 申請 → 從 Redis 取碼 → 驗證 → email_verified=true") - c := NewClient(t) - target := "verified-e2e@example.com" - - startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/start", map[string]string{ - "target": target, - }, true) - var start struct { - ChallengeID string `json:"challenge_id"` - ExpiresIn int `json:"expires_in"` - } - require.NoError(t, json.Unmarshal(startEnv.Data, &start)) - require.NotEmpty(t, start.ChallengeID) - require.Positive(t, start.ExpiresIn) - - code := FetchE2EOTP(t, start.ChallengeID) - c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/confirm", map[string]string{ - "challenge_id": start.ChallengeID, - "code": code, - }, true) - - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) - var me struct { - BusinessEmail string `json:"business_email"` - BusinessEmailVerified bool `json:"business_email_verified"` - } - require.NoError(t, json.Unmarshal(env.Data, &me)) - require.Equal(t, target, me.BusinessEmail) - require.True(t, me.BusinessEmailVerified) -} - -func TestMember_PhoneVerification_FullFlow(t *testing.T) { - e2eStep(t, "M-05/M-06", "POST", "/me/verifications/phone/{start,confirm}", "業務 phone OTP 申請 → 從 Redis 取碼 → 驗證 → phone_verified=true") - c := NewClient(t) - target := "+886912345678" - - startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/start", map[string]string{ - "target": target, - }, true) - var start struct { - ChallengeID string `json:"challenge_id"` - ExpiresIn int `json:"expires_in"` - } - require.NoError(t, json.Unmarshal(startEnv.Data, &start)) - require.NotEmpty(t, start.ChallengeID) - require.Positive(t, start.ExpiresIn) - - code := FetchE2EOTP(t, start.ChallengeID) - c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/confirm", map[string]string{ - "challenge_id": start.ChallengeID, - "code": code, - }, true) - - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) - var me struct { - BusinessPhone string `json:"business_phone"` - BusinessPhoneVerified bool `json:"business_phone_verified"` - } - require.NoError(t, json.Unmarshal(env.Data, &me)) - require.Equal(t, target, me.BusinessPhone) - require.True(t, me.BusinessPhoneVerified) -} - -func TestMember_TOTP_Status(t *testing.T) { - e2eStep(t, "M-07", "GET", "/api/v1/members/me/totp", "查 TOTP 狀態(初始 enrolled=false)") - c := NewClient(t) - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) - var st struct { - Enrolled bool `json:"enrolled"` - } - require.NoError(t, json.Unmarshal(env.Data, &st)) - require.False(t, st.Enrolled) -} - -func TestMember_TOTP_FullFlow(t *testing.T) { - e2eStep(t, "M-08~M-12", "POST", "/me/totp/*", "TOTP 全鏈路:enroll-start → confirm → verify → replay 403 → backup-codes → DELETE") - c := NewClient(t) - - startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-start", nil, true) - var start struct { - OtpauthURL string `json:"otpauth_url"` - Digits int `json:"digits"` - PeriodSec int `json:"period_seconds"` - } - require.NoError(t, json.Unmarshal(startEnv.Data, &start)) - require.NotEmpty(t, start.OtpauthURL) - require.Positive(t, start.Digits) - require.Positive(t, start.PeriodSec) - - code := codeFromOtpauthURL(t, start.OtpauthURL, start.Digits, start.PeriodSec) - confirmEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-confirm", map[string]string{ - "code": code, - }, true) - var confirmed struct { - BackupCodes []string `json:"backup_codes"` - } - require.NoError(t, json.Unmarshal(confirmEnv.Data, &confirmed)) - require.NotEmpty(t, confirmed.BackupCodes) - - statusEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) - var status struct { - Enrolled bool `json:"enrolled"` - BackupCodesRemaining int `json:"backup_codes_remaining"` - } - require.NoError(t, json.Unmarshal(statusEnv.Data, &status)) - require.True(t, status.Enrolled) - require.Equal(t, len(confirmed.BackupCodes), status.BackupCodesRemaining) - - c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ - "code": code, - }, true) - - replayEnv := c.DoExpectHTTP(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ - "code": code, - }, true, http.StatusForbidden) - require.NotEqual(t, int64(successCode), replayEnv.Code) - - backupEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/backup-codes", nil, true) - var backup struct { - BackupCodes []string `json:"backup_codes"` - } - require.NoError(t, json.Unmarshal(backupEnv.Data, &backup)) - require.NotEmpty(t, backup.BackupCodes) - - c.DoExpectOK(t, http.MethodDelete, "/api/v1/members/me/totp", nil, true) - finalEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) - var finalStatus struct { - Enrolled bool `json:"enrolled"` - } - require.NoError(t, json.Unmarshal(finalEnv.Data, &finalStatus)) - require.False(t, finalStatus.Enrolled) -} - -func codeFromOtpauthURL(t *testing.T, rawURL string, digits, periodSec int) string { - t.Helper() - u, err := url.Parse(rawURL) - require.NoError(t, err) - require.Equal(t, "otpauth", u.Scheme) - require.Equal(t, "totp", u.Host) - - q := u.Query() - secret, err := membertotp.DecodeSecret(q.Get("secret")) - require.NoError(t, err) - if digits <= 0 { - digits, _ = strconv.Atoi(q.Get("digits")) - } - if periodSec <= 0 { - periodSec, _ = strconv.Atoi(q.Get("period")) - } - code, err := membertotp.Generate(secret, time.Now(), time.Duration(periodSec)*time.Second, digits) - require.NoError(t, err) - return code -} diff --git a/test/e2e/permission_test.go b/test/e2e/permission_test.go deleted file mode 100644 index b1ba685..0000000 --- a/test/e2e/permission_test.go +++ /dev/null @@ -1,270 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestPermission_Catalog(t *testing.T) { - e2eStep(t, "P-01", "GET", "/api/v1/permissions/catalog", "讀全平台 Permission Catalog 樹狀結構") - c := NewClient(t) - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/catalog?tree=true", nil, true) - var data struct { - Tree []map[string]any `json:"tree"` - } - require.NoError(t, json.Unmarshal(env.Data, &data)) - require.NotEmpty(t, data.Tree) -} - -func TestPermission_Me(t *testing.T) { - e2eStep(t, "P-02", "GET", "/api/v1/permissions/me", "讀當前 user 的角色 + 權限樹") - c := NewClient(t) - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/me?include_tree=true", nil, true) - var data struct { - UID string `json:"uid"` - TenantID string `json:"tenant_id"` - Roles []string `json:"roles"` - Permissions map[string]string `json:"permissions"` - Tree []map[string]any `json:"tree"` - } - require.NoError(t, json.Unmarshal(env.Data, &data)) - require.Equal(t, c.Fixture.UID, data.UID) - require.Equal(t, c.Fixture.TenantID, data.TenantID) - require.Contains(t, data.Roles, c.Fixture.RoleKey) - require.NotEmpty(t, data.Permissions) -} - -func TestPermission_RoleCRUD(t *testing.T) { - e2eStep(t, "P-03~P-06", "*", "/api/v1/permissions/roles", "租戶角色 CRUD:建立 → 列表 → 更新 display_name → 刪除") - c := NewClient(t) - - createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{ - "key": "e2e_custom_role", - "display_name": "E2E Custom", - "status": "open", - }, true) - var role struct { - ID string `json:"id"` - Key string `json:"key"` - } - require.NoError(t, json.Unmarshal(createEnv.Data, &role)) - require.Equal(t, "e2e_custom_role", role.Key) - require.NotEmpty(t, role.ID) - // 避免 e2e-up 反覆跑時 role 殘留 → 後續 Create 撞 unique key - t.Cleanup(func() { _, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) }) - - listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles", nil, true) - var list struct { - Roles []struct { - Key string `json:"key"` - } `json:"roles"` - } - require.NoError(t, json.Unmarshal(listEnv.Data, &list)) - found := false - for _, r := range list.Roles { - if r.Key == "e2e_custom_role" { - found = true - break - } - } - require.True(t, found, "created role should appear in list") - - patchEnv := c.DoExpectOK(t, http.MethodPatch, "/api/v1/permissions/roles/"+role.ID, map[string]string{ - "display_name": "E2E Custom Renamed", - }, true) - var patched struct { - DisplayName string `json:"display_name"` - } - require.NoError(t, json.Unmarshal(patchEnv.Data, &patched)) - require.Equal(t, "E2E Custom Renamed", patched.DisplayName) - - c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) -} - -func TestPermission_RolePermissions(t *testing.T) { - e2eStep(t, "P-07/P-08", "PUT/GET", "/api/v1/permissions/roles/:id/permissions", "Role 全量替換 Permission(含 parent closure),再讀回比對") - c := NewClient(t) - permissionID := firstCatalogPermissionID(t, c) - - createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{ - "key": "e2e_role_permissions", - "display_name": "E2E Role Permissions", - }, true) - var role struct { - ID string `json:"id"` - } - require.NoError(t, json.Unmarshal(createEnv.Data, &role)) - require.NotEmpty(t, role.ID) - t.Cleanup(func() { _, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) }) - - c.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/roles/"+role.ID+"/permissions", map[string][]string{ - "permission_ids": {permissionID}, - }, true) - - listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles/"+role.ID+"/permissions", nil, true) - var list struct { - Permissions []struct { - ID string `json:"id"` - } `json:"permissions"` - } - require.NoError(t, json.Unmarshal(listEnv.Data, &list)) - require.NotEmpty(t, list.Permissions) - found := false - for _, p := range list.Permissions { - if p.ID == permissionID { - found = true - break - } - } - require.True(t, found, "role should include requested permission") - - c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) -} - -func TestPermission_AssignUserRole(t *testing.T) { - e2eStep(t, "P-09~P-11", "*", "/api/v1/permissions/users/:uid/roles", "User ↔ Role 指派 / 列表 / 撤銷") - c := NewClient(t) - - createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{ - "key": "e2e_assign_role", - "display_name": "E2E Assign", - }, true) - var role struct { - ID string `json:"id"` - } - require.NoError(t, json.Unmarshal(createEnv.Data, &role)) - t.Cleanup(func() { - _, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles/"+role.ID, nil, true) - _, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) - }) - - c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles", map[string]string{ - "role_id": role.ID, - }, true) - - listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles", nil, true) - var list struct { - UserRoles []struct { - RoleID string `json:"role_id"` - } `json:"user_roles"` - } - require.NoError(t, json.Unmarshal(listEnv.Data, &list)) - found := false - for _, r := range list.UserRoles { - if r.RoleID == role.ID { - found = true - break - } - } - require.True(t, found) - - c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles/"+role.ID, nil, true) - c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) -} - -func TestPermission_RoleMappingCRUD(t *testing.T) { - e2eStep(t, "P-12", "*", "/api/v1/permissions/role-mappings", "外部 IdP group → 內部 Role.Key 對映 CRUD") - c := NewClient(t) - roleKey := "e2e_mapping_role" - externalKey := fmt.Sprintf("e2e-group-%s", c.Fixture.UID) - - createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{ - "key": roleKey, - "display_name": "E2E Mapping Role", - }, true) - var role struct { - ID string `json:"id"` - } - require.NoError(t, json.Unmarshal(createEnv.Data, &role)) - require.NotEmpty(t, role.ID) - t.Cleanup(func() { - _, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{ - "external_source": "zitadel", - "external_key": externalKey, - }, true) - _, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) - }) - - upsertEnv := c.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/role-mappings", map[string]string{ - "external_source": "zitadel", - "external_key": externalKey, - "internal_role_key": roleKey, - }, true) - var mapping struct { - ID string `json:"id"` - ExternalSource string `json:"external_source"` - ExternalKey string `json:"external_key"` - InternalRoleID string `json:"internal_role_id"` - InternalRoleKey string `json:"internal_role_key"` - } - require.NoError(t, json.Unmarshal(upsertEnv.Data, &mapping)) - require.NotEmpty(t, mapping.ID) - require.Equal(t, "zitadel", mapping.ExternalSource) - require.Equal(t, externalKey, mapping.ExternalKey) - require.Equal(t, role.ID, mapping.InternalRoleID) - require.Equal(t, roleKey, mapping.InternalRoleKey) - - listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/role-mappings?source=zitadel", nil, true) - var list struct { - Mappings []struct { - ExternalKey string `json:"external_key"` - } `json:"mappings"` - } - require.NoError(t, json.Unmarshal(listEnv.Data, &list)) - found := false - for _, item := range list.Mappings { - if item.ExternalKey == externalKey { - found = true - break - } - } - require.True(t, found, "created role mapping should appear in list") - - c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{ - "external_source": "zitadel", - "external_key": externalKey, - }, true) - c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) -} - -func TestPermission_CasbinRBAC(t *testing.T) { - e2eStep(t, "P-13/P-14", "*", "/api/v1/permissions/{policy/reload,roles}", "Casbin enforcement:owner reload policy → no-role user GET /roles=403") - if os.Getenv("E2E_CASBIN") != "1" { - t.Skip("set E2E_CASBIN=1 and use e2e.casbin.yaml to enable Casbin enforcement") - } - - owner := NewClient(t) - reloadEnv := owner.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/policy/reload", nil, true) - var reload struct { - Tenant string `json:"tenant"` - TS int64 `json:"ts"` - } - require.NoError(t, json.Unmarshal(reloadEnv.Data, &reload)) - require.Equal(t, owner.Fixture.TenantID, reload.Tenant) - require.Positive(t, reload.TS) - - noRole := NewNoRoleClient(t) - denied := noRole.DoExpectHTTP(t, http.MethodGet, "/api/v1/permissions/roles", nil, true, http.StatusForbidden) - require.NotEqual(t, int64(successCode), denied.Code) -} - -func firstCatalogPermissionID(t *testing.T, c *Client) string { - t.Helper() - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/catalog?tree=false", nil, true) - var data struct { - List []struct { - ID string `json:"id"` - } `json:"list"` - } - require.NoError(t, json.Unmarshal(env.Data, &data)) - require.NotEmpty(t, data.List) - require.NotEmpty(t, data.List[0].ID) - return data.List[0].ID -} diff --git a/test/e2e/setup_test.go b/test/e2e/setup_test.go deleted file mode 100644 index c2d92e6..0000000 --- a/test/e2e/setup_test.go +++ /dev/null @@ -1,151 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "testing" - "time" -) - -var sharedFixture Fixture - -func TestMain(m *testing.M) { - if err := loadSharedFixture(); err != nil { - fmt.Fprintf(os.Stderr, "e2e bootstrap: %v\n", err) - os.Exit(1) - } - os.Exit(m.Run()) -} - -// e2eStep prints a one-line banner at the start of every E2E test so `go test -v` -// shows the user-facing test ID, HTTP method/path, and a Chinese summary instead -// of just the Go function name. Format: -// -// ▶ [M-01] GET /api/v1/members/me — 讀 profile -// -// Keep IDs in sync with docs/e2e-testing.md (測試覆蓋矩陣). -func e2eStep(t *testing.T, id, method, path, desc string) { - t.Helper() - switch { - case method == "" && path == "": - t.Logf("▶ [%s] %s", id, desc) - case method == "": - t.Logf("▶ [%s] %s — %s", id, path, desc) - default: - t.Logf("▶ [%s] %s %s — %s", id, method, path, desc) - } -} - -func loadSharedFixture() error { - path := os.Getenv("E2E_STATE_FILE") - if path == "" { - path = filepath.Join("fixtures", "state.json") - } - raw, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read %s: %w (run make e2e-full)", path, err) - } - if err := json.Unmarshal(raw, &sharedFixture); err != nil { - return err - } - if sharedFixture.BaseURL == "" || sharedFixture.AccessToken == "" { - return fmt.Errorf("invalid fixture in %s", path) - } - if override := os.Getenv("E2E_BASE_URL"); override != "" { - sharedFixture.BaseURL = override - } - return nil -} - -func refreshTokenPair(baseURL, refreshToken string) (Fixture, error) { - body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken}) - req, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/auth/token/refresh", bytes.NewReader(body)) - if err != nil { - return Fixture{}, err - } - req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return Fixture{}, err - } - defer resp.Body.Close() - raw, err := io.ReadAll(resp.Body) - if err != nil { - return Fixture{}, err - } - var env Envelope - if err := json.Unmarshal(raw, &env); err != nil { - return Fixture{}, err - } - if resp.StatusCode != http.StatusOK || env.Code != successCode { - return Fixture{}, fmt.Errorf("refresh failed: status=%d code=%d", resp.StatusCode, env.Code) - } - var data struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - } - if err := json.Unmarshal(env.Data, &data); err != nil { - return Fixture{}, err - } - out := Fixture{AccessToken: data.AccessToken, RefreshToken: data.RefreshToken} - if out.AccessToken == "" || out.RefreshToken == "" { - return Fixture{}, fmt.Errorf("refresh returned empty tokens") - } - return out, nil -} - -func loadFixture() Fixture { - return sharedFixture -} - -func freshClient(t *testing.T) *Client { - t.Helper() - fx := loadFixture() - base := fx.BaseURL - if override := os.Getenv("E2E_BASE_URL"); override != "" { - base = override - } - return &Client{ - BaseURL: base, - HTTP: &http.Client{Timeout: 15 * time.Second}, - Fixture: fx, - } -} - -func isolatedAuthClient(t *testing.T) *Client { - t.Helper() - path := os.Getenv("E2E_STATE_FILE") - if path == "" { - path = filepath.Join("fixtures", "state.json") - } - raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read fixture: %v", err) - } - var fx Fixture - if err := json.Unmarshal(raw, &fx); err != nil { - t.Fatalf("parse fixture: %v", err) - } - if override := os.Getenv("E2E_BASE_URL"); override != "" { - fx.BaseURL = override - } - pair, err := refreshTokenPair(fx.BaseURL, fx.RefreshToken) - if err != nil { - t.Fatalf("isolated refresh: %v", err) - } - fx.AccessToken = pair.AccessToken - fx.RefreshToken = pair.RefreshToken - return &Client{ - BaseURL: fx.BaseURL, - HTTP: &http.Client{Timeout: 15 * time.Second}, - Fixture: fx, - } -} diff --git a/test/k6/README.md b/test/k6/README.md new file mode 100644 index 0000000..9444995 --- /dev/null +++ b/test/k6/README.md @@ -0,0 +1,155 @@ +# k6 API tests + +完整的 Gateway API smoke + journey 測試套件。**所有對外端點**都至少在 `smoke/` 或 `journeys/` 裡有一發(含登入 MFA、忘記/改密碼、註冊 resume)。 + +## TL;DR + +```bash +make k6-up # docker:mongo + redis + mailhog + postgres + zitadel +make k6-wait # 等 ZITADEL ready + 把 PAT 寫到 env file +make k6-gateway & # 起 gateway(背景;吃 etc/gateway.k6.yaml) +make k6-seed-admin # (rbac journey 才需要)seed tenant_admin user +make k6-all # 跑 smoke + journey +make k6-down # 清掉 +``` + +單跑一個檔(記得先載入環境變數): + +```bash +source deploy/zitadel/machinekey/k6.env +k6 run test/k6/smoke/health.js +k6 run test/k6/journeys/email_register_full.js +``` + +## 環境變數 + +| 變數 | 預設 | 說明 | +|---|---|---| +| `BASE_URL` | `http://localhost:8888` | Gateway base URL | +| `MAILHOG_URL` | `http://localhost:8025` | MailHog HTTP API(撈 email OTP) | +| `REDIS_ADDR` | `localhost:6379` | Redis 位址(撈 SMS OTP) | +| `TENANT_SLUG` | `k6-tenant` | register payload tenant | +| `INVITE_CODE` | `K6INVITE` | tenant 開啟 invite 時用 | +| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | — | rbac journey seeded admin | +| `OTP_POLL_INTERVAL_MS` | `300` | OTP poll 頻率 | +| `OTP_POLL_TIMEOUT_MS` | `5000` | OTP poll 超時 | +| `RESEND_COOLDOWN_SECONDS` | `60` | 與 `gateway.k6.yaml` OTP 重送冷卻一致(cooldown smoke 會 sleep) | + +## 目錄結構 + +``` +test/k6/ +├── README.md +├── lib/ # 共用 helper +│ ├── config.js # 環境變數 + unique() + SUCCESS_CODE +│ ├── http.js # get/post/...、checkEnvelope、withBearer +│ ├── otp.js # fetchEmailOTP (MailHog) / fetchSMSOTP (Redis) +│ ├── totp.js # HMAC-SHA1 TOTP(P4) +│ ├── auth.js # register / confirm / login / MFA / password helper +│ ├── member.js # TOTP enroll / change password helper +│ └── seed.js # tenant + invite + admin role bootstrap(P5) +├── smoke/ # 每個端點至少一發 +│ ├── health.js # GET /api/v1/health +│ ├── auth_public.js # register / login / refresh / social-start (+negative) +│ ├── auth_register_resume.js # register/resume(happy + 404/409/429/400) +│ ├── auth_password.js # password/forgot + reset(happy + 各種 negative) +│ ├── auth_login_mfa.js # login/mfa(MFA 前置 + negative) +│ ├── auth_bearer.js # logout +│ ├── member.js # me / patch / verify / TOTP / change-password negative +│ ├── permission_read.js # catalog / me +│ └── permission_admin.js # roles CRUD / role-permissions / user-roles / mappings / policy reload +└── journeys/ # 完整流程 + ├── email_register_full.js # register → confirm OTP(MailHog) → me → patch → logout + ├── register_resume_full.js # register → resume → confirm → me + ├── password_forgot_reset_full.js # forgot → reset → login(新密碼) + ├── login_mfa_full.js # enroll TOTP → login MFA → me + ├── change_password_full.js # change password → login(新密碼) + ├── login_refresh.js # login → refresh → me → logout + ├── email_verify.js # register → confirm → email verify start → confirm + ├── phone_verify.js # register → confirm → phone verify (Redis OTP) + ├── totp_full.js # enroll → confirm → verify → backup-codes → disable + ├── rbac_admin.js # role CRUD → assign → policy reload → me/permissions + └── token_exchange.js # ZITADEL id_token → CloudEP JWT +``` + +## OTP 怎麼撈 + +- **Email** → `make k6-up` 含 MailHog(:1025 SMTP / :8025 HTTP API)。Gateway 把 OTP 寄到 MailHog;k6 透過 `/api/v2/search?kind=to&query=` 撈到信件,從 body 抓 6 位數。 +- **SMS** → mock provider 把 body 寫到 `dev:notification:last:sms:`(見 [mock_sender.go](../../internal/model/notification/provider/sms/mock_sender.go) `WithMockRedis`),k6 用 `k6/experimental/redis` 直接讀。 + +兩者都有 `OTP_POLL_TIMEOUT_MS`(預設 5 秒)保護,超時直接 fail。 + +## 覆蓋率(39 endpoints) + +| 模組 | 端點 | 覆蓋 | +|---|---|---| +| Normal | `GET /api/v1/health` | smoke/health | +| Auth 公開 | `POST /register` | journeys/email_register_full + smoke/auth_public | +| Auth 公開 | `POST /register/confirm` | journeys/email_register_full | +| Auth 公開 | `POST /register/resend` | smoke/auth_public | +| Auth 公開 | `POST /register/resume` | smoke/auth_register_resume + journeys/register_resume_full | +| Auth 公開 | `POST /password/forgot` | smoke/auth_password + journeys/password_forgot_reset_full | +| Auth 公開 | `POST /password/reset` | smoke/auth_password + journeys/password_forgot_reset_full | +| Auth 公開 | `POST /login/mfa` | smoke/auth_login_mfa + journeys/login_mfa_full | +| Auth 公開 | `POST /register/social/start` | smoke/auth_public (happy) | +| Auth 公開 | `GET /register/social/callback` | smoke/auth_public (negative — TODO happy) | +| Auth 公開 | `POST /login` | journeys/login_refresh + smoke/auth_public (negative) | +| Auth 公開 | `POST /token/refresh` | journeys/login_refresh + smoke/auth_public (negative) | +| Auth 公開 | `POST /token/exchange` | journeys/token_exchange (negative — TODO happy) | +| Auth 公開 | `POST /login/social/start` | smoke/auth_public (happy) | +| Auth 公開 | `GET /login/social/callback` | smoke/auth_public (negative — TODO happy) | +| Auth Bearer | `POST /logout` | smoke/auth_bearer + journeys/email_register_full | +| Member | `GET /me` | smoke/member + all journeys | +| Member | `PATCH /me` | smoke/member + journeys/email_register_full | +| Member | `POST /me/verifications/email/start` | smoke/member + journeys/email_verify | +| Member | `POST /me/verifications/email/confirm` | smoke/member (negative) + journeys/email_verify (happy) | +| Member | `POST /me/verifications/phone/start` | smoke/member + journeys/phone_verify | +| Member | `POST /me/verifications/phone/confirm` | smoke/member (negative) + journeys/phone_verify (happy) | +| Member | `GET /me/totp` | smoke/member + journeys/totp_full | +| Member | `POST /me/totp/enroll-start` | smoke/member + journeys/totp_full | +| Member | `POST /me/totp/enroll-confirm` | smoke/member (negative) + journeys/totp_full (happy) | +| Member | `POST /me/totp/verify` | smoke/member (negative) + journeys/totp_full (happy) | +| Member | `POST /me/totp/backup-codes` | smoke/member (negative) + journeys/totp_full (happy) | +| Member | `DELETE /me/totp` | smoke/member + journeys/totp_full | +| Member | `POST /me/password` | smoke/member (negative) + journeys/change_password_full | +| Perm 讀 | `GET /permissions/catalog` | smoke/permission_read + journeys/rbac_admin | +| Perm 讀 | `GET /permissions/me` | smoke/permission_read + journeys/rbac_admin | +| Perm 管理 | `GET /roles` | smoke/permission_admin + journeys/rbac_admin | +| Perm 管理 | `POST /roles` | smoke/permission_admin + journeys/rbac_admin | +| Perm 管理 | `PATCH /roles/:id` | smoke/permission_admin + journeys/rbac_admin | +| Perm 管理 | `DELETE /roles/:id` | smoke/permission_admin + journeys/rbac_admin | +| Perm 管理 | `GET /roles/:id/permissions` | smoke/permission_admin + journeys/rbac_admin | +| Perm 管理 | `PUT /roles/:id/permissions` | smoke/permission_admin + journeys/rbac_admin | +| Perm 管理 | `GET /users/:uid/roles` | smoke/permission_admin + journeys/rbac_admin | +| Perm 管理 | `POST /users/:uid/roles` | smoke/permission_admin + journeys/rbac_admin | +| Perm 管理 | `DELETE /users/:uid/roles/:role_id` | smoke/permission_admin + journeys/rbac_admin | +| Perm 管理 | `GET /role-mappings` | smoke/permission_admin | +| Perm 管理 | `PUT /role-mappings` | smoke/permission_admin | +| Perm 管理 | `DELETE /role-mappings` | smoke/permission_admin | +| Perm 管理 | `POST /policy/reload` | smoke/permission_admin + journeys/rbac_admin | + +## RBAC 系列(需 admin seed) + +`journeys/rbac_admin.js` 需要 `ADMIN_EMAIL` / `ADMIN_PASSWORD` 環境變數。`make k6-seed-admin` +會跑 [cmd/k6-seed-admin](../../cmd/k6-seed-admin/main.go): + +1. 用 API 註冊一個固定的 `k6-admin@k6.local` +2. 從 MailHog 撈 OTP 完成 confirm +3. 寫入 permission catalog + 預設 system roles(透過 `internal/model/permission/seed`) +4. 指派 `tenant_admin` 給該 UID +5. 把 `ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_UID` 寫到 `k6.env` + +`k6-seed-admin` 是冪等的,重跑沒事。 + +沒跑 seed 時,`rbac_admin.js` 會印 skip notice 並 exit 0(admin endpoint 的路由存在性 +已由 `smoke/permission_admin.js` 涵蓋)。 + +## 已知無法純 k6 跑的 + +- `GET /api/v1/auth/register/social/callback` 與 `GET /api/v1/auth/login/social/callback` happy path 需要 Google OAuth UI 跳轉,純 k6 無法走完。Smoke 只覆蓋 negative(無效 state → 400)。 +- `POST /api/v1/auth/token/exchange` happy path 需要一個來自 ZITADEL 的有效 `id_token`;目前 `make k6-up` 的 ZITADEL bootstrap 只建 service account PAT,沒有開 OIDC client password grant,因此 happy 路徑 TODO,smoke 只跑 negative。要開:在 `deploy/zitadel/steps.yaml` 加 Application + password grant,再用 `/oauth/v2/token` 拿 id_token。 + +## 不要 commit 的東西 + +- `deploy/zitadel/machinekey/zitadel-admin-sa.token` / `.json` 已在 `.gitignore`。 +- `etc/gateway.k6.yaml` 內的 secret 都是固定 dev 值,本機 / CI 可用,**勿** 上 prod。 diff --git a/test/k6/journeys/change_password_full.js b/test/k6/journeys/change_password_full.js new file mode 100644 index 0000000..21da827 --- /dev/null +++ b/test/k6/journeys/change_password_full.js @@ -0,0 +1,46 @@ +// Journey: change password while logged in → login with new password +// +// Endpoints: +// POST /api/v1/auth/register/confirm path (via registerAndConfirm) +// POST /api/v1/members/me/password +// POST /api/v1/auth/login +import { post, checkError } from '../lib/http.js'; +import { cfg } from '../lib/config.js'; +import { registerAndConfirm, loginStep } from '../lib/auth.js'; +import { changePassword } from '../lib/member.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const { identity, tokens } = registerAndConfirm(); + const bearer = { Authorization: `Bearer ${tokens.access_token}` }; + const newPassword = 'K6-ChangePass-8!'; + + const data = changePassword(identity.password, newPassword, bearer); + if (!data.ok) { + throw new Error('change password journey: expected ok=true'); + } + + const login = loginStep({ + email: identity.email, + password: newPassword, + }); + if (!login.access_token) { + throw new Error('change password journey: login with new password failed'); + } + + checkError( + post('/api/v1/auth/login', { + tenant_slug: cfg.tenantSlug, + email: identity.email, + password: identity.password, + }), + 'POST /auth/login (old password after change)', + 401, + 28501000, + ); +} diff --git a/test/k6/journeys/email_register_full.js b/test/k6/journeys/email_register_full.js new file mode 100644 index 0000000..a636bde --- /dev/null +++ b/test/k6/journeys/email_register_full.js @@ -0,0 +1,46 @@ +// Journey: full email registration happy path. +// +// Endpoints exercised: +// POST /api/v1/auth/register (RegisterReq) +// POST /api/v1/auth/register/confirm (RegisterConfirmReq, OTP via MailHog) +// GET /api/v1/members/me (Bearer) +// PATCH /api/v1/members/me (UpdateMemberMeReq) +// POST /api/v1/auth/logout (Bearer) +import { get, patch, checkEnvelope } from '../lib/http.js'; +import { registerAndConfirm, logout } from '../lib/auth.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { + checks: ['rate==1.0'], + http_req_failed: ['rate<0.05'], + }, +}; + +export default function () { + const { identity, tokens } = registerAndConfirm(); + const bearer = { Authorization: `Bearer ${tokens.access_token}` }; + + // GET /members/me + const meRes = get('/api/v1/members/me', bearer); + const me = checkEnvelope(meRes, 'GET /members/me'); + if (!me.data || me.data.uid !== tokens.uid) { + throw new Error(`me.uid mismatch: ${meRes.body}`); + } + + // PATCH /members/me + const newName = `${identity.displayName} updated`; + const patchRes = patch('/api/v1/members/me', { + display_name: newName, + language: 'en', + currency: 'TWD', + }, bearer); + const patched = checkEnvelope(patchRes, 'PATCH /members/me'); + if (patched.data.display_name !== newName) { + throw new Error(`display_name did not update: got=${patched.data.display_name}`); + } + + // POST /auth/logout + logout({ accessToken: tokens.access_token }); +} diff --git a/test/k6/journeys/email_verify.js b/test/k6/journeys/email_verify.js new file mode 100644 index 0000000..5a43584 --- /dev/null +++ b/test/k6/journeys/email_verify.js @@ -0,0 +1,53 @@ +// Journey: business email verification end-to-end +// +// Endpoints exercised: +// POST /api/v1/auth/register +// POST /api/v1/auth/register/confirm +// POST /api/v1/members/me/verifications/email/start +// POST /api/v1/members/me/verifications/email/confirm +// GET /api/v1/members/me (verify business_email_verified flag is true) +import { get, post, checkEnvelope } from '../lib/http.js'; +import { registerAndConfirm } from '../lib/auth.js'; +import { fetchEmailOTP } from '../lib/otp.js'; +import { unique } from '../lib/config.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const { tokens } = registerAndConfirm(); + const bearer = { Authorization: `Bearer ${tokens.access_token}` }; + + // Use a fresh business email distinct from the registration one so the + // verify OTP can be distinguished from the registration OTP in MailHog. + const businessEmail = `${unique('biz')}@k6.local`; + const since = Date.now() - 1000; // tolerate slight clock skew + + const startRes = post( + '/api/v1/members/me/verifications/email/start', + { target: businessEmail }, + bearer, + ); + const start = checkEnvelope(startRes, 'POST /me/verifications/email/start').data; + if (!start.challenge_id) throw new Error('email/start: missing challenge_id'); + + const code = fetchEmailOTP(businessEmail, { since }); + + const confirmRes = post( + '/api/v1/members/me/verifications/email/confirm', + { challenge_id: start.challenge_id, code }, + bearer, + ); + checkEnvelope(confirmRes, 'POST /me/verifications/email/confirm'); + + const me = checkEnvelope(get('/api/v1/members/me', bearer), 'GET /members/me (post-verify)').data; + if (me.business_email !== businessEmail) { + throw new Error(`business_email not set: got=${me.business_email}`); + } + if (me.business_email_verified !== true) { + throw new Error(`business_email_verified should be true: got=${me.business_email_verified}`); + } +} diff --git a/test/k6/journeys/login_mfa_full.js b/test/k6/journeys/login_mfa_full.js new file mode 100644 index 0000000..6daf640 --- /dev/null +++ b/test/k6/journeys/login_mfa_full.js @@ -0,0 +1,45 @@ +// Journey: login with TOTP MFA — enroll → login (mfa_required) → login/mfa → /me +// +// Endpoints: +// POST /api/v1/auth/register/confirm path (via registerAndConfirm) +// POST /api/v1/members/me/totp/enroll-start + enroll-confirm +// POST /api/v1/auth/login +// POST /api/v1/auth/login/mfa +// GET /api/v1/members/me +import { get, checkEnvelope } from '../lib/http.js'; +import { registerAndConfirm, loginExpectMFA, loginMfaConfirm } from '../lib/auth.js'; +import { enrollTOTP } from '../lib/member.js'; +import { generateTOTP } from '../lib/totp.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const { identity, tokens } = registerAndConfirm(); + const bearer = { Authorization: `Bearer ${tokens.access_token}` }; + const { otpauthUrl } = enrollTOTP(bearer); + + const mfa = loginExpectMFA({ + email: identity.email, + password: identity.password, + }); + const totpCode = generateTOTP(otpauthUrl); + const session = loginMfaConfirm({ + challengeId: mfa.mfa_challenge_id, + code: totpCode, + }); + if (!session.access_token) { + throw new Error('login/mfa journey: missing access_token'); + } + + const me = checkEnvelope( + get('/api/v1/members/me', { Authorization: `Bearer ${session.access_token}` }), + 'GET /members/me (after login/mfa)', + ).data; + if (me.uid !== session.uid) { + throw new Error('login/mfa journey: uid mismatch'); + } +} diff --git a/test/k6/journeys/login_refresh.js b/test/k6/journeys/login_refresh.js new file mode 100644 index 0000000..3c6e3c4 --- /dev/null +++ b/test/k6/journeys/login_refresh.js @@ -0,0 +1,65 @@ +// Journey: (login →) refresh → me → logout +// +// Endpoints exercised: +// POST /api/v1/auth/login (tried; gracefully skipped if ZITADEL v2 +// doesn't support password grant) +// POST /api/v1/auth/token/refresh (always exercised, using register tokens) +// GET /api/v1/members/me (always) +// POST /api/v1/auth/logout (always) +// +// Why the conditional login? Modern ZITADEL v2 disables OAuth resource-owner +// password grant by default ({"error":"unsupported_grant_type"}), so the +// gateway's /auth/login → zitadel.VerifyPassword pipeline returns +// 502 + 28802000 in the default k6 environment. We probe login once; if it +// 5xx's we fall back to the registration tokens so the rest of the journey +// (refresh, me, logout) still gets coverage. +import { get, post, safeJson, checkEnvelope } from '../lib/http.js'; +import { registerAndConfirm, refreshToken, logout } from '../lib/auth.js'; +import { cfg } from '../lib/config.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const { identity, tokens: initial } = registerAndConfirm(); + + // Probe login. If the env can't do password-grant, fall back to the + // tokens we already have from registerAndConfirm. + let tokens = initial; + const loginRes = post('/api/v1/auth/login', { + tenant_slug: cfg.tenantSlug, + email: identity.email, + password: identity.password, + }); + if (loginRes.status === 200) { + const body = checkEnvelope(loginRes, 'POST /auth/login (happy)'); + tokens = body.data; + if (tokens.uid !== initial.uid) throw new Error('login returned different uid'); + } else { + const body = safeJson(loginRes) || {}; + // Only the documented zitadel third-party failure is acceptable here. + if (loginRes.status !== 502 || body.code !== 28802000) { + throw new Error( + `login: unexpected failure status=${loginRes.status} body=${loginRes.body}`, + ); + } + // proceed with register tokens; covered by /token/refresh below. + } + + // refresh access token (works regardless of which token path we took) + const refreshed = refreshToken({ refreshToken: tokens.refresh_token }); + if (!refreshed.access_token) throw new Error('refresh: missing access_token'); + + // GET /me with the refreshed access token + const me = checkEnvelope( + get('/api/v1/members/me', { Authorization: `Bearer ${refreshed.access_token}` }), + 'GET /members/me (after refresh)', + ).data; + if (me.uid !== tokens.uid) throw new Error('me.uid mismatch'); + + // logout + logout({ accessToken: refreshed.access_token }); +} diff --git a/test/k6/journeys/password_forgot_reset_full.js b/test/k6/journeys/password_forgot_reset_full.js new file mode 100644 index 0000000..e7ebcff --- /dev/null +++ b/test/k6/journeys/password_forgot_reset_full.js @@ -0,0 +1,51 @@ +// Journey: forgot password → reset → login with new password +// +// Endpoints: +// POST /api/v1/auth/register + /register/confirm (setup) +// POST /api/v1/auth/password/forgot +// POST /api/v1/auth/password/reset +// POST /api/v1/auth/login +import { post, checkError } from '../lib/http.js'; +import { cfg } from '../lib/config.js'; +import { registerAndConfirm, passwordForgot, passwordReset, loginStep } from '../lib/auth.js'; +import { fetchEmailOTP } from '../lib/otp.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const { identity } = registerAndConfirm(); + const newPassword = 'K6-ResetPass-9!'; + + const since = Date.now(); + const forgot = passwordForgot({ email: identity.email }); + const code = fetchEmailOTP(identity.email, { since }); + passwordReset({ + challengeId: forgot.challenge_id, + code, + newPassword, + }); + + const login = loginStep({ + email: identity.email, + password: newPassword, + }); + if (!login.access_token) { + throw new Error('password reset journey: login with new password failed'); + } + + // old password should fail + checkError( + post('/api/v1/auth/login', { + tenant_slug: cfg.tenantSlug, + email: identity.email, + password: identity.password, + }), + 'POST /auth/login (old password after reset)', + 401, + 28501000, + ); +} diff --git a/test/k6/journeys/phone_verify.js b/test/k6/journeys/phone_verify.js new file mode 100644 index 0000000..416975a --- /dev/null +++ b/test/k6/journeys/phone_verify.js @@ -0,0 +1,68 @@ +// Journey: business phone verification end-to-end (SMS OTP via Redis) +// +// Endpoints exercised: +// POST /api/v1/auth/register +// POST /api/v1/auth/register/confirm +// POST /api/v1/members/me/verifications/phone/start +// POST /api/v1/members/me/verifications/phone/confirm +// GET /api/v1/members/me (verify business_phone_verified flag is true) +// +// SMS OTP source: the mock SMS sender (when WithMockRedis is wired by the +// notification factory in k6 mode) writes the SMS body to Redis at +// "dev:notification:last:sms:". fetchSMSOTP polls that key. +// +// k6/experimental/redis requires k6 v0.46+. +import { get, post, checkEnvelope } from '../lib/http.js'; +import { registerAndConfirm } from '../lib/auth.js'; +import { fetchSMSOTP } from '../lib/otp.js'; +import { unique } from '../lib/config.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +// Generate a deterministic-ish unique E.164 number per iteration to avoid +// collisions across concurrent runs. Use +886912 + 7 digits derived from the +// VU+iter+timestamp. +function uniquePhone() { + const ts = Date.now() % 1_000_000_0; + const vu = (typeof __VU !== 'undefined' && __VU) || 0; + const iter = (typeof __ITER !== 'undefined' && __ITER) || 0; + const suffix = String(ts + vu * 100 + iter).padStart(7, '0').slice(-7); + return `+886912${suffix}`; +} + +export default async function () { + const { tokens } = registerAndConfirm(); + const bearer = { Authorization: `Bearer ${tokens.access_token}` }; + + const phone = uniquePhone(); + + const startRes = post( + '/api/v1/members/me/verifications/phone/start', + { target: phone }, + bearer, + ); + const start = checkEnvelope(startRes, 'POST /me/verifications/phone/start').data; + if (!start.challenge_id) throw new Error('phone/start: missing challenge_id'); + + const { code } = await fetchSMSOTP(phone); + if (!code) throw new Error(`could not extract SMS OTP for ${phone}`); + + const confirmRes = post( + '/api/v1/members/me/verifications/phone/confirm', + { challenge_id: start.challenge_id, code }, + bearer, + ); + checkEnvelope(confirmRes, 'POST /me/verifications/phone/confirm'); + + const me = checkEnvelope(get('/api/v1/members/me', bearer), 'GET /members/me (post-phone-verify)').data; + if (me.business_phone !== phone) { + throw new Error(`business_phone not set: got=${me.business_phone}`); + } + if (me.business_phone_verified !== true) { + throw new Error(`business_phone_verified should be true: got=${me.business_phone_verified}`); + } +} diff --git a/test/k6/journeys/rbac_admin.js b/test/k6/journeys/rbac_admin.js new file mode 100644 index 0000000..9173642 --- /dev/null +++ b/test/k6/journeys/rbac_admin.js @@ -0,0 +1,131 @@ +// Journey: admin RBAC end-to-end (login admin → roles CRUD → assign → reload → verify → cleanup) +// +// Endpoints exercised (happy path, admin role required): +// POST /api/v1/auth/login (admin) +// GET /api/v1/permissions/roles +// POST /api/v1/permissions/roles +// PATCH /api/v1/permissions/roles/:id +// GET /api/v1/permissions/roles/:id/permissions +// PUT /api/v1/permissions/roles/:id/permissions +// GET /api/v1/permissions/catalog (to pick a permission_id) +// POST /api/v1/permissions/users/:uid/roles +// GET /api/v1/permissions/users/:uid/roles +// POST /api/v1/permissions/policy/reload +// GET /api/v1/permissions/me (as the assigned user) +// DELETE /api/v1/permissions/users/:uid/roles/:role_id +// DELETE /api/v1/permissions/roles/:id +// +// PREREQUISITE — admin user seeded by `make k6-seed-admin`. ADMIN_EMAIL, +// ADMIN_PASSWORD env vars must be set (k6.env exports them). If unset the +// journey prints a skip notice and exits 0 (covered by smoke/permission_admin +// for route-existence checks). +import { sleep } from 'k6'; +import { get, post, put, patch, del, checkEnvelope } from '../lib/http.js'; +import { login } from '../lib/auth.js'; +import { registerAndConfirm } from '../lib/auth.js'; +import { cfg, unique } from '../lib/config.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +function pickAnyPermissionID(bearer) { + const cat = checkEnvelope(get('/api/v1/permissions/catalog', bearer), 'GET /permissions/catalog').data; + const list = (cat && (cat.list || cat.tree)) || []; + // Find a leaf — node with http_methods set. + const stack = [...list]; + while (stack.length) { + const n = stack.shift(); + if (n.http_methods && n.id) return n.id; + if (n.children) for (const c of n.children) stack.push(c); + } + if (list.length > 0 && list[0].id) return list[0].id; + throw new Error('catalog: no permissions found'); +} + +export default function () { + if (!cfg.adminEmail || (!cfg.adminPassword && !cfg.adminAccessToken)) { + console.log('[rbac_admin] skipped: ADMIN_EMAIL + (ADMIN_PASSWORD or ADMIN_ACCESS_TOKEN) env not set. Run `make k6-seed-admin` first.'); + return; + } + + // 1. obtain admin access_token. Prefer ADMIN_ACCESS_TOKEN (issued by + // k6-seed-admin at registration time) because ZITADEL v2 disables OAuth + // password grant by default, so /auth/login → VerifyPassword returns 502. + let adminAccessToken = cfg.adminAccessToken; + if (!adminAccessToken) { + const admin = login({ email: cfg.adminEmail, password: cfg.adminPassword }); + adminAccessToken = admin.access_token; + } + const adminBearer = { Authorization: `Bearer ${adminAccessToken}` }; + + // 2. register a target user (we'll assign a role to them and verify via /me) + const { tokens: target } = registerAndConfirm(); + const targetBearer = { Authorization: `Bearer ${target.access_token}` }; + + // 3. list roles (sanity) + const rolesBefore = checkEnvelope(get('/api/v1/permissions/roles', adminBearer), 'GET /roles').data; + if (!rolesBefore || !Array.isArray(rolesBefore.roles)) throw new Error('roles list bad shape'); + + // 4. create a role + const roleKey = unique('k6role').replace(/[^a-z0-9_]/g, '_'); + const created = checkEnvelope( + post('/api/v1/permissions/roles', { key: roleKey, display_name: 'k6 RBAC role' }, adminBearer), + 'POST /roles', + ).data; + const roleID = created.id; + + // 5. patch role display name + const newDisplay = `${created.display_name} (patched)`; + checkEnvelope( + patch(`/api/v1/permissions/roles/${roleID}`, { display_name: newDisplay }, adminBearer), + 'PATCH /roles/:id', + ); + + // 6. get role permissions (empty initially) + checkEnvelope(get(`/api/v1/permissions/roles/${roleID}/permissions`, adminBearer), 'GET /roles/:id/permissions'); + + // 7. put role permissions (replace with one permission id picked from catalog) + const permID = pickAnyPermissionID(adminBearer); + checkEnvelope( + put(`/api/v1/permissions/roles/${roleID}/permissions`, { permission_ids: [permID] }, adminBearer), + 'PUT /roles/:id/permissions', + ); + + // 8. assign role to target user + checkEnvelope( + post(`/api/v1/permissions/users/${target.uid}/roles`, { role_id: roleID }, adminBearer), + 'POST /users/:uid/roles', + ); + + // 9. list target user's roles + const targetRoles = checkEnvelope( + get(`/api/v1/permissions/users/${target.uid}/roles`, adminBearer), + 'GET /users/:uid/roles', + ).data; + const hasRole = targetRoles.user_roles && targetRoles.user_roles.some((ur) => ur.role_id === roleID); + if (!hasRole) throw new Error('assigned role not visible in user_roles'); + + // 10. policy reload + checkEnvelope(post('/api/v1/permissions/policy/reload', {}, adminBearer), 'POST /policy/reload'); + + // give the gateway a moment for the pub/sub reload to settle + sleep(0.5); + + // 11. target /permissions/me reflects the role + const me = checkEnvelope(get('/api/v1/permissions/me', targetBearer), 'GET /permissions/me').data; + if (!me.roles || !me.roles.includes(roleKey)) { + throw new Error(`/permissions/me did not include role ${roleKey}: got=${JSON.stringify(me.roles)}`); + } + + // 12. revoke role + checkEnvelope( + del(`/api/v1/permissions/users/${target.uid}/roles/${roleID}`, null, adminBearer), + 'DELETE /users/:uid/roles/:role_id', + ); + + // 13. delete role (now safe because no user is assigned) + checkEnvelope(del(`/api/v1/permissions/roles/${roleID}`, null, adminBearer), 'DELETE /roles/:id'); +} diff --git a/test/k6/journeys/register_resume_full.js b/test/k6/journeys/register_resume_full.js new file mode 100644 index 0000000..e883ac0 --- /dev/null +++ b/test/k6/journeys/register_resume_full.js @@ -0,0 +1,52 @@ +// Journey: 未完成註冊 → resume → confirm OTP +// +// Endpoints: +// POST /api/v1/auth/register +// POST /api/v1/auth/register/resume +// POST /api/v1/auth/register/confirm +// GET /api/v1/members/me +import { sleep } from 'k6'; +import { get, checkEnvelope } from '../lib/http.js'; +import { cfg } from '../lib/config.js'; +import { + makeIdentity, + registerEmail, + registerResume, + confirmRegister, +} from '../lib/auth.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const identity = makeIdentity('resume-journey'); + registerEmail({ + email: identity.email, + password: identity.password, + displayName: identity.displayName, + }); + sleep(cfg.resendCooldownSeconds + 1); + + const resumed = registerResume({ email: identity.email }); + const tokens = confirmRegister({ + email: identity.email, + challengeId: resumed.challenge_id, + }); + if (!tokens.access_token) { + throw new Error('register/resume journey: missing access_token after confirm'); + } + + const me = checkEnvelope( + get('/api/v1/members/me', { Authorization: `Bearer ${tokens.access_token}` }), + 'GET /members/me (after resume confirm)', + ).data; + if (me.uid !== tokens.uid) { + throw new Error('register/resume journey: me.uid mismatch'); + } + if (me.status !== 'active') { + throw new Error(`register/resume journey: expected active status, got ${me.status}`); + } +} diff --git a/test/k6/journeys/token_exchange.js b/test/k6/journeys/token_exchange.js new file mode 100644 index 0000000..6f9924e --- /dev/null +++ b/test/k6/journeys/token_exchange.js @@ -0,0 +1,51 @@ +// Journey: ZITADEL id_token → CloudEP JWT exchange. +// +// Endpoint exercised: +// POST /api/v1/auth/token/exchange (TokenExchangeReq) +// +// Happy path requires a valid ZITADEL id_token issued for the gateway's +// configured OIDC client. The default `make k6-up` ZITADEL bootstrap only +// creates a service-account PAT (no human-user OIDC client + password grant), +// so we cover negative cases only and mark the happy path as TODO. +// +// To enable happy-path: configure a ZITADEL OIDC application with password +// grant in deploy/zitadel/steps.yaml, export OAUTH_CLIENT_ID + secret + user +// credentials, then call ZITADEL's /oauth/v2/token to obtain an id_token. +import { post, checkErrorOneOf } from '../lib/http.js'; +import { cfg } from '../lib/config.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + // Negative 1: empty id_token → 400 missing/invalid input + const r1 = post('/api/v1/auth/token/exchange', { + tenant_slug: cfg.tenantSlug, + id_token: '', + }); + // either 400 (validation) or 401 (id_token invalid) + if (r1.status !== 400 && r1.status !== 401) { + throw new Error(`empty id_token: unexpected status ${r1.status}: ${r1.body}`); + } + + // Negative 2: bogus id_token. Two acceptable outcomes depending on whether + // a JWKS URL is wired: + // 401+28501000 → invalid id_token (JWKS wired; gateway verified locally) + // 502+28802000 → zitadel request failed (default k6 env: no JWKS configured, + // gateway falls through to a remote introspection call). + // Either outcome proves the endpoint exists and refuses bad tokens. + const r2 = post('/api/v1/auth/token/exchange', { + tenant_slug: cfg.tenantSlug, + id_token: 'not.a.real.jwt', + }); + checkErrorOneOf(r2, 'POST /auth/token/exchange (bogus id_token)', [ + [401, 28501000], + [502, 28802000], + ]); + + // TODO: happy path — needs ZITADEL OAuth client + password grant configured. + console.log('[token_exchange] happy path skipped (needs ZITADEL OIDC client; see file header)'); +} diff --git a/test/k6/journeys/totp_full.js b/test/k6/journeys/totp_full.js new file mode 100644 index 0000000..7793f12 --- /dev/null +++ b/test/k6/journeys/totp_full.js @@ -0,0 +1,81 @@ +// Journey: TOTP full lifecycle (enroll → verify → backup-codes → disable) +// +// Endpoints exercised: +// GET /api/v1/members/me/totp (before) +// POST /api/v1/members/me/totp/enroll-start +// POST /api/v1/members/me/totp/enroll-confirm +// POST /api/v1/members/me/totp/verify +// POST /api/v1/members/me/totp/backup-codes (regenerate) +// GET /api/v1/members/me/totp (mid; enrolled=true) +// DELETE /api/v1/members/me/totp +// GET /api/v1/members/me/totp (after; enrolled=false) +// +// TOTP code generation is local (lib/totp.js) — no external authenticator. +import { sleep } from 'k6'; +import { get, post, del, checkEnvelope } from '../lib/http.js'; +import { registerAndConfirm } from '../lib/auth.js'; +import { generateTOTP, parseOTPAuth } from '../lib/totp.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const { tokens } = registerAndConfirm(); + const bearer = { Authorization: `Bearer ${tokens.access_token}` }; + + // 1. status before enroll + const before = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (before)').data; + if (before.enrolled !== false) throw new Error('totp should be disabled initially'); + + // 2. enroll-start → get otpauth URL + const enroll = checkEnvelope(post('/api/v1/members/me/totp/enroll-start', null, bearer), 'POST /me/totp/enroll-start').data; + parseOTPAuth(enroll.otpauth_url); // validate it parses + const code = generateTOTP(enroll.otpauth_url); + + // 3. enroll-confirm + const confirm = checkEnvelope( + post('/api/v1/members/me/totp/enroll-confirm', { code }, bearer), + 'POST /me/totp/enroll-confirm', + ).data; + if (!Array.isArray(confirm.backup_codes) || confirm.backup_codes.length === 0) { + throw new Error('enroll-confirm: backup_codes missing'); + } + + // 4. status mid (enrolled=true) + const mid = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (mid)').data; + if (mid.enrolled !== true) throw new Error('totp should be enrolled after confirm'); + + // 5. verify — wait until current TOTP differs from the one just consumed + // so the replay guard (Member.TOTP.ReplayTTLSeconds) does not reject it. + let verifyCode = generateTOTP(enroll.otpauth_url); + for (let i = 0; verifyCode === code && i < 30; i++) { + sleep(1.1); + verifyCode = generateTOTP(enroll.otpauth_url); + } + if (verifyCode === code) { + throw new Error('TOTP window did not advance; cannot avoid replay'); + } + checkEnvelope( + post('/api/v1/members/me/totp/verify', { code: verifyCode }, bearer), + 'POST /me/totp/verify', + ); + + // 6. backup-codes (regenerate) + const regen = checkEnvelope( + post('/api/v1/members/me/totp/backup-codes', null, bearer), + 'POST /me/totp/backup-codes', + ).data; + if (!Array.isArray(regen.backup_codes) || regen.backup_codes.length === 0) { + throw new Error('backup-codes: missing codes'); + } + + // 7. disable + checkEnvelope(del('/api/v1/members/me/totp', null, bearer), 'DELETE /me/totp'); + + // 8. status after disable + const after = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (after)').data; + if (after.enrolled !== false) throw new Error('totp should be disabled after DELETE'); +} diff --git a/test/k6/lib/auth.js b/test/k6/lib/auth.js new file mode 100644 index 0000000..8a4343a --- /dev/null +++ b/test/k6/lib/auth.js @@ -0,0 +1,189 @@ +// Auth flow helpers — register / confirm / login / refresh / logout. +// All requests go through lib/http.js; OTP comes from lib/otp.js. +import { post } from './http.js'; +import { checkEnvelope } from './http.js'; +import { cfg, unique } from './config.js'; +import { fetchEmailOTP } from './otp.js'; +import { generateTOTP } from './totp.js'; + +// makeIdentity returns a unique (email, password, display_name) tuple for the +// current VU iteration. Use to avoid collisions in concurrent runs. +export function makeIdentity(prefix = 'k6') { + const slug = unique(prefix); + return { + email: `${slug}@k6.local`, + password: 'K6-StrongPass-1!', + displayName: `K6 ${slug}`, + }; +} + +// registerEmail calls POST /api/v1/auth/register and returns the parsed +// RegisterData (challenge_id, expires_in, uid). +export function registerEmail({ tenantSlug = cfg.tenantSlug, inviteCode = cfg.inviteCode, email, password, displayName, language = 'zh-TW', termsVersion = '2025-01-01', marketingOptIn = false } = {}) { + const res = post('/api/v1/auth/register', { + tenant_slug: tenantSlug, + invite_code: inviteCode, + email, + password, + display_name: displayName, + language, + accept_terms_version: termsVersion, + marketing_opt_in: marketingOptIn, + }); + const body = checkEnvelope(res, 'POST /auth/register'); + if (!body.data || !body.data.challenge_id) { + throw new Error(`register: missing challenge_id in ${res.body}`); + } + return body.data; +} + +// confirmRegister fetches the OTP from MailHog then calls +// POST /api/v1/auth/register/confirm. Returns AuthTokenData. +export function confirmRegister({ tenantSlug = cfg.tenantSlug, email, challengeId }) { + const code = fetchEmailOTP(email); + const res = post('/api/v1/auth/register/confirm', { + tenant_slug: tenantSlug, + challenge_id: challengeId, + code, + }); + const body = checkEnvelope(res, 'POST /auth/register/confirm'); + if (!body.data || !body.data.access_token) { + throw new Error(`register/confirm: missing access_token in ${res.body}`); + } + return body.data; +} + +// resendRegister calls POST /api/v1/auth/register/resend. +// Returns RegisterData (challenge_id, expires_in, uid). +export function resendRegister({ tenantSlug = cfg.tenantSlug, challengeId }) { + const res = post('/api/v1/auth/register/resend', { + tenant_slug: tenantSlug, + challenge_id: challengeId, + }); + return checkEnvelope(res, 'POST /auth/register/resend').data; +} + +// registerResume calls POST /api/v1/auth/register/resume. +export function registerResume({ tenantSlug = cfg.tenantSlug, email }) { + const res = post('/api/v1/auth/register/resume', { + tenant_slug: tenantSlug, + email, + }); + return checkEnvelope(res, 'POST /auth/register/resume').data; +} + +// passwordForgot calls POST /api/v1/auth/password/forgot. +export function passwordForgot({ tenantSlug = cfg.tenantSlug, email }) { + const res = post('/api/v1/auth/password/forgot', { + tenant_slug: tenantSlug, + email, + }); + return checkEnvelope(res, 'POST /auth/password/forgot').data; +} + +// passwordReset calls POST /api/v1/auth/password/reset. +export function passwordReset({ + tenantSlug = cfg.tenantSlug, + challengeId, + code, + newPassword, +}) { + const res = post('/api/v1/auth/password/reset', { + tenant_slug: tenantSlug, + challenge_id: challengeId, + code, + new_password: newPassword, + }); + return checkEnvelope(res, 'POST /auth/password/reset').data; +} + +// loginStep calls POST /api/v1/auth/login and returns LoginData (tokens or MFA challenge). +export function loginStep({ tenantSlug = cfg.tenantSlug, email, password }) { + const res = post('/api/v1/auth/login', { + tenant_slug: tenantSlug, + email, + password, + }); + return checkEnvelope(res, 'POST /auth/login').data; +} + +// loginMfaConfirm completes login after MFA challenge. +export function loginMfaConfirm({ tenantSlug = cfg.tenantSlug, challengeId, code }) { + const res = post('/api/v1/auth/login/mfa', { + tenant_slug: tenantSlug, + challenge_id: challengeId, + code, + }); + return checkEnvelope(res, 'POST /auth/login/mfa').data; +} + +// loginExpectMFA returns LoginData when mfa_required=true. +export function loginExpectMFA({ tenantSlug = cfg.tenantSlug, email, password }) { + const data = loginStep({ tenantSlug, email, password }); + if (!data.mfa_required) { + throw new Error(`loginExpectMFA: expected mfa_required, got ${JSON.stringify(data)}`); + } + if (!data.mfa_challenge_id) { + throw new Error('loginExpectMFA: missing mfa_challenge_id'); + } + return data; +} + +// login calls POST /api/v1/auth/login. When TOTP is enrolled, pass totpCode or otpauthUrl. +export function login({ tenantSlug = cfg.tenantSlug, email, password, totpCode, otpauthUrl } = {}) { + const step = loginStep({ tenantSlug, email, password }); + if (step.mfa_required) { + let code = totpCode; + if (!code && otpauthUrl) { + code = generateTOTP(otpauthUrl); + } + if (!code) { + throw new Error('login: MFA required but totpCode/otpauthUrl not provided'); + } + const tokens = loginMfaConfirm({ + tenantSlug, + challengeId: step.mfa_challenge_id, + code, + }); + if (!tokens.access_token) { + throw new Error('login/mfa: missing access_token'); + } + return tokens; + } + if (!step.access_token) { + throw new Error(`login: missing access_token in ${JSON.stringify(step)}`); + } + return step; +} + +// refreshToken calls POST /api/v1/auth/token/refresh. +export function refreshToken({ refreshToken }) { + const res = post('/api/v1/auth/token/refresh', { refresh_token: refreshToken }); + const body = checkEnvelope(res, 'POST /auth/token/refresh'); + if (!body.data || !body.data.access_token) { + throw new Error(`token/refresh: missing access_token in ${res.body}`); + } + return body.data; +} + +// logout calls POST /api/v1/auth/logout (requires Bearer access_token). +export function logout({ accessToken }) { + const res = post('/api/v1/auth/logout', null, { Authorization: `Bearer ${accessToken}` }); + const body = checkEnvelope(res, 'POST /auth/logout'); + return body.data; +} + +// registerAndConfirm is the most common building block: makes an identity, +// runs register → confirm, returns { identity, tokens, registerData }. +export function registerAndConfirm({ tenantSlug = cfg.tenantSlug, inviteCode = cfg.inviteCode } = {}) { + const identity = makeIdentity(); + const reg = registerEmail({ + tenantSlug, + inviteCode, + email: identity.email, + password: identity.password, + displayName: identity.displayName, + }); + const tokens = confirmRegister({ tenantSlug, email: identity.email, challengeId: reg.challenge_id }); + return { identity, tokens, registerData: reg }; +} diff --git a/test/k6/lib/config.js b/test/k6/lib/config.js new file mode 100644 index 0000000..d9ac81d --- /dev/null +++ b/test/k6/lib/config.js @@ -0,0 +1,45 @@ +// Test config — read from env so the same scripts run locally and in CI. +// +// Required: +// BASE_URL gateway base URL (default http://localhost:8888) +// MAILHOG_URL MailHog HTTP API (default http://localhost:8025) +// REDIS_ADDR Redis address for SMS OTP read (default localhost:6379) +// +// Optional (test data tuning): +// TENANT_SLUG tenant used for register payloads (default k6-tenant) +// INVITE_CODE invite code for tenants requiring it (default K6INVITE) +// ADMIN_EMAIL seeded admin login for rbac journeys +// ADMIN_PASSWORD +// ADMIN_ACCESS_TOKEN pre-issued admin access_token (from k6-seed-admin); +// used by rbac_admin.js to bypass /auth/login when +// ZITADEL v2 password grant is unavailable. +// ADMIN_REFRESH_TOKEN matching refresh token (optional, future use). +// +// All scripts must import from here; do NOT hard-code URLs/credentials. +export const cfg = { + baseUrl: __ENV.BASE_URL || 'http://localhost:8888', + mailhogUrl: __ENV.MAILHOG_URL || 'http://localhost:8025', + redisAddr: __ENV.REDIS_ADDR || 'localhost:6379', + tenantSlug: __ENV.TENANT_SLUG || 'k6-tenant', + inviteCode: __ENV.INVITE_CODE || 'K6INVITE', + adminEmail: __ENV.ADMIN_EMAIL || '', + adminPassword: __ENV.ADMIN_PASSWORD || '', + adminAccessToken: __ENV.ADMIN_ACCESS_TOKEN || '', + adminRefreshToken: __ENV.ADMIN_REFRESH_TOKEN || '', + // OTP polling parameters (MailHog / Redis) + otpPollIntervalMs: parseInt(__ENV.OTP_POLL_INTERVAL_MS || '300', 10), + otpPollTimeoutMs: parseInt(__ENV.OTP_POLL_TIMEOUT_MS || '5000', 10), + /** 與 etc/gateway.k6.yaml Member.OTP.ResendCooldownSeconds 一致 */ + resendCooldownSeconds: parseInt(__ENV.RESEND_COOLDOWN_SECONDS || '60', 10), +}; + +// Build a unique-ish identity per VU+iteration so concurrent runs do not collide. +export function unique(prefix) { + const ts = Date.now(); + const vu = (typeof __VU !== 'undefined' && __VU) || 0; + const iter = (typeof __ITER !== 'undefined' && __ITER) || 0; + return `${prefix}-${ts}-${vu}-${iter}`; +} + +// Common biz code: 102000 = success envelope (see internal/library/errors/code). +export const SUCCESS_CODE = 102000; diff --git a/test/k6/lib/http.js b/test/k6/lib/http.js new file mode 100644 index 0000000..e20015f --- /dev/null +++ b/test/k6/lib/http.js @@ -0,0 +1,120 @@ +// HTTP helpers — every request goes through these so checks/envelope handling +// is consistent across smoke and journey scripts. +import http from 'k6/http'; +import { check, fail } from 'k6'; +import { cfg, SUCCESS_CODE } from './config.js'; + +const JSON_HEADERS = { 'Content-Type': 'application/json', Accept: 'application/json' }; + +function url(path) { + if (path.startsWith('http://') || path.startsWith('https://')) return path; + return cfg.baseUrl + path; +} + +function mergeHeaders(extra) { + return Object.assign({}, JSON_HEADERS, extra || {}); +} + +export function withBearer(token, extra) { + return mergeHeaders(Object.assign({ Authorization: `Bearer ${token}` }, extra || {})); +} + +export function get(path, headers, params) { + return http.get(url(path), Object.assign({ headers: mergeHeaders(headers) }, params || {})); +} + +export function post(path, body, headers, params) { + return http.post( + url(path), + body == null ? null : JSON.stringify(body), + Object.assign({ headers: mergeHeaders(headers) }, params || {}), + ); +} + +export function put(path, body, headers, params) { + return http.put( + url(path), + body == null ? null : JSON.stringify(body), + Object.assign({ headers: mergeHeaders(headers) }, params || {}), + ); +} + +export function patch(path, body, headers, params) { + return http.patch( + url(path), + body == null ? null : JSON.stringify(body), + Object.assign({ headers: mergeHeaders(headers) }, params || {}), + ); +} + +export function del(path, body, headers, params) { + return http.del( + url(path), + body == null ? null : JSON.stringify(body), + Object.assign({ headers: mergeHeaders(headers) }, params || {}), + ); +} + +// safeJson parses res.body; returns null when body is empty/not JSON. +export function safeJson(res) { + if (!res || !res.body || res.body.length === 0) return null; + try { + return JSON.parse(res.body); + } catch (_) { + return null; + } +} + +// checkEnvelope verifies the standard CloudEP success envelope. +// { code: 102000, message: "SUCCESS", data: ... } +// Returns the parsed body so callers can keep chaining. +export function checkEnvelope(res, label, expectedStatus = 200, expectedCode = SUCCESS_CODE) { + const body = safeJson(res); + const ok = check(res, { + [`${label}: status ${expectedStatus}`]: (r) => r.status === expectedStatus, + [`${label}: code ${expectedCode}`]: () => body && body.code === expectedCode, + }); + if (!ok) { + // surface real payload so failures are actionable in `make k6-*` output + fail(`${label} failed: status=${res.status} body=${res.body}`); + } + return body; +} + +// checkError expects a non-2xx response and a business error code. +// expectedBiz is the 8-digit numeric SSCCCDDD code. +export function checkError(res, label, expectedStatus, expectedBiz) { + const body = safeJson(res); + const ok = check(res, { + [`${label}: status ${expectedStatus}`]: (r) => r.status === expectedStatus, + [`${label}: code ${expectedBiz}`]: () => body && body.code === expectedBiz, + }); + if (!ok) { + fail(`${label} expected error ${expectedBiz} got status=${res.status} body=${res.body}`); + } + return body; +} + +// checkErrorOneOf accepts any of several (status, code) pairs. Use for +// endpoints whose error path depends on environment wiring (e.g. +// /auth/login may legitimately return either: +// 401 + 28501000 (invalid credentials, OAuth wired) +// 502 + 28802000 (zitadel request failed, OAuth not wired or password +// grant not enabled — true of modern ZITADEL v2 by default) +// pairs: array of [status, bizCode] tuples. +export function checkErrorOneOf(res, label, pairs) { + const body = safeJson(res); + const matched = pairs.find( + ([s, c]) => res.status === s && body && body.code === c, + ); + const labelStr = pairs.map(([s, c]) => `${s}+${c}`).join(' | '); + const ok = check(res, { + [`${label}: one of {${labelStr}}`]: () => Boolean(matched), + }); + if (!ok) { + fail( + `${label} expected one of [${labelStr}] got status=${res.status} body=${res.body}`, + ); + } + return body; +} diff --git a/test/k6/lib/member.js b/test/k6/lib/member.js new file mode 100644 index 0000000..37f3895 --- /dev/null +++ b/test/k6/lib/member.js @@ -0,0 +1,28 @@ +// Member flow helpers — TOTP enroll / change password. +import { post, checkEnvelope } from './http.js'; +import { generateTOTP } from './totp.js'; + +export function enrollTOTP(bearer) { + const enroll = checkEnvelope( + post('/api/v1/members/me/totp/enroll-start', null, bearer), + 'POST /me/totp/enroll-start', + ).data; + if (!enroll.otpauth_url) { + throw new Error('enroll-start: missing otpauth_url'); + } + const code = generateTOTP(enroll.otpauth_url); + checkEnvelope( + post('/api/v1/members/me/totp/enroll-confirm', { code }, bearer), + 'POST /me/totp/enroll-confirm', + ); + return { otpauthUrl: enroll.otpauth_url }; +} + +export function changePassword(currentPassword, newPassword, bearer) { + const res = post( + '/api/v1/members/me/password', + { current_password: currentPassword, new_password: newPassword }, + bearer, + ); + return checkEnvelope(res, 'POST /me/password').data; +} diff --git a/test/k6/lib/otp.js b/test/k6/lib/otp.js new file mode 100644 index 0000000..347cfbb --- /dev/null +++ b/test/k6/lib/otp.js @@ -0,0 +1,110 @@ +// OTP retrieval helpers (P1 skeleton). +// +// fetchEmailOTP(email) → reads the latest MailHog mail for `email`, scans body +// for a 6-digit code. Full implementation in P2 lib. +// fetchSMSOTP(phone) → reads "dev:notification:last:sms:" from Redis +// (k6/experimental/redis). Implementation in P3. +// +// Both poll for up to cfg.otpPollTimeoutMs because Notifier writes happen +// asynchronously (Send goroutine + Redis hook). +import http from 'k6/http'; +import { sleep } from 'k6'; +// k6 v2.0+ uses 'k6/x/redis' (the experimental module was promoted). The +// default export is a namespace; the constructor is `redis.Client`. +import redis from 'k6/x/redis'; +import { cfg } from './config.js'; + +const SIX_DIGITS = /\b(\d{6})\b/; +// CSS hex colors (#aabbcc) look like 6-digit OTPs to a naive regex; strip +// them before scanning. We also strip quoted-printable soft-line-breaks +// (`=\n`) since MailHog returns bodies QP-encoded. +const CSS_HEX = /#[0-9a-fA-F]{6}\b/g; +const QP_SOFT_BREAK = /=\r?\n/g; + +function sleepMs(ms) { + sleep(ms / 1000); +} + +// extractOTPFromText returns the LAST 6-digit number found (after stripping +// CSS hex colors). "Last" because the OTP is typically rendered near the +// bottom of the email body, after the header/branding markup. +export function extractOTPFromText(text) { + if (!text) return ''; + const cleaned = String(text).replace(QP_SOFT_BREAK, '').replace(CSS_HEX, ''); + const all = cleaned.match(/\b\d{6}\b/g); + if (!all || all.length === 0) return ''; + return all[all.length - 1]; +} + +// fetchEmailOTP polls MailHog's /api/v2/search?kind=to&query= +// until a 6-digit OTP can be parsed out of the most recent message body. +// +// opts.since (ms epoch, default 0): ignore mails delivered before this ts — +// needed when the same address has multiple OTPs in MailHog (e.g. register +// then email-verify), so the verify step picks up the new mail rather than +// the register one. +// opts.limit (default 5): number of latest mails to inspect each poll. +export function fetchEmailOTP(email, opts = {}) { + const since = opts.since || 0; + const limit = opts.limit || 5; + const deadline = Date.now() + cfg.otpPollTimeoutMs; + const u = `${cfg.mailhogUrl}/api/v2/search?kind=to&query=${encodeURIComponent(email)}&start=0&limit=${limit}`; + let last = ''; + while (Date.now() < deadline) { + const res = http.get(u); + if (res.status === 200) { + try { + const body = JSON.parse(res.body); + const items = (body && body.items) || []; + for (const item of items) { + const created = item.Created ? Date.parse(item.Created) : 0; + if (created && created < since) continue; + const candidates = [ + item.Content && item.Content.Body, + item.MIME && item.MIME.Parts && item.MIME.Parts.map((p) => p.Body).join('\n'), + ].filter(Boolean); + for (const c of candidates) { + const code = extractOTPFromText(c); + if (code) return code; + } + } + if (items.length > 0) last = `no 6-digit code in ${items.length} items (since=${since})`; + } catch (e) { + last = `parse-error: ${e}`; + } + } else { + last = `mailhog status ${res.status}`; + } + sleepMs(cfg.otpPollIntervalMs); + } + throw new Error(`fetchEmailOTP(${email}) timed out: ${last}`); +} + +// fetchSMSOTP polls Redis key "dev:notification:last:sms:" set by the +// mock SMS sender (see internal/model/notification/provider/sms/mock_sender.go). +// +// opts.prevBody: when supplied, the poll ignores writes whose body equals +// prevBody. This is essential when the same phone has two OTPs in a single +// test (e.g. register-resend or re-verify); the caller passes the body it +// already consumed so the helper waits for the next one. +// +// Requires k6 v2.0+ which exposes the redis client at k6/x/redis (the +// `k6/experimental/redis` module was removed in v2.0). Statically imported +// above so this works in default k6 compatibility mode. +export async function fetchSMSOTP(phone, opts = {}) { + const prev = opts.prevBody || ''; + const [host, port] = cfg.redisAddr.split(':'); + const client = new redis.Client({ socket: { host: host, port: parseInt(port || '6379', 10) } }); + const key = `dev:notification:last:sms:${phone}`; + + const deadline = Date.now() + cfg.otpPollTimeoutMs; + while (Date.now() < deadline) { + const body = await client.get(key); + if (body && body !== prev) { + const code = extractOTPFromText(body); + if (code) return { code, body }; + } + sleepMs(cfg.otpPollIntervalMs); + } + throw new Error(`fetchSMSOTP(${phone}) timed out`); +} diff --git a/test/k6/lib/totp.js b/test/k6/lib/totp.js new file mode 100644 index 0000000..70a4bf7 --- /dev/null +++ b/test/k6/lib/totp.js @@ -0,0 +1,106 @@ +// TOTP (RFC 6238) generator for k6. +// +// Used by journeys/totp_full.js: parse the otpauth_url returned by +// /me/totp/enroll-start, then compute a 6-digit code for the current 30s window. +// +// k6 ships HMAC-SHA1 in k6/crypto so no xk6 extension is required. +import crypto from 'k6/crypto'; + +const BASE32_ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +// base32Decode returns a Uint8Array for a (possibly padded) base32 string. +// Accepts mixed case and ignores '=' padding and whitespace. +export function base32Decode(input) { + if (!input) return new Uint8Array(0); + let cleaned = ''; + for (let i = 0; i < input.length; i++) { + const c = input[i].toUpperCase(); + if (c === '=' || c === ' ' || c === '\n' || c === '\r' || c === '\t') continue; + cleaned += c; + } + const out = []; + let bits = 0; + let value = 0; + for (let i = 0; i < cleaned.length; i++) { + const idx = BASE32_ALPHA.indexOf(cleaned[i]); + if (idx < 0) throw new Error(`base32: invalid char '${cleaned[i]}'`); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + bits -= 8; + out.push((value >> bits) & 0xff); + } + } + return new Uint8Array(out); +} + +// parseOTPAuth extracts the base32 secret + algo/digits/period from an +// otpauth://totp/... URL. Returns defaults (SHA1, 6 digits, 30s period) for +// fields not present. Throws if `secret` is missing. +export function parseOTPAuth(url) { + if (!url || url.indexOf('otpauth://totp/') !== 0) { + throw new Error(`parseOTPAuth: not an otpauth totp url: ${url}`); + } + const q = url.indexOf('?'); + if (q < 0) throw new Error('parseOTPAuth: missing query string'); + const query = url.slice(q + 1); + const params = {}; + for (const part of query.split('&')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + const k = decodeURIComponent(part.slice(0, eq)); + const v = decodeURIComponent(part.slice(eq + 1)); + params[k.toLowerCase()] = v; + } + if (!params.secret) throw new Error('parseOTPAuth: missing secret param'); + return { + secret: params.secret, + algorithm: (params.algorithm || 'SHA1').toUpperCase(), + digits: parseInt(params.digits || '6', 10), + period: parseInt(params.period || '30', 10), + }; +} + +// hotp computes an HOTP code as per RFC 4226 given a key (Uint8Array) and a +// 64-bit counter. Returns a digits-long zero-padded string. +function hotp(keyBytes, counter, digits) { + // Build 8-byte big-endian counter buffer + const buf = new ArrayBuffer(8); + const view = new DataView(buf); + // Counter can exceed Number.MAX_SAFE_INTEGER theoretically but for unix-time + // counters until year ~9999 it fits comfortably in a 32-bit low half. + const high = Math.floor(counter / 0x100000000); + const low = counter >>> 0; + view.setUint32(0, high, false); + view.setUint32(4, low, false); + + // hmac with binary output gives us an ArrayBuffer of 20 bytes for SHA1 + const macBuf = crypto.hmac('sha1', keyBytes.buffer, buf, 'binary'); + const mac = new Uint8Array(macBuf); + const offset = mac[mac.length - 1] & 0x0f; + const bin = + ((mac[offset] & 0x7f) << 24) | + ((mac[offset + 1] & 0xff) << 16) | + ((mac[offset + 2] & 0xff) << 8) | + (mac[offset + 3] & 0xff); + const mod = Math.pow(10, digits); + return String(bin % mod).padStart(digits, '0'); +} + +// generateTOTP returns the current TOTP code for the given otpauth secret. +// Pass timeOverrideMs to test specific windows; defaults to Date.now(). +export function generateTOTP(otpauthURLOrSecret, timeOverrideMs) { + let opts; + if (typeof otpauthURLOrSecret === 'string' && otpauthURLOrSecret.indexOf('otpauth://') === 0) { + opts = parseOTPAuth(otpauthURLOrSecret); + } else { + opts = { secret: otpauthURLOrSecret, algorithm: 'SHA1', digits: 6, period: 30 }; + } + if (opts.algorithm !== 'SHA1') { + throw new Error(`generateTOTP: only SHA1 supported, got ${opts.algorithm}`); + } + const key = base32Decode(opts.secret); + const t = typeof timeOverrideMs === 'number' ? timeOverrideMs : Date.now(); + const counter = Math.floor(t / 1000 / opts.period); + return hotp(key, counter, opts.digits); +} diff --git a/test/k6/smoke/auth_bearer.js b/test/k6/smoke/auth_bearer.js new file mode 100644 index 0000000..c6863c9 --- /dev/null +++ b/test/k6/smoke/auth_bearer.js @@ -0,0 +1,29 @@ +// smoke: bearer-protected auth endpoints +// +// Covers: +// POST /api/v1/auth/logout (happy: 200) +// POST /api/v1/auth/logout (negative: 401 without Bearer) +import { post, checkEnvelope, checkError } from '../lib/http.js'; +import { registerAndConfirm } from '../lib/auth.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + // negative: no bearer + const naked = post('/api/v1/auth/logout'); + checkError(naked, 'POST /auth/logout (no bearer)', 401, 28501000); + + // happy: with bearer + const { tokens } = registerAndConfirm(); + const ok = post('/api/v1/auth/logout', null, { + Authorization: `Bearer ${tokens.access_token}`, + }); + const body = checkEnvelope(ok, 'POST /auth/logout'); + if (!body.data || body.data.ok !== true) { + throw new Error(`logout: data.ok != true: ${ok.body}`); + } +} diff --git a/test/k6/smoke/auth_login_mfa.js b/test/k6/smoke/auth_login_mfa.js new file mode 100644 index 0000000..0d68ba4 --- /dev/null +++ b/test/k6/smoke/auth_login_mfa.js @@ -0,0 +1,81 @@ +// smoke: POST /api/v1/auth/login/mfa +// +// Covers: +// login → mfa_required(已啟用 TOTP 時不回 token) +// 403 — TOTP 錯誤(29505000) +// 404 — challenge 不存在(28301000) +// 403 — tenant slug 不符(28505000) +// 400 — 缺少 code / challenge_id +// +// Happy path(login → login/mfa → JWT)見 journeys/login_mfa_full.js +import { post, checkError } from '../lib/http.js'; +import { cfg } from '../lib/config.js'; +import { registerAndConfirm, loginExpectMFA } from '../lib/auth.js'; +import { enrollTOTP } from '../lib/member.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const { identity, tokens } = registerAndConfirm(); + const bearer = { Authorization: `Bearer ${tokens.access_token}` }; + const { otpauthUrl } = enrollTOTP(bearer); + + const mfa = loginExpectMFA({ + email: identity.email, + password: identity.password, + }); + if (mfa.access_token) { + throw new Error('login with TOTP enrolled should not return access_token'); + } + + // bad TOTP + checkError( + post('/api/v1/auth/login/mfa', { + tenant_slug: cfg.tenantSlug, + challenge_id: mfa.mfa_challenge_id, + code: '000000', + }), + 'POST /auth/login/mfa (bad totp)', + 403, + 29505000, + ); + + // unknown challenge — Redis miss 目前回 500(28201000) + checkError( + post('/api/v1/auth/login/mfa', { + tenant_slug: cfg.tenantSlug, + challenge_id: '00000000-0000-0000-0000-000000000000', + code: '123456', + }), + 'POST /auth/login/mfa (unknown challenge)', + 500, + 28201000, + ); + + // unknown tenant slug(resolveTenant 失敗) + checkError( + post('/api/v1/auth/login/mfa', { + tenant_slug: 'wrong-tenant-slug', + challenge_id: mfa.mfa_challenge_id, + code: '123456', + }), + 'POST /auth/login/mfa (unknown tenant)', + 404, + 29301000, + ); + + // missing fields + const missing = post('/api/v1/auth/login/mfa', { tenant_slug: cfg.tenantSlug }); + if (missing.status !== 400) { + throw new Error(`login/mfa missing fields: expected 400 got ${missing.status}`); + } + + // otpauthUrl kept for journey reuse sanity (not used further in smoke) + if (!otpauthUrl) { + throw new Error('enrollTOTP did not return otpauthUrl'); + } +} diff --git a/test/k6/smoke/auth_password.js b/test/k6/smoke/auth_password.js new file mode 100644 index 0000000..1ac8306 --- /dev/null +++ b/test/k6/smoke/auth_password.js @@ -0,0 +1,142 @@ +// smoke: POST /api/v1/auth/password/{forgot,reset} +// +// Covers forgot: +// happy — active platform 帳號寄重設 OTP +// 404 — member 不存在(28301000) +// 403 — 未驗證帳號(28505000) +// 400 — 缺少 email +// 429 — OTP 重送冷卻(29604000) +// +// Covers reset: +// 403 — OTP 錯誤(29505000) +// 404 — challenge 不存在(29301000) +// 403 — purpose 不符(用 registration challenge)(29505000) +// 400 — 新密碼太短 +import { sleep } from 'k6'; +import { post, checkError } from '../lib/http.js'; +import { cfg } from '../lib/config.js'; +import { + makeIdentity, + registerEmail, + registerAndConfirm, + passwordForgot, +} from '../lib/auth.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const active = registerAndConfirm(); + + // forgot happy + const forgot = passwordForgot({ email: active.identity.email }); + if (!forgot.challenge_id) { + throw new Error('password/forgot happy: missing challenge_id'); + } + + // forgot member not found + checkError( + post('/api/v1/auth/password/forgot', { + tenant_slug: cfg.tenantSlug, + email: `no-such-${Date.now()}@k6.local`, + }), + 'POST /auth/password/forgot (member not found)', + 404, + 28301000, + ); + + // forgot unverified account + const pending = makeIdentity('pwd-unverified'); + registerEmail({ + email: pending.email, + password: pending.password, + displayName: pending.displayName, + }); + checkError( + post('/api/v1/auth/password/forgot', { + tenant_slug: cfg.tenantSlug, + email: pending.email, + }), + 'POST /auth/password/forgot (unverified)', + 403, + 28505000, + ); + + // forgot missing email + const missingForgot = post('/api/v1/auth/password/forgot', { tenant_slug: cfg.tenantSlug }); + if (missingForgot.status !== 400) { + throw new Error(`password/forgot missing email: expected 400 got ${missingForgot.status}`); + } + + // forgot resend cooldown + checkError( + post('/api/v1/auth/password/forgot', { + tenant_slug: cfg.tenantSlug, + email: active.identity.email, + }), + 'POST /auth/password/forgot (resend cooldown)', + 429, + 29604000, + ); + sleep(cfg.resendCooldownSeconds + 1); + passwordForgot({ email: active.identity.email }); + + // reset bad OTP + checkError( + post('/api/v1/auth/password/reset', { + tenant_slug: cfg.tenantSlug, + challenge_id: forgot.challenge_id, + code: '000000', + new_password: 'K6-NewPass-2!', + }), + 'POST /auth/password/reset (bad otp)', + 403, + 29505000, + ); + + // reset unknown challenge — Redis miss 目前回 500(29201000),非 404 + checkError( + post('/api/v1/auth/password/reset', { + tenant_slug: cfg.tenantSlug, + challenge_id: '00000000-0000-0000-0000-000000000000', + code: '123456', + new_password: 'K6-NewPass-2!', + }), + 'POST /auth/password/reset (unknown challenge)', + 500, + 29201000, + ); + + // reset purpose mismatch — registration challenge on password reset endpoint + const regOnly = makeIdentity('pwd-purpose'); + const reg = registerEmail({ + email: regOnly.email, + password: regOnly.password, + displayName: regOnly.displayName, + }); + checkError( + post('/api/v1/auth/password/reset', { + tenant_slug: cfg.tenantSlug, + challenge_id: reg.challenge_id, + code: '123456', + new_password: 'K6-NewPass-2!', + }), + 'POST /auth/password/reset (purpose mismatch)', + 403, + 29505000, + ); + + // reset weak password (validate min=8) + const weak = post('/api/v1/auth/password/reset', { + tenant_slug: cfg.tenantSlug, + challenge_id: forgot.challenge_id, + code: '123456', + new_password: 'short', + }); + if (weak.status !== 400) { + throw new Error(`password/reset weak password: expected 400 got ${weak.status}`); + } +} diff --git a/test/k6/smoke/auth_public.js b/test/k6/smoke/auth_public.js new file mode 100644 index 0000000..1a3722e --- /dev/null +++ b/test/k6/smoke/auth_public.js @@ -0,0 +1,96 @@ +// smoke: public auth endpoints (no Bearer) +// +// Covers: +// POST /api/v1/auth/register (happy → challenge_id) +// POST /api/v1/auth/register/resend (happy → new challenge_id) +// POST /api/v1/auth/login (negative → invalid credentials) +// POST /api/v1/auth/token/refresh (negative → invalid refresh token) +// POST /api/v1/auth/register/social/start (happy → oauth_url) +// POST /api/v1/auth/login/social/start (happy → oauth_url) +// GET /api/v1/auth/register/social/callback (negative → invalid state) +// GET /api/v1/auth/login/social/callback (negative → invalid state) +// +// Note: social callback happy path requires browser redirect (skipped — see README). +import { sleep } from 'k6'; +import { get, post, checkEnvelope, checkError, checkErrorOneOf } from '../lib/http.js'; +import { cfg, unique } from '../lib/config.js'; +import { registerEmail, resendRegister } from '../lib/auth.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + // 1. register happy + const email = `${unique('smoke-pub')}@k6.local`; + const reg = registerEmail({ email, password: 'K6-StrongPass-1!', displayName: 'smoke-pub' }); + + // 2. register resend happy (new challenge issued). Respect the resend + // cooldown (etc/gateway.k6.yaml → Member.OTP.ResendCooldownSeconds=1) plus + // a small safety margin so we don't hit 29604000. + sleep(1.2); + resendRegister({ challengeId: reg.challenge_id }); + + // 3. login negative — wrong password against a non-existent user. + // Accepted outcomes: + // 401+28501000 → invalid credentials path (full OAuth client wired) + // 502+28802000 → zitadel request failed (default k6 env: no OAuth client, + // and modern ZITADEL v2 disables password grant anyway). + // Either outcome proves the endpoint is reachable + returns an error envelope. + const loginRes = post('/api/v1/auth/login', { + tenant_slug: cfg.tenantSlug, + email: 'no-such-user@k6.local', + password: 'wrongPassword1!', + }); + checkErrorOneOf(loginRes, 'POST /auth/login (negative)', [ + [401, 28501000], + [502, 28802000], + ]); + + // 4. token/refresh negative — bogus refresh token. Same dual-outcome reason. + const refreshRes = post('/api/v1/auth/token/refresh', { refresh_token: 'not-a-token' }); + checkErrorOneOf(refreshRes, 'POST /auth/token/refresh (negative)', [ + [401, 28501000], + [502, 28802000], + ]); + + // 5. register/social/start — requires Google IdP wired in ZITADEL. + // Accepted outcomes: + // 200+102000 → oauth_url returned (full Google IdP wired) + // 502+28802000 → zitadel request failed (default k6 env: no GoogleIdPID). + // We verify the endpoint accepts the request and returns either path. + const regSocialRes = post('/api/v1/auth/register/social/start', { + tenant_slug: cfg.tenantSlug, + invite_code: cfg.inviteCode, + provider: 'google', + accept_terms_version: '2025-01-01', + language: 'zh-TW', + redirect_uri: 'http://localhost:8888/api/v1/auth/register/social/callback', + }); + checkErrorOneOf(regSocialRes, 'POST /auth/register/social/start', [ + [200, 102000], + [502, 28802000], + ]); + + // 6. login/social/start — same dual-outcome reason. + const loginSocialRes = post('/api/v1/auth/login/social/start', { + tenant_slug: cfg.tenantSlug, + provider: 'google', + redirect_uri: 'http://localhost:8888/api/v1/auth/login/social/callback', + }); + checkErrorOneOf(loginSocialRes, 'POST /auth/login/social/start', [ + [200, 102000], + [502, 28802000], + ]); + + // 7. register/social/callback negative — invalid state + const cbReg = get('/api/v1/auth/register/social/callback?code=fake&state=invalid'); + // 400 with 28101000 (oauth state invalid) + checkError(cbReg, 'GET /auth/register/social/callback (invalid state)', 400, 28101000); + + // 8. login/social/callback negative — invalid state + const cbLogin = get('/api/v1/auth/login/social/callback?code=fake&state=invalid'); + checkError(cbLogin, 'GET /auth/login/social/callback (invalid state)', 400, 28101000); +} diff --git a/test/k6/smoke/auth_register_resume.js b/test/k6/smoke/auth_register_resume.js new file mode 100644 index 0000000..033acf9 --- /dev/null +++ b/test/k6/smoke/auth_register_resume.js @@ -0,0 +1,103 @@ +// smoke: POST /api/v1/auth/register/resume +// +// Covers: +// happy — 未完成註冊帳號重寄 registration OTP +// 404 — member 不存在(28301000) +// 404 — tenant 不存在(29301000) +// 409 — 帳號已驗證(28309000) +// 400 — 缺少 email +// 429 — OTP 重送冷卻(29604000) +import { sleep } from 'k6'; +import { post, checkError } from '../lib/http.js'; +import { cfg } from '../lib/config.js'; +import { + makeIdentity, + registerEmail, + registerAndConfirm, + registerResume, +} from '../lib/auth.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + // happy: register 但不 confirm → 等冷卻後 resume 取得新 challenge + const pending = makeIdentity('resume-happy'); + const reg = registerEmail({ + email: pending.email, + password: pending.password, + displayName: pending.displayName, + }); + sleep(cfg.resendCooldownSeconds + 1); + const resumed = registerResume({ email: pending.email }); + if (!resumed.challenge_id) { + throw new Error('register/resume happy: missing challenge_id'); + } + if (resumed.uid !== reg.uid) { + throw new Error(`register/resume happy: uid mismatch ${resumed.uid} vs ${reg.uid}`); + } + + // 404 member not found + checkError( + post('/api/v1/auth/register/resume', { + tenant_slug: cfg.tenantSlug, + email: `no-such-${Date.now()}@k6.local`, + }), + 'POST /auth/register/resume (member not found)', + 404, + 28301000, + ); + + // 404 tenant not found + checkError( + post('/api/v1/auth/register/resume', { + tenant_slug: 'no-such-tenant-slug', + email: pending.email, + }), + 'POST /auth/register/resume (tenant not found)', + 404, + 29301000, + ); + + // 409 already verified + const verified = registerAndConfirm(); + checkError( + post('/api/v1/auth/register/resume', { + tenant_slug: cfg.tenantSlug, + email: verified.identity.email, + }), + 'POST /auth/register/resume (already verified)', + 409, + 28309000, + ); + + // 400 missing email + const missing = post('/api/v1/auth/register/resume', { tenant_slug: cfg.tenantSlug }); + if (missing.status !== 400) { + throw new Error(`register/resume missing email: expected 400 got ${missing.status}`); + } + + // 429 resend cooldown — 連續兩次 resume 同一 unverified 帳號 + const cooldownUser = makeIdentity('resume-cooldown'); + registerEmail({ + email: cooldownUser.email, + password: cooldownUser.password, + displayName: cooldownUser.displayName, + }); + sleep(cfg.resendCooldownSeconds + 1); + registerResume({ email: cooldownUser.email }); + checkError( + post('/api/v1/auth/register/resume', { + tenant_slug: cfg.tenantSlug, + email: cooldownUser.email, + }), + 'POST /auth/register/resume (resend cooldown)', + 429, + 29604000, + ); + sleep(cfg.resendCooldownSeconds + 1); + registerResume({ email: cooldownUser.email }); +} diff --git a/test/k6/smoke/health.js b/test/k6/smoke/health.js new file mode 100644 index 0000000..64b686e --- /dev/null +++ b/test/k6/smoke/health.js @@ -0,0 +1,21 @@ +// smoke: GET /api/v1/health +// Covers: normal.PingData health endpoint. +import { get, checkEnvelope } from '../lib/http.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { + checks: ['rate==1.0'], + http_req_failed: ['rate==0.0'], + http_req_duration: ['p(95)<500'], + }, +}; + +export default function () { + const res = get('/api/v1/health'); + const body = checkEnvelope(res, 'GET /api/v1/health'); + if (!body || !body.data || typeof body.data.pong !== 'string') { + throw new Error(`unexpected payload: ${res.body}`); + } +} diff --git a/test/k6/smoke/member.js b/test/k6/smoke/member.js new file mode 100644 index 0000000..1a50da1 --- /dev/null +++ b/test/k6/smoke/member.js @@ -0,0 +1,142 @@ +// smoke: member endpoints (Bearer) +// +// Covers (14 endpoints + change password negatives): +// GET /api/v1/members/me +// PATCH /api/v1/members/me +// POST /api/v1/members/me/verifications/email/start +// POST /api/v1/members/me/verifications/email/confirm (negative: invalid code) +// POST /api/v1/members/me/verifications/phone/start +// POST /api/v1/members/me/verifications/phone/confirm (negative: invalid code) +// GET /api/v1/members/me/totp (status before enroll) +// POST /api/v1/members/me/totp/enroll-start +// POST /api/v1/members/me/totp/enroll-confirm (negative: invalid code) +// POST /api/v1/members/me/totp/verify (negative: not enrolled) +// POST /api/v1/members/me/totp/backup-codes (negative: not enrolled) +// POST /api/v1/members/me/password (negative: wrong current / no bearer / weak) +// +// Happy paths for TOTP and verification end-to-end live in journeys/. +import { get, post, patch, del, checkEnvelope, checkError } from '../lib/http.js'; +import { registerAndConfirm } from '../lib/auth.js'; +import { unique } from '../lib/config.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const { identity, tokens } = registerAndConfirm(); + const bearer = { Authorization: `Bearer ${tokens.access_token}` }; + + // 1. GET /me + const me = checkEnvelope(get('/api/v1/members/me', bearer), 'GET /members/me').data; + if (me.uid !== tokens.uid) throw new Error('me.uid mismatch'); + + // 2. PATCH /me + const newName = `smoke-${unique('m')}`; + const patched = checkEnvelope(patch('/api/v1/members/me', { display_name: newName }, bearer), 'PATCH /members/me').data; + if (patched.display_name !== newName) throw new Error('display_name not updated'); + + // 3. POST /me/verifications/email/start + const eStart = checkEnvelope( + post('/api/v1/members/me/verifications/email/start', { target: `verify-${unique('e')}@k6.local` }, bearer), + 'POST /me/verifications/email/start', + ).data; + if (!eStart.challenge_id) throw new Error('email start: missing challenge_id'); + + // 4. POST /me/verifications/email/confirm (negative — wrong code 000000) + checkError( + post('/api/v1/members/me/verifications/email/confirm', { challenge_id: eStart.challenge_id, code: '000000' }, bearer), + 'POST /me/verifications/email/confirm (bad code)', + 403, + 29505000, + ); + + // 5. POST /me/verifications/phone/start + const pStart = checkEnvelope( + post('/api/v1/members/me/verifications/phone/start', { target: '+886912000001' }, bearer), + 'POST /me/verifications/phone/start', + ).data; + if (!pStart.challenge_id) throw new Error('phone start: missing challenge_id'); + + // 6. POST /me/verifications/phone/confirm (negative) + checkError( + post('/api/v1/members/me/verifications/phone/confirm', { challenge_id: pStart.challenge_id, code: '000000' }, bearer), + 'POST /me/verifications/phone/confirm (bad code)', + 403, + 29505000, + ); + + // 7. GET /me/totp (status — not enrolled) + const totpStatus = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp').data; + if (totpStatus.enrolled !== false) throw new Error('totp should not be enrolled'); + + // 8. POST /me/totp/enroll-start (happy) + const enroll = checkEnvelope(post('/api/v1/members/me/totp/enroll-start', null, bearer), 'POST /me/totp/enroll-start').data; + if (!enroll.otpauth_url) throw new Error('enroll-start: missing otpauth_url'); + + // 9. POST /me/totp/enroll-confirm (negative — bad code) + checkError( + post('/api/v1/members/me/totp/enroll-confirm', { code: '000000' }, bearer), + 'POST /me/totp/enroll-confirm (bad code)', + 403, + 29505000, + ); + + // 10. POST /me/totp/verify (negative — not enrolled) + checkError( + post('/api/v1/members/me/totp/verify', { code: '000000' }, bearer), + 'POST /me/totp/verify (not enrolled)', + 409, + 29309000, + ); + + // 11. POST /me/totp/backup-codes (negative — not enrolled) + checkError( + post('/api/v1/members/me/totp/backup-codes', null, bearer), + 'POST /me/totp/backup-codes (not enrolled)', + 409, + 29309000, + ); + + // 12. DELETE /me/totp — when not enrolled the contract returns 200 (idempotent disable). + // Accept either 200 success envelope or 404 (member-not-found edge case). + const delRes = del('/api/v1/members/me/totp', null, bearer); + if (delRes.status !== 200 && delRes.status !== 404) { + throw new Error(`DELETE /me/totp unexpected status ${delRes.status}: ${delRes.body}`); + } + + // 13. POST /me/password (negative — wrong current password) + checkError( + post( + '/api/v1/members/me/password', + { current_password: 'WrongPass-1!', new_password: 'K6-NewPass-2!' }, + bearer, + ), + 'POST /me/password (wrong current)', + 401, + 29501000, + ); + + // 14. POST /me/password (negative — no bearer) + checkError( + post('/api/v1/members/me/password', { + current_password: identity.password, + new_password: 'K6-NewPass-2!', + }), + 'POST /me/password (no bearer)', + 401, + 29501000, + ); + + // 15. POST /me/password (negative — weak new password) + const weakPwd = post( + '/api/v1/members/me/password', + { current_password: identity.password, new_password: 'short' }, + bearer, + ); + if (weakPwd.status !== 400) { + throw new Error(`POST /me/password weak password: expected 400 got ${weakPwd.status}`); + } +} diff --git a/test/k6/smoke/permission_admin.js b/test/k6/smoke/permission_admin.js new file mode 100644 index 0000000..9736305 --- /dev/null +++ b/test/k6/smoke/permission_admin.js @@ -0,0 +1,70 @@ +// smoke: permission admin endpoints (Bearer + Casbin RBAC required) +// +// Goal: verify each route is wired and rejects a non-admin caller. Full happy- +// path admin testing lives in journeys/rbac_admin.js (requires seeded admin). +// +// Covers (12 endpoints, mostly negative since the test user has no admin role): +// GET /api/v1/permissions/roles +// POST /api/v1/permissions/roles +// PATCH /api/v1/permissions/roles/:id +// DELETE /api/v1/permissions/roles/:id +// GET /api/v1/permissions/roles/:id/permissions +// PUT /api/v1/permissions/roles/:id/permissions +// GET /api/v1/permissions/users/:uid/roles +// POST /api/v1/permissions/users/:uid/roles +// DELETE /api/v1/permissions/users/:uid/roles/:role_id +// GET /api/v1/permissions/role-mappings +// PUT /api/v1/permissions/role-mappings +// DELETE /api/v1/permissions/role-mappings +// POST /api/v1/permissions/policy/reload +// +// Each call is expected to return 403 (RBAC) — we just need to confirm the +// status is non-2xx and the route exists (no 404). +import { get, post, put, patch, del } from '../lib/http.js'; +import { check } from 'k6'; +import { registerAndConfirm } from '../lib/auth.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +function assertForbidden(res, label) { + // Casbin reject → 403 (31507000 forbidden). We tolerate 401/403 here since + // the exact code may vary across edge cases (missing role vs RBAC denied). + const ok = check(res, { + [`${label}: route exists (not 404)`]: (r) => r.status !== 404, + [`${label}: non-2xx (RBAC blocks)`]: (r) => r.status >= 400 && r.status < 500, + }); + if (!ok) { + throw new Error(`${label}: unexpected status ${res.status} body=${res.body}`); + } +} + +export default function () { + const { tokens } = registerAndConfirm(); + const bearer = { Authorization: `Bearer ${tokens.access_token}` }; + const fakeID = '000000000000000000000000'; + + assertForbidden(get('/api/v1/permissions/roles', bearer), 'GET /roles'); + assertForbidden(post('/api/v1/permissions/roles', { key: 'smoke_role' }, bearer), 'POST /roles'); + assertForbidden(patch(`/api/v1/permissions/roles/${fakeID}`, { display_name: 'x' }, bearer), 'PATCH /roles/:id'); + assertForbidden(del(`/api/v1/permissions/roles/${fakeID}`, null, bearer), 'DELETE /roles/:id'); + assertForbidden(get(`/api/v1/permissions/roles/${fakeID}/permissions`, bearer), 'GET /roles/:id/permissions'); + assertForbidden(put(`/api/v1/permissions/roles/${fakeID}/permissions`, { permission_ids: [] }, bearer), 'PUT /roles/:id/permissions'); + assertForbidden(get(`/api/v1/permissions/users/${tokens.uid}/roles`, bearer), 'GET /users/:uid/roles'); + assertForbidden(post(`/api/v1/permissions/users/${tokens.uid}/roles`, { role_id: fakeID }, bearer), 'POST /users/:uid/roles'); + assertForbidden(del(`/api/v1/permissions/users/${tokens.uid}/roles/${fakeID}`, null, bearer), 'DELETE /users/:uid/roles/:role_id'); + assertForbidden(get('/api/v1/permissions/role-mappings', bearer), 'GET /role-mappings'); + assertForbidden(put('/api/v1/permissions/role-mappings', { + external_source: 'zitadel', + external_key: 'smoke', + internal_role_key: 'platform_admin', + }, bearer), 'PUT /role-mappings'); + assertForbidden(del('/api/v1/permissions/role-mappings', { + external_source: 'zitadel', + external_key: 'smoke', + }, bearer), 'DELETE /role-mappings'); + assertForbidden(post('/api/v1/permissions/policy/reload', {}, bearer), 'POST /policy/reload'); +} diff --git a/test/k6/smoke/permission_read.js b/test/k6/smoke/permission_read.js new file mode 100644 index 0000000..22cb086 --- /dev/null +++ b/test/k6/smoke/permission_read.js @@ -0,0 +1,43 @@ +// smoke: permission read endpoints (Bearer, no RBAC required) +// +// Covers: +// GET /api/v1/permissions/catalog (?tree=true and flat) +// GET /api/v1/permissions/me (regular user → empty roles ok) +// GET /api/v1/permissions/me?include_tree=true +import { get, checkEnvelope } from '../lib/http.js'; +import { registerAndConfirm } from '../lib/auth.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { checks: ['rate==1.0'] }, +}; + +export default function () { + const { tokens } = registerAndConfirm(); + const bearer = { Authorization: `Bearer ${tokens.access_token}` }; + + // GET /catalog (flat). When the catalog is empty (no perms seeded into + // gateway_k6) the Go struct uses `omitempty`, so both list and tree are + // legitimately stripped from the response; the envelope success is enough + // to prove the endpoint and auth chain work. When perms ARE seeded + // (after k6-seed-admin), .list is a non-empty array. + const flat = checkEnvelope(get('/api/v1/permissions/catalog', bearer), 'GET /permissions/catalog').data; + if (flat && flat.list !== undefined && !Array.isArray(flat.list)) { + throw new Error(`catalog: .list is not an array: ${JSON.stringify(flat)}`); + } + + // GET /catalog?tree=true (same envelope-only assertion). + const tree = checkEnvelope(get('/api/v1/permissions/catalog?tree=true', bearer), 'GET /permissions/catalog?tree=true').data; + if (tree && tree.tree !== undefined && !Array.isArray(tree.tree)) { + throw new Error(`catalog tree: .tree is not an array: ${JSON.stringify(tree)}`); + } + + // GET /me + const me = checkEnvelope(get('/api/v1/permissions/me', bearer), 'GET /permissions/me').data; + if (me.uid !== tokens.uid) throw new Error('me.uid mismatch'); + if (!Array.isArray(me.roles)) throw new Error('me.roles is not array'); + + // GET /me?include_tree=true + checkEnvelope(get('/api/v1/permissions/me?include_tree=true', bearer), 'GET /permissions/me?include_tree=true'); +}