some fixes and refactors
This commit is contained in:
parent
ead8a566d0
commit
75cfc6c928
16 changed files with 381 additions and 425 deletions
|
|
@ -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
75
frontend/app/api.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const userData = await res.json();
|
|
||||||
setUser(userData);
|
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" },
|
|
||||||
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();
|
|
||||||
setUser(userData);
|
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 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" },
|
|
||||||
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();
|
|
||||||
setUser(userData);
|
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 () => {
|
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
85
frontend/app/components/Header.tsx
Normal file
85
frontend/app/components/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
64
frontend/app/hooks/useRequireAuth.ts
Normal file
64
frontend/app/hooks/useRequireAuth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data: ProfileData = await res.json();
|
|
||||||
const formValues = toFormData(data);
|
const formValues = toFormData(data);
|
||||||
setFormData(formValues);
|
setFormData(formValues);
|
||||||
setOriginalData(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",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
|
||||||
contact_email: formData.contact_email || null,
|
contact_email: formData.contact_email || null,
|
||||||
telegram: formData.telegram || null,
|
telegram: formData.telegram || null,
|
||||||
signal: formData.signal || null,
|
signal: formData.signal || null,
|
||||||
nostr_npub: formData.nostr_npub || null,
|
nostr_npub: formData.nostr_npub || null,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const data: ProfileData = await res.json();
|
|
||||||
const formValues = toFormData(data);
|
const formValues = toFormData(data);
|
||||||
setFormData(formValues);
|
setFormData(formValues);
|
||||||
setOriginalData(formValues);
|
setOriginalData(formValues);
|
||||||
setToast({ message: "Profile saved successfully!", type: "success" });
|
setToast({ message: "Profile saved successfully!", type: "success" });
|
||||||
} else if (res.status === 422) {
|
} catch (err) {
|
||||||
// Handle validation errors from backend
|
console.error("Profile save error:", err);
|
||||||
const errorData = await res.json();
|
if (err instanceof ApiError && err.status === 422) {
|
||||||
if (errorData.detail?.field_errors) {
|
const errorData = err.data as { detail?: { field_errors?: FieldErrors } };
|
||||||
|
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" });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Profile save error:", err);
|
|
||||||
setToast({ message: "Network error. Please try again.", type: "error" });
|
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}>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
{
|
{
|
||||||
"status": "failed",
|
"status": "passed",
|
||||||
"failedTests": [
|
"failedTests": []
|
||||||
"e8b79b4ee550a37632f1-b6f4d12ec6021e7a3bc8",
|
|
||||||
"e8b79b4ee550a37632f1-600f6ae7070fb14ef7f9"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
@ -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]
|
|
||||||
```
|
|
||||||
|
|
@ -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]
|
|
||||||
```
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue