feat: 添加角色管理模块(API + UI)

v3
veypi 1 week ago
parent 4101daeed3
commit 691f1df75b

@ -11,6 +11,7 @@ import (
apiAuth "github.com/veypi/vbase/api/auth" apiAuth "github.com/veypi/vbase/api/auth"
"github.com/veypi/vbase/api/oauth" "github.com/veypi/vbase/api/oauth"
"github.com/veypi/vbase/api/org" "github.com/veypi/vbase/api/org"
"github.com/veypi/vbase/api/role"
"github.com/veypi/vbase/api/user" "github.com/veypi/vbase/api/user"
"github.com/veypi/vbase/auth" "github.com/veypi/vbase/auth"
"github.com/veypi/vigo" "github.com/veypi/vigo"
@ -28,6 +29,7 @@ func init() {
Router.Extend("/auth", apiAuth.Router) Router.Extend("/auth", apiAuth.Router)
Router.Extend("/users", user.Router) Router.Extend("/users", user.Router)
Router.Extend("/orgs", org.Router) Router.Extend("/orgs", org.Router)
Router.Extend("/roles", role.Router)
Router.Extend("/oauth", oauth.Router) Router.Extend("/oauth", oauth.Router)
// 404 处理 // 404 处理

@ -0,0 +1,43 @@
package role
import (
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
)
type CreateReq struct {
Code string `json:"code" src:"json" desc:"Role Code"`
Name string `json:"name" src:"json" desc:"Role Name"`
Description string `json:"description" src:"json" desc:"Role Description"`
OrgID string `json:"org_id" src:"json" desc:"Organization ID (Optional)"`
}
func create(x *vigo.X, req *CreateReq) (*models.Role, error) {
// Check if role code already exists
var count int64
if err := cfg.DB().Model(&models.Role{}).Where("code = ?", req.Code).Count(&count).Error; err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
if count > 0 {
return nil, vigo.ErrAlreadyExists.WithArgs("Role Code")
}
role := &models.Role{
Code: req.Code,
Name: req.Name,
Description: req.Description,
IsSystem: false, // Default to false for user created roles
Status: 1,
}
if req.OrgID != "" {
role.OrgID = &req.OrgID
}
if err := cfg.DB().Create(role).Error; err != nil {
return nil, vigo.ErrDatabase.WithError(err)
}
return role, nil
}

@ -0,0 +1,28 @@
package role
import (
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
)
type DelReq struct {
ID string `src:"path@id" desc:"Role ID"`
}
func del(x *vigo.X, req *DelReq) error {
var role models.Role
if err := cfg.DB().First(&role, "id = ?", req.ID).Error; err != nil {
return vigo.ErrNotFound
}
if role.IsSystem {
return vigo.NewError("cannot delete system role").WithCode(40300)
}
if err := cfg.DB().Delete(&role).Error; err != nil {
return vigo.ErrDatabase.WithError(err)
}
return nil
}

@ -0,0 +1,19 @@
package role
import (
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
)
type GetReq struct {
ID string `src:"path@id" desc:"Role ID"`
}
func get(x *vigo.X, req *GetReq) (*models.Role, error) {
var role models.Role
if err := cfg.DB().First(&role, "id = ?", req.ID).Error; err != nil {
return nil, vigo.ErrNotFound
}
return &role, nil
}

@ -0,0 +1,18 @@
package role
import (
"github.com/veypi/vbase/auth"
"github.com/veypi/vigo"
)
var Router = vigo.NewRouter()
func init() {
Router.Get("/", "List Roles", auth.VBaseAuth.Perm("role:read"), list)
Router.Get("/{id}", "Get Role Detail", auth.VBaseAuth.Perm("role:read"), get)
Router.Post("/", "Create Role", auth.VBaseAuth.Perm("role:create"), create)
Router.Patch("/{id}", "Update Role", auth.VBaseAuth.Perm("role:update"), patch)
Router.Delete("/{id}", "Delete Role", auth.VBaseAuth.Perm("role:delete"), del)
Router.Get("/{id}/permissions", "Get Role Permissions", auth.VBaseAuth.Perm("role:read"), getPermissions)
Router.Put("/{id}/permissions", "Update Role Permissions", auth.VBaseAuth.Perm("role:update"), updatePermissions)
}

@ -0,0 +1,58 @@
package role
import (
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
)
type ListReq struct {
Page int `json:"page" src:"query" default:"1"`
PageSize int `json:"page_size" src:"query" default:"20"`
OrgID *string `json:"org_id" src:"query" desc:"Organization ID"`
Keyword *string `json:"keyword" src:"query" desc:"Search Keyword"`
}
type ListResp struct {
Items []models.Role `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
func list(x *vigo.X, req *ListReq) (*ListResp, error) {
db := cfg.DB().Model(&models.Role{})
if req.OrgID != nil {
db = db.Where("org_id = ?", *req.OrgID)
}
if req.Keyword != nil && *req.Keyword != "" {
db = db.Where("name LIKE ? OR code LIKE ?", "%"+*req.Keyword+"%", "%"+*req.Keyword+"%")
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
var roles []models.Role
offset := (req.Page - 1) * req.PageSize
if err := db.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&roles).Error; err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
totalPages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
totalPages++
}
return &ListResp{
Items: roles,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
TotalPages: totalPages,
}, nil
}

@ -0,0 +1,44 @@
package role
import (
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
)
type PatchReq struct {
ID string `src:"path@id" desc:"Role ID"`
Name *string `json:"name" src:"json" desc:"Role Name"`
Description *string `json:"description" src:"json" desc:"Role Description"`
Status *int `json:"status" src:"json" desc:"Status"`
}
func patch(x *vigo.X, req *PatchReq) (*models.Role, error) {
var role models.Role
if err := cfg.DB().First(&role, "id = ?", req.ID).Error; err != nil {
return nil, vigo.ErrNotFound
}
if role.IsSystem {
return nil, vigo.NewError("cannot modify system role").WithCode(40300)
}
updates := map[string]interface{}{}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Status != nil {
updates["status"] = *req.Status
}
if len(updates) > 0 {
if err := cfg.DB().Model(&role).Updates(updates).Error; err != nil {
return nil, vigo.ErrDatabase.WithError(err)
}
}
return &role, nil
}

@ -0,0 +1,64 @@
package role
import (
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
"gorm.io/gorm"
)
type GetPermissionsReq struct {
RoleID string `src:"path@id" desc:"Role ID"`
}
func getPermissions(x *vigo.X, req *GetPermissionsReq) ([]models.Permission, error) {
var rolePermissions []models.RolePermission
if err := cfg.DB().Preload("Permission").Where("role_id = ?", req.RoleID).Find(&rolePermissions).Error; err != nil {
return nil, vigo.ErrDatabase.WithError(err)
}
permissions := make([]models.Permission, 0, len(rolePermissions))
for _, rp := range rolePermissions {
permissions = append(permissions, rp.Permission)
}
return permissions, nil
}
type UpdatePermissionsReq struct {
RoleID string `src:"path@id" desc:"Role ID"`
PermissionIDs []string `json:"permission_ids" src:"json" desc:"List of Permission IDs"`
}
func updatePermissions(x *vigo.X, req *UpdatePermissionsReq) error {
var role models.Role
if err := cfg.DB().First(&role, "id = ?", req.RoleID).Error; err != nil {
return vigo.ErrNotFound
}
if role.IsSystem {
return vigo.NewError("cannot modify permissions of system role").WithCode(40300)
}
return cfg.DB().Transaction(func(tx *gorm.DB) error {
// Delete existing permissions
if err := tx.Where("role_id = ?", req.RoleID).Delete(&models.RolePermission{}).Error; err != nil {
return err
}
// Add new permissions
if len(req.PermissionIDs) > 0 {
rolePermissions := make([]models.RolePermission, 0, len(req.PermissionIDs))
for _, pid := range req.PermissionIDs {
rolePermissions = append(rolePermissions, models.RolePermission{
RoleID: req.RoleID,
PermissionID: pid,
Condition: "none", // Default condition
})
}
if err := tx.Create(&rolePermissions).Error; err != nil {
return err
}
}
return nil
})
}

@ -20,4 +20,9 @@ func init() {
Router.Patch("/{user_id}", "更新用户", auth.VBaseAuth.PermWithOwner("user:update", "user_id"), patch) Router.Patch("/{user_id}", "更新用户", auth.VBaseAuth.PermWithOwner("user:update", "user_id"), patch)
Router.Delete("/{user_id}", "删除用户", auth.VBaseAuth.Perm("user:admin"), del) Router.Delete("/{user_id}", "删除用户", auth.VBaseAuth.Perm("user:admin"), del)
Router.Patch("/{user_id}/status", "更新用户状态", auth.VBaseAuth.Perm("user:admin"), updateStatus) Router.Patch("/{user_id}/status", "更新用户状态", auth.VBaseAuth.Perm("user:admin"), updateStatus)
Router.Get("/{user_id}/roles", "Get User Roles", auth.VBaseAuth.PermWithOwner("user:read", "user_id"), getRoles)
Router.Put("/{user_id}/roles", "Update User Roles", auth.VBaseAuth.Perm("user:admin"), updateRoles)
Router.Get("/{user_id}/permissions", "Get User Permissions", auth.VBaseAuth.PermWithOwner("user:read", "user_id"), getPermissions)
Router.Put("/{user_id}/permissions", "Update User Permissions", auth.VBaseAuth.Perm("user:admin"), updatePermissions)
} }

@ -0,0 +1,66 @@
package user
import (
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
"gorm.io/gorm"
)
// User specific permissions (data-level or direct assignment)
type GetPermissionsReq struct {
UserID string `src:"path@user_id" desc:"User ID"`
}
func getPermissions(x *vigo.X, req *GetPermissionsReq) ([]models.UserPermission, error) {
var userPermissions []models.UserPermission
if err := cfg.DB().Where("user_id = ?", req.UserID).Find(&userPermissions).Error; err != nil {
return nil, vigo.ErrDatabase.WithError(err)
}
return userPermissions, nil
}
type UpdatePermissionsReq struct {
UserID string `src:"path@user_id" desc:"User ID"`
Permissions []struct {
PermissionID string `json:"permission_id"`
ResourceID string `json:"resource_id"`
} `json:"permissions" src:"json" desc:"List of User Permissions"`
}
func updatePermissions(x *vigo.X, req *UpdatePermissionsReq) error {
var user models.User
if err := cfg.DB().First(&user, "id = ?", req.UserID).Error; err != nil {
return vigo.ErrNotFound
}
grantor := ""
if uid := x.Get("user_id"); uid != nil {
if s, ok := uid.(string); ok {
grantor = s
}
}
return cfg.DB().Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", req.UserID).Delete(&models.UserPermission{}).Error; err != nil {
return err
}
if len(req.Permissions) > 0 {
userPermissions := make([]models.UserPermission, 0, len(req.Permissions))
for _, p := range req.Permissions {
userPermissions = append(userPermissions, models.UserPermission{
UserID: req.UserID,
PermissionID: p.PermissionID,
ResourceID: p.ResourceID,
GrantedBy: grantor,
})
}
if err := tx.Create(&userPermissions).Error; err != nil {
return err
}
}
return nil
})
}

@ -0,0 +1,57 @@
package user
import (
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
"gorm.io/gorm"
)
type GetRolesReq struct {
UserID string `src:"path@user_id" desc:"User ID"`
}
func getRoles(x *vigo.X, req *GetRolesReq) ([]models.Role, error) {
var userRoles []models.UserRole
if err := cfg.DB().Preload("Role").Where("user_id = ?", req.UserID).Find(&userRoles).Error; err != nil {
return nil, vigo.ErrDatabase.WithError(err)
}
roles := make([]models.Role, 0, len(userRoles))
for _, ur := range userRoles {
roles = append(roles, ur.Role)
}
return roles, nil
}
type UpdateRolesReq struct {
UserID string `src:"path@user_id" desc:"User ID"`
RoleIDs []string `json:"role_ids" src:"json" desc:"Role IDs"`
}
func updateRoles(x *vigo.X, req *UpdateRolesReq) error {
var user models.User
if err := cfg.DB().First(&user, "id = ?", req.UserID).Error; err != nil {
return vigo.ErrNotFound
}
return cfg.DB().Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", req.UserID).Delete(&models.UserRole{}).Error; err != nil {
return err
}
if len(req.RoleIDs) > 0 {
userRoles := make([]models.UserRole, 0, len(req.RoleIDs))
for _, rid := range req.RoleIDs {
userRoles = append(userRoles, models.UserRole{
UserID: req.UserID,
RoleID: rid,
})
}
if err := tx.Create(&userRoles).Error; err != nil {
return err
}
}
return nil
})
}

@ -668,7 +668,6 @@ func (a *appAuth) checkPermissionDB(ctx context.Context, userID, orgID, permissi
Count(&count).Error; err != nil { Count(&count).Error; err != nil {
return false, err return false, err
} }
fmt.Printf("[DEBUG] CheckPermission: role perm count=%d checked=%v\n", count, permsToCheck)
if count > 0 { if count > 0 {
return true, nil return true, nil
} }

@ -21,6 +21,7 @@
"nav.home": "Home", "nav.home": "Home",
"nav.oauth": "OAuth Apps", "nav.oauth": "OAuth Apps",
"nav.org": "Organizations", "nav.org": "Organizations",
"nav.roles": "Roles",
"nav.profile": "Profile", "nav.profile": "Profile",
"nav.users": "Users", "nav.users": "Users",
"org.create": "Create Organization", "org.create": "Create Organization",
@ -53,7 +54,14 @@
"user.email": "Email", "user.email": "Email",
"user.profile": "User Profile", "user.profile": "User Profile",
"user.role": "Role", "user.role": "Role",
"user.username": "Username" "user.username": "Username",
"role.name": "Role Name",
"role.code": "Role Code",
"role.description": "Description",
"role.create": "Create Role",
"role.edit": "Edit Role",
"role.delete_confirm": "Are you sure you want to delete this role?",
"role.search_placeholder": "Search roles..."
}, },
"zh-CN": { "zh-CN": {
"auth.email": "邮箱", "auth.email": "邮箱",
@ -77,6 +85,7 @@
"nav.home": "首页", "nav.home": "首页",
"nav.oauth": "OAuth应用", "nav.oauth": "OAuth应用",
"nav.org": "组织管理", "nav.org": "组织管理",
"nav.roles": "角色管理",
"nav.profile": "个人中心", "nav.profile": "个人中心",
"nav.users": "用户管理", "nav.users": "用户管理",
"org.create": "创建组织", "org.create": "创建组织",
@ -109,6 +118,13 @@
"user.email": "邮箱", "user.email": "邮箱",
"user.profile": "个人资料", "user.profile": "个人资料",
"user.role": "角色", "user.role": "角色",
"user.username": "用户名" "user.username": "用户名",
"role.name": "角色名称",
"role.code": "角色代码",
"role.description": "描述",
"role.create": "创建角色",
"role.edit": "编辑角色",
"role.delete_confirm": "确定要删除该角色吗?",
"role.search_placeholder": "搜索角色..."
} }
} }

@ -162,6 +162,7 @@
{label: () => $t('nav.profile'), icon: "<i class='fas fa-user'></i>", path: "/profile"}, {label: () => $t('nav.profile'), icon: "<i class='fas fa-user'></i>", path: "/profile"},
// Admin only items would be filtered here ideally // Admin only items would be filtered here ideally
{label: () => $t('nav.users'), icon: "<i class='fas fa-users-cog'></i>", path: "/users"}, {label: () => $t('nav.users'), icon: "<i class='fas fa-users-cog'></i>", path: "/users"},
{label: () => $t('nav.roles'), icon: "<i class='fas fa-user-tag'></i>", path: "/roles"},
{label: () => $t('nav.oauth'), icon: "<i class='fas fa-key'></i>", path: "/oauth/apps"}, {label: () => $t('nav.oauth'), icon: "<i class='fas fa-key'></i>", path: "/oauth/apps"},
]; ];

@ -174,7 +174,7 @@
loadApps = async () => { loadApps = async () => {
try { try {
const res = await $axios.get('/api/oauth/clients'); const res = await $axios.get('/api/oauth/clients');
apps = res || []; apps = res.items || [];
} catch (e) { } catch (e) {
$message.error(e.message); $message.error(e.message);
} }

@ -4,225 +4,225 @@
<head> <head>
<meta name="description" content="Org Detail"> <meta name="description" content="Org Detail">
<title>{{ org ? org.name : $t('org.detail') }}</title> <title>{{ org ? org.name : $t('org.detail') }}</title>
<style>
body {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-md);
}
.header-left {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.btn-back {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--bg-color-tertiary);
color: var(--text-color);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-back:hover {
background: var(--border-color);
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
}
.section {
background: var(--bg-color-secondary);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
}
.section-title {
font-size: var(--font-size-lg);
font-weight: 600;
margin-bottom: var(--spacing-md);
color: var(--text-color);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
}
.info-item {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.info-label {
font-size: var(--font-size-sm);
color: var(--text-color-secondary);
}
.info-value {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--text-color);
}
.org-icon-large {
width: 64px;
height: 64px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 28px;
font-weight: bold;
}
.org-header-info {
display: flex;
align-items: center;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.org-header-text {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.org-header-name {
font-size: var(--font-size-2xl);
font-weight: 600;
color: var(--text-color);
}
.org-header-desc {
font-size: var(--font-size-md);
color: var(--text-color-secondary);
}
/* Members Table */
.members-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--bg-color-tertiary);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-color-secondary);
}
td {
font-size: var(--font-size-md);
color: var(--text-color);
}
tr:hover td {
background-color: var(--bg-color-tertiary);
}
.role-badge {
display: inline-flex;
align-items: center;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 500;
}
.role-admin {
background-color: var(--color-primary);
color: var(--color-primary-text);
}
.role-member {
background-color: var(--bg-color-tertiary);
color: var(--text-color-secondary);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
gap: var(--spacing-md);
color: var(--text-color-secondary);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-lg);
gap: var(--spacing-sm);
color: var(--text-color-secondary);
text-align: center;
}
.empty-state i {
font-size: 48px;
color: var(--border-color);
}
</style>
</head> </head>
<style>
body {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-md);
}
.header-left {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.btn-back {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--bg-color-tertiary);
color: var(--text-color);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-back:hover {
background: var(--border-color);
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
}
.section {
background: var(--bg-color-secondary);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
}
.section-title {
font-size: var(--font-size-lg);
font-weight: 600;
margin-bottom: var(--spacing-md);
color: var(--text-color);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
}
.info-item {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.info-label {
font-size: var(--font-size-sm);
color: var(--text-color-secondary);
}
.info-value {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--text-color);
}
.org-icon-large {
width: 64px;
height: 64px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 28px;
font-weight: bold;
}
.org-header-info {
display: flex;
align-items: center;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.org-header-text {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.org-header-name {
font-size: var(--font-size-2xl);
font-weight: 600;
color: var(--text-color);
}
.org-header-desc {
font-size: var(--font-size-md);
color: var(--text-color-secondary);
}
/* Members Table */
.members-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--bg-color-tertiary);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-color-secondary);
}
td {
font-size: var(--font-size-md);
color: var(--text-color);
}
tr:hover td {
background-color: var(--bg-color-tertiary);
}
.role-badge {
display: inline-flex;
align-items: center;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 500;
}
.role-admin {
background-color: var(--color-primary);
color: var(--color-primary-text);
}
.role-member {
background-color: var(--bg-color-tertiary);
color: var(--text-color-secondary);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
gap: var(--spacing-md);
color: var(--text-color-secondary);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-lg);
gap: var(--spacing-sm);
color: var(--text-color-secondary);
text-align: center;
}
.empty-state i {
font-size: 48px;
color: var(--border-color);
}
</style>
<body> <body>
<!-- Loading State --> <!-- Loading State -->
@ -231,11 +231,11 @@
<span>{{ $t('common.loading') }}</span> <span>{{ $t('common.loading') }}</span>
</div> </div>
<template v:if="!loading && org"> <div v-if="!loading && org">
<!-- Page Header --> <!-- Page Header -->
<div class="page-header"> <div class="page-header">
<div class="header-left"> <div class="header-left">
<button class="btn-back" @click="goBack" title="{{ $t('common.back') }}"> <button class="btn-back" @click="goBack" :title="$t('common.back')">
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
</button> </button>
<h1>{{ $t('org.detail') }}</h1> <h1>{{ $t('org.detail') }}</h1>
@ -271,7 +271,7 @@
<span class="info-label">{{ $t('org.created_at') }}</span> <span class="info-label">{{ $t('org.created_at') }}</span>
<span class="info-value">{{ formatDate(org.created_at) }}</span> <span class="info-value">{{ formatDate(org.created_at) }}</span>
</div> </div>
<div class="info-item" v:if="org.updated_at"> <div class="info-item" v-if="org.updated_at">
<span class="info-label">{{ $t('org.updated_at') }}</span> <span class="info-label">{{ $t('org.updated_at') }}</span>
<span class="info-value">{{ formatDate(org.updated_at) }}</span> <span class="info-value">{{ formatDate(org.updated_at) }}</span>
</div> </div>
@ -288,12 +288,12 @@
</div> </div>
</div> </div>
<div class="empty-state" v:if="members.length === 0"> <div class="empty-state" v-if="members.length === 0">
<i class="fas fa-user-slash"></i> <i class="fas fa-user-slash"></i>
<p>{{ $t('org.no_members') }}</p> <p>{{ $t('org.no_members') }}</p>
</div> </div>
<table v:if="members.length > 0"> <table v-if="members.length > 0">
<thead> <thead>
<tr> <tr>
<th>{{ $t('user.username') }}</th> <th>{{ $t('user.username') }}</th>
@ -303,7 +303,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v:for="member in members"> <tr v-for="member in members">
<td>{{ member.username }}</td> <td>{{ member.username }}</td>
<td>{{ member.email || '-' }}</td> <td>{{ member.email || '-' }}</td>
<td> <td>
@ -313,11 +313,11 @@
</td> </td>
<td> <td>
<v-btn size="sm" color="danger" variant="outline" :click="() => removeMember(member)" <v-btn size="sm" color="danger" variant="outline" :click="() => removeMember(member)"
v:if="member.id !== currentUserId"> v-if="member.id !== currentUserId">
<i class="fas fa-user-minus"></i> <i class="fas fa-user-minus"></i>
{{ $t('org.remove_member') }} {{ $t('org.remove_member') }}
</v-btn> </v-btn>
<span v:else style="color: var(--text-color-tertiary); font-size: var(--font-size-sm);"> <span v-else style="color: var(--text-color-tertiary); font-size: var(--font-size-sm);">
{{ $t('org.you') }} {{ $t('org.you') }}
</span> </span>
</td> </td>
@ -325,15 +325,15 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</template> </div>
<!-- Edit Dialog --> <!-- Edit Dialog -->
<v-dialog v:visible="showEditModal" title="{{ $t('org.edit') }}"> <v-dialog v:visible="showEditModal" :title="$t('org.edit')">
<v-input type="text" v:value="editForm.name" label="{{ $t('org.name') }}" required <v-input type="text" v:value="editForm.name" :label="$t('org.name')" required
placeholder="{{ $t('org.name_placeholder') }}"> :placeholder="$t('org.name_placeholder')">
</v-input> </v-input>
<v-input type="textarea" v:value="editForm.description" label="{{ $t('org.description') }}" <v-input type="textarea" v:value="editForm.description" :label="$t('org.description')"
placeholder="{{ $t('org.desc_placeholder') }}"> :placeholder="$t('org.desc_placeholder')">
</v-input> </v-input>
<div vslot="footer"> <div vslot="footer">
<v-btn variant="outline" :click="closeEditModal">{{ $t('common.cancel') }}</v-btn> <v-btn variant="outline" :click="closeEditModal">{{ $t('common.cancel') }}</v-btn>
@ -365,7 +365,7 @@
$axios.get(`/api/orgs/${orgId}/members`) $axios.get(`/api/orgs/${orgId}/members`)
]); ]);
org = orgRes; org = orgRes;
members = membersRes || []; members = membersRes.items || [];
} catch (e) { } catch (e) {
$message.error(e.message); $message.error(e.message);
if (e.status === 404) { if (e.status === 404) {

@ -0,0 +1,286 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Role Management">
<title>{{ $t('nav.roles') }}</title>
<style>
body {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
box-sizing: border-box;
background-color: var(--bg-color);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
.page-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-color);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.search-box {
display: flex;
align-items: center;
gap: var(--spacing-sm);
background: var(--bg-color-secondary);
padding: 0 var(--spacing-md);
border-radius: var(--radius-full);
border: 1px solid var(--border-color);
min-width: 320px;
height: 40px;
transition: all var(--transition-base);
}
.search-box:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary), transparent 85%);
}
.search-box input {
border: none;
background: transparent;
outline: none;
font-size: var(--font-size-md);
color: var(--text-color);
width: 100%;
height: 100%;
}
.table-container {
flex: 1;
overflow: auto;
background: var(--bg-color-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
}
table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
}
th,
td {
text-align: left;
padding: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--bg-color-tertiary);
font-weight: 600;
position: sticky;
top: 0;
z-index: 10;
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background-color: var(--bg-color-tertiary);
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-active {
background-color: color-mix(in srgb, var(--color-success), transparent 85%);
color: var(--color-success);
}
.status-inactive {
background-color: color-mix(in srgb, var(--text-color-disabled), transparent 85%);
color: var(--text-color-secondary);
}
</style>
</head>
<body>
<div class="page-header">
<div class="page-title">
<i class="fas fa-user-tag" style="color: var(--color-primary);"></i>
{{ $t('nav.roles') }}
</div>
<div style="display: flex; gap: var(--spacing-md); align-items: center;">
<div class="search-box">
<i class="fas fa-search" style="color: var(--text-color-tertiary);"></i>
<input type="text" v:value="searchQuery" :placeholder="$t('role.search_placeholder')">
</div>
<v-btn color="primary" :click="openCreateModal">
<i class="fas fa-plus"></i>
{{ $t('common.create') }}
</v-btn>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>{{ $t('role.code') }}</th>
<th>{{ $t('role.name') }}</th>
<th>{{ $t('role.description') }}</th>
<th>System</th>
<th>Status</th>
<th>{{ $t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in filteredRoles">
<td>{{ r.id }}</td>
<td>{{ r.code }}</td>
<td>{{ r.name }}</td>
<td>{{ r.description }}</td>
<td>
<span class="status-badge" :class="r.is_system ? 'status-active' : 'status-inactive'">
{{ r.is_system ? 'Yes' : 'No' }}
</span>
</td>
<td>
<span class="status-badge" :class="r.status === 1 ? 'status-active' : 'status-inactive'">
{{ r.status === 1 ? 'Active' : 'Inactive' }}
</span>
</td>
<td>
<div style="display: flex; gap: 8px;">
<v-btn icon size="sm" variant="outline" :click="() => openEditModal(r)" :title="$t('common.edit')" :disabled="r.is_system">
<i class="fas fa-edit"></i>
</v-btn>
<v-btn icon size="sm" color="danger" variant="outline" :click="() => deleteRole(r)" :title="$t('common.delete')" :disabled="r.is_system">
<i class="fas fa-trash"></i>
</v-btn>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create/Edit Dialog -->
<v-dialog v:visible="showModal"
:title="isEdit ? $t('role.edit') : $t('role.create')">
<form @submit.prevent="saveRole" style="display: grid; gap: 16px;">
<v-input :label="$t('role.code')" required v:value="formData.code" :disabled="isEdit" placeholder="e.g. admin"></v-input>
<v-input :label="$t('role.name')" required v:value="formData.name" placeholder="e.g. Administrator"></v-input>
<v-input :label="$t('role.description')" v:value="formData.description" placeholder="Description..."></v-input>
</form>
<div vslot="footer">
<v-btn variant="outline" :click="closeModal">{{ $t('common.cancel') }}</v-btn>
<v-btn color="primary" :click="saveRole">
{{ isEdit ? $t('common.save') : $t('common.create') }}
</v-btn>
</div>
</v-dialog>
</body>
<script setup>
roles = [];
searchQuery = "";
showModal = false;
isEdit = false;
formData = {
id: null,
code: "",
name: "",
description: ""
};
loadRoles = async () => {
try {
const res = await $axios.get('/api/roles');
roles = res.items || [];
} catch (e) {
$message.error(e.message);
}
};
filteredRoles = () => {
if (!searchQuery) return roles;
const query = searchQuery.toLowerCase();
return roles.filter(r =>
r.name.toLowerCase().includes(query) ||
r.code.toLowerCase().includes(query) ||
(r.description && r.description.toLowerCase().includes(query))
);
};
openCreateModal = () => {
isEdit = false;
formData = { id: null, code: "", name: "", description: "" };
showModal = true;
};
openEditModal = (r) => {
isEdit = true;
formData = { ...r };
showModal = true;
};
closeModal = () => {
showModal = false;
};
saveRole = async () => {
if (!formData.code || !formData.name) {
$message.error($t('org.required_fields')); // Reusing existing message or add new one
return;
}
try {
if (isEdit) {
const payload = {
name: formData.name,
description: formData.description
};
await $axios.patch(`/api/roles/${formData.id}`, payload);
$message.success($t('org.updated')); // Reusing
} else {
await $axios.post('/api/roles', formData);
$message.success($t('org.created')); // Reusing
}
closeModal();
loadRoles();
} catch (e) {
$message.error(e.message);
}
};
deleteRole = async (r) => {
try {
await $message.confirm($t('role.delete_confirm'));
await $axios.delete(`/api/roles/${r.id}`);
$message.success($t('org.deleted')); // Reusing
loadRoles();
} catch (e) {
// Cancelled
}
};
</script>
<script>
$data.loadRoles();
</script>
</html>

@ -20,6 +20,9 @@ const routes = [
meta: { auth: true } meta: { auth: true }
}, },
// Role Management
{ path: '/roles', component: '/page/sys/role/index.html', layout: 'default', meta: { auth: true } },
// User System // User System
{ path: '/profile', component: '/page/user/profile.html', layout: 'default', meta: { auth: true } }, { path: '/profile', component: '/page/user/profile.html', layout: 'default', meta: { auth: true } },
{ {

Loading…
Cancel
Save