Replaces the device-events demo with the actual product:
- Prisma + PostGIS data layer (postgis/postgis:17-3.5). Lat/lng decimals
are the source of truth; a generated geometry(Point,4326) column with
a GIST index backs bbox queries. Migrations apply on container boot.
- JWT auth (bcryptjs + httpOnly cookie) with public registration that
creates an org; per-org roles (ORG_ADMIN/MEMBER/VIEWER) enforced by
guards on all /orgs/:orgId routes.
- Scoped API keys (X-API-Key, sha256-hashed, shown once) for
programmatic access, manageable by org admins.
- REST API: jobs/tickets CRUD with filters, points query (time range,
recordedAt cursor, bbox), members, devices, api-keys.
- MQTT ingest: devices publish to devices/{username}/points and /jobs;
unknown tickets auto-create stub jobs (source=DEVICE); every message
is raw-logged to device_events; acks on devices/{username}/jobs/ack.
Broker gets a dedicated backend user; testuser is now a plain device.
- Realtime: plain-WS gateway at /api/ws (socket.io removed) with
cookie auth and per-job channels feeding the map live.
- Next.js frontend: login/register, jobs list with filters, job detail
with live Google map (APWA utility colors, polylines per run) behind
a provider-neutral JobMap abstraction for a future Esri swap, and
settings pages for members/devices/api-keys.
- Seed: Umagul org, admin user, testuser device, demo job with RTK
points. Sample publisher updated to the new topic contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
125 lines
3.6 KiB
TypeScript
125 lines
3.6 KiB
TypeScript
import { useRouter } from 'next/router';
|
|
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
import { api, ApiError } from './api';
|
|
|
|
export interface AuthUser {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface Membership {
|
|
role: 'ORG_ADMIN' | 'MEMBER' | 'VIEWER';
|
|
org: { id: string; name: string; slug: string };
|
|
}
|
|
|
|
interface MeResponse {
|
|
user: AuthUser;
|
|
memberships: Membership[];
|
|
}
|
|
|
|
interface AuthContextValue {
|
|
user: AuthUser | null;
|
|
memberships: Membership[];
|
|
activeOrg: Membership | null;
|
|
loading: boolean;
|
|
setActiveOrgId: (orgId: string) => void;
|
|
login: (email: string, password: string) => Promise<void>;
|
|
register: (data: { email: string; password: string; name: string; orgName: string }) => Promise<void>;
|
|
logout: () => Promise<void>;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
|
|
const ACTIVE_ORG_KEY = 'ulhub_active_org';
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
const [memberships, setMemberships] = useState<Membership[]>([]);
|
|
const [activeOrgId, setActiveOrgIdState] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const applySession = useCallback((session: MeResponse) => {
|
|
setUser(session.user);
|
|
setMemberships(session.memberships);
|
|
setActiveOrgIdState((current) => {
|
|
const stored = current ?? localStorage.getItem(ACTIVE_ORG_KEY);
|
|
const valid = session.memberships.some((m) => m.org.id === stored);
|
|
return valid ? stored : (session.memberships[0]?.org.id ?? null);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
api
|
|
.get<MeResponse>('/api/auth/me')
|
|
.then(applySession)
|
|
.catch(() => setUser(null))
|
|
.finally(() => setLoading(false));
|
|
}, [applySession]);
|
|
|
|
const setActiveOrgId = useCallback((orgId: string) => {
|
|
localStorage.setItem(ACTIVE_ORG_KEY, orgId);
|
|
setActiveOrgIdState(orgId);
|
|
}, []);
|
|
|
|
const login = useCallback(
|
|
async (email: string, password: string) => {
|
|
applySession(await api.post<MeResponse>('/api/auth/login', { email, password }));
|
|
},
|
|
[applySession],
|
|
);
|
|
|
|
const register = useCallback(
|
|
async (data: { email: string; password: string; name: string; orgName: string }) => {
|
|
applySession(await api.post<MeResponse>('/api/auth/register', data));
|
|
},
|
|
[applySession],
|
|
);
|
|
|
|
const logout = useCallback(async () => {
|
|
await api.post('/api/auth/logout', {}).catch(() => undefined);
|
|
localStorage.removeItem(ACTIVE_ORG_KEY);
|
|
setUser(null);
|
|
setMemberships([]);
|
|
setActiveOrgIdState(null);
|
|
}, []);
|
|
|
|
const value = useMemo<AuthContextValue>(
|
|
() => ({
|
|
user,
|
|
memberships,
|
|
activeOrg: memberships.find((m) => m.org.id === activeOrgId) ?? null,
|
|
loading,
|
|
setActiveOrgId,
|
|
login,
|
|
register,
|
|
logout,
|
|
}),
|
|
[user, memberships, activeOrgId, loading, setActiveOrgId, login, register, logout],
|
|
);
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
export function useAuth(): AuthContextValue {
|
|
const ctx = useContext(AuthContext);
|
|
if (!ctx) {
|
|
throw new Error('useAuth must be used inside AuthProvider');
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
// Client-side page guard: redirects to /login when unauthenticated.
|
|
export function useRequireAuth() {
|
|
const auth = useAuth();
|
|
const router = useRouter();
|
|
useEffect(() => {
|
|
if (!auth.loading && !auth.user) {
|
|
router.replace('/login');
|
|
}
|
|
}, [auth.loading, auth.user, router]);
|
|
return auth;
|
|
}
|
|
|
|
export { ApiError };
|