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>
246 lines
6.4 KiB
TypeScript
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,
|
|
};
|
|
});
|