feat: granular hamkar/khanegi permissions (manage / settings / appearance)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 20:48:32 +03:30
co-authored by Cursor
parent 674da4e939
commit 30dc83890b
5 changed files with 134 additions and 18 deletions
+60 -7
View File
@@ -23,8 +23,12 @@ import { PermissionsGuard } from '../auth/permissions.guard';
import { RequirePermissions } from '../auth/permissions.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import {
ANY_TENANT_READ,
productAnyReadPermissions,
productReadPermission,
productUiWritePermission,
productWritePermission,
userHasAnyPermission,
userHasPermission,
} from '../auth/permissions';
import {
@@ -38,6 +42,15 @@ import { TenantsService } from './tenants.service';
const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i;
/** Fields edited from the appearance pages; guarded by `<product>.ui:write`. */
const APPEARANCE_FIELDS = [
'appUiConfig',
'uiVariant',
'primaryColor',
'secondaryColor',
'backgroundColor',
] as const;
type AuthUser = { id: string; role?: string; permissions?: unknown };
@Controller('tenants')
@@ -49,18 +62,18 @@ export class TenantsController {
) {}
@Get()
@RequirePermissions('hamkar:read', 'khanegi:read', 'others:read', { mode: 'any' })
@RequirePermissions(...ANY_TENANT_READ, { mode: 'any' })
async findAll(
@Query('product') product?: ProductType,
@CurrentUser() user?: AuthUser,
) {
if (product) {
this.assertProductAccess(user, product, 'read');
this.assertAnyProductRead(user, product);
return this.tenantsService.findAll(product);
}
const rows = await this.tenantsService.findAll();
return rows.filter((row) =>
userHasPermission(user || {}, productReadPermission(row.product)),
userHasAnyPermission(user || {}, productAnyReadPermissions(row.product)),
);
}
@@ -103,10 +116,10 @@ export class TenantsController {
}
@Get(':id')
@RequirePermissions('hamkar:read', 'khanegi:read', 'others:read', { mode: 'any' })
@RequirePermissions(...ANY_TENANT_READ, { mode: 'any' })
async findOne(@Param('id') id: string, @CurrentUser() user: AuthUser) {
const tenant = await this.tenantsService.findOne(id);
this.assertProductAccess(user, tenant.product, 'read');
this.assertAnyProductRead(user, tenant.product);
return tenant;
}
@@ -118,14 +131,48 @@ export class TenantsController {
}
@Patch(':id')
@RequirePermissions('hamkar:write', 'khanegi:write', 'others:write', { mode: 'any' })
@RequirePermissions(
'hamkar:write',
'hamkar.ui:write',
'khanegi:write',
'khanegi.ui:write',
'others:write',
{ mode: 'any' },
)
async update(
@Param('id') id: string,
@Body() dto: UpdateTenantDto,
@CurrentUser() user: AuthUser,
) {
const tenant = await this.tenantsService.findOne(id);
this.assertProductAccess(user, tenant.product, 'write');
const before = tenant as unknown as Record<string, unknown>;
const body = dto as Record<string, unknown>;
const appearanceChanged = APPEARANCE_FIELDS.some((key) => {
if (body[key] === undefined) return false;
return JSON.stringify(body[key]) !== JSON.stringify(before[key] ?? null);
});
const touchesIdentity = Object.keys(body).some(
(key) =>
body[key] !== undefined &&
!(APPEARANCE_FIELDS as readonly string[]).includes(key),
);
if (touchesIdentity) {
this.assertProductAccess(user, tenant.product, 'write');
}
if (appearanceChanged) {
const uiCode = productUiWritePermission(tenant.product);
const allowed = uiCode
? userHasPermission(user || {}, uiCode)
: userHasPermission(user || {}, productWritePermission(tenant.product));
if (!allowed) {
throw new ForbiddenException('شما به ظاهر این محصول دسترسی ندارید');
}
}
if (!touchesIdentity && !appearanceChanged) {
this.assertAnyProductRead(user, tenant.product);
}
return this.tenantsService.update(id, dto, user.id);
}
@@ -150,4 +197,10 @@ export class TenantsController {
throw new ForbiddenException('شما به این محصول دسترسی ندارید');
}
}
private assertAnyProductRead(user: AuthUser | undefined, product: string) {
if (!userHasAnyPermission(user || {}, productAnyReadPermissions(product))) {
throw new ForbiddenException('شما به این محصول دسترسی ندارید');
}
}
}