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:
@@ -200,6 +200,13 @@
|
||||
- 调用者只能分配或移除 `roles.level` 严格低于自己最高启用角色等级的角色。
|
||||
- `owner` 角色只能由当前拥有启用 `owner` 角色且拥有 `admin.users.assign-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 或调试字段。
|
||||
|
||||
## Admin Data Tools
|
||||
|
||||
@@ -724,10 +724,21 @@ CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
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,
|
||||
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
|
||||
ON user_sessions(user_id);
|
||||
|
||||
|
||||
@@ -85,6 +85,12 @@ export type AuthUser = {
|
||||
emailVerified: boolean;
|
||||
roles: RoleSummary[];
|
||||
permissions: string[];
|
||||
viewAs?: ViewAsSummary;
|
||||
};
|
||||
|
||||
export type ViewAsSummary = {
|
||||
mode: 'user' | 'role';
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ReferralSummary = {
|
||||
@@ -148,6 +154,12 @@ type RolePermissionRow = QueryResultRow & {
|
||||
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 permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
||||
const ownerRoleKey = 'owner';
|
||||
@@ -555,6 +567,38 @@ async function userPermissions(userId: number, client: DbClient | null = null):
|
||||
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> {
|
||||
const user = await runQueryOne<UserRow>(
|
||||
client,
|
||||
@@ -1275,9 +1319,66 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
|
||||
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
|
||||
WHERE s.token_hash = $1
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
userId: number,
|
||||
payload: Record<string, unknown>,
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
registerUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
startViewAsRole,
|
||||
startViewAsUser,
|
||||
stopViewAs,
|
||||
updateAdminUserRoles,
|
||||
updateCurrentUser,
|
||||
updatePermission,
|
||||
@@ -1089,6 +1092,47 @@ app.get('/api/auth/me', async (request, reply) => {
|
||||
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) => {
|
||||
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
|
||||
return;
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -78,6 +78,12 @@ export const systemWordingMessages = {
|
||||
logout: 'Log out',
|
||||
register: 'Register'
|
||||
},
|
||||
viewAs: {
|
||||
banner: 'View As {name}',
|
||||
exit: 'Exit',
|
||||
userAction: 'View As user',
|
||||
roleAction: 'View As role'
|
||||
},
|
||||
search: {
|
||||
label: 'Search Pokopia Wiki',
|
||||
placeholder: 'Search wiki',
|
||||
@@ -1530,6 +1536,12 @@ export const systemWordingMessages = {
|
||||
logout: '退出',
|
||||
register: '注册'
|
||||
},
|
||||
viewAs: {
|
||||
banner: 'View As {name}',
|
||||
exit: '退出',
|
||||
userAction: '以用户身份 View As',
|
||||
roleAction: '以角色身份 View As'
|
||||
},
|
||||
search: {
|
||||
label: '搜索 Pokopia Wiki',
|
||||
placeholder: '搜索 Wiki',
|
||||
|
||||
Reference in New Issue
Block a user