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 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.middleware.cors import CORSMiddleware
@ -8,6 +8,20 @@ from pydantic import BaseModel
from sqlalchemy import select, func, desc
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 models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR
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]
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

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) =>
mockUser?.permissions.includes(permission) ?? false
);
const mockHasRole = vi.fn((role: string) =>
mockUser?.roles.includes(role) ?? false
);
vi.mock("../auth-context", () => ({
useAuth: () => ({
@ -27,6 +30,7 @@ vi.mock("../auth-context", () => ({
isLoading: mockIsLoading,
logout: mockLogout,
hasPermission: mockHasPermission,
hasRole: mockHasRole,
}),
Permission: {
VIEW_COUNTER: "view_counter",
@ -52,6 +56,9 @@ beforeEach(() => {
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,
@ -114,7 +121,8 @@ describe("AuditPage", () => {
render(<AuditPage />);
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";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth, Permission } from "../auth-context";
import { API_URL } from "../config";
import { Permission } from "../auth-context";
import { api } from "../api";
import { sharedStyles } from "../styles/shared";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
interface CounterRecord {
id: number;
@ -38,31 +39,17 @@ export default function AuditPage() {
const [sumError, setSumError] = useState<string | null>(null);
const [counterPage, setCounterPage] = useState(1);
const [sumPage, setSumPage] = useState(1);
const { user, isLoading, logout, hasPermission } = useAuth();
const router = useRouter();
const canViewAudit = hasPermission(Permission.VIEW_AUDIT);
useEffect(() => {
if (!isLoading) {
if (!user) {
router.push("/login");
} else if (!canViewAudit) {
router.push("/");
}
}
}, [isLoading, user, router, canViewAudit]);
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_AUDIT,
fallbackRedirect: "/",
});
const fetchCounterRecords = useCallback(async (page: number) => {
setCounterError(null);
try {
const res = await fetch(`${API_URL}/api/audit/counter?page=${page}&per_page=10`, {
credentials: "include",
});
if (!res.ok) {
throw new Error("Failed to load counter records");
}
const data = await res.json();
const data = await api.get<PaginatedResponse<CounterRecord>>(
`/api/audit/counter?page=${page}&per_page=10`
);
setCounterData(data);
} catch (err) {
setCounterData(null);
@ -73,13 +60,9 @@ export default function AuditPage() {
const fetchSumRecords = useCallback(async (page: number) => {
setSumError(null);
try {
const res = await fetch(`${API_URL}/api/audit/sum?page=${page}&per_page=10`, {
credentials: "include",
});
if (!res.ok) {
throw new Error("Failed to load sum records");
}
const data = await res.json();
const data = await api.get<PaginatedResponse<SumRecord>>(
`/api/audit/sum?page=${page}&per_page=10`
);
setSumData(data);
} catch (err) {
setSumData(null);
@ -88,21 +71,16 @@ export default function AuditPage() {
}, []);
useEffect(() => {
if (user && canViewAudit) {
if (user && isAuthorized) {
fetchCounterRecords(counterPage);
}
}, [user, counterPage, canViewAudit, fetchCounterRecords]);
}, [user, counterPage, isAuthorized, fetchCounterRecords]);
useEffect(() => {
if (user && canViewAudit) {
if (user && isAuthorized) {
fetchSumRecords(sumPage);
}
}, [user, sumPage, canViewAudit, fetchSumRecords]);
const handleLogout = async () => {
await logout();
router.push("/login");
};
}, [user, sumPage, isAuthorized, fetchSumRecords]);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
@ -116,23 +94,13 @@ export default function AuditPage() {
);
}
if (!user || !canViewAudit) {
if (!user || !isAuthorized) {
return null;
}
return (
<main style={styles.main}>
<div style={styles.header}>
<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>
<Header currentPage="audit" />
<div style={styles.content}>
<div style={styles.tablesContainer}>

View file

@ -2,7 +2,7 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import { API_URL } from "./config";
import { api, ApiError } from "./api";
// Permission constants matching backend
export const Permission = {
@ -43,13 +43,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const checkAuth = async () => {
try {
const res = await fetch(`${API_URL}/api/auth/me`, {
credentials: "include",
});
if (res.ok) {
const userData = await res.json();
const userData = await api.get<User>("/api/auth/me");
setUser(userData);
}
} catch {
// Not authenticated
} finally {
@ -58,44 +53,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
const login = async (email: string, password: string) => {
const res = await fetch(`${API_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Login failed");
}
const userData = await res.json();
try {
const userData = await api.post<User>("/api/auth/login", { email, password });
setUser(userData);
} catch (err) {
if (err instanceof ApiError) {
const data = err.data as { detail?: string };
throw new Error(data?.detail || "Login failed");
}
throw err;
}
};
const register = async (email: string, password: string) => {
const res = await fetch(`${API_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Registration failed");
}
const userData = await res.json();
try {
const userData = await api.post<User>("/api/auth/register", { email, password });
setUser(userData);
} catch (err) {
if (err instanceof ApiError) {
const data = err.data as { detail?: string };
throw new Error(data?.detail || "Registration failed");
}
throw err;
}
};
const logout = async () => {
await fetch(`${API_URL}/api/auth/logout`, {
method: "POST",
credentials: "include",
});
try {
await api.post("/api/auth/logout");
} catch {
// Ignore errors on logout
}
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 () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }),
} as Response);
@ -84,6 +85,7 @@ describe("Home - Authenticated", () => {
test("renders sign out button", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }),
} as Response);
@ -93,6 +95,7 @@ describe("Home - Authenticated", () => {
test("clicking sign out calls logout and redirects", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }),
} as Response);
@ -107,6 +110,7 @@ describe("Home - Authenticated", () => {
test("renders counter value after fetch", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }),
} as Response);
@ -118,6 +122,7 @@ describe("Home - Authenticated", () => {
test("fetches counter with credentials", async () => {
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 0 }),
} as Response);
@ -135,6 +140,7 @@ describe("Home - Authenticated", () => {
test("renders increment button", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 0 }),
} as Response);
@ -145,8 +151,8 @@ describe("Home - Authenticated", () => {
test("clicking increment button calls API with credentials", async () => {
const fetchSpy = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 0 }) } as Response)
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 1 }) } as Response);
.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());
@ -166,8 +172,8 @@ describe("Home - Authenticated", () => {
test("clicking increment updates displayed count", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 0 }) } as Response)
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 1 }) } as Response);
.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());

View file

@ -1,55 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth, Permission } from "./auth-context";
import { API_URL } from "./config";
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 Home() {
const [count, setCount] = useState<number | null>(null);
const { user, isLoading, logout, hasPermission, hasRole } = useAuth();
const router = useRouter();
const canViewCounter = hasPermission(Permission.VIEW_COUNTER);
const isRegularUser = hasRole("regular");
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_COUNTER,
});
useEffect(() => {
if (!isLoading) {
if (!user) {
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())
if (user && isAuthorized) {
api.get<{ value: number }>("/api/counter")
.then((data) => setCount(data.value))
.catch(() => setCount(null));
}
}, [user]);
}, [user, isAuthorized]);
const increment = async () => {
const res = await fetch(`${API_URL}/api/counter/increment`, {
method: "POST",
credentials: "include",
});
const data = await res.json();
const data = await api.post<{ value: number }>("/api/counter/increment");
setCount(data.value);
};
const handleLogout = async () => {
await logout();
router.push("/login");
};
if (isLoading) {
return (
<main style={styles.main}>
@ -58,31 +34,13 @@ export default function Home() {
);
}
if (!user || !canViewCounter) {
if (!user || !isAuthorized) {
return null;
}
return (
<main style={styles.main}>
<div style={styles.header}>
<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>
<Header currentPage="counter" />
<div style={styles.content}>
<div style={styles.counterCard}>

View file

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

View file

@ -1,10 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth, Permission } from "../auth-context";
import { API_URL } from "../config";
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("");
@ -12,21 +13,9 @@ export default function SumPage() {
const [result, setResult] = useState<number | null>(null);
const [showResult, setShowResult] = useState(false);
const [error, setError] = useState<string | null>(null);
const { user, isLoading, logout, hasPermission, hasRole } = useAuth();
const router = useRouter();
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 { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.USE_SUM,
});
const handleSum = async () => {
const numA = parseFloat(a) || 0;
@ -34,16 +23,7 @@ export default function SumPage() {
setError(null);
try {
const res = await fetch(`${API_URL}/api/sum`, {
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();
const data = await api.post<{ result: number }>("/api/sum", { a: numA, b: numB });
setResult(data.result);
setShowResult(true);
} catch (err) {
@ -59,11 +39,6 @@ export default function SumPage() {
setError(null);
};
const handleLogout = async () => {
await logout();
router.push("/login");
};
if (isLoading) {
return (
<main style={styles.main}>
@ -72,31 +47,13 @@ export default function SumPage() {
);
}
if (!user || !canUseSum) {
if (!user || !isAuthorized) {
return null;
}
return (
<main style={styles.main}>
<div style={styles.header}>
<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>
<Header currentPage="sum" />
<div style={styles.content}>
<div style={styles.card}>

View file

@ -100,7 +100,10 @@ test.describe("Counter - Authenticated", () => {
// Second user increments
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)
await page.reload();

View file

@ -237,17 +237,17 @@ test.describe("Profile - Validation", () => {
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");
// Enter telegram with @ but too short (needs 5+ chars)
await page.fill("#telegram", "@ab");
// Enter telegram with @ but nothing after (needs at least 1 char)
await page.fill("#telegram", "@");
// Wait for debounced validation
await page.waitForTimeout(600);
// Should show error about length
await expect(page.getByText(/at least 5 characters/i)).toBeVisible();
// Should show error about needing at least one character
await expect(page.getByText(/at least one character after @/i)).toBeVisible();
// Save button should be disabled
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 }) => {
await page.goto("/profile");
// Enter invalid telegram
await page.fill("#telegram", "noat");
await expect(page.getByText(/must start with @/i)).toBeVisible();
// Enter invalid telegram (just @ with no handle)
await page.fill("#telegram", "@");
// Wait for debounced validation
await page.waitForTimeout(600);
await expect(page.getByText(/at least one character after @/i)).toBeVisible();
// Fix it
await page.fill("#telegram", "@validhandle");
// Wait for debounced validation
await page.waitForTimeout(600);
// 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
const saveButton = page.getByRole("button", { name: /save changes/i });

View file

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

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]
```