Fix SMTP delivery, add password reset and user deletion
SMTP: - Add connection/greeting/socket timeouts so a stalled Gmail connection no longer hangs sign-up - Wrap sendMail in try/catch and fall back to logging the code - Derive secure from port (465 implicit TLS vs 587 STARTTLS) - Strip whitespace from the Gmail app password - Document SMTP_HOST/SMTP_PORT in .env.example and environment.d.ts Password reset (new): - POST /(api)/auth/forgot-password emails a 6-digit code and does not reveal whether the address is registered - POST /(api)/auth/reset-password validates the code, sets the new password, verifies the email, and signs the user in - password_reset_codes table added to seed-db.mjs - "Forgot password?" flow on the mobile sign-in screen User deletion (new): - DELETE /(api)/admin/users/[id], owner-only, blocks self-deletion - Delete button with confirmation on the dashboard Users page Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a0b297285a
commit
eceb6b45d5
@@ -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;
|
||||
|
||||
+115
-43
@@ -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<Ride[]>([]);
|
||||
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<string | null>(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<RidesResponse>(
|
||||
`/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 (
|
||||
<>
|
||||
<h2>Rides & payments</h2>
|
||||
<div className="toolbar">
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<input
|
||||
placeholder="Search email, driver or address…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||
/>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">All payments</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="unpaid">Unpaid</option>
|
||||
</select>
|
||||
<button className="secondary" onClick={search}>
|
||||
Search
|
||||
</button>
|
||||
<span className="muted">
|
||||
{fmt(meta.total)} ride{meta.total === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Route</th>
|
||||
<th>User</th>
|
||||
<th>Driver</th>
|
||||
<th>Time (min)</th>
|
||||
<th>Fare</th>
|
||||
<th>Payment</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rides.map((r) => (
|
||||
<tr key={r.ride_id}>
|
||||
<td>{r.ride_id}</td>
|
||||
<td>
|
||||
{r.origin_address} → {r.destination_address}
|
||||
</td>
|
||||
<td>{r.user_email}</td>
|
||||
<td>{r.driver.name}</td>
|
||||
<td>{r.ride_time}</td>
|
||||
<td>{r.fare_price.toLocaleString()}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${
|
||||
r.payment_status.toLowerCase() === "paid" ? "paid" : "unpaid"
|
||||
}`}
|
||||
>
|
||||
{r.payment_status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(r.created_at).toLocaleString()}</td>
|
||||
{!loading && !error && rides.length === 0 && (
|
||||
<div className="muted">No rides match the current filters.</div>
|
||||
)}
|
||||
|
||||
{rides.length > 0 && (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Route</th>
|
||||
<th>User</th>
|
||||
<th>Driver</th>
|
||||
<th>Time (min)</th>
|
||||
<th>Fare</th>
|
||||
<th>Payment</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className={loading ? "loading" : ""}>
|
||||
{rides.map((r) => (
|
||||
<tr key={r.ride_id} style={loading ? { opacity: 0.5 } : undefined}>
|
||||
<td>{r.ride_id}</td>
|
||||
<td>
|
||||
{r.origin_address} → {r.destination_address}
|
||||
</td>
|
||||
<td>{r.user_email}</td>
|
||||
<td>{r.driver.name}</td>
|
||||
<td>{r.ride_time}</td>
|
||||
<td>{fmt(r.fare_price)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${
|
||||
r.payment_status.toLowerCase() === "paid" ? "paid" : "unpaid"
|
||||
}`}
|
||||
>
|
||||
{r.payment_status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(r.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="toolbar pager">
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={meta.page <= 1 || loading}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="muted">
|
||||
Page {meta.page} of {meta.pages}
|
||||
</span>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={meta.page >= meta.pages || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Stats | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 <div className="error">{error}</div>;
|
||||
if (!stats) return <div>Loading…</div>;
|
||||
if (!stats) return <div className="muted">Loading…</div>;
|
||||
|
||||
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() {
|
||||
<div className="cards">
|
||||
<div className="card">
|
||||
<div className="label">Users</div>
|
||||
<div className="value">{stats.totals.users}</div>
|
||||
<div className="value">{fmt(t.users)}</div>
|
||||
<div className="sub">+{fmt(t.new_users_7d)} this week</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="label">Drivers</div>
|
||||
<div className="value">{stats.totals.drivers}</div>
|
||||
<div className="value">{fmt(t.drivers)}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="label">Rides</div>
|
||||
<div className="value">{stats.totals.rides}</div>
|
||||
<div className="value">{fmt(t.rides)}</div>
|
||||
<div className="sub">{fmt(t.rides_today)} today</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="label">Revenue (paid)</div>
|
||||
<div className="value">{stats.totals.revenue.toLocaleString()}</div>
|
||||
<div className="value">{fmt(t.revenue)}</div>
|
||||
<div className="sub">avg fare {fmt(t.avg_fare)}</div>
|
||||
</div>
|
||||
<div className={`card ${t.pending_count > 0 ? "warn" : ""}`}>
|
||||
<div className="label">Pending payments</div>
|
||||
<div className="value">{fmt(t.pending_count)}</div>
|
||||
<div className="sub">{fmt(t.pending_revenue)} outstanding</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Rides — last 14 days</h2>
|
||||
<h2>Last 14 days</h2>
|
||||
<div className="toolbar">
|
||||
<select value={metric} onChange={(e) => setMetric(e.target.value as "rides" | "revenue")}>
|
||||
<option value="rides">Rides</option>
|
||||
<option value="revenue">Revenue (paid)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="chart" style={{ marginBottom: 40 }}>
|
||||
{stats.trend.map((d) => (
|
||||
<div
|
||||
key={d.day}
|
||||
className="bar"
|
||||
style={{ height: `${(d.rides / maxRides) * 100}%` }}
|
||||
title={`${d.day}: ${d.rides} rides`}
|
||||
style={{ height: `${Math.max(1, (d[metric] / max) * 100)}%` }}
|
||||
title={`${d.day}: ${metric === "rides" ? `${d.rides} rides` : fmt(d.revenue)}`}
|
||||
>
|
||||
<span>{d.day.slice(5)}</span>
|
||||
</div>
|
||||
@@ -71,8 +99,8 @@ export default function Stats() {
|
||||
{stats.topDrivers.map((d) => (
|
||||
<tr key={d.driver_id}>
|
||||
<td>{d.name}</td>
|
||||
<td>{d.rides}</td>
|
||||
<td>{d.revenue.toLocaleString()}</td>
|
||||
<td>{fmt(d.rides)}</td>
|
||||
<td>{fmt(d.revenue)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<h2>Users</h2>
|
||||
@@ -110,10 +127,13 @@ export default function Users() {
|
||||
</td>
|
||||
<td>{u.rides}</td>
|
||||
<td>{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<td className="row-actions">
|
||||
<button className="secondary" onClick={() => toggleVerified(u)}>
|
||||
{u.email_verified ? "Unverify" : "Verify"}
|
||||
</button>
|
||||
<button className="danger" onClick={() => remove(u)}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user