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:
65
web/components/Layout.tsx
Normal file
65
web/components/Layout.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ReactNode } from 'react';
|
||||
import { useAuth } from '../lib/auth-context';
|
||||
|
||||
export default function Layout({ children, title }: { children: ReactNode; title?: string }) {
|
||||
const { user, memberships, activeOrg, setActiveOrgId, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title ? `${title} · UlHub` : 'UlHub'}</title>
|
||||
</Head>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1.5rem',
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderBottom: '1px solid #ddd',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
<Link href="/" style={{ fontWeight: 700, fontSize: '1.1rem', textDecoration: 'none', color: '#111' }}>
|
||||
UlHub
|
||||
</Link>
|
||||
{user && (
|
||||
<>
|
||||
<nav style={{ display: 'flex', gap: '1rem' }}>
|
||||
<Link href="/">Jobs</Link>
|
||||
<Link href="/settings/devices">Devices</Link>
|
||||
<Link href="/settings/members">Members</Link>
|
||||
<Link href="/settings/api-keys">API Keys</Link>
|
||||
</nav>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{memberships.length > 1 ? (
|
||||
<select value={activeOrg?.org.id ?? ''} onChange={(e) => setActiveOrgId(e.target.value)}>
|
||||
{memberships.map((m) => (
|
||||
<option key={m.org.id} value={m.org.id}>
|
||||
{m.org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span style={{ color: '#555' }}>{activeOrg?.org.name}</span>
|
||||
)}
|
||||
<span style={{ color: '#888', fontSize: '0.9rem' }}>{user.email}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
router.push('/login');
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
<main style={{ fontFamily: 'sans-serif', padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
web/components/map/JobMap.tsx
Normal file
10
web/components/map/JobMap.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { JobMapProps } from './types';
|
||||
|
||||
// Provider dispatcher. Google is the only implementation for now; an Esri
|
||||
// implementation would be selected here (e.g. via NEXT_PUBLIC_MAP_PROVIDER).
|
||||
const GoogleJobMap = dynamic(() => import('./google/GoogleJobMap'), { ssr: false });
|
||||
|
||||
export default function JobMap(props: JobMapProps) {
|
||||
return <GoogleJobMap {...props} />;
|
||||
}
|
||||
120
web/components/map/google/GoogleJobMap.tsx
Normal file
120
web/components/map/google/GoogleJobMap.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/// <reference types="google.maps" />
|
||||
import { APIProvider, Map as GoogleMap, useMap } from '@vis.gl/react-google-maps';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { JobMapProps, MapPoint, UTILITY_COLORS } from '../types';
|
||||
|
||||
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || '';
|
||||
|
||||
const DEFAULT_CENTER = { lat: 39.5, lng: -98.35 }; // continental US
|
||||
const DEFAULT_ZOOM = 4;
|
||||
|
||||
function colorFor(utilityType: string): string {
|
||||
return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN;
|
||||
}
|
||||
|
||||
function orderKey(p: MapPoint): number {
|
||||
return p.sequence ?? new Date(p.recordedAt).getTime();
|
||||
}
|
||||
|
||||
// Draws points as colored circles plus one polyline per utility run.
|
||||
// Imperative overlays (google.maps.Marker/Polyline) are used because
|
||||
// @vis.gl/react-google-maps has no polyline component.
|
||||
function PointsLayer({ points, fitBounds }: { points: MapPoint[]; fitBounds: boolean }) {
|
||||
const map = useMap();
|
||||
const overlaysRef = useRef<{ setMap: (m: google.maps.Map | null) => void }[]>([]);
|
||||
const fittedCountRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlaysRef.current.forEach((o) => o.setMap(null));
|
||||
overlaysRef.current = [];
|
||||
if (points.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const byUtility = new Map<string, MapPoint[]>();
|
||||
for (const p of points) {
|
||||
const group = byUtility.get(p.utilityType) ?? [];
|
||||
group.push(p);
|
||||
byUtility.set(p.utilityType, group);
|
||||
}
|
||||
|
||||
for (const [utility, group] of byUtility) {
|
||||
const sorted = [...group].sort((a, b) => orderKey(a) - orderKey(b));
|
||||
const color = colorFor(utility);
|
||||
|
||||
const line = new google.maps.Polyline({
|
||||
path: sorted.map((p) => ({ lat: p.lat, lng: p.lng })),
|
||||
strokeColor: color,
|
||||
strokeOpacity: 0.8,
|
||||
strokeWeight: 3,
|
||||
map,
|
||||
});
|
||||
overlaysRef.current.push(line);
|
||||
|
||||
for (const p of sorted) {
|
||||
const marker = new google.maps.Marker({
|
||||
position: { lat: p.lat, lng: p.lng },
|
||||
map,
|
||||
icon: {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
scale: 5,
|
||||
fillColor: color,
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#ffffff',
|
||||
strokeWeight: 1.5,
|
||||
},
|
||||
title: `${p.utilityType} · ${p.fixType}${p.depth != null ? ` · depth ${p.depth}m` : ''}\n${new Date(p.recordedAt).toLocaleString()}`,
|
||||
});
|
||||
overlaysRef.current.push(marker);
|
||||
}
|
||||
}
|
||||
|
||||
if (fitBounds && points.length !== fittedCountRef.current) {
|
||||
fittedCountRef.current = points.length;
|
||||
const bounds = new google.maps.LatLngBounds();
|
||||
points.forEach((p) => bounds.extend({ lat: p.lat, lng: p.lng }));
|
||||
map.fitBounds(bounds, 48);
|
||||
}
|
||||
}, [map, points, fitBounds]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function GoogleJobMap({ points, fitBounds = true, heightPx = 480 }: JobMapProps) {
|
||||
if (!API_KEY) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: heightPx,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px dashed #999',
|
||||
borderRadius: 8,
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Set NEXT_PUBLIC_GOOGLE_MAPS_API_KEY in .env to enable the map.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: heightPx, borderRadius: 8, overflow: 'hidden' }}>
|
||||
<APIProvider apiKey={API_KEY}>
|
||||
<GoogleMap
|
||||
defaultCenter={points[0] ? { lat: points[0].lat, lng: points[0].lng } : DEFAULT_CENTER}
|
||||
defaultZoom={points[0] ? 18 : DEFAULT_ZOOM}
|
||||
mapTypeId="hybrid"
|
||||
gestureHandling="greedy"
|
||||
>
|
||||
<PointsLayer points={points} fitBounds={fitBounds} />
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
web/components/map/types.ts
Normal file
34
web/components/map/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Provider-neutral mapping types. Pages import only these and <JobMap>;
|
||||
// swapping Google for Esri later means adding a new provider implementation
|
||||
// of JobMapProps under components/map/esri/ and switching the dispatcher.
|
||||
|
||||
export interface MapPoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
utilityType: string;
|
||||
fixType: string;
|
||||
depth: number | null;
|
||||
sequence: number | null;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export interface JobMapProps {
|
||||
points: MapPoint[];
|
||||
// Pan/zoom to fit all points whenever their count changes
|
||||
fitBounds?: boolean;
|
||||
heightPx?: number;
|
||||
}
|
||||
|
||||
// APWA uniform color code for marking underground utilities
|
||||
export const UTILITY_COLORS: Record<string, string> = {
|
||||
ELECTRIC: '#d32f2f', // red
|
||||
GAS: '#fbc02d', // yellow
|
||||
WATER: '#1976d2', // blue
|
||||
SEWER: '#388e3c', // green
|
||||
TELECOM: '#f57c00', // orange
|
||||
CATV: '#f57c00', // orange
|
||||
FIBER: '#f57c00', // orange
|
||||
STEAM: '#fbc02d', // yellow
|
||||
UNKNOWN: '#e91e8c', // pink (unknown/proposed)
|
||||
};
|
||||
35
web/lib/api.ts
Normal file
35
web/lib/api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJson<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const message = Array.isArray(body?.message)
|
||||
? body.message.join('; ')
|
||||
: body?.message || `Request failed (${res.status})`;
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(path: string) => fetchJson<T>(path),
|
||||
post: <T = unknown>(path: string, data: unknown) =>
|
||||
fetchJson<T>(path, { method: 'POST', body: JSON.stringify(data) }),
|
||||
patch: <T = unknown>(path: string, data: unknown) =>
|
||||
fetchJson<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
delete: <T = unknown>(path: string) => fetchJson<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
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 };
|
||||
60
web/lib/use-job-stream.ts
Normal file
60
web/lib/use-job-stream.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { MapPoint } from '../components/map/types';
|
||||
|
||||
// Subscribes to live points for a job over the backend WebSocket.
|
||||
// Reconnects with capped exponential backoff; resubscribes on reconnect.
|
||||
export function useJobStream(jobId: string | null, onPoints: (points: MapPoint[]) => void) {
|
||||
const handlerRef = useRef(onPoints);
|
||||
handlerRef.current = onPoints;
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let closed = false;
|
||||
let attempt = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = () => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
||||
|
||||
socket.onopen = () => {
|
||||
attempt = 0;
|
||||
socket?.send(JSON.stringify({ type: 'subscribe', channel: `job:${jobId}` }));
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'points' && msg.jobId === jobId) {
|
||||
handlerRef.current(msg.points);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
attempt += 1;
|
||||
const delay = Math.min(1000 * 2 ** attempt, 15000);
|
||||
timer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
socket?.close();
|
||||
};
|
||||
}, [jobId]);
|
||||
}
|
||||
5470
web/package-lock.json
generated
Normal file
5470
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,13 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vis.gl/react-google-maps": "^1.4.0",
|
||||
"next": "14.2.5",
|
||||
"react": "18.3.0",
|
||||
"react-dom": "18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
|
||||
10
web/pages/_app.tsx
Normal file
10
web/pages/_app.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { AppProps } from 'next/app';
|
||||
import { AuthProvider } from '../lib/auth-context';
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Component {...pageProps} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +1,118 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../components/Layout';
|
||||
import { api } from '../lib/api';
|
||||
import { useRequireAuth } from '../lib/auth-context';
|
||||
|
||||
const WS_PATH = '/api/device-events/ws';
|
||||
|
||||
interface ApiStatus {
|
||||
message: string;
|
||||
docs: string;
|
||||
interface JobRow {
|
||||
id: string;
|
||||
ticketNumber: string;
|
||||
title: string;
|
||||
address: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
assignedTo: { id: string; name: string } | null;
|
||||
_count: { points: number };
|
||||
}
|
||||
|
||||
interface DeviceRecord {
|
||||
id: number;
|
||||
topic: string;
|
||||
payload: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
const STATUSES = ['', 'OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
|
||||
|
||||
export default function Home() {
|
||||
const [status, setStatus] = useState<ApiStatus | null>(null);
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
OPEN: '#1976d2',
|
||||
IN_PROGRESS: '#f57c00',
|
||||
COMPLETED: '#388e3c',
|
||||
CANCELLED: '#9e9e9e',
|
||||
};
|
||||
|
||||
export default function JobsPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const [jobs, setJobs] = useState<JobRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [status, setStatus] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<DeviceRecord[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/status')
|
||||
.then((res) => res.json())
|
||||
.then(setStatus)
|
||||
.catch((err) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/device-events')
|
||||
.then((res) => res.json())
|
||||
.then((payload) => {
|
||||
const records = Array.isArray(payload) ? payload : payload?.records ?? [];
|
||||
setMessages(Array.isArray(records) ? records : []);
|
||||
setConnected(true);
|
||||
if (!activeOrg) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (q) params.set('q', q);
|
||||
api
|
||||
.get<{ jobs: JobRow[]; total: number }>(`/api/orgs/${activeOrg.org.id}/jobs?${params}`)
|
||||
.then((res) => {
|
||||
setJobs(res.jobs);
|
||||
setTotal(res.total);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message || 'Unable to load device events');
|
||||
});
|
||||
.catch((err) => setError(err.message));
|
||||
}, [activeOrg, status, q]);
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const socket = new WebSocket(`${wsProtocol}://${window.location.host}${WS_PATH}`);
|
||||
|
||||
socket.onopen = () => {
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.type === 'connected') {
|
||||
setConnected(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) => [payload, ...prev].slice(0, 20));
|
||||
} catch (err) {
|
||||
console.error('Failed to parse device-event payload', err);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
setConnected(false);
|
||||
setError('Realtime stream disconnected');
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>UlHub</title>
|
||||
<meta name="description" content="UlHub monolithic app" />
|
||||
</Head>
|
||||
<main style={{ padding: '3rem', fontFamily: 'system-ui, sans-serif', maxWidth: '900px' }}>
|
||||
<h1>UlHub</h1>
|
||||
<p>React + Next.js frontend with NestJS backend and live MQTT WebSocket data.</p>
|
||||
|
||||
<section style={{ marginTop: '1.5rem' }}>
|
||||
<h2>API Status</h2>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
{status ? (
|
||||
<div>
|
||||
<p>{status.message}</p>
|
||||
<p>{status.docs}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Loading status...</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: '1.5rem' }}>
|
||||
<h2>Device Events</h2>
|
||||
<p>Status: {connected ? 'Connected' : 'Connecting...'}</p>
|
||||
<ul>
|
||||
{(Array.isArray(messages) ? messages : []).map((msg, index) => (
|
||||
<li key={`${msg.topic}-${index}`} style={{ marginBottom: '0.75rem' }}>
|
||||
<strong>{msg.topic}</strong> <span style={{ color: '#666' }}>{msg.receivedAt}</span>
|
||||
<br />
|
||||
{msg.payload}
|
||||
</li>
|
||||
<Layout title="Jobs">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||
<h1 style={{ margin: 0 }}>Jobs</h1>
|
||||
<span style={{ color: '#888' }}>{total} total</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||
<input placeholder="Search ticket, title, address…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s || 'All statuses'}
|
||||
</option>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
</select>
|
||||
<Link href="/jobs/new">
|
||||
<button>+ New job</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.5rem' }}>Ticket</th>
|
||||
<th>Title</th>
|
||||
<th>Address</th>
|
||||
<th>Status</th>
|
||||
<th>Source</th>
|
||||
<th>Assignee</th>
|
||||
<th style={{ textAlign: 'right' }}>Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem' }}>
|
||||
<Link href={`/jobs/${job.id}`}>{job.ticketNumber}</Link>
|
||||
</td>
|
||||
<td>{job.title}</td>
|
||||
<td>{job.address ?? '—'}</td>
|
||||
<td>
|
||||
<span style={{ color: STATUS_COLORS[job.status] ?? '#333', fontWeight: 600 }}>{job.status}</span>
|
||||
</td>
|
||||
<td>{job.source}</td>
|
||||
<td>{job.assignedTo?.name ?? '—'}</td>
|
||||
<td style={{ textAlign: 'right' }}>{job._count.points}</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && !error && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
|
||||
No jobs yet. Create one, or let a device post points to auto-create its ticket.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
115
web/pages/jobs/[jobId].tsx
Normal file
115
web/pages/jobs/[jobId].tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import JobMap from '../../components/map/JobMap';
|
||||
import type { MapPoint } from '../../components/map/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
import { useJobStream } from '../../lib/use-job-stream';
|
||||
|
||||
interface JobDetail {
|
||||
id: string;
|
||||
ticketNumber: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
address: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
dueAt: string | null;
|
||||
createdAt: string;
|
||||
assignedTo: { id: string; name: string; email: string } | null;
|
||||
_count: { points: number };
|
||||
}
|
||||
|
||||
const STATUSES = ['OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
|
||||
|
||||
export default function JobDetailPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const router = useRouter();
|
||||
const jobId = typeof router.query.jobId === 'string' ? router.query.jobId : null;
|
||||
const orgId = activeOrg?.org.id ?? null;
|
||||
|
||||
const [job, setJob] = useState<JobDetail | null>(null);
|
||||
const [points, setPoints] = useState<MapPoint[]>([]);
|
||||
const [live, setLive] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orgId || !jobId) {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`)
|
||||
.then(setJob)
|
||||
.catch((err) => setError(err.message));
|
||||
api
|
||||
.get<{ points: MapPoint[] }>(`/api/orgs/${orgId}/jobs/${jobId}/points`)
|
||||
.then((res) => setPoints(res.points))
|
||||
.catch((err) => setError(err.message));
|
||||
}, [orgId, jobId]);
|
||||
|
||||
const onLivePoints = useCallback((incoming: MapPoint[]) => {
|
||||
setPoints((prev) => {
|
||||
const seen = new Set(prev.map((p) => p.id));
|
||||
const fresh = incoming.filter((p) => !seen.has(p.id));
|
||||
return fresh.length > 0 ? [...prev, ...fresh] : prev;
|
||||
});
|
||||
setLive((n) => n + incoming.length);
|
||||
}, []);
|
||||
|
||||
useJobStream(jobId, onLivePoints);
|
||||
|
||||
const updateStatus = async (status: string) => {
|
||||
if (!orgId || !jobId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setJob(await api.patch<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`, { status }));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={job ? job.ticketNumber : 'Job'}>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
{job && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<h1 style={{ margin: 0 }}>{job.ticketNumber}</h1>
|
||||
<span style={{ fontSize: '1.1rem' }}>{job.title}</span>
|
||||
<select value={job.status} onChange={(e) => updateStatus(e.target.value)} style={{ marginLeft: 'auto' }}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p style={{ color: '#666' }}>
|
||||
{job.address && <>{job.address} · </>}
|
||||
source {job.source} · created {new Date(job.createdAt).toLocaleString()}
|
||||
{job.assignedTo && <> · assigned to {job.assignedTo.name}</>}
|
||||
{job.dueAt && <> · locate by {new Date(job.dueAt).toLocaleString()}</>}
|
||||
</p>
|
||||
{job.description && <p>{job.description}</p>}
|
||||
|
||||
<div style={{ margin: '1rem 0', display: 'flex', gap: '1rem', color: '#555' }}>
|
||||
<span>
|
||||
<strong>{points.length}</strong> points
|
||||
</span>
|
||||
<span style={{ color: live > 0 ? '#388e3c' : '#999' }}>
|
||||
● live{live > 0 ? ` (+${live} this session)` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<JobMap points={points} heightPx={520} />
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
74
web/pages/jobs/new.tsx
Normal file
74
web/pages/jobs/new.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
|
||||
export default function NewJobPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({ ticketNumber: '', title: '', address: '', description: '', dueAt: '' });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const set = (key: keyof typeof form) => (e: { target: { value: string } }) =>
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }));
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!activeOrg) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const job = await api.post<{ id: string }>(`/api/orgs/${activeOrg.org.id}/jobs`, {
|
||||
ticketNumber: form.ticketNumber,
|
||||
title: form.title,
|
||||
address: form.address || undefined,
|
||||
description: form.description || undefined,
|
||||
dueAt: form.dueAt ? new Date(form.dueAt).toISOString() : undefined,
|
||||
});
|
||||
router.push(`/jobs/${job.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create job');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="New job">
|
||||
<h1>New job</h1>
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: 480 }}>
|
||||
<label>
|
||||
Ticket number *
|
||||
<input value={form.ticketNumber} onChange={set('ticketNumber')} required style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Title *
|
||||
<input value={form.title} onChange={set('title')} required style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Address / dig site
|
||||
<input value={form.address} onChange={set('address')} style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea value={form.description} onChange={set('description')} rows={4} style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Locate-by date
|
||||
<input type="datetime-local" value={form.dueAt} onChange={set('dueAt')} style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
{error && <p style={{ color: '#c62828', margin: 0 }}>{error}</p>}
|
||||
<button type="submit" disabled={submitting} style={{ padding: '0.6rem' }}>
|
||||
{submitting ? 'Creating…' : 'Create job'}
|
||||
</button>
|
||||
</form>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
72
web/pages/login.tsx
Normal file
72
web/pages/login.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { useAuth } from '../lib/auth-context';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, loading, login } = useAuth();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [loading, user, router]);
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
router.push('/');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: 'sans-serif', maxWidth: 360, margin: '10vh auto', padding: '0 1rem' }}>
|
||||
<Head>
|
||||
<title>Log in · UlHub</title>
|
||||
</Head>
|
||||
<h1>UlHub</h1>
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
/>
|
||||
</label>
|
||||
{error && <p style={{ color: '#c62828', margin: 0 }}>{error}</p>}
|
||||
<button type="submit" disabled={submitting} style={{ padding: '0.6rem' }}>
|
||||
{submitting ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
No account? <Link href="/register">Register</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
78
web/pages/register.tsx
Normal file
78
web/pages/register.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useAuth } from '../lib/auth-context';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { register } = useAuth();
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({ name: '', email: '', password: '', orgName: '' });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const set = (key: keyof typeof form) => (e: { target: { value: string } }) =>
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }));
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await register(form);
|
||||
router.push('/');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: 'sans-serif', maxWidth: 360, margin: '10vh auto', padding: '0 1rem' }}>
|
||||
<Head>
|
||||
<title>Register · UlHub</title>
|
||||
</Head>
|
||||
<h1>Create your account</h1>
|
||||
<p style={{ color: '#666' }}>Registering creates a new organization with you as its admin.</p>
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label>
|
||||
Your name
|
||||
<input value={form.name} onChange={set('name')} required style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={set('email')}
|
||||
required
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password (min 8 chars)
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={set('password')}
|
||||
required
|
||||
minLength={8}
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Organization name
|
||||
<input value={form.orgName} onChange={set('orgName')} required style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
{error && <p style={{ color: '#c62828', margin: 0 }}>{error}</p>}
|
||||
<button type="submit" disabled={submitting} style={{ padding: '0.6rem' }}>
|
||||
{submitting ? 'Creating…' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
Already registered? <Link href="/login">Log in</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
149
web/pages/settings/api-keys.tsx
Normal file
149
web/pages/settings/api-keys.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
|
||||
interface ApiKeyRow {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
scopes: string[];
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const ALL_SCOPES = ['jobs:read', 'jobs:write', 'points:read', 'points:write', 'devices:read'];
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const orgId = activeOrg?.org.id ?? null;
|
||||
const isAdmin = activeOrg?.role === 'ORG_ADMIN';
|
||||
|
||||
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [scopes, setScopes] = useState<string[]>(['jobs:read', 'points:read']);
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!orgId || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<ApiKeyRow[]>(`/api/orgs/${orgId}/api-keys`)
|
||||
.then((rows) => {
|
||||
setKeys(rows);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
}, [orgId, isAdmin]);
|
||||
|
||||
useEffect(reload, [reload]);
|
||||
|
||||
const toggleScope = (scope: string) =>
|
||||
setScopes((s) => (s.includes(scope) ? s.filter((x) => x !== scope) : [...s, scope]));
|
||||
|
||||
const createKey = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const created = await api.post<ApiKeyRow & { key: string }>(`/api/orgs/${orgId}/api-keys`, { name, scopes });
|
||||
setCreatedKey(created.key);
|
||||
setName('');
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (keyId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/orgs/${orgId}/api-keys/${keyId}`);
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Layout title="API Keys">
|
||||
<h1>API Keys</h1>
|
||||
<p>Only organization admins can manage API keys.</p>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="API Keys">
|
||||
<h1>API Keys</h1>
|
||||
<p style={{ color: '#666' }}>
|
||||
Send keys in the <code>X-API-Key</code> header. Access is limited to this organization and the selected scopes.
|
||||
</p>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
|
||||
{createdKey && (
|
||||
<div style={{ background: '#e8f5e9', border: '1px solid #388e3c', borderRadius: 6, padding: '1rem', margin: '1rem 0' }}>
|
||||
<strong>Copy this key now — it will not be shown again:</strong>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginTop: '0.5rem' }}>
|
||||
<code style={{ wordBreak: 'break-all' }}>{createdKey}</code>
|
||||
<button onClick={() => navigator.clipboard.writeText(createdKey)}>Copy</button>
|
||||
<button onClick={() => setCreatedKey(null)}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={createKey} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
|
||||
<input
|
||||
placeholder="key name (e.g. GIS export)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
style={{ padding: '0.4rem', width: 220 }}
|
||||
/>
|
||||
{ALL_SCOPES.map((scope) => (
|
||||
<label key={scope} style={{ whiteSpace: 'nowrap' }}>
|
||||
<input type="checkbox" checked={scopes.includes(scope)} onChange={() => toggleScope(scope)} /> {scope}
|
||||
</label>
|
||||
))}
|
||||
<button type="submit" disabled={scopes.length === 0}>
|
||||
Create key
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.5rem' }}>Name</th>
|
||||
<th>Prefix</th>
|
||||
<th>Scopes</th>
|
||||
<th>Last used</th>
|
||||
<th>Status</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem' }}>{k.name}</td>
|
||||
<td>
|
||||
<code>{k.keyPrefix}…</code>
|
||||
</td>
|
||||
<td>{k.scopes.join(', ')}</td>
|
||||
<td>{k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleString() : 'never'}</td>
|
||||
<td style={{ color: k.revokedAt ? '#c62828' : '#388e3c' }}>{k.revokedAt ? 'revoked' : 'active'}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{!k.revokedAt && <button onClick={() => revoke(k.id)}>Revoke</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
150
web/pages/settings/devices.tsx
Normal file
150
web/pages/settings/devices.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
|
||||
interface DeviceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
serialNumber: string | null;
|
||||
mqttUsername: string;
|
||||
isActive: boolean;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const orgId = activeOrg?.org.id ?? null;
|
||||
const isAdmin = activeOrg?.role === 'ORG_ADMIN';
|
||||
|
||||
const [devices, setDevices] = useState<DeviceRow[]>([]);
|
||||
const [form, setForm] = useState({ name: '', mqttUsername: '', serialNumber: '' });
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<DeviceRow[]>(`/api/orgs/${orgId}/devices`)
|
||||
.then((rows) => {
|
||||
setDevices(rows);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
}, [orgId]);
|
||||
|
||||
useEffect(reload, [reload]);
|
||||
|
||||
const addDevice = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const created = await api.post<DeviceRow & { provisioning: { pointsTopic: string } }>(
|
||||
`/api/orgs/${orgId}/devices`,
|
||||
{
|
||||
name: form.name,
|
||||
mqttUsername: form.mqttUsername,
|
||||
serialNumber: form.serialNumber || undefined,
|
||||
},
|
||||
);
|
||||
setNotice(`Device created. It should publish points to ${created.provisioning.pointsTopic}`);
|
||||
setForm({ name: '', mqttUsername: '', serialNumber: '' });
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (device: DeviceRow) => {
|
||||
try {
|
||||
await api.patch(`/api/orgs/${orgId}/devices/${device.id}`, { isActive: !device.isActive });
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (deviceId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/orgs/${orgId}/devices/${deviceId}`);
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="Devices">
|
||||
<h1>Devices</h1>
|
||||
<p style={{ color: '#666' }}>
|
||||
Field devices publish to <code>devices/<mqtt username>/points</code>. Broker credentials are provisioned
|
||||
separately for now.
|
||||
</p>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
{notice && <p style={{ color: '#388e3c' }}>{notice}</p>}
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={addDevice} style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
placeholder="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
style={{ padding: '0.4rem' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="mqtt username"
|
||||
value={form.mqttUsername}
|
||||
onChange={(e) => setForm((f) => ({ ...f, mqttUsername: e.target.value }))}
|
||||
required
|
||||
style={{ padding: '0.4rem' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="serial number (optional)"
|
||||
value={form.serialNumber}
|
||||
onChange={(e) => setForm((f) => ({ ...f, serialNumber: e.target.value }))}
|
||||
style={{ padding: '0.4rem' }}
|
||||
/>
|
||||
<button type="submit">Add device</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.5rem' }}>Name</th>
|
||||
<th>MQTT username</th>
|
||||
<th>Serial</th>
|
||||
<th>Status</th>
|
||||
<th>Last seen</th>
|
||||
{isAdmin && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((d) => (
|
||||
<tr key={d.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem' }}>{d.name}</td>
|
||||
<td>
|
||||
<code>{d.mqttUsername}</code>
|
||||
</td>
|
||||
<td>{d.serialNumber ?? '—'}</td>
|
||||
<td style={{ color: d.isActive ? '#388e3c' : '#9e9e9e' }}>{d.isActive ? 'active' : 'disabled'}</td>
|
||||
<td>{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'}</td>
|
||||
{isAdmin && (
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button onClick={() => toggleActive(d)}>{d.isActive ? 'Disable' : 'Enable'}</button>{' '}
|
||||
<button onClick={() => remove(d.id)}>Delete</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
138
web/pages/settings/members.tsx
Normal file
138
web/pages/settings/members.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
|
||||
interface MemberRow {
|
||||
role: string;
|
||||
createdAt: string;
|
||||
user: { id: string; email: string; name: string };
|
||||
}
|
||||
|
||||
const ROLES = ['ORG_ADMIN', 'MEMBER', 'VIEWER'];
|
||||
|
||||
export default function MembersPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const orgId = activeOrg?.org.id ?? null;
|
||||
const isAdmin = activeOrg?.role === 'ORG_ADMIN';
|
||||
|
||||
const [members, setMembers] = useState<MemberRow[]>([]);
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState('MEMBER');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<MemberRow[]>(`/api/orgs/${orgId}/members`)
|
||||
.then((rows) => {
|
||||
setMembers(rows);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
}, [orgId]);
|
||||
|
||||
useEffect(reload, [reload]);
|
||||
|
||||
const addMember = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post(`/api/orgs/${orgId}/members`, { email, role });
|
||||
setEmail('');
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const changeRole = async (userId: string, newRole: string) => {
|
||||
try {
|
||||
await api.patch(`/api/orgs/${orgId}/members/${userId}`, { role: newRole });
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (userId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/orgs/${orgId}/members/${userId}`);
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="Members">
|
||||
<h1>Members</h1>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={addMember} style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="registered user's email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={{ padding: '0.4rem', width: 280 }}
|
||||
/>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">Add member</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.5rem' }}>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Since</th>
|
||||
{isAdmin && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.user.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem' }}>{m.user.name}</td>
|
||||
<td>{m.user.email}</td>
|
||||
<td>
|
||||
{isAdmin && m.user.id !== user.id ? (
|
||||
<select value={m.role} onChange={(e) => changeRole(m.user.id, e.target.value)}>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
m.role
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(m.createdAt).toLocaleDateString()}</td>
|
||||
{isAdmin && (
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{m.user.id !== user.id && <button onClick={() => remove(m.user.id)}>Remove</button>}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user