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:
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user