Initial commit

This commit is contained in:
root
2025-12-23 18:20:45 -05:00
committed by Sebastian Krupinski
commit ef82b32a6d
24 changed files with 4008 additions and 0 deletions

48
src/integrations.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { ModuleIntegrations } from '@KTXC/types/moduleTypes';
const integrations: ModuleIntegrations = {
// Admin settings menu
admin_settings_menu: [
{
id: 'user_manager.accounts',
label: 'Users',
path: '/user/accounts',
icon: 'mdi-account-multiple',
priority: 80,
},
{
id: 'user_manager.roles',
label: 'Roles',
path: '/user/roles',
icon: 'mdi-shield-account',
priority: 81,
},
],
// User detail panels
user_manager_detail_panels: [
{
id: 'user_manager.account',
label: 'Account',
icon: 'mdi-account',
priority: 10,
component: () => import('@/views/AccountPanel.vue'),
},
{
id: 'user_manager.security',
label: 'Security',
icon: 'mdi-shield-lock',
priority: 20,
component: () => import('@/views/SecurityPanel.vue'),
},
{
id: 'user_manager.roles',
label: 'Roles',
icon: 'mdi-shield-account',
priority: 30,
component: () => import('@/views/RolesPanel.vue'),
},
],
};
export default integrations;

14
src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import routes from './routes'
import integrations from './integrations'
import type { App as Vue } from 'vue'
// CSS filename injected by vite plugin
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { routes, integrations }
export default {
install(app: Vue) {
// Module-specific Vue plugins (if needed)
}
}

40
src/routes.ts Normal file
View File

@@ -0,0 +1,40 @@
const routes = [
{
name: 'user-accounts',
path: '/user/accounts',
component: () => import('@/views/AccountList.vue'),
meta: { requiresAuth: true, permission: 'user_manager.user.view' }
},
{
name: 'user-account-detail',
path: '/user/accounts/:uid',
component: () => import('@/views/AccountDetail.vue'),
meta: { requiresAuth: true, permission: 'user_manager.user.modify' }
},
{
name: 'user-account-create',
path: '/user/accounts/create',
component: () => import('@/views/AccountCreate.vue'),
meta: { requiresAuth: true, permission: 'user_manager.user.create' }
},
{
name: 'user-roles',
path: '/user/roles',
component: () => import('@/views/RoleList.vue'),
meta: { requiresAuth: true, permission: 'user_manager.role.view' }
},
{
name: 'user-role-create',
path: '/user/roles/create',
component: () => import('@/views/RoleEditor.vue'),
meta: { requiresAuth: true, permission: 'user_manager.role.manage' }
},
{
name: 'user-role-edit',
path: '/user/roles/edit/:rid',
component: () => import('@/views/RoleEditor.vue'),
meta: { requiresAuth: true, permission: 'user_manager.role.manage' }
}
];
export default routes;

View File

@@ -0,0 +1,122 @@
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper';
import type { ApiResponse, Role } from '@/types';
/**
* Role Manager Service
* API client for role management operations
*/
class RoleManagerService {
private baseUrl = '/user/roles';
private transactionCounter = 0;
private getTransaction(): string {
return `txn-${Date.now()}-${++this.transactionCounter}`;
}
/**
* List all roles
*/
async listRoles(): Promise<Role[]> {
const response = await fetchWrapper.post<ApiResponse<Role[]>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'role.list',
data: {}
},
{ skipLogoutOnError: true }
);
return response.data;
}
/**
* Fetch single role
*/
async fetchRole(rid: string): Promise<Role> {
const response = await fetchWrapper.post<ApiResponse<Role>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'role.fetch',
data: { rid }
}
);
return response.data;
}
/**
* Create new role
*/
async createRole(roleData: {
label: string;
description?: string;
permissions?: string[];
}): Promise<Role> {
const response = await fetchWrapper.post<ApiResponse<Role>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'role.create',
data: roleData
}
);
return response.data;
}
/**
* Update role
*/
async updateRole(rid: string, updates: {
label?: string;
description?: string;
permissions?: string[];
}): Promise<boolean> {
const response = await fetchWrapper.post<ApiResponse<boolean>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'role.update',
data: { rid, ...updates }
}
);
return response.data;
}
/**
* Delete role
*/
async deleteRole(rid: string): Promise<boolean> {
const response = await fetchWrapper.post<ApiResponse<boolean>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'role.delete',
data: { rid }
}
);
return response.data;
}
/**
* Get available permissions (grouped with metadata)
*/
async listPermissions(): Promise<import('@/types').PermissionGroup> {
const response = await fetchWrapper.post<ApiResponse<import('@/types').PermissionGroup>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'permissions.list',
data: {}
}
);
return response.data;
}
}
export const roleManagerService = new RoleManagerService();

