Merge pull request 'feat/env' (#1) from feat/env into main
Reviewed-on: #1
This commit is contained in:
commit
93e94e8c5d
|
|
@ -15,6 +15,9 @@ coverage.html
|
||||||
# 專案編譯產物(根目錄 binary 名稱與 module 相同時)
|
# 專案編譯產物(根目錄 binary 名稱與 module 相同時)
|
||||||
/gateway
|
/gateway
|
||||||
|
|
||||||
|
# k6 / dev binary 產物
|
||||||
|
/bin/
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# go-doc / 工具 binary
|
# go-doc / 工具 binary
|
||||||
# =========================
|
# =========================
|
||||||
|
|
@ -76,4 +79,5 @@ temp/
|
||||||
# E2E 產物(make e2e-full / e2e-up 生成)
|
# E2E 產物(make e2e-full / e2e-up 生成)
|
||||||
test/e2e/fixtures/state.json
|
test/e2e/fixtures/state.json
|
||||||
test/e2e/fixtures/gateway.pid
|
test/e2e/fixtures/gateway.pid
|
||||||
|
.dev/
|
||||||
.cache/
|
.cache/
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,8 @@ linters:
|
||||||
paths:
|
paths:
|
||||||
- generate/doc-generate
|
- generate/doc-generate
|
||||||
- docs/openapi
|
- docs/openapi
|
||||||
|
- frontend
|
||||||
|
- cmd/k6-seed-admin
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
|
|
@ -142,6 +144,8 @@ formatters:
|
||||||
generated: lax
|
generated: lax
|
||||||
paths:
|
paths:
|
||||||
- generate/doc-generate
|
- generate/doc-generate
|
||||||
|
- frontend
|
||||||
|
- cmd/k6-seed-admin
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|
|
||||||
112
AGENTS.md
112
AGENTS.md
|
|
@ -1,97 +1,39 @@
|
||||||
# AGENTS.md
|
# 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/<module>/`,內部分 `domain`(介面 + enum + errors) / `repository`(Mongo / Redis 實作) / `usecase`(原子業務邏輯) / `config`。
|
## 一分鐘快速理解
|
||||||
|
|
||||||
跨模組編排(例如「發 OTP → 寄信 → 驗碼 → 更新 profile」)一律放在 `internal/logic/<module>/`,**usecase 不可呼叫其他 usecase**。
|
- **業務模組** 一律放 `internal/model/<module>/`,內部分 `domain` / `repository` / `usecase` / `config`。
|
||||||
|
- **跨模組編排**(多 usecase 串接)放 `internal/logic/<module>/`;**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 列舉前 |
|
| [docs/AGENTS.md](docs/AGENTS.md) | Agent 工作準則總覽(必讀) |
|
||||||
| [`generate/doc-generate/README.md`](generate/doc-generate/README.md) | 需要查 go-doc 支援的 tag / `@respdoc` 寫法時 |
|
| [README.md](README.md) | 專案總覽、開發約定、HTTP / 錯誤格式 |
|
||||||
| `internal/model/<module>/README.md` | 動到該模組的領域邏輯時 |
|
| [docs/model.md](docs/model.md) | `internal/model/{module}` 分層規範 |
|
||||||
| `docs/model.md`(若存在) | 全局架構規範 |
|
| [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) | 統一註冊/登入完整時序 |
|
||||||
### 1. 修改 API(`.api` → handler / types / docs)
|
| [docs/e2e-testing.md](docs/e2e-testing.md) | E2E 測試流程 |
|
||||||
|
|
||||||
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/<module>/<handler>_logic.go` 的業務邏輯
|
|
||||||
5. `go build ./...` 確保編譯通過
|
|
||||||
6. `make lint` / `make test` 視改動範圍跑
|
|
||||||
|
|
||||||
### 2. 新增 / 修改業務模組
|
|
||||||
|
|
||||||
- 領域介面與型別放 `internal/model/<module>/domain/`
|
|
||||||
- Mongo / Redis 實作放 `internal/model/<module>/repository/`
|
|
||||||
- 原子 usecase 放 `internal/model/<module>/usecase/`(**不可**互相呼叫)
|
|
||||||
- 多步驟流程編排放 `internal/logic/<module>/`
|
|
||||||
- 模組的對外裝配入口統一在 `internal/model/<module>/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` 加 `<Name> rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc.<Name> = middleware.New<Name>Middleware(...).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/<module>/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()` 裡呼叫 `<module>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 用繁中描述「為什麼」改,不是「改了什麼」
|
|
||||||
|
|
||||||
## 指令速查
|
## 指令速查
|
||||||
|
|
||||||
| 指令 | 用途 |
|
```bash
|
||||||
|---|---|
|
make gen-api # .api → handler / logic(skip exists) / types
|
||||||
| `make gen-api` | `.api` → handler / logic(skip exists)/ types |
|
make gen-doc # .api → docs/openapi/gateway.yaml
|
||||||
| `make gen-doc` | `.api` → `docs/openapi/gateway.yaml` |
|
make fix # gofmt + goimports + lint --fix + lint
|
||||||
| `make gen-mock` | 模組 mock(gomock) |
|
make check # fix + test(提交前必跑)
|
||||||
| `make tools` | 安裝 goctl / goimports / golangci-lint |
|
make run-dev # 本機啟動(需 make deps-up)
|
||||||
| `make fix` | gofmt + goimports + lint --fix + lint |
|
make deps-up # docker compose Mongo + Redis
|
||||||
| `make check` | fix + test(提交前) |
|
```
|
||||||
| `make run-dev` | 本機啟動(需 `make deps-up`) |
|
|
||||||
| `make deps-up` | docker compose Mongo + Redis |
|
|
||||||
| `make mongo-index` | 建立 / 更新 Mongo 索引 |
|
|
||||||
|
|
||||||
完整列表跑 `make help`。
|
完整列表:`make help`。
|
||||||
|
|
|
||||||
|
|
@ -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」這種顯而易見的描述。
|
||||||
303
Makefile
303
Makefile
|
|
@ -15,9 +15,6 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.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: ## 顯示可用指令
|
help: ## 顯示可用指令
|
||||||
@echo "Gateway Makefile"
|
@echo "Gateway Makefile"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
@ -57,38 +54,6 @@ gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML
|
||||||
|
|
||||||
test: ## 執行測試
|
test: ## 執行測試
|
||||||
$(GO) 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)
|
fmt: ## gofmt + goimports(不含 lint)
|
||||||
$(GOFMT) -s -w $(GOFILES)
|
$(GOFMT) -s -w $(GOFILES)
|
||||||
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)
|
@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)
|
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 追蹤)
|
DOCKER_COMPOSE ?= docker compose
|
||||||
@test -f etc/gateway.dev.yaml || cp etc/gateway.dev.example.yaml etc/gateway.dev.yaml
|
COMPOSE_FILE := deploy/docker-compose.yml
|
||||||
@echo ">> etc/gateway.dev.yaml ready (edit locally; not committed)"
|
COMPOSE := $(DOCKER_COMPOSE) -f $(COMPOSE_FILE)
|
||||||
|
|
||||||
run-dev: setup-dev ## 啟動 Gateway(etc/gateway.dev.yaml,需 make deps-up)
|
deps-up: ## 起 Mongo + Redis(最小本機依賴)
|
||||||
$(GO) run gateway.go -f etc/gateway.dev.yaml
|
$(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)
|
deps-down: ## 停服務(保留 volume)
|
||||||
docker compose up -d mongo redis
|
$(COMPOSE) --profile smtp --profile k6 down
|
||||||
|
|
||||||
deps-up-smtp: ## 啟動 Mongo + Redis + MailHog(本機 SMTP 測試)
|
deps-down-v: ## 停服務並刪 volume(會清資料)
|
||||||
docker compose --profile smtp up -d mongo redis mailhog
|
$(COMPOSE) --profile smtp --profile k6 down -v
|
||||||
|
|
||||||
deps-down: ## 停止 docker compose 容器(保留 volume)
|
deps-logs: ## 看 compose log
|
||||||
docker compose --profile smtp down
|
$(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
|
K6 ?= k6
|
||||||
docker compose --profile smtp logs -f
|
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: ## 查看依賴服務狀態
|
# k6 安裝指引(macOS / Linux)
|
||||||
docker compose --profile smtp ps
|
define K6_INSTALL_HINT
|
||||||
|
k6 not found in PATH.
|
||||||
|
|
||||||
mongo-index: ## 建立 notification Mongo 索引(需 Mongo 已啟動)
|
Install:
|
||||||
$(GO) run ./cmd/mongo-index -f etc/gateway.dev.yaml
|
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)
|
Or use docker (no install): K6='docker run --rm -i --network host -v $$PWD:/app -w /app grafana/k6:latest' make k6-smoke
|
||||||
@test -n "$(METHOD)" || (echo "usage: make notify-test METHOD=email-send TO=you@example.com" && \
|
endef
|
||||||
echo " make notify-test METHOD=sms-send PHONE=0912345678" && \
|
export K6_INSTALL_HINT
|
||||||
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,)
|
|
||||||
|
|
||||||
totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證(Google Authenticator;需 Redis)
|
k6-check: ## 檢查 k6 是否安裝(沒裝會印 install 指引)
|
||||||
$(GO) run ./cmd/totp-test -f etc/gateway.dev.yaml \
|
@command -v $(K6) >/dev/null 2>&1 || (echo "$$K6_INSTALL_HINT"; exit 1)
|
||||||
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(UID),-uid "$(UID)",) \
|
@echo "k6: $$($(K6) version 2>&1 | head -1)"
|
||||||
$(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \
|
|
||||||
$(if $(CODE),-code "$(CODE)",)
|
|
||||||
|
|
||||||
member-seed: setup-dev ## 建立 dev tenant + member(需 Mongo+Redis)
|
k6-up: ## 起 k6 全棧(mongo + redis + mailhog + postgres + zitadel)
|
||||||
$(GO) run ./cmd/member-seed -f etc/gateway.dev.yaml \
|
$(COMPOSE) --profile k6 up -d mongo redis mailhog postgres zitadel
|
||||||
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(EMAIL),-email "$(EMAIL)",)
|
@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 可載入
|
k6-wait: ## 等 ZITADEL ready + 把 PAT 寫到 deploy/zitadel/machinekey/k6.env
|
||||||
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v
|
@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
|
||||||
|
|
@ -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 文件生成。
|
基於 [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 run-dev` | 啟動 Gateway(`etc/gateway.dev.yaml`,需 Docker) |
|
||||||
| `make config-check` | 驗證 yaml 可載入 |
|
| `make config-check` | 驗證 yaml 可載入 |
|
||||||
| `make mongo-index` | 建立 notification Mongo 索引 |
|
| `make mongo-index` | 建立 notification Mongo 索引 |
|
||||||
|
| `make dev-up` | **一鍵**本機全套(Docker + ZITADEL + MailHog + Gateway) |
|
||||||
|
| `make frontend-dev` | 啟動 React 前台 + 管理後台(`frontend/`,:5173) |
|
||||||
|
| `make dev-down` | 關閉 `dev-up` 啟動的一切 |
|
||||||
|
|
||||||
## 專案結構
|
## 專案結構
|
||||||
|
|
||||||
```
|
```
|
||||||
gateway/
|
gateway/
|
||||||
|
├── frontend/ # Vite + React 使用者前台與管理後台
|
||||||
├── gateway.go # 程式入口
|
├── gateway.go # 程式入口
|
||||||
├── etc/gateway.yaml # 服務設定(埠號等)
|
├── etc/gateway.yaml # 服務設定(埠號等)
|
||||||
├── generate/
|
├── generate/
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 <name> [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"}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# ZITADEL bootstrap outputs(PAT / machine key)— 不入 git
|
||||||
|
machinekey/zitadel-admin-sa.token
|
||||||
|
machinekey/zitadel-admin-sa.json
|
||||||
|
|
@ -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 值,**只能**本機用。
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
|
||||||
|
|
@ -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/<module>/`,內部分 `domain`(介面 + enum + errors) / `repository`(Mongo / Redis 實作) / `usecase`(原子業務邏輯) / `config`。
|
||||||
|
|
||||||
|
跨模組編排(例如「發 OTP → 寄信 → 驗碼 → 更新 profile」)一律放在 `internal/logic/<module>/`,**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/<module>/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/<module>/<handler>_logic.go` 的業務邏輯
|
||||||
|
5. `go build ./...` 確保編譯通過
|
||||||
|
6. `make lint` / `make test` 視改動範圍跑
|
||||||
|
|
||||||
|
### 2. 新增 / 修改業務模組
|
||||||
|
|
||||||
|
- 領域介面與型別放 `internal/model/<module>/domain/`
|
||||||
|
- Mongo / Redis 實作放 `internal/model/<module>/repository/`
|
||||||
|
- 原子 usecase 放 `internal/model/<module>/usecase/`(**不可**互相呼叫)
|
||||||
|
- 多步驟流程編排放 `internal/logic/<module>/`
|
||||||
|
- 模組的對外裝配入口統一在 `internal/model/<module>/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` 加 `<Name> rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc.<Name> = middleware.New<Name>Middleware(...).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/<module>/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()` 裡呼叫 `<module>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`。
|
||||||
|
|
@ -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:<phone>)
|
||||||
|
# - 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
|
||||||
|
|
@ -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?
|
||||||
|
|
@ -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/
|
||||||
|
```
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Gateway API 控制台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 <p className="loading">載入中…</p>;
|
||||||
|
return <Navigate to={token ? '/app' : '/login'} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/register/confirm" element={<ConfirmPage />} />
|
||||||
|
<Route path="/password/forgot" element={<ForgotPasswordPage />} />
|
||||||
|
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route element={<UserLayout />}>
|
||||||
|
<Route path="/app" element={<HomePage />} />
|
||||||
|
<Route path="/app/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/app/security" element={<SecurityPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route element={<AdminRoute />}>
|
||||||
|
<Route element={<AdminLayout />}>
|
||||||
|
<Route path="/admin" element={<AdminHomePage />} />
|
||||||
|
<Route path="/admin/roles" element={<RolesPage />} />
|
||||||
|
<Route path="/admin/role-permissions" element={<RolePermissionsPage />} />
|
||||||
|
<Route path="/admin/users" element={<UserRolesPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/" element={<RootRedirect />} />
|
||||||
|
<Route path="*" element={<RootRedirect />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -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<AuthTokenData> {
|
||||||
|
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<LoginData>('/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<AuthTokenData>('/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<RegisterData>('/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<AuthTokenData>('/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<RegisterData>('/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<RegisterData>('/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<PasswordChallengeData>('/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<AuthTokenData>('/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;
|
||||||
|
}
|
||||||
|
|
@ -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<T = unknown> {
|
||||||
|
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<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit & { auth?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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<T> | null = null;
|
||||||
|
try {
|
||||||
|
json = text ? (JSON.parse(text) as Envelope<T>) : 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;
|
||||||
|
}
|
||||||
|
|
@ -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<MemberMe>('/api/v1/members/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMe(body: {
|
||||||
|
display_name?: string;
|
||||||
|
language?: string;
|
||||||
|
currency?: string;
|
||||||
|
phone?: string;
|
||||||
|
}) {
|
||||||
|
return api<MemberMe>('/api/v1/members/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startEmailVerification(target: string) {
|
||||||
|
return api<VerificationStart>('/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<VerificationStart>('/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<TOTPStatus>('/api/v1/members/me/totp');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startTOTPEnroll() {
|
||||||
|
return api<TOTPEnrollStart>('/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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface MePermissions {
|
||||||
|
uid: string;
|
||||||
|
tenant_id: string;
|
||||||
|
roles: string[];
|
||||||
|
permissions: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MePermissions>('/api/v1/permissions/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRoles() {
|
||||||
|
return api<RoleList>('/api/v1/permissions/roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRole(key: string, displayName: string) {
|
||||||
|
return api<Role>('/api/v1/permissions/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ key, display_name: displayName, status: 'open' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRole(id: string, displayName: string) {
|
||||||
|
return api<Role>(`/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<UserRoleList>(`/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<PermissionCatalog>(
|
||||||
|
`/api/v1/permissions/catalog${qs ? `?${qs}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRolePermissions(roleId: string) {
|
||||||
|
return api<RolePermissions>(`/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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -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 <p className="loading">載入中…</p>;
|
||||||
|
if (!token) return <Navigate to="/login" replace />;
|
||||||
|
if (!isAdmin) return <Navigate to="/app" replace />;
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
@ -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 <p className="loading">載入中…</p>;
|
||||||
|
if (!token) return <Navigate to="/login" replace />;
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="totp-qr-panel">
|
||||||
|
{dataUrl ? (
|
||||||
|
<img src={dataUrl} alt="TOTP QR Code" width={220} height={220} />
|
||||||
|
) : err ? (
|
||||||
|
<p className="form-error">{err}</p>
|
||||||
|
) : (
|
||||||
|
<p className="hint">產生 QR Code 中…</p>
|
||||||
|
)}
|
||||||
|
<div className="totp-qr-meta">
|
||||||
|
{issuer && (
|
||||||
|
<p>
|
||||||
|
<span className="muted">Issuer</span> {issuer}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{account && (
|
||||||
|
<p>
|
||||||
|
<span className="muted">帳號</span> {account}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="hint">
|
||||||
|
用 Google Authenticator、1Password 等 App 掃描 QR,或手動輸入密鑰(進階設定)。
|
||||||
|
</p>
|
||||||
|
<details className="totp-manual">
|
||||||
|
<summary>無法掃描?顯示 otpauth 連結</summary>
|
||||||
|
<code className="otpauth-url">{otpauthUrl}</code>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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<void>;
|
||||||
|
signOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(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<string[]>(() => {
|
||||||
|
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth 需在 AuthProvider 內');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
/* Global reset — layout in App.css */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="shell admin-shell">
|
||||||
|
<aside className="admin-sidebar">
|
||||||
|
<div className="brand">管理後台</div>
|
||||||
|
<nav className="admin-nav">
|
||||||
|
<Link to="/admin">總覽</Link>
|
||||||
|
<Link to="/admin/roles">角色管理</Link>
|
||||||
|
<Link to="/admin/role-permissions">角色權限</Link>
|
||||||
|
<Link to="/admin/users">使用者角色</Link>
|
||||||
|
</nav>
|
||||||
|
<Link to="/app" className="back-link">
|
||||||
|
← 回使用者前台
|
||||||
|
</Link>
|
||||||
|
<button type="button" className="btn-ghost" onClick={handleLogout}>
|
||||||
|
登出
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
<main className="page admin-page">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<Link to="/app" className="brand">
|
||||||
|
Gateway
|
||||||
|
</Link>
|
||||||
|
<nav className="nav">
|
||||||
|
<Link to="/app">首頁</Link>
|
||||||
|
<Link to="/app/profile">個人資料</Link>
|
||||||
|
<Link to="/app/security">安全設定</Link>
|
||||||
|
{isAdmin && <Link to="/admin">管理後台</Link>}
|
||||||
|
</nav>
|
||||||
|
<div className="topbar-right">
|
||||||
|
<span className="uid">{uid}</span>
|
||||||
|
<button type="button" className="btn-ghost" onClick={handleLogout}>
|
||||||
|
登出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="page">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1>管理後台</h1>
|
||||||
|
<p className="hint">以 tenant_admin / tenant_owner 角色登入後可使用。</p>
|
||||||
|
<div className="cards">
|
||||||
|
<div className="card">
|
||||||
|
<h3>目前管理員</h3>
|
||||||
|
<p>UID: {uid}</p>
|
||||||
|
<ul className="tag-list">
|
||||||
|
{roles.map((r) => (
|
||||||
|
<li key={r} className="tag">
|
||||||
|
{r}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>權限管理</h3>
|
||||||
|
<ul className="admin-links">
|
||||||
|
<li>
|
||||||
|
<Link to="/admin/roles">角色管理</Link> — 新增 / 改名 / 刪除角色
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/admin/role-permissions">角色權限</Link> — 勾選 API
|
||||||
|
權限
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/admin/users">使用者角色</Link> — 指派 tenant_admin 等
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>系統維護</h3>
|
||||||
|
<p className="hint">角色或權限變更後,可手動觸發 policy 重載。</p>
|
||||||
|
<button type="button" className="btn-primary" onClick={reloadPolicy}>
|
||||||
|
重載 Casbin Policy
|
||||||
|
</button>
|
||||||
|
{msg && <p className="form-ok">{msg}</p>}
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<string, PermissionNode[]>();
|
||||||
|
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<Role[]>([]);
|
||||||
|
const [roleId, setRoleId] = useState(presetRole);
|
||||||
|
const [catalog, setCatalog] = useState<PermissionNode[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(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 (
|
||||||
|
<div>
|
||||||
|
<h1>角色權限設定</h1>
|
||||||
|
<p className="hint">
|
||||||
|
為自訂角色勾選 API 權限。儲存後會自動重載 policy。系統內建角色(tenant_admin
|
||||||
|
等)權限由種子資料定義,建議只調整非系統角色。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
{msg && <p className="form-ok">{msg}</p>}
|
||||||
|
|
||||||
|
<div className="form-inline-block admin-toolbar">
|
||||||
|
<label>
|
||||||
|
選擇角色
|
||||||
|
<select value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||||
|
<option value="">— 請選擇 —</option>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<option key={r.id} value={r.id} disabled={r.is_system}>
|
||||||
|
{r.display_name} ({r.key}){r.is_system ? ' · 系統' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{roleId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={save}
|
||||||
|
disabled={loading || selectedRole?.is_system}
|
||||||
|
>
|
||||||
|
{loading ? '儲存中…' : '儲存權限'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRole?.is_system && (
|
||||||
|
<p className="hint">系統角色不建議在此修改;請建立自訂角色(如 support)再指派權限。</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{roleId && !selectedRole?.is_system && (
|
||||||
|
<div className="perm-grid">
|
||||||
|
{groups.map(([parent, items]) => (
|
||||||
|
<section key={parent} className="perm-group">
|
||||||
|
<h3>
|
||||||
|
<code>{parent}</code>
|
||||||
|
</h3>
|
||||||
|
<ul className="perm-check-list">
|
||||||
|
{items.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(p.id)}
|
||||||
|
onChange={() => toggle(p.id)}
|
||||||
|
/>
|
||||||
|
<span className="perm-label">
|
||||||
|
<strong>{p.name}</strong>
|
||||||
|
{p.http_methods && p.http_path && (
|
||||||
|
<span className="perm-api">
|
||||||
|
{p.http_methods} {p.http_path}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<Role[]>([]);
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1>角色管理</h1>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
|
||||||
|
<form onSubmit={create} className="form form-inline-block">
|
||||||
|
<input
|
||||||
|
placeholder="角色 key(例:support)"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="顯示名稱"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
新增角色
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>顯示名稱</th>
|
||||||
|
<th>系統</th>
|
||||||
|
<th>狀態</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>
|
||||||
|
<code>{r.key}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="table-input"
|
||||||
|
defaultValue={r.display_name}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (e.target.value !== r.display_name) {
|
||||||
|
rename(r.id, e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{r.is_system ? '是' : '否'}</td>
|
||||||
|
<td>{r.status}</td>
|
||||||
|
<td className="table-actions">
|
||||||
|
{!r.is_system && (
|
||||||
|
<Link to={`/admin/role-permissions?role=${r.id}`}>
|
||||||
|
設定權限
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{!r.is_system && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-danger-sm"
|
||||||
|
onClick={() => remove(r.id, r.is_system)}
|
||||||
|
>
|
||||||
|
刪除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<Role[]>([]);
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1>使用者角色</h1>
|
||||||
|
<p className="hint">
|
||||||
|
輸入成員 UID(註冊後可在首頁查看)。指派 <code>tenant_admin</code>{' '}
|
||||||
|
或 <code>tenant_owner</code> 後,對方重新登入即可進管理後台。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={search} className="form form-inline-block">
|
||||||
|
<input
|
||||||
|
placeholder="使用者 UID(例:K6-10000001)"
|
||||||
|
value={uid}
|
||||||
|
onChange={(e) => setUid(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
查詢
|
||||||
|
</button>
|
||||||
|
{myUid && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost"
|
||||||
|
onClick={() => setUid(myUid)}
|
||||||
|
>
|
||||||
|
查我自己
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{msg && <p className="form-ok">{msg}</p>}
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
|
||||||
|
{userRoles.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2>目前角色</h2>
|
||||||
|
<ul className="role-assign-list">
|
||||||
|
{userRoles.map((ur) => (
|
||||||
|
<li key={ur.role_id}>
|
||||||
|
<span>
|
||||||
|
{ur.display_name} (<code>{ur.role_key}</code>)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-danger-sm"
|
||||||
|
onClick={() => revoke(ur.role_id)}
|
||||||
|
>
|
||||||
|
撤銷
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>指派新角色</h2>
|
||||||
|
<div className="form-inline-block">
|
||||||
|
<select
|
||||||
|
value={assignRoleId}
|
||||||
|
onChange={(e) => setAssignRoleId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">選擇角色…</option>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.display_name} ({r.key})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn-primary" onClick={assign}>
|
||||||
|
指派
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<ConfirmStep>('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 (
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1>完成 Email 驗證</h1>
|
||||||
|
<p className="hint">
|
||||||
|
輸入註冊 Email,若有待驗證帳號將寄送驗證碼({RESEND_COOLDOWN_SECONDS}{' '}
|
||||||
|
秒內不可重複寄送)。
|
||||||
|
</p>
|
||||||
|
<form onSubmit={sendCode} className="form">
|
||||||
|
<label>
|
||||||
|
租戶
|
||||||
|
<input value={tenant} onChange={(e) => setTenant(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={sendDisabled}>
|
||||||
|
{loading
|
||||||
|
? '寄送中…'
|
||||||
|
: cooldownLabel(secondsLeft, '寄送驗證碼')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-footer">
|
||||||
|
還沒註冊? <Link to="/register">前往註冊</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/login">登入</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1>Email 驗證</h1>
|
||||||
|
<p className="hint">
|
||||||
|
驗證碼已寄至 <strong>{email}</strong>(開發環境請到 MailHog 查看)
|
||||||
|
</p>
|
||||||
|
<form onSubmit={submit} className="form">
|
||||||
|
<label>
|
||||||
|
6 位數驗證碼
|
||||||
|
<input
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
maxLength={6}
|
||||||
|
pattern="\d{6}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
{loading ? '驗證中…' : '完成註冊'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
onClick={resend}
|
||||||
|
disabled={sendDisabled}
|
||||||
|
>
|
||||||
|
{cooldownLabel(secondsLeft, '重送驗證碼')}
|
||||||
|
</button>
|
||||||
|
{resendMsg && <p className="hint">{resendMsg}</p>}
|
||||||
|
<p className="auth-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('email');
|
||||||
|
setCode('');
|
||||||
|
setError('');
|
||||||
|
setResendMsg('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
換一個 Email
|
||||||
|
</button>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/login">返回登入</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<ForgotStep>('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 (
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1>忘記密碼</h1>
|
||||||
|
<p className="auth-hint">
|
||||||
|
僅限平台註冊(Email + 密碼)的帳號。第三方或 LDAP 登入請向管理員洽詢。
|
||||||
|
</p>
|
||||||
|
<form onSubmit={sendCode} className="form">
|
||||||
|
<label>
|
||||||
|
租戶
|
||||||
|
<input value={tenant} onChange={(e) => setTenant(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
{resendMsg && <p className="form-ok">{resendMsg}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={loading || !canSend}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? '寄送中…'
|
||||||
|
: cooldownLabel(secondsLeft, '寄送重設驗證碼')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-footer">
|
||||||
|
<Link to="/login">返回登入</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1>重設密碼</h1>
|
||||||
|
<p className="auth-hint">
|
||||||
|
驗證碼已寄至 <strong>{email}</strong>(開發環境請至 MailHog :8025 查看)。
|
||||||
|
</p>
|
||||||
|
<form onSubmit={submitReset} className="form">
|
||||||
|
<label>
|
||||||
|
驗證碼
|
||||||
|
<input
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
新密碼
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
確認新密碼
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
{resendMsg && <p className="form-ok">{resendMsg}</p>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
{loading ? '重設中…' : '確認重設密碼'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
onClick={resend}
|
||||||
|
disabled={loading || !canSend}
|
||||||
|
>
|
||||||
|
{cooldownLabel(secondsLeft, '重新寄送驗證碼')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('email');
|
||||||
|
setCode('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setError('');
|
||||||
|
setResendMsg('');
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
更換 Email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-footer">
|
||||||
|
<Link to="/login">返回登入</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<memberApi.MemberMe | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
memberApi
|
||||||
|
.getMe()
|
||||||
|
.then(setMe)
|
||||||
|
.catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) return <p className="form-error">{error}</p>;
|
||||||
|
if (!me) return <p className="loading">載入個人資料…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>你好,{me.display_name || me.zitadel_email || uid}</h1>
|
||||||
|
<div className="cards">
|
||||||
|
<div className="card">
|
||||||
|
<h3>帳號</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>UID</dt>
|
||||||
|
<dd>{me.uid}</dd>
|
||||||
|
<dt>登入 Email</dt>
|
||||||
|
<dd>{me.zitadel_email ?? '—'}</dd>
|
||||||
|
<dt>狀態</dt>
|
||||||
|
<dd>{me.status}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>驗證狀態</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>商業聯絡 Email</dt>
|
||||||
|
<dd>
|
||||||
|
{me.business_email || me.zitadel_email || '未設定'}{' '}
|
||||||
|
{me.business_email_verified ? '✓ 已驗證' : '未驗證'}
|
||||||
|
</dd>
|
||||||
|
<dt>商業手機</dt>
|
||||||
|
<dd>
|
||||||
|
{me.business_phone || '未設定'}{' '}
|
||||||
|
{me.business_phone_verified ? '✓' : '未驗證'}
|
||||||
|
</dd>
|
||||||
|
<dt>雙因素 (TOTP)</dt>
|
||||||
|
<dd>{me.totp_enrolled ? '已啟用' : '未啟用'}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>我的角色</h3>
|
||||||
|
<ul className="tag-list">
|
||||||
|
{roles.length === 0 ? (
|
||||||
|
<li className="muted">尚無指派角色</li>
|
||||||
|
) : (
|
||||||
|
roles.map((r) => (
|
||||||
|
<li key={r} className="tag">
|
||||||
|
{r}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<string | null>(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 (
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1>雙因素驗證</h1>
|
||||||
|
<p className="auth-hint">請輸入驗證器 App 的 6 位數驗證碼,或備援碼。</p>
|
||||||
|
<form onSubmit={submitMfa} className="form">
|
||||||
|
<label>
|
||||||
|
驗證碼
|
||||||
|
<input
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(e) => setTotpCode(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
{loading ? '驗證中…' : '確認登入'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
onClick={backToPassword}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
返回重新輸入密碼
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1>登入</h1>
|
||||||
|
<form onSubmit={submit} className="form">
|
||||||
|
<label>
|
||||||
|
租戶
|
||||||
|
<input value={tenant} onChange={(e) => setTenant(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
密碼
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
{info && <p className="form-ok">{info}</p>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
{loading ? '登入中…' : '登入'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-footer">
|
||||||
|
還沒有帳號? <Link to="/register">註冊</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/register/confirm">尚未完成驗證?</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/password/forgot">忘記密碼?</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1>個人資料</h1>
|
||||||
|
<form onSubmit={submit} className="form form-narrow">
|
||||||
|
<label>
|
||||||
|
顯示名稱
|
||||||
|
<input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
語系
|
||||||
|
<input value={language} onChange={(e) => setLanguage(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
幣別
|
||||||
|
<input value={currency} onChange={(e) => setCurrency(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
聯絡電話
|
||||||
|
<input value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
{msg && <p className="form-ok">{msg}</p>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
儲存
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1>註冊</h1>
|
||||||
|
<form onSubmit={submit} className="form">
|
||||||
|
<label>
|
||||||
|
租戶
|
||||||
|
<input value={tenant} onChange={(e) => setTenant(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
邀請碼
|
||||||
|
<input value={invite} onChange={(e) => setInvite(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
密碼
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
顯示名稱
|
||||||
|
<input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
{loading ? '送出中…' : '註冊並寄送驗證碼'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-footer">
|
||||||
|
已有帳號? <Link to="/login">登入</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/register/confirm">尚未完成驗證?</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<memberApi.MemberMe | null>(null);
|
||||||
|
const [totp, setTotp] = useState<memberApi.TOTPStatus | null>(null);
|
||||||
|
const [enrollInfo, setEnrollInfo] = useState<memberApi.TOTPEnrollStart | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1>安全設定</h1>
|
||||||
|
{msg && <p className="form-ok">{msg}</p>}
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<h2>登入密碼</h2>
|
||||||
|
{canChangePassword ? (
|
||||||
|
<form onSubmit={changePassword} className="form">
|
||||||
|
<label>
|
||||||
|
目前密碼
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
新密碼
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
確認新密碼
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
更新密碼
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="hint">
|
||||||
|
您的帳號由第三方或企業目錄登入,無法在此變更密碼。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<h2>商業聯絡 Email</h2>
|
||||||
|
<p className="hint">
|
||||||
|
註冊時驗證的是<strong>登入信箱</strong>(ZITADEL);此處為<strong>業務聯絡信箱</strong>(通知、帳務等)。
|
||||||
|
若與註冊信箱相同,完成註冊 OTP 後會自動標為已驗證。
|
||||||
|
</p>
|
||||||
|
{me?.business_email_verified ? (
|
||||||
|
<p className="form-ok">
|
||||||
|
已驗證:{me.business_email || me.zitadel_email}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{me?.zitadel_email && (
|
||||||
|
<p className="hint">
|
||||||
|
登入信箱:{me.zitadel_email}
|
||||||
|
{me.business_email && me.business_email !== me.zitadel_email
|
||||||
|
? ` · 目前商業信箱:${me.business_email}(未驗證)`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form onSubmit={startEmail} className="form-inline">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder={me?.zitadel_email ?? 'biz@example.com'}
|
||||||
|
value={emailTarget}
|
||||||
|
onChange={(e) => setEmailTarget(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit">寄送驗證碼</button>
|
||||||
|
</form>
|
||||||
|
{emailChallenge && (
|
||||||
|
<form onSubmit={confirmEmail} className="form-inline">
|
||||||
|
<input
|
||||||
|
placeholder="6 位驗證碼(MailHog :8025)"
|
||||||
|
value={emailCode}
|
||||||
|
onChange={(e) => setEmailCode(e.target.value)}
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<button type="submit">確認</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<h2>商業手機驗證</h2>
|
||||||
|
<form onSubmit={startPhone} className="form-inline">
|
||||||
|
<input
|
||||||
|
placeholder="+886912345678"
|
||||||
|
value={phoneTarget}
|
||||||
|
onChange={(e) => setPhoneTarget(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit">寄送驗證碼</button>
|
||||||
|
</form>
|
||||||
|
{phoneChallenge && (
|
||||||
|
<form onSubmit={confirmPhone} className="form-inline">
|
||||||
|
<input
|
||||||
|
placeholder="6 位驗證碼"
|
||||||
|
value={phoneCode}
|
||||||
|
onChange={(e) => setPhoneCode(e.target.value)}
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<button type="submit">確認</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<h2>雙因素驗證 (TOTP)</h2>
|
||||||
|
<p>
|
||||||
|
狀態:{totp?.enrolled ? '已啟用' : '未啟用'}
|
||||||
|
{totp?.enrolled &&
|
||||||
|
` · 備援碼剩餘 ${totp.backup_codes_remaining} 組`}
|
||||||
|
</p>
|
||||||
|
{!totp?.enrolled ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn-primary" onClick={startTotp}>
|
||||||
|
開始綁定
|
||||||
|
</button>
|
||||||
|
{enrollInfo && (
|
||||||
|
<TotpQrPanel
|
||||||
|
otpauthUrl={enrollInfo.otpauth_url}
|
||||||
|
issuer={enrollInfo.issuer}
|
||||||
|
account={enrollInfo.account}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{enrollInfo && (
|
||||||
|
<form onSubmit={confirmTotp} className="form-inline">
|
||||||
|
<input
|
||||||
|
placeholder="Authenticator 6 碼"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(e) => setTotpCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit">完成綁定</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn-danger" onClick={disableTotp}>
|
||||||
|
停用 TOTP
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{backupCodes.length > 0 && (
|
||||||
|
<pre className="backup-codes">{backupCodes.join('\n')}</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./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"]
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -29,6 +29,32 @@ type (
|
||||||
ChallengeID string `json:"challenge_id" validate:"required"` // 註冊流程的 OTP challenge ID
|
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 {
|
AuthTokenData {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
|
@ -64,6 +90,23 @@ type (
|
||||||
Password string `json:"password" validate:"required,min=8,max=128"` // 密碼(8-128 字元)
|
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 {
|
TokenRefreshReq {
|
||||||
RefreshToken string `json:"refresh_token" validate:"required"` // 先前核發的 refresh token
|
RefreshToken string `json:"refresh_token" validate:"required"` // 先前核發的 refresh token
|
||||||
}
|
}
|
||||||
|
|
@ -107,6 +150,12 @@ type (
|
||||||
Data AuthTokenData `json:"data"`
|
Data AuthTokenData `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LoginOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data LoginData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
RegisterSocialStartOKStatus {
|
RegisterSocialStartOKStatus {
|
||||||
Code int64 `json:"code"`
|
Code int64 `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|
@ -124,6 +173,18 @@ type (
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data LogoutData `json:"data"`
|
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(
|
@server(
|
||||||
|
|
@ -229,6 +290,77 @@ service gateway {
|
||||||
@handler registerResend
|
@handler registerResend
|
||||||
post /register/resend (RegisterResendReq) returns (RegisterData)
|
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"
|
@doc "Social 註冊:建立 session 並回傳 OAuth URL"
|
||||||
/*
|
/*
|
||||||
@respdoc-200 (RegisterSocialStartOKStatus) // 成功(code=102000)
|
@respdoc-200 (RegisterSocialStartOKStatus) // 成功(code=102000)
|
||||||
|
|
@ -299,9 +431,9 @@ service gateway {
|
||||||
@handler registerSocialCallback
|
@handler registerSocialCallback
|
||||||
get /register/social/callback (RegisterSocialCallbackReq) returns (AuthTokenData)
|
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 (
|
@respdoc-400 (
|
||||||
10101000: (APIErrorStatus) 參數格式錯誤
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
10104000: (APIErrorStatus) 缺少必填欄位
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
|
@ -327,7 +459,33 @@ service gateway {
|
||||||
) // 第三方服務錯誤
|
) // 第三方服務錯誤
|
||||||
*/
|
*/
|
||||||
@handler login
|
@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"
|
@doc "以 refresh_token 換發新的 access/refresh token"
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,15 @@ type (
|
||||||
Phone string `json:"phone,optional"` // 聯絡電話 E.164 格式(可選)
|
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 {
|
VerificationStartReq {
|
||||||
Target string `json:"target"` // 驗證目標:email 地址或 E.164 手機號(依端點而定)
|
Target string `json:"target"` // 驗證目標:email 地址或 E.164 手機號(依端點而定)
|
||||||
}
|
}
|
||||||
|
|
@ -165,6 +174,29 @@ service gateway {
|
||||||
@handler updateMemberMe
|
@handler updateMemberMe
|
||||||
patch /me (UpdateMemberMeReq) returns (MemberMeData)
|
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 驗證"
|
@doc "開始業務 email 驗證"
|
||||||
/*
|
/*
|
||||||
@respdoc-200 (VerificationStartOKStatus) // 成功(code=102000)
|
@respdoc-200 (VerificationStartOKStatus) // 成功(code=102000)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,11 +20,17 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
server.AddRoutes(
|
server.AddRoutes(
|
||||||
[]rest.Route{
|
[]rest.Route{
|
||||||
{
|
{
|
||||||
// Email + 密碼登入(ZITADEL ROPG → CloudEP JWT)
|
// Email + 密碼登入(ZITADEL ROPG → CloudEP JWT;若已啟用 TOTP 則回傳 MFA challenge)
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/login",
|
Path: "/login",
|
||||||
Handler: auth.LoginHandler(serverCtx),
|
Handler: auth.LoginHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 確認登入 MFA(TOTP / 備援碼)並核發 JWT
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/login/mfa",
|
||||||
|
Handler: auth.LoginMfaConfirmHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Social 登入 OAuth callback
|
// Social 登入 OAuth callback
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
|
|
@ -37,6 +43,18 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/login/social/start",
|
Path: "/login/social/start",
|
||||||
Handler: auth.LoginSocialStartHandler(serverCtx),
|
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)
|
// Email 註冊(建立 ZITADEL + member,寄 registration OTP)
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
|
|
@ -55,6 +73,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/register/resend",
|
Path: "/register/resend",
|
||||||
Handler: auth.RegisterResendHandler(serverCtx),
|
Handler: auth.RegisterResendHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 恢復未完成註冊(依 Email 重寄 registration OTP)
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/register/resume",
|
||||||
|
Handler: auth.RegisterResumeHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Social 註冊 OAuth callback
|
// Social 註冊 OAuth callback
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
|
|
@ -114,6 +138,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/me",
|
Path: "/me",
|
||||||
Handler: member.UpdateMemberMeHandler(serverCtx),
|
Handler: member.UpdateMemberMeHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 變更登入密碼(僅 platform_native 平台帳號)
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/me/password",
|
||||||
|
Handler: member.ChangePasswordHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// TOTP 狀態
|
// TOTP 狀態
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fieldPassword = "password"
|
||||||
|
|
||||||
// Client calls ZITADEL Management API v2 and OAuth token endpoints.
|
// Client calls ZITADEL Management API v2 and OAuth token endpoints.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
conf Conf
|
conf Conf
|
||||||
|
|
@ -131,8 +133,8 @@ func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest
|
||||||
"email": req.Email,
|
"email": req.Email,
|
||||||
"isVerified": req.EmailVerified,
|
"isVerified": req.EmailVerified,
|
||||||
},
|
},
|
||||||
"password": map[string]any{
|
fieldPassword: map[string]any{
|
||||||
"password": req.Password,
|
fieldPassword: req.Password,
|
||||||
"changeRequired": false,
|
"changeRequired": false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +154,28 @@ func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest
|
||||||
return &CreateHumanUserResult{UserID: out.UserID}, nil
|
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.
|
// DeactivateUser disables login for the user via POST /v2/users/{id}/deactivate.
|
||||||
func (c *Client) DeactivateUser(ctx context.Context, userID string) error {
|
func (c *Client) DeactivateUser(ctx context.Context, userID string) error {
|
||||||
if c == nil {
|
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)
|
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 {
|
type TokenResult struct {
|
||||||
AccessToken string
|
AccessToken string
|
||||||
IDToken string
|
IDToken string
|
||||||
ExpiresIn int
|
ExpiresIn int
|
||||||
TokenType string
|
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) {
|
func (c *Client) VerifyPassword(ctx context.Context, username, password string) (*TokenResult, error) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil, ErrNotConfigured
|
return nil, ErrNotConfigured
|
||||||
}
|
}
|
||||||
if c.conf.OAuthClientID == "" || c.conf.OAuthClientSecret == "" {
|
if c.conf.OAuthClientID != "" && c.conf.OAuthClientSecret != "" {
|
||||||
return nil, fmt.Errorf("zitadel: oauth client credentials are required for password verification")
|
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 := url.Values{}
|
||||||
form.Set("grant_type", "password")
|
form.Set("grant_type", fieldPassword)
|
||||||
form.Set("client_id", c.conf.OAuthClientID)
|
form.Set("client_id", c.conf.OAuthClientID)
|
||||||
form.Set("client_secret", c.conf.OAuthClientSecret)
|
form.Set("client_secret", c.conf.OAuthClientSecret)
|
||||||
form.Set("username", username)
|
form.Set("username", username)
|
||||||
form.Set("password", password)
|
form.Set(fieldPassword, password)
|
||||||
form.Set("scope", "openid profile email")
|
form.Set("scope", "openid profile email")
|
||||||
|
|
||||||
return c.postToken(ctx, form)
|
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 {
|
if resp.StatusCode == http.StatusConflict {
|
||||||
return ErrUserAlreadyExists
|
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))
|
return fmt.Errorf("zitadel: %s %s: status %d: %s", method, endpoint, resp.StatusCode, truncateBody(raw))
|
||||||
}
|
}
|
||||||
if out != nil && len(raw) > 0 {
|
if out != nil && len(raw) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,33 @@ func TestVerifyPassword(t *testing.T) {
|
||||||
require.ErrorIs(t, err, zitadel.ErrInvalidCredentials)
|
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) {
|
func TestNewClientDisabledWhenIssuerEmpty(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c, err := zitadel.NewClient(zitadel.Conf{})
|
c, err := zitadel.NewClient(zitadel.Conf{})
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package auth
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
errs "gateway/internal/library/errors"
|
errs "gateway/internal/library/errors"
|
||||||
"gateway/internal/library/errors/code"
|
"gateway/internal/library/errors/code"
|
||||||
|
|
@ -60,6 +61,12 @@ func zitadelIdentityFromToken(ctx context.Context, client *zitadel.Client, tok *
|
||||||
if tok == nil {
|
if tok == nil {
|
||||||
return nil, errb.SvcThirdParty("empty token result")
|
return nil, errb.SvcThirdParty("empty token result")
|
||||||
}
|
}
|
||||||
|
if tok.Subject != "" {
|
||||||
|
return &zitadel.IDTokenClaims{
|
||||||
|
Sub: tok.Subject,
|
||||||
|
Email: tok.Email,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
if tok.IDToken != "" {
|
if tok.IDToken != "" {
|
||||||
claims, err := zitadel.ParseIDTokenClaims(tok.IDToken)
|
claims, err := zitadel.ParseIDTokenClaims(tok.IDToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -124,3 +131,89 @@ func isMemberNotFound(err error) bool {
|
||||||
e := errs.FromError(err)
|
e := errs.FromError(err)
|
||||||
return e != nil && e.Category() == code.ResNotFound
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
if err := requireLoginDeps(l.svcCtx); err != nil {
|
||||||
return nil, err
|
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)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
dommember "gateway/internal/model/member/domain/usecase"
|
dommember "gateway/internal/model/member/domain/usecase"
|
||||||
"gateway/internal/svc"
|
"gateway/internal/svc"
|
||||||
|
|
@ -62,5 +63,13 @@ func (l *RegisterConfirmLogic) RegisterConfirm(req *types.RegisterConfirmReq) (*
|
||||||
return nil, err
|
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)
|
return issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, ch.UID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import (
|
||||||
memberenum "gateway/internal/model/member/domain/enum"
|
memberenum "gateway/internal/model/member/domain/enum"
|
||||||
dommember "gateway/internal/model/member/domain/usecase"
|
dommember "gateway/internal/model/member/domain/usecase"
|
||||||
"gateway/internal/svc"
|
"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) {
|
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 {
|
if sc.MemberLifecycle == nil {
|
||||||
return errb.SysNotImplemented("member lifecycle not configured")
|
return errb.SysNotImplemented("member lifecycle not configured")
|
||||||
}
|
}
|
||||||
|
if sc.MemberProfile == nil {
|
||||||
|
return errb.SysNotImplemented("member profile not configured")
|
||||||
|
}
|
||||||
if sc.MemberOTP == nil {
|
if sc.MemberOTP == nil {
|
||||||
return errb.SysNotImplemented("member OTP not configured")
|
return errb.SysNotImplemented("member OTP not configured")
|
||||||
}
|
}
|
||||||
|
|
@ -96,3 +102,109 @@ func requireRegistrationDeps(sc *svc.ServiceContext) error {
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -42,6 +43,21 @@ func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, e
|
||||||
return nil, err
|
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
|
regCfg := l.svcCtx.Config.Member.Defaults().Registration
|
||||||
var inviteCodeID string
|
var inviteCodeID string
|
||||||
if regCfg.RequireInviteCode {
|
if regCfg.RequireInviteCode {
|
||||||
|
|
@ -53,23 +69,14 @@ func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, e
|
||||||
Code: req.InviteCode,
|
Code: req.InviteCode,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
inviteCodeID = consumed.ID
|
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{
|
memberDTO, err := l.svcCtx.MemberLifecycle.CreateUnverified(l.ctx, &dommember.CreatePlatformMemberRequest{
|
||||||
TenantID: tenant.TenantID,
|
TenantID: tenant.TenantID,
|
||||||
Email: email,
|
Email: email,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,8 @@ import (
|
||||||
notifuc "gateway/internal/model/notification/domain/usecase"
|
notifuc "gateway/internal/model/notification/domain/usecase"
|
||||||
"gateway/internal/svc"
|
"gateway/internal/svc"
|
||||||
"gateway/internal/types"
|
"gateway/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func startVerification(
|
func startVerification(
|
||||||
|
|
@ -76,7 +78,9 @@ func startVerification(
|
||||||
}
|
}
|
||||||
if os.Getenv("GATEWAY_E2E") == "1" && sc.Redis != nil && sc.Redis.Zero() != nil {
|
if os.Getenv("GATEWAY_E2E") == "1" && sc.Redis != nil && sc.Redis.Zero() != nil {
|
||||||
key := fmt.Sprintf("e2e:otp:%s", dto.ChallengeID)
|
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{
|
return &types.VerificationStartData{
|
||||||
ChallengeID: dto.ChallengeID,
|
ChallengeID: dto.ChallengeID,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ func LoginSessionRedisKey(sessionID string) string {
|
||||||
return fmt.Sprintf("auth:login:session:%s", sessionID)
|
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.
|
// NormalizeInviteCode trims and uppercases user input before hashing.
|
||||||
func NormalizeInviteCode(code string) string {
|
func NormalizeInviteCode(code string) string {
|
||||||
return strings.ToUpper(strings.TrimSpace(code))
|
return strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,5 @@ var (
|
||||||
ErrDuplicateRegistrationMeta = fmt.Errorf("auth: duplicate registration metadata")
|
ErrDuplicateRegistrationMeta = fmt.Errorf("auth: duplicate registration metadata")
|
||||||
ErrRegistrationSessionNotFound = fmt.Errorf("auth: registration session not found")
|
ErrRegistrationSessionNotFound = fmt.Errorf("auth: registration session not found")
|
||||||
ErrLoginSessionNotFound = fmt.Errorf("auth: login session not found")
|
ErrLoginSessionNotFound = fmt.Errorf("auth: login session not found")
|
||||||
|
ErrLoginMFAChallengeNotFound = fmt.Errorf("auth: login mfa challenge not found")
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -16,6 +16,7 @@ type Module struct {
|
||||||
RegistrationMeta domusecase.RegistrationMetaUseCase
|
RegistrationMeta domusecase.RegistrationMetaUseCase
|
||||||
RegistrationSession domusecase.RegistrationSessionUseCase
|
RegistrationSession domusecase.RegistrationSessionUseCase
|
||||||
LoginSession domusecase.LoginSessionUseCase
|
LoginSession domusecase.LoginSessionUseCase
|
||||||
|
LoginMFAChallenge domusecase.LoginMFAChallengeUseCase
|
||||||
|
|
||||||
Invites domrepo.InviteRepository
|
Invites domrepo.InviteRepository
|
||||||
RegistrationMetaRepo domrepo.RegistrationMetaRepository
|
RegistrationMetaRepo domrepo.RegistrationMetaRepository
|
||||||
|
|
@ -47,6 +48,7 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
||||||
regMetaRepo := repository.NewRegistrationMetaRepository(repository.RegistrationMetaRepositoryParam{Conf: param.MongoConf})
|
regMetaRepo := repository.NewRegistrationMetaRepository(repository.RegistrationMetaRepositoryParam{Conf: param.MongoConf})
|
||||||
sessionStore := repository.NewRedisRegistrationSessionStore(param.Redis)
|
sessionStore := repository.NewRedisRegistrationSessionStore(param.Redis)
|
||||||
loginStore := repository.NewRedisLoginSessionStore(param.Redis)
|
loginStore := repository.NewRedisLoginSessionStore(param.Redis)
|
||||||
|
loginMFAStore := repository.NewRedisLoginMFAChallengeStore(param.Redis)
|
||||||
lock := param.Lock
|
lock := param.Lock
|
||||||
if lock == nil {
|
if lock == nil {
|
||||||
lock = repository.NewRedisInviteConsumeLock(param.Redis)
|
lock = repository.NewRedisInviteConsumeLock(param.Redis)
|
||||||
|
|
@ -68,6 +70,9 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
||||||
LoginSession: MustLoginSessionUseCase(LoginSessionUseCaseParam{
|
LoginSession: MustLoginSessionUseCase(LoginSessionUseCaseParam{
|
||||||
Store: loginStore,
|
Store: loginStore,
|
||||||
}),
|
}),
|
||||||
|
LoginMFAChallenge: MustLoginMFAChallengeUseCase(LoginMFAChallengeUseCaseParam{
|
||||||
|
Store: loginMFAStore,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
return mod, nil
|
return mod, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ type MemberRepository interface {
|
||||||
Insert(ctx context.Context, member *entity.Member) error
|
Insert(ctx context.Context, member *entity.Member) error
|
||||||
GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error)
|
GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error)
|
||||||
GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID 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)
|
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
|
UpdateStatus(ctx context.Context, tenantID, uid string, status enum.MemberStatus, suspendReason string) error
|
||||||
List(ctx context.Context, filter ListMembersFilter) ([]*entity.Member, int64, error)
|
List(ctx context.Context, filter ListMembersFilter) ([]*entity.Member, int64, error)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
type ProfileUseCase interface {
|
type ProfileUseCase interface {
|
||||||
GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error)
|
GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error)
|
||||||
GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*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)
|
Update(ctx context.Context, req *UpdateMemberRequest) (*MemberDTO, error)
|
||||||
List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error)
|
List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error)
|
||||||
SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error
|
SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error
|
||||||
|
|
@ -54,6 +55,7 @@ type MemberDTO struct {
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
UID string `json:"uid"`
|
UID string `json:"uid"`
|
||||||
ZitadelEmail string `json:"zitadel_email,omitempty"`
|
ZitadelEmail string `json:"zitadel_email,omitempty"`
|
||||||
|
ZitadelUserID string `json:"zitadel_user_id,omitempty"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
Avatar string `json:"avatar,omitempty"`
|
Avatar string `json:"avatar,omitempty"`
|
||||||
Phone string `json:"phone,omitempty"`
|
Phone string `json:"phone,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package repository
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
libmongo "gateway/internal/library/mongo"
|
libmongo "gateway/internal/library/mongo"
|
||||||
|
|
@ -85,6 +86,21 @@ func (r *memberRepository) GetByZitadelUserID(ctx context.Context, tenantID, zit
|
||||||
return &doc, nil
|
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) {
|
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()}
|
set := bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()}
|
||||||
if update.DisplayName != nil {
|
if update.DisplayName != nil {
|
||||||
|
|
@ -213,6 +229,11 @@ func (r *memberRepository) Index20260520001UP(ctx context.Context) error {
|
||||||
[]int32{1, 1}, true); err != nil {
|
[]int32{1, 1}, true); err != nil {
|
||||||
return err
|
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,
|
return r.db.PopulateMultiIndex(ctx,
|
||||||
[]string{member.BSONFieldTenantID, member.BSONFieldMemberStatus, member.BSONFieldCreateAt},
|
[]string{member.BSONFieldTenantID, member.BSONFieldMemberStatus, member.BSONFieldCreateAt},
|
||||||
[]int32{1, 1, -1}, false)
|
[]int32{1, 1, -1}, false)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ func memberToDTO(m *entity.Member) *domusecase.MemberDTO {
|
||||||
TenantID: m.TenantID,
|
TenantID: m.TenantID,
|
||||||
UID: m.UID,
|
UID: m.UID,
|
||||||
ZitadelEmail: m.ZitadelEmail,
|
ZitadelEmail: m.ZitadelEmail,
|
||||||
|
ZitadelUserID: m.ZitadelUserID,
|
||||||
DisplayName: m.DisplayName,
|
DisplayName: m.DisplayName,
|
||||||
Avatar: m.Avatar,
|
Avatar: m.Avatar,
|
||||||
Phone: m.Phone,
|
Phone: m.Phone,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue