修正 dev 登入與管理後台:ZITADEL Sessions 驗密、bootstrap admin、角色指派 UX。
ZITADEL v2 不支援 password grant,改優先走 Sessions API 以恢復 Email 登入; dev-up 自動 seed 權限與 admin@k6.local,並改善使用者角色頁在無角色時仍可指派。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
43c5a015ca
commit
b754a2d07d
12
Makefile
12
Makefile
|
|
@ -197,6 +197,7 @@ k6-wait: k6-wait-ldap ## 等 OpenLDAP + ZITADEL ready + bootstrap IdP/OAuth +
|
|||
if [ -s "$(K6_BOOTSTRAP_ENV)" ]; then cat $(K6_BOOTSTRAP_ENV) >> $(K6_ENV_FILE); fi; \
|
||||
echo "wrote $(K6_ENV_FILE)"
|
||||
@$(MAKE) -s k6-seed-fixtures
|
||||
@$(MAKE) -s k6-seed-permissions
|
||||
@echo "tip: 'source $(K6_ENV_FILE)' to load into your shell"
|
||||
@echo "tip: run 'make dev-restart-gateway' if gateway was already running"
|
||||
|
||||
|
|
@ -226,6 +227,12 @@ print("tenant matched=" + t.matchedCount + " upserted=" + (t.upsertedId?1:0) + "
|
|||
# Back-compat alias
|
||||
k6-seed-tenant: k6-seed-fixtures ## (alias for k6-seed-fixtures)
|
||||
|
||||
k6-seed-permissions: ## upsert permission catalog + system roles for k6-tenant
|
||||
@mkdir -p bin
|
||||
$(GO) build -o bin/permission-seed ./cmd/permission-seed
|
||||
@./bin/permission-seed -tenants $(K6_TENANT_ID)
|
||||
@echo "seeded permissions + system roles for $(K6_TENANT_ID)"
|
||||
|
||||
k6-build: ## 建 gateway binary 給 k6 使用
|
||||
@mkdir -p $(dir $(K6_GATEWAY_BIN))
|
||||
$(GO) build -o $(K6_GATEWAY_BIN) ./gateway.go
|
||||
|
|
@ -276,7 +283,7 @@ 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 dev-restart-gateway ## 一鍵起全套:mongo/redis/mailhog/zitadel + seed + Gateway 背景
|
||||
dev-up: k6-up k6-wait k6-build dev-restart-gateway dev-seed-admin ## 一鍵起全套:mongo/redis/mailhog/zitadel + seed + Gateway + bootstrap admin
|
||||
@echo ""
|
||||
@echo "=========================================="
|
||||
@echo " 本機測試環境已就緒"
|
||||
|
|
@ -287,9 +294,10 @@ dev-up: k6-up k6-wait k6-build dev-restart-gateway ## 一鍵起全套:mongo/re
|
|||
@echo " ZITADEL 主控台 http://localhost:8080/ui/console"
|
||||
@echo ""
|
||||
@echo " 註冊預設:租戶 k6-tenant · 邀請碼 K6INVITE"
|
||||
@echo " 原始管理員:admin@k6.local / Admin-Pass-1!(dev-up 已自動建立)"
|
||||
@echo " LDAP 登入:alice / Password1!(make k6-wait 已自動設定 ZITADEL IdP)"
|
||||
@echo ""
|
||||
@echo " 管理後台:make dev-seed-admin 後用輸出的帳密登入"
|
||||
@echo " 管理後台:用 admin@k6.local 登入 → 頂部「管理後台」→ 角色 / 權限"
|
||||
@echo " 關閉環境:make dev-down"
|
||||
@echo " 查看狀態:make dev-status"
|
||||
@echo " OAuth/LDAP 設定變更後:make dev-restart-gateway"
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@
|
|||
// 6. Print ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_UID env exports to stdout so
|
||||
// callers can `eval "$(make k6-seed-admin ...)"` or redirect into a file.
|
||||
//
|
||||
// Re-running is safe: register is idempotent at the OTP-confirm step (the
|
||||
// challenge is fresh per call), and seed.Apply / UserRole insert are
|
||||
// idempotent-by-key.
|
||||
// Re-running is safe: register skips when email exists, and seed.Apply /
|
||||
// UserRole insert are idempotent-by-key.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -29,6 +28,8 @@ import (
|
|||
"time"
|
||||
|
||||
libmongo "gateway/internal/library/mongo"
|
||||
memberentity "gateway/internal/model/member/domain/entity"
|
||||
memberenum "gateway/internal/model/member/domain/enum"
|
||||
memberrepo "gateway/internal/model/member/repository"
|
||||
permdomain "gateway/internal/model/permission/domain"
|
||||
permentity "gateway/internal/model/permission/domain/entity"
|
||||
|
|
@ -40,17 +41,22 @@ import (
|
|||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const (
|
||||
authAlreadyRegisteredCode = 28303000
|
||||
authInvalidCredentialsCode = 28501000
|
||||
legacyAdminPassword = "K6-Admin-Pass-1!"
|
||||
)
|
||||
|
||||
var (
|
||||
flagBase = flag.String("base", envOr("BASE_URL", "http://localhost:8888"), "Gateway base URL")
|
||||
flagMailhog = flag.String("mailhog", envOr("MAILHOG_URL", "http://localhost:8025"), "MailHog HTTP API URL")
|
||||
flagTenant = flag.String("tenant", envOr("TENANT_SLUG", "k6-tenant"), "Tenant slug")
|
||||
flagInvite = flag.String("invite", envOr("INVITE_CODE", "K6INVITE"), "Invite code")
|
||||
// Default email is rotated per-invocation. Re-running seed-admin against
|
||||
// a stable email would collide with the existing ZITADEL user (28303000
|
||||
// email already registered) since ZITADEL state lives outside docker
|
||||
// volumes that `make k6-down` clears. Override with -email or ADMIN_EMAIL.
|
||||
flagEmail = flag.String("email", envOr("ADMIN_EMAIL", fmt.Sprintf("k6-admin-%d@k6.local", time.Now().Unix())), "Admin email")
|
||||
flagPassword = flag.String("password", envOr("ADMIN_PASSWORD", "K6-Admin-Pass-1!"), "Admin password")
|
||||
// Fixed bootstrap admin for dev / frontend. Re-run is idempotent: if the
|
||||
// email is already registered we look up the UID in Mongo and ensure
|
||||
// tenant_admin is assigned. Override with -email or ADMIN_EMAIL.
|
||||
flagEmail = flag.String("email", envOr("ADMIN_EMAIL", "admin@k6.local"), "Admin email")
|
||||
flagPassword = flag.String("password", envOr("ADMIN_PASSWORD", "Admin-Pass-1!"), "Admin password")
|
||||
flagMongoHost = flag.String("mongo-host", envOr("K6_MONGO_HOST", "127.0.0.1"), "Mongo host")
|
||||
flagMongoPort = flag.Int("mongo-port", envOrInt("K6_MONGO_PORT", 27017), "Mongo port")
|
||||
flagMongoDB = flag.String("mongo-db", envOr("K6_MONGO_DB", "gateway_k6"), "Mongo database")
|
||||
|
|
@ -71,30 +77,6 @@ func main() {
|
|||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
logf("registering admin %s @ %s", *flagEmail, *flagBase)
|
||||
regResp, err := register(ctx)
|
||||
if err != nil {
|
||||
exitf("register: %v", err)
|
||||
}
|
||||
logf("challenge_id=%s uid=%s", regResp.ChallengeID, regResp.UID)
|
||||
|
||||
code, err := pollOTP(ctx, *flagEmail, time.Duration(*flagPollSecs)*time.Second)
|
||||
if err != nil {
|
||||
exitf("poll OTP: %v", err)
|
||||
}
|
||||
logf("OTP=%s", code)
|
||||
|
||||
tokens, err := confirm(ctx, regResp.ChallengeID, code)
|
||||
if err != nil {
|
||||
exitf("register/confirm: %v", err)
|
||||
}
|
||||
logf("registration confirmed; admin uid=%s access_token=%d chars", regResp.UID, len(tokens.AccessToken))
|
||||
|
||||
if *flagDryRun {
|
||||
writeOutput(*flagEmail, *flagPassword, regResp.UID, "", tokens)
|
||||
return
|
||||
}
|
||||
|
||||
mongoConf := &libmongo.Conf{
|
||||
Schema: "mongodb",
|
||||
Host: *flagMongoHost,
|
||||
|
|
@ -112,10 +94,25 @@ func main() {
|
|||
}
|
||||
logf("tenant_id=%s", tenantID)
|
||||
|
||||
if err := seedRoles(ctx, mongoConf, tenantID); err != nil {
|
||||
exitf("seed roles: %v", err)
|
||||
if !*flagDryRun {
|
||||
if err := seedRoles(ctx, mongoConf, tenantID); err != nil {
|
||||
exitf("seed roles: %v", err)
|
||||
}
|
||||
}
|
||||
roleID, err := assignAdmin(ctx, mongoConf, tenantID, regResp.UID)
|
||||
|
||||
logf("ensuring bootstrap admin %s @ %s", *flagEmail, *flagBase)
|
||||
adminUID, tokens, err := ensureAdminUser(ctx, mongoConf, tenantID)
|
||||
if err != nil {
|
||||
exitf("ensure admin: %v", err)
|
||||
}
|
||||
logf("bootstrap admin uid=%s", adminUID)
|
||||
|
||||
if *flagDryRun {
|
||||
writeOutput(*flagEmail, *flagPassword, adminUID, tenantID, tokens)
|
||||
return
|
||||
}
|
||||
|
||||
roleID, err := assignAdmin(ctx, mongoConf, tenantID, adminUID)
|
||||
if err != nil {
|
||||
exitf("assign tenant_admin: %v", err)
|
||||
}
|
||||
|
|
@ -135,7 +132,141 @@ func main() {
|
|||
// before callers (e.g. make k6-journey) hit /roles.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
writeOutput(*flagEmail, *flagPassword, regResp.UID, tenantID, tokens)
|
||||
writeOutput(*flagEmail, *flagPassword, adminUID, tenantID, tokens)
|
||||
}
|
||||
|
||||
// ensureAdminUser registers the bootstrap admin or reuses an existing member
|
||||
// when the email is already taken. Mongo is checked first so re-runs do not
|
||||
// depend on ZITADEL password matching -password.
|
||||
func ensureAdminUser(
|
||||
ctx context.Context,
|
||||
conf *libmongo.Conf,
|
||||
tenantID string,
|
||||
) (uid string, tokens *confirmResp, err error) {
|
||||
if member, lookupErr := lookupMemberByEmail(ctx, conf, tenantID, *flagEmail); lookupErr == nil {
|
||||
logf("existing member uid=%s status=%s", member.UID, member.Status)
|
||||
switch member.Status {
|
||||
case memberenum.MemberStatusActive:
|
||||
return member.UID, nil, nil
|
||||
case memberenum.MemberStatusUnverified:
|
||||
return finishUnverifiedRegistration(ctx, member.UID)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("member uid=%s status=%s cannot be bootstrapped", member.UID, member.Status)
|
||||
}
|
||||
}
|
||||
|
||||
uid, tokens, err = registerAndConfirm(ctx, *flagPassword)
|
||||
if err == nil {
|
||||
return uid, tokens, nil
|
||||
}
|
||||
if isPasswordMismatchErr(err) && *flagPassword != legacyAdminPassword {
|
||||
logf("retry register with legacy bootstrap password")
|
||||
uid, tokens, retryErr := registerAndConfirm(ctx, legacyAdminPassword)
|
||||
if retryErr == nil {
|
||||
logf("warn: admin was created with legacy password %q; set ADMIN_PASSWORD=%q to login",
|
||||
legacyAdminPassword, legacyAdminPassword)
|
||||
return uid, tokens, nil
|
||||
}
|
||||
}
|
||||
if !isRecoverableRegisterErr(err) {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
member, lookupErr := lookupMemberByEmail(ctx, conf, tenantID, *flagEmail)
|
||||
if lookupErr != nil {
|
||||
return "", nil, fmt.Errorf(
|
||||
"admin email exists in ZITADEL but no member in Mongo — set ADMIN_PASSWORD to the password from the first seed, or run make k6-down: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if isPasswordMismatchErr(err) {
|
||||
logf("warn: ZITADEL password differs from -password; continuing role assign for uid=%s", member.UID)
|
||||
}
|
||||
logf("reusing member uid=%s status=%s after register conflict", member.UID, member.Status)
|
||||
|
||||
switch member.Status {
|
||||
case memberenum.MemberStatusActive:
|
||||
return member.UID, nil, nil
|
||||
case memberenum.MemberStatusUnverified:
|
||||
return finishUnverifiedRegistration(ctx, member.UID)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("member uid=%s status=%s cannot be bootstrapped", member.UID, member.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func registerAndConfirm(ctx context.Context, password string) (uid string, tokens *confirmResp, err error) {
|
||||
regResp, err := register(ctx, password)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
logf("challenge_id=%s uid=%s", regResp.ChallengeID, regResp.UID)
|
||||
|
||||
code, err := pollOTP(ctx, *flagEmail, time.Duration(*flagPollSecs)*time.Second)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
logf("OTP=%s", code)
|
||||
|
||||
tokens, err = confirm(ctx, regResp.ChallengeID, code)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
logf("registration confirmed; access_token=%d chars", len(tokens.AccessToken))
|
||||
return regResp.UID, tokens, nil
|
||||
}
|
||||
|
||||
func finishUnverifiedRegistration(ctx context.Context, uid string) (string, *confirmResp, error) {
|
||||
regResp, err := resumeRegister(ctx)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("resume unverified registration uid=%s: %w", uid, err)
|
||||
}
|
||||
logf("resumed registration challenge_id=%s uid=%s", regResp.ChallengeID, regResp.UID)
|
||||
|
||||
code, err := pollOTP(ctx, *flagEmail, time.Duration(*flagPollSecs)*time.Second)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
logf("OTP=%s", code)
|
||||
|
||||
tokens, err := confirm(ctx, regResp.ChallengeID, code)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
logf("registration confirmed; access_token=%d chars", len(tokens.AccessToken))
|
||||
if regResp.UID != "" {
|
||||
uid = regResp.UID
|
||||
}
|
||||
return uid, tokens, nil
|
||||
}
|
||||
|
||||
func isRecoverableRegisterErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, fmt.Sprintf("code=%d", authAlreadyRegisteredCode)) ||
|
||||
strings.Contains(msg, fmt.Sprintf("code=%d", authInvalidCredentialsCode)) ||
|
||||
strings.Contains(msg, "already registered") ||
|
||||
strings.Contains(msg, "invalid credentials")
|
||||
}
|
||||
|
||||
func isPasswordMismatchErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, fmt.Sprintf("code=%d", authInvalidCredentialsCode)) ||
|
||||
strings.Contains(msg, "invalid credentials")
|
||||
}
|
||||
|
||||
func lookupMemberByEmail(ctx context.Context, conf *libmongo.Conf, tenantID, email string) (*memberentity.Member, error) {
|
||||
repo := memberrepo.NewMemberRepository(memberrepo.MemberRepositoryParam{Conf: conf})
|
||||
member, err := repo.GetByZitadelEmail(ctx, tenantID, strings.ToLower(strings.TrimSpace(email)))
|
||||
if err != nil || member == nil || member.UID == "" {
|
||||
return nil, fmt.Errorf("member not found for email=%s tenant=%s: %w", email, tenantID, err)
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// broadcastReload publishes a casbin reload event on the same Redis channel
|
||||
|
|
@ -184,13 +315,13 @@ type envelope struct {
|
|||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func register(ctx context.Context) (*registerResp, error) {
|
||||
func register(ctx context.Context, password string) (*registerResp, error) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"tenant_slug": *flagTenant,
|
||||
"invite_code": *flagInvite,
|
||||
"email": *flagEmail,
|
||||
"password": *flagPassword,
|
||||
"display_name": "k6 admin",
|
||||
"password": password,
|
||||
"display_name": "Bootstrap Admin",
|
||||
"language": "zh-TW",
|
||||
"accept_terms_version": "2025-01-01",
|
||||
"marketing_opt_in": false,
|
||||
|
|
@ -206,6 +337,22 @@ func register(ctx context.Context) (*registerResp, error) {
|
|||
return &r, nil
|
||||
}
|
||||
|
||||
func resumeRegister(ctx context.Context) (*registerResp, error) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"tenant_slug": *flagTenant,
|
||||
"email": *flagEmail,
|
||||
})
|
||||
env, err := doJSON(ctx, "POST", *flagBase+"/api/v1/auth/register/resume", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r registerResp
|
||||
if err := json.Unmarshal(env.Data, &r); err != nil {
|
||||
return nil, fmt.Errorf("decode register resume data: %w", err)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func confirm(ctx context.Context, challengeID, code string) (*confirmResp, error) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"tenant_slug": *flagTenant,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
// permission-seed upserts the platform permission catalog and default system
|
||||
// roles for one or more tenants. Safe to re-run (idempotent by key).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
libmongo "gateway/internal/library/mongo"
|
||||
permrepo "gateway/internal/model/permission/repository"
|
||||
permseed "gateway/internal/model/permission/seed"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
var (
|
||||
flagMongoHost = flag.String("mongo-host", envOr("K6_MONGO_HOST", "127.0.0.1"), "Mongo host")
|
||||
flagMongoPort = flag.Int("mongo-port", envOrInt("K6_MONGO_PORT", 27017), "Mongo port")
|
||||
flagMongoDB = flag.String("mongo-db", envOr("K6_MONGO_DB", "gateway_k6"), "Mongo database")
|
||||
flagTenants = flag.String("tenants", envOr("PERM_SEED_TENANTS", "k6-tenant"), "Comma-separated tenant_id list")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
logx.Disable()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tenantIDs := splitCSV(*flagTenants)
|
||||
if len(tenantIDs) == 0 {
|
||||
exitf("no tenants to seed")
|
||||
}
|
||||
|
||||
conf := &libmongo.Conf{
|
||||
Schema: "mongodb",
|
||||
Host: *flagMongoHost,
|
||||
Port: *flagMongoPort,
|
||||
Database: *flagMongoDB,
|
||||
}
|
||||
|
||||
perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: conf})
|
||||
roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: conf})
|
||||
rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: conf})
|
||||
|
||||
rpt, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{
|
||||
TenantIDs: tenantIDs,
|
||||
})
|
||||
if err != nil {
|
||||
exitf("seed: %v", err)
|
||||
}
|
||||
|
||||
logf("tenants=%v catalog=%d roles=%d role_perms=%d",
|
||||
tenantIDs, rpt.CatalogUpserted, rpt.RolesUpserted, rpt.RolePermissionSet)
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func envOr(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envOrInt(k string, def int) int {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func logf(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "[permission-seed] "+format+"\n", a...)
|
||||
}
|
||||
|
||||
func exitf(format string, a ...any) {
|
||||
logf(format, a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -107,8 +107,8 @@ Zitadel:
|
|||
APIBase: http://localhost:8080
|
||||
ServiceUserToken: ""
|
||||
DefaultOrgID: ""
|
||||
OAuthClientID: "374875801008562439"
|
||||
OAuthClientSecret: "Z1SUCIsozer52x2DNhKHXcTZROicf2lFFLLr4pkTZjLXHfkunTzjKYmMk2EHKDch"
|
||||
OAuthClientID: ""
|
||||
OAuthClientSecret: ""
|
||||
GoogleClientID: ""
|
||||
GoogleClientSecret: ""
|
||||
GoogleIdPID: ""
|
||||
|
|
|
|||
|
|
@ -52,13 +52,22 @@ make frontend-dev
|
|||
|
||||
### 取得管理員權限
|
||||
|
||||
```bash
|
||||
make k6-seed-admin
|
||||
# 將輸出的 ADMIN_ACCESS_TOKEN 貼到瀏覽器 — 需自行在 localStorage 設 access_token
|
||||
# 或:用 seed 產生的帳密在 /login 登入(若 ZITADEL password grant 可用)
|
||||
```
|
||||
`make dev-up` 會自動建立原始管理員並寫入 Mongo:
|
||||
|
||||
較簡單做法:完成一般註冊登入後,在 Mongo 手動指派 `tenant_admin`,或跑 `k6-seed-admin` 用新帳號登入。
|
||||
| 欄位 | 值 |
|
||||
|------|-----|
|
||||
| Email | `admin@k6.local` |
|
||||
| 密碼 | `Admin-Pass-1!` |
|
||||
| 租戶 | `k6-tenant` |
|
||||
| 角色 | `tenant_admin` |
|
||||
|
||||
登入後頂部會出現「管理後台」,或在首頁 `/app` 的「權限管理」卡片進入。
|
||||
|
||||
手動重跑 seed(冪等):
|
||||
|
||||
```bash
|
||||
make dev-seed-admin
|
||||
```
|
||||
|
||||
## 環境變數
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ export interface UserRoleList {
|
|||
user_roles: Array<{
|
||||
role_id: string;
|
||||
role_key: string;
|
||||
display_name: string;
|
||||
role_display_name?: string;
|
||||
display_name?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,30 @@ import type { Role } from '../../api/permission';
|
|||
import { ApiError } from '../../api/http';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const UID_PREFIX = 'K6';
|
||||
|
||||
/** 補齊 UID:10000002 → K6-10000002;已是 K6-10000002 則原樣 */
|
||||
function normalizeUid(raw: string): string {
|
||||
const s = raw.trim().toUpperCase();
|
||||
if (/^[A-Z]{2,4}-\d+$/.test(s)) return s;
|
||||
if (/^\d+$/.test(s)) return `${UID_PREFIX}-${s}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function mapUserRoles(
|
||||
rows: permApi.UserRoleList['user_roles'] | undefined,
|
||||
): Array<{ role_id: string; role_key: string; display_name: string }> {
|
||||
return (rows ?? []).map((ur) => ({
|
||||
role_id: ur.role_id,
|
||||
role_key: ur.role_key,
|
||||
display_name: ur.role_display_name ?? ur.display_name ?? ur.role_key,
|
||||
}));
|
||||
}
|
||||
|
||||
export function UserRolesPage() {
|
||||
const { uid: myUid } = useAuth();
|
||||
const [uid, setUid] = useState('');
|
||||
const [searchedUid, setSearchedUid] = useState('');
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [userRoles, setUserRoles] = useState<
|
||||
Array<{ role_id: string; role_key: string; display_name: string }>
|
||||
|
|
@ -19,28 +40,42 @@ export function UserRolesPage() {
|
|||
permApi.listRoles().then((r) => setRoles(r.roles ?? []));
|
||||
};
|
||||
|
||||
const queryUid = (raw: string) => normalizeUid(raw);
|
||||
|
||||
const search = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setMsg('');
|
||||
setSearchedUid('');
|
||||
loadRoleOptions();
|
||||
const targetUid = queryUid(uid);
|
||||
if (targetUid !== uid.trim()) {
|
||||
setUid(targetUid);
|
||||
}
|
||||
try {
|
||||
const r = await permApi.listUserRoles(uid.trim());
|
||||
setUserRoles(r.user_roles ?? []);
|
||||
setMsg('已載入');
|
||||
const r = await permApi.listUserRoles(targetUid);
|
||||
setUserRoles(mapUserRoles(r.user_roles));
|
||||
setSearchedUid(targetUid);
|
||||
setMsg(
|
||||
(r.user_roles ?? []).length > 0
|
||||
? '已載入'
|
||||
: '查詢成功:此使用者目前尚無角色,可直接下方指派',
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : '查詢失敗');
|
||||
setUserRoles([]);
|
||||
setSearchedUid('');
|
||||
}
|
||||
};
|
||||
|
||||
const assign = async () => {
|
||||
if (!assignRoleId) return;
|
||||
if (!assignRoleId || !searchedUid) return;
|
||||
setError('');
|
||||
try {
|
||||
await permApi.assignUserRole(uid.trim(), assignRoleId);
|
||||
const r = await permApi.listUserRoles(uid.trim());
|
||||
setUserRoles(r.user_roles ?? []);
|
||||
await permApi.assignUserRole(searchedUid, assignRoleId);
|
||||
const r = await permApi.listUserRoles(searchedUid);
|
||||
setUserRoles(mapUserRoles(r.user_roles));
|
||||
setAssignRoleId('');
|
||||
setMsg('已指派角色');
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : '指派失敗');
|
||||
|
|
@ -48,12 +83,12 @@ export function UserRolesPage() {
|
|||
};
|
||||
|
||||
const revoke = async (roleId: string) => {
|
||||
if (!confirm('撤銷此角色?')) return;
|
||||
if (!confirm('撤銷此角色?') || !searchedUid) return;
|
||||
setError('');
|
||||
try {
|
||||
await permApi.revokeUserRole(uid.trim(), roleId);
|
||||
const r = await permApi.listUserRoles(uid.trim());
|
||||
setUserRoles(r.user_roles ?? []);
|
||||
await permApi.revokeUserRole(searchedUid, roleId);
|
||||
const r = await permApi.listUserRoles(searchedUid);
|
||||
setUserRoles(mapUserRoles(r.user_roles));
|
||||
setMsg('已撤銷');
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : '撤銷失敗');
|
||||
|
|
@ -64,13 +99,14 @@ export function UserRolesPage() {
|
|||
<div>
|
||||
<h1>使用者角色</h1>
|
||||
<p className="hint">
|
||||
輸入成員 UID(註冊後可在首頁查看)。指派 <code>tenant_admin</code>{' '}
|
||||
或 <code>tenant_owner</code> 後,對方重新登入即可進管理後台。
|
||||
請輸入<strong>完整 UID</strong>(例:<code>K6-10000002</code>,可在對方首頁
|
||||
/app 查看)。只輸入數字如 <code>10000002</code> 也會自動補上前綴。指派{' '}
|
||||
<code>tenant_admin</code> 或 <code>tenant_owner</code> 後,對方重新登入即可進管理後台。
|
||||
</p>
|
||||
|
||||
<form onSubmit={search} className="form form-inline-block">
|
||||
<input
|
||||
placeholder="使用者 UID(例:K6-10000001)"
|
||||
placeholder="使用者 UID(例:K6-10000002 或 10000002)"
|
||||
value={uid}
|
||||
onChange={(e) => setUid(e.target.value)}
|
||||
required
|
||||
|
|
@ -92,25 +128,32 @@ export function UserRolesPage() {
|
|||
{msg && <p className="form-ok">{msg}</p>}
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
|
||||
{userRoles.length > 0 && (
|
||||
{searchedUid && (
|
||||
<>
|
||||
<p className="hint">
|
||||
查詢目標:<code>{searchedUid}</code>
|
||||
</p>
|
||||
<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>
|
||||
{userRoles.length === 0 ? (
|
||||
<p className="muted">尚無指派角色</p>
|
||||
) : (
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
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 { roles, uid, isAdmin } = useAuth();
|
||||
const [me, setMe] = useState<memberApi.MemberMe | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
|
|
@ -64,6 +65,23 @@ export function HomePage() {
|
|||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="card">
|
||||
<h3>權限管理</h3>
|
||||
<p className="hint">您具備管理員身份,可調整角色與權限。</p>
|
||||
<ul className="admin-links">
|
||||
<li>
|
||||
<Link to="/admin/roles">角色管理</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/admin/role-permissions">角色權限設定</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/admin/users">使用者角色指派</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -199,16 +199,20 @@ type TokenResult struct {
|
|||
Email string
|
||||
}
|
||||
|
||||
// VerifyPassword checks email/password credentials. Uses OAuth2 ROPG when OAuthClientID
|
||||
// and OAuthClientSecret are configured; otherwise uses ZITADEL v2 Sessions API (PAT).
|
||||
// VerifyPassword checks email/password credentials. ZITADEL v2 disables the
|
||||
// resource-owner password grant by default, so when a service PAT is configured
|
||||
// we use the Sessions API first. ROPG remains a fallback for legacy instances.
|
||||
func (c *Client) VerifyPassword(ctx context.Context, username, password string) (*TokenResult, error) {
|
||||
if c == nil {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
if c.conf.ServiceUserToken != "" {
|
||||
return c.verifyPasswordSession(ctx, username, password)
|
||||
}
|
||||
if c.conf.OAuthClientID != "" && c.conf.OAuthClientSecret != "" {
|
||||
return c.verifyPasswordROPG(ctx, username, password)
|
||||
}
|
||||
return c.verifyPasswordSession(ctx, username, password)
|
||||
return nil, fmt.Errorf("zitadel: password verification not configured (need service token or oauth client)")
|
||||
}
|
||||
|
||||
func (c *Client) verifyPasswordROPG(ctx context.Context, username, password string) (*TokenResult, error) {
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ func TestVerifyPassword(t *testing.T) {
|
|||
|
||||
c, err := zitadel.NewClient(zitadel.Conf{
|
||||
Issuer: srv.URL,
|
||||
ServiceUserToken: testPAT,
|
||||
OAuthClientID: testClientID,
|
||||
OAuthClientSecret: testSecret,
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue