Admin can create user accounts directly (#331)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m47s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m47s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
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
This commit is contained in:
parent
64f2deb40f
commit
9cf7b85b93
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { BackupModule } from '../backup/backup.module';
|
import { BackupModule } from '../backup/backup.module';
|
||||||
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
import { QuotasModule } from '../quotas/quotas.module';
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
import { SchedulerModule } from '../scheduler/scheduler.module';
|
import { SchedulerModule } from '../scheduler/scheduler.module';
|
||||||
import { SearchModule } from '../search/search.module';
|
import { SearchModule } from '../search/search.module';
|
||||||
@ -19,7 +20,15 @@ import { UserAdminController } from './user-admin.controller';
|
|||||||
import { UserAdminService } from './user-admin.service';
|
import { UserAdminService } from './user-admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule, BackupModule, SearchModule],
|
imports: [
|
||||||
|
QuotasModule,
|
||||||
|
UsersModule,
|
||||||
|
AuthModule,
|
||||||
|
SchedulerModule,
|
||||||
|
BackupModule,
|
||||||
|
SearchModule,
|
||||||
|
PondsModule,
|
||||||
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
AdminSettingsController,
|
AdminSettingsController,
|
||||||
BackupAdminController,
|
BackupAdminController,
|
||||||
|
|||||||
@ -12,9 +12,11 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
AdminCreateUserInput,
|
||||||
AdminUserListQuery,
|
AdminUserListQuery,
|
||||||
AdminUserListView,
|
AdminUserListView,
|
||||||
AdminUserView,
|
AdminUserView,
|
||||||
|
adminCreateUserSchema,
|
||||||
adminUserListQuerySchema,
|
adminUserListQuerySchema,
|
||||||
setSiteAdminSchema,
|
setSiteAdminSchema,
|
||||||
setUserDisabledSchema,
|
setUserDisabledSchema,
|
||||||
@ -31,6 +33,14 @@ import { UserAdminService } from './user-admin.service';
|
|||||||
export class UserAdminController {
|
export class UserAdminController {
|
||||||
constructor(private readonly users: UserAdminService) {}
|
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()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,
|
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,
|
||||||
|
|||||||
@ -69,6 +69,64 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
|
|||||||
await app.close();
|
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 () => {
|
it('lists and searches users (Site-Admin only)', async () => {
|
||||||
const res = await api()
|
const res = await api()
|
||||||
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
|
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
AdminCreateUserInput,
|
||||||
AdminUserListQuery,
|
AdminUserListQuery,
|
||||||
AdminUserListView,
|
AdminUserListView,
|
||||||
AdminUserStatus,
|
AdminUserStatus,
|
||||||
@ -10,7 +11,9 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
import { PseudonymizationService } from './pseudonymization.service';
|
import { PseudonymizationService } from './pseudonymization.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,12 +30,33 @@ export class UserAdminService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly pseudonymizer: PseudonymizationService,
|
private readonly pseudonymizer: PseudonymizationService,
|
||||||
private readonly auth: AuthService,
|
private readonly auth: AuthService,
|
||||||
|
private readonly users: UsersService,
|
||||||
|
private readonly ponds: PondsService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(UserAdminService.name);
|
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> {
|
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
|
||||||
const q = query.q?.trim();
|
const q = query.q?.trim();
|
||||||
const where: Prisma.UserWhereInput = q
|
const where: Prisma.UserWhereInput = q
|
||||||
|
|||||||
@ -53,6 +53,7 @@ export const AUDIT_EVENTS = {
|
|||||||
'setup.completed': { severity: 'info' },
|
'setup.completed': { severity: 'info' },
|
||||||
'setup.preseeded': { severity: 'info' },
|
'setup.preseeded': { severity: 'info' },
|
||||||
'setup.smtp_stored': { severity: 'info' },
|
'setup.smtp_stored': { severity: 'info' },
|
||||||
|
'user.created_by_admin': { severity: 'notice' },
|
||||||
'user.deleted': { severity: 'notice' },
|
'user.deleted': { severity: 'notice' },
|
||||||
'user.disabled_set': { severity: 'notice' },
|
'user.disabled_set': { severity: 'notice' },
|
||||||
'user.pseudonymized': { severity: 'notice' },
|
'user.pseudonymized': { severity: 'notice' },
|
||||||
|
|||||||
@ -123,6 +123,11 @@ for (const scheme of SCHEMES) {
|
|||||||
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
|
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
|
||||||
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
|
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
|
||||||
await expectClean(page, `/admin (${scheme})`);
|
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();
|
await context.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -44,3 +44,43 @@ test('disabling a user in the admin UI blocks their login, enabling restores it'
|
|||||||
await admin.close();
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@ -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 { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
import { MailCheck, ShieldMinus, ShieldPlus, Trash2, UserCheck, UserX } from 'lucide-react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { IconButton } from '../components/IconButton';
|
import { IconButton } from '../components/IconButton';
|
||||||
|
import { Field, FormError, applyFieldErrors } from '../components/forms';
|
||||||
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
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;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@ -21,6 +27,8 @@ export function UserManager(): React.JSX.Element {
|
|||||||
const { user: me } = useAuth();
|
const { user: me } = useAuth();
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const createButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['admin', 'users', q, page],
|
queryKey: ['admin', 'users', q, page],
|
||||||
@ -42,6 +50,24 @@ export function UserManager(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<section className="settings-section user-manager">
|
<section className="settings-section user-manager">
|
||||||
<h2>{t('title')}</h2>
|
<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
|
<input
|
||||||
className="user-manager__search"
|
className="user-manager__search"
|
||||||
type="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({
|
function UserRow({
|
||||||
user,
|
user,
|
||||||
isSelf,
|
isSelf,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
# Audit event catalogue
|
# 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 #305; 1.7 added `branding.changed`,
|
||||||
issue #306; 1.6 added `font.uploaded` and
|
issue #306; 1.6 added `font.uploaded` and
|
||||||
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
|
`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 |
|
| 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.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.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` | — |
|
| `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |
|
||||||
|
|||||||
@ -32,5 +32,20 @@
|
|||||||
},
|
},
|
||||||
"prev": "Zurück",
|
"prev": "Zurück",
|
||||||
"next": "Weiter",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,5 +32,20 @@
|
|||||||
},
|
},
|
||||||
"prev": "Previous",
|
"prev": "Previous",
|
||||||
"next": "Next",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { passwordSchema, usernameSchema } from './auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site-Admin user management (issue #59): the list + actions an instance
|
* Site-Admin user management (issue #59): the list + actions an instance
|
||||||
* operator uses for support, abuse handling, and GDPR groundwork. Deleting a
|
* 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 setUserDisabledSchema = z.object({ disabled: z.boolean() });
|
||||||
export const setSiteAdminSchema = z.object({ isSiteAdmin: 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. */
|
/** The pseudonym a deleted user's authorship shows as. */
|
||||||
export const DELETED_USER_DISPLAY_NAME = 'Deleted user';
|
export const DELETED_USER_DISPLAY_NAME = 'Deleted user';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user