Add remember-me and OTP autofill, fix session persistence

Sign-in gains a "Keep me signed in" checkbox: checked issues a 30-day
token and prefills the address next launch, unchecked drops the session
to 12 hours and forgets the address. The TTL is chosen server-side in
the login route.

Emailed codes are now reachable without retyping. OtpField opts into the
iOS one-time-code keyboard suggestion and raises a paste chip when the
user returns from Gmail with a code on the clipboard. The mails put the
code first in the subject and body, which is what makes Gmail render its
"Copy code" notification action at all.

Fixes found along the way:

- Session was wiped on every launch. decodeJwtExp used atob, which
  neither RN 0.74 nor Expo SDK 51 defines, so it threw, returned null,
  and the caller read that as "expired" and deleted the token. Replaced
  with a dependency-free base64url decoder, and restore now only
  discards a session it can prove is expired.
- Verification and reset codes counted attempts but never enforced them,
  leaving a 6-digit code open to unlimited guessing. Both routes now
  charge the attempt before comparing so concurrent guesses can't race
  past the cap of five, and compare in constant time.
- A wrong verification code showed the "Verified" success screen:
  onModalHide fired unconditionally, so the failure state advanced the
  flow. Only an explicit "verified" state does that now.
- fetchAPI discarded the server's error body, so the UI substring-matched
  synthetic status strings and showed "Could not sign in" for everything.
  It now throws ApiError carrying status and the server's message.
- Login answered a missing account faster than a wrong password; it now
  runs the same scrypt work either way.
- Blank email or password is caught client-side instead of surfacing as
  an opaque 400, and a failed attempt only clears the password on a 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krikorios
