diff --git a/frontend/app/admin/random-jobs/page.tsx b/frontend/app/admin/random-jobs/page.tsx deleted file mode 100644 index e4613c3..0000000 --- a/frontend/app/admin/random-jobs/page.tsx +++ /dev/null @@ -1,229 +0,0 @@ -"use client"; - -import { useEffect, useState, useCallback } from "react"; -import { Permission } from "../../auth-context"; -import { api } from "../../api"; -import { sharedStyles } from "../../styles/shared"; -import { Header } from "../../components/Header"; -import { useRequireAuth } from "../../hooks/useRequireAuth"; -import { components } from "../../generated/api"; - -type RandomNumberOutcome = components["schemas"]["RandomNumberOutcomeResponse"]; - -export default function AdminRandomJobsPage() { - const [outcomes, setOutcomes] = useState([]); - const [error, setError] = useState(null); - const [isLoadingData, setIsLoadingData] = useState(true); - const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.VIEW_AUDIT, - fallbackRedirect: "/", - }); - - const fetchOutcomes = useCallback(async () => { - setError(null); - try { - const data = await api.get("/api/audit/random-jobs"); - setOutcomes(data); - } catch (err) { - setOutcomes([]); - setError(err instanceof Error ? err.message : "Failed to load outcomes"); - } finally { - setIsLoadingData(false); - } - }, []); - - useEffect(() => { - if (user && isAuthorized) { - fetchOutcomes(); - } - }, [user, isAuthorized, fetchOutcomes]); - - const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleString(); - }; - - if (isLoading) { - return ( -
-
Loading...
-
- ); - } - - if (!user || !isAuthorized) { - return null; - } - - return ( -
-
- -
-
-
-

Random Number Job Outcomes

- {outcomes.length} outcomes -
- -
- - - - - - - - - - - - - - {error && ( - - - - )} - {!error && isLoadingData && ( - - - - )} - {!error && !isLoadingData && outcomes.length === 0 && ( - - - - )} - {!error && - !isLoadingData && - outcomes.map((outcome) => ( - - - - - - - - - - ))} - -
IDJob IDTriggered ByValueDurationStatusCreated At
- {error} -
- Loading... -
- No job outcomes yet -
{outcome.id}{outcome.job_id}{outcome.triggered_by_email}{outcome.value}{outcome.duration_ms}ms - {outcome.status} - {formatDate(outcome.created_at)}
-
-
-
-
- ); -} - -const pageStyles: Record = { - content: { - flex: 1, - padding: "2rem", - overflowY: "auto", - }, - tableCard: { - background: "rgba(255, 255, 255, 0.03)", - backdropFilter: "blur(10px)", - border: "1px solid rgba(255, 255, 255, 0.08)", - borderRadius: "20px", - padding: "1.5rem", - boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", - maxWidth: "1200px", - margin: "0 auto", - }, - tableHeader: { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: "1rem", - }, - tableTitle: { - fontFamily: "'Instrument Serif', Georgia, serif", - fontSize: "1.5rem", - fontWeight: 400, - color: "#fff", - margin: 0, - }, - totalCount: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "0.875rem", - color: "rgba(255, 255, 255, 0.4)", - }, - tableWrapper: { - overflowX: "auto", - }, - table: { - width: "100%", - borderCollapse: "collapse", - fontFamily: "'DM Sans', system-ui, sans-serif", - }, - th: { - textAlign: "left", - padding: "0.75rem 1rem", - fontSize: "0.75rem", - fontWeight: 600, - color: "rgba(255, 255, 255, 0.4)", - textTransform: "uppercase", - letterSpacing: "0.05em", - borderBottom: "1px solid rgba(255, 255, 255, 0.08)", - }, - tr: { - borderBottom: "1px solid rgba(255, 255, 255, 0.04)", - }, - td: { - padding: "0.875rem 1rem", - fontSize: "0.875rem", - color: "rgba(255, 255, 255, 0.7)", - }, - tdNum: { - padding: "0.875rem 1rem", - fontSize: "0.875rem", - color: "rgba(255, 255, 255, 0.9)", - fontFamily: "'DM Mono', monospace", - }, - tdValue: { - padding: "0.875rem 1rem", - fontSize: "1rem", - color: "#a78bfa", - fontWeight: 600, - fontFamily: "'DM Mono', monospace", - }, - tdDate: { - padding: "0.875rem 1rem", - fontSize: "0.75rem", - color: "rgba(255, 255, 255, 0.4)", - }, - statusBadge: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "0.7rem", - fontWeight: 500, - padding: "0.25rem 0.5rem", - borderRadius: "4px", - textTransform: "uppercase", - background: "rgba(34, 197, 94, 0.2)", - color: "rgba(34, 197, 94, 0.9)", - }, - emptyRow: { - padding: "2rem 1rem", - textAlign: "center", - color: "rgba(255, 255, 255, 0.3)", - fontSize: "0.875rem", - }, - errorRow: { - padding: "2rem 1rem", - textAlign: "center", - color: "#f87171", - fontSize: "0.875rem", - }, -}; - -const styles = { ...sharedStyles, ...pageStyles }; diff --git a/frontend/app/audit/page.test.tsx b/frontend/app/audit/page.test.tsx deleted file mode 100644 index b39c65f..0000000 --- a/frontend/app/audit/page.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { render, screen, waitFor, cleanup } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import AuditPage from "./page"; - -// Mock next/navigation -const mockPush = vi.fn(); -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: mockPush }), -})); - -// Default mock values for admin user -let mockUser: { id: number; email: string; roles: string[]; permissions: string[] } | null = { - id: 1, - email: "admin@example.com", - roles: ["admin"], - permissions: ["view_audit"], -}; -let mockIsLoading = false; -const mockLogout = vi.fn(); -const mockHasPermission = vi.fn( - (permission: string) => mockUser?.permissions.includes(permission) ?? false -); -const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false); - -vi.mock("../auth-context", () => ({ - useAuth: () => ({ - user: mockUser, - isLoading: mockIsLoading, - logout: mockLogout, - hasPermission: mockHasPermission, - hasRole: mockHasRole, - }), - Permission: { - VIEW_COUNTER: "view_counter", - INCREMENT_COUNTER: "increment_counter", - USE_SUM: "use_sum", - VIEW_AUDIT: "view_audit", - }, -})); - -// Mock fetch -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -beforeEach(() => { - vi.clearAllMocks(); - mockUser = { - id: 1, - email: "admin@example.com", - roles: ["admin"], - permissions: ["view_audit"], - }; - mockIsLoading = false; - mockHasPermission.mockImplementation( - (permission: string) => mockUser?.permissions.includes(permission) ?? false - ); - mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false); - // Default: successful empty response - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ records: [], total: 0, page: 1, per_page: 10, total_pages: 1 }), - }); -}); - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); -}); - -describe("AuditPage", () => { - it("shows loading state", () => { - mockIsLoading = true; - render(); - expect(screen.getByText("Loading...")).toBeTruthy(); - }); - - it("redirects to login when not authenticated", async () => { - mockUser = null; - render(); - await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith("/login"); - }); - }); - - it("redirects to home when user lacks audit permission", async () => { - mockUser = { - id: 1, - email: "user@example.com", - roles: ["regular"], - permissions: ["view_counter"], - }; - mockHasPermission.mockReturnValue(false); - render(); - await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith("/"); - }); - }); - - it("displays error message when API fetch fails", async () => { - mockFetch.mockRejectedValue(new Error("Network error")); - render(); - - await waitFor(() => { - // Both tables should show errors since both calls fail - const errors = screen.getAllByText("Network error"); - expect(errors.length).toBeGreaterThan(0); - }); - }); - - it("displays error when API returns non-ok response", async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - json: () => Promise.resolve({ detail: "Internal server error" }), - }); - - render(); - - await waitFor(() => { - const errors = screen.getAllByText("Request failed: 500"); - expect(errors.length).toBeGreaterThan(0); - }); - }); - - it("displays records when fetch succeeds", async () => { - const counterResponse = { - records: [ - { - id: 1, - user_email: "recorduser@example.com", - value_before: 0, - value_after: 1, - created_at: "2024-01-01T00:00:00Z", - }, - ], - total: 1, - page: 1, - per_page: 10, - total_pages: 1, - }; - - const sumResponse = { - records: [], - total: 0, - page: 1, - per_page: 10, - total_pages: 1, - }; - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(counterResponse), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(sumResponse), - }); - - render(); - - await waitFor(() => { - expect(screen.getByText("recorduser@example.com")).toBeTruthy(); - }); - }); - - it("shows table headers", async () => { - render(); - - await waitFor(() => { - // Check for counter table headers - expect(screen.getByText("Counter Activity")).toBeTruthy(); - expect(screen.getByText("Sum Activity")).toBeTruthy(); - }); - }); -}); diff --git a/frontend/app/audit/page.tsx b/frontend/app/audit/page.tsx deleted file mode 100644 index 9747f2c..0000000 --- a/frontend/app/audit/page.tsx +++ /dev/null @@ -1,242 +0,0 @@ -"use client"; - -import { useEffect, useState, useCallback } from "react"; -import { Permission } from "../auth-context"; -import { api } from "../api"; -import { layoutStyles, cardStyles, tableStyles, paginationStyles } from "../styles/shared"; -import { Header } from "../components/Header"; -import { useRequireAuth } from "../hooks/useRequireAuth"; -import { components } from "../generated/api"; - -// Use generated types from OpenAPI schema -type _CounterRecord = components["schemas"]["CounterRecordResponse"]; -type _SumRecord = components["schemas"]["SumRecordResponse"]; -type PaginatedCounterRecords = components["schemas"]["PaginatedResponse_CounterRecordResponse_"]; -type PaginatedSumRecords = components["schemas"]["PaginatedResponse_SumRecordResponse_"]; - -export default function AuditPage() { - const [counterData, setCounterData] = useState(null); - const [sumData, setSumData] = useState(null); - const [counterError, setCounterError] = useState(null); - const [sumError, setSumError] = useState(null); - const [counterPage, setCounterPage] = useState(1); - const [sumPage, setSumPage] = useState(1); - const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.VIEW_AUDIT, - fallbackRedirect: "/", - }); - - const fetchCounterRecords = useCallback(async (page: number) => { - setCounterError(null); - try { - const data = await api.get( - `/api/audit/counter?page=${page}&per_page=10` - ); - setCounterData(data); - } catch (err) { - setCounterData(null); - setCounterError(err instanceof Error ? err.message : "Failed to load counter records"); - } - }, []); - - const fetchSumRecords = useCallback(async (page: number) => { - setSumError(null); - try { - const data = await api.get(`/api/audit/sum?page=${page}&per_page=10`); - setSumData(data); - } catch (err) { - setSumData(null); - setSumError(err instanceof Error ? err.message : "Failed to load sum records"); - } - }, []); - - useEffect(() => { - if (user && isAuthorized) { - fetchCounterRecords(counterPage); - } - }, [user, counterPage, isAuthorized, fetchCounterRecords]); - - useEffect(() => { - if (user && isAuthorized) { - fetchSumRecords(sumPage); - } - }, [user, sumPage, isAuthorized, fetchSumRecords]); - - const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleString(); - }; - - if (isLoading) { - return ( -
-
Loading...
-
- ); - } - - if (!user || !isAuthorized) { - return null; - } - - return ( -
-
- -
-
- {/* Counter Records Table */} -
-
-

Counter Activity

- {counterData?.total ?? 0} records -
-
- - - - - - - - - - - {counterError && ( - - - - )} - {!counterError && - counterData?.records.map((record) => ( - - - - - - - ))} - {!counterError && (!counterData || counterData.records.length === 0) && ( - - - - )} - -
UserBeforeAfterDate
- {counterError} -
{record.user_email}{record.value_before}{record.value_after}{formatDate(record.created_at)}
- No records yet -
-
- {counterData && counterData.total_pages > 1 && ( -
- - - {counterPage} / {counterData.total_pages} - - -
- )} -
- - {/* Sum Records Table */} -
-
-

Sum Activity

- {sumData?.total ?? 0} records -
-
- - - - - - - - - - - - {sumError && ( - - - - )} - {!sumError && - sumData?.records.map((record) => ( - - - - - - - - ))} - {!sumError && (!sumData || sumData.records.length === 0) && ( - - - - )} - -
UserABResultDate
- {sumError} -
{record.user_email}{record.a}{record.b}{record.result}{formatDate(record.created_at)}
- No records yet -
-
- {sumData && sumData.total_pages > 1 && ( -
- - - {sumPage} / {sumData.total_pages} - - -
- )} -
-
-
-
- ); -} - -// Page-specific styles only -const styles: Record = { - tablesContainer: { - display: "flex", - flexDirection: "column", - gap: "2rem", - maxWidth: "1200px", - margin: "0 auto", - }, - tdResult: { - padding: "0.875rem 1rem", - fontSize: "0.875rem", - color: "#a78bfa", - fontWeight: 600, - fontFamily: "'DM Sans', monospace", - }, -}; diff --git a/frontend/app/auth-context.tsx b/frontend/app/auth-context.tsx index f2a0929..8399683 100644 --- a/frontend/app/auth-context.tsx +++ b/frontend/app/auth-context.tsx @@ -12,9 +12,6 @@ export type PermissionType = components["schemas"]["Permission"]; // The type annotation ensures compile-time validation against the generated schema. // Adding a new permission in the backend will cause a type error here until updated. export const Permission: Record = { - VIEW_COUNTER: "view_counter", - INCREMENT_COUNTER: "increment_counter", - USE_SUM: "use_sum", VIEW_AUDIT: "view_audit", FETCH_PRICE: "fetch_price", MANAGE_OWN_PROFILE: "manage_own_profile", diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx index afacc97..4a8622f 100644 --- a/frontend/app/components/Header.tsx +++ b/frontend/app/components/Header.tsx @@ -8,17 +8,13 @@ import constants from "../../../shared/constants.json"; const { ADMIN, REGULAR } = constants.roles; type PageId = - | "counter" - | "sum" | "profile" | "invites" | "booking" | "appointments" - | "audit" | "admin-invites" | "admin-availability" | "admin-appointments" - | "admin-random-jobs" | "admin-price-history"; interface HeaderProps { @@ -34,8 +30,6 @@ interface NavItem { } const REGULAR_NAV_ITEMS: NavItem[] = [ - { id: "counter", label: "Counter", href: "/" }, - { id: "sum", label: "Sum", href: "/sum" }, { id: "booking", label: "Book", href: "/booking", regularOnly: true }, { id: "appointments", label: "Appointments", href: "/appointments", regularOnly: true }, { id: "invites", label: "My Invites", href: "/invites", regularOnly: true }, @@ -43,11 +37,9 @@ const REGULAR_NAV_ITEMS: NavItem[] = [ ]; const ADMIN_NAV_ITEMS: NavItem[] = [ - { id: "audit", label: "Audit", href: "/audit", adminOnly: true }, { id: "admin-invites", label: "Invites", href: "/admin/invites", adminOnly: true }, { id: "admin-availability", label: "Availability", href: "/admin/availability", adminOnly: true }, { id: "admin-appointments", label: "Appointments", href: "/admin/appointments", adminOnly: true }, - { id: "admin-random-jobs", label: "Random Jobs", href: "/admin/random-jobs", adminOnly: true }, { id: "admin-price-history", label: "Prices", href: "/admin/price-history", adminOnly: true }, ]; diff --git a/frontend/app/page.test.tsx b/frontend/app/page.test.tsx deleted file mode 100644 index 3bbb3d7..0000000 --- a/frontend/app/page.test.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; -import { expect, test, vi, beforeEach, afterEach, describe } from "vitest"; -import Home from "./page"; - -// Mock next/navigation -const mockPush = vi.fn(); -vi.mock("next/navigation", () => ({ - useRouter: () => ({ - push: mockPush, - }), -})); - -// Default mock values -let mockUser: { id: number; email: string; roles: string[]; permissions: string[] } | null = { - id: 1, - email: "test@example.com", - roles: ["regular"], - permissions: ["view_counter", "increment_counter", "use_sum"], -}; -let mockIsLoading = false; -const mockLogout = vi.fn(); -const mockHasPermission = vi.fn( - (permission: string) => mockUser?.permissions.includes(permission) ?? false -); -const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false); - -vi.mock("./auth-context", () => ({ - useAuth: () => ({ - user: mockUser, - isLoading: mockIsLoading, - logout: mockLogout, - hasPermission: mockHasPermission, - hasRole: mockHasRole, - }), - Permission: { - VIEW_COUNTER: "view_counter", - INCREMENT_COUNTER: "increment_counter", - USE_SUM: "use_sum", - VIEW_AUDIT: "view_audit", - }, -})); - -beforeEach(() => { - vi.clearAllMocks(); - // Reset to authenticated state - mockUser = { - id: 1, - email: "test@example.com", - roles: ["regular"], - permissions: ["view_counter", "increment_counter", "use_sum"], - }; - mockIsLoading = false; - mockHasPermission.mockImplementation( - (permission: string) => mockUser?.permissions.includes(permission) ?? false - ); - mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false); -}); - -afterEach(() => { - cleanup(); -}); - -describe("Home - Authenticated", () => { - test("renders loading state when isLoading is true", () => { - mockIsLoading = true; - vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {})); - - render(); - expect(screen.getByText("Loading...")).toBeDefined(); - }); - - test("renders user email in header", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ value: 42 }), - } as Response); - - render(); - expect(screen.getByText("test@example.com")).toBeDefined(); - }); - - test("renders sign out button", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ value: 42 }), - } as Response); - - render(); - expect(screen.getByText("Sign out")).toBeDefined(); - }); - - test("clicking sign out calls logout and redirects", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ value: 42 }), - } as Response); - - render(); - fireEvent.click(screen.getByText("Sign out")); - - await waitFor(() => { - expect(mockLogout).toHaveBeenCalled(); - expect(mockPush).toHaveBeenCalledWith("/login"); - }); - }); - - test("renders counter value after fetch", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ value: 42 }), - } as Response); - - render(); - await waitFor(() => { - expect(screen.getByText("42")).toBeDefined(); - }); - }); - - test("fetches counter with credentials", async () => { - const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ value: 0 }), - } as Response); - - render(); - - await waitFor(() => { - expect(fetchSpy).toHaveBeenCalledWith( - "http://localhost:8000/api/counter", - expect.objectContaining({ - credentials: "include", - }) - ); - }); - }); - - test("renders increment button", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ value: 0 }), - } as Response); - - render(); - expect(screen.getByText("Increment")).toBeDefined(); - }); - - test("clicking increment button calls API with credentials", async () => { - const fetchSpy = vi - .spyOn(global, "fetch") - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 0 }) } as Response) - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 1 }) } as Response); - - render(); - await waitFor(() => expect(screen.getByText("0")).toBeDefined()); - - fireEvent.click(screen.getByText("Increment")); - - await waitFor(() => { - expect(fetchSpy).toHaveBeenCalledWith( - "http://localhost:8000/api/counter/increment", - expect.objectContaining({ - method: "POST", - credentials: "include", - }) - ); - }); - }); - - test("clicking increment updates displayed count", async () => { - vi.spyOn(global, "fetch") - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 0 }) } as Response) - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 1 }) } as Response); - - render(); - await waitFor(() => expect(screen.getByText("0")).toBeDefined()); - - fireEvent.click(screen.getByText("Increment")); - await waitFor(() => expect(screen.getByText("1")).toBeDefined()); - }); -}); - -describe("Home - Unauthenticated", () => { - test("redirects to login when not authenticated", async () => { - mockUser = null; - - render(); - - await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith("/login"); - }); - }); - - test("returns null when not authenticated", () => { - mockUser = null; - - const { container } = render(); - // Should render nothing (just redirects) - expect(container.querySelector("main")).toBeNull(); - }); - - test("does not fetch counter when no user", () => { - mockUser = null; - const fetchSpy = vi.spyOn(global, "fetch"); - - render(); - - expect(fetchSpy).not.toHaveBeenCalled(); - }); -}); - -describe("Home - Loading State", () => { - test("does not redirect while loading", () => { - mockIsLoading = true; - mockUser = null; - - render(); - - expect(mockPush).not.toHaveBeenCalled(); - }); - - test("shows loading indicator while loading", () => { - mockIsLoading = true; - - render(); - - expect(screen.getByText("Loading...")).toBeDefined(); - }); -}); - -describe("Home - Navigation", () => { - test("shows My Profile link for regular user", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - json: () => Promise.resolve({ value: 0 }), - } as Response); - - render(); - - await waitFor(() => { - expect(screen.getByText("My Profile")).toBeDefined(); - }); - }); - - test("does not show My Profile link for admin user", async () => { - mockUser = { - id: 1, - email: "admin@example.com", - roles: ["admin"], - permissions: ["view_counter", "view_audit"], - }; - - vi.spyOn(global, "fetch").mockResolvedValue({ - json: () => Promise.resolve({ value: 0 }), - } as Response); - - render(); - - // Wait for render - admin sees admin nav (Audit, Invites) not regular nav - await waitFor(() => { - expect(screen.getByText("Audit")).toBeDefined(); - }); - expect(screen.queryByText("My Profile")).toBeNull(); - }); -}); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index fad8948..c1e5c9e 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,103 +1,39 @@ "use client"; -import { useEffect, useState } from "react"; -import { Permission } from "./auth-context"; -import { api } from "./api"; -import { layoutStyles, cardStyles, buttonStyles } from "./styles/shared"; -import { Header } from "./components/Header"; -import { useRequireAuth } from "./hooks/useRequireAuth"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "./auth-context"; +import { layoutStyles } from "./styles/shared"; +import constants from "../../shared/constants.json"; + +const { ADMIN, REGULAR } = constants.roles; export default function Home() { - const [count, setCount] = useState(null); - const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.VIEW_COUNTER, - }); + const { user, isLoading, hasRole } = useAuth(); + const router = useRouter(); useEffect(() => { - if (user && isAuthorized) { - api - .get<{ value: number }>("/api/counter") - .then((data) => setCount(data.value)) - .catch(() => setCount(null)); + if (isLoading) return; + + if (!user) { + router.replace("/login"); + return; } - }, [user, isAuthorized]); - const increment = async () => { - const data = await api.post<{ value: number }>("/api/counter/increment"); - setCount(data.value); - }; - - if (isLoading) { - return ( -
-
Loading...
-
- ); - } - - if (!user || !isAuthorized) { - return null; - } + // Redirect based on role + if (hasRole(ADMIN)) { + router.replace("/admin/appointments"); + } else if (hasRole(REGULAR)) { + router.replace("/booking"); + } else { + // User with no roles - redirect to login + router.replace("/login"); + } + }, [user, isLoading, hasRole, router]); return (
-
- -
-
- Current Count -

