Admin can create user accounts directly (#331) #333

Merged
fable-5 merged 1 commits from feat/331-admin-create-user into main 2026-08-05 13:40:31 +02:00
12 changed files with 321 additions and 6 deletions
Showing only changes of commit 9cf7b85b93 - Show all commits

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BackupModule } from '../backup/backup.module';
import { PondsModule } from '../ponds/ponds.module';
import { QuotasModule } from '../quotas/quotas.module';
import { SchedulerModule } from '../scheduler/scheduler.module';
import { SearchModule } from '../search/search.module';
@ -19,7 +20,15 @@ import { UserAdminController } from './user-admin.controller';
import { UserAdminService } from './user-admin.service';
@Module({
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule, BackupModule, SearchModule],
imports: [
QuotasModule,
UsersModule,
AuthModule,
SchedulerModule,
BackupModule,
SearchModule,
PondsModule,
],
controllers: [
AdminSettingsController,
BackupAdminController,

View File

@ -12,9 +12,11 @@ import {
UseGuards,
} from '@nestjs/common';
import {
AdminCreateUserInput,
AdminUserListQuery,
AdminUserListView,
AdminUserView,
adminCreateUserSchema,
adminUserListQuerySchema,
setSiteAdminSchema,
setUserDisabledSchema,
@ -31,6 +33,14 @@ import { UserAdminService } from './user-admin.service';
export class UserAdminController {
constructor(private readonly users: UserAdminService) {}
@Post()
async create(
@Body(new ZodValidationPipe(adminCreateUserSchema)) input: AdminCreateUserInput,
@Req() request: AuthedRequest,
): Promise<AdminUserView> {
return this.users.createUser(request.user!, input);
}
@Get()
async list(
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,

View File

@ -69,6 +69,64 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
await app.close();
});
it('creates an account that can log in right away, with a personal pond (issue #331)', async () => {
const username = `ua-created-${suffix}`;
const res = await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.admin1!)
.send({
username,
email: `${username}@example.org`,
displayName: 'UA Created',
password,
locale: 'de',
})
.expect(201);
const created = res.body as { id: string; status: string };
ids.created = created.id;
// No verification hop: the admin vouched for the address.
expect(created.status).toBe('ACTIVE');
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
// The personal pond exists exactly like after self-registration.
expect(await prisma.pond.count({ where: { ownerId: created.id, type: 'PERSONAL' } })).toBe(1);
});
it('rejects duplicate usernames with a field-level conflict', async () => {
await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.admin1!)
.send({
username: `ua-created-${suffix}`,
email: `ua-created-other-${suffix}@example.org`,
displayName: 'UA Dup',
password,
locale: 'en',
})
.expect(409)
.expect((r) =>
expect((r.body as { details: Record<string, string[]> }).details.username).toEqual([
'validation.taken',
]),
);
});
it('refuses creation for non-admins', async () => {
await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.bob!)
.send({
username: `ua-sneak-${suffix}`,
email: `ua-sneak-${suffix}@example.org`,
displayName: 'UA Sneak',
password,
locale: 'en',
})
.expect(403);
});
it('lists and searches users (Site-Admin only)', async () => {
const res = await api()
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)

View File

@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
AdminCreateUserInput,
AdminUserListQuery,
AdminUserListView,
AdminUserStatus,
@ -10,7 +11,9 @@ import { PinoLogger } from 'nestjs-pino';
import { AuthService } from '../auth/auth.service';
import { AuditService } from '../audit/audit.service';
import { PondsService } from '../ponds/ponds.service';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { PseudonymizationService } from './pseudonymization.service';
/**
@ -27,12 +30,33 @@ export class UserAdminService {
private readonly prisma: PrismaService,
private readonly pseudonymizer: PseudonymizationService,
private readonly auth: AuthService,
private readonly users: UsersService,
private readonly ponds: PondsService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(UserAdminService.name);
}
/**
* Creates an account on behalf of a user (issue #331). The e-mail is
* marked verified immediately the admin vouches for the address and
* the personal pond is provisioned exactly like the verify-email path
* does, so the account is indistinguishable from a self-registered one.
*/
async createUser(actor: User, input: AdminCreateUserInput): Promise<AdminUserView> {
const user = await this.users.createUser(input);
const verified = await this.users.markEmailVerified(user.id);
await this.ponds.ensurePersonalPond(verified);
await this.audit.record({
action: 'user.created_by_admin',
actorId: actor.id,
targetType: 'user',
targetId: user.id,
});
return this.viewOf(verified, await this.pondCountOf(user.id));
}
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
const q = query.q?.trim();
const where: Prisma.UserWhereInput = q

View File

@ -53,6 +53,7 @@ export const AUDIT_EVENTS = {
'setup.completed': { severity: 'info' },
'setup.preseeded': { severity: 'info' },
'setup.smtp_stored': { severity: 'info' },
'user.created_by_admin': { severity: 'notice' },
'user.deleted': { severity: 'notice' },
'user.disabled_set': { severity: 'notice' },
'user.pseudonymized': { severity: 'notice' },

View File

@ -123,6 +123,11 @@ for (const scheme of SCHEMES) {
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
await expectClean(page, `/admin (${scheme})`);
// Anlage-Dialog (issue #331) im selben Kontext öffnen und mitscannen —
// wieder KEIN eigener Test (Rate-Limit-Lehre aus #301).
await page.locator('.user-manager__create').click();
await page.locator('.create-user-dialog').waitFor();
await expectClean(page, `/admin Anlage-Dialog (${scheme})`);
await context.close();
});
});

