some fixes and refactors

This commit is contained in:
counterweight 2025-12-19 11:08:19 +01:00
parent ead8a566d0
commit 75cfc6c928
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
16 changed files with 381 additions and 425 deletions

View file

@ -1,6 +1,6 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from typing import Any, Callable, Generic, TypeVar from typing import Callable, Generic, TypeVar
from fastapi import FastAPI, Depends, HTTPException, Response, status, Query from fastapi import FastAPI, Depends, HTTPException, Response, status, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -8,6 +8,20 @@ from pydantic import BaseModel
from sqlalchemy import select, func, desc from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
COOKIE_NAME,
UserCreate,
UserLogin,
UserResponse,
get_password_hash,
get_user_by_email,
authenticate_user,
create_access_token,
get_current_user,
require_permission,
build_user_response,
)
from database import engine, get_db, Base from database import engine, get_db, Base
from models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR from models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR
from validation import validate_profile_fields from validation import validate_profile_fields
@ -47,20 +61,6 @@ async def paginate_with_user_email(
records: list[R] = [row_mapper(record, email) for record, email in rows] records: list[R] = [row_mapper(record, email) for record, email in rows]
return records, total, total_pages return records, total, total_pages
from auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
COOKIE_NAME,
UserCreate,
UserLogin,
UserResponse,
get_password_hash,
get_user_by_email,
authenticate_user,
create_access_token,
get_current_user,
require_permission,
build_user_response,
)
@asynccontextmanager @asynccontextmanager

75
frontend/app/api.ts Normal file
View file

