514 lines
13 KiB
TypeScript
514 lines
13 KiB
TypeScript
import { createTRPCRouter, publicProcedure } from "../utils";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
|
|
import { setCookie } from "vinxi/http";
|
|
import type { User } from "~/db/types";
|
|
import { toUserProfile } from "~/types/user";
|
|
import { getUserProviders, unlinkProvider } from "~/server/provider-helpers";
|
|
import { z } from "zod";
|
|
import { getAuthSession } from "~/server/session-helpers";
|
|
import { logAuditEvent } from "~/server/audit";
|
|
import { getClientIP, getUserAgent } from "~/server/security";
|
|
import { generatePasswordSetEmail } from "~/server/email-templates";
|
|
import { formatDeviceDescription } from "~/server/device-utils";
|
|
import sendEmail from "~/server/email";
|
|
import {
|
|
updateEmailSchema,
|
|
updateDisplayNameSchema,
|
|
updateProfileImageSchema,
|
|
changePasswordSchema,
|
|
setPasswordSchema,
|
|
deleteAccountSchema
|
|
} from "../schemas/user";
|
|
|
|
export const userRouter = createTRPCRouter({
|
|
getProfile: publicProcedure.query(async ({ ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
const conn = ConnectionFactory();
|
|
const res = await conn.execute({
|
|
sql: "SELECT * FROM User WHERE id = ?",
|
|
args: [userId]
|
|
});
|
|
|
|
if (res.rows.length === 0) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found"
|
|
});
|
|
}
|
|
|
|
const user = res.rows[0] as unknown as User;
|
|
return toUserProfile(user);
|
|
}),
|
|
|
|
updateEmail: publicProcedure
|
|
.input(updateEmailSchema)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
const { email } = input;
|
|
const conn = ConnectionFactory();
|
|
|
|
await conn.execute({
|
|
sql: "UPDATE User SET email = ?, email_verified = ? WHERE id = ?",
|
|
args: [email, 0, userId]
|
|
});
|
|
|
|
const res = await conn.execute({
|
|
sql: "SELECT * FROM User WHERE id = ?",
|
|
args: [userId]
|
|
});
|
|
|
|
const user = res.rows[0] as unknown as User;
|
|
|
|
return toUserProfile(user);
|
|
}),
|
|
|
|
updateDisplayName: publicProcedure
|
|
.input(updateDisplayNameSchema)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
const { displayName } = input;
|
|
const conn = ConnectionFactory();
|
|
|
|
await conn.execute({
|
|
sql: "UPDATE User SET display_name = ? WHERE id = ?",
|
|
args: [displayName, userId]
|
|
});
|
|
|
|
const res = await conn.execute({
|
|
sql: "SELECT * FROM User WHERE id = ?",
|
|
args: [userId]
|
|
});
|
|
|
|
const user = res.rows[0] as unknown as User;
|
|
return toUserProfile(user);
|
|
}),
|
|
|
|
updateProfileImage: publicProcedure
|
|
.input(updateProfileImageSchema)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
const { imageUrl } = input;
|
|
const conn = ConnectionFactory();
|
|
|
|
await conn.execute({
|
|
sql: "UPDATE User SET image = ? WHERE id = ?",
|
|
args: [imageUrl, userId]
|
|
});
|
|
|
|
const res = await conn.execute({
|
|
sql: "SELECT * FROM User WHERE id = ?",
|
|
args: [userId]
|
|
});
|
|
|
|
const user = res.rows[0] as unknown as User;
|
|
return toUserProfile(user);
|
|
}),
|
|
|
|
changePassword: publicProcedure
|
|
.input(changePasswordSchema)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
const { oldPassword, newPassword, newPasswordConfirmation } = input;
|
|
|
|
if (newPassword !== newPasswordConfirmation) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Password Mismatch"
|
|
});
|
|
}
|
|
|
|
const conn = ConnectionFactory();
|
|
const res = await conn.execute({
|
|
sql: "SELECT * FROM User WHERE id = ?",
|
|
args: [userId]
|
|
});
|
|
|
|
if (res.rows.length === 0) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found"
|
|
});
|
|
}
|
|
|
|
const user = res.rows[0] as unknown as User;
|
|
|
|
if (!user.password_hash) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "No password set"
|
|
});
|
|
}
|
|
|
|
const passwordMatch = await checkPassword(
|
|
oldPassword,
|
|
user.password_hash
|
|
);
|
|
|
|
if (!passwordMatch) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Password did not match record"
|
|
});
|
|
}
|
|
|
|
const newPasswordHash = await hashPassword(newPassword);
|
|
await conn.execute({
|
|
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
|
|
args: [newPasswordHash, userId]
|
|
});
|
|
|
|
return { success: true, message: "success" };
|
|
}),
|
|
|
|
setPassword: publicProcedure
|
|
.input(setPasswordSchema)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
const { newPassword, newPasswordConfirmation } = input;
|
|
|
|
if (newPassword !== newPasswordConfirmation) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Password Mismatch"
|
|
});
|
|
}
|
|
|
|
const conn = ConnectionFactory();
|
|
const res = await conn.execute({
|
|
sql: "SELECT * FROM User WHERE id = ?",
|
|
args: [userId]
|
|
});
|
|
|
|
if (res.rows.length === 0) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found"
|
|
});
|
|
}
|
|
|
|
const user = res.rows[0] as unknown as User;
|
|
|
|
if (user.password_hash) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Password exists"
|
|
});
|
|
}
|
|
|
|
// For OAuth accounts, require verified email before setting password
|
|
if (user.provider !== "email" && (!user.email || !user.email_verified)) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Email verification required before setting password"
|
|
});
|
|
}
|
|
|
|
const passwordHash = await hashPassword(newPassword);
|
|
await conn.execute({
|
|
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
|
|
args: [passwordHash, userId]
|
|
});
|
|
|
|
// Send email notification about password being set
|
|
if (user.email) {
|
|
try {
|
|
const h3Event = ctx.event.nativeEvent
|
|
? ctx.event.nativeEvent
|
|
: (ctx.event as any);
|
|
const clientIP = getClientIP(h3Event);
|
|
const userAgent = getUserAgent(h3Event);
|
|
|
|
const deviceInfo = formatDeviceDescription({
|
|
userAgent
|
|
});
|
|
|
|
const providerName =
|
|
user.provider === "google"
|
|
? "Google"
|
|
: user.provider === "github"
|
|
? "GitHub"
|
|
: "provider";
|
|
|
|
const htmlContent = generatePasswordSetEmail({
|
|
providerName,
|
|
setTime: new Date().toLocaleString(),
|
|
deviceInfo,
|
|
ipAddress: clientIP
|
|
});
|
|
|
|
await sendEmail(
|
|
user.email,
|
|
"Password Added to Your Account",
|
|
htmlContent
|
|
);
|
|
|
|
console.log(`[setPassword] Confirmation email sent to ${user.email}`);
|
|
} catch (emailError) {
|
|
console.error(
|
|
"[setPassword] Failed to send confirmation email:",
|
|
emailError
|
|
);
|
|
// Don't fail the operation if email fails
|
|
}
|
|
}
|
|
|
|
return { success: true, message: "success" };
|
|
}),
|
|
|
|
deleteAccount: publicProcedure
|
|
.input(deleteAccountSchema)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
const { password } = input;
|
|
const conn = ConnectionFactory();
|
|
|
|
const res = await conn.execute({
|
|
sql: "SELECT * FROM User WHERE id = ?",
|
|
args: [userId]
|
|
});
|
|
|
|
if (res.rows.length === 0) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found"
|
|
});
|
|
}
|
|
|
|
const user = res.rows[0] as unknown as User;
|
|
|
|
if (!user.password_hash) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Password required"
|
|
});
|
|
}
|
|
|
|
const passwordMatch = await checkPassword(password, user.password_hash);
|
|
|
|
if (!passwordMatch) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Password Did Not Match"
|
|
});
|
|
}
|
|
|
|
await conn.execute({
|
|
sql: `UPDATE User SET
|
|
email = ?,
|
|
email_verified = ?,
|
|
password_hash = ?,
|
|
display_name = ?,
|
|
provider = ?,
|
|
image = ?
|
|
WHERE id = ?`,
|
|
args: [null, 0, null, "user deleted", null, null, userId]
|
|
});
|
|
|
|
return { success: true, message: "deleted" };
|
|
}),
|
|
|
|
getProviders: publicProcedure.query(async ({ ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
const providers = await getUserProviders(userId);
|
|
|
|
return providers.map((p) => ({
|
|
id: p.id,
|
|
provider: p.provider,
|
|
email: p.email || undefined,
|
|
displayName: p.display_name || undefined,
|
|
lastUsedAt: p.last_used_at,
|
|
createdAt: p.created_at
|
|
}));
|
|
}),
|
|
|
|
unlinkProvider: publicProcedure
|
|
.input(
|
|
z.object({
|
|
provider: z.enum(["email", "google", "github"])
|
|
})
|
|
)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
await unlinkProvider(userId, input.provider);
|
|
|
|
return { success: true, message: "Provider unlinked" };
|
|
}),
|
|
|
|
getSessions: publicProcedure.query(async ({ ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
const conn = ConnectionFactory();
|
|
const res = await conn.execute({
|
|
sql: `SELECT session_id, token_family, created_at, expires_at, last_rotated_at,
|
|
rotation_count, client_ip, user_agent
|
|
FROM Session
|
|
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
|
|
ORDER BY last_rotated_at DESC`,
|
|
args: [userId]
|
|
});
|
|
|
|
// Get current session to mark it
|
|
const currentSession = await getAuthSession(ctx.event as any);
|
|
|
|
return res.rows.map((row: any) => ({
|
|
sessionId: row.session_id,
|
|
tokenFamily: row.token_family,
|
|
createdAt: row.created_at,
|
|
expiresAt: row.expires_at,
|
|
lastRotatedAt: row.last_rotated_at,
|
|
rotationCount: row.rotation_count,
|
|
clientIp: row.client_ip,
|
|
userAgent: row.user_agent,
|
|
isCurrent: currentSession?.sessionId === row.session_id
|
|
}));
|
|
}),
|
|
|
|
revokeSession: publicProcedure
|
|
.input(
|
|
z.object({
|
|
sessionId: z.string()
|
|
})
|
|
)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.userId;
|
|
|
|
if (!userId) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Not authenticated"
|
|
});
|
|
}
|
|
|
|
const conn = ConnectionFactory();
|
|
|
|
// Verify session belongs to this user
|
|
const sessionCheck = await conn.execute({
|
|
sql: "SELECT user_id, token_family FROM Session WHERE session_id = ?",
|
|
args: [input.sessionId]
|
|
});
|
|
|
|
if (sessionCheck.rows.length === 0) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Session not found"
|
|
});
|
|
}
|
|
|
|
const session = sessionCheck.rows[0] as any;
|
|
if (session.user_id !== userId) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "Cannot revoke another user's session"
|
|
});
|
|
}
|
|
|
|
// Revoke the entire token family (all sessions on this device)
|
|
await conn.execute({
|
|
sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?",
|
|
args: [session.token_family]
|
|
});
|
|
|
|
// Log audit event
|
|
const h3Event = ctx.event.nativeEvent
|
|
? ctx.event.nativeEvent
|
|
: (ctx.event as any);
|
|
const clientIP = getClientIP(h3Event);
|
|
const userAgent = getUserAgent(h3Event);
|
|
|
|
await logAuditEvent({
|
|
userId,
|
|
eventType: "auth.session_revoked",
|
|
eventData: {
|
|
sessionId: input.sessionId,
|
|
tokenFamily: session.token_family,
|
|
reason: "user_revoked"
|
|
},
|
|
ipAddress: clientIP,
|
|
userAgent,
|
|
success: true
|
|
});
|
|
|
|
return { success: true, message: "Session revoked" };
|
|
})
|
|
});
|