View File

@@ -0,0 +1,125 @@
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper';
import type { ApiResponse, User } from '@/types';
/**
* User Manager Service
* API client for user management operations
*/
class UserManagerService {
private baseUrl = '/user/accounts';
private transactionCounter = 0;
private getTransaction(): string {
return `txn-${Date.now()}-${++this.transactionCounter}`;
}
/**
* List all users
*/
async listUsers(filters?: { enabled?: boolean; role?: string }): Promise<User[]> {
const response = await fetchWrapper.post<ApiResponse<User[]>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'user.list',
data: filters || {}
},
{ skipLogoutOnError: true }
);
return response.data;
}
/**
* Fetch single user
*/
async fetchUser(uid: string): Promise<User> {
const response = await fetchWrapper.post<ApiResponse<User>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'user.fetch',
data: { uid }
}
);
return response.data;
}
/**
* Create new user
*/
async createUser(userData: {
identity: string;
label: string;
enabled?: boolean;
roles?: string[];
profile?: Record<string, any>;
}): Promise<User> {
const response = await fetchWrapper.post<ApiResponse<User>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'user.create',
data: userData
}
);
return response.data;
}
/**
* Update user
*/
async updateUser(uid: string, updates: {
label?: string;
enabled?: boolean;
roles?: string[];
profile?: Record<string, any>;
}): Promise<boolean> {
const response = await fetchWrapper.post<ApiResponse<boolean>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'user.update',
data: { uid, ...updates }
}
);
return response.data;
}
/**
* Delete user
*/
async deleteUser(uid: string): Promise<boolean> {
const response = await fetchWrapper.post<ApiResponse<boolean>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'user.delete',
data: { uid }
}
);
return response.data;
}
/**
* Unlink external provider
*/
async unlinkProvider(uid: string): Promise<boolean> {
const response = await fetchWrapper.post<ApiResponse<boolean>>(
`${this.baseUrl}/v1`,
{
version: 1,
transaction: this.getTransaction(),
operation: 'user.provider.unlink',
data: { uid }
}
);
return response.data;
}
}
export const userManagerService = new UserManagerService();

1
src/style.css Normal file
View File

@@ -0,0 +1 @@
/* user manager module styles */

69
src/types/index.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* User Manager Types
*/
export interface User {
uid: string;
tid: string;
identity: string;
label: string;
enabled: boolean;
roles: string[];
permissions?: string[];
provider: string | null;
provider_subject: string | null;
provider_managed_fields: string[];
profile: Record<string, any>;
profile_editable?: Record<string, {
value: any;
editable: boolean;
provider: string | null;
}>;
}
export interface Role {
rid: string;
tid: string;
label: string;
description: string;
permissions: string[];
system: boolean;
user_count?: number;
}
export interface Permission {
label: string;
description: string;
group: string;
module: string;
deprecated?: boolean;
}
export interface PermissionGroup {
[group: string]: {
[permission: string]: Permission;
};
}
export interface ApiRequest {
version: number;
transaction: string;
operation: string;
data?: any;
}
export interface ApiResponse<T = any> {
version: number;
transaction: string;
operation: string;
status: 'success' | 'error';
data: T;
}
export interface UserDetailPanel {
id: string;
label: string;
icon?: string;
priority?: number;
component: () => Promise<any>;
}

322
src/views/AccountCreate.vue Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>