28d9e59e34
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>
483 lines
14 KiB
TypeScript
483 lines
14 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate, useParams } from 'react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
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();
|
|
return (
|
|
<Window title={title}>
|
|
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
|
{t('common.comingSoon')}
|
|
</p>
|
|
</Window>
|
|
);
|
|
}
|
|
|
|
/** Role + active-status badges shared by the list and the detail view. */
|
|
function UserBadges({ user }: { user: User }) {
|
|
const { t } = useTranslation();
|
|
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` — 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();
|
|
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. */
|
|
export function AdminSources() {
|
|
const { t } = useTranslation();
|
|
return <StubPanel title={t('admin.tabs.sources')} />;
|
|
}
|
|
|
|
/** `/admin/instance` — service health, ML_SERVICE_URL, reindex. Scaffold. */
|
|
export function AdminInstance() {
|
|
const { t } = useTranslation();
|
|
return <StubPanel title={t('admin.tabs.instance')} />;
|
|
}
|