Initial commit
This commit is contained in:
322
src/views/AccountCreate.vue
Normal file
322
src/views/AccountCreate.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { userManagerService } from '@/services/userManagerService';
|
||||
import { roleManagerService } from '@/services/roleManagerService';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import type { Role } from '@/types';
|
||||
|
||||
interface CredentialPanel {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
priority: number;
|
||||
component: any;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const integrationStore = useIntegrationStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const roles = ref<Role[]>([]);
|
||||
const loadingRoles = ref(false);
|
||||
|
||||
const currentStep = ref(1);
|
||||
const createdUser = ref<any | null>(null);
|
||||
const credentialPanels = ref<CredentialPanel[]>([]);
|
||||
|
||||
const form = ref({
|
||||
identity: '',
|
||||
label: '',
|
||||
enabled: true,
|
||||
roles: [] as string[]
|
||||
});
|
||||
|
||||
const canProceedToStep2 = computed(() => {
|
||||
return createdUser.value !== null;
|
||||
});
|
||||
|
||||
const goBack = () => {
|
||||
if (currentStep.value === 2 && createdUser.value) {
|
||||
// Allow going back to edit user details
|
||||
currentStep.value = 1;
|
||||
} else {
|
||||
router.push({ name: 'user_manager.user-accounts' });
|
||||
}
|
||||
};
|
||||
|
||||
const loadRoles = async () => {
|
||||
loadingRoles.value = true;
|
||||
try {
|
||||
roles.value = await roleManagerService.listRoles();
|
||||
} catch (err: any) {
|
||||
// Non-fatal: user can still be created without roles
|
||||
console.error('Failed to load roles:', err);
|
||||
} finally {
|
||||
loadingRoles.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadCredentialPanels = async () => {
|
||||
const integrations = integrationStore.getItems('user_manager_create_credentials');
|
||||
const components = [];
|
||||
|
||||
for (const panel of integrations) {
|
||||
try {
|
||||
if (panel.component) {
|
||||
const module = await panel.component();
|
||||
components.push({
|
||||
id: panel.id,
|
||||
label: panel.label || panel.id,
|
||||
icon: panel.icon,
|
||||
priority: panel.priority ?? 100,
|
||||
component: module.default
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UserManager] Failed to load credential panel component for', panel.id, ':', error);
|
||||
}
|
||||
}
|
||||
|
||||
credentialPanels.value = components.sort((a, b) => a.priority - b.priority);
|
||||
};
|
||||
|
||||
const createUser = async () => {
|
||||
error.value = null;
|
||||
|
||||
const identity = form.value.identity.trim();
|
||||
const label = form.value.label.trim();
|
||||
if (!identity || !label) {
|
||||
error.value = 'Identity and Display Name are required';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const created = await userManagerService.createUser({
|
||||
identity,
|
||||
label,
|
||||
enabled: form.value.enabled,
|
||||
roles: form.value.roles
|
||||
});
|
||||
|
||||
createdUser.value = created;
|
||||
|
||||
// If there are credential panels, move to step 2, otherwise finish
|
||||
if (credentialPanels.value.length > 0) {
|
||||
currentStep.value = 2;
|
||||
} else {
|
||||
router.push({ name: 'user_manager.user-account-detail', params: { uid: created.uid } });
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to create user';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const skipCredentials = () => {
|
||||
if (createdUser.value) {
|
||||
router.push({ name: 'user_manager.user-account-detail', params: { uid: createdUser.value.uid } });
|
||||
}
|
||||
};
|
||||
|
||||
const finishSetup = () => {
|
||||
if (createdUser.value) {
|
||||
router.push({ name: 'user_manager.user-account-detail', params: { uid: createdUser.value.uid } });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles();
|
||||
loadCredentialPanels();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VContainer fluid>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCard class="mb-4">
|
||||
<VCardTitle class="d-flex align-center">
|
||||
<VBtn
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
@click="goBack"
|
||||
/>
|
||||
<VIcon icon="mdi-account-plus" class="mx-2" />
|
||||
<span>Create User</span>
|
||||
</VCardTitle>
|
||||
</VCard>
|
||||
|
||||
<VAlert
|
||||
v-if="error"
|
||||
type="error"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="error = null"
|
||||
>
|
||||
{{ error }}
|
||||
</VAlert>
|
||||
|
||||
<!-- Stepper -->
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VStepper v-model="currentStep" alt-labels>
|
||||
<VStepperHeader>
|
||||
<VStepperItem
|
||||
:complete="currentStep > 1"
|
||||
:value="1"
|
||||
title="User Details"
|
||||
subtitle="Basic information"
|
||||
/>
|
||||
<VDivider />
|
||||
<VStepperItem
|
||||
:value="2"
|
||||
:disabled="!canProceedToStep2"
|
||||
title="Credentials"
|
||||
subtitle="Optional setup"
|
||||
/>
|
||||
</VStepperHeader>
|
||||
|
||||
<VStepperWindow>
|
||||
<!-- Step 1: User Details -->
|
||||
<VStepperWindowItem :value="1">
|
||||
<VForm @submit.prevent="createUser">
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="form.identity"
|
||||
label="Identity (Email/Username)"
|
||||
prepend-inner-icon="mdi-at"
|
||||
variant="outlined"
|
||||
required
|
||||
:disabled="createdUser !== null"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="form.label"
|
||||
label="Display Name"
|
||||
prepend-inner-icon="mdi-account"
|
||||
variant="outlined"
|
||||
required
|
||||
:disabled="createdUser !== null"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="form.enabled"
|
||||
label="Account Enabled"
|
||||
color="primary"
|
||||
hide-details
|
||||
:disabled="createdUser !== null"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="form.roles"
|
||||
label="Roles"
|
||||
:items="roles.map(r => ({ title: r.label, value: r.rid }))"
|
||||
:loading="loadingRoles"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
hide-details
|
||||
:disabled="createdUser !== null"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VRow class="mt-4">
|
||||
<VCol cols="12" class="d-flex justify-end gap-2">
|
||||
<VBtn
|
||||
v-if="!createdUser"
|
||||
type="submit"
|
||||
color="primary"
|
||||
prepend-icon="mdi-account-plus"
|
||||
:loading="loading"
|
||||
>
|
||||
Create User
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else
|
||||
color="primary"
|
||||
@click="currentStep = 2"
|
||||
>
|
||||
Next: Setup Credentials
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VStepperWindowItem>
|
||||
|
||||
<!-- Step 2: Credential Setup -->
|
||||
<VStepperWindowItem :value="2">
|
||||
<div v-if="credentialPanels.length === 0" class="text-center py-8">
|
||||
<VIcon icon="mdi-information" size="48" color="grey" class="mb-4" />
|
||||
<p class="text-body-1 mb-4">No credential setup options available.</p>
|
||||
<VBtn color="primary" @click="finishSetup">
|
||||
Finish
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<VAlert type="info" class="mb-4">
|
||||
<strong>Optional:</strong> You can set up authentication credentials now, or skip this step and configure them later.
|
||||
</VAlert>
|
||||
|
||||
<VRow>
|
||||
<VCol
|
||||
v-for="panel in credentialPanels"
|
||||
:key="panel.id"
|
||||
cols="12"
|
||||
>
|
||||
<component
|
||||
:is="panel.component"
|
||||
:user="createdUser"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VRow class="mt-4">
|
||||
<VCol cols="12" class="d-flex justify-space-between">
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
@click="currentStep = 1"
|
||||
>
|
||||
Back
|
||||
</VBtn>
|
||||
<div class="d-flex gap-2">
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
@click="skipCredentials"
|
||||
>
|
||||
Skip
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
prepend-icon="mdi-check"
|
||||
@click="finishSetup"
|
||||
>
|
||||
Finish
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VStepperWindowItem>
|
||||
</VStepperWindow>
|
||||
</VStepper>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VContainer>
|
||||
</template>
|
||||
139
src/views/AccountDetail.vue
Normal file
139
src/views/AccountDetail.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { userManagerService } from '@/services/userManagerService';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import type { User, UserDetailPanel } from '@/types';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const integrationStore = useIntegrationStore();
|
||||
|
||||
const user = ref<User | null>(null);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const activeTab = ref(0);
|
||||
const panels = ref<UserDetailPanel[]>([]);
|
||||
|
||||
const uid = computed(() => route.params.uid as string);
|
||||
|
||||
// Load panels from integration store and resolve component promises
|
||||
const loadPanels = async () => {
|
||||
const integrations = integrationStore.getItems('user_manager_detail_panels');
|
||||
const components = [];
|
||||
|
||||
for (const panel of integrations) {
|
||||
try {
|
||||
if (panel.component) {
|
||||
const module = await panel.component();
|
||||
components.push({
|
||||
id: panel.id,
|
||||
label: panel.label || panel.id,
|
||||
icon: panel.icon,
|
||||
priority: panel.priority ?? 100,
|
||||
component: module.default
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UserManager] Failed to load component for', panel.id, ':', error);
|
||||
}
|
||||
}
|
||||
|
||||
panels.value = components.sort((a, b) => a.priority - b.priority);
|
||||
};
|
||||
|
||||
const loadUser = async () => {
|
||||
if (!uid.value) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
user.value = await userManagerService.fetchUser(uid.value);
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load user';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.push({ name: 'user_manager.user-accounts' });
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPanels();
|
||||
await loadUser();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VContainer fluid>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<!-- Header -->
|
||||
<VCard class="mb-4">
|
||||
<VCardTitle class="d-flex align-center">
|
||||
<VBtn
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
@click="goBack"
|
||||
/>
|
||||
<VIcon icon="mdi-account" class="mx-2" />
|
||||
<span v-if="user">{{ user.label }}</span>
|
||||
<span v-else>Loading...</span>
|
||||
</VCardTitle>
|
||||
</VCard>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<VAlert
|
||||
v-if="error"
|
||||
type="error"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="error = null"
|
||||
>
|
||||
{{ error }}
|
||||
</VAlert>
|
||||
|
||||
<!-- Loading State -->
|
||||
<VCard v-if="loading">
|
||||
<VCardText class="text-center py-8">
|
||||
<VProgressCircular indeterminate color="primary" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- User Detail Tabs -->
|
||||
<VCard v-else-if="user && panels.length > 0">
|
||||
<VTabs v-model="activeTab">
|
||||
<VTab
|
||||
v-for="panel in panels"
|
||||
:key="panel.id"
|
||||
:value="panel.id"
|
||||
>
|
||||
<VIcon v-if="panel.icon" :icon="panel.icon" class="mr-2" size="small" />
|
||||
{{ panel.label }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<VCardText class="pa-6">
|
||||
<VWindow v-model="activeTab">
|
||||
<VWindowItem
|
||||
v-for="panel in panels"
|
||||
:key="panel.id"
|
||||
:value="panel.id"
|
||||
>
|
||||
<component
|
||||
:is="panel.component"
|
||||
:user="user"
|
||||
@update="loadUser"
|
||||
/>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VContainer>
|
||||
</template>
|
||||
276
src/views/AccountList.vue
Normal file
276
src/views/AccountList.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { userManagerService } from '@/services/userManagerService';
|
||||
import { roleManagerService } from '@/services/roleManagerService';
|
||||
import type { User, Role } from '@/types';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const roles = ref<Role[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const search = ref('');
|
||||
const filterEnabled = ref<boolean | null>(null);
|
||||
const filterRole = ref<string | null>(null);
|
||||
|
||||
const headers = [
|
||||
{ title: 'User', key: 'label', sortable: true },
|
||||
{ title: 'Identity', key: 'identity', sortable: true },
|
||||
{ title: 'Provider', key: 'provider', sortable: true },
|
||||
{ title: 'Roles', key: 'roles', sortable: false },
|
||||
{ title: 'Status', key: 'enabled', sortable: true },
|
||||
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' }
|
||||
];
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
let filtered = users.value;
|
||||
|
||||
if (search.value) {
|
||||
const searchLower = search.value.toLowerCase();
|
||||
filtered = filtered.filter(user =>
|
||||
user.label.toLowerCase().includes(searchLower) ||
|
||||
user.identity.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
if (filterEnabled.value !== null) {
|
||||
filtered = filtered.filter(user => user.enabled === filterEnabled.value);
|
||||
}
|
||||
|
||||
if (filterRole.value) {
|
||||
filtered = filtered.filter(user => user.roles.includes(filterRole.value!));
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const roleMap = computed(() => {
|
||||
const map = new Map<string, Role>();
|
||||
roles.value.forEach(role => map.set(role.rid, role));
|
||||
return map;
|
||||
});
|
||||
|
||||
const getProviderIcon = (provider: string | null) => {
|
||||
if (!provider) return null;
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
'oidc': 'mdi-shield-key',
|
||||
'password': 'mdi-lock',
|
||||
'totp': 'mdi-two-factor-authentication'
|
||||
};
|
||||
|
||||
return icons[provider] || 'mdi-shield-key';
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: string | null) => {
|
||||
if (!provider) return 'Local';
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
'oidc': 'OIDC',
|
||||
'password': 'Password',
|
||||
'totp': 'TOTP'
|
||||
};
|
||||
|
||||
return labels[provider] || provider.toUpperCase();
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const [usersData, rolesData] = await Promise.all([
|
||||
userManagerService.listUsers(),
|
||||
roleManagerService.listRoles()
|
||||
]);
|
||||
|
||||
users.value = usersData;
|
||||
roles.value = rolesData;
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load user data:', err);
|
||||
error.value = err.message || 'Failed to load data';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createUser = () => {
|
||||
router.push({ name: 'user_manager.user-account-create' });
|
||||
};
|
||||
|
||||
const editUser = (user: User) => {
|
||||
router.push({ name: 'user_manager.user-account-detail', params: { uid: user.uid } });
|
||||
};
|
||||
|
||||
const deleteUser = async (user: User) => {
|
||||
if (!confirm(`Are you sure you want to delete user "${user.label}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userManagerService.deleteUser(user.uid);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to delete user';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VContainer fluid>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center">
|
||||
<VIcon icon="mdi-account-multiple" class="mr-2" />
|
||||
User Management
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="createUser"
|
||||
>
|
||||
Create User
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
<!-- Filters -->
|
||||
<VRow class="mb-4">
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="search"
|
||||
label="Search users"
|
||||
density="compact"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="3">
|
||||
<VSelect
|
||||
v-model="filterEnabled"
|
||||
label="Status"
|
||||
:items="[
|
||||
{ title: 'All', value: null },
|
||||
{ title: 'Enabled', value: true },
|
||||
{ title: 'Disabled', value: false }
|
||||
]"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="3">
|
||||
<VSelect
|
||||
v-model="filterRole"
|
||||
label="Role"
|
||||
:items="[
|
||||
{ title: 'All Roles', value: null },
|
||||
...roles.map(r => ({ title: r.label, value: r.rid }))
|
||||
]"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<VAlert
|
||||
v-if="error"
|
||||
type="error"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="error = null"
|
||||
>
|
||||
{{ error }}
|
||||
</VAlert>
|
||||
|
||||
<!-- User Table -->
|
||||
<VDataTable
|
||||
:headers="headers"
|
||||
:items="filteredUsers"
|
||||
:loading="loading"
|
||||
item-value="uid"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template #item.label="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar
|
||||
size="32"
|
||||
:color="item.enabled ? 'primary' : 'grey'"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ item.label.charAt(0).toUpperCase() }}
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="font-weight-medium">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.provider="{ item }">
|
||||
<VChip
|
||||
v-if="item.provider"
|
||||
size="small"
|
||||
:prepend-icon="getProviderIcon(item.provider)"
|
||||
>
|
||||
{{ getProviderLabel(item.provider) }}
|
||||
</VChip>
|
||||
<VChip v-else size="small" color="grey-lighten-2">
|
||||
Local
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.roles="{ item }">
|
||||
<VChip
|
||||
v-for="roleId in item.roles"
|
||||
:key="roleId"
|
||||
size="small"
|
||||
class="mr-1"
|
||||
>
|
||||
{{ roleMap.get(roleId)?.label || roleId }}
|
||||
</VChip>
|
||||
<span v-if="item.roles.length === 0" class="text-grey">
|
||||
No roles
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.enabled="{ item }">
|
||||
<VChip
|
||||
:color="item.enabled ? 'success' : 'error'"
|
||||
size="small"
|
||||
>
|
||||
{{ item.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<VBtn
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="editUser(item)"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="deleteUser(item)"
|
||||
/>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VContainer>
|
||||
</template>
|
||||
210
src/views/AccountPanel.vue
Normal file
210
src/views/AccountPanel.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { userManagerService } from '@/services/userManagerService';
|
||||
import type { User } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const success = ref<string | null>(null);
|
||||
|
||||
const formData = ref({
|
||||
label: props.user.label,
|
||||
identity: props.user.identity,
|
||||
enabled: props.user.enabled,
|
||||
profile: { ...(typeof props.user.profile === 'object' && !Array.isArray(props.user.profile) && props.user.profile ? props.user.profile : {}) }
|
||||
});
|
||||
|
||||
const normalizedProfile = computed<Record<string, any>>(() => {
|
||||
const profile: any = props.user.profile;
|
||||
if (!profile || Array.isArray(profile) || typeof profile !== 'object') return {};
|
||||
return profile;
|
||||
});
|
||||
|
||||
const isLocalAccount = computed(() => !props.user.provider);
|
||||
|
||||
// Standard profile fields that should always be shown
|
||||
const standardFields = ['email', 'phone', 'first_name', 'last_name'];
|
||||
|
||||
const profileFields = computed(() => {
|
||||
const profile = normalizedProfile.value;
|
||||
const managed = new Set<string>((props.user.provider_managed_fields ?? []) as string[]);
|
||||
|
||||
// Always show standard fields, plus any additional fields from the profile
|
||||
const allKeys = new Set([...standardFields, ...Object.keys(profile)]);
|
||||
|
||||
return Array.from(allKeys).map((key) => ({
|
||||
key,
|
||||
value: profile[key] ?? '',
|
||||
// Local accounts: all fields editable
|
||||
// External accounts: field is editable only if NOT in provider_managed_fields
|
||||
editable: isLocalAccount.value ? true : !managed.has(key),
|
||||
provider: managed.has(key) ? props.user.provider : null
|
||||
}));
|
||||
});
|
||||
|
||||
const hasLockedFields = computed(() => {
|
||||
return profileFields.value.some(f => !f.editable);
|
||||
});
|
||||
|
||||
const saveChanges = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
success.value = null;
|
||||
|
||||
try {
|
||||
// Build profile updates (only editable fields)
|
||||
const profileUpdates: Record<string, any> = {};
|
||||
profileFields.value.forEach(field => {
|
||||
if (field.editable && formData.value.profile[field.key] !== undefined) {
|
||||
profileUpdates[field.key] = formData.value.profile[field.key];
|
||||
}
|
||||
});
|
||||
|
||||
await userManagerService.updateUser(props.user.uid, {
|
||||
label: formData.value.label,
|
||||
enabled: formData.value.enabled,
|
||||
profile: profileUpdates
|
||||
});
|
||||
|
||||
success.value = 'User profile updated successfully';
|
||||
emit('update');
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to update user';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VForm @submit.prevent="saveChanges">
|
||||
<VAlert
|
||||
v-if="error"
|
||||
type="error"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="error = null"
|
||||
>
|
||||
{{ error }}
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-if="success"
|
||||
type="success"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="success = null"
|
||||
>
|
||||
{{ success }}
|
||||
</VAlert>
|
||||
|
||||
<div class="profile-form-container">
|
||||
|
||||
<!-- Basic Info -->
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="formData.label"
|
||||
label="Display Name"
|
||||
prepend-inner-icon="mdi-account"
|
||||
variant="outlined"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="formData.identity"
|
||||
label="Identity (Email/Username)"
|
||||
variant="outlined"
|
||||
disabled
|
||||
hint="Identity cannot be changed"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="formData.enabled"
|
||||
label="Account Enabled"
|
||||
color="primary"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VDivider class="my-6" />
|
||||
|
||||
<!-- Provider Info -->
|
||||
<VRow v-if="user.provider">
|
||||
<VCol cols="12">
|
||||
<VAlert type="info" icon="mdi-shield-key">
|
||||
<div class="font-weight-medium mb-2">
|
||||
External Provider: {{ user.provider.toUpperCase() }}
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
Fields marked with <VIcon icon="mdi-shield-key" size="x-small" class="mx-1" /> are managed by the external identity provider and cannot be edited here.
|
||||
</div>
|
||||
</VAlert>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Profile Fields -->
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<div class="text-h6 mb-4">Profile Information</div>
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-for="field in profileFields"
|
||||
:key="field.key"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="formData.profile[field.key]"
|
||||
:label="field.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())"
|
||||
variant="outlined"
|
||||
:disabled="!field.editable"
|
||||
:hint="field.editable ? '' : `Managed by ${field.provider}`"
|
||||
:persistent-hint="!field.editable"
|
||||
>
|
||||
<template v-if="!field.editable" #append-inner>
|
||||
<VIcon icon="mdi-shield-key" size="small" color="warning" />
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<VRow class="mt-4">
|
||||
<VCol cols="12" class="d-flex justify-end">
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="loading"
|
||||
prepend-icon="mdi-content-save"
|
||||
>
|
||||
Save Changes
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-form-container {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
348
src/views/RoleEditor.vue
Normal file
348
src/views/RoleEditor.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { roleManagerService } from '@/services/roleManagerService';
|
||||
import type { Role, PermissionGroup } from '@/types';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const role = ref<Role | null>(null);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const success = ref<string | null>(null);
|
||||
|
||||
const availablePermissions = ref<PermissionGroup>({});
|
||||
|
||||
const isEditMode = computed(() => !!route.params.rid);
|
||||
const rid = computed(() => route.params.rid as string);
|
||||
|
||||
const formData = ref({
|
||||
label: '',
|
||||
description: '',
|
||||
permissions: [] as string[]
|
||||
});
|
||||
|
||||
const loadRole = async () => {
|
||||
if (!isEditMode.value) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
role.value = await roleManagerService.fetchRole(rid.value);
|
||||
formData.value = {
|
||||
label: role.value.label,
|
||||
description: role.value.description,
|
||||
permissions: [...role.value.permissions]
|
||||
};
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load role';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const permissionGroups = computed(() => {
|
||||
return Object.keys(availablePermissions.value).sort();
|
||||
});
|
||||
|
||||
const isPermissionSelected = (permission: string) => {
|
||||
return formData.value.permissions.includes(permission);
|
||||
};
|
||||
|
||||
const togglePermission = (permission: string) => {
|
||||
const index = formData.value.permissions.indexOf(permission);
|
||||
if (index > -1) {
|
||||
formData.value.permissions.splice(index, 1);
|
||||
} else {
|
||||
formData.value.permissions.push(permission);
|
||||
}
|
||||
};
|
||||
|
||||
const isGroupExpanded = ref<Record<string, boolean>>({});
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
isGroupExpanded.value[group] = !isGroupExpanded.value[group];
|
||||
};
|
||||
|
||||
const loadPermissions = async () => {
|
||||
try {
|
||||
availablePermissions.value = await roleManagerService.listPermissions();
|
||||
// Expand all groups by default
|
||||
permissionGroups.value.forEach(group => {
|
||||
isGroupExpanded.value[group] = true;
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Ignore error, permissions list is optional
|
||||
}
|
||||
};
|
||||
|
||||
const saveRole = async () => {
|
||||
saving.value = true;
|
||||
error.value = null;
|
||||
success.value = null;
|
||||
|
||||
try {
|
||||
if (isEditMode.value) {
|
||||
await roleManagerService.updateRole(rid.value, formData.value);
|
||||
success.value = 'Role updated successfully';
|
||||
} else {
|
||||
await roleManagerService.createRole(formData.value);
|
||||
success.value = 'Role created successfully';
|
||||
setTimeout(() => {
|
||||
goBack();
|
||||
}, 1000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || `Failed to ${isEditMode.value ? 'update' : 'create'} role`;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.push({ name: 'user_manager.user-roles' });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadPermissions();
|
||||
if (isEditMode.value) {
|
||||
loadRole();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VContainer fluid class="role-editor-container">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<!-- Header -->
|
||||
<div class="mb-4 pa-4">
|
||||
<div class="d-flex align-center text-h5">
|
||||
<VBtn
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
@click="goBack"
|
||||
/>
|
||||
<VIcon icon="mdi-shield-account" class="mx-2" />
|
||||
<span v-if="isEditMode && role">{{ role.label }}</span>
|
||||
<span v-else-if="isEditMode">Loading...</span>
|
||||
<span v-else>Create New Role</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error/Success Alerts -->
|
||||
<VAlert
|
||||
v-if="error"
|
||||
type="error"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="error = null"
|
||||
>
|
||||
{{ error }}
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-if="success"
|
||||
type="success"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="success = null"
|
||||
>
|
||||
{{ success }}
|
||||
</VAlert>
|
||||
|
||||
<!-- System Role Warning -->
|
||||
<VAlert
|
||||
v-if="isEditMode && role?.system"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
>
|
||||
This is a system role and cannot be modified.
|
||||
</VAlert>
|
||||
|
||||
<!-- Role Form -->
|
||||
<div class="role-card-container">
|
||||
<div class="role-card">
|
||||
<div class="role-card-body">
|
||||
<VForm @submit.prevent="saveRole">
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VTextField
|
||||
v-model="formData.label"
|
||||
label="Role Name"
|
||||
variant="outlined"
|
||||
:disabled="role?.system"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="8">
|
||||
<VTextarea
|
||||
v-model="formData.description"
|
||||
label="Description"
|
||||
variant="outlined"
|
||||
:disabled="role?.system"
|
||||
rows="3"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="8">
|
||||
<VDivider class="my-4" />
|
||||
<div class="text-h6 mb-2">Permissions</div>
|
||||
<div class="text-body-2 text-grey mb-4">
|
||||
Select permissions to grant to this role. Permissions are organized by module and feature.
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="8">
|
||||
<div class="permissions-card">
|
||||
<div class="pa-4">
|
||||
<div class="permissions-container">
|
||||
<div v-if="permissionGroups.length === 0" class="text-center py-4 text-grey">
|
||||
No permissions available
|
||||
</div>
|
||||
|
||||
<div v-for="group in permissionGroups" :key="group" class="mb-4">
|
||||
<div
|
||||
class="d-flex align-center cursor-pointer pa-2 rounded hover-bg"
|
||||
@click="toggleGroup(group)"
|
||||
>
|
||||
<VIcon
|
||||
:icon="isGroupExpanded[group] ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="font-weight-medium">{{ group }}</span>
|
||||
<VSpacer />
|
||||
<VChip size="x-small" color="grey">
|
||||
{{ Object.keys(availablePermissions[group]).length }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<VExpandTransition>
|
||||
<div v-show="isGroupExpanded[group]" class="ml-8 mt-2">
|
||||
<div
|
||||
v-for="(meta, permission) in availablePermissions[group]"
|
||||
:key="permission"
|
||||
class="mb-2"
|
||||
>
|
||||
<VCheckbox
|
||||
:model-value="isPermissionSelected(permission as string)"
|
||||
:disabled="role?.system"
|
||||
hide-details
|
||||
density="compact"
|
||||
@update:model-value="togglePermission(permission as string)"
|
||||
>
|
||||
<template #label>
|
||||
<div>
|
||||
<div class="d-flex align-center">
|
||||
<span>{{ meta.label }}</span>
|
||||
<VChip
|
||||
v-if="meta.deprecated"
|
||||
size="x-small"
|
||||
color="warning"
|
||||
class="ml-2"
|
||||
>
|
||||
Deprecated
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ meta.description }}
|
||||
</div>
|
||||
<div class="text-caption text-grey mt-1">
|
||||
<code class="text-caption">{{ permission }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
</div>
|
||||
|
||||
<VDivider class="my-4" />
|
||||
|
||||
<div class="d-flex align-center">
|
||||
<VIcon icon="mdi-information" size="small" class="mr-2" color="info" />
|
||||
<span class="text-body-2">
|
||||
{{ formData.permissions.length }} permission(s) selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<VAlert v-if="formData.permissions.length === 0" type="warning" class="mt-4">
|
||||
This role has no permissions assigned. Users with this role will have no access.
|
||||
</VAlert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<!-- User Count (Edit Mode Only) -->
|
||||
<VCol v-if="isEditMode && role" cols="12" md="8">
|
||||
<VDivider class="my-4" />
|
||||
<div class="info-card pa-4">
|
||||
<div class="text-subtitle-2 mb-2">Role Assignment</div>
|
||||
<div class="text-body-2">
|
||||
This role is assigned to <strong>{{ role.user_count || 0 }}</strong> user(s).
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<!-- Actions -->
|
||||
<VCol cols="12" md="8">
|
||||
<VDivider class="my-4" />
|
||||
<div class="d-flex justify-end gap-2 role-card-actions">
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
@click="goBack"
|
||||
>
|
||||
Cancel
|
||||
</VBtn>
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
:disabled="role?.system || !formData.label"
|
||||
prepend-icon="mdi-content-save"
|
||||
>
|
||||
{{ isEditMode ? 'Update Role' : 'Create Role' }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-editor-container {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.role-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.permissions-card {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
188
src/views/RoleList.vue
Normal file
188
src/views/RoleList.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { roleManagerService } from '@/services/roleManagerService';
|
||||
import type { Role } from '@/types';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const roles = ref<Role[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const headers = [
|
||||
{ title: 'Role', key: 'label', sortable: true },
|
||||
{ title: 'Description', key: 'description', sortable: false },
|
||||
{ title: 'Permissions', key: 'permissions', sortable: false },
|
||||
{ title: 'Users', key: 'user_count', sortable: true },
|
||||
{ title: 'Type', key: 'system', sortable: true },
|
||||
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' }
|
||||
];
|
||||
|
||||
const loadRoles = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
roles.value = await roleManagerService.listRoles();
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load roles';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createRole = () => {
|
||||
router.push({ name: 'user_manager.user-role-create' });
|
||||
};
|
||||
|
||||
const editRole = (role: Role) => {
|
||||
router.push({ name: 'user_manager.user-role-edit', params: { rid: role.rid } });
|
||||
};
|
||||
|
||||
const deleteRole = async (role: Role) => {
|
||||
if (role.system) {
|
||||
error.value = 'System roles cannot be deleted';
|
||||
return;
|
||||
}
|
||||
|
||||
if (role.user_count && role.user_count > 0) {
|
||||
error.value = `Cannot delete role assigned to ${role.user_count} user(s)`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete role "${role.label}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await roleManagerService.deleteRole(role.rid);
|
||||
await loadRoles();
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to delete role';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VContainer fluid>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center">
|
||||
<VIcon icon="mdi-shield-account" class="mr-2" />
|
||||
Role Management
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="createRole"
|
||||
>
|
||||
Create Role
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
<!-- Error Alert -->
|
||||
<VAlert
|
||||
v-if="error"
|
||||
type="error"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="error = null"
|
||||
>
|
||||
{{ error }}
|
||||
</VAlert>
|
||||
|
||||
<!-- Roles Table -->
|
||||
<VDataTable
|
||||
:headers="headers"
|
||||
:items="roles"
|
||||
:loading="loading"
|
||||
item-value="rid"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template #item.label="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<VIcon
|
||||
icon="mdi-shield-account"
|
||||
:color="item.system ? 'primary' : 'grey'"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-weight-medium">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.description="{ item }">
|
||||
<span class="text-grey">
|
||||
{{ item.description || 'No description' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.permissions="{ item }">
|
||||
<VChip
|
||||
v-if="item.permissions.length > 0"
|
||||
size="small"
|
||||
prepend-icon="mdi-shield-check"
|
||||
>
|
||||
{{ item.permissions.length }} permission(s)
|
||||
</VChip>
|
||||
<span v-else class="text-grey">
|
||||
No permissions
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.user_count="{ item }">
|
||||
<VChip size="small">
|
||||
{{ item.user_count || 0 }} user(s)
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.system="{ item }">
|
||||
<VChip
|
||||
v-if="item.system"
|
||||
size="small"
|
||||
color="info"
|
||||
>
|
||||
System
|
||||
</VChip>
|
||||
<VChip
|
||||
v-else
|
||||
size="small"
|
||||
color="grey-lighten-2"
|
||||
>
|
||||
Custom
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<VBtn
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="item.system"
|
||||
@click="editRole(item)"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
:disabled="item.system || (item.user_count && item.user_count > 0)"
|
||||
@click="deleteRole(item)"
|
||||
/>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VContainer>
|
||||
</template>
|
||||
204
src/views/RolesPanel.vue
Normal file
204
src/views/RolesPanel.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { userManagerService } from '@/services/userManagerService';
|
||||
import { roleManagerService } from '@/services/roleManagerService';
|
||||
import type { User, Role } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const loadingRoles = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const success = ref<string | null>(null);
|
||||
|
||||
const availableRoles = ref<Role[]>([]);
|
||||
const selectedRoles = ref<string[]>([...props.user.roles]);
|
||||
|
||||
const loadRoles = async () => {
|
||||
loadingRoles.value = true;
|
||||
|
||||
try {
|
||||
availableRoles.value = await roleManagerService.listRoles();
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load roles';
|
||||
} finally {
|
||||
loadingRoles.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveChanges = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
success.value = null;
|
||||
|
||||
try {
|
||||
await userManagerService.updateUser(props.user.uid, {
|
||||
roles: selectedRoles.value
|
||||
});
|
||||
|
||||
success.value = 'User roles updated successfully';
|
||||
emit('update');
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to update roles';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VAlert
|
||||
v-if="error"
|
||||
type="error"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="error = null"
|
||||
>
|
||||
{{ error }}
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-if="success"
|
||||
type="success"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="success = null"
|
||||
>
|
||||
{{ success }}
|
||||
</VAlert>
|
||||
|
||||
<div class="roles-panel-container">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<div class="text-h6 mb-4">
|
||||
<VIcon icon="mdi-shield-account" class="mr-2" />
|
||||
Role Assignment
|
||||
</div>
|
||||
<p class="text-body-2 text-grey mb-4">
|
||||
Assign roles to grant permissions to this user. Multiple roles can be assigned.
|
||||
</p>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="8">
|
||||
<VCard variant="outlined">
|
||||
<VCardText>
|
||||
<VAutocomplete
|
||||
v-model="selectedRoles"
|
||||
:items="availableRoles"
|
||||
item-title="label"
|
||||
item-value="rid"
|
||||
label="Assigned Roles"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:loading="loadingRoles"
|
||||
variant="outlined"
|
||||
hint="Select one or more roles"
|
||||
persistent-hint
|
||||
>
|
||||
<template #chip="{ props, item }">
|
||||
<VChip
|
||||
v-bind="props"
|
||||
:text="item.raw.label"
|
||||
:disabled="item.raw.system"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item="{ props, item }">
|
||||
<VListItem v-bind="props" :title="item.raw.label">
|
||||
<template #subtitle>
|
||||
<div class="text-caption">
|
||||
{{ item.raw.description || 'No description' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey mt-1">
|
||||
{{ item.raw.permissions.length }} permission(s)
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="item.raw.system" #append>
|
||||
<VChip size="x-small" color="info">
|
||||
System
|
||||
</VChip>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VAutocomplete>
|
||||
|
||||
<VDivider class="my-4" />
|
||||
|
||||
<!-- Current Permissions Preview -->
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 mb-2">Effective Permissions</div>
|
||||
<div v-if="user.permissions && user.permissions.length > 0">
|
||||
<VChip
|
||||
v-for="permission in user.permissions"
|
||||
:key="permission"
|
||||
size="small"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ permission }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div v-else class="text-grey text-caption">
|
||||
No permissions assigned
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
:loading="loading"
|
||||
:disabled="JSON.stringify(selectedRoles) === JSON.stringify(user.roles)"
|
||||
prepend-icon="mdi-content-save"
|
||||
@click="saveChanges"
|
||||
>
|
||||
Save Changes
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Role Details Sidebar -->
|
||||
<VCol cols="12" md="4">
|
||||
<VCard variant="outlined">
|
||||
<VCardTitle class="text-subtitle-1">
|
||||
Selected Roles
|
||||
</VCardTitle>
|
||||
<VCardText>
|
||||
<div v-if="selectedRoles.length === 0" class="text-grey text-caption">
|
||||
No roles selected
|
||||
</div>
|
||||
<div v-for="roleId in selectedRoles" :key="roleId" class="mb-3">
|
||||
<div class="font-weight-medium">
|
||||
{{ availableRoles.find(r => r.rid === roleId)?.label }}
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ availableRoles.find(r => r.rid === roleId)?.description }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.roles-panel-container {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
100
src/views/SecurityPanel.vue
Normal file
100
src/views/SecurityPanel.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import type { User } from '@/types';
|
||||
|
||||
interface SecurityPanel {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
priority: number;
|
||||
component: any;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [];
|
||||
}>();
|
||||
|
||||
const integrationStore = useIntegrationStore();
|
||||
const panels = ref<SecurityPanel[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// Load panels from integration store and resolve component promises
|
||||
const loadPanels = async () => {
|
||||
const integrations = integrationStore.getItems('user_manager_security_panels');
|
||||
const components = [];
|
||||
|
||||
for (const panel of integrations) {
|
||||
try {
|
||||
if (panel.component) {
|
||||
const module = await panel.component();
|
||||
components.push({
|
||||
id: panel.id,
|
||||
label: panel.label || panel.id,
|
||||
icon: panel.icon,
|
||||
priority: panel.priority ?? 100,
|
||||
component: module.default
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UserManager] Failed to load security panel component for', panel.id, ':', error);
|
||||
}
|
||||
}
|
||||
|
||||
panels.value = components.sort((a, b) => a.priority - b.priority);
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
emit('update');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPanels();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VRow v-if="loading" class="mb-4">
|
||||
<VCol cols="12">
|
||||
<VProgressCircular indeterminate color="primary" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VRow v-else-if="panels.length === 0">
|
||||
<VCol cols="12">
|
||||
<VAlert type="info">
|
||||
No security management options are available.
|
||||
</VAlert>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div v-else class="security-panels-container">
|
||||
<VRow>
|
||||
<VCol
|
||||
v-for="panel in panels"
|
||||
:key="panel.id"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<component
|
||||
:is="panel.component"
|
||||
:user="user"
|
||||
@update="handleUpdate"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.security-panels-container {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user