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

@@ -200,6 +200,13 @@
- 调用者只能分配或移除 `roles.level` 严格低于自己最高启用角色等级的角色。 - 调用者只能分配或移除 `roles.level` 严格低于自己最高启用角色等级的角色。
- `owner` 角色只能由当前拥有启用 `owner` 角色且拥有 `admin.users.assign-owner` 权限的调用者分配或移除。 - `owner` 角色只能由当前拥有启用 `owner` 角色且拥有 `admin.users.assign-owner` 权限的调用者分配或移除。
- 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。 - 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。
- Owner 可使用 View As 调试权限和用户工作流:
- 只有当前 session 的真实用户拥有启用 `owner` 角色且邮箱已验证时,才能启动或退出 View As。
- View As User 会让当前 session 以目标用户的对外身份、角色和权限进行后续操作;普通写入仍按当前生效用户记录编辑署名。
- View As Role 会保留真实 Owner 的用户资料和邮箱验证状态,但后续权限判断只使用所选启用角色的权限;该模式用于验证角色能力边界,不伪造某个具体用户。
- 同一 session 同一时间只能 View As 一个用户或一个角色;退出后恢复真实 Owner 身份。
- 当前用户 API 可返回必要的 `viewAs` 展示状态,只包含模式和展示标签;不返回 session token、token hash、内部 session 字段或调试 payload。
- 前端在顶部显示全站 View As Banner文案为当前 View As 对象并提供退出按钮View As 状态不得只隐藏在管理页内。
- 管理 API 只返回权限管理所需字段不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。 - 管理 API 只返回权限管理所需字段不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。
## Admin Data Tools ## Admin Data Tools

View File

@@ -724,10 +724,21 @@ CREATE TABLE IF NOT EXISTS user_sessions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE, token_hash text NOT NULL UNIQUE,
view_as_user_id integer REFERENCES users(id) ON DELETE SET NULL,
view_as_role_id integer REFERENCES roles(id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now() created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT user_sessions_view_as_single_target_check CHECK (view_as_user_id IS NULL OR view_as_role_id IS NULL)
); );
ALTER TABLE user_sessions
ADD COLUMN IF NOT EXISTS view_as_user_id integer REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS view_as_role_id integer REFERENCES roles(id) ON DELETE SET NULL;
ALTER TABLE user_sessions
DROP CONSTRAINT IF EXISTS user_sessions_view_as_single_target_check,
ADD CONSTRAINT user_sessions_view_as_single_target_check CHECK (view_as_user_id IS NULL OR view_as_role_id IS NULL);
CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
ON user_sessions(user_id); ON user_sessions(user_id);

View File

