feat(auth): add view as user and role functionality for owners

Allow owners to impersonate users or roles for debugging permissions.
Add view-as targets to user sessions and resolve effective permissions.
Display a persistent banner in the app shell to exit view-as mode.
This commit is contained in:
2026-05-07 20:31:52 +08:00
parent ee054dcd15
commit 02db73aa4e
12 changed files with 411 additions and 3 deletions

View File

@@ -27,6 +27,7 @@ import { api, notifyAuthChange, onAuthChange, type AuthUser, type Language } fro
const { t, locale } = useI18n();
const router = useRouter();
const currentUser = ref<AuthUser | null>(null);
const viewAsBusy = ref(false);
const languages = ref<Language[]>([
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
@@ -134,6 +135,21 @@ async function logout() {
await router.push('/');
}
async function stopViewAs() {
if (viewAsBusy.value) {
return;
}
viewAsBusy.value = true;
try {
const response = await api.stopViewAs();
currentUser.value = response.user;
notifyAuthChange();
} finally {
viewAsBusy.value = false;
}
}
async function loadLanguages() {
try {
const loadedLanguages = await api.languages();
@@ -179,7 +195,9 @@ onUnmounted(() => {
:languages="languages"
:locale="locale"
:nav-items="navItems"
:view-as-busy="viewAsBusy"
@logout="logout"
@stop-view-as="stopViewAs"
@update:locale="updateLocale"
>
<NuxtPage :key="locale" />

View File

@@ -20,6 +20,7 @@ import GlobalSearch from './GlobalSearch.vue';
import NotificationBell from './NotificationBell.vue';
import PokeBallMark from './PokeBallMark.vue';
import StatusBadge from './StatusBadge.vue';
import ViewAsBanner from './ViewAsBanner.vue';
type NavBadge = {
label: string;
@@ -53,10 +54,12 @@ defineProps<{
languages: Language[];
locale: string;
navItems: NavItem[];
viewAsBusy?: boolean;
}>();
const emit = defineEmits<{
logout: [];
stopViewAs: [];
'update:locale': [value: string];
}>();
@@ -147,6 +150,10 @@ function requestLogout() {
emit('logout');
}
function requestStopViewAs() {
emit('stopViewAs');
}
function isDesktopSidebar() {
return typeof window !== 'undefined' && window.matchMedia('(min-width: 901px)').matches;
}
@@ -345,6 +352,13 @@ onBeforeUnmount(() => {
</div>
</header>
<ViewAsBanner
v-if="currentUser?.viewAs"
:view-as="currentUser.viewAs"
:busy="viewAsBusy"
@stop="requestStopViewAs"
/>
<button class="site-sidebar-scrim" type="button" :aria-label="t('nav.closeMenu')" @click="closeSidebar"></button>
<aside id="app-sidebar" class="site-sidebar" :aria-label="t('nav.main')">

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { useI18n } from 'vue-i18n';
import { iconClose, iconProfile } from '../icons';
import type { ViewAsSummary } from '../services/api';
defineProps<{
viewAs: ViewAsSummary;
busy?: boolean;
}>();
const emit = defineEmits<{
stop: [];
}>();
const { t } = useI18n();
</script>
<template>
<section class="view-as-banner" role="status" aria-live="polite">
<div class="view-as-banner__inner">
<span class="view-as-banner__label">
<Icon :icon="iconProfile" class="ui-icon" aria-hidden="true" />
{{ t('viewAs.banner', { name: viewAs.label }) }}
</span>
<button class="ui-button ui-button--ghost ui-button--small view-as-banner__button" type="button" :disabled="busy" @click="emit('stop')">
<Icon :icon="iconClose" class="ui-icon" aria-hidden="true" />
{{ t('viewAs.exit') }}
</button>
</div>
</section>
</template>

View File

@@ -24,6 +24,7 @@ export const iconEdit: AppIcon = 'mdi:pencil-outline';
export const iconError: AppIcon = 'mdi:close-circle-outline';
export const iconEvent: AppIcon = 'mdi:calendar-star';
export const iconExternal: AppIcon = 'mdi:open-in-new';
export const iconEye: AppIcon = 'mdi:eye-outline';
export const iconGitCommit: AppIcon = 'mdi:source-commit';
export const iconHabitat: AppIcon = 'mdi:pine-tree';
export const iconHome: AppIcon = 'mdi:home-variant-outline';

View File

@@ -777,6 +777,12 @@ export interface AuthUser {
emailVerified: boolean;
roles: RoleSummary[];
permissions: string[];
viewAs?: ViewAsSummary;
}
export interface ViewAsSummary {
mode: 'user' | 'role';
label: string;
}
export interface ReferralSummary {
@@ -1358,6 +1364,9 @@ export const api = {
resetPassword: (payload: { token: string; password: string }) =>
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
me: (options?: ApiRequestOptions) => getJson<{ user: AuthUser }>('/api/auth/me', options),
viewAsUser: (userId: string | number) => sendJson<{ user: AuthUser }>('/api/auth/view-as/user', 'POST', { userId }),
viewAsRole: (roleId: string | number) => sendJson<{ user: AuthUser }>('/api/auth/view-as/role', 'POST', { roleId }),
stopViewAs: () => sendJson<{ user: AuthUser }>('/api/auth/view-as/stop', 'POST', {}),
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
changePassword: (payload: ChangePasswordPayload) =>
sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload),

View File

@@ -370,6 +370,42 @@ svg {
padding: 0;
}
.view-as-banner {
position: sticky;
top: 64px;
z-index: 44;
grid-column: 2;
border-bottom: 1px solid rgba(31, 42, 59, 0.14);
background: #fff7cc;
}
.view-as-banner__inner {
width: min(100%, var(--container));
min-height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin: 0 auto;
padding: 8px 24px;
}
.view-as-banner__label {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--pokemon-blue-deep);
font-size: 14px;
font-weight: 900;
line-height: 1.25;
overflow-wrap: anywhere;
}
.view-as-banner__button {
flex: 0 0 auto;
}
.site-sidebar {
position: sticky;
top: 0;
@@ -7955,6 +7991,19 @@ button:disabled,
gap: 6px;
}
.view-as-banner {
top: 60px;
}
.view-as-banner__inner {
min-height: 48px;
padding: 8px 12px;
}
.view-as-banner__button {
min-height: 40px;
}
.sidebar-toggle {
width: 44px;
min-width: 44px;

View File

@@ -20,6 +20,7 @@ import {
iconDelete,
iconDish,
iconEdit,
iconEye,
iconHabitat,
iconItem,
iconKey,
@@ -35,6 +36,7 @@ import {
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
import {
api,
notifyAuthChange,
type AncientArtifact,
type AiModerationApiFormat,
type AiModerationAuthMode,
@@ -370,6 +372,7 @@ const activeConfigTab = computed({
}
});
const canEdit = computed(() => can('admin.access'));
const canUseViewAs = computed(() => currentUser.value?.roles.some((role) => role.key === 'owner') === true && !currentUser.value?.viewAs);
const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value));
const canSetLanguageDefault = computed(() => languageForm.value.code === 'en');
const configModalTitle = computed(() =>
@@ -822,6 +825,14 @@ function openUserRoles(user: AdminUser) {
userRoleModalOpen.value = true;
}
async function viewAsUser(user: AdminUser) {
await run(async () => {
const response = await api.viewAsUser(user.id);
currentUser.value = response.user;
notifyAuthChange();
});
}
function closeUserRoleModal() {
userRoleModalOpen.value = false;
resetUserRoleForm();
@@ -854,6 +865,14 @@ function editRolePermissions(role: RoleDetail) {
rolePermissionsModalOpen.value = true;
}
async function viewAsRole(role: RoleDetail) {
await run(async () => {
const response = await api.viewAsRole(role.id);
currentUser.value = response.user;
notifyAuthChange();
});
}
function closeRolePermissionsModal() {
rolePermissionsModalOpen.value = false;
resetRolePermissionForm();
@@ -1851,6 +1870,10 @@ onMounted(() => {
</span>
</span>
<span class="row-actions">
<button v-if="canUseViewAs" type="button" :disabled="busy" @click="viewAsUser(user)">
<Icon :icon="iconEye" class="ui-icon" aria-hidden="true" />
{{ t('viewAs.userAction') }}
</button>
<button v-if="can('admin.users.update') && can('admin.roles.read')" type="button" :disabled="busy" @click="openUserRoles(user)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.userRoles') }}
@@ -1883,6 +1906,10 @@ onMounted(() => {
</span>
</span>
<span class="row-actions">
<button v-if="canUseViewAs && role.enabled" type="button" :disabled="busy" @click="viewAsRole(role)">
<Icon :icon="iconEye" class="ui-icon" aria-hidden="true" />
{{ t('viewAs.roleAction') }}
</button>
<button v-if="can('admin.roles.update')" type="button" :disabled="busy" @click="editRole(role)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}