Files
CRM_Montzar/apps/web/src/stores/auth.ts
T
MahdiandCursor 0e1bc4a1fe fix(access): honor finance sections and team-member data scope
Role treasury keys now open the first allowed finance page, and team-member ownership limits lists, dashboard, and reports instead of the whole organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 11:20:20 +03:30

246 lines
6.4 KiB
TypeScript

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';
const USER_KEY = 'mento_user';
const LEGACY_KEYS = ['ment_token', 'ment_user'] as const;
const DEFAULT_AVATAR = '/brand/user-avatar.png';
function readStoredToken(): string | null {
return localStorage.getItem(TOKEN_KEY) ?? localStorage.getItem('ment_token');
}
function readStoredUser(): AuthUserDto | null {
const raw = localStorage.getItem(USER_KEY) ?? localStorage.getItem('ment_user');
if (!raw) return null;
try {
return JSON.parse(raw) as AuthUserDto;
} catch {
return null;
}
}
function clearLegacy() {
for (const key of LEGACY_KEYS) localStorage.removeItem(key);
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(readStoredToken());
const user = ref<AuthUserDto | null>(readStoredUser());
const avatarObjectUrl = ref<string | null>(null);
const isAuthenticated = computed(() => Boolean(token.value));
const avatarSrc = computed(() => avatarObjectUrl.value || DEFAULT_AVATAR);
function clearAvatarUrl() {
if (avatarObjectUrl.value) {
URL.revokeObjectURL(avatarObjectUrl.value);
avatarObjectUrl.value = null;
}
}
async function refreshAvatar() {
if (!token.value || !user.value?.hasAvatar) {
clearAvatarUrl();
return;
}
try {
const { data } = await api.get<Blob>('/auth/me/avatar', {
responseType: 'blob',
});
if (!data || data.type.includes('json') || data.size === 0) {
clearAvatarUrl();
return;
}
const next = URL.createObjectURL(data);
clearAvatarUrl();
avatarObjectUrl.value = next;
} catch {
clearAvatarUrl();
}
}
function persist(payload: LoginResponse) {
token.value = payload.accessToken;
user.value = payload.user;
localStorage.setItem(TOKEN_KEY, payload.accessToken);
localStorage.setItem(USER_KEY, JSON.stringify(payload.user));
clearLegacy();
void refreshAvatar();
}
async function login(identifier: string, password: string) {
const { data } = await api.post<LoginResult>('/auth/login', {
identifier,
password,
});
return applyLoginResult(data);
}
async function requestLoginOtp(mobile: string) {
const { data } = await api.post<LoginChallengeResponse>('/auth/login/otp', {
mobile,
});
return data;
}
async function verifyLoginOtp(challengeId: string, code: string) {
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);
}
async function startRegister(payload: RegisterOrganizationRequest) {
const { data } = await api.post<RegisterChallengeResponse>(
'/auth/register',
payload,
);
return data;
}
async function verifyRegister(
challengeId: string,
mobileCode: string,
emailCode?: string,
) {
const { data } = await api.post<LoginResponse>('/auth/register/verify', {
challengeId,
mobileCode,
...(emailCode ? { emailCode } : {}),
});
persist(data);
}
async function logout() {
try {
if (token.value) await api.post('/auth/logout');
} catch {
/* نشست ممکن است از قبل بسته شده باشد */
}
token.value = null;
user.value = null;
clearAvatarUrl();
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearLegacy();
}
function setUser(next: AuthUserDto) {
user.value = next;
localStorage.setItem(USER_KEY, JSON.stringify(next));
void refreshAvatar();
}
async function hydrate() {
if (!token.value) return;
try {
const { data } = await api.get<AuthUserDto>('/auth/me');
setUser(data);
} catch {
/* interceptor handles 401 */
}
}
async function updateProfile(payload: UpdateProfileRequest) {
const { data } = await api.patch<AuthUserDto>('/auth/me', payload);
setUser(data);
}
async function uploadAvatar(file: File) {
const body = new FormData();
body.append('file', file);
const { data } = await api.post<AuthUserDto>('/auth/me/avatar', body);
setUser(data);
}
async function removeAvatar() {
const { data } = await api.delete<AuthUserDto>('/auth/me/avatar');
setUser(data);
}
/** مشاهده فقط‌خواندنی به‌جای کاربر، یا بازگشت با targetUserId = خود مالک */
async function viewAs(targetUserId: string) {
const { data } = await api.post<LoginResponse>('/auth/view-as', {
targetUserId,
});
persist(data);
}
if (token.value) void hydrate();
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void hydrate();
});
}
return {
token,
user,
isAuthenticated,
avatarSrc,
login,
requestLoginOtp,
verifyLoginOtp,
selectOrganization,
switchOrganization,
listOrganizations,
createOrganization,
startRegister,
verifyRegister,
logout,
setUser,
hydrate,
updateProfile,
uploadAvatar,
removeAvatar,
viewAs,
};
});