diff --git a/.github/workflows/ghcr-k3s-cicd.yml b/.github/workflows/ghcr-k3s-cicd.yml index 921d3506..39e39956 100644 --- a/.github/workflows/ghcr-k3s-cicd.yml +++ b/.github/workflows/ghcr-k3s-cicd.yml @@ -290,6 +290,7 @@ jobs: spec: nodeSelector: kubernetes.io/arch: amd64 + kubernetes.io/hostname: hdtbcs-ovh1 imagePullSecrets: - name: ${PULL_SECRET} containers: @@ -379,6 +380,7 @@ jobs: spec: nodeSelector: kubernetes.io/arch: amd64 + kubernetes.io/hostname: hdtbcs-ovh1 imagePullSecrets: - name: ${PULL_SECRET} containers: diff --git a/assets/brand/built with HollowData's tech.svg b/assets/brand/built with HollowData's tech.svg new file mode 100644 index 00000000..2d23e82e --- /dev/null +++ b/assets/brand/built with HollowData's tech.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/basaltpass-backend/cmd/infopipe-seed/main.go b/basaltpass-backend/cmd/infopipe-seed/main.go new file mode 100644 index 00000000..7ffb2e1e --- /dev/null +++ b/basaltpass-backend/cmd/infopipe-seed/main.go @@ -0,0 +1,289 @@ +package main + +import ( + "basaltpass-backend/internal/common" + config "basaltpass-backend/internal/config" + migration "basaltpass-backend/internal/migration" + "basaltpass-backend/internal/model" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type serviceDef struct { + ID string + Name string + Description string + BaseURL string + Scopes []string +} + +type seededClient struct { + AppID uint `json:"app_id"` + Name string `json:"name"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + Scopes []string `json:"scopes"` +} + +type report struct { + TenantID uint `json:"tenant_id"` + TenantCode string `json:"tenant_code"` + AdminUserID uint `json:"admin_user_id"` + Clients []seededClient `json:"clients"` + CrossAppTrusts []string `json:"cross_app_trusts"` + EnvPath string `json:"env_path"` + SeededAt time.Time `json:"seeded_at"` +} + +func main() { + var ( + configPath = flag.String("config", "", "optional BasaltPass config path") + envPath = flag.String("env-out", "", "optional env output path") + ) + flag.Parse() + + if _, err := config.Load(*configPath); err != nil { + log.Fatalf("load config: %v", err) + } + if err := migration.RunMigrations(); err != nil { + log.Fatalf("run migrations: %v", err) + } + + outPath := strings.TrimSpace(*envPath) + if outPath == "" { + outPath = filepath.Clean(filepath.Join("..", "..", "integration", "secrets", "basaltpass.infopipe.env")) + } + + rep, err := seed(common.DB(), outPath) + if err != nil { + log.Fatalf("seed infopipe apps: %v", err) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(rep); err != nil { + log.Fatalf("encode report: %v", err) + } +} + +func seed(db *gorm.DB, envPath string) (*report, error) { + if db == nil { + return nil, fmt.Errorf("database is not available") + } + now := time.Now().UTC() + tenant := model.Tenant{ + Name: "Info Pipeline", + Code: "infopipe", + Description: "Local integration tenant for the information pipeline.", + Status: model.TenantStatusActive, + Metadata: model.JSONMap{"managed_by": "infopipe-seed"}, + } + if err := db.Where("code = ?", tenant.Code).Assign(tenant).FirstOrCreate(&tenant).Error; err != nil { + return nil, fmt.Errorf("upsert tenant: %w", err) + } + + admin := model.User{ + Email: "infopipe-admin@basalt.local", + PasswordHash: mustHash("InfoPipe@12345"), + Nickname: "infopipe-admin", + EmailVerified: true, + } + isAdmin := true + admin.IsSystemAdmin = &isAdmin + if err := db.Where("email = ? AND enforced_tenant_id = ?", admin.Email, 0).Assign(admin).FirstOrCreate(&admin).Error; err != nil { + return nil, fmt.Errorf("upsert admin user: %w", err) + } + if err := db.Where("tenant_id = ? AND user_id = ?", tenant.ID, admin.ID). + Assign(model.TenantUser{Role: model.TenantRoleOwner}). + FirstOrCreate(&model.TenantUser{TenantID: tenant.ID, UserID: admin.ID, Role: model.TenantRoleOwner}).Error; err != nil { + return nil, fmt.Errorf("upsert tenant owner: %w", err) + } + + services := []serviceDef{ + {"orion", "Orion", "Mission control and review service.", "http://orion:8120", []string{"s2s.read", "s2s.token_exchange", "apicred.llm", "llm.plan_mission", "llm.generate_product_spec", "llm.review_artifact", "llm.summarize_evidence"}}, + {"vesper", "Vesper", "Analysis and brief generation service.", "http://vesper:8121", []string{"s2s.read", "s2s.token_exchange", "apicred.llm", "llm.extract_entities", "llm.extract_claims", "llm.summarize_event", "llm.generate_brief"}}, + {"cis", "CIS", "Source planning service.", "http://cis:8195", []string{"s2s.read", "s2s.token_exchange", "apicred.llm", "cis.source_plan", "araneae.read", "araneae.write", "hashslip.read", "hashslip.write"}}, + {"docode", "DoCode", "Crawler and code generation service.", "http://docode:8110", []string{"s2s.read", "s2s.token_exchange", "docode.jobs", "docode.write", "llm", "apicred.read"}}, + {"hashslip", "HashSlip", "Fact and artifact store.", "http://hashslip:8106", []string{"s2s.read", "hashslip.read", "hashslip.write"}}, + {"araneae", "Araneae", "Crawler execution service.", "http://araneae-control:8180", []string{"s2s.read", "araneae.read", "araneae.write", "hashslip.write"}}, + {"apicred", "APICred", "LLM capability and provider gateway.", "http://apicred:8103", []string{"s2s.read", "llm", "apicred.read", "llm.gateway", "llm.grants"}}, + {"atlas", "Atlas", "Graph projection service.", "http://atlas:8130", []string{"s2s.read", "hashslip.read", "atlas.read"}}, + {"forge", "Forge", "Replay and evaluation service.", "http://forge:8150", []string{"s2s.read", "hashslip.read", "forge.eval"}}, + {"dispatch", "Dispatch", "Artifact delivery service.", "http://dispatch:8140", []string{"s2s.read", "hashslip.read", "atlas.read", "dispatch.read"}}, + } + + appIDs := map[string]uint{} + clients := []seededClient{} + for _, svc := range services { + app := model.App{ + TenantID: tenant.ID, + Name: svc.Name, + Description: svc.Description, + HomepageURL: svc.BaseURL, + Status: model.AppStatusActive, + IsVerified: true, + } + if err := db.Where("tenant_id = ? AND name = ?", tenant.ID, svc.Name).Assign(app).FirstOrCreate(&app).Error; err != nil { + return nil, fmt.Errorf("upsert app %s: %w", svc.ID, err) + } + appIDs[svc.ID] = app.ID + appUser := model.AppUser{ + AppID: app.ID, + UserID: admin.ID, + FirstAuthorizedAt: now, + LastAuthorizedAt: now, + Scopes: strings.Join(svc.Scopes, " "), + Status: model.AppUserStatusActive, + } + if err := db.Where("app_id = ? AND user_id = ?", app.ID, admin.ID). + Assign(appUser). + FirstOrCreate(&model.AppUser{AppID: app.ID, UserID: admin.ID, FirstAuthorizedAt: now, LastAuthorizedAt: now, Scopes: appUser.Scopes, Status: model.AppUserStatusActive}).Error; err != nil { + return nil, fmt.Errorf("authorize admin for app %s: %w", svc.ID, err) + } + + secret := "bp-infopipe-" + svc.ID + "-secret" + client := model.OAuthClient{ + AppID: app.ID, + ClientID: "infopipe-" + svc.ID, + ClientSecret: secret, + TokenEndpointAuthMethod: model.OAuthTokenEndpointAuthClientSecretPost, + SubjectType: model.OAuthSubjectTypePublic, + RedirectURIs: "http://localhost/callback", + Scopes: strings.Join(svc.Scopes, ","), + GrantTypes: "client_credentials,authorization_code,refresh_token,urn:ietf:params:oauth:grant-type:token-exchange", + IsActive: true, + AllowedOrigins: "http://localhost", + CreatedBy: admin.ID, + } + client.HashClientSecret() + var existing model.OAuthClient + if err := db.Where("client_id = ?", client.ClientID).First(&existing).Error; err == nil { + client.ID = existing.ID + client.Model = existing.Model + if err := db.Model(&existing).Updates(map[string]any{ + "app_id": client.AppID, + "client_secret": client.ClientSecret, + "token_endpoint_auth_method": client.TokenEndpointAuthMethod, + "subject_type": client.SubjectType, + "redirect_uris": client.RedirectURIs, + "scopes": client.Scopes, + "grant_types": client.GrantTypes, + "is_active": true, + "allowed_origins": client.AllowedOrigins, + "created_by": admin.ID, + }).Error; err != nil { + return nil, fmt.Errorf("update client %s: %w", client.ClientID, err) + } + } else if err == gorm.ErrRecordNotFound { + if err := db.Create(&client).Error; err != nil { + return nil, fmt.Errorf("create client %s: %w", client.ClientID, err) + } + } else { + return nil, fmt.Errorf("lookup client %s: %w", client.ClientID, err) + } + clients = append(clients, seededClient{ + AppID: app.ID, + Name: svc.Name, + ClientID: "infopipe-" + svc.ID, + ClientSecret: secret, + Scopes: svc.Scopes, + }) + } + + trustPairs := [][2]string{ + {"orion", "cis"}, {"orion", "vesper"}, {"orion", "hashslip"}, {"orion", "apicred"}, {"orion", "dispatch"}, {"orion", "atlas"}, + {"vesper", "hashslip"}, {"vesper", "apicred"}, {"vesper", "atlas"}, + {"cis", "araneae"}, {"cis", "hashslip"}, {"cis", "apicred"}, {"cis", "docode"}, + {"docode", "apicred"}, + {"araneae", "hashslip"}, + {"forge", "orion"}, {"forge", "vesper"}, {"forge", "hashslip"}, {"forge", "atlas"}, + {"dispatch", "hashslip"}, {"dispatch", "atlas"}, + } + trustNames := []string{} + for _, pair := range trustPairs { + sourceID, targetID := appIDs[pair[0]], appIDs[pair[1]] + trust := model.CrossAppTrust{ + TenantID: tenant.ID, + SourceAppID: sourceID, + TargetAppID: targetID, + AllowedScopes: "s2s.read,s2s.token_exchange,apicred.llm,llm,apicred.read,docode.jobs,docode.write,hashslip.read,hashslip.write,araneae.read,araneae.write,llm.gateway,llm.grants,atlas.read,forge.eval,dispatch.read", + MaxTokenTTL: 3600, + Description: "Info pipeline local integration trust: " + pair[0] + " -> " + pair[1], + IsActive: true, + CreatedBy: admin.ID, + } + if err := db.Where("tenant_id = ? AND source_app_id = ? AND target_app_id = ?", tenant.ID, sourceID, targetID). + Assign(trust).FirstOrCreate(&trust).Error; err != nil { + return nil, fmt.Errorf("upsert trust %s->%s: %w", pair[0], pair[1], err) + } + trustNames = append(trustNames, pair[0]+"->"+pair[1]) + } + sort.Strings(trustNames) + + rep := &report{ + TenantID: tenant.ID, + TenantCode: tenant.Code, + AdminUserID: admin.ID, + Clients: clients, + CrossAppTrusts: trustNames, + EnvPath: envPath, + SeededAt: now, + } + if err := writeEnv(envPath, clients); err != nil { + return nil, err + } + return rep, nil +} + +func writeEnv(path string, clients []seededClient) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create env dir: %w", err) + } + values := map[string]string{ + "AUTH_PROVIDER_MODE": "real", + "BASALTPASS_PROVIDER_MODE": "real", + "BASALTPASS_BASE_URL": "http://127.0.0.1:8101", + "BASALTPASS_DOCKER_URL": "http://host.docker.internal:8101", + "USE_MOCK_BASALTPASS": "false", + "APICRED_REQUIRE_REAL_PROVIDER": "false", + } + for _, client := range clients { + key := strings.ToUpper(strings.ReplaceAll(client.Name, " ", "_")) + values[key+"_CLIENT_ID"] = client.ClientID + values[key+"_CLIENT_SECRET"] = client.ClientSecret + } + order := make([]string, 0, len(values)) + for key := range values { + order = append(order, key) + } + sort.Strings(order) + var b strings.Builder + b.WriteString("# Generated by BasaltPass infopipe-seed. Local integration credentials only.\n") + for _, key := range order { + b.WriteString(key) + b.WriteString("=") + b.WriteString(values[key]) + b.WriteString("\n") + } + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + return fmt.Errorf("write env: %w", err) + } + return nil +} + +func mustHash(password string) string { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + panic(err) + } + return string(hash) +} diff --git a/basaltpass-backend/config/settings.yaml b/basaltpass-backend/config/settings.yaml index a9054826..c6f9d181 100644 --- a/basaltpass-backend/config/settings.yaml +++ b/basaltpass-backend/config/settings.yaml @@ -292,6 +292,7 @@ settings: - openid - profile - email + - groups - offline_access - s2s.read - s2s.user.read diff --git a/basaltpass-backend/internal/api/v1/routes/public.go b/basaltpass-backend/internal/api/v1/routes/public.go index 3fe3d84b..3f4ce9de 100644 --- a/basaltpass-backend/internal/api/v1/routes/public.go +++ b/basaltpass-backend/internal/api/v1/routes/public.go @@ -7,6 +7,7 @@ import ( publicSettings "basaltpass-backend/internal/handler/public/settings" "basaltpass-backend/internal/handler/public/signup" publicTenant "basaltpass-backend/internal/handler/public/tenant" + "basaltpass-backend/internal/handler/public/trust" "basaltpass-backend/internal/middleware/ratelimit" "github.com/gofiber/fiber/v2" @@ -18,6 +19,8 @@ func InitPublicRouteDependencies(_ *gorm.DB) {} // RegisterPublicRoutes 注册公开路由(无需认证) func RegisterPublicRoutes(v1 fiber.Router) { + v1.Post("/trust/token", trust.IssueTrustTokenHandler) + // 健康检查端点 v1.Get("/health", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) diff --git a/basaltpass-backend/internal/handler/public/oauth/discovery.go b/basaltpass-backend/internal/handler/public/oauth/discovery.go index 6883da3f..fb6a6af0 100644 --- a/basaltpass-backend/internal/handler/public/oauth/discovery.go +++ b/basaltpass-backend/internal/handler/public/oauth/discovery.go @@ -79,6 +79,7 @@ func DiscoveryHandler(c *fiber.Ctx) error { "email", "address", "phone", + "groups", "offline_access", }, ResponseTypesSupported: []string{ @@ -141,6 +142,7 @@ func DiscoveryHandler(c *fiber.Ctx) error { "birthdate", "picture", "preferred_username", + "groups", "locale", "zoneinfo", "updated_at", diff --git a/basaltpass-backend/internal/handler/public/oauth/oidc_e2e_test.go b/basaltpass-backend/internal/handler/public/oauth/oidc_e2e_test.go index 0e5d3206..6baa0e2e 100644 --- a/basaltpass-backend/internal/handler/public/oauth/oidc_e2e_test.go +++ b/basaltpass-backend/internal/handler/public/oauth/oidc_e2e_test.go @@ -36,7 +36,7 @@ func setupOIDCE2EDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("open sqlite failed: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.Tenant{}, &model.TenantUser{}, &model.Gender{}, &model.UserProfile{}, &model.App{}, &model.AppUser{}, &model.OAuthClient{}, &model.OAuthAuthorizationCode{}, &model.OAuthAccessToken{}, &model.OAuthRefreshToken{}, &model.OIDCSigningKey{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.Role{}, &model.UserRole{}, &model.Tenant{}, &model.TenantUser{}, &model.TenantRbacRole{}, &model.TenantUserRbacRole{}, &model.Gender{}, &model.UserProfile{}, &model.App{}, &model.AppUser{}, &model.OAuthClient{}, &model.OAuthAuthorizationCode{}, &model.OAuthAccessToken{}, &model.OAuthRefreshToken{}, &model.OIDCSigningKey{}); err != nil { t.Fatalf("auto migrate failed: %v", err) } common.SetDBForTest(db) @@ -62,6 +62,8 @@ func TestOIDCAuthCodePKCEIssuesIDTokenAndJWKSVerifies(t *testing.T) { AvatarURL: "https://rp.example/avatar.png", EmailVerified: true, } + isSystemAdmin := true + user.IsSystemAdmin = &isSystemAdmin if err := db.Create(&user).Error; err != nil { t.Fatalf("create user: %v", err) } @@ -74,7 +76,7 @@ func TestOIDCAuthCodePKCEIssuesIDTokenAndJWKSVerifies(t *testing.T) { } client := model.OAuthClient{AppID: app.ID, ClientID: "oidc-client", ClientSecret: "oidc-secret", RedirectURIs: "https://rp.example/callback", IsActive: true} client.HashClientSecret() - client.SetScopeList([]string{"openid", "profile", "email", "offline_access"}) + client.SetScopeList([]string{"openid", "profile", "email", "groups", "offline_access"}) if err := db.Create(&client).Error; err != nil { t.Fatalf("create client: %v", err) } @@ -82,7 +84,7 @@ func TestOIDCAuthCodePKCEIssuesIDTokenAndJWKSVerifies(t *testing.T) { svc := NewOAuthServerService() verifier := "verifier-123" challenge := base64.RawURLEncoding.EncodeToString(sha256sum(verifier)) - authReq := &AuthorizeRequest{ClientID: client.ClientID, RedirectURI: "https://rp.example/callback", ResponseType: "code", Scope: "openid profile email offline_access", CodeChallenge: challenge, CodeChallengeMethod: "S256", Nonce: "nonce-xyz"} + authReq := &AuthorizeRequest{ClientID: client.ClientID, RedirectURI: "https://rp.example/callback", ResponseType: "code", Scope: "openid profile email groups offline_access", CodeChallenge: challenge, CodeChallengeMethod: "S256", Nonce: "nonce-xyz"} code, err := svc.GenerateAuthorizationCode(user.ID, authReq, &client) if err != nil { t.Fatalf("generate code: %v", err) @@ -124,10 +126,14 @@ func TestOIDCAuthCodePKCEIssuesIDTokenAndJWKSVerifies(t *testing.T) { t.Fatalf("expected email claims in id_token: email=%v email_verified=%v", claims["email"], claims["email_verified"]) } for _, claim := range []string{"name", "preferred_username", "given_name", "family_name", "middle_name", "picture"} { - if _, exists := claims[claim]; exists { - t.Fatalf("code-flow id_token should leave %s to userinfo, claims=%v", claim, claims) + if _, exists := claims[claim]; !exists { + t.Fatalf("code-flow id_token should include %s for automatic RP onboarding, claims=%v", claim, claims) } } + groups := stringSliceClaim(claims["groups"]) + if !containsString(groups, "basaltpass-system-admin") || !containsString(groups, "tenant:oidc:member") { + t.Fatalf("expected system and tenant groups in id_token, groups=%v", groups) + } if claims["acr"] == "" || len(stringSliceClaim(claims["amr"])) == 0 { t.Fatalf("expected acr/amr claims: acr=%v amr=%v", claims["acr"], claims["amr"]) } diff --git a/basaltpass-backend/internal/handler/public/oauth/server_handler.go b/basaltpass-backend/internal/handler/public/oauth/server_handler.go index d71723dc..482a496b 100644 --- a/basaltpass-backend/internal/handler/public/oauth/server_handler.go +++ b/basaltpass-backend/internal/handler/public/oauth/server_handler.go @@ -496,6 +496,8 @@ func TokenHandler(c *fiber.Ctx) error { grantType := c.FormValue("grant_type") switch grantType { + case "client_credentials": + return handleClientCredentialsGrant(c) case "authorization_code": return handleAuthorizationCodeGrant(c) case "refresh_token": @@ -515,6 +517,161 @@ func setOAuthTokenResponseHeaders(c *fiber.Ctx) { c.Set("Pragma", "no-cache") } +func handleClientCredentialsGrant(c *fiber.Ctx) error { + client, err := authenticateOAuthClientForEndpoint(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid_client", + "error_description": "Client authentication failed", + }) + } + if !clientAllowsGrant(*client, "client_credentials") { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "unauthorized_client", + "error_description": "Client is not allowed to use client_credentials", + }) + } + + scope := strings.TrimSpace(c.FormValue("scope")) + if scope == "" { + scope = strings.ReplaceAll(client.Scopes, ",", " ") + } + grantedScopes := strings.Join(intersectScopes(scope, client.Scopes), " ") + if grantedScopes == "" { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "insufficient_scope", + "error_description": "No requested scopes are allowed for this client", + }) + } + + tenantID := oauthServerService.resolveClientTenantID(client) + if tenantID == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid_client", + "error_description": "Client tenant is unavailable", + }) + } + + userID, err := serviceSubjectUserID(client.AppID, tenantID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "server_error", + "error_description": "Failed to resolve service subject", + }) + } + + tokenStr, err := model.GenerateAccessToken() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "server_error", + "error_description": "Failed to generate token", + }) + } + + if err := common.DB().Create(&model.OAuthAccessToken{ + Token: tokenStr, + ClientID: client.ClientID, + UserID: userID, + TenantID: tenantID, + AppID: client.AppID, + Scopes: grantedScopes, + ExpiresAt: time.Now().Add(1 * time.Hour), + }).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "server_error", + "error_description": "Failed to persist token", + }) + } + + now := time.Now() + common.DB().Model(&client).Update("last_used_at", &now) + return c.JSON(fiber.Map{ + "access_token": tokenStr, + "token_type": "Bearer", + "expires_in": 3600, + "scope": grantedScopes, + }) +} + +func intersectScopes(requestedStr string, allowedStr string) []string { + requested := splitScopeText(requestedStr) + allowed := splitScopeText(allowedStr) + allowedSet := make(map[string]struct{}, len(allowed)) + for _, scope := range allowed { + allowedSet[scope] = struct{}{} + } + if _, ok := allowedSet["*"]; ok { + return requested + } + out := make([]string, 0, len(requested)) + for _, scope := range requested { + if _, ok := allowedSet[scope]; ok { + out = append(out, scope) + } + } + return out +} + +func splitScopeText(value string) []string { + value = strings.ReplaceAll(value, ",", " ") + fields := strings.Fields(value) + out := make([]string, 0, len(fields)) + seen := map[string]struct{}{} + for _, field := range fields { + scope := strings.TrimSpace(field) + if scope == "" { + continue + } + if _, ok := seen[scope]; ok { + continue + } + seen[scope] = struct{}{} + out = append(out, scope) + } + return out +} + +func serviceSubjectUserID(appID uint, tenantID uint) (uint, error) { + email := "service-app-" + strconv.FormatUint(uint64(appID), 10) + "@basalt.local" + user := model.User{ + Email: email, + Nickname: email, + EmailVerified: true, + } + if err := common.DB().Where("email = ? AND enforced_tenant_id = ?", email, 0).Assign(user).FirstOrCreate(&user).Error; err != nil { + return 0, err + } + if err := common.DB().Where("tenant_id = ? AND user_id = ?", tenantID, user.ID). + Assign(model.TenantUser{Role: model.TenantRoleMember}). + FirstOrCreate(&model.TenantUser{TenantID: tenantID, UserID: user.ID, Role: model.TenantRoleMember}).Error; err != nil { + return 0, err + } + var apps []model.App + if err := common.DB().Where("tenant_id = ? AND status = ?", tenantID, model.AppStatusActive).Find(&apps).Error; err != nil { + return 0, err + } + for _, app := range apps { + if err := common.DB().Where("app_id = ? AND user_id = ?", app.ID, user.ID). + Assign(model.AppUser{ + FirstAuthorizedAt: time.Now(), + LastAuthorizedAt: time.Now(), + Scopes: "*", + Status: model.AppUserStatusActive, + }). + FirstOrCreate(&model.AppUser{ + AppID: app.ID, + UserID: user.ID, + FirstAuthorizedAt: time.Now(), + LastAuthorizedAt: time.Now(), + Scopes: "*", + Status: model.AppUserStatusActive, + }).Error; err != nil { + return 0, err + } + } + return user.ID, nil +} + // handleAuthorizationCodeGrant 处理授权码授权 func handleAuthorizationCodeGrant(c *fiber.Ctx) error { // 解析令牌请求 diff --git a/basaltpass-backend/internal/handler/public/oauth/server_service.go b/basaltpass-backend/internal/handler/public/oauth/server_service.go index 1db2381d..26896fe5 100644 --- a/basaltpass-backend/internal/handler/public/oauth/server_service.go +++ b/basaltpass-backend/internal/handler/public/oauth/server_service.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "net/url" + "sort" "strconv" "strings" "time" @@ -474,6 +475,25 @@ func (s *OAuthServerService) buildIDToken(clientID string, userID uint, nonce st if nonce != "" { claims["nonce"] = nonce } + if hasScope(scopes, "profile") { + var user model.User + if err := s.db.First(&user, userID).Error; err != nil { + return "", err + } + displayName := oidcDisplayName(user, sub) + givenName, familyName, middleName := oidcNameParts(user, displayName) + claims["name"] = displayName + claims["nickname"] = oidcNickname(user, displayName) + claims["preferred_username"] = oidcPreferredUsername(user, sub) + claims["given_name"] = givenName + claims["family_name"] = familyName + if middleName != "" { + claims["middle_name"] = middleName + } + if user.AvatarURL != "" { + claims["picture"] = user.AvatarURL + } + } if hasScope(scopes, "email") { var user model.User if err := s.db.Select("email", "email_verified").First(&user, userID).Error; err != nil { @@ -484,12 +504,92 @@ func (s *OAuthServerService) buildIDToken(clientID string, userID uint, nonce st claims["email_verified"] = user.EmailVerified } } + if hasScope(scopes, "groups") { + groups, err := s.oidcGroups(clientID, userID) + if err != nil { + return "", err + } + claims["groups"] = groups + } token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) token.Header["kid"] = GetKeyID() return token.SignedString(privateKey) } +// oidcGroups returns stable, tenant-aware group names suitable for relying +// parties such as Harbor. System administrators receive a dedicated group +// that can be mapped directly to an administrator group at the RP. +func (s *OAuthServerService) oidcGroups(clientID string, userID uint) ([]string, error) { + groups := make([]string, 0, 4) + seen := map[string]struct{}{} + add := func(group string) { + group = strings.TrimSpace(group) + if group == "" { + return + } + if _, ok := seen[group]; ok { + return + } + seen[group] = struct{}{} + groups = append(groups, group) + } + + var user model.User + if err := s.db.Select("id", "is_system_admin").First(&user, userID).Error; err != nil { + return nil, err + } + if user.IsSuperAdmin() { + add("basaltpass-system-admin") + } + + var globalRoleCodes []string + if err := s.db.Table("system_auth_roles"). + Select("system_auth_roles.code"). + Joins("JOIN system_auth_user_roles ON system_auth_user_roles.role_id = system_auth_roles.id"). + Where("system_auth_user_roles.user_id = ?", userID). + Pluck("system_auth_roles.code", &globalRoleCodes).Error; err != nil { + return nil, err + } + for _, code := range globalRoleCodes { + add("role:" + code) + } + + var client model.OAuthClient + if err := s.db.Select("client_id", "app_id").Where("client_id = ?", clientID).First(&client).Error; err != nil { + return nil, err + } + tenantID := s.resolveClientTenantID(&client) + if tenantID > 0 { + var tenant model.Tenant + if err := s.db.Select("id", "code").First(&tenant, tenantID).Error; err != nil { + return nil, err + } + prefix := "tenant:" + strings.TrimSpace(tenant.Code) + ":" + var membership model.TenantUser + if err := s.db.Where("user_id = ? AND tenant_id = ?", userID, tenantID).First(&membership).Error; err == nil { + add(prefix + string(membership.Role)) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + var tenantRoleCodes []string + if err := s.db.Table("tenant_roles"). + Select("tenant_roles.code"). + Joins("JOIN tenant_user_roles ON tenant_user_roles.role_id = tenant_roles.id"). + Where("tenant_user_roles.user_id = ? AND tenant_user_roles.tenant_id = ?", userID, tenantID). + Pluck("tenant_roles.code", &tenantRoleCodes).Error; err != nil { + return nil, err + } + for _, code := range tenantRoleCodes { + add(prefix + "role:" + code) + } + } + + sort.Strings(groups) + return groups, nil +} + func oidcDisplayName(user model.User, sub string) string { if name := strings.TrimSpace(user.Nickname); name != "" { return name @@ -512,6 +612,19 @@ func oidcDisplayName(user model.User, sub string) string { return sub } +func oidcPreferredUsername(user model.User, sub string) string { + if email := strings.TrimSpace(user.Email); email != "" { + if at := strings.Index(email, "@"); at > 0 { + return email[:at] + } + return email + } + if username := strings.TrimSpace(user.Nickname); username != "" { + return username + } + return sub +} + func oidcNickname(user model.User, fallback string) string { if nickname := strings.TrimSpace(user.Nickname); nickname != "" { return nickname @@ -1042,7 +1155,7 @@ func (s *OAuthServerService) GetUserInfo(token string) (*UserInfoResponse, error if hasScope(oauthToken.Scopes, "profile") { response.Nickname = oidcNickname(user, displayName) response.NickName = response.Nickname - response.PreferredUsername = displayName + response.PreferredUsername = oidcPreferredUsername(user, response.Sub) response.GivenName = givenName response.FamilyName = familyName response.MiddleName = middleName @@ -1065,6 +1178,13 @@ func (s *OAuthServerService) GetUserInfo(token string) (*UserInfoResponse, error response.Address = address } } + if hasScope(oauthToken.Scopes, "groups") { + groups, err := s.oidcGroups(oauthToken.ClientID, user.ID) + if err != nil { + return nil, err + } + response.Groups = groups + } return response, nil } @@ -1161,6 +1281,7 @@ type UserInfoResponse struct { Address *OIDCAddressClaim `json:"address,omitempty"` // 地址 Picture string `json:"picture,omitempty"` // 头像 UpdatedAt int64 `json:"updated_at"` // 更新时间 + Groups []string `json:"groups,omitempty"` // 用户所属角色组 } type OIDCAddressClaim struct { diff --git a/basaltpass-backend/internal/handler/public/trust/handler.go b/basaltpass-backend/internal/handler/public/trust/handler.go new file mode 100644 index 00000000..cda1874f --- /dev/null +++ b/basaltpass-backend/internal/handler/public/trust/handler.go @@ -0,0 +1,88 @@ +package trust + +import ( + "basaltpass-backend/internal/common" + "basaltpass-backend/internal/model" + "crypto/rand" + "encoding/hex" + "strings" + "time" + + "github.com/gofiber/fiber/v2" +) + +type tokenRequest struct { + ServiceID string `json:"service_id"` + Scopes []string `json:"scopes"` +} + +// IssueTrustTokenHandler provides the minimal trust-token contract used by +// APICred-gated services. The identity check is backed by a real BasaltPass +// OAuth client named infopipe-; callers pass that client's secret +// as a bearer token. +func IssueTrustTokenHandler(c *fiber.Ctx) error { + var req tokenRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid_json"}) + } + serviceID := strings.TrimSpace(req.ServiceID) + if serviceID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "service_id_required"}) + } + secret := bearer(c.Get("Authorization")) + if secret == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing_service_secret"}) + } + + var client model.OAuthClient + clientID := "infopipe-" + serviceID + if err := common.DB().Where("client_id = ? AND is_active = ?", clientID, true).First(&client).Error; err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "service_not_registered"}) + } + if !client.VerifyClientSecret(secret) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "service_secret_invalid"}) + } + if !scopesAllowed(client.GetScopeList(), req.Scopes) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "scope_not_allowed"}) + } + + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token_generation_failed"}) + } + expiresAt := time.Now().UTC().Add(time.Hour) + return c.JSON(fiber.Map{ + "token": "trust:" + serviceID + ":" + hex.EncodeToString(nonce), + "expires_at": expiresAt.Format(time.RFC3339), + "service_identity": serviceID, + "scopes": req.Scopes, + }) +} + +func bearer(value string) string { + value = strings.TrimSpace(value) + if strings.HasPrefix(strings.ToLower(value), "bearer ") { + return strings.TrimSpace(value[7:]) + } + return "" +} + +func scopesAllowed(have []string, requested []string) bool { + if len(requested) == 0 { + return true + } + set := map[string]struct{}{} + for _, scope := range have { + set[strings.TrimSpace(scope)] = struct{}{} + } + for _, scope := range requested { + scope = strings.TrimSpace(scope) + if scope == "" { + continue + } + if _, ok := set[scope]; !ok { + return false + } + } + return true +} diff --git a/basaltpass-backend/internal/handler/tenant/cross_app_trust_handler.go b/basaltpass-backend/internal/handler/tenant/cross_app_trust_handler.go index 45046c73..426b2146 100644 --- a/basaltpass-backend/internal/handler/tenant/cross_app_trust_handler.go +++ b/basaltpass-backend/internal/handler/tenant/cross_app_trust_handler.go @@ -120,7 +120,7 @@ func CreateCrossAppTrustHandler(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "allowed_scopes is required"}) } if req.MaxTokenTTL <= 0 { - req.MaxTokenTTL = 300 + req.MaxTokenTTL = 3600 } if req.MaxTokenTTL > 3600 { req.MaxTokenTTL = 3600 @@ -192,7 +192,7 @@ func UpdateCrossAppTrustHandler(c *fiber.Ctx) error { if req.MaxTokenTTL != nil { ttl := *req.MaxTokenTTL if ttl <= 0 { - ttl = 300 + ttl = 3600 } if ttl > 3600 { ttl = 3600 diff --git a/basaltpass-backend/internal/middleware/config/settings.yaml b/basaltpass-backend/internal/middleware/config/settings.yaml index 5ec1e5fd..f4edef34 100644 --- a/basaltpass-backend/internal/middleware/config/settings.yaml +++ b/basaltpass-backend/internal/middleware/config/settings.yaml @@ -292,6 +292,7 @@ settings: - openid - profile - email + - groups - offline_access - s2s.read - s2s.user.read diff --git a/basaltpass-backend/internal/model/cross_app_trust.go b/basaltpass-backend/internal/model/cross_app_trust.go index 52227a66..52244d77 100644 --- a/basaltpass-backend/internal/model/cross_app_trust.go +++ b/basaltpass-backend/internal/model/cross_app_trust.go @@ -10,11 +10,11 @@ import "time" type CrossAppTrust struct { ID uint `gorm:"primaryKey" json:"id"` TenantID uint `gorm:"not null;index" json:"tenant_id"` - SourceAppID uint `gorm:"not null;index" json:"source_app_id"` // the app that initiates token exchange (e.g. WeavInt) - TargetAppID uint `gorm:"not null;index" json:"target_app_id"` // the app whose API is being called (e.g. Araneae) - AllowedScopes string `gorm:"type:text" json:"allowed_scopes"` // comma-separated scope list that can be granted - MaxTokenTTL int `gorm:"default:300" json:"max_token_ttl"` // max token lifetime in seconds (default 5 min) - Description string `gorm:"size:500" json:"description"` // optional human-readable description + SourceAppID uint `gorm:"not null;index" json:"source_app_id"` // the app that initiates token exchange (e.g. WeavInt) + TargetAppID uint `gorm:"not null;index" json:"target_app_id"` // the app whose API is being called (e.g. Araneae) + AllowedScopes string `gorm:"type:text" json:"allowed_scopes"` // comma-separated scope list that can be granted + MaxTokenTTL int `gorm:"default:3600" json:"max_token_ttl"` // max token lifetime in seconds (default 1 hour) + Description string `gorm:"size:500" json:"description"` // optional human-readable description IsActive bool `gorm:"default:true" json:"is_active"` CreatedBy uint `gorm:"not null" json:"created_by"` CreatedAt time.Time `json:"created_at"` diff --git a/basaltpass-backend/internal/service/scope/scopes.go b/basaltpass-backend/internal/service/scope/scopes.go index d9851dca..c12f9a75 100644 --- a/basaltpass-backend/internal/service/scope/scopes.go +++ b/basaltpass-backend/internal/service/scope/scopes.go @@ -16,6 +16,7 @@ func DefaultAllowedScopes() []string { Email, Address, Phone, + Groups, OfflineAccess, S2SRead, S2SUserRead, @@ -56,6 +57,8 @@ func Describe(s string) Meta { return Meta{Scope: s, Category: "oidc", Title: "Address", Description: "允许通过 userinfo 获取地址信息"} case Phone: return Meta{Scope: s, Category: "oidc", Title: "Phone", Description: "允许通过 userinfo 获取手机号与验证状态"} + case Groups: + return Meta{Scope: s, Category: "oidc", Title: "Groups", Description: "允许在 ID Token 和 UserInfo 中返回用户所属角色组"} case OfflineAccess: return Meta{Scope: s, Category: "oidc", Title: "Offline access", Description: "允许申请 refresh_token(需要服务端启用刷新令牌)"} case S2SRead: @@ -96,6 +99,7 @@ const ( Email = "email" Address = "address" Phone = "phone" + Groups = "groups" OfflineAccess = "offline_access" ) diff --git a/basaltpass-backend/internal/service/settings/service.go b/basaltpass-backend/internal/service/settings/service.go index afc63f65..27411c63 100644 --- a/basaltpass-backend/internal/service/settings/service.go +++ b/basaltpass-backend/internal/service/settings/service.go @@ -132,6 +132,7 @@ func defaultItems() map[string]SettingItem { "openid", "profile", "email", + "groups", "offline_access", // S2S / third-party app scopes "s2s.read", diff --git a/basaltpass-backend/internal/service/tokenexchange/service.go b/basaltpass-backend/internal/service/tokenexchange/service.go index b429cfa9..0f6c5b1e 100644 --- a/basaltpass-backend/internal/service/tokenexchange/service.go +++ b/basaltpass-backend/internal/service/tokenexchange/service.go @@ -131,7 +131,7 @@ func (s *Service) Exchange(clientID string, clientAppID uint, clientTenantID uin // Step 7: Determine TTL ttl := trust.MaxTokenTTL if ttl <= 0 { - ttl = 300 + ttl = 3600 } if ttl > 3600 { ttl = 3600 // hard cap at 1 hour diff --git a/basaltpass-frontend/apps/admin/dist/index.html b/basaltpass-frontend/apps/admin/dist/index.html index 661024ed..eb31ed9a 100644 --- a/basaltpass-frontend/apps/admin/dist/index.html +++ b/basaltpass-frontend/apps/admin/dist/index.html @@ -23,15 +23,15 @@ .catch(function () {}) })() - + - - - - - - - + + + + + + +
diff --git a/basaltpass-frontend/apps/tenant/dist/index.html b/basaltpass-frontend/apps/tenant/dist/index.html index 4cc29ef7..88be8572 100644 --- a/basaltpass-frontend/apps/tenant/dist/index.html +++ b/basaltpass-frontend/apps/tenant/dist/index.html @@ -23,14 +23,14 @@ .catch(function () {}) })() - + - - - - - + + + + +
diff --git a/basaltpass-frontend/apps/user/dist/index.html b/basaltpass-frontend/apps/user/dist/index.html index 459834f5..a6072725 100644 --- a/basaltpass-frontend/apps/user/dist/index.html +++ b/basaltpass-frontend/apps/user/dist/index.html @@ -23,17 +23,17 @@ .catch(function () {}) })() - + - - - - - - - - + + + + + + + +
diff --git a/basaltpass-frontend/apps/user/src/router.tsx b/basaltpass-frontend/apps/user/src/router.tsx index d866d10d..74683b24 100644 --- a/basaltpass-frontend/apps/user/src/router.tsx +++ b/basaltpass-frontend/apps/user/src/router.tsx @@ -15,7 +15,6 @@ import Privacy from '../../../src/features/auth/Privacy' import Dashboard from '../../../src/features/user/Dashboard' import Profile from '../../../src/features/user/profile/Index' import Settings from '../../../src/features/user/Settings' -import Help from '../../../src/features/user/Help' import About from '../../../src/features/user/About' import SoftwareCopyright from '../../../src/features/user/SoftwareCopyright' import Notifications from '../../../src/features/user/Notifications' @@ -155,14 +154,6 @@ export default function AppRouter() { } /> - - - - } - /> - -
- -
-
-
- diff --git a/basaltpass-frontend/src/features/user/Help.tsx b/basaltpass-frontend/src/features/user/Help.tsx deleted file mode 100644 index 2813f47a..00000000 --- a/basaltpass-frontend/src/features/user/Help.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { useState, useMemo } from 'react' -import Layout from '@features/user/components/Layout' -import { PInput, PButton, PCard } from '@ui' -import { useI18n } from '@shared/i18n' -import { - QuestionMarkCircleIcon, - MagnifyingGlassIcon, - CheckCircleIcon, - HandThumbUpIcon, - HandThumbDownIcon -} from '@heroicons/react/24/outline' - -// FAQ -const faqCategories = [ - { id: 'all', nameKey: 'userHelp.categories.all', color: 'bg-gray-100 text-gray-800' }, - { id: 'account', nameKey: 'userHelp.categories.account', color: 'bg-indigo-100 text-indigo-800' }, - { id: 'security', nameKey: 'userHelp.categories.security', color: 'bg-green-100 text-green-800' }, - { id: 'payment', nameKey: 'userHelp.categories.payment', color: 'bg-indigo-100 text-indigo-800' }, - { id: 'wallet', nameKey: 'userHelp.categories.wallet', color: 'bg-yellow-100 text-yellow-800' }, - { id: 'subscription', nameKey: 'userHelp.categories.subscription', color: 'bg-red-100 text-red-800' } -] - -const faqs = [ - { - id: 1, - category: 'account', - questionKey: 'userHelp.faq.items.faq1.question', - answerKey: 'userHelp.faq.items.faq1.answer', - helpful: 45, - notHelpful: 2 - }, - { - id: 2, - category: 'account', - questionKey: 'userHelp.faq.items.faq2.question', - answerKey: 'userHelp.faq.items.faq2.answer', - helpful: 38, - notHelpful: 1 - }, - { - id: 3, - category: 'security', - questionKey: 'userHelp.faq.items.faq3.question', - answerKey: 'userHelp.faq.items.faq3.answer', - helpful: 52, - notHelpful: 3 - }, - { - id: 4, - category: 'security', - questionKey: 'userHelp.faq.items.faq4.question', - answerKey: 'userHelp.faq.items.faq4.answer', - helpful: 28, - notHelpful: 5 - }, - { - id: 5, - category: 'payment', - questionKey: 'userHelp.faq.items.faq5.question', - answerKey: 'userHelp.faq.items.faq5.answer', - helpful: 67, - notHelpful: 4 - }, - { - id: 6, - category: 'payment', - questionKey: 'userHelp.faq.items.faq6.question', - answerKey: 'userHelp.faq.items.faq6.answer', - helpful: 41, - notHelpful: 2 - }, - { - id: 7, - category: 'wallet', - questionKey: 'userHelp.faq.items.faq7.question', - answerKey: 'userHelp.faq.items.faq7.answer', - helpful: 34, - notHelpful: 1 - }, - { - id: 8, - category: 'wallet', - questionKey: 'userHelp.faq.items.faq8.question', - answerKey: 'userHelp.faq.items.faq8.answer', - helpful: 23, - notHelpful: 3 - }, - { - id: 9, - category: 'subscription', - questionKey: 'userHelp.faq.items.faq9.question', - answerKey: 'userHelp.faq.items.faq9.answer', - helpful: 31, - notHelpful: 2 - }, - { - id: 10, - category: 'subscription', - questionKey: 'userHelp.faq.items.faq10.question', - answerKey: 'userHelp.faq.items.faq10.answer', - helpful: 29, - notHelpful: 1 - }, - { - id: 11, - category: 'security', - questionKey: 'userHelp.faq.items.faq11.question', - answerKey: 'userHelp.faq.items.faq11.answer', - helpful: 56, - notHelpful: 2 - }, - { - id: 12, - category: 'account', - questionKey: 'userHelp.faq.items.faq12.question', - answerKey: 'userHelp.faq.items.faq12.answer', - helpful: 19, - notHelpful: 1 - } -] - -export default function Help() { - const { t } = useI18n() - const [openFaq, setOpenFaq] = useState(null) - const [searchQuery, setSearchQuery] = useState('') - const [selectedCategory, setSelectedCategory] = useState('all') - const [feedbackSubmitted, setFeedbackSubmitted] = useState>(new Set()) - const currentYear = new Date().getFullYear() - - const toggleFaq = (index: number) => { - setOpenFaq(openFaq === index ? null : index) - } - - const handleFeedback = (faqId: number, isHelpful: boolean) => { - setFeedbackSubmitted(prev => new Set(prev).add(faqId)) - // - console.log(`FAQ ${faqId} feedback: ${isHelpful ? 'helpful' : 'not helpful'}`) - } - - const filteredFaqs = useMemo(() => { - return faqs.filter(faq => { - const question = t(faq.questionKey) - const answer = t(faq.answerKey) - const matchesSearch = question.toLowerCase().includes(searchQuery.toLowerCase()) || - answer.toLowerCase().includes(searchQuery.toLowerCase()) - const matchesCategory = selectedCategory === 'all' || faq.category === selectedCategory - return matchesSearch && matchesCategory - }) - }, [searchQuery, selectedCategory, t]) - - return ( - -
- {/* */} -
-

