android flesh out

This commit is contained in:
2026-06-01 12:58:34 -04:00
parent ba73daa66c
commit 542172d1e8
183 changed files with 26946 additions and 761 deletions

View File

@@ -1,9 +1,10 @@
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { eq, and, isNull } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema/auth";
import { users, accounts } from "~/server/db/schema/auth";
import { hashPassword, verifyPassword } from "~/server/auth/password";
import { createSession } from "~/server/auth/session";
import { signJWT } from "~/server/auth/jwt";
export async function createUserWithPassword(
name: string,
@@ -28,7 +29,11 @@ export async function createUserWithPassword(
.insert(users)
.values({ name, email, passwordHash })
.returning();
return user;
const session = await createSession(user.id);
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
return { user, sessionToken: session.sessionToken, accessToken };
}
export async function authenticateUser(
@@ -57,7 +62,275 @@ export async function authenticateUser(
}
const session = await createSession(user.id);
return { user, sessionToken: session.sessionToken };
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
return { user, sessionToken: session.sessionToken, accessToken };
}
const GOOGLE_ISSUER = "https://accounts.google.com";
/**
* Verifies a Google ID token using firebase-admin and returns the user.
* If the user does not exist, creates a new account.
* If the user exists but has not linked Google, links the provider.
*/
export async function authenticateWithGoogle(idToken: string) {
const { initializeApp, cert, getApps } = await import("firebase-admin/app");
// Initialize Firebase Admin if not already done
if (getApps().length === 0) {
// Try to load from environment or use application default credentials
const projectId = process.env.FIREBASE_PROJECT_ID;
const clientEmail = process.env.FIREBASE_CLIENT_EMAIL;
const privateKey = process.env.FIREBASE_PRIVATE_KEY;
if (projectId && clientEmail && privateKey) {
initializeApp({
credential: cert({
projectId,
clientEmail,
privateKey: privateKey.replace(/\\n/g, "\n"),
}),
});
} else {
// Fall back to application default credentials
initializeApp({ projectId: projectId ?? "kordant" });
}
}
let decodedToken: { uid: string; email?: string; name?: string; picture?: string };
try {
const authModule = await import("firebase-admin/auth");
decodedToken = await authModule.getAuth().verifyIdToken(idToken);
} catch (err) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Google ID token",
});
}
const googleUserId = decodedToken.uid;
const email = decodedToken.email;
const name = decodedToken.name ?? email?.split("@")[0] ?? "User";
const avatarUrl = decodedToken.picture ?? null;
if (!email) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Google account has no email address",
});
}
// Check if this Google account is already linked
const [existingAccount] = await db
.select()
.from(accounts)
.where(
and(
eq(accounts.provider, "google"),
eq(accounts.providerAccountId, googleUserId),
),
)
.limit(1);
let userId: string;
let isNewUser = false;
if (existingAccount) {
// Already linked — use the existing user
userId = existingAccount.userId;
isNewUser = false;
// Update the access token if provided
await db
.update(accounts)
.set({
accessToken: idToken,
updatedAt: new Date(),
})
.where(eq(accounts.id, existingAccount.id));
} else {
// Not linked — check if a user with this email exists
const [existingUserByEmail] = await db
.select()
.from(users)
.where(and(eq(users.email, email), isNull(users.deletedAt)))
.limit(1);
if (existingUserByEmail) {
// Link Google to existing user
userId = existingUserByEmail.id;
isNewUser = false;
await db.insert(accounts).values({
userId,
provider: "google",
providerAccountId: googleUserId,
accessToken: idToken,
});
// Update avatar if not set
if (!existingUserByEmail.image && avatarUrl) {
await db.update(users).set({ image: avatarUrl }).where(eq(users.id, userId));
}
} else {
// Create new user with Google
isNewUser = true;
const [newUser] = await db
.insert(users)
.values({
name,
email,
image: avatarUrl,
emailVerified: new Date(),
})
.returning();
userId = newUser.id;
await db.insert(accounts).values({
userId,
provider: "google",
providerAccountId: googleUserId,
accessToken: idToken,
});
}
}
// Create session and JWT
const session = await createSession(userId);
const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
const refreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found after creation" });
}
return { user, sessionToken: session.sessionToken, accessToken, refreshToken, isNewUser };
}
/**
* Refreshes an access token using a valid refresh token.
*/
export async function refreshAccessToken(refreshToken: string) {
const { verifyJWT, signJWT } = await import("~/server/auth/jwt");
let payload: { sub?: string; type?: string };
try {
payload = await verifyJWT<{ sub: string; type: string }>(refreshToken);
} catch {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid or expired refresh token",
});
}
if (payload.type !== "refresh") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token type",
});
}
const userId = payload.sub!;
const [user] = await db
.select()
.from(users)
.where(and(eq(users.id, userId), isNull(users.deletedAt)))
.limit(1);
if (!user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User not found",
});
}
const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
const newRefreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
/**
* Sends a password reset email.
*/
export async function forgotPassword(email: string) {
const [user] = await db
.select()
.from(users)
.where(and(eq(users.email, email), isNull(users.deletedAt)))
.limit(1);
if (!user) {
// Don't reveal whether the email exists
return { success: true };
}
// Generate a reset token (valid for 1 hour)
const resetToken = await signJWT(
{ sub: user.id, type: "password-reset" },
{ expiresIn: "1h" },
);
// In production, send via email service (Resend, SendGrid, etc.)
// For now, we log it and return success
console.log(`Password reset token for ${email}: ${resetToken}`);
// TODO: Send email via Resend
// const { Resend } = await import("resend");
// const resend = new Resend(process.env.RESEND_API_KEY);
// await resend.emails.send({
// from: "Kordant <support@kordant.com>",
// to: email,
// subject: "Reset your password",
// html: `<a href="${process.env.APP_URL}/reset-password?token=${resetToken}">Reset password</a>`,
// });
return { success: true };
}
/**
* Resets a user's password using a valid reset token.
*/
export async function resetPassword(token: string, newPassword: string) {
const { verifyJWT } = await import("~/server/auth/jwt");
let payload: { sub?: string; type?: string };
try {
payload = await verifyJWT<{ sub: string; type: string }>(token);
} catch {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid or expired reset token",
});
}
if (payload.type !== "password-reset") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token type",
});
}
const userId = payload.sub!;
const passwordHash = await hashPassword(newPassword);
await db
.update(users)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(users.id, userId));
return { success: true };
}
/**
* Revokes all sessions for a user (logout everywhere).
*/
export async function revokeUserSessions(userId: string) {
const { sessions } = await import("~/server/db/schema/auth");
await db
.delete(sessions)
.where(eq(sessions.userId, userId));
return { success: true };
}
export async function getUserById(id: string) {