@ -0,0 +1,75 @@
import { API_URL } from "./config";
/**
* Simple API client that centralizes fetch configuration.
* All requests include credentials and proper headers.
*/
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown
) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_URL}${endpoint}`;
const headers: HeadersInit = {
...options.headers,
};
if (options.body && typeof options.body === "string") {
headers["Content-Type"] = "application/json";
}
const res = await fetch(url, {
...options,
headers,
credentials: "include",
});
if (!res.ok) {
let data: unknown;
try {
data = await res.json();
} catch {
// Response wasn't JSON
}
throw new ApiError(
`Request failed: ${res.status}`,
res.status,
data
);
}
return res.json();
}
export const api = {
get<T>(endpoint: string): Promise<T> {
return request<T>(endpoint);
},
post<T>(endpoint: string, body?: unknown): Promise<T> {
return request<T>(endpoint, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
},
put<T>(endpoint: string, body?: unknown): Promise<T> {
return request<T>(endpoint, {
method: "PUT",
body: body ? JSON.stringify(body) : undefined,
});
},
};

View file

@ -20,6 +20,9 @@ const mockLogout = vi.fn();
const mockHasPermission = vi.fn((permission: string) => const mockHasPermission = vi.fn((permission: string) =>
mockUser?.permissions.includes(permission) ?? false mockUser?.permissions.includes(permission) ?? false
); );
const mockHasRole = vi.fn((role: string) =>
mockUser?.roles.includes(role) ?? false
);
vi.mock("../auth-context", () => ({ vi.mock("../auth-context", () => ({
useAuth: () => ({ useAuth: () => ({
@ -27,6 +30,7 @@ vi.mock("../auth-context", () => ({
isLoading: mockIsLoading, isLoading: mockIsLoading,
logout: mockLogout, logout: mockLogout,
hasPermission: mockHasPermission, hasPermission: mockHasPermission,
hasRole: mockHasRole,
}), }),
Permission: { Permission: {
VIEW_COUNTER: "view_counter", VIEW_COUNTER: "view_counter",
@ -52,6 +56,9 @@ beforeEach(() => {
mockHasPermission.mockImplementation((permission: string) => mockHasPermission.mockImplementation((permission: string) =>
mockUser?.permissions.includes(permission) ?? false mockUser?.permissions.includes(permission) ?? false
); );
mockHasRole.mockImplementation((role: string) =>
mockUser?.roles.includes(role) ?? false
);
// Default: successful empty response // Default: successful empty response
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,
@ -114,7 +121,8 @@ describe("AuditPage", () => {
render(<AuditPage />); render(<AuditPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Failed to load counter records")).toBeTruthy(); const errors = screen.getAllByText("Request failed: 500");
expect(errors.length).toBeGreaterThan(0);
}); });
}); });

View file

@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation"; import { Permission } from "../auth-context";
import { useAuth, Permission } from "../auth-context"; import { api } from "../api";
import { API_URL } from "../config";
import { sharedStyles } from "../styles/shared"; import { sharedStyles } from "../styles/shared";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
interface CounterRecord { interface CounterRecord {
id: number; id: number;
@ -38,31 +39,17 @@ export default function AuditPage() {
const [sumError, setSumError] = useState<string | null>(null); const [sumError, setSumError] = useState<string | null>(null);
const [counterPage, setCounterPage] = useState(1); const [counterPage, setCounterPage] = useState(1);
const [sumPage, setSumPage] = useState(1); const [sumPage, setSumPage] = useState(1);
const { user, isLoading, logout, hasPermission } = useAuth(); const { user, isLoading, isAuthorized } = useRequireAuth({
const router = useRouter(); requiredPermission: Permission.VIEW_AUDIT,
fallbackRedirect: "/",
const canViewAudit = hasPermission(Permission.VIEW_AUDIT); });
useEffect(() => {
if (!isLoading) {
if (!user) {
router.push("/login");
} else if (!canViewAudit) {
router.push("/");
}
}
}, [isLoading, user, router, canViewAudit]);
const fetchCounterRecords = useCallback(async (page: number) => { const fetchCounterRecords = useCallback(async (page: number) => {
setCounterError(null); setCounterError(null);
try { try {
const res = await fetch(`${API_URL}/api/audit/counter?page=${page}&per_page=10`, { const data = await api.get<PaginatedResponse<CounterRecord>>(
credentials: "include", `/api/audit/counter?page=${page}&per_page=10`
}); );
if (!res.ok) {
throw new Error("Failed to load counter records");
}
const data = await res.json();
setCounterData(data); setCounterData(data);
} catch (err) { } catch (err) {
setCounterData(null); setCounterData(null);
@ -73,13 +60,9 @@ export default function AuditPage() {
const fetchSumRecords = useCallback(async (page: number) => { const fetchSumRecords = useCallback(async (page: number) => {
setSumError(null); setSumError(null);
try { try {
const res = await fetch(`${API_URL}/api/audit/sum?page=${page}&per_page=10`, { const data = await api.get<PaginatedResponse<SumRecord>>(
credentials: "include", `/api/audit/sum?page=${page}&per_page=10`
}); );
if (!res.ok) {
throw new Error("Failed to load sum records");
}
const data = await res.json();
setSumData(data); setSumData(data);
} catch (err) { } catch (err) {
setSumData(null); setSumData(null);
@ -88,21 +71,16 @@ export default function AuditPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (user && canViewAudit) { if (user && isAuthorized) {
fetchCounterRecords(counterPage); fetchCounterRecords(counterPage);
} }
}, [user, counterPage, canViewAudit, fetchCounterRecords]); }, [user, counterPage, isAuthorized, fetchCounterRecords]);
useEffect(() => { useEffect(() => {
if (user && canViewAudit) { if (user && isAuthorized) {
fetchSumRecords(sumPage); fetchSumRecords(sumPage);
} }
}, [user, sumPage, canViewAudit, fetchSumRecords]); }, [user, sumPage, isAuthorized, fetchSumRecords]);
const handleLogout = async () => {
await logout();
router.push("/login");
};
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(); return new Date(dateStr).toLocaleString();
@ -116,23 +94,13 @@ export default function AuditPage() {
); );
} }
if (!user || !canViewAudit) { if (!user || !isAuthorized) {
return null; return null;
} }
return ( return (
<main style={styles.main}> <main style={styles.main}>
<div style={styles.header}> <Header currentPage="audit" />
<div style={styles.nav}>
<span style={styles.navCurrent}>Audit</span>
</div>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>
<button onClick={handleLogout} style={styles.logoutBtn}>
Sign out
</button>
</div>
</div>
<div style={styles.content}> <div style={styles.content}>
<div style={styles.tablesContainer}> <div style={styles.tablesContainer}>

View file

@ -2,7 +2,7 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import { API_URL } from "./config"; import { api, ApiError } from "./api";
// Permission constants matching backend // Permission constants matching backend
export const Permission = { export const Permission = {
@ -43,13 +43,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const checkAuth = async () => { const checkAuth = async () => {
try { try {
const res = await fetch(`${API_URL}/api/auth/me`, { const userData = await api.get<User>("/api/auth/me");
credentials: "include", setUser(userData);
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
}
} catch { } catch {
// Not authenticated // Not authenticated
} finally { } finally {
@ -58,44 +53,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}; };
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
const res = await fetch(`${API_URL}/api/auth/login`, { try {
method: "POST", const userData = await api.post<User>("/api/auth/login", { email, password });
headers: { "Content-Type": "application/json" }, setUser(userData);
credentials: "include", } catch (err) {
body: JSON.stringify({ email, password }), if (err instanceof ApiError) {
}); const data = err.data as { detail?: string };
throw new Error(data?.detail || "Login failed");
if (!res.ok) { }
const error = await res.json(); throw err;
throw new Error(error.detail || "Login failed");
} }
const userData = await res.json();
setUser(userData);
}; };
const register = async (email: string, password: string) => { const register = async (email: string, password: string) => {
const res = await fetch(`${API_URL}/api/auth/register`, { try {
method: "POST", const userData = await api.post<User>("/api/auth/register", { email, password });
headers: { "Content-Type": "application/json" }, setUser(userData);
credentials: "include", } catch (err) {
body: JSON.stringify({ email, password }), if (err instanceof ApiError) {
}); const data = err.data as { detail?: string };
throw new Error(data?.detail || "Registration failed");
if (!res.ok) { }
const error = await res.json(); throw err;
throw new Error(error.detail || "Registration failed");
} }
const userData = await res.json();
setUser(userData);
}; };
const logout = async () => { const logout = async () => {
await fetch(`${API_URL}/api/auth/logout`, { try {
method: "POST", await api.post("/api/auth/logout");
credentials: "include", } catch {
}); // Ignore errors on logout
}
setUser(null); setUser(null);
}; };

View file

@ -0,0 +1,85 @@
"use client";
import { useRouter } from "next/navigation";
import { useAuth } from "../auth-context";
import { sharedStyles } from "../styles/shared";
type PageId = "counter" | "sum" | "profile" | "audit";
interface HeaderProps {
currentPage: PageId;
}
interface NavItem {
id: PageId;
label: string;
href: string;
regularOnly?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ id: "counter", label: "Counter", href: "/" },
{ id: "sum", label: "Sum", href: "/sum" },
{ id: "profile", label: "My Profile", href: "/profile", regularOnly: true },
];
export function Header({ currentPage }: HeaderProps) {
const { user, logout, hasRole } = useAuth();
const router = useRouter();
const isRegularUser = hasRole("regular");
const handleLogout = async () => {
await logout();
router.push("/login");
};
if (!user) return null;
// For audit page (admin), show only the current page label
if (currentPage === "audit") {
return (
<div style={sharedStyles.header}>
<div style={sharedStyles.nav}>
<span style={sharedStyles.navCurrent}>Audit</span>
</div>
<div style={sharedStyles.userInfo}>
<span style={sharedStyles.userEmail}>{user.email}</span>
<button onClick={handleLogout} style={sharedStyles.logoutBtn}>
Sign out
</button>
</div>
</div>
);
}
// For regular pages, build nav with links
const visibleItems = NAV_ITEMS.filter(
(item) => !item.regularOnly || isRegularUser
);
return (
<div style={sharedStyles.header}>
<div style={sharedStyles.nav}>
{visibleItems.map((item, index) => (
<span key={item.id}>
{index > 0 && <span style={sharedStyles.navDivider}></span>}
{item.id === currentPage ? (
<span style={sharedStyles.navCurrent}>{item.label}</span>
) : (
<a href={item.href} style={sharedStyles.navLink}>
{item.label}
</a>
)}
</span>
))}
</div>
<div style={sharedStyles.userInfo}>
<span style={sharedStyles.userEmail}>{user.email}</span>
<button onClick={handleLogout} style={sharedStyles.logoutBtn}>
Sign out
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth, PermissionType, Permission } from "../auth-context";
interface UseRequireAuthOptions {
/** Required permission to access the page */
requiredPermission?: PermissionType;
/** Required role to access the page */
requiredRole?: string;
/** Where to redirect if permission check fails (defaults to best available page) */
fallbackRedirect?: string;
}
interface UseRequireAuthResult {
user: ReturnType<typeof useAuth>["user"];
isLoading: boolean;
isAuthorized: boolean;
}
/**
* Hook that handles authentication and authorization checks.
* Automatically redirects to login if not authenticated,
* or to a fallback page if missing required permissions.
*/
export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireAuthResult {
const { requiredPermission, requiredRole, fallbackRedirect } = options;
const { user, isLoading, hasPermission, hasRole } = useAuth();
const router = useRouter();
const isAuthorized = (() => {
if (!user) return false;
if (requiredPermission && !hasPermission(requiredPermission)) return false;
if (requiredRole && !hasRole(requiredRole)) return false;
return true;
})();
useEffect(() => {
if (isLoading) return;
if (!user) {
router.push("/login");
return;
}
if (!isAuthorized) {
// Redirect to the most appropriate page based on permissions
const redirect = fallbackRedirect ?? (
hasPermission(Permission.VIEW_AUDIT) ? "/audit" :
hasPermission(Permission.VIEW_COUNTER) ? "/" :
"/login"
);
router.push(redirect);
}
}, [isLoading, user, isAuthorized, router, fallbackRedirect, hasPermission]);
return {
user,
isLoading,
isAuthorized,
};
}

View file

@ -75,6 +75,7 @@ describe("Home - Authenticated", () => {
test("renders user email in header", async () => { test("renders user email in header", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({ vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }), json: () => Promise.resolve({ value: 42 }),
} as Response); } as Response);
@ -84,6 +85,7 @@ describe("Home - Authenticated", () => {
test("renders sign out button", async () => { test("renders sign out button", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({ vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }), json: () => Promise.resolve({ value: 42 }),
} as Response); } as Response);
@ -93,6 +95,7 @@ describe("Home - Authenticated", () => {
test("clicking sign out calls logout and redirects", async () => { test("clicking sign out calls logout and redirects", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({ vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }), json: () => Promise.resolve({ value: 42 }),
} as Response); } as Response);
@ -107,6 +110,7 @@ describe("Home - Authenticated", () => {
test("renders counter value after fetch", async () => { test("renders counter value after fetch", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({ vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }), json: () => Promise.resolve({ value: 42 }),
} as Response); } as Response);
@ -118,6 +122,7 @@ describe("Home - Authenticated", () => {
test("fetches counter with credentials", async () => { test("fetches counter with credentials", async () => {
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 0 }), json: () => Promise.resolve({ value: 0 }),
} as Response); } as Response);
@ -135,6 +140,7 @@ describe("Home - Authenticated", () => {
test("renders increment button", async () => { test("renders increment button", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({ vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 0 }), json: () => Promise.resolve({ value: 0 }),
} as Response); } as Response);
@ -145,8 +151,8 @@ describe("Home - Authenticated", () => {
test("clicking increment button calls API with credentials", async () => { test("clicking increment button calls API with credentials", async () => {
const fetchSpy = vi const fetchSpy = vi
.spyOn(global, "fetch") .spyOn(global, "fetch")
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 0 }) } as Response) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 0 }) } as Response)
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 1 }) } as Response); .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 1 }) } as Response);
render(<Home />); render(<Home />);
await waitFor(() => expect(screen.getByText("0")).toBeDefined()); await waitFor(() => expect(screen.getByText("0")).toBeDefined());
@ -166,8 +172,8 @@ describe("Home - Authenticated", () => {
test("clicking increment updates displayed count", async () => { test("clicking increment updates displayed count", async () => {
vi.spyOn(global, "fetch") vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 0 }) } as Response) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 0 }) } as Response)
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 1 }) } as Response); .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 1 }) } as Response);
render(<Home />); render(<Home />);
await waitFor(() => expect(screen.getByText("0")).toBeDefined()); await waitFor(() => expect(screen.getByText("0")).toBeDefined());

View file

@ -1,55 +1,31 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { Permission } from "./auth-context";
import { useAuth, Permission } from "./auth-context"; import { api } from "./api";
import { API_URL } from "./config";
import { sharedStyles } from "./styles/shared"; import { sharedStyles } from "./styles/shared";
import { Header } from "./components/Header";
import { useRequireAuth } from "./hooks/useRequireAuth";
export default function Home() { export default function Home() {
const [count, setCount] = useState<number | null>(null); const [count, setCount] = useState<number | null>(null);
const { user, isLoading, logout, hasPermission, hasRole } = useAuth(); const { user, isLoading, isAuthorized } = useRequireAuth({
const router = useRouter(); requiredPermission: Permission.VIEW_COUNTER,
});
const canViewCounter = hasPermission(Permission.VIEW_COUNTER);
const isRegularUser = hasRole("regular");
useEffect(() => { useEffect(() => {
if (!isLoading) { if (user && isAuthorized) {
if (!user) { api.get<{ value: number }>("/api/counter")
router.push("/login");
} else if (!canViewCounter) {
// Redirect to audit if user has audit permission, otherwise to login
router.push(hasPermission(Permission.VIEW_AUDIT) ? "/audit" : "/login");
}
}
}, [isLoading, user, router, canViewCounter, hasPermission]);
useEffect(() => {
if (user) {
fetch(`${API_URL}/api/counter`, {
credentials: "include",
})
.then((res) => res.json())
.then((data) => setCount(data.value)) .then((data) => setCount(data.value))
.catch(() => setCount(null)); .catch(() => setCount(null));
} }
}, [user]); }, [user, isAuthorized]);
const increment = async () => { const increment = async () => {
const res = await fetch(`${API_URL}/api/counter/increment`, { const data = await api.post<{ value: number }>("/api/counter/increment");
method: "POST",
credentials: "include",
});
const data = await res.json();
setCount(data.value); setCount(data.value);
}; };
const handleLogout = async () => {
await logout();
router.push("/login");
};
if (isLoading) { if (isLoading) {
return ( return (
<main style={styles.main}> <main style={styles.main}>
@ -58,31 +34,13 @@ export default function Home() {
); );
} }
if (!user || !canViewCounter) { if (!user || !isAuthorized) {
return null; return null;
} }
return ( return (
<main style={styles.main}> <main style={styles.main}>
<div style={styles.header}> <Header currentPage="counter" />
<div style={styles.nav}>
<span style={styles.navCurrent}>Counter</span>
<span style={styles.navDivider}></span>
<a href="/sum" style={styles.navLink}>Sum</a>
{isRegularUser && (
<>
<span style={styles.navDivider}></span>
<a href="/profile" style={styles.navLink}>My Profile</a>
</>
)}
</div>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>
<button onClick={handleLogout} style={styles.logoutBtn}>
Sign out
</button>
</div>
</div>
<div style={styles.content}> <div style={styles.content}>
<div style={styles.counterCard}> <div style={styles.counterCard}>

View file

@ -1,11 +1,11 @@
"use client"; "use client";
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { bech32 } from "bech32"; import { bech32 } from "bech32";
import { useAuth } from "../auth-context"; import { api, ApiError } from "../api";
import { API_URL } from "../config";
import { sharedStyles } from "../styles/shared"; import { sharedStyles } from "../styles/shared";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
interface ProfileData { interface ProfileData {
contact_email: string | null; contact_email: string | null;
@ -111,8 +111,10 @@ function toFormData(data: ProfileData): FormData {
} }
export default function ProfilePage() { export default function ProfilePage() {
const { user, isLoading, logout, hasRole } = useAuth(); const { user, isLoading, isAuthorized } = useRequireAuth({
const router = useRouter(); requiredRole: "regular",
fallbackRedirect: "/audit",
});
const [originalData, setOriginalData] = useState<FormData | null>(null); const [originalData, setOriginalData] = useState<FormData | null>(null);
const [formData, setFormData] = useState<FormData>({ const [formData, setFormData] = useState<FormData>({
contact_email: "", contact_email: "",
@ -126,8 +128,6 @@ export default function ProfilePage() {
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null); const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isRegularUser = hasRole("regular");
// Check if form has changes // Check if form has changes
const hasChanges = useCallback(() => { const hasChanges = useCallback(() => {
if (!originalData) return false; if (!originalData) return false;
@ -144,42 +144,25 @@ export default function ProfilePage() {
return Object.keys(errors).length === 0; return Object.keys(errors).length === 0;
}, [errors]); }, [errors]);
useEffect(() => {
if (!isLoading) {
if (!user) {
router.push("/login");
} else if (!isRegularUser) {
router.push("/audit");
}
}
}, [isLoading, user, router, isRegularUser]);
const fetchProfile = useCallback(async () => { const fetchProfile = useCallback(async () => {
try { try {
const res = await fetch(`${API_URL}/api/profile`, { const data = await api.get<ProfileData>("/api/profile");
credentials: "include", const formValues = toFormData(data);
}); setFormData(formValues);
if (res.ok) { setOriginalData(formValues);
const data: ProfileData = await res.json();
const formValues = toFormData(data);
setFormData(formValues);
setOriginalData(formValues);
} else {
setToast({ message: "Failed to load profile", type: "error" });
}
} catch (err) { } catch (err) {
console.error("Profile load error:", err); console.error("Profile load error:", err);
setToast({ message: "Network error. Please try again.", type: "error" }); setToast({ message: "Failed to load profile", type: "error" });
} finally { } finally {
setIsLoadingProfile(false); setIsLoadingProfile(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
if (user && isRegularUser) { if (user && isAuthorized) {
fetchProfile(); fetchProfile();
} }
}, [user, isRegularUser, fetchProfile]); }, [user, isAuthorized, fetchProfile]);
// Auto-dismiss toast after 3 seconds // Auto-dismiss toast after 3 seconds
useEffect(() => { useEffect(() => {
@ -238,47 +221,32 @@ export default function ProfilePage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const res = await fetch(`${API_URL}/api/profile`, { const data = await api.put<ProfileData>("/api/profile", {
method: "PUT", contact_email: formData.contact_email || null,
headers: { "Content-Type": "application/json" }, telegram: formData.telegram || null,
credentials: "include", signal: formData.signal || null,
body: JSON.stringify({ nostr_npub: formData.nostr_npub || null,
contact_email: formData.contact_email || null,
telegram: formData.telegram || null,
signal: formData.signal || null,
nostr_npub: formData.nostr_npub || null,
}),
}); });
const formValues = toFormData(data);
if (res.ok) { setFormData(formValues);
const data: ProfileData = await res.json(); setOriginalData(formValues);
const formValues = toFormData(data); setToast({ message: "Profile saved successfully!", type: "success" });
setFormData(formValues); } catch (err) {
setOriginalData(formValues); console.error("Profile save error:", err);
setToast({ message: "Profile saved successfully!", type: "success" }); if (err instanceof ApiError && err.status === 422) {
} else if (res.status === 422) { const errorData = err.data as { detail?: { field_errors?: FieldErrors } };
// Handle validation errors from backend if (errorData?.detail?.field_errors) {
const errorData = await res.json();
if (errorData.detail?.field_errors) {
setErrors(errorData.detail.field_errors); setErrors(errorData.detail.field_errors);
} }
setToast({ message: "Please fix the errors below", type: "error" }); setToast({ message: "Please fix the errors below", type: "error" });
} else { } else {
setToast({ message: "Failed to save profile", type: "error" }); setToast({ message: "Network error. Please try again.", type: "error" });
} }
} catch (err) {
console.error("Profile save error:", err);
setToast({ message: "Network error. Please try again.", type: "error" });
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const handleLogout = async () => {
await logout();
router.push("/login");
};
if (isLoading || isLoadingProfile) { if (isLoading || isLoadingProfile) {
return ( return (
<main style={styles.main}> <main style={styles.main}>
@ -287,7 +255,7 @@ export default function ProfilePage() {
); );
} }
if (!user || !isRegularUser) { if (!user || !isAuthorized) {
return null; return null;
} }
@ -307,21 +275,7 @@ export default function ProfilePage() {
</div> </div>
)} )}
<div style={styles.header}> <Header currentPage="profile" />
<div style={styles.nav}>
<a href="/" style={styles.navLink}>Counter</a>
<span style={styles.navDivider}></span>
<a href="/sum" style={styles.navLink}>Sum</a>
<span style={styles.navDivider}></span>
<span style={styles.navCurrent}>My Profile</span>
</div>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>
<button onClick={handleLogout} style={styles.logoutBtn}>
Sign out
</button>
</div>
</div>
<div style={styles.content}> <div style={styles.content}>
<div style={styles.profileCard}> <div style={styles.profileCard}>

View file

@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { Permission } from "../auth-context";
import { useAuth, Permission } from "../auth-context"; import { api } from "../api";
import { API_URL } from "../config";
import { sharedStyles } from "../styles/shared"; import { sharedStyles } from "../styles/shared";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
export default function SumPage() { export default function SumPage() {
const [a, setA] = useState(""); const [a, setA] = useState("");
@ -12,21 +13,9 @@ export default function SumPage() {
const [result, setResult] = useState<number | null>(null); const [result, setResult] = useState<number | null>(null);
const [showResult, setShowResult] = useState(false); const [showResult, setShowResult] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { user, isLoading, logout, hasPermission, hasRole } = useAuth(); const { user, isLoading, isAuthorized } = useRequireAuth({
const router = useRouter(); requiredPermission: Permission.USE_SUM,
});
const canUseSum = hasPermission(Permission.USE_SUM);
const isRegularUser = hasRole("regular");
useEffect(() => {
if (!isLoading) {
if (!user) {
router.push("/login");
} else if (!canUseSum) {
router.push(hasPermission(Permission.VIEW_AUDIT) ? "/audit" : "/login");
}
}
}, [isLoading, user, router, canUseSum, hasPermission]);
const handleSum = async () => { const handleSum = async () => {
const numA = parseFloat(a) || 0; const numA = parseFloat(a) || 0;
@ -34,16 +23,7 @@ export default function SumPage() {
setError(null); setError(null);
try { try {
const res = await fetch(`${API_URL}/api/sum`, { const data = await api.post<{ result: number }>("/api/sum", { a: numA, b: numB });
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ a: numA, b: numB }),
});
if (!res.ok) {
throw new Error("Calculation failed");
}
const data = await res.json();
setResult(data.result); setResult(data.result);
setShowResult(true); setShowResult(true);
} catch (err) { } catch (err) {
@ -59,11 +39,6 @@ export default function SumPage() {
setError(null); setError(null);
}; };
const handleLogout = async () => {
await logout();
router.push("/login");
};
if (isLoading) { if (isLoading) {
return ( return (
<main style={styles.main}> <main style={styles.main}>
@ -72,31 +47,13 @@ export default function SumPage() {
); );
} }
if (!user || !canUseSum) { if (!user || !isAuthorized) {
return null; return null;
} }
return ( return (
<main style={styles.main}> <main style={styles.main}>
<div style={styles.header}> <Header currentPage="sum" />
<div style={styles.nav}>
<a href="/" style={styles.navLink}>Counter</a>
<span style={styles.navDivider}></span>
<span style={styles.navCurrent}>Sum</span>
{isRegularUser && (
<>
<span style={styles.navDivider}></span>
<a href="/profile" style={styles.navLink}>My Profile</a>
</>
)}
</div>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>
<button onClick={handleLogout} style={styles.logoutBtn}>
Sign out
</button>
</div>
</div>
<div style={styles.content}> <div style={styles.content}>
<div style={styles.card}> <div style={styles.card}>

View file

@ -100,7 +100,10 @@ test.describe("Counter - Authenticated", () => {
// Second user increments // Second user increments
await page2.click("text=Increment"); await page2.click("text=Increment");
await expect(page2.locator("h1")).toHaveText(String(page2InitialValue + 1)); // Wait for counter to update - use >= because parallel tests may also increment
await expect(page2.locator("h1")).not.toHaveText(String(page2InitialValue));
const page2AfterIncrement = Number(await page2.locator("h1").textContent());
expect(page2AfterIncrement).toBeGreaterThanOrEqual(page2InitialValue + 1);
// First user reloads and sees the increment (value should be >= what page2 has) // First user reloads and sees the increment (value should be >= what page2 has)
await page.reload(); await page.reload();

View file

@ -237,17 +237,17 @@ test.describe("Profile - Validation", () => {
await expect(page.locator("#telegram")).toHaveValue("@testhandle"); await expect(page.locator("#telegram")).toHaveValue("@testhandle");
}); });
test("shows error for telegram handle that is too short", async ({ page }) => { test("shows error for telegram handle with no characters after @", async ({ page }) => {
await page.goto("/profile"); await page.goto("/profile");
// Enter telegram with @ but too short (needs 5+ chars) // Enter telegram with @ but nothing after (needs at least 1 char)
await page.fill("#telegram", "@ab"); await page.fill("#telegram", "@");
// Wait for debounced validation // Wait for debounced validation
await page.waitForTimeout(600); await page.waitForTimeout(600);
// Should show error about length // Should show error about needing at least one character
await expect(page.getByText(/at least 5 characters/i)).toBeVisible(); await expect(page.getByText(/at least one character after @/i)).toBeVisible();
// Save button should be disabled // Save button should be disabled
const saveButton = page.getByRole("button", { name: /save changes/i }); const saveButton = page.getByRole("button", { name: /save changes/i });
@ -271,15 +271,22 @@ test.describe("Profile - Validation", () => {
test("can fix validation error and save", async ({ page }) => { test("can fix validation error and save", async ({ page }) => {
await page.goto("/profile"); await page.goto("/profile");
// Enter invalid telegram // Enter invalid telegram (just @ with no handle)
await page.fill("#telegram", "noat"); await page.fill("#telegram", "@");
await expect(page.getByText(/must start with @/i)).toBeVisible();
// Wait for debounced validation
await page.waitForTimeout(600);
await expect(page.getByText(/at least one character after @/i)).toBeVisible();
// Fix it // Fix it
await page.fill("#telegram", "@validhandle"); await page.fill("#telegram", "@validhandle");
// Wait for debounced validation
await page.waitForTimeout(600);
// Error should disappear // Error should disappear
await expect(page.getByText(/must start with @/i)).not.toBeVisible(); await expect(page.getByText(/at least one character after @/i)).not.toBeVisible();
// Should be able to save // Should be able to save
const saveButton = page.getByRole("button", { name: /save changes/i }); const saveButton = page.getByRole("button", { name: /save changes/i });

View file

@ -1,7 +1,4 @@
{ {
"status": "failed", "status": "passed",
"failedTests": [ "failedTests": []
"e8b79b4ee550a37632f1-b6f4d12ec6021e7a3bc8",
"e8b79b4ee550a37632f1-600f6ae7070fb14ef7f9"
]
} }

View file

@ -1,57 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e1]:
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- link "Counter" [ref=e5] [cursor=pointer]:
- /url: /
- generic [ref=e6]: •
- link "Sum" [ref=e7] [cursor=pointer]:
- /url: /sum
- generic [ref=e8]: •
- generic [ref=e9]: My Profile
- generic [ref=e10]:
- generic [ref=e11]: user@example.com
- button "Sign out" [ref=e12] [cursor=pointer]
- generic [ref=e14]:
- generic [ref=e15]:
- heading "My Profile" [level=1] [ref=e16]
- paragraph [ref=e17]: Manage your contact information
- generic [ref=e18]:
- generic [ref=e19]:
- generic [ref=e20]:
- text: Login Email
- generic [ref=e21]: Read only
- textbox [disabled] [ref=e22]: user@example.com
- generic [ref=e23]: This is your login email and cannot be changed here.
- paragraph [ref=e25]: Contact Details
- paragraph [ref=e26]: These are for communication purposes only — they won't affect your login.
- generic [ref=e27]:
- generic [ref=e28]: Contact Email
- textbox "Contact Email" [ref=e29]:
- /placeholder: alternate@example.com
- generic [ref=e30]:
- generic [ref=e31]: Telegram
- textbox "Telegram" [active] [ref=e32]:
- /placeholder: "@username"
- text: "@noat"
- generic [ref=e33]:
- generic [ref=e34]: Signal
- textbox "Signal" [ref=e35]:
- /placeholder: username.01
- generic [ref=e36]:
- generic [ref=e37]: Nostr (npub)
- textbox "Nostr (npub)" [ref=e38]:
- /placeholder: npub1...
- button "Save Changes" [ref=e39] [cursor=pointer]
- status [ref=e40]:
- generic [ref=e41]:
- img [ref=e43]
- generic [ref=e45]:
- text: Static route
- button "Hide static indicator" [ref=e46] [cursor=pointer]:
- img [ref=e47]
- alert [ref=e50]
```