View File

@ -44,3 +44,43 @@ test('disabling a user in the admin UI blocks their login, enabling restores it'
await admin.close();
}
});
/**
* Direct account creation (issue #331): the dialog creates an active account
* the new user logs in immediately, no verification hop. The account stays
* in the e2e database; the unique name keeps reruns independent.
*/
test('creating a user in the admin UI yields an account that can log in at once', async ({
browser,
}) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const username = `created-${Date.now()}`;
const password = 'ein sicheres anfangspasswort';
const page = await admin.newPage();
await page.goto('/admin');
await page.locator('.user-manager__create').click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel(/username|benutzername/i).fill(username);
await dialog.getByLabel(/e-mail/i).fill(`${username}@example.org`);
await dialog.getByLabel(/display name|anzeigename/i).fill('Created via UI');
await dialog.getByLabel(/initial password|anfangspasswort/i).fill(password);
await dialog.getByRole('button', { name: /^create$|^anlegen$/i }).click();
await expect(dialog).toBeHidden();
// The list refetches; the fresh account is findable.
await page.locator('.user-manager__search').fill(username);
const row = page.locator(`.user-row[data-username="${username}"]`);
await expect(row).toBeVisible();
await expect(row.locator('.user-row__status')).toHaveText(/active|aktiv/i);
await admin.close();
// No verification mail hop: login works right away.
const ctx = await request.newContext({ baseURL: BASE_URL });
const res = await ctx.post('/api/v1/auth/login', {
data: { usernameOrEmail: username, password },
});
expect(res.status()).toBe(200);
await ctx.dispose();
});

View File

