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:
124
web/lib/auth-context.tsx
Normal file
124
web/lib/auth-context.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user