Files
CRM_Montzar/apps/web/src/views/adm/AdmUsersView.vue
T
MahdiandCursor 25a24772fc 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>
2026-11-10 14:59:32 +03:30

532 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type { PlatformUserDto, UserRole } from '@mento/shared';
import { USER_ROLE_LABELS, USER_ROLES } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const query = ref('');
const ownersOnly = ref(false);
const loading = ref(false);
const error = ref('');
const success = ref('');
const items = ref<PlatformUserDto[]>([]);
const openId = ref<string | null>(null);
const busyId = ref('');
const resetUserId = ref<string | null>(null);
const resetPassword = ref('');
const resetting = ref(false);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformUserDto[]>('/platform/users', {
params: {
...(query.value.trim() ? { q: query.value.trim() } : {}),
...(ownersOnly.value ? { owners: '1' } : {}),
},
});
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری کاربران ناموفق بود');
} finally {
loading.value = false;
}
}
function toggleOpen(id: string) {
openId.value = openId.value === id ? null : id;
if (resetUserId.value !== id) {
resetUserId.value = null;
resetPassword.value = '';
}
}
function startReset(userId: string) {
resetUserId.value = resetUserId.value === userId ? null : userId;
resetPassword.value = '';
error.value = '';
success.value = '';
}
async function submitReset(userId: string) {
resetting.value = true;
error.value = '';
success.value = '';
try {
await platformApi.patch(`/platform/users/${userId}/password`, {
password: resetPassword.value,
});
success.value = 'رمز عبور به‌روز شد';
resetUserId.value = null;
resetPassword.value = '';
} catch (e) {
error.value = apiError(e, 'ریست رمز ناموفق بود');
} finally {
resetting.value = false;
}
}
async function setUserActive(user: PlatformUserDto, isActive: boolean) {
busyId.value = user.id;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformUserDto>(
`/platform/users/${user.id}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === user.id ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت کاربر ناموفق بود');
} finally {
busyId.value = '';
}
}
async function setMembershipActive(userId: string, membershipId: string, isActive: boolean) {
busyId.value = membershipId;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformUserDto>(
`/platform/memberships/${membershipId}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر عضویت ناموفق بود');
} finally {
busyId.value = '';
}
}
async function setMembershipRole(userId: string, membershipId: string, role: UserRole) {
busyId.value = `role-${membershipId}`;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformUserDto>(
`/platform/memberships/${membershipId}/role`,
{ role },
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر نقش ناموفق بود');
} finally {
busyId.value = '';
}
}
function setOwnersOnly(value: boolean) {
if (ownersOnly.value === value) return;
ownersOnly.value = value;
void load();
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<header class="head">
<p class="lead">نقش عضویت و ریست رمز. اشتراک از منوی اشتراک تنظیم می‌شود.</p>
<div class="head-actions">
<div class="seg" role="group" aria-label="فیلتر مالکان">
<button
type="button"
:class="{ on: !ownersOnly }"
:aria-pressed="!ownersOnly"
:disabled="loading"
@click="setOwnersOnly(false)"
>
همه
</button>
<button
type="button"
:class="{ on: ownersOnly }"
:aria-pressed="ownersOnly"
:disabled="loading"
@click="setOwnersOnly(true)"
>
فقط مالکان
</button>
</div>
<form class="search" @submit.prevent="load">
<input v-model="query" type="search" placeholder="نام، ایمیل یا موبایل" />
<button class="btn" type="submit" :disabled="loading">جستجو</button>
</form>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">
{{ ownersOnly ? 'مالکی پیدا نشد.' : 'کاربری پیدا نشد.' }}
</p>
<div v-else class="list">
<article v-for="user in items" :key="user.id" class="row">
<button type="button" class="row-main" @click="toggleOpen(user.id)">
<div>
<strong>
{{ user.fullName }}
<em v-if="user.isBillingOwner" class="owner-tag">مالک</em>
</strong>
<span>{{ user.email }}{{ user.mobile ? ` · ${user.mobile}` : '' }}</span>
</div>
<div class="meta">
<em :class="user.isActive ? 'on' : 'off'">{{ user.isActive ? 'فعال' : 'غیرفعال' }}</em>
<small>
{{ toFaDigits(user.memberships.length) }} محیط
<template v-if="user.lastLoginAt">
· آخرین ورود {{ formatJalaliNumeric(new Date(user.lastLoginAt)) }}
</template>
</small>
</div>
</button>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="startReset(user.id)">
ریست رمز
</button>
<button
class="btn"
type="button"
:disabled="busyId === user.id"
@click="setUserActive(user, !user.isActive)"
>
{{ user.isActive ? 'غیرفعال کردن حساب' : 'فعال کردن حساب' }}
</button>
</div>
<form
v-if="resetUserId === user.id"
class="reset-panel"
@submit.prevent="submitReset(user.id)"
>
<label>
<span>رمز جدید (حداقل ۸ کاراکتر)</span>
<input
v-model="resetPassword"
type="password"
required
minlength="8"
autocomplete="new-password"
/>
</label>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="resetting">
{{ resetting ? 'در حال ذخیره…' : 'ذخیره رمز' }}
</button>
<button class="btn btn-ghost" type="button" @click="startReset(user.id)">
انصراف
</button>
</div>
</form>
<div v-if="openId === user.id" class="detail">
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
<ul v-else>
<li v-for="mem in user.memberships" :key="mem.membershipId">
<div>
<strong>{{ mem.organizationName }}</strong>
<span>
محیط {{ mem.organizationIsActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
<div class="member-actions">
<select
:value="mem.role"
:disabled="mem.role === 'owner' || busyId === `role-${mem.membershipId}`"
:title="mem.role === 'owner' ? 'مالک همان ثبت‌نام‌کننده است و عوض نمی‌شود' : undefined"
@change="
setMembershipRole(
user.id,
mem.membershipId,
($event.target as HTMLSelectElement).value as UserRole,
)
"
>
<option
v-for="role in USER_ROLES.filter((role) => role !== 'owner' || mem.role === 'owner')"
:key="role"
:value="role"
>
{{ USER_ROLE_LABELS[role] }}
</option>
</select>
<button
class="btn btn-ghost"
type="button"
:disabled="busyId === mem.membershipId"
@click="setMembershipActive(user.id, mem.membershipId, !mem.isActive)"
>
{{ mem.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }}
</button>
</div>
</li>
</ul>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.95rem;
max-width: 1080px;
}
.head {
display: flex;
justify-content: space-between;
gap: 0.85rem;
flex-wrap: wrap;
align-items: center;
}
.head-actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
align-items: center;
}
.seg {
display: flex;
padding: 0.25rem;
border-radius: 14px;
background: #fff;
border: 1px solid var(--line-light);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.seg button {
border: 0;
background: transparent;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
color: var(--muted);
padding: 0.5rem 0.75rem;
border-radius: 10px;
cursor: pointer;
}
.seg button.on {
background: #eef2ff;
color: #312e81;
}
.owner-tag {
display: inline-block;
margin-inline-start: 0.4rem;
padding: 0.08rem 0.42rem;
border-radius: 999px;
background: #eef2ff;
color: #312e81;
font-size: 0.72rem;
font-style: normal;
font-weight: 800;
vertical-align: middle;
}
.lead,
.head p,
.muted {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.search {
display: flex;
gap: 0.45rem;
padding: 0.25rem;
border-radius: 14px;
background: #fff;
border: 1px solid var(--line-light);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.search input,
.reset-panel input,
.member-actions select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.6rem 0.75rem;
font: inherit;
background: #fff;
}
.search input {
min-width: 180px;
border: 0;
box-shadow: none;
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.list {
display: grid;
gap: 0.7rem;
}
.row {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 0.95rem 1rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.7rem 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.035);
}
.row-main {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 0;
background: transparent;
text-align: start;
cursor: pointer;
padding: 0;
font: inherit;
color: inherit;
}
.row-main span,
.row-main small {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-top: 0.2rem;
}
.meta {
text-align: end;
}
.actions {
display: flex;
gap: 0.45rem;
align-items: flex-start;
flex-wrap: wrap;
}
.on,
.off {
font-style: normal;
font-size: 0.78rem;
font-weight: 800;
}
.on {
color: var(--success-deep);
}
.off {
color: var(--danger);
}
.reset-panel {
grid-column: 1 / -1;
background: #f8fafc;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.85rem;
display: grid;
gap: 0.7rem;
}
.reset-panel label {
display: grid;
gap: 0.35rem;
}
.reset-panel span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
.detail {
grid-column: 1 / -1;
border-top: 1px solid var(--line);
padding-top: 0.75rem;
}
.detail ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.detail li {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: center;
flex-wrap: wrap;
}
.detail span {
display: block;
color: var(--muted);
font-size: 0.8rem;
}
.billing-skip {
margin: 0 0 0.75rem;
}
.member-actions {
display: flex;
gap: 0.45rem;
align-items: center;
}
@media (max-width: 720px) {
.search {
width: 100%;
}
.search input {
flex: 1;
min-width: 0;
}
.row {
grid-template-columns: 1fr;
}
}
</style>