@ -1,12 +1,18 @@
import type { AdminUserListView, AdminUserView } from '@dorfteich/shared';
import { zodResolver } from '@hookform/resolvers/zod';
import type { AdminCreateUserFormInput, AdminUserListView, AdminUserView } from '@dorfteich/shared';
import { adminCreateUserSchema } from '@dorfteich/shared';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { MailCheck, ShieldMinus, ShieldPlus, Trash2, UserCheck, UserX } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context';
import { IconButton } from '../components/IconButton';
import { Field, FormError, applyFieldErrors } from '../components/forms';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable';
import { useModalFocus } from '../lib/use-modal-focus';
const PAGE_SIZE = 20;
@ -21,6 +27,8 @@ export function UserManager(): React.JSX.Element {
const { user: me } = useAuth();
const [q, setQ] = useState('');
const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false);
const createButtonRef = useRef<HTMLButtonElement>(null);
const query = useQuery({
queryKey: ['admin', 'users', q, page],
@ -42,6 +50,24 @@ export function UserManager(): React.JSX.Element {
return (
<section className="settings-section user-manager">
<h2>{t('title')}</h2>
<button
type="button"
ref={createButtonRef}
className="button user-manager__create"
onClick={() => setCreating(true)}
>
{t('create.button')}
</button>
{creating && (
<CreateUserDialog
onClose={() => setCreating(false)}
onCreated={() => {
setCreating(false);
void query.refetch();
}}
returnFocusRef={createButtonRef}
/>
)}
<input
className="user-manager__search"
type="search"
@ -92,6 +118,98 @@ export function UserManager(): React.JSX.Element {
);
}
/**
* Direct account creation by a Site Admin (issue #331): same field rules as
* self-registration, but the account is active immediately no verification
* mail hop. Field names stay flat (no dots) react-hook-form treats dots as
* path separators (#322).
*/
function CreateUserDialog({
onClose,
onCreated,
returnFocusRef,
}: {
onClose: () => void;
onCreated: () => void;
returnFocusRef: React.RefObject<HTMLElement | null>;
}): React.JSX.Element {
const { t, i18n } = useTranslation('users');
const [error, setError] = useState<unknown>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useDismissable(dialogRef, true, onClose);
useModalFocus(dialogRef, returnFocusRef);
const form = useForm<AdminCreateUserFormInput>({
resolver: zodResolver(adminCreateUserSchema) as Resolver<AdminCreateUserFormInput>,
defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' },
});
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
try {
await apiPost('/admin/users', input);
onCreated();
} catch (err) {
setError(err);
applyFieldErrors(err, (name, fieldError) =>
form.setError(name as keyof AdminCreateUserFormInput, fieldError),
);
}
});
return (
<div className="modal-overlay">
<div
className="modal create-user-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={dialogRef}
>
<h2 className="modal__title" id={titleId}>
{t('create.title')}
</h2>
<p>{t('create.intro')}</p>
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<Field label={t('create.username')} error={form.formState.errors.username?.message}>
<input type="text" autoComplete="off" {...form.register('username')} />
</Field>
<Field label={t('create.email')} error={form.formState.errors.email?.message}>
<input type="email" autoComplete="off" {...form.register('email')} />
</Field>
<Field label={t('create.displayName')} error={form.formState.errors.displayName?.message}>
<input type="text" autoComplete="off" {...form.register('displayName')} />
</Field>
<Field
label={t('create.password')}
hint={t('create.passwordHint')}
error={form.formState.errors.password?.message}
>
<input type="password" autoComplete="new-password" {...form.register('password')} />
</Field>
<Field label={t('create.locale')}>
<select {...form.register('locale')}>
<option value="de">{t('create.localeDe')}</option>
<option value="en">{t('create.localeEn')}</option>
</select>
</Field>
<div className="modal__actions">
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('create.submit')}
</button>
<button type="button" className="linklike" onClick={onClose}>
{t('create.cancel')}
</button>
</div>
</form>
</div>
</div>
);
}
function UserRow({
user,
isSelf,

View File

@ -1,6 +1,7 @@
# Audit event catalogue
**Catalogue version 1.8 (2026-08-01; 1.8 adds `pond.archived`,
**Catalogue version 1.9 (2026-08-05; 1.9 adds `user.created_by_admin`,
issue #331; 1.8 added `pond.archived`,
issue #305; 1.7 added `branding.changed`,
issue #306; 1.6 added `font.uploaded` and
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
@ -91,6 +92,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
| Id | Trigger | Severity | Actor | Target | Fields |
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
| `user.created_by_admin` | Site Admin creates an account directly (#331) | notice | the admin | `user` | — |
| `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) |
| `user.site_admin_set` | Site-Admin privilege granted/revoked | notice | the admin | `user` | `isSiteAdmin` (bool) |
| `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |

View File

@ -32,5 +32,20 @@
},
"prev": "Zurück",
"next": "Weiter",
"page": "Seite {{page}}"
"page": "Seite {{page}}",
"create": {
"button": "Person anlegen",
"title": "Person anlegen",
"intro": "Das Konto ist sofort aktiv — es wird keine Bestätigungs-E-Mail verschickt. Teile das Passwort auf einem sicheren Weg mit.",
"username": "Benutzername",
"email": "E-Mail",
"displayName": "Anzeigename",
"password": "Anfangspasswort",
"passwordHint": "Mindestens 10 Zeichen. Die Person sollte es nach dem ersten Login ändern.",
"locale": "Sprache",
"localeDe": "Deutsch",
"localeEn": "Englisch",
"submit": "Anlegen",
"cancel": "Abbrechen"
}
}

View File

@ -32,5 +32,20 @@
},
"prev": "Previous",
"next": "Next",
"page": "Page {{page}}"
"page": "Page {{page}}",
"create": {
"button": "Create user",
"title": "Create user",
"intro": "The account is active immediately — no verification mail is sent. Share the password over a secure channel.",
"username": "Username",
"email": "E-mail",
"displayName": "Display name",
"password": "Initial password",
"passwordHint": "At least 10 characters. The user should change it after their first login.",
"locale": "Language",
"localeDe": "German",
"localeEn": "English",
"submit": "Create",
"cancel": "Cancel"
}
}

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import { passwordSchema, usernameSchema } from './auth';
/**
* Site-Admin user management (issue #59): the list + actions an instance
* operator uses for support, abuse handling, and GDPR groundwork. Deleting a
@ -39,5 +41,21 @@ export type AdminUserListQuery = z.infer<typeof adminUserListQuerySchema>;
export const setUserDisabledSchema = z.object({ disabled: z.boolean() });
export const setSiteAdminSchema = z.object({ isSiteAdmin: z.boolean() });
/**
* Direct account creation by a Site Admin (issue #331). Same field rules as
* self-registration, but the account skips e-mail verification: the admin
* vouches for the address, so the user can log in right away.
*/
export const adminCreateUserSchema = z.object({
username: usernameSchema,
email: z.string().email('validation.email.invalid').max(254),
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
password: passwordSchema,
locale: z.enum(['de', 'en']).default('en'),
});
export type AdminCreateUserInput = z.infer<typeof adminCreateUserSchema>;
/** Form-side type: locale is optional before Zod applies its default. */
export type AdminCreateUserFormInput = z.input<typeof adminCreateUserSchema>;
/** The pseudonym a deleted user's authorship shows as. */
export const DELETED_USER_DISPLAY_NAME = 'Deleted user';