feat(access): keep a single owner per organization
The person who registered stays the only owner. Full day-to-day access is granted through the manager role instead of a second owner. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -70,6 +71,16 @@ export class MembershipsService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** هر محیط یک مالک فعال دارد: همان کسی که محیط را ثبت کرده. */
|
||||
async assertSoleOwner(organizationId: string, userId: string) {
|
||||
const others = await this.countActiveOwners(organizationId, userId);
|
||||
if (others > 0) {
|
||||
throw new BadRequestException(
|
||||
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبتنام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async createMembership(data: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
@@ -78,6 +89,9 @@ export class MembershipsService {
|
||||
commissionPercent?: number;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
if (data.role === 'owner' && data.isActive !== false) {
|
||||
await this.assertSoleOwner(data.organizationId, data.userId);
|
||||
}
|
||||
return this.prisma.membership.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
|
||||
@@ -543,6 +543,9 @@ export class PlatformService {
|
||||
});
|
||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||
if (isActive && !row.isActive) {
|
||||
if (row.role === 'owner') {
|
||||
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
|
||||
}
|
||||
await this.billing.assertSeatAvailable(row.organizationId, row.userId);
|
||||
}
|
||||
if (!isActive && row.role === 'owner') {
|
||||
@@ -573,6 +576,9 @@ export class PlatformService {
|
||||
});
|
||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||
|
||||
if (role === 'owner' && row.role !== 'owner') {
|
||||
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
|
||||
}
|
||||
if (row.role === 'owner' && role !== 'owner') {
|
||||
const owners = await this.memberships.countActiveOwners(
|
||||
row.organizationId,
|
||||
@@ -580,7 +586,7 @@ export class PlatformService {
|
||||
);
|
||||
if (owners < 1) {
|
||||
throw new BadRequestException(
|
||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
||||
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,8 +104,10 @@ export class UsersService {
|
||||
orgRoleId: dto.orgRoleId,
|
||||
role: dto.role,
|
||||
});
|
||||
if (resolved.role === 'owner' && actorRole !== 'owner') {
|
||||
throw new ForbiddenException('فقط مالک میتواند نقش مدیر سیستم را واگذار کند');
|
||||
if (resolved.role === 'owner') {
|
||||
throw new BadRequestException(
|
||||
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبتنام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
|
||||
@@ -449,6 +451,10 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
if (nextRole === 'owner' && target.role !== 'owner') {
|
||||
await this.memberships.assertSoleOwner(organizationId, target.userId);
|
||||
}
|
||||
|
||||
if (target.role === 'owner' && nextRole !== 'owner') {
|
||||
const owners = await this.memberships.countActiveOwners(
|
||||
organizationId,
|
||||
@@ -456,7 +462,7 @@ export class UsersService {
|
||||
);
|
||||
if (owners < 1) {
|
||||
throw new BadRequestException(
|
||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
||||
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -528,6 +534,9 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
if (isActive && !target.isActive) {
|
||||
if (target.role === 'owner') {
|
||||
await this.memberships.assertSoleOwner(organizationId, targetId);
|
||||
}
|
||||
await this.billing.assertSeatAvailable(organizationId, targetId);
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
@@ -558,6 +567,9 @@ export class UsersService {
|
||||
resolved: { role: UserRole; orgRoleId: string },
|
||||
): Promise<OrgUserDto> {
|
||||
await this.billing.assertSeatAvailable(organizationId, userId);
|
||||
if (resolved.role === 'owner') {
|
||||
await this.memberships.assertSoleOwner(organizationId, userId);
|
||||
}
|
||||
const existing = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId },
|
||||
});
|
||||
|
||||
@@ -138,7 +138,7 @@ const editPassword = ref('');
|
||||
const editRoleLabel = ref('');
|
||||
const editRoleId = ref('');
|
||||
const editInitialRoleId = ref('');
|
||||
/** مدیر نمیتواند نقش مالک را عوض کند؛ آن موقع فقط برچسب نقش نشان داده میشود */
|
||||
/** نقش مالک قفل است؛ مالک همان ثبتنامکننده میماند */
|
||||
const editRoleLocked = ref(false);
|
||||
|
||||
const roleFormOpen = ref(false);
|
||||
@@ -253,7 +253,7 @@ const parentRoleOptions = computed(() => {
|
||||
});
|
||||
|
||||
const inviteableRoles = computed(() =>
|
||||
roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'),
|
||||
roles.value.filter((r) => r.systemKey !== 'owner'),
|
||||
);
|
||||
|
||||
function roleTreeLabel(role: OrgRoleDto) {
|
||||
@@ -482,7 +482,7 @@ function openEditUser(user: OrgUserDto) {
|
||||
editRoleLabel.value = user.orgRoleName || '—';
|
||||
editRoleId.value = user.orgRoleId ?? '';
|
||||
editInitialRoleId.value = user.orgRoleId ?? '';
|
||||
editRoleLocked.value = user.role === 'owner' && auth.user?.role !== 'owner';
|
||||
editRoleLocked.value = user.role === 'owner';
|
||||
editOpen.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
@@ -1517,7 +1517,7 @@ onUnmounted(() => {
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-else class="hint">نقش فعلی: {{ editRoleLabel }} — نقش مالک را فقط مالک میتواند تغییر دهد.</p>
|
||||
<p v-else class="hint">نقش فعلی: {{ editRoleLabel }} — مالک همان کسی است که ثبتنام کرده و عوض نمیشود. برای دسترسی کامل نقش مدیر را بدهید.</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<button type="button" class="btn ghost" @click="editOpen = false">انصراف</button>
|
||||
|
||||
@@ -51,7 +51,8 @@ async function loadOwners() {
|
||||
const { data } = await platformApi.get<PlatformOrganizationDto>(
|
||||
`/platform/organizations/${orgId.value}`,
|
||||
);
|
||||
owners.value = (data.members ?? []).filter((member) => member.role === 'owner');
|
||||
const ownersInOrder = (data.members ?? []).filter((member) => member.role === 'owner');
|
||||
owners.value = ownersInOrder.slice(0, 1);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری اشتراک محیط ناموفق بود');
|
||||
} finally {
|
||||
|
||||
@@ -554,7 +554,8 @@ onMounted(() => {
|
||||
<div class="member-actions">
|
||||
<select
|
||||
:value="member.role"
|
||||
:disabled="busyId === `role-${member.membershipId}`"
|
||||
:disabled="member.role === 'owner' || busyId === `role-${member.membershipId}`"
|
||||
:title="member.role === 'owner' ? 'مالک همان ثبتنامکننده است و عوض نمیشود' : undefined"
|
||||
@change="
|
||||
setMembershipRole(
|
||||
member.membershipId,
|
||||
@@ -562,7 +563,11 @@ onMounted(() => {
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="role in USER_ROLES" :key="role" :value="role">
|
||||
<option
|
||||
v-for="role in USER_ROLES.filter((role) => role !== 'owner' || member.role === 'owner')"
|
||||
:key="role"
|
||||
:value="role"
|
||||
>
|
||||
{{ USER_ROLE_LABELS[role] }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
@@ -247,7 +247,8 @@ onMounted(() => {
|
||||
<div class="member-actions">
|
||||
<select
|
||||
:value="mem.role"
|
||||
:disabled="busyId === `role-${mem.membershipId}`"
|
||||
:disabled="mem.role === 'owner' || busyId === `role-${mem.membershipId}`"
|
||||
:title="mem.role === 'owner' ? 'مالک همان ثبتنامکننده است و عوض نمیشود' : undefined"
|
||||
@change="
|
||||
setMembershipRole(
|
||||
user.id,
|
||||
@@ -256,7 +257,11 @@ onMounted(() => {
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="role in USER_ROLES" :key="role" :value="role">
|
||||
<option
|
||||
v-for="role in USER_ROLES.filter((role) => role !== 'owner' || mem.role === 'owner')"
|
||||
:key="role"
|
||||
:value="role"
|
||||
>
|
||||
{{ USER_ROLE_LABELS[role] }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user