Phase 0.2: Remove frontend deprecated code
- Delete pages: sum, audit, admin/random-jobs - Delete old homepage (counter) and create redirect page - Update Header.tsx: remove Counter, Sum, Audit, Random Jobs nav items - Update auth-context.tsx: remove VIEW_COUNTER, INCREMENT_COUNTER, USE_SUM permissions - Update profile/page.test.tsx: fix nav link assertions
This commit is contained in:
parent
5bad1e7e17
commit
a5c1eccb4b
9 changed files with 27 additions and 1260 deletions
|
|
@ -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<RandomNumberOutcome[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(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<RandomNumberOutcome[]>("/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 (
|
|
||||||
<main style={styles.main}>
|
|
||||||
<div style={styles.loader}>Loading...</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user || !isAuthorized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={styles.main}>
|
|
||||||
<Header currentPage="admin-random-jobs" />
|
|
||||||
|
|
||||||
<div style={styles.content}>
|
|
||||||
<div style={styles.tableCard}>
|
|
||||||
<div style={styles.tableHeader}>
|
|
||||||
<h2 style={styles.tableTitle}>Random Number Job Outcomes</h2>
|
|
||||||
<span style={styles.totalCount}>{outcomes.length} outcomes</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={styles.tableWrapper}>
|
|
||||||
<table style={styles.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={styles.th}>ID</th>
|
|
||||||
<th style={styles.th}>Job ID</th>
|
|
||||||
<th style={styles.th}>Triggered By</th>
|
|
||||||
<th style={styles.th}>Value</th>
|
|
||||||
<th style={styles.th}>Duration</th>
|
|
||||||
<th style={styles.th}>Status</th>
|
|
||||||
<th style={styles.th}>Created At</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{error && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} style={styles.errorRow}>
|
|
||||||
{error}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!error && isLoadingData && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} style={styles.emptyRow}>
|
|
||||||
Loading...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!error && !isLoadingData && outcomes.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} style={styles.emptyRow}>
|
|
||||||
No job outcomes yet
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!error &&
|
|
||||||
!isLoadingData &&
|
|
||||||
outcomes.map((outcome) => (
|
|
||||||
<tr key={outcome.id} style={styles.tr}>
|
|
||||||
<td style={styles.tdNum}>{outcome.id}</td>
|
|
||||||
<td style={styles.tdNum}>{outcome.job_id}</td>
|
|
||||||
<td style={styles.td}>{outcome.triggered_by_email}</td>
|
|
||||||
<td style={styles.tdValue}>{outcome.value}</td>
|
|
||||||
<td style={styles.tdNum}>{outcome.duration_ms}ms</td>
|
|
||||||
<td style={styles.td}>
|
|
||||||
<span style={styles.statusBadge}>{outcome.status}</span>
|
|
||||||
</td>
|
|
||||||
<td style={styles.tdDate}>{formatDate(outcome.created_at)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageStyles: Record<string, React.CSSProperties> = {
|
|
||||||
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 };
|
|
||||||
|
|
@ -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(<AuditPage />);
|
|
||||||
expect(screen.getByText("Loading...")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redirects to login when not authenticated", async () => {
|
|
||||||
mockUser = null;
|
|
||||||
render(<AuditPage />);
|
|
||||||
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(<AuditPage />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("displays error message when API fetch fails", async () => {
|
|
||||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
|
||||||
render(<AuditPage />);
|
|
||||||
|
|
||||||
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(<AuditPage />);
|
|
||||||
|
|
||||||
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(<AuditPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("recorduser@example.com")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows table headers", async () => {
|
|
||||||
render(<AuditPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Check for counter table headers
|
|
||||||
expect(screen.getByText("Counter Activity")).toBeTruthy();
|
|
||||||
expect(screen.getByText("Sum Activity")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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<PaginatedCounterRecords | null>(null);
|
|
||||||
const [sumData, setSumData] = useState<PaginatedSumRecords | null>(null);
|
|
||||||
const [counterError, setCounterError] = useState<string | null>(null);
|
|
||||||
const [sumError, setSumError] = useState<string | null>(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<PaginatedCounterRecords>(
|
|
||||||
`/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<PaginatedSumRecords>(`/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 (
|
|
||||||
<main style={layoutStyles.main}>
|
|
||||||
<div style={layoutStyles.loader}>Loading...</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user || !isAuthorized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={layoutStyles.main}>
|
|
||||||
<Header currentPage="audit" />
|
|
||||||
|
|
||||||
<div style={layoutStyles.contentScrollable}>
|
|
||||||
<div style={styles.tablesContainer}>
|
|
||||||
{/* Counter Records Table */}
|
|
||||||
<div style={cardStyles.tableCard}>
|
|
||||||
<div style={tableStyles.tableHeader}>
|
|
||||||
<h2 style={tableStyles.tableTitle}>Counter Activity</h2>
|
|
||||||
<span style={tableStyles.totalCount}>{counterData?.total ?? 0} records</span>
|
|
||||||
</div>
|
|
||||||
<div style={tableStyles.tableWrapper}>
|
|
||||||
<table style={tableStyles.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={tableStyles.th}>User</th>
|
|
||||||
<th style={tableStyles.th}>Before</th>
|
|
||||||
<th style={tableStyles.th}>After</th>
|
|
||||||
<th style={tableStyles.th}>Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{counterError && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} style={tableStyles.errorRow}>
|
|
||||||
{counterError}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!counterError &&
|
|
||||||
counterData?.records.map((record) => (
|
|
||||||
<tr key={record.id} style={tableStyles.tr}>
|
|
||||||
<td style={tableStyles.td}>{record.user_email}</td>
|
|
||||||
<td style={tableStyles.tdNum}>{record.value_before}</td>
|
|
||||||
<td style={tableStyles.tdNum}>{record.value_after}</td>
|
|
||||||
<td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{!counterError && (!counterData || counterData.records.length === 0) && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} style={tableStyles.emptyRow}>
|
|
||||||
No records yet
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{counterData && counterData.total_pages > 1 && (
|
|
||||||
<div style={paginationStyles.pagination}>
|
|
||||||
<button
|
|
||||||
onClick={() => setCounterPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={counterPage === 1}
|
|
||||||
style={paginationStyles.pageBtn}
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
<span style={paginationStyles.pageInfo}>
|
|
||||||
{counterPage} / {counterData.total_pages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setCounterPage((p) => Math.min(counterData.total_pages, p + 1))}
|
|
||||||
disabled={counterPage === counterData.total_pages}
|
|
||||||
style={paginationStyles.pageBtn}
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sum Records Table */}
|
|
||||||
<div style={cardStyles.tableCard}>
|
|
||||||
<div style={tableStyles.tableHeader}>
|
|
||||||
<h2 style={tableStyles.tableTitle}>Sum Activity</h2>
|
|
||||||
<span style={tableStyles.totalCount}>{sumData?.total ?? 0} records</span>
|
|
||||||
</div>
|
|
||||||
<div style={tableStyles.tableWrapper}>
|
|
||||||
<table style={tableStyles.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={tableStyles.th}>User</th>
|
|
||||||
<th style={tableStyles.th}>A</th>
|
|
||||||
<th style={tableStyles.th}>B</th>
|
|
||||||
<th style={tableStyles.th}>Result</th>
|
|
||||||
<th style={tableStyles.th}>Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sumError && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} style={tableStyles.errorRow}>
|
|
||||||
{sumError}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!sumError &&
|
|
||||||
sumData?.records.map((record) => (
|
|
||||||
<tr key={record.id} style={tableStyles.tr}>
|
|
||||||
<td style={tableStyles.td}>{record.user_email}</td>
|
|
||||||
<td style={tableStyles.tdNum}>{record.a}</td>
|
|
||||||
<td style={tableStyles.tdNum}>{record.b}</td>
|
|
||||||
<td style={styles.tdResult}>{record.result}</td>
|
|
||||||
<td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{!sumError && (!sumData || sumData.records.length === 0) && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} style={tableStyles.emptyRow}>
|
|
||||||
No records yet
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{sumData && sumData.total_pages > 1 && (
|
|
||||||
<div style={paginationStyles.pagination}>
|
|
||||||
<button
|
|
||||||
onClick={() => setSumPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={sumPage === 1}
|
|
||||||
style={paginationStyles.pageBtn}
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
<span style={paginationStyles.pageInfo}>
|
|
||||||
{sumPage} / {sumData.total_pages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setSumPage((p) => Math.min(sumData.total_pages, p + 1))}
|
|
||||||
disabled={sumPage === sumData.total_pages}
|
|
||||||
style={paginationStyles.pageBtn}
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page-specific styles only
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -12,9 +12,6 @@ export type PermissionType = components["schemas"]["Permission"];
|
||||||
// The type annotation ensures compile-time validation against the generated schema.
|
// 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.
|
// Adding a new permission in the backend will cause a type error here until updated.
|
||||||
export const Permission: Record<string, PermissionType> = {
|
export const Permission: Record<string, PermissionType> = {
|
||||||
VIEW_COUNTER: "view_counter",
|
|
||||||
INCREMENT_COUNTER: "increment_counter",
|
|
||||||
USE_SUM: "use_sum",
|
|
||||||
VIEW_AUDIT: "view_audit",
|
VIEW_AUDIT: "view_audit",
|
||||||
FETCH_PRICE: "fetch_price",
|
FETCH_PRICE: "fetch_price",
|
||||||
MANAGE_OWN_PROFILE: "manage_own_profile",
|
MANAGE_OWN_PROFILE: "manage_own_profile",
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,13 @@ import constants from "../../../shared/constants.json";
|
||||||
const { ADMIN, REGULAR } = constants.roles;
|
const { ADMIN, REGULAR } = constants.roles;
|
||||||
|
|
||||||
type PageId =
|
type PageId =
|
||||||
| "counter"
|
|
||||||
| "sum"
|
|
||||||
| "profile"
|
| "profile"
|
||||||
| "invites"
|
| "invites"
|
||||||
| "booking"
|
| "booking"
|
||||||
| "appointments"
|
| "appointments"
|
||||||
| "audit"
|
|
||||||
| "admin-invites"
|
| "admin-invites"
|
||||||
| "admin-availability"
|
| "admin-availability"
|
||||||
| "admin-appointments"
|
| "admin-appointments"
|
||||||
| "admin-random-jobs"
|
|
||||||
| "admin-price-history";
|
| "admin-price-history";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
|
|
@ -34,8 +30,6 @@ interface NavItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
const REGULAR_NAV_ITEMS: 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: "booking", label: "Book", href: "/booking", regularOnly: true },
|
||||||
{ id: "appointments", label: "Appointments", href: "/appointments", regularOnly: true },
|
{ id: "appointments", label: "Appointments", href: "/appointments", regularOnly: true },
|
||||||
{ id: "invites", label: "My Invites", href: "/invites", 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[] = [
|
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-invites", label: "Invites", href: "/admin/invites", adminOnly: true },
|
||||||
{ id: "admin-availability", label: "Availability", href: "/admin/availability", adminOnly: true },
|
{ id: "admin-availability", label: "Availability", href: "/admin/availability", adminOnly: true },
|
||||||
{ id: "admin-appointments", label: "Appointments", href: "/admin/appointments", 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 },
|
{ id: "admin-price-history", label: "Prices", href: "/admin/price-history", adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(<Home />);
|
|
||||||
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(<Home />);
|
|
||||||
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(<Home />);
|
|
||||||
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(<Home />);
|
|
||||||
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(<Home />);
|
|
||||||
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(<Home />);
|
|
||||||
|
|
||||||
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(<Home />);
|
|
||||||
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(<Home />);
|
|
||||||
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(<Home />);
|
|
||||||
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(<Home />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null when not authenticated", () => {
|
|
||||||
mockUser = null;
|
|
||||||
|
|
||||||
const { container } = render(<Home />);
|
|
||||||
// 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(<Home />);
|
|
||||||
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Home - Loading State", () => {
|
|
||||||
test("does not redirect while loading", () => {
|
|
||||||
mockIsLoading = true;
|
|
||||||
mockUser = null;
|
|
||||||
|
|
||||||
render(<Home />);
|
|
||||||
|
|
||||||
expect(mockPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows loading indicator while loading", () => {
|
|
||||||
mockIsLoading = true;
|
|
||||||
|
|
||||||
render(<Home />);
|
|
||||||
|
|
||||||
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(<Home />);
|
|
||||||
|
|
||||||
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(<Home />);
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,103 +1,39 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { Permission } from "./auth-context";
|
import { useRouter } from "next/navigation";
|
||||||
import { api } from "./api";
|
import { useAuth } from "./auth-context";
|
||||||
import { layoutStyles, cardStyles, buttonStyles } from "./styles/shared";
|
import { layoutStyles } from "./styles/shared";
|
||||||
import { Header } from "./components/Header";
|
import constants from "../../shared/constants.json";
|
||||||
import { useRequireAuth } from "./hooks/useRequireAuth";
|
|
||||||
|
const { ADMIN, REGULAR } = constants.roles;
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [count, setCount] = useState<number | null>(null);
|
const { user, isLoading, hasRole } = useAuth();
|
||||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
const router = useRouter();
|
||||||
requiredPermission: Permission.VIEW_COUNTER,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && isAuthorized) {
|
if (isLoading) return;
|
||||||
api
|
|
||||||
.get<{ value: number }>("/api/counter")
|
if (!user) {
|
||||||
.then((data) => setCount(data.value))
|
router.replace("/login");
|
||||||
.catch(() => setCount(null));
|
return;
|
||||||
}
|
}
|
||||||
}, [user, isAuthorized]);
|
|
||||||
|
|
||||||
const increment = async () => {
|
// Redirect based on role
|
||||||
const data = await api.post<{ value: number }>("/api/counter/increment");
|
if (hasRole(ADMIN)) {
|
||||||
setCount(data.value);
|
router.replace("/admin/appointments");
|
||||||
};
|
} else if (hasRole(REGULAR)) {
|
||||||
|
router.replace("/booking");
|
||||||
if (isLoading) {
|
} else {
|
||||||
return (
|
// User with no roles - redirect to login
|
||||||
<main style={layoutStyles.main}>
|
router.replace("/login");
|
||||||
<div style={layoutStyles.loader}>Loading...</div>
|
}
|
||||||
</main>
|
}, [user, isLoading, hasRole, router]);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user || !isAuthorized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={layoutStyles.main}>
|
<main style={layoutStyles.main}>
|
||||||
<Header currentPage="counter" />
|
<div style={layoutStyles.loader}>Redirecting...</div>
|
||||||
|
|
||||||
<div style={layoutStyles.contentCentered}>
|
|
||||||
<div style={styles.counterCard}>
|
|
||||||
<span style={styles.counterLabel}>Current Count</span>
|
|
||||||
<h1 style={styles.counter}>{count === null ? "..." : count}</h1>
|
|
||||||
<button onClick={increment} style={styles.incrementBtn}>
|
|
||||||
<span style={styles.plusIcon}>+</span>
|
|
||||||
Increment
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page-specific styles only - truly unique to this page
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -211,8 +211,8 @@ describe("ProfilePage - Navigation", () => {
|
||||||
|
|
||||||
render(<ProfilePage />);
|
render(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Counter")).toBeDefined();
|
expect(screen.getByText("Book")).toBeDefined();
|
||||||
expect(screen.getByText("Sum")).toBeDefined();
|
expect(screen.getByText("Appointments")).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<number | null>(null);
|
|
||||||
const [showResult, setShowResult] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(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 (
|
|
||||||
<main style={styles.main}>
|
|
||||||
<div style={styles.loader}>Loading...</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user || !isAuthorized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={styles.main}>
|
|
||||||
<Header currentPage="sum" />
|
|
||||||
|
|
||||||
<div style={styles.content}>
|
|
||||||
<div style={styles.card}>
|
|
||||||
<span style={styles.label}>Sum Calculator</span>
|
|
||||||
|
|
||||||
{!showResult ? (
|
|
||||||
<div style={styles.inputSection}>
|
|
||||||
<div style={styles.inputRow}>
|
|
||||||
<div style={styles.inputWrapper}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={a}
|
|
||||||
onChange={(e) => setA(e.target.value)}
|
|
||||||
placeholder="0"
|
|
||||||
style={styles.input}
|
|
||||||
aria-label="First number"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span style={styles.operator}>+</span>
|
|
||||||
<div style={styles.inputWrapper}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={b}
|
|
||||||
onChange={(e) => setB(e.target.value)}
|
|
||||||
placeholder="0"
|
|
||||||
style={styles.input}
|
|
||||||
aria-label="Second number"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={handleSum} style={styles.sumBtn} disabled={a === "" && b === ""}>
|
|
||||||
<span style={styles.equalsIcon}>=</span>
|
|
||||||
Calculate
|
|
||||||
</button>
|
|
||||||
{error && <div style={styles.error}>{error}</div>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={styles.resultSection}>
|
|
||||||
<div style={styles.equation}>
|
|
||||||
<span style={styles.equationNum}>{parseFloat(a) || 0}</span>
|
|
||||||
<span style={styles.equationOp}>+</span>
|
|
||||||
<span style={styles.equationNum}>{parseFloat(b) || 0}</span>
|
|
||||||
<span style={styles.equationOp}>=</span>
|
|
||||||
</div>
|
|
||||||
<h1 style={styles.result}>{result}</h1>
|
|
||||||
<button onClick={handleReset} style={styles.resetBtn}>
|
|
||||||
<span style={styles.resetIcon}>↺</span>
|
|
||||||
Try Again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageStyles: Record<string, React.CSSProperties> = {
|
|
||||||
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 };
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue