import { pgTable, text, boolean, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core'; import { ports } from './ports'; import { clients } from './clients'; /** * Portal users - one per client account that's been invited to the client * portal. Separate from the CRM `users` table (managed by better-auth) so the * authentication realms stay isolated. * * Created by an admin from the client detail page; the admin's invite mails * an activation token that lets the client set their own password. */ export const portalUsers = pgTable( 'portal_users', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), portId: text('port_id') .notNull() .references(() => ports.id), clientId: text('client_id') .notNull() .references(() => clients.id, { onDelete: 'cascade' }), email: text('email').notNull(), /** * scrypt-hashed password. Format: `salt:keyHex` (both base64url). Null * until the user activates their account. */ passwordHash: text('password_hash'), /** * Watermark for JWT-session revocation on password change. Any * `verifyPortalToken` call where the JWT's `iat` is older than this * value rejects the token even if it's otherwise valid. Updated on * `resetPassword`, `activateAccount`, and `changePortalPassword` so * a stolen cookie stops working after the legitimate owner does the * forgot-password / change-password dance. auth-flow-auditor C1. */ passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }) .notNull() .defaultNow(), name: text('name'), isActive: boolean('is_active').notNull().default(true), lastLoginAt: timestamp('last_login_at', { withTimezone: true }), createdBy: text('created_by').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ uniqueIndex('idx_portal_users_email_unique').on(table.email), index('idx_portal_users_client').on(table.clientId), index('idx_portal_users_port').on(table.portId), ], ); /** * Single-use tokens for portal-account activation and password reset. * * `tokenHash` is a SHA-256 hash of the raw token sent in the email. Lookups * happen by hash so a DB compromise never leaks active tokens. */ export const portalAuthTokens = pgTable( 'portal_auth_tokens', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), portalUserId: text('portal_user_id') .notNull() .references(() => portalUsers.id, { onDelete: 'cascade' }), tokenHash: text('token_hash').notNull(), type: text('type').notNull(), // 'activation' | 'reset' expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), usedAt: timestamp('used_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ uniqueIndex('idx_portal_tokens_hash_unique').on(table.tokenHash), index('idx_portal_tokens_user').on(table.portalUserId), ], ); export type PortalUser = typeof portalUsers.$inferSelect; export type NewPortalUser = typeof portalUsers.$inferInsert; export type PortalAuthToken = typeof portalAuthTokens.$inferSelect; export type NewPortalAuthToken = typeof portalAuthTokens.$inferInsert;