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:
@@ -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" />
|
||||
|
||||
@@ -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')">
|
||||
|
||||
32
frontend/src/components/ViewAsBanner.vue
Normal file
32
frontend/src/components/ViewAsBanner.vue
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
Reference in New Issue
Block a user