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),
|
transformResponse: (raw: RawUser[]) => raw.map(toUser),
|
||||||
providesTags: ['User'],
|
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<
|
createUser: build.mutation<
|
||||||
User,
|
User,
|
||||||
{ username: string; password: string; role: 'admin' | 'user' }
|
{ username: string; password: string; role: 'admin' | 'user' }
|
||||||
@@ -39,11 +44,20 @@ export const adminApi = api.injectEndpoints({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
transformResponse: (raw: RawUser) => toUser(raw),
|
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>({
|
deleteUser: build.mutation<void, string>({
|
||||||
query: (id) => ({ url: `/admin/users/${id}`, method: 'DELETE' }),
|
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,
|
overrideExisting: false,
|
||||||
@@ -51,7 +65,9 @@ export const adminApi = api.injectEndpoints({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useGetUsersQuery,
|
useGetUsersQuery,
|
||||||
|
useGetUserQuery,
|
||||||
useCreateUserMutation,
|
useCreateUserMutation,
|
||||||
useUpdateUserMutation,
|
useUpdateUserMutation,
|
||||||
useDeleteUserMutation,
|
useDeleteUserMutation,
|
||||||
|
useResetPasswordMutation,
|
||||||
} = adminApi;
|
} = adminApi;
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const toUser = (r: RawUser): User => ({
|
|||||||
username: r.username,
|
username: r.username,
|
||||||
// MVP role model: superuser → admin, everyone else → user.
|
// MVP role model: superuser → admin, everyone else → user.
|
||||||
role: r.is_superuser ? 'admin' : 'user',
|
role: r.is_superuser ? 'admin' : 'user',
|
||||||
|
isActive: r.is_active,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export interface User {
|
|||||||
username: string;
|
username: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
role: 'admin' | 'user';
|
role: 'admin' | 'user';
|
||||||
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastActiveAt?: string;
|
lastActiveAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 }) {
|
function StubPanel({ title }: { title: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -12,16 +43,430 @@ function StubPanel({ title }: { title: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/admin/users` — user list (add/remove). Scaffold. */
|
/** Role + active-status badges shared by the list and the detail view. */
|
||||||
export function AdminUsers() {
|
function UserBadges({ user }: { user: User }) {
|
||||||
const { t } = useTranslation();
|
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() {
|
export function AdminUserDetail() {
|
||||||
const { t } = useTranslation();
|
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. */
|
/** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
|
||||||
|
|||||||
@@ -400,6 +400,49 @@ const en = {
|
|||||||
sources: 'Sources',
|
sources: 'Sources',
|
||||||
instance: 'Instance',
|
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: {
|
notFound: {
|
||||||
title: 'Page not found',
|
title: 'Page not found',
|
||||||
|
|||||||
@@ -401,6 +401,49 @@ const ru: Translations = {
|
|||||||
sources: 'Источники',
|
sources: 'Источники',
|
||||||
instance: 'Сервер',
|
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: {
|
notFound: {
|
||||||
title: 'Страница не найдена',
|
title: 'Страница не найдена',
|
||||||
|
|||||||
Reference in New Issue
Block a user