feat: owner read-only view-as from team users table
Allow org owners to inspect any member's data scope via dual JWT; server blocks mutations and team page stays reachable for return. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,7 +26,15 @@ const route = useRoute();
|
||||
const { unreadTotal: chatUnreadTotal, ensureConnected: ensureChatConnected } = useOrgChat();
|
||||
const chatBadge = computed(() => chatUnreadTotal.value);
|
||||
|
||||
const canOpenTeamWhileViewing = computed(() =>
|
||||
Boolean(auth.user?.viewAs?.canOpenTeamSettings),
|
||||
);
|
||||
/** تنظیمات مدیریتی واقعی (نقش مشاهدهشده) */
|
||||
const canSettings = computed(() => canManageSettings(auth.user?.role));
|
||||
/** نمایش هاب تنظیمات؛ در مشاهده فقطخواندنی فقط صفحهٔ تیم باز میماند */
|
||||
const showSettingsNav = computed(
|
||||
() => canSettings.value || canOpenTeamWhileViewing.value,
|
||||
);
|
||||
const showOwnRecordsBanner = computed(
|
||||
() => Boolean(auth.user?.role) && !canViewAllRecords(auth.user?.role),
|
||||
);
|
||||
@@ -142,15 +150,30 @@ function moduleAllowed(module?: string) {
|
||||
return enabledModules.value.includes(module as never);
|
||||
}
|
||||
|
||||
function cardAllowedForNav(card: {
|
||||
ownerOnly?: boolean;
|
||||
admin?: boolean;
|
||||
module?: string;
|
||||
to?: string;
|
||||
}) {
|
||||
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
|
||||
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false;
|
||||
if (!moduleAllowed(card.module)) return false;
|
||||
if (
|
||||
canOpenTeamWhileViewing.value &&
|
||||
!canSettings.value &&
|
||||
card.to &&
|
||||
!card.to.startsWith('/settings/team')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const hubVisible = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const hub of NAV_HUBS) {
|
||||
map[hub.key] = hub.cards.some(
|
||||
(card) =>
|
||||
(!card.ownerOnly || auth.user?.role === 'owner') &&
|
||||
(!card.admin || canSettings.value) &&
|
||||
moduleAllowed(card.module),
|
||||
);
|
||||
map[hub.key] = hub.cards.some((card) => cardAllowedForNav(card));
|
||||
}
|
||||
return map;
|
||||
});
|
||||
@@ -159,12 +182,7 @@ const openHub = computed<NavHubDef | null>(() => {
|
||||
if (!openHubKey.value) return null;
|
||||
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
||||
if (!hub) return null;
|
||||
const cards = hub.cards.filter(
|
||||
(card) =>
|
||||
(!card.ownerOnly || auth.user?.role === 'owner') &&
|
||||
(!card.admin || canSettings.value) &&
|
||||
moduleAllowed(card.module),
|
||||
);
|
||||
const cards = hub.cards.filter((card) => cardAllowedForNav(card));
|
||||
if (
|
||||
hub.key === 'customers' &&
|
||||
isMobileNav.value &&
|
||||
@@ -183,10 +201,7 @@ const openHub = computed<NavHubDef | null>(() => {
|
||||
return {
|
||||
...hub,
|
||||
cards,
|
||||
shortcuts: hub.shortcuts.filter(
|
||||
(item) =>
|
||||
(!item.admin || canSettings.value) && moduleAllowed(item.module),
|
||||
),
|
||||
shortcuts: hub.shortcuts.filter((item) => cardAllowedForNav(item)),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -657,7 +672,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="canSettings"
|
||||
v-if="showSettingsNav"
|
||||
class="nav-fold"
|
||||
:class="{ active: settingsActive || openHubKey === 'settings' }"
|
||||
>
|
||||
@@ -1027,7 +1042,7 @@ onUnmounted(() => {
|
||||
<i class="chev" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canSettings"
|
||||
v-if="showSettingsNav"
|
||||
class="nav-item nav-fold-btn"
|
||||
type="button"
|
||||
:class="{
|
||||
|
||||
@@ -500,9 +500,13 @@ router.beforeEach((to) => {
|
||||
const roles = to.meta.roles as UserRole[] | undefined;
|
||||
if (roles?.length) {
|
||||
const role = auth.user?.role as UserRole | undefined;
|
||||
if (!role || !roles.includes(role)) {
|
||||
return { name: 'home' };
|
||||
if (role && roles.includes(role)) {
|
||||
return true;
|
||||
}
|
||||
if (to.name === 'team-settings' && auth.user?.viewAs?.canOpenTeamSettings) {
|
||||
return true;
|
||||
}
|
||||
return { name: 'home' };
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -199,6 +199,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
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();
|
||||
|
||||
return {
|
||||
@@ -221,5 +229,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
updateProfile,
|
||||
uploadAvatar,
|
||||
removeAvatar,
|
||||
viewAs,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -102,6 +102,12 @@ const loadedAvatars = new Set<string>();
|
||||
const canInvite = computed(() => canManageUsers(auth.user?.role));
|
||||
const canEditOrg = computed(() => canManageSettings(auth.user?.role));
|
||||
const canEditRoles = computed(() => canManageUsers(auth.user?.role));
|
||||
/** مالک واقعی میتواند از جدول کاربران وارد مشاهده شود / برگردد */
|
||||
const canViewAsUsers = computed(
|
||||
() =>
|
||||
auth.user?.role === 'owner' || Boolean(auth.user?.viewAs?.canOpenTeamSettings),
|
||||
);
|
||||
const viewAsBusy = ref(false);
|
||||
|
||||
const selectedRole = computed(
|
||||
() => roles.value.find((r) => r.id === selectedRoleId.value) ?? null,
|
||||
@@ -232,8 +238,38 @@ function pickRole(id: string) {
|
||||
menuUserId.value = null;
|
||||
}
|
||||
|
||||
function onUserActivate(user: OrgUserDto) {
|
||||
if (user.orgRoleId) pickRole(user.orgRoleId);
|
||||
async function onUserActivate(user: OrgUserDto) {
|
||||
if (!canViewAsUsers.value) {
|
||||
if (user.orgRoleId) pickRole(user.orgRoleId);
|
||||
return;
|
||||
}
|
||||
const impersonatorId = auth.user?.viewAs?.impersonatorId;
|
||||
if (impersonatorId && user.id === impersonatorId) {
|
||||
await switchViewAs(user.id);
|
||||
return;
|
||||
}
|
||||
if (user.id === auth.user?.id) {
|
||||
if (user.orgRoleId) pickRole(user.orgRoleId);
|
||||
return;
|
||||
}
|
||||
await switchViewAs(user.id);
|
||||
}
|
||||
|
||||
async function switchViewAs(targetUserId: string) {
|
||||
if (viewAsBusy.value) return;
|
||||
viewAsBusy.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await auth.viewAs(targetUserId);
|
||||
ok.value = auth.user?.viewAs
|
||||
? `در حال مشاهده بهجای ${auth.user.fullName}`
|
||||
: 'به حساب خودتان برگشتید.';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر حالت مشاهده ناموفق بود.');
|
||||
} finally {
|
||||
viewAsBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLastLogin(iso: string | null | undefined) {
|
||||
|
||||
Reference in New Issue
Block a user