mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Same email now contains a 6-digit code and a magic link, both backed by a
single verificationToken row. After submitting their email, the user
lands on /auth/verify-code with a single-input form (inputmode=numeric,
autocomplete=one-time-code, auto-submit on 6 digits or paste) and can
either type the code or use the link from the email. Either path
consumes the same row — single-use, replace-on-resend.
This is the structural fix for the silent-quarantine pattern observed
with Atkins and Sustainable Building UK: corporate gateways are happier
with short transactional content than long opaque token URLs, and a
code can't be broken by SafeLinks-style URL rewriting. The link path is
preserved so users whose email gets through unmangled keep one-click UX.
Security:
- Codes are 6-digit, crypto.randomInt-generated, stored as sha256
hashed against NEXTAUTH_SECRET on the same row as the link token
- 5-attempt lockout per code (attempts column); 6th attempt with the
correct code still fails
- Per-email send rate limit: 5/hour fixed window (authRateLimits
table); 6th send returns an error
- Code + link share a 10-minute window (maxAge dropped from 1h)
- Resending replaces any prior token rows for the identifier so only
the latest send is ever live
Implementation:
- verificationCode.ts holds generateCode + hashCode + the pure
evaluateCodeAttempt decision tree; 9 unit tests cover every branch
of the verification outcome (no-such-row, expired, locked-out, ok,
wrong-with-newAttempts, locked-out-still-rejects-correct-code)
- sendVerificationRequest now hashes the URL token the same way
/verify/[token]/page.tsx does, applies the rate limit + records the
code + replaces older rows in two transactions
- CredentialsProvider (id: "email-code") calls evaluateCodeAttempt
inside a transaction, handles all 5 outcomes, creates the user on
first successful code (parity with the magic-link callback path)
- oauthId backfill in the signIn callback is now guarded on
account.type === "oauth" so the credentials flow doesn't pollute
oauthProvider with "email-code"
- Migration is additive: code_hash nullable, attempts default 0; new
authRateLimits table is independent. In-flight tokens at deploy time
keep working via the link path.
Vercel preview deployment is the test surface; a Mailpit + Cypress E2E
loop is intentionally deferred per the lean-setup plan in docs/wip/
auth-email-code-fallback-plan.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
5.5 KiB
TypeScript
210 lines
5.5 KiB
TypeScript
import {
|
||
bigint,
|
||
bigserial,
|
||
text,
|
||
timestamp,
|
||
pgTable,
|
||
primaryKey,
|
||
integer,
|
||
boolean,
|
||
json,
|
||
pgEnum,
|
||
varchar,
|
||
} from "drizzle-orm/pg-core";
|
||
import { InferModel } from "drizzle-orm";
|
||
|
||
// -------------------------
|
||
// USERS
|
||
// -------------------------
|
||
export const user = pgTable("user", {
|
||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||
firstName: text("firstName"),
|
||
email: text("email").notNull().unique(),
|
||
emailVerified: timestamp("emailVerified", { mode: "date" }),
|
||
oauthId: text("oauth_id"),
|
||
oauthProvider: text("oauth_provider").$type<
|
||
"google" | "credentials" | "azure-ad-b2c"
|
||
>(),
|
||
image: text("image"),
|
||
onboarded: boolean("onboarded").default(false).notNull(),
|
||
lastLogin: timestamp("last_login", { mode: "date" }),
|
||
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
|
||
.defaultNow()
|
||
.notNull(),
|
||
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
|
||
.defaultNow()
|
||
.notNull(),
|
||
});
|
||
|
||
// -------------------------
|
||
// ACCOUNTS (OAuth providers)
|
||
// -------------------------
|
||
export const accounts = pgTable(
|
||
"account",
|
||
{
|
||
userId: bigint("userId", { mode: "bigint" })
|
||
.notNull()
|
||
.references(() => user.id, { onDelete: "cascade" }),
|
||
type: text("type").$type<"oauth" | "email" | "credentials">().notNull(),
|
||
provider: text("provider").notNull(),
|
||
providerAccountId: text("providerAccountId").notNull(),
|
||
refresh_token: text("refresh_token"),
|
||
access_token: text("access_token"),
|
||
expires_at: integer("expires_at"),
|
||
token_type: text("token_type"),
|
||
scope: text("scope"),
|
||
id_token: text("id_token"),
|
||
session_state: text("session_state"),
|
||
},
|
||
(account) => [
|
||
primaryKey({ columns: [account.provider, account.providerAccountId] }),
|
||
]
|
||
);
|
||
|
||
export const sessions = pgTable("session", {
|
||
sessionToken: text("sessionToken").primaryKey(),
|
||
userId: bigint("userId", { mode: "bigint" })
|
||
.notNull()
|
||
.references(() => user.id, { onDelete: "cascade" }),
|
||
expires: timestamp("expires", { mode: "date" }).notNull(),
|
||
});
|
||
|
||
export const verificationTokens = pgTable(
|
||
"verificationToken",
|
||
{
|
||
identifier: text("identifier").notNull(),
|
||
token: text("token").notNull(),
|
||
expires: timestamp("expires", { mode: "date" }).notNull(),
|
||
codeHash: text("code_hash"),
|
||
attempts: integer("attempts").notNull().default(0),
|
||
},
|
||
(vt) => [primaryKey({ columns: [vt.identifier, vt.token] })]
|
||
);
|
||
|
||
export const authRateLimits = pgTable(
|
||
"authRateLimits",
|
||
{
|
||
scope: text("scope").notNull(),
|
||
key: text("key").notNull(),
|
||
count: integer("count").notNull().default(0),
|
||
windowStart: timestamp("window_start", { mode: "date" }).notNull(),
|
||
},
|
||
(rl) => [primaryKey({ columns: [rl.scope, rl.key] })]
|
||
);
|
||
|
||
export const UserType: [string, ...string[]] = [
|
||
"private_landlord",
|
||
"private_tenant",
|
||
"social_landlord",
|
||
"social_tenant",
|
||
"homeowner",
|
||
"other",
|
||
];
|
||
|
||
export const PropertyCount: [string, ...string[]] = [
|
||
// Private landlord options
|
||
"1",
|
||
"2–5",
|
||
"6–20",
|
||
"21+",
|
||
// Social landlord options
|
||
"1–50",
|
||
"51–100",
|
||
"101–300",
|
||
"301–1000",
|
||
"1000+",
|
||
];
|
||
|
||
export const ReferralSource: [string, ...string[]] = [
|
||
"search",
|
||
"social_media",
|
||
"NRLA",
|
||
"partner",
|
||
"word_of_mouth",
|
||
"other",
|
||
];
|
||
|
||
export const Goal: [string, ...string[]] = [
|
||
"access_funding",
|
||
"net_zero",
|
||
"improve_condition",
|
||
"save_money",
|
||
"other",
|
||
];
|
||
|
||
export const userTypeEnum = pgEnum("user_profiles_user_type", UserType);
|
||
export const propertyCountEnum = pgEnum(
|
||
"user_profiles_property_count",
|
||
PropertyCount
|
||
);
|
||
export const referralSourceEnum = pgEnum(
|
||
"user_profiles_referral_source",
|
||
ReferralSource
|
||
);
|
||
|
||
// ----------------------------
|
||
// MAIN TABLE
|
||
// ----------------------------
|
||
export const userProfiles = pgTable("user_profiles", {
|
||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||
|
||
userId: bigint("user_id", { mode: "bigint" })
|
||
.notNull()
|
||
.references(() => user.id, { onDelete: "cascade" }),
|
||
|
||
// Profile
|
||
userType: userTypeEnum("user_type").notNull(),
|
||
propertyCount: propertyCountEnum("property_count"), // Nullable for homeowners / tenants
|
||
|
||
// Goals (multi-select)
|
||
goals: json("goals").$type<(typeof Goal)[number][]>(),
|
||
|
||
// Referral
|
||
referralSource: referralSourceEnum("referral_source"),
|
||
nrlaMembershipId: varchar("nrla_membership_id", { length: 255 }),
|
||
|
||
// Compliance
|
||
acceptedPrivacy: boolean("accepted_privacy").notNull().default(false),
|
||
acceptedPrivacyAt: timestamp("accepted_privacy_at", {
|
||
withTimezone: true,
|
||
precision: 6,
|
||
}),
|
||
|
||
// Marketing
|
||
marketingOptIn: boolean("marketing_opt_in").default(false),
|
||
marketingOptInAt: timestamp("marketing_opt_in_at", {
|
||
withTimezone: true,
|
||
precision: 6,
|
||
}),
|
||
|
||
// Basic user identity
|
||
firstName: text("first_name"),
|
||
lastName: text("last_name"),
|
||
|
||
// Metadata
|
||
createdAt: timestamp("created_at", {
|
||
precision: 6,
|
||
withTimezone: true,
|
||
})
|
||
.defaultNow()
|
||
.notNull(),
|
||
updatedAt: timestamp("updated_at", {
|
||
precision: 6,
|
||
withTimezone: true,
|
||
})
|
||
.defaultNow()
|
||
.notNull(),
|
||
});
|
||
|
||
// -------------------------
|
||
// Types
|
||
// -------------------------
|
||
export type User = InferModel<typeof user, "select">;
|
||
export type NewUser = InferModel<typeof user, "insert">;
|
||
|
||
export type Account = InferModel<typeof accounts, "select">;
|
||
export type Session = InferModel<typeof sessions, "select">;
|
||
export type VerificationToken = InferModel<typeof verificationTokens, "select">;
|
||
|
||
export type UserProfile = InferModel<typeof userProfiles, "select">;
|
||
export type NewUserProfile = InferModel<typeof userProfiles, "insert">;
|