Add self-hosted auth, admin API, and owner web dashboard

- Replace Clerk with self-hosted JWT auth (register/login/verify, bcrypt
  passwords, Gmail OTP with console fallback)
- Add lib/db.ts pg pool + transaction helpers; seed script migrates legacy
  Clerk-era schema (drop clerk_id, enforce UUID ids and unique email)
- Add owner-gated admin API: stats, users, drivers CRUD, rides
- Add dashboard/ Vite React owner dashboard (login, overview, users,
  fleet, rides) with dev-server proxy to avoid Expo CORS middleware
- Add scripts/set-owner.mjs for role management
This commit is contained in:
Krikorios
2026-08-23 16:38:41 +03:00
parent fbe92c9d16
commit a0b297285a
75 changed files with 5158 additions and 2837 deletions
+37
View File
@@ -0,0 +1,37 @@
import { sql } from "@/lib/db";
import { requireAuth } from "@/lib/jwt";
export const corsHeaders: Record<string, string> = {
"Access-Control-Allow-Origin": process.env.ADMIN_CORS_ORIGIN ?? "*",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export const withCors = (response: Response): Response => {
for (const [key, value] of Object.entries(corsHeaders)) {
response.headers.set(key, value);
}
return response;
};
export const preflight = (): Response => withCors(new Response(null, { status: 204 }));
// Returns the authenticated owner or a ready-to-return error Response.
export const requireOwner = async (
req: Request,
): Promise<{ userId: string; email: string } | { error: Response }> => {
const auth = requireAuth(req);
if ("error" in auth) return auth;
const rows = await sql<{ role: string | null }>`
SELECT role FROM users WHERE id = ${auth.userId}
`;
if (rows[0]?.role !== "owner") {
return {
error: Response.json({ error: "Forbidden." }, { status: 403 }),
};
}
return auth;
};
+15 -91
View File
@@ -1,97 +1,21 @@
import type {
StartOAuthFlowParams,
StartOAuthFlowReturnType,
} from "@clerk/clerk-expo";
import * as Linking from "expo-linking";
import * as SecureStore from "expo-secure-store";
import { fetchAPI } from "./fetch";
import type { SessionUser } from "./session";
export interface TokenCache {
getToken: (key: string) => Promise<string | undefined | null>;
saveToken: (key: string, token: string) => Promise<void>;
clearToken?: (key: string) => void;
}
export const tokenCache = {
async getToken(key: string) {
try {
const item = await SecureStore.getItemAsync(key);
return item;
} catch (error) {
console.error("GET_TOKEN_CACHE: ", error);
await SecureStore.deleteItemAsync(key);
return null;
}
},
async saveToken(key: string, value: string) {
try {
return SecureStore.setItemAsync(key, value);
} catch (err) {
console.error("SAVE_TOKEN_CACHE: ", err);
return;
}
},
export type AuthResult = {
token: string;
user: SessionUser;
};
type StartOAuthFlowType = (
startOAuthFlowParams?: StartOAuthFlowParams,
) => Promise<StartOAuthFlowReturnType>;
// Exchanges a Google idToken (obtained via expo-auth-session on the client)
// for a Waseel session issued by our own server.
export const googleAuth = async (
idToken: string,
): Promise<AuthResult> => {
const response = await fetchAPI("/(api)/auth/google", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idToken }),
});
export const googleOAuth = async (startOAuthFlow: StartOAuthFlowType) => {
try {
// No explicit scheme: in Expo Go this resolves to exp://... so the
// browser can redirect back into the app; in a standalone build it
// automatically uses the app.json scheme ("waseel").
const { createdSessionId, signUp, setActive } = await startOAuthFlow({
redirectUrl: Linking.createURL("/(root)/(tabs)/home"),
});
if (createdSessionId) {
if (setActive) {
await setActive!({ session: createdSessionId });
if (signUp && signUp.createdUserId) {
await fetchAPI("/(api)/user", {
method: "POST",
body: JSON.stringify({
name: `${signUp.firstName} ${signUp.lastName}`,
email: signUp.emailAddress,
clerkId: signUp.createdUserId,
}),
});
}
return {
success: true,
code: "success",
message: "You are logged in.",
};
}
return {
success: false,
code: "failed",
message: "An error occured.",
};
} else {
// Use signIn or signUp for next steps such as MFA
}
} catch (err) {
console.log("[OAUTH]: ", err);
return {
success: false,
code: (
err as {
code?: string;
}
)?.code,
message: "Internal Server Error.",
};
}
return response.data as AuthResult;
};
+67
View File
@@ -0,0 +1,67 @@
import { Pool, type QueryResultRow } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 10_000,
});
type SqlValue = string | number | boolean | null | Date;
export async function sql<R extends QueryResultRow = QueryResultRow>(
strings: TemplateStringsArray,
...values: SqlValue[]
): Promise<R[]> {
const text = strings.reduce(
(acc, chunk, i) =>
acc + chunk + (i < values.length ? `$${i + 1}` : ""),
"",
);
const result = await pool.query<R>(text, values);
return result.rows;
}
export async function transaction<T>(
callback: (
tx: <R extends QueryResultRow = QueryResultRow>(
strings: TemplateStringsArray,
...values: SqlValue[]
) => Promise<R[]>,
) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const tx = async <R extends QueryResultRow = QueryResultRow>(
strings: TemplateStringsArray,
...values: SqlValue[]
) => {
const text = strings.reduce(
(acc, chunk, i) =>
acc + chunk + (i < values.length ? `$${i + 1}` : ""),
"",
);
const result = await client.query<R>(text, values);
return result.rows;
};
const out = await callback(tx);
await client.query("COMMIT");
return out;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
+19 -2
View File
@@ -1,10 +1,27 @@
import { useState, useEffect, useCallback } from "react";
// Set by lib/session.tsx once a token is available; read synchronously here so
// callers never have to await SecureStore before every request.
let authToken: string | null = null;
export const setAuthToken = (token: string) => {
authToken = token;
};
export const clearAuthToken = () => {
authToken = null;
};
export const fetchAPI = async (url: string, options?: RequestInit) => {
try {
const response = await fetch(url, options);
const headers = new Headers(options?.headers);
if (authToken && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
new Error(`HTTP error! status: ${response.status}`);
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
+100
View File
@@ -0,0 +1,100 @@
import { createHmac, timingSafeEqual } from "crypto";
const base64Url = (input: Buffer | string): string =>
Buffer.from(input)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const fromBase64Url = (input: string): Buffer =>
Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/"), "base64");
export type JwtPayload = {
sub: string;
email: string;
iat: number;
exp: number;
};
const secret = (): string => {
const value = process.env.AUTH_JWT_SECRET;
if (!value) throw new Error("Missing AUTH_JWT_SECRET.");
return value;
};
export const signJwt = (
payload: { sub: string; email: string },
expiresInSeconds = 30 * 24 * 60 * 60,
): string => {
const iat = Math.floor(Date.now() / 1000);
const body: JwtPayload = { ...payload, iat, exp: iat + expiresInSeconds };
const header = base64Url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const claims = base64Url(JSON.stringify(body));
const signature = base64Url(
createHmac("sha256", secret()).update(`${header}.${claims}`).digest(),
);
return `${header}.${claims}.${signature}`;
};
export const verifyJwt = (token: string): JwtPayload | null => {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [header, claims, signature] = parts;
const expected = createHmac("sha256", secret())
.update(`${header}.${claims}`)
.digest();
const received = fromBase64Url(signature);
if (
expected.length !== received.length ||
!timingSafeEqual(expected, received)
) {
return null;
}
try {
const payload = JSON.parse(fromBase64Url(claims).toString()) as JwtPayload;
if (payload.exp * 1000 < Date.now()) return null;
return payload;
} catch {
return null;
}
};
// Returns the authenticated principal or a ready-to-return error Response.
export const requireAuth = (
req: Request,
): { userId: string; email: string } | { error: Response } => {
const header = req.headers.get("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
const payload = token ? verifyJwt(token) : null;
if (!payload) {
return {
error: Response.json({ error: "Unauthorized." }, { status: 401 }),
};
}
return { userId: payload.sub, email: payload.email };
};
export const decodeJwtExp = (token: string): number | null => {
try {
const claims = JSON.parse(
fromBase64Url(token.split(".")[1]).toString(),
) as JwtPayload;
return claims.exp ?? null;
} catch {
return null;
}
};
+96
View File
@@ -0,0 +1,96 @@
// Sends transactional email through the Gmail API using an OAuth2 refresh
// token (no third-party email service needed on a self-hosted box).
//
// 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.
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send";
let cachedAccessToken: { token: string; expiresAt: number } | null = null;
const getAccessToken = async (): Promise<string | null> => {
const clientId = process.env.GMAIL_CLIENT_ID;
const clientSecret = process.env.GMAIL_CLIENT_SECRET;
const refreshToken = process.env.GMAIL_REFRESH_TOKEN;
if (!clientId || !clientSecret || !refreshToken) return null;
if (cachedAccessToken && cachedAccessToken.expiresAt > Date.now() + 60_000) {
return cachedAccessToken.token;
}
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;
};
export const sendEmail = async (
to: string,
subject: string,
text: string,
): Promise<void> => {
const accessToken = await getAccessToken();
if (!accessToken) {
// Not configured: fall back to the server log so development still works.
console.log(`[MAIL to=${to}] ${subject}\n${text}`);
return;
}
const from = process.env.GMAIL_FROM;
if (!from) throw new Error("Missing GMAIL_FROM.");
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}`);
}
};
+24 -10
View File
@@ -1,3 +1,4 @@
import { calculateFare } from "@/lib/pricing";
import type { Driver, MarkerData } from "@/types/type";
const directionsAPI = process.env.EXPO_PUBLIC_GOOGLE_API_KEY;
@@ -37,11 +38,12 @@ export const calculateRegion = ({
destinationLongitude?: number | null;
}) => {
if (!userLatitude || !userLongitude) {
// Default to Beirut, Lebanon.
return {
latitude: 37.78825,
longitude: -122.4324,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
latitude: 33.8938,
longitude: 35.5018,
latitudeDelta: 0.09,
longitudeDelta: 0.09,
};
}
@@ -100,20 +102,32 @@ export const calculateDriverTimes = async ({
`https://maps.googleapis.com/maps/api/directions/json?origin=${marker.latitude},${marker.longitude}&destination=${userLatitude},${userLongitude}&key=${directionsAPI}`,
);
const dataToUser = await responseToUser.json();
const timeToUser = dataToUser.routes[0].legs[0].duration.value; // Time in seconds
const responseToDestination = await fetch(
`https://maps.googleapis.com/maps/api/directions/json?origin=${userLatitude},${userLongitude}&destination=${destinationLatitude},${destinationLongitude}&key=${directionsAPI}`,
);
const dataToDestination = await responseToDestination.json();
const timeToDestination =
dataToDestination.routes[0].legs[0].duration.value; // Time in seconds
// Google returns no routes when a leg is unreachable (ZERO_RESULTS).
const legToUser = dataToUser.routes?.[0]?.legs?.[0];
const legToDestination = dataToDestination.routes?.[0]?.legs?.[0];
if (!legToUser || !legToDestination) {
return { ...marker, time: 0, price: "0.00" };
}
const totalTime = (timeToUser + timeToDestination) / 60; // Total time in minutes
const price = (totalTime * 0.5).toFixed(2); // Calculate price based on time
const timeToUser = legToUser.duration.value; // Pickup ETA in seconds
const timeToDestination = legToDestination.duration.value; // Trip duration in seconds
return { ...marker, time: totalTime, price };
// The rider pays for the trip leg only (distance + duration) —
// never for the driver's approach.
const price = calculateFare({
distanceMeters: legToDestination.distance.value,
durationSeconds: timeToDestination,
});
const totalTripTime = (timeToUser + timeToDestination) / 60; // Minutes until drop-off
return { ...marker, time: totalTripTime, price };
});
return await Promise.all(timesPromises);
+24
View File
@@ -0,0 +1,24 @@
import { randomBytes, scryptSync, timingSafeEqual } from "crypto";
const KEY_LENGTH = 64;
export const hashPassword = (password: string): string => {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, KEY_LENGTH).toString("hex");
return `scrypt:${salt}:${hash}`;
};
export const verifyPassword = (
password: string,
stored: string,
): boolean => {
const [scheme, salt, hash] = stored.split(":");
if (scheme !== "scrypt" || !salt || !hash) return false;
const candidate = scryptSync(password, salt, KEY_LENGTH);
const expected = Buffer.from(hash, "hex");
return (
candidate.length === expected.length && timingSafeEqual(candidate, expected)
);
};
+34
View File
@@ -0,0 +1,34 @@
// Fare model tuned to the Lebanese market:
// - Distance-based with a base fare (like CTaxi/local taxis), not time-only.
// - Typical Beirut rides land in the $38 range riders expect.
// - Prices are quoted in USD (the de facto ride-hailing currency) with an
// L.B.P. equivalent shown for cash settlement.
export const FARE = {
base: 1.5, // USD, flag drop
perKm: 0.55, // USD per kilometer of the trip
perMin: 0.15, // USD per minute of the trip
minimum: 3.0, // USD minimum fare
} as const;
// Parallel market rate used for the L.B.P. cash equivalent shown in-app.
export const LBP_RATE = 89500;
export const calculateFare = ({
distanceMeters,
durationSeconds,
}: {
distanceMeters: number;
durationSeconds: number;
}): string => {
const km = distanceMeters / 1000;
const minutes = durationSeconds / 60;
const fare = FARE.base + km * FARE.perKm + minutes * FARE.perMin;
return Math.max(fare, FARE.minimum).toFixed(2);
};
// Rounds to the nearest 1,000 L.B.P. — the smallest practical cash note.
export const formatLBP = (usd: number): string =>
`${(Math.round((usd * LBP_RATE) / 1000) * 1000).toLocaleString("en-US")} L.B.P.`;
+157
View File
@@ -0,0 +1,157 @@
import * as SecureStore from "expo-secure-store";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { setAuthToken, clearAuthToken } from "./fetch";
const TOKEN_KEY = "waseel_auth_token";
const USER_KEY = "waseel_auth_user";
const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
export type SessionUser = {
id: string;
name: string;
email: string;
role?: string | null;
avatarUrl?: string | null;
};
type AuthResult = { token: string; user: SessionUser };
type SessionContextValue = {
isLoaded: boolean;
isSignedIn: boolean;
userId: string | null;
user: SessionUser | null;
setSession: (result: AuthResult) => Promise<void>;
setUserRole: (role: string) => void;
signOut: () => Promise<void>;
};
const SessionContext = createContext<SessionContextValue | null>(null);
// Mirrors lib/jwt.ts payload decoding (no signature check needed client-side).
export const decodeJwtExp = (token: string): number | null => {
try {
const claims = JSON.parse(
atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
);
return typeof claims.exp === "number" ? claims.exp : null;
} catch {
return null;
}
};
export const SessionProvider = ({ children }: { children: ReactNode }) => {
const [isLoaded, setIsLoaded] = useState(false);
const [user, setUser] = useState<SessionUser | null>(null);
useEffect(() => {
let cancelled = false;
const restore = async () => {
try {
const [token, storedUser] = await Promise.all([
SecureStore.getItemAsync(TOKEN_KEY),
SecureStore.getItemAsync(USER_KEY),
]);
if (!token || !storedUser) return;
const exp = decodeJwtExp(token) ?? 0;
if (exp * 1000 < Date.now()) {
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(USER_KEY);
return;
}
if (cancelled) return;
setAuthToken(token);
setUser(JSON.parse(storedUser) as SessionUser);
} catch (error) {
console.error("[SESSION_RESTORE]: ", error);
} finally {
if (!cancelled) setIsLoaded(true);
}
};
void restore();
return () => {
cancelled = true;
};
}, []);
const setSession = useCallback(async (result: AuthResult) => {
setAuthToken(result.token);
setUser(result.user);
await SecureStore.setItemAsync(TOKEN_KEY, result.token);
await SecureStore.setItemAsync(USER_KEY, JSON.stringify(result.user));
}, []);
const setUserRole = useCallback(
(role: string) => {
setUser((currentUser) => {
if (!currentUser) return currentUser;
const updated = { ...currentUser, role };
void SecureStore.setItemAsync(USER_KEY, JSON.stringify(updated));
return updated;
});
},
[],
);
const signOut = useCallback(async () => {
clearAuthToken();
setUser(null);
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(USER_KEY);
}, []);
const value = useMemo<SessionContextValue>(
() => ({
isLoaded,
isSignedIn: user !== null,
userId: user?.id ?? null,
user,
setSession,
setUserRole,
signOut,
}),
[isLoaded, user, setSession, setUserRole, signOut],
);
return (
<SessionContext.Provider value={value}>
{children}
</SessionContext.Provider>
);
};
export const useSession = (): SessionContextValue => {
const context = useContext(SessionContext);
if (!context) {
throw new Error("useSession must be used within a SessionProvider.");
}
return context;
};
// Alias kept for parity with the previous auth API.
export const useAuth = useSession;
export const TOKEN_TTL_SECONDS = DEFAULT_TOKEN_TTL_SECONDS;
+39
View File
@@ -0,0 +1,39 @@
import { sql } from "@/lib/db";
import { signJwt } from "@/lib/jwt";
export type UserProfile = {
id: string;
name: string;
email: string;
role: string | null;
};
type UserRow = {
id: string;
name: string;
email: string;
role: string | null;
};
export const toProfile = (row: UserRow): UserProfile => ({
id: row.id,
name: row.name,
email: row.email,
role: row.role,
});
export const issueSession = (
row: UserRow,
): { token: string; user: UserProfile } => ({
token: signJwt({ sub: row.id, email: row.email }),
user: toProfile(row),
});
export const findUserByEmail = async (
email: string,
): Promise<UserRow | null> => {
const rows = await sql<UserRow>`
SELECT id, name, email, role FROM users WHERE email = ${email}
`;
return rows[0] ?? null;
};
+6 -9
View File
@@ -1,20 +1,17 @@
import type { Ride } from "@/types/type";
export const sortRides = (rides: Ride[]): Ride[] => {
const result = rides.sort((a, b) => {
const dateA = new Date(`${a.created_at}T${a.ride_time}`);
const dateB = new Date(`${b.created_at}T${b.ride_time}`);
return dateB.getTime() - dateA.getTime();
});
return result.reverse();
return [...rides].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
};
export function formatTime(minutes: number): string {
const formattedMinutes = +minutes?.toFixed(0) || 0;
const formattedMinutes = Math.round(minutes) || 0;
if (formattedMinutes < 60) {
return `${minutes} min`;
return `${formattedMinutes} min`;
} else {
const hours = Math.floor(formattedMinutes / 60);
const remainingMinutes = formattedMinutes % 60;