Add Prettier for TypeScript formatting

- Install prettier
- Configure .prettierrc.json and .prettierignore
- Add npm scripts: format, format:check
- Add Makefile target: format-frontend
- Format all frontend files
This commit is contained in:
counterweight 2025-12-21 21:59:26 +01:00
parent 4b394b0698
commit 37de6f70e0
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
44 changed files with 906 additions and 856 deletions

View file

@ -1,4 +1,4 @@
.PHONY: install-backend install-frontend install setup-hooks backend frontend db db-stop db-ready db-seed dev test test-backend test-frontend test-e2e typecheck generate-types generate-types-standalone check-types-fresh check-constants lint-backend format-backend fix-backend security-backend lint-frontend fix-frontend
.PHONY: install-backend install-frontend install setup-hooks backend frontend db db-stop db-ready db-seed dev test test-backend test-frontend test-e2e typecheck generate-types generate-types-standalone check-types-fresh check-constants lint-backend format-backend fix-backend security-backend lint-frontend fix-frontend format-frontend
-include .env
export
@ -111,3 +111,6 @@ lint-frontend:
fix-frontend:
cd frontend && npm run lint:fix
format-frontend:
cd frontend && npm run format

4
frontend/.prettierignore Normal file
View file

@ -0,0 +1,4 @@
.next/
node_modules/
app/generated/

View file

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

View file