2026-08-23 23:33:51 +03:00
co-authored by Claude Opus 5
parent eceb6b45d5
commit bc23c94ea2
15 changed files with 558 additions and 148 deletions
+10 -12
View File
@@ -1,10 +1,11 @@
import { createHash, randomInt } from "crypto";
import { sql } from "@/lib/db";
import { sendEmail } from "@/lib/mailer";
const hashCode = (email: string, code: string): string =>
createHash("sha256").update(`${email}:${code}`).digest("hex");
import {
CODE_TTL_MINUTES,
generateCode,
hashCode,
resetEmail,
} from "@/lib/otp";
export async function POST(req: Request) {
const { email } = await req.json();
@@ -25,14 +26,14 @@ export async function POST(req: Request) {
return Response.json({ data: { sent: false } });
}
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
const code = generateCode();
await sql`
INSERT INTO password_reset_codes (email, code_hash, expires_at)
VALUES (
${normalized},
${hashCode(normalized, code)},
CURRENT_TIMESTAMP + INTERVAL '15 minutes'
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
)
ON CONFLICT (email) DO UPDATE SET
code_hash = EXCLUDED.code_hash,
@@ -40,11 +41,8 @@ export async function POST(req: Request) {
attempts = 0
`;
const delivered = await sendEmail(
normalized,
"Reset your Waseel password",
`We received a request to reset your Waseel password.\n\nYour reset code is: ${code}\n\nIt expires in 15 minutes. If you didn't ask for this, you can ignore this email.`,
);
const mail = resetEmail(code);
const delivered = await sendEmail(normalized, mail.subject, mail.text);
return Response.json({
data: {
+22 -6
View File
@@ -1,13 +1,25 @@
import { createHash } from "crypto";
import { sql } from "@/lib/db";
import { verifyPassword } from "@/lib/password";
import { SESSION_TTL_SECONDS, SHORT_SESSION_TTL_SECONDS } from "@/lib/jwt";
import { hashPassword, verifyPassword } from "@/lib/password";
import { issueSession, toProfile } from "@/lib/users";
// Compared against when no account matches, so a wrong email costs the same
// scrypt work as a wrong password instead of answering noticeably faster.
const DUMMY_HASH = hashPassword("waseel-no-such-account");
export async function POST(req: Request) {
const { email, password } = await req.json();
const { email, password, remember } = await req.json();
if (!email?.trim() || !password) {
// Names only, never values: this is the one 400 that looks like a server
// fault from the app, so say which field arrived empty.
console.warn(
"[LOGIN]: rejected empty",
[!email?.trim() && "email", !password && "password"]
.filter(Boolean)
.join(" + "),
);
return Response.json(
{ error: "Email and password are required." },
{ status: 400 },
@@ -29,8 +41,9 @@ export async function POST(req: Request) {
`;
const user = rows[0];
const valid = verifyPassword(password, user?.password_hash ?? DUMMY_HASH);
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
if (!user || !user.password_hash || !valid) {
return Response.json(
{ error: "Invalid email or password." },
{ status: 401 },
@@ -44,7 +57,10 @@ export async function POST(req: Request) {
);
}
const session = issueSession(user);
const session = issueSession(
user,
remember === false ? SHORT_SESSION_TTL_SECONDS : SESSION_TTL_SECONDS,
);
return Response.json({
data: { token: session.token, user: toProfile(user) },
+11 -9
View File
@@ -1,8 +1,12 @@
import { createHash, randomInt } from "crypto";
import { sql } from "@/lib/db";
import { hashPassword } from "@/lib/password";
import { sendEmail } from "@/lib/mailer";
import {
CODE_TTL_MINUTES,
generateCode,
hashCode,
verificationEmail,
} from "@/lib/otp";
const normalizePhone = (raw: string): string => {
const cleaned = raw.replace(/[^\d+]/g, "");
@@ -10,9 +14,6 @@ const normalizePhone = (raw: string): string => {
return `+961${cleaned.replace(/^0+/, "")}`;
};
const hashCode = (email: string, code: string): string =>
createHash("sha256").update(`${email}:${code}`).digest("hex");
export async function POST(req: Request) {
const { name, email, phone, password, role } = await req.json();
@@ -62,14 +63,14 @@ export async function POST(req: Request) {
role = EXCLUDED.role
`;
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
const code = generateCode();
await sql`
INSERT INTO email_verification_codes (email, code_hash, expires_at)
VALUES (
${email.trim().toLowerCase()},
${hashCode(email.trim().toLowerCase(), code)},
CURRENT_TIMESTAMP + INTERVAL '15 minutes'
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
)
ON CONFLICT (email) DO UPDATE SET
code_hash = EXCLUDED.code_hash,
@@ -77,10 +78,11 @@ export async function POST(req: Request) {
attempts = 0
`;
const mail = verificationEmail(code);
const delivered = await sendEmail(
email.trim().toLowerCase(),
"Your Waseel verification code",
`Welcome to Waseel!\n\nYour verification code is: ${code}\n\nIt expires in 15 minutes.`,
mail.subject,
mail.text,
);
return Response.json(
+14 -15
View File
@@ -1,12 +1,8 @@
import { createHash } from "crypto";
import { sql } from "@/lib/db";
import { MAX_CODE_ATTEMPTS, codeMatches } from "@/lib/otp";
import { hashPassword } from "@/lib/password";
import { issueSession, toProfile } from "@/lib/users";
const hashCode = (email: string, code: string): string =>
createHash("sha256").update(`${email}:${code}`).digest("hex");
export async function POST(req: Request) {
const { email, code, password } = await req.json();
@@ -27,19 +23,22 @@ export async function POST(req: Request) {
const normalized = email.trim().toLowerCase();
try {
const valid = await sql<{ email: string }>`
SELECT email FROM password_reset_codes
WHERE email = ${normalized}
AND code_hash = ${hashCode(normalized, code)}
AND expires_at > CURRENT_TIMESTAMP
// Charge the attempt before comparing so concurrent guesses can't race
// past the cap, and so a correct guess still costs one of the five.
const attempts = await sql<{ code_hash: string; attempts: number }>`
UPDATE password_reset_codes
SET attempts = attempts + 1
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
RETURNING code_hash, attempts
`;
if (!valid[0]) {
await sql`
UPDATE password_reset_codes SET attempts = attempts + 1
WHERE email = ${normalized}
`;
const record = attempts[0];
if (
!record ||
record.attempts > MAX_CODE_ATTEMPTS ||
!codeMatches(record.code_hash, normalized, code)
) {
return Response.json(
{ error: "Invalid or expired reset code." },
{ status: 400 },
+27 -20
View File
@@ -1,11 +1,10 @@
import { createHash } from "crypto";
import { sql } from "@/lib/db";
import {
MAX_CODE_ATTEMPTS,
codeMatches,
} from "@/lib/otp";
import { issueSession, toProfile } from "@/lib/users";
const hashCode = (email: string, code: string): string =>
createHash("sha256").update(`${email}:${code}`).digest("hex");
export async function POST(req: Request) {
const { email, code } = await req.json();
@@ -19,6 +18,28 @@ export async function POST(req: Request) {
const normalized = email.trim().toLowerCase();
try {
// Charge the attempt before comparing so concurrent guesses can't race
// past the cap, and so a correct guess still costs one of the five.
const attempts = await sql<{ code_hash: string; attempts: number }>`
UPDATE email_verification_codes
SET attempts = attempts + 1
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
RETURNING code_hash, attempts
`;
const record = attempts[0];
if (
!record ||
record.attempts > MAX_CODE_ATTEMPTS ||
!codeMatches(record.code_hash, normalized, code)
) {
return Response.json(
{ error: "Invalid or expired verification code." },
{ status: 400 },
);
}
const rows = await sql<{
id: string;
name: string;
@@ -27,27 +48,13 @@ export async function POST(req: Request) {
}>`
UPDATE users SET email_verified = TRUE
WHERE email = ${normalized}
AND EXISTS (
SELECT 1 FROM email_verification_codes
WHERE email = ${normalized}
AND code_hash = ${hashCode(normalized, code)}
AND expires_at > CURRENT_TIMESTAMP
)
RETURNING id, name, email, role
`;
const user = rows[0];
if (!user) {
await sql`
UPDATE email_verification_codes SET attempts = attempts + 1
WHERE email = ${normalized}
`;
return Response.json(
{ error: "Invalid or expired verification code." },
{ status: 400 },
);
return Response.json({ error: "User not found." }, { status: 404 });
}
await sql`DELETE FROM email_verification_codes WHERE email = ${normalized}`;