Multi-environment and multi-user construction
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { USER_ROLE_LABELS } from '@/lib/roles';
|
||||
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
|
||||
import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const orgs = ref<OrganizationMembershipDto[]>([]);
|
||||
const open = ref(false);
|
||||
const createOpen = ref(false);
|
||||
const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
const error = ref('');
|
||||
const newName = ref('');
|
||||
const newField = ref<BusinessField>('personal');
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
const menuEl = ref<HTMLElement | null>(null);
|
||||
const menuStyle = ref<Record<string, string>>({});
|
||||
let ignoreDocClickUntil = 0;
|
||||
|
||||
const currentName = computed(
|
||||
() => auth.user?.organizationName || orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || 'محیط کاری',
|
||||
);
|
||||
|
||||
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 loadOrgs() {
|
||||
try {
|
||||
orgs.value = await auth.listOrganizations();
|
||||
} catch {
|
||||
orgs.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
error.value = '';
|
||||
if (open.value) {
|
||||
open.value = false;
|
||||
return;
|
||||
}
|
||||
ignoreDocClickUntil = Date.now() + 400;
|
||||
open.value = true;
|
||||
await nextTick();
|
||||
placeMenu();
|
||||
await loadOrgs();
|
||||
}
|
||||
|
||||
function placeMenu() {
|
||||
const btn = rootEl.value?.querySelector('button');
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
const box = btn.getBoundingClientRect();
|
||||
menuStyle.value = {
|
||||
bottom: `${Math.round(window.innerHeight - box.top + 6)}px`,
|
||||
right: `${Math.round(window.innerWidth - box.right)}px`,
|
||||
minWidth: `${Math.round(Math.max(box.width, 200))}px`,
|
||||
};
|
||||
}
|
||||
|
||||
async function choose(org: OrganizationMembershipDto) {
|
||||
if (org.id === auth.user?.organizationId) {
|
||||
open.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.switchOrganization(org.id);
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر محیط ناموفق بود');
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
open.value = false;
|
||||
newName.value = '';
|
||||
newField.value = 'personal';
|
||||
error.value = '';
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const name = newName.value.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام محیط باید حداقل دو حرف باشد.';
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.createOrganization({
|
||||
name,
|
||||
businessField: newField.value,
|
||||
});
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ساخت محیط ناموفق بود');
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDocClick(event: MouseEvent) {
|
||||
if (!open.value || Date.now() < ignoreDocClickUntil) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadOrgs();
|
||||
document.addEventListener('click', onDocClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="orgs.length > 1" ref="rootEl" class="org-switch">
|
||||
<button
|
||||
class="org-btn"
|
||||
:class="{ compact }"
|
||||
type="button"
|
||||
:title="currentName"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="building" :size="18" />
|
||||
<span>{{ currentName }}</span>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="menuEl"
|
||||
class="menu"
|
||||
:style="menuStyle"
|
||||
role="listbox"
|
||||
>
|
||||
<p class="hint">ورود به محیط</p>
|
||||
<button
|
||||
v-for="org in orgs"
|
||||
:key="org.id"
|
||||
type="button"
|
||||
class="org-item"
|
||||
:class="{ on: org.id === auth.user?.organizationId }"
|
||||
:disabled="loading"
|
||||
@click="choose(org)"
|
||||
>
|
||||
<strong>{{ org.name }}</strong>
|
||||
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
|
||||
</button>
|
||||
<button type="button" class="create" :disabled="loading" @click="openCreate">
|
||||
محیط جدید
|
||||
</button>
|
||||
<p v-if="error && !createOpen" class="err">{{ error }}</p>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<UiModal :open="createOpen" title="محیط جدید" @close="createOpen = false">
|
||||
<form class="form" @submit.prevent="submitCreate">
|
||||
<label>
|
||||
نام محیط
|
||||
<input v-model="newName" required minlength="2" placeholder="مثلاً شرکت یا کار روزمره" />
|
||||
</label>
|
||||
<label>
|
||||
زمینه فعالیت
|
||||
<select v-model="newField">
|
||||
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
|
||||
{{ BUSINESS_FIELD_LABELS[field] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<button type="button" class="ghost" @click="createOpen = false">انصراف</button>
|
||||
<button type="button" class="primary" :disabled="creating" @click="submitCreate">
|
||||
{{ creating ? 'در حال ساخت…' : 'ساخت و ورود' }}
|
||||
</button>
|
||||
</template>
|
||||
</UiModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.org-switch {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.org-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
padding: 0 0.45rem 0.15rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.org-btn span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-btn:hover {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.org-btn.compact {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
padding: 0;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.org-btn.compact span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.org-btn.compact:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
padding: 0.45rem;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.org-item,
|
||||
.create {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 9px;
|
||||
padding: 0.45rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.org-item strong {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.org-item span {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.org-item:hover,
|
||||
.create:hover {
|
||||
background: color-mix(in srgb, var(--accent-soft) 70%, white);
|
||||
}
|
||||
|
||||
.org-item.on {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.create {
|
||||
margin-top: 0.15rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #b91c1c;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.form input,
|
||||
.form select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.ghost,
|
||||
.primary {
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@ import { formatJalaliShort, toFaDigits } from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
import QuickCreate from '@/components/QuickCreate.vue';
|
||||
import BrandName from '@/components/brand/BrandName.vue';
|
||||
import OrgSwitcher from '@/components/OrgSwitcher.vue';
|
||||
import NavHubPanel from '@/components/nav/NavHubPanel.vue';
|
||||
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
|
||||
import type {
|
||||
@@ -523,10 +523,13 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="logout-link" type="button" title="خروج از حساب" @click="onLogout">
|
||||
<UiIcon name="logout" :size="18" />
|
||||
<span>خروج از حساب</span>
|
||||
</button>
|
||||
<div class="foot-actions">
|
||||
<OrgSwitcher :compact="collapsed" />
|
||||
<button class="logout-link" type="button" title="خروج از حساب" @click="onLogout">
|
||||
<UiIcon name="logout" :size="18" />
|
||||
<span>خروج از حساب</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="collapse-btn desktop-only"
|
||||
type="button"
|
||||
@@ -1079,6 +1082,11 @@ onUnmounted(() => {
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.foot-actions {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,12 +2,17 @@ import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import type {
|
||||
AuthUserDto,
|
||||
CreateOrganizationRequest,
|
||||
LoginChallengeResponse,
|
||||
LoginOrganizationChoiceResponse,
|
||||
LoginResponse,
|
||||
LoginResult,
|
||||
OrganizationMembershipDto,
|
||||
RegisterChallengeResponse,
|
||||
RegisterOrganizationRequest,
|
||||
UpdateProfileRequest,
|
||||
} from '@mento/shared';
|
||||
import { isLoginOrganizationChoice } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const TOKEN_KEY = 'mento_token';
|
||||
@@ -79,11 +84,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
async function login(identifier: string, password: string) {
|
||||
const { data } = await api.post<LoginResponse>('/auth/login', {
|
||||
const { data } = await api.post<LoginResult>('/auth/login', {
|
||||
identifier,
|
||||
password,
|
||||
});
|
||||
persist(data);
|
||||
return applyLoginResult(data);
|
||||
}
|
||||
|
||||
async function requestLoginOtp(mobile: string) {
|
||||
@@ -94,10 +99,40 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
async function verifyLoginOtp(challengeId: string, code: string) {
|
||||
const { data } = await api.post<LoginResponse>('/auth/login/otp/verify', {
|
||||
const { data } = await api.post<LoginResult>('/auth/login/otp/verify', {
|
||||
challengeId,
|
||||
code,
|
||||
});
|
||||
return applyLoginResult(data);
|
||||
}
|
||||
|
||||
function applyLoginResult(data: LoginResult): LoginOrganizationChoiceResponse | void {
|
||||
if (isLoginOrganizationChoice(data)) return data;
|
||||
persist(data);
|
||||
}
|
||||
|
||||
async function selectOrganization(selectionToken: string, organizationId: string) {
|
||||
const { data } = await api.post<LoginResponse>('/auth/select-organization', {
|
||||
selectionToken,
|
||||
organizationId,
|
||||
});
|
||||
persist(data);
|
||||
}
|
||||
|
||||
async function switchOrganization(organizationId: string) {
|
||||
const { data } = await api.post<LoginResponse>('/auth/switch-organization', {
|
||||
organizationId,
|
||||
});
|
||||
persist(data);
|
||||
}
|
||||
|
||||
async function listOrganizations() {
|
||||
const { data } = await api.get<OrganizationMembershipDto[]>('/auth/organizations');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function createOrganization(payload: CreateOrganizationRequest) {
|
||||
const { data } = await api.post<LoginResponse>('/organizations', payload);
|
||||
persist(data);
|
||||
}
|
||||
|
||||
@@ -174,6 +209,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
login,
|
||||
requestLoginOtp,
|
||||
verifyLoginOtp,
|
||||
selectOrganization,
|
||||
switchOrganization,
|
||||
listOrganizations,
|
||||
createOrganization,
|
||||
startRegister,
|
||||
verifyRegister,
|
||||
logout,
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue';
|
||||
import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
import axios from 'axios';
|
||||
import type { LoginChallengeResponse } from '@mento/shared';
|
||||
import type { LoginChallengeResponse, LoginOrganizationChoiceResponse } from '@mento/shared';
|
||||
import { USER_ROLE_LABELS } from '@/lib/roles';
|
||||
import { brand } from '@mento/shared';
|
||||
import BrandName from '@/components/brand/BrandName.vue';
|
||||
|
||||
@@ -24,6 +25,7 @@ const showPassword = ref(false);
|
||||
const remember = ref(true);
|
||||
const otpCode = ref('');
|
||||
const challenge = ref<LoginChallengeResponse | null>(null);
|
||||
const pendingChoice = ref<LoginOrganizationChoiceResponse | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -48,12 +50,20 @@ async function goHome() {
|
||||
await router.push(redirect);
|
||||
}
|
||||
|
||||
async function afterAuth(result: LoginOrganizationChoiceResponse | void) {
|
||||
if (result) {
|
||||
pendingChoice.value = result;
|
||||
return;
|
||||
}
|
||||
await goHome();
|
||||
}
|
||||
|
||||
async function submitPasswordLogin() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.login(identifier.value.trim(), password.value);
|
||||
await goHome();
|
||||
const result = await auth.login(identifier.value.trim(), password.value);
|
||||
await afterAuth(result);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ورود ناموفق بود');
|
||||
} finally {
|
||||
@@ -80,8 +90,8 @@ async function submitOtpCode() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.verifyLoginOtp(challenge.value.challengeId, otpCode.value.trim());
|
||||
await goHome();
|
||||
const result = await auth.verifyLoginOtp(challenge.value.challengeId, otpCode.value.trim());
|
||||
await afterAuth(result);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تأیید کد ناموفق بود');
|
||||
} finally {
|
||||
@@ -103,6 +113,25 @@ function backToOtpMobile() {
|
||||
challenge.value = null;
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
async function pickOrganization(organizationId: string) {
|
||||
if (!pendingChoice.value) return;
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.selectOrganization(pendingChoice.value.selectionToken, organizationId);
|
||||
await goHome();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'انتخاب محیط ناموفق بود');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelOrgChoice() {
|
||||
pendingChoice.value = null;
|
||||
error.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,7 +148,33 @@ function backToOtpMobile() {
|
||||
|
||||
<div class="form-inner">
|
||||
<h1>خوش آمدید 👋</h1>
|
||||
<p class="lead">برای ورود به حساب کاربری خود، اطلاعاتتان را وارد کنید</p>
|
||||
<p class="lead">
|
||||
{{
|
||||
pendingChoice
|
||||
? 'در کدام محیط میخواهید وارد شوید؟'
|
||||
: 'برای ورود به حساب کاربری خود، اطلاعاتتان را وارد کنید'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<template v-if="pendingChoice">
|
||||
<button type="button" class="back" @click="cancelOrgChoice">بازگشت</button>
|
||||
<div class="org-list">
|
||||
<button
|
||||
v-for="org in pendingChoice.organizations"
|
||||
:key="org.id"
|
||||
type="button"
|
||||
class="org-card"
|
||||
:disabled="loading"
|
||||
@click="pickOrganization(org.id)"
|
||||
>
|
||||
<strong>{{ org.name }}</strong>
|
||||
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<div class="mode-tabs" role="tablist">
|
||||
<button
|
||||
@@ -257,6 +312,7 @@ function backToOtpMobile() {
|
||||
حساب کاربری ندارید؟
|
||||
<RouterLink to="/register">ثبتنام کنید</RouterLink>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -503,6 +559,36 @@ h1 {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.org-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.org-card {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
text-align: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem 1rem;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.org-card:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent-soft) 55%, white);
|
||||
}
|
||||
|
||||
.org-card strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.org-card span {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
width: min(1040px, 100%);
|
||||
margin: 0.85rem auto 0;
|
||||
|
||||
@@ -296,8 +296,12 @@ function openInvite() {
|
||||
}
|
||||
|
||||
async function submitInvite() {
|
||||
if (!inviteName.value.trim() || !inviteMobile.value.trim() || invitePassword.value.length < 8) {
|
||||
error.value = 'نام، شماره موبایل و رمز حداقل ۸ کاراکتر لازم است.';
|
||||
if (!inviteName.value.trim() || !inviteMobile.value.trim()) {
|
||||
error.value = 'نام و شماره موبایل لازم است.';
|
||||
return;
|
||||
}
|
||||
if (invitePassword.value && invitePassword.value.length < 8) {
|
||||
error.value = 'رمز عبور در صورت ورود باید حداقل ۸ کاراکتر باشد.';
|
||||
return;
|
||||
}
|
||||
inviteSaving.value = true;
|
||||
@@ -306,12 +310,12 @@ async function submitInvite() {
|
||||
const payload: InviteUserRequest = {
|
||||
mobile: inviteMobile.value.trim(),
|
||||
fullName: inviteName.value.trim(),
|
||||
password: invitePassword.value,
|
||||
orgRoleId: inviteRoleId.value || undefined,
|
||||
...(invitePassword.value ? { password: invitePassword.value } : {}),
|
||||
};
|
||||
await api.post('/users', payload);
|
||||
inviteOpen.value = false;
|
||||
ok.value = 'کاربر جدید اضافه شد.';
|
||||
ok.value = 'کاربر به این محیط اضافه شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'افزودن کاربر ناموفق بود');
|
||||
@@ -826,7 +830,11 @@ onUnmounted(() => {
|
||||
<form class="form" @submit.prevent="submitInvite">
|
||||
<label>نام و نام خانوادگی<input v-model="inviteName" required minlength="2" /></label>
|
||||
<label>شماره موبایل<input v-model="inviteMobile" dir="ltr" inputmode="tel" placeholder="0912…" /></label>
|
||||
<label>رمز عبور<input v-model="invitePassword" type="password" minlength="8" /></label>
|
||||
<label>
|
||||
رمز عبور
|
||||
<input v-model="invitePassword" type="password" minlength="8" placeholder="فقط برای کاربر جدید" />
|
||||
</label>
|
||||
<p class="hint">اگر این شماره از قبل حساب دارد، بدون رمز به همین محیط اضافه میشود.</p>
|
||||
<label>
|
||||
نقش
|
||||
<select v-model="inviteRoleId">
|
||||
@@ -927,6 +935,7 @@ h1 {
|
||||
|
||||
.lead,
|
||||
.muted,
|
||||
.hint,
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
|
||||
Reference in New Issue
Block a user