{count === null ? "..." : count}

- -
-
+
Redirecting...
); } - -// Page-specific styles only - truly unique to this page -const styles: Record = { - counterCard: { - ...cardStyles.card, - borderRadius: "32px", - padding: "4rem 5rem", - textAlign: "center", - }, - counterLabel: { - fontFamily: "'DM Sans', system-ui, sans-serif", - display: "block", - color: "rgba(255, 255, 255, 0.4)", - fontSize: "0.875rem", - textTransform: "uppercase", - letterSpacing: "0.1em", - marginBottom: "1rem", - }, - counter: { - fontFamily: "'Instrument Serif', Georgia, serif", - fontSize: "8rem", - fontWeight: 400, - color: "#fff", - margin: 0, - lineHeight: 1, - background: "linear-gradient(135deg, #fff 0%, #a78bfa 100%)", - WebkitBackgroundClip: "text", - WebkitTextFillColor: "transparent", - backgroundClip: "text", - }, - incrementBtn: { - ...buttonStyles.primaryButton, - marginTop: "2.5rem", - padding: "1rem 2.5rem", - fontSize: "1.125rem", - borderRadius: "16px", - display: "inline-flex", - alignItems: "center", - gap: "0.5rem", - }, - plusIcon: { - fontSize: "1.5rem", - fontWeight: 400, - }, -}; diff --git a/frontend/app/profile/page.test.tsx b/frontend/app/profile/page.test.tsx index 03f436b..17cd74e 100644 --- a/frontend/app/profile/page.test.tsx +++ b/frontend/app/profile/page.test.tsx @@ -211,8 +211,8 @@ describe("ProfilePage - Navigation", () => { render(); await waitFor(() => { - expect(screen.getByText("Counter")).toBeDefined(); - expect(screen.getByText("Sum")).toBeDefined(); + expect(screen.getByText("Book")).toBeDefined(); + expect(screen.getByText("Appointments")).toBeDefined(); }); }); diff --git a/frontend/app/sum/page.tsx b/frontend/app/sum/page.tsx deleted file mode 100644 index 813a39b..0000000 --- a/frontend/app/sum/page.tsx +++ /dev/null @@ -1,248 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Permission } from "../auth-context"; -import { api } from "../api"; -import { sharedStyles } from "../styles/shared"; -import { Header } from "../components/Header"; -import { useRequireAuth } from "../hooks/useRequireAuth"; - -export default function SumPage() { - const [a, setA] = useState(""); - const [b, setB] = useState(""); - const [result, setResult] = useState(null); - const [showResult, setShowResult] = useState(false); - const [error, setError] = useState(null); - const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.USE_SUM, - }); - - const handleSum = async () => { - const numA = parseFloat(a) || 0; - const numB = parseFloat(b) || 0; - setError(null); - - try { - const data = await api.post<{ result: number }>("/api/sum", { a: numA, b: numB }); - setResult(data.result); - setShowResult(true); - } catch (err) { - setError(err instanceof Error ? err.message : "Calculation failed"); - } - }; - - const handleReset = () => { - setA(""); - setB(""); - setResult(null); - setShowResult(false); - setError(null); - }; - - if (isLoading) { - return ( -
-
Loading...
-
- ); - } - - if (!user || !isAuthorized) { - return null; - } - - return ( -
-
- -
-
- Sum Calculator - - {!showResult ? ( -
-
-
- setA(e.target.value)} - placeholder="0" - style={styles.input} - aria-label="First number" - /> -
- + -
- setB(e.target.value)} - placeholder="0" - style={styles.input} - aria-label="Second number" - /> -
-
- - {error &&
{error}
} -
- ) : ( -
-
- {parseFloat(a) || 0} - + - {parseFloat(b) || 0} - = -
-

{result}

- -
- )} -
-
-
- ); -} - -const pageStyles: Record = { - card: { - background: "rgba(255, 255, 255, 0.03)", - backdropFilter: "blur(10px)", - border: "1px solid rgba(255, 255, 255, 0.08)", - borderRadius: "32px", - padding: "3rem 4rem", - textAlign: "center", - boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", - minWidth: "400px", - }, - label: { - fontFamily: "'DM Sans', system-ui, sans-serif", - display: "block", - color: "rgba(255, 255, 255, 0.4)", - fontSize: "0.875rem", - textTransform: "uppercase", - letterSpacing: "0.1em", - marginBottom: "2rem", - }, - inputSection: { - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: "2rem", - }, - inputRow: { - display: "flex", - alignItems: "center", - gap: "1rem", - }, - inputWrapper: { - position: "relative", - }, - input: { - fontFamily: "'Instrument Serif', Georgia, serif", - fontSize: "3rem", - fontWeight: 400, - width: "120px", - padding: "0.75rem 1rem", - textAlign: "center", - background: "rgba(255, 255, 255, 0.05)", - border: "2px solid rgba(255, 255, 255, 0.1)", - borderRadius: "16px", - color: "#fff", - outline: "none", - transition: "border-color 0.2s, box-shadow 0.2s", - }, - operator: { - fontFamily: "'Instrument Serif', Georgia, serif", - fontSize: "3rem", - color: "#a78bfa", - fontWeight: 400, - }, - sumBtn: { - fontFamily: "'DM Sans', system-ui, sans-serif", - padding: "1rem 2.5rem", - fontSize: "1.125rem", - fontWeight: 600, - background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)", - color: "#fff", - border: "none", - borderRadius: "16px", - cursor: "pointer", - display: "inline-flex", - alignItems: "center", - gap: "0.5rem", - transition: "transform 0.2s, box-shadow 0.2s", - boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)", - }, - equalsIcon: { - fontSize: "1.5rem", - fontWeight: 400, - }, - resultSection: { - display: "flex", - flexDirection: "column", - alignItems: "center", - }, - equation: { - display: "flex", - alignItems: "center", - gap: "0.5rem", - marginBottom: "0.5rem", - }, - equationNum: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "1.25rem", - color: "rgba(255, 255, 255, 0.6)", - }, - equationOp: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "1.25rem", - color: "rgba(139, 92, 246, 0.8)", - }, - result: { - fontFamily: "'Instrument Serif', Georgia, serif", - fontSize: "7rem", - fontWeight: 400, - color: "#fff", - margin: 0, - lineHeight: 1, - background: "linear-gradient(135deg, #fff 0%, #a78bfa 100%)", - WebkitBackgroundClip: "text", - WebkitTextFillColor: "transparent", - backgroundClip: "text", - }, - resetBtn: { - fontFamily: "'DM Sans', system-ui, sans-serif", - marginTop: "2rem", - padding: "0.875rem 2rem", - fontSize: "1rem", - fontWeight: 500, - background: "rgba(255, 255, 255, 0.05)", - color: "rgba(255, 255, 255, 0.7)", - border: "1px solid rgba(255, 255, 255, 0.1)", - borderRadius: "12px", - cursor: "pointer", - display: "inline-flex", - alignItems: "center", - gap: "0.5rem", - transition: "all 0.2s", - }, - resetIcon: { - fontSize: "1.25rem", - }, - error: { - fontFamily: "'DM Sans', system-ui, sans-serif", - color: "#f87171", - fontSize: "0.875rem", - marginTop: "0.5rem", - }, -}; - -const styles = { ...sharedStyles, ...pageStyles };