@@ -85,6 +85,12 @@ export type AuthUser = {
emailVerified: boolean; emailVerified: boolean;
roles: RoleSummary[]; roles: RoleSummary[];
permissions: string[]; permissions: string[];
viewAs?: ViewAsSummary;
};
export type ViewAsSummary = {
mode: 'user' | 'role';
label: string;
}; };
export type ReferralSummary = { export type ReferralSummary = {
@@ -148,6 +154,12 @@ type RolePermissionRow = QueryResultRow & {
permission_id: number; permission_id: number;
}; };
type SessionRow = QueryResultRow & {
user_id: number;
view_as_user_id: number | null;
view_as_role_id: number | null;
};
const roleKeyPattern = /^[a-z][a-z0-9-]{1,63}$/; const roleKeyPattern = /^[a-z][a-z0-9-]{1,63}$/;
const permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/; const permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
const ownerRoleKey = 'owner'; const ownerRoleKey = 'owner';
@@ -555,6 +567,38 @@ async function userPermissions(userId: number, client: DbClient | null = null):
return rows.map((row) => row.key); return rows.map((row) => row.key);
} }
async function rolePermissions(roleId: number, client: DbClient | null = null): Promise<string[]> {
const rows = await runQuery<QueryResultRow & { key: string }>(
client,
`
SELECT DISTINCT p.key
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE rp.role_id = $1
AND p.enabled = true
ORDER BY p.key
`,
[roleId]
);
return rows.map((row) => row.key);
}
async function roleById(roleId: number, client: DbClient | null = null): Promise<RoleSummary | null> {
const role = await runQueryOne<RoleRow>(
client,
`
SELECT id, key, name, description, level, enabled, system_role
FROM roles
WHERE id = $1
AND enabled = true
`,
[roleId]
);
return role ? toRoleSummary(role) : null;
}
async function publicUserById(userId: number, client: DbClient | null = null): Promise<AuthUser | null> { async function publicUserById(userId: number, client: DbClient | null = null): Promise<AuthUser | null> {
const user = await runQueryOne<UserRow>( const user = await runQueryOne<UserRow>(
client, client,
@@ -1275,9 +1319,66 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
return null; return null;
} }
const session = await queryOne<QueryResultRow & { user_id: number }>( const session = await queryOne<SessionRow>(
` `
SELECT s.user_id SELECT s.user_id, s.view_as_user_id, s.view_as_role_id
FROM user_sessions s
WHERE s.token_hash = $1
AND s.expires_at > now()
`,
[hashToken(token)]
);
if (!session) {
return null;
}
const realUser = await publicUserById(session.user_id);
if (!realUser) {
return null;
}
const realUserCanViewAs = realUser.emailVerified && realUser.roles.some((role) => role.key === ownerRoleKey);
if (realUserCanViewAs && session.view_as_user_id) {
const viewAsUser = await publicUserById(session.view_as_user_id);
if (viewAsUser) {
return {
...viewAsUser,
viewAs: {
mode: 'user',
label: viewAsUser.displayName || viewAsUser.email
}
};
}
}
if (realUserCanViewAs && session.view_as_role_id) {
const role = await roleById(session.view_as_role_id);
if (role) {
return {
...realUser,
roles: [role],
permissions: await rolePermissions(role.id),
viewAs: {
mode: 'role',
label: role.name
}
};
}
}
return realUser;
}
async function realUserBySessionToken(token: string): Promise<AuthUser | null> {
if (token.length < 32) {
return null;
}
const session = await queryOne<SessionRow>(
`
SELECT s.user_id, s.view_as_user_id, s.view_as_role_id
FROM user_sessions s FROM user_sessions s
WHERE s.token_hash = $1 WHERE s.token_hash = $1
AND s.expires_at > now() AND s.expires_at > now()
@@ -1288,6 +1389,89 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
return session ? publicUserById(session.user_id) : null; return session ? publicUserById(session.user_id) : null;
} }
function assertOwnerViewAsUser(user: AuthUser | null): AuthUser {
if (!user || !user.emailVerified || !user.roles.some((role) => role.key === ownerRoleKey)) {
throw statusError('server.permissions.permissionDenied', 403);
}
return user;
}
function cleanViewAsId(value: unknown): number {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
throw statusError('server.permissions.invalidSelection', 400);
}
return id;
}
export async function startViewAsUser(sessionToken: string, payload: Record<string, unknown>): Promise<AuthUser> {
assertOwnerViewAsUser(await realUserBySessionToken(sessionToken));
const targetUserId = cleanViewAsId(payload.userId);
const targetUser = await publicUserById(targetUserId);
if (!targetUser) {
throw statusError('server.permissions.userNotFound', 404);
}
await pool.query(
`
UPDATE user_sessions
SET view_as_user_id = $1,
view_as_role_id = NULL
WHERE token_hash = $2
AND expires_at > now()
`,
[targetUserId, hashToken(sessionToken)]
);
const user = await getUserBySessionToken(sessionToken);
if (!user) {
throw statusError('server.errors.loginRequired', 401);
}
return user;
}
export async function startViewAsRole(sessionToken: string, payload: Record<string, unknown>): Promise<AuthUser> {
assertOwnerViewAsUser(await realUserBySessionToken(sessionToken));
const targetRoleId = cleanViewAsId(payload.roleId);
const role = await roleById(targetRoleId);
if (!role) {
throw statusError('server.permissions.roleNotFound', 404);
}
await pool.query(
`
UPDATE user_sessions
SET view_as_user_id = NULL,
view_as_role_id = $1
WHERE token_hash = $2
AND expires_at > now()
`,
[targetRoleId, hashToken(sessionToken)]
);
const user = await getUserBySessionToken(sessionToken);
if (!user) {
throw statusError('server.errors.loginRequired', 401);
}
return user;
}
export async function stopViewAs(sessionToken: string): Promise<AuthUser> {
const realUser = assertOwnerViewAsUser(await realUserBySessionToken(sessionToken));
await pool.query(
`
UPDATE user_sessions
SET view_as_user_id = NULL,
view_as_role_id = NULL
WHERE token_hash = $1
AND expires_at > now()
`,
[hashToken(sessionToken)]
);
return realUser;
}
export async function updateCurrentUser( export async function updateCurrentUser(
userId: number, userId: number,
payload: Record<string, unknown>, payload: Record<string, unknown>,

View File

@@ -22,6 +22,9 @@ import {
registerUser, registerUser,
requestPasswordReset, requestPasswordReset,
resetPassword, resetPassword,
startViewAsRole,
startViewAsUser,
stopViewAs,
updateAdminUserRoles, updateAdminUserRoles,
updateCurrentUser, updateCurrentUser,
updatePermission, updatePermission,
@@ -1089,6 +1092,47 @@ app.get('/api/auth/me', async (request, reply) => {
return { user }; return { user };
}); });
app.post('/api/auth/view-as/user', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getSessionToken(request);
if (!token) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
return { user: await startViewAsUser(token, payload) };
});
app.post('/api/auth/view-as/role', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getSessionToken(request);
if (!token) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
return { user: await startViewAsRole(token, payload) };
});
app.post('/api/auth/view-as/stop', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getSessionToken(request);
if (!token) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
return { user: await stopViewAs(token) };
});
app.patch('/api/auth/me', async (request, reply) => { app.patch('/api/auth/me', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return; return;

View File

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

View File

@@ -20,6 +20,7 @@ import GlobalSearch from './GlobalSearch.vue';
import NotificationBell from './NotificationBell.vue'; import NotificationBell from './NotificationBell.vue';
import PokeBallMark from './PokeBallMark.vue'; import PokeBallMark from './PokeBallMark.vue';
import StatusBadge from './StatusBadge.vue'; import StatusBadge from './StatusBadge.vue';
import ViewAsBanner from './ViewAsBanner.vue';
type NavBadge = { type NavBadge = {
label: string; label: string;
@@ -53,10 +54,12 @@ defineProps<{
languages: Language[]; languages: Language[];
locale: string; locale: string;
navItems: NavItem[]; navItems: NavItem[];
viewAsBusy?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
logout: []; logout: [];
stopViewAs: [];
'update:locale': [value: string]; 'update:locale': [value: string];
}>(); }>();
@@ -147,6 +150,10 @@ function requestLogout() {
emit('logout'); emit('logout');
} }
function requestStopViewAs() {
emit('stopViewAs');
}
function isDesktopSidebar() { function isDesktopSidebar() {
return typeof window !== 'undefined' && window.matchMedia('(min-width: 901px)').matches; return typeof window !== 'undefined' && window.matchMedia('(min-width: 901px)').matches;
} }
@@ -345,6 +352,13 @@ onBeforeUnmount(() => {
</div> </div>
</header> </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> <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')"> <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 iconError: AppIcon = 'mdi:close-circle-outline';
export const iconEvent: AppIcon = 'mdi:calendar-star'; export const iconEvent: AppIcon = 'mdi:calendar-star';
export const iconExternal: AppIcon = 'mdi:open-in-new'; export const iconExternal: AppIcon = 'mdi:open-in-new';
export const iconEye: AppIcon = 'mdi:eye-outline';
export const iconGitCommit: AppIcon = 'mdi:source-commit'; export const iconGitCommit: AppIcon = 'mdi:source-commit';
export const iconHabitat: AppIcon = 'mdi:pine-tree'; export const iconHabitat: AppIcon = 'mdi:pine-tree';
export const iconHome: AppIcon = 'mdi:home-variant-outline'; export const iconHome: AppIcon = 'mdi:home-variant-outline';

View File

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

View File

@@ -370,6 +370,42 @@ svg {
padding: 0; 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 { .site-sidebar {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -7955,6 +7991,19 @@ button:disabled,
gap: 6px; 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 { .sidebar-toggle {
width: 44px; width: 44px;
min-width: 44px; min-width: 44px;

View File

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

View File

@@ -78,6 +78,12 @@ export const systemWordingMessages = {
logout: 'Log out', logout: 'Log out',
register: 'Register' register: 'Register'
}, },
viewAs: {
banner: 'View As {name}',
exit: 'Exit',
userAction: 'View As user',
roleAction: 'View As role'
},
search: { search: {
label: 'Search Pokopia Wiki', label: 'Search Pokopia Wiki',
placeholder: 'Search wiki', placeholder: 'Search wiki',
@@ -1530,6 +1536,12 @@ export const systemWordingMessages = {
logout: '退出', logout: '退出',
register: '注册' register: '注册'
}, },
viewAs: {
banner: 'View As {name}',
exit: '退出',
userAction: '以用户身份 View As',
roleAction: '以角色身份 View As'
},
search: { search: {
label: '搜索 Pokopia Wiki', label: '搜索 Pokopia Wiki',
placeholder: '搜索 Wiki', placeholder: '搜索 Wiki',