feat: add customer authentication and scoped RBAC
This commit is contained in:
@@ -18,4 +18,5 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.15 // indirect
|
||||
github.com/segmentio/kafka-go v0.4.49 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
)
|
||||
|
||||
@@ -38,6 +38,8 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/taosdata/driver-go/v3 v3.8.1 h1:kkd4ABsGiU+oXDbsw/sic985LKAvnpF9Gb/TEunTnLE=
|
||||
github.com/taosdata/driver-go/v3 v3.8.1/go.mod h1:S6OGOinfR0xxxaMGsvBi9cLkYxEIW1p6qqr8QJATTlg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -26,12 +27,18 @@ type tokenPrincipal struct {
|
||||
}
|
||||
|
||||
type apiAuthenticator struct {
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
local *authStore
|
||||
adapters []IdentityAdapter
|
||||
}
|
||||
|
||||
func withAPIAuth(next http.Handler, cfg config.Config) http.Handler {
|
||||
authenticator, err := newAPIAuthenticator(cfg)
|
||||
func withAPIAuth(next http.Handler, cfg config.Config, databases ...*sql.DB) http.Handler {
|
||||
var db *sql.DB
|
||||
if len(databases) > 0 {
|
||||
db = databases[0]
|
||||
}
|
||||
authenticator, err := newAPIAuthenticator(cfg, db)
|
||||
if err != nil {
|
||||
log.Printf("platform API authentication misconfigured: %v", err)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -41,7 +48,7 @@ func withAPIAuth(next http.Handler, cfg config.Config) http.Handler {
|
||||
return authenticator.middleware(next)
|
||||
}
|
||||
|
||||
func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) {
|
||||
func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, error) {
|
||||
mode := strings.ToLower(strings.TrimSpace(cfg.AuthMode))
|
||||
if mode == "" {
|
||||
mode = "disabled"
|
||||
@@ -49,7 +56,11 @@ func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) {
|
||||
if mode != "disabled" && mode != "enforce" {
|
||||
return nil, fmt.Errorf("AUTH_MODE must be disabled or enforce")
|
||||
}
|
||||
authenticator := &apiAuthenticator{mode: mode}
|
||||
local, err := newAuthStore(db, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authenticator := &apiAuthenticator{mode: mode, local: local}
|
||||
configured := []configuredPrincipal{}
|
||||
if strings.TrimSpace(cfg.AuthTokensJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(cfg.AuthTokensJSON), &configured); err != nil {
|
||||
@@ -75,22 +86,46 @@ func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) {
|
||||
return nil, fmt.Errorf("duplicate authentication token")
|
||||
}
|
||||
seen[hash] = true
|
||||
authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: platform.Principal{Name: item.Name, Role: item.Role}})
|
||||
principal := platform.Principal{Name: item.Name, Username: item.Name, Role: item.Role, UserType: item.Role, AuthProvider: "legacy-token", MenuKeys: append([]string(nil), adminMenus...)}
|
||||
authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: principal})
|
||||
}
|
||||
if mode == "enforce" && len(authenticator.tokens) == 0 {
|
||||
return nil, fmt.Errorf("enforce mode requires AUTH_TOKEN or AUTH_TOKENS_JSON")
|
||||
if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && len(authenticator.adapters) == 0 {
|
||||
return nil, fmt.Errorf("enforce mode requires a local identity store, identity adapter, AUTH_TOKEN, or AUTH_TOKENS_JSON")
|
||||
}
|
||||
return authenticator, nil
|
||||
}
|
||||
|
||||
func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v2/auth/login" {
|
||||
if a.local == nil {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_AUTH_UNAVAILABLE", "账号登录尚未启用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "登录接口仅支持 POST", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
a.local.login(w, r)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticate(r)
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-platform"`)
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "需要有效的访问令牌", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" {
|
||||
menu := requiredMenu(r)
|
||||
allowed := principal.CanMenu(menu)
|
||||
if menu == "shared" {
|
||||
allowed = principal.CanMenu("monitor") || principal.CanMenu("vehicles") || principal.CanMenu("tracks") || principal.CanMenu("statistics")
|
||||
}
|
||||
if menu == "" || !allowed {
|
||||
httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问此功能", menu, requestTraceID(r))
|
||||
return
|
||||
}
|
||||
}
|
||||
required := requiredRole(r)
|
||||
if roleRank(principal.Role) < roleRank(required) {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
||||
@@ -99,11 +134,33 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
ctx := platform.WithPrincipal(r.Context(), principal)
|
||||
r = r.WithContext(ctx)
|
||||
if r.URL.Path == "/api/v2/session" {
|
||||
principal.VehicleCount = len(principal.VehicleVINs)
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
platform.Principal
|
||||
AuthMode string `json:"authMode"`
|
||||
}{principal.Name, principal.Role, a.mode})
|
||||
}{principal, a.mode})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v2/auth/logout" {
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "退出接口仅支持 POST", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if a.local != nil && principal.AuthProvider == "local" {
|
||||
a.local.logout(r.Context(), bearerToken(r))
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v2/auth/password" {
|
||||
if r.Method != http.MethodPut || a.local == nil {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "密码修改接口仅支持 PUT", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
a.local.changePassword(w, r, principal)
|
||||
return
|
||||
}
|
||||
if a.local != nil && a.local.handleAdmin(w, r, principal) {
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -112,16 +169,27 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
|
||||
func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bool) {
|
||||
if a.mode == "disabled" {
|
||||
return platform.Principal{Name: "local-developer", Role: "admin"}, true
|
||||
return platform.Principal{Name: "local-developer", Username: "local-developer", Role: "admin", UserType: "admin", AuthProvider: "disabled", MenuKeys: append([]string(nil), adminMenus...)}, true
|
||||
}
|
||||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
token := strings.TrimSpace(header[7:])
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
if a.local != nil {
|
||||
if principal, ok := a.local.authenticate(r.Context(), token); ok {
|
||||
return principal, true
|
||||
}
|
||||
}
|
||||
for _, adapter := range a.adapters {
|
||||
principal, matched, err := adapter.AuthenticateBearer(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Printf("identity adapter %s rejected credential: %v", adapter.Name(), err)
|
||||
continue
|
||||
}
|
||||
if matched {
|
||||
return principal, true
|
||||
}
|
||||
}
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
var match platform.Principal
|
||||
matched := 0
|
||||
@@ -135,6 +203,34 @@ func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bo
|
||||
return match, matched == 1
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(header[7:])
|
||||
}
|
||||
|
||||
func requiredMenu(r *http.Request) string {
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
|
||||
return "monitor"
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
return "shared"
|
||||
case path == "/api/v2/tracks":
|
||||
return "tracks"
|
||||
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary":
|
||||
return "statistics"
|
||||
case path == "/api/vehicles", path == "/api/vehicles/resolve":
|
||||
return "shared"
|
||||
case strings.HasPrefix(path, "/api/v2/vehicles/"):
|
||||
return "vehicles"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func requiredRole(r *http.Request) string {
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") {
|
||||
return "operator"
|
||||
@@ -160,12 +256,15 @@ func requiredRole(r *http.Request) string {
|
||||
if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) {
|
||||
return "admin"
|
||||
}
|
||||
if r.Method == http.MethodPut && path == "/api/v2/auth/password" {
|
||||
return "viewer"
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func roleRank(role string) int {
|
||||
switch strings.ToLower(role) {
|
||||
case "viewer":
|
||||
case "viewer", "customer":
|
||||
return 1
|
||||
case "operator":
|
||||
return 2
|
||||
|
||||
747
vehicle-data-platform/apps/api/internal/app/auth_store.go
Normal file
747
vehicle-data-platform/apps/api/internal/app/auth_store.go
Normal file
@@ -0,0 +1,747 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
const (
|
||||
localSessionCacheTTL = 30 * time.Second
|
||||
loginLockDuration = 15 * time.Minute
|
||||
maxLoginFailures = 5
|
||||
)
|
||||
|
||||
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$`)
|
||||
|
||||
var customerMenuSet = map[string]bool{
|
||||
"monitor": true, "vehicles": true, "tracks": true, "statistics": true,
|
||||
}
|
||||
|
||||
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"}
|
||||
|
||||
type authUser struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
UserType string `json:"userType"`
|
||||
Status string `json:"status"`
|
||||
CustomerRef string `json:"customerRef"`
|
||||
TenantRef string `json:"tenantRef"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
ExternalSubject string `json:"externalSubject,omitempty"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"vehicleVins"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type localCredential struct {
|
||||
authUser
|
||||
PasswordHash string
|
||||
FailedLoginCount int
|
||||
LockedUntil sql.NullTime
|
||||
}
|
||||
|
||||
type userMutation struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status"`
|
||||
CustomerRef string `json:"customerRef"`
|
||||
TenantRef string `json:"tenantRef"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"vehicleVins"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type passwordChangeRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Session platform.Principal `json:"session"`
|
||||
}
|
||||
|
||||
type cachedSession struct {
|
||||
principal platform.Principal
|
||||
expiresAt time.Time
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
type authStore struct {
|
||||
db *sql.DB
|
||||
sessionTTL time.Duration
|
||||
cacheMu sync.RWMutex
|
||||
cache map[string]cachedSession
|
||||
}
|
||||
|
||||
func newAuthStore(db *sql.DB, cfg config.Config) (*authStore, error) {
|
||||
if db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ttl := cfg.SessionTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 12 * time.Hour
|
||||
}
|
||||
store := &authStore{db: db, sessionTTL: ttl, cache: map[string]cachedSession{}}
|
||||
if err := store.ensureBootstrapAdmin(context.Background(), cfg.BootstrapAdminUsername, cfg.BootstrapAdminPassword); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *authStore) ensureBootstrapAdmin(ctx context.Context, username, password string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" && password == "" {
|
||||
return nil
|
||||
}
|
||||
if !usernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("BOOTSTRAP_ADMIN_USERNAME is invalid")
|
||||
}
|
||||
if err := validatePassword(password); err != nil {
|
||||
return fmt.Errorf("BOOTSTRAP_ADMIN_PASSWORD: %w", err)
|
||||
}
|
||||
var exists int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM platform_user WHERE user_type='admin'`).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check bootstrap admin: %w", err)
|
||||
}
|
||||
if exists > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash bootstrap password: %w", err)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,auth_provider,created_by,updated_by) VALUES(?,?,?,'admin','enabled','local','bootstrap','bootstrap')`, username, "平台管理员", string(hash))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create bootstrap admin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authStore) login(w http.ResponseWriter, r *http.Request) {
|
||||
var input loginRequest
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
if !usernamePattern.MatchString(input.Username) || len(input.Password) > 128 {
|
||||
s.audit(r.Context(), input.Username, "login", "user", input.Username, "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
credential, err := s.localCredential(r.Context(), input.Username)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
s.audit(r.Context(), input.Username, "login", "user", input.Username, "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if credential.Status != "enabled" {
|
||||
s.audit(r.Context(), input.Username, "login", "user", strconv.FormatUint(credential.ID, 10), "denied", map[string]any{"reason": "disabled"}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusForbidden, "ACCOUNT_DISABLED", "账号已停用,请联系管理员", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if credential.LockedUntil.Valid && credential.LockedUntil.Time.After(now) {
|
||||
httpx.WriteError(w, http.StatusTooManyRequests, "ACCOUNT_LOCKED", "登录失败次数过多,请稍后再试", credential.LockedUntil.Time.Format(time.RFC3339), requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(input.Password)) != nil {
|
||||
failures := credential.FailedLoginCount + 1
|
||||
var locked any
|
||||
if failures >= maxLoginFailures {
|
||||
locked = now.Add(loginLockDuration)
|
||||
}
|
||||
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET failed_login_count=?,locked_until=? WHERE id=?`, failures, locked, credential.ID)
|
||||
s.audit(r.Context(), input.Username, "login", "user", strconv.FormatUint(credential.ID, 10), "denied", map[string]any{"reason": "invalid_credentials", "failures": failures}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
principal, err := s.principalForUser(r.Context(), credential.authUser)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
accessToken, tokenHash, err := randomSessionToken()
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
sessionID, err := randomHex(16)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
expiresAt := now.Add(s.sessionTTL)
|
||||
_, err = s.db.ExecContext(r.Context(), `INSERT INTO platform_user_session(id,user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent) VALUES(?,?,?,?,?,?,?,?)`, sessionID, credential.ID, tokenHash[:], now, expiresAt, now, remoteAddress(r), truncateUTF8(r.UserAgent(), 255))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID)
|
||||
principal.SessionID = sessionID
|
||||
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
|
||||
s.audit(r.Context(), principal.Name, "login", "user", strconv.FormatUint(credential.ID, 10), "success", nil, remoteAddress(r))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal})
|
||||
}
|
||||
|
||||
func (s *authStore) authenticate(ctx context.Context, token string) (platform.Principal, bool) {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
key := hex.EncodeToString(hash[:])
|
||||
if principal, ok := s.cacheGet(key); ok {
|
||||
return principal, true
|
||||
}
|
||||
var user authUser
|
||||
var sessionID string
|
||||
var expiresAt time.Time
|
||||
var external sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.user_type,u.status,u.customer_ref,u.tenant_ref,u.auth_provider,u.external_subject,u.last_login_at,u.created_at,u.updated_at,s.id,s.expires_at FROM platform_user_session s JOIN platform_user u ON u.id=s.user_id WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) AND u.status='enabled'`, hash[:]).Scan(
|
||||
&user.ID, &user.Username, &user.DisplayName, &user.UserType, &user.Status, &user.CustomerRef, &user.TenantRef, &user.AuthProvider, &external, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &sessionID, &expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
user.ExternalSubject = external.String
|
||||
principal, err := s.principalForUser(ctx, user)
|
||||
if err != nil {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
principal.SessionID = sessionID
|
||||
s.cachePut(key, principal, expiresAt)
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func (s *authStore) logout(ctx context.Context, token string) {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE token_hash=? AND revoked_at IS NULL`, hash[:])
|
||||
s.cacheMu.Lock()
|
||||
delete(s.cache, hex.EncodeToString(hash[:]))
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *authStore) changePassword(w http.ResponseWriter, r *http.Request, principal platform.Principal) {
|
||||
if principal.AuthProvider != "local" || principal.SubjectID == "" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_CHANGE_UNAVAILABLE", "当前登录方式不支持修改密码", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
var input passwordChangeRequest
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
if err := validatePassword(input.NewPassword); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_INVALID", err.Error(), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(principal.SubjectID, 10, 64)
|
||||
var currentHash string
|
||||
if err := s.db.QueryRowContext(r.Context(), `SELECT password_hash FROM platform_user WHERE id=? AND status='enabled'`, id).Scan(¤tHash); err != nil || bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(input.CurrentPassword)) != nil {
|
||||
s.audit(r.Context(), principal.Name, "password.change", "user", principal.SubjectID, "denied", map[string]any{"reason": "current_password_invalid"}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "CURRENT_PASSWORD_INVALID", "当前密码不正确", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(input.NewPassword)) == nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_UNCHANGED", "新密码不能与当前密码相同", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(input.NewPassword), 12)
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user SET password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL,updated_by=? WHERE id=?`, string(hash), principal.Name, id); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法撤销旧会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
s.invalidateUser(id)
|
||||
s.audit(r.Context(), principal.Name, "password.change", "user", principal.SubjectID, "success", nil, remoteAddress(r))
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"changed": true})
|
||||
}
|
||||
|
||||
func (s *authStore) localCredential(ctx context.Context, username string) (localCredential, error) {
|
||||
var credential localCredential
|
||||
var external sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,failed_login_count,locked_until,last_login_at,created_at,updated_at FROM platform_user WHERE username=? AND auth_provider='local'`, username).Scan(
|
||||
&credential.ID, &credential.Username, &credential.DisplayName, &credential.PasswordHash, &credential.UserType, &credential.Status, &credential.CustomerRef, &credential.TenantRef, &credential.AuthProvider, &external, &credential.FailedLoginCount, &credential.LockedUntil, &credential.LastLoginAt, &credential.CreatedAt, &credential.UpdatedAt,
|
||||
)
|
||||
credential.ExternalSubject = external.String
|
||||
return credential, err
|
||||
}
|
||||
|
||||
func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) {
|
||||
menus := append([]string(nil), adminMenus...)
|
||||
vehicles := []string{}
|
||||
if user.UserType == "customer" {
|
||||
menus = []string{}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err := rows.Scan(&value); err != nil {
|
||||
rows.Close()
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
if customerMenuSet[value] {
|
||||
menus = append(menus, value)
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT vin FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
rows.Close()
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
vehicles = append(vehicles, vin)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
}
|
||||
return platform.Principal{
|
||||
SubjectID: strconv.FormatUint(user.ID, 10), Name: user.DisplayName, Username: user.Username,
|
||||
Role: user.UserType, UserType: user.UserType, CustomerRef: user.CustomerRef, TenantRef: user.TenantRef,
|
||||
AuthProvider: user.AuthProvider, MenuKeys: menus, VehicleVINs: vehicles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *authStore) handleAdmin(w http.ResponseWriter, r *http.Request, principal platform.Principal) bool {
|
||||
if r.URL.Path != "/api/v2/admin/users" && !strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/") {
|
||||
return false
|
||||
}
|
||||
if principal.UserType != "admin" && principal.Role != "admin" {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "仅管理员可以管理账号与权限", "", requestTraceID(r))
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/admin/users":
|
||||
s.listUsers(w, r)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users":
|
||||
s.createCustomer(w, r, principal)
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/"):
|
||||
s.updateCustomer(w, r, principal)
|
||||
default:
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "不支持的账号管理操作", "", requestTraceID(r))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *authStore) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.db.QueryContext(r.Context(), `SELECT id,username,display_name,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,last_login_at,created_at,updated_at FROM platform_user ORDER BY user_type ASC,created_at DESC`)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号列表", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
users := []authUser{}
|
||||
for rows.Next() {
|
||||
var user authUser
|
||||
var external sql.NullString
|
||||
if err := rows.Scan(&user.ID, &user.Username, &user.DisplayName, &user.UserType, &user.Status, &user.CustomerRef, &user.TenantRef, &user.AuthProvider, &external, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号列表", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
user.ExternalSubject = external.String
|
||||
principal, err := s.principalForUser(r.Context(), user)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
user.MenuKeys = principal.MenuKeys
|
||||
user.VehicleVINs = principal.VehicleVINs
|
||||
users = append(users, user)
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), users)
|
||||
}
|
||||
|
||||
func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
|
||||
var input userMutation
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
|
||||
menus, vins, err := s.validateMutation(r.Context(), input, true)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(r.Context(), `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,?,'customer',?,?,?,'local',?,?)`, strings.TrimSpace(input.Username), strings.TrimSpace(input.DisplayName), string(hash), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor.Name, actor.Name)
|
||||
if err != nil {
|
||||
writeUserMutationError(w, r, err, "创建")
|
||||
return
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
if err := replaceGrants(r.Context(), tx, uint64(id), menus, vins, actor.Name); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法保存客户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"menus": menus, "vehicleCount": len(vins)}, remoteAddress(r))
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
|
||||
idText := strings.TrimPrefix(r.URL.Path, "/api/v2/admin/users/")
|
||||
id, err := strconv.ParseUint(idText, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_ID_INVALID", "账号编号无效", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
var input userMutation
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
|
||||
menus, vins, err := s.validateMutation(r.Context(), input, false)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法更新客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userType string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT user_type FROM platform_user WHERE id=? FOR UPDATE`, id).Scan(&userType); err != nil || userType != "customer" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "CUSTOMER_USER_REQUIRED", "只能通过此功能维护客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
args := []any{strings.TrimSpace(input.DisplayName), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor.Name}
|
||||
query := `UPDATE platform_user SET display_name=?,status=?,customer_ref=?,tenant_ref=?,updated_by=?`
|
||||
if input.Password != "" {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
query += `,password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL`
|
||||
args = append(args, string(hash))
|
||||
}
|
||||
query += ` WHERE id=?`
|
||||
args = append(args, id)
|
||||
if _, err := tx.ExecContext(r.Context(), query, args...); err != nil {
|
||||
writeUserMutationError(w, r, err, "更新")
|
||||
return
|
||||
}
|
||||
if err := replaceGrants(r.Context(), tx, id, menus, vins, actor.Name); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法保存客户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if input.Status == "disabled" || input.Password != "" {
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法撤销旧会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法更新客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
s.invalidateUser(id)
|
||||
s.audit(r.Context(), actor.Name, "user.update", "user", idText, "success", map[string]any{"status": input.Status, "menus": menus, "vehicleCount": len(vins), "passwordReset": input.Password != ""}, remoteAddress(r))
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *authStore) validateMutation(ctx context.Context, input userMutation, requirePassword bool) ([]string, []string, error) {
|
||||
if requirePassword && !usernamePattern.MatchString(strings.TrimSpace(input.Username)) {
|
||||
return nil, nil, fmt.Errorf("用户名需为 3-64 位字母、数字、点、下划线或短横线")
|
||||
}
|
||||
if strings.TrimSpace(input.DisplayName) == "" || len([]rune(strings.TrimSpace(input.DisplayName))) > 48 {
|
||||
return nil, nil, fmt.Errorf("客户名称不能为空且不能超过 48 个字符")
|
||||
}
|
||||
if input.Status != "enabled" && input.Status != "disabled" {
|
||||
return nil, nil, fmt.Errorf("账号状态无效")
|
||||
}
|
||||
if requirePassword || input.Password != "" {
|
||||
if err := validatePassword(input.Password); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
menus := normalizeMenus(input.MenuKeys)
|
||||
if len(menus) == 0 {
|
||||
return nil, nil, fmt.Errorf("至少分配一个客户菜单")
|
||||
}
|
||||
vins := normalizeVINs(input.VehicleVINs)
|
||||
if len(vins) == 0 {
|
||||
return nil, nil, fmt.Errorf("至少分配一辆可查看车辆")
|
||||
}
|
||||
if len(vins) > 5000 {
|
||||
return nil, nil, fmt.Errorf("单个客户最多分配 5000 辆车")
|
||||
}
|
||||
found, err := existingVINs(ctx, s.db, vins)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("校验车辆失败")
|
||||
}
|
||||
if len(found) != len(vins) {
|
||||
missing := []string{}
|
||||
for _, vin := range vins {
|
||||
if !found[vin] {
|
||||
missing = append(missing, vin)
|
||||
}
|
||||
if len(missing) == 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("存在未接入的 VIN:%s", strings.Join(missing, "、"))
|
||||
}
|
||||
return menus, vins, nil
|
||||
}
|
||||
|
||||
func replaceGrants(ctx context.Context, tx *sql.Tx, userID uint64, menus, vins []string, actor string) error {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_menu WHERE user_id=?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, menu := range menus {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`, userID, menu, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, vin := range vins {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle(user_id,vin,source_system,granted_by) VALUES(?,?,'manual',?)`, userID, vin, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingVINs(ctx context.Context, db *sql.DB, vins []string) (map[string]bool, error) {
|
||||
result := map[string]bool{}
|
||||
for start := 0; start < len(vins); start += 500 {
|
||||
end := start + 500
|
||||
if end > len(vins) {
|
||||
end = len(vins)
|
||||
}
|
||||
part := vins[start:end]
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(part)), ",")
|
||||
args := make([]any, len(part))
|
||||
for index := range part {
|
||||
args[index] = part[index]
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT vin FROM (SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'' UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>'') v WHERE vin IN (`+placeholders+`)`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
result[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeMenus(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
menus := []string{}
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if customerMenuSet[value] && !seen[value] {
|
||||
seen[value] = true
|
||||
menus = append(menus, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(menus)
|
||||
return menus
|
||||
}
|
||||
|
||||
func normalizeVINs(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
vins := []string{}
|
||||
for _, value := range values {
|
||||
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\t' || r == ' '
|
||||
}) {
|
||||
vin := strings.ToUpper(strings.TrimSpace(part))
|
||||
if vin != "" && len(vin) <= 64 && !seen[vin] {
|
||||
seen[vin] = true
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(vins)
|
||||
return vins
|
||||
}
|
||||
|
||||
func validatePassword(password string) error {
|
||||
if len(password) < 10 || len(password) > 128 {
|
||||
return fmt.Errorf("密码长度需为 10-128 位")
|
||||
}
|
||||
classes := 0
|
||||
if regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||
classes++
|
||||
}
|
||||
if regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||
classes++
|
||||
}
|
||||
if regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||
classes++
|
||||
}
|
||||
if regexp.MustCompile(`[^A-Za-z0-9]`).MatchString(password) {
|
||||
classes++
|
||||
}
|
||||
if classes < 3 {
|
||||
return fmt.Errorf("密码需包含大小写字母、数字、特殊字符中的至少三类")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomSessionToken() (string, [sha256.Size]byte, error) {
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", [sha256.Size]byte{}, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw[:])
|
||||
return token, sha256.Sum256([]byte(token)), nil
|
||||
}
|
||||
|
||||
func randomHex(size int) (string, error) {
|
||||
raw := make([]byte, size)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func (s *authStore) cacheGet(key string) (platform.Principal, bool) {
|
||||
s.cacheMu.RLock()
|
||||
entry, ok := s.cache[key]
|
||||
s.cacheMu.RUnlock()
|
||||
now := time.Now()
|
||||
if !ok || now.After(entry.expiresAt) || now.Sub(entry.cachedAt) > localSessionCacheTTL {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
return entry.principal.Clone(), true
|
||||
}
|
||||
|
||||
func (s *authStore) cachePut(key string, principal platform.Principal, expiresAt time.Time) {
|
||||
s.cacheMu.Lock()
|
||||
s.cache[key] = cachedSession{principal: principal.Clone(), expiresAt: expiresAt, cachedAt: time.Now()}
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *authStore) invalidateUser(userID uint64) {
|
||||
subject := strconv.FormatUint(userID, 10)
|
||||
s.cacheMu.Lock()
|
||||
for key, entry := range s.cache {
|
||||
if entry.principal.SubjectID == subject {
|
||||
delete(s.cache, key)
|
||||
}
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *authStore) audit(ctx context.Context, actor, action, targetType, targetID, result string, detail any, remote string) {
|
||||
var detailJSON any
|
||||
if detail != nil {
|
||||
if encoded, err := json.Marshal(detail); err == nil {
|
||||
detailJSON = encoded
|
||||
}
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `INSERT INTO platform_auth_audit(actor,action,target_type,target_id,result,detail_json,remote_addr) VALUES(?,?,?,?,?,?,?)`, truncateUTF8(actor, 96), action, targetType, targetID, result, detailJSON, truncateUTF8(remote, 96))
|
||||
}
|
||||
|
||||
func decodeAuthJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
defer r.Body.Close()
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", "", requestTraceID(r))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func remoteAddress(r *http.Request) string {
|
||||
if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" {
|
||||
return forwarded
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func truncateUTF8(value string, max int) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) > max {
|
||||
runes = runes[:max]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func writeUserMutationError(w http.ResponseWriter, r *http.Request, err error, action string) {
|
||||
message := "无法" + action + "客户账号"
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
message = "用户名已存在"
|
||||
}
|
||||
httpx.WriteError(w, http.StatusConflict, "USER_MUTATION_FAILED", message, "", requestTraceID(r))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
// IdentityAdapter is the narrow trust boundary for a future Yudao Cloud,
|
||||
// RuoYi Sys or company SSO integration. Implementations must validate a signed,
|
||||
// short-lived credential (or use server-side introspection) before returning a
|
||||
// principal. Unsigned forwarding headers must never implement this interface.
|
||||
//
|
||||
// External identities should be mapped to platform_user through
|
||||
// (auth_provider, external_subject); menu and vehicle grants remain platform
|
||||
// authorization data so changing an upstream login system cannot bypass Scope.
|
||||
type IdentityAdapter interface {
|
||||
Name() string
|
||||
AuthenticateBearer(context.Context, string) (platform.Principal, bool, error)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
dataMode = "mock"
|
||||
}
|
||||
var store platform.Store = platform.NewMockStore()
|
||||
var authDB *sql.DB
|
||||
var storeErr error
|
||||
if dataMode != "mock" && dataMode != "production" {
|
||||
storeErr = fmt.Errorf("DATA_MODE must be mock or production")
|
||||
@@ -46,6 +47,7 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
storeErr = fmt.Errorf("connect production mysql: %w", err)
|
||||
}
|
||||
} else {
|
||||
authDB = db
|
||||
var tdengine *sql.DB
|
||||
if cfg.TDengineDSN != "" {
|
||||
tdengine, err = platform.OpenSQL(ctx, cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
@@ -92,7 +94,7 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
// Reverse geocoding consumes the server-side AMap credential, so it must
|
||||
// stay behind the same authentication boundary as the platform API.
|
||||
api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient)
|
||||
handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg))
|
||||
handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg, authDB))
|
||||
handler = withAppConfig(handler, cfg)
|
||||
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
|
||||
return withRequestTimeout(handler, cfg.RequestTimeout)
|
||||
|
||||
@@ -22,6 +22,9 @@ type Config struct {
|
||||
AuthToken string
|
||||
AuthMode string
|
||||
AuthTokensJSON string
|
||||
BootstrapAdminUsername string
|
||||
BootstrapAdminPassword string
|
||||
SessionTTL time.Duration
|
||||
DataMode string
|
||||
ExportDir string
|
||||
RequestTimeout time.Duration
|
||||
@@ -56,6 +59,9 @@ func Load() Config {
|
||||
AuthToken: os.Getenv("AUTH_TOKEN"),
|
||||
AuthMode: authMode(),
|
||||
AuthTokensJSON: os.Getenv("AUTH_TOKENS_JSON"),
|
||||
BootstrapAdminUsername: os.Getenv("BOOTSTRAP_ADMIN_USERNAME"),
|
||||
BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
SessionTTL: time.Duration(envInt("AUTH_SESSION_TTL_HOURS", 12)) * time.Hour,
|
||||
DataMode: dataMode(),
|
||||
ExportDir: os.Getenv("EXPORT_DIR"),
|
||||
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
|
||||
@@ -41,6 +41,19 @@ func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSumm
|
||||
}
|
||||
|
||||
func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[AlertEvent], error) {
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
if strings.TrimSpace(query.Keyword) == "" {
|
||||
return Page[AlertEvent]{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "客户账号仅可查询已授权车辆的告警"}
|
||||
}
|
||||
vin, err := s.resolveVehicleVIN(ctx, query.Keyword, query.Protocol)
|
||||
if err != nil {
|
||||
return Page[AlertEvent]{}, err
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, vin); err != nil {
|
||||
return Page[AlertEvent]{}, err
|
||||
}
|
||||
query.Keyword = vin
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return Page[AlertEvent]{}, err
|
||||
|
||||
@@ -480,6 +480,8 @@ func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err er
|
||||
if clientErr, ok := asClientError(err); ok {
|
||||
status := http.StatusBadRequest
|
||||
switch {
|
||||
case clientErr.Code == "VEHICLE_PERMISSION_DENIED":
|
||||
status = http.StatusForbidden
|
||||
case strings.HasSuffix(clientErr.Code, "_NOT_FOUND"):
|
||||
status = http.StatusNotFound
|
||||
case strings.HasSuffix(clientErr.Code, "_CONFLICT"), clientErr.Code == "ALERT_ACTION_NOT_ALLOWED":
|
||||
|
||||
@@ -21,6 +21,7 @@ func buildVehicleListSQL(query url.Values) SQLQuery {
|
||||
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
|
||||
args := []any{}
|
||||
where := []string{"1 = 1"}
|
||||
where, args = appendVINListFilter(where, args, "s.vin", query.Get("scopeVins"))
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
@@ -72,6 +73,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
|
||||
args := []any{}
|
||||
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
||||
where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
|
||||
having := []string{}
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
@@ -173,6 +175,7 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
|
||||
args := []any{}
|
||||
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
||||
where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
|
||||
having := []string{}
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
@@ -294,6 +297,7 @@ func buildRealtimeLocationSQL(query url.Values) SQLQuery {
|
||||
offset := parsePositive(query.Get("offset"), 0)
|
||||
args := []any{}
|
||||
where := []string{"1 = 1"}
|
||||
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "l.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
@@ -323,6 +327,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
|
||||
args := []any{}
|
||||
where := []string{"l.vin IS NOT NULL", "l.vin <> ''"}
|
||||
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
|
||||
having := []string{}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "l.protocol = ?")
|
||||
@@ -449,6 +454,7 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
if len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
@@ -510,6 +516,7 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
|
||||
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
@@ -544,6 +551,7 @@ func buildMileageStatisticsWhere(query url.Values) (string, []any) {
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
|
||||
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
@@ -616,6 +624,7 @@ func buildFleetLatestMileageSQL(query url.Values) SQLQuery {
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
where, args = appendVINListFilter(where, args, "l.vin", query.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
if len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "l.protocol", protocols)
|
||||
|
||||
@@ -3,8 +3,49 @@ package platform
|
||||
import "context"
|
||||
|
||||
type Principal struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
SubjectID string `json:"subjectId,omitempty"`
|
||||
SessionID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Role string `json:"role"`
|
||||
UserType string `json:"userType"`
|
||||
CustomerRef string `json:"customerRef,omitempty"`
|
||||
TenantRef string `json:"tenantRef,omitempty"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"-"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
}
|
||||
|
||||
func (p Principal) Clone() Principal {
|
||||
p.MenuKeys = append([]string(nil), p.MenuKeys...)
|
||||
p.VehicleVINs = append([]string(nil), p.VehicleVINs...)
|
||||
p.VehicleCount = len(p.VehicleVINs)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p Principal) CanMenu(key string) bool {
|
||||
if p.UserType == "admin" || p.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
for _, value := range p.MenuKeys {
|
||||
if value == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p Principal) CanVIN(vin string) bool {
|
||||
if p.UserType != "customer" {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range p.VehicleVINs {
|
||||
if allowed == vin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type principalContextKey struct{}
|
||||
|
||||
@@ -29,6 +29,9 @@ var vehicleProfileSourceSystemPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._:-
|
||||
|
||||
func (s *Service) VehicleProfile(ctx context.Context, vin string) (VehicleProfile, error) {
|
||||
vin = strings.TrimSpace(vin)
|
||||
if err := authorizeVehicleVIN(ctx, vin); err != nil {
|
||||
return VehicleProfile{}, err
|
||||
}
|
||||
if err := validateProfileVIN(vin); err != nil {
|
||||
return VehicleProfile{}, err
|
||||
}
|
||||
|
||||
@@ -484,7 +484,11 @@ func matchesMonitorStatus(row VehicleRealtimeRow, requested string) bool {
|
||||
}
|
||||
|
||||
func (s *Service) MonitorSummary(ctx context.Context, query url.Values) (MonitorSummary, error) {
|
||||
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query))
|
||||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return MonitorSummary{}, err
|
||||
}
|
||||
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery))
|
||||
if err != nil {
|
||||
return MonitorSummary{}, err
|
||||
}
|
||||
@@ -538,7 +542,11 @@ func (s *Service) buildMonitorSummary(ctx context.Context, query url.Values, veh
|
||||
}
|
||||
|
||||
func (s *Service) MonitorWorkspace(ctx context.Context, query url.Values) (MonitorWorkspaceResponse, error) {
|
||||
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query))
|
||||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return MonitorWorkspaceResponse{}, err
|
||||
}
|
||||
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery))
|
||||
if err != nil {
|
||||
return MonitorWorkspaceResponse{}, err
|
||||
}
|
||||
@@ -566,7 +574,11 @@ func (s *Service) MonitorWorkspace(ctx context.Context, query url.Values) (Monit
|
||||
}
|
||||
|
||||
func (s *Service) MonitorMap(ctx context.Context, query url.Values) (MonitorMapResponse, error) {
|
||||
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query))
|
||||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return MonitorMapResponse{}, err
|
||||
}
|
||||
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery))
|
||||
if err != nil {
|
||||
return MonitorMapResponse{}, err
|
||||
}
|
||||
@@ -684,7 +696,11 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
|
||||
}
|
||||
|
||||
func (s *Service) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
||||
return s.store.Vehicles(ctx, query)
|
||||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return Page[VehicleRow]{}, err
|
||||
}
|
||||
return s.store.Vehicles(ctx, scopedQuery)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveVehicleIdentity(ctx context.Context, keyword string, protocol string) (VehicleIdentityResolution, error) {
|
||||
@@ -704,11 +720,19 @@ func (s *Service) ResolveVehicleIdentity(ctx context.Context, keyword string, pr
|
||||
}
|
||||
|
||||
func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
|
||||
return s.store.VehicleCoverage(ctx, query)
|
||||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return Page[VehicleCoverageRow]{}, err
|
||||
}
|
||||
return s.store.VehicleCoverage(ctx, scopedQuery)
|
||||
}
|
||||
|
||||
func (s *Service) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
|
||||
return s.store.VehicleCoverageSummary(ctx, query)
|
||||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return VehicleCoverageSummary{}, err
|
||||
}
|
||||
return s.store.VehicleCoverageSummary(ctx, scopedQuery)
|
||||
}
|
||||
|
||||
func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) {
|
||||
@@ -743,6 +767,15 @@ func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[V
|
||||
func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, protocol string) (VehicleServiceOverview, error) {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
resolvedScopeVIN, err := s.resolveVehicleVIN(ctx, keyword, protocol)
|
||||
if err != nil {
|
||||
return VehicleServiceOverview{}, err
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, resolvedScopeVIN); err != nil {
|
||||
return VehicleServiceOverview{}, err
|
||||
}
|
||||
}
|
||||
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
|
||||
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
|
||||
Keywords: []string{keyword},
|
||||
@@ -827,6 +860,10 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
keyword := strings.TrimSpace(vin)
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}}
|
||||
vehicleQuery, err := applyPrincipalVehicleScope(ctx, vehicleQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
vehicles, err := s.store.Vehicles(ctx, vehicleQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
@@ -843,6 +880,9 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
if queryVIN == "" {
|
||||
queryVIN = keyword
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, queryVIN); err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
if resolvedVIN == "" {
|
||||
qualityQuery := url.Values{"keyword": {keyword}, "limit": {"20"}}
|
||||
if protocol != "" {
|
||||
@@ -1495,6 +1535,9 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates
|
||||
if err != nil {
|
||||
return LatestTelemetryResponse{}, err
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, resolvedVIN); err != nil {
|
||||
return LatestTelemetryResponse{}, err
|
||||
}
|
||||
rawResult := make(chan latestTelemetryRawResult, len(canonicalVehicleProtocols))
|
||||
catalogResult := make(chan latestTelemetryCatalogResult, 1)
|
||||
for _, protocol := range canonicalVehicleProtocols {
|
||||
@@ -3616,7 +3659,10 @@ func cloneStringMap(values map[string]string) map[string]string {
|
||||
}
|
||||
|
||||
func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (url.Values, error) {
|
||||
resolved := cloneValues(query)
|
||||
resolved, err := applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(resolved.Get("keywords")) != "" {
|
||||
return resolved, nil
|
||||
}
|
||||
@@ -3629,6 +3675,9 @@ func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (ur
|
||||
return nil, err
|
||||
}
|
||||
if resolvedVIN != "" {
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" && !principal.CanVIN(resolvedVIN) {
|
||||
return nil, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
|
||||
}
|
||||
resolved.Set("vin", resolvedVIN)
|
||||
}
|
||||
return resolved, nil
|
||||
@@ -3640,6 +3689,10 @@ func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protoco
|
||||
return keyword, nil
|
||||
}
|
||||
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}}
|
||||
vehicleQuery, err := applyPrincipalVehicleScope(ctx, vehicleQuery)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if protocol = strings.TrimSpace(protocol); protocol != "" {
|
||||
vehicleQuery.Set("protocol", protocol)
|
||||
}
|
||||
@@ -3654,6 +3707,44 @@ func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protoco
|
||||
return identity.VIN, nil
|
||||
}
|
||||
|
||||
func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Values, error) {
|
||||
next := cloneValues(query)
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
return next, nil
|
||||
}
|
||||
allowed := make(map[string]bool, len(principal.VehicleVINs))
|
||||
for _, vin := range principal.VehicleVINs {
|
||||
allowed[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
}
|
||||
for _, value := range []string{next.Get("vin"), next.Get("vins")} {
|
||||
for _, vin := range strings.Split(value, ",") {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if vin != "" && isLikelyVIN(vin) && !allowed[vin] {
|
||||
return nil, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(principal.VehicleVINs) == 0 {
|
||||
next.Set("scopeVins", "__NO_VEHICLE_SCOPE__")
|
||||
} else {
|
||||
next.Set("scopeVins", strings.Join(principal.VehicleVINs, ","))
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func authorizeVehicleVIN(ctx context.Context, vin string) error {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
return nil
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if vin == "" || !principal.CanVIN(vin) {
|
||||
return clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneValues(values url.Values) url.Values {
|
||||
cloned := make(url.Values, len(values))
|
||||
for key, current := range values {
|
||||
|
||||
@@ -51,6 +51,8 @@ type countingStore struct {
|
||||
vehiclesCalls int
|
||||
vehicleRealtimeCalls int
|
||||
overviewBatchCalls int
|
||||
lastVehicleQuery url.Values
|
||||
lastRealtimeQuery url.Values
|
||||
}
|
||||
|
||||
func newCountingStore() *countingStore {
|
||||
@@ -59,14 +61,33 @@ func newCountingStore() *countingStore {
|
||||
|
||||
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
||||
s.vehiclesCalls++
|
||||
s.lastVehicleQuery = cloneValues(query)
|
||||
return s.MockStore.Vehicles(ctx, query)
|
||||
}
|
||||
|
||||
func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||
s.vehicleRealtimeCalls++
|
||||
s.lastRealtimeQuery = cloneValues(query)
|
||||
return s.MockStore.VehicleRealtime(ctx, query)
|
||||
}
|
||||
|
||||
func TestCustomerVehicleScopeIsInjectedAndExplicitBypassIsDenied(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
service := NewService(store)
|
||||
principal := Principal{Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}}
|
||||
ctx := WithPrincipal(context.Background(), principal)
|
||||
if _, err := service.Vehicles(ctx, url.Values{"limit": {"20"}}); err != nil {
|
||||
t.Fatalf("scoped vehicle list failed: %v", err)
|
||||
}
|
||||
if got := store.lastVehicleQuery.Get("scopeVins"); got != "LB9A32A24R0LS1426" {
|
||||
t.Fatalf("scopeVins=%q", got)
|
||||
}
|
||||
_, err := service.VehicleRealtime(ctx, url.Values{"vin": {"LMRKH9AC2R1004087"}})
|
||||
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" {
|
||||
t.Fatalf("cross-vehicle query should be forbidden, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||
s.overviewBatchCalls++
|
||||
return s.MockStore.VehicleServiceOverviews(ctx, query)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ApiEnvelope,
|
||||
AdminUser,
|
||||
AccessQuery,
|
||||
AccessSummary,
|
||||
AccessThresholdConfig,
|
||||
@@ -41,6 +42,8 @@ import type {
|
||||
RealtimeLocationRow,
|
||||
SourceReadinessPlan,
|
||||
SessionInfo,
|
||||
LoginResponse,
|
||||
CustomerUserInput,
|
||||
TrackPlaybackResponse,
|
||||
VehicleRealtimeRow,
|
||||
VehicleCoverageRow,
|
||||
@@ -171,6 +174,20 @@ function withTraceID(message: string, traceID?: string) {
|
||||
|
||||
export const api = {
|
||||
session: (signal?: AbortSignal) => request<SessionInfo>('/api/v2/session', withSignal(undefined, signal)),
|
||||
login: (credentials: { username: string; password: string }) => request<LoginResponse>('/api/v2/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials)
|
||||
}),
|
||||
logout: () => request<{ loggedOut: boolean }>('/api/v2/auth/logout', { method: 'POST' }),
|
||||
changePassword: (input: { currentPassword: string; newPassword: string }) => request<{ changed: boolean }>('/api/v2/auth/password', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
adminUsers: (signal?: AbortSignal) => request<AdminUser[]>('/api/v2/admin/users', withSignal(undefined, signal)),
|
||||
createCustomerUser: (input: CustomerUserInput) => request<{ id: number }>('/api/v2/admin/users', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
updateCustomerUser: (id: number, input: CustomerUserInput) => request<{ id: number }>(`/api/v2/admin/users/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined),
|
||||
monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined),
|
||||
monitorWorkspace: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorWorkspaceResponse>(`/api/v2/monitor/workspace?${params.toString()}`, signal ? { signal } : undefined),
|
||||
|
||||
@@ -5,11 +5,53 @@ export interface ApiEnvelope<T> {
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
subjectId?: string;
|
||||
name: string;
|
||||
role: 'viewer' | 'operator' | 'admin';
|
||||
username?: string;
|
||||
role: 'viewer' | 'operator' | 'admin' | 'customer';
|
||||
userType?: 'viewer' | 'operator' | 'admin' | 'customer';
|
||||
authProvider?: 'local' | 'legacy-token' | 'disabled' | string;
|
||||
menuKeys?: string[];
|
||||
vehicleCount?: number;
|
||||
customerRef?: string;
|
||||
tenantRef?: string;
|
||||
authMode: 'disabled' | 'enforce';
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
expiresAt: string;
|
||||
session: Omit<SessionInfo, 'authMode'>;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
userType: 'admin' | 'customer';
|
||||
status: 'enabled' | 'disabled';
|
||||
customerRef: string;
|
||||
tenantRef: string;
|
||||
authProvider: string;
|
||||
externalSubject?: string;
|
||||
menuKeys: string[];
|
||||
vehicleVins: string[];
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CustomerUserInput {
|
||||
username?: string;
|
||||
displayName: string;
|
||||
password?: string;
|
||||
status: 'enabled' | 'disabled';
|
||||
customerRef: string;
|
||||
tenantRef: string;
|
||||
menuKeys: string[];
|
||||
vehicleVins: string[];
|
||||
}
|
||||
|
||||
export interface MonitorSummary {
|
||||
totalVehicles: number;
|
||||
onlineVehicles: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AppShell } from './layout/AppShell';
|
||||
import { AuthGate } from './auth/AuthGate';
|
||||
@@ -7,6 +7,8 @@ import { PlatformErrorBoundary, RoutePage } from './routing/RouteBoundary';
|
||||
import { RoutePageFactories, RoutePages } from './routing/routeModules';
|
||||
import { ROUTER_FUTURE } from './routing/routerConfig';
|
||||
import { QUERY_MEMORY } from './queryPolicy';
|
||||
import { usePlatformSession } from './auth/AuthGate';
|
||||
import { hasMenu } from './auth/session';
|
||||
|
||||
export function createPlatformQueryClient() {
|
||||
return new QueryClient({
|
||||
@@ -35,6 +37,17 @@ function PlatformReadySignal() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function defaultPath(menuKeys: string[]) {
|
||||
const first = ['monitor', 'vehicles', 'tracks', 'statistics'].find((key) => menuKeys.includes(key));
|
||||
return first ? `/${first}` : '/monitor';
|
||||
}
|
||||
|
||||
function ProtectedRoute({ menu, children }: { menu: string; children: ReactNode }) {
|
||||
const { session } = usePlatformSession();
|
||||
if (!hasMenu(session, menu)) return <Navigate to={defaultPath(session.menuKeys ?? [])} replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
export function AppV2() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -43,15 +56,16 @@ export function AppV2() {
|
||||
<AuthGate><BrowserRouter future={ROUTER_FUTURE}>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Navigate to="/monitor" replace />} />
|
||||
<Route path="/monitor" element={<RoutePage page={RoutePages.Monitor} recreatePage={RoutePageFactories.Monitor} label="全局监控" />} />
|
||||
<Route path="/vehicles/:vin?" element={<RoutePage page={RoutePages.Vehicles} recreatePage={RoutePageFactories.Vehicles} label="车辆查询" />} />
|
||||
<Route path="/tracks" element={<RoutePage page={RoutePages.Tracks} recreatePage={RoutePageFactories.Tracks} label="轨迹回放" />} />
|
||||
<Route path="/history" element={<RoutePage page={RoutePages.History} recreatePage={RoutePageFactories.History} label="历史数据" />} />
|
||||
<Route path="/statistics" element={<RoutePage page={RoutePages.Statistics} recreatePage={RoutePageFactories.Statistics} label="里程查询" />} />
|
||||
<Route path="/access" element={<RoutePage page={RoutePages.Access} recreatePage={RoutePageFactories.Access} label="接入管理" />} />
|
||||
<Route path="/alerts/*" element={<RoutePage page={RoutePages.Alerts} recreatePage={RoutePageFactories.Alerts} label="告警中心" />} />
|
||||
<Route path="/operations" element={<RoutePage page={RoutePages.Operations} recreatePage={RoutePageFactories.Operations} label="运维质量" />} />
|
||||
<Route index element={<SessionIndex />} />
|
||||
<Route path="/monitor" element={<ProtectedRoute menu="monitor"><RoutePage page={RoutePages.Monitor} recreatePage={RoutePageFactories.Monitor} label="全局监控" /></ProtectedRoute>} />
|
||||
<Route path="/vehicles/:vin?" element={<ProtectedRoute menu="vehicles"><RoutePage page={RoutePages.Vehicles} recreatePage={RoutePageFactories.Vehicles} label="车辆查询" /></ProtectedRoute>} />
|
||||
<Route path="/tracks" element={<ProtectedRoute menu="tracks"><RoutePage page={RoutePages.Tracks} recreatePage={RoutePageFactories.Tracks} label="轨迹回放" /></ProtectedRoute>} />
|
||||
<Route path="/history" element={<ProtectedRoute menu="history"><RoutePage page={RoutePages.History} recreatePage={RoutePageFactories.History} label="历史数据" /></ProtectedRoute>} />
|
||||
<Route path="/statistics" element={<ProtectedRoute menu="statistics"><RoutePage page={RoutePages.Statistics} recreatePage={RoutePageFactories.Statistics} label="里程查询" /></ProtectedRoute>} />
|
||||
<Route path="/access" element={<ProtectedRoute menu="access"><RoutePage page={RoutePages.Access} recreatePage={RoutePageFactories.Access} label="接入管理" /></ProtectedRoute>} />
|
||||
<Route path="/alerts/*" element={<ProtectedRoute menu="alerts"><RoutePage page={RoutePages.Alerts} recreatePage={RoutePageFactories.Alerts} label="告警中心" /></ProtectedRoute>} />
|
||||
<Route path="/operations" element={<ProtectedRoute menu="operations"><RoutePage page={RoutePages.Operations} recreatePage={RoutePageFactories.Operations} label="运维质量" /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute menu="users"><RoutePage page={RoutePages.Users} recreatePage={RoutePageFactories.Users} label="账号管理" /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/monitor" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
@@ -60,3 +74,8 @@ export function AppV2() {
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionIndex() {
|
||||
const { session } = usePlatformSession();
|
||||
return <Navigate to={defaultPath(session.menuKeys ?? [])} replace />;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session';
|
||||
import { AuthGate, usePlatformSession } from './AuthGate';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: { session: mocks.session } }));
|
||||
const mocks = vi.hoisted(() => ({ session: vi.fn(), login: vi.fn(), logout: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: { session: mocks.session, login: mocks.login, logout: mocks.logout } }));
|
||||
|
||||
function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
|
||||
const { session, logout } = usePlatformSession();
|
||||
@@ -21,10 +21,14 @@ function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mocks.session.mockReset();
|
||||
mocks.login.mockReset();
|
||||
mocks.logout.mockReset();
|
||||
mocks.logout.mockResolvedValue({ loggedOut: true });
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
test('treats login and logout as complete query and mutation cache boundaries', async () => {
|
||||
mocks.logout.mockResolvedValue({ loggedOut: true });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(['previous-user-vehicle-data'], { plate: '粤A00001' });
|
||||
client.getMutationCache().build(client, { mutationFn: async () => ({ ok: true }) });
|
||||
@@ -33,11 +37,12 @@ test('treats login and logout as complete query and mutation cache boundaries',
|
||||
if (getAccessToken() !== 'next-user-token') throw new Error('未登录');
|
||||
return { name: 'next-user', role: 'viewer', authMode: 'enforce' };
|
||||
});
|
||||
mocks.login.mockResolvedValue({ accessToken: 'next-user-token', expiresAt: new Date().toISOString(), session: { name: 'next-user', role: 'customer' } });
|
||||
|
||||
render(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={aborted} /></AuthGate></QueryClientProvider>);
|
||||
const token = await screen.findByPlaceholderText('Bearer token');
|
||||
fireEvent.change(token, { target: { value: 'next-user-token' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '进入平台' }));
|
||||
fireEvent.change(await screen.findByPlaceholderText('请输入用户名'), { target: { value: 'next-user' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'StrongPass!1' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(await screen.findByText('next-user')).toBeInTheDocument();
|
||||
expect(client.getQueryData(['previous-user-vehicle-data'])).toBeUndefined();
|
||||
@@ -47,7 +52,7 @@ test('treats login and logout as complete query and mutation cache boundaries',
|
||||
await waitFor(() => expect(client.getMutationCache().getAll()).toHaveLength(1));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出测试会话' }));
|
||||
expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument();
|
||||
expect(await screen.findByPlaceholderText('请输入用户名')).toBeInTheDocument();
|
||||
await waitFor(() => expect(aborted).toHaveBeenCalledTimes(1));
|
||||
expect(client.getQueryCache().find({ queryKey: ['protected-vehicle-data'] })).toBeUndefined();
|
||||
expect(client.getMutationCache().getAll()).toHaveLength(0);
|
||||
@@ -55,6 +60,7 @@ test('treats login and logout as complete query and mutation cache boundaries',
|
||||
});
|
||||
|
||||
test('an expired protected request returns to login and removes the active user cache', async () => {
|
||||
mocks.logout.mockResolvedValue({ loggedOut: true });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
setAccessToken('next-user-token');
|
||||
mocks.session.mockImplementation(async () => {
|
||||
@@ -67,7 +73,7 @@ test('an expired protected request returns to login and removes the active user
|
||||
client.setQueryData(['active-user-detail'], { plate: '粤A00002' });
|
||||
window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT));
|
||||
|
||||
expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument();
|
||||
expect(await screen.findByPlaceholderText('请输入用户名')).toBeInTheDocument();
|
||||
expect(client.getQueryCache().find({ queryKey: ['active-user-detail'] })).toBeUndefined();
|
||||
expect(getAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createContext, FormEvent, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
|
||||
|
||||
@@ -19,8 +19,13 @@ export function usePlatformSession() {
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [tokenVersion, setTokenVersion] = useState(0);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [draftToken, setDraftToken] = useState('');
|
||||
const [legacyMode, setLegacyMode] = useState(false);
|
||||
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
|
||||
const [loginPending, setLoginPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState('');
|
||||
const session = useQuery({
|
||||
queryKey: ['platform-session', tokenVersion],
|
||||
queryFn: ({ signal }) => api.session(signal),
|
||||
@@ -31,31 +36,73 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
}, [queryClient]);
|
||||
const logout = useCallback(() => {
|
||||
const finishLogout = useCallback(() => {
|
||||
clearAccessToken();
|
||||
clearClientSession();
|
||||
setPassword('');
|
||||
setDraftToken('');
|
||||
setAttempted(false);
|
||||
setLoginError('');
|
||||
setTokenVersion((value) => value + 1);
|
||||
}, [clearClientSession]);
|
||||
const logout = useCallback(() => {
|
||||
void api.logout().catch(() => undefined).finally(finishLogout);
|
||||
}, [finishLogout]);
|
||||
useEffect(() => {
|
||||
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout);
|
||||
return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout);
|
||||
}, [logout]);
|
||||
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, finishLogout);
|
||||
return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, finishLogout);
|
||||
}, [finishLogout]);
|
||||
|
||||
const login = (event: FormEvent) => {
|
||||
const login = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setAccessToken(draftToken);
|
||||
clearClientSession();
|
||||
setAttempted(true);
|
||||
setTokenVersion((value) => value + 1);
|
||||
setLoginError('');
|
||||
setLoginPending(true);
|
||||
try {
|
||||
if (legacyMode) {
|
||||
setAccessToken(draftToken);
|
||||
} else {
|
||||
const result = await api.login({ username: username.trim(), password });
|
||||
setAccessToken(result.accessToken);
|
||||
}
|
||||
clearClientSession();
|
||||
setTokenVersion((value) => value + 1);
|
||||
} catch (error) {
|
||||
clearAccessToken();
|
||||
setLoginError(error instanceof Error ? error.message : '登录失败,请稍后重试');
|
||||
} finally {
|
||||
setLoginPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (session.isPending) {
|
||||
return <div className="v2-auth-screen"><div className="v2-auth-card"><img className="v2-auth-logo" src="/brand-logo.svg" alt="灵牛智能" /><i className="v2-auth-spinner" /><strong>正在验证访问身份…</strong></div></div>;
|
||||
return <div className="v2-auth-screen"><div className="v2-auth-card v2-auth-loading"><img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" /><i className="v2-auth-spinner" /><strong>正在验证登录状态…</strong></div></div>;
|
||||
}
|
||||
if (!session.data) {
|
||||
return <div className="v2-auth-screen"><form className="v2-auth-card" onSubmit={login}><img className="v2-auth-logo" src="/brand-logo.svg" alt="灵牛智能" /><h1>车辆数据中台</h1><p>请输入运维人员分配的访问令牌。令牌仅保存在当前浏览器会话中。</p><label><span>访问令牌</span><input autoFocus required type="password" autoComplete="current-password" value={draftToken} onChange={(event) => setDraftToken(event.target.value)} placeholder="Bearer token" /></label>{attempted && session.error ? <em>{session.error.message}</em> : null}<button type="submit" disabled={!draftToken.trim()}>进入平台</button></form></div>;
|
||||
const error = loginError || (attempted && session.error ? session.error.message : '');
|
||||
return <div className="v2-auth-screen">
|
||||
<section className="v2-auth-intro" aria-hidden="true">
|
||||
<img src="/brand-logo.svg" alt="" />
|
||||
<h2>让每一辆车的数据<br />清晰、可控、可信</h2>
|
||||
<p>统一查看实时状态、车辆轨迹与里程数据。账号权限由管理员按车辆和功能精确分配。</p>
|
||||
<div><span>车辆级数据隔离</span><span>登录审计</span><span>可扩展身份接入</span></div>
|
||||
</section>
|
||||
<form className="v2-auth-card" onSubmit={login}>
|
||||
<img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" />
|
||||
<h1>登录车辆数据中台</h1>
|
||||
<p>{legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'}</p>
|
||||
{legacyMode ? <label><span>运维访问令牌</span><input autoFocus required type="password" autoComplete="off" value={draftToken} onChange={(event) => setDraftToken(event.target.value)} placeholder="Bearer token" /></label> : <>
|
||||
<label><span>用户名</span><input autoFocus required autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入用户名" /></label>
|
||||
<label><span>密码</span><input required type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></label>
|
||||
</>}
|
||||
{error ? <em role="alert">{error}</em> : null}
|
||||
<button type="submit" disabled={loginPending || (legacyMode ? !draftToken.trim() : !username.trim() || !password)}>{loginPending ? '正在登录…' : '登录'}</button>
|
||||
<button className="v2-auth-mode-switch" type="button" onClick={() => { setLegacyMode((value) => !value); setLoginError(''); }}>
|
||||
{legacyMode ? '返回账号密码登录' : '使用运维令牌登录'}
|
||||
</button>
|
||||
<small>登录即表示你同意遵守平台数据安全规范</small>
|
||||
</form>
|
||||
</div>;
|
||||
}
|
||||
return <AuthContext.Provider value={{ session: session.data, logout }}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
const TOKEN_KEY = 'vehicle-platform.access-token';
|
||||
export const PLATFORM_UNAUTHORIZED_EVENT = 'vehicle-platform:unauthorized';
|
||||
|
||||
export type PlatformRole = 'viewer' | 'operator' | 'admin';
|
||||
|
||||
export interface PlatformSession {
|
||||
name: string;
|
||||
role: PlatformRole;
|
||||
authMode: 'disabled' | 'enforce';
|
||||
}
|
||||
export type PlatformSession = SessionInfo;
|
||||
|
||||
export function getAccessToken() {
|
||||
return window.sessionStorage.getItem(TOKEN_KEY) ?? '';
|
||||
@@ -34,6 +28,13 @@ export function canOperate(session: PlatformSession) {
|
||||
return session.role === 'operator' || session.role === 'admin';
|
||||
}
|
||||
|
||||
export function hasMenu(session: PlatformSession, menu: string) {
|
||||
if (session.role === 'admin') return true;
|
||||
if (!session.menuKeys) return session.role !== 'customer';
|
||||
return session.menuKeys.includes(menu);
|
||||
}
|
||||
|
||||
export function canAdminister(session: PlatformSession) {
|
||||
return session.role === 'admin';
|
||||
}
|
||||
import type { SessionInfo } from '../../api/types';
|
||||
|
||||
@@ -8,10 +8,11 @@ const routing = vi.hoisted(() => ({
|
||||
scheduleIdleRoutePreloads: vi.fn(() => vi.fn()),
|
||||
shouldPreloadRouteOnIntent: vi.fn(() => true)
|
||||
}));
|
||||
const auth = vi.hoisted(() => ({ session: { name: 'test-user', role: 'viewer' as const } as any }));
|
||||
|
||||
vi.mock('../routing/routeModules', () => routing);
|
||||
vi.mock('../auth/AuthGate', () => ({
|
||||
usePlatformSession: () => ({ session: { name: 'test-user', role: 'viewer' }, logout: vi.fn() })
|
||||
usePlatformSession: () => ({ session: auth.session, logout: vi.fn() })
|
||||
}));
|
||||
|
||||
import { AppShell } from './AppShell';
|
||||
@@ -19,6 +20,24 @@ import { AppShell } from './AppShell';
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
auth.session = { name: 'test-user', role: 'viewer' };
|
||||
});
|
||||
|
||||
test('renders only explicitly assigned customer menus', () => {
|
||||
auth.session = { name: '客户甲', role: 'customer', userType: 'customer', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'] };
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor']}>
|
||||
<Routes><Route element={<AppShell />}><Route path="*" element={<span>页面内容</span>} /></Route></Routes>
|
||||
</MemoryRouter>);
|
||||
|
||||
expect(screen.getByRole('link', { name: '全局监控' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '车辆查询' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '轨迹回放' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '里程查询' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '历史数据' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '告警中心' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '接入管理' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '账号管理' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '运维质量' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('reschedules likely route preloads when the active module changes', async () => {
|
||||
|
||||
@@ -12,19 +12,22 @@ import {
|
||||
IconUser,
|
||||
IconExit
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { hasMenu } from '../auth/session';
|
||||
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
|
||||
|
||||
const navigation = [
|
||||
{ to: '/monitor', label: '全局监控', icon: IconHome },
|
||||
{ to: '/vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ to: '/tracks', label: '轨迹回放', icon: IconMapPin },
|
||||
{ to: '/history', label: '历史数据', icon: IconBarChartHStroked },
|
||||
{ to: '/statistics', label: '里程查询', icon: IconBarChartHStroked },
|
||||
{ to: '/alerts', label: '告警中心', icon: IconAlarm },
|
||||
{ to: '/access', label: '接入管理', icon: IconBox }
|
||||
{ to: '/monitor', menu: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ to: '/vehicles', menu: 'vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ to: '/tracks', menu: 'tracks', label: '轨迹回放', icon: IconMapPin },
|
||||
{ to: '/history', menu: 'history', label: '历史数据', icon: IconBarChartHStroked },
|
||||
{ to: '/statistics', menu: 'statistics', label: '里程查询', icon: IconBarChartHStroked },
|
||||
{ to: '/alerts', menu: 'alerts', label: '告警中心', icon: IconAlarm },
|
||||
{ to: '/access', menu: 'access', label: '接入管理', icon: IconBox },
|
||||
{ to: '/users', menu: 'users', label: '账号管理', icon: IconUser }
|
||||
];
|
||||
|
||||
const pageNames: Record<string, string> = {
|
||||
@@ -35,7 +38,8 @@ const pageNames: Record<string, string> = {
|
||||
statistics: '里程查询',
|
||||
alerts: '告警中心',
|
||||
access: '接入管理',
|
||||
operations: '运维质量'
|
||||
operations: '运维质量',
|
||||
users: '账号管理'
|
||||
};
|
||||
|
||||
const pageHelp: Record<string, { summary: string; tips: string[] }> = {
|
||||
@@ -46,7 +50,8 @@ const pageHelp: Record<string, { summary: string; tips: string[] }> = {
|
||||
statistics: { summary: '按日期区间比较车辆每日里程与总里程。', tips: ['未选择车牌时按车队分页展示。', '可配置 JT808、GB32960、YUTONG_MQTT 的启用状态与优先级。'] },
|
||||
alerts: { summary: '查看、确认和关闭车辆业务告警。', tips: ['筛选条件会限定列表和统计口径。', '处置前请核对证据与版本,避免覆盖其他人员的操作。'] },
|
||||
access: { summary: '核对车辆接入覆盖、身份差异和协议质量。', tips: ['差异列表是主要工作区,可从统计卡片快速下钻。', '阈值配置仅对有权限的账号开放。'] },
|
||||
operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] }
|
||||
operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] },
|
||||
users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] }
|
||||
};
|
||||
|
||||
function ContextHelp({ section, onClose }: { section: string; onClose: () => void }) {
|
||||
@@ -72,8 +77,9 @@ export function AppShell() {
|
||||
const activeRoutePath = `/${section}`;
|
||||
const { session, logout } = usePlatformSession();
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const [mobileLayout, setMobileLayout] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches);
|
||||
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员' }[session.role];
|
||||
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员', customer: '客户' }[session.role];
|
||||
useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(max-width: 680px)');
|
||||
@@ -91,24 +97,61 @@ export function AppShell() {
|
||||
<h1>{pageNames[section] ?? '车辆数据中台'}</h1>
|
||||
<div className="v2-topbar-actions">
|
||||
<button type="button" aria-label="帮助" aria-expanded={helpOpen} aria-controls="v2-context-help" onClick={() => setHelpOpen(true)}><IconHelpCircle /></button>
|
||||
<span className="v2-current-user"><IconUser /><b>{session.name}</b><small>{roleLabel}</small></span>
|
||||
<button type="button" className="v2-current-user" title="修改密码" onClick={() => setPasswordOpen(true)}><IconUser /><b>{session.name}</b><small>{roleLabel}</small></button>
|
||||
<button type="button" className="v2-password-mobile" aria-label="修改密码" onClick={() => setPasswordOpen(true)}><IconUser /></button>
|
||||
<button type="button" aria-label="退出登录" title="退出登录" onClick={logout}><IconExit /></button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="v2-content"><Outlet /></main>
|
||||
</div>
|
||||
{helpOpen ? <div id="v2-context-help"><ContextHelp section={section} onClose={() => setHelpOpen(false)} /></div> : null}
|
||||
{passwordOpen ? <PasswordDialog onClose={() => setPasswordOpen(false)} onChanged={logout} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged: () => void }) {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (newPassword !== confirmPassword) { setError('两次输入的新密码不一致'); return; }
|
||||
setPending(true); setError('');
|
||||
try {
|
||||
await api.changePassword({ currentPassword, newPassword });
|
||||
onChanged();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '密码修改失败');
|
||||
} finally { setPending(false); }
|
||||
};
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && !pending) onClose(); };
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [onClose, pending]);
|
||||
return <div className="v2-password-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget && !pending) onClose(); }}><form className="v2-password-dialog" role="dialog" aria-modal="true" aria-labelledby="v2-password-title" onSubmit={submit}>
|
||||
<header><div><h2 id="v2-password-title">修改登录密码</h2><p>修改成功后,所有设备需要重新登录。</p></div><button type="button" aria-label="关闭修改密码" onClick={onClose}>×</button></header>
|
||||
<label><span>当前密码</span><input autoFocus required type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} /></label>
|
||||
<label><span>新密码</span><input required minLength={10} type="password" autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} placeholder="至少 10 位,包含三类字符" /></label>
|
||||
<label><span>确认新密码</span><input required minLength={10} type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} /></label>
|
||||
{error ? <em role="alert">{error}</em> : null}
|
||||
<footer><button type="button" onClick={onClose} disabled={pending}>取消</button><button type="submit" disabled={pending || !currentPassword || !newPassword || !confirmPassword}>{pending ? '正在修改…' : '确认修改'}</button></footer>
|
||||
</form></div>;
|
||||
}
|
||||
|
||||
const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]];
|
||||
const mobileMoreNavigation = [navigation[3], navigation[5], navigation[6], { to: '/operations', label: '运维质量', icon: IconSetting }];
|
||||
const mobileMoreNavigation = [navigation[3], navigation[5], navigation[6], navigation[7], { to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting }];
|
||||
|
||||
function MobileNavigation() {
|
||||
const location = useLocation();
|
||||
const { session } = usePlatformSession();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreActive = mobileMoreNavigation.some((item) => location.pathname.startsWith(item.to));
|
||||
const primary = mobilePrimaryNavigation.filter((item) => hasMenu(session, item.menu));
|
||||
const more = mobileMoreNavigation.filter((item) => hasMenu(session, item.menu));
|
||||
const moreActive = more.some((item) => location.pathname.startsWith(item.to));
|
||||
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
|
||||
useEffect(() => setMoreOpen(false), [location.pathname]);
|
||||
useEffect(() => {
|
||||
@@ -120,17 +163,19 @@ function MobileNavigation() {
|
||||
|
||||
const link = ({ to, label, icon: Icon }: (typeof navigation)[number]) => <NavLink key={to} to={to} aria-label={label} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}><Icon size="large" /><span>{label}</span></NavLink>;
|
||||
return <>
|
||||
{moreOpen ? <div className="v2-mobile-more-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setMoreOpen(false); }}><section className="v2-mobile-more-sheet" role="dialog" aria-modal="true" aria-label="更多功能"><header><div><strong>更多功能</strong><span>数据分析与系统管理</span></div><button type="button" aria-label="关闭更多功能" onClick={() => setMoreOpen(false)}>×</button></header><nav>{mobileMoreNavigation.map(link)}</nav></section></div> : null}
|
||||
{moreOpen && more.length ? <div className="v2-mobile-more-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setMoreOpen(false); }}><section className="v2-mobile-more-sheet" role="dialog" aria-modal="true" aria-label="更多功能"><header><div><strong>更多功能</strong><span>数据分析与系统管理</span></div><button type="button" aria-label="关闭更多功能" onClick={() => setMoreOpen(false)}>×</button></header><nav>{more.map(link)}</nav></section></div> : null}
|
||||
<nav className="v2-mobile-navigation" aria-label="主导航">
|
||||
{mobilePrimaryNavigation.map(link)}
|
||||
<button type="button" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} onClick={() => setMoreOpen((value) => !value)}><IconMore size="large" /><span>更多</span></button>
|
||||
{primary.map(link)}
|
||||
{more.length ? <button type="button" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} onClick={() => setMoreOpen((value) => !value)}><IconMore size="large" /><span>更多</span></button> : null}
|
||||
</nav>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const { session } = usePlatformSession();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
|
||||
const visibleNavigation = navigation.filter((item) => hasMenu(session, item.menu));
|
||||
|
||||
return (
|
||||
<aside className={`v2-sidebar${collapsed ? ' is-collapsed' : ''}`}>
|
||||
@@ -139,17 +184,17 @@ function Sidebar() {
|
||||
<img className="v2-brand-symbol" src="/brand-mark.svg" alt="" aria-hidden="true" />
|
||||
</div>
|
||||
<nav className="v2-navigation" aria-label="主导航">
|
||||
{navigation.map(({ to, label, icon: Icon }) => (
|
||||
{visibleNavigation.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} aria-label={label} title={collapsed ? label : undefined} onPointerEnter={() => warmRoute(to)} onFocus={() => warmRoute(to)} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-nav-item ${isActive ? 'is-active' : ''}`}>
|
||||
<Icon size="large" />
|
||||
<span className="v2-nav-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<NavLink to="/operations" aria-label="运维质量" title={collapsed ? '运维质量' : undefined} onPointerEnter={() => warmRoute('/operations')} onFocus={() => warmRoute('/operations')} onPointerDown={() => warmRoute('/operations')} className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}>
|
||||
{hasMenu(session, 'operations') ? <NavLink to="/operations" aria-label="运维质量" title={collapsed ? '运维质量' : undefined} onPointerEnter={() => warmRoute('/operations')} onFocus={() => warmRoute('/operations')} onPointerDown={() => warmRoute('/operations')} className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}>
|
||||
<IconSetting size="large" />
|
||||
<span className="v2-nav-label">运维质量</span>
|
||||
</NavLink>
|
||||
</NavLink> : null}
|
||||
<button className="v2-collapse" type="button" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
|
||||
<IconChevronLeft />
|
||||
<span>收起</span>
|
||||
|
||||
137
vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx
Normal file
137
vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { AdminUser, CustomerUserInput } from '../../api/types';
|
||||
|
||||
const customerMenus = [
|
||||
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
|
||||
{ key: 'vehicles', label: '车辆查询', description: '查看车辆档案与最新遥测' },
|
||||
{ key: 'tracks', label: '轨迹回放', description: '查询授权车辆的历史轨迹' },
|
||||
{ key: 'statistics', label: '里程查询', description: '查询授权车辆的每日与区间里程' }
|
||||
];
|
||||
|
||||
type Draft = {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
status: 'enabled' | 'disabled';
|
||||
customerRef: string;
|
||||
tenantRef: string;
|
||||
menuKeys: string[];
|
||||
vehicleVins: string[];
|
||||
};
|
||||
|
||||
const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleVins: [] };
|
||||
|
||||
function draftFromUser(user?: AdminUser): Draft {
|
||||
if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleVins: [] };
|
||||
return { username: user.username, displayName: user.displayName, password: '', status: user.status, customerRef: user.customerRef, tenantRef: user.tenantRef, menuKeys: [...user.menuKeys], vehicleVins: [...user.vehicleVins] };
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) return '尚未登录';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 });
|
||||
const customers = useMemo(() => (users.data ?? []).filter((user) => user.userType === 'customer'), [users.data]);
|
||||
const [selectedID, setSelectedID] = useState<number | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const selected = useMemo(() => customers.find((user) => user.id === selectedID), [customers, selectedID]);
|
||||
const [draft, setDraft] = useState<Draft>(() => draftFromUser());
|
||||
const [vehicleKeyword, setVehicleKeyword] = useState('');
|
||||
const deferredVehicleKeyword = useDeferredValue(vehicleKeyword.trim());
|
||||
const [bulkVINs, setBulkVINs] = useState('');
|
||||
const [feedback, setFeedback] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!creating && selected) setDraft(draftFromUser(selected));
|
||||
}, [creating, selected]);
|
||||
|
||||
const candidates = useQuery({
|
||||
queryKey: ['permission-vehicle-candidates', deferredVehicleKeyword],
|
||||
queryFn: ({ signal }) => api.vehicles(new URLSearchParams({ keyword: deferredVehicleKeyword, limit: '20', offset: '0' }), signal),
|
||||
enabled: deferredVehicleKeyword.length >= 1,
|
||||
staleTime: 30_000
|
||||
});
|
||||
const assigned = useMemo(() => new Set(draft.vehicleVins), [draft.vehicleVins]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const input: CustomerUserInput = { displayName: draft.displayName.trim(), password: draft.password || undefined, status: draft.status, customerRef: draft.customerRef.trim(), tenantRef: draft.tenantRef.trim(), menuKeys: draft.menuKeys, vehicleVins: draft.vehicleVins };
|
||||
if (creating) return api.createCustomerUser({ ...input, username: draft.username.trim(), password: draft.password });
|
||||
if (!selected) throw new Error('请先选择客户账号');
|
||||
return api.updateCustomerUser(selected.id, input);
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
setFeedback(creating ? '客户账号已创建' : '账号与权限已更新');
|
||||
setCreating(false);
|
||||
setSelectedID(result.id);
|
||||
setDraft((value) => ({ ...value, password: '' }));
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
},
|
||||
onError: (error) => setFeedback(error instanceof Error ? error.message : '保存失败')
|
||||
});
|
||||
|
||||
const startCreate = () => {
|
||||
setCreating(true);
|
||||
setSelectedID(null);
|
||||
setDraft(draftFromUser());
|
||||
setVehicleKeyword('');
|
||||
setBulkVINs('');
|
||||
setFeedback('');
|
||||
};
|
||||
const selectCustomer = (user: AdminUser) => {
|
||||
setCreating(false);
|
||||
setSelectedID(user.id);
|
||||
setDraft(draftFromUser(user));
|
||||
setVehicleKeyword('');
|
||||
setBulkVINs('');
|
||||
setFeedback('');
|
||||
};
|
||||
const toggleVIN = (vin: string) => setDraft((value) => ({ ...value, vehicleVins: value.vehicleVins.includes(vin) ? value.vehicleVins.filter((item) => item !== vin) : [...value.vehicleVins, vin].sort() }));
|
||||
const addBulkVINs = () => {
|
||||
const next = bulkVINs.split(/[\s,,;;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean);
|
||||
setDraft((value) => ({ ...value, vehicleVins: [...new Set([...value.vehicleVins, ...next])].sort() }));
|
||||
setBulkVINs('');
|
||||
};
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); setFeedback(''); save.mutate(); };
|
||||
|
||||
return <div className="v2-user-admin">
|
||||
<header className="v2-user-admin-heading">
|
||||
<div><h2>客户账号与数据权限</h2><p>账号只会看到已分配的菜单和车辆;权限修改最多在 30 秒内对现有会话生效。</p></div>
|
||||
<button type="button" onClick={startCreate}>新建客户账号</button>
|
||||
</header>
|
||||
<div className="v2-user-admin-grid">
|
||||
<aside className="v2-user-list">
|
||||
<div className="v2-user-list-summary"><strong>{customers.length}</strong><span>个客户账号</span></div>
|
||||
{users.isPending ? <p className="v2-user-empty">正在加载账号…</p> : customers.length === 0 ? <p className="v2-user-empty">还没有客户账号</p> : customers.map((user) => <button key={user.id} type="button" className={selectedID === user.id && !creating ? 'is-active' : ''} onClick={() => selectCustomer(user)}>
|
||||
<span className="v2-user-avatar">{user.displayName.slice(0, 1)}</span>
|
||||
<span><b>{user.displayName}</b><small>@{user.username} · {user.vehicleVins.length} 辆车</small></span>
|
||||
<i className={user.status === 'enabled' ? 'is-enabled' : ''}>{user.status === 'enabled' ? '启用' : '停用'}</i>
|
||||
</button>)}
|
||||
</aside>
|
||||
<main className="v2-user-editor">
|
||||
{!creating && !selected ? <div className="v2-user-editor-empty"><strong>选择一个客户账号</strong><p>查看或调整账号状态、菜单和车辆范围。</p><button type="button" onClick={startCreate}>创建第一个客户账号</button></div> : <form onSubmit={submit}>
|
||||
<div className="v2-user-editor-title"><div><h3>{creating ? '创建客户账号' : selected?.displayName}</h3><p>{creating ? '设置登录身份和最小必要权限' : `最近登录:${formatTime(selected?.lastLoginAt)}`}</p></div><label className="v2-user-status"><input type="checkbox" checked={draft.status === 'enabled'} onChange={(event) => setDraft((value) => ({ ...value, status: event.target.checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '账号启用' : '账号停用'}</span></label></div>
|
||||
<section><h4>登录身份</h4><div className="v2-user-fields">
|
||||
<label><span>用户名</span><input required disabled={!creating} value={draft.username} onChange={(event) => setDraft((value) => ({ ...value, username: event.target.value }))} placeholder="例如 customer-huadong" /></label>
|
||||
<label><span>客户名称</span><input required value={draft.displayName} onChange={(event) => setDraft((value) => ({ ...value, displayName: event.target.value }))} placeholder="显示在平台右上角" /></label>
|
||||
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><input required={creating} type="password" autoComplete="new-password" value={draft.password} onChange={(event) => setDraft((value) => ({ ...value, password: event.target.value }))} placeholder="至少 10 位,包含三类字符" /></label>
|
||||
<label><span>客户标识(可选)</span><input value={draft.customerRef} onChange={(event) => setDraft((value) => ({ ...value, customerRef: event.target.value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
|
||||
</div></section>
|
||||
<section><h4>菜单权限 <small>只能分配客户开放菜单</small></h4><div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><input type="checkbox" checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div></section>
|
||||
<section><div className="v2-vehicle-permission-heading"><h4>车辆权限 <small>已选择 {draft.vehicleVins.length} 辆</small></h4>{draft.vehicleVins.length ? <button type="button" onClick={() => setDraft((value) => ({ ...value, vehicleVins: [] }))}>清空</button> : null}</div>
|
||||
<div className="v2-vehicle-permission-tools"><label><span>按车牌或 VIN 搜索</span><input value={vehicleKeyword} onChange={(event) => setVehicleKeyword(event.target.value)} placeholder="输入后显示候选车辆" /></label><label><span>批量粘贴 VIN</span><span className="v2-bulk-vin"><input value={bulkVINs} onChange={(event) => setBulkVINs(event.target.value)} placeholder="空格、逗号或换行分隔" /><button type="button" disabled={!bulkVINs.trim()} onClick={addBulkVINs}>加入</button></span></label></div>
|
||||
{deferredVehicleKeyword ? <div className="v2-vehicle-candidates">{candidates.isPending ? <p>正在搜索车辆…</p> : (candidates.data?.items ?? []).length === 0 ? <p>没有匹配车辆</p> : candidates.data?.items.map((vehicle) => <button type="button" className={assigned.has(vehicle.vin) ? 'is-selected' : ''} key={`${vehicle.vin}-${vehicle.protocol}`} onClick={() => toggleVIN(vehicle.vin)}><span><b>{vehicle.plate || '未登记车牌'}</b><small>{vehicle.vin}</small></span><i>{assigned.has(vehicle.vin) ? '已分配' : '选择'}</i></button>)}</div> : null}
|
||||
{draft.vehicleVins.length ? <div className="v2-assigned-vins">{draft.vehicleVins.map((vin) => <button type="button" key={vin} onClick={() => toggleVIN(vin)}>{vin}<span>×</span></button>)}</div> : <p className="v2-user-empty">尚未分配车辆,客户将无法看到任何车辆数据。</p>}
|
||||
</section>
|
||||
{feedback ? <p className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} role="status">{feedback}</p> : null}
|
||||
<footer><button type="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</button></footer>
|
||||
</form>}
|
||||
</main>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lazy, type ComponentType } from 'react';
|
||||
|
||||
export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations';
|
||||
export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations' | 'users';
|
||||
type RouteModule = { default: ComponentType };
|
||||
type RoutePreloadConnection = { saveData?: boolean; effectiveType?: string };
|
||||
type RoutePreloadRuntime = { visibilityState?: DocumentVisibilityState; deviceMemory?: number };
|
||||
@@ -18,7 +18,8 @@ const importers: Record<RouteSection, () => Promise<RouteModule>> = {
|
||||
statistics: () => import('../pages/StatisticsPage'),
|
||||
access: () => import('../pages/AccessPage'),
|
||||
alerts: () => import('../pages/AlertsPage'),
|
||||
operations: () => import('../pages/OperationsPage')
|
||||
operations: () => import('../pages/OperationsPage'),
|
||||
users: () => import('../pages/UsersPage')
|
||||
};
|
||||
|
||||
const pendingModules = new Map<RouteSection, Promise<RouteModule>>();
|
||||
@@ -30,7 +31,8 @@ const likelyNextRoutes: Record<RouteSection, RouteSection[]> = {
|
||||
statistics: ['history', 'vehicles'],
|
||||
access: ['monitor', 'operations'],
|
||||
alerts: ['monitor', 'access'],
|
||||
operations: ['access', 'monitor']
|
||||
operations: ['access', 'monitor'],
|
||||
users: ['monitor', 'vehicles']
|
||||
};
|
||||
|
||||
function routeLoadTimeout<T>(promise: Promise<T>, section: string, timeoutMs: number) {
|
||||
@@ -195,7 +197,8 @@ export const RoutePages = {
|
||||
Statistics: createRouteComponent('statistics'),
|
||||
Access: createRouteComponent('access'),
|
||||
Alerts: createRouteComponent('alerts'),
|
||||
Operations: createRouteComponent('operations')
|
||||
Operations: createRouteComponent('operations'),
|
||||
Users: createRouteComponent('users')
|
||||
} as const;
|
||||
|
||||
export const RoutePageFactories = {
|
||||
@@ -206,5 +209,6 @@ export const RoutePageFactories = {
|
||||
Statistics: () => createRouteComponent('statistics'),
|
||||
Access: () => createRouteComponent('access'),
|
||||
Alerts: () => createRouteComponent('alerts'),
|
||||
Operations: () => createRouteComponent('operations')
|
||||
Operations: () => createRouteComponent('operations'),
|
||||
Users: () => createRouteComponent('users')
|
||||
} as const;
|
||||
|
||||
@@ -18,13 +18,23 @@ body { margin: 0; background: var(--v2-bg); color: var(--v2-text); font-family:
|
||||
button, input, select { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
.v2-auth-screen { display: grid; min-height: 100vh; place-items: center; background: radial-gradient(circle at 50% 10%, #edf4ff 0, #f4f7fb 42%, #eef2f7 100%); padding: 20px; }
|
||||
.v2-auth-card { display: flex; width: min(390px, 100%); flex-direction: column; align-items: stretch; border: 1px solid var(--v2-border); border-radius: 16px; background: #fff; padding: 34px; box-shadow: 0 22px 70px rgba(21, 32, 51, .12); }
|
||||
.v2-auth-screen { display: grid; min-height: 100vh; grid-template-columns: minmax(360px, 1.15fr) minmax(420px, .85fr); align-items: center; background: #f3f6fa; padding: clamp(28px, 5vw, 80px); }
|
||||
.v2-auth-intro { max-width: 640px; padding: 36px 8vw 36px 4vw; }
|
||||
.v2-auth-intro > img { width: 176px; }
|
||||
.v2-auth-intro h2 { margin: 54px 0 20px; color: #14223a; font-size: clamp(34px, 4vw, 58px); font-weight: 720; line-height: 1.16; letter-spacing: -.055em; }
|
||||
.v2-auth-intro p { max-width: 520px; margin: 0; color: #617087; font-size: 16px; line-height: 1.9; }
|
||||
.v2-auth-intro > div { display: flex; flex-wrap: wrap; gap: 24px; margin-top: 42px; color: #3f5068; font-size: 13px; font-weight: 650; }
|
||||
.v2-auth-intro > div span::before { display: inline-block; width: 7px; height: 7px; margin-right: 8px; border-radius: 50%; background: var(--v2-blue); content: ""; }
|
||||
.v2-auth-card { display: flex; width: min(430px, 100%); flex-direction: column; align-items: stretch; border: 1px solid #e0e7f0; border-radius: 18px; background: #fff; padding: 42px; box-shadow: 0 28px 80px rgba(21, 32, 51, .11); }
|
||||
.v2-auth-loading { grid-column: 1 / -1; justify-self: center; }
|
||||
.v2-auth-card > strong { margin-top: 12px; text-align: center; }
|
||||
.v2-auth-logo { display: block; width: 150px; height: auto; margin: 0 auto 18px; }
|
||||
.v2-auth-card h1 { margin: 0; text-align: center; font-size: 22px; }.v2-auth-card p { margin: 10px 0 24px; color: var(--v2-muted); text-align: center; font-size: 12px; line-height: 1.7; }
|
||||
.v2-auth-card label { display: flex; flex-direction: column; gap: 7px; color: #58667a; font-size: 12px; font-weight: 600; }.v2-auth-card input { height: 40px; border: 1px solid #d7e0ec; border-radius: 8px; padding: 0 11px; outline: 0; }.v2-auth-card input:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
|
||||
.v2-auth-card em { margin-top: 9px; color: var(--v2-red); font-size: 11px; font-style: normal; }.v2-auth-card button { height: 40px; margin-top: 18px; border: 0; border-radius: 8px; background: var(--v2-blue); color: #fff; cursor: pointer; font-weight: 700; }.v2-auth-card button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.v2-auth-card h1 { margin: 2px 0 0; text-align: center; font-size: 24px; letter-spacing: -.035em; }.v2-auth-card p { margin: 11px 0 26px; color: var(--v2-muted); text-align: center; font-size: 13px; line-height: 1.7; }
|
||||
.v2-auth-card label { display: flex; flex-direction: column; gap: 8px; margin-top: 15px; color: #44536a; font-size: 13px; font-weight: 650; }.v2-auth-card input { height: 46px; border: 1px solid #d5deea; border-radius: 9px; padding: 0 13px; outline: 0; font-size: 14px; }.v2-auth-card input:focus { border-color: #7aaafb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
|
||||
.v2-auth-card em { margin-top: 12px; color: var(--v2-red); font-size: 12px; font-style: normal; }.v2-auth-card button { height: 46px; margin-top: 22px; border: 0; border-radius: 9px; background: var(--v2-blue); color: #fff; cursor: pointer; font-size: 14px; font-weight: 700; }.v2-auth-card button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.v2-auth-card .v2-auth-mode-switch { height: 36px; margin-top: 8px; background: transparent; color: #68778c; font-size: 12px; font-weight: 600; }
|
||||
.v2-auth-card .v2-auth-mode-switch:hover { color: var(--v2-blue); }
|
||||
.v2-auth-card > small { margin-top: 14px; color: #9aa5b5; text-align: center; font-size: 10px; }
|
||||
.v2-auth-spinner { width: 24px; height: 24px; margin: auto; border: 3px solid #d8e5fa; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
|
||||
|
||||
.v2-shell { height: 100vh; height: 100dvh; overflow: hidden; background: var(--v2-bg); }
|
||||
@@ -58,6 +68,13 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-help-panel > footer { display: flex; align-items: center; gap: 6px; margin-top: 14px; color: #8996a8; font-size: 10px; }
|
||||
.v2-help-panel kbd { border: 1px solid #dce4ef; border-radius: 4px; background: #fff; padding: 2px 5px; color: #59677c; font-family: inherit; }
|
||||
.v2-current-user { display: flex; align-items: center; gap: 6px; margin: 0 5px; color: #59677c; font-size: 11px; }.v2-current-user b { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-current-user small, .v2-role-badge { border-radius: 10px; background: var(--v2-blue-soft); padding: 2px 6px; color: var(--v2-blue); font-size: 9px; }
|
||||
.v2-topbar-actions .v2-current-user { display: flex; width: auto; padding: 0 7px; }
|
||||
.v2-password-mobile { display: none !important; }
|
||||
.v2-password-backdrop { position: fixed; z-index: 1400; inset: 0; display: grid; place-items: center; background: rgba(15,23,42,.28); padding: 16px; }
|
||||
.v2-password-dialog { width: min(410px,100%); border: 1px solid #e0e7ef; border-radius: 14px; background: #fff; padding: 22px; box-shadow: 0 28px 80px rgba(15,23,42,.2); }
|
||||
.v2-password-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }.v2-password-dialog h2 { margin: 0; font-size: 19px; }.v2-password-dialog header p { margin: 6px 0 0; color: #8491a3; font-size: 10px; }.v2-password-dialog header button { width: 30px; height: 30px; border: 0; border-radius: 7px; background: #f2f5f8; color: #66758a; cursor: pointer; }
|
||||
.v2-password-dialog > label { display: flex; flex-direction: column; gap: 6px; margin-top: 14px; color: #5a6980; font-size: 11px; font-weight: 650; }.v2-password-dialog input { height: 40px; border: 1px solid #d8e1ec; border-radius: 8px; padding: 0 10px; outline: 0; font-size: 13px; }.v2-password-dialog input:focus { border-color: #84b0f7; box-shadow: 0 0 0 3px rgba(18,104,243,.07); }.v2-password-dialog > em { display: block; margin-top: 10px; color: var(--v2-red); font-size: 10px; font-style: normal; }
|
||||
.v2-password-dialog footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }.v2-password-dialog footer button { height: 36px; border: 1px solid #d7e0eb; border-radius: 7px; background: #fff; padding: 0 14px; color: #5d6d83; cursor: pointer; font-size: 11px; font-weight: 650; }.v2-password-dialog footer button[type="submit"] { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }.v2-password-dialog footer button:disabled { opacity: .5; }
|
||||
.v2-role-notice { margin: 6px 0; border-radius: 6px; background: #f6f8fb; padding: 8px; color: var(--v2-muted); font-size: 8px; line-height: 1.5; }
|
||||
.v2-content { min-width: 0; min-height: 0; flex: 1; overflow: auto; }
|
||||
.v2-sidebar.is-collapsed { width: 68px; }
|
||||
@@ -1359,6 +1376,78 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-mileage-evidence { flex-direction: column; line-height: 1.5; }
|
||||
}
|
||||
|
||||
/* Account administration follows the platform's open table/workspace layout. */
|
||||
.v2-user-admin { min-height: 100%; padding: 22px 24px 34px; }
|
||||
.v2-user-admin-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin: 0 auto 18px; max-width: 1480px; }
|
||||
.v2-user-admin-heading h2 { margin: 0; font-size: 22px; letter-spacing: -.035em; }
|
||||
.v2-user-admin-heading p { margin: 7px 0 0; color: var(--v2-muted); font-size: 12px; }
|
||||
.v2-user-admin-heading > button, .v2-user-editor form > footer button, .v2-user-editor-empty button { height: 38px; border: 0; border-radius: 8px; background: var(--v2-blue); padding: 0 16px; color: #fff; cursor: pointer; font-size: 12px; font-weight: 700; }
|
||||
.v2-user-admin-grid { display: grid; max-width: 1480px; min-height: 690px; margin: 0 auto; grid-template-columns: 300px minmax(0, 1fr); overflow: hidden; border: 1px solid var(--v2-border); border-radius: 13px; background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-user-list { min-width: 0; border-right: 1px solid var(--v2-border); background: #f9fbfd; padding: 12px; }
|
||||
.v2-user-list-summary { display: flex; align-items: baseline; gap: 7px; padding: 8px 9px 14px; color: #7a8798; font-size: 11px; }
|
||||
.v2-user-list-summary strong { color: #26364e; font-size: 20px; }
|
||||
.v2-user-list > button { display: grid; width: 100%; grid-template-columns: 38px minmax(0,1fr) auto; align-items: center; gap: 10px; border: 0; border-radius: 9px; background: transparent; padding: 10px; color: #273750; text-align: left; cursor: pointer; }
|
||||
.v2-user-list > button:hover { background: #f0f4f9; }
|
||||
.v2-user-list > button.is-active { background: #eaf2ff; box-shadow: inset 3px 0 var(--v2-blue); }
|
||||
.v2-user-avatar { display: grid; width: 38px; height: 38px; place-items: center; border-radius: 9px; background: #dfe9f8; color: #3a5e91; font-weight: 800; }
|
||||
.v2-user-list button > span:nth-child(2) { min-width: 0; }
|
||||
.v2-user-list button b, .v2-user-list button small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-user-list button b { font-size: 12px; }
|
||||
.v2-user-list button small { margin-top: 4px; color: #8290a3; font-size: 9px; }
|
||||
.v2-user-list button i { border-radius: 10px; background: #f0f2f5; padding: 3px 6px; color: #8a96a6; font-size: 8px; font-style: normal; }
|
||||
.v2-user-list button i.is-enabled { background: #e9f8f2; color: #16805b; }
|
||||
.v2-user-editor { min-width: 0; }
|
||||
.v2-user-editor-empty { display: grid; height: 100%; place-content: center; justify-items: center; color: #4a5b72; text-align: center; }
|
||||
.v2-user-editor-empty strong { font-size: 18px; }.v2-user-editor-empty p { margin: 8px 0 18px; color: #8a96a7; font-size: 12px; }
|
||||
.v2-user-editor form { height: 100%; padding: 24px 28px 82px; }
|
||||
.v2-user-editor-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 20px; border-bottom: 1px solid #edf1f5; }
|
||||
.v2-user-editor-title h3 { margin: 0; font-size: 20px; }.v2-user-editor-title p { margin: 6px 0 0; color: #8591a2; font-size: 10px; }
|
||||
.v2-user-status { display: flex; align-items: center; gap: 8px; color: #45556c; font-size: 11px; font-weight: 650; }
|
||||
.v2-user-status input { width: 16px; height: 16px; accent-color: var(--v2-blue); }
|
||||
.v2-user-editor section { padding: 20px 0; border-bottom: 1px solid #edf1f5; }
|
||||
.v2-user-editor h4 { margin: 0 0 14px; color: #2c3d54; font-size: 13px; }
|
||||
.v2-user-editor h4 small { margin-left: 7px; color: #91a0b2; font-size: 9px; font-weight: 500; }
|
||||
.v2-user-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; }
|
||||
.v2-user-fields label, .v2-vehicle-permission-tools label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: #637188; font-size: 10px; font-weight: 650; }
|
||||
.v2-user-fields input, .v2-vehicle-permission-tools input { width: 100%; height: 38px; border: 1px solid #dbe3ed; border-radius: 7px; padding: 0 10px; outline: 0; color: #273750; font-size: 12px; }
|
||||
.v2-user-fields input:focus, .v2-vehicle-permission-tools input:focus { border-color: #8ab4f7; box-shadow: 0 0 0 3px rgba(18,104,243,.07); }
|
||||
.v2-user-fields input:disabled { background: #f5f7fa; color: #8390a0; }
|
||||
.v2-menu-permissions { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 9px; }
|
||||
.v2-menu-permissions label { display: flex; min-width: 0; align-items: flex-start; gap: 10px; border: 1px solid #e0e7ef; border-radius: 8px; padding: 11px; cursor: pointer; }
|
||||
.v2-menu-permissions label.is-selected { border-color: #a9c8fb; background: #f3f7ff; }
|
||||
.v2-menu-permissions input { margin-top: 2px; accent-color: var(--v2-blue); }.v2-menu-permissions span { min-width: 0; }.v2-menu-permissions b, .v2-menu-permissions small { display: block; }.v2-menu-permissions b { color: #33445c; font-size: 11px; }.v2-menu-permissions small { margin-top: 4px; color: #8491a3; font-size: 9px; line-height: 1.5; }
|
||||
.v2-vehicle-permission-heading { display: flex; align-items: center; justify-content: space-between; }.v2-vehicle-permission-heading h4 { margin-bottom: 14px; }.v2-vehicle-permission-heading > button { border: 0; background: transparent; color: #7d899a; cursor: pointer; font-size: 10px; }
|
||||
.v2-vehicle-permission-tools { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
|
||||
.v2-bulk-vin { display: flex; gap: 6px; }.v2-bulk-vin button { flex: 0 0 auto; border: 1px solid #cbd8e9; border-radius: 7px; background: #f7f9fc; padding: 0 11px; color: #46607e; cursor: pointer; font-size: 10px; }
|
||||
.v2-vehicle-candidates { display: grid; max-height: 194px; margin-top: 10px; grid-template-columns: repeat(2,minmax(0,1fr)); overflow: auto; border: 1px solid #e1e7ef; border-radius: 8px; padding: 5px; }
|
||||
.v2-vehicle-candidates > p { grid-column: 1/-1; margin: 16px; color: #8996a7; font-size: 10px; }
|
||||
.v2-vehicle-candidates > button { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: 6px; background: transparent; padding: 8px; text-align: left; cursor: pointer; }.v2-vehicle-candidates > button:hover { background: #f4f7fa; }.v2-vehicle-candidates > button.is-selected { background: #edf4ff; }
|
||||
.v2-vehicle-candidates b, .v2-vehicle-candidates small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-vehicle-candidates b { color: #34455c; font-size: 10px; }.v2-vehicle-candidates small { margin-top: 3px; color: #8a96a6; font-size: 8px; }.v2-vehicle-candidates i { color: var(--v2-blue); font-size: 9px; font-style: normal; }
|
||||
.v2-assigned-vins { display: flex; max-height: 118px; flex-wrap: wrap; gap: 6px; overflow: auto; margin-top: 10px; }.v2-assigned-vins button { height: 26px; border: 1px solid #dbe4ef; border-radius: 6px; background: #f7f9fc; padding: 0 7px; color: #52637a; cursor: pointer; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; }.v2-assigned-vins button span { margin-left: 6px; color: #99a4b3; }
|
||||
.v2-user-empty { margin: 14px 4px; color: #8b97a7; font-size: 10px; line-height: 1.6; }
|
||||
.v2-user-feedback { margin: 14px 0 0; color: #16805b; font-size: 11px; }.v2-user-feedback.is-error { color: var(--v2-red); }
|
||||
.v2-user-editor form > footer { display: flex; justify-content: flex-end; margin-top: 18px; }.v2-user-editor form > footer button:disabled { opacity: .5; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.v2-auth-screen { grid-template-columns: 1fr; }
|
||||
.v2-auth-intro { display: none; }
|
||||
.v2-auth-card { justify-self: center; }
|
||||
.v2-user-admin { padding: 12px; }
|
||||
.v2-user-admin-grid { grid-template-columns: 230px minmax(560px,1fr); overflow-x: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.v2-auth-screen { min-height: 100dvh; padding: 16px; }
|
||||
.v2-auth-card { padding: 28px 22px; }
|
||||
.v2-user-admin { padding: 8px 8px 82px; }
|
||||
.v2-user-admin-heading { align-items: flex-start; margin-bottom: 10px; }.v2-user-admin-heading h2 { font-size: 17px; }.v2-user-admin-heading p { display: none; }.v2-user-admin-heading > button { height: 34px; flex: 0 0 auto; padding: 0 10px; font-size: 10px; }
|
||||
.v2-user-admin-grid { display: flex; min-height: 0; overflow: visible; border: 0; box-shadow: none; flex-direction: column; gap: 8px; background: transparent; }
|
||||
.v2-user-list { display: flex; border: 1px solid var(--v2-border); border-radius: 10px; overflow-x: auto; padding: 6px; background: #fff; }.v2-user-list-summary { flex: 0 0 auto; padding: 8px; }.v2-user-list > button { min-width: 210px; }
|
||||
.v2-user-editor { border: 1px solid var(--v2-border); border-radius: 10px; overflow: hidden; background: #fff; }.v2-user-editor form { padding: 16px 14px 22px; }.v2-user-fields, .v2-menu-permissions, .v2-vehicle-permission-tools, .v2-vehicle-candidates { grid-template-columns: 1fr; }
|
||||
.v2-password-mobile { display: grid !important; }
|
||||
.v2-topbar-actions .v2-current-user { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.v2-access-filter-v3 { grid-template-columns: minmax(210px, 1.3fr) repeat(3, minmax(120px, .75fr)) auto auto; }
|
||||
.v2-access-filter-v3 > button { min-width: 76px; }
|
||||
|
||||
Reference in New Issue
Block a user