fix(api): harden user self-service and resource linking

This commit is contained in:
2026-03-31 21:02:36 +02:00
parent e8c0d3c3eb
commit 99db52929f
24 changed files with 2882 additions and 38 deletions
+86 -22
View File
@@ -57,7 +57,7 @@ export const userRouter = createTRPCRouter({
me: protectedProcedure.query(async ({ ctx }) => {
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
where: { id: ctx.dbUser!.id },
select: {
id: true,
name: true,
@@ -255,24 +255,79 @@ export const userRouter = createTRPCRouter({
);
if (input.resourceId) {
await findUniqueOrThrow(
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { id: true },
select: { id: true, userId: true },
}),
"Resource",
);
// Unlink any resource previously linked to this user
if (resource.userId && resource.userId !== input.userId) {
throw new TRPCError({
code: "CONFLICT",
message: "Resource is already linked to another user",
});
}
// Unlink any other resource previously linked to this user.
await ctx.db.resource.updateMany({
where: { userId: input.userId },
where: {
userId: input.userId,
NOT: { id: input.resourceId },
},
data: { userId: null },
});
// Link the new resource
await ctx.db.resource.update({
where: { id: input.resourceId },
const linkResult = await ctx.db.resource.updateMany({
where: {
id: input.resourceId,
OR: [
{ userId: null },
{ userId: input.userId },
],
},
data: { userId: input.userId },
});
if (linkResult.count !== 1) {
const [userStillExists, resourceStillExists] = await Promise.all([
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true },
}),
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { id: true, userId: true },
}),
]);
if (!userStillExists) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (!resourceStillExists) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
if (resourceStillExists.userId && resourceStillExists.userId !== input.userId) {
throw new TRPCError({
code: "CONFLICT",
message: "Resource is already linked to another user",
});
}
throw new TRPCError({
code: "CONFLICT",
message: "Resource link changed during update. Please retry.",
});
}
} else {
// Unlink
await ctx.db.resource.updateMany({
@@ -309,7 +364,7 @@ export const userRouter = createTRPCRouter({
getDashboardLayout: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
where: { id: ctx.dbUser!.id },
select: { dashboardLayout: true, updatedAt: true },
});
return {
@@ -322,7 +377,7 @@ export const userRouter = createTRPCRouter({
.input(z.object({ layout: dashboardLayoutSchema }))
.mutation(async ({ ctx, input }) => {
const updated = await ctx.db.user.update({
where: { email: ctx.session.user?.email ?? "" },
where: { id: ctx.dbUser!.id },
data: { dashboardLayout: input.layout as unknown as import("@capakraken/db").Prisma.InputJsonValue },
select: { updatedAt: true },
});
@@ -531,10 +586,13 @@ export const userRouter = createTRPCRouter({
verifyAndEnableTotp: protectedProcedure
.input(z.object({ token: z.string().length(6) }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: ctx.dbUser!.id },
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true },
});
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true },
}),
"User",
);
if (!user.totpSecret) {
throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." });
@@ -612,10 +670,13 @@ export const userRouter = createTRPCRouter({
verifyTotp: publicProcedure
.input(z.object({ userId: z.string(), token: z.string().length(6) }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: input.userId },
select: { id: true, totpSecret: true, totpEnabled: true },
});
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, totpSecret: true, totpEnabled: true },
}),
"User",
);
if (!user.totpEnabled || !user.totpSecret) {
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." });
@@ -641,10 +702,13 @@ export const userRouter = createTRPCRouter({
/** Get MFA status for the current user. */
getMfaStatus: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: ctx.dbUser!.id },
select: { totpEnabled: true },
});
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { totpEnabled: true },
}),
"User",
);
return { totpEnabled: user.totpEnabled };
}),
});