18 Commits

Author SHA1 Message Date
Francisco Gaona
3086f78d34 WIp - manage role permissions per object 2025-12-30 06:16:54 +01:00
Francisco Gaona
d15fc918d1 WIP - field level permission 2025-12-30 05:54:56 +01:00
Francisco Gaona
56c0c3838d WIP - permissions working as expected 2025-12-30 04:50:51 +01:00
Francisco Gaona
9ac69e30d0 WIP - better handling of viewAll modifyAll 2025-12-30 04:43:51 +01:00
Francisco Gaona
d37183ba45 WIp - fix displaying related model names in lookup fields 2025-12-30 04:22:56 +01:00
Francisco Gaona
b4bdeeb9f6 WIP - permissions progress 2025-12-30 03:26:50 +01:00
Francisco Gaona
f4143ab106 WIP - Fix objection and model registry 2025-12-27 06:08:25 +01:00
Francisco Gaona
516e132611 WIP - move docs 2025-12-24 21:46:05 +01:00
Francisco Gaona
c5305490c1 WIP - use objection and working lookup field to owner 2025-12-24 21:43:58 +01:00
Francisco Gaona
4520f94b69 WIP - using objection base model to handle objects operations 2025-12-24 20:18:43 +01:00
Francisco Gaona
e4f1ba96ad WIP - custom migrations when object is created 2025-12-24 19:54:13 +01:00
Francisco Gaona
52c0849de2 WIP - manage tenant users from central 2025-12-24 12:17:22 +01:00
Francisco Gaona
b9fa3bd008 WIP - improve login to tenants by domains 2025-12-24 11:42:44 +01:00
Francisco Gaona
2bc672e4c5 WIP - some fixes 2025-12-24 10:54:19 +01:00
Francisco Gaona
962c84e6d2 WIP - fix lookup field 2025-12-24 00:05:15 +01:00
Francisco Gaona
fc1bec4de7 WIP - related lists and look up field 2025-12-23 23:59:04 +01:00
Francisco Gaona
0275b96014 WIP - central operations 2025-12-23 23:38:45 +01:00
Francisco Gaona
e4f3bad971 WIp - fix login into central 2025-12-23 22:16:58 +01:00
16 changed files with 80 additions and 2199 deletions

View File

@@ -68,7 +68,6 @@ exports.up = function (knex) {
table.timestamp('expiresAt').nullable();
table.timestamp('revokedAt').nullable();
table.timestamp('createdAt').defaultTo(knex.fn.now());
table.timestamp('updatedAt').defaultTo(knex.fn.now());
table
.foreign('objectDefinitionId')

View File

@@ -1,13 +0,0 @@
exports.up = function (knex) {
return knex.schema
.table('record_shares', (table) => {
table.timestamp('updatedAt').defaultTo(knex.fn.now());
});
};
exports.down = function (knex) {
return knex.schema
.table('record_shares', (table) => {
table.dropColumn('updatedAt');
});
};

View File

@@ -9,27 +9,6 @@ export interface RecordShareAccessLevel {
export class RecordShare extends BaseModel {
static tableName = 'record_shares';
// Don't use snake_case mapping since DB columns are already camelCase
static get columnNameMappers() {
return {
parse(obj: any) {
return obj;
},
format(obj: any) {
return obj;
},
};
}
// Don't auto-set timestamps - let DB defaults handle them
$beforeInsert() {
// Don't call super - skip BaseModel's timestamp logic
}
$beforeUpdate() {
// Don't call super - skip BaseModel's timestamp logic
}
id!: string;
objectDefinitionId!: string;
recordId!: string;
@@ -39,7 +18,6 @@ export class RecordShare extends BaseModel {
expiresAt?: Date;
revokedAt?: Date;
createdAt!: Date;
updatedAt!: Date;
static get jsonSchema() {
return {
@@ -59,22 +37,8 @@ export class RecordShare extends BaseModel {
canDelete: { type: 'boolean' },
},
},
expiresAt: {
anyOf: [
{ type: 'string', format: 'date-time' },
{ type: 'null' },
{ type: 'object' } // Allow Date objects
]
},
revokedAt: {
anyOf: [
{ type: 'string', format: 'date-time' },
{ type: 'null' },
{ type: 'object' } // Allow Date objects
]
},
createdAt: { type: ['string', 'object'], format: 'date-time' },
updatedAt: { type: ['string', 'object'], format: 'date-time' },
expiresAt: { type: 'string', format: 'date-time' },
revokedAt: { type: 'string', format: 'date-time' },
},
};
}

View File

@@ -1,19 +0,0 @@
import { IsString, IsBoolean, IsOptional, IsDateString } from 'class-validator';
export class CreateRecordShareDto {
@IsString()
granteeUserId: string;
@IsBoolean()
canRead: boolean;
@IsBoolean()
canEdit: boolean;
@IsBoolean()
canDelete: boolean;
@IsOptional()
@IsDateString()
expiresAt?: string;
}

View File

@@ -3,13 +3,11 @@ import { RbacService } from './rbac.service';
import { AbilityFactory } from './ability.factory';
import { AuthorizationService } from './authorization.service';
import { SetupRolesController } from './setup-roles.controller';
import { SetupUsersController } from './setup-users.controller';
import { RecordSharingController } from './record-sharing.controller';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
controllers: [SetupRolesController, SetupUsersController, RecordSharingController],
controllers: [SetupRolesController],
providers: [RbacService, AbilityFactory, AuthorizationService],
exports: [RbacService, AbilityFactory, AuthorizationService],
})

