Build core domain: orgs, users, jobs, locate points, auth, live map

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>
This commit is contained in:
ulhub
2026-07-14 12:43:25 +00:00
parent cbe0cc6da2
commit eae4075265
79 changed files with 10215 additions and 494 deletions

124
web/lib/auth-context.tsx Normal file
View File

@@ -0,0 +1,124 @@
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 };