Multi-environment and multi-user construction

This commit is contained in:
2026-09-15 21:37:58 +03:30
parent e61fad95c6
commit d3f97b9dc4
37 changed files with 1349 additions and 201 deletions
+92 -6
View File
@@ -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;
+14 -5
View File
@@ -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;