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:
2026-10-17 19:57:26 +03:30
co-authored by Cursor
parent 52c7306e78
commit f7b07f6b7a
13 changed files with 435 additions and 49 deletions
+33 -18
View File
@@ -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="{
+6 -2
View File
@@ -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;
});
+9
View File
@@ -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,
};
});
+38 -2
View File
@@ -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) {