修正 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:
王性驊 2026-05-28 14:45:11 +08:00
parent 43c5a015ca
commit b754a2d07d
10 changed files with 413 additions and 87 deletions

View File

@ -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; \ if [ -s "$(K6_BOOTSTRAP_ENV)" ]; then cat $(K6_BOOTSTRAP_ENV) >> $(K6_ENV_FILE); fi; \
echo "wrote $(K6_ENV_FILE)" echo "wrote $(K6_ENV_FILE)"
@$(MAKE) -s k6-seed-fixtures @$(MAKE) -s k6-seed-fixtures
@$(MAKE) -s k6-seed-permissions
@echo "tip: 'source $(K6_ENV_FILE)' to load into your shell" @echo "tip: 'source $(K6_ENV_FILE)' to load into your shell"
@echo "tip: run 'make dev-restart-gateway' if gateway was already running" @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 # Back-compat alias
k6-seed-tenant: k6-seed-fixtures ## (alias for k6-seed-fixtures) 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 使用 k6-build: ## 建 gateway binary 給 k6 使用
@mkdir -p $(dir $(K6_GATEWAY_BIN)) @mkdir -p $(dir $(K6_GATEWAY_BIN))
$(GO) build -o $(K6_GATEWAY_BIN) ./gateway.go $(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 .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 "==========================================" @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 " ZITADEL 主控台 http://localhost:8080/ui/console"
@echo "" @echo ""
@echo " 註冊預設:租戶 k6-tenant · 邀請碼 K6INVITE" @echo " 註冊預設:租戶 k6-tenant · 邀請碼 K6INVITE"
@echo " 原始管理員admin@k6.local / Admin-Pass-1!dev-up 已自動建立)"
@echo " LDAP 登入alice / Password1!make k6-wait 已自動設定 ZITADEL IdP" @echo " LDAP 登入alice / Password1!make k6-wait 已自動設定 ZITADEL IdP"
@echo "" @echo ""
@echo " 管理後台:make dev-seed-admin 後用輸出的帳密登入" @echo " 管理後台:用 admin@k6.local 登入 → 頂部「管理後台」→ 角色 / 權限"
@echo " 關閉環境make dev-down" @echo " 關閉環境make dev-down"
@echo " 查看狀態make dev-status" @echo " 查看狀態make dev-status"
@echo " OAuth/LDAP 設定變更後make dev-restart-gateway" @echo " OAuth/LDAP 設定變更後make dev-restart-gateway"

View File

@ -11,9 +11,8 @@
// 6. Print ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_UID env exports to stdout so // 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. // 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 // Re-running is safe: register skips when email exists, and seed.Apply /
// challenge is fresh per call), and seed.Apply / UserRole insert are // UserRole insert are idempotent-by-key.
// idempotent-by-key.
package main package main
import ( import (
@ -29,6 +28,8 @@ import (
"time" "time"
libmongo "gateway/internal/library/mongo" 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" memberrepo "gateway/internal/model/member/repository"
permdomain "gateway/internal/model/permission/domain" permdomain "gateway/internal/model/permission/domain"
permentity "gateway/internal/model/permission/domain/entity" permentity "gateway/internal/model/permission/domain/entity"
@ -40,17 +41,22 @@ import (
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )
const (
authAlreadyRegisteredCode = 28303000
authInvalidCredentialsCode = 28501000
legacyAdminPassword = "K6-Admin-Pass-1!"
)
var ( var (
flagBase = flag.String("base", envOr("BASE_URL", "http://localhost:8888"), "Gateway base URL") 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") 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") flagTenant = flag.String("tenant", envOr("TENANT_SLUG", "k6-tenant"), "Tenant slug")
flagInvite = flag.String("invite", envOr("INVITE_CODE", "K6INVITE"), "Invite code") flagInvite = flag.String("invite", envOr("INVITE_CODE", "K6INVITE"), "Invite code")
// Default email is rotated per-invocation. Re-running seed-admin against // Fixed bootstrap admin for dev / frontend. Re-run is idempotent: if the
// a stable email would collide with the existing ZITADEL user (28303000 // email is already registered we look up the UID in Mongo and ensure
// email already registered) since ZITADEL state lives outside docker // tenant_admin is assigned. Override with -email or ADMIN_EMAIL.
// volumes that `make k6-down` clears. Override with -email or ADMIN_EMAIL. flagEmail = flag.String("email", envOr("ADMIN_EMAIL", "admin@k6.local"), "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", "Admin-Pass-1!"), "Admin password")
flagPassword = flag.String("password", envOr("ADMIN_PASSWORD", "K6-Admin-Pass-1!"), "Admin password")
flagMongoHost = flag.String("mongo-host", envOr("K6_MONGO_HOST", "127.0.0.1"), "Mongo host") 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") 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") 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) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() 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{ mongoConf := &libmongo.Conf{
Schema: "mongodb", Schema: "mongodb",
Host: *flagMongoHost, Host: *flagMongoHost,
@ -112,10 +94,25 @@ func main() {
} }
logf("tenant_id=%s", tenantID) logf("tenant_id=%s", tenantID)
if !*flagDryRun {
if err := seedRoles(ctx, mongoConf, tenantID); err != nil { if err := seedRoles(ctx, mongoConf, tenantID); err != nil {
exitf("seed roles: %v", err) 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 { if err != nil {
exitf("assign tenant_admin: %v", err) exitf("assign tenant_admin: %v", err)
} }
@ -135,7 +132,141 @@ func main() {
// before callers (e.g. make k6-journey) hit /roles. // before callers (e.g. make k6-journey) hit /roles.
time.Sleep(500 * time.Millisecond) 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 // broadcastReload publishes a casbin reload event on the same Redis channel
@ -184,13 +315,13 @@ type envelope struct {
Data json.RawMessage `json:"data"` 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{ body, _ := json.Marshal(map[string]any{
"tenant_slug": *flagTenant, "tenant_slug": *flagTenant,
"invite_code": *flagInvite, "invite_code": *flagInvite,
"email": *flagEmail, "email": *flagEmail,
"password": *flagPassword, "password": password,
"display_name": "k6 admin", "display_name": "Bootstrap Admin",
"language": "zh-TW", "language": "zh-TW",
"accept_terms_version": "2025-01-01", "accept_terms_version": "2025-01-01",
"marketing_opt_in": false, "marketing_opt_in": false,
@ -206,6 +337,22 @@ func register(ctx context.Context) (*registerResp, error) {
return &r, nil 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) { func confirm(ctx context.Context, challengeID, code string) (*confirmResp, error) {
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"tenant_slug": *flagTenant, "tenant_slug": *flagTenant,

View File

@ -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)
}

View File

@ -107,8 +107,8 @@ Zitadel:
APIBase: http://localhost:8080 APIBase: http://localhost:8080
ServiceUserToken: "" ServiceUserToken: ""
DefaultOrgID: "" DefaultOrgID: ""
OAuthClientID: "374875801008562439" OAuthClientID: ""
OAuthClientSecret: "Z1SUCIsozer52x2DNhKHXcTZROicf2lFFLLr4pkTZjLXHfkunTzjKYmMk2EHKDch" OAuthClientSecret: ""
GoogleClientID: "" GoogleClientID: ""
GoogleClientSecret: "" GoogleClientSecret: ""
GoogleIdPID: "" GoogleIdPID: ""

View File

@ -52,13 +52,22 @@ make frontend-dev
### 取得管理員權限 ### 取得管理員權限
```bash `make dev-up` 會自動建立原始管理員並寫入 Mongo
make k6-seed-admin
# 將輸出的 ADMIN_ACCESS_TOKEN 貼到瀏覽器 — 需自行在 localStorage 設 access_token
# 或:用 seed 產生的帳密在 /login 登入(若 ZITADEL password grant 可用)
```
較簡單做法:完成一般註冊登入後,在 Mongo 手動指派 `tenant_admin`,或跑 `k6-seed-admin` 用新帳號登入。 | 欄位 | 值 |
|------|-----|
| Email | `admin@k6.local` |
| 密碼 | `Admin-Pass-1!` |
| 租戶 | `k6-tenant` |
| 角色 | `tenant_admin` |
登入後頂部會出現「管理後台」,或在首頁 `/app` 的「權限管理」卡片進入。
手動重跑 seed冪等
```bash
make dev-seed-admin
```
## 環境變數 ## 環境變數

View File

@ -23,7 +23,8 @@ export interface UserRoleList {
user_roles: Array<{ user_roles: Array<{
role_id: string; role_id: string;
role_key: string; role_key: string;
display_name: string; role_display_name?: string;
display_name?: string;
}>; }>;
} }

View File

@ -4,9 +4,30 @@ import type { Role } from '../../api/permission';
import { ApiError } from '../../api/http'; import { ApiError } from '../../api/http';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
const UID_PREFIX = 'K6';
/** 補齊 UID10000002 → 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() { export function UserRolesPage() {
const { uid: myUid } = useAuth(); const { uid: myUid } = useAuth();
const [uid, setUid] = useState(''); const [uid, setUid] = useState('');
const [searchedUid, setSearchedUid] = useState('');
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [userRoles, setUserRoles] = useState< const [userRoles, setUserRoles] = useState<
Array<{ role_id: string; role_key: string; display_name: string }> Array<{ role_id: string; role_key: string; display_name: string }>
@ -19,28 +40,42 @@ export function UserRolesPage() {
permApi.listRoles().then((r) => setRoles(r.roles ?? [])); permApi.listRoles().then((r) => setRoles(r.roles ?? []));
}; };
const queryUid = (raw: string) => normalizeUid(raw);
const search = async (e: FormEvent) => { const search = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setMsg(''); setMsg('');
setSearchedUid('');
loadRoleOptions(); loadRoleOptions();
const targetUid = queryUid(uid);
if (targetUid !== uid.trim()) {
setUid(targetUid);
}
try { try {
const r = await permApi.listUserRoles(uid.trim()); const r = await permApi.listUserRoles(targetUid);
setUserRoles(r.user_roles ?? []); setUserRoles(mapUserRoles(r.user_roles));
setMsg('已載入'); setSearchedUid(targetUid);
setMsg(
(r.user_roles ?? []).length > 0
? '已載入'
: '查詢成功:此使用者目前尚無角色,可直接下方指派',
);
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : '查詢失敗'); setError(e instanceof ApiError ? e.message : '查詢失敗');
setUserRoles([]); setUserRoles([]);
setSearchedUid('');
} }
}; };
const assign = async () => { const assign = async () => {
if (!assignRoleId) return; if (!assignRoleId || !searchedUid) return;
setError(''); setError('');
try { try {
await permApi.assignUserRole(uid.trim(), assignRoleId); await permApi.assignUserRole(searchedUid, assignRoleId);
const r = await permApi.listUserRoles(uid.trim()); const r = await permApi.listUserRoles(searchedUid);
setUserRoles(r.user_roles ?? []); setUserRoles(mapUserRoles(r.user_roles));
setAssignRoleId('');
setMsg('已指派角色'); setMsg('已指派角色');
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : '指派失敗'); setError(e instanceof ApiError ? e.message : '指派失敗');
@ -48,12 +83,12 @@ export function UserRolesPage() {
}; };
const revoke = async (roleId: string) => { const revoke = async (roleId: string) => {
if (!confirm('撤銷此角色?')) return; if (!confirm('撤銷此角色?') || !searchedUid) return;
setError(''); setError('');
try { try {
await permApi.revokeUserRole(uid.trim(), roleId); await permApi.revokeUserRole(searchedUid, roleId);
const r = await permApi.listUserRoles(uid.trim()); const r = await permApi.listUserRoles(searchedUid);
setUserRoles(r.user_roles ?? []); setUserRoles(mapUserRoles(r.user_roles));
setMsg('已撤銷'); setMsg('已撤銷');
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : '撤銷失敗'); setError(e instanceof ApiError ? e.message : '撤銷失敗');
@ -64,13 +99,14 @@ export function UserRolesPage() {
<div> <div>
<h1>使</h1> <h1>使</h1>
<p className="hint"> <p className="hint">
UID <code>tenant_admin</code>{' '} <strong> UID</strong><code>K6-10000002</code>
<code>tenant_owner</code> /app <code>10000002</code> {' '}
<code>tenant_admin</code> <code>tenant_owner</code>
</p> </p>
<form onSubmit={search} className="form form-inline-block"> <form onSubmit={search} className="form form-inline-block">
<input <input
placeholder="使用者 UIDK6-10000001" placeholder="使用者 UIDK6-10000002 或 10000002"
value={uid} value={uid}
onChange={(e) => setUid(e.target.value)} onChange={(e) => setUid(e.target.value)}
required required
@ -92,9 +128,15 @@ export function UserRolesPage() {
{msg && <p className="form-ok">{msg}</p>} {msg && <p className="form-ok">{msg}</p>}
{error && <p className="form-error">{error}</p>} {error && <p className="form-error">{error}</p>}
{userRoles.length > 0 && ( {searchedUid && (
<> <>
<p className="hint">
<code>{searchedUid}</code>
</p>
<h2></h2> <h2></h2>
{userRoles.length === 0 ? (
<p className="muted"></p>
) : (
<ul className="role-assign-list"> <ul className="role-assign-list">
{userRoles.map((ur) => ( {userRoles.map((ur) => (
<li key={ur.role_id}> <li key={ur.role_id}>
@ -111,6 +153,7 @@ export function UserRolesPage() {
</li> </li>
))} ))}
</ul> </ul>
)}
<h2></h2> <h2></h2>
<div className="form-inline-block"> <div className="form-inline-block">

View File

@ -1,10 +1,11 @@
import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import * as memberApi from '../../api/member'; import * as memberApi from '../../api/member';
import { ApiError } from '../../api/http'; import { ApiError } from '../../api/http';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
export function HomePage() { export function HomePage() {
const { roles, uid } = useAuth(); const { roles, uid, isAdmin } = useAuth();
const [me, setMe] = useState<memberApi.MemberMe | null>(null); const [me, setMe] = useState<memberApi.MemberMe | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -64,6 +65,23 @@ export function HomePage() {
)} )}
</ul> </ul>
</div> </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>
</div> </div>
); );

View File

@ -199,16 +199,20 @@ type TokenResult struct {
Email string Email string
} }
// VerifyPassword checks email/password credentials. Uses OAuth2 ROPG when OAuthClientID // VerifyPassword checks email/password credentials. ZITADEL v2 disables the
// and OAuthClientSecret are configured; otherwise uses ZITADEL v2 Sessions API (PAT). // 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) { 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.ServiceUserToken != "" {
return c.verifyPasswordSession(ctx, username, password)
}
if c.conf.OAuthClientID != "" && c.conf.OAuthClientSecret != "" { if c.conf.OAuthClientID != "" && c.conf.OAuthClientSecret != "" {
return c.verifyPasswordROPG(ctx, username, password) 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) { func (c *Client) verifyPasswordROPG(ctx context.Context, username, password string) (*TokenResult, error) {

View File

@ -104,7 +104,6 @@ func TestVerifyPassword(t *testing.T) {
c, err := zitadel.NewClient(zitadel.Conf{ c, err := zitadel.NewClient(zitadel.Conf{
Issuer: srv.URL, Issuer: srv.URL,
ServiceUserToken: testPAT,
OAuthClientID: testClientID, OAuthClientID: testClientID,
OAuthClientSecret: testSecret, OAuthClientSecret: testSecret,
}) })