@ -189,7 +189,7 @@ export default function AdminAppointmentsPage() {
const handleCancel = async (appointmentId: number) => {
setCancellingId(appointmentId);
setError(null);
try {
await api.post<AppointmentResponse>(`/api/admin/appointments/${appointmentId}/cancel`, {});
await fetchAppointments();
@ -225,13 +225,9 @@ export default function AdminAppointmentsPage() {
<Header currentPage="admin-appointments" />
<div style={styles.content}>
<h1 style={styles.pageTitle}>All Appointments</h1>
<p style={styles.pageSubtitle}>
View and manage all user appointments
</p>
<p style={styles.pageSubtitle}>View and manage all user appointments</p>
{error && (
<div style={styles.errorBanner}>{error}</div>
)}
{error && <div style={styles.errorBanner}>{error}</div>}
{/* Status Filter */}
<div style={styles.filterRow}>
@ -269,26 +265,20 @@ export default function AdminAppointmentsPage() {
>
<div style={styles.appointmentHeader}>
<div>
<div style={styles.appointmentTime}>
{formatDateTime(apt.slot_start)}
</div>
<div style={styles.appointmentUser}>
{apt.user_email}
</div>
{apt.note && (
<div style={styles.appointmentNote}>
&quot;{apt.note}&quot;
</div>
)}
<span style={{
...styles.statusBadge,
background: status.bgColor,
color: status.textColor,
}}>
<div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
<div style={styles.appointmentUser}>{apt.user_email}</div>
{apt.note && <div style={styles.appointmentNote}>&quot;{apt.note}&quot;</div>}
<span
style={{
...styles.statusBadge,
background: status.bgColor,
color: status.textColor,
}}
>
{status.text}
</span>
</div>
{apt.status === "booked" && (
<div style={styles.buttonGroup}>
{confirmCancelId === apt.id ? (
@ -327,4 +317,3 @@ export default function AdminAppointmentsPage() {
</main>
);
}

View file

@ -8,7 +8,13 @@ import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api";
import constants from "../../../../shared/constants.json";
import { formatDate, formatDisplayDate, getDateRange, formatTimeString, isWeekend } from "../../utils/date";
import {
formatDate,
formatDisplayDate,
getDateRange,
formatTimeString,
isWeekend,
} from "../../utils/date";
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.booking;
@ -57,14 +63,14 @@ export default function AdminAvailabilityPage() {
const fetchAvailability = useCallback(async () => {
const dateRange = getDateRange(minAdvanceDays, maxAdvanceDays);
if (!dateRange.length) return;
try {
const fromDate = formatDate(dateRange[0]);
const toDate = formatDate(dateRange[dateRange.length - 1]);
const data = await api.get<AvailabilityResponse>(
`/api/admin/availability?from=${fromDate}&to=${toDate}`
);
const map = new Map<string, TimeSlot[]>();
for (const day of data.days) {
map.set(day.date, day.slots);
@ -118,21 +124,21 @@ export default function AdminAvailabilityPage() {
const saveAvailability = async () => {
if (!selectedDate) return;
setIsSaving(true);
setError(null);
try {
const slots = editSlots.map((s) => ({
start_time: s.start_time + ":00",
end_time: s.end_time + ":00",
}));
await api.put("/api/admin/availability", {
date: formatDate(selectedDate),
slots,
});
await fetchAvailability();
closeModal();
} catch (err) {
@ -144,16 +150,16 @@ export default function AdminAvailabilityPage() {
const clearAvailability = async () => {
if (!selectedDate) return;
setIsSaving(true);
setError(null);
try {
await api.put("/api/admin/availability", {
date: formatDate(selectedDate),
slots: [],
});
await fetchAvailability();
closeModal();
} catch (err) {
@ -186,16 +192,16 @@ export default function AdminAvailabilityPage() {
const executeCopy = async () => {
if (!copySource || copyTargets.size === 0) return;
setIsCopying(true);
setError(null);
try {
await api.post("/api/admin/availability/copy", {
source_date: copySource,
target_dates: Array.from(copyTargets),
});
await fetchAvailability();
cancelCopyMode();
} catch (err) {
@ -236,10 +242,12 @@ export default function AdminAvailabilityPage() {
</div>
{copySource && (
<div style={styles.copyActions}>
<span style={styles.copyHint}>
Select days to copy to, then click Copy
</span>
<button onClick={executeCopy} disabled={copyTargets.size === 0 || isCopying} style={styles.copyButton}>
<span style={styles.copyHint}>Select days to copy to, then click Copy</span>
<button
onClick={executeCopy}
disabled={copyTargets.size === 0 || isCopying}
style={styles.copyButton}
>
{isCopying ? "Copying..." : `Copy to ${copyTargets.size} day(s)`}
</button>
<button onClick={cancelCopyMode} style={styles.cancelButton}>
@ -249,9 +257,7 @@ export default function AdminAvailabilityPage() {
)}
</div>
{error && !selectedDate && (
<div style={styles.errorBanner}>{error}</div>
)}
{error && !selectedDate && <div style={styles.errorBanner}>{error}</div>}
<div style={styles.calendar}>
{dates.map((date) => {
@ -318,9 +324,7 @@ export default function AdminAvailabilityPage() {
{selectedDate && (
<div style={styles.modalOverlay} onClick={closeModal}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h2 style={styles.modalTitle}>
Edit Availability - {formatDisplayDate(selectedDate)}
</h2>
<h2 style={styles.modalTitle}>Edit Availability - {formatDisplayDate(selectedDate)}</h2>
{error && <div style={styles.modalError}>{error}</div>}
@ -333,7 +337,9 @@ export default function AdminAvailabilityPage() {
style={styles.timeSelect}
>
{TIME_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option>
<option key={t} value={t}>
{t}
</option>
))}
</select>
<span style={styles.slotDash}></span>
@ -343,7 +349,9 @@ export default function AdminAvailabilityPage() {
style={styles.timeSelect}
>
{TIME_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option>
<option key={t} value={t}>
{t}
</option>
))}
</select>
<button
@ -629,4 +637,3 @@ const pageStyles: Record<string, React.CSSProperties> = {
};
const styles = { ...sharedStyles, ...pageStyles };

View file

@ -66,10 +66,10 @@ export default function AdminInvitesPage() {
setCreateError("Please select a godfather");
return;
}
setIsCreating(true);
setCreateError(null);
try {
await api.post("/api/admin/invites", {
godfather_id: parseInt(newGodfatherId),
@ -185,12 +185,10 @@ export default function AdminInvitesPage() {
<option value={SPENT}>Spent</option>
<option value={REVOKED}>Revoked</option>
</select>
<span style={styles.totalCount}>
{data?.total ?? 0} invites
</span>
<span style={styles.totalCount}>{data?.total ?? 0} invites</span>
</div>
</div>
<div style={styles.tableWrapper}>
<table style={styles.table}>
<thead>
@ -206,43 +204,48 @@ export default function AdminInvitesPage() {
<tbody>
{error && (
<tr>
<td colSpan={6} style={styles.errorRow}>{error}</td>
<td colSpan={6} style={styles.errorRow}>
{error}
</td>
</tr>
)}
{!error && data?.records.map((record) => (
<tr key={record.id} style={styles.tr}>
<td style={styles.tdCode}>{record.identifier}</td>
<td style={styles.td}>{record.godfather_email}</td>
<td style={styles.td}>
<span style={{ ...styles.statusBadge, ...getStatusBadgeStyle(record.status) }}>
{record.status}
</span>
</td>
<td style={styles.td}>
{record.used_by_email || "-"}
</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
<td style={styles.td}>
{record.status === READY && (
<button
onClick={() => handleRevoke(record.id)}
style={styles.revokeButton}
{!error &&
data?.records.map((record) => (
<tr key={record.id} style={styles.tr}>
<td style={styles.tdCode}>{record.identifier}</td>
<td style={styles.td}>{record.godfather_email}</td>
<td style={styles.td}>
<span
style={{ ...styles.statusBadge, ...getStatusBadgeStyle(record.status) }}
>
Revoke
</button>
)}
</td>
</tr>
))}
{record.status}
</span>
</td>
<td style={styles.td}>{record.used_by_email || "-"}</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
<td style={styles.td}>
{record.status === READY && (
<button
onClick={() => handleRevoke(record.id)}
style={styles.revokeButton}
>
Revoke
</button>
)}
</td>
</tr>
))}
{!error && (!data || data.records.length === 0) && (
<tr>
<td colSpan={6} style={styles.emptyRow}>No invites yet</td>
<td colSpan={6} style={styles.emptyRow}>
No invites yet
</td>
</tr>
)}
</tbody>
</table>
</div>
{data && data.total_pages > 1 && (
<div style={styles.pagination}>
<button
@ -500,4 +503,3 @@ const pageStyles: Record<string, React.CSSProperties> = {
};
const styles = { ...sharedStyles, ...pageStyles };

View file

@ -16,26 +16,23 @@ export class ApiError extends Error {
}
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${API_URL}${endpoint}`;
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
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 {
@ -43,13 +40,9 @@ async function request<T>(
} catch {
// Response wasn't JSON
}
throw new ApiError(
`Request failed: ${res.status}`,
res.status,
data
);
throw new ApiError(`Request failed: ${res.status}`, res.status, data);
}
return res.json();
}
@ -57,14 +50,14 @@ 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",
@ -72,4 +65,3 @@ export const api = {
});
},
};

View file

@ -181,7 +181,7 @@ export default function AppointmentsPage() {
const handleCancel = async (appointmentId: number) => {
setCancellingId(appointmentId);
setError(null);
try {
await api.post<AppointmentResponse>(`/api/appointments/${appointmentId}/cancel`, {});
await fetchAppointments();
@ -217,29 +217,25 @@ export default function AppointmentsPage() {
<Header currentPage="appointments" />
<div style={styles.content}>
<h1 style={styles.pageTitle}>My Appointments</h1>
<p style={styles.pageSubtitle}>
View and manage your booked appointments
</p>
<p style={styles.pageSubtitle}>View and manage your booked appointments</p>
{error && (
<div style={styles.errorBanner}>{error}</div>
)}
{error && <div style={styles.errorBanner}>{error}</div>}
{isLoadingAppointments ? (
<div style={styles.emptyState}>Loading appointments...</div>
) : appointments.length === 0 ? (
<div style={styles.emptyState}>
<p>You don&apos;t have any appointments yet.</p>
<a href="/booking" style={styles.emptyStateLink}>Book an appointment</a>
<a href="/booking" style={styles.emptyStateLink}>
Book an appointment
</a>
</div>
) : (
<>
{/* Upcoming Appointments */}
{upcomingAppointments.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Upcoming ({upcomingAppointments.length})
</h2>
<h2 style={styles.sectionTitle}>Upcoming ({upcomingAppointments.length})</h2>
<div style={styles.appointmentList}>
{upcomingAppointments.map((apt) => {
const status = getStatusDisplay(apt.status, true);
@ -250,20 +246,18 @@ export default function AppointmentsPage() {
<div style={styles.appointmentTime}>
{formatDateTime(apt.slot_start)}
</div>
{apt.note && (
<div style={styles.appointmentNote}>
{apt.note}
</div>
)}
<span style={{
...styles.statusBadge,
background: status.bgColor,
color: status.textColor,
}}>
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
<span
style={{
...styles.statusBadge,
background: status.bgColor,
color: status.textColor,
}}
>
{status.text}
</span>
</div>
{apt.status === "booked" && (
<div style={styles.buttonGroup}>
{confirmCancelId === apt.id ? (
@ -310,20 +304,19 @@ export default function AppointmentsPage() {
{pastOrCancelledAppointments.map((apt) => {
const status = getStatusDisplay(apt.status, true);
return (
<div key={apt.id} style={{...styles.appointmentCard, ...styles.appointmentCardPast}}>
<div style={styles.appointmentTime}>
{formatDateTime(apt.slot_start)}
</div>
{apt.note && (
<div style={styles.appointmentNote}>
{apt.note}
</div>
)}
<span style={{
...styles.statusBadge,
background: status.bgColor,
color: status.textColor,
}}>
<div
key={apt.id}
style={{ ...styles.appointmentCard, ...styles.appointmentCardPast }}
>
<div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
<span
style={{
...styles.statusBadge,
background: status.bgColor,
color: status.textColor,
}}
>
{status.text}
</span>
</div>
@ -338,4 +331,3 @@ export default function AppointmentsPage() {
</main>
);
}

View file

@ -17,12 +17,10 @@ let mockUser: { id: number; email: string; roles: string[]; permissions: string[
};
let mockIsLoading = false;
const mockLogout = vi.fn();
const mockHasPermission = vi.fn((permission: string) =>
mockUser?.permissions.includes(permission) ?? false
);
const mockHasRole = vi.fn((role: string) =>
mockUser?.roles.includes(role) ?? false
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: () => ({
@ -53,12 +51,10 @@ beforeEach(() => {
permissions: ["view_audit"],
};
mockIsLoading = false;
mockHasPermission.mockImplementation((permission: string) =>
mockUser?.permissions.includes(permission) ?? false
);
mockHasRole.mockImplementation((role: string) =>
mockUser?.roles.includes(role) ?? false
mockHasPermission.mockImplementation(
(permission: string) => mockUser?.permissions.includes(permission) ?? false
);
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
// Default: successful empty response
mockFetch.mockResolvedValue({
ok: true,
@ -142,7 +138,7 @@ describe("AuditPage", () => {
per_page: 10,
total_pages: 1,
};
const sumResponse = {
records: [],
total: 0,

View file

@ -42,9 +42,7 @@ export default function AuditPage() {
const fetchSumRecords = useCallback(async (page: number) => {
setSumError(null);
try {
const data = await api.get<PaginatedSumRecords>(
`/api/audit/sum?page=${page}&per_page=10`
);
const data = await api.get<PaginatedSumRecords>(`/api/audit/sum?page=${page}&per_page=10`);
setSumData(data);
} catch (err) {
setSumData(null);
@ -90,9 +88,7 @@ export default function AuditPage() {
<div style={styles.tableCard}>
<div style={styles.tableHeader}>
<h2 style={styles.tableTitle}>Counter Activity</h2>
<span style={styles.totalCount}>
{counterData?.total ?? 0} records
</span>
<span style={styles.totalCount}>{counterData?.total ?? 0} records</span>
</div>
<div style={styles.tableWrapper}>
<table style={styles.table}>
@ -107,20 +103,25 @@ export default function AuditPage() {
<tbody>
{counterError && (
<tr>
<td colSpan={4} style={styles.errorRow}>{counterError}</td>
<td colSpan={4} style={styles.errorRow}>
{counterError}
</td>
</tr>
)}
{!counterError && counterData?.records.map((record) => (
<tr key={record.id} style={styles.tr}>
<td style={styles.td}>{record.user_email}</td>
<td style={styles.tdNum}>{record.value_before}</td>
<td style={styles.tdNum}>{record.value_after}</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
</tr>
))}
{!counterError &&
counterData?.records.map((record) => (
<tr key={record.id} style={styles.tr}>
<td style={styles.td}>{record.user_email}</td>
<td style={styles.tdNum}>{record.value_before}</td>
<td style={styles.tdNum}>{record.value_after}</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
</tr>
))}
{!counterError && (!counterData || counterData.records.length === 0) && (
<tr>
<td colSpan={4} style={styles.emptyRow}>No records yet</td>
<td colSpan={4} style={styles.emptyRow}>
No records yet
</td>
</tr>
)}
</tbody>
@ -153,9 +154,7 @@ export default function AuditPage() {
<div style={styles.tableCard}>
<div style={styles.tableHeader}>
<h2 style={styles.tableTitle}>Sum Activity</h2>
<span style={styles.totalCount}>
{sumData?.total ?? 0} records
</span>
<span style={styles.totalCount}>{sumData?.total ?? 0} records</span>
</div>
<div style={styles.tableWrapper}>
<table style={styles.table}>
@ -171,21 +170,26 @@ export default function AuditPage() {
<tbody>
{sumError && (
<tr>
<td colSpan={5} style={styles.errorRow}>{sumError}</td>
<td colSpan={5} style={styles.errorRow}>
{sumError}
</td>
</tr>
)}
{!sumError && sumData?.records.map((record) => (
<tr key={record.id} style={styles.tr}>
<td style={styles.td}>{record.user_email}</td>
<td style={styles.tdNum}>{record.a}</td>
<td style={styles.tdNum}>{record.b}</td>
<td style={styles.tdResult}>{record.result}</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
</tr>
))}
{!sumError &&
sumData?.records.map((record) => (
<tr key={record.id} style={styles.tr}>
<td style={styles.td}>{record.user_email}</td>
<td style={styles.tdNum}>{record.a}</td>
<td style={styles.tdNum}>{record.b}</td>
<td style={styles.tdResult}>{record.result}</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
</tr>
))}
{!sumError && (!sumData || sumData.records.length === 0) && (
<tr>
<td colSpan={5} style={styles.emptyRow}>No records yet</td>
<td colSpan={5} style={styles.emptyRow}>
No records yet
</td>
</tr>
)}
</tbody>

View file

@ -25,7 +25,7 @@ export const Permission = {
CANCEL_ANY_APPOINTMENT: "cancel_any_appointment",
} as const;
export type PermissionType = typeof Permission[keyof typeof Permission];
export type PermissionType = (typeof Permission)[keyof typeof Permission];
// Use generated type from OpenAPI schema
type User = components["schemas"]["UserResponse"];
@ -100,13 +100,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null);
};
const hasPermission = useCallback((permission: PermissionType): boolean => {
return user?.permissions.includes(permission) ?? false;
}, [user]);
const hasPermission = useCallback(
(permission: PermissionType): boolean => {
return user?.permissions.includes(permission) ?? false;
},
[user]
);
const hasRole = useCallback((role: string): boolean => {
return user?.roles.includes(role) ?? false;
}, [user]);
const hasRole = useCallback(
(role: string): boolean => {
return user?.roles.includes(role) ?? false;
},
[user]
);
return (
<AuthContext.Provider

View file

@ -234,14 +234,17 @@ export default function BookingPage() {
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true);
// Memoize dates to prevent infinite re-renders
const dates = useMemo(() => getDateRange(minAdvanceDays, maxAdvanceDays), [minAdvanceDays, maxAdvanceDays]);
const dates = useMemo(
() => getDateRange(minAdvanceDays, maxAdvanceDays),
[minAdvanceDays, maxAdvanceDays]
);
const fetchSlots = useCallback(async (date: Date) => {
setIsLoadingSlots(true);
setError(null);
setAvailableSlots([]);
setSelectedSlot(null);
try {
const dateStr = formatDate(date);
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
@ -261,7 +264,7 @@ export default function BookingPage() {
const fetchAllAvailability = async () => {
setIsLoadingAvailability(true);
const availabilitySet = new Set<string>();
// Fetch availability for all dates in parallel
const promises = dates.map(async (date) => {
try {
@ -275,7 +278,7 @@ export default function BookingPage() {
console.error(`Failed to fetch availability for ${formatDate(date)}:`, err);
}
});
await Promise.all(promises);
setDatesWithAvailability(availabilitySet);
setIsLoadingAvailability(false);
@ -307,20 +310,22 @@ export default function BookingPage() {
const handleBook = async () => {
if (!selectedSlot) return;
setIsBooking(true);
setError(null);
try {
const appointment = await api.post<AppointmentResponse>("/api/booking", {
slot_start: selectedSlot.start_time,
note: note || null,
});
setSuccessMessage(`Appointment booked for ${formatTime(appointment.slot_start)} - ${formatTime(appointment.slot_end)}`);
setSuccessMessage(
`Appointment booked for ${formatTime(appointment.slot_start)} - ${formatTime(appointment.slot_end)}`
);
setSelectedSlot(null);
setNote("");
// Refresh slots to show the booked one is gone
if (selectedDate) {
await fetchSlots(selectedDate);
@ -359,13 +364,9 @@ export default function BookingPage() {
Select a date to see available {slotDurationMinutes}-minute slots
</p>
{successMessage && (
<div style={styles.successBanner}>{successMessage}</div>
)}
{successMessage && <div style={styles.successBanner}>{successMessage}</div>}
{error && (
<div style={styles.errorBanner}>{error}</div>
)}
{error && <div style={styles.errorBanner}>{error}</div>}
{/* Date Selection */}
<div style={styles.section}>
@ -376,7 +377,7 @@ export default function BookingPage() {
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
const hasAvailability = datesWithAvailability.has(dateStr);
const isDisabled = !hasAvailability || isLoadingAvailability;
return (
<button
key={dateStr}
@ -404,13 +405,14 @@ export default function BookingPage() {
{selectedDate && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available Slots for {selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric"
Available Slots for{" "}
{selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})}
</h2>
{isLoadingSlots ? (
<div style={styles.emptyState}>Loading slots...</div>
) : availableSlots.length === 0 ? (
@ -442,27 +444,28 @@ export default function BookingPage() {
<div style={styles.confirmCard}>
<h3 style={styles.confirmTitle}>Confirm Booking</h3>
<p style={styles.confirmTime}>
<strong>Time:</strong> {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
<strong>Time:</strong> {formatTime(selectedSlot.start_time)} -{" "}
{formatTime(selectedSlot.end_time)}
</p>
<div>
<label style={styles.inputLabel}>
Note (optional, max {noteMaxLength} chars)
</label>
<label style={styles.inputLabel}>Note (optional, max {noteMaxLength} chars)</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))}
placeholder="Add a note about your appointment..."
style={styles.textarea}
/>
<div style={{
...styles.charCount,
...(note.length >= noteMaxLength ? styles.charCountWarning : {}),
}}>
<div
style={{
...styles.charCount,
...(note.length >= noteMaxLength ? styles.charCountWarning : {}),
}}
>
{note.length}/{noteMaxLength}
</div>
</div>
<div style={styles.buttonRow}>
<button
onClick={handleBook}
@ -488,4 +491,3 @@ export default function BookingPage() {
</main>
);
}

View file

@ -7,7 +7,17 @@ import constants from "../../../shared/constants.json";
const { ADMIN, REGULAR } = constants.roles;
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "appointments" | "audit" | "admin-invites" | "admin-availability" | "admin-appointments";
type PageId =
| "counter"
| "sum"
| "profile"
| "invites"
| "booking"
| "appointments"
| "audit"
| "admin-invites"
| "admin-availability"
| "admin-appointments";
interface HeaderProps {
currentPage: PageId;
@ -82,4 +92,3 @@ export function Header({ currentPage }: HeaderProps) {
</div>
);
}

View file

@ -1,2 +1 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";

View file

@ -46,11 +46,13 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
if (!isAuthorized) {
// Redirect to the most appropriate page based on permissions
const redirect = fallbackRedirect ?? (
hasPermission(Permission.VIEW_AUDIT) ? "/audit" :
hasPermission(Permission.VIEW_COUNTER) ? "/" :
"/login"
);
const redirect =
fallbackRedirect ??
(hasPermission(Permission.VIEW_AUDIT)
? "/audit"
: hasPermission(Permission.VIEW_COUNTER)
? "/"
: "/login");
router.push(redirect);
}
}, [isLoading, user, isAuthorized, router, fallbackRedirect, hasPermission]);
@ -61,4 +63,3 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
isAuthorized,
};
}

View file

@ -80,38 +80,27 @@ export default function InvitesPage() {
<div style={styles.pageCard}>
<div style={styles.cardHeader}>
<h1 style={styles.cardTitle}>My Invites</h1>
<p style={styles.cardSubtitle}>
Share your invite codes with friends to let them join
</p>
<p style={styles.cardSubtitle}>Share your invite codes with friends to let them join</p>
</div>
{invites.length === 0 ? (
<div style={styles.emptyState}>
<p style={styles.emptyText}>You don&apos;t have any invites yet.</p>
<p style={styles.emptyHint}>
Contact an admin if you need invite codes to share.
</p>
<p style={styles.emptyHint}>Contact an admin if you need invite codes to share.</p>
</div>
) : (
<div style={styles.sections}>
{/* Ready Invites */}
{readyInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available ({readyInvites.length})
</h2>
<p style={styles.sectionHint}>
Share these links with people you want to invite
</p>
<h2 style={styles.sectionTitle}>Available ({readyInvites.length})</h2>
<p style={styles.sectionHint}>Share these links with people you want to invite</p>
<div style={styles.inviteList}>
{readyInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCard}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteActions}>
<button
onClick={() => copyToClipboard(invite)}
style={styles.copyButton}
>
<button onClick={() => copyToClipboard(invite)} style={styles.copyButton}>
{copiedId === invite.id ? "Copied!" : "Copy Link"}
</button>
</div>
@ -124,18 +113,14 @@ export default function InvitesPage() {
{/* Spent Invites */}
{spentInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Used ({spentInvites.length})
</h2>
<h2 style={styles.sectionTitle}>Used ({spentInvites.length})</h2>
<div style={styles.inviteList}>
{spentInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardSpent}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteeMeta}>
<span style={styles.statusBadgeSpent}>Used</span>
<span style={styles.inviteeEmail}>
by {invite.used_by_email}
</span>
<span style={styles.inviteeEmail}>by {invite.used_by_email}</span>
</div>
</div>
))}
@ -146,9 +131,7 @@ export default function InvitesPage() {
{/* Revoked Invites */}
{revokedInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Revoked ({revokedInvites.length})
</h2>
<h2 style={styles.sectionTitle}>Revoked ({revokedInvites.length})</h2>
<div style={styles.inviteList}>
{revokedInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardRevoked}>
@ -324,4 +307,3 @@ const pageStyles: Record<string, React.CSSProperties> = {
};
const styles = { ...sharedStyles, ...pageStyles };

View file

@ -41,7 +41,9 @@ export default function LoginPage() {
{error && <div style={styles.error}>{error}</div>}
<div style={styles.field}>
<label htmlFor="email" style={styles.label}>Email</label>
<label htmlFor="email" style={styles.label}>
Email
</label>
<input
id="email"
type="email"
@ -54,7 +56,9 @@ export default function LoginPage() {
</div>
<div style={styles.field}>
<label htmlFor="password" style={styles.label}>Password</label>
<label htmlFor="password" style={styles.label}>
Password
</label>
<input
id="password"
type="password"

View file

@ -19,12 +19,10 @@ let mockUser: { id: number; email: string; roles: string[]; permissions: string[
};
let mockIsLoading = false;
const mockLogout = vi.fn();
const mockHasPermission = vi.fn((permission: string) =>
mockUser?.permissions.includes(permission) ?? false
);
const mockHasRole = vi.fn((role: string) =>
mockUser?.roles.includes(role) ?? false
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: () => ({
@ -52,12 +50,10 @@ beforeEach(() => {
permissions: ["view_counter", "increment_counter", "use_sum"],
};
mockIsLoading = false;
mockHasPermission.mockImplementation((permission: string) =>
mockUser?.permissions.includes(permission) ?? false
);
mockHasRole.mockImplementation((role: string) =>
mockUser?.roles.includes(role) ?? false
mockHasPermission.mockImplementation(
(permission: string) => mockUser?.permissions.includes(permission) ?? false
);
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
});
afterEach(() => {
@ -101,7 +97,7 @@ describe("Home - Authenticated", () => {
render(<Home />);
fireEvent.click(screen.getByText("Sign out"));
await waitFor(() => {
expect(mockLogout).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith("/login");
@ -238,7 +234,7 @@ describe("Home - Navigation", () => {
} as Response);
render(<Home />);
await waitFor(() => {
expect(screen.getByText("My Profile")).toBeDefined();
});
@ -257,7 +253,7 @@ describe("Home - Navigation", () => {
} as Response);
render(<Home />);
// Wait for render - admin sees admin nav (Audit, Invites) not regular nav
await waitFor(() => {
expect(screen.getByText("Audit")).toBeDefined();

View file

@ -15,7 +15,8 @@ export default function Home() {
useEffect(() => {
if (user && isAuthorized) {
api.get<{ value: number }>("/api/counter")
api
.get<{ value: number }>("/api/counter")
.then((data) => setCount(data.value))
.catch(() => setCount(null));
}

View file

@ -162,12 +162,13 @@ describe("ProfilePage - Display", () => {
test("displays empty fields when profile has null values", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
render(<ProfilePage />);
@ -291,7 +292,7 @@ describe("ProfilePage - Form Behavior", () => {
} as Response);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
});
@ -308,78 +309,83 @@ describe("ProfilePage - Form Behavior", () => {
test("auto-prepends @ to telegram when user starts with letter", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
// Type a letter without @ - should auto-prepend @
fireEvent.change(telegramInput, { target: { value: "myhandle" } });
expect(telegramInput.value).toBe("@myhandle");
});
test("does not auto-prepend @ if user types @ first", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
// User types @ first - no auto-prepend
fireEvent.change(telegramInput, { target: { value: "@myhandle" } });
expect(telegramInput.value).toBe("@myhandle");
});
});
describe("ProfilePage - Form Submission", () => {
test("shows success toast after successful save", async () => {
const fetchSpy = vi.spyOn(global, "fetch")
const fetchSpy = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
contact_email: "new@example.com",
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: "new@example.com",
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
@ -410,27 +416,29 @@ describe("ProfilePage - Form Submission", () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response)
.mockResolvedValueOnce({
ok: false,
status: 422,
json: () => Promise.resolve({
detail: {
field_errors: {
telegram: "Backend error: invalid handle",
json: () =>
Promise.resolve({
detail: {
field_errors: {
telegram: "Backend error: invalid handle",
},
},
},
}),
}),
} as Response);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
@ -452,17 +460,18 @@ describe("ProfilePage - Form Submission", () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response)
.mockRejectedValueOnce(new Error("Network error"));
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
@ -489,17 +498,18 @@ describe("ProfilePage - Form Submission", () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response)
.mockReturnValueOnce(submitPromise as Promise<Response>);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
@ -519,12 +529,13 @@ describe("ProfilePage - Form Submission", () => {
// Resolve the promise
resolveSubmit!({
ok: true,
json: () => Promise.resolve({
contact_email: "new@example.com",
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: "new@example.com",
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
await waitFor(() => {
@ -532,4 +543,3 @@ describe("ProfilePage - Form Submission", () => {
});
});
});

View file

@ -34,7 +34,8 @@ function validateEmail(value: string): string | undefined {
if (!value) return undefined;
// More comprehensive email regex that matches email-validator behavior
// Checks for: local part, @, domain with at least one dot, valid TLD
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email address";
}
@ -72,7 +73,7 @@ function validateNostrNpub(value: string): string | undefined {
if (!value.startsWith(npubRules.prefix)) {
return `Nostr npub must start with '${npubRules.prefix}'`;
}
try {
const decoded = bech32.decode(value);
if (decoded.prefix !== "npub") {
@ -186,7 +187,7 @@ export default function ProfilePage() {
const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
// For telegram: auto-prepend @ if user starts with a valid letter
if (field === "telegram" && value && !value.startsWith("@")) {
// Check if first char is a valid telegram handle start (letter)
@ -194,14 +195,14 @@ export default function ProfilePage() {
value = "@" + value;
}
}
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear any pending validation timeout
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current);
}
// Debounce validation - wait 500ms after user stops typing
validationTimeoutRef.current = setTimeout(() => {
const newFormData = { ...formData, [field]: value };
@ -212,11 +213,11 @@ export default function ProfilePage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const validationErrors = validateForm(formData);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return;
}
@ -300,9 +301,7 @@ export default function ProfilePage() {
style={{ ...styles.input, ...styles.inputReadOnly }}
disabled
/>
<span style={styles.hint}>
This is your login email and cannot be changed here.
</span>
<span style={styles.hint}>This is your login email and cannot be changed here.</span>
</div>
{/* Godfather - shown if user was invited */}
@ -315,9 +314,7 @@ export default function ProfilePage() {
<div style={styles.godfatherBox}>
<span style={styles.godfatherEmail}>{godfatherEmail}</span>
</div>
<span style={styles.hint}>
The user who invited you to join.
</span>
<span style={styles.hint}>The user who invited you to join.</span>
</div>
)}
@ -344,9 +341,7 @@ export default function ProfilePage() {
}}
placeholder="alternate@example.com"
/>
{errors.contact_email && (
<span style={styles.errorText}>{errors.contact_email}</span>
)}
{errors.contact_email && <span style={styles.errorText}>{errors.contact_email}</span>}
</div>
{/* Telegram */}
@ -365,9 +360,7 @@ export default function ProfilePage() {
}}
placeholder="@username"
/>
{errors.telegram && (
<span style={styles.errorText}>{errors.telegram}</span>
)}
{errors.telegram && <span style={styles.errorText}>{errors.telegram}</span>}
</div>
{/* Signal */}
@ -386,9 +379,7 @@ export default function ProfilePage() {
}}
placeholder="username.01"
/>
{errors.signal && (
<span style={styles.errorText}>{errors.signal}</span>
)}
{errors.signal && <span style={styles.errorText}>{errors.signal}</span>}
</div>
{/* Nostr npub */}
@ -407,9 +398,7 @@ export default function ProfilePage() {
}}
placeholder="npub1..."
/>
{errors.nostr_npub && (
<span style={styles.errorText}>{errors.nostr_npub}</span>
)}
{errors.nostr_npub && <span style={styles.errorText}>{errors.nostr_npub}</span>}
</div>
<button

View file

@ -13,7 +13,7 @@ export default function SignupWithCodePage() {
useEffect(() => {
// Wait for auth check to complete before redirecting
if (isLoading) return;
if (user) {
// Already logged in, redirect to home
router.replace("/");
@ -25,15 +25,17 @@ export default function SignupWithCodePage() {
}, [user, isLoading, code, router]);
return (
<main style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #0f0f23 100%)",
color: "rgba(255,255,255,0.6)",
fontFamily: "'DM Sans', system-ui, sans-serif",
}}>
<main
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #0f0f23 100%)",
color: "rgba(255,255,255,0.6)",
fontFamily: "'DM Sans', system-ui, sans-serif",
}}
>
Redirecting...
</main>
);

View file

@ -15,19 +15,19 @@ interface InviteCheckResponse {
function SignupContent() {
const searchParams = useSearchParams();
const initialCode = searchParams.get("code") || "";
const [inviteCode, setInviteCode] = useState(initialCode);
const [inviteValid, setInviteValid] = useState<boolean | null>(null);
const [inviteError, setInviteError] = useState("");
const [isCheckingInvite, setIsCheckingInvite] = useState(false);
const [isCheckingInitialCode, setIsCheckingInitialCode] = useState(!!initialCode);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { user, register } = useAuth();
const router = useRouter();
@ -52,7 +52,7 @@ function SignupContent() {
const response = await api.get<InviteCheckResponse>(
`/api/invites/${encodeURIComponent(code.trim())}/check`
);
if (response.valid) {
setInviteValid(true);
setInviteError("");
@ -141,7 +141,9 @@ function SignupContent() {
{inviteError && <div style={styles.error}>{inviteError}</div>}
<div style={styles.field}>
<label htmlFor="inviteCode" style={styles.label}>Invite Code</label>
<label htmlFor="inviteCode" style={styles.label}>
Invite Code
</label>
<input
id="inviteCode"
type="text"
@ -156,7 +158,14 @@ function SignupContent() {
required
autoFocus
/>
<span style={{ ...styles.link, fontSize: "0.8rem", marginTop: "0.5rem", display: "block" }}>
<span
style={{
...styles.link,
fontSize: "0.8rem",
marginTop: "0.5rem",
display: "block",
}}
>
Ask your inviter for this code
</span>
</div>
@ -193,12 +202,17 @@ function SignupContent() {
<div style={styles.header}>
<h1 style={styles.title}>Create account</h1>
<p style={styles.subtitle}>
Using invite: <code style={{
background: "rgba(255,255,255,0.1)",
padding: "0.2rem 0.5rem",
borderRadius: "4px",
fontSize: "0.85rem"
}}>{inviteCode}</code>
Using invite:{" "}
<code
style={{
background: "rgba(255,255,255,0.1)",
padding: "0.2rem 0.5rem",
borderRadius: "4px",
fontSize: "0.85rem",
}}
>
{inviteCode}
</code>
</p>
</div>
@ -206,7 +220,9 @@ function SignupContent() {
{error && <div style={styles.error}>{error}</div>}
<div style={styles.field}>
<label htmlFor="email" style={styles.label}>Email</label>
<label htmlFor="email" style={styles.label}>
Email
</label>
<input
id="email"
type="email"
@ -220,7 +236,9 @@ function SignupContent() {
</div>
<div style={styles.field}>
<label htmlFor="password" style={styles.label}>Password</label>
<label htmlFor="password" style={styles.label}>
Password
</label>
<input
id="password"
type="password"
@ -233,7 +251,9 @@ function SignupContent() {
</div>
<div style={styles.field}>
<label htmlFor="confirmPassword" style={styles.label}>Confirm Password</label>
<label htmlFor="confirmPassword" style={styles.label}>
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
@ -282,17 +302,17 @@ function SignupContent() {
export default function SignupPage() {
return (
<Suspense fallback={
<main style={styles.main}>
<div style={styles.container}>
<div style={styles.card}>
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>
Loading...
<Suspense
fallback={
<main style={styles.main}>
<div style={styles.container}>
<div style={styles.card}>
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>Loading...</div>
</div>
</div>
</div>
</main>
}>
</main>
}
>
<SignupContent />
</Suspense>
);

View file

@ -103,4 +103,3 @@ export const authFormStyles: Record<string, CSSProperties> = {
fontWeight: 500,
},
};

View file

@ -21,7 +21,7 @@ export default function SumPage() {
const numA = parseFloat(a) || 0;
const numB = parseFloat(b) || 0;
setError(null);
try {
const data = await api.post<{ result: number }>("/api/sum", { a: numA, b: numB });
setResult(data.result);
@ -58,7 +58,7 @@ export default function SumPage() {
<div style={styles.content}>
<div style={styles.card}>
<span style={styles.label}>Sum Calculator</span>
{!showResult ? (
<div style={styles.inputSection}>
<div style={styles.inputRow}>
@ -84,17 +84,11 @@ export default function SumPage() {
/>
</div>
</div>
<button
onClick={handleSum}
style={styles.sumBtn}
disabled={a === "" && b === ""}
>
<button onClick={handleSum} style={styles.sumBtn} disabled={a === "" && b === ""}>
<span style={styles.equalsIcon}>=</span>
Calculate
</button>
{error && (
<div style={styles.error}>{error}</div>
)}
{error && <div style={styles.error}>{error}</div>}
</div>
) : (
<div style={styles.resultSection}>

View file

@ -10,7 +10,7 @@ export interface StatusDisplay {
/**
* Get display information for an appointment status.
*
*
* @param status - The appointment status string
* @param isOwnView - If true, uses "Cancelled by you" instead of "Cancelled by user"
*/
@ -25,9 +25,12 @@ export function getStatusDisplay(status: string, isOwnView: boolean = false): St
textColor: "#f87171",
};
case "cancelled_by_admin":
return { text: "Cancelled by admin", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" };
return {
text: "Cancelled by admin",
bgColor: "rgba(239, 68, 68, 0.2)",
textColor: "#f87171",
};
default:
return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" };
}
}

View file

@ -17,8 +17,8 @@ export function formatDate(d: Date): string {
*/
export function formatTime(isoString: string): string {
const d = new Date(isoString);
return d.toLocaleTimeString("en-US", {
hour: "2-digit",
return d.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
@ -81,4 +81,3 @@ export function isWeekend(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6;
}

View file

@ -29,21 +29,21 @@ test.describe("Admin Invites Page", () => {
test("godfather selection is a dropdown with users, not a number input", async ({ page }) => {
await page.goto("/admin/invites");
// The godfather selector should be a <select> element, not an <input type="number">
const selectElement = page.locator("select").first();
await expect(selectElement).toBeVisible();
// Wait for users to load by checking for a known user in the dropdown
await expect(selectElement).toContainText(REGULAR_USER_EMAIL);
// Verify it has user options (at least the seeded users)
const options = selectElement.locator("option");
const optionCount = await options.count();
// Should have at least 2 options: placeholder + at least one user
expect(optionCount).toBeGreaterThanOrEqual(2);
// There should NOT be a number input for godfather ID
const numberInput = page.locator('input[type="number"]');
await expect(numberInput).toHaveCount(0);
@ -51,20 +51,20 @@ test.describe("Admin Invites Page", () => {
test("can create invite by selecting user from dropdown", async ({ page }) => {
await page.goto("/admin/invites");
// Wait for page to load
await page.waitForSelector("select");
// Select the regular user as godfather
const godfatherSelect = page.locator("select").first();
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
// Click create invite
await page.click('button:has-text("Create Invite")');
// Wait for the invite to appear in the table
await expect(page.locator("table")).toContainText(REGULAR_USER_EMAIL);
// Verify an invite code appears (format: word-word-NN)
const inviteCodeCell = page.locator("td").first();
await expect(inviteCodeCell).toHaveText(/^[a-z]+-[a-z]+-\d{2}$/);
@ -72,18 +72,18 @@ test.describe("Admin Invites Page", () => {
test("create button is disabled when no user selected", async ({ page }) => {
await page.goto("/admin/invites");
// Wait for page to load
await page.waitForSelector("select");
// The create button should be disabled initially (no user selected)
const createButton = page.locator('button:has-text("Create Invite")');
await expect(createButton).toBeDisabled();
// Select a user
const godfatherSelect = page.locator("select").first();
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
// Now the button should be enabled
await expect(createButton).toBeEnabled();
});
@ -91,23 +91,27 @@ test.describe("Admin Invites Page", () => {
test("can revoke a ready invite", async ({ page }) => {
await page.goto("/admin/invites");
await page.waitForSelector("select");
// Create an invite first
const godfatherSelect = page.locator("select").first();
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
await page.click('button:has-text("Create Invite")');
// Wait for the new invite to appear and capture its code
// The new invite should be the first row with godfather = REGULAR_USER_EMAIL and status = ready
const newInviteRow = page.locator("tr").filter({ hasText: REGULAR_USER_EMAIL }).filter({ hasText: "ready" }).first();
const newInviteRow = page
.locator("tr")
.filter({ hasText: REGULAR_USER_EMAIL })
.filter({ hasText: "ready" })
.first();
await expect(newInviteRow).toBeVisible();
// Get the invite code from this row (first cell)
const inviteCode = await newInviteRow.locator("td").first().textContent();
// Click revoke on this specific row
await newInviteRow.locator('button:has-text("Revoke")').click();
// Verify this specific invite now shows "revoked"
const revokedRow = page.locator("tr").filter({ hasText: inviteCode! });
await expect(revokedRow).toContainText("revoked");
@ -116,20 +120,20 @@ test.describe("Admin Invites Page", () => {
test("status filter works", async ({ page }) => {
await page.goto("/admin/invites");
await page.waitForSelector("select");
// Create an invite
const godfatherSelect = page.locator("select").first();
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
await page.click('button:has-text("Create Invite")');
await expect(page.locator("table")).toContainText("ready");
// Filter by "revoked" status - should show no ready invites
const statusFilter = page.locator("select").nth(1); // Second select is the status filter
await statusFilter.selectOption("revoked");
// Wait for the filter to apply
await page.waitForResponse((resp) => resp.url().includes("status=revoked"));
// Filter by "ready" status - should show our invite
await statusFilter.selectOption("ready");
await page.waitForResponse((resp) => resp.url().includes("status=ready"));
@ -145,10 +149,10 @@ test.describe("Admin Invites Access Control", () => {
await page.fill('input[type="password"]', "user123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
// Try to access admin invites page
await page.goto("/admin/invites");
// Should be redirected away (to home page based on fallbackRedirect)
await expect(page).not.toHaveURL("/admin/invites");
});
@ -156,9 +160,8 @@ test.describe("Admin Invites Access Control", () => {
test("unauthenticated user cannot access admin invites page", async ({ page }) => {
await page.context().clearCookies();
await page.goto("/admin/invites");
// Should be redirected to login
await expect(page).toHaveURL("/login");
});
});

View file

@ -4,23 +4,23 @@ import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpe
/**
* Appointments Page E2E Tests
*
*
* Tests for viewing and cancelling user appointments.
*/
// Set up availability and create a booking
async function createTestBooking(page: Page) {
const dateStr = getTomorrowDateStr();
// First login as admin to set availability
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const adminCookies = await page.context().cookies();
const adminAuthCookie = adminCookies.find(c => c.name === "auth_token");
const adminAuthCookie = adminCookies.find((c) => c.name === "auth_token");
if (!adminAuthCookie) throw new Error("No admin auth cookie");
await page.request.put(`${API_URL}/api/admin/availability`, {
headers: {
Cookie: `auth_token=${adminAuthCookie.value}`,
@ -31,22 +31,22 @@ async function createTestBooking(page: Page) {
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
},
});
// Login as regular user
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const userCookies = await page.context().cookies();
const userAuthCookie = userCookies.find(c => c.name === "auth_token");
const userAuthCookie = userCookies.find((c) => c.name === "auth_token");
if (!userAuthCookie) throw new Error("No user auth cookie");
// Create booking - use a random minute to avoid conflicts with parallel tests
const randomMinute = Math.floor(Math.random() * 11) * 15; // 0, 15, 30, 45 etc up to 165 min
const hour = 9 + Math.floor(randomMinute / 60);
const minute = randomMinute % 60;
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
const timeStr = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
const response = await page.request.post(`${API_URL}/api/booking`, {
headers: {
Cookie: `auth_token=${userAuthCookie.value}`,
@ -57,7 +57,7 @@ async function createTestBooking(page: Page) {
note: "Test appointment",
},
});
return response.json();
}
@ -69,20 +69,20 @@ test.describe("Appointments Page - Regular User Access", () => {
test("regular user can access appointments page", async ({ page }) => {
await page.goto("/appointments");
await expect(page).toHaveURL("/appointments");
await expect(page.getByRole("heading", { name: "My Appointments" })).toBeVisible();
});
test("regular user sees Appointments link in navigation", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "Appointments" })).toBeVisible();
});
test("shows empty state when no appointments", async ({ page }) => {
await page.goto("/appointments");
await expect(page.getByText("don't have any appointments")).toBeVisible();
await expect(page.getByRole("link", { name: "Book an appointment" })).toBeVisible();
});
@ -92,10 +92,10 @@ test.describe("Appointments Page - With Bookings", () => {
test("shows user's appointments", async ({ page }) => {
// Create a booking first
await createTestBooking(page);
// Go to appointments page
await page.goto("/appointments");
// Should see the appointment
await expect(page.getByText("Test appointment")).toBeVisible();
await expect(page.getByText("Booked", { exact: true })).toBeVisible();
@ -104,16 +104,16 @@ test.describe("Appointments Page - With Bookings", () => {
test("can cancel an appointment", async ({ page }) => {
// Create a booking
await createTestBooking(page);
// Go to appointments page
await page.goto("/appointments");
// Click cancel button
await page.getByRole("button", { name: "Cancel" }).first().click();
// Confirm cancellation
await page.getByRole("button", { name: "Confirm" }).click();
// Should show cancelled status
await expect(page.getByText("Cancelled by you")).toBeVisible();
});
@ -121,19 +121,19 @@ test.describe("Appointments Page - With Bookings", () => {
test("can abort cancellation", async ({ page }) => {
// Create a booking
await createTestBooking(page);
// Go to appointments page
await page.goto("/appointments");
// Wait for appointments to load
await expect(page.getByRole("heading", { name: /Upcoming/ })).toBeVisible({ timeout: 10000 });
// Click cancel button
await page.getByRole("button", { name: "Cancel" }).first().click();
// Click No to abort
await page.getByRole("button", { name: "No" }).click();
// Should still show as booked (use first() since there may be multiple bookings)
await expect(page.getByText("Booked", { exact: true }).first()).toBeVisible();
});
@ -143,9 +143,9 @@ test.describe("Appointments Page - Access Control", () => {
test("admin cannot access appointments page", async ({ page }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await page.goto("/appointments");
// Should be redirected
await expect(page).not.toHaveURL("/appointments");
});
@ -153,17 +153,17 @@ test.describe("Appointments Page - Access Control", () => {
test("admin does not see Appointments link", async ({ page }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await page.goto("/audit");
await expect(page.getByRole("link", { name: "Appointments" })).not.toBeVisible();
});
test("unauthenticated user redirected to login", async ({ page }) => {
await clearAuth(page);
await page.goto("/appointments");
await expect(page).toHaveURL("/login");
});
});
@ -172,17 +172,17 @@ test.describe("Appointments API", () => {
test("regular user can view appointments via API", async ({ page }) => {
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
const response = await page.request.get(`${API_URL}/api/appointments`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
},
});
expect(response.status()).toBe(200);
expect(Array.isArray(await response.json())).toBe(true);
}
@ -191,22 +191,19 @@ test.describe("Appointments API", () => {
test("regular user can cancel appointment via API", async ({ page }) => {
// Create a booking
const booking = await createTestBooking(page);
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie && booking && booking.id) {
const response = await page.request.post(
`${API_URL}/api/appointments/${booking.id}/cancel`,
{
headers: {
Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json",
},
data: {},
}
);
const response = await page.request.post(`${API_URL}/api/appointments/${booking.id}/cancel`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json",
},
data: {},
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.status).toBe("cancelled_by_user");
@ -216,19 +213,18 @@ test.describe("Appointments API", () => {
test("admin cannot view user appointments via API", async ({ page }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
const response = await page.request.get(`${API_URL}/api/appointments`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
},
});
expect(response.status()).toBe(403);
}
});
});

View file

@ -23,13 +23,13 @@ async function createInvite(request: APIRequestContext): Promise<string> {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
const cookies = loginResp.headers()["set-cookie"];
// Get admin user ID (we'll use admin as godfather for simplicity)
const meResp = await request.get(`${API_BASE}/api/auth/me`, {
headers: { Cookie: cookies },
});
const admin = await meResp.json();
// Create invite
const inviteResp = await request.post(`${API_BASE}/api/admin/invites`, {
data: { godfather_id: admin.id },
@ -61,7 +61,7 @@ test.describe("Authentication Flow", () => {
test("signup page has invite code form", async ({ page }) => {
await page.goto("/signup");
await expect(page.locator("h1")).toHaveText("Join with Invite");
await expect(page.locator('input#inviteCode')).toBeVisible();
await expect(page.locator("input#inviteCode")).toBeVisible();
await expect(page.locator('button[type="submit"]')).toHaveText("Continue");
await expect(page.locator('a[href="/login"]')).toBeVisible();
});
@ -80,19 +80,22 @@ test.describe("Authentication Flow", () => {
});
test.describe("Logged-in User Visiting Invite URL", () => {
test("redirects to home when logged-in user visits direct invite URL", async ({ page, request }) => {
test("redirects to home when logged-in user visits direct invite URL", async ({
page,
request,
}) => {
const email = uniqueEmail();
const inviteCode = await createInvite(request);
// First sign up to create a user
await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
@ -110,13 +113,13 @@ test.describe("Logged-in User Visiting Invite URL", () => {
// Sign up and stay logged in
await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
@ -136,18 +139,18 @@ test.describe("Signup with Invite", () => {
const inviteCode = await createInvite(request);
await page.goto("/signup");
// Step 1: Enter invite code
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
// Wait for form to transition to registration form
await expect(page.locator("h1")).toHaveText("Create account");
// Step 2: Fill registration form
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
// Should redirect to home after signup
@ -162,17 +165,17 @@ test.describe("Signup with Invite", () => {
// Use direct URL with code
await page.goto(`/signup/${inviteCode}`);
// Should redirect to signup with code in query and validate
await page.waitForURL(/\/signup\?code=/);
// Wait for form to transition to registration form
await expect(page.locator("h1")).toHaveText("Create account");
// Fill registration form
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
// Should redirect to home
@ -181,7 +184,7 @@ test.describe("Signup with Invite", () => {
test("shows error for invalid invite code", async ({ page }) => {
await page.goto("/signup");
await page.fill('input#inviteCode', "fake-code-99");
await page.fill("input#inviteCode", "fake-code-99");
await page.click('button[type="submit"]');
// Should show error
@ -192,14 +195,14 @@ test.describe("Signup with Invite", () => {
const inviteCode = await createInvite(request);
await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', uniqueEmail());
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "differentpassword");
await page.fill("input#email", uniqueEmail());
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "differentpassword");
await page.click('button[type="submit"]');
await expect(page.getByText("Passwords do not match")).toBeVisible();
@ -209,14 +212,14 @@ test.describe("Signup with Invite", () => {
const inviteCode = await createInvite(request);
await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', uniqueEmail());
await page.fill('input#password', "short");
await page.fill('input#confirmPassword', "short");
await page.fill("input#email", uniqueEmail());
await page.fill("input#password", "short");
await page.fill("input#confirmPassword", "short");
await page.click('button[type="submit"]');
await expect(page.getByText("Password must be at least 6 characters")).toBeVisible();
@ -231,7 +234,7 @@ test.describe("Login", () => {
// Create a test user with invite
testEmail = uniqueEmail();
const inviteCode = await createInvite(request);
// Register the test user via backend API
await request.post(`${API_BASE}/api/auth/register`, {
data: {
@ -292,13 +295,13 @@ test.describe("Logout", () => {
// Sign up
await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
@ -315,13 +318,13 @@ test.describe("Logout", () => {
// Sign up
await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
@ -342,13 +345,13 @@ test.describe("Session Persistence", () => {
// Sign up
await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
await expect(page.getByText(email)).toBeVisible();
@ -366,13 +369,13 @@ test.describe("Session Persistence", () => {
const inviteCode = await createInvite(request);
await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
@ -388,13 +391,13 @@ test.describe("Session Persistence", () => {
const inviteCode = await createInvite(request);
await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");

View file

@ -4,7 +4,7 @@ import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpe
/**
* Availability Page E2E Tests
*
*
* Tests for the admin availability management page.
*/
@ -23,7 +23,7 @@ test.describe("Availability Page - Admin Access", () => {
test("admin can access availability page", async ({ page }) => {
await page.goto("/admin/availability");
await expect(page).toHaveURL("/admin/availability");
await expect(page.getByRole("heading", { name: "Availability" })).toBeVisible();
await expect(page.getByText("Configure your available time slots")).toBeVisible();
@ -31,29 +31,29 @@ test.describe("Availability Page - Admin Access", () => {
test("admin sees Availability link in nav", async ({ page }) => {
await page.goto("/audit");
const availabilityLink = page.locator('a[href="/admin/availability"]');
await expect(availabilityLink).toBeVisible();
});
test("availability page shows calendar grid", async ({ page }) => {
await page.goto("/admin/availability");
// Should show tomorrow's date in the calendar
const tomorrowText = getTomorrowDisplay();
await expect(page.getByText(tomorrowText)).toBeVisible();
// Should show "No availability" for days without slots
await expect(page.getByText("No availability").first()).toBeVisible();
});
test("can open edit modal by clicking a day", async ({ page }) => {
await page.goto("/admin/availability");
// Click on the first day card
const tomorrowText = getTomorrowDisplay();
await page.getByText(tomorrowText).click();
// Modal should appear
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
await expect(page.getByRole("button", { name: "Save" })).toBeVisible();
@ -62,133 +62,142 @@ test.describe("Availability Page - Admin Access", () => {
test("can add availability slot", async ({ page }) => {
await page.goto("/admin/availability");
// Wait for initial data load to complete
await page.waitForLoadState("networkidle");
// Find a day card with "No availability" and click on it
// This ensures we're clicking on a day without existing slots
const dayCardWithNoAvailability = page.locator('[data-testid^="day-card-"]').filter({
has: page.getByText("No availability")
}).first();
const dayCardWithNoAvailability = page
.locator('[data-testid^="day-card-"]')
.filter({
has: page.getByText("No availability"),
})
.first();
await dayCardWithNoAvailability.click();
// Wait for modal
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
// Set up listeners for both PUT and GET before clicking Save to avoid race condition
const putPromise = page.waitForResponse(resp =>
resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
const putPromise = page.waitForResponse(
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
);
const getPromise = page.waitForResponse(resp =>
resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
const getPromise = page.waitForResponse(
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
);
await page.getByRole("button", { name: "Save" }).click();
await putPromise;
await getPromise;
// Wait for modal to close
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
// Should now show the slot (the card we clicked should now have this slot)
await expect(page.getByText("09:00 - 17:00")).toBeVisible();
});
test("can clear availability", async ({ page }) => {
await page.goto("/admin/availability");
// Wait for initial data load to complete
await page.waitForLoadState("networkidle");
// Find a day card with "No availability" and click on it
const dayCardWithNoAvailability = page.locator('[data-testid^="day-card-"]').filter({
has: page.getByText("No availability")
}).first();
const dayCardWithNoAvailability = page
.locator('[data-testid^="day-card-"]')
.filter({
has: page.getByText("No availability"),
})
.first();
// Get the testid so we can find the same card later
const testId = await dayCardWithNoAvailability.getAttribute('data-testid');
const testId = await dayCardWithNoAvailability.getAttribute("data-testid");
const targetCard = page.locator(`[data-testid="${testId}"]`);
// First add availability
await dayCardWithNoAvailability.click();
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
// Set up listeners for both PUT and GET before clicking Save to avoid race condition
const savePutPromise = page.waitForResponse(resp =>
resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
const savePutPromise = page.waitForResponse(
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
);
const saveGetPromise = page.waitForResponse(resp =>
resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
const saveGetPromise = page.waitForResponse(
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
);
await page.getByRole("button", { name: "Save" }).click();
await savePutPromise;
await saveGetPromise;
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
// Verify slot exists in the specific card we clicked
await expect(targetCard.getByText("09:00 - 17:00")).toBeVisible();
// Now clear it - click on the same card using the testid
await targetCard.click();
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
// Set up listeners for both PUT and GET before clicking Clear to avoid race condition
const clearPutPromise = page.waitForResponse(resp =>
resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
const clearPutPromise = page.waitForResponse(
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
);
const clearGetPromise = page.waitForResponse(resp =>
resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
const clearGetPromise = page.waitForResponse(
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
);
await page.getByRole("button", { name: "Clear All" }).click();
await clearPutPromise;
await clearGetPromise;
// Wait for modal to close
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
// Slot should be gone from this specific card
await expect(targetCard.getByText("09:00 - 17:00")).not.toBeVisible();
});
test("can add multiple slots", async ({ page }) => {
await page.goto("/admin/availability");
// Wait for initial data load to complete
await page.waitForLoadState("networkidle");
// Find a day card with "No availability" and click on it (to avoid conflicts with booking tests)
const dayCardWithNoAvailability = page.locator('[data-testid^="day-card-"]').filter({
has: page.getByText("No availability")
}).first();
const testId = await dayCardWithNoAvailability.getAttribute('data-testid');
const dayCardWithNoAvailability = page
.locator('[data-testid^="day-card-"]')
.filter({
has: page.getByText("No availability"),
})
.first();
const testId = await dayCardWithNoAvailability.getAttribute("data-testid");
const targetCard = page.locator(`[data-testid="${testId}"]`);
await dayCardWithNoAvailability.click();
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
// First slot is 09:00-17:00 by default - change it to morning only
const timeSelects = page.locator("select");
await timeSelects.nth(1).selectOption("12:00"); // Change first slot end to 12:00
// Add another slot for afternoon
await page.getByText("+ Add Time Range").click();
// Change second slot times to avoid overlap
await timeSelects.nth(2).selectOption("14:00"); // Second slot start
await timeSelects.nth(3).selectOption("17:00"); // Second slot end
// Set up listeners for both PUT and GET before clicking Save to avoid race condition
const putPromise = page.waitForResponse(resp =>
resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
const putPromise = page.waitForResponse(
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
);
const getPromise = page.waitForResponse(resp =>
resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
const getPromise = page.waitForResponse(
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
);
await page.getByRole("button", { name: "Save" }).click();
await putPromise;
await getPromise;
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
// Should see both slots in the card we clicked
await expect(targetCard.getByText("09:00 - 12:00")).toBeVisible();
await expect(targetCard.getByText("14:00 - 17:00")).toBeVisible();
@ -199,9 +208,9 @@ test.describe("Availability Page - Access Control", () => {
test("regular user cannot access availability page", async ({ page }) => {
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
await page.goto("/admin/availability");
// Should be redirected (to counter/home for regular users)
await expect(page).not.toHaveURL("/admin/availability");
});
@ -209,18 +218,18 @@ test.describe("Availability Page - Access Control", () => {
test("regular user does not see Availability link", async ({ page }) => {
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
await page.goto("/");
const availabilityLink = page.locator('a[href="/admin/availability"]');
await expect(availabilityLink).toHaveCount(0);
});
test("unauthenticated user redirected to login", async ({ page }) => {
await clearAuth(page);
await page.goto("/admin/availability");
await expect(page).toHaveURL("/login");
});
});
@ -229,13 +238,13 @@ test.describe("Availability API", () => {
test("admin can set availability via API", async ({ page, request }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
const dateStr = getTomorrowDateStr();
const response = await request.put(`${API_URL}/api/admin/availability`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
@ -246,7 +255,7 @@ test.describe("Availability API", () => {
slots: [{ start_time: "10:00:00", end_time: "12:00:00" }],
},
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.date).toBe(dateStr);
@ -257,13 +266,13 @@ test.describe("Availability API", () => {
test("regular user cannot access availability API", async ({ page, request }) => {
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
const dateStr = getTomorrowDateStr();
const response = await request.get(
`${API_URL}/api/admin/availability?from=${dateStr}&to=${dateStr}`,
{
@ -272,9 +281,8 @@ test.describe("Availability API", () => {
},
}
);
expect(response.status()).toBe(403);
}
});
});

View file

@ -4,27 +4,27 @@ import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpe
/**
* Booking Page E2E Tests
*
*
* Tests for the user booking page.
*/
// Set up availability for a date using the API with retry logic
async function setAvailability(page: Page, dateStr: string, maxRetries = 3) {
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (!authCookie) {
throw new Error("No auth cookie found when trying to set availability");
}
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
if (attempt > 0) {
// Wait before retry
await page.waitForTimeout(500);
}
const response = await page.request.put(`${API_URL}/api/admin/availability`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
@ -35,20 +35,20 @@ async function setAvailability(page: Page, dateStr: string, maxRetries = 3) {
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
},
});
if (response.ok()) {
return; // Success
}
const body = await response.text();
lastError = new Error(`Failed to set availability: ${response.status()} - ${body}`);
// Only retry on 500 errors
if (response.status() !== 500) {
throw lastError;
}
}
throw lastError;
}
@ -60,23 +60,25 @@ test.describe("Booking Page - Regular User Access", () => {
test("regular user can access booking page", async ({ page }) => {
await page.goto("/booking");
await expect(page).toHaveURL("/booking");
await expect(page.getByRole("heading", { name: "Book an Appointment" })).toBeVisible();
});
test("regular user sees Book link in navigation", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "Book" })).toBeVisible();
});
test("booking page shows date selection", async ({ page }) => {
await page.goto("/booking");
await expect(page.getByRole("heading", { name: "Select a Date" })).toBeVisible();
// Should see multiple date buttons
const dateButtons = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
const dateButtons = page
.locator("button")
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
await expect(dateButtons.first()).toBeVisible();
});
@ -87,14 +89,16 @@ test.describe("Booking Page - Regular User Access", () => {
await setAvailability(page, getTomorrowDateStr());
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
await page.goto("/booking");
// Wait for availability check to complete
await page.waitForTimeout(2000);
// Find an enabled date button (one with availability)
const dateButtons = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
const dateButtons = page
.locator("button")
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
let enabledButton = null;
const buttonCount = await dateButtons.count();
for (let i = 0; i < buttonCount; i++) {
@ -105,42 +109,44 @@ test.describe("Booking Page - Regular User Access", () => {
break;
}
}
// Should have at least one enabled date (tomorrow)
expect(enabledButton).not.toBeNull();
await enabledButton!.click();
// Should show Available Slots section (use heading to be specific)
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
});
test("shows no slots or message when no availability", async ({ page }) => {
await page.goto("/booking");
// Wait for date buttons to load and availability check to complete
await page.waitForTimeout(2000);
// Find an enabled date button (one that has availability or is still loading)
// If all dates are disabled, we can't test clicking, so verify disabled state
const dateButtons = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
const dateButtons = page
.locator("button")
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
const enabledButtons = dateButtons.filter({ hasNotText: /disabled/ });
const enabledCount = await enabledButtons.count();
if (enabledCount > 0) {
// Click the first enabled date button
await enabledButtons.first().click();
// Wait for the section to appear
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
// Should either show no slots message OR show no slot buttons
// Wait a moment for API to return
await page.waitForTimeout(1000);
// If no availability is set, we'll see the "No available slots" message
const noSlotsMessage = page.getByText("No available slots for this date");
const isNoSlotsVisible = await noSlotsMessage.isVisible().catch(() => false);
if (!isNoSlotsVisible) {
// There might be some slots from shared state - just verify the section loads
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
@ -166,22 +172,25 @@ test.describe("Booking Page - With Availability", () => {
test("shows available slots when availability is set", async ({ page }) => {
await page.goto("/booking");
// Get tomorrow's display name to click the correct button
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date using the weekday name
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first();
const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click();
// Wait for "Available Slots" section to appear
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
// Wait for loading to finish (no "Loading slots..." text)
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
// Should see some slot buttons (look for any button with time-like pattern)
// The format might be "09:00" or "9:00 AM" depending on locale
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
@ -190,24 +199,27 @@ test.describe("Booking Page - With Availability", () => {
test("clicking slot shows confirmation form", async ({ page }) => {
await page.goto("/booking");
// Get tomorrow's display name
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first();
const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click();
// Wait for any slot to appear
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
// Click first slot
await slotButtons.first().click();
// Should show confirmation form
await expect(page.getByText("Confirm Booking")).toBeVisible();
await expect(page.getByRole("button", { name: "Book Appointment" })).toBeVisible();
@ -215,68 +227,76 @@ test.describe("Booking Page - With Availability", () => {
test("can book an appointment with note", async ({ page }) => {
await page.goto("/booking");
// Get tomorrow's display name
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first();
const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click();
// Wait for slots to load
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
// Click second slot (to avoid booking same slot as other tests)
await slotButtons.nth(1).click();
// Add a note
await page.fill("textarea", "Test booking note");
// Book
await page.getByRole("button", { name: "Book Appointment" }).click();
// Should show success message
await expect(page.getByText(/Appointment booked/)).toBeVisible();
});
test("booked slot disappears from available slots", async ({ page }) => {
await page.goto("/booking");
// Get tomorrow's display name
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first();
const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click();
// Wait for slots to load
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
// Count initial slots
const initialCount = await slotButtons.count();
// Click any slot (3rd to avoid conflicts)
const slotToBook = slotButtons.nth(2);
const _slotText = await slotToBook.textContent();
await slotToBook.click();
// Book it
await page.getByRole("button", { name: "Book Appointment" }).click();
// Wait for booking form to disappear (indicates booking completed)
await expect(page.getByRole("button", { name: "Book Appointment" })).not.toBeVisible({ timeout: 10000 });
await expect(page.getByRole("button", { name: "Book Appointment" })).not.toBeVisible({
timeout: 10000,
});
// Wait for success message
await expect(page.getByText(/Appointment booked/)).toBeVisible();
// Should have one less slot now
const newCount = await slotButtons.count();
expect(newCount).toBe(initialCount - 1);
@ -287,9 +307,9 @@ test.describe("Booking Page - Access Control", () => {
test("admin cannot access booking page", async ({ page }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await page.goto("/booking");
// Should be redirected away (to audit or home)
await expect(page).not.toHaveURL("/booking");
});
@ -297,17 +317,17 @@ test.describe("Booking Page - Access Control", () => {
test("admin does not see Book link", async ({ page }) => {
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await page.goto("/audit");
await expect(page.getByRole("link", { name: "Book" })).not.toBeVisible();
});
test("unauthenticated user redirected to login", async ({ page }) => {
await clearAuth(page);
await page.goto("/booking");
await expect(page).toHaveURL("/login");
});
});
@ -320,13 +340,13 @@ test.describe("Booking API", () => {
const dateStr = getTomorrowDateStr();
await setAvailability(page, dateStr);
await clearAuth(page);
// Login as regular user
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
// Use 11:45 to avoid conflicts with other tests using 10:00
const response = await request.post(`${API_URL}/api/booking`, {
@ -339,7 +359,7 @@ test.describe("Booking API", () => {
note: "API test booking",
},
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.note).toBe("API test booking");
@ -352,10 +372,10 @@ test.describe("Booking API", () => {
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const dateStr = getTomorrowDateStr();
await setAvailability(page, dateStr);
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
const response = await request.post(`${API_URL}/api/booking`, {
headers: {
@ -366,9 +386,8 @@ test.describe("Booking API", () => {
slot_start: `${dateStr}T10:15:00Z`,
},
});
expect(response.status()).toBe(403);
}
});
});

View file

@ -15,12 +15,12 @@ async function createInvite(request: APIRequestContext): Promise<string> {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
const cookies = loginResp.headers()["set-cookie"];
const meResp = await request.get(`${API_BASE}/api/auth/me`, {
headers: { Cookie: cookies },
});
const admin = await meResp.json();
const inviteResp = await request.post(`${API_BASE}/api/admin/invites`, {
data: { godfather_id: admin.id },
headers: { Cookie: cookies },
@ -33,28 +33,28 @@ async function createInvite(request: APIRequestContext): Promise<string> {
async function authenticate(page: Page, request: APIRequestContext): Promise<string> {
const email = uniqueEmail();
const inviteCode = await createInvite(request);
await page.context().clearCookies();
await page.goto("/signup");
// Enter invite code first
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
// Click and wait for invite check API to complete
await Promise.all([
page.waitForResponse((resp) => resp.url().includes("/check") && resp.status() === 200),
page.click('button[type="submit"]'),
]);
// Wait for registration form
await expect(page.locator("h1")).toHaveText("Create account");
// Fill registration
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
return email;
}
@ -87,19 +87,19 @@ test.describe("Counter - Authenticated", () => {
await expect(page.locator("h1")).not.toHaveText("...");
const before = Number(await page.locator("h1").textContent());
// Click increment and wait for each update to complete
await page.click("text=Increment");
await expect(page.locator("h1")).not.toHaveText(String(before));
const afterFirst = Number(await page.locator("h1").textContent());
await page.click("text=Increment");
await expect(page.locator("h1")).not.toHaveText(String(afterFirst));
const afterSecond = Number(await page.locator("h1").textContent());
await page.click("text=Increment");
await expect(page.locator("h1")).not.toHaveText(String(afterSecond));
// Final value should be at least 3 more than we started with
const final = Number(await page.locator("h1").textContent());
expect(final).toBeGreaterThanOrEqual(before + 3);
@ -177,13 +177,13 @@ test.describe("Counter - Session Integration", () => {
// Sign up with invite
await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode);
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");

View file

@ -37,4 +37,3 @@ export async function loginUser(page: Page, email: string, password: string) {
await page.click('button[type="submit"]');
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 });
}

View file

@ -20,4 +20,3 @@ export function getTomorrowDateStr(): string {
tomorrow.setDate(tomorrow.getDate() + 1);
return formatDateLocal(tomorrow);
}

View file

@ -2,7 +2,7 @@ import { test, expect, Page } from "@playwright/test";
/**
* Permission-based E2E tests
*
*
* These tests verify that:
* 1. Regular users can only access Counter and Sum pages
* 2. Admin users can only access the Audit page
@ -18,7 +18,9 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
function getRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Required environment variable ${name} is not set. Run 'source .env' or set it in your environment.`);
throw new Error(
`Required environment variable ${name} is not set. Run 'source .env' or set it in your environment.`
);
}
return value;
}
@ -64,10 +66,10 @@ test.describe("Regular User Access", () => {
test("can access counter page", async ({ page }) => {
await page.goto("/");
// Should stay on counter page
await expect(page).toHaveURL("/");
// Should see counter UI
await expect(page.getByText("Current Count")).toBeVisible();
await expect(page.getByRole("button", { name: /increment/i })).toBeVisible();
@ -75,28 +77,28 @@ test.describe("Regular User Access", () => {
test("can access sum page", async ({ page }) => {
await page.goto("/sum");
// Should stay on sum page
await expect(page).toHaveURL("/sum");
// Should see sum UI
await expect(page.getByText("Sum Calculator")).toBeVisible();
});
test("cannot access audit page - redirected to counter", async ({ page }) => {
await page.goto("/audit");
// Should be redirected to counter page (home)
await expect(page).toHaveURL("/");
});
test("navigation only shows Counter and Sum", async ({ page }) => {
await page.goto("/");
// Should see Counter and Sum in nav
await expect(page.getByText("Counter")).toBeVisible();
await expect(page.getByText("Sum")).toBeVisible();
// Should NOT see Audit in nav (for regular users)
const auditLinks = page.locator('a[href="/audit"]');
await expect(auditLinks).toHaveCount(0);
@ -104,11 +106,11 @@ test.describe("Regular User Access", () => {
test("can navigate between Counter and Sum", async ({ page }) => {
await page.goto("/");
// Go to Sum
await page.click('a[href="/sum"]');
await expect(page).toHaveURL("/sum");
// Go back to Counter
await page.click('a[href="/"]');
await expect(page).toHaveURL("/");
@ -116,31 +118,31 @@ test.describe("Regular User Access", () => {
test("can use counter functionality", async ({ page }) => {
await page.goto("/");
// Get initial count (might be any number)
const countElement = page.locator("h1").first();
await expect(countElement).toBeVisible();
// Click increment
await page.click('button:has-text("Increment")');
// Wait for update
await page.waitForTimeout(500);
// Counter should have updated (we just verify no error occurred)
await expect(countElement).toBeVisible();
});
test("can use sum functionality", async ({ page }) => {
await page.goto("/sum");
// Fill in numbers
await page.fill('input[aria-label="First number"]', "5");
await page.fill('input[aria-label="Second number"]', "3");
// Calculate
await page.click('button:has-text("Calculate")');
// Should show result
await expect(page.getByText("8")).toBeVisible();
});
@ -154,24 +156,24 @@ test.describe("Admin User Access", () => {
test("redirected from counter page to audit", async ({ page }) => {
await page.goto("/");
// Should be redirected to audit page
await expect(page).toHaveURL("/audit");
});
test("redirected from sum page to audit", async ({ page }) => {
await page.goto("/sum");
// Should be redirected to audit page
await expect(page).toHaveURL("/audit");
});
test("can access audit page", async ({ page }) => {
await page.goto("/audit");
// Should stay on audit page
await expect(page).toHaveURL("/audit");
// Should see audit tables
await expect(page.getByText("Counter Activity")).toBeVisible();
await expect(page.getByText("Sum Activity")).toBeVisible();
@ -179,10 +181,10 @@ test.describe("Admin User Access", () => {
test("navigation only shows Audit", async ({ page }) => {
await page.goto("/audit");
// Should see Audit as current
await expect(page.getByText("Audit")).toBeVisible();
// Should NOT see Counter or Sum links (for admin users)
const counterLinks = page.locator('a[href="/"]');
const sumLinks = page.locator('a[href="/sum"]');
@ -192,10 +194,10 @@ test.describe("Admin User Access", () => {
test("audit page shows records", async ({ page }) => {
await page.goto("/audit");
// Should see the tables
await expect(page.getByRole("table")).toHaveCount(2);
// Should see column headers (use first() since there are two tables with same headers)
await expect(page.getByRole("columnheader", { name: "User" }).first()).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Date" }).first()).toBeVisible();
@ -228,11 +230,11 @@ test.describe("Permission Boundary via API", () => {
// Login as regular user
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
// Get cookies
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
// Try to call audit API directly
const response = await request.get(`${API_URL}/api/audit/counter`, {
@ -240,7 +242,7 @@ test.describe("Permission Boundary via API", () => {
Cookie: `auth_token=${authCookie.value}`,
},
});
expect(response.status()).toBe(403);
}
});
@ -249,11 +251,11 @@ test.describe("Permission Boundary via API", () => {
// Login as admin
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
// Get cookies
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
// Try to call counter API directly
const response = await request.get(`${API_URL}/api/counter`, {
@ -261,7 +263,7 @@ test.describe("Permission Boundary via API", () => {
Cookie: `auth_token=${authCookie.value}`,
},
});
expect(response.status()).toBe(403);
}
});
@ -273,11 +275,11 @@ test.describe("Session and Logout", () => {
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
await expect(page).toHaveURL("/");
// Logout
await page.click("text=Sign out");
await expect(page).toHaveURL("/login");
// Try to access counter
await page.goto("/");
await expect(page).toHaveURL("/login");
@ -293,12 +295,11 @@ test.describe("Session and Logout", () => {
path: "/",
},
]);
// Try to access protected page
await page.goto("/");
// Should be redirected to login
await expect(page).toHaveURL("/login");
});
});

View file

@ -2,7 +2,7 @@ import { test, expect, Page } from "@playwright/test";
/**
* Profile E2E tests
*
*
* These tests verify that:
* 1. Regular users can access and use the profile page
* 2. Admin users cannot access the profile page
@ -51,8 +51,8 @@ async function loginUser(page: Page, email: string, password: string) {
// Helper to clear profile data via API
async function clearProfileData(page: Page) {
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
await page.request.put(`${API_URL}/api/profile`, {
headers: {
@ -77,10 +77,10 @@ test.describe("Profile - Regular User Access", () => {
test("can navigate to profile page from counter", async ({ page }) => {
await page.goto("/");
// Should see My Profile link
await expect(page.getByText("My Profile")).toBeVisible();
// Click to navigate
await page.click('a[href="/profile"]');
await expect(page).toHaveURL("/profile");
@ -88,10 +88,10 @@ test.describe("Profile - Regular User Access", () => {
test("can navigate to profile page from sum", async ({ page }) => {
await page.goto("/sum");
// Should see My Profile link
await expect(page.getByText("My Profile")).toBeVisible();
// Click to navigate
await page.click('a[href="/profile"]');
await expect(page).toHaveURL("/profile");
@ -99,17 +99,17 @@ test.describe("Profile - Regular User Access", () => {
test("profile page displays correct elements", async ({ page }) => {
await page.goto("/profile");
// Should see page title
await expect(page.getByRole("heading", { name: "My Profile" })).toBeVisible();
// Should see login email label with read-only badge
await expect(page.getByText("Login EmailRead only")).toBeVisible();
// Should see contact details section
await expect(page.getByText("Contact Details")).toBeVisible();
await expect(page.getByText(/communication purposes only/i)).toBeVisible();
// Should see all form fields
await expect(page.getByLabel("Contact Email")).toBeVisible();
await expect(page.getByLabel("Telegram")).toBeVisible();
@ -119,7 +119,7 @@ test.describe("Profile - Regular User Access", () => {
test("login email is displayed and read-only", async ({ page }) => {
await page.goto("/profile");
// Login email should show the user's email
const loginEmailInput = page.locator('input[type="email"][disabled]');
await expect(loginEmailInput).toHaveValue(REGULAR_USER.email);
@ -128,7 +128,7 @@ test.describe("Profile - Regular User Access", () => {
test("navigation shows Counter, Sum, and My Profile", async ({ page }) => {
await page.goto("/profile");
// Should see all nav items (Counter and Sum as links)
await expect(page.locator('a[href="/"]')).toBeVisible();
await expect(page.locator('a[href="/sum"]')).toBeVisible();
@ -147,7 +147,7 @@ test.describe("Profile - Form Behavior", () => {
test("new user has empty profile fields", async ({ page }) => {
await page.goto("/profile");
// All editable fields should be empty
await expect(page.getByLabel("Contact Email")).toHaveValue("");
await expect(page.getByLabel("Telegram")).toHaveValue("");
@ -157,7 +157,7 @@ test.describe("Profile - Form Behavior", () => {
test("save button is disabled when no changes", async ({ page }) => {
await page.goto("/profile");
// Save button should be disabled
const saveButton = page.getByRole("button", { name: /save changes/i });
await expect(saveButton).toBeDisabled();
@ -165,10 +165,10 @@ test.describe("Profile - Form Behavior", () => {
test("save button is enabled after making changes", async ({ page }) => {
await page.goto("/profile");
// Make a change
await page.fill("#telegram", "@testhandle");
// Save button should be enabled
const saveButton = page.getByRole("button", { name: /save changes/i });
await expect(saveButton).toBeEnabled();
@ -176,22 +176,22 @@ test.describe("Profile - Form Behavior", () => {
test("can save profile and values persist", async ({ page }) => {
await page.goto("/profile");
// Fill in all fields
await page.fill("#contact_email", "contact@test.com");
await page.fill("#telegram", "@testuser");
await page.fill("#signal", "signal.42");
await page.fill("#nostr_npub", VALID_NPUB);
// Save
await page.click('button:has-text("Save Changes")');
// Should see success message
await expect(page.getByText(/saved successfully/i)).toBeVisible();
// Reload and verify values persist
await page.reload();
await expect(page.getByLabel("Contact Email")).toHaveValue("contact@test.com");
await expect(page.getByLabel("Telegram")).toHaveValue("@testuser");
await expect(page.getByLabel("Signal")).toHaveValue("signal.42");
@ -200,20 +200,20 @@ test.describe("Profile - Form Behavior", () => {
test("can clear a field and save", async ({ page }) => {
await page.goto("/profile");
// First set a value
await page.fill("#telegram", "@initial");
await page.click('button:has-text("Save Changes")');
await expect(page.getByText(/saved successfully/i)).toBeVisible();
// Wait for toast to disappear
await expect(page.getByText(/saved successfully/i)).not.toBeVisible({ timeout: 5000 });
// Clear the field
await page.fill("#telegram", "");
await page.click('button:has-text("Save Changes")');
await expect(page.getByText(/saved successfully/i)).toBeVisible();
// Reload and verify it's cleared
await page.reload();
await expect(page.getByLabel("Telegram")).toHaveValue("");
@ -229,26 +229,26 @@ test.describe("Profile - Validation", () => {
test("auto-prepends @ for telegram when starting with letter", async ({ page }) => {
await page.goto("/profile");
// Type a letter without @ - should auto-prepend @
await page.fill("#telegram", "testhandle");
// Should show @testhandle in the input
await expect(page.locator("#telegram")).toHaveValue("@testhandle");
});
test("shows error for telegram handle with no characters after @", async ({ page }) => {
await page.goto("/profile");
// 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 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 });
await expect(saveButton).toBeDisabled();
@ -256,13 +256,13 @@ test.describe("Profile - Validation", () => {
test("shows error for invalid npub", async ({ page }) => {
await page.goto("/profile");
// Enter invalid npub
await page.fill("#nostr_npub", "invalidnpub");
// Should show error
await expect(page.getByText(/must start with 'npub1'/i)).toBeVisible();
// Save button should be disabled
const saveButton = page.getByRole("button", { name: /save changes/i });
await expect(saveButton).toBeDisabled();
@ -270,38 +270,38 @@ test.describe("Profile - Validation", () => {
test("can fix validation error and save", async ({ page }) => {
await page.goto("/profile");
// 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(/at least one character after @/i)).not.toBeVisible();
// Should be able to save
const saveButton = page.getByRole("button", { name: /save changes/i });
await expect(saveButton).toBeEnabled();
await page.click('button:has-text("Save Changes")');
await expect(page.getByText(/saved successfully/i)).toBeVisible();
});
test("shows error for invalid email format", async ({ page }) => {
await page.goto("/profile");
// Enter invalid email
await page.fill("#contact_email", "not-an-email");
// Should show error
await expect(page.getByText(/valid email/i)).toBeVisible();
});
@ -315,25 +315,25 @@ test.describe("Profile - Admin User Access", () => {
test("admin does not see My Profile link", async ({ page }) => {
await page.goto("/audit");
// Should be on audit page
await expect(page).toHaveURL("/audit");
// Should NOT see My Profile link
await expect(page.locator('a[href="/profile"]')).toHaveCount(0);
});
test("admin cannot access profile page - redirected to audit", async ({ page }) => {
await page.goto("/profile");
// Should be redirected to audit
await expect(page).toHaveURL("/audit");
});
test("admin API call to profile returns 403", async ({ page, request }) => {
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) {
// Try to call profile API directly
const response = await request.get(`${API_URL}/api/profile`, {
@ -341,7 +341,7 @@ test.describe("Profile - Admin User Access", () => {
Cookie: `auth_token=${authCookie.value}`,
},
});
expect(response.status()).toBe(403);
}
});
@ -362,4 +362,3 @@ test.describe("Profile - Unauthenticated Access", () => {
expect(response.status()).toBe(401);
});
});

View file

@ -1,31 +1,26 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
'react-hooks': reactHooks,
"react-hooks": reactHooks,
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
// Downgrade to warnings - existing patterns use these
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/set-state-in-effect': 'off',
"react-hooks/exhaustive-deps": "warn",
"react-hooks/set-state-in-effect": "off",
},
},
{
ignores: [
'.next/**',
'node_modules/**',
'app/generated/**',
],
ignores: [".next/**", "node_modules/**", "app/generated/**"],
}
);

View file

@ -3,4 +3,3 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;

View file

@ -24,6 +24,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"jsdom": "^26.0.0",
"openapi-typescript": "^7.10.1",
"prettier": "^3.7.4",
"typescript": "5.9.3",
"typescript-eslint": "^8.50.0",
"vitest": "^2.1.8"
@ -4293,6 +4294,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",

View file

@ -11,7 +11,9 @@
"test:e2e": "playwright test",
"generate-api-types": "openapi-typescript http://localhost:8000/openapi.json -o app/generated/api.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"bech32": "^2.0.0",
@ -30,6 +32,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"jsdom": "^26.0.0",
"openapi-typescript": "^7.10.1",
"prettier": "^3.7.4",
"typescript": "5.9.3",
"typescript-eslint": "^8.50.0",
"vitest": "^2.1.8"

View file

@ -15,4 +15,3 @@ export default defineConfig({
baseURL: "http://localhost:3000",
},
});

View file

@ -19,4 +19,3 @@
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -8,4 +8,3 @@ export default defineConfig({
include: ["app/**/*.test.{ts,tsx}"],
},
});