{t('userHelp.title')}

-
- - {/* */} - -
-
- -

{t('userHelp.search.title')}

-
-
- setSearchQuery(e.target.value)} - size="lg" - icon={} - variant="rounded" - /> -
-
-
- - {/* */} - -
-

{t('userHelp.categories.title')}

-
- {faqCategories.map((category) => ( - setSelectedCategory(category.id)} - variant={selectedCategory === category.id ? 'primary' : 'secondary'} - size="sm" - className="rounded-full" - > - {t(category.nameKey)} - - ))} -
-
-
- - {/* */} - -
-
-

- {t('userHelp.faq.titleWithCount', { count: filteredFaqs.length })} -

- {searchQuery && ( - setSearchQuery('')} - variant="ghost" - size="sm" - className="text-gray-600" - > - {t('userHelp.faq.clearSearch')} - - )} -
- - {filteredFaqs.length === 0 ? ( -
- -

{t('userHelp.empty.title')}

-

{t('userHelp.empty.description')}

-
- ) : ( -
- {filteredFaqs.map((faq) => ( -
- toggleFaq(faq.id)} - variant="secondary" - className="w-full justify-between text-left px-6 py-4" - > -
-
- - {t(faq.questionKey)} - -
- c.id === faq.category)?.color || 'bg-gray-100 text-gray-800' - }`}> - {t(faqCategories.find(c => c.id === faq.category)?.nameKey || 'userHelp.categories.all')} - -
-
-
- - - - - -
- {openFaq === faq.id && ( -
-
-

{t(faq.answerKey)}

- - {!feedbackSubmitted.has(faq.id) ? ( -
- {t('userHelp.feedback.question')} - handleFeedback(faq.id, true)} - variant="ghost" - size="sm" - leftIcon={} - className="text-green-600" - > - {t('userHelp.feedback.helpful')} - - handleFeedback(faq.id, false)} - variant="ghost" - size="sm" - leftIcon={} - className="text-red-600" - > - {t('userHelp.feedback.notHelpful')} - -
- ) : ( -
- - {t('userHelp.feedback.thanks')} -
- )} -
-
- )} -
- ))} -
- )} -
-
- - {/* */} - -
-
-
-

{t('userHelp.system.title')}

-

- {t('userHelp.system.description')} -

-
-
-
- {t('userHelp.system.allServicesRunning')} -
-
-
-
-
- - {t('userHelp.system.services.api')} -
-
-
- {t('userHelp.system.status.normal')} -
-
-
-
- - {t('userHelp.system.services.payment')} -
-
-
- {t('userHelp.system.status.normal')} -
-
-
-
- - {t('userHelp.system.services.database')} -
-
-
- {t('userHelp.system.status.normal')} -
-
-
-
-
- -
-

- {t('userHelp.footer.copyrightPrefix', { year: currentYear })} - - HollowData - - {t('userHelp.footer.copyrightSuffix')} -

-
- -
-
- ) -} diff --git a/basaltpass-frontend/src/features/user/components/Layout.tsx b/basaltpass-frontend/src/features/user/components/Layout.tsx index 51782fe3..faa6bc84 100644 --- a/basaltpass-frontend/src/features/user/components/Layout.tsx +++ b/basaltpass-frontend/src/features/user/components/Layout.tsx @@ -6,7 +6,6 @@ import { HomeIcon, UserIcon, CogIcon, - QuestionMarkCircleIcon, BellIcon, UserGroupIcon, WalletIcon, @@ -98,7 +97,6 @@ export default function Layout({ children }: LayoutProps) { { name: t('userLayout.nav.myApps'), href: ROUTES.user.myApps, icon: Squares2X2Icon }, { name: t('userLayout.nav.security'), href: ROUTES.user.security, icon: ShieldCheckIcon }, { name: t('userLayout.nav.settings'), href: ROUTES.user.settings, icon: CogIcon }, - { name: t('userLayout.nav.help'), href: ROUTES.user.help, icon: QuestionMarkCircleIcon }, ] useEffect(() => { diff --git a/basaltpass-frontend/src/features/user/order/OrderSuccess.tsx b/basaltpass-frontend/src/features/user/order/OrderSuccess.tsx index 7d46efd7..1a03e916 100644 --- a/basaltpass-frontend/src/features/user/order/OrderSuccess.tsx +++ b/basaltpass-frontend/src/features/user/order/OrderSuccess.tsx @@ -137,16 +137,6 @@ export default function OrderSuccessPage() { - {/* */} -
-

- {t('userOrderSuccess.footer.prefix')} - - {t('userOrderSuccess.footer.helpCenter')} - - {t('userOrderSuccess.footer.suffix')} -

-
)} diff --git a/basaltpass-frontend/src/features/user/subscription/Index.tsx b/basaltpass-frontend/src/features/user/subscription/Index.tsx index 630d9d3f..098bc95c 100644 --- a/basaltpass-frontend/src/features/user/subscription/Index.tsx +++ b/basaltpass-frontend/src/features/user/subscription/Index.tsx @@ -4,7 +4,7 @@ import { PCard, PButton, PSkeleton, PBadge, PPageHeader } from '@ui' import { listSubscriptions, cancelSubscription } from '@api/subscription/subscription' import { SubscriptionResponse } from '@types/domain/subscription' import { Link } from 'react-router-dom' -import { ChevronRightIcon, CubeIcon, WalletIcon, QuestionMarkCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline' +import { ChevronRightIcon, CubeIcon, WalletIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline' import { ROUTES } from '@constants' import { useI18n } from '@shared/i18n' @@ -173,16 +173,6 @@ export default function SubscriptionIndex() {

{t('pages.userSubscriptionIndex.links.walletDesc')}

- - -
-

{t('pages.userSubscriptionIndex.links.helpTitle')}

-

{t('pages.userSubscriptionIndex.links.helpDesc')}

-
- diff --git a/basaltpass-frontend/src/features/user/subscription/Products.tsx b/basaltpass-frontend/src/features/user/subscription/Products.tsx index 265af359..4a39f3ac 100644 --- a/basaltpass-frontend/src/features/user/subscription/Products.tsx +++ b/basaltpass-frontend/src/features/user/subscription/Products.tsx @@ -8,7 +8,7 @@ import { Product, Price } from '@types/domain/subscription' import { Link, useNavigate } from 'react-router-dom' import client from '@api/client' import { ChevronRightIcon, CreditCardIcon } from '@heroicons/react/24/outline' -import { CubeIcon, WalletIcon, QuestionMarkCircleIcon, SparklesIcon, ArrowRightIcon } from '@heroicons/react/24/outline' +import { CubeIcon, WalletIcon, SparklesIcon, ArrowRightIcon } from '@heroicons/react/24/outline' import { ROUTES } from '@constants' import { useI18n } from '@shared/i18n' @@ -244,7 +244,7 @@ export default function ProductsPage() { {/* */}

{t('pages.userSubscriptionProducts.links.title')}

-
+
{t('pages.userSubscriptionProducts.links.walletDesc')}

- - -
-

{t('pages.userSubscriptionProducts.links.helpTitle')}

-

{t('pages.userSubscriptionProducts.links.helpDesc')}

-
-
diff --git a/basaltpass-frontend/src/features/user/wallet/Recharge.tsx b/basaltpass-frontend/src/features/user/wallet/Recharge.tsx index d4a53b86..ae7147ec 100644 --- a/basaltpass-frontend/src/features/user/wallet/Recharge.tsx +++ b/basaltpass-frontend/src/features/user/wallet/Recharge.tsx @@ -355,26 +355,6 @@ export default function Recharge() { - {/* */} - -
-

{t('pages.walletRecharge.faq.title')}

-
-
-

{t('pages.walletRecharge.faq.q1')}

-

{t('pages.walletRecharge.faq.a1')}

-
-
-

{t('pages.walletRecharge.faq.q2')}

-

{t('pages.walletRecharge.faq.a2')}

-
-
-

{t('pages.walletRecharge.faq.q3')}

-

{t('pages.walletRecharge.faq.a3')}

-
-
-
-
diff --git a/basaltpass-frontend/src/features/user/wallet/Withdraw.tsx b/basaltpass-frontend/src/features/user/wallet/Withdraw.tsx index 0542a403..ca5b302a 100644 --- a/basaltpass-frontend/src/features/user/wallet/Withdraw.tsx +++ b/basaltpass-frontend/src/features/user/wallet/Withdraw.tsx @@ -313,31 +313,6 @@ export default function Withdraw() { - {/* */} - -
-

{t('pages.walletWithdraw.faq.title')}

-
-
-

{t('pages.walletWithdraw.faq.q1')}

-

{t('pages.walletWithdraw.faq.a1')}

-
-
-

{t('pages.walletWithdraw.faq.q2')}

-

{t('pages.walletWithdraw.faq.a2')}

-
-
-

{t('pages.walletWithdraw.faq.q3')}

-

{t('pages.walletWithdraw.faq.a3')}

-
-
-

{t('pages.walletWithdraw.faq.q4')}

-

{t('pages.walletWithdraw.faq.a4')}

-
-
-
-
- {/* */}
    diff --git a/basaltpass-frontend/src/shared/assets/brand/basaltpass-logo-symbol.svg b/basaltpass-frontend/src/shared/assets/brand/basaltpass-logo-symbol.svg index 02893405..b4ae9991 100644 --- a/basaltpass-frontend/src/shared/assets/brand/basaltpass-logo-symbol.svg +++ b/basaltpass-frontend/src/shared/assets/brand/basaltpass-logo-symbol.svg @@ -1,5 +1,4 @@ - diff --git a/basaltpass-frontend/src/shared/assets/brand/built-with-hollowdata-tech.svg b/basaltpass-frontend/src/shared/assets/brand/built-with-hollowdata-tech.svg new file mode 100644 index 00000000..2d23e82e --- /dev/null +++ b/basaltpass-frontend/src/shared/assets/brand/built-with-hollowdata-tech.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/basaltpass-frontend/src/shared/constants/routes/user.ts b/basaltpass-frontend/src/shared/constants/routes/user.ts index 01bbfefc..2fb26d45 100644 --- a/basaltpass-frontend/src/shared/constants/routes/user.ts +++ b/basaltpass-frontend/src/shared/constants/routes/user.ts @@ -21,7 +21,6 @@ export const USER_ROUTES = { securityPasskey: '/security/passkey', securityLoginHistory: '/security/login-history', settings: '/settings', - help: '/help', about: '/about', copyright: '/copyright' } as const diff --git a/basaltpass-frontend/src/shared/generated/buildInfo.ts b/basaltpass-frontend/src/shared/generated/buildInfo.ts index f461b8c6..a51ef992 100644 --- a/basaltpass-frontend/src/shared/generated/buildInfo.ts +++ b/basaltpass-frontend/src/shared/generated/buildInfo.ts @@ -1,6 +1,6 @@ export const buildInfo = { - version: "beancs-v1.0.49", - commit: "a0ac896", - generatedAt: "2026-06-28T00:47:43.348Z", + version: "beancs-v1.0.52", + commit: "90751fd", + generatedAt: "2026-06-29T17:01:43.022Z", copyrightStartYear: 2024, } as const diff --git a/basaltpass-frontend/src/shared/i18n/messages/en.ts b/basaltpass-frontend/src/shared/i18n/messages/en.ts index e4d80a7d..db8fe23e 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/en.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/en.ts @@ -716,15 +716,6 @@ const en = { support: 'Contact support promptly if issues occur', }, }, - faq: { - title: 'FAQ', - q1: 'How long does recharge take?', - a1: 'Usually 1-5 minutes. If delayed, please check later.', - q2: 'What if recharge fails?', - a2: 'Check network and payment details, or try another method.', - q3: 'Can I cancel a recharge?', - a3: 'Successful recharge cannot be canceled. Confirm amount before payment.', - }, }, walletWithdraw: { header: { @@ -795,17 +786,6 @@ const en = { worktime: 'Requests are processed on business days 9:00-18:00', }, }, - faq: { - title: 'FAQ', - q1: 'How long does withdrawal take?', - a1: 'Alipay/WeChat within 24 hours, bank card 1-3 business days.', - q2: 'What if withdrawal fails?', - a2: 'Check account details or contact support for help.', - q3: 'Can I cancel a withdrawal?', - a3: 'No, it cannot be canceled after submission.', - q4: 'How much is the withdrawal fee?', - a4: 'Currently free of charge.', - }, }, userSubscriptionIndex: { header: { @@ -837,8 +817,6 @@ const en = { productsDesc: 'Browse available products and services', walletTitle: 'Wallet', walletDesc: 'Manage your account balance and payments', - helpTitle: 'Help Center', - helpDesc: 'Get subscription-related help', }, cancelModal: { title: 'Confirm Subscription Cancellation', @@ -889,8 +867,6 @@ const en = { subscriptionsDesc: 'View and manage your subscriptions', walletTitle: 'Wallet', walletDesc: 'Manage your account balance', - helpTitle: 'Help Center', - helpDesc: 'Get subscription-related help', }, }, userSubscriptionCheckout: { @@ -1260,8 +1236,6 @@ const en = { profileDesc: 'Edit profile information', settings: 'System Settings', settingsDesc: 'Configure system preferences', - help: 'Help Center', - helpDesc: 'Get usage help', }, recentTransactions: { title: 'Recent Transactions', @@ -1482,7 +1456,6 @@ const en = { myApps: 'My Apps', security: 'Security', settings: 'Settings', - help: 'Help', }, theme: { openMenu: 'Theme', @@ -1912,6 +1885,8 @@ const en = { permissionManagement: 'Permission Management', userManagement: 'User Management', createRole: 'Create Role', + edit: 'Edit', + delete: 'Delete', cancel: 'Cancel', update: 'Update', create: 'Create', @@ -3668,103 +3643,6 @@ const en = { copyFailed: 'Copy failed:', }, }, - userHelp: { - title: 'Help Center', - search: { - title: 'Search Help', - placeholder: 'Search questions or keywords...', - }, - categories: { - title: 'Browse by Category', - all: 'All', - account: 'Account Management', - security: 'Security Settings', - payment: 'Payments & Top-ups', - wallet: 'Wallet Management', - subscription: 'Subscription Services', - }, - faq: { - titleWithCount: 'FAQs ({{count}})', - clearSearch: 'Clear Search', - items: { - faq1: { - question: 'How do I register a new account?', - answer: 'Click the "Sign Up" button at the top-right corner, enter your email and password, and complete email verification to create your account. You can also register with a phone number.', - }, - faq2: { - question: 'What should I do if I forgot my password?', - answer: 'You can reset your password via email or phone number. Click the "Forgot Password" link on the login page and follow the instructions. The reset link is valid for 30 minutes.', - }, - faq3: { - question: 'How do I enable two-factor authentication?', - answer: 'Go to the "Security Settings" page, open "Two-Factor Authentication", then scan the QR code or enter the key and input the verification code to finish setup. Enabling a backup method is recommended.', - }, - faq4: { - question: 'How do I set up a passkey?', - answer: 'Open "Passkey Management" in Security Settings, click "Add Passkey", and follow your browser prompts for biometrics or PIN setup. Passkeys provide a more secure passwordless login experience.', - }, - faq5: { - question: 'How can I top up my wallet?', - answer: 'You can top up your wallet in multiple ways: 1) Alipay QR payment 2) WeChat Pay 3) Bank card transfer. Most top-ups arrive within 1-5 minutes, while large amounts may require manual review.', - }, - faq6: { - question: 'How long does withdrawal take?', - answer: 'Withdrawal time depends on your method: bank card withdrawals usually take 1-3 business days, and Alipay withdrawals usually arrive within 24 hours. The single withdrawal limit is ¥50,000.', - }, - faq7: { - question: 'How can I view transaction history?', - answer: 'In the wallet page, click "Transaction History", or check recent transactions on the dashboard. You can filter by time, type, or status, and export records in CSV format.', - }, - faq8: { - question: 'What if my wallet balance is insufficient?', - answer: 'When your wallet balance is low, the system will prompt you to recharge. You can also enable auto top-up to recharge a specified amount once balance falls below your threshold.', - }, - faq9: { - question: 'How do I manage subscription plans?', - answer: 'On the subscription page, you can view your current plan, upgrade or downgrade, and cancel your subscription. After cancellation, service continues until the current billing cycle ends.', - }, - faq10: { - question: 'How is subscription billing calculated?', - answer: 'Subscription fees are charged monthly or yearly based on your plan. Annual plans usually include a discount. Fees are automatically deducted from your wallet at the start of each billing cycle.', - }, - faq11: { - question: 'How is account security protected?', - answer: 'We use bank-grade protections including SSL encryption, two-factor authentication, real-time abnormal login monitoring, regular security audits, and data backups. All sensitive data is encrypted at rest.', - }, - faq12: { - question: 'How can I update my personal information?', - answer: 'In the profile page, you can update your avatar, nickname, email, phone number, and other details. Identity verification is required when changing sensitive information.', - }, - }, - }, - empty: { - title: 'No related questions found', - description: 'Try different keywords or contact our support team.', - }, - feedback: { - question: 'Was this answer helpful?', - helpful: 'Helpful', - notHelpful: 'Not Helpful', - thanks: 'Thanks for your feedback!', - }, - system: { - title: 'System Status', - description: 'Check real-time service status', - allServicesRunning: 'All services are running normally', - services: { - api: 'API Service', - payment: 'Payment System', - database: 'Database', - }, - status: { - normal: 'Normal', - }, - }, - footer: { - copyrightPrefix: 'Copyright 2024 - {{year}} BasaltPass BasaltBase powered by ', - copyrightSuffix: '', - }, - }, adminDashboard: { layoutTitle: 'Admin Console', title: 'Admin Dashboard', @@ -4197,11 +4075,6 @@ const en = { backToConsole: 'Back to Console', viewMoreProducts: 'View More Products', }, - footer: { - prefix: 'If you have any questions, please visit our ', - helpCenter: 'Help Center', - suffix: ' or contact support.', - }, logs: { fetchOrderFailed: 'Failed to fetch order:', }, diff --git a/basaltpass-frontend/src/shared/i18n/messages/zh.ts b/basaltpass-frontend/src/shared/i18n/messages/zh.ts index 6f1765d0..9decb084 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/zh.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/zh.ts @@ -716,15 +716,6 @@ const en = { support: 'Contact support promptly if issues occur', }, }, - faq: { - title: 'FAQ', - q1: 'How long does recharge take?', - a1: 'Usually 1-5 minutes. If delayed, please check later.', - q2: 'What if recharge fails?', - a2: 'Check network and payment details, or try another method.', - q3: 'Can I cancel a recharge?', - a3: 'Successful recharge cannot be canceled. Confirm amount before payment.', - }, }, walletWithdraw: { header: { @@ -795,17 +786,6 @@ const en = { worktime: 'Requests are processed on business days 9:00-18:00', }, }, - faq: { - title: 'FAQ', - q1: 'How long does withdrawal take?', - a1: 'Alipay/WeChat within 24 hours, bank card 1-3 business days.', - q2: 'What if withdrawal fails?', - a2: 'Check account details or contact support for help.', - q3: 'Can I cancel a withdrawal?', - a3: 'No, it cannot be canceled after submission.', - q4: 'How much is the withdrawal fee?', - a4: 'Currently free of charge.', - }, }, userSubscriptionIndex: { header: { @@ -837,8 +817,6 @@ const en = { productsDesc: 'Browse available products and services', walletTitle: 'Wallet', walletDesc: 'Manage your account balance and payments', - helpTitle: 'Help Center', - helpDesc: 'Get subscription-related help', }, cancelModal: { title: 'Confirm Subscription Cancellation', @@ -889,8 +867,6 @@ const en = { subscriptionsDesc: 'View and manage your subscriptions', walletTitle: 'Wallet', walletDesc: 'Manage your account balance', - helpTitle: 'Help Center', - helpDesc: 'Get subscription-related help', }, }, userSubscriptionCheckout: { @@ -1260,8 +1236,6 @@ const en = { profileDesc: 'Edit profile information', settings: 'System Settings', settingsDesc: 'Configure system preferences', - help: 'Help Center', - helpDesc: 'Get usage help', }, recentTransactions: { title: 'Recent Transactions', @@ -1912,6 +1886,8 @@ const en = { permissionManagement: 'Permission Management', userManagement: 'User Management', createRole: 'Create Role', + edit: 'Edit', + delete: 'Delete', cancel: 'Cancel', update: 'Update', create: 'Create', @@ -3668,103 +3644,6 @@ const en = { copyFailed: 'Copy failed:', }, }, - userHelp: { - title: '帮助中心', - search: { - title: '搜索帮助', - placeholder: '搜索问题或关键词……', - }, - categories: { - title: '按分类浏览', - all: '全部', - account: '账户管理', - security: '安全设置', - payment: '支付与充值', - wallet: '钱包管理', - subscription: '订阅服务', - }, - faq: { - titleWithCount: '常见问题({{count}})', - clearSearch: '清除搜索', - items: { - faq1: { - question: '如何注册新账户?', - answer: '点击右上角的“注册”按钮,输入邮箱和密码,完成邮箱验证即可创建账户。你也可以使用手机号注册。', - }, - faq2: { - question: '忘记密码怎么办?', - answer: '你可以通过邮箱或手机号重置密码。点击登录页的“忘记密码”链接并按提示操作。重置链接有效期为 30 分钟。', - }, - faq3: { - question: '如何开启两步验证?', - answer: '进入“安全设置”页面,打开“两步验证”,然后扫描二维码或输入密钥,并填入验证码完成设置。建议同时开启备用验证方式。', - }, - faq4: { - question: '如何设置通行密钥(Passkey)?', - answer: '在安全设置中打开“通行密钥管理”,点击“添加通行密钥”,并按浏览器提示完成生物识别或 PIN 设置。通行密钥可提供更安全的无密码登录体验。', - }, - faq5: { - question: '如何充值钱包?', - answer: '你可以通过多种方式充值:1) 支付宝扫码 2) 微信支付 3) 银行卡转账。多数充值会在 1–5 分钟内到账,大额充值可能需要人工审核。', - }, - faq6: { - question: '提现需要多长时间?', - answer: '提现时间取决于方式:银行卡提现通常需要 1–3 个工作日,支付宝提现通常 24 小时内到账。单笔提现限额为 ¥50,000。', - }, - faq7: { - question: '如何查看交易记录?', - answer: '在钱包页面点击“交易记录”,或在仪表盘查看近期交易。你可以按时间、类型或状态筛选,并导出 CSV 格式记录。', - }, - faq8: { - question: '钱包余额不足怎么办?', - answer: '当钱包余额不足时,系统会提示你充值。你也可以开启自动充值,在余额低于阈值时自动充值指定金额。', - }, - faq9: { - question: '如何管理订阅套餐?', - answer: '在订阅页面,你可以查看当前套餐、进行升级或降级,并取消订阅。取消后服务将持续到当前计费周期结束。', - }, - faq10: { - question: '订阅费用如何计算?', - answer: '订阅费用按你的套餐按月或按年收取。年度套餐通常包含折扣。每个计费周期开始时,费用会自动从你的钱包扣除。', - }, - faq11: { - question: '账户安全如何保障?', - answer: '我们采用银行级防护,包括 SSL 加密、两步验证、实时异常登录监控、定期安全审计和数据备份。所有敏感数据在存储时均经过加密。', - }, - faq12: { - question: '如何更新个人信息?', - answer: '在个人资料页面,你可以更新头像、昵称、邮箱、手机号等信息。修改敏感信息需要进行身份验证。', - }, - }, - }, - empty: { - title: '未找到相关问题', - description: '请尝试不同的关键词,或联系我们的支持团队。', - }, - feedback: { - question: '这个回答有帮助吗?', - helpful: '有帮助', - notHelpful: '没帮助', - thanks: '感谢你的反馈!', - }, - system: { - title: '系统状态', - description: '查看实时服务状态', - allServicesRunning: '所有服务运行正常', - services: { - api: 'API 服务', - payment: '支付系统', - database: '数据库', - }, - status: { - normal: '正常', - }, - }, - footer: { - copyrightPrefix: 'Copyright 2024 - {{year}} BasaltPass BasaltBase 由 ', - copyrightSuffix: '', - }, - }, adminDashboard: { layoutTitle: 'Admin Console', title: 'Admin Dashboard', @@ -4194,11 +4073,6 @@ const en = { backToConsole: '返回控制台', viewMoreProducts: '查看更多产品', }, - footer: { - prefix: '如有任何问题,请访问我们的', - helpCenter: '帮助中心', - suffix: '或联系客服。', - }, logs: { fetchOrderFailed: '获取订单失败:', }, diff --git a/basaltpass-frontend/src/shared/ui/PSkeleton.tsx b/basaltpass-frontend/src/shared/ui/PSkeleton.tsx index d5859a8b..6e61a393 100644 --- a/basaltpass-frontend/src/shared/ui/PSkeleton.tsx +++ b/basaltpass-frontend/src/shared/ui/PSkeleton.tsx @@ -1,4 +1,6 @@ import React from 'react' +import basaltpassLogo from '../assets/brand/basaltpass-logo-symbol.svg' +import hollowDataBadge from '../assets/brand/built-with-hollowdata-tech.svg' // ───────────────────────────────────────────── // translated PSkeleton @@ -294,34 +296,74 @@ interface PageLoaderProps { animated?: boolean } +const loaderGradients = [ + { + background: 'linear-gradient(90deg,#8fd8ff 0%,#f7fdff 28%,#ff2d1d 54%,#ff7a1a 100%)', + shadow: '0 0 18px rgba(255,90,35,0.34)', + }, + { + background: 'linear-gradient(90deg,#8be9fd 0%,#60a5fa 34%,#7c3aed 68%,#f472b6 100%)', + shadow: '0 0 18px rgba(96,165,250,0.32)', + }, + { + background: 'linear-gradient(90deg,#34d399 0%,#a7f3d0 30%,#22d3ee 64%,#2563eb 100%)', + shadow: '0 0 18px rgba(34,211,238,0.3)', + }, + { + background: 'linear-gradient(90deg,#fbbf24 0%,#fb7185 34%,#ef4444 66%,#7c2d12 100%)', + shadow: '0 0 18px rgba(251,113,133,0.3)', + }, + { + background: 'linear-gradient(90deg,#c4b5fd 0%,#818cf8 30%,#38bdf8 65%,#14b8a6 100%)', + shadow: '0 0 18px rgba(129,140,248,0.3)', + }, +] + function PageLoader({ message = 'loading...', animated = true }: PageLoaderProps) { - const pulse = animated ? 'animate-pulse' : '' + const loaderGradient = React.useMemo( + () => loaderGradients[Math.floor(Math.random() * loaderGradients.length)], + [], + ) + return ( -
    -
    - {/* Logo translated */} -
    - {/* translated */} -
    -
    -
    -
    - {/* translated */} -
    -
    +
    +
    + BasaltPass +
    +
    +
    + {message &&

    {message}

    }
    - {message &&

    {message}

    } +
    +
    + Built with HollowData's tech
    diff --git a/config/settings.yaml b/config/settings.yaml index 1dfc740f..192104a6 100644 --- a/config/settings.yaml +++ b/config/settings.yaml @@ -279,6 +279,8 @@ settings: - openid - profile - email + - groups + - offline_access category: oauth description: 允许的 OAuth Scope 列表 oauth.enable_refresh_tokens: diff --git a/docs/ROUTES.md b/docs/ROUTES.md index 6bf9242b..dd1bc3c0 100644 --- a/docs/ROUTES.md +++ b/docs/ROUTES.md @@ -237,6 +237,7 @@ | POST | /api/v1/admin/wallets/:id/unfreeze | | GET | /api/v1/admin/wallets/stats | | HEAD | /api/v1/admin/wallets/stats | +| POST | /api/v1/auth/2fa/email/send | | POST | /api/v1/auth/console/authorize | | POST | /api/v1/auth/console/exchange | | POST | /api/v1/auth/identity/switch | @@ -698,10 +699,6 @@ | PUT | /api/v1/tenant/notifications/:id | | GET | /api/v1/tenant/notifications/stats | | HEAD | /api/v1/tenant/notifications/stats | -| GET | /api/v1/tenant/notifications/users | -| HEAD | /api/v1/tenant/notifications/users | -| GET | /api/v1/tenant/notifications/users/search | -| HEAD | /api/v1/tenant/notifications/users/search | | GET | /api/v1/tenant/oauth/clients/ | | GET | /api/v1/tenant/oauth/clients/ | | HEAD | /api/v1/tenant/oauth/clients/ | @@ -966,6 +963,8 @@ | HEAD | /api/v1/tenant/users/global-candidates | | POST | /api/v1/tenant/users/global-candidates/:id/authorize | | POST | /api/v1/tenant/users/invite | +| GET | /api/v1/tenant/users/search | +| HEAD | /api/v1/tenant/users/search | | GET | /api/v1/tenant/users/stats | | GET | /api/v1/tenant/users/stats | | HEAD | /api/v1/tenant/users/stats | @@ -1000,6 +999,7 @@ | HEAD | /api/v1/tenant/wallets/stats | | GET | /api/v1/tenants/by-code/:code | | HEAD | /api/v1/tenants/by-code/:code | +| POST | /api/v1/trust/token | | CONNECT | /api/v1/usage | | DELETE | /api/v1/usage | | GET | /api/v1/usage |