View File

@@ -1,324 +0,0 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Body,
UseGuards,
ForbiddenException,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { RecordShare } from '../models/record-share.model';
import { ObjectDefinition } from '../models/object-definition.model';
import { User } from '../models/user.model';
import { AuthorizationService } from './authorization.service';
import { CreateRecordShareDto } from './dto/create-record-share.dto';
@Controller('runtime/objects/:objectApiName/records/:recordId/shares')
@UseGuards(JwtAuthGuard)
export class RecordSharingController {
constructor(
private tenantDbService: TenantDatabaseService,
private authService: AuthorizationService,
) {}
@Get()
async getRecordShares(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('recordId') recordId: string,
@CurrentUser() currentUser: any,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get object definition
const objectDef = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDef) {
throw new Error('Object not found');
}
// Get the record to check ownership
const tableName = this.getTableName(objectDef.apiName);
const record = await knex(tableName)
.where({ id: recordId })
.first();
if (!record) {
throw new Error('Record not found');
}
// Only owner can view shares
if (record.ownerId !== currentUser.userId) {
// Check if user has modify all permission
const user: any = await User.query(knex)
.findById(currentUser.userId)
.withGraphFetched('roles.objectPermissions');
if (!user) {
throw new ForbiddenException('User not found');
}
const hasModifyAll = user.roles?.some(role =>
role.objectPermissions?.some(
perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll
)
);
if (!hasModifyAll) {
throw new ForbiddenException('Only the record owner or users with Modify All permission can view shares');
}
}
// Get all active shares for this record
const shares = await RecordShare.query(knex)
.where({ objectDefinitionId: objectDef.id, recordId })
.whereNull('revokedAt')
.where(builder => {
builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date());
})
.withGraphFetched('[granteeUser]')
.orderBy('createdAt', 'desc');
return shares;
}
@Post()
async createRecordShare(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('recordId') recordId: string,
@CurrentUser() currentUser: any,
@Body() data: CreateRecordShareDto,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get object definition
const objectDef = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDef) {
throw new Error('Object not found');
}
// Get the record to check ownership
const tableName = this.getTableName(objectDef.apiName);
const record = await knex(tableName)
.where({ id: recordId })
.first();
if (!record) {
throw new Error('Record not found');
}
// Check if user can share - either owner or has modify permissions
const canShare = await this.canUserShareRecord(
currentUser.userId,
record,
objectDef,
knex,
);
if (!canShare) {
throw new ForbiddenException('You do not have permission to share this record');
}
// Cannot share with self
if (data.granteeUserId === currentUser.userId) {
throw new Error('Cannot share record with yourself');
}
// Check if share already exists
const existingShare = await RecordShare.query(knex)
.where({
objectDefinitionId: objectDef.id,
recordId,
granteeUserId: data.granteeUserId,
})
.whereNull('revokedAt')
.first();
if (existingShare) {
// Update existing share
const updated = await RecordShare.query(knex)
.patchAndFetchById(existingShare.id, {
accessLevel: {
canRead: data.canRead,
canEdit: data.canEdit,
canDelete: data.canDelete,
},
// Convert ISO string to MySQL datetime format
expiresAt: data.expiresAt
? knex.raw('?', [new Date(data.expiresAt).toISOString().slice(0, 19).replace('T', ' ')])
: null,
} as any);
return RecordShare.query(knex)
.findById(updated.id)
.withGraphFetched('[granteeUser]');
}
// Create new share
const share = await RecordShare.query(knex).insertAndFetch({
objectDefinitionId: objectDef.id,
recordId,
granteeUserId: data.granteeUserId,
grantedByUserId: currentUser.userId,
accessLevel: {
canRead: data.canRead,
canEdit: data.canEdit,
canDelete: data.canDelete,
},
// Convert ISO string to MySQL datetime format: YYYY-MM-DD HH:MM:SS
expiresAt: data.expiresAt
? knex.raw('?', [new Date(data.expiresAt).toISOString().slice(0, 19).replace('T', ' ')])
: null,
} as any);
return RecordShare.query(knex)
.findById(share.id)
.withGraphFetched('[granteeUser]');
}
@Delete(':shareId')
async deleteRecordShare(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('recordId') recordId: string,
@Param('shareId') shareId: string,
@CurrentUser() currentUser: any,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get object definition
const objectDef = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDef) {
throw new Error('Object not found');
}
// Get the record to check ownership
const tableName = this.getTableName(objectDef.apiName);
const record = await knex(tableName)
.where({ id: recordId })
.first();
if (!record) {
throw new Error('Record not found');
}
// Only owner can revoke shares
if (record.ownerId !== currentUser.userId) {
// Check if user has modify all permission
const user: any = await User.query(knex)
.findById(currentUser.userId)
.withGraphFetched('roles.objectPermissions');
if (!user) {
throw new ForbiddenException('User not found');
}
const hasModifyAll = user.roles?.some(role =>
role.objectPermissions?.some(
perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll
)
);
if (!hasModifyAll) {
throw new ForbiddenException('Only the record owner or users with Modify All permission can revoke shares');
}
}
// Revoke the share (soft delete)
await RecordShare.query(knex)
.patchAndFetchById(shareId, {
revokedAt: knex.fn.now() as any,
});
return { success: true };
}
private async canUserShareRecord(
userId: string,
record: any,
objectDef: ObjectDefinition,
knex: any,
): Promise<boolean> {
// Owner can always share
if (record.ownerId === userId) {
return true;
}
// Check if user has modify all or edit permissions
const user: any = await User.query(knex)
.findById(userId)
.withGraphFetched('roles.objectPermissions');
if (!user) {
return false;
}
// Check for canModifyAll permission
const hasModifyAll = user.roles?.some(role =>
role.objectPermissions?.some(
perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll
)
);
if (hasModifyAll) {
return true;
}
// Check for canEdit permission (user needs edit to share)
const hasEdit = user.roles?.some(role =>
role.objectPermissions?.some(
perm => perm.objectDefinitionId === objectDef.id && perm.canEdit
)
);
// If user has edit permission, check if they can actually edit this record
// by using the authorization service
if (hasEdit) {
try {
await this.authService.assertCanPerformAction(
'update',
objectDef,
record,
user,
knex,
);
return true;
} catch {
return false;
}
}
return false;
}
private getTableName(apiName: string): string {
// Convert CamelCase to snake_case and pluralize
const snakeCase = apiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
// Simple pluralization
if (snakeCase.endsWith('y')) {
return snakeCase.slice(0, -1) + 'ies';
} else if (snakeCase.endsWith('s')) {
return snakeCase + 'es';
} else {
return snakeCase + 's';
}
}
}

