client->server stuff, nav on mobile improvements

This commit is contained in:
Michael Freno
2025-12-19 20:35:21 -05:00
parent 76fb86d519
commit 3e606d2354
12 changed files with 420 additions and 124 deletions

View File

@@ -7,6 +7,13 @@ import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
import { SignJWT, jwtVerify } from "jose";
import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/types/user";
import {
emailSchema,
passwordSchema,
registrationSchema,
loginSchema,
passwordResetSchema
} from "~/server/api/schemas/validation";
// Helper to create JWT token
async function createJWT(
@@ -239,7 +246,7 @@ export const authRouter = createTRPCRouter({
emailLogin: publicProcedure
.input(
z.object({
email: z.string().email(),
email: emailSchema,
token: z.string(),
rememberMe: z.boolean().optional()
})
@@ -317,7 +324,7 @@ export const authRouter = createTRPCRouter({
emailVerification: publicProcedure
.input(
z.object({
email: z.string().email(),
email: emailSchema,
token: z.string()
})
)
@@ -360,22 +367,9 @@ export const authRouter = createTRPCRouter({
// Email/password registration
emailRegistration: publicProcedure
.input(
z.object({
email: z.string().email(),
password: z.string().min(8),
passwordConfirmation: z.string().min(8)
})
)
.input(registrationSchema)
.mutation(async ({ input, ctx }) => {
const { email, password, passwordConfirmation } = input;
if (password !== passwordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "passwordMismatch"
});
}
const { email, password } = input;
const passwordHash = await hashPassword(password);
const conn = ConnectionFactory();
@@ -411,13 +405,7 @@ export const authRouter = createTRPCRouter({
// Email/password login
emailPasswordLogin: publicProcedure
.input(
z.object({
email: z.string().email(),
password: z.string(),
rememberMe: z.boolean().optional()
})
)
.input(loginSchema)
.mutation(async ({ input, ctx }) => {
const { email, password, rememberMe } = input;
@@ -477,7 +465,7 @@ export const authRouter = createTRPCRouter({
requestEmailLinkLogin: publicProcedure
.input(
z.object({
email: z.string().email(),
email: emailSchema,
rememberMe: z.boolean().optional()
})
)
@@ -582,7 +570,7 @@ export const authRouter = createTRPCRouter({
// Request password reset
requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() }))
.input(z.object({ email: emailSchema }))
.mutation(async ({ input, ctx }) => {
const { email } = input;
@@ -681,22 +669,9 @@ export const authRouter = createTRPCRouter({
// Reset password with token
resetPassword: publicProcedure
.input(
z.object({
token: z.string(),
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8)
})
)
.input(passwordResetSchema)
.mutation(async ({ input, ctx }) => {
const { token, newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch"
});
}
const { token, newPassword } = input;
try {
// Verify JWT token
@@ -743,7 +718,7 @@ export const authRouter = createTRPCRouter({
// Resend email verification
resendEmailVerification: publicProcedure
.input(z.object({ email: z.string().email() }))
.input(z.object({ email: emailSchema }))
.mutation(async ({ input, ctx }) => {
const { email } = input;
@@ -852,4 +827,3 @@ export const authRouter = createTRPCRouter({
return { success: true };
})
});

View File

@@ -11,6 +11,13 @@ import {
import { setCookie } from "vinxi/http";
import type { User } from "~/types/user";
import { toUserProfile } from "~/types/user";
import {
updateEmailSchema,
updateDisplayNameSchema,
passwordChangeSchema,
passwordSetSchema,
deleteAccountSchema
} from "~/server/api/schemas/validation";
export const userRouter = createTRPCRouter({
// Get current user profile
@@ -43,7 +50,7 @@ export const userRouter = createTRPCRouter({
// Update email
updateEmail: publicProcedure
.input(z.object({ email: z.string().email() }))
.input(updateEmailSchema)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -80,7 +87,7 @@ export const userRouter = createTRPCRouter({
// Update display name
updateDisplayName: publicProcedure
.input(z.object({ displayName: z.string().min(1).max(50) }))
.input(updateDisplayNameSchema)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -111,7 +118,9 @@ export const userRouter = createTRPCRouter({
// Update profile image
updateProfileImage: publicProcedure
.input(z.object({ imageUrl: z.string() }))
.input(
z.object({ imageUrl: z.string().url().optional().or(z.literal("")) })
)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -142,13 +151,7 @@ export const userRouter = createTRPCRouter({
// Change password (requires old password)
changePassword: publicProcedure
.input(
z.object({
oldPassword: z.string(),
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8)
})
)
.input(passwordChangeSchema)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -159,14 +162,7 @@ export const userRouter = createTRPCRouter({
});
}
const { oldPassword, newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch"
});
}
const { oldPassword, newPassword } = input;
const conn = ConnectionFactory();
const res = await conn.execute({
@@ -224,12 +220,7 @@ export const userRouter = createTRPCRouter({
// Set password (for OAuth users who don't have password)
setPassword: publicProcedure
.input(
z.object({
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8)
})
)
.input(passwordSetSchema)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -240,14 +231,7 @@ export const userRouter = createTRPCRouter({
});
}
const { newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch"
});
}
const { password } = input;
const conn = ConnectionFactory();
const res = await conn.execute({
@@ -272,7 +256,7 @@ export const userRouter = createTRPCRouter({
}
// Set password
const passwordHash = await hashPassword(newPassword);
const passwordHash = await hashPassword(password);
await conn.execute({
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
args: [passwordHash, userId]
@@ -293,7 +277,7 @@ export const userRouter = createTRPCRouter({
// Delete account (anonymize data)
deleteAccount: publicProcedure
.input(z.object({ password: z.string() }))
.input(deleteAccountSchema)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);

View File

@@ -0,0 +1,153 @@
import { z } from "zod";
/**
* Validation Schemas for tRPC Procedures
*
* These schemas are the source of truth for server-side validation.
* Client-side validation (src/lib/validation.ts) is optional for UX only.
*/
// ============================================================================
// Base Schemas
// ============================================================================
/**
* Email validation schema
* - Must be valid email format
* - Min 3 chars, max 255 chars
* - Trimmed and lowercased automatically
*/
export const emailSchema = z
.string()
.trim()
.toLowerCase()
.email("Invalid email address")
.min(3, "Email too short")
.max(255, "Email too long");
/**
* Password validation schema
* - Minimum 8 characters
* - Maximum 128 characters
* - Can add additional complexity requirements if needed
*/
export const passwordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password too long");
/**
* Display name validation schema
* - Minimum 1 character (after trim)
* - Maximum 50 characters
*/
export const displayNameSchema = z
.string()
.trim()
.min(1, "Display name is required")
.max(50, "Display name too long");
/**
* Comment body validation schema
* - Minimum 1 character (after trim)
* - Maximum 10,000 characters
*/
export const commentBodySchema = z
.string()
.trim()
.min(1, "Comment cannot be empty")
.max(10000, "Comment too long (max 10,000 characters)");
// ============================================================================
// Composed Schemas
// ============================================================================
/**
* Email/password login schema
*/
export const loginSchema = z.object({
email: emailSchema,
password: passwordSchema,
rememberMe: z.boolean().optional()
});
/**
* Email/password registration schema with password confirmation
*/
export const registrationSchema = z
.object({
email: emailSchema,
password: passwordSchema,
passwordConfirmation: passwordSchema,
displayName: displayNameSchema.optional()
})
.refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords do not match",
path: ["passwordConfirmation"]
});
/**
* Password change schema (requires old password)
*/
export const passwordChangeSchema = z
.object({
oldPassword: passwordSchema,
newPassword: passwordSchema,
newPasswordConfirmation: passwordSchema
})
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "New passwords do not match",
path: ["newPasswordConfirmation"]
})
.refine((data) => data.oldPassword !== data.newPassword, {
message: "New password must be different from old password",
path: ["newPassword"]
});
/**
* Password reset schema (no old password required)
*/
export const passwordResetSchema = z
.object({
token: z.string(),
newPassword: passwordSchema,
newPasswordConfirmation: passwordSchema
})
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match",
path: ["newPasswordConfirmation"]
});
/**
* Password set schema (for OAuth users setting password first time)
*/
export const passwordSetSchema = z
.object({
password: passwordSchema,
passwordConfirmation: passwordSchema
})
.refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords do not match",
path: ["passwordConfirmation"]
});
/**
* Update email schema
*/
export const updateEmailSchema = z.object({
email: emailSchema
});
/**
* Update display name schema
*/
export const updateDisplayNameSchema = z.object({
displayName: displayNameSchema
});
/**
* Account deletion schema
*/
export const deleteAccountSchema = z.object({
password: passwordSchema
});