diff --git a/.env.example b/.env.example index d4e9cdd..747ef95 100644 --- a/.env.example +++ b/.env.example @@ -14,11 +14,13 @@ EXPO_PUBLIC_GOOGLE_AUTH_WEB_CLIENT_ID=XXXXXXXX.apps.googleusercontent.com EXPO_PUBLIC_GOOGLE_AUTH_IOS_CLIENT_ID=XXXXXXXX.apps.googleusercontent.com EXPO_PUBLIC_GOOGLE_AUTH_ANDROID_CLIENT_ID=XXXXXXXX.apps.googleusercontent.com -# gmail api (oauth refresh token with gmail.send scope; leave blank to log codes to server console) -GMAIL_CLIENT_ID= -GMAIL_CLIENT_SECRET= -GMAIL_REFRESH_TOKEN= -GMAIL_FROM="Waseel " +# gmail smtp (app password, needs 2-step verification; leave blank to log codes to server console) +# host/port are optional -- default to smtp.gmail.com:465, use 587 if 465 is blocked +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_USER=you@gmail.com +SMTP_PASS=your-16-char-app-password +SMTP_FROM="Waseel " # geoapify api key EXPO_PUBLIC_GEOAPIFY_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX diff --git a/app/(api)/admin/rides+api.ts b/app/(api)/admin/rides+api.ts index 29fb019..be1ac33 100644 --- a/app/(api)/admin/rides+api.ts +++ b/app/(api)/admin/rides+api.ts @@ -1,5 +1,28 @@ import { requireOwner, withCors, preflight } from "@/lib/admin"; -import { sql } from "@/lib/db"; +import { query, type SqlValue } from "@/lib/db"; + +const PAGE_SIZE = 25; + +const SELECT_RIDES = ` + SELECT + r.ride_id, + r.origin_address, + r.destination_address, + r.ride_time, + r.fare_price, + r.payment_status, + r.created_at, + u.id AS user_id, + u.email AS user_email, + json_build_object( + 'driver_id', d.id, + 'name', d.first_name || ' ' || d.last_name, + 'rating', d.rating + ) AS driver + FROM rides r + INNER JOIN drivers d ON d.id = r.driver_id + INNER JOIN users u ON u.id = r.user_id +`; export async function OPTIONS() { return preflight(); @@ -12,55 +35,52 @@ export async function GET(request: Request) { try { const url = new URL(request.url); const status = url.searchParams.get("status")?.trim().toLowerCase() ?? ""; + const q = url.searchParams.get("q")?.trim() ?? ""; + const page = Math.max(1, Number(url.searchParams.get("page")) || 1); - const rows = status - ? await sql` - SELECT - r.ride_id, - r.origin_address, - r.destination_address, - r.ride_time, - r.fare_price, - r.payment_status, - r.created_at, - u.id AS user_id, - u.email AS user_email, - json_build_object( - 'driver_id', d.id, - 'name', d.first_name || ' ' || d.last_name, - 'rating', d.rating - ) AS driver - FROM rides r - INNER JOIN drivers d ON d.id = r.driver_id - INNER JOIN users u ON u.id = r.user_id - WHERE LOWER(r.payment_status) = ${status} - ORDER BY r.created_at DESC - LIMIT 500 - ` - : await sql` - SELECT - r.ride_id, - r.origin_address, - r.destination_address, - r.ride_time, - r.fare_price, - r.payment_status, - r.created_at, - u.id AS user_id, - u.email AS user_email, - json_build_object( - 'driver_id', d.id, - 'name', d.first_name || ' ' || d.last_name, - 'rating', d.rating - ) AS driver - FROM rides r - INNER JOIN drivers d ON d.id = r.driver_id - INNER JOIN users u ON u.id = r.user_id - ORDER BY r.created_at DESC - LIMIT 500 - `; + const conds: string[] = []; + const params: SqlValue[] = []; - return withCors(Response.json({ data: rows })); + if (status) { + params.push(status); + conds.push(`LOWER(r.payment_status) = $${params.length}`); + } + + if (q) { + params.push(`%${q}%`); + const n = params.length; + conds.push( + `(u.email ILIKE $${n} OR (d.first_name || ' ' || d.last_name) ILIKE $${n} OR ` + + `r.origin_address ILIKE $${n} OR r.destination_address ILIKE $${n})`, + ); + } + + const where = conds.length ? ` WHERE ${conds.join(" AND ")}` : ""; + + const [{ count }] = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count + FROM rides r + INNER JOIN drivers d ON d.id = r.driver_id + INNER JOIN users u ON u.id = r.user_id${where}`, + params, + ); + + const rows = await query( + `${SELECT_RIDES}${where} + ORDER BY r.created_at DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, + [...params, PAGE_SIZE, (page - 1) * PAGE_SIZE], + ); + + return withCors( + Response.json({ + data: rows, + total: count, + page, + pageSize: PAGE_SIZE, + pages: Math.max(1, Math.ceil(count / PAGE_SIZE)), + }), + ); } catch (error) { console.error("[ADMIN_RIDES]: ", error); return withCors( diff --git a/app/(api)/admin/stats+api.ts b/app/(api)/admin/stats+api.ts index 5f94846..67ccf2d 100644 --- a/app/(api)/admin/stats+api.ts +++ b/app/(api)/admin/stats+api.ts @@ -15,12 +15,22 @@ export async function GET(request: Request) { drivers: number; rides: number; revenue: number; + rides_today: number; + avg_fare: number; + pending_count: number; + pending_revenue: number; + new_users_7d: number; }>` SELECT (SELECT COUNT(*)::int FROM users) AS users, (SELECT COUNT(*)::int FROM drivers) AS drivers, (SELECT COUNT(*)::int FROM rides) AS rides, - (SELECT COALESCE(SUM(fare_price), 0)::int FROM rides WHERE payment_status = 'paid') AS revenue + (SELECT COALESCE(SUM(fare_price), 0)::int FROM rides WHERE payment_status = 'paid') AS revenue, + (SELECT COUNT(*)::int FROM rides WHERE created_at >= CURRENT_DATE) AS rides_today, + (SELECT COALESCE(ROUND(AVG(fare_price)), 0)::int FROM rides WHERE payment_status = 'paid') AS avg_fare, + (SELECT COUNT(*)::int FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_count, + (SELECT COALESCE(SUM(fare_price), 0)::int FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_revenue, + (SELECT COUNT(*)::int FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days') AS new_users_7d `; const trend = await sql<{ day: string; rides: number; revenue: number }>` diff --git a/app/(api)/admin/users/[id]+api.ts b/app/(api)/admin/users/[id]+api.ts index c2dd4a0..3f22f3b 100644 --- a/app/(api)/admin/users/[id]+api.ts +++ b/app/(api)/admin/users/[id]+api.ts @@ -58,3 +58,37 @@ export async function PATCH(request: Request, { id }: { id: string }) { ); } } + +export async function DELETE(request: Request, { id }: { id: string }) { + const auth = await requireOwner(request); + if ("error" in auth) return withCors(auth.error); + + if (id === auth.userId) { + return withCors( + Response.json( + { error: "You cannot delete your own account." }, + { status: 400 }, + ), + ); + } + + try { + // rides.user_id is ON DELETE CASCADE, so a rider's rides go with them. + const rows = await sql<{ id: string }>` + DELETE FROM users WHERE id = ${id} RETURNING id + `; + + if (!rows[0]) { + return withCors( + Response.json({ error: "User not found." }, { status: 404 }), + ); + } + + return withCors(Response.json({ data: rows[0] })); + } catch (error) { + console.error("[ADMIN_USER_DELETE]: ", error); + return withCors( + Response.json({ error: "Internal Server Error" }, { status: 500 }), + ); + } +} diff --git a/app/(api)/auth/forgot-password+api.ts b/app/(api)/auth/forgot-password+api.ts new file mode 100644 index 0000000..5569eb3 --- /dev/null +++ b/app/(api)/auth/forgot-password+api.ts @@ -0,0 +1,61 @@ +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"); + +export async function POST(req: Request) { + const { email } = await req.json(); + + if (!email?.trim()) { + return Response.json({ error: "Email is required." }, { status: 400 }); + } + + const normalized = email.trim().toLowerCase(); + + try { + const users = await sql<{ id: string }>` + SELECT id FROM users WHERE email = ${normalized} + `; + + // Don't reveal whether the address is registered: always answer the same. + if (!users[0]) { + return Response.json({ data: { sent: false } }); + } + + const code = String(randomInt(0, 1_000_000)).padStart(6, "0"); + + await sql` + INSERT INTO password_reset_codes (email, code_hash, expires_at) + VALUES ( + ${normalized}, + ${hashCode(normalized, code)}, + CURRENT_TIMESTAMP + INTERVAL '15 minutes' + ) + ON CONFLICT (email) DO UPDATE SET + code_hash = EXCLUDED.code_hash, + expires_at = EXCLUDED.expires_at, + 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.`, + ); + + return Response.json({ + data: { + sent: delivered, + // Without SMTP configured there is nothing to receive, so surface the + // code to keep the reset flow usable on a self-hosted box. + ...(delivered ? {} : { devCode: code }), + }, + }); + } catch (error) { + console.error("[FORGOT_PASSWORD]: ", error); + return Response.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/app/(api)/auth/register+api.ts b/app/(api)/auth/register+api.ts index 4d87977..9e302ca 100644 --- a/app/(api)/auth/register+api.ts +++ b/app/(api)/auth/register+api.ts @@ -14,7 +14,7 @@ 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 } = await req.json(); + const { name, email, phone, password, role } = await req.json(); if (!name?.trim() || !email?.trim() || !password) { return Response.json( @@ -23,6 +23,8 @@ export async function POST(req: Request) { ); } + const normalizedRole = role === "driver" ? "driver" : "rider"; + if (typeof password !== "string" || password.length < 8) { return Response.json( { error: "Password must be at least 8 characters." }, @@ -44,18 +46,20 @@ export async function POST(req: Request) { // Unverified rows may be re-registered (e.g. the first mail never arrived). await sql` - INSERT INTO users (name, email, phone, password_hash, email_verified) + INSERT INTO users (name, email, phone, password_hash, email_verified, role) VALUES ( ${name.trim()}, ${email.trim().toLowerCase()}, ${phone ? normalizePhone(phone) : null}, ${hashPassword(password)}, - FALSE + FALSE, + ${normalizedRole} ) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, phone = COALESCE(EXCLUDED.phone, users.phone), - password_hash = EXCLUDED.password_hash + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role `; const code = String(randomInt(0, 1_000_000)).padStart(6, "0"); @@ -73,13 +77,23 @@ export async function POST(req: Request) { attempts = 0 `; - await sendEmail( + 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.`, ); - return Response.json({ data: { sent: true } }, { status: 201 }); + return Response.json( + { + data: { + sent: delivered, + // Without SMTP/Gmail configured there is nothing to receive, so + // surface the code to keep self-hosted sign-up usable. + ...(delivered ? {} : { devCode: code }), + }, + }, + { status: 201 }, + ); } catch (error) { console.error("[REGISTER]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); diff --git a/app/(api)/auth/reset-password+api.ts b/app/(api)/auth/reset-password+api.ts new file mode 100644 index 0000000..de16d28 --- /dev/null +++ b/app/(api)/auth/reset-password+api.ts @@ -0,0 +1,79 @@ +import { createHash } from "crypto"; + +import { sql } from "@/lib/db"; +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(); + + if (!email?.trim() || !/^\d{6}$/.test(code ?? "")) { + return Response.json( + { error: "Email and a 6-digit code are required." }, + { status: 400 }, + ); + } + + if (typeof password !== "string" || password.length < 8) { + return Response.json( + { error: "Password must be at least 8 characters." }, + { status: 400 }, + ); + } + + 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 + `; + + if (!valid[0]) { + await sql` + UPDATE password_reset_codes SET attempts = attempts + 1 + WHERE email = ${normalized} + `; + + return Response.json( + { error: "Invalid or expired reset code." }, + { status: 400 }, + ); + } + + // A successful reset also proves control of the mailbox, so verify it too. + const rows = await sql<{ + id: string; + name: string; + email: string; + role: string | null; + }>` + UPDATE users + SET password_hash = ${hashPassword(password)}, email_verified = TRUE + WHERE email = ${normalized} + RETURNING id, name, email, role + `; + + const user = rows[0]; + + if (!user) { + return Response.json({ error: "User not found." }, { status: 404 }); + } + + await sql`DELETE FROM password_reset_codes WHERE email = ${normalized}`; + + const session = issueSession(user); + + return Response.json({ + data: { token: session.token, user: toProfile(user) }, + }); + } catch (error) { + console.error("[RESET_PASSWORD]: ", error); + return Response.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/app/(auth)/sign-in.tsx b/app/(auth)/sign-in.tsx index 1666b0a..68041ca 100644 --- a/app/(auth)/sign-in.tsx +++ b/app/(auth)/sign-in.tsx @@ -1,6 +1,16 @@ import { Link, useRouter } from "expo-router"; import { useCallback, useState } from "react"; -import { Alert, Image, ScrollView, Text, View } from "react-native"; +import { + Alert, + Image, + KeyboardAvoidingView, + Platform, + ScrollView, + Text, + TouchableOpacity, + View, +} from "react-native"; +import ReactNativeModal from "react-native-modal"; import { CustomButton } from "@/components/custom-button"; import { InputField } from "@/components/input-field"; @@ -17,6 +27,101 @@ const SignIn = () => { password: "", }); + // Forgot-password flow: "request" collects the email, "reset" collects the + // emailed code and a new password. + const [reset, setReset] = useState({ + state: "closed" as "closed" | "request" | "reset", + email: "", + code: "", + password: "", + devCode: "", + error: "", + busy: false, + }); + + const openReset = () => + setReset({ + state: "request", + email: form.email, + code: "", + password: "", + devCode: "", + error: "", + busy: false, + }); + + const closeReset = () => + setReset((prev) => ({ ...prev, state: "closed" })); + + const onRequestReset = async () => { + if (!reset.email.trim()) { + setReset((prev) => ({ ...prev, error: "Enter your email address." })); + return; + } + + setReset((prev) => ({ ...prev, busy: true, error: "" })); + + try { + const response = await fetchAPI("/(api)/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: reset.email.trim() }), + }); + + setReset((prev) => ({ + ...prev, + state: "reset", + busy: false, + devCode: + (response as { data?: { devCode?: string } })?.data?.devCode ?? "", + })); + } catch { + // The endpoint hides whether the email exists, so move on regardless. + setReset((prev) => ({ ...prev, state: "reset", busy: false })); + } + }; + + const onSubmitReset = async () => { + if (!/^\d{6}$/.test(reset.code)) { + setReset((prev) => ({ ...prev, error: "Enter the 6-digit code." })); + return; + } + + if (reset.password.length < 8) { + setReset((prev) => ({ + ...prev, + error: "Password must be at least 8 characters.", + })); + return; + } + + setReset((prev) => ({ ...prev, busy: true, error: "" })); + + try { + const response = await fetchAPI("/(api)/auth/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: reset.email.trim(), + code: reset.code, + password: reset.password, + }), + }); + + await setSession(response.data); + setReset((prev) => ({ ...prev, state: "closed", busy: false })); + router.replace("/"); + } catch (err: any) { + setReset((prev) => ({ + ...prev, + busy: false, + error: String(err?.message ?? "").includes("400") + ? "Invalid or expired reset code." + : "Could not reset your password. Please try again.", + })); + } + }; + const onSignInPress = useCallback(async () => { try { const response = await fetchAPI("/(api)/auth/login", { @@ -47,7 +152,17 @@ const SignIn = () => { }, [isLoaded, form.email, form.password, setSession, router]); return ( - + + { className="mt-6" /> + + + Forgot password? + + + { Sign up + + + + + Reset password + + + + Enter your email and we'll send you a 6-digit reset code. + + + + setReset((prev) => ({ ...prev, email })) + } + /> + + {reset.error ? ( + {reset.error} + ) : null} + + + + + + + + + Enter new password + + + + We've sent a reset code to {reset.email} + + + {reset.devCode ? ( + + + Email delivery is not configured on this server. Your reset + code is {reset.devCode} + + + ) : null} + + setReset((prev) => ({ ...prev, code }))} + /> + + + setReset((prev) => ({ ...prev, password })) + } + /> + + {reset.error ? ( + {reset.error} + ) : null} + + + + + ); }; diff --git a/app/(auth)/sign-up.tsx b/app/(auth)/sign-up.tsx index d275a32..33f7ba5 100644 --- a/app/(auth)/sign-up.tsx +++ b/app/(auth)/sign-up.tsx @@ -1,6 +1,15 @@ import { Link, router } from "expo-router"; import { useState } from "react"; -import { Alert, Image, ScrollView, Text, View } from "react-native"; +import { + Alert, + Image, + KeyboardAvoidingView, + Platform, + ScrollView, + Text, + TouchableOpacity, + View, +} from "react-native"; import ReactNativeModal from "react-native-modal"; import { CustomButton } from "@/components/custom-button"; @@ -10,9 +19,25 @@ import { icons, images } from "@/constants"; import { fetchAPI } from "@/lib/fetch"; import { useSession } from "@/lib/session"; +const ROLES = [ + { + value: "rider", + title: "I need a ride", + description: "Book rides and get where you're going", + }, + { + value: "driver", + title: "I want to drive", + description: "Offer rides and earn money with your car", + }, +] as const; + +type Role = (typeof ROLES)[number]["value"]; + const SignUp = () => { const { setSession } = useSession(); + const [role, setRole] = useState("rider"); const [form, setForm] = useState({ name: "", email: "", @@ -24,6 +49,7 @@ const SignUp = () => { state: "default", error: "", code: "", + devCode: "", }); const onSignUpPress = async () => { @@ -44,7 +70,7 @@ const SignUp = () => { } try { - await fetchAPI("/(api)/auth/register", { + const response = await fetchAPI("/(api)/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -52,12 +78,15 @@ const SignUp = () => { email: form.email, phone: form.phone.trim(), password: form.password, + role, }), }); setVerification((prevVerification) => ({ ...prevVerification, state: "pending", + devCode: + (response as { data?: { devCode?: string } })?.data?.devCode ?? "", })); setForm((prevForm) => ({ @@ -98,7 +127,17 @@ const SignUp = () => { }; return ( - + + { + + How will you use Waseel? + + + {ROLES.map((option) => { + const selected = role === option.value; + return ( + setRole(option.value)} + activeOpacity={0.8} + className={`flex-1 justify-center rounded-2xl border p-4 ${ + selected + ? "border-primary-500 bg-primary-500/10" + : "border-neutral-100 bg-neutral-100" + }`} + > + {`${option.title} + + {option.title} + + + {option.description} + + + ); + })} + + { We've sent a verification code to {form.email} + {verification.devCode ? ( + + + Email delivery is not configured on this server. Your + verification code is{" "} + {verification.devCode} + + + ) : null} + { + ); }; diff --git a/components/oauth.tsx b/components/oauth.tsx index cceb5b7..2ea3856 100644 --- a/components/oauth.tsx +++ b/components/oauth.tsx @@ -14,12 +14,36 @@ type OAuthProps = { }; export const OAuth = ({ title }: OAuthProps) => { + const clientId = process.env.EXPO_PUBLIC_GOOGLE_AUTH_WEB_CLIENT_ID; + const iosClientId = process.env.EXPO_PUBLIC_GOOGLE_AUTH_IOS_CLIENT_ID; + const androidClientId = + process.env.EXPO_PUBLIC_GOOGLE_AUTH_ANDROID_CLIENT_ID; + + const isConfigured = Boolean( + clientId && (androidClientId || iosClientId), + ); + + if (!isConfigured) return null; + + return ; +}; + +function GoogleOAuth({ + title, + clientId, + iosClientId, + androidClientId, +}: OAuthProps & { + clientId: string; + iosClientId?: string; + androidClientId?: string; +}) { const { setSession } = useSession(); const [request, response, promptAsync] = Google.useIdTokenAuthRequest({ - clientId: process.env.EXPO_PUBLIC_GOOGLE_AUTH_WEB_CLIENT_ID, - iosClientId: process.env.EXPO_PUBLIC_GOOGLE_AUTH_IOS_CLIENT_ID, - androidClientId: process.env.EXPO_PUBLIC_GOOGLE_AUTH_ANDROID_CLIENT_ID, + clientId, + iosClientId, + androidClientId, }); useEffect(() => { @@ -78,4 +102,4 @@ export const OAuth = ({ title }: OAuthProps) => { /> ); -}; +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 6a2a2df..84c59ad 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -176,6 +176,25 @@ select { margin: 10px 0; } +.muted { + color: var(--muted); + font-size: 13px; +} + +.card .sub { + color: var(--muted); + font-size: 12px; + margin-top: 4px; +} + +.card.warn .value { + color: var(--danger); +} + +.pager { + margin-top: 14px; +} + .login-wrap { margin: auto; width: 340px; diff --git a/dashboard/src/pages/Rides.tsx b/dashboard/src/pages/Rides.tsx index ce35c9c..4522796 100644 --- a/dashboard/src/pages/Rides.tsx +++ b/dashboard/src/pages/Rides.tsx @@ -13,76 +13,148 @@ type Ride = { driver: { driver_id: number; name: string; rating: number }; }; +type RidesResponse = { + data: Ride[]; + total: number; + page: number; + pageSize: number; + pages: number; +}; + +const fmt = (n: number) => n.toLocaleString(); + export default function Rides() { const [rides, setRides] = useState([]); + const [meta, setMeta] = useState({ total: 0, page: 1, pages: 1 }); const [status, setStatus] = useState(""); + const [query, setQuery] = useState(""); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const load = useCallback(async (status: string) => { + const load = useCallback(async (status: string, q: string, page: number) => { + setLoading(true); try { - const res = await api<{ data: Ride[] }>( - `/admin/rides${status ? `?status=${encodeURIComponent(status)}` : ""}`, + const params = new URLSearchParams(); + if (status) params.set("status", status); + if (q) params.set("q", q); + if (page > 1) params.set("page", String(page)); + const res = await api( + `/admin/rides${params.size ? `?${params}` : ""}`, ); setRides(res.data); + setMeta({ total: res.total, page: res.page, pages: res.pages }); setError(null); } catch (e) { setError((e as Error).message); + } finally { + setLoading(false); } }, []); useEffect(() => { - load(status); - }, [load, status]); + load(status, query, page); + }, [load, status, page]); + + const search = () => { + setPage(1); + load(status, query, 1); + }; return ( <>

