diff --git a/Makefile b/Makefile index e2a9be4..2f74872 100644 --- a/Makefile +++ b/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" diff --git a/cmd/k6-seed-admin/main.go b/cmd/k6-seed-admin/main.go index a4910a0..71e3506 100644 --- a/cmd/k6-seed-admin/main.go +++ b/cmd/k6-seed-admin/main.go @@ -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, diff --git a/cmd/permission-seed/main.go b/cmd/permission-seed/main.go new file mode 100644 index 0000000..2a757e7 --- /dev/null +++ b/cmd/permission-seed/main.go @@ -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) +} diff --git a/etc/gateway.k6.yaml b/etc/gateway.k6.yaml index 530a242..056f991 100644 --- a/etc/gateway.k6.yaml +++ b/etc/gateway.k6.yaml @@ -107,8 +107,8 @@ Zitadel: APIBase: http://localhost:8080 ServiceUserToken: "" DefaultOrgID: "" - OAuthClientID: "374875801008562439" - OAuthClientSecret: "Z1SUCIsozer52x2DNhKHXcTZROicf2lFFLLr4pkTZjLXHfkunTzjKYmMk2EHKDch" + OAuthClientID: "" + OAuthClientSecret: "" GoogleClientID: "" GoogleClientSecret: "" GoogleIdPID: "" diff --git a/frontend/README.md b/frontend/README.md index a0d3e21..008cd74 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 +``` ## 環境變數 diff --git a/frontend/src/api/permission.ts b/frontend/src/api/permission.ts index 10b159d..0010d47 100644 --- a/frontend/src/api/permission.ts +++ b/frontend/src/api/permission.ts @@ -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; }>; } diff --git a/frontend/src/pages/admin/UserRolesPage.tsx b/frontend/src/pages/admin/UserRolesPage.tsx index f56bb93..424cf5c 100644 --- a/frontend/src/pages/admin/UserRolesPage.tsx +++ b/frontend/src/pages/admin/UserRolesPage.tsx @@ -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([]); 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() {

使用者角色

- 輸入成員 UID(註冊後可在首頁查看)。指派 tenant_admin{' '} - 或 tenant_owner 後,對方重新登入即可進管理後台。 + 請輸入完整 UID(例:K6-10000002,可在對方首頁 + /app 查看)。只輸入數字如 10000002 也會自動補上前綴。指派{' '} + tenant_admintenant_owner 後,對方重新登入即可進管理後台。

setUid(e.target.value)} required @@ -92,25 +128,32 @@ export function UserRolesPage() { {msg &&

{msg}

} {error &&

{error}

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

+ 查詢目標:{searchedUid} +

目前角色

-
    - {userRoles.map((ur) => ( -
  • - - {ur.display_name} ({ur.role_key}) - - -
  • - ))} -
+ {userRoles.length === 0 ? ( +

尚無指派角色

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

指派新角色

diff --git a/frontend/src/pages/user/HomePage.tsx b/frontend/src/pages/user/HomePage.tsx index 23f2856..c3d53fc 100644 --- a/frontend/src/pages/user/HomePage.tsx +++ b/frontend/src/pages/user/HomePage.tsx @@ -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(null); const [error, setError] = useState(''); @@ -64,6 +65,23 @@ export function HomePage() { )}
+ {isAdmin && ( +
+

權限管理

+

您具備管理員身份,可調整角色與權限。

+
    +
  • + 角色管理 +
  • +
  • + 角色權限設定 +
  • +
  • + 使用者角色指派 +
  • +
+
+ )}
); diff --git a/internal/library/zitadel/client.go b/internal/library/zitadel/client.go index 082b9d4..809c485 100644 --- a/internal/library/zitadel/client.go +++ b/internal/library/zitadel/client.go @@ -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) { diff --git a/internal/library/zitadel/client_test.go b/internal/library/zitadel/client_test.go index e8c6040..258470b 100644 --- a/internal/library/zitadel/client_test.go +++ b/internal/library/zitadel/client_test.go @@ -104,7 +104,6 @@ func TestVerifyPassword(t *testing.T) { c, err := zitadel.NewClient(zitadel.Conf{ Issuer: srv.URL, - ServiceUserToken: testPAT, OAuthClientID: testClientID, OAuthClientSecret: testSecret, })