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
+352
View File
@@ -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>
+13 -5
View File
@@ -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;
+42 -3
View File
@@ -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,
+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;