Compare commits
1 Commits
56c0c3838d
...
feature/sa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16907aadf8 |
@@ -68,6 +68,7 @@ exports.up = function (knex) {
|
|||||||
table.timestamp('expiresAt').nullable();
|
table.timestamp('expiresAt').nullable();
|
||||||
table.timestamp('revokedAt').nullable();
|
table.timestamp('revokedAt').nullable();
|
||||||
table.timestamp('createdAt').defaultTo(knex.fn.now());
|
table.timestamp('createdAt').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
table
|
table
|
||||||
.foreign('objectDefinitionId')
|
.foreign('objectDefinitionId')
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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,6 +9,27 @@ export interface RecordShareAccessLevel {
|
|||||||
export class RecordShare extends BaseModel {
|
export class RecordShare extends BaseModel {
|
||||||
static tableName = 'record_shares';
|
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;
|
id!: string;
|
||||||
objectDefinitionId!: string;
|
objectDefinitionId!: string;
|
||||||
recordId!: string;
|
recordId!: string;
|
||||||
@@ -18,6 +39,7 @@ export class RecordShare extends BaseModel {
|
|||||||
expiresAt?: Date;
|
expiresAt?: Date;
|
||||||
revokedAt?: Date;
|
revokedAt?: Date;
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
static get jsonSchema() {
|
static get jsonSchema() {
|
||||||
return {
|
return {
|
||||||
@@ -37,8 +59,22 @@ export class RecordShare extends BaseModel {
|
|||||||
canDelete: { type: 'boolean' },
|
canDelete: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expiresAt: { type: 'string', format: 'date-time' },
|
expiresAt: {
|
||||||
revokedAt: { type: 'string', format: 'date-time' },
|
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' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -583,7 +583,10 @@ export class ObjectService {
|
|||||||
throw new NotFoundException('Record not found');
|
throw new NotFoundException('Record not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return record;
|
// Filter fields based on field-level permissions
|
||||||
|
const filteredRecord = await this.authService.filterReadableFields(record, objectDefModel.fields, user);
|
||||||
|
|
||||||
|
return filteredRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRecord(
|
async createRecord(
|
||||||
@@ -738,4 +741,162 @@ export class ObjectService {
|
|||||||
|
|
||||||
return { success: true };
|
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,6 +3,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
|
Put,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -11,6 +12,7 @@ import { ObjectService } from './object.service';
|
|||||||
import { FieldMapperService } from './field-mapper.service';
|
import { FieldMapperService } from './field-mapper.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { TenantId } from '../tenant/tenant.decorator';
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
|
||||||
@Controller('setup/objects')
|
@Controller('setup/objects')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -18,6 +20,7 @@ export class SetupObjectController {
|
|||||||
constructor(
|
constructor(
|
||||||
private objectService: ObjectService,
|
private objectService: ObjectService,
|
||||||
private fieldMapperService: FieldMapperService,
|
private fieldMapperService: FieldMapperService,
|
||||||
|
private tenantDbService: TenantDatabaseService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -77,4 +80,47 @@ export class SetupObjectController {
|
|||||||
) {
|
) {
|
||||||
return this.objectService.updateObjectDefinition(tenantId, objectApiName, data);
|
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,7 +156,20 @@ export class AbilityFactory {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all roles for field permission
|
// 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)
|
||||||
for (const role of user.roles) {
|
for (const role of user.roles) {
|
||||||
if (role.fieldPermissions) {
|
if (role.fieldPermissions) {
|
||||||
const fieldPerm = role.fieldPermissions.find(fp => fp.fieldDefinitionId === fieldDefinitionId);
|
const fieldPerm = role.fieldPermissions.find(fp => fp.fieldDefinitionId === fieldDefinitionId);
|
||||||
@@ -167,8 +180,8 @@ export class AbilityFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: allow if no explicit restriction
|
// Field permissions exist but this field is not explicitly granted → deny
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
19
backend/src/rbac/dto/create-record-share.dto.ts
Normal file
19
backend/src/rbac/dto/create-record-share.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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,8 +2,14 @@ import { Module } from '@nestjs/common';
|
|||||||
import { RbacService } from './rbac.service';
|
import { RbacService } from './rbac.service';
|
||||||
import { AbilityFactory } from './ability.factory';
|
import { AbilityFactory } from './ability.factory';
|
||||||
import { AuthorizationService } from './authorization.service';
|
import { AuthorizationService } from './authorization.service';
|
||||||
|
import { SetupRolesController } from './setup-roles.controller';
|
||||||
|
import { SetupUsersController } from './setup-users.controller';
|
||||||
|
import { RecordSharingController } from './record-sharing.controller';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [TenantModule],
|
||||||
|
controllers: [SetupRolesController, SetupUsersController, RecordSharingController],
|
||||||
providers: [RbacService, AbilityFactory, AuthorizationService],
|
providers: [RbacService, AbilityFactory, AuthorizationService],
|
||||||
exports: [RbacService, AbilityFactory, AuthorizationService],
|
exports: [RbacService, AbilityFactory, AuthorizationService],
|
||||||
})
|
})
|
||||||
|
|||||||
324
backend/src/rbac/record-sharing.controller.ts
Normal file
324
backend/src/rbac/record-sharing.controller.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
backend/src/rbac/setup-roles.controller.ts
Normal file
141
backend/src/rbac/setup-roles.controller.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
146
backend/src/rbac/setup-users.controller.ts
Normal file
146
backend/src/rbac/setup-users.controller.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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,6 +105,16 @@ const staticMenuItems = [
|
|||||||
url: '/setup/objects',
|
url: '/setup/objects',
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Users',
|
||||||
|
url: '/setup/users',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Roles',
|
||||||
|
url: '/setup/roles',
|
||||||
|
icon: Layers,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
344
frontend/components/FieldLevelSecurity.vue
Normal file
344
frontend/components/FieldLevelSecurity.vue
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<FieldLevelSecurity
|
||||||
<CardHeader>
|
v-if="objectId && objectApiName && fields && fields.length > 0"
|
||||||
<CardTitle>Field-Level Security</CardTitle>
|
:object-id="objectId"
|
||||||
<CardDescription>
|
:object-api-name="objectApiName"
|
||||||
Control field visibility and editability by role (coming soon)
|
:fields="fields"
|
||||||
</CardDescription>
|
/>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<div v-else-if="!objectId" class="text-sm text-muted-foreground">
|
||||||
<div class="text-sm text-muted-foreground">
|
Object ID not available
|
||||||
Field-level permissions will be managed through role configuration.
|
</div>
|
||||||
Navigate to Setup → Roles to configure field access for each role.
|
|
||||||
|
<div v-else-if="!fields || fields.length === 0" class="text-sm text-muted-foreground">
|
||||||
|
No fields available
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -65,10 +65,13 @@ import { ref, watch } from 'vue';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
|
||||||
import { Label } from '~/components/ui/label';
|
import { Label } from '~/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
||||||
|
import FieldLevelSecurity from '~/components/FieldLevelSecurity.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
objectApiName: string;
|
objectApiName: string;
|
||||||
|
objectId?: string;
|
||||||
orgWideDefault?: string;
|
orgWideDefault?: string;
|
||||||
|
fields?: any[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
348
frontend/components/RecordSharing.vue
Normal file
348
frontend/components/RecordSharing.vue
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
<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>
|
||||||
33
frontend/components/ui/checkbox.vue
Normal file
33
frontend/components/ui/checkbox.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<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,9 +2,11 @@
|
|||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
||||||
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
|
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
|
||||||
import RelatedList from '@/components/RelatedList.vue'
|
import RelatedList from '@/components/RelatedList.vue'
|
||||||
|
import RecordSharing from '@/components/RecordSharing.vue'
|
||||||
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
|
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
|
||||||
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
|
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
@@ -20,11 +22,13 @@ interface Props {
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
objectId?: string // For fetching page layout
|
objectId?: string // For fetching page layout
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
|
showSharing?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
loading: false,
|
loading: false,
|
||||||
baseUrl: '/runtime/objects',
|
baseUrl: '/runtime/objects',
|
||||||
|
showSharing: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -130,8 +134,22 @@ const usePageLayout = computed(() => {
|
|||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
</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>
|
||||||
|
|
||||||
|
<!-- Details Tab -->
|
||||||
|
<TabsContent value="details" class="space-y-6">
|
||||||
<!-- Content with Page Layout -->
|
<!-- Content with Page Layout -->
|
||||||
<Card v-else-if="usePageLayout">
|
<Card v-if="usePageLayout">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Details</CardTitle>
|
<CardTitle>Details</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -202,9 +220,11 @@ const usePageLayout = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Related Lists -->
|
<!-- Related Lists Tab -->
|
||||||
<div v-if="config.relatedLists && config.relatedLists.length > 0" class="space-y-6">
|
<TabsContent value="related" class="space-y-6">
|
||||||
|
<div v-if="config.relatedLists && config.relatedLists.length > 0">
|
||||||
<RelatedList
|
<RelatedList
|
||||||
v-for="relatedList in config.relatedLists"
|
v-for="relatedList in config.relatedLists"
|
||||||
:key="relatedList.relationName"
|
:key="relatedList.relationName"
|
||||||
@@ -215,6 +235,22 @@ const usePageLayout = computed(() => {
|
|||||||
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
|
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,9 @@
|
|||||||
<TabsContent value="access" class="mt-6">
|
<TabsContent value="access" class="mt-6">
|
||||||
<ObjectAccessSettings
|
<ObjectAccessSettings
|
||||||
:object-api-name="object.apiName"
|
:object-api-name="object.apiName"
|
||||||
|
:object-id="object.id"
|
||||||
:org-wide-default="object.orgWideDefault"
|
:org-wide-default="object.orgWideDefault"
|
||||||
|
:fields="object.fields"
|
||||||
@update="handleAccessUpdate"
|
@update="handleAccessUpdate"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
231
frontend/pages/setup/roles/[id].vue
Normal file
231
frontend/pages/setup/roles/[id].vue
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" @click="navigateTo('/setup/roles')" class="mb-2">
|
||||||
|
← Back to Roles
|
||||||
|
</Button>
|
||||||
|
<h1 class="text-3xl font-bold">{{ role?.name || 'Role' }}</h1>
|
||||||
|
<p class="text-muted-foreground">{{ role?.description || 'No description' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs v-else default-value="details" class="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
|
<TabsTrigger value="users">Users</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="details" class="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Role Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Name</Label>
|
||||||
|
<p class="font-medium">{{ role?.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Guard</Label>
|
||||||
|
<Badge variant="outline">{{ role?.guardName || 'tenant' }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<Label class="text-muted-foreground">Description</Label>
|
||||||
|
<p class="font-medium">{{ role?.description || 'No description' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Created At</Label>
|
||||||
|
<p class="font-medium">{{ formatDate(role?.createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Updated At</Label>
|
||||||
|
<p class="font-medium">{{ formatDate(role?.updatedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="users" class="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Assigned Users</CardTitle>
|
||||||
|
<CardDescription>Manage user assignments for this role</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button @click="showAddUserDialog = true" size="sm">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="roleUsers.length === 0" class="text-center py-8 text-muted-foreground">
|
||||||
|
No users assigned. Add users to grant them this role.
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="user in roleUsers"
|
||||||
|
:key="user.id"
|
||||||
|
class="flex items-center justify-between p-3 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ getUserName(user) }}</p>
|
||||||
|
<p class="text-sm text-muted-foreground">{{ user.email }}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" @click="removeUser(user.id)">
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- Add User Dialog -->
|
||||||
|
<Dialog v-model:open="showAddUserDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select a user to assign this role
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Available Users</Label>
|
||||||
|
<Select v-model="selectedUserId" @update:model-value="(value) => selectedUserId = value">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choose a user" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="user in availableUsers" :key="user.id" :value="user.id">
|
||||||
|
{{ getUserName(user) }} ({{ user.email }})
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showAddUserDialog = false">Cancel</Button>
|
||||||
|
<Button @click="addUser" :disabled="!selectedUserId">
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '~/components/ui/dialog';
|
||||||
|
import { Label } from '~/components/ui/label';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
||||||
|
import { Plus, X } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const { api } = useApi();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const role = ref<any>(null);
|
||||||
|
const roleUsers = ref<any[]>([]);
|
||||||
|
const allUsers = ref<any[]>([]);
|
||||||
|
const showAddUserDialog = ref(false);
|
||||||
|
const selectedUserId = ref('');
|
||||||
|
|
||||||
|
const availableUsers = computed(() => {
|
||||||
|
const assignedIds = new Set(roleUsers.value.map(u => u.id));
|
||||||
|
return allUsers.value.filter(u => !assignedIds.has(u.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadRole = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const roleId = route.params.id;
|
||||||
|
const response = await api.get(`/setup/roles/${roleId}`);
|
||||||
|
role.value = response;
|
||||||
|
roleUsers.value = response.users || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load role:', error);
|
||||||
|
toast.error('Failed to load role');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAllUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/setup/users');
|
||||||
|
allUsers.value = response || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load users:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUser = async () => {
|
||||||
|
if (!selectedUserId.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/setup/roles/${route.params.id}/users`, {
|
||||||
|
userId: selectedUserId.value,
|
||||||
|
});
|
||||||
|
toast.success('User added successfully');
|
||||||
|
showAddUserDialog.value = false;
|
||||||
|
selectedUserId.value = '';
|
||||||
|
await loadRole();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to add user:', error);
|
||||||
|
toast.error(error.message || 'Failed to add user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUser = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/setup/roles/${route.params.id}/users/${userId}`);
|
||||||
|
toast.success('User removed successfully');
|
||||||
|
await loadRole();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to remove user:', error);
|
||||||
|
toast.error(error.message || 'Failed to remove user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserName = (user: any) => {
|
||||||
|
if (!user) return 'Unknown';
|
||||||
|
if (user.firstName || user.lastName) {
|
||||||
|
return [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
return user.email || 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
if (!date) return 'N/A';
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadRole(), loadAllUsers()]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
285
frontend/pages/setup/roles/index.vue
Normal file
285
frontend/pages/setup/roles/index.vue
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<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>
|
||||||
227
frontend/pages/setup/users/[id].vue
Normal file
227
frontend/pages/setup/users/[id].vue
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" @click="navigateTo('/setup/users')" class="mb-2">
|
||||||
|
← Back to Users
|
||||||
|
</Button>
|
||||||
|
<h1 class="text-3xl font-bold">{{ getUserName(user) }}</h1>
|
||||||
|
<p class="text-muted-foreground">{{ user?.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs v-else default-value="details" class="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
|
<TabsTrigger value="roles">Roles</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="details" class="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Email</Label>
|
||||||
|
<p class="font-medium">{{ user?.email }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">First Name</Label>
|
||||||
|
<p class="font-medium">{{ user?.firstName || 'N/A' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Last Name</Label>
|
||||||
|
<p class="font-medium">{{ user?.lastName || 'N/A' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Created At</Label>
|
||||||
|
<p class="font-medium">{{ formatDate(user?.createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Updated At</Label>
|
||||||
|
<p class="font-medium">{{ formatDate(user?.updatedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="roles" class="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Assigned Roles</CardTitle>
|
||||||
|
<CardDescription>Manage role assignments for this user</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button @click="showAddRoleDialog = true" size="sm">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
Add Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="userRoles.length === 0" class="text-center py-8 text-muted-foreground">
|
||||||
|
No roles assigned. Add roles to grant permissions.
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="role in userRoles"
|
||||||
|
:key="role.id"
|
||||||
|
class="flex items-center justify-between p-3 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ role.name }}</p>
|
||||||
|
<p class="text-sm text-muted-foreground">{{ role.description || 'No description' }}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" @click="removeRole(role.id)">
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- Add Role Dialog -->
|
||||||
|
<Dialog v-model:open="showAddRoleDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Role</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select a role to assign to this user
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Available Roles</Label>
|
||||||
|
<Select v-model="selectedRoleId" @update:model-value="(value) => selectedRoleId = value">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choose a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="role in availableRoles" :key="role.id" :value="role.id">
|
||||||
|
{{ role.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showAddRoleDialog = false">Cancel</Button>
|
||||||
|
<Button @click="addRole" :disabled="!selectedRoleId">
|
||||||
|
Add Role
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '~/components/ui/dialog';
|
||||||
|
import { Label } from '~/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
||||||
|
import { Plus, X } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const { api } = useApi();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const user = ref<any>(null);
|
||||||
|
const userRoles = ref<any[]>([]);
|
||||||
|
const allRoles = ref<any[]>([]);
|
||||||
|
const showAddRoleDialog = ref(false);
|
||||||
|
const selectedRoleId = ref('');
|
||||||
|
|
||||||
|
const availableRoles = computed(() => {
|
||||||
|
const assignedIds = new Set(userRoles.value.map(r => r.id));
|
||||||
|
return allRoles.value.filter(r => !assignedIds.has(r.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadUser = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const userId = route.params.id;
|
||||||
|
const response = await api.get(`/setup/users/${userId}`);
|
||||||
|
user.value = response;
|
||||||
|
userRoles.value = response.roles || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load user:', error);
|
||||||
|
toast.error('Failed to load user');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAllRoles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/setup/roles');
|
||||||
|
allRoles.value = response || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load roles:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRole = async () => {
|
||||||
|
if (!selectedRoleId.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/setup/users/${route.params.id}/roles`, {
|
||||||
|
roleId: selectedRoleId.value,
|
||||||
|
});
|
||||||
|
toast.success('Role added successfully');
|
||||||
|
showAddRoleDialog.value = false;
|
||||||
|
selectedRoleId.value = '';
|
||||||
|
await loadUser();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to add role:', error);
|
||||||
|
toast.error(error.message || 'Failed to add role');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRole = async (roleId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/setup/users/${route.params.id}/roles/${roleId}`);
|
||||||
|
toast.success('Role removed successfully');
|
||||||
|
await loadUser();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to remove role:', error);
|
||||||
|
toast.error(error.message || 'Failed to remove role');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserName = (user: any) => {
|
||||||
|
if (!user) return 'User';
|
||||||
|
if (user.firstName || user.lastName) {
|
||||||
|
return [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
return user.email || 'User';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
if (!date) return 'N/A';
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadUser(), loadAllRoles()]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
290
frontend/pages/setup/users/index.vue
Normal file
290
frontend/pages/setup/users/index.vue
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
<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