generated from corrad-software/corrad-af-2024
585 lines
24 KiB
Vue
585 lines
24 KiB
Vue
<script setup>
|
||
import { ref, onMounted } from 'vue';
|
||
import { useDmsStore } from '~/stores/dms';
|
||
|
||
// Define page metadata
|
||
definePageMeta({
|
||
title: "Role Management",
|
||
middleware: ["auth"],
|
||
requiresAuth: true,
|
||
breadcrumb: [
|
||
{
|
||
name: "DMS",
|
||
path: "/dms",
|
||
},
|
||
{
|
||
name: "Role Management",
|
||
path: "/dms/role-management",
|
||
},
|
||
],
|
||
});
|
||
|
||
// Store
|
||
const dmsStore = useDmsStore();
|
||
|
||
// State variables
|
||
const isLoading = ref(true);
|
||
const users = ref([]);
|
||
const roles = ref([]);
|
||
const activeTab = ref('users');
|
||
const searchQuery = ref('');
|
||
const hasError = ref(false);
|
||
const errorMessage = ref('');
|
||
|
||
// Filtered users
|
||
const filteredUsers = computed(() => {
|
||
if (!searchQuery.value) return users.value;
|
||
|
||
const query = searchQuery.value.toLowerCase();
|
||
return users.value.filter(user =>
|
||
user.name.toLowerCase().includes(query) ||
|
||
user.email.toLowerCase().includes(query) ||
|
||
user.department.toLowerCase().includes(query) ||
|
||
user.roles.some(role => role.toLowerCase().includes(query))
|
||
);
|
||
});
|
||
|
||
// Methods
|
||
const loadUsers = async () => {
|
||
isLoading.value = true;
|
||
hasError.value = false;
|
||
|
||
try {
|
||
// This would be an actual API call in production
|
||
// For demo, we'll simulate it with mock data
|
||
await new Promise(resolve => setTimeout(resolve, 800));
|
||
|
||
users.value = [
|
||
{
|
||
id: 'user1',
|
||
name: 'Aiman Fakhrullah',
|
||
email: 'aiman@example.com',
|
||
department: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||
roles: ['engineer'],
|
||
lastLogin: '2023-12-15T08:30:00Z',
|
||
status: 'active'
|
||
},
|
||
{
|
||
id: 'user2',
|
||
name: 'Ahmad Zaki',
|
||
email: 'ahmad@example.com',
|
||
department: 'JKR Bahagian Kewangan',
|
||
roles: ['finance', 'approver'],
|
||
lastLogin: '2023-12-14T14:45:00Z',
|
||
status: 'active'
|
||
},
|
||
{
|
||
id: 'user3',
|
||
name: 'Siti Aminah',
|
||
email: 'siti@example.com',
|
||
department: 'JKR Cawangan Kuala Terengganu',
|
||
roles: ['manager', 'approver'],
|
||
lastLogin: '2023-12-13T09:15:00Z',
|
||
status: 'active'
|
||
},
|
||
{
|
||
id: 'admin1',
|
||
name: 'Admin User',
|
||
email: 'admin@example.com',
|
||
department: 'IT Department',
|
||
roles: ['admin'],
|
||
lastLogin: '2023-12-15T10:00:00Z',
|
||
status: 'active'
|
||
}
|
||
];
|
||
} catch (error) {
|
||
console.error('Failed to load users:', error);
|
||
hasError.value = true;
|
||
errorMessage.value = 'Failed to load users. Please try again.';
|
||
} finally {
|
||
isLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const loadRoles = async () => {
|
||
isLoading.value = true;
|
||
hasError.value = false;
|
||
|
||
try {
|
||
// This would be an actual API call to Authentik in production
|
||
// For demo, we'll simulate it with mock data
|
||
await new Promise(resolve => setTimeout(resolve, 600));
|
||
|
||
roles.value = [
|
||
{
|
||
id: 'admin',
|
||
name: 'Administrator',
|
||
description: 'Full system access',
|
||
userCount: 1,
|
||
permissions: {
|
||
documents: {
|
||
view: true,
|
||
edit: true,
|
||
delete: true,
|
||
approve: true,
|
||
reject: true,
|
||
download: true
|
||
},
|
||
cabinets: {
|
||
view: true,
|
||
create: true,
|
||
edit: true,
|
||
delete: true
|
||
},
|
||
accessRequests: {
|
||
approve: true,
|
||
reject: true,
|
||
viewAll: true
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'manager',
|
||
name: 'Manager',
|
||
description: 'Department management',
|
||
userCount: 1,
|
||
permissions: {
|
||
documents: {
|
||
view: true,
|
||
edit: true,
|
||
delete: false,
|
||
approve: true,
|
||
reject: true,
|
||
download: true
|
||
},
|
||
cabinets: {
|
||
view: true,
|
||
create: true,
|
||
edit: true,
|
||
delete: false
|
||
},
|
||
accessRequests: {
|
||
approve: true,
|
||
reject: true,
|
||
viewAll: true
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'engineer',
|
||
name: 'Engineer',
|
||
description: 'Engineering staff',
|
||
userCount: 1,
|
||
permissions: {
|
||
documents: {
|
||
view: true,
|
||
edit: true,
|
||
delete: false,
|
||
approve: false,
|
||
reject: false,
|
||
download: true
|
||
},
|
||
cabinets: {
|
||
view: true,
|
||
create: false,
|
||
edit: false,
|
||
delete: false
|
||
},
|
||
accessRequests: {
|
||
approve: false,
|
||
reject: false,
|
||
viewAll: false
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'finance',
|
||
name: 'Finance',
|
||
description: 'Finance department staff',
|
||
userCount: 1,
|
||
permissions: {
|
||
documents: {
|
||
view: true,
|
||
edit: true,
|
||
delete: false,
|
||
approve: false,
|
||
reject: false,
|
||
download: true
|
||
},
|
||
cabinets: {
|
||
view: true,
|
||
create: false,
|
||
edit: false,
|
||
delete: false
|
||
},
|
||
accessRequests: {
|
||
approve: false,
|
||
reject: false,
|
||
viewAll: false
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'approver',
|
||
name: 'Approver',
|
||
description: 'Can approve access requests',
|
||
userCount: 2,
|
||
permissions: {
|
||
documents: {
|
||
view: true,
|
||
edit: false,
|
||
delete: false,
|
||
approve: true,
|
||
reject: true,
|
||
download: false
|
||
},
|
||
cabinets: {
|
||
view: true,
|
||
create: false,
|
||
edit: false,
|
||
delete: false
|
||
},
|
||
accessRequests: {
|
||
approve: true,
|
||
reject: true,
|
||
viewAll: true
|
||
}
|
||
}
|
||
}
|
||
];
|
||
} catch (error) {
|
||
console.error('Failed to load roles:', error);
|
||
hasError.value = true;
|
||
errorMessage.value = 'Failed to load roles. Please try again.';
|
||
} finally {
|
||
isLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const switchTab = (tab) => {
|
||
activeTab.value = tab;
|
||
|
||
if (tab === 'users' && users.value.length === 0) {
|
||
loadUsers();
|
||
} else if (tab === 'roles' && roles.value.length === 0) {
|
||
loadRoles();
|
||
}
|
||
};
|
||
|
||
const formatDateTime = (dateString) => {
|
||
if (!dateString) return 'Never';
|
||
|
||
const date = new Date(dateString);
|
||
return date.toLocaleString('en-US', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
};
|
||
|
||
// Icons
|
||
const getSvgIcon = (iconName) => {
|
||
const icons = {
|
||
'users': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>`,
|
||
'roles': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>`,
|
||
'search': `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>`,
|
||
'add': `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
|
||
'edit': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
|
||
'trash': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`
|
||
};
|
||
|
||
return icons[iconName] || '';
|
||
};
|
||
|
||
// Lifecycle hooks
|
||
onMounted(() => {
|
||
loadUsers();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="dms-role-management">
|
||
<LayoutsBreadcrumb />
|
||
|
||
<rs-card class="h-full">
|
||
<template #body>
|
||
<div class="h-full flex flex-col">
|
||
|
||
<!-- Loading State -->
|
||
<div v-if="isLoading" class="flex items-center justify-center h-full">
|
||
<div class="text-center">
|
||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||
<p class="text-gray-600 dark:text-gray-400">Loading role management...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Error State -->
|
||
<div v-else-if="hasError" class="flex items-center justify-center h-full">
|
||
<div class="text-center p-6">
|
||
<div class="text-red-500 text-5xl mb-4">⚠️</div>
|
||
<h2 class="text-xl font-semibold text-red-600 mb-2">Error Loading Data</h2>
|
||
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ errorMessage }}</p>
|
||
<rs-button @click="activeTab === 'users' ? loadUsers() : loadRoles()" variant="primary">
|
||
Retry
|
||
</rs-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content -->
|
||
<div v-else class="h-full flex flex-col">
|
||
<!-- Header with authentik info -->
|
||
<div class="bg-indigo-50 dark:bg-indigo-900/20 border-b border-indigo-200 dark:border-indigo-800">
|
||
<div class="px-6 py-4">
|
||
<div class="flex items-center">
|
||
<div class="w-10 h-10 rounded bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center mr-3">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-600 dark:text-indigo-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<h2 class="text-lg font-medium text-indigo-900 dark:text-indigo-100">Authentik Integration</h2>
|
||
<p class="text-sm text-indigo-600 dark:text-indigo-300">Role-Based Access Control Management</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs Navigation -->
|
||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||
<div class="px-6">
|
||
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
|
||
<button
|
||
v-for="tab in [{ id: 'users', label: 'Users', icon: 'users' }, { id: 'roles', label: 'Roles', icon: 'roles' }]"
|
||
:key="tab.id"
|
||
@click="switchTab(tab.id)"
|
||
class="py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||
:class="[
|
||
activeTab === tab.id
|
||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:border-gray-600'
|
||
]"
|
||
>
|
||
<div class="flex items-center">
|
||
<span v-html="getSvgIcon(tab.icon)" class="mr-2"></span>
|
||
<span>{{ tab.label }}</span>
|
||
</div>
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Action Bar -->
|
||
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4">
|
||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||
<!-- Search -->
|
||
<div class="relative">
|
||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||
<span v-html="getSvgIcon('search')" class="text-gray-400 dark:text-gray-500"></span>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
v-model="searchQuery"
|
||
class="pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:ring-indigo-500 focus:border-indigo-500 w-64"
|
||
placeholder="Search..."
|
||
/>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div>
|
||
<rs-button variant="primary" class="flex items-center">
|
||
<span v-html="getSvgIcon('add')" class="mr-1.5"></span>
|
||
<span>{{ activeTab === 'users' ? 'Add User' : 'Add Role' }}</span>
|
||
</rs-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab Content -->
|
||
<div class="flex-1 p-4 overflow-auto">
|
||
<!-- Users Tab -->
|
||
<div v-if="activeTab === 'users'" class="h-full">
|
||
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||
<thead class="bg-gray-50 dark:bg-gray-900/10">
|
||
<tr>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
User
|
||
</th>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Department
|
||
</th>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Roles
|
||
</th>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Last Login
|
||
</th>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Status
|
||
</th>
|
||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||
<tr v-for="user in filteredUsers" :key="user.id" class="hover:bg-gray-50 dark:hover:bg-gray-900/10">
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<div class="flex items-center">
|
||
<div class="flex-shrink-0 h-10 w-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||
{{ user.name.charAt(0) }}
|
||
</div>
|
||
<div class="ml-4">
|
||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{{ user.name }}
|
||
</div>
|
||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||
{{ user.email }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||
<div class="max-w-xs truncate">{{ user.department }}</div>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<div class="flex flex-wrap gap-1">
|
||
<span
|
||
v-for="role in user.roles"
|
||
:key="role"
|
||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||
:class="{
|
||
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/20 dark:text-indigo-300': role === 'admin',
|
||
'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300': role === 'manager',
|
||
'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300': role === 'engineer',
|
||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300': role === 'finance',
|
||
'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300': role === 'approver'
|
||
}"
|
||
>
|
||
{{ role }}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||
{{ formatDateTime(user.lastLogin) }}
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span
|
||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||
:class="{
|
||
'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300': user.status === 'active',
|
||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300': user.status === 'pending',
|
||
'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300': user.status === 'inactive'
|
||
}"
|
||
>
|
||
{{ user.status }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||
<button class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-3">
|
||
<span v-html="getSvgIcon('edit')"></span>
|
||
</button>
|
||
<button class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
|
||
<span v-html="getSvgIcon('trash')"></span>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Roles Tab -->
|
||
<div v-else-if="activeTab === 'roles'" class="h-full">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div
|
||
v-for="role in roles"
|
||
:key="role.id"
|
||
class="bg-white dark:bg-gray-800 shadow overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700"
|
||
>
|
||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||
<div class="flex justify-between items-start">
|
||
<div>
|
||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ role.name }}</h3>
|
||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ role.description }}</p>
|
||
</div>
|
||
<div class="flex space-x-2">
|
||
<button class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">
|
||
<span v-html="getSvgIcon('edit')"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="px-6 py-4">
|
||
<div class="mb-4">
|
||
<div class="flex justify-between items-center mb-2">
|
||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100">Document Permissions</h4>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="(value, key) in role.permissions.documents"
|
||
:key="`doc-${key}`"
|
||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||
:class="value ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400 line-through'"
|
||
>
|
||
{{ key }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-4">
|
||
<div class="flex justify-between items-center mb-2">
|
||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100">Cabinet Permissions</h4>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="(value, key) in role.permissions.cabinets"
|
||
:key="`cab-${key}`"
|
||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||
:class="value ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400 line-through'"
|
||
>
|
||
{{ key }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="flex justify-between items-center mb-2">
|
||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100">Access Request Permissions</h4>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="(value, key) in role.permissions.accessRequests"
|
||
:key="`acc-${key}`"
|
||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||
:class="value ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400 line-through'"
|
||
>
|
||
{{ key }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-900/10 text-right">
|
||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||
{{ role.userCount }} {{ role.userCount === 1 ? 'user' : 'users' }} with this role
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</rs-card>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.dms-role-management {
|
||
height: calc(100vh - 64px);
|
||
}
|
||
</style> |