diff --git a/src/api/endpoints/admin.ts b/src/api/endpoints/admin.ts index 6f148d7..142aa14 100644 --- a/src/api/endpoints/admin.ts +++ b/src/api/endpoints/admin.ts @@ -14,6 +14,11 @@ export const adminApi = api.injectEndpoints({ transformResponse: (raw: RawUser[]) => raw.map(toUser), providesTags: ['User'], }), + getUser: build.query({ + 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({ query: (id) => ({ url: `/admin/users/${id}`, method: 'DELETE' }), - invalidatesTags: ['User'], + invalidatesTags: (_res, _err, id) => ['User', { type: 'User', id }], + }), + resetPassword: build.mutation({ + 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; diff --git a/src/api/mappers.ts b/src/api/mappers.ts index fb2b1a5..fdd16ca 100644 --- a/src/api/mappers.ts +++ b/src/api/mappers.ts @@ -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, }); diff --git a/src/api/types.ts b/src/api/types.ts index 65967ed..975bbce 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -181,6 +181,7 @@ export interface User { username: string; email?: string; role: 'admin' | 'user'; + isActive: boolean; createdAt: string; lastActiveAt?: string; } diff --git a/src/features/admin/panels.tsx b/src/features/admin/panels.tsx index 055dd52..0e7c70a 100644 --- a/src/features/admin/panels.tsx +++ b/src/features/admin/panels.tsx @@ -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 ; + return ( +
+ + {t(`admin.users.role.${user.role}`)} + + {!user.isActive && ( + {t('admin.users.status.blocked')} + )} +
+ ); } -/** `/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 ( + +
+ { + setCreateOpen(open); + if (!open) resetForm(); + }} + title={t('admin.users.newDialog.title')} + trigger={ + + } + footer={ +
+ + +
+ } + > +
void handleCreate(e)} + style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }} + > + setUsername(e.target.value)} + placeholder={t('admin.users.newDialog.username')} + autoComplete="off" + /> + setPassword(e.target.value)} + placeholder={t('admin.users.newDialog.password')} + autoComplete="new-password" + /> + setRole(v === 'admin' ? 'admin' : 'user')} + items={[ + { value: 'user', label: t('admin.users.role.user') }, + { value: 'admin', label: t('admin.users.role.admin') }, + ]} + /> + {createError && ( + + {t('admin.users.newDialog.error')} + + )} + +
+
+ + {isLoading && } + {isError && } + {!isLoading && !isError && (users?.length ?? 0) === 0 && ( + + )} + {!isLoading && !isError && users && users.length > 0 && ( +
+ {users.map((user) => ( + + ))} +
+ )} +
+ ); +} + +/** 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 ( + + + + + + + + + ⋯ + + + + + {user.role === 'admin' + ? t('admin.users.menu.makeUser') + : t('admin.users.menu.makeAdmin')} + + setResetOpen(true)}> + {t('admin.users.menu.resetPassword')} + + {/* Guard against locking yourself out of the instance. */} + {!isSelf && ( + + user.isActive ? setDeactivateOpen(true) : toggleActive() + } + > + {user.isActive + ? t('admin.users.menu.deactivate') + : t('admin.users.menu.activate')} + + )} + + + + + + void deleteUser(user.id)} + /> + + ); +} + +/** 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 ( + { + onOpenChange(o); + if (!o) { + setValue(''); + setDone(false); + } + }} + title={t('admin.users.resetDialog.title', { name: user.username })} + footer={ +
+ + +
+ } + > + {done ? ( + {t('admin.users.resetDialog.done')} + ) : ( +
void handleSubmit(e)}> + setValue(e.target.value)} + placeholder={t('admin.users.resetDialog.newPassword')} + autoComplete="new-password" + /> + + )} +
+ ); +} + +/** `/admin/users/:userId` — per-user detail: identity, role toggle, reset, status. */ export function AdminUserDetail() { const { t } = useTranslation(); - return ; + 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 ( + + + +
+ {isLoading && } + {isError && } + {user && ( +
+
+

{user.username}

+ +
+ +
+ {t('admin.users.added', { + date: new Date(user.createdAt).toLocaleDateString(), + })} +
+ +
+ + + {!isSelf && ( + + )} +
+ + +
+ )} +
+
+ ); } /** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */ diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7658e05..c268b21 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 1457ab1..5cb34bc 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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: 'Страница не найдена',