feat/env #1
|
|
@ -79,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/
|
||||||
|
|
|
||||||
97
Makefile
97
Makefile
|
|
@ -215,3 +215,100 @@ k6-down: ## 停 k6 stack 並清 volume(會清 Postgres / Mongo / Redis 資料
|
||||||
$(COMPOSE) --profile k6 down -v
|
$(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
|
@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"
|
@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/
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,18 @@ export ZITADEL_SERVICE_TOKEN=$(cat deploy/zitadel/machinekey/zitadel-admin-sa.to
|
||||||
|
|
||||||
`make k6-gateway` 會自動做這件事。
|
`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
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
export ZITADEL_SERVICE_TOKEN=lvYZ4FNjNap4H2Lx7h9s02gr1tB4VxdWFk2yl4Fj3T3lSHhSn1Mv4lS6dGygiuP2cQ6j1D4
|
export ZITADEL_SERVICE_TOKEN=4ozUZSXQZ2iaObRwejZT95BtuEPI4j_UZoCqUOM4B26KhYAW4k3uSVokd-sat49HrDMSkxM
|
||||||
export BASE_URL=http://localhost:8888
|
export BASE_URL=http://localhost:8888
|
||||||
export MAILHOG_URL=http://localhost:8025
|
export MAILHOG_URL=http://localhost:8025
|
||||||
export REDIS_ADDR=localhost:6379
|
export REDIS_ADDR=localhost:6379
|
||||||
export ADMIN_EMAIL=k6-admin-1779775084@k6.local
|
|
||||||
export ADMIN_PASSWORD=K6-Admin-Pass-1!
|
|
||||||
export ADMIN_UID=K6-10000075
|
|
||||||
export ADMIN_TENANT_ID=k6-tenant
|
|
||||||
export ADMIN_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJ0ZW5hbnRfaWQiOiJrNi10ZW5hbnQiLCJ1aWQiOiJLNi0xMDAwMDA3NSIsInR5cCI6ImFjY2VzcyIsImF1dGhfZ2VuIjowLCJleHAiOjE3Nzk3NzU5ODUsImlhdCI6MTc3OTc3NTA4NSwianRpIjoiMzJlYjYyMGMtNmU5Ny00YWM5LTgzYTItMGQyMjRhODcwOWVjIn0.TQiRHCk-QVKShBNIR4F9TGQrSCc9YatmxCgE2oxnV6I
|
|
||||||
export ADMIN_REFRESH_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJ0ZW5hbnRfaWQiOiJrNi10ZW5hbnQiLCJ1aWQiOiJLNi0xMDAwMDA3NSIsInR5cCI6InJlZnJlc2giLCJhdXRoX2dlbiI6MCwiZXhwIjoxNzgwMzc5ODg1LCJpYXQiOjE3Nzk3NzUwODUsImp0aSI6IjA5ODczZTQyLTg2NGUtNDQ4ZS04OTBiLTBlMjhlZTZkOTA2YyJ9.jIIZykM2L9kmNeUzSU-fHtUsIUS6eyvGb06eT7DWPUk
|
|
||||||
|
|
|
||||||
|
|
@ -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,59 @@
|
||||||
|
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 { 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 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,93 @@
|
||||||
|
import { api, setTokens } from './http';
|
||||||
|
|
||||||
|
export interface AuthTokenData {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
uid: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterData {
|
||||||
|
challenge_id: string;
|
||||||
|
expires_in: number;
|
||||||
|
uid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(tenantSlug: string, email: string, password: string) {
|
||||||
|
const data = await api<AuthTokenData>('/api/v1/auth/login', {
|
||||||
|
auth: false,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tenant_slug: tenantSlug, email, password }),
|
||||||
|
});
|
||||||
|
setTokens(data.access_token, data.refresh_token);
|
||||||
|
localStorage.setItem('uid', data.uid);
|
||||||
|
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 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,100 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface MemberMe {
|
||||||
|
tenant_id: string;
|
||||||
|
uid: 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' });
|
||||||
|
}
|
||||||
|
|
@ -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,3 @@
|
||||||
|
/** 本機 k6 / dev 預設值,可在登入頁覆寫 */
|
||||||
|
export const DEFAULT_TENANT = 'k6-tenant';
|
||||||
|
export const DEFAULT_INVITE = 'K6INVITE';
|
||||||
|
|
@ -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,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,95 @@
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import * as authApi from '../../api/auth';
|
||||||
|
import { ApiError } from '../../api/http';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
export function ConfirmPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { syncSession, refreshRoles } = useAuth();
|
||||||
|
const [tenant, setTenant] = useState('');
|
||||||
|
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 raw = sessionStorage.getItem('register_pending');
|
||||||
|
if (!raw) return;
|
||||||
|
const p = JSON.parse(raw) as {
|
||||||
|
tenant: string;
|
||||||
|
challenge_id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
setTenant(p.tenant);
|
||||||
|
setChallengeId(p.challenge_id);
|
||||||
|
setEmail(p.email);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await authApi.registerConfirm(tenant, challengeId, code);
|
||||||
|
sessionStorage.removeItem('register_pending');
|
||||||
|
syncSession();
|
||||||
|
await refreshRoles();
|
||||||
|
navigate('/app');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : '驗證失敗');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resend = async () => {
|
||||||
|
setResendMsg('');
|
||||||
|
try {
|
||||||
|
await authApi.registerResend(tenant, challengeId);
|
||||||
|
setResendMsg('已重新寄送驗證碼');
|
||||||
|
} catch (err) {
|
||||||
|
setResendMsg(err instanceof ApiError ? err.message : '重送失敗');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!challengeId) {
|
||||||
|
return (
|
||||||
|
<div className="auth-card">
|
||||||
|
<p>請先完成註冊步驟。</p>
|
||||||
|
<Link to="/register">返回註冊</Link>
|
||||||
|
</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}>
|
||||||
|
重送驗證碼
|
||||||
|
</button>
|
||||||
|
{resendMsg && <p className="hint">{resendMsg}</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,75 @@
|
||||||
|
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 } from '../../config';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import * as permApi from '../../api/permission';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { syncSession, refreshRoles } = useAuth();
|
||||||
|
const [tenant, setTenant] = useState(
|
||||||
|
() => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT,
|
||||||
|
);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = 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);
|
||||||
|
await authApi.login(tenant, email, password);
|
||||||
|
syncSession();
|
||||||
|
await refreshRoles();
|
||||||
|
const me = await permApi.getMyPermissions();
|
||||||
|
const admin = permApi.isAdminRole(me.roles ?? []);
|
||||||
|
navigate(admin ? '/admin' : '/app');
|
||||||
|
} catch (err) {
|
||||||
|
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>
|
||||||
|
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>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
{loading ? '登入中…' : '登入'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-footer">
|
||||||
|
還沒有帳號? <Link to="/register">註冊</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,97 @@
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'register_pending',
|
||||||
|
JSON.stringify({
|
||||||
|
tenant,
|
||||||
|
challenge_id: data.challenge_id,
|
||||||
|
email,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
navigate('/register/confirm');
|
||||||
|
} catch (err) {
|
||||||
|
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>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
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 [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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>安全設定</h1>
|
||||||
|
{msg && <p className="form-ok">{msg}</p>}
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
|
||||||
|
<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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -163,22 +163,31 @@ 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", "password")
|
||||||
form.Set("client_id", c.conf.OAuthClientID)
|
form.Set("client_id", c.conf.OAuthClientID)
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
"password": map[string]any{"password": 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 any, 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")
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue