From df639669710ffa21413a6b246f47bc1a3484255f Mon Sep 17 00:00:00 2001 From: S-Mubarak Date: Tue, 24 Mar 2026 10:35:00 +0100 Subject: [PATCH] feat: add admin dashboard and mobile app shell --- .../src/modules/reports/reports.controller.ts | 132 +++++++ .../api/src/modules/reports/reports.routes.ts | 20 + .../src/modules/reports/reports.schemas.ts | 42 +++ apps/mobile/app/(app)/(tabs)/_layout.tsx | 16 + apps/mobile/app/(app)/(tabs)/index.tsx | 55 +++ apps/mobile/app/(app)/(tabs)/reports.tsx | 32 ++ apps/mobile/app/(app)/(tabs)/settings.tsx | 38 ++ apps/mobile/app/(app)/_layout.tsx | 11 + apps/mobile/app/(auth)/_layout.tsx | 11 + apps/mobile/app/(auth)/login.tsx | 53 +++ apps/mobile/app/_layout.tsx | 13 +- apps/mobile/app/index.tsx | 58 +-- apps/mobile/app/lib/api.ts | 19 + .../mobile/app/providers/session-provider.tsx | 32 ++ .../app/_components/protected-app-shell.tsx | 108 ++++++ .../app/auth/_components/request-otp-form.tsx | 79 ++++ .../app/auth/_components/verify-otp-form.tsx | 134 +++++++ apps/web/app/auth/request-otp/page.tsx | 19 + apps/web/app/auth/verify-otp/page.tsx | 19 + apps/web/app/dashboard/admin/page.tsx | 20 + apps/web/app/dashboard/admin/queue.tsx | 132 +++++++ apps/web/app/dashboard/layout.tsx | 7 + apps/web/app/dashboard/page.tsx | 29 ++ .../app/dashboard/reports/[reportId]/page.tsx | 26 ++ .../reports/[reportId]/report-detail.tsx | 138 +++++++ apps/web/app/dashboard/reports/new/page.tsx | 20 + .../reports/new/report-submission-form.tsx | 152 ++++++++ apps/web/app/dashboard/reports/page.tsx | 20 + .../app/dashboard/reports/reports-list.tsx | 151 ++++++++ apps/web/app/globals.css | 344 ++++++++++++++++++ apps/web/app/health/page.tsx | 81 +++++ apps/web/app/layout.tsx | 18 + apps/web/app/lib/api.ts | 4 + apps/web/app/lib/auth-fetch.ts | 64 ++++ apps/web/app/lib/auth-storage.ts | 80 ++++ apps/web/app/page.tsx | 56 +++ apps/web/eslint.config.mjs | 23 ++ apps/web/next-env.d.ts | 6 + apps/web/next.config.ts | 7 + apps/web/package.json | 26 ++ apps/web/tsconfig.json | 25 ++ 41 files changed, 2263 insertions(+), 57 deletions(-) create mode 100644 apps/mobile/app/(app)/(tabs)/_layout.tsx create mode 100644 apps/mobile/app/(app)/(tabs)/index.tsx create mode 100644 apps/mobile/app/(app)/(tabs)/reports.tsx create mode 100644 apps/mobile/app/(app)/(tabs)/settings.tsx create mode 100644 apps/mobile/app/(app)/_layout.tsx create mode 100644 apps/mobile/app/(auth)/_layout.tsx create mode 100644 apps/mobile/app/(auth)/login.tsx create mode 100644 apps/mobile/app/lib/api.ts create mode 100644 apps/mobile/app/providers/session-provider.tsx create mode 100644 apps/web/app/_components/protected-app-shell.tsx create mode 100644 apps/web/app/auth/_components/request-otp-form.tsx create mode 100644 apps/web/app/auth/_components/verify-otp-form.tsx create mode 100644 apps/web/app/auth/request-otp/page.tsx create mode 100644 apps/web/app/auth/verify-otp/page.tsx create mode 100644 apps/web/app/dashboard/admin/page.tsx create mode 100644 apps/web/app/dashboard/admin/queue.tsx create mode 100644 apps/web/app/dashboard/layout.tsx create mode 100644 apps/web/app/dashboard/page.tsx create mode 100644 apps/web/app/dashboard/reports/[reportId]/page.tsx create mode 100644 apps/web/app/dashboard/reports/[reportId]/report-detail.tsx create mode 100644 apps/web/app/dashboard/reports/new/page.tsx create mode 100644 apps/web/app/dashboard/reports/new/report-submission-form.tsx create mode 100644 apps/web/app/dashboard/reports/page.tsx create mode 100644 apps/web/app/dashboard/reports/reports-list.tsx create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/health/page.tsx create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/lib/api.ts create mode 100644 apps/web/app/lib/auth-fetch.ts create mode 100644 apps/web/app/lib/auth-storage.ts create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/eslint.config.mjs create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package.json create mode 100644 apps/web/tsconfig.json diff --git a/apps/api/src/modules/reports/reports.controller.ts b/apps/api/src/modules/reports/reports.controller.ts index 01984b6..2313b24 100644 --- a/apps/api/src/modules/reports/reports.controller.ts +++ b/apps/api/src/modules/reports/reports.controller.ts @@ -6,8 +6,11 @@ import { logger } from '../../core/logging/logger'; import { MediaUploadModel } from '../media/media-upload.model'; import { ReportModel } from './report.model'; import { enqueueStellarAnchor } from './reports.anchor.queue'; +import { StatusUpdateModel } from './status-update.model'; import { CreateReportDTO, + ReportDetailParamsDTO, + ReportListQueryDTO, ReportsMapQueryDTO, UpdateReportStatusDTO, VerifyReportDTO, @@ -298,3 +301,132 @@ export const getMapReports = async ( return next(error); } }; + +export const getReportList = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const query = req.query as unknown as ReportListQueryDTO; + const filter: Record = {}; + + if (query.status) { + filter.status = query.status; + } + + if (query.category) { + filter.category = query.category; + } + + if (query.mine && req.user?.id) { + filter.reporter_user_id = req.user.id; + } + + const page = query.page; + const pageSize = query.pageSize; + + const [reports, total] = await Promise.all([ + ReportModel.find(filter) + .sort({ createdAt: -1 }) + .skip((page - 1) * pageSize) + .limit(pageSize) + .lean() + .exec(), + ReportModel.countDocuments(filter), + ]); + const typedReports = reports as Array<{ + _id: unknown; + title: string; + category: string; + status: string; + anchor_status: string; + integrity_flag: string; + location: { type: 'Point'; coordinates: [number, number] }; + createdAt?: Date; + }>; + + return res.status(200).json({ + page, + pageSize, + total, + data: typedReports.map((report) => ({ + id: String(report._id), + title: report.title, + category: report.category, + status: report.status, + anchor_status: report.anchor_status, + integrity_flag: report.integrity_flag, + created_at: report.createdAt ?? null, + location: report.location, + })), + }); + } catch (error) { + return next(error); + } +}; + +export const getReportDetail = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const { reportId } = req.params as unknown as ReportDetailParamsDTO; + + const report = await ReportModel.findById(reportId).lean(); + if (!report) { + throw new AppError('Report not found', 404, 'REPORT_NOT_FOUND'); + } + const typedReport = report as typeof report & { + createdAt?: Date; + updatedAt?: Date; + }; + + const statusUpdates = await StatusUpdateModel.find({ reportId: report._id }) + .sort({ createdAt: -1 }) + .lean() + .exec(); + const typedStatusUpdates = statusUpdates as Array<{ + nextStatus: string; + note?: string | null; + createdAt?: Date; + }>; + + return res.status(200).json({ + id: String(report._id), + title: report.title, + description: report.description, + category: report.category, + status: report.status, + anchor_status: report.anchor_status, + anchor_attempts: report.anchor_attempts, + anchor_last_error: report.anchor_last_error, + integrity_flag: report.integrity_flag, + exif_verified: report.exif_verified, + exif_distance_meters: report.exif_distance_meters, + stellar_tx_hash: report.stellar_tx_hash, + snapshot_hash: report.snapshot_hash, + created_at: typedReport.createdAt ?? null, + updated_at: typedReport.updatedAt ?? null, + location: report.location, + media_urls: report.media_urls, + history: [ + { + type: 'CREATED', + status: 'PENDING', + note: 'Report submitted', + createdAt: typedReport.createdAt ?? null, + }, + ...typedStatusUpdates.map((update) => ({ + type: 'STATUS_UPDATE', + status: update.nextStatus, + note: update.note ?? null, + createdAt: update.createdAt ?? null, + })), + ], + }); + } catch (error) { + return next(error); + } +}; diff --git a/apps/api/src/modules/reports/reports.routes.ts b/apps/api/src/modules/reports/reports.routes.ts index c415e76..09366b5 100644 --- a/apps/api/src/modules/reports/reports.routes.ts +++ b/apps/api/src/modules/reports/reports.routes.ts @@ -1,6 +1,8 @@ import { Router } from 'express'; import { createReport, + getReportDetail, + getReportList, getMapReports, verifyReport, updateReportStatus, @@ -11,6 +13,8 @@ import { validateRequest } from '../../core/validation/validate-request'; import { stellarAnchoringRateLimiter } from '../../core/rate-limit/rate-limit.middleware'; import { createReportBodySchema, + reportDetailParamsSchema, + reportListQuerySchema, reportsMapQuerySchema, updateReportStatusBodySchema, verifyReportBodySchema, @@ -19,6 +23,22 @@ import { const router: Router = Router(); +router.get( + '/', + authenticateToken, + requireRole(['CITIZEN', 'AGENCY_ADMIN']), + validateRequest({ query: reportListQuerySchema }), + getReportList, +); + +router.get( + '/:reportId', + authenticateToken, + requireRole(['CITIZEN', 'AGENCY_ADMIN']), + validateRequest({ params: reportDetailParamsSchema }), + getReportDetail, +); + router.get( '/map', authenticateToken, diff --git a/apps/api/src/modules/reports/reports.schemas.ts b/apps/api/src/modules/reports/reports.schemas.ts index 1c8dc56..ee3d1f4 100644 --- a/apps/api/src/modules/reports/reports.schemas.ts +++ b/apps/api/src/modules/reports/reports.schemas.ts @@ -123,8 +123,50 @@ const boundsQuerySchema = z export const reportsMapQuerySchema = z.union([radiusQuerySchema, boundsQuerySchema]); +const optionalTrimmed = () => + z + .string({ + invalid_type_error: 'value must be a string', + }) + .trim() + .optional() + .transform((value) => (value && value.length > 0 ? value : undefined)); + +const positiveInt = (field: string, fallback: number) => + z + .string({ + invalid_type_error: `${field} must be a number`, + }) + .trim() + .optional() + .transform((value) => { + if (!value) { + return fallback; + } + + return Number(value); + }) + .refine((value) => Number.isInteger(value) && value > 0, `${field} must be a positive integer`); + +export const reportListQuerySchema = z.object({ + page: positiveInt('page', 1), + pageSize: positiveInt('pageSize', 20).refine((value) => value <= 100, 'pageSize must be <= 100'), + status: optionalTrimmed(), + category: optionalTrimmed(), + mine: z + .enum(['true', 'false']) + .optional() + .transform((value) => value === 'true'), +}); + +export const reportDetailParamsSchema = z.object({ + reportId: trimmed('reportId'), +}); + export type CreateReportDTO = z.infer; export type VerifyReportDTO = z.infer; export type UpdateReportStatusDTO = z.infer; export type VerifyStatusDTO = z.infer; export type ReportsMapQueryDTO = z.infer; +export type ReportListQueryDTO = z.infer; +export type ReportDetailParamsDTO = z.infer; diff --git a/apps/mobile/app/(app)/(tabs)/_layout.tsx b/apps/mobile/app/(app)/(tabs)/_layout.tsx new file mode 100644 index 0000000..c83e927 --- /dev/null +++ b/apps/mobile/app/(app)/(tabs)/_layout.tsx @@ -0,0 +1,16 @@ +import { Tabs } from 'expo-router'; + +export default function TabsLayout() { + return ( + + + + + + ); +} diff --git a/apps/mobile/app/(app)/(tabs)/index.tsx b/apps/mobile/app/(app)/(tabs)/index.tsx new file mode 100644 index 0000000..5bee0d9 --- /dev/null +++ b/apps/mobile/app/(app)/(tabs)/index.tsx @@ -0,0 +1,55 @@ +import { Link } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function HomeScreen() { + return ( + + Mobile Foundation + Signed-in app shell. + + Tabs, routing, environment-aware API helpers, and session context are in place for + report and auth flows. + + + Open reports tab + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + backgroundColor: '#fffaf2', + }, + eyebrow: { + textTransform: 'uppercase', + letterSpacing: 2, + color: '#2f5d50', + marginBottom: 12, + fontWeight: '700', + }, + title: { + fontSize: 34, + fontWeight: '700', + color: '#112219', + }, + copy: { + marginTop: 16, + color: '#405149', + lineHeight: 22, + }, + secondaryButton: { + marginTop: 24, + borderColor: '#d9d0bf', + borderWidth: 1, + color: '#112219', + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 999, + overflow: 'hidden', + fontWeight: '700', + }, +}); diff --git a/apps/mobile/app/(app)/(tabs)/reports.tsx b/apps/mobile/app/(app)/(tabs)/reports.tsx new file mode 100644 index 0000000..8e7f013 --- /dev/null +++ b/apps/mobile/app/(app)/(tabs)/reports.tsx @@ -0,0 +1,32 @@ +import { StyleSheet, Text, View } from 'react-native'; + +export default function ReportsPlaceholderScreen() { + return ( + + Reports tab placeholder + + This tab is reserved for mobile report submission and history screens in the next + issue batches. + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + backgroundColor: '#fff', + }, + title: { + fontSize: 28, + fontWeight: '700', + color: '#112219', + }, + copy: { + marginTop: 12, + color: '#51615a', + lineHeight: 22, + }, +}); diff --git a/apps/mobile/app/(app)/(tabs)/settings.tsx b/apps/mobile/app/(app)/(tabs)/settings.tsx new file mode 100644 index 0000000..f116f1f --- /dev/null +++ b/apps/mobile/app/(app)/(tabs)/settings.tsx @@ -0,0 +1,38 @@ +import { Link } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function SettingsPlaceholderScreen() { + return ( + + Settings placeholder + Session controls and profile settings will land here. + + Return to auth shell + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + backgroundColor: '#fff', + }, + title: { + fontSize: 28, + fontWeight: '700', + color: '#112219', + }, + copy: { + marginTop: 12, + color: '#51615a', + lineHeight: 22, + }, + link: { + marginTop: 20, + color: '#1f4d3f', + fontWeight: '700', + }, +}); diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx new file mode 100644 index 0000000..9ebb11e --- /dev/null +++ b/apps/mobile/app/(app)/_layout.tsx @@ -0,0 +1,11 @@ +import { Stack } from 'expo-router'; + +export default function AppLayout() { + return ( + + ); +} diff --git a/apps/mobile/app/(auth)/_layout.tsx b/apps/mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000..149c586 --- /dev/null +++ b/apps/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,11 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ( + + ); +} diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx new file mode 100644 index 0000000..8fc7753 --- /dev/null +++ b/apps/mobile/app/(auth)/login.tsx @@ -0,0 +1,53 @@ +import { Link } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function LoginScreen() { + return ( + + Sidewalk Mobile + Auth shell ready. + + This route is the foundation for OTP login and session restore in the next issue batch. + + + Continue to app shell + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + backgroundColor: '#f4efe6', + }, + eyebrow: { + textTransform: 'uppercase', + letterSpacing: 2, + color: '#2f5d50', + marginBottom: 12, + fontWeight: '700', + }, + title: { + fontSize: 36, + fontWeight: '700', + color: '#112219', + }, + copy: { + marginTop: 16, + color: '#405149', + lineHeight: 22, + }, + primaryButton: { + marginTop: 28, + backgroundColor: '#1f4d3f', + color: '#f8fff8', + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 999, + overflow: 'hidden', + fontWeight: '700', + }, +}); diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index d2a8b0b..6bd59dd 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,5 +1,14 @@ -import { Stack } from "expo-router"; +import { Stack } from 'expo-router'; +import { SessionProvider } from './providers/session-provider'; export default function RootLayout() { - return ; + return ( + + + + ); } diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 703397b..11805fd 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,57 +1,5 @@ -import { StatusBar } from 'expo-status-bar'; -import { useEffect, useState } from 'react'; -import { StyleSheet, Text, View, ActivityIndicator } from 'react-native'; +import { Redirect } from 'expo-router'; -export default function App() { - const [status, setStatus] = useState('Checking...'); - const [loading, setLoading] = useState(true); - - const API_URL = 'http://localhost:5001/api/health'; - - useEffect(() => { - fetch(API_URL) - .then((res) => res.json()) - .then((data) => { - setStatus(`API: ${data.status}\nStellar: ${data.stellar_connected}`); - setLoading(false); - }) - .catch((err) => { - setStatus('Error connecting to API'); - console.error(err); - setLoading(false); - }); - }, []); - - return ( - - Sidewalk 🌍 - - {loading ? ( - - ) : ( - {status} - )} - - - - ); +export default function Index() { + return ; } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, - title: { - fontSize: 24, - fontWeight: 'bold', - marginBottom: 20, - }, - status: { - fontSize: 16, - textAlign: 'center', - color: '#333', - }, -}); diff --git a/apps/mobile/app/lib/api.ts b/apps/mobile/app/lib/api.ts new file mode 100644 index 0000000..24f5158 --- /dev/null +++ b/apps/mobile/app/lib/api.ts @@ -0,0 +1,19 @@ +import Constants from 'expo-constants'; + +const configuredUrl = + Constants.expoConfig?.extra?.apiBaseUrl ?? + process.env.EXPO_PUBLIC_API_BASE_URL ?? + 'http://localhost:5001'; + +export const apiBaseUrl = configuredUrl.replace(/\/+$/, ''); + +export const apiFetch = async (path: string, init?: RequestInit): Promise => { + const response = await fetch(`${apiBaseUrl}${path}`, init); + const payload = (await response.json()) as T & { error?: { message?: string } }; + + if (!response.ok) { + throw new Error(payload.error?.message ?? 'Request failed'); + } + + return payload; +}; diff --git a/apps/mobile/app/providers/session-provider.tsx b/apps/mobile/app/providers/session-provider.tsx new file mode 100644 index 0000000..a1c417f --- /dev/null +++ b/apps/mobile/app/providers/session-provider.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { createContext, ReactNode, useContext, useMemo, useState } from 'react'; + +type SessionState = { + accessToken: string | null; + setAccessToken: (token: string | null) => void; +}; + +const SessionContext = createContext(null); + +export function SessionProvider({ children }: Readonly<{ children: ReactNode }>) { + const [accessToken, setAccessToken] = useState(null); + const value = useMemo( + () => ({ + accessToken, + setAccessToken, + }), + [accessToken], + ); + + return {children}; +} + +export const useSession = () => { + const context = useContext(SessionContext); + if (!context) { + throw new Error('useSession must be used within SessionProvider'); + } + + return context; +}; diff --git a/apps/web/app/_components/protected-app-shell.tsx b/apps/web/app/_components/protected-app-shell.tsx new file mode 100644 index 0000000..991e327 --- /dev/null +++ b/apps/web/app/_components/protected-app-shell.tsx @@ -0,0 +1,108 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { ReactNode, useEffect, useState } from 'react'; +import { getApiBaseUrl } from '../lib/api'; +import { + clearSession, + getAccessToken, + getStoredRole, + persistSession, +} from '../lib/auth-storage'; + +export function ProtectedAppShell({ children }: Readonly<{ children: ReactNode }>) { + const router = useRouter(); + const [isReady, setIsReady] = useState(false); + const [role, setRole] = useState(null); + + useEffect(() => { + let cancelled = false; + + const ensureSession = async () => { + const accessToken = getAccessToken(); + if (accessToken) { + if (!cancelled) { + setRole(getStoredRole()); + setIsReady(true); + } + return; + } + + try { + const response = await fetch(`${getApiBaseUrl()}/api/auth/refresh`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + clientType: 'web', + deviceId: 'web-browser', + }), + }); + + const payload = (await response.json()) as { accessToken?: string; expiresIn?: string }; + if (!response.ok || !payload.accessToken) { + throw new Error('refresh_failed'); + } + + persistSession({ + accessToken: payload.accessToken, + expiresIn: payload.expiresIn ?? '15m', + }); + + if (!cancelled) { + setRole(getStoredRole()); + setIsReady(true); + } + } catch { + clearSession(); + router.replace('/auth/request-otp'); + } + }; + + void ensureSession(); + + return () => { + cancelled = true; + }; + }, [router]); + + if (!isReady) { + return ( +
+
+

Session

+

Restoring web session.

+

Checking for a stored access token or refresh cookie.

+
+
+ ); + } + + return ( + <> +
+
+

Sidewalk Web

+ Signed in{role ? ` as ${role.toLowerCase()}` : ''} +
+ +
+ {children} + + ); +} diff --git a/apps/web/app/auth/_components/request-otp-form.tsx b/apps/web/app/auth/_components/request-otp-form.tsx new file mode 100644 index 0000000..ef9ac59 --- /dev/null +++ b/apps/web/app/auth/_components/request-otp-form.tsx @@ -0,0 +1,79 @@ +'use client'; + +import Link from 'next/link'; +import { FormEvent, useState } from 'react'; +import { getApiBaseUrl } from '../../lib/api'; + +export function RequestOtpForm() { + const [email, setEmail] = useState(''); + const [expiresInSeconds, setExpiresInSeconds] = useState(null); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + setError(null); + setExpiresInSeconds(null); + + try { + const response = await fetch(`${getApiBaseUrl()}/api/auth/request-otp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + + const payload = (await response.json()) as { + expiresInSeconds?: number; + error?: { message?: string }; + }; + + if (!response.ok) { + throw new Error(payload.error?.message ?? 'Unable to request OTP'); + } + + setExpiresInSeconds(payload.expiresInSeconds ?? null); + if (typeof window !== 'undefined') { + window.localStorage.setItem('sidewalk:auth-email', email); + } + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to request OTP'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ + + +
+ + {expiresInSeconds ? ( +

+ OTP issued. It expires in about {Math.ceil(expiresInSeconds / 60)} minutes. +

+ ) : null} + {error ?

{error}

: null} + +

+ Already have a code? Verify OTP +

+
+ ); +} diff --git a/apps/web/app/auth/_components/verify-otp-form.tsx b/apps/web/app/auth/_components/verify-otp-form.tsx new file mode 100644 index 0000000..8ed4d87 --- /dev/null +++ b/apps/web/app/auth/_components/verify-otp-form.tsx @@ -0,0 +1,134 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { FormEvent, useEffect, useState } from 'react'; +import { persistSession } from '../../lib/auth-storage'; +import { getApiBaseUrl } from '../../lib/api'; + +const defaultDeviceId = 'web-browser'; + +export function VerifyOtpForm() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [district, setDistrict] = useState(''); + const [role, setRole] = useState<'CITIZEN' | 'AGENCY_ADMIN'>('CITIZEN'); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const storedEmail = window.localStorage.getItem('sidewalk:auth-email'); + if (storedEmail) { + setEmail(storedEmail); + } + }, []); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + setError(null); + + try { + const response = await fetch(`${getApiBaseUrl()}/api/auth/verify-otp`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + code, + district: district || undefined, + role, + deviceId: defaultDeviceId, + clientType: 'web', + }), + }); + + const payload = (await response.json()) as { + accessToken?: string; + expiresIn?: string; + error?: { message?: string }; + }; + + if (!response.ok || !payload.accessToken) { + throw new Error(payload.error?.message ?? 'Unable to verify OTP'); + } + + persistSession({ + accessToken: payload.accessToken, + email, + expiresIn: payload.expiresIn ?? '15m', + role, + }); + + router.replace('/dashboard'); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to verify OTP'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ + + + + + + + + +
+ + {error ?

{error}

: null} + +

+ Need a new code? Request OTP +

+
+ ); +} diff --git a/apps/web/app/auth/request-otp/page.tsx b/apps/web/app/auth/request-otp/page.tsx new file mode 100644 index 0000000..9b60d1c --- /dev/null +++ b/apps/web/app/auth/request-otp/page.tsx @@ -0,0 +1,19 @@ +import { RequestOtpForm } from '../_components/request-otp-form'; + +export default function RequestOtpPage() { + return ( +
+
+

Web Auth

+

Request a login code.

+

+ Start a passwordless session for citizen or agency dashboards using the existing + OTP endpoints in the API. +

+
+
+ +
+
+ ); +} diff --git a/apps/web/app/auth/verify-otp/page.tsx b/apps/web/app/auth/verify-otp/page.tsx new file mode 100644 index 0000000..df8aefa --- /dev/null +++ b/apps/web/app/auth/verify-otp/page.tsx @@ -0,0 +1,19 @@ +import { VerifyOtpForm } from '../_components/verify-otp-form'; + +export default function VerifyOtpPage() { + return ( +
+
+

Web Auth

+

Verify your login code.

+

+ Complete OTP verification, store the session locally, and bootstrap protected + dashboard routes without relying on future auth work. +

+
+
+ +
+
+ ); +} diff --git a/apps/web/app/dashboard/admin/page.tsx b/apps/web/app/dashboard/admin/page.tsx new file mode 100644 index 0000000..8433aee --- /dev/null +++ b/apps/web/app/dashboard/admin/page.tsx @@ -0,0 +1,20 @@ +import { AdminQueue } from './queue'; + +export default function AdminDashboardPage() { + return ( +
+
+

Admin Queue

+

Triage incoming reports.

+

+ Filter recent reports, review integrity state, and anchor a status update from a + single moderation screen. +

+
+ +
+ +
+
+ ); +} diff --git a/apps/web/app/dashboard/admin/queue.tsx b/apps/web/app/dashboard/admin/queue.tsx new file mode 100644 index 0000000..ab2d76a --- /dev/null +++ b/apps/web/app/dashboard/admin/queue.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { FormEvent, useEffect, useState } from 'react'; +import { authenticatedJsonFetch } from '../../lib/auth-fetch'; + +type QueueReport = { + id: string; + title: string; + category: string; + status: string; + anchor_status: string; + integrity_flag: string; +}; + +type QueueResponse = { + data: QueueReport[]; +}; + +const nextStatuses = ['ACKNOWLEDGED', 'RESOLVED', 'REJECTED', 'ESCALATED']; + +export function AdminQueue() { + const [reports, setReports] = useState([]); + const [selectedReportId, setSelectedReportId] = useState(''); + const [status, setStatus] = useState('ACKNOWLEDGED'); + const [stellarTxHash, setStellarTxHash] = useState(''); + const [evidence, setEvidence] = useState(''); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const loadQueue = async () => { + try { + const payload = await authenticatedJsonFetch('/api/reports?page=1&pageSize=10'); + if (!cancelled) { + setReports(payload.data); + setSelectedReportId(payload.data[0]?.id ?? ''); + } + } catch (loadError) { + if (!cancelled) { + setError(loadError instanceof Error ? loadError.message : 'Unable to load moderation queue'); + } + } + }; + + void loadQueue(); + + return () => { + cancelled = true; + }; + }, []); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setMessage(null); + + try { + await authenticatedJsonFetch('/api/reports/status', { + method: 'POST', + body: JSON.stringify({ + originalTxHash: stellarTxHash, + status, + evidence: evidence || undefined, + }), + }); + setMessage('Status update anchored successfully.'); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to submit status update'); + } + }; + + return ( + <> +
+ {reports.map((report) => ( +
+

{report.category}

+

{report.title}

+

+ {report.status} · {report.anchor_status} +

+ {report.integrity_flag !== 'NORMAL' ? ( +

Integrity flag: {report.integrity_flag}

+ ) : null} + +
+ ))} +
+ +
+
+ + + +