diff --git a/backend/api/README.md b/backend/api/README.md index f983f1b..ffa0db5 100644 --- a/backend/api/README.md +++ b/backend/api/README.md @@ -1,12 +1,19 @@ # Platform API -严格沿用 `sample/server` 的 BSM-SDK Core 分层和运行方式。运行前设置: +平台总后台 API 严格沿用 `sample/server` 的 BSM-SDK Core 分层和运行方式。 + +启动前请设置运行配置;JWT 密钥必须为 16、24 或 32 个字符。 ```powershell $env:BSM_RuntimeMode="dev" $env:BSM_Prefix="$(Get-Location)/etc" -$env:BSM_JwtSecretKey="仅本地使用的随机密钥" +$env:BSM_JwtSecretKey="local-dev-key-16" +$env:HEQI_PLATFORM_ROOT_PASSWORD="请设置不少于12位的root初始密码" go run ./cmd/main/main.go ``` -`cmd/cli` 提供 `version` 与 `migrate`;受保护接口使用 `middleware.JwtAuth(true)`。UUID V7 主键及完整中文注释以 `../migrations` 为准。 +应用启动和 `go run ./cmd/cli/main.go migrate` 都会在事务内幂等创建平台 `root` 账号。账号名固定为 `root`;优先使用 `HEQI_PLATFORM_ROOT_PASSWORD`,未设置时仅使用开发环境默认值。root 首次登录后必须通过 `PUT /heqi/v1/auth/password` 修改密码。 + +匿名接口为 `POST /heqi/v1/auth/login`;其余平台接口经 `middleware.JwtAuth(true)` 保护。请求头 `Authorization` 直接传递 JWT 原始值,不使用 `Bearer` 前缀。 + +UUID V7 主键、模型中文注释和 PostgreSQL 变更记录以 `../migrations` 为准。 diff --git a/backend/api/cmd/cli/main.go b/backend/api/cmd/cli/main.go index 1adac3e..631ad0a 100644 --- a/backend/api/cmd/cli/main.go +++ b/backend/api/cmd/cli/main.go @@ -7,6 +7,7 @@ import ( "git.apinb.com/heqiapp/platforms/backend/api/internal/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/initdb" _ "git.apinb.com/heqiapp/platforms/backend/api/internal/models" ) @@ -23,6 +24,9 @@ func main() { case "migrate": config.New(serviceKey) impl.NewImpl() + if err := initdb.New(impl.DBService); err != nil { + panic(err) + } fmt.Println("platform database auto migrate completed") default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) diff --git a/backend/api/cmd/main/main.go b/backend/api/cmd/main/main.go index 17a2bff..43ca02c 100644 --- a/backend/api/cmd/main/main.go +++ b/backend/api/cmd/main/main.go @@ -15,6 +15,7 @@ import ( "git.apinb.com/bsm-sdk/core/printer" "git.apinb.com/heqiapp/platforms/backend/api/internal/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/initdb" "git.apinb.com/heqiapp/platforms/backend/api/internal/routers" "github.com/gin-gonic/gin" ) @@ -24,6 +25,9 @@ const serviceKey = "heqi" func main() { config.New(serviceKey) impl.NewImpl() + if err := initdb.New(impl.DBService); err != nil { + panic(err) + } app := gin.Default() middleware.Mode(app) diff --git a/backend/api/internal/initdb/new.go b/backend/api/internal/initdb/new.go new file mode 100644 index 0000000..3795c19 --- /dev/null +++ b/backend/api/internal/initdb/new.go @@ -0,0 +1,11 @@ +// Package initdb 提供应用启动后的基础数据初始化。 +package initdb + +import "gorm.io/gorm" + +// New 在同一事务中初始化平台基础数据。 +func New(database *gorm.DB) error { + return database.Transaction(func(tx *gorm.DB) error { + return InitPlatformRoot(tx) + }) +} diff --git a/backend/api/internal/initdb/platform.go b/backend/api/internal/initdb/platform.go new file mode 100644 index 0000000..3942e24 --- /dev/null +++ b/backend/api/internal/initdb/platform.go @@ -0,0 +1,57 @@ +package initdb + +import ( + "errors" + "os" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +const ( + // PlatformRootUsername 是平台总后台的内置根账号名称。 + PlatformRootUsername = "root" + // PlatformRootPassword 是仅用于首次启动的初始密码,首次登录后必须修改。 + PlatformRootPassword = "Heqi@Root2026" + // PlatformRootRoleCode 表示根账号的平台角色。 + PlatformRootRoleCode = "platform_root" +) + +// InitPlatformRoot 幂等创建平台总后台 root 账号。 +func InitPlatformRoot(database *gorm.DB) error { + var account models.IdnAccount + err := database.Where("username = ?", PlatformRootUsername).First(&account).Error + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(platformRootPassword()), bcrypt.DefaultCost) + if err != nil { + return err + } + + account = models.IdnAccount{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled"}, + Username: PlatformRootUsername, + DisplayName: "平台根管理员", + PasswordHash: string(passwordHash), + RoleCode: PlatformRootRoleCode, + MustChangePassword: true, + Phone: "", + AccountType: "operator", + ServiceArea: "全国", + } + return database.Create(&account).Error +} + +// platformRootPassword 优先读取部署环境传入的 root 初始密码。 +func platformRootPassword() string { + if password := os.Getenv("HEQI_PLATFORM_ROOT_PASSWORD"); len(password) >= 12 { + return password + } + return PlatformRootPassword +} diff --git a/backend/api/internal/logic/platform/auth.go b/backend/api/internal/logic/platform/auth.go new file mode 100644 index 0000000..d1eb839 --- /dev/null +++ b/backend/api/internal/logic/platform/auth.go @@ -0,0 +1,146 @@ +package platform + +import ( + "strings" + + "git.apinb.com/bsm-sdk/core/crypto/token" + "git.apinb.com/bsm-sdk/core/env" + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// LoginRequest 是平台总后台的账号密码登录请求。 +type LoginRequest struct { + Username string `json:"username" binding:"required,max=64"` + Password string `json:"password" binding:"required,min=8,max=128"` +} + +// LoginReply 是后台登录成功后的访问凭证与账号状态。 +type LoginReply struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Identity string `json:"identity"` + DisplayName string `json:"display_name"` + RoleCode string `json:"role_code"` + MustChangePassword bool `json:"must_change_password"` +} + +// Login 校验平台账号密码并签发 BSM JWT。 +func Login(ctx *gin.Context) { + var request LoginRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + + var account models.IdnAccount + err := impl.DBService.Where("username = ?", strings.TrimSpace(request.Username)).First(&account).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + infra.Response.Error(ctx, err) + return + } + if account.Status != "enabled" { + infra.Response.Error(ctx, errcode.ErrAccountDisabled) + return + } + if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.Password)) != nil { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + + accessToken, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt( + 0, + account.Identity.String(), + "platform_admin", + account.RoleCode, + map[string]string{"username": account.Username, "display_name": account.DisplayName}, + map[string]string{"must_change_password": boolText(account.MustChangePassword)}, + ) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, LoginReply{ + AccessToken: accessToken, + TokenType: "JWT", + Identity: account.Identity.String(), + DisplayName: account.DisplayName, + RoleCode: account.RoleCode, + MustChangePassword: account.MustChangePassword, + }) +} + +// CurrentProfile 返回当前已认证的平台管理员资料。 +func CurrentProfile(ctx *gin.Context) { + claims, err := middleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + return + } + var account models.IdnAccount + if err := impl.DBService.Where("identity = ?", claims.Identity).First(&account).Error; err != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + infra.Response.Success(ctx, gin.H{ + "identity": account.Identity.String(), "username": account.Username, "display_name": account.DisplayName, + "role_code": account.RoleCode, "must_change_password": account.MustChangePassword, "mfa_enabled": account.MFAEnabled, + }) +} + +// ChangePasswordRequest 是已登录账号的改密请求。 +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" binding:"required,min=8,max=128"` + NewPassword string `json:"new_password" binding:"required,min=12,max=128"` +} + +// ChangePassword 修改当前账号密码并解除首次登录改密限制。 +func ChangePassword(ctx *gin.Context) { + claims, err := middleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + return + } + var request ChangePasswordRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var account models.IdnAccount + if err := impl.DBService.Where("identity = ?", claims.Identity).First(&account).Error; err != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.CurrentPassword)) != nil { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost) + if err != nil { + infra.Response.Error(ctx, err) + return + } + if err := impl.DBService.Model(&account).Updates(map[string]any{"password_hash": string(passwordHash), "must_change_password": false}).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"changed": true}) +} + +// boolText 将布尔值转换为 JWT 扩展字段约定的字符串。 +func boolText(value bool) string { + if value { + return "true" + } + return "false" +} diff --git a/backend/api/internal/models/idn_account.go b/backend/api/internal/models/idn_account.go index 0cca5b2..bbcf84b 100644 --- a/backend/api/internal/models/idn_account.go +++ b/backend/api/internal/models/idn_account.go @@ -5,9 +5,15 @@ import "git.apinb.com/bsm-sdk/core/database" // IdnAccount 对应 idn_account,表示用户或服务人员身份账户。 type IdnAccount struct { Entity - Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null" json:"phone"` // 手机号,响应时需脱敏 - AccountType string `gorm:"column:account_type;type:varchar(32);not null" json:"account_type"` // 账户类型 - ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域 + Username string `gorm:"column:username;type:varchar(64);uniqueIndex" json:"username"` // 登录用户名。 + DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称。 + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;default:''" json:"-"` // 密码哈希值,禁止在接口中返回。 + RoleCode string `gorm:"column:role_code;type:varchar(64);not null;default:'user'" json:"role_code"` // 平台角色编码。 + MustChangePassword bool `gorm:"column:must_change_password;not null;default:false" json:"must_change_password"` // 是否必须修改初始密码。 + MFAEnabled bool `gorm:"column:mfa_enabled;not null;default:false" json:"mfa_enabled"` // 是否启用多因素认证。 + Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null" json:"phone"` // 手机号,用于登录和通知。 + AccountType string `gorm:"column:account_type;type:varchar(32);not null" json:"account_type"` // 账号类型,例如 user、operator。 + ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域描述。 } func init() { database.AppendMigrate(&IdnAccount{}) } diff --git a/backend/api/internal/routers/register.go b/backend/api/internal/routers/register.go index b09df93..7152919 100644 --- a/backend/api/internal/routers/register.go +++ b/backend/api/internal/routers/register.go @@ -14,10 +14,13 @@ func Register(srvKey string, engine *gin.Engine) { v1Key := fmt.Sprintf("/%s/%s", srvKey, "v1") anonymous := engine.Group(v1Key) anonymous.GET("/ping/hello", platform.PingHello) + anonymous.POST("/auth/login", platform.Login) protected := engine.Group(v1Key) protected.Use(middleware.JwtAuth(true)) { + protected.GET("/auth/profile", platform.CurrentProfile) + protected.PUT("/auth/password", platform.ChangePassword) protected.GET("/dashboard/overview", platform.DashboardOverview) gasStationGroup := protected.Group("/organization/org_gas_station") gasStationGroup.POST("", platform.CreateOrgGasStation) diff --git a/backend/migrations/0004_alter_idn_account_platform_login.sql b/backend/migrations/0004_alter_idn_account_platform_login.sql new file mode 100644 index 0000000..cc76b2f --- /dev/null +++ b/backend/migrations/0004_alter_idn_account_platform_login.sql @@ -0,0 +1,15 @@ +-- 平台总后台登录字段;所有业务主表仍使用应用生成的 UUID V7 identity。 +ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS username varchar(64); +ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS display_name varchar(64) NOT NULL DEFAULT ''; +ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS password_hash varchar(255) NOT NULL DEFAULT ''; +ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS role_code varchar(64) NOT NULL DEFAULT 'user'; +ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS must_change_password boolean NOT NULL DEFAULT false; +ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS mfa_enabled boolean NOT NULL DEFAULT false; +CREATE UNIQUE INDEX IF NOT EXISTS uk_idn_account_username ON idn_account (username) WHERE username IS NOT NULL; + +COMMENT ON COLUMN idn_account.username IS '登录用户名;平台 root 由 internal/initdb/platform.go 幂等初始化'; +COMMENT ON COLUMN idn_account.display_name IS '后台界面展示名称'; +COMMENT ON COLUMN idn_account.password_hash IS 'bcrypt 密码哈希,禁止在 API 中返回'; +COMMENT ON COLUMN idn_account.role_code IS '平台角色编码'; +COMMENT ON COLUMN idn_account.must_change_password IS '首次登录或重置密码后必须修改密码'; +COMMENT ON COLUMN idn_account.mfa_enabled IS '是否启用多因素认证'; diff --git a/frontend/platform_admin/src/App.vue b/frontend/platform_admin/src/App.vue index 0257b0f..9ec9c90 100644 --- a/frontend/platform_admin/src/App.vue +++ b/frontend/platform_admin/src/App.vue @@ -1,19 +1,13 @@ diff --git a/frontend/platform_admin/src/api/platform.ts b/frontend/platform_admin/src/api/platform.ts index 49b9955..b94a35a 100644 --- a/frontend/platform_admin/src/api/platform.ts +++ b/frontend/platform_admin/src/api/platform.ts @@ -1,53 +1,16 @@ -// 平台总后台 API 客户端,仅封装首期 M0 + M1 接口。 -const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/Platform/v1'; +// 平台总后台 API 客户端;JWT 使用 BSM 中间件要求的原始令牌值。 +const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/v1'; const developmentJWT = import.meta.env.VITE_DEV_JWT?.trim(); +const tokenStorageKey = 'heqi.platform_admin.access_token'; -export type OrgGasStation = { - identity: string; - stationCode: string; - name: string; - principal: string; - serviceArea: string; - status: string; - createdAt: string; -}; - -export type OrgDeliveryPoint = { - identity: string; - deliveryCode: string; - name: string; - gasStationName: string; - serviceArea: string; - status: string; -}; - -export type OrgServicePerson = { - identity: string; - name: string; - phoneMasked: string; - roles: string; - workStatus: string; - credentialStatus: string; -}; - -export type IdnAccount = { - identity: string; - phoneMasked: string; - accountType: string; - status: string; - serviceArea: string; -}; - -export type SafEvent = { - identity: string; - eventCode: string; - level: number; - title: string; - status: string; - createdAt: string; -}; - +export type OrgGasStation = { identity: string; stationCode: string; name: string; principal: string; serviceArea: string; status: string; createdAt: string }; +export type OrgDeliveryPoint = { identity: string; deliveryCode: string; name: string; gasStationName: string; serviceArea: string; status: string }; +export type OrgServicePerson = { identity: string; name: string; phoneMasked: string; roles: string; workStatus: string; credentialStatus: string }; +export type IdnAccount = { identity: string; phoneMasked: string; accountType: string; status: string; serviceArea: string }; +export type SafEvent = { identity: string; eventCode: string; level: number; title: string; status: string; createdAt: string }; export type DashboardOverview = Record; +export type LoginReply = { accessToken: string; tokenType: string; identity: string; displayName: string; roleCode: string; mustChangePassword: boolean }; +export type Profile = { identity: string; username: string; displayName: string; roleCode: string; mustChangePassword: boolean; mfaEnabled: boolean }; type ListReply = { total: number; list: T[] }; type RawOrgDeliveryPoint = { identity: string; delivery_code: string; name: string; gas_station_name?: string; service_area: string; status: string }; @@ -55,14 +18,15 @@ type RawOrgServicePerson = { identity: string; name: string; phone_masked?: stri type RawIdnAccount = { identity: string; phone_masked: string; account_type: string; status: string; service_area: string }; type RawSafEvent = { identity: string; event_code: string; level: number; title: string; status: string; created_at: string }; +function currentToken(): string | undefined { + return localStorage.getItem(tokenStorageKey) || developmentJWT; +} + async function request(path: string, init?: RequestInit): Promise { + const accessToken = currentToken(); const response = await fetch(`${apiBaseURL}${path}`, { - headers: { - 'Content-Type': 'application/json', - ...(developmentJWT ? { Authorization: `Bearer ${developmentJWT}` } : {}), - ...(init?.headers ?? {}), - }, ...init, + headers: { 'Content-Type': 'application/json', ...(accessToken ? { Authorization: accessToken } : {}), ...(init?.headers ?? {}) }, }); const payload = (await response.json()) as { code?: number; message?: string; details?: T }; if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败'); @@ -70,6 +34,18 @@ async function request(path: string, init?: RequestInit): Promise { } export const platformAPI = { + hasSession: () => Boolean(currentToken()), + clearSession: () => localStorage.removeItem(tokenStorageKey), + login: async (username: string, password: string) => { + const item = await request<{ access_token: string; token_type: string; identity: string; display_name: string; role_code: string; must_change_password: boolean }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }); + localStorage.setItem(tokenStorageKey, item.access_token); + return { accessToken: item.access_token, tokenType: item.token_type, identity: item.identity, displayName: item.display_name, roleCode: item.role_code, mustChangePassword: item.must_change_password } satisfies LoginReply; + }, + getProfile: async () => { + const item = await request<{ identity: string; username: string; display_name: string; role_code: string; must_change_password: boolean; mfa_enabled: boolean }>('/auth/profile'); + return { identity: item.identity, username: item.username, displayName: item.display_name, roleCode: item.role_code, mustChangePassword: item.must_change_password, mfaEnabled: item.mfa_enabled } satisfies Profile; + }, + changePassword: (currentPassword: string, newPassword: string) => request<{ changed: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }), getDashboard: async () => { const item = await request<{ gas_station_count: number; delivery_point_count: number; service_person_count: number; user_count: number; pending_safety_count: number }>('/dashboard/overview'); return { gasStationCount: item.gas_station_count, deliveryPointCount: item.delivery_point_count, servicePersonCount: item.service_person_count, userCount: item.user_count, pendingSafetyCount: item.pending_safety_count }; @@ -78,8 +54,7 @@ export const platformAPI = { const reply = await request>('/organization/org_gas_station'); return reply.list.map((item) => ({ identity: item.identity, stationCode: item.station_code, name: item.name, principal: item.principal, serviceArea: item.service_area, status: item.status, createdAt: item.created_at })); }, - createOrgGasStation: (body: Pick) => - request('/organization/org_gas_station', { method: 'POST', body: JSON.stringify({ station_code: body.stationCode, name: body.name, principal: body.principal, service_area: body.serviceArea }) }), + createOrgGasStation: (body: Pick) => request('/organization/org_gas_station', { method: 'POST', body: JSON.stringify({ station_code: body.stationCode, name: body.name, principal: body.principal, service_area: body.serviceArea }) }), listOrgDeliveryPoint: async () => (await request>('/organization/org_delivery_point')).list.map((item) => ({ identity: item.identity, deliveryCode: item.delivery_code, name: item.name, gasStationName: item.gas_station_name ?? '未关联', serviceArea: item.service_area, status: item.status })), listOrgServicePerson: async () => (await request>('/organization/org_service_person')).list.map((item) => ({ identity: item.identity, name: item.name, phoneMasked: item.phone_masked ?? '***', roles: item.roles, workStatus: item.work_status, credentialStatus: item.credential_status })), listIdnAccount: async () => (await request>('/identity/idn_account')).list.map((item) => ({ identity: item.identity, phoneMasked: item.phone_masked, accountType: item.account_type, status: item.status, serviceArea: item.service_area })), diff --git a/frontend/platform_admin/src/style.css b/frontend/platform_admin/src/style.css index c8f581f..cd9d43f 100644 --- a/frontend/platform_admin/src/style.css +++ b/frontend/platform_admin/src/style.css @@ -2,6 +2,9 @@ * { box-sizing: border-box; } body { margin: 0; } button, input { font: inherit; } +.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top right, #d8f3e7, transparent 42%), #f4f7fb; } +.login-card { width: min(420px, 100%); display: grid; gap: 14px; padding: 38px; background: #fff; border: 1px solid #e1e9f0; border-radius: 16px; box-shadow: 0 18px 50px rgb(15 42 67 / 12%); } +.login-card .brand-mark { margin-bottom: 6px; }.login-card h1 { margin: 0; color: #102a43; }.login-card p { color: #536575; line-height: 1.6; margin: 0; } .shell { min-height: 100vh; display: grid; grid-template-columns: 240px 1fr; } .sidebar { background: #102a43; color: #e8f1fb; padding: 28px 16px; display: flex; flex-direction: column; } .brand { display: flex; align-items: center; gap: 12px; padding: 0 10px 30px; } @@ -9,7 +12,10 @@ button, input { font: inherit; } .brand strong, .brand small { display: block; }.brand small { color: #a8c2dc; margin-top: 3px; } nav { display: grid; gap: 6px; } nav button { border: 0; border-radius: 8px; background: transparent; color: inherit; text-align: left; padding: 11px 14px; cursor: pointer; } nav button:hover, nav button.active { background: #1e4b70; } .mock-note { font-size: 12px; color: #a8c2dc; line-height: 1.6; margin-top: auto; padding: 12px; background: #173b59; border-radius: 8px; } +.account { margin-top: auto; display: grid; gap: 5px; padding: 12px; background: #173b59; border-radius: 8px; }.account small { color: #a8c2dc; }.link-button { padding: 4px 0; text-align: left; background: transparent; color: #cde9dd; font-size: 12px; } .content { padding: 34px; max-width: 1500px; width: 100%; }.content header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 25px; }.eyebrow { color: #33a474; font-size: 12px; font-weight: 700; margin: 0 0 6px; }.content h1, .content h2 { margin: 0; }.content h1 { font-size: 28px; }.content h2 { font-size: 17px; } .cards { display: grid; grid-template-columns: repeat(5, minmax(140px, 1fr)); gap: 16px; margin-bottom: 18px; }.cards article, .panel { border: 1px solid #e1e9f0; background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgb(15 42 67 / 4%); }.cards article { padding: 20px; }.cards span { color: #66788a; font-size: 13px; }.cards strong { display: block; font-size: 28px; margin-top: 12px; color: #0f3859; }.panel { padding: 22px; margin-bottom: 18px; }.panel p { color: #536575; line-height: 1.7; } form { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)) auto; gap: 10px; margin-top: 16px; } input { min-width: 0; border: 1px solid #cbd7e3; border-radius: 7px; padding: 10px 11px; } button { border: 0; border-radius: 7px; padding: 10px 15px; color: #fff; background: #1677c8; cursor: pointer; }button:disabled { opacity: .65; cursor: wait; }.secondary { background: #fff; color: #1677c8; border: 1px solid #a9c9e7; }.table-panel { padding: 0; overflow-x: auto; } table { border-collapse: collapse; width: 100%; min-width: 650px; } th, td { padding: 14px 18px; border-bottom: 1px solid #edf1f5; text-align: left; font-size: 14px; } th { background: #f8fafc; color: #4c6376; font-weight: 600; } td { color: #233446; }.error { background: #fff0f0; color: #b42318; padding: 11px 14px; border-radius: 8px; } -@media (max-width: 900px) { .shell { grid-template-columns: 1fr; }.sidebar { min-height: auto; }.mock-note { display: none; } nav { grid-template-columns: repeat(3, 1fr); }.content { padding: 20px; }.cards { grid-template-columns: repeat(2, 1fr); } form { grid-template-columns: 1fr; } } +.warning { border-color: #ead4a7; background: #fffdf7; }.password-form { grid-template-columns: repeat(3, minmax(140px, 1fr)) auto; }.catalog { display: grid; grid-template-columns: repeat(2, minmax(280px, 1fr)); gap: 18px; }.catalog .panel { margin: 0; }.tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }.tags span { color: #176e4d; background: #e4f6ee; padding: 5px 9px; border-radius: 20px; font-size: 12px; } +@media (max-width: 900px) { .shell { grid-template-columns: 1fr; }.sidebar { min-height: auto; }.mock-note { display: none; } nav { grid-template-columns: repeat(3, 1fr); }.content { padding: 20px; }.cards, .catalog { grid-template-columns: repeat(2, 1fr); } form, .password-form { grid-template-columns: 1fr; } } +@media (max-width: 560px) { nav, .cards, .catalog { grid-template-columns: 1fr; }.login-card { padding: 28px 22px; } }