diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index d61c29f21..65f3a1fa2 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -39,5 +39,56 @@ jobs: - name: Run Next.js build env: - NEXT_PUBLIC_API_URL: http://localhost:4000/api - run: npm run build \ No newline at end of file + 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 \ No newline at end of file diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 631850a16..e7121eb7a 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -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'), @@ -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 = @@ -104,4 +105,4 @@ export default function LoginPage() { ); -} +} \ No newline at end of file diff --git a/frontend/app/(dashboard)/reports/builder/page.tsx b/frontend/app/(dashboard)/reports/builder/page.tsx index 40ae22b52..932f516c7 100644 --- a/frontend/app/(dashboard)/reports/builder/page.tsx +++ b/frontend/app/(dashboard)/reports/builder/page.tsx @@ -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; - groupBy: string; -} - export default function ReportBuilderPage() { const [dataset, setDataset] = useState("assets"); const [filters, setFilters] = useState>({}); @@ -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"); @@ -52,7 +63,6 @@ export default function ReportBuilderPage() { }; const exportPDF = async () => { - // Placeholder — would use jsPDF to render preview alert("PDF export coming soon"); }; @@ -71,7 +81,6 @@ export default function ReportBuilderPage() {
- {/* Sidebar — compose */}

Dataset

- {/* Main — preview */}
@@ -111,7 +119,6 @@ export default function ReportBuilderPage() {
- {/* Saved reports list */} {savedReports.length > 0 && (

Saved Reports

@@ -126,7 +133,6 @@ export default function ReportBuilderPage() {
)} - {/* Save dialog */} {showSave && (
setShowSave(false)} /> @@ -142,4 +148,4 @@ export default function ReportBuilderPage() { )}
); -} +} \ No newline at end of file diff --git a/frontend/app/(dashboard)/search/page.tsx b/frontend/app/(dashboard)/search/page.tsx index 02a37db75..c8cac6f39 100644 --- a/frontend/app/(dashboard)/search/page.tsx +++ b/frontend/app/(dashboard)/search/page.tsx @@ -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 = { asset: Package, @@ -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); @@ -135,4 +127,4 @@ export default function SearchPage() {
); -} +} \ No newline at end of file diff --git a/frontend/lib/api/saved-reports.ts b/frontend/lib/api/saved-reports.ts new file mode 100644 index 000000000..2f33f7afc --- /dev/null +++ b/frontend/lib/api/saved-reports.ts @@ -0,0 +1,35 @@ +import { api } from '../api'; + +export interface SavedReport { + id: string; + name: string; + dataset: string; + filters: Record; + groupBy: string; + viewMode: string; +} + +export interface CreateSavedReportInput { + name: string; + dataset: string; + filters: Record; + groupBy: string; + viewMode: string; +} + +export const savedReportsApiClient = { + list: (): Promise => + api.get('/saved-reports').then((r) => r.data), + + create: (data: CreateSavedReportInput): Promise => + api.post('/saved-reports', data).then((r) => r.data), + + rename: (id: string, name: string): Promise => + api.patch(`/saved-reports/${id}`, { name }).then((r) => r.data), + + delete: (id: string): Promise => + api.delete(`/saved-reports/${id}`).then(() => undefined), + + duplicate: (id: string): Promise => + api.post(`/saved-reports/${id}/duplicate`).then((r) => r.data), +}; \ No newline at end of file diff --git a/frontend/lib/api/search.ts b/frontend/lib/api/search.ts new file mode 100644 index 000000000..d9d801aa7 --- /dev/null +++ b/frontend/lib/api/search.ts @@ -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 => + api + .get('/search', { params: { q: query } }) + .then((r) => r.data.results ?? []), +}; \ No newline at end of file diff --git a/frontend/lib/utils/redirect-validation.ts b/frontend/lib/utils/redirect-validation.ts new file mode 100644 index 000000000..52a261f50 --- /dev/null +++ b/frontend/lib/utils/redirect-validation.ts @@ -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; +} \ No newline at end of file