View File

@@ -1,11 +1,6 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -25,117 +20,4 @@ export class SetupRolesController {
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;
}
@Patch(':id')
async updateRole(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() data: { name?: string; description?: string; guardName?: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const updateData: any = {};
if (data.name) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.guardName) updateData.guardName = data.guardName;
const role = await Role.query(knex).patchAndFetchById(id, updateData);
return role;
}
@Delete(':id')
async deleteRole(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Delete role user assignments first
await knex('user_roles').where({ roleId: id }).delete();
// Delete role permissions
await knex('role_permissions').where({ roleId: id }).delete();
await knex('role_object_permissions').where({ roleId: id }).delete();
// Delete the role
await Role.query(knex).deleteById(id);
return { success: true };
}
@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 };
}
}

View File

@@ -1,146 +0,0 @@
import {
Controller,
Get,
Post,
Patch,
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;
}
@Patch(':id')
async updateUser(
@TenantId() tenantId: string,
@Param('id') id: 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);
const updateData: any = {};
if (data.email) updateData.email = data.email;
if (data.firstName !== undefined) updateData.firstName = data.firstName;
if (data.lastName !== undefined) updateData.lastName = data.lastName;
// Hash password if provided
if (data.password) {
updateData.password = await bcrypt.hash(data.password, 10);
}
const user = await User.query(knex).patchAndFetchById(id, updateData);
return user;
}
@Delete(':id')
async deleteUser(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Delete user role assignments first
await knex('user_roles').where({ userId: id }).delete();
// Delete the user
await User.query(knex).deleteById(id);
return { success: true };
}
@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 };
}
}

