feat: improve frontend permission handling (#12760)

* feat: change isProductPro logic (#12712)

* fix: tighten frontend RBAC permission guards (#12717)

* fix: tighten frontend RBAC permission guards

* feat: add permission directive coverage

* refactor frontend global store usage

* fix: harden file access and fallback handling

* feat: improve frontend permission handling

---------

Co-authored-by: CityFun <31820853+zhengkunwang223@users.noreply.github.com>
This commit is contained in:
ssongliu
2026-05-21 12:39:49 +08:00
committed by zhengkunwang223
co-authored by CityFun
parent 4517ae2f81
commit d0faee4eeb
39 changed files with 220 additions and 79 deletions
+12
View File
@@ -640,6 +640,10 @@ func (b *BaseApi) MoveFile(c *gin.Context) {
// @Router /files/download [get]
func (b *BaseApi) Download(c *gin.Context) {
filePath := c.Query("path")
if files.ShouldDenySensitiveFileRead(filePath) {
helper.InternalServer(c, buserr.New("ErrSensitiveFileRead"))
return
}
file, err := os.Open(filePath)
if err != nil {
helper.InternalServer(c, err)
@@ -675,6 +679,10 @@ func (b *BaseApi) DownloadChunkFiles(c *gin.Context) {
helper.ErrorWithDetail(c, http.StatusInternalServerError, "ErrPathNotFound", nil)
return
}
if files.ShouldDenySensitiveFileRead(req.Path) {
helper.InternalServer(c, buserr.New("ErrSensitiveFileRead"))
return
}
filePath := req.Path
fstFile, err := fileOp.OpenFile(filePath)
if err != nil {
@@ -1335,6 +1343,10 @@ func (b *BaseApi) DownloadFileShare(c *gin.Context) {
helper.InternalServer(c, err)
return
}
if files.ShouldDenySensitiveFileRead(filePath) {
helper.InternalServer(c, buserr.New("ErrSensitiveFileRead"))
return
}
file, err := os.Open(filePath)
if err != nil {
helper.InternalServer(c, err)
+11
View File
@@ -669,6 +669,9 @@ func applyDecompressOwnership(srcPath, dstPath string) error {
}
func (f *FileService) GetContent(op request.FileContentReq) (response.FileInfo, error) {
if files.ShouldDenySensitiveFileRead(op.Path) {
return response.FileInfo{}, buserr.New("ErrSensitiveFileRead")
}
info, err := files.NewFileInfo(files.FileOption{
Path: op.Path,
Expand: true,
@@ -705,6 +708,9 @@ func (f *FileService) GetContent(op request.FileContentReq) (response.FileInfo,
}
func (f *FileService) GetPreviewContent(op request.FileContentReq) (response.FileInfo, error) {
if files.ShouldDenySensitiveFileRead(op.Path) {
return response.FileInfo{}, buserr.New("ErrSensitiveFileRead")
}
info, err := files.NewFileInfo(files.FileOption{
Path: op.Path,
Expand: false,
@@ -945,6 +951,11 @@ func buildHistoryMoveTargetPath(dst, name, sourcePath string, sourceCount int) s
}
func (f *FileService) FileDownload(d request.FileDownload) (string, error) {
for _, p := range d.Paths {
if files.ShouldDenySensitiveFileRead(p) {
return "", buserr.New("ErrSensitiveFileRead")
}
}
filePath := d.Paths[0]
if d.Compress {
tempPath := filepath.Join(os.TempDir(), fmt.Sprintf("%d", time.Now().UnixNano()))
+4
View File
@@ -20,6 +20,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"gorm.io/gorm"
)
@@ -131,6 +132,9 @@ func (s *FileShareService) Create(req request.FileShareCreate) (*response.FileSh
if path == "" || strings.Contains(path, "..") {
return nil, buserr.New("ErrFileSharePath")
}
if files.ShouldDenySensitiveFileRead(path) {
return nil, buserr.New("ErrSensitiveFileRead")
}
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return nil, buserr.New("ErrFileSharePath")
+1
View File
@@ -139,6 +139,7 @@ ExportDate: "Time"
#file
ErrFileCanNotRead: 'File preview is not supported'
ErrSensitiveFileRead: 'Reading sensitive files is not allowed'
ErrFileToLarge: 'File is larger than 10 MB'
ErrPathNotFound: 'Directory does not exist'
ErrMovePathFailed: 'Target path cannot include source path'
+1
View File
@@ -134,6 +134,7 @@ ExportUser: 'Usuario'
ExportStatus: 'Estado de inicio de sesión'
ExportDate: 'Fecha y hora'
ErrFileCanNotRead: 'Este archivo no soporta vista previa'
ErrSensitiveFileRead: 'No se permite leer archivos sensibles'
ErrFileToLarge: 'El archivo es mayor a 10M y no puede abrirse'
ErrPathNotFound: 'El directorio no existe'
ErrMovePathFailed: 'La ruta de destino no puede contener la ruta original'
+1
View File
@@ -134,6 +134,7 @@ ExportUser: 'ユーザー'
ExportStatus: 'ログイン状態'
ExportDate: '時間'
ErrFileCanNotRead: 'このファイルはプレビューをサポートしていません'
ErrSensitiveFileRead: '機密ファイルの読み取りは許可されていません'
ErrFileToLarge: 'ファイルは 10M より大きいため開けません'
ErrPathNotFound: 'ディレクトリが存在しません'
ErrMovePathFailed: 'ターゲット パスに元のパスを含めることはできません'
+1
View File
@@ -134,6 +134,7 @@ ExportUser: '사용자'
ExportStatus: '로그인 상태'
ExportDate: '시간'
ErrFileCanNotRead: '이 파일은 미리보기를 지원하지 않습니다'
ErrSensitiveFileRead: '민감한 파일을 읽을 수 없습니다'
ErrFileToLarge: '파일이 10M보다 커서 열 수 없습니다'
ErrPathNotFound: '디렉토리가 존재하지 않습니다'
ErrMovePathFailed: '대상 경로에는 원래 경로가 포함될 수 없습니다'
+1
View File
@@ -134,6 +134,7 @@ ExportUser: 'Pengguna'
ExportStatus: 'Status Log Masuk'
ExportDate: 'Masa'
ErrFileCanNotRead: 'Fail ini tidak menyokong pratonton'
ErrSensitiveFileRead: 'Membaca fail sensitif tidak dibenarkan'
ErrFileToLarge: 'Fail lebih besar daripada 10M dan tidak boleh dibuka'
ErrPathNotFound: 'Direktori tidak wujud'
ErrMovePathFailed: 'Laluan sasaran tidak boleh mengandungi laluan asal'
+1
View File
@@ -134,6 +134,7 @@ ExportUser: 'Usuário'
ExportStatus: 'Status de Login'
ExportDate: 'Hora'
ErrFileCanNotRead: 'Este arquivo não suporta visualização'
ErrSensitiveFileRead: 'A leitura de arquivos sensíveis não é permitida'
ErrFileToLarge: 'O arquivo é maior que 10M e não pode ser aberto'
ErrPathNotFound: 'O diretório não existe'
ErrMovePathFailed: 'O caminho de destino não pode conter o caminho original'
+1
View File
@@ -134,6 +134,7 @@ ExportUser: 'Пользователь'
ExportStatus: 'Статус входа'
ExportDate: 'Время'
ErrFileCanNotRead: 'Этот файл не поддерживает предварительный просмотр'
ErrSensitiveFileRead: 'Чтение конфиденциальных файлов запрещено'
ErrFileToLarge: 'Файл больше 10 МБ и не может быть открыт'
ErrPathNotFound: 'Каталог не существует'
ErrMovePathFailed: 'Целевой путь не может содержать исходный путь'
+1
View File
@@ -134,6 +134,7 @@ ExportUser: 'Kullanıcı'
ExportStatus: 'Giriş Durumu'
ExportDate: 'Zaman'
ErrFileCanNotRead: 'Bu dosya önizlemeyi desteklemiyor'
ErrSensitiveFileRead: 'Hassas dosyaların okunmasına izin verilmez'
ErrFileToLarge: 'Dosya 10M dan büyük ve açılamıyor'
ErrPathNotFound: 'Dizin mevcut değil'
ErrMovePathFailed: 'Hedef yol orijinal yolu içeremez'
+1
View File
@@ -134,6 +134,7 @@ ExportUser: '使用者'
ExportStatus: '登入狀態'
ExportDate: '時間'
ErrFileCanNotRead: '此檔案不支援預覽'
ErrSensitiveFileRead: '不允許讀取敏感檔案'
ErrFileToLarge: '檔案超過10M,無法開啟'
ErrPathNotFound: '目錄不存在'
ErrMovePathFailed: '目標路徑不可包含原路徑!'
+1
View File
@@ -139,6 +139,7 @@ ExportDate: "时间"
#file
ErrFileCanNotRead: "此文件不支持预览"
ErrSensitiveFileRead: "不允许读取敏感文件"
ErrFileToLarge: "文件超过 10M,无法打开"
ErrPathNotFound: "目录不存在"
ErrMovePathFailed: "目标路径不能包含原路径!"
+26
View File
@@ -17,5 +17,31 @@ func ShouldFilterSensitivePath(path string) bool {
return true
}
}
return ShouldDenySensitiveFileRead(path)
}
func ShouldDenySensitiveFileRead(path string) bool {
if isSensitiveSSHPath(path) {
return true
}
realPath, err := filepath.EvalSymlinks(path)
if err != nil {
return false
}
return isSensitiveSSHPath(realPath)
}
func isSensitiveSSHPath(path string) bool {
cleanedPath := filepath.Clean(path)
parts := strings.Split(filepath.ToSlash(cleanedPath), "/")
for i, part := range parts {
if part != ".ssh" {
continue
}
if i == len(parts)-1 {
return true
}
return !strings.HasSuffix(parts[len(parts)-1], ".pub")
}
return false
}
@@ -24,8 +24,9 @@
<script lang="ts" setup>
import { routerToNameWithQuery, routerToPathWithQuery } from '@/utils/router';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { hasPermissionMetaAccess } from '@/utils/rbac';
defineOptions({ name: 'RouterButton' });
@@ -37,7 +38,7 @@ const props = defineProps({
});
const buttonArray = computed(() => {
return props.buttons;
return props.buttons.filter((button) => hasPermissionMetaAccess(button.permission));
});
const router = useRouter();
@@ -52,10 +53,25 @@ const handleChange = (label: string) => {
};
onMounted(() => {
syncActiveName();
});
watch(
() => [router.currentRoute.value.path, buttonArray.value.map((button) => button.label).join('|')],
() => {
syncActiveName();
},
);
function syncActiveName() {
if (!buttonArray.value.length) {
activeName.value = '';
return;
}
if (buttonArray.value.length) {
let isPathExist = false;
const btn = buttonArray.value.find((btn) => {
return router.currentRoute.value.path.startsWith(btn.path);
return btn.path && router.currentRoute.value.path.startsWith(btn.path);
});
if (btn) {
isPathExist = true;
@@ -65,7 +81,7 @@ onMounted(() => {
activeName.value = buttonArray.value[0].label;
}
}
});
}
</script>
<style lang="scss" scoped>
@@ -1,25 +1,43 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { hasManagePermissionAccess, hasPermissionAccess, toManagePermission } from '@/utils/permission';
import {
hasManagePermissionAccess,
hasPermissionAccess,
toManagePermission,
type PermissionBindingValue,
} from '@/utils/permission';
const getRoutePermission = (route: ReturnType<typeof useRoute>) => {
const getRoutePermission = (route: ReturnType<typeof useRoute>): PermissionBindingValue => {
const metaPermission = route.meta?.permission;
if (typeof metaPermission === 'string' && metaPermission) {
return metaPermission;
}
if (Array.isArray(metaPermission)) {
return metaPermission;
}
for (const record of [...route.matched].reverse()) {
const permission = record.meta?.permission;
if (typeof permission === 'string' && permission) {
return permission;
}
if (Array.isArray(permission)) {
return permission;
}
}
return '';
};
export const useMenuManagePermission = (permission?: string) => {
const toManagePermissionValue = (permission: PermissionBindingValue) => {
if (Array.isArray(permission)) {
return permission.map(toManagePermission).filter(Boolean);
}
return toManagePermission(permission || '');
};
export const useMenuManagePermission = (permission?: PermissionBindingValue) => {
const route = useRoute();
const { isAdmin, isNodeAdmin } = useGlobalStore();
@@ -30,7 +48,7 @@ export const useMenuManagePermission = (permission?: string) => {
return getRoutePermission(route);
});
const managePermission = computed(() => {
return toManagePermission(sourcePermission.value);
return toManagePermissionValue(sourcePermission.value);
});
const hasAdminManagePermission = computed(() => isAdmin.value || isNodeAdmin.value);
const hasPermission = computed(() => {
@@ -48,6 +66,6 @@ export const useMenuManagePermission = (permission?: string) => {
};
};
export const useCan = (permission?: string) => {
export const useCan = (permission?: PermissionBindingValue) => {
return useMenuManagePermission(permission).hasPermission;
};
+19 -14
View File
@@ -1,10 +1,12 @@
import { getCurrentScope, onScopeDispose } from 'vue';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { setPrimaryColor } from '@/utils/theme';
let themeListenerInitialized = false;
export const useTheme = () => {
const { isXpackOrEE, themeConfig } = useGlobalStore();
const switchTheme = () => {
const { isXpackOrEE, themeConfig } = useGlobalStore();
let itemTheme = themeConfig.value.theme;
if (itemTheme === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -26,20 +28,23 @@ export const useTheme = () => {
}
};
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const onSystemThemeChange = () => {
const { themeConfig } = useGlobalStore();
if (themeConfig.value.theme === 'auto') {
switchTheme();
const ensureSystemThemeListener = () => {
if (themeListenerInitialized || typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const onSystemThemeChange = () => {
if (themeConfig.value.theme === 'auto') {
switchTheme();
}
};
mediaQuery.addEventListener('change', onSystemThemeChange);
themeListenerInitialized = true;
};
mediaQuery.addEventListener('change', onSystemThemeChange);
if (getCurrentScope()) {
onScopeDispose(() => {
mediaQuery.removeEventListener('change', onSystemThemeChange);
});
}
ensureSystemThemeListener();
return {
switchTheme,
+5 -1
View File
@@ -4257,13 +4257,17 @@ const message = {
nodeAdmin: 'Node Admin',
nodeAdminDesc:
'Has management permissions for specified nodes and can manage resources and configurations within those nodes.',
readOnly: 'Read Only',
readOnlyDesc:
'Has read-only access to all system permissions and can view all resources and configurations.',
bindNode: 'Bind Node',
boundUsers: 'Bound Users',
role: 'Role',
roleName: 'Name',
permission: 'Permissions',
permissionDuplicate: 'Only one role can be assigned to each node',
nodeAdminMasterForbidden: 'Node Admin permissions cannot be added to the master node',
nodeAdminMasterConfirm:
'Node Admin permissions on the master node are elevated. Assign them carefully based on actual management needs.',
permissionLinkageTip:
'Related permissions are selected automatically when dependencies exist; after manual removal, some features may show "Current user has no permission".',
allViewPermissionHelper: 'Grants all view permissions for AI Gateway.',
+4 -1
View File
@@ -4314,13 +4314,16 @@ const message = {
nodeAdmin: 'Administrador de nodo',
nodeAdminDesc:
'Tiene permisos de administración para nodos especificados y puede gestionar recursos y configuraciones dentro de esos nodos.',
readOnly: 'Solo lectura',
readOnlyDesc: 'Tiene acceso de solo lectura a todos los recursos y configuraciones del sistema.',
bindNode: 'Vincular nodo',
boundUsers: 'Usuarios vinculados',
role: 'Rol',
roleName: 'Nombre',
permission: 'Permisos',
permissionDuplicate: 'Solo se puede asignar un rol a cada nodo',
nodeAdminMasterForbidden: 'No se pueden agregar permisos de administrador de nodo al nodo principal',
nodeAdminMasterConfirm:
'Los permisos de administrador de nodo en el nodo principal son elevados. Asígnelos con cuidado según las necesidades reales de administración.',
permissionLinkageTip:
'Si hay dependencias, los permisos relacionados se seleccionarán automáticamente; tras quitarlos manualmente, algunas funciones pueden mostrar "El usuario actual no tiene permiso".',
allViewPermissionHelper: 'Concede todos los permisos de visualización para AI Gateway.',
+4 -1
View File
@@ -4296,13 +4296,16 @@ const message = {
superAdminDesc: 'システム全体の管理権限を持ちすべてのリソースと設定を管理できます',
nodeAdmin: 'ノード管理者',
nodeAdminDesc: '指定されたノードの管理権限を持ちノード内のリソースと設定を管理できます',
readOnly: '読み取り専用',
readOnlyDesc: 'システムのすべての閲覧権限を持ちすべてのリソースと設定を表示できます',
bindNode: 'ノードをバインド',
boundUsers: 'バインド済みユーザー',
role: 'ロール',
roleName: '名前',
permission: '権限',
permissionDuplicate: '各ノードには1つのロールのみ割り当てられます',
nodeAdminMasterForbidden: 'マスターノードにノード管理者権限を追加することはできません',
nodeAdminMasterConfirm:
'マスターノードのノード管理者権限は高いため実際の管理ニーズに合わせて慎重に割り当ててください',
permissionLinkageTip:
'依存関係がある場合関連権限は自動選択されます手動で解除すると一部機能で現在のユーザーには権限がありませんと表示される場合があります',
allViewPermissionHelper: 'AI ゲートウェイのすべての表示権限を付与します',
+4 -1
View File
@@ -4209,13 +4209,16 @@ const message = {
superAdminDesc: '시스템 전체 관리 권한을 보유하며 모든 리소스와 설정을 관리할 있습니다.',
nodeAdmin: '노드 관리자',
nodeAdminDesc: '지정된 노드의 관리 권한을 보유하며 노드 리소스와 설정을 관리할 있습니다.',
readOnly: '읽기 전용',
readOnlyDesc: '시스템의 모든 읽기 전용 권한을 보유하며 모든 리소스와 설정을 있습니다.',
bindNode: '노드 연결',
boundUsers: '바인딩된 사용자',
role: '역할',
roleName: '이름',
permission: '권한',
permissionDuplicate: ' 노드에는 하나의 역할만 지정할 있습니다',
nodeAdminMasterForbidden: '마스터 노드에는 노드 관리자 권한을 추가할 없습니다',
nodeAdminMasterConfirm:
'마스터 노드의 노드 관리자 권한은 높은 수준이므로 실제 관리 필요에 따라 신중하게 할당하세요.',
permissionLinkageTip:
'의존 관계가 있으면 관련 권한이 자동 선택되며, 수동으로 해제하면 일부 기능에서 "현재 사용자에게 권한이 없습니다" 표시될 있습니다.',
allViewPermissionHelper: 'AI 게이트웨이의 모든 보기 권한을 부여합니다.',
+5 -1
View File
@@ -4352,13 +4352,17 @@ const message = {
nodeAdmin: 'Pentadbir Nod',
nodeAdminDesc:
'Mempunyai kebenaran pengurusan untuk nod yang ditentukan dan boleh mengurus sumber serta konfigurasi dalam nod tersebut.',
readOnly: 'Baca Sahaja',
readOnlyDesc:
'Mempunyai semua keizinan baca sahaja sistem dan boleh melihat semua sumber serta konfigurasi.',
bindNode: 'Ikat Nod',
boundUsers: 'Pengguna Terikat',
role: 'Peranan',
roleName: 'Nama',
permission: 'Kebenaran',
permissionDuplicate: 'Setiap nod hanya boleh diberikan satu peranan',
nodeAdminMasterForbidden: 'Kebenaran Pentadbir Nod tidak boleh ditambah pada nod utama',
nodeAdminMasterConfirm:
'Kebenaran Pentadbir Nod pada nod utama adalah tinggi. Tetapkannya dengan berhati-hati mengikut keperluan pengurusan sebenar.',
permissionLinkageTip:
'Kebenaran berkaitan akan dipilih automatik jika ada kebergantungan; selepas dialih keluar manual, sesetengah ciri mungkin memaparkan "Pengguna semasa tiada kebenaran".',
allViewPermissionHelper: 'Memberikan semua kebenaran paparan untuk AI Gateway.',
+4 -1
View File
@@ -4491,13 +4491,16 @@ const message = {
nodeAdmin: 'Administrador de ',
nodeAdminDesc:
'Possui permissões de gerenciamento para nós especificados e pode gerenciar recursos e configurações dentro desses nós.',
readOnly: 'Somente leitura',
readOnlyDesc: 'Tem acesso somente de leitura a todos os recursos e configurações do sistema.',
bindNode: 'Vincular ',
boundUsers: 'Usuários vinculados',
role: 'Função',
roleName: 'Nome',
permission: 'Permissões',
permissionDuplicate: 'Apenas uma função pode ser atribuída a cada ',
nodeAdminMasterForbidden: 'Permissões de administrador de não podem ser adicionadas ao principal',
nodeAdminMasterConfirm:
'As permissões de Administrador de no principal são elevadas. Atribua-as com cuidado conforme as necessidades reais de administração.',
permissionLinkageTip:
'Permissões relacionadas serão selecionadas automaticamente quando houver dependências; após removê-las manualmente, alguns recursos podem mostrar "O usuário atual não tem permissão".',
allViewPermissionHelper: 'Concede todas as permissões de visualização do AI Gateway.',
+4 -1
View File
@@ -4346,13 +4346,16 @@ const message = {
nodeAdmin: 'Администратор узла',
nodeAdminDesc:
'Имеет права управления указанными узлами и может управлять ресурсами и конфигурациями внутри этих узлов.',
readOnly: 'Только чтение',
readOnlyDesc: 'Имеет доступ только на просмотр всех системных ресурсов и настроек.',
bindNode: 'Привязать узел',
boundUsers: 'Привязанные пользователи',
role: 'Роль',
roleName: 'Название',
permission: 'Разрешения',
permissionDuplicate: 'Каждому узлу можно назначить только одну роль',
nodeAdminMasterForbidden: 'Права администратора узла нельзя добавить к главному узлу',
nodeAdminMasterConfirm:
'Права администратора узла на главном узле являются повышенными. Назначайте их осторожно с учетом реальных потребностей администрирования.',
permissionLinkageTip:
'При наличии зависимостей связанные права выбираются автоматически; после ручного снятия некоторые функции могут показать "У текущего пользователя нет разрешения".',
allViewPermissionHelper: 'Предоставляет все права просмотра для AI Gateway.',
+4 -1
View File
@@ -4343,13 +4343,16 @@ const message = {
nodeAdmin: 'Düğüm Yöneticisi',
nodeAdminDesc:
'Belirtilen düğümler için yönetim izinlerine sahiptir ve bu düğümlerdeki kaynakları ve yapılandırmaları yönetebilir.',
readOnly: 'Salt Okunur',
readOnlyDesc: 'Tüm sistem kaynaklarını ve yapılandırmalarını yalnızca görüntüleyebilir.',
bindNode: 'Düğüm Bağla',
boundUsers: 'Bağlı Kullanıcılar',
role: 'Rol',
roleName: 'Ad',
permission: 'İzinler',
permissionDuplicate: 'Her düğüme yalnızca bir rol atanabilir',
nodeAdminMasterForbidden: 'Ana düğüme düğüm yöneticisi izni eklenemez',
nodeAdminMasterConfirm:
'Ana düğümdeki Düğüm Yöneticisi izinleri yüksektir. Gerçek yönetim ihtiyaçlarına göre dikkatli şekilde atayın.',
permissionLinkageTip:
'Bağımlılık varsa ilişkili izinler otomatik seçilir; manuel kaldırıldıktan sonra bazı özelliklerde "Geçerli kullanıcının izni yok" gösterilebilir.',
allViewPermissionHelper: 'AI Gateway için tüm görüntüleme izinlerini verir.',
+3 -1
View File
@@ -3983,13 +3983,15 @@ const message = {
superAdminDesc: '擁有系統全部管理權限,可管理所有資源和設定。',
nodeAdmin: '節點管理員',
nodeAdminDesc: '擁有指定節點的管理權限,可管理節點內資源和設定。',
readOnly: '唯讀使用者',
readOnlyDesc: '擁有系統全部唯讀權限,可查看所有資源和設定。',
bindNode: '綁定節點',
boundUsers: '綁定使用者',
role: '角色',
roleName: '名稱',
permission: '權限',
permissionDuplicate: '每個節點只能新增一種角色',
nodeAdminMasterForbidden: '主節點不允許新增節點管理員權限',
nodeAdminMasterConfirm: '主節點節點管理員權限較高,請結合實際管理需要謹慎分配。',
permissionLinkageTip: '存在依賴時將自動勾選關聯權限,手動取消後部分功能可能提示「目前使用者無權限」。',
allViewPermissionHelper: '擁有 AI 閘道的所有檢視權限。',
apiKeyViewPermissionHelper: '僅能檢視 AI 閘道 API Key。',
+3 -1
View File
@@ -4571,13 +4571,15 @@ const message = {
superAdminDesc: '拥有系统全部管理权限可管理所有资源和配置',
nodeAdmin: '节点管理员',
nodeAdminDesc: '拥有指定节点的管理权限可管理节点内资源和配置',
readOnly: '只读用户',
readOnlyDesc: '拥有系统全部只读权限可查看所有资源和配置',
bindNode: '绑定节点',
boundUsers: '绑定用户',
role: '角色',
roleName: '名称',
permission: '权限',
permissionDuplicate: '每个节点只能添加一种角色',
nodeAdminMasterForbidden: '主节点不允许添加节点管理员权限',
nodeAdminMasterConfirm: '主节点节点管理员权限较高请结合实际管理需要谨慎分配',
permissionLinkageTip: '存在依赖时将自动勾选关联权限手动取消后部分功能可能提示当前用户无权限',
allViewPermissionHelper: '拥有 AI 网关的所有查看权限',
apiKeyViewPermissionHelper: '仅能查看 AI 网关 API Key',
@@ -38,7 +38,7 @@ import { menuList } from '@/routers/router';
import { MenuStore } from '@/store';
import { getSettingBaseInfo } from '@/api/modules/setting';
import PrimaryMenu from '@/assets/images/menu-bg.svg?component';
import { hasPermission, hasRouteRoleAccess } from '@/utils/rbac';
import { hasPermissionMetaAccess, hasRouteRoleAccess } from '@/utils/rbac';
import { useGlobalStore } from '@/composables/useGlobalStore';
const route = useRoute();
@@ -129,12 +129,7 @@ function allowMenuItem(item: RouteRecordRaw) {
if (!hasRouteRoleAccess(item.meta)) {
return false;
}
const permission = item.meta?.permission as string | undefined;
if (!permission) {
return true;
}
const allowed = hasPermission(permission);
return allowed;
return hasPermissionMetaAccess(item.meta?.permission as string | string[] | undefined);
}
function buildMenuListFromSettings(hideMenuValue?: string) {
+8 -4
View File
@@ -99,10 +99,14 @@ router.beforeEach(async (to, from, next) => {
cachedRoute !== to.path &&
!isRedirecting
) {
isRedirecting = true;
next(cachedRoute);
NProgress.done();
return;
const cachedRouteInfo = router.resolve(cachedRoute);
if (cachedRouteInfo.matched.length > 0 && hasRouteAccess(cachedRouteInfo)) {
isRedirecting = true;
next(cachedRoute);
NProgress.done();
return;
}
localStorage.removeItem(activeMenuKey);
}
if (!hasRouteAccess(to)) {
+1
View File
@@ -28,4 +28,5 @@ declare interface RouterButton {
path?: string;
name?: string;
count?: number;
permission?: string | string[];
}
+11 -2
View File
@@ -4,18 +4,24 @@ import { GlobalStore } from '@/store';
export type PermissionBindingValue = string | string[] | undefined;
export type PermissionMode = 'manage' | 'view';
const getRoutePermission = () => {
const getRoutePermission = (): PermissionBindingValue => {
const route = router.currentRoute.value;
const metaPermission = route.meta?.permission;
if (typeof metaPermission === 'string' && metaPermission) {
return metaPermission;
}
if (Array.isArray(metaPermission)) {
return metaPermission;
}
for (const record of [...route.matched].reverse()) {
const permission = record.meta?.permission;
if (typeof permission === 'string' && permission) {
return permission;
}
if (Array.isArray(permission)) {
return permission;
}
}
return '';
@@ -38,6 +44,9 @@ export const toPermissionList = (value: PermissionBindingValue) => {
return [value];
}
const routePermission = getRoutePermission();
if (Array.isArray(routePermission)) {
return routePermission;
}
return routePermission ? [routePermission] : [];
};
@@ -51,7 +60,7 @@ const hasPermissionAccessByMode = (mode: PermissionMode, value?: PermissionBindi
if (normalizedPermissions.length === 0) {
return false;
}
return normalizedPermissions.every((permission) => globalStore.hasPermission(permission));
return normalizedPermissions.some((permission) => globalStore.hasPermission(permission));
};
export const hasManagePermissionAccess = (value?: PermissionBindingValue) => {
+27 -16
View File
@@ -1,28 +1,35 @@
import { getUserInfo } from '@/api/modules/auth';
import { getEnterpriseUserInfo } from '@/extensions/xpack';
import { useGlobalStore } from '@/composables/useGlobalStore';
import type { RouteMeta } from 'vue-router';
import { GlobalStore } from '@/store';
export type PermissionMetaValue = string | string[];
type RouteAccessMeta = {
adminOnly?: boolean;
protectedRoleOnly?: boolean;
permission?: PermissionMetaValue;
};
type RouteAccessTarget = {
matched: Array<{
meta?: RouteMeta & {
permission?: string;
};
meta?: RouteMeta & RouteAccessMeta;
}>;
};
export const syncAuthInfo = async (currentNode?: string) => {
const { globalStore, currentNode: storeCurrentNode, isEnterprise } = useGlobalStore();
if (!isEnterprise.value) {
const globalStore = GlobalStore();
const storeCurrentNode = globalStore.currentNode;
if (!globalStore.isEnterprise) {
const res = await getUserInfo();
globalStore.setAuthInfo({
isAdmin: res.data.role === 'ADMIN',
permissions: res.data.permissions || [],
nodeRoles: res.data.nodeRoles || [],
});
return res.data;
}
const res = await getEnterpriseUserInfo(currentNode ?? storeCurrentNode.value);
const res = await getEnterpriseUserInfo(currentNode ?? storeCurrentNode);
globalStore.setAuthInfo({
isAdmin: res.data.role === 'ADMIN',
permissions: res.data.permissions || [],
@@ -32,7 +39,18 @@ export const syncAuthInfo = async (currentNode?: string) => {
};
export const hasPermission = (permission: string) => {
return useGlobalStore().globalStore.hasPermission(permission);
return GlobalStore().hasPermission(permission);
};
export const hasPermissionMetaAccess = (permission?: PermissionMetaValue) => {
if (!permission) {
return true;
}
if (Array.isArray(permission)) {
const permissions = permission.filter(Boolean);
return permissions.length === 0 || permissions.some((item) => hasPermission(item));
}
return hasPermission(permission);
};
export const hasRouteRoleAccess = (meta?: RouteMeta & RouteAccessMeta) => {
@@ -54,14 +72,7 @@ export const hasRouteRoleAccess = (meta?: RouteMeta & RouteAccessMeta) => {
};
export const hasRoutePermissionAccess = (route: RouteAccessTarget) => {
const requiredPermissions = [
...new Set(
route.matched
.map((record) => record.meta?.permission)
.filter((permission): permission is string => !!permission),
),
];
return requiredPermissions.every((permission) => hasPermission(permission));
return route.matched.every((record) => hasPermissionMetaAccess(record.meta?.permission));
};
export const hasRouteAccess = (route: RouteAccessTarget) => {
+2 -2
View File
@@ -136,7 +136,7 @@ export async function loadMasterProductProFromDB() {
globalStore.isProductPro = res.data.status === 'Bound';
}
}
useTheme().switchTheme();
switchTheme();
initFavicon();
loadDataFromDB();
}
@@ -167,7 +167,7 @@ export async function getXpackSettingForTheme() {
} else {
resetXSetting();
}
useTheme().switchTheme();
switchTheme();
initFavicon();
}
@@ -44,7 +44,6 @@
import { reactive, ref } from 'vue';
import type { FormInstance } from 'element-plus';
import { ElMessageBox } from 'element-plus';
import { useMenuManagePermission } from '@/composables/useMenuManagePermission';
import { useI18n } from 'vue-i18n';
import { AI } from '@/api/interface/ai';
import { approveAgentChannelPairing, getAgentDiscordConfig, updateAgentDiscordConfig } from '@/api/modules/ai';
@@ -78,7 +78,6 @@
import { reactive, ref } from 'vue';
import type { FormInstance } from 'element-plus';
import { ElMessageBox } from 'element-plus';
import { useMenuManagePermission } from '@/composables/useMenuManagePermission';
import { useI18n } from 'vue-i18n';
import { AI } from '@/api/interface/ai';
import { approveAgentChannelPairing, getAgentTelegramConfig, updateAgentTelegramConfig } from '@/api/modules/ai';
@@ -215,19 +215,6 @@ const {
watermarkShow,
} = useGlobalStore();
const {
globalStore,
isEnterprise,
isIntl,
isMobile,
isOffline,
isXpackOrEE,
openMenuTabs,
themeConfig,
watermark,
watermarkShow,
} = useGlobalStore();
const loading = ref(false);
const { switchTheme } = useTheme();
+1
View File
@@ -100,6 +100,7 @@ onUnmounted(() => {
height: 100%;
background-color: var(--panel-button-active) !important;
box-shadow: none !important;
outline: none !important;
border: 2px solid transparent !important;
}
@@ -57,6 +57,7 @@ const handleClose = () => {
const buttons = [
{
label: i18n.global.t('commons.button.install'),
permission: true,
click: function (row: Runtime.SupportExtension) {
installExtension(row);
},
@@ -66,6 +67,7 @@ const buttons = [
},
{
label: i18n.global.t('commons.button.uninstall'),
permission: true,
click: function (row: Runtime.SupportExtension) {
unInstallPHPExtension(row);
},
@@ -52,6 +52,7 @@ const buttons = [
},
{
label: i18n.global.t('commons.button.delete'),
permission: true,
click: function (row: Runtime.PHPExtensions) {
openDelete(row);
},