fix(api): harden user self-service and resource linking
This commit is contained in:
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user