diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d982107..eecbf30 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -208,6 +208,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/admin-quotas.spec.ts + - name: Reset login rate limit before admin-users pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run admin-users pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts + - name: Reset login rate limit before offline pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts index add7a8c..762f2f7 100644 --- a/apps/api/src/admin/admin.module.ts +++ b/apps/api/src/admin/admin.module.ts @@ -1,15 +1,19 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { QuotasModule } from '../quotas/quotas.module'; import { UsersModule } from '../users/users.module'; import { AdminSettingsController } from './admin.controller'; +import { PseudonymizationService } from './pseudonymization.service'; import { QuotaAdminController } from './quota-admin.controller'; import { QuotaAdminService } from './quota-admin.service'; +import { UserAdminController } from './user-admin.controller'; +import { UserAdminService } from './user-admin.service'; @Module({ - imports: [QuotasModule, UsersModule], - controllers: [AdminSettingsController, QuotaAdminController], - providers: [QuotaAdminService], + imports: [QuotasModule, UsersModule, AuthModule], + controllers: [AdminSettingsController, QuotaAdminController, UserAdminController], + providers: [QuotaAdminService, UserAdminService, PseudonymizationService], }) export class AdminModule {} diff --git a/apps/api/src/admin/pseudonymization.service.ts b/apps/api/src/admin/pseudonymization.service.ts new file mode 100644 index 0000000..dacdd09 --- /dev/null +++ b/apps/api/src/admin/pseudonymization.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { DELETED_USER_DISPLAY_NAME } from '@dorfteich/shared'; +import { PinoLogger } from 'nestjs-pino'; + +import { PrismaService } from '../prisma/prisma.service'; + +/** + * GDPR account deletion (issue #59, security.md §Privacy). Rather than + * hard-deleting the user row — which would orphan or cascade authored content — + * this scrubs the personal data, drops the login credentials, and trashes the + * personal pond. The (kept) row is what `created_by`/authorship references, so + * shared content the user authored simply shows as "Deleted user". + */ +@Injectable() +export class PseudonymizationService { + constructor( + private readonly prisma: PrismaService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(PseudonymizationService.name); + } + + async pseudonymize(userId: string): Promise { + const marker = `deleted-${userId}`; + await this.prisma.$transaction(async (tx) => { + // Remove every login path (password + any linked identities). + await tx.userIdentity.deleteMany({ where: { userId } }); + await tx.session.deleteMany({ where: { userId } }); + await tx.user.update({ + where: { id: userId }, + data: { + username: marker, + email: `${marker}@deleted.invalid`, + displayName: DELETED_USER_DISPLAY_NAME, + status: 'DISABLED', + isSiteAdmin: false, + emailVerifiedAt: null, + }, + }); + // The personal pond follows the trash path (security.md §Privacy). + await tx.pond.updateMany({ + where: { ownerId: userId, type: 'PERSONAL', deletedAt: null }, + data: { deletedAt: new Date(), deletedBy: userId }, + }); + }); + this.logger.info({ userId }, 'audit: user pseudonymized (account deleted)'); + } +} diff --git a/apps/api/src/admin/user-admin.controller.ts b/apps/api/src/admin/user-admin.controller.ts new file mode 100644 index 0000000..13d8661 --- /dev/null +++ b/apps/api/src/admin/user-admin.controller.ts @@ -0,0 +1,70 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + AdminUserListQuery, + AdminUserListView, + AdminUserView, + adminUserListQuerySchema, + setSiteAdminSchema, + setUserDisabledSchema, +} from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { SiteAdminGuard } from './site-admin.guard'; +import { UserAdminService } from './user-admin.service'; + +/** Site-Admin user management (issue #59). */ +@Controller('admin/users') +@UseGuards(SiteAdminGuard) +export class UserAdminController { + constructor(private readonly users: UserAdminService) {} + + @Get() + async list( + @Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery, + ): Promise { + return this.users.list(query); + } + + @Patch(':id/disabled') + async setDisabled( + @Param('id') id: string, + @Body(new ZodValidationPipe(setUserDisabledSchema)) input: { disabled: boolean }, + @Req() request: AuthedRequest, + ): Promise { + return this.users.setDisabled(request.user!, id, input.disabled); + } + + @Post(':id/resend-verification') + @HttpCode(204) + async resendVerification(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.users.resendVerification(request.user!, id); + } + + @Delete(':id') + @HttpCode(204) + async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.users.deleteUser(request.user!, id); + } + + @Patch(':id/site-admin') + async setSiteAdmin( + @Param('id') id: string, + @Body(new ZodValidationPipe(setSiteAdminSchema)) input: { isSiteAdmin: boolean }, + @Req() request: AuthedRequest, + ): Promise { + return this.users.setSiteAdmin(request.user!, id, input.isSiteAdmin); + } +} diff --git a/apps/api/src/admin/user-admin.e2e.db.test.ts b/apps/api/src/admin/user-admin.e2e.db.test.ts new file mode 100644 index 0000000..731c123 --- /dev/null +++ b/apps/api/src/admin/user-admin.e2e.db.test.ts @@ -0,0 +1,138 @@ +import { INestApplication } from '@nestjs/common'; +import { AdminUserListView } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { PondsService } from '../ponds/ponds.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * Site-Admin user management end to end (issue #59): disable logs a user out + * and blocks login; delete pseudonymizes authorship and trashes the personal + * pond; the last Site Admin and one's own account are protected. + */ +describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'nutzerverwaltung ist ernst 1'; + const ids: Record = {}; + const cookies: Record = {}; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string, siteAdmin: boolean): Promise { + const users = app.get(UsersService); + const username = `ua-${handle}-${suffix}`; + const user = await users.createUser({ + username, + email: `${username}@example.org`, + displayName: `UA ${handle}`, + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + if (siteAdmin) + await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } }); + ids[handle] = user.id; + cookies[handle] = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + await makeUser('admin1', true); + await makeUser('admin2', true); + await makeUser('bob', false); + // Bob gets a personal pond (trashed on delete). + await app + .get(PondsService) + .ensurePersonalPond(await prisma.user.findUniqueOrThrow({ where: { id: ids.bob! } })); + }); + + afterAll(async () => { + const all = Object.values(ids); + await prisma.session.deleteMany({ where: { userId: { in: all } } }); + await prisma.pond.deleteMany({ where: { ownerId: { in: all } } }); + await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); + await prisma.user.deleteMany({ where: { id: { in: all } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('lists and searches users (Site-Admin only)', async () => { + const res = await api() + .get(`/api/v1/admin/users?q=ua-bob-${suffix}`) + .set('Cookie', cookies.admin1!) + .expect(200); + const body = res.body as AdminUserListView; + expect(body.users.map((u) => u.id)).toContain(ids.bob); + await api().get('/api/v1/admin/users').set('Cookie', cookies.bob!).expect(403); + }); + + it('disabling logs the user out everywhere and blocks login', async () => { + await api() + .patch(`/api/v1/admin/users/${ids.bob}/disabled`) + .set('Cookie', cookies.admin1!) + .send({ disabled: true }) + .expect(200); + // Existing session is gone… + await api().get('/api/v1/auth/me').set('Cookie', cookies.bob!).expect(401); + // …and login is refused with a distinct code. + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: `ua-bob-${suffix}`, password }) + .expect(403) + .expect((r) => expect((r.body as { code: string }).code).toBe('account_disabled')); + + await api() + .patch(`/api/v1/admin/users/${ids.bob}/disabled`) + .set('Cookie', cookies.admin1!) + .send({ disabled: false }) + .expect(200); + }); + + it('deleting pseudonymizes authorship and trashes the personal pond', async () => { + await api().delete(`/api/v1/admin/users/${ids.bob}`).set('Cookie', cookies.admin1!).expect(204); + const user = await prisma.user.findUniqueOrThrow({ where: { id: ids.bob! } }); + expect(user.displayName).toBe('Deleted user'); + expect(user.username).toBe(`deleted-${ids.bob}`); + expect(user.status).toBe('DISABLED'); + const personal = await prisma.pond.findFirstOrThrow({ + where: { ownerId: ids.bob!, type: 'PERSONAL' }, + }); + expect(personal.deletedAt).not.toBeNull(); + // Credentials are gone → login impossible even with the old password. + expect(await prisma.userIdentity.count({ where: { userId: ids.bob! } })).toBe(0); + }); + + it("protects the last Site Admin and one's own account", async () => { + // Revoke admin2 → fine (admin1 remains). + await api() + .patch(`/api/v1/admin/users/${ids.admin2}/site-admin`) + .set('Cookie', cookies.admin1!) + .send({ isSiteAdmin: false }) + .expect(200); + // Now admin1 is the last → cannot revoke self, and self-action is blocked anyway. + await api() + .patch(`/api/v1/admin/users/${ids.admin1}/site-admin`) + .set('Cookie', cookies.admin1!) + .send({ isSiteAdmin: false }) + .expect(400) + .expect((r) => expect((r.body as { code: string }).code).toBe('cannot_modify_self')); + await api() + .delete(`/api/v1/admin/users/${ids.admin1}`) + .set('Cookie', cookies.admin1!) + .expect(400) + .expect((r) => expect((r.body as { code: string }).code).toBe('cannot_modify_self')); + }); +}); diff --git a/apps/api/src/admin/user-admin.service.ts b/apps/api/src/admin/user-admin.service.ts new file mode 100644 index 0000000..4a0f8fd --- /dev/null +++ b/apps/api/src/admin/user-admin.service.ts @@ -0,0 +1,137 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + AdminUserListQuery, + AdminUserListView, + AdminUserStatus, + AdminUserView, +} from '@dorfteich/shared'; +import { Prisma, User } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { AuthService } from '../auth/auth.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { PseudonymizationService } from './pseudonymization.service'; + +/** + * Site-Admin user administration (issue #59): the searchable list plus the + * lifecycle actions (disable/enable, resend verification, delete, grant/revoke + * Site Admin). Guards protect the operator from locking the instance out — + * you cannot act on your own account, and the last Site Admin cannot be + * dropped. Every action is audit-logged with the actor; Site-Admin gating is + * the controller's job. + */ +@Injectable() +export class UserAdminService { + constructor( + private readonly prisma: PrismaService, + private readonly pseudonymizer: PseudonymizationService, + private readonly auth: AuthService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(UserAdminService.name); + } + + async list(query: AdminUserListQuery): Promise { + const q = query.q?.trim(); + const where: Prisma.UserWhereInput = q + ? { + OR: [ + { username: { contains: q, mode: 'insensitive' } }, + { email: { contains: q, mode: 'insensitive' } }, + { displayName: { contains: q, mode: 'insensitive' } }, + ], + } + : {}; + const [total, rows] = await Promise.all([ + this.prisma.user.count({ where }), + this.prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + ]); + const owned = await this.prisma.pond.groupBy({ + by: ['ownerId'], + where: { ownerId: { in: rows.map((u) => u.id) }, deletedAt: null }, + _count: { _all: true }, + }); + const pondCount = new Map(owned.map((o) => [o.ownerId, o._count._all])); + return { + users: rows.map((u) => this.viewOf(u, pondCount.get(u.id) ?? 0)), + total, + page: query.page, + pageSize: query.pageSize, + }; + } + + async setDisabled(actor: User, id: string, disabled: boolean): Promise { + await this.requireOther(actor, id); + const updated = await this.prisma.user.update({ + where: { id }, + data: { status: disabled ? 'DISABLED' : 'ACTIVE' }, + }); + // A disabled user is logged out everywhere; login then blocks with a + // distinct message (auth.service: account_disabled). + if (disabled) await this.prisma.session.deleteMany({ where: { userId: id } }); + this.logger.info({ actor: actor.id, userId: id, disabled }, 'audit: user disabled toggled'); + return this.viewOf(updated, await this.pondCountOf(id)); + } + + async resendVerification(actor: User, id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + if (!user) throw new NotFoundException(); + await this.auth.resendVerification(user.email); // no-op unless PENDING + this.logger.info({ actor: actor.id, userId: id }, 'audit: verification resent'); + } + + async deleteUser(actor: User, id: string): Promise { + const user = await this.requireOther(actor, id); + if (user.isSiteAdmin) await this.assertNotLastSiteAdmin(); + await this.pseudonymizer.pseudonymize(id); + this.logger.info({ actor: actor.id, userId: id }, 'audit: user deleted'); + } + + async setSiteAdmin(actor: User, id: string, value: boolean): Promise { + const user = await this.requireOther(actor, id); // not on self + if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin(); + const updated = await this.prisma.user.update({ + where: { id }, + data: { isSiteAdmin: value }, + }); + this.logger.info({ actor: actor.id, userId: id, isSiteAdmin: value }, 'audit: site-admin set'); + return this.viewOf(updated, await this.pondCountOf(id)); + } + + private async requireOther(actor: User, id: string): Promise { + if (id === actor.id) throw new BadRequestException({ code: 'cannot_modify_self' }); + const user = await this.prisma.user.findUnique({ where: { id } }); + if (!user) throw new NotFoundException(); + return user; + } + + private async assertNotLastSiteAdmin(): Promise { + const admins = await this.prisma.user.count({ + where: { isSiteAdmin: true, status: { not: 'DISABLED' } }, + }); + if (admins <= 1) throw new BadRequestException({ code: 'last_site_admin' }); + } + + private async pondCountOf(id: string): Promise { + return this.prisma.pond.count({ where: { ownerId: id, deletedAt: null } }); + } + + private viewOf(user: User, pondCount: number): AdminUserView { + return { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + status: user.status as AdminUserStatus, + isSiteAdmin: user.isSiteAdmin, + createdAt: user.createdAt.toISOString(), + lastLoginAt: user.lastLoginAt?.toISOString() ?? null, + pondCount, + }; + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index d9c4887..0ad630a 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -20,6 +20,6 @@ import { SessionsModule } from './sessions.module'; // opts out with @Public(). { provide: APP_GUARD, useClass: AuthGuard }, ], - exports: [AuthTokensService], + exports: [AuthTokensService, AuthService], }) export class AuthModule {} diff --git a/apps/web/e2e/admin-users.spec.ts b/apps/web/e2e/admin-users.spec.ts new file mode 100644 index 0000000..f33b17f --- /dev/null +++ b/apps/web/e2e/admin-users.spec.ts @@ -0,0 +1,46 @@ +import { expect, request, test } from '@playwright/test'; + +import { contextForUser, FIXTURE_PASSWORD } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** + * Site-Admin user management UI (issue #59): disabling a user through the admin + * list logs them out and blocks login with a distinct message; enabling + * restores access. (Delete + pseudonymization is covered thoroughly by the api + * db test; the browser pack stays non-destructive so fixtures survive.) + */ +test('disabling a user in the admin UI blocks their login, enabling restores it', async ({ + browser, +}) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + + const login = async (): Promise => { + const ctx = await request.newContext({ baseURL: BASE_URL }); + const res = await ctx.post('/api/v1/auth/login', { + data: { usernameOrEmail: 'fixture-viewer', password: FIXTURE_PASSWORD }, + }); + const status = res.status(); + await ctx.dispose(); + return status; + }; + + expect(await login()).toBe(200); // active to begin with + + const page = await admin.newPage(); + await page.goto('/admin'); + await page.locator('.user-manager__search').fill('fixture-viewer'); + const row = page.locator('.user-row[data-username="fixture-viewer"]'); + await expect(row).toBeVisible(); + + try { + await row.locator('.user-row__disable').click(); + await expect(row.locator('.user-row__status')).toHaveText(/disabled|deaktiviert/i); + expect(await login()).toBe(403); // account_disabled + } finally { + // Re-enable so the fixture is left intact for other packs / reruns. + await row.locator('.user-row__disable').click(); + await expect(row.locator('.user-row__status')).toHaveText(/active|aktiv/i); + await admin.close(); + } +}); diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 4e84648..0f82131 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -9,6 +9,7 @@ import deMembers from '@dorfteich/shared/i18n/de/members.json'; import dePublic from '@dorfteich/shared/i18n/de/public.json'; import deQuotas from '@dorfteich/shared/i18n/de/quotas.json'; import deSearch from '@dorfteich/shared/i18n/de/search.json'; +import deUsers from '@dorfteich/shared/i18n/de/users.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import enAccess from '@dorfteich/shared/i18n/en/access.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json'; @@ -21,6 +22,7 @@ import enMembers from '@dorfteich/shared/i18n/en/members.json'; import enPublic from '@dorfteich/shared/i18n/en/public.json'; import enQuotas from '@dorfteich/shared/i18n/en/quotas.json'; import enSearch from '@dorfteich/shared/i18n/en/search.json'; +import enUsers from '@dorfteich/shared/i18n/en/users.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import i18n from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; @@ -50,6 +52,7 @@ void i18n public: enPublic, quotas: enQuotas, search: enSearch, + users: enUsers, }, de: { common: deCommon, @@ -64,6 +67,7 @@ void i18n public: dePublic, quotas: deQuotas, search: deSearch, + users: deUsers, }, }, defaultNS: 'common', diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index d903847..dbb179f 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Field, FormError, FormSuccess } from '../components/forms'; import { apiGet, apiPatch } from '../lib/api'; import { QuotaManager } from './QuotaManager'; +import { UserManager } from './UserManager'; interface InstanceSettings { 'auth.registrationMode': 'open' | 'closed'; @@ -97,6 +98,7 @@ export function AdminSettingsPage(): React.JSX.Element { + ); } diff --git a/apps/web/src/pages/UserManager.tsx b/apps/web/src/pages/UserManager.tsx new file mode 100644 index 0000000..ff94694 --- /dev/null +++ b/apps/web/src/pages/UserManager.tsx @@ -0,0 +1,179 @@ +import type { AdminUserListView, AdminUserView } from '@dorfteich/shared'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useAuth } from '../auth/auth-context'; +import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; + +const PAGE_SIZE = 20; + +/** + * Site-Admin user management (issue #59): a searchable, paginated list with the + * lifecycle actions. Destructive ones use an inline two-step confirm; the api + * enforces the "not on self / not the last Site Admin" rules — this only hides + * the buttons on your own row. + */ +export function UserManager(): React.JSX.Element { + const { t } = useTranslation('users'); + const { user: me } = useAuth(); + const [q, setQ] = useState(''); + const [page, setPage] = useState(1); + + const query = useQuery({ + queryKey: ['admin', 'users', q, page], + queryFn: () => + apiGet( + `/admin/users?q=${encodeURIComponent(q)}&page=${page}&pageSize=${PAGE_SIZE}`, + ), + placeholderData: keepPreviousData, + }); + + const run = async (fn: () => Promise): Promise => { + await fn(); + await query.refetch(); + }; + + const data = query.data; + const totalPages = data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1; + + return ( +
+

{t('title')}

+ { + setQ(e.target.value); + setPage(1); + }} + /> + + + + + + + + + + + + + {(data?.users ?? []).map((u) => ( + + ))} + +
{t('columns.user')}{t('columns.status')}{t('columns.role')}{t('columns.ponds')}{t('columns.lastLogin')}{t('columns.actions')}
+
+ + {t('page', { page })} + +
+
+ ); +} + +function UserRow({ + user, + isSelf, + run, +}: { + user: AdminUserView; + isSelf: boolean; + run: (fn: () => Promise) => Promise; +}): React.JSX.Element { + const { t } = useTranslation('users'); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const disabled = user.status === 'DISABLED'; + + return ( + + + {user.displayName} + @{user.username} + {isSelf && {t('actions.you')}} +
{user.email}
+ + {t(`status.${user.status}`)} + {user.isSiteAdmin ? t('role.admin') : t('role.user')} + {user.pondCount} + {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : t('never')} + + {!isSelf && ( + <> + + {user.status === 'PENDING_VERIFICATION' && ( + + )} + + {confirmingDelete ? ( + + ) : ( + + )} + + )} + + + ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 2d45e96..9f07c66 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1724,3 +1724,33 @@ button { .quota-row__flag { color: var(--color-danger); } + +/* User management (issue #59) */ +.user-manager__search { + width: 100%; + margin-bottom: var(--space-3); +} + +.user-row__username, +.user-row__email { + color: var(--color-text-muted); + font-size: 0.85rem; +} + +.user-row__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.user-row__delete-confirm { + color: var(--color-danger); + font-weight: 600; +} + +.user-manager__pager { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-3); +} diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index b652f0b..5e240d4 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -43,6 +43,8 @@ "member_exists": "Diese Person ist bereits Mitglied dieses Teichs.", "member_not_a_member": "Diese Person ist kein Mitglied dieses Teichs.", "member_is_owner": "Die Mitgliedschaft des Teich-Eigentümers kann hier nicht geändert werden.", + "cannot_modify_self": "Du kannst diese Aktion nicht auf dein eigenes Konto anwenden.", + "last_site_admin": "Der letzte Site-Admin kann nicht entfernt werden.", "validation": { "required": "Dieses Feld ist erforderlich.", "taken": "Dieser Wert ist bereits vergeben.", diff --git a/packages/shared/i18n/de/users.json b/packages/shared/i18n/de/users.json new file mode 100644 index 0000000..90f38ee --- /dev/null +++ b/packages/shared/i18n/de/users.json @@ -0,0 +1,36 @@ +{ + "title": "Personen", + "search": "Suche nach Benutzername, E-Mail oder Name", + "columns": { + "user": "Person", + "status": "Status", + "role": "Rolle", + "ponds": "Teiche", + "created": "Erstellt", + "lastLogin": "Letzter Login", + "actions": "Aktionen" + }, + "status": { + "PENDING_VERIFICATION": "Ausstehend", + "ACTIVE": "Aktiv", + "DISABLED": "Deaktiviert" + }, + "role": { + "admin": "Site-Admin", + "user": "Person" + }, + "nie": "never", + "actions": { + "disable": "Deaktivieren", + "enable": "Aktivieren", + "resend": "Bestätigung erneut senden", + "delete": "Löschen", + "grantAdmin": "Zum Admin machen", + "revokeAdmin": "Admin entfernen", + "deleteConfirm": "{{name}} löschen? Die Urheberschaft wird zu „Gelöschte Person“ und der persönliche Teich wandert in den Papierkorb. Nicht rückgängig machbar.", + "du": "you" + }, + "prev": "Zurück", + "next": "Weiter", + "page": "Seite {{page}}" +} diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 373dc89..adc4563 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -43,6 +43,8 @@ "member_exists": "This user is already a member of this pond.", "member_not_a_member": "This user is not a member of this pond.", "member_is_owner": "The pond owner's membership cannot be changed here.", + "cannot_modify_self": "You cannot perform this action on your own account.", + "last_site_admin": "The last Site Admin cannot be removed.", "validation": { "required": "This field is required.", "taken": "This value is already taken.", diff --git a/packages/shared/i18n/en/users.json b/packages/shared/i18n/en/users.json new file mode 100644 index 0000000..c7db568 --- /dev/null +++ b/packages/shared/i18n/en/users.json @@ -0,0 +1,36 @@ +{ + "title": "Users", + "search": "Search by username, e-mail or name", + "columns": { + "user": "User", + "status": "Status", + "role": "Role", + "ponds": "Ponds", + "created": "Created", + "lastLogin": "Last login", + "actions": "Actions" + }, + "status": { + "PENDING_VERIFICATION": "Pending", + "ACTIVE": "Active", + "DISABLED": "Disabled" + }, + "role": { + "admin": "Site Admin", + "user": "User" + }, + "never": "never", + "actions": { + "disable": "Disable", + "enable": "Enable", + "resend": "Resend verification", + "delete": "Delete", + "grantAdmin": "Make admin", + "revokeAdmin": "Remove admin", + "deleteConfirm": "Delete {{name}}? Their authorship becomes “Deleted user” and their personal pond is trashed. This cannot be undone.", + "you": "you" + }, + "prev": "Previous", + "next": "Next", + "page": "Page {{page}}" +} diff --git a/packages/shared/src/admin-users.ts b/packages/shared/src/admin-users.ts new file mode 100644 index 0000000..23fb089 --- /dev/null +++ b/packages/shared/src/admin-users.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +/** + * Site-Admin user management (issue #59): the list + actions an instance + * operator uses for support, abuse handling, and GDPR groundwork. Deleting a + * user pseudonymizes their authorship ("Deleted user") and trashes their + * personal pond (security.md §Privacy) rather than hard-deleting rows. + */ + +export type AdminUserStatus = 'PENDING_VERIFICATION' | 'ACTIVE' | 'DISABLED'; + +export interface AdminUserView { + id: string; + username: string; + email: string; + displayName: string; + status: AdminUserStatus; + isSiteAdmin: boolean; + createdAt: string; + lastLoginAt: string | null; + /** Ponds this user owns (personal + shared). */ + pondCount: number; +} + +export interface AdminUserListView { + users: AdminUserView[]; + total: number; + page: number; + pageSize: number; +} + +export const adminUserListQuerySchema = z.object({ + q: z.string().trim().optional(), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), +}); +export type AdminUserListQuery = z.infer; + +export const setUserDisabledSchema = z.object({ disabled: z.boolean() }); +export const setSiteAdminSchema = z.object({ isSiteAdmin: z.boolean() }); + +/** The pseudonym a deleted user's authorship shows as. */ +export const DELETED_USER_DISPLAY_NAME = 'Deleted user'; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7a1c75f..759c71c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from './admin-users'; export * from './api-error'; export * from './auth'; export * from './collab-token';