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:
co-authored by
Claude Opus 5
parent
eceb6b45d5
commit
bc23c94ea2
@@ -1,10 +1,11 @@
|
|||||||
import { createHash, randomInt } from "crypto";
|
|
||||||
|
|
||||||
import { sql } from "@/lib/db";
|
import { sql } from "@/lib/db";
|
||||||
import { sendEmail } from "@/lib/mailer";
|
import { sendEmail } from "@/lib/mailer";
|
||||||
|
import {
|
||||||
const hashCode = (email: string, code: string): string =>
|
CODE_TTL_MINUTES,
|
||||||
createHash("sha256").update(`${email}:${code}`).digest("hex");
|
generateCode,
|
||||||
|
hashCode,
|
||||||
|
resetEmail,
|
||||||
|
} from "@/lib/otp";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const { email } = await req.json();
|
const { email } = await req.json();
|
||||||
@@ -25,14 +26,14 @@ export async function POST(req: Request) {
|
|||||||
return Response.json({ data: { sent: false } });
|
return Response.json({ data: { sent: false } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
|
const code = generateCode();
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO password_reset_codes (email, code_hash, expires_at)
|
INSERT INTO password_reset_codes (email, code_hash, expires_at)
|
||||||
VALUES (
|
VALUES (
|
||||||
${normalized},
|
${normalized},
|
||||||
${hashCode(normalized, code)},
|
${hashCode(normalized, code)},
|
||||||
CURRENT_TIMESTAMP + INTERVAL '15 minutes'
|
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
||||||
)
|
)
|
||||||
ON CONFLICT (email) DO UPDATE SET
|
ON CONFLICT (email) DO UPDATE SET
|
||||||
code_hash = EXCLUDED.code_hash,
|
code_hash = EXCLUDED.code_hash,
|
||||||
@@ -40,11 +41,8 @@ export async function POST(req: Request) {
|
|||||||
attempts = 0
|
attempts = 0
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const delivered = await sendEmail(
|
const mail = resetEmail(code);
|
||||||
normalized,
|
const delivered = await sendEmail(normalized, mail.subject, mail.text);
|
||||||
"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.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { createHash } from "crypto";
|
|
||||||
|
|
||||||
import { sql } from "@/lib/db";
|
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";
|
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) {
|
export async function POST(req: Request) {
|
||||||
const { email, password } = await req.json();
|
const { email, password, remember } = await req.json();
|
||||||
|
|
||||||
if (!email?.trim() || !password) {
|
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(
|
return Response.json(
|
||||||
{ error: "Email and password are required." },
|
{ error: "Email and password are required." },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -29,8 +41,9 @@ export async function POST(req: Request) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const user = rows[0];
|
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(
|
return Response.json(
|
||||||
{ error: "Invalid email or password." },
|
{ error: "Invalid email or password." },
|
||||||
{ status: 401 },
|
{ 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({
|
return Response.json({
|
||||||
data: { token: session.token, user: toProfile(user) },
|
data: { token: session.token, user: toProfile(user) },
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { createHash, randomInt } from "crypto";
|
|
||||||
|
|
||||||
import { sql } from "@/lib/db";
|
import { sql } from "@/lib/db";
|
||||||
import { hashPassword } from "@/lib/password";
|
import { hashPassword } from "@/lib/password";
|
||||||
import { sendEmail } from "@/lib/mailer";
|
import { sendEmail } from "@/lib/mailer";
|
||||||
|
import {
|
||||||
|
CODE_TTL_MINUTES,
|
||||||
|
generateCode,
|
||||||
|
hashCode,
|
||||||
|
verificationEmail,
|
||||||
|
} from "@/lib/otp";
|
||||||
|
|
||||||
const normalizePhone = (raw: string): string => {
|
const normalizePhone = (raw: string): string => {
|
||||||
const cleaned = raw.replace(/[^\d+]/g, "");
|
const cleaned = raw.replace(/[^\d+]/g, "");
|
||||||
@@ -10,9 +14,6 @@ const normalizePhone = (raw: string): string => {
|
|||||||
return `+961${cleaned.replace(/^0+/, "")}`;
|
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) {
|
export async function POST(req: Request) {
|
||||||
const { name, email, phone, password, role } = await req.json();
|
const { name, email, phone, password, role } = await req.json();
|
||||||
|
|
||||||
@@ -62,14 +63,14 @@ export async function POST(req: Request) {
|
|||||||
role = EXCLUDED.role
|
role = EXCLUDED.role
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
|
const code = generateCode();
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO email_verification_codes (email, code_hash, expires_at)
|
INSERT INTO email_verification_codes (email, code_hash, expires_at)
|
||||||
VALUES (
|
VALUES (
|
||||||
${email.trim().toLowerCase()},
|
${email.trim().toLowerCase()},
|
||||||
${hashCode(email.trim().toLowerCase(), code)},
|
${hashCode(email.trim().toLowerCase(), code)},
|
||||||
CURRENT_TIMESTAMP + INTERVAL '15 minutes'
|
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
||||||
)
|
)
|
||||||
ON CONFLICT (email) DO UPDATE SET
|
ON CONFLICT (email) DO UPDATE SET
|
||||||
code_hash = EXCLUDED.code_hash,
|
code_hash = EXCLUDED.code_hash,
|
||||||
@@ -77,10 +78,11 @@ export async function POST(req: Request) {
|
|||||||
attempts = 0
|
attempts = 0
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const mail = verificationEmail(code);
|
||||||
const delivered = await sendEmail(
|
const delivered = await sendEmail(
|
||||||
email.trim().toLowerCase(),
|
email.trim().toLowerCase(),
|
||||||
"Your Waseel verification code",
|
mail.subject,
|
||||||
`Welcome to Waseel!\n\nYour verification code is: ${code}\n\nIt expires in 15 minutes.`,
|
mail.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { createHash } from "crypto";
|
|
||||||
|
|
||||||
import { sql } from "@/lib/db";
|
import { sql } from "@/lib/db";
|
||||||
|
import { MAX_CODE_ATTEMPTS, codeMatches } from "@/lib/otp";
|
||||||
import { hashPassword } from "@/lib/password";
|
import { hashPassword } from "@/lib/password";
|
||||||
import { issueSession, toProfile } from "@/lib/users";
|
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) {
|
export async function POST(req: Request) {
|
||||||
const { email, code, password } = await req.json();
|
const { email, code, password } = await req.json();
|
||||||
|
|
||||||
@@ -27,19 +23,22 @@ export async function POST(req: Request) {
|
|||||||
const normalized = email.trim().toLowerCase();
|
const normalized = email.trim().toLowerCase();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const valid = await sql<{ email: string }>`
|
// Charge the attempt before comparing so concurrent guesses can't race
|
||||||
SELECT email FROM password_reset_codes
|
// past the cap, and so a correct guess still costs one of the five.
|
||||||
WHERE email = ${normalized}
|
const attempts = await sql<{ code_hash: string; attempts: number }>`
|
||||||
AND code_hash = ${hashCode(normalized, code)}
|
UPDATE password_reset_codes
|
||||||
AND expires_at > CURRENT_TIMESTAMP
|
SET attempts = attempts + 1
|
||||||
|
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
||||||
|
RETURNING code_hash, attempts
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!valid[0]) {
|
const record = attempts[0];
|
||||||
await sql`
|
|
||||||
UPDATE password_reset_codes SET attempts = attempts + 1
|
|
||||||
WHERE email = ${normalized}
|
|
||||||
`;
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
!record ||
|
||||||
|
record.attempts > MAX_CODE_ATTEMPTS ||
|
||||||
|
!codeMatches(record.code_hash, normalized, code)
|
||||||
|
) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Invalid or expired reset code." },
|
{ error: "Invalid or expired reset code." },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { createHash } from "crypto";
|
|
||||||
|
|
||||||
import { sql } from "@/lib/db";
|
import { sql } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
MAX_CODE_ATTEMPTS,
|
||||||
|
codeMatches,
|
||||||
|
} from "@/lib/otp";
|
||||||
import { issueSession, toProfile } from "@/lib/users";
|
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) {
|
export async function POST(req: Request) {
|
||||||
const { email, code } = await req.json();
|
const { email, code } = await req.json();
|
||||||
|
|
||||||
@@ -19,6 +18,28 @@ export async function POST(req: Request) {
|
|||||||
const normalized = email.trim().toLowerCase();
|
const normalized = email.trim().toLowerCase();
|
||||||
|
|
||||||
try {
|
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<{
|
const rows = await sql<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -27,27 +48,13 @@ export async function POST(req: Request) {
|
|||||||
}>`
|
}>`
|
||||||
UPDATE users SET email_verified = TRUE
|
UPDATE users SET email_verified = TRUE
|
||||||
WHERE email = ${normalized}
|
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
|
RETURNING id, name, email, role
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await sql`
|
return Response.json({ error: "User not found." }, { status: 404 });
|
||||||
UPDATE email_verification_codes SET attempts = attempts + 1
|
|
||||||
WHERE email = ${normalized}
|
|
||||||
`;
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Invalid or expired verification code." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await sql`DELETE FROM email_verification_codes WHERE email = ${normalized}`;
|
await sql`DELETE FROM email_verification_codes WHERE email = ${normalized}`;
|
||||||
|
|||||||
+103
-24
@@ -1,5 +1,5 @@
|
|||||||
import { Link, useRouter } from "expo-router";
|
import { Link, useRouter } from "expo-router";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Image,
|
Image,
|
||||||
@@ -15,17 +15,46 @@ import ReactNativeModal from "react-native-modal";
|
|||||||
import { CustomButton } from "@/components/custom-button";
|
import { CustomButton } from "@/components/custom-button";
|
||||||
import { InputField } from "@/components/input-field";
|
import { InputField } from "@/components/input-field";
|
||||||
import { OAuth } from "@/components/oauth";
|
import { OAuth } from "@/components/oauth";
|
||||||
|
import { OtpField } from "@/components/otp-field";
|
||||||
import { icons, images } from "@/constants";
|
import { icons, images } from "@/constants";
|
||||||
import { fetchAPI } from "@/lib/fetch";
|
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||||
import { useSession } from "@/lib/session";
|
import { getRememberedEmail, rememberEmail, useSession } from "@/lib/session";
|
||||||
|
|
||||||
const SignIn = () => {
|
const SignIn = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isLoaded, setSession } = useSession();
|
const { setSession } = useSession();
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// On by default: a rider signing in on their own phone shouldn't have to
|
||||||
|
// opt into staying signed in. Unchecking it shortens the session to 12h and
|
||||||
|
// stops the address being prefilled next time.
|
||||||
|
const [remember, setRemember] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
getRememberedEmail()
|
||||||
|
.then((email) => {
|
||||||
|
if (!email || cancelled) return;
|
||||||
|
|
||||||
|
// SecureStore can resolve after the user has started typing, so only
|
||||||
|
// fill a field that's still untouched.
|
||||||
|
setForm((prevForm) =>
|
||||||
|
prevForm.email ? prevForm : { ...prevForm, email },
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Nothing stored, or the keychain is unavailable: start blank.
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Forgot-password flow: "request" collects the email, "reset" collects the
|
// Forgot-password flow: "request" collects the email, "reset" collects the
|
||||||
// emailed code and a new password.
|
// emailed code and a new password.
|
||||||
@@ -109,20 +138,36 @@ const SignIn = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await setSession(response.data);
|
await setSession(response.data);
|
||||||
|
await rememberEmail(remember ? reset.email.trim() : null);
|
||||||
setReset((prev) => ({ ...prev, state: "closed", busy: false }));
|
setReset((prev) => ({ ...prev, state: "closed", busy: false }));
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setReset((prev) => ({
|
setReset((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
busy: false,
|
busy: false,
|
||||||
error: String(err?.message ?? "").includes("400")
|
error:
|
||||||
? "Invalid or expired reset code."
|
err instanceof ApiError && err.status < 500
|
||||||
|
? err.message
|
||||||
: "Could not reset your password. Please try again.",
|
: "Could not reset your password. Please try again.",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSignInPress = useCallback(async () => {
|
const onSignInPress = useCallback(async () => {
|
||||||
|
if (busy) return;
|
||||||
|
|
||||||
|
// Catch the blank-field case here: the server answers 400 for it, which
|
||||||
|
// otherwise surfaces as a generic "could not sign in".
|
||||||
|
if (!form.email.trim() || !form.password) {
|
||||||
|
Alert.alert(
|
||||||
|
"Missing information",
|
||||||
|
"Enter both your email and your password.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchAPI("/(api)/auth/login", {
|
const response = await fetchAPI("/(api)/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -130,26 +175,30 @@ const SignIn = () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: form.email,
|
email: form.email,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
|
remember,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await setSession(response.data);
|
await setSession(response.data);
|
||||||
|
await rememberEmail(remember ? form.email.trim() : null);
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const status = String(err?.message ?? "");
|
const message =
|
||||||
const message = status.includes("403")
|
err instanceof ApiError && err.status < 500
|
||||||
? "Please verify your email first."
|
? err.message
|
||||||
: status.includes("401")
|
|
||||||
? "Invalid email or password."
|
|
||||||
: "Could not sign in. Please try again.";
|
: "Could not sign in. Please try again.";
|
||||||
|
|
||||||
Alert.alert("Error", message);
|
Alert.alert("Error", message);
|
||||||
setForm((prevForm) => ({
|
|
||||||
...prevForm,
|
// Only a rejected password is worth retyping. Clearing it after a
|
||||||
password: "",
|
// network blip or a 403 just makes the next attempt fail differently.
|
||||||
}));
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
setForm((prevForm) => ({ ...prevForm, password: "" }));
|
||||||
}
|
}
|
||||||
}, [isLoaded, form.email, form.password, setSession, router]);
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [busy, form.email, form.password, remember, setSession, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -190,6 +239,8 @@ const SignIn = () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
|
autoComplete="email"
|
||||||
|
textContentType="username"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputField
|
<InputField
|
||||||
@@ -204,11 +255,38 @@ const SignIn = () => {
|
|||||||
password: value,
|
password: value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
autoComplete="current-password"
|
||||||
|
textContentType="password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setRemember((current) => !current)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: remember }}
|
||||||
|
className="flex-row items-center mt-4"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`h-6 w-6 rounded-md items-center justify-center border-2 ${
|
||||||
|
remember
|
||||||
|
? "bg-primary-500 border-primary-500"
|
||||||
|
: "bg-white border-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{remember ? (
|
||||||
|
<Text className="text-white text-xs font-JakartaBold">✓</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="ml-3 font-JakartaMedium text-[15px] text-black">
|
||||||
|
Keep me signed in
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<CustomButton
|
<CustomButton
|
||||||
title="Sign In"
|
title={busy ? "Signing in…" : "Sign In"}
|
||||||
onPress={onSignInPress}
|
onPress={onSignInPress}
|
||||||
|
disabled={busy}
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -248,6 +326,8 @@ const SignIn = () => {
|
|||||||
icon={icons.email}
|
icon={icons.email}
|
||||||
value={reset.email}
|
value={reset.email}
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
|
autoComplete="email"
|
||||||
|
textContentType="username"
|
||||||
onChangeText={(email) =>
|
onChangeText={(email) =>
|
||||||
setReset((prev) => ({ ...prev, email }))
|
setReset((prev) => ({ ...prev, email }))
|
||||||
}
|
}
|
||||||
@@ -288,14 +368,11 @@ const SignIn = () => {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<InputField
|
<OtpField
|
||||||
label="Code"
|
|
||||||
icon={icons.lock}
|
|
||||||
placeholder="••••••"
|
|
||||||
value={reset.code}
|
value={reset.code}
|
||||||
maxLength={6}
|
onChange={(code) =>
|
||||||
keyboardType="numeric"
|
setReset((prev) => ({ ...prev, code, error: "" }))
|
||||||
onChangeText={(code) => setReset((prev) => ({ ...prev, code }))}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputField
|
<InputField
|
||||||
@@ -307,6 +384,8 @@ const SignIn = () => {
|
|||||||
onChangeText={(password) =>
|
onChangeText={(password) =>
|
||||||
setReset((prev) => ({ ...prev, password }))
|
setReset((prev) => ({ ...prev, password }))
|
||||||
}
|
}
|
||||||
|
autoComplete="new-password"
|
||||||
|
textContentType="newPassword"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{reset.error ? (
|
{reset.error ? (
|
||||||
|
|||||||
+63
-31
@@ -1,5 +1,5 @@
|
|||||||
import { Link, router } from "expo-router";
|
import { Link, router } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Image,
|
Image,
|
||||||
@@ -15,8 +15,9 @@ import ReactNativeModal from "react-native-modal";
|
|||||||
import { CustomButton } from "@/components/custom-button";
|
import { CustomButton } from "@/components/custom-button";
|
||||||
import { InputField } from "@/components/input-field";
|
import { InputField } from "@/components/input-field";
|
||||||
import { OAuth } from "@/components/oauth";
|
import { OAuth } from "@/components/oauth";
|
||||||
|
import { OtpField } from "@/components/otp-field";
|
||||||
import { icons, images } from "@/constants";
|
import { icons, images } from "@/constants";
|
||||||
import { fetchAPI } from "@/lib/fetch";
|
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
const ROLES = [
|
const ROLES = [
|
||||||
@@ -45,11 +46,14 @@ const SignUp = () => {
|
|||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// "verified" is a hand-off state: it hides the code modal so its onModalHide
|
||||||
|
// can bring up the success modal, since two modals can't cross-fade.
|
||||||
const [verification, setVerification] = useState({
|
const [verification, setVerification] = useState({
|
||||||
state: "default",
|
state: "default" as "default" | "pending" | "verified" | "success",
|
||||||
error: "",
|
error: "",
|
||||||
code: "",
|
code: "",
|
||||||
devCode: "",
|
devCode: "",
|
||||||
|
busy: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSignUpPress = async () => {
|
const onSignUpPress = async () => {
|
||||||
@@ -82,12 +86,14 @@ const SignUp = () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
setVerification((prevVerification) => ({
|
setVerification({
|
||||||
...prevVerification,
|
|
||||||
state: "pending",
|
state: "pending",
|
||||||
|
error: "",
|
||||||
|
code: "",
|
||||||
|
busy: false,
|
||||||
devCode:
|
devCode:
|
||||||
(response as { data?: { devCode?: string } })?.data?.devCode ?? "",
|
(response as { data?: { devCode?: string } })?.data?.devCode ?? "",
|
||||||
}));
|
});
|
||||||
|
|
||||||
setForm((prevForm) => ({
|
setForm((prevForm) => ({
|
||||||
...prevForm,
|
...prevForm,
|
||||||
@@ -98,33 +104,61 @@ const SignUp = () => {
|
|||||||
...prevForm,
|
...prevForm,
|
||||||
password: "",
|
password: "",
|
||||||
}));
|
}));
|
||||||
Alert.alert("Error", err?.message ?? "Could not create your account.");
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
err instanceof ApiError && err.status < 500
|
||||||
|
? err.message
|
||||||
|
: "Could not create your account.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPressVerify = async () => {
|
const onPressVerify = useCallback(
|
||||||
|
async (code: string) => {
|
||||||
|
if (!/^\d{6}$/.test(code)) {
|
||||||
|
setVerification((prevVerification) => ({
|
||||||
|
...prevVerification,
|
||||||
|
error: "Enter the 6-digit code.",
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerification((prevVerification) =>
|
||||||
|
// Guard the double submit that auto-verify + a button tap would cause.
|
||||||
|
prevVerification.busy
|
||||||
|
? prevVerification
|
||||||
|
: { ...prevVerification, busy: true, error: "" },
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchAPI("/(api)/auth/verify", {
|
const response = await fetchAPI("/(api)/auth/verify", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email: form.email, code: verification.code }),
|
body: JSON.stringify({ email: form.email, code }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await setSession(response.data);
|
await setSession(response.data);
|
||||||
setVerification((prevVerification) => ({
|
setVerification((prevVerification) => ({
|
||||||
...prevVerification,
|
...prevVerification,
|
||||||
state: "success",
|
state: "verified",
|
||||||
|
busy: false,
|
||||||
}));
|
}));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
// Stay on the code modal so the user can retry; only a real success
|
||||||
|
// advances the flow.
|
||||||
setVerification((prevVerification) => ({
|
setVerification((prevVerification) => ({
|
||||||
...prevVerification,
|
...prevVerification,
|
||||||
error: err?.message?.includes("400")
|
code: "",
|
||||||
? "Invalid or expired verification code."
|
busy: false,
|
||||||
: err?.message ?? "Verification failed.",
|
error:
|
||||||
state: "failed",
|
err instanceof ApiError && err.status < 500
|
||||||
|
? err.message
|
||||||
|
: "Verification failed. Please try again.",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[form.email, setSession],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -266,10 +300,11 @@ const SignUp = () => {
|
|||||||
|
|
||||||
<ReactNativeModal
|
<ReactNativeModal
|
||||||
onModalHide={() =>
|
onModalHide={() =>
|
||||||
setVerification((prevVerification) => ({
|
setVerification((prevVerification) =>
|
||||||
...prevVerification,
|
prevVerification.state === "verified"
|
||||||
state: "success",
|
? { ...prevVerification, state: "success" }
|
||||||
}))
|
: prevVerification,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
isVisible={verification.state === "pending"}
|
isVisible={verification.state === "pending"}
|
||||||
>
|
>
|
||||||
@@ -292,31 +327,28 @@ const SignUp = () => {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<InputField
|
<OtpField
|
||||||
label="Code"
|
|
||||||
icon={icons.lock}
|
|
||||||
placeholder="••••••"
|
|
||||||
value={verification.code}
|
value={verification.code}
|
||||||
maxLength={6}
|
onChange={(code) =>
|
||||||
secureTextEntry
|
|
||||||
keyboardType="numeric"
|
|
||||||
onChangeText={(code) =>
|
|
||||||
setVerification((prevVerification) => ({
|
setVerification((prevVerification) => ({
|
||||||
...prevVerification,
|
...prevVerification,
|
||||||
code,
|
code,
|
||||||
|
error: "",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
onComplete={onPressVerify}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{verification.error && (
|
{verification.error ? (
|
||||||
<Text className="text-rose-500 text-sm mt-1">
|
<Text className="text-rose-500 text-sm mt-1">
|
||||||
{verification.error}
|
{verification.error}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<CustomButton
|
<CustomButton
|
||||||
title="Verify Email"
|
title={verification.busy ? "Verifying…" : "Verify Email"}
|
||||||
onPress={onPressVerify}
|
onPress={() => onPressVerify(verification.code)}
|
||||||
|
disabled={verification.busy}
|
||||||
className="mt-5 bg-emerald-500"
|
className="mt-5 bg-emerald-500"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AppState, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
import { InputField } from "@/components/input-field";
|
||||||
|
import { icons } from "@/constants";
|
||||||
|
|
||||||
|
// `\b` won't match between two digits, so a longer run like an order number
|
||||||
|
// never yields a false positive.
|
||||||
|
const CODE_PATTERN = /\b\d{6}\b/;
|
||||||
|
|
||||||
|
/** Pulls the 6-digit code out of whatever the user copied from the email. */
|
||||||
|
export const extractCode = (raw: string | null | undefined): string | null =>
|
||||||
|
raw ? (CODE_PATTERN.exec(raw)?.[0] ?? null) : null;
|
||||||
|
|
||||||
|
type OtpFieldProps = {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (code: string) => void;
|
||||||
|
/** Fired once the field holds a complete 6-digit code. */
|
||||||
|
onComplete?: (code: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code entry for the emailed verification/reset codes.
|
||||||
|
*
|
||||||
|
* Three ways in, cheapest first:
|
||||||
|
* 1. iOS surfaces the code above the keyboard once Mail has it —
|
||||||
|
* `textContentType="oneTimeCode"` is what opts the field into that.
|
||||||
|
* 2. Gmail's notification carries a "Copy code" action (Android) and the
|
||||||
|
* code is one long-press away on any platform: coming back to the app
|
||||||
|
* with a code on the clipboard raises the paste chip below.
|
||||||
|
* 3. Typing it.
|
||||||
|
*/
|
||||||
|
export const OtpField = ({
|
||||||
|
label = "Code",
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onComplete,
|
||||||
|
}: OtpFieldProps) => {
|
||||||
|
const [pasteReady, setPasteReady] = useState(false);
|
||||||
|
const completedFor = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// `hasStringAsync` inspects the clipboard without reading it, so it never
|
||||||
|
// trips the iOS paste prompt — that only fires on the explicit tap below.
|
||||||
|
const refreshPasteChip = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setPasteReady(await Clipboard.hasStringAsync());
|
||||||
|
} catch {
|
||||||
|
setPasteReady(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshPasteChip();
|
||||||
|
|
||||||
|
// The user leaves for Gmail and comes back with the code copied.
|
||||||
|
const subscription = AppState.addEventListener("change", (state) => {
|
||||||
|
if (state === "active") void refreshPasteChip();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [refreshPasteChip]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(next: string) => {
|
||||||
|
// Paste of a whole line ("123456 is your Waseel…") still lands the code.
|
||||||
|
const digits =
|
||||||
|
next.length > 6
|
||||||
|
? (extractCode(next) ?? next.replace(/\D/g, "").slice(0, 6))
|
||||||
|
: next.replace(/\D/g, "");
|
||||||
|
|
||||||
|
onChange(digits);
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPastePress = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const code = extractCode(await Clipboard.getStringAsync());
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
onChange(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to the hint below.
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasteReady(false);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// Auto-submit on a complete code, but only once per distinct code so a
|
||||||
|
// rejected code isn't resubmitted on every re-render.
|
||||||
|
useEffect(() => {
|
||||||
|
if (value.length !== 6 || !onComplete) return;
|
||||||
|
if (completedFor.current === value) return;
|
||||||
|
|
||||||
|
completedFor.current = value;
|
||||||
|
onComplete(value);
|
||||||
|
}, [value, onComplete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<InputField
|
||||||
|
label={label}
|
||||||
|
icon={icons.lock}
|
||||||
|
placeholder="123456"
|
||||||
|
value={value}
|
||||||
|
onChangeText={handleChange}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
maxLength={6}
|
||||||
|
// iOS reads codes out of Mail; Android's autofill only covers SMS, so
|
||||||
|
// there the paste chip is the fast path.
|
||||||
|
textContentType="oneTimeCode"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
importantForAutofill="yes"
|
||||||
|
inputStyles="tracking-[8px] text-lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pasteReady && value.length < 6 ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPastePress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
className="self-start mt-2 rounded-full bg-primary-500/10 px-4 py-2"
|
||||||
|
>
|
||||||
|
<Text className="text-primary-500 font-JakartaSemiBold text-sm">
|
||||||
|
Paste code from Gmail
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
+22
-2
@@ -12,6 +12,17 @@ export const clearAuthToken = () => {
|
|||||||
authToken = null;
|
authToken = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Carries the HTTP status so callers can branch on it instead of on text. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const fetchAPI = async (url: string, options?: RequestInit) => {
|
export const fetchAPI = async (url: string, options?: RequestInit) => {
|
||||||
try {
|
try {
|
||||||
const headers = new Headers(options?.headers);
|
const headers = new Headers(options?.headers);
|
||||||
@@ -20,12 +31,21 @@ export const fetchAPI = async (url: string, options?: RequestInit) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, { ...options, headers });
|
const response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
// Every route answers with `{ error }`; keep that text so the UI can show
|
||||||
|
// what actually went wrong instead of guessing from a status code.
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
throw new ApiError(
|
||||||
|
response.status,
|
||||||
|
body?.error ?? `Request failed with status ${response.status}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fetch error:", error);
|
console.error(`Fetch error: ${url}`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-1
@@ -23,9 +23,15 @@ const secret = (): string => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** "Remember me" sessions: long enough that a rider rarely signs in again. */
|
||||||
|
export const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
/** Sessions on a shared or borrowed phone: survives a day, then expires. */
|
||||||
|
export const SHORT_SESSION_TTL_SECONDS = 12 * 60 * 60;
|
||||||
|
|
||||||
export const signJwt = (
|
export const signJwt = (
|
||||||
payload: { sub: string; email: string },
|
payload: { sub: string; email: string },
|
||||||
expiresInSeconds = 30 * 24 * 60 * 60,
|
expiresInSeconds = SESSION_TTL_SECONDS,
|
||||||
): string => {
|
): string => {
|
||||||
const iat = Math.floor(Date.now() / 1000);
|
const iat = Math.floor(Date.now() / 1000);
|
||||||
const body: JwtPayload = { ...payload, iat, exp: iat + expiresInSeconds };
|
const body: JwtPayload = { ...payload, iat, exp: iat + expiresInSeconds };
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
// Shared helpers for the 6-digit email codes used by sign-up verification and
|
||||||
|
// password reset. Both flows store a salted hash keyed by email, so the code
|
||||||
|
// itself only ever lives in the outgoing mail.
|
||||||
|
|
||||||
|
import { createHash, randomInt, timingSafeEqual } from "crypto";
|
||||||
|
|
||||||
|
export const CODE_TTL_MINUTES = 15;
|
||||||
|
|
||||||
|
// A 6-digit code is only 1,000,000 wide, so the attempt cap is what actually
|
||||||
|
// makes it safe to email. Both code tables carry an `attempts` column.
|
||||||
|
export const MAX_CODE_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
export const generateCode = (): string =>
|
||||||
|
String(randomInt(0, 1_000_000)).padStart(6, "0");
|
||||||
|
|
||||||
|
export const hashCode = (email: string, code: string): string =>
|
||||||
|
createHash("sha256").update(`${email}:${code}`).digest("hex");
|
||||||
|
|
||||||
|
export const codeMatches = (
|
||||||
|
storedHash: string,
|
||||||
|
email: string,
|
||||||
|
code: string,
|
||||||
|
): boolean => {
|
||||||
|
const expected = Buffer.from(storedHash, "hex");
|
||||||
|
const actual = Buffer.from(hashCode(email, code), "hex");
|
||||||
|
|
||||||
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gmail and iOS Mail only offer a one-tap "copy code" / keyboard suggestion
|
||||||
|
// when the code leads the subject line and the body opens with a recognised
|
||||||
|
// phrasing. Keep both formats intact when editing this copy.
|
||||||
|
export const verificationEmail = (code: string) => ({
|
||||||
|
subject: `${code} is your Waseel verification code`,
|
||||||
|
text:
|
||||||
|
`${code} is your Waseel verification code.\n\n` +
|
||||||
|
`Welcome to Waseel! Enter this code in the app to finish signing up.\n` +
|
||||||
|
`It expires in ${CODE_TTL_MINUTES} minutes.\n`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resetEmail = (code: string) => ({
|
||||||
|
subject: `${code} is your Waseel password reset code`,
|
||||||
|
text:
|
||||||
|
`${code} is your Waseel password reset code.\n\n` +
|
||||||
|
`We received a request to reset your Waseel password. Enter this code in ` +
|
||||||
|
`the app to choose a new one.\n` +
|
||||||
|
`It expires in ${CODE_TTL_MINUTES} minutes. If you didn't ask for this, ` +
|
||||||
|
`you can ignore this email.\n`,
|
||||||
|
});
|
||||||
+62
-5
@@ -13,8 +13,25 @@ import { setAuthToken, clearAuthToken } from "./fetch";
|
|||||||
|
|
||||||
const TOKEN_KEY = "waseel_auth_token";
|
const TOKEN_KEY = "waseel_auth_token";
|
||||||
const USER_KEY = "waseel_auth_user";
|
const USER_KEY = "waseel_auth_user";
|
||||||
|
const REMEMBERED_EMAIL_KEY = "waseel_remembered_email";
|
||||||
const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address to prefill on the sign-in screen, or null when the last sign-in
|
||||||
|
* cleared "remember me". The token itself is stored either way — what
|
||||||
|
* "remember me" changes is how long the server makes it live.
|
||||||
|
*/
|
||||||
|
export const getRememberedEmail = (): Promise<string | null> =>
|
||||||
|
SecureStore.getItemAsync(REMEMBERED_EMAIL_KEY);
|
||||||
|
|
||||||
|
export const rememberEmail = async (email: string | null): Promise<void> => {
|
||||||
|
if (email) {
|
||||||
|
await SecureStore.setItemAsync(REMEMBERED_EMAIL_KEY, email);
|
||||||
|
} else {
|
||||||
|
await SecureStore.deleteItemAsync(REMEMBERED_EMAIL_KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export type SessionUser = {
|
export type SessionUser = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -37,12 +54,46 @@ type SessionContextValue = {
|
|||||||
|
|
||||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||||
|
|
||||||
|
const BASE64_ALPHABET =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a base64url segment without `atob`.
|
||||||
|
*
|
||||||
|
* Neither React Native 0.74 nor Expo SDK 51 defines a global `atob`, so the
|
||||||
|
* previous implementation threw on every call — which silently expired the
|
||||||
|
* stored session on each launch (see decodeJwtExp). Skipping unknown
|
||||||
|
* characters also makes the missing "=" padding in a JWT a non-issue.
|
||||||
|
*/
|
||||||
|
const decodeBase64Url = (segment: string): string => {
|
||||||
|
const normalized = segment.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
|
||||||
|
let output = "";
|
||||||
|
let buffer = 0;
|
||||||
|
let bits = 0;
|
||||||
|
|
||||||
|
for (const character of normalized) {
|
||||||
|
const value = BASE64_ALPHABET.indexOf(character);
|
||||||
|
|
||||||
|
if (value === -1) continue;
|
||||||
|
|
||||||
|
buffer = (buffer << 6) | value;
|
||||||
|
bits += 6;
|
||||||
|
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
output += String.fromCharCode((buffer >> bits) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
// Mirrors lib/jwt.ts payload decoding (no signature check needed client-side).
|
// Mirrors lib/jwt.ts payload decoding (no signature check needed client-side).
|
||||||
export const decodeJwtExp = (token: string): number | null => {
|
export const decodeJwtExp = (token: string): number | null => {
|
||||||
try {
|
try {
|
||||||
const claims = JSON.parse(
|
const claims = JSON.parse(decodeBase64Url(token.split(".")[1]));
|
||||||
atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
|
|
||||||
);
|
|
||||||
return typeof claims.exp === "number" ? claims.exp : null;
|
return typeof claims.exp === "number" ? claims.exp : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -65,8 +116,14 @@ export const SessionProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
if (!token || !storedUser) return;
|
if (!token || !storedUser) return;
|
||||||
|
|
||||||
const exp = decodeJwtExp(token) ?? 0;
|
const exp = decodeJwtExp(token);
|
||||||
if (exp * 1000 < Date.now()) {
|
|
||||||
|
// Only discard a session we can positively prove is expired. Treating
|
||||||
|
// an unreadable token as expired is what made every launch sign the
|
||||||
|
// user back out; if it really is bad, the next request gets a 401.
|
||||||
|
if (exp === null) {
|
||||||
|
console.warn("[SESSION_RESTORE]: could not read token expiry");
|
||||||
|
} else if (exp * 1000 < Date.now()) {
|
||||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||||
await SecureStore.deleteItemAsync(USER_KEY);
|
await SecureStore.deleteItemAsync(USER_KEY);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+2
-1
@@ -24,8 +24,9 @@ export const toProfile = (row: UserRow): UserProfile => ({
|
|||||||
|
|
||||||
export const issueSession = (
|
export const issueSession = (
|
||||||
row: UserRow,
|
row: UserRow,
|
||||||
|
expiresInSeconds?: number,
|
||||||
): { token: string; user: UserProfile } => ({
|
): { token: string; user: UserProfile } => ({
|
||||||
token: signJwt({ sub: row.id, email: row.email }),
|
token: signJwt({ sub: row.id, email: row.email }, expiresInSeconds),
|
||||||
user: toProfile(row),
|
user: toProfile(row),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Generated
+9
@@ -27,6 +27,7 @@
|
|||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"expo": "~51.0.28",
|
"expo": "~51.0.28",
|
||||||
"expo-auth-session": "~5.5.2",
|
"expo-auth-session": "~5.5.2",
|
||||||
|
"expo-clipboard": "~6.0.3",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-crypto": "^57.0.1",
|
"expo-crypto": "^57.0.1",
|
||||||
"expo-font": "~12.0.9",
|
"expo-font": "~12.0.9",
|
||||||
@@ -9347,6 +9348,14 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-clipboard": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-RIKDsuHkYfaspifbFpVC8sBVFKR05L7Pj7mU2/XkbrW9m01OBNvdpGraXEMsTFCx97xMGsZpEw9pPquL4j4xVg==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "16.0.2",
|
"version": "16.0.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"expo": "~51.0.28",
|
"expo": "~51.0.28",
|
||||||
"expo-auth-session": "~5.5.2",
|
"expo-auth-session": "~5.5.2",
|
||||||
|
"expo-clipboard": "~6.0.3",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-crypto": "^57.0.1",
|
"expo-crypto": "^57.0.1",
|
||||||
"expo-font": "~12.0.9",
|
"expo-font": "~12.0.9",
|
||||||
|
|||||||
Reference in New Issue
Block a user