feat(admin): wire Admin Users management (A9)
User list with create dialog, role toggle, (de)activate, and reset-password; self-lockout guard. Adds getUser/resetPassword endpoints, an isActive field on the User type, and en/ru strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,11 @@ export const adminApi = api.injectEndpoints({
|
||||
transformResponse: (raw: RawUser[]) => raw.map(toUser),
|
||||
providesTags: ['User'],
|
||||
}),
|
||||
getUser: build.query<User, string>({
|
||||
query: (id) => `/admin/users/${id}`,
|
||||
transformResponse: (raw: RawUser) => toUser(raw),
|
||||
providesTags: (_res, _err, id) => [{ type: 'User', id }],
|
||||
}),
|
||||
createUser: build.mutation<
|
||||
User,
|
||||
{ username: string; password: string; role: 'admin' | 'user' }
|
||||
@@ -39,11 +44,20 @@ export const adminApi = api.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
transformResponse: (raw: RawUser) => toUser(raw),
|
||||
invalidatesTags: ['User'],
|
||||
invalidatesTags: (_res, _err, { id }) => ['User', { type: 'User', id }],
|
||||
}),
|
||||
// Soft delete on the backend: deactivates the account and revokes its
|
||||
// sessions (see admin.py::deactivate_user), so it also refreshes the row.
|
||||
deleteUser: build.mutation<void, string>({
|
||||
query: (id) => ({ url: `/admin/users/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['User'],
|
||||
invalidatesTags: (_res, _err, id) => ['User', { type: 'User', id }],
|
||||
}),
|
||||
resetPassword: build.mutation<void, { id: string; newPassword: string }>({
|
||||
query: ({ id, newPassword }) => ({
|
||||
url: `/admin/users/${id}/reset-password`,
|
||||
method: 'POST',
|
||||
body: { new_password: newPassword },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
@@ -51,7 +65,9 @@ export const adminApi = api.injectEndpoints({
|
||||
|
||||
export const {
|
||||
useGetUsersQuery,
|
||||
useGetUserQuery,
|
||||
useCreateUserMutation,
|
||||
useUpdateUserMutation,
|
||||
useDeleteUserMutation,
|
||||
useResetPasswordMutation,
|
||||
} = adminApi;
|
||||
|
||||
@@ -128,6 +128,7 @@ export const toUser = (r: RawUser): User => ({
|
||||
username: r.username,
|
||||
// MVP role model: superuser → admin, everyone else → user.
|
||||
role: r.is_superuser ? 'admin' : 'user',
|
||||
isActive: r.is_active,
|
||||
createdAt: r.created_at,
|
||||
});
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ export interface User {
|
||||
username: string;
|
||||
email?: string;
|
||||
role: 'admin' | 'user';
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Window } from '@olly/modern-sk';
|
||||
import {
|
||||
Window,
|
||||
Card,
|
||||
Button,
|
||||
TextField,
|
||||
SegmentedControl,
|
||||
Dialog,
|
||||
AlertDialog,
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Badge,
|
||||
Callout,
|
||||
} from '@olly/modern-sk';
|
||||
import { Icon } from '../../components/common/Icon';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import {
|
||||
useGetUsersQuery,
|
||||
useGetUserQuery,
|
||||
useCreateUserMutation,
|
||||
useUpdateUserMutation,
|
||||
useDeleteUserMutation,
|
||||
useResetPasswordMutation,
|
||||
} from '../../api/endpoints/admin';
|
||||
import type { User } from '../../api/types';
|
||||
|
||||
function StubPanel({ title }: { title: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -12,16 +43,430 @@ function StubPanel({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `/admin/users` — user list (add/remove). Scaffold. */
|
||||
export function AdminUsers() {
|
||||
/** Role + active-status badges shared by the list and the detail view. */
|
||||
function UserBadges({ user }: { user: User }) {
|
||||
const { t } = useTranslation();
|
||||
return <StubPanel title={t('admin.tabs.users')} />;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.375rem', alignItems: 'center' }}>
|
||||
<Badge variant={user.role === 'admin' ? 'lime' : 'neutral'}>
|
||||
{t(`admin.users.role.${user.role}`)}
|
||||
</Badge>
|
||||
{!user.isActive && (
|
||||
<Badge variant="ember">{t('admin.users.status.blocked')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/admin/users/:userId` — per-user permissions / reset password / status. Scaffold. */
|
||||
/** `/admin/users` — user list with add / role toggle / (de)activate / reset password. */
|
||||
export function AdminUsers() {
|
||||
const { t } = useTranslation();
|
||||
const { data: users, isLoading, isError, refetch } = useGetUsersQuery();
|
||||
const [createUser, { isLoading: isCreating }] = useCreateUserMutation();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||
const [createError, setCreateError] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setRole('user');
|
||||
setCreateError(false);
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = username.trim();
|
||||
if (!name || !password) return;
|
||||
setCreateError(false);
|
||||
try {
|
||||
await createUser({ username: name, password, role }).unwrap();
|
||||
setCreateOpen(false);
|
||||
resetForm();
|
||||
} catch {
|
||||
setCreateError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window title={t('admin.tabs.users')}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) resetForm();
|
||||
}}
|
||||
title={t('admin.users.newDialog.title')}
|
||||
trigger={
|
||||
<Button variant="primary">
|
||||
<Icon name="plus" /> {t('admin.users.add')}
|
||||
</Button>
|
||||
}
|
||||
footer={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
|
||||
{t('admin.users.newDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!username.trim() || !password || isCreating}
|
||||
onClick={(e) => void handleCreate(e)}
|
||||
>
|
||||
{t('admin.users.newDialog.create')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => void handleCreate(e)}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
||||
>
|
||||
<TextField
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t('admin.users.newDialog.username')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('admin.users.newDialog.password')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v === 'admin' ? 'admin' : 'user')}
|
||||
items={[
|
||||
{ value: 'user', label: t('admin.users.role.user') },
|
||||
{ value: 'admin', label: t('admin.users.role.admin') },
|
||||
]}
|
||||
/>
|
||||
{createError && (
|
||||
<Callout variant="danger">
|
||||
{t('admin.users.newDialog.error')}
|
||||
</Callout>
|
||||
)}
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{isLoading && <LoadingSkeleton rows={5} height={56} />}
|
||||
{isError && <ErrorState onRetry={refetch} />}
|
||||
{!isLoading && !isError && (users?.length ?? 0) === 0 && (
|
||||
<EmptyState icon="☺" title={t('admin.users.empty')} />
|
||||
)}
|
||||
{!isLoading && !isError && users && users.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{users.map((user) => (
|
||||
<UserRow key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row in the user list: identity + badges + actions menu. */
|
||||
function UserRow({ user }: { user: User }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user: currentUser } = usePermissions();
|
||||
const isSelf = currentUser?.id === user.id;
|
||||
|
||||
const [updateUser] = useUpdateUserMutation();
|
||||
const [deleteUser] = useDeleteUserMutation();
|
||||
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||
|
||||
const toggleRole = () =>
|
||||
void updateUser({
|
||||
id: user.id,
|
||||
role: user.role === 'admin' ? 'user' : 'admin',
|
||||
});
|
||||
const toggleActive = () =>
|
||||
void updateUser({ id: user.id, isActive: !user.isActive });
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
padding: '0.75rem 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate(`/admin/users/${user.id}`)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
textAlign: 'left',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
{isSelf && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-3)',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{t('admin.users.you')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('admin.users.added', {
|
||||
date: new Date(user.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<UserBadges user={user} />
|
||||
|
||||
<Menu>
|
||||
<MenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t('admin.users.menu.options')}
|
||||
>
|
||||
⋯
|
||||
</IconButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem onSelect={toggleRole}>
|
||||
{user.role === 'admin'
|
||||
? t('admin.users.menu.makeUser')
|
||||
: t('admin.users.menu.makeAdmin')}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => setResetOpen(true)}>
|
||||
{t('admin.users.menu.resetPassword')}
|
||||
</MenuItem>
|
||||
{/* Guard against locking yourself out of the instance. */}
|
||||
{!isSelf && (
|
||||
<MenuItem
|
||||
onSelect={() =>
|
||||
user.isActive ? setDeactivateOpen(true) : toggleActive()
|
||||
}
|
||||
>
|
||||
{user.isActive
|
||||
? t('admin.users.menu.deactivate')
|
||||
: t('admin.users.menu.activate')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
|
||||
<ResetPasswordDialog
|
||||
user={user}
|
||||
open={resetOpen}
|
||||
onOpenChange={setResetOpen}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deactivateOpen}
|
||||
onOpenChange={setDeactivateOpen}
|
||||
title={t('admin.users.deactivateDialog.title')}
|
||||
description={t('admin.users.deactivateDialog.description', {
|
||||
name: user.username,
|
||||
})}
|
||||
cancelLabel={t('admin.users.deactivateDialog.cancel')}
|
||||
actionLabel={t('admin.users.deactivateDialog.confirm')}
|
||||
destructive
|
||||
onAction={() => void deleteUser(user.id)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reset-password dialog: admin sets a new password for the account. */
|
||||
function ResetPasswordDialog({
|
||||
user,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
user: User;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [resetPassword, { isLoading }] = useResetPasswordMutation();
|
||||
const [value, setValue] = useState('');
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!value) return;
|
||||
await resetPassword({ id: user.id, newPassword: value }).unwrap();
|
||||
setValue('');
|
||||
setDone(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
onOpenChange(o);
|
||||
if (!o) {
|
||||
setValue('');
|
||||
setDone(false);
|
||||
}
|
||||
}}
|
||||
title={t('admin.users.resetDialog.title', { name: user.username })}
|
||||
footer={
|
||||
<div
|
||||
style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t('admin.users.resetDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!value || isLoading}
|
||||
onClick={(e) => void handleSubmit(e)}
|
||||
>
|
||||
{t('admin.users.resetDialog.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{done ? (
|
||||
<Callout variant="success">{t('admin.users.resetDialog.done')}</Callout>
|
||||
) : (
|
||||
<form onSubmit={(e) => void handleSubmit(e)}>
|
||||
<TextField
|
||||
autoFocus
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t('admin.users.resetDialog.newPassword')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/admin/users/:userId` — per-user detail: identity, role toggle, reset, status. */
|
||||
export function AdminUserDetail() {
|
||||
const { t } = useTranslation();
|
||||
return <StubPanel title={t('admin.userDetail')} />;
|
||||
const navigate = useNavigate();
|
||||
const { userId = '' } = useParams();
|
||||
const { user: currentUser } = usePermissions();
|
||||
|
||||
const { data: user, isLoading, isError, refetch } = useGetUserQuery(userId, {
|
||||
skip: !userId,
|
||||
});
|
||||
const [updateUser] = useUpdateUserMutation();
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
|
||||
const isSelf = currentUser?.id === user?.id;
|
||||
|
||||
return (
|
||||
<Window title={t('admin.userDetail')}>
|
||||
<Button variant="ghost" size="sm" onClick={() => void navigate('/admin/users')}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{isLoading && <LoadingSkeleton rows={3} height={40} />}
|
||||
{isError && <ErrorState onRetry={refetch} />}
|
||||
{user && (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
|
||||
>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>{user.username}</h2>
|
||||
<UserBadges user={user} />
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('admin.users.added', {
|
||||
date: new Date(user.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
void updateUser({
|
||||
id: user.id,
|
||||
role: user.role === 'admin' ? 'user' : 'admin',
|
||||
})
|
||||
}
|
||||
>
|
||||
{user.role === 'admin'
|
||||
? t('admin.users.menu.makeUser')
|
||||
: t('admin.users.menu.makeAdmin')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setResetOpen(true)}>
|
||||
{t('admin.users.menu.resetPassword')}
|
||||
</Button>
|
||||
{!isSelf && (
|
||||
<Button
|
||||
variant={user.isActive ? 'ember' : 'ghost'}
|
||||
onClick={() =>
|
||||
void updateUser({ id: user.id, isActive: !user.isActive })
|
||||
}
|
||||
>
|
||||
{user.isActive
|
||||
? t('admin.users.menu.deactivate')
|
||||
: t('admin.users.menu.activate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResetPasswordDialog
|
||||
user={user}
|
||||
open={resetOpen}
|
||||
onOpenChange={setResetOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
|
||||
|
||||
@@ -400,6 +400,49 @@ const en = {
|
||||
sources: 'Sources',
|
||||
instance: 'Instance',
|
||||
},
|
||||
users: {
|
||||
add: 'Add user',
|
||||
empty: 'No users yet.',
|
||||
you: '(you)',
|
||||
added: 'Added {{date}}',
|
||||
role: {
|
||||
admin: 'Admin',
|
||||
user: 'User',
|
||||
},
|
||||
status: {
|
||||
blocked: 'Blocked',
|
||||
},
|
||||
newDialog: {
|
||||
title: 'Add user',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
create: 'Create',
|
||||
cancel: 'Cancel',
|
||||
error: 'Could not create the user — the name may be taken.',
|
||||
},
|
||||
menu: {
|
||||
options: 'Options',
|
||||
makeAdmin: 'Make admin',
|
||||
makeUser: 'Revoke admin',
|
||||
resetPassword: 'Reset password',
|
||||
activate: 'Activate',
|
||||
deactivate: 'Deactivate',
|
||||
},
|
||||
resetDialog: {
|
||||
title: 'Reset password — {{name}}',
|
||||
newPassword: 'New password',
|
||||
submit: 'Set password',
|
||||
cancel: 'Cancel',
|
||||
done: 'Password updated.',
|
||||
},
|
||||
deactivateDialog: {
|
||||
title: 'Deactivate user?',
|
||||
description:
|
||||
'{{name}} will be signed out and blocked from signing in. You can reactivate them later.',
|
||||
confirm: 'Deactivate',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
|
||||
@@ -401,6 +401,49 @@ const ru: Translations = {
|
||||
sources: 'Источники',
|
||||
instance: 'Сервер',
|
||||
},
|
||||
users: {
|
||||
add: 'Добавить',
|
||||
empty: 'Пользователей пока нет.',
|
||||
you: '(вы)',
|
||||
added: 'Добавлен {{date}}',
|
||||
role: {
|
||||
admin: 'Админ',
|
||||
user: 'Пользователь',
|
||||
},
|
||||
status: {
|
||||
blocked: 'Заблокирован',
|
||||
},
|
||||
newDialog: {
|
||||
title: 'Новый пользователь',
|
||||
username: 'Логин',
|
||||
password: 'Пароль',
|
||||
create: 'Создать',
|
||||
cancel: 'Отмена',
|
||||
error: 'Не удалось создать пользователя — возможно, логин занят.',
|
||||
},
|
||||
menu: {
|
||||
options: 'Действия',
|
||||
makeAdmin: 'Сделать админом',
|
||||
makeUser: 'Снять админа',
|
||||
resetPassword: 'Сбросить пароль',
|
||||
activate: 'Разблокировать',
|
||||
deactivate: 'Заблокировать',
|
||||
},
|
||||
resetDialog: {
|
||||
title: 'Сброс пароля — {{name}}',
|
||||
newPassword: 'Новый пароль',
|
||||
submit: 'Задать пароль',
|
||||
cancel: 'Отмена',
|
||||
done: 'Пароль обновлён.',
|
||||
},
|
||||
deactivateDialog: {
|
||||
title: 'Заблокировать пользователя?',
|
||||
description:
|
||||
'{{name}} будет разлогинен и не сможет войти. Позже можно разблокировать.',
|
||||
confirm: 'Заблокировать',
|
||||
cancel: 'Отмена',
|
||||
},
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Страница не найдена',
|
||||
|
||||
Reference in New Issue
Block a user