WIP - admin users and roles
This commit is contained in:
@@ -3,11 +3,12 @@ import { RbacService } from './rbac.service';
|
|||||||
import { AbilityFactory } from './ability.factory';
|
import { AbilityFactory } from './ability.factory';
|
||||||
import { AuthorizationService } from './authorization.service';
|
import { AuthorizationService } from './authorization.service';
|
||||||
import { SetupRolesController } from './setup-roles.controller';
|
import { SetupRolesController } from './setup-roles.controller';
|
||||||
|
import { SetupUsersController } from './setup-users.controller';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TenantModule],
|
imports: [TenantModule],
|
||||||
controllers: [SetupRolesController],
|
controllers: [SetupRolesController, SetupUsersController],
|
||||||
providers: [RbacService, AbilityFactory, AuthorizationService],
|
providers: [RbacService, AbilityFactory, AuthorizationService],
|
||||||
exports: [RbacService, AbilityFactory, AuthorizationService],
|
exports: [RbacService, AbilityFactory, AuthorizationService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
@@ -20,4 +24,77 @@ export class SetupRolesController {
|
|||||||
|
|
||||||
return await Role.query(knex).select('*').orderBy('name', 'asc');
|
return await Role.query(knex).select('*').orderBy('name', 'asc');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getRole(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
return await Role.query(knex).findById(id).withGraphFetched('users');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createRole(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body() data: { name: string; description?: string; guardName?: string },
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
const role = await Role.query(knex).insert({
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
guardName: data.guardName || 'tenant',
|
||||||
|
});
|
||||||
|
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':roleId/users')
|
||||||
|
async addUserToRole(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('roleId') roleId: string,
|
||||||
|
@Body() data: { userId: string },
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
// Check if assignment already exists
|
||||||
|
const existing = await knex('user_roles')
|
||||||
|
.where({ userId: data.userId, roleId })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { success: true, message: 'User already assigned' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex('user_roles').insert({
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
userId: data.userId,
|
||||||
|
roleId,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':roleId/users/:userId')
|
||||||
|
async removeUserFromRole(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('roleId') roleId: string,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
await knex('user_roles')
|
||||||
|
.where({ userId, roleId })
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
backend/src/rbac/setup-users.controller.ts
Normal file
104
backend/src/rbac/setup-users.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { User } from '../models/user.model';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
@Controller('setup/users')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class SetupUsersController {
|
||||||
|
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getUsers(@TenantId() tenantId: string) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
return await User.query(knex).withGraphFetched('roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getUser(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
return await User.query(knex).findById(id).withGraphFetched('roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createUser(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body() data: { email: string; password: string; firstName?: string; lastName?: string },
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||||
|
|
||||||
|
const user = await User.query(knex).insert({
|
||||||
|
email: data.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':userId/roles')
|
||||||
|
async addRoleToUser(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() data: { roleId: string },
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
// Check if assignment already exists
|
||||||
|
const existing = await knex('user_roles')
|
||||||
|
.where({ userId, roleId: data.roleId })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { success: true, message: 'Role already assigned' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex('user_roles').insert({
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
userId,
|
||||||
|
roleId: data.roleId,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':userId/roles/:roleId')
|
||||||
|
async removeRoleFromUser(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Param('roleId') roleId: string,
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
await knex('user_roles')
|
||||||
|
.where({ userId, roleId })
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,16 @@ const staticMenuItems = [
|
|||||||
url: '/setup/objects',
|
url: '/setup/objects',
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Users',
|
||||||
|
url: '/setup/users',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Roles',
|
||||||
|
url: '/setup/roles',
|
||||||
|
icon: Layers,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
231
frontend/pages/setup/roles/[id].vue
Normal file
231
frontend/pages/setup/roles/[id].vue
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" @click="navigateTo('/setup/roles')" class="mb-2">
|
||||||
|
← Back to Roles
|
||||||
|
</Button>
|
||||||
|
<h1 class="text-3xl font-bold">{{ role?.name || 'Role' }}</h1>
|
||||||
|
<p class="text-muted-foreground">{{ role?.description || 'No description' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs v-else default-value="details" class="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
|
<TabsTrigger value="users">Users</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="details" class="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Role Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Name</Label>
|
||||||
|
<p class="font-medium">{{ role?.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Guard</Label>
|
||||||
|
<Badge variant="outline">{{ role?.guardName || 'tenant' }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<Label class="text-muted-foreground">Description</Label>
|
||||||
|
<p class="font-medium">{{ role?.description || 'No description' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Created At</Label>
|
||||||
|
<p class="font-medium">{{ formatDate(role?.createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Updated At</Label>
|
||||||
|
<p class="font-medium">{{ formatDate(role?.updatedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="users" class="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Assigned Users</CardTitle>
|
||||||
|
<CardDescription>Manage user assignments for this role</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button @click="showAddUserDialog = true" size="sm">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="roleUsers.length === 0" class="text-center py-8 text-muted-foreground">
|
||||||
|
No users assigned. Add users to grant them this role.
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="user in roleUsers"
|
||||||
|
:key="user.id"
|
||||||
|
class="flex items-center justify-between p-3 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ getUserName(user) }}</p>
|
||||||
|
<p class="text-sm text-muted-foreground">{{ user.email }}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" @click="removeUser(user.id)">
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- Add User Dialog -->
|
||||||
|
<Dialog v-model:open="showAddUserDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select a user to assign this role
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Available Users</Label>
|
||||||
|
<Select v-model="selectedUserId" @update:model-value="(value) => selectedUserId = value">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choose a user" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="user in availableUsers" :key="user.id" :value="user.id">
|
||||||
|
{{ getUserName(user) }} ({{ user.email }})
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showAddUserDialog = false">Cancel</Button>
|
||||||
|
<Button @click="addUser" :disabled="!selectedUserId">
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '~/components/ui/dialog';
|
||||||
|
import { Label } from '~/components/ui/label';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
||||||
|
import { Plus, X } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const { api } = useApi();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const role = ref<any>(null);
|
||||||
|
const roleUsers = ref<any[]>([]);
|
||||||
|
const allUsers = ref<any[]>([]);
|
||||||
|
const showAddUserDialog = ref(false);
|
||||||
|
const selectedUserId = ref('');
|
||||||
|
|
||||||
|
const availableUsers = computed(() => {
|
||||||
|
const assignedIds = new Set(roleUsers.value.map(u => u.id));
|
||||||
|
return allUsers.value.filter(u => !assignedIds.has(u.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadRole = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const roleId = route.params.id;
|
||||||
|
const response = await api.get(`/setup/roles/${roleId}`);
|
||||||
|
role.value = response;
|
||||||
|
roleUsers.value = response.users || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load role:', error);
|
||||||
|
toast.error('Failed to load role');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAllUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/setup/users');
|
||||||
|
allUsers.value = response || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load users:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUser = async () => {
|
||||||
|
if (!selectedUserId.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/setup/roles/${route.params.id}/users`, {
|
||||||
|
userId: selectedUserId.value,
|
||||||
|
});
|
||||||
|
toast.success('User added successfully');
|
||||||
|
showAddUserDialog.value = false;
|
||||||
|
selectedUserId.value = '';
|
||||||
|
await loadRole();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to add user:', error);
|
||||||
|
toast.error(error.message || 'Failed to add user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUser = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/setup/roles/${route.params.id}/users/${userId}`);
|
||||||
|
toast.success('User removed successfully');
|
||||||
|
await loadRole();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to remove user:', error);
|
||||||
|
toast.error(error.message || 'Failed to remove user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserName = (user: any) => {
|
||||||
|
if (!user) return 'Unknown';
|
||||||
|
if (user.firstName || user.lastName) {
|
||||||
|
return [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
return user.email || 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
if (!date) return 'N/A';
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadRole(), loadAllUsers()]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
166
frontend/pages/setup/roles/index.vue
Normal file
166
frontend/pages/setup/roles/index.vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold">Roles</h1>
|
||||||
|
<p class="text-muted-foreground">Manage roles and permissions</p>
|
||||||
|
</div>
|
||||||
|
<Button @click="showCreateDialog = true">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
New Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead>Guard</TableHead>
|
||||||
|
<TableHead>Users</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead class="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-if="loading">
|
||||||
|
<TableCell :colspan="6" class="text-center py-8">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-else-if="roles.length === 0">
|
||||||
|
<TableCell :colspan="6" class="text-center py-8 text-muted-foreground">
|
||||||
|
No roles found. Create your first role to get started.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-else v-for="role in roles" :key="role.id" class="cursor-pointer hover:bg-muted/50" @click="navigateTo(`/setup/roles/${role.id}`)">
|
||||||
|
<TableCell class="font-medium">{{ role.name }}</TableCell>
|
||||||
|
<TableCell>{{ role.description || 'No description' }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{{ role.guardName || 'tenant' }}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{{ role.userCount || 0 }} users
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ formatDate(role.createdAt) }}</TableCell>
|
||||||
|
<TableCell class="text-right" @click.stop>
|
||||||
|
<Button variant="ghost" size="icon" @click="navigateTo(`/setup/roles/${role.id}`)">
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Role Dialog -->
|
||||||
|
<Dialog v-model:open="showCreateDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Role</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new role to the system
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="name">Name</Label>
|
||||||
|
<Input id="name" v-model="newRole.name" placeholder="Sales Manager" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="description">Description (Optional)</Label>
|
||||||
|
<Input id="description" v-model="newRole.description" placeholder="Manages sales team and deals" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="guardName">Guard Name</Label>
|
||||||
|
<Select v-model="newRole.guardName" @update:model-value="(value) => newRole.guardName = value">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select guard" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tenant">Tenant</SelectItem>
|
||||||
|
<SelectItem value="central">Central</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showCreateDialog = false">Cancel</Button>
|
||||||
|
<Button @click="createRole" :disabled="!newRole.name">
|
||||||
|
Create Role
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/ui/table';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '~/components/ui/dialog';
|
||||||
|
import { Input } from '~/components/ui/input';
|
||||||
|
import { Label } from '~/components/ui/label';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
||||||
|
import { Plus, Eye } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { api } = useApi();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const roles = ref<any[]>([]);
|
||||||
|
const showCreateDialog = ref(false);
|
||||||
|
const newRole = ref({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
guardName: 'tenant',
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadRoles = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const response = await api.get('/setup/roles');
|
||||||
|
roles.value = response || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load roles:', error);
|
||||||
|
toast.error('Failed to load roles');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRole = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/setup/roles', newRole.value);
|
||||||
|
toast.success('Role created successfully');
|
||||||
|
showCreateDialog.value = false;
|
||||||
|
newRole.value = { name: '', description: '', guardName: 'tenant' };
|
||||||
|
await loadRoles();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to create role:', error);
|
||||||
|
toast.error(error.message || 'Failed to create role');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
if (!date) return 'N/A';
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRoles();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
227
frontend/pages/setup/users/[id].vue
Normal file
227
frontend/pages/setup/users/[id].vue
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" @click="navigateTo('/setup/users')" class="mb-2">
|
||||||
|
← Back to Users
|
||||||
|
</Button>
|
||||||
|
<h1 class="text-3xl font-bold">{{ getUserName(user) }}</h1>
|
||||||
|
<p class="text-muted-foreground">{{ user?.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs v-else default-value="details" class="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
|
<TabsTrigger value="roles">Roles</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="details" class="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Email</Label>
|
||||||
|
<p class="font-medium">{{ user?.email }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">First Name</Label>
|
||||||
|
<p class="font-medium">{{ user?.firstName || 'N/A' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Last Name</Label>
|
||||||
|
<p class="font-medium">{{ user?.lastName || 'N/A' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Created At</Label>
|
||||||
|
<p class="font-medium">{{ formatDate(user?.createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Updated At</Label>
|
||||||
|
<p class="font-medium">{{ formatDate(user?.updatedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="roles" class="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Assigned Roles</CardTitle>
|
||||||
|
<CardDescription>Manage role assignments for this user</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button @click="showAddRoleDialog = true" size="sm">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
Add Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="userRoles.length === 0" class="text-center py-8 text-muted-foreground">
|
||||||
|
No roles assigned. Add roles to grant permissions.
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="role in userRoles"
|
||||||
|
:key="role.id"
|
||||||
|
class="flex items-center justify-between p-3 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ role.name }}</p>
|
||||||
|
<p class="text-sm text-muted-foreground">{{ role.description || 'No description' }}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" @click="removeRole(role.id)">
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- Add Role Dialog -->
|
||||||
|
<Dialog v-model:open="showAddRoleDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Role</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select a role to assign to this user
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Available Roles</Label>
|
||||||
|
<Select v-model="selectedRoleId" @update:model-value="(value) => selectedRoleId = value">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choose a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="role in availableRoles" :key="role.id" :value="role.id">
|
||||||
|
{{ role.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showAddRoleDialog = false">Cancel</Button>
|
||||||
|
<Button @click="addRole" :disabled="!selectedRoleId">
|
||||||
|
Add Role
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '~/components/ui/dialog';
|
||||||
|
import { Label } from '~/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
||||||
|
import { Plus, X } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const { api } = useApi();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const user = ref<any>(null);
|
||||||
|
const userRoles = ref<any[]>([]);
|
||||||
|
const allRoles = ref<any[]>([]);
|
||||||
|
const showAddRoleDialog = ref(false);
|
||||||
|
const selectedRoleId = ref('');
|
||||||
|
|
||||||
|
const availableRoles = computed(() => {
|
||||||
|
const assignedIds = new Set(userRoles.value.map(r => r.id));
|
||||||
|
return allRoles.value.filter(r => !assignedIds.has(r.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadUser = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const userId = route.params.id;
|
||||||
|
const response = await api.get(`/setup/users/${userId}`);
|
||||||
|
user.value = response;
|
||||||
|
userRoles.value = response.roles || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load user:', error);
|
||||||
|
toast.error('Failed to load user');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAllRoles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/setup/roles');
|
||||||
|
allRoles.value = response || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load roles:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRole = async () => {
|
||||||
|
if (!selectedRoleId.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/setup/users/${route.params.id}/roles`, {
|
||||||
|
roleId: selectedRoleId.value,
|
||||||
|
});
|
||||||
|
toast.success('Role added successfully');
|
||||||
|
showAddRoleDialog.value = false;
|
||||||
|
selectedRoleId.value = '';
|
||||||
|
await loadUser();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to add role:', error);
|
||||||
|
toast.error(error.message || 'Failed to add role');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRole = async (roleId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/setup/users/${route.params.id}/roles/${roleId}`);
|
||||||
|
toast.success('Role removed successfully');
|
||||||
|
await loadUser();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to remove role:', error);
|
||||||
|
toast.error(error.message || 'Failed to remove role');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserName = (user: any) => {
|
||||||
|
if (!user) return 'User';
|
||||||
|
if (user.firstName || user.lastName) {
|
||||||
|
return [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
return user.email || 'User';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
if (!date) return 'N/A';
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadUser(), loadAllRoles()]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
169
frontend/pages/setup/users/index.vue
Normal file
169
frontend/pages/setup/users/index.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold">Users</h1>
|
||||||
|
<p class="text-muted-foreground">Manage user accounts and access</p>
|
||||||
|
</div>
|
||||||
|
<Button @click="showCreateDialog = true">
|
||||||
|
<UserPlus class="mr-2 h-4 w-4" />
|
||||||
|
New User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Roles</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead class="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-if="loading">
|
||||||
|
<TableCell :colspan="5" class="text-center py-8">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-else-if="users.length === 0">
|
||||||
|
<TableCell :colspan="5" class="text-center py-8 text-muted-foreground">
|
||||||
|
No users found. Create your first user to get started.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-else v-for="user in users" :key="user.id" class="cursor-pointer hover:bg-muted/50" @click="navigateTo(`/setup/users/${user.id}`)">
|
||||||
|
<TableCell class="font-medium">{{ getUserName(user) }}</TableCell>
|
||||||
|
<TableCell>{{ user.email }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex gap-1 flex-wrap">
|
||||||
|
<Badge v-for="role in user.roles" :key="role.id" variant="secondary">
|
||||||
|
{{ role.name }}
|
||||||
|
</Badge>
|
||||||
|
<span v-if="!user.roles || user.roles.length === 0" class="text-muted-foreground text-sm">
|
||||||
|
No roles
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ formatDate(user.createdAt) }}</TableCell>
|
||||||
|
<TableCell class="text-right" @click.stop>
|
||||||
|
<Button variant="ghost" size="icon" @click="navigateTo(`/setup/users/${user.id}`)">
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create User Dialog -->
|
||||||
|
<Dialog v-model:open="showCreateDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new user to the system
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="email">Email</Label>
|
||||||
|
<Input id="email" v-model="newUser.email" type="email" placeholder="user@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="password">Password</Label>
|
||||||
|
<Input id="password" v-model="newUser.password" type="password" placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="firstName">First Name (Optional)</Label>
|
||||||
|
<Input id="firstName" v-model="newUser.firstName" placeholder="John" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="lastName">Last Name (Optional)</Label>
|
||||||
|
<Input id="lastName" v-model="newUser.lastName" placeholder="Doe" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showCreateDialog = false">Cancel</Button>
|
||||||
|
<Button @click="createUser" :disabled="!newUser.email || !newUser.password">
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/ui/table';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '~/components/ui/dialog';
|
||||||
|
import { Input } from '~/components/ui/input';
|
||||||
|
import { Label } from '~/components/ui/label';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { UserPlus, Eye } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
|
||||||
|
const { api } = useApi();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const users = ref<any[]>([]);
|
||||||
|
const showCreateDialog = ref(false);
|
||||||
|
const newUser = ref({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const response = await api.get('/setup/users');
|
||||||
|
users.value = response || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load users:', error);
|
||||||
|
toast.error('Failed to load users');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUser = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/setup/users', newUser.value);
|
||||||
|
toast.success('User created successfully');
|
||||||
|
showCreateDialog.value = false;
|
||||||
|
newUser.value = { email: '', password: '', firstName: '', lastName: '' };
|
||||||
|
await loadUsers();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to create user:', error);
|
||||||
|
toast.error(error.message || 'Failed to create user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserName = (user: any) => {
|
||||||
|
if (user.firstName || user.lastName) {
|
||||||
|
return [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
return user.email;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
if (!date) return 'N/A';
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user