feat(timeline): add pulse animation for in-flight drag mutations
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { makeAuditLogger } from "../lib/audit-helpers.js";
|
||||
import type { TRPCContext } from "../trpc.js";
|
||||
|
||||
export const CreateUserInputSchema = z.object({
|
||||
@@ -51,10 +51,6 @@ export const UserIdInputSchema = z.object({
|
||||
type UserReadContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
type UserMutationContext = UserReadContext;
|
||||
|
||||
function withAuditUser(userId: string | undefined) {
|
||||
return userId ? { userId } : {};
|
||||
}
|
||||
|
||||
export async function listAssignableUsers(ctx: UserReadContext) {
|
||||
return ctx.db.user.findMany({
|
||||
select: {
|
||||
@@ -113,6 +109,7 @@ export async function createUser(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof CreateUserInputSchema>,
|
||||
) {
|
||||
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
|
||||
const existing = await ctx.db.user.findUnique({ where: { email: input.email } });
|
||||
if (existing) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" });
|
||||
@@ -142,15 +139,12 @@ export async function createUser(
|
||||
});
|
||||
}
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "CREATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
after: user as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return user;
|
||||
@@ -160,6 +154,7 @@ export async function setUserPassword(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof SetUserPasswordInputSchema>,
|
||||
) {
|
||||
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
@@ -176,14 +171,11 @@ export async function setUserPassword(
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
source: "ui",
|
||||
summary: "Password reset by admin",
|
||||
});
|
||||
|
||||
@@ -194,6 +186,7 @@ export async function updateUserRole(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UpdateUserRoleInputSchema>,
|
||||
) {
|
||||
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
|
||||
const before = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.id },
|
||||
@@ -208,16 +201,13 @@ export async function updateUserRole(
|
||||
select: { id: true, name: true, email: true, systemRole: true },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: updated.id,
|
||||
entityName: `${updated.name} (${updated.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
before: before as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Changed role from ${before.systemRole} to ${updated.systemRole}`,
|
||||
});
|
||||
|
||||
@@ -228,6 +218,7 @@ export async function updateUserName(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UpdateUserNameInputSchema>,
|
||||
) {
|
||||
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
|
||||
const before = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.id },
|
||||
@@ -242,16 +233,13 @@ export async function updateUserName(
|
||||
select: { id: true, name: true, email: true },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: updated.id,
|
||||
entityName: `${updated.name} (${updated.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
before: before as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Changed name from "${before.name}" to "${updated.name}"`,
|
||||
});
|
||||
|
||||
@@ -381,6 +369,7 @@ export async function setUserPermissions(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof SetUserPermissionsInputSchema>,
|
||||
) {
|
||||
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
|
||||
const before = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
@@ -394,16 +383,13 @@ export async function setUserPermissions(
|
||||
data: { permissionOverrides: input.overrides ?? Prisma.DbNull },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: input.userId,
|
||||
entityName: `${before.name} (${before.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<string, unknown>,
|
||||
after: { permissionOverrides: input.overrides } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: input.overrides
|
||||
? `Set permission overrides (granted: ${input.overrides.granted?.length ?? 0}, denied: ${input.overrides.denied?.length ?? 0})`
|
||||
: "Cleared permission overrides",
|
||||
@@ -416,6 +402,7 @@ export async function resetUserPermissions(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
|
||||
const before = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
@@ -429,16 +416,13 @@ export async function resetUserPermissions(
|
||||
data: { permissionOverrides: Prisma.DbNull },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: input.userId,
|
||||
entityName: `${before.name} (${before.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<string, unknown>,
|
||||
after: { permissionOverrides: null } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: "Reset permission overrides to role defaults",
|
||||
});
|
||||
|
||||
@@ -472,6 +456,7 @@ export async function deactivateUser(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
|
||||
if (ctx.dbUser!.id === input.userId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot deactivate your own account." });
|
||||
}
|
||||
@@ -493,14 +478,11 @@ export async function deactivateUser(
|
||||
// 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,
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
source: "ui",
|
||||
summary: "User deactivated",
|
||||
});
|
||||
|
||||
@@ -511,6 +493,7 @@ export async function reactivateUser(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
@@ -525,14 +508,11 @@ export async function reactivateUser(
|
||||
|
||||
await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: true } });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
source: "ui",
|
||||
summary: "User reactivated",
|
||||
});
|
||||
|
||||
@@ -543,6 +523,7 @@ export async function deleteUser(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
|
||||
if (ctx.dbUser!.id === input.userId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot delete your own account." });
|
||||
}
|
||||
@@ -563,14 +544,11 @@ export async function deleteUser(
|
||||
// Unlink resource (nullable FK — belt-and-suspenders)
|
||||
await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null } });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "DELETE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
source: "ui",
|
||||
summary: "User account permanently deleted",
|
||||
});
|
||||
|
||||
@@ -586,6 +564,7 @@ export async function disableTotp(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
@@ -599,14 +578,11 @@ export async function disableTotp(
|
||||
data: { totpEnabled: false, totpSecret: null },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
source: "ui",
|
||||
summary: "Disabled TOTP MFA (admin override)",
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user