Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend development
|
||||||
|
node_modules/
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.cache/
|
||||||
|
.vite/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
/static/
|
||||||
|
|
||||||
|
# Backend development
|
||||||
|
/lib/vendor/
|
||||||
|
coverage/
|
||||||
|
phpunit.xml.cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpstan.cache
|
||||||
|
.phpactor/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
9
composer.json
Normal file
9
composer.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "ktxm/user-manager",
|
||||||
|
"type": "project",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"KTXM\\UserManager\\": "lib/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
lib/Module.php
Normal file
114
lib/Module.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXM\UserManager;
|
||||||
|
|
||||||
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Manager Module
|
||||||
|
*/
|
||||||
|
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{}
|
||||||
|
|
||||||
|
public function handle(): string
|
||||||
|
{
|
||||||
|
return 'user_manager';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'User Manager';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author(): string
|
||||||
|
{
|
||||||
|
return 'Ktrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Administrative user account and role management interface.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function version(): string
|
||||||
|
{
|
||||||
|
return '0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Module Permission
|
||||||
|
'user_manager' => [
|
||||||
|
'label' => 'Access User Manager',
|
||||||
|
'description' => 'View and access the user manager module',
|
||||||
|
'group' => 'User Management'
|
||||||
|
],
|
||||||
|
|
||||||
|
// User Account Management Permissions
|
||||||
|
'user_manager.user.view' => [
|
||||||
|
'label' => 'View Users',
|
||||||
|
'description' => 'View user list and user details',
|
||||||
|
'group' => 'User Account Management'
|
||||||
|
],
|
||||||
|
'user_manager.user.create' => [
|
||||||
|
'label' => 'Create Users',
|
||||||
|
'description' => 'Create new user accounts',
|
||||||
|
'group' => 'User Account Management'
|
||||||
|
],
|
||||||
|
'user_manager.user.edit' => [
|
||||||
|
'label' => 'Edit Users',
|
||||||
|
'description' => 'Edit user details and settings',
|
||||||
|
'group' => 'User Account Management'
|
||||||
|
],
|
||||||
|
'user_manager.user.delete' => [
|
||||||
|
'label' => 'Delete Users',
|
||||||
|
'description' => 'Delete user accounts',
|
||||||
|
'group' => 'User Account Management'
|
||||||
|
],
|
||||||
|
'user_manager.user.*' => [
|
||||||
|
'label' => 'Full User Management',
|
||||||
|
'description' => 'All user management operations',
|
||||||
|
'group' => 'User Account Management'
|
||||||
|
],
|
||||||
|
|
||||||
|
// User Role Management Permissions
|
||||||
|
'user_manager.role.view' => [
|
||||||
|
'label' => 'View Roles',
|
||||||
|
'description' => 'View role list and role details',
|
||||||
|
'group' => 'User Role Management'
|
||||||
|
],
|
||||||
|
'user_manager.role.manage' => [
|
||||||
|
'label' => 'Manage Roles',
|
||||||
|
'description' => 'Create, edit, and delete roles',
|
||||||
|
'group' => 'User Role Management'
|
||||||
|
],
|
||||||
|
'user_manager.role.assign' => [
|
||||||
|
'label' => 'Assign Roles',
|
||||||
|
'description' => 'Assign roles to users',
|
||||||
|
'group' => 'User Role Management'
|
||||||
|
],
|
||||||
|
'user_manager.role.*' => [
|
||||||
|
'label' => 'Full Role Management',
|
||||||
|
'description' => 'All role management operations',
|
||||||
|
'group' => 'User Role Management'
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerBI(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'handle' => $this->handle(),
|
||||||
|
'namespace' => 'UserManager',
|
||||||
|
'version' => $this->version(),
|
||||||
|
'label' => $this->label(),
|
||||||
|
'author' => $this->author(),
|
||||||
|
'description' => $this->description(),
|
||||||
|
'boot' => 'static/module.mjs',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
1526
package-lock.json
generated
Normal file
1526
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "user_manager",
|
||||||
|
"description": "Ktrix User Manager Module - User and Role Administration",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"author": "Ktrix",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --mode production --config vite.config.ts",
|
||||||
|
"dev": "vite build --mode development --config vite.config.ts",
|
||||||
|
"watch": "vite build --mode development --watch --config vite.config.ts",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdi/js": "^7.4.47",
|
||||||
|
"pinia": "^2.3.1",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "^4.5.1",
|
||||||
|
"vuetify": "^3.10.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^7.1.2",
|
||||||
|
"vue-tsc": "^3.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/integrations.ts
Normal file
48
src/integrations.ts
Normal 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
14
src/main.ts
Normal 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
40
src/routes.ts
Normal 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;
|
||||||
122
src/services/roleManagerService.ts
Normal file
122
src/services/roleManagerService.ts
Normal 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();
|
||||||
125
src/services/userManagerService.ts
Normal file
125
src/services/userManagerService.ts
Normal 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
1
src/style.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* user manager module styles */
|
||||||
69
src/types/index.ts
Normal file
69
src/types/index.ts
Normal 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
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>
|
||||||
15
tsconfig.app.json
Normal file
15
tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@KTXC/*": ["../../core/src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node20/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
53
vite.config.ts
Normal file
53
vite.config.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
{
|
||||||
|
name: 'inject-css-filename',
|
||||||
|
enforce: 'post',
|
||||||
|
generateBundle(_options, bundle) {
|
||||||
|
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
|
||||||
|
if (!cssFile) return
|
||||||
|
|
||||||
|
for (const fileName of Object.keys(bundle)) {
|
||||||
|
const chunk = bundle[fileName]
|
||||||
|
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
|
||||||
|
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
|
||||||
|
console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@KTXC': path.resolve(__dirname, '../../core/src')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, 'src/main.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'module.mjs',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['vue', 'vue-router', 'pinia'],
|
||||||
|
output: {
|
||||||
|
assetFileNames: (assetInfo) => {
|
||||||
|
if (assetInfo.name?.endsWith('.css')) {
|
||||||
|
return 'user_manager-[hash].css'
|
||||||
|
}
|
||||||
|
return '[name]-[hash][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user