hopeful
This commit is contained in:
@@ -9,6 +9,7 @@ import { blogRouter } from "./routers/blog";
|
||||
import { gitActivityRouter } from "./routers/git-activity";
|
||||
import { postHistoryRouter } from "./routers/post-history";
|
||||
import { infillRouter } from "./routers/infill";
|
||||
import { accountRouter } from "./routers/account";
|
||||
import { createTRPCRouter, createTRPCContext } from "./utils";
|
||||
import type { H3Event } from "h3";
|
||||
|
||||
@@ -23,7 +24,8 @@ export const appRouter = createTRPCRouter({
|
||||
blog: blogRouter,
|
||||
gitActivity: gitActivityRouter,
|
||||
postHistory: postHistoryRouter,
|
||||
infill: infillRouter
|
||||
infill: infillRouter,
|
||||
account: accountRouter
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
255
src/server/api/routers/account.ts
Normal file
255
src/server/api/routers/account.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
getUserProviders,
|
||||
unlinkProvider,
|
||||
getProviderSummary
|
||||
} from "~/server/provider-helpers";
|
||||
import {
|
||||
getUserActiveSessions,
|
||||
revokeUserSession,
|
||||
revokeOtherUserSessions,
|
||||
getSessionCountByDevice
|
||||
} from "~/server/session-management";
|
||||
import { getAuthSession } from "~/server/session-helpers";
|
||||
import { logAuditEvent } from "~/server/audit";
|
||||
import { getAuditContext } from "~/server/security";
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import type { Context } from "../utils";
|
||||
|
||||
/**
|
||||
* Extract H3Event from Context
|
||||
*/
|
||||
function getH3Event(ctx: Context): H3Event {
|
||||
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
|
||||
return ctx.event.nativeEvent as H3Event;
|
||||
}
|
||||
return ctx.event as unknown as H3Event;
|
||||
}
|
||||
|
||||
export const accountRouter = createTRPCRouter({
|
||||
/**
|
||||
* Get all linked authentication providers for current user
|
||||
*/
|
||||
getLinkedProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const summary = await getProviderSummary(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
providers: summary.providers,
|
||||
count: summary.count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching linked providers:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch linked providers"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Unlink an authentication provider
|
||||
*/
|
||||
unlinkProvider: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
provider: z.enum(["email", "google", "github"])
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const { provider } = input;
|
||||
|
||||
await unlinkProvider(userId, provider);
|
||||
|
||||
// Log audit event
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.provider.unlinked",
|
||||
eventData: { provider },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${provider} authentication unlinked successfully`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error unlinking provider:", error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to unlink provider"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all active sessions for current user
|
||||
*/
|
||||
getActiveSessions: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const sessions = await getUserActiveSessions(userId);
|
||||
|
||||
// Mark current session
|
||||
const currentSession = await getAuthSession(getH3Event(ctx));
|
||||
const currentSessionId = currentSession?.sessionId;
|
||||
|
||||
const sessionsWithCurrent = sessions.map((session) => ({
|
||||
...session,
|
||||
current: session.sessionId === currentSessionId
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessions: sessionsWithCurrent
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching active sessions:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch active sessions"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get session statistics by device type
|
||||
*/
|
||||
getSessionStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const stats = await getSessionCountByDevice(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching session stats:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch session stats"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Revoke a specific session
|
||||
*/
|
||||
revokeSession: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const { sessionId } = input;
|
||||
|
||||
await revokeUserSession(userId, sessionId);
|
||||
|
||||
// Log audit event
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.session_revoked",
|
||||
eventData: { sessionId, reason: "user_request" },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Session revoked successfully"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error revoking session:", error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to revoke session"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Revoke all other sessions (keep current session active)
|
||||
*/
|
||||
revokeOtherSessions: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
|
||||
// Get current session
|
||||
const currentSession = await getAuthSession(getH3Event(ctx));
|
||||
if (!currentSession) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "No active session found"
|
||||
});
|
||||
}
|
||||
|
||||
const revokedCount = await revokeOtherUserSessions(
|
||||
userId,
|
||||
currentSession.sessionId
|
||||
);
|
||||
|
||||
// Log audit event
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.sessions_bulk_revoked",
|
||||
eventData: {
|
||||
revokedCount,
|
||||
keptSession: currentSession.sessionId,
|
||||
reason: "user_request"
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${revokedCount} session(s) revoked successfully`,
|
||||
revokedCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error revoking other sessions:", error);
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to revoke sessions"
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
} from "~/server/utils";
|
||||
import { setCookie, getCookie } from "vinxi/http";
|
||||
import type { User } from "~/db/types";
|
||||
import {
|
||||
linkProvider,
|
||||
findUserByProvider,
|
||||
findUserByEmail,
|
||||
updateProviderLastUsed
|
||||
} from "~/server/provider-helpers";
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
checkResponse,
|
||||
@@ -259,72 +265,96 @@ export const authRouter = createTRPCRouter({
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
console.log("[GitHub Callback] Checking if user exists...");
|
||||
const query = `SELECT * FROM User WHERE provider = ? AND display_name = ?`;
|
||||
const params = ["github", login];
|
||||
const res = await conn.execute({ sql: query, args: params });
|
||||
|
||||
let userId: string;
|
||||
// Strategy 1: Check if this GitHub identity already linked
|
||||
let userId = await findUserByProvider("github", login);
|
||||
|
||||
if (res.rows[0]) {
|
||||
userId = (res.rows[0] as unknown as User).id;
|
||||
console.log("[GitHub Callback] Existing user found:", userId);
|
||||
let isNewUser = false;
|
||||
let isLinkedAccount = false;
|
||||
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: `UPDATE User SET email = ?, email_verified = ?, image = ? WHERE id = ?`,
|
||||
args: [email, emailVerified ? 1 : 0, icon, userId]
|
||||
});
|
||||
console.log("[GitHub Callback] User data updated");
|
||||
} catch (updateError: any) {
|
||||
if (
|
||||
updateError.code === "SQLITE_CONSTRAINT" &&
|
||||
updateError.message?.includes("User.email")
|
||||
) {
|
||||
console.error(
|
||||
"[GitHub Callback] Email conflict during update:",
|
||||
email
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message:
|
||||
"This email is already associated with another account. Please sign in with that account or use a different email address."
|
||||
});
|
||||
}
|
||||
throw updateError;
|
||||
}
|
||||
if (userId) {
|
||||
console.log(
|
||||
"[GitHub Callback] Existing GitHub provider found:",
|
||||
userId
|
||||
);
|
||||
// Update provider info
|
||||
await updateProviderLastUsed(userId, "github");
|
||||
} else {
|
||||
userId = uuidV4();
|
||||
console.log("[GitHub Callback] Creating new user:", userId);
|
||||
|
||||
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const insertParams = [
|
||||
userId,
|
||||
email,
|
||||
emailVerified ? 1 : 0,
|
||||
login,
|
||||
"github",
|
||||
icon
|
||||
];
|
||||
|
||||
try {
|
||||
await conn.execute({ sql: insertQuery, args: insertParams });
|
||||
console.log("[GitHub Callback] New user created");
|
||||
} catch (insertError: any) {
|
||||
if (
|
||||
insertError.code === "SQLITE_CONSTRAINT" &&
|
||||
insertError.message?.includes("User.email")
|
||||
) {
|
||||
console.error(
|
||||
"[GitHub Callback] Email conflict during insert:",
|
||||
email
|
||||
// Strategy 2: Check if email matches existing user (account linking)
|
||||
if (email) {
|
||||
userId = await findUserByEmail(email);
|
||||
if (userId) {
|
||||
console.log(
|
||||
"[GitHub Callback] Found existing user by email, linking GitHub account:",
|
||||
userId
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message:
|
||||
"This email is already associated with another account. Please sign in with that account or use a different email address."
|
||||
});
|
||||
// Link GitHub to existing account
|
||||
try {
|
||||
await linkProvider(userId, "github", {
|
||||
providerUserId: login,
|
||||
email: email,
|
||||
displayName: login,
|
||||
image: icon
|
||||
});
|
||||
isLinkedAccount = true;
|
||||
} catch (linkError: any) {
|
||||
console.error(
|
||||
"[GitHub Callback] Failed to link provider:",
|
||||
linkError.message
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: linkError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Create new user
|
||||
if (!userId) {
|
||||
userId = uuidV4();
|
||||
console.log("[GitHub Callback] Creating new user:", userId);
|
||||
|
||||
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const insertParams = [
|
||||
userId,
|
||||
email,
|
||||
emailVerified ? 1 : 0,
|
||||
login,
|
||||
"github",
|
||||
icon
|
||||
];
|
||||
|
||||
try {
|
||||
await conn.execute({ sql: insertQuery, args: insertParams });
|
||||
|
||||
// Also create UserProvider entry for new user
|
||||
await linkProvider(userId, "github", {
|
||||
providerUserId: login,
|
||||
email: email,
|
||||
displayName: login,
|
||||
image: icon
|
||||
});
|
||||
|
||||
isNewUser = true;
|
||||
console.log("[GitHub Callback] New user created");
|
||||
} catch (insertError: any) {
|
||||
if (
|
||||
insertError.code === "SQLITE_CONSTRAINT" &&
|
||||
insertError.message?.includes("User.email")
|
||||
) {
|
||||
console.error(
|
||||
"[GitHub Callback] Email conflict during insert:",
|
||||
email
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message:
|
||||
"This email is already associated with another account. Please sign in with that account or use a different email address."
|
||||
});
|
||||
}
|
||||
throw insertError;
|
||||
}
|
||||
throw insertError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +382,11 @@ export const authRouter = createTRPCRouter({
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.login.success",
|
||||
eventData: { method: "github", isNewUser: !res.rows[0] },
|
||||
eventData: {
|
||||
method: "github",
|
||||
isNewUser,
|
||||
isLinkedAccount
|
||||
},
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
success: true
|
||||
@@ -485,57 +519,97 @@ export const authRouter = createTRPCRouter({
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
console.log("[Google Callback] Checking if user exists...");
|
||||
const query = `SELECT * FROM User WHERE provider = ? AND email = ?`;
|
||||
const params = ["google", email];
|
||||
const res = await conn.execute({ sql: query, args: params });
|
||||
|
||||
let userId: string;
|
||||
// Strategy 1: Check if this Google identity already linked
|
||||
let userId = await findUserByProvider("google", email);
|
||||
|
||||
if (res.rows[0]) {
|
||||
userId = (res.rows[0] as unknown as User).id;
|
||||
console.log("[Google Callback] Existing user found:", userId);
|
||||
let isNewUser = false;
|
||||
let isLinkedAccount = false;
|
||||
|
||||
await conn.execute({
|
||||
sql: `UPDATE User SET email = ?, email_verified = ?, display_name = ?, image = ? WHERE id = ?`,
|
||||
args: [email, email_verified ? 1 : 0, name, image, userId]
|
||||
});
|
||||
console.log("[Google Callback] User data updated");
|
||||
if (userId) {
|
||||
console.log(
|
||||
"[Google Callback] Existing Google provider found:",
|
||||
userId
|
||||
);
|
||||
// Update provider info
|
||||
await updateProviderLastUsed(userId, "google");
|
||||
} else {
|
||||
userId = uuidV4();
|
||||
console.log("[Google Callback] Creating new user:", userId);
|
||||
|
||||
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const insertParams = [
|
||||
userId,
|
||||
email,
|
||||
email_verified ? 1 : 0,
|
||||
name,
|
||||
"google",
|
||||
image
|
||||
];
|
||||
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: insertQuery,
|
||||
args: insertParams
|
||||
});
|
||||
console.log("[Google Callback] New user created");
|
||||
} catch (insertError: any) {
|
||||
if (
|
||||
insertError.code === "SQLITE_CONSTRAINT" &&
|
||||
insertError.message?.includes("User.email")
|
||||
) {
|
||||
// Strategy 2: Check if email matches existing user (account linking)
|
||||
userId = await findUserByEmail(email);
|
||||
if (userId) {
|
||||
console.log(
|
||||
"[Google Callback] Found existing user by email, linking Google account:",
|
||||
userId
|
||||
);
|
||||
// Link Google to existing account
|
||||
try {
|
||||
await linkProvider(userId, "google", {
|
||||
providerUserId: email,
|
||||
email: email,
|
||||
displayName: name,
|
||||
image: image
|
||||
});
|
||||
isLinkedAccount = true;
|
||||
} catch (linkError: any) {
|
||||
console.error(
|
||||
"[Google Callback] Email conflict during insert:",
|
||||
email
|
||||
"[Google Callback] Failed to link provider:",
|
||||
linkError.message
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message:
|
||||
"This email is already associated with another account. Please sign in with that account instead."
|
||||
message: linkError.message
|
||||
});
|
||||
}
|
||||
throw insertError;
|
||||
}
|
||||
|
||||
// Strategy 3: Create new user
|
||||
if (!userId) {
|
||||
userId = uuidV4();
|
||||
console.log("[Google Callback] Creating new user:", userId);
|
||||
|
||||
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const insertParams = [
|
||||
userId,
|
||||
email,
|
||||
email_verified ? 1 : 0,
|
||||
name,
|
||||
"google",
|
||||
image
|
||||
];
|
||||
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: insertQuery,
|
||||
args: insertParams
|
||||
});
|
||||
|
||||
// Also create UserProvider entry for new user
|
||||
await linkProvider(userId, "google", {
|
||||
providerUserId: email,
|
||||
email: email,
|
||||
displayName: name,
|
||||
image: image
|
||||
});
|
||||
|
||||
isNewUser = true;
|
||||
console.log("[Google Callback] New user created");
|
||||
} catch (insertError: any) {
|
||||
if (
|
||||
insertError.code === "SQLITE_CONSTRAINT" &&
|
||||
insertError.message?.includes("User.email")
|
||||
) {
|
||||
console.error(
|
||||
"[Google Callback] Email conflict during insert:",
|
||||
email
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message:
|
||||
"This email is already associated with another account. Please sign in with that account instead."
|
||||
});
|
||||
}
|
||||
throw insertError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,7 +637,11 @@ export const authRouter = createTRPCRouter({
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.login.success",
|
||||
eventData: { method: "google", isNewUser: !res.rows[0] },
|
||||
eventData: {
|
||||
method: "google",
|
||||
isNewUser,
|
||||
isLinkedAccount
|
||||
},
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
success: true
|
||||
@@ -989,6 +1067,36 @@ export const authRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email already exists (User table or UserProvider table)
|
||||
const existingUserId = await findUserByEmail(email);
|
||||
if (existingUserId) {
|
||||
// User exists - check if they have a password
|
||||
const conn = ConnectionFactory();
|
||||
const userCheck = await conn.execute({
|
||||
sql: "SELECT password_hash, provider FROM User WHERE id = ?",
|
||||
args: [existingUserId]
|
||||
});
|
||||
|
||||
if (userCheck.rows.length > 0) {
|
||||
const existingUser = userCheck.rows[0] as any;
|
||||
|
||||
// If user has a password, it's a duplicate registration attempt
|
||||
if (existingUser.password_hash) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "duplicate"
|
||||
});
|
||||
}
|
||||
|
||||
// If user doesn't have a password (provider-only), redirect to login
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"An account with this email already exists. Please sign in and add a password from your account settings."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const conn = ConnectionFactory();
|
||||
const userId = uuidV4();
|
||||
@@ -999,6 +1107,12 @@ export const authRouter = createTRPCRouter({
|
||||
args: [userId, email, passwordHash, "email"]
|
||||
});
|
||||
|
||||
// Create UserProvider entry for email auth
|
||||
await linkProvider(userId, "email", {
|
||||
providerUserId: email,
|
||||
email: email
|
||||
});
|
||||
|
||||
// Create session with client info
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
|
||||
@@ -4,14 +4,11 @@ import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
|
||||
import { setCookie } from "vinxi/http";
|
||||
import type { User } from "~/db/types";
|
||||
import { toUserProfile } from "~/types/user";
|
||||
import {
|
||||
updateEmailSchema,
|
||||
updateDisplayNameSchema,
|
||||
updateProfileImageSchema,
|
||||
changePasswordSchema,
|
||||
setPasswordSchema,
|
||||
deleteAccountSchema
|
||||
} from "../schemas/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";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
getProfile: publicProcedure.query(async ({ ctx }) => {
|
||||
@@ -242,6 +239,55 @@ export const userRouter = createTRPCRouter({
|
||||
args: [passwordHash, userId]
|
||||
});
|
||||
|
||||
// Send email notification about password being set
|
||||
if (user.email) {
|
||||
try {
|
||||
const { generatePasswordSetEmail } =
|
||||
await import("~/server/email-templates");
|
||||
const { formatDeviceDescription } =
|
||||
await import("~/server/device-utils");
|
||||
const { default: sendEmail } = await import("~/server/email");
|
||||
|
||||
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" };
|
||||
}),
|
||||
|
||||
@@ -303,5 +349,152 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
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" };
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user