feat: wire frontend pages to tRPC APIs

- Add hooks (useAuth, useSubscription, useNotifications) for real API data
- Add auth service (login/signup) with password hashing and session support
- Replace stub auth with real tRPC calls in login/signup/onboarding pages
- Replace mock dashboard data with real API data from hooks
- Create service pages: DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers, Settings
- Update Navbar, TopBar, Sidebar with real user data and correct routes
- Add passwordHash field to users schema for credential auth
- Fix tests to work with real hooks (mock tRPC/hooks)
This commit is contained in:
2026-05-25 17:34:48 -04:00
parent eb8e57c674
commit 7cbcde6a6b
46 changed files with 2418 additions and 418 deletions

View File

@@ -28,7 +28,7 @@ const mockRemoveMember = vi.mocked(removeMember);
const mockUpdateMemberRole = vi.mocked(updateMemberRole);
type User = {
id: string; email: string; name: string | null; image: string | null;
id: string; email: string; name: string | null; image: string | null; passwordHash: string | null;
role: "user" | "family_admin" | "family_member" | "support"; emailVerified: Date | null; deletedAt: Date | null;
stripeCustomerId: string | null;
createdAt: Date; updatedAt: Date;
@@ -96,7 +96,7 @@ function createCaller(user: User | null) {
}
const baseUser: User = {
id: "user-1", email: "a@b.com", name: "Test", image: null,
id: "user-1", email: "a@b.com", name: "Test", image: null, passwordHash: null,
role: "user", emailVerified: null, deletedAt: null,
stripeCustomerId: null,
createdAt: new Date(), updatedAt: new Date(),

View File

@@ -1,13 +1,14 @@
import { wrap } from "@typeschema/valibot";
import { object, string, minLength, email as emailVal } from "valibot";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "../utils";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../utils";
import {
UpdateUserSchema,
InviteMemberSchema,
RemoveMemberSchema,
UpdateRoleSchema,
} from "../schemas/user";
import { getUserById, updateUser, deleteUser } from "~/server/services/user.service";
import { getUserById, updateUser, deleteUser, createUserWithPassword, authenticateUser } from "~/server/services/user.service";
import {
getFamilyGroup,
inviteMember,
@@ -15,7 +16,33 @@ import {
updateMemberRole,
} from "~/server/services/family.service";
const LoginSchema = object({
email: string([emailVal()]),
password: string([minLength(1)]),
});
const SignupSchema = object({
name: string([minLength(1)]),
email: string([emailVal()]),
password: string([minLength(8)]),
});
export const userRouter = createTRPCRouter({
login: publicProcedure
.input(wrap(LoginSchema))
.mutation(async ({ input }) => {
return authenticateUser(input.email, input.password);
}),
signup: publicProcedure
.input(wrap(SignupSchema))
.mutation(async ({ input }) => {
const user = await createUserWithPassword(input.name, input.email, input.password);
const { createSession } = await import("~/server/auth/session");
const session = await createSession(user.id);
return { user, sessionToken: session.sessionToken };
}),
me: protectedProcedure.query(async ({ ctx }) => {
const user = await getUserById(ctx.user.id);
return user;

View File

@@ -1,22 +1,20 @@
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "./schema";
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/shieldai",
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
const client = createClient({
url: process.env.DATABASE_URL ?? "libsql://shieldai-dev-mikefreno.aws-us-east-1.turso.io",
authToken: process.env.DATABASE_AUTH_TOKEN,
});
export const db = drizzle(pool, { schema });
export { pool };
export const db = drizzle(client, { schema });
export { client };
process.on("SIGTERM", () => {
pool.end().catch(() => process.exit(1));
client.close().catch(() => process.exit(1));
});
process.on("SIGINT", () => {
pool.end().catch(() => process.exit(1));
client.close().catch(() => process.exit(1));
});

View File

@@ -1,5 +1,5 @@
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { db, pool } from "./index";
import { migrate } from "drizzle-orm/libsql/migrator";
import { db, client } from "./index";
export async function runMigrations() {
console.log("[db] Running migrations...");
@@ -15,7 +15,7 @@ export async function runMigrations() {
const isMainModule = process.argv[1]?.includes("migrate");
if (isMainModule) {
runMigrations()
.then(() => pool.end())
.then(() => client.close())
.catch((error) => {
console.error(error);
process.exit(1);

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { getTableConfig } from "drizzle-orm/pg-core";
import { getTableConfig } from "drizzle-orm/sqlite-core";
import * as schema from "./schema";
const tableNames = [
@@ -75,6 +75,7 @@ describe("users table", () => {
expect(colNames).toContain("email_verified");
expect(colNames).toContain("name");
expect(colNames).toContain("image");
expect(colNames).toContain("password_hash");
expect(colNames).toContain("role");
expect(colNames).toContain("stripe_customer_id");
expect(colNames).toContain("deleted_at");
@@ -82,8 +83,8 @@ describe("users table", () => {
expect(colNames).toContain("updated_at");
});
it("has 10 columns", () => {
expect(config.columns).toHaveLength(10);
it("has 11 columns", () => {
expect(config.columns).toHaveLength(11);
});
it("has 3 indexes", () => {

View File

@@ -1,26 +1,26 @@
import { pgTable, text, timestamp, uniqueIndex, index, uuid, integer, boolean } from "drizzle-orm/pg-core";
import { userRole, deviceType, platform } from "./enums";
import { sqliteTable, text, integer, uniqueIndex, index } from "drizzle-orm/sqlite-core";
export const users = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(),
export const users = sqliteTable("users", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
email: text("email").notNull().unique(),
emailVerified: timestamp("email_verified", { withTimezone: true, mode: "date" }),
emailVerified: integer("email_verified", { mode: "timestamp_ms" }),
name: text("name"),
image: text("image"),
role: userRole("role").default("user").notNull(),
passwordHash: text("password_hash"),
role: text("role").default("user").notNull(),
stripeCustomerId: text("stripe_customer_id"),
deletedAt: timestamp("deleted_at", { withTimezone: true, mode: "date" }),
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()),
deletedAt: integer("deleted_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
}, (table) => ({
emailIdx: index("users_email_idx").on(table.email),
roleIdx: index("users_role_idx").on(table.role),
stripeCustomerIdIdx: index("users_stripe_customer_id_idx").on(table.stripeCustomerId),
}));
export const accounts = pgTable("accounts", {
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
export const accounts = sqliteTable("accounts", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
providerAccountId: text("provider_account_id").notNull(),
accessToken: text("access_token"),
@@ -28,39 +28,39 @@ export const accounts = pgTable("accounts", {
expiresAt: integer("expires_at"),
tokenType: text("token_type"),
scope: text("scope"),
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
}, (table) => ({
userProviderUnique: uniqueIndex("accounts_user_provider_unique").on(table.userId, table.provider, table.providerAccountId),
userIdIdx: index("accounts_user_id_idx").on(table.userId),
}));
export const sessions = pgTable("sessions", {
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
sessionToken: text("session_token").notNull().unique(),
expires: timestamp("expires", { withTimezone: true, mode: "date" }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
}, (table) => ({
sessionTokenIdx: index("sessions_session_token_idx").on(table.sessionToken),
userIdIdx: index("sessions_user_id_idx").on(table.userId),
}));
export const deviceTokens = pgTable("device_tokens", {
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
deviceType: deviceType("device_type").notNull(),
export const deviceTokens = sqliteTable("device_tokens", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
deviceType: text("device_type").notNull(),
token: text("token").notNull().unique(),
platform: platform("platform").notNull(),
platform: text("platform").notNull(),
appName: text("app_name"),
appVersion: text("app_version"),
osVersion: text("os_version"),
model: text("model"),
isActive: boolean("is_active").default(true).notNull(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()),
isActive: integer("is_active", { mode: "boolean" }).default(true).notNull(),
lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
}, (table) => ({
userIdIdx: index("device_tokens_user_id_idx").on(table.userId),
deviceTypeIdx: index("device_tokens_device_type_idx").on(table.deviceType),

View File

@@ -1,4 +1,4 @@
import { db, pool } from "./index";
import { db, client } from "./index";
import {
users,
familyGroups,
@@ -300,7 +300,7 @@ export async function seed() {
const isMainModule = process.argv[1]?.includes("seed");
if (isMainModule) {
seed()
.then(() => pool.end())
.then(() => client.close())
.catch((error) => {
console.error("[seed] Error:", error);
process.exit(1);

View File

@@ -2,6 +2,63 @@ import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema/auth";
import { hashPassword, verifyPassword } from "~/server/auth/password";
import { createSession } from "~/server/auth/session";
export async function createUserWithPassword(
name: string,
email: string,
password: string,
) {
const [existing] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "Email already in use",
});
}
const passwordHash = await hashPassword(password);
const [user] = await db
.insert(users)
.values({ name, email, passwordHash })
.returning();
return user;
}
export async function authenticateUser(
email: string,
password: string,
) {
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user || !user.passwordHash) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid email or password",
});
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid email or password",
});
}
const session = await createSession(user.id);
return { user, sessionToken: session.sessionToken };
}
export async function getUserById(id: string) {
const user = await db.query.users.findFirst({