Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,56 @@ jobs:

- name: Run Next.js build
env:
NEXT_PUBLIC_API_URL: http://localhost:4000/api
run: npm run build
NEXT_PUBLIC_API_URL: http://localhost:6003/api
run: npm run build

e2e:
name: Playwright E2E
runs-on: ubuntu-latest

defaults:
run:
working-directory: frontend

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Build the app
env:
NEXT_PUBLIC_API_URL: http://localhost:6003/api
run: npm run build

- name: Run Playwright tests
env:
NEXT_PUBLIC_API_URL: http://localhost:6003/api
run: npm run test:e2e

- name: Upload HTML report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report
retention-days: 7

- name: Upload traces
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: frontend/test-results
retention-days: 7
5 changes: 3 additions & 2 deletions frontend/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useAuthStore } from '@/store/auth.store';
import { getSafeRedirect } from '@/lib/utils/redirect-validation';

const schema = z.object({
email: z.string().email('Enter a valid email address'),
Expand All @@ -32,7 +33,7 @@ function LoginForm() {
const onSubmit = async (values: FormValues) => {
try {
await login(values);
const redirect = searchParams.get('redirect') || '/dashboard';
const redirect = getSafeRedirect(searchParams.get('redirect'));
router.push(redirect);
} catch (err: unknown) {
const message =
Expand Down Expand Up @@ -104,4 +105,4 @@ export default function LoginPage() {
<LoginForm />
</Suspense>
);
}
}
58 changes: 32 additions & 26 deletions frontend/app/(dashboard)/reports/builder/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { Download, Plus, BarChart3, Table as TableIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { savedReportsApiClient, type SavedReport } from "@/lib/api/saved-reports";

const DATASETS = ["assets", "maintenance", "transfers", "activity"] as const;
const GROUP_BY_OPTIONS = ["status", "category", "department", "month"] as const;

interface SavedReport {
id: string;
name: string;
dataset: string;
filters: Record<string, string>;
groupBy: string;
}

export default function ReportBuilderPage() {
const [dataset, setDataset] = useState<string>("assets");
const [filters, setFilters] = useState<Record<string, string>>({});
Expand All @@ -27,23 +18,43 @@ export default function ReportBuilderPage() {
const [showSave, setShowSave] = useState(false);
const [viewMode, setViewMode] = useState<"table" | "chart">("table");

// Load saved reports from the API on mount
useEffect(() => {
savedReportsApiClient
.list()
.then(setSavedReports)
.catch(() => setSavedReports([]));
}, []);

const addFilter = () => setFilters((prev) => ({ ...prev, "": "" }));

const saveReport = () => {
const saveReport = async () => {
if (!reportName) return;
setSavedReports((prev) => [...prev, {
id: Date.now().toString(),
name: reportName,
dataset,
filters: { ...filters },
groupBy,
}]);
try {
const created = await savedReportsApiClient.create({
name: reportName,
dataset,
filters: { ...filters },
groupBy,
viewMode,
});
setSavedReports((prev) => [...prev, created]);
} catch {
// Fallback: store locally if the API is not available yet
setSavedReports((prev) => [...prev, {
id: Date.now().toString(),
name: reportName,
dataset,
filters: { ...filters },
groupBy,
viewMode,
}]);
}
setShowSave(false);
setReportName("");
};

const exportCSV = () => {
// Placeholder — would generate CSV from preview data
const blob = new Blob(["Not yet connected to live data"], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
Expand All @@ -52,7 +63,6 @@ export default function ReportBuilderPage() {
};

const exportPDF = async () => {
// Placeholder — would use jsPDF to render preview
alert("PDF export coming soon");
};

Expand All @@ -71,7 +81,6 @@ export default function ReportBuilderPage() {
</div>

<div className="grid grid-cols-12 gap-6">
{/* Sidebar — compose */}
<div className="col-span-3 bg-white border rounded-xl p-4 space-y-4">
<h3 className="font-medium text-sm text-gray-700">Dataset</h3>
<select value={dataset} onChange={(e) => setDataset(e.target.value)} className="w-full border rounded-lg px-3 py-2 text-sm">
Expand Down Expand Up @@ -99,7 +108,6 @@ export default function ReportBuilderPage() {
</select>
</div>

{/* Main — preview */}
<div className="col-span-9 space-y-4">
<div className="flex items-center gap-2">
<Button variant={viewMode === "table" ? "default" : "outline"} size="sm" onClick={() => setViewMode("table")}><TableIcon className="w-4 h-4 mr-1" /> Table</Button>
Expand All @@ -111,7 +119,6 @@ export default function ReportBuilderPage() {
</div>
</div>

{/* Saved reports list */}
{savedReports.length > 0 && (
<div className="mt-8">
<h3 className="font-medium text-gray-700 mb-3">Saved Reports</h3>
Expand All @@ -126,7 +133,6 @@ export default function ReportBuilderPage() {
</div>
)}

{/* Save dialog */}
{showSave && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/40" onClick={() => setShowSave(false)} />
Expand All @@ -142,4 +148,4 @@ export default function ReportBuilderPage() {
)}
</div>
);
}
}
22 changes: 7 additions & 15 deletions frontend/app/(dashboard)/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,7 @@
import { useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { Search, Package, Users, FileText, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";

interface SearchResult {
id: string;
type: "asset" | "user" | "document";
title: string;
subtitle: string;
href: string;
}
import { searchApiClient, type SearchResult } from "@/lib/api/search";

const TYPE_ICONS: Record<string, typeof Package> = {
asset: Package,
Expand All @@ -36,16 +28,16 @@ export default function SearchPage() {

useEffect(() => {
if (!query.trim()) { setResults([]); return; }
const controller = new AbortController();
const timer = setTimeout(() => {
setLoading(true);
// In real implementation, calls GET /api/search?q=...
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then((r) => r.json())
.then((data) => setResults(data.results ?? []))
searchApiClient
.search(query)
.then((data) => setResults(data))
.catch(() => setResults([]))
.finally(() => setLoading(false));
}, 300);
return () => clearTimeout(timer);
return () => { clearTimeout(timer); controller.abort(); };
}, [query]);

const filtered = activeType === "all" ? results : results.filter((r) => r.type === activeType);
Expand Down Expand Up @@ -135,4 +127,4 @@ export default function SearchPage() {
</div>
</div>
);
}
}
35 changes: 35 additions & 0 deletions frontend/lib/api/saved-reports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { api } from '../api';

export interface SavedReport {
id: string;
name: string;
dataset: string;
filters: Record<string, string>;
groupBy: string;
viewMode: string;
}

export interface CreateSavedReportInput {
name: string;
dataset: string;
filters: Record<string, string>;
groupBy: string;
viewMode: string;
}

export const savedReportsApiClient = {
list: (): Promise<SavedReport[]> =>
api.get<SavedReport[]>('/saved-reports').then((r) => r.data),

create: (data: CreateSavedReportInput): Promise<SavedReport> =>
api.post<SavedReport>('/saved-reports', data).then((r) => r.data),

rename: (id: string, name: string): Promise<SavedReport> =>
api.patch<SavedReport>(`/saved-reports/${id}`, { name }).then((r) => r.data),

delete: (id: string): Promise<void> =>
api.delete(`/saved-reports/${id}`).then(() => undefined),

duplicate: (id: string): Promise<SavedReport> =>
api.post<SavedReport>(`/saved-reports/${id}/duplicate`).then((r) => r.data),
};
20 changes: 20 additions & 0 deletions frontend/lib/api/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { api } from '../api';

export interface SearchResult {
id: string;
type: 'asset' | 'user' | 'document';
title: string;
subtitle: string;
href: string;
}

export interface SearchResponse {
results: SearchResult[];
}

export const searchApiClient = {
search: (query: string): Promise<SearchResult[]> =>
api
.get<SearchResponse>('/search', { params: { q: query } })
.then((r) => r.data.results ?? []),
};
35 changes: 35 additions & 0 deletions frontend/lib/utils/redirect-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Validates a redirect URL to prevent open-redirect attacks.
* Only same-origin relative paths are allowed.
*
* Rules:
* - Must start with a single "/"
* - Must not start with "//" (protocol-relative)
* - Must not contain backslashes
* - Must not contain a scheme (e.g. "https:", "http:")
* - Falls back to "/dashboard" if invalid
*/
export function getSafeRedirect(redirect: string | null): string {
const fallback = '/dashboard';

if (!redirect) return fallback;

// Reject anything that doesn't start with "/"
if (!redirect.startsWith('/')) return fallback;

// Reject protocol-relative URLs ("//evil.example")
if (redirect.startsWith('//')) return fallback;

// Reject backslashes (e.g. "\evil.example" or "/\evil.example")
if (redirect.includes('\\')) return fallback;

// Reject encoded variants that decode to a scheme or protocol-relative path
const decoded = decodeURIComponent(redirect);
if (decoded.includes('://')) return fallback;
if (decoded.startsWith('//')) return fallback;

// Reject any scheme prefix (e.g. "javascript:", "data:")
if (/[a-zA-Z][a-zA-Z0-9+.-]*:/.test(redirect.split('/')[0])) return fallback;

return redirect;
}
Loading