View File

@@ -105,16 +105,6 @@ const staticMenuItems = [
url: '/setup/objects',
icon: Boxes,
},
{
title: 'Users',
url: '/setup/users',
icon: Users,
},
{
title: 'Roles',
url: '/setup/roles',
icon: Layers,
},
],
},
]

View File

@@ -1,348 +0,0 @@
<template>
<div class="record-sharing space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Sharing</h3>
<p class="text-sm text-muted-foreground">
Grant access to specific users for this record
</p>
</div>
<Button @click="showShareDialog = true" size="sm">
<UserPlus class="h-4 w-4 mr-2" />
Share
</Button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-sm text-destructive">
{{ error }}
</div>
<!-- Shares List -->
<div v-else-if="shares.length > 0" class="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Access</TableHead>
<TableHead>Shared</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="share in shares" :key="share.id">
<TableCell class="font-medium">
{{ getUserName(share.granteeUser) }}
</TableCell>
<TableCell>{{ share.granteeUser.email }}</TableCell>
<TableCell>
<div class="flex gap-1">
<Badge v-if="share.accessLevel.canRead" variant="secondary">Read</Badge>
<Badge v-if="share.accessLevel.canEdit" variant="secondary">Edit</Badge>
<Badge v-if="share.accessLevel.canDelete" variant="secondary">Delete</Badge>
</div>
</TableCell>
<TableCell>{{ formatDate(share.createdAt) }}</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="icon"
@click="removeShare(share.id)"
:disabled="removing === share.id"
>
<Trash2 class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Empty State -->
<div v-else class="text-center py-8 text-muted-foreground border rounded-lg">
<Users class="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>This record is not shared with anyone</p>
<p class="text-sm">Click "Share" to grant access to other users</p>
</div>
<!-- Share Dialog -->
<Dialog v-model:open="showShareDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Share Record</DialogTitle>
<DialogDescription>
Grant access to this record to specific users
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label for="user">User</Label>
<Select v-model="newShare.userId" @update:model-value="(value) => newShare.userId = value">
<SelectTrigger>
<SelectValue placeholder="Select user" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
>
{{ getUserName(user) }} ({{ user.email }})
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-3">
<Label>Permissions</Label>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<Checkbox
id="canRead"
v-model:checked="newShare.canRead"
@update:checked="(value) => newShare.canRead = value"
/>
<label
for="canRead"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Can Read
</label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="canEdit"
v-model:checked="newShare.canEdit"
@update:checked="(value) => newShare.canEdit = value"
/>
<label
for="canEdit"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Can Edit
</label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="canDelete"
v-model:checked="newShare.canDelete"
@update:checked="(value) => newShare.canDelete = value"
/>
<label
for="canDelete"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Can Delete
</label>
</div>
</div>
</div>
<div class="space-y-2">
<Label for="expiresAt">Expires At (Optional)</Label>
<div class="flex gap-2">
<DatePicker
v-model="expiresDate"
placeholder="Select date"
class="flex-1"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showShareDialog = false">Cancel</Button>
<Button
@click="createShare"
:disabled="!newShare.userId || (!newShare.canRead && !newShare.canEdit && !newShare.canDelete) || sharing"
>
{{ sharing ? 'Sharing...' : 'Share' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Badge } from '~/components/ui/badge';
import Checkbox from '~/components/ui/checkbox.vue';
import DatePicker from '~/components/ui/date-picker/DatePicker.vue';
import { UserPlus, Trash2, Users } from 'lucide-vue-next';
interface Props {
objectApiName: string;
recordId: string;
ownerId?: string;
}
const props = defineProps<Props>();
const { api } = useApi();
const { toast } = useToast();
const loading = ref(true);
const sharing = ref(false);
const removing = ref<string | null>(null);
const error = ref<string | null>(null);
const shares = ref<any[]>([]);
const allUsers = ref<any[]>([]);
const showShareDialog = ref(false);
const newShare = ref({
userId: '',
canRead: true,
canEdit: false,
canDelete: false,
expiresAt: '',
});
const expiresDate = ref<Date | null>(null);
const expiresTime = ref('');
// Computed property to combine date and time into ISO string
const combinedExpiresAt = computed(() => {
if (!expiresDate.value) return '';
const date = new Date(expiresDate.value);
if (expiresTime.value) {
const [hours, minutes] = expiresTime.value.split(':');
date.setHours(parseInt(hours), parseInt(minutes), 0, 0);
} else {
date.setHours(23, 59, 59, 999); // Default to end of day
}
return date.toISOString();
});
// Filter out users who already have shares
const availableUsers = computed(() => {
const sharedUserIds = new Set(shares.value.map(s => s.granteeUserId));
return allUsers.value.filter(u => !sharedUserIds.has(u.id));
});
const loadShares = async () => {
try {
loading.value = true;
error.value = null;
const response = await api.get(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`
);
shares.value = response || [];
} catch (e: any) {
console.error('Failed to load shares:', e);
error.value = e.message || 'Failed to load shares';
// If user is not owner, they can't see shares
if (e.message?.includes('owner')) {
error.value = 'Only the record owner can manage sharing';
}
} finally {
loading.value = false;
}
};
const loadUsers = async () => {
try {
const response = await api.get('/setup/users');
allUsers.value = response || [];
} catch (e: any) {
console.error('Failed to load users:', e);
}
};
const createShare = async () => {
try {
sharing.value = true;
const expiresAtValue = combinedExpiresAt.value;
console.log('Creating share, expiresAt value:', expiresAtValue);
const payload: any = {
granteeUserId: newShare.value.userId,
canRead: newShare.value.canRead,
canEdit: newShare.value.canEdit,
canDelete: newShare.value.canDelete,
};
// Only include expiresAt if it has a value
if (expiresAtValue) {
payload.expiresAt = expiresAtValue;
console.log('Including expiresAt in payload:', payload.expiresAt);
} else {
console.log('Skipping expiresAt - no date selected');
}
console.log('Final payload:', payload);
await api.post(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`,
payload
);
toast.success('Record shared successfully');
showShareDialog.value = false;
newShare.value = {
userId: '',
canRead: true,
canEdit: false,
canDelete: false,
expiresAt: '',
};
expiresDate.value = null;
expiresTime.value = '';
await loadShares();
} catch (e: any) {
console.error('Failed to share record:', e);
toast.error(e.message || 'Failed to share record');
} finally {
sharing.value = false;
}
};
const removeShare = async (shareId: string) => {
try {
removing.value = shareId;
await api.delete(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares/${shareId}`
);
toast.success('Share removed successfully');
await loadShares();
} catch (e: any) {
console.error('Failed to remove share:', e);
toast.error(e.message || 'Failed to remove share');
} finally {
removing.value = null;
}
};
const getUserName = (user: any) => {
if (!user) return 'Unknown';
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(async () => {
await Promise.all([loadShares(), loadUsers()]);
});
definePageMeta({
layout: 'default',
});
</script>

View File

@@ -1,33 +0,0 @@
<script setup lang="ts">
import { Check } from 'lucide-vue-next'
import { CheckboxIndicator, CheckboxRoot, type CheckboxRootEmits, type CheckboxRootProps, useForwardPropsEmits } from 'radix-vue'
import { computed, type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-bind="forwarded"
:class="
cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
props.class,
)
"
>
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
<Check class="h-4 w-4" />
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@@ -2,11 +2,9 @@
import { computed, ref, onMounted } 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 FieldRenderer from '@/components/fields/FieldRenderer.vue'
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
import RelatedList from '@/components/RelatedList.vue'
import RecordSharing from '@/components/RecordSharing.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
@@ -22,13 +20,11 @@ interface Props {
loading?: boolean
objectId?: string // For fetching page layout
baseUrl?: string
showSharing?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
baseUrl: '/runtime/objects',
showSharing: true,
})
const emit = defineEmits<{
@@ -134,123 +130,91 @@ const usePageLayout = computed(() => {
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
<!-- Tabs for Details, Related, and Sharing -->
<Tabs v-else default-value="details" class="space-y-6">
<TabsList>
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger v-if="config.relatedLists && config.relatedLists.length > 0" value="related">
Related
</TabsTrigger>
<TabsTrigger v-if="showSharing && data.id" value="sharing">
Sharing
</TabsTrigger>
</TabsList>
<!-- Content with Page Layout -->
<Card v-else-if="usePageLayout">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent>
<PageLayoutRenderer
:fields="config.fields"
:layout="pageLayout"
:model-value="data"
:readonly="true"
/>
</CardContent>
</Card>
<!-- Details Tab -->
<TabsContent value="details" class="space-y-6">
<!-- Content with Page Layout -->
<Card v-if="usePageLayout">
<!-- Traditional Section-based Layout -->
<div v-else class="space-y-6">
<Card v-for="(section, idx) in sections" :key="idx">
<Collapsible
v-if="section.collapsible"
:default-open="!section.defaultCollapsed"
>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent>
<PageLayoutRenderer
:fields="config.fields"
:layout="pageLayout"
:model-value="data"
:readonly="true"
/>
</CardContent>
</Card>
<!-- Traditional Section-based Layout -->
<div v-else class="space-y-6">
<Card v-for="(section, idx) in sections" :key="idx">
<Collapsible
v-if="section.collapsible"
:default-open="!section.defaultCollapsed"
>
<CardHeader>
<CollapsibleTrigger class="flex items-center justify-between w-full hover:bg-muted/50 -m-2 p-2 rounded">
<div>
<CardTitle v-if="section.title">{{ section.title }}</CardTitle>
<CardDescription v-if="section.description">
{{ section.description }}
</CardDescription>
</div>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent>
<div class="grid gap-6 md:grid-cols-2">
<FieldRenderer
v-for="field in getFieldsBySection(section)"
:key="field.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
:base-url="baseUrl"
/>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
<template v-else>
<CardHeader v-if="section.title || section.description">
<CollapsibleTrigger class="flex items-center justify-between w-full hover:bg-muted/50 -m-2 p-2 rounded">
<div>
<CardTitle v-if="section.title">{{ section.title }}</CardTitle>
<CardDescription v-if="section.description">
{{ section.description }}
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid gap-6 md:grid-cols-2">
<FieldRenderer
v-for="field in getFieldsBySection(section)"
:key="field?.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
:base-url="baseUrl"
/>
</div>
</CardContent>
</template>
</Card>
</div>
</TabsContent>
</div>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent>
<div class="grid gap-6 md:grid-cols-2">
<FieldRenderer
v-for="field in getFieldsBySection(section)"
:key="field.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
:base-url="baseUrl"
/>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
<!-- Related Lists Tab -->
<TabsContent value="related" class="space-y-6">
<div v-if="config.relatedLists && config.relatedLists.length > 0">
<RelatedList
v-for="relatedList in config.relatedLists"
:key="relatedList.relationName"
:config="relatedList"
:parent-id="data.id"
:related-records="data[relatedList.relationName]"
@navigate="(objectApiName, recordId) => emit('navigate', objectApiName, recordId)"
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
/>
</div>
</TabsContent>
<!-- Sharing Tab -->
<TabsContent value="sharing">
<Card>
<CardContent class="pt-6">
<RecordSharing
v-if="data.id && config.objectApiName"
:object-api-name="config.objectApiName"
:record-id="data.id"
:owner-id="data.ownerId"
/>
<template v-else>
<CardHeader v-if="section.title || section.description">
<CardTitle v-if="section.title">{{ section.title }}</CardTitle>
<CardDescription v-if="section.description">
{{ section.description }}
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid gap-6 md:grid-cols-2">
<FieldRenderer
v-for="field in getFieldsBySection(section)"
:key="field?.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
:base-url="baseUrl"
/>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</template>
</Card>
</div>
<!-- Related Lists -->
<div v-if="config.relatedLists && config.relatedLists.length > 0" class="space-y-6">
<RelatedList
v-for="relatedList in config.relatedLists"
:key="relatedList.relationName"
:config="relatedList"
:parent-id="data.id"
:related-records="data[relatedList.relationName]"
@navigate="(objectApiName, recordId) => emit('navigate', objectApiName, recordId)"
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
/>
</div>
</div>
</template>

View File

@@ -1,231 +0,0 @@
<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>

View File

@@ -1,285 +0,0 @@
<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>
<div class="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" @click="navigateTo(`/setup/roles/${role.id}`)">
<Eye class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" @click="openEditDialog(role)">
<Edit class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" @click="openDeleteDialog(role)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</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>
<!-- Edit Role Dialog -->
<Dialog v-model:open="showEditDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Role</DialogTitle>
<DialogDescription>
Update role information
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label for="edit-name">Name</Label>
<Input id="edit-name" v-model="editRole.name" placeholder="Role name" />
</div>
<div class="space-y-2">
<Label for="edit-description">Description</Label>
<Input id="edit-description" v-model="editRole.description" placeholder="Role description" />
</div>
<div class="space-y-2">
<Label for="edit-guardName">Guard Name</Label>
<Select v-model="editRole.guardName" @update:model-value="(value) => editRole.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="showEditDialog = false">Cancel</Button>
<Button @click="updateRole" :disabled="!editRole.name">
Update Role
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog v-model:open="showDeleteDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Role</DialogTitle>
<DialogDescription>
Are you sure you want to delete this role? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="showDeleteDialog = false">Cancel</Button>
<Button variant="destructive" @click="deleteRole">
Delete 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, Edit, Trash2 } 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 showEditDialog = ref(false);
const showDeleteDialog = ref(false);
const newRole = ref({
name: '',
description: '',
guardName: 'tenant',
});
const editRole = ref({
id: '',
name: '',
description: '',
guardName: 'tenant',
});
const roleToDelete = ref<any>(null);
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 openEditDialog = (role: any) => {
editRole.value = {
id: role.id,
name: role.name,
description: role.description || '',
guardName: role.guardName || 'tenant',
};
showEditDialog.value = true;
};
const updateRole = async () => {
try {
await api.patch(`/setup/roles/${editRole.value.id}`, {
name: editRole.value.name,
description: editRole.value.description,
guardName: editRole.value.guardName,
});
toast.success('Role updated successfully');
showEditDialog.value = false;
await loadRoles();
} catch (error: any) {
console.error('Failed to update role:', error);
toast.error(error.message || 'Failed to update role');
}
};
const openDeleteDialog = (role: any) => {
roleToDelete.value = role;
showDeleteDialog.value = true;
};
const deleteRole = async () => {
try {
await api.delete(`/setup/roles/${roleToDelete.value.id}`);
toast.success('Role deleted successfully');
showDeleteDialog.value = false;
roleToDelete.value = null;
await loadRoles();
} catch (error: any) {
console.error('Failed to delete role:', error);
toast.error(error.message || 'Failed to delete role');
}
};
const formatDate = (date: string) => {
if (!date) return 'N/A';
return new Date(date).toLocaleDateString();
};
onMounted(() => {
loadRoles();
});
</script>

View File

@@ -1,227 +0,0 @@
<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>

View File

@@ -1,290 +0,0 @@
<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>
<div class="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" @click="navigateTo(`/setup/users/${user.id}`)">
<Eye class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" @click="openEditDialog(user)">
<Edit class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" @click="openDeleteDialog(user)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</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>
<!-- Edit User Dialog -->
<Dialog v-model:open="showEditDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>
Update user information
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label for="edit-email">Email</Label>
<Input id="edit-email" v-model="editUser.email" type="email" placeholder="user@example.com" />
</div>
<div class="space-y-2">
<Label for="edit-password">Password (leave blank to keep current)</Label>
<Input id="edit-password" v-model="editUser.password" type="password" placeholder="••••••••" />
</div>
<div class="space-y-2">
<Label for="edit-firstName">First Name</Label>
<Input id="edit-firstName" v-model="editUser.firstName" placeholder="John" />
</div>
<div class="space-y-2">
<Label for="edit-lastName">Last Name</Label>
<Input id="edit-lastName" v-model="editUser.lastName" placeholder="Doe" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showEditDialog = false">Cancel</Button>
<Button @click="updateUser" :disabled="!editUser.email">
Update User
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog v-model:open="showDeleteDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogDescription>
Are you sure you want to delete this user? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="showDeleteDialog = false">Cancel</Button>
<Button variant="destructive" @click="deleteUser">
Delete 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, Edit, Trash2 } from 'lucide-vue-next';
const { api } = useApi();
const { toast } = useToast();
const loading = ref(true);
const users = ref<any[]>([]);
const showCreateDialog = ref(false);
const showEditDialog = ref(false);
const showDeleteDialog = ref(false);
const newUser = ref({
email: '',
password: '',
firstName: '',
lastName: '',
});
const editUser = ref({
id: '',
email: '',
password: '',
firstName: '',
lastName: '',
});
const userToDelete = ref<any>(null);
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 openEditDialog = (user: any) => {
editUser.value = {
id: user.id,
email: user.email,
password: '',
firstName: user.firstName || '',
lastName: user.lastName || '',
};
showEditDialog.value = true;
};
const updateUser = async () => {
try {
const payload: any = {
email: editUser.value.email,
firstName: editUser.value.firstName,
lastName: editUser.value.lastName,
};
if (editUser.value.password) {
payload.password = editUser.value.password;
}
await api.patch(`/setup/users/${editUser.value.id}`, payload);
toast.success('User updated successfully');
showEditDialog.value = false;
await loadUsers();
} catch (error: any) {
console.error('Failed to update user:', error);
toast.error(error.message || 'Failed to update user');
}
};
const openDeleteDialog = (user: any) => {
userToDelete.value = user;
showDeleteDialog.value = true;
};
const deleteUser = async () => {
try {
await api.delete(`/setup/users/${userToDelete.value.id}`);
toast.success('User deleted successfully');
showDeleteDialog.value = false;
userToDelete.value = null;
await loadUsers();
} catch (error: any) {
console.error('Failed to delete user:', error);
toast.error(error.message || 'Failed to delete 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>