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

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();
setUser(userData);
}
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");
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 userData = await res.json();
setUser(userData);
};
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");
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 userData = await res.json();
setUser(userData);
};
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 formValues = toFormData(data);
setFormData(formValues);
setOriginalData(formValues);
} else {
setToast({ message: "Failed to load profile", type: "error" });
}
const data = await api.get<ProfileData>("/api/profile");
const formValues = toFormData(data);
setFormData(formValues);
setOriginalData(formValues);
} 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({
contact_email: formData.contact_email || null,
telegram: formData.telegram || null,
signal: formData.signal || null,
nostr_npub: formData.nostr_npub || null,
}),
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) {
const formValues = toFormData(data);
setFormData(formValues);
setOriginalData(formValues);
setToast({ message: "Profile saved successfully!", type: "success" });
} 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" });
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 {
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]
```