Compare commits
16 Commits
feature/sa
...
56c0c3838d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56c0c3838d | ||
|
|
9ac69e30d0 | ||
|
|
d37183ba45 | ||
|
|
b4bdeeb9f6 | ||
|
|
f4143ab106 | ||
|
|
516e132611 | ||
|
|
c5305490c1 | ||
|
|
4520f94b69 | ||
|
|
e4f1ba96ad | ||
|
|
52c0849de2 | ||
|
|
b9fa3bd008 | ||
|
|
2bc672e4c5 | ||
|
|
962c84e6d2 | ||
|
|
fc1bec4de7 | ||
|
|
0275b96014 | ||
|
|
e4f3bad971 |
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -583,10 +583,7 @@ export class ObjectService {
|
||||
throw new NotFoundException('Record not found');
|
||||
}
|
||||
|
||||
// Filter fields based on field-level permissions
|
||||
const filteredRecord = await this.authService.filterReadableFields(record, objectDefModel.fields, user);
|
||||
|
||||
return filteredRecord;
|
||||
return record;
|
||||
}
|
||||
|
||||
async createRecord(
|
||||
@@ -741,162 +738,4 @@ export class ObjectService {
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getFieldPermissions(tenantId: string, objectId: string) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Get all field permissions for this object's fields
|
||||
const permissions = await knex('role_field_permissions as rfp')
|
||||
.join('field_definitions as fd', 'fd.id', 'rfp.fieldDefinitionId')
|
||||
.where('fd.objectDefinitionId', objectId)
|
||||
.select('rfp.*');
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
async updateFieldPermission(
|
||||
tenantId: string,
|
||||
roleId: string,
|
||||
fieldDefinitionId: string,
|
||||
canRead: boolean,
|
||||
canEdit: boolean,
|
||||
) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Check if permission already exists
|
||||
const existing = await knex('role_field_permissions')
|
||||
.where({ roleId, fieldDefinitionId })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
// Update existing permission
|
||||
await knex('role_field_permissions')
|
||||
.where({ roleId, fieldDefinitionId })
|
||||
.update({
|
||||
canRead,
|
||||
canEdit,
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
} else {
|
||||
// Create new permission
|
||||
await knex('role_field_permissions').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
roleId,
|
||||
fieldDefinitionId,
|
||||
canRead,
|
||||
canEdit,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getObjectPermissions(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
roleId: string,
|
||||
) {
|
||||
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 NotFoundException(`Object ${objectApiName} not found`);
|
||||
}
|
||||
|
||||
// Get role object permissions
|
||||
const permission = await knex('role_object_permissions')
|
||||
.where({ roleId, objectDefinitionId: objectDef.id })
|
||||
.first();
|
||||
|
||||
if (!permission) {
|
||||
// Return default permissions (all false)
|
||||
return {
|
||||
canCreate: false,
|
||||
canRead: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
canViewAll: false,
|
||||
canModifyAll: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canCreate: Boolean(permission.canCreate),
|
||||
canRead: Boolean(permission.canRead),
|
||||
canEdit: Boolean(permission.canEdit),
|
||||
canDelete: Boolean(permission.canDelete),
|
||||
canViewAll: Boolean(permission.canViewAll),
|
||||
canModifyAll: Boolean(permission.canModifyAll),
|
||||
};
|
||||
}
|
||||
|
||||
async updateObjectPermissions(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
data: {
|
||||
roleId: string;
|
||||
canCreate: boolean;
|
||||
canRead: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
canViewAll: boolean;
|
||||
canModifyAll: boolean;
|
||||
},
|
||||
) {
|
||||
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 NotFoundException(`Object ${objectApiName} not found`);
|
||||
}
|
||||
|
||||
// Check if permission already exists
|
||||
const existing = await knex('role_object_permissions')
|
||||
.where({ roleId: data.roleId, objectDefinitionId: objectDef.id })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
// Update existing permission
|
||||
await knex('role_object_permissions')
|
||||
.where({ roleId: data.roleId, objectDefinitionId: objectDef.id })
|
||||
.update({
|
||||
canCreate: data.canCreate,
|
||||
canRead: data.canRead,
|
||||
canEdit: data.canEdit,
|
||||
canDelete: data.canDelete,
|
||||
canViewAll: data.canViewAll,
|
||||
canModifyAll: data.canModifyAll,
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
} else {
|
||||
// Create new permission
|
||||
await knex('role_object_permissions').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
roleId: data.roleId,
|
||||
objectDefinitionId: objectDef.id,
|
||||
canCreate: data.canCreate,
|
||||
canRead: data.canRead,
|
||||
canEdit: data.canEdit,
|
||||
canDelete: data.canDelete,
|
||||
canViewAll: data.canViewAll,
|
||||
canModifyAll: data.canModifyAll,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
@@ -12,7 +11,6 @@ import { ObjectService } from './object.service';
|
||||
import { FieldMapperService } from './field-mapper.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { TenantId } from '../tenant/tenant.decorator';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
|
||||
@Controller('setup/objects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -20,7 +18,6 @@ export class SetupObjectController {
|
||||
constructor(
|
||||
private objectService: ObjectService,
|
||||
private fieldMapperService: FieldMapperService,
|
||||
private tenantDbService: TenantDatabaseService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -80,47 +77,4 @@ export class SetupObjectController {
|
||||
) {
|
||||
return this.objectService.updateObjectDefinition(tenantId, objectApiName, data);
|
||||
}
|
||||
|
||||
@Get(':objectId/field-permissions')
|
||||
async getFieldPermissions(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectId') objectId: string,
|
||||
) {
|
||||
return this.objectService.getFieldPermissions(tenantId, objectId);
|
||||
}
|
||||
|
||||
@Put(':objectId/field-permissions')
|
||||
async updateFieldPermission(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectId') objectId: string,
|
||||
@Body() data: { roleId: string; fieldDefinitionId: string; canRead: boolean; canEdit: boolean },
|
||||
) {
|
||||
return this.objectService.updateFieldPermission(tenantId, data.roleId, data.fieldDefinitionId, data.canRead, data.canEdit);
|
||||
}
|
||||
|
||||
@Get(':objectApiName/permissions/:roleId')
|
||||
async getObjectPermissions(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Param('roleId') roleId: string,
|
||||
) {
|
||||
return this.objectService.getObjectPermissions(tenantId, objectApiName, roleId);
|
||||
}
|
||||
|
||||
@Put(':objectApiName/permissions')
|
||||
async updateObjectPermissions(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Body() data: {
|
||||
roleId: string;
|
||||
canCreate: boolean;
|
||||
canRead: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
canViewAll: boolean;
|
||||
canModifyAll: boolean;
|
||||
},
|
||||
) {
|
||||
return this.objectService.updateObjectPermissions(tenantId, objectApiName, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,20 +156,7 @@ export class AbilityFactory {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Collect all field permissions from all roles
|
||||
const allFieldPermissions: RoleFieldPermission[] = [];
|
||||
for (const role of user.roles) {
|
||||
if (role.fieldPermissions) {
|
||||
allFieldPermissions.push(...role.fieldPermissions);
|
||||
}
|
||||
}
|
||||
|
||||
// If there are NO field permissions configured at all, allow by default
|
||||
if (allFieldPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If field permissions exist, check for explicit grants (union of all roles)
|
||||
// Check all roles for field permission
|
||||
for (const role of user.roles) {
|
||||
if (role.fieldPermissions) {
|
||||
const fieldPerm = role.fieldPermissions.find(fp => fp.fieldDefinitionId === fieldDefinitionId);
|
||||
@@ -180,8 +167,8 @@ export class AbilityFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// Field permissions exist but this field is not explicitly granted → deny
|
||||
return false;
|
||||
// Default: allow if no explicit restriction
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,14 +2,8 @@ import { Module } from '@nestjs/common';
|
||||
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],
|
||||
providers: [RbacService, AbilityFactory, AuthorizationService],
|
||||
exports: [RbacService, AbilityFactory, AuthorizationService],
|
||||
})
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +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 { Role } from '../models/role.model';
|
||||
|
||||
@Controller('setup/roles')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SetupRolesController {
|
||||
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
@Get()
|
||||
async getRoles(@TenantId() tenantId: string) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Field-Level Security</CardTitle>
|
||||
<CardDescription>
|
||||
Control which fields each role can read and edit
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
|
||||
<div v-else-if="roles.length === 0" class="text-sm text-muted-foreground py-4">
|
||||
No roles available. Create roles first to manage field-level permissions.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Role Selector -->
|
||||
<div class="space-y-2">
|
||||
<Label>Select Role</Label>
|
||||
<Select v-model="selectedRoleId" @update:model-value="(value) => selectedRoleId = value">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Choose a role to configure permissions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="role in roles" :key="role.id" :value="role.id">
|
||||
{{ role.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Object-Level Permissions -->
|
||||
<div v-if="selectedRoleId" class="space-y-2">
|
||||
<h3 class="text-sm font-medium">Object-Level Permissions</h3>
|
||||
<div class="rounded-md border">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/50">
|
||||
<th class="p-3 text-left font-medium">Permission</th>
|
||||
<th class="p-3 text-center font-medium">Enabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b hover:bg-muted/30">
|
||||
<td class="p-3">Create</td>
|
||||
<td class="p-3 text-center">
|
||||
<Checkbox
|
||||
:model-value="objectPermissions.canCreate"
|
||||
@update:model-value="(checked: boolean) => updateObjectPermission('canCreate', checked)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-muted/30">
|
||||
<td class="p-3">Read</td>
|
||||
<td class="p-3 text-center">
|
||||
<Checkbox
|
||||
:model-value="objectPermissions.canRead"
|
||||
@update:model-value="(checked: boolean) => updateObjectPermission('canRead', checked)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-muted/30">
|
||||
<td class="p-3">Edit</td>
|
||||
<td class="p-3 text-center">
|
||||
<Checkbox
|
||||
:model-value="objectPermissions.canEdit"
|
||||
@update:model-value="(checked: boolean) => updateObjectPermission('canEdit', checked)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-muted/30">
|
||||
<td class="p-3">Delete</td>
|
||||
<td class="p-3 text-center">
|
||||
<Checkbox
|
||||
:model-value="objectPermissions.canDelete"
|
||||
@update:model-value="(checked: boolean) => updateObjectPermission('canDelete', checked)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-muted/30">
|
||||
<td class="p-3">View All</td>
|
||||
<td class="p-3 text-center">
|
||||
<Checkbox
|
||||
:model-value="objectPermissions.canViewAll"
|
||||
@update:model-value="(checked: boolean) => updateObjectPermission('canViewAll', checked)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-muted/30">
|
||||
<td class="p-3">Modify All</td>
|
||||
<td class="p-3 text-center">
|
||||
<Checkbox
|
||||
:model-value="objectPermissions.canModifyAll"
|
||||
@update:model-value="(checked: boolean) => updateObjectPermission('canModifyAll', checked)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Field-Level Permissions -->
|
||||
<div v-if="selectedRoleId" class="space-y-2">
|
||||
<h3 class="text-sm font-medium">Field-Level Permissions</h3>
|
||||
<div class="rounded-md border">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/50">
|
||||
<th class="p-3 text-left font-medium">Field</th>
|
||||
<th class="p-3 text-center font-medium">Read</th>
|
||||
<th class="p-3 text-center font-medium">Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="field in fields"
|
||||
:key="field.id"
|
||||
class="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td class="p-3">
|
||||
<div>
|
||||
<div class="font-medium">{{ field.label }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ field.apiName }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3 text-center">
|
||||
<Checkbox
|
||||
:model-value="hasPermission(field.id, selectedRoleId, 'read')"
|
||||
@update:model-value="(checked: boolean) => updatePermission(field.id, selectedRoleId, 'read', checked)"
|
||||
:disabled="field.isSystem"
|
||||
/>
|
||||
</td>
|
||||
<td class="p-3 text-center">
|
||||
<Checkbox
|
||||
:model-value="hasPermission(field.id, selectedRoleId, 'edit')"
|
||||
@update:model-value="(checked: boolean) => updatePermission(field.id, selectedRoleId, 'edit', checked)"
|
||||
:disabled="field.isSystem || !hasPermission(field.id, selectedRoleId, 'read')"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Info class="h-4 w-4" />
|
||||
<span>System fields are always readable. Edit permissions require read permission first. Changes save automatically.</span>
|
||||
</div>
|
||||
|
||||
<div v-if="saving" class="flex items-center gap-2 text-sm text-primary">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
|
||||
import { Checkbox } from '~/components/ui/checkbox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
||||
import { Label } from '~/components/ui/label';
|
||||
import { Info } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps<{
|
||||
objectId: string;
|
||||
objectApiName: string;
|
||||
fields: any[];
|
||||
}>();
|
||||
|
||||
const { api } = useApi();
|
||||
const { toast } = useToast();
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const roles = ref<any[]>([]);
|
||||
const selectedRoleId = ref<string>('');
|
||||
const permissions = ref<Map<string, Map<string, { canRead: boolean; canEdit: boolean }>>>(new Map());
|
||||
const objectPermissions = ref({
|
||||
canCreate: false,
|
||||
canRead: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
canViewAll: false,
|
||||
canModifyAll: false,
|
||||
});
|
||||
|
||||
// Load roles and permissions
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
// Load roles
|
||||
const rolesResponse = await api.get('/setup/roles');
|
||||
roles.value = rolesResponse || [];
|
||||
|
||||
// Load existing permissions for this object
|
||||
const permsResponse = await api.get(`/setup/objects/${props.objectId}/field-permissions`);
|
||||
|
||||
// Build permissions map: fieldId -> roleId -> {canRead, canEdit}
|
||||
const permsMap = new Map();
|
||||
if (permsResponse && Array.isArray(permsResponse)) {
|
||||
for (const perm of permsResponse) {
|
||||
if (!permsMap.has(perm.fieldDefinitionId)) {
|
||||
permsMap.set(perm.fieldDefinitionId, new Map());
|
||||
}
|
||||
permsMap.get(perm.fieldDefinitionId).set(perm.roleId, {
|
||||
canRead: Boolean(perm.canRead),
|
||||
canEdit: Boolean(perm.canEdit),
|
||||
});
|
||||
}
|
||||
}
|
||||
permissions.value = permsMap;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load field permissions:', error);
|
||||
toast.error('Failed to load field permissions');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const hasPermission = (fieldId: string, roleId: string, type: 'read' | 'edit'): boolean => {
|
||||
const fieldPerms = permissions.value.get(fieldId);
|
||||
if (!fieldPerms) return true; // Default to true if no permissions set
|
||||
const rolePerm = fieldPerms.get(roleId);
|
||||
if (!rolePerm) return true; // Default to true if no permissions set
|
||||
const value = type === 'read' ? rolePerm.canRead : rolePerm.canEdit;
|
||||
return Boolean(value); // Convert 1/0 to true/false
|
||||
};
|
||||
|
||||
const updatePermission = async (fieldId: string, roleId: string, type: 'read' | 'edit', checked: boolean) => {
|
||||
try {
|
||||
saving.value = true;
|
||||
|
||||
// Get current permissions
|
||||
if (!permissions.value.has(fieldId)) {
|
||||
permissions.value.set(fieldId, new Map());
|
||||
}
|
||||
const fieldPerms = permissions.value.get(fieldId)!;
|
||||
|
||||
if (!fieldPerms.has(roleId)) {
|
||||
fieldPerms.set(roleId, { canRead: true, canEdit: true });
|
||||
}
|
||||
const perm = fieldPerms.get(roleId)!;
|
||||
|
||||
// Update permission
|
||||
if (type === 'read') {
|
||||
perm.canRead = checked;
|
||||
// If disabling read, also disable edit
|
||||
if (!checked) {
|
||||
perm.canEdit = false;
|
||||
}
|
||||
} else {
|
||||
perm.canEdit = checked;
|
||||
// If enabling edit, also enable read
|
||||
if (checked) {
|
||||
perm.canRead = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Save to backend
|
||||
await api.put(`/setup/objects/${props.objectId}/field-permissions`, {
|
||||
roleId,
|
||||
fieldDefinitionId: fieldId,
|
||||
canRead: perm.canRead,
|
||||
canEdit: perm.canEdit,
|
||||
});
|
||||
|
||||
toast.success('Permission updated');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update field permission:', error);
|
||||
toast.error(error.message || 'Failed to update permission');
|
||||
|
||||
// Revert change
|
||||
if (!permissions.value.has(fieldId)) return;
|
||||
const fieldPerms = permissions.value.get(fieldId)!;
|
||||
if (!fieldPerms.has(roleId)) return;
|
||||
const perm = fieldPerms.get(roleId)!;
|
||||
if (type === 'read') {
|
||||
perm.canRead = !checked;
|
||||
} else {
|
||||
perm.canEdit = !checked;
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateObjectPermission = async (permission: string, checked: boolean) => {
|
||||
if (!selectedRoleId.value) return;
|
||||
|
||||
try {
|
||||
saving.value = true;
|
||||
|
||||
// Update local state
|
||||
(objectPermissions.value as any)[permission] = checked;
|
||||
|
||||
// Save to backend
|
||||
await api.put(`/setup/objects/${props.objectApiName}/permissions`, {
|
||||
roleId: selectedRoleId.value,
|
||||
...objectPermissions.value,
|
||||
});
|
||||
|
||||
toast.success('Object permission updated');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update object permission:', error);
|
||||
toast.error(error.message || 'Failed to update permission');
|
||||
|
||||
// Revert change
|
||||
(objectPermissions.value as any)[permission] = !checked;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load object permissions when role changes
|
||||
watch(selectedRoleId, async (roleId) => {
|
||||
if (!roleId) return;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/setup/objects/${props.objectApiName}/permissions/${roleId}`);
|
||||
if (response) {
|
||||
objectPermissions.value = {
|
||||
canCreate: Boolean(response.canCreate),
|
||||
canRead: Boolean(response.canRead),
|
||||
canEdit: Boolean(response.canEdit),
|
||||
canDelete: Boolean(response.canDelete),
|
||||
canViewAll: Boolean(response.canViewAll),
|
||||
canModifyAll: Boolean(response.canModifyAll),
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load object permissions:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -43,20 +43,20 @@
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FieldLevelSecurity
|
||||
v-if="objectId && objectApiName && fields && fields.length > 0"
|
||||
:object-id="objectId"
|
||||
:object-api-name="objectApiName"
|
||||
:fields="fields"
|
||||
/>
|
||||
|
||||
<div v-else-if="!objectId" class="text-sm text-muted-foreground">
|
||||
Object ID not available
|
||||
</div>
|
||||
|
||||
<div v-else-if="!fields || fields.length === 0" class="text-sm text-muted-foreground">
|
||||
No fields available
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Field-Level Security</CardTitle>
|
||||
<CardDescription>
|
||||
Control field visibility and editability by role (coming soon)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Field-level permissions will be managed through role configuration.
|
||||
Navigate to Setup → Roles to configure field access for each role.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -65,13 +65,10 @@ import { ref, watch } from 'vue';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
|
||||
import { Label } from '~/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
||||
import FieldLevelSecurity from '~/components/FieldLevelSecurity.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
objectApiName: string;
|
||||
objectId?: string;
|
||||
orgWideDefault?: string;
|
||||
fields?: any[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -60,9 +60,7 @@
|
||||
<TabsContent value="access" class="mt-6">
|
||||
<ObjectAccessSettings
|
||||
:object-api-name="object.apiName"
|
||||
:object-id="object.id"
|
||||
:org-wide-default="object.orgWideDefault"
|
||||
:fields="object.fields"
|
||||
@update="handleAccessUpdate"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user