mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 00:00:50 +00:00
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:
committed by
zhengkunwang223
co-authored by
CityFun
parent
4517ae2f81
commit
d0faee4eeb
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -134,6 +134,7 @@ ExportUser: 'ユーザー'
|
||||
ExportStatus: 'ログイン状態'
|
||||
ExportDate: '時間'
|
||||
ErrFileCanNotRead: 'このファイルはプレビューをサポートしていません'
|
||||
ErrSensitiveFileRead: '機密ファイルの読み取りは許可されていません'
|
||||
ErrFileToLarge: 'ファイルは 10M より大きいため開けません'
|
||||
ErrPathNotFound: 'ディレクトリが存在しません'
|
||||
ErrMovePathFailed: 'ターゲット パスに元のパスを含めることはできません'
|
||||
|
||||
@@ -134,6 +134,7 @@ ExportUser: '사용자'
|
||||
ExportStatus: '로그인 상태'
|
||||
ExportDate: '시간'
|
||||
ErrFileCanNotRead: '이 파일은 미리보기를 지원하지 않습니다'
|
||||
ErrSensitiveFileRead: '민감한 파일을 읽을 수 없습니다'
|
||||
ErrFileToLarge: '파일이 10M보다 커서 열 수 없습니다'
|
||||
ErrPathNotFound: '디렉토리가 존재하지 않습니다'
|
||||
ErrMovePathFailed: '대상 경로에는 원래 경로가 포함될 수 없습니다'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -134,6 +134,7 @@ ExportUser: 'Пользователь'
|
||||
ExportStatus: 'Статус входа'
|
||||
ExportDate: 'Время'
|
||||
ErrFileCanNotRead: 'Этот файл не поддерживает предварительный просмотр'
|
||||
ErrSensitiveFileRead: 'Чтение конфиденциальных файлов запрещено'
|
||||
ErrFileToLarge: 'Файл больше 10 МБ и не может быть открыт'
|
||||
ErrPathNotFound: 'Каталог не существует'
|
||||
ErrMovePathFailed: 'Целевой путь не может содержать исходный путь'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -134,6 +134,7 @@ ExportUser: '使用者'
|
||||
ExportStatus: '登入狀態'
|
||||
ExportDate: '時間'
|
||||
ErrFileCanNotRead: '此檔案不支援預覽'
|
||||
ErrSensitiveFileRead: '不允許讀取敏感檔案'
|
||||
ErrFileToLarge: '檔案超過10M,無法開啟'
|
||||
ErrPathNotFound: '目錄不存在'
|
||||
ErrMovePathFailed: '目標路徑不可包含原路徑!'
|
||||
|
||||
@@ -139,6 +139,7 @@ ExportDate: "时间"
|
||||
|
||||
#file
|
||||
ErrFileCanNotRead: "此文件不支持预览"
|
||||
ErrSensitiveFileRead: "不允许读取敏感文件"
|
||||
ErrFileToLarge: "文件超过 10M,无法打开"
|
||||
ErrPathNotFound: "目录不存在"
|
||||
ErrMovePathFailed: "目标路径不能包含原路径!"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -4296,13 +4296,16 @@ const message = {
|
||||
superAdminDesc: 'システム全体の管理権限を持ち、すべてのリソースと設定を管理できます。',
|
||||
nodeAdmin: 'ノード管理者',
|
||||
nodeAdminDesc: '指定されたノードの管理権限を持ち、ノード内のリソースと設定を管理できます。',
|
||||
readOnly: '読み取り専用',
|
||||
readOnlyDesc: 'システムのすべての閲覧権限を持ち、すべてのリソースと設定を表示できます。',
|
||||
bindNode: 'ノードをバインド',
|
||||
boundUsers: 'バインド済みユーザー',
|
||||
role: 'ロール',
|
||||
roleName: '名前',
|
||||
permission: '権限',
|
||||
permissionDuplicate: '各ノードには1つのロールのみ割り当てられます',
|
||||
nodeAdminMasterForbidden: 'マスターノードにノード管理者権限を追加することはできません',
|
||||
nodeAdminMasterConfirm:
|
||||
'マスターノードのノード管理者権限は高いため、実際の管理ニーズに合わせて慎重に割り当ててください。',
|
||||
permissionLinkageTip:
|
||||
'依存関係がある場合、関連権限は自動選択されます。手動で解除すると、一部機能で「現在のユーザーには権限がありません」と表示される場合があります。',
|
||||
allViewPermissionHelper: 'AI ゲートウェイのすべての表示権限を付与します。',
|
||||
|
||||
@@ -4209,13 +4209,16 @@ const message = {
|
||||
superAdminDesc: '시스템 전체 관리 권한을 보유하며 모든 리소스와 설정을 관리할 수 있습니다.',
|
||||
nodeAdmin: '노드 관리자',
|
||||
nodeAdminDesc: '지정된 노드의 관리 권한을 보유하며 노드 내 리소스와 설정을 관리할 수 있습니다.',
|
||||
readOnly: '읽기 전용',
|
||||
readOnlyDesc: '시스템의 모든 읽기 전용 권한을 보유하며 모든 리소스와 설정을 볼 수 있습니다.',
|
||||
bindNode: '노드 연결',
|
||||
boundUsers: '바인딩된 사용자',
|
||||
role: '역할',
|
||||
roleName: '이름',
|
||||
permission: '권한',
|
||||
permissionDuplicate: '각 노드에는 하나의 역할만 지정할 수 있습니다',
|
||||
nodeAdminMasterForbidden: '마스터 노드에는 노드 관리자 권한을 추가할 수 없습니다',
|
||||
nodeAdminMasterConfirm:
|
||||
'마스터 노드의 노드 관리자 권한은 높은 수준이므로 실제 관리 필요에 따라 신중하게 할당하세요.',
|
||||
permissionLinkageTip:
|
||||
'의존 관계가 있으면 관련 권한이 자동 선택되며, 수동으로 해제하면 일부 기능에서 "현재 사용자에게 권한이 없습니다"가 표시될 수 있습니다.',
|
||||
allViewPermissionHelper: 'AI 게이트웨이의 모든 보기 권한을 부여합니다.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -4491,13 +4491,16 @@ const message = {
|
||||
nodeAdmin: 'Administrador de Nó',
|
||||
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 Nó',
|
||||
boundUsers: 'Usuários vinculados',
|
||||
role: 'Função',
|
||||
roleName: 'Nome',
|
||||
permission: 'Permissões',
|
||||
permissionDuplicate: 'Apenas uma função pode ser atribuída a cada nó',
|
||||
nodeAdminMasterForbidden: 'Permissões de administrador de nó não podem ser adicionadas ao nó principal',
|
||||
nodeAdminMasterConfirm:
|
||||
'As permissões de Administrador de Nó no nó 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.',
|
||||
|
||||
@@ -4346,13 +4346,16 @@ const message = {
|
||||
nodeAdmin: 'Администратор узла',
|
||||
nodeAdminDesc:
|
||||
'Имеет права управления указанными узлами и может управлять ресурсами и конфигурациями внутри этих узлов.',
|
||||
readOnly: 'Только чтение',
|
||||
readOnlyDesc: 'Имеет доступ только на просмотр всех системных ресурсов и настроек.',
|
||||
bindNode: 'Привязать узел',
|
||||
boundUsers: 'Привязанные пользователи',
|
||||
role: 'Роль',
|
||||
roleName: 'Название',
|
||||
permission: 'Разрешения',
|
||||
permissionDuplicate: 'Каждому узлу можно назначить только одну роль',
|
||||
nodeAdminMasterForbidden: 'Права администратора узла нельзя добавить к главному узлу',
|
||||
nodeAdminMasterConfirm:
|
||||
'Права администратора узла на главном узле являются повышенными. Назначайте их осторожно с учетом реальных потребностей администрирования.',
|
||||
permissionLinkageTip:
|
||||
'При наличии зависимостей связанные права выбираются автоматически; после ручного снятия некоторые функции могут показать "У текущего пользователя нет разрешения".',
|
||||
allViewPermissionHelper: 'Предоставляет все права просмотра для AI Gateway.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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。',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Vendored
+1
@@ -28,4 +28,5 @@ declare interface RouterButton {
|
||||
path?: string;
|
||||
name?: string;
|
||||
count?: number;
|
||||
permission?: string | string[];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user