From 9cf7b85b934059a868c1ee7f04e621aff6043cbc Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Wed, 5 Aug 2026 12:23:01 +0200 Subject: [PATCH] Admin can create user accounts directly (#331) POST /admin/users (Site-Admin guard) creates an account with the same field rules as self-registration, but active immediately: the admin vouches for the address, so the e-mail is marked verified and the personal pond is provisioned exactly like the verify-email path does (markEmailVerified alone would skip the pond). The user manager gains a create dialog (useModalFocus/useDismissable, Field wiring, flat RHF field names per the #322 lesson). New audit action user.created_by_admin, catalogue bumped to 1.9. Tests: api e2e-db (create + immediate login + personal pond, duplicate username 409, non-admin 403), web e2e through the dialog, and the admin a11y scan now opens the dialog too. Both packs verified locally against a fresh stack. Closes #331 --- apps/api/src/admin/admin.module.ts | 11 +- apps/api/src/admin/user-admin.controller.ts | 10 ++ apps/api/src/admin/user-admin.e2e.db.test.ts | 58 +++++++++ apps/api/src/admin/user-admin.service.ts | 24 ++++ apps/api/src/audit/audit-actions.ts | 1 + apps/web/e2e/a11y.spec.ts | 5 + apps/web/e2e/admin-users.spec.ts | 40 ++++++ apps/web/src/pages/UserManager.tsx | 122 ++++++++++++++++++- docs/architecture/audit-events.md | 4 +- packages/shared/i18n/de/users.json | 17 ++- packages/shared/i18n/en/users.json | 17 ++- packages/shared/src/admin-users.ts | 18 +++ 12 files changed, 321 insertions(+), 6 deletions(-) diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts index 56e6b97..030c881 100644 --- a/apps/api/src/admin/admin.module.ts +++ b/apps/api/src/admin/admin.module.ts @@ -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, diff --git a/apps/api/src/admin/user-admin.controller.ts b/apps/api/src/admin/user-admin.controller.ts index 13d8661..0c1e525 100644 --- a/apps/api/src/admin/user-admin.controller.ts +++ b/apps/api/src/admin/user-admin.controller.ts @@ -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 { + return this.users.createUser(request.user!, input); + } + @Get() async list( @Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery, diff --git a/apps/api/src/admin/user-admin.e2e.db.test.ts b/apps/api/src/admin/user-admin.e2e.db.test.ts index 5fb17f8..2488f61 100644 --- a/apps/api/src/admin/user-admin.e2e.db.test.ts +++ b/apps/api/src/admin/user-admin.e2e.db.test.ts @@ -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 }).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}`) diff --git a/apps/api/src/admin/user-admin.service.ts b/apps/api/src/admin/user-admin.service.ts index 2d230e4..d048ec7 100644 --- a/apps/api/src/admin/user-admin.service.ts +++ b/apps/api/src/admin/user-admin.service.ts @@ -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 { + 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 { const q = query.q?.trim(); const where: Prisma.UserWhereInput = q diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts index 699cd65..cfcffe8 100644 --- a/apps/api/src/audit/audit-actions.ts +++ b/apps/api/src/audit/audit-actions.ts @@ -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' }, diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index 605a08b..bc3c093 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -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(); }); }); diff --git a/apps/web/e2e/admin-users.spec.ts b/apps/web/e2e/admin-users.spec.ts index f33b17f..aa19ace 100644 --- a/apps/web/e2e/admin-users.spec.ts +++ b/apps/web/e2e/admin-users.spec.ts @@ -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(); +}); diff --git a/apps/web/src/pages/UserManager.tsx b/apps/web/src/pages/UserManager.tsx index 9c75239..46a7ec9 100644 --- a/apps/web/src/pages/UserManager.tsx +++ b/apps/web/src/pages/UserManager.tsx @@ -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(null); const query = useQuery({ queryKey: ['admin', 'users', q, page], @@ -42,6 +50,24 @@ export function UserManager(): React.JSX.Element { return (

{t('title')}

+ + {creating && ( + setCreating(false)} + onCreated={() => { + setCreating(false); + void query.refetch(); + }} + returnFocusRef={createButtonRef} + /> + )} void; + onCreated: () => void; + returnFocusRef: React.RefObject; +}): React.JSX.Element { + const { t, i18n } = useTranslation('users'); + const [error, setError] = useState(null); + const dialogRef = useRef(null); + const titleId = useId(); + useDismissable(dialogRef, true, onClose); + useModalFocus(dialogRef, returnFocusRef); + + const form = useForm({ + resolver: zodResolver(adminCreateUserSchema) as Resolver, + 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 ( +
+
+

+ {t('create.title')} +

+

{t('create.intro')}

+
+ + + + + + + + + + + + + + + + +
+ + +
+ +
+
+ ); +} + function UserRow({ user, isSelf, diff --git a/docs/architecture/audit-events.md b/docs/architecture/audit-events.md index 17e58ea..40836e3 100644 --- a/docs/architecture/audit-events.md +++ b/docs/architecture/audit-events.md @@ -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` | — | diff --git a/packages/shared/i18n/de/users.json b/packages/shared/i18n/de/users.json index c0cf29d..1c254ff 100644 --- a/packages/shared/i18n/de/users.json +++ b/packages/shared/i18n/de/users.json @@ -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" + } } diff --git a/packages/shared/i18n/en/users.json b/packages/shared/i18n/en/users.json index c7db568..6377453 100644 --- a/packages/shared/i18n/en/users.json +++ b/packages/shared/i18n/en/users.json @@ -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" + } } diff --git a/packages/shared/src/admin-users.ts b/packages/shared/src/admin-users.ts index 23fb089..6bb743f 100644 --- a/packages/shared/src/admin-users.ts +++ b/packages/shared/src/admin-users.ts @@ -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; 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; +/** Form-side type: locale is optional before Zod applies its default. */ +export type AdminCreateUserFormInput = z.input; + /** The pseudonym a deleted user's authorship shows as. */ export const DELETED_USER_DISPLAY_NAME = 'Deleted user';