View file

@ -1,57 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e1]:
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- link "Counter" [ref=e5] [cursor=pointer]:
- /url: /
- generic [ref=e6]: •
- link "Sum" [ref=e7] [cursor=pointer]:
- /url: /sum
- generic [ref=e8]: •
- generic [ref=e9]: My Profile
- generic [ref=e10]:
- generic [ref=e11]: user@example.com
- button "Sign out" [ref=e12] [cursor=pointer]
- generic [ref=e14]:
- generic [ref=e15]:
- heading "My Profile" [level=1] [ref=e16]
- paragraph [ref=e17]: Manage your contact information
- generic [ref=e18]:
- generic [ref=e19]:
- generic [ref=e20]:
- text: Login Email
- generic [ref=e21]: Read only
- textbox [disabled] [ref=e22]: user@example.com
- generic [ref=e23]: This is your login email and cannot be changed here.
- paragraph [ref=e25]: Contact Details
- paragraph [ref=e26]: These are for communication purposes only — they won't affect your login.
- generic [ref=e27]:
- generic [ref=e28]: Contact Email
- textbox "Contact Email" [ref=e29]:
- /placeholder: alternate@example.com
- generic [ref=e30]:
- generic [ref=e31]: Telegram
- textbox "Telegram" [active] [ref=e32]:
- /placeholder: "@username"
- text: "@ab"
- generic [ref=e33]:
- generic [ref=e34]: Signal
- textbox "Signal" [ref=e35]:
- /placeholder: username.01
- generic [ref=e36]:
- generic [ref=e37]: Nostr (npub)
- textbox "Nostr (npub)" [ref=e38]:
- /placeholder: npub1...
- button "Save Changes" [ref=e39] [cursor=pointer]
- status [ref=e40]:
- generic [ref=e41]:
- img [ref=e43]
- generic [ref=e45]:
- text: Static route
- button "Hide static indicator" [ref=e46] [cursor=pointer]:
- img [ref=e47]
- alert [ref=e50]
```