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:
Цвылев Александр Вадимович
2026-07-28 20:40:36 +03:00
parent 57044dabba
commit 28d9e59e34
6 changed files with 557 additions and 8 deletions
+18 -2
View File
@@ -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;
+1
View File
@@ -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,
});
+1
View File
@@ -181,6 +181,7 @@ export interface User {
username: string;
email?: string;
role: 'admin' | 'user';
isActive: boolean;
createdAt: string;
lastActiveAt?: string;
}
+451 -6
View File
@@ -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. */
+43
View File
@@ -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',
+43
View File
@@ -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: 'Страница не найдена',