Rides & payments

- setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && search()} + /> + + + + {fmt(meta.total)} ride{meta.total === 1 ? "" : "s"} +
+ {error &&
{error}
} - - - - - - - - - - - - - - - {rides.map((r) => ( - - - - - - - - - + {!loading && !error && rides.length === 0 && ( +
No rides match the current filters.
+ )} + + {rides.length > 0 && ( +
IDRouteUserDriverTime (min)FarePaymentDate
{r.ride_id} - {r.origin_address} → {r.destination_address} - {r.user_email}{r.driver.name}{r.ride_time}{r.fare_price.toLocaleString()} - - {r.payment_status} - - {new Date(r.created_at).toLocaleString()}
+ + + + + + + + + + - ))} - -
IDRouteUserDriverTime (min)FarePaymentDate
+ + + {rides.map((r) => ( + + {r.ride_id} + + {r.origin_address} → {r.destination_address} + + {r.user_email} + {r.driver.name} + {r.ride_time} + {fmt(r.fare_price)} + + + {r.payment_status} + + + {new Date(r.created_at).toLocaleString()} + + ))} + + + )} + +
+ + + Page {meta.page} of {meta.pages} + + +
); } diff --git a/dashboard/src/pages/Stats.tsx b/dashboard/src/pages/Stats.tsx index 8d8b33d..dea87a1 100644 --- a/dashboard/src/pages/Stats.tsx +++ b/dashboard/src/pages/Stats.tsx @@ -2,14 +2,27 @@ import { useEffect, useState } from "react"; import { api } from "../lib/api"; type Stats = { - totals: { users: number; drivers: number; rides: number; revenue: number }; + totals: { + users: number; + drivers: number; + rides: number; + revenue: number; + rides_today: number; + avg_fare: number; + pending_count: number; + pending_revenue: number; + new_users_7d: number; + }; trend: { day: string; rides: number; revenue: number }[]; topDrivers: { driver_id: number; name: string; rides: number; revenue: number }[]; }; +const fmt = (n: number) => n.toLocaleString(); + export default function Stats() { const [stats, setStats] = useState(null); const [error, setError] = useState(null); + const [metric, setMetric] = useState<"rides" | "revenue">("rides"); useEffect(() => { api<{ data: Stats }>("/admin/stats") @@ -18,9 +31,10 @@ export default function Stats() { }, []); if (error) return
{error}
; - if (!stats) return
Loading…
; + if (!stats) return
Loading…
; - const maxRides = Math.max(1, ...stats.trend.map((d) => d.rides)); + const t = stats.totals; + const max = Math.max(1, ...stats.trend.map((d) => d[metric])); return ( <> @@ -28,30 +42,44 @@ export default function Stats() {
Users
-
{stats.totals.users}
+
{fmt(t.users)}
+
+{fmt(t.new_users_7d)} this week
Drivers
-
{stats.totals.drivers}
+
{fmt(t.drivers)}
Rides
-
{stats.totals.rides}
+
{fmt(t.rides)}
+
{fmt(t.rides_today)} today
Revenue (paid)
-
{stats.totals.revenue.toLocaleString()}
+
{fmt(t.revenue)}
+
avg fare {fmt(t.avg_fare)}
+
+
0 ? "warn" : ""}`}> +
Pending payments
+
{fmt(t.pending_count)}
+
{fmt(t.pending_revenue)} outstanding
-

Rides — last 14 days

+

Last 14 days

+
+ +
{stats.trend.map((d) => (
{d.day.slice(5)}
@@ -71,8 +99,8 @@ export default function Stats() { {stats.topDrivers.map((d) => ( {d.name} - {d.rides} - {d.revenue.toLocaleString()} + {fmt(d.rides)} + {fmt(d.revenue)} ))} diff --git a/dashboard/src/pages/Users.tsx b/dashboard/src/pages/Users.tsx index fbaa512..ea255ae 100644 --- a/dashboard/src/pages/Users.tsx +++ b/dashboard/src/pages/Users.tsx @@ -58,6 +58,23 @@ export default function Users() { } }; + const remove = async (u: User) => { + if ( + !window.confirm( + `Delete ${u.name} (${u.email})? This also removes their rides and cannot be undone.`, + ) + ) { + return; + } + + try { + await api(`/admin/users/${u.id}`, { method: "DELETE" }); + await load(query); + } catch (e) { + setError((e as Error).message); + } + }; + return ( <>

Users

@@ -110,10 +127,13 @@ export default function Users() { {u.rides} {new Date(u.created_at).toLocaleDateString()} - + + ))} diff --git a/environment.d.ts b/environment.d.ts index dfc7813..10a8534 100644 --- a/environment.d.ts +++ b/environment.d.ts @@ -19,11 +19,12 @@ declare global { EXPO_PUBLIC_GOOGLE_AUTH_IOS_CLIENT_ID: string; EXPO_PUBLIC_GOOGLE_AUTH_ANDROID_CLIENT_ID: string; - // gmail api - GMAIL_CLIENT_ID: string; - GMAIL_CLIENT_SECRET: string; - GMAIL_REFRESH_TOKEN: string; - GMAIL_FROM: string; + // gmail smtp + SMTP_HOST: string; + SMTP_PORT: string; + SMTP_USER: string; + SMTP_PASS: string; + SMTP_FROM: string; // geoapify api key EXPO_PUBLIC_GEOAPIFY_API_KEY: string; diff --git a/lib/db.ts b/lib/db.ts index bf6fa07..0515fe7 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -7,7 +7,7 @@ const pool = new Pool({ connectionTimeoutMillis: 10_000, }); -type SqlValue = string | number | boolean | null | Date; +export type SqlValue = string | number | boolean | null | Date; export async function sql( strings: TemplateStringsArray, @@ -24,6 +24,14 @@ export async function sql( return result.rows; } +export async function query( + text: string, + values: SqlValue[] = [], +): Promise { + const result = await pool.query(text, values); + return result.rows; +} + export async function transaction( callback: ( tx: ( diff --git a/lib/mailer.ts b/lib/mailer.ts index af60d9a..5222876 100644 --- a/lib/mailer.ts +++ b/lib/mailer.ts @@ -1,96 +1,66 @@ -// Sends transactional email through the Gmail API using an OAuth2 refresh -// token (no third-party email service needed on a self-hosted box). +// Sends transactional email through Gmail SMTP using an App Password. // // Setup (one-time): -// 1. Google Cloud console -> enable Gmail API, create an OAuth client. -// 2. Generate a refresh token with scope -// https://www.googleapis.com/auth/gmail.send -// 3. Set GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN, GMAIL_FROM. +// 1. Google account -> Security -> 2-Step Verification -> enable. +// 2. Create an App Password (myaccount.google.com/apppasswords). +// 3. Set SMTP_USER, SMTP_PASS and optionally SMTP_FROM in .env. -const TOKEN_URL = "https://oauth2.googleapis.com/token"; -const SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"; +import nodemailer from "nodemailer"; -let cachedAccessToken: { token: string; expiresAt: number } | null = null; +// Google shows the App Password in "abcd efgh ijkl mnop" form; the spaces are +// presentation only and must not reach the AUTH exchange. +const getPassword = (): string | undefined => + process.env.SMTP_PASS?.replace(/\s+/g, ""); -const getAccessToken = async (): Promise => { - const clientId = process.env.GMAIL_CLIENT_ID; - const clientSecret = process.env.GMAIL_CLIENT_SECRET; - const refreshToken = process.env.GMAIL_REFRESH_TOKEN; +export const isMailConfigured = (): boolean => + Boolean(process.env.SMTP_USER && getPassword()); - if (!clientId || !clientSecret || !refreshToken) return null; +let transporter: nodemailer.Transporter | null = null; - if (cachedAccessToken && cachedAccessToken.expiresAt > Date.now() + 60_000) { - return cachedAccessToken.token; +const getTransporter = (): nodemailer.Transporter => { + if (!transporter) { + const port = Number(process.env.SMTP_PORT) || 465; + + transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST ?? "smtp.gmail.com", + port, + // 465 is implicit TLS; 587 starts plaintext and upgrades via STARTTLS. + secure: port === 465, + auth: { + user: process.env.SMTP_USER, + pass: getPassword(), + }, + // Without these a stalled connection blocks the request forever, which + // hangs sign-up rather than falling back to the logged code below. + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 20_000, + }); } - - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - grant_type: "refresh_token", - }), - }); - - if (!response.ok) { - throw new Error(`Gmail token exchange failed: ${response.status}`); - } - - const data = (await response.json()) as { - access_token: string; - expires_in: number; - }; - - cachedAccessToken = { - token: data.access_token, - expiresAt: Date.now() + data.expires_in * 1000, - }; - - return cachedAccessToken.token; + return transporter; }; export const sendEmail = async ( to: string, subject: string, text: string, -): Promise => { - const accessToken = await getAccessToken(); - if (!accessToken) { +): Promise => { + if (!isMailConfigured()) { // Not configured: fall back to the server log so development still works. console.log(`[MAIL to=${to}] ${subject}\n${text}`); - return; + return false; } - const from = process.env.GMAIL_FROM; - if (!from) throw new Error("Missing GMAIL_FROM."); + const from = process.env.SMTP_FROM ?? process.env.SMTP_USER!; - const mime = [ - `From: ${from}`, - `To: ${to}`, - `Subject: ${subject}`, - "Content-Type: text/plain; charset=UTF-8", - "", - text, - ].join("\r\n"); - - const response = await fetch(SEND_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - raw: Buffer.from(mime) - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""), - }), - }); - - if (!response.ok) { - throw new Error(`Gmail send failed: ${response.status}`); + try { + await getTransporter().sendMail({ from, to, subject, text }); + return true; + } catch (error) { + // Delivery is best-effort: report the failure and let the caller surface + // the code another way instead of failing the whole request. + console.error(`[MAIL to=${to}] send failed:`, error); + console.log(`[MAIL to=${to}] ${subject}\n${text}`); + return false; } }; diff --git a/package-lock.json b/package-lock.json index 6c75572..69e45a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "expo-system-ui": "~3.0.7", "expo-web-browser": "~13.0.3", "nativewind": "^2.0.11", + "nodemailer": "^9.0.5", "pg": "^8.23.0", "prettier": "^3.3.3", "react": "18.2.0", @@ -58,6 +59,7 @@ "devDependencies": { "@babel/core": "^7.20.0", "@types/jest": "^29.5.12", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.23.1", "@types/react": "~18.2.45", "@types/react-test-renderer": "^18.0.7", @@ -5950,6 +5952,15 @@ "version": "8.3.0", "license": "MIT" }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "dev": true, @@ -14188,6 +14199,14 @@ "url": "https://github.com/sponsors/antelle" } }, + "node_modules/nodemailer": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "license": "MIT", diff --git a/package.json b/package.json index 95cd740..dd20fb6 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "expo-system-ui": "~3.0.7", "expo-web-browser": "~13.0.3", "nativewind": "^2.0.11", + "nodemailer": "^9.0.5", "pg": "^8.23.0", "prettier": "^3.3.3", "react": "18.2.0", @@ -109,6 +110,7 @@ "devDependencies": { "@babel/core": "^7.20.0", "@types/jest": "^29.5.12", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.23.1", "@types/react": "~18.2.45", "@types/react-test-renderer": "^18.0.7", diff --git a/scripts/seed-db.mjs b/scripts/seed-db.mjs index a457838..6e07048 100644 --- a/scripts/seed-db.mjs +++ b/scripts/seed-db.mjs @@ -92,6 +92,13 @@ await sql`CREATE TABLE IF NOT EXISTS email_verification_codes ( expires_at TIMESTAMP NOT NULL )`; +await sql`CREATE TABLE IF NOT EXISTS password_reset_codes ( + email VARCHAR(255) PRIMARY KEY, + code_hash TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMP NOT NULL +)`; + await sql`CREATE TABLE IF NOT EXISTS drivers ( id SERIAL PRIMARY KEY, first_name VARCHAR(100) NOT NULL,