Add Site-Admin user management (#59)
Some checks failed
CD / Build and push images (push) Successful in 3m12s
CI / Lint, typecheck, test (push) Failing after 2m29s
CI / Auth e2e pack (push) Successful in 3m32s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
Some checks failed
CD / Build and push images (push) Successful in 3m12s
CI / Lint, typecheck, test (push) Failing after 2m29s
CI / Auth e2e pack (push) Successful in 3m32s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
Instance operators get basic user administration for support, abuse handling, and GDPR groundwork (security.md §Privacy). - api `admin/`: Site-Admin-gated `/admin/users` — a searchable, paginated list (username, e-mail, status, role, pond count, last login) plus lifecycle actions: disable/enable (a disabled user is logged out everywhere and login is refused with the distinct `account_disabled`), resend verification, delete, and grant/revoke Site Admin. Guards: you cannot act on your own account (`cannot_modify_self`) and the last Site Admin cannot be dropped (`last_site_admin`). Every action is audit-logged with the actor. - `PseudonymizationService`: account deletion scrubs the PII, removes all login identities + sessions, and trashes the personal pond — the kept row is what authorship references, so shared content the user authored shows as "Deleted user" (no orphaned/cascaded content). - web: the Admin area gains a 'Users' surface — search, pagination, and the actions (destructive ones behind an inline two-step confirm; self-actions hidden). New `users` i18n namespace (de+en). - tests: `user-admin.e2e.db.test.ts` (disable → logout + login blocked; delete → pseudonymized authorship + personal pond trashed + credentials gone; last Site Admin and self protected; Site-Admin gating); a non-destructive browser `admin-users` pack proving disable-in-UI blocks login and enable restores it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
6d3db7db38
commit
42e97b9df2
@ -208,6 +208,16 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/admin-quotas.spec.ts
|
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
|
- name: Reset login rate limit before offline pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
|||||||
@ -1,15 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { QuotasModule } from '../quotas/quotas.module';
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
import { AdminSettingsController } from './admin.controller';
|
import { AdminSettingsController } from './admin.controller';
|
||||||
|
import { PseudonymizationService } from './pseudonymization.service';
|
||||||
import { QuotaAdminController } from './quota-admin.controller';
|
import { QuotaAdminController } from './quota-admin.controller';
|
||||||
import { QuotaAdminService } from './quota-admin.service';
|
import { QuotaAdminService } from './quota-admin.service';
|
||||||
|
import { UserAdminController } from './user-admin.controller';
|
||||||
|
import { UserAdminService } from './user-admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [QuotasModule, UsersModule],
|
imports: [QuotasModule, UsersModule, AuthModule],
|
||||||
controllers: [AdminSettingsController, QuotaAdminController],
|
controllers: [AdminSettingsController, QuotaAdminController, UserAdminController],
|
||||||
providers: [QuotaAdminService],
|
providers: [QuotaAdminService, UserAdminService, PseudonymizationService],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
48
apps/api/src/admin/pseudonymization.service.ts
Normal file
48
apps/api/src/admin/pseudonymization.service.ts
Normal file
@ -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<void> {
|
||||||
|
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)');
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/api/src/admin/user-admin.controller.ts
Normal file
70
apps/api/src/admin/user-admin.controller.ts
Normal file
@ -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<AdminUserListView> {
|
||||||
|
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<AdminUserView> {
|
||||||
|
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<void> {
|
||||||
|
await this.users.resendVerification(request.user!, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||||
|
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<AdminUserView> {
|
||||||
|
return this.users.setSiteAdmin(request.user!, id, input.isSiteAdmin);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
apps/api/src/admin/user-admin.e2e.db.test.ts
Normal file
138
apps/api/src/admin/user-admin.e2e.db.test.ts
Normal file
@ -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<string, string> = {};
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
async function makeUser(handle: string, siteAdmin: boolean): Promise<void> {
|
||||||
|
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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
137
apps/api/src/admin/user-admin.service.ts
Normal file
137
apps/api/src/admin/user-admin.service.ts
Normal file
@ -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<AdminUserListView> {
|
||||||
|
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<AdminUserView> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<AdminUserView> {
|
||||||
|
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<User> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,6 @@ import { SessionsModule } from './sessions.module';
|
|||||||
// opts out with @Public().
|
// opts out with @Public().
|
||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
],
|
],
|
||||||
exports: [AuthTokensService],
|
exports: [AuthTokensService, AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
46
apps/web/e2e/admin-users.spec.ts
Normal file
46
apps/web/e2e/admin-users.spec.ts
Normal file
@ -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<number> => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -9,6 +9,7 @@ import deMembers from '@dorfteich/shared/i18n/de/members.json';
|
|||||||
import dePublic from '@dorfteich/shared/i18n/de/public.json';
|
import dePublic from '@dorfteich/shared/i18n/de/public.json';
|
||||||
import deQuotas from '@dorfteich/shared/i18n/de/quotas.json';
|
import deQuotas from '@dorfteich/shared/i18n/de/quotas.json';
|
||||||
import deSearch from '@dorfteich/shared/i18n/de/search.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 deSettings from '@dorfteich/shared/i18n/de/settings.json';
|
||||||
import enAccess from '@dorfteich/shared/i18n/en/access.json';
|
import enAccess from '@dorfteich/shared/i18n/en/access.json';
|
||||||
import enAuth from '@dorfteich/shared/i18n/en/auth.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 enPublic from '@dorfteich/shared/i18n/en/public.json';
|
||||||
import enQuotas from '@dorfteich/shared/i18n/en/quotas.json';
|
import enQuotas from '@dorfteich/shared/i18n/en/quotas.json';
|
||||||
import enSearch from '@dorfteich/shared/i18n/en/search.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 enSettings from '@dorfteich/shared/i18n/en/settings.json';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
@ -50,6 +52,7 @@ void i18n
|
|||||||
public: enPublic,
|
public: enPublic,
|
||||||
quotas: enQuotas,
|
quotas: enQuotas,
|
||||||
search: enSearch,
|
search: enSearch,
|
||||||
|
users: enUsers,
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
common: deCommon,
|
common: deCommon,
|
||||||
@ -64,6 +67,7 @@ void i18n
|
|||||||
public: dePublic,
|
public: dePublic,
|
||||||
quotas: deQuotas,
|
quotas: deQuotas,
|
||||||
search: deSearch,
|
search: deSearch,
|
||||||
|
users: deUsers,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Field, FormError, FormSuccess } from '../components/forms';
|
import { Field, FormError, FormSuccess } from '../components/forms';
|
||||||
import { apiGet, apiPatch } from '../lib/api';
|
import { apiGet, apiPatch } from '../lib/api';
|
||||||
import { QuotaManager } from './QuotaManager';
|
import { QuotaManager } from './QuotaManager';
|
||||||
|
import { UserManager } from './UserManager';
|
||||||
|
|
||||||
interface InstanceSettings {
|
interface InstanceSettings {
|
||||||
'auth.registrationMode': 'open' | 'closed';
|
'auth.registrationMode': 'open' | 'closed';
|
||||||
@ -97,6 +98,7 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<QuotaManager />
|
<QuotaManager />
|
||||||
|
<UserManager />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
179
apps/web/src/pages/UserManager.tsx
Normal file
179
apps/web/src/pages/UserManager.tsx
Normal file
@ -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<AdminUserListView>(
|
||||||
|
`/admin/users?q=${encodeURIComponent(q)}&page=${page}&pageSize=${PAGE_SIZE}`,
|
||||||
|
),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<unknown>): Promise<void> => {
|
||||||
|
await fn();
|
||||||
|
await query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = query.data;
|
||||||
|
const totalPages = data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-section user-manager">
|
||||||
|
<h2>{t('title')}</h2>
|
||||||
|
<input
|
||||||
|
className="user-manager__search"
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
placeholder={t('search')}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQ(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<table className="table user-manager__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('columns.user')}</th>
|
||||||
|
<th>{t('columns.status')}</th>
|
||||||
|
<th>{t('columns.role')}</th>
|
||||||
|
<th>{t('columns.ponds')}</th>
|
||||||
|
<th>{t('columns.lastLogin')}</th>
|
||||||
|
<th>{t('columns.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(data?.users ?? []).map((u) => (
|
||||||
|
<UserRow key={u.id} user={u} isSelf={u.id === me?.id} run={run} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="user-manager__pager">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
{t('prev')}
|
||||||
|
</button>
|
||||||
|
<span>{t('page', { page })}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
{t('next')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({
|
||||||
|
user,
|
||||||
|
isSelf,
|
||||||
|
run,
|
||||||
|
}: {
|
||||||
|
user: AdminUserView;
|
||||||
|
isSelf: boolean;
|
||||||
|
run: (fn: () => Promise<unknown>) => Promise<void>;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('users');
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
|
const disabled = user.status === 'DISABLED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="user-row" data-username={user.username}>
|
||||||
|
<td>
|
||||||
|
{user.displayName}
|
||||||
|
<span className="user-row__username"> @{user.username}</span>
|
||||||
|
{isSelf && <span className="badge">{t('actions.you')}</span>}
|
||||||
|
<div className="user-row__email">{user.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="user-row__status">{t(`status.${user.status}`)}</td>
|
||||||
|
<td>{user.isSiteAdmin ? t('role.admin') : t('role.user')}</td>
|
||||||
|
<td>{user.pondCount}</td>
|
||||||
|
<td>{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : t('never')}</td>
|
||||||
|
<td className="user-row__actions">
|
||||||
|
{!isSelf && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike user-row__disable"
|
||||||
|
onClick={() =>
|
||||||
|
void run(() =>
|
||||||
|
apiPatch(`/admin/users/${user.id}/disabled`, { disabled: !disabled }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{disabled ? t('actions.enable') : t('actions.disable')}
|
||||||
|
</button>
|
||||||
|
{user.status === 'PENDING_VERIFICATION' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike"
|
||||||
|
onClick={() =>
|
||||||
|
void run(() => apiPost(`/admin/users/${user.id}/resend-verification`))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('actions.resend')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike"
|
||||||
|
onClick={() =>
|
||||||
|
void run(() =>
|
||||||
|
apiPatch(`/admin/users/${user.id}/site-admin`, {
|
||||||
|
isSiteAdmin: !user.isSiteAdmin,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.isSiteAdmin ? t('actions.revokeAdmin') : t('actions.grantAdmin')}
|
||||||
|
</button>
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike user-row__delete-confirm"
|
||||||
|
title={t('actions.deleteConfirm', { name: user.displayName })}
|
||||||
|
onClick={() => void run(() => apiDelete(`/admin/users/${user.id}`))}
|
||||||
|
>
|
||||||
|
{t('actions.delete')}?
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike user-row__delete"
|
||||||
|
onClick={() => setConfirmingDelete(true)}
|
||||||
|
>
|
||||||
|
{t('actions.delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1724,3 +1724,33 @@ button {
|
|||||||
.quota-row__flag {
|
.quota-row__flag {
|
||||||
color: var(--color-danger);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -43,6 +43,8 @@
|
|||||||
"member_exists": "Diese Person ist bereits Mitglied dieses Teichs.",
|
"member_exists": "Diese Person ist bereits Mitglied dieses Teichs.",
|
||||||
"member_not_a_member": "Diese Person ist kein 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.",
|
"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": {
|
"validation": {
|
||||||
"required": "Dieses Feld ist erforderlich.",
|
"required": "Dieses Feld ist erforderlich.",
|
||||||
"taken": "Dieser Wert ist bereits vergeben.",
|
"taken": "Dieser Wert ist bereits vergeben.",
|
||||||
|
|||||||
36
packages/shared/i18n/de/users.json
Normal file
36
packages/shared/i18n/de/users.json
Normal file
@ -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}}"
|
||||||
|
}
|
||||||
@ -43,6 +43,8 @@
|
|||||||
"member_exists": "This user is already a member of this pond.",
|
"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_not_a_member": "This user is not a member of this pond.",
|
||||||
"member_is_owner": "The pond owner's membership cannot be changed here.",
|
"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": {
|
"validation": {
|
||||||
"required": "This field is required.",
|
"required": "This field is required.",
|
||||||
"taken": "This value is already taken.",
|
"taken": "This value is already taken.",
|
||||||
|
|||||||
36
packages/shared/i18n/en/users.json
Normal file
36
packages/shared/i18n/en/users.json
Normal file
@ -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}}"
|
||||||
|
}
|
||||||
43
packages/shared/src/admin-users.ts
Normal file
43
packages/shared/src/admin-users.ts
Normal file
@ -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<typeof adminUserListQuerySchema>;
|
||||||
|
|
||||||
|
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';
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
export * from './admin-users';
|
||||||
export * from './api-error';
|
export * from './api-error';
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './collab-token';
|
export * from './collab-token';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user