fix(auth): allow admins to change any username including their own
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / ts / ts (packages + web) (push) Successful in 2m59s
ci / api / api (push) Successful in 49s
ci / db / db (push) Successful in 1m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / ts / ts (packages + web) (push) Successful in 2m59s
ci / api / api (push) Successful in 49s
ci / db / db (push) Successful in 1m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
This commit is contained in:
1 parent
b0cbd0f149
commit
c34272d508
16 files changed
+431
-266
No files matched your search
@@ -154,6 +154,39 @@ describe('users admin', () => {
|
||||
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['root', 'anna'])('lets admins change the username of %s', async (target) => {
|
||||
const root = makeUser('root', 'admin');
|
||||
const anna = makeUser('anna', 'user');
|
||||
const { user, api, session } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||
await user.click(await screen.findByRole('button', { name: `Действия с ${target}` }));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
|
||||
const input = within(dialog).getByLabelText('Логин');
|
||||
await user.clear(input);
|
||||
await user.type(input, `new.${target}`);
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(await screen.findByText(`@new.${target}`)).toBeInTheDocument();
|
||||
expect(api.users.get(target === 'root' ? root.id : anna.id)?.username).toBe(`new.${target}`);
|
||||
if (target === 'root') expect(session.state).toMatchObject({ status: 'signed_in', user: { username: 'new.root' } });
|
||||
});
|
||||
|
||||
it('keeps the username dialog open when the new username is taken', async () => {
|
||||
const root = makeUser('root', 'admin');
|
||||
const anna = makeUser('anna', 'user');
|
||||
const { user, api } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||
await user.click(await screen.findByRole('button', { name: 'Действия с anna' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
|
||||
const input = within(dialog).getByLabelText('Логин');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ROOT');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
|
||||
expect(api.users.get(anna.id)?.username).toBe('anna');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers moderators only what they may do', async () => {
|
||||
const mod = makeUser('mod', 'moderator');
|
||||
const anna = makeUser('anna', 'user');
|
||||
|
||||
@@ -15,6 +15,7 @@ type Entry = components['schemas']['AuditEntry'];
|
||||
function details(e: Entry, roleName: (r: string) => string): string {
|
||||
const p = e.payload ?? {};
|
||||
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
|
||||
if (e.action === 'user.username_change') return `${String(p.from)} → ${String(p.to)}`;
|
||||
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
|
||||
(v): v is string => typeof v === 'string' && v !== '',
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ export function UsersPage() {
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
const query = useDebounced(q.trim(), 200);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [renaming, setRenaming] = useState<AdminUser | null>(null);
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
@@ -112,6 +113,8 @@ export function UsersPage() {
|
||||
const actionsFor = (u: AdminUser): MenuItem[] => {
|
||||
const items: MenuItem[] = [];
|
||||
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
|
||||
if (me.role === 'admin')
|
||||
items.push({ label: t('users.change_username'), onSelect: () => setRenaming(u) });
|
||||
if (can('reset_password'))
|
||||
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
|
||||
if (can('reset_recommendations'))
|
||||
@@ -250,6 +253,18 @@ export function UsersPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{renaming && (
|
||||
<ChangeUsernameDialog
|
||||
user={renaming}
|
||||
onClose={() => setRenaming(null)}
|
||||
onSaved={async (updated) => {
|
||||
if (updated.id === me.id) session.setUser({ ...me, username: updated.username });
|
||||
setRenaming(null);
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirm && (
|
||||
<Dialog
|
||||
title={t(`users.${TITLES[confirm.kind]}`)}
|
||||
@@ -368,6 +383,40 @@ function CreateUserDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeUsernameDialog({ user, onClose, onSaved }: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
onSaved: (user: AdminUser) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const fieldError = useFieldError();
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const save = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', {
|
||||
params: { path: { id: user.id } }, body: { username: username.trim() },
|
||||
})),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
const error = fieldError(save.error, 'username');
|
||||
return (
|
||||
<Dialog title={t('users.change_username')} onClose={onClose} footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common:form.cancel')}</Button>
|
||||
<Button type="submit" form="change-username" variant="primary" busy={save.isPending}
|
||||
disabled={!username.trim() || username.trim() === user.username}>{t('common:settings.save')}</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="change-username" className={s.sessions} noValidate onSubmit={(e) => { e.preventDefault(); save.mutate(); }}>
|
||||
<Field label={t('create.username')} value={username} onChange={setUsername} autoCapitalize="none"
|
||||
spellCheck={false} hint={t('create.username_hint')} error={error} mono />
|
||||
<FormError>{save.error && !error ? errorText(save.error) : null}</FormError>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -243,6 +243,13 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
|
||||
const target = users.get(userPath[1]!);
|
||||
if (!target) return problem(404, 'common.not_found');
|
||||
if (typeof body?.username === 'string') {
|
||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||
const username = body.username.trim().toLowerCase();
|
||||
if ([...users.values()].some((u) => u.id !== target.id && u.username.toLowerCase() === username))
|
||||
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
|
||||
target.username = username;
|
||||
}
|
||||
if (typeof body?.reviews_uploads === 'boolean') {
|
||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||
target.reviews_uploads = body.reviews_uploads;
|
||||
|
||||
Reference in new issue
Block a user