feat: user invite flow, deactivate/delete, favicon, dashboard loading fix, admin full-width
- Invite flow: admin can invite users by email with role selection; accept-invite page sets password and creates the account; 72-hour token expiry; E2E tests - User deactivate/reactivate/delete: new tRPC procedures + UI buttons; deactivation revokes all active sessions immediately; delete cascades vacation/broadcast records; isActive field added via migration 20260402000000_user_isactive - Auth: block login for inactive users with audit entry - Favicon: SVG favicon + ICO/PNG fallbacks (16, 32, 180, 192, 512px); manifest updated - Dashboard: GridLayout dynamic-import loading skeleton prevents blank dark area on first login before react-grid-layout chunk is cached - Admin users: remove max-w-5xl constraint so table uses full page width - Dev: docker container restart workflow documented in LEARNINGS.md; Prisma generate must run inside the container after schema changes (named node_modules volume) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
+23
-8
@@ -24,8 +24,11 @@ describe("assistant user self-service dashboard layout tools", () => {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
dashboardLayout: {
|
||||
version: 2,
|
||||
gridCols: 12,
|
||||
widgets: [
|
||||
{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } },
|
||||
// Valid widget type so normalization preserves it
|
||||
{ id: "stat-1", type: "stat-cards", x: 0, y: 0, w: 12, h: 3 },
|
||||
],
|
||||
},
|
||||
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
|
||||
@@ -40,14 +43,26 @@ describe("assistant user self-service dashboard layout tools", () => {
|
||||
where: { id: "user_1" },
|
||||
select: { dashboardLayout: true, updatedAt: true },
|
||||
});
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
layout: {
|
||||
version: 2,
|
||||
gridCols: 12,
|
||||
widgets: [],
|
||||
const parsed = JSON.parse(result.content) as { layout: { widgets: unknown[] } | null };
|
||||
expect(parsed.layout).not.toBeNull();
|
||||
expect(parsed.layout?.widgets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns null layout when stored widgets have no valid type (bug #27 guard)", async () => {
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
dashboardLayout: {
|
||||
widgets: [{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }],
|
||||
},
|
||||
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
updatedAt: "2026-03-30T18:00:00.000Z",
|
||||
});
|
||||
};
|
||||
const ctx = createToolContext(db, SystemRole.ADMIN);
|
||||
const result = await executeTool("get_dashboard_layout", "{}", ctx);
|
||||
// Unknown widget type → normalises to empty → client gets null to use default layout
|
||||
expect(JSON.parse(result.content)).toMatchObject({ layout: null });
|
||||
});
|
||||
|
||||
it("saves dashboard layout through the real user router path", async () => {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Unit tests for the invite tRPC router.
|
||||
*
|
||||
* Tests cover:
|
||||
* - createInvite: token created, email sent
|
||||
* - createInvite: duplicate active invite → CONFLICT
|
||||
* - getInvite: valid token → returns email + role
|
||||
* - getInvite: used token → BAD_REQUEST
|
||||
* - getInvite: expired token → BAD_REQUEST
|
||||
* - getInvite: unknown token → NOT_FOUND
|
||||
* - acceptInvite: valid → user created, usedAt set
|
||||
* - acceptInvite: used token → BAD_REQUEST
|
||||
* - acceptInvite: expired token → BAD_REQUEST
|
||||
* - revokeInvite: deletes the token
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { SystemRole } from "@capakraken/db";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../lib/email.js", () => ({ sendEmail: vi.fn().mockResolvedValue(true) }));
|
||||
vi.mock("@node-rs/argon2", () => ({ hash: vi.fn().mockResolvedValue("$argon2id$hashed") }));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ADMIN_USER = { id: "admin_1" };
|
||||
const FUTURE = new Date(Date.now() + 72 * 60 * 60 * 1000);
|
||||
const PAST = new Date(Date.now() - 1000);
|
||||
|
||||
function makeInviteDb(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(inviteDb: ReturnType<typeof makeInviteDb> = makeInviteDb()) {
|
||||
return {
|
||||
db: {
|
||||
inviteToken: inviteDb,
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue({}) },
|
||||
} as never,
|
||||
dbUser: { id: ADMIN_USER.id, systemRole: "ADMIN", permissionOverrides: null },
|
||||
session: {
|
||||
user: { id: ADMIN_USER.id, email: "admin@example.com" },
|
||||
expires: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Import after mocks ───────────────────────────────────────────────────────
|
||||
const { inviteRouter } = await import("../router/invite.js");
|
||||
|
||||
function caller(ctx: ReturnType<typeof makeCtx>) {
|
||||
return inviteRouter.createCaller(ctx as never);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createInvite", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("creates a token and sends an email", async () => {
|
||||
const { sendEmail } = await import("../lib/email.js");
|
||||
const ctx = makeCtx();
|
||||
await caller(ctx).createInvite({ email: "alice@example.com", role: SystemRole.USER });
|
||||
|
||||
expect(ctx.db.inviteToken.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ email: "alice@example.com", role: SystemRole.USER }),
|
||||
}),
|
||||
);
|
||||
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: "alice@example.com" }));
|
||||
});
|
||||
|
||||
it("throws CONFLICT when an active invite already exists", async () => {
|
||||
const inviteDb = makeInviteDb({
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "existing" }),
|
||||
});
|
||||
const ctx = makeCtx(inviteDb);
|
||||
await expect(
|
||||
caller(ctx).createInvite({ email: "alice@example.com", role: SystemRole.USER }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInvite", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns email and role for a valid unused token", async () => {
|
||||
const inviteDb = makeInviteDb({
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
email: "alice@example.com",
|
||||
role: SystemRole.MANAGER,
|
||||
expiresAt: FUTURE,
|
||||
usedAt: null,
|
||||
}),
|
||||
});
|
||||
const result = await caller(makeCtx(inviteDb)).getInvite({ token: "abc" });
|
||||
expect(result).toEqual({ email: "alice@example.com", role: SystemRole.MANAGER });
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for unknown token", async () => {
|
||||
const ctx = makeCtx();
|
||||
await expect(caller(ctx).getInvite({ token: "unknown" })).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST for already-used token", async () => {
|
||||
const inviteDb = makeInviteDb({
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
email: "alice@example.com",
|
||||
role: SystemRole.USER,
|
||||
expiresAt: FUTURE,
|
||||
usedAt: new Date(),
|
||||
}),
|
||||
});
|
||||
await expect(caller(makeCtx(inviteDb)).getInvite({ token: "abc" })).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST for expired token", async () => {
|
||||
const inviteDb = makeInviteDb({
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
email: "alice@example.com",
|
||||
role: SystemRole.USER,
|
||||
expiresAt: PAST,
|
||||
usedAt: null,
|
||||
}),
|
||||
});
|
||||
await expect(caller(makeCtx(inviteDb)).getInvite({ token: "abc" })).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("acceptInvite", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("creates the user and marks token as used", async () => {
|
||||
const inviteDb = makeInviteDb({
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "inv_1",
|
||||
email: "alice@example.com",
|
||||
role: SystemRole.USER,
|
||||
expiresAt: FUTURE,
|
||||
usedAt: null,
|
||||
token: "validtoken",
|
||||
}),
|
||||
});
|
||||
const ctx = makeCtx(inviteDb);
|
||||
const result = await caller(ctx).acceptInvite({ token: "validtoken", password: "SecurePass1!" });
|
||||
|
||||
expect(ctx.db.user.create).toHaveBeenCalled();
|
||||
expect(inviteDb.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { usedAt: expect.any(Date) } }),
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST for used token", async () => {
|
||||
const inviteDb = makeInviteDb({
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
email: "alice@example.com",
|
||||
role: SystemRole.USER,
|
||||
expiresAt: FUTURE,
|
||||
usedAt: new Date(),
|
||||
token: "usedtoken",
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
caller(makeCtx(inviteDb)).acceptInvite({ token: "usedtoken", password: "SecurePass1!" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST for expired token", async () => {
|
||||
const inviteDb = makeInviteDb({
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
email: "alice@example.com",
|
||||
role: SystemRole.USER,
|
||||
expiresAt: PAST,
|
||||
usedAt: null,
|
||||
token: "expiredtoken",
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
caller(makeCtx(inviteDb)).acceptInvite({ token: "expiredtoken", password: "SecurePass1!" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeInvite", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("deletes the invite token", async () => {
|
||||
const inviteDb = makeInviteDb();
|
||||
const ctx = makeCtx(inviteDb);
|
||||
const result = await caller(ctx).revokeInvite({ id: "inv_1" });
|
||||
expect(inviteDb.delete).toHaveBeenCalledWith({ where: { id: "inv_1" } });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -134,7 +134,7 @@ describe("user-procedure-support", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes dashboard layouts before returning them", async () => {
|
||||
it("returns null layout when stored widgets have no valid type (bug #27 guard)", async () => {
|
||||
const findUnique = vi.fn().mockResolvedValue({
|
||||
dashboardLayout: {
|
||||
widgets: [{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }],
|
||||
@@ -146,8 +146,9 @@ describe("user-procedure-support", () => {
|
||||
user: { findUnique },
|
||||
}));
|
||||
|
||||
// Widgets with unknown types normalise to empty → return null so client uses default
|
||||
expect(result).toEqual({
|
||||
layout: { version: 2, gridCols: 12, widgets: [] },
|
||||
layout: null,
|
||||
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -760,7 +760,7 @@ describe("user profile and TOTP self-service", () => {
|
||||
});
|
||||
|
||||
describe("user dashboard and favorites", () => {
|
||||
it("falls back to the normalized default dashboard layout when stored data is invalid", async () => {
|
||||
it("returns null layout when stored data has no valid widget types (bug #27 guard)", async () => {
|
||||
const findUnique = vi.fn().mockResolvedValue({
|
||||
dashboardLayout: {
|
||||
widgets: [
|
||||
@@ -781,12 +781,9 @@ describe("user dashboard and favorites", () => {
|
||||
where: { id: "user_admin" },
|
||||
select: { dashboardLayout: true, updatedAt: true },
|
||||
});
|
||||
// Widgets with unknown types are dropped → empty → client uses default layout
|
||||
expect(result).toEqual({
|
||||
layout: {
|
||||
version: 2,
|
||||
gridCols: 12,
|
||||
widgets: [],
|
||||
},
|
||||
layout: null,
|
||||
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Unit tests for getDashboardLayout — bug #27 regression guard.
|
||||
*
|
||||
* Verifies that getDashboardLayout returns null (not an empty layout) when the
|
||||
* stored dashboardLayout is an empty object or contains only unknown widget
|
||||
* types. This allows the client to fall back to the default stat-cards layout.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../lib/audit.js", () => ({ createAuditEntry: vi.fn() }));
|
||||
vi.mock("../lib/system-settings-runtime.js", () => ({ resolveSystemSettingsRuntime: vi.fn() }));
|
||||
|
||||
const { getDashboardLayout } = await import(
|
||||
"../router/user-self-service-procedure-support.js"
|
||||
);
|
||||
|
||||
function makeCtx(dashboardLayout: unknown) {
|
||||
return {
|
||||
db: {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue(
|
||||
dashboardLayout !== undefined ? { dashboardLayout, updatedAt: new Date() } : null,
|
||||
),
|
||||
},
|
||||
} as never,
|
||||
dbUser: { id: "user_1" },
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getDashboardLayout — #27 regression", () => {
|
||||
it("returns null when dashboardLayout is null (new user)", async () => {
|
||||
const ctx = makeCtx(null);
|
||||
const result = await getDashboardLayout(ctx as never);
|
||||
expect(result.layout).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when dashboardLayout is an empty object", async () => {
|
||||
const ctx = makeCtx({});
|
||||
const result = await getDashboardLayout(ctx as never);
|
||||
expect(result.layout).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when dashboardLayout has no valid widget types", async () => {
|
||||
const ctx = makeCtx({
|
||||
version: 1,
|
||||
widgets: [{ type: "old-unknown-widget-type", id: "w1", x: 0, y: 0, w: 4, h: 3 }],
|
||||
});
|
||||
const result = await getDashboardLayout(ctx as never);
|
||||
expect(result.layout).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when dashboardLayout has an empty widgets array", async () => {
|
||||
const ctx = makeCtx({ version: 2, gridCols: 12, widgets: [] });
|
||||
const result = await getDashboardLayout(ctx as never);
|
||||
expect(result.layout).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the layout when it contains at least one valid widget", async () => {
|
||||
const ctx = makeCtx({
|
||||
version: 2,
|
||||
gridCols: 12,
|
||||
widgets: [{ type: "stat-cards", id: "w1", x: 0, y: 0, w: 12, h: 3 }],
|
||||
});
|
||||
const result = await getDashboardLayout(ctx as never);
|
||||
expect(result.layout).not.toBeNull();
|
||||
expect(result.layout?.widgets).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,7 @@ export async function listUsers(ctx: UserReadContext) {
|
||||
lastActiveAt: true,
|
||||
permissionOverrides: true,
|
||||
totpEnabled: true,
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
@@ -467,6 +468,120 @@ export async function getEffectiveUserPermissions(
|
||||
};
|
||||
}
|
||||
|
||||
export async function deactivateUser(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
if (ctx.dbUser!.id === input.userId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot deactivate your own account." });
|
||||
}
|
||||
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, name: true, email: true, isActive: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already inactive." });
|
||||
}
|
||||
|
||||
await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: false } });
|
||||
|
||||
// Invalidate all existing sessions so the user is logged out immediately
|
||||
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
source: "ui",
|
||||
summary: "User deactivated",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reactivateUser(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, name: true, email: true, isActive: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
|
||||
if (user.isActive) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already active." });
|
||||
}
|
||||
|
||||
await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: true } });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
source: "ui",
|
||||
summary: "User reactivated",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteUser(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
if (ctx.dbUser!.id === input.userId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot delete your own account." });
|
||||
}
|
||||
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, name: true, email: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
|
||||
// These tables have required (non-nullable) FKs to User — must be removed first
|
||||
await ctx.db.vacation.deleteMany({ where: { requestedById: input.userId } });
|
||||
await ctx.db.notificationBroadcast.deleteMany({ where: { senderId: input.userId } });
|
||||
await ctx.db.inviteToken.deleteMany({ where: { createdById: input.userId } });
|
||||
|
||||
// Unlink resource (nullable FK — belt-and-suspenders)
|
||||
await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null } });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "DELETE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
source: "ui",
|
||||
summary: "User account permanently deleted",
|
||||
});
|
||||
|
||||
// Delete user — Prisma cascade covers: Account, Session, ActiveSession,
|
||||
// Notification, ReportTemplate, AssistantApproval, Comment.
|
||||
// Nullable FKs (AuditLog.userId, etc.) become NULL via Prisma SET NULL default.
|
||||
await ctx.db.user.delete({ where: { id: input.userId } });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function disableTotp(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
|
||||
@@ -61,8 +61,11 @@ export async function getDashboardLayout(ctx: UserSelfServiceContext) {
|
||||
select: { dashboardLayout: true, updatedAt: true },
|
||||
});
|
||||
|
||||
const normalized = user?.dashboardLayout
|
||||
? normalizeDashboardLayout(user.dashboardLayout)
|
||||
: null;
|
||||
return {
|
||||
layout: user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null,
|
||||
layout: normalized?.widgets.length ? normalized : null,
|
||||
updatedAt: user?.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
countActiveUsers,
|
||||
CreateUserInputSchema,
|
||||
createUser,
|
||||
deactivateUser,
|
||||
reactivateUser,
|
||||
deleteUser,
|
||||
disableTotp,
|
||||
getEffectiveUserPermissions,
|
||||
LinkUserResourceInputSchema,
|
||||
@@ -116,6 +119,18 @@ export const userRouter = createTRPCRouter({
|
||||
.input(VerifyAndEnableTotpInputSchema)
|
||||
.mutation(({ ctx, input }) => verifyAndEnableTotpSelfService(ctx, input)),
|
||||
|
||||
deactivate: adminProcedure
|
||||
.input(UserIdInputSchema)
|
||||
.mutation(({ ctx, input }) => deactivateUser(ctx, input)),
|
||||
|
||||
reactivate: adminProcedure
|
||||
.input(UserIdInputSchema)
|
||||
.mutation(({ ctx, input }) => reactivateUser(ctx, input)),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(UserIdInputSchema)
|
||||
.mutation(({ ctx, input }) => deleteUser(ctx, input)),
|
||||
|
||||
/** Admin override: disable TOTP for a specific user. */
|
||||
disableTotp: adminProcedure
|
||||
.input(UserIdInputSchema)
|
||||
|
||||
Reference in New Issue
Block a user