From 7ab80e6d00b8d1491b609249ff03c45fbb3648a9 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 30 Oct 2026 19:42:22 +0100 Subject: [PATCH 01/15] feat: use one source of login state, add a login page and delete a gymnast The menu, the protected pages and the gymnasts page now read the same answer from the server (me) through useAuth(), so they cannot disagree and a reload keeps a logged-in coach on the page. Log in goes to a login page with the demo coach and the test coach; ProtectedRoute waits for the answer and remembers where the visitor was going. A gymnast can be deleted after a confirmation. Part of #136, #138 --- shared/demo.ts | 15 ++ src/App.flow.test.tsx | 201 ++++++++++++++++ src/App.test.tsx | 53 ++++- src/App.tsx | 12 +- src/components/ProtectedRoute.test.tsx | 71 ++++++ src/components/ProtectedRoute.tsx | 36 ++- src/components/auth/AuthProvider.tsx | 26 ++ src/components/auth/LoginPage.test.tsx | 222 ++++++++++++++++++ src/components/auth/LoginPage.tsx | 139 +++++++++++ src/components/auth/useAuth.ts | 23 ++ .../gymnasts/DeleteGymnastButton.test.tsx | 106 +++++++++ .../gymnasts/DeleteGymnastButton.tsx | 95 ++++++++ src/components/gymnasts/GymnastList.test.tsx | 27 ++- src/components/gymnasts/GymnastList.tsx | 11 +- src/components/gymnasts/GymnastRow.tsx | 18 +- .../gymnasts/YourGymnastsPage.test.tsx | 78 +++--- src/components/gymnasts/YourGymnastsPage.tsx | 45 +++- src/components/navbar/Navbar.test.tsx | 87 +++++-- src/components/navbar/Navbar.tsx | 44 +++- src/lib/apollo/client.test.ts | 74 ++++++ src/lib/apollo/client.ts | 21 +- src/lib/authOperations.ts | 28 +++ src/lib/gymnastOperations.ts | 12 + src/lib/insecureConnection.test.ts | 25 ++ src/lib/insecureConnection.ts | 14 ++ 25 files changed, 1368 insertions(+), 115 deletions(-) create mode 100644 shared/demo.ts create mode 100644 src/App.flow.test.tsx create mode 100644 src/components/ProtectedRoute.test.tsx create mode 100644 src/components/auth/AuthProvider.tsx create mode 100644 src/components/auth/LoginPage.test.tsx create mode 100644 src/components/auth/LoginPage.tsx create mode 100644 src/components/auth/useAuth.ts create mode 100644 src/components/gymnasts/DeleteGymnastButton.test.tsx create mode 100644 src/components/gymnasts/DeleteGymnastButton.tsx create mode 100644 src/lib/apollo/client.test.ts create mode 100644 src/lib/authOperations.ts create mode 100644 src/lib/insecureConnection.test.ts create mode 100644 src/lib/insecureConnection.ts diff --git a/shared/demo.ts b/shared/demo.ts new file mode 100644 index 0000000..cb31913 --- /dev/null +++ b/shared/demo.ts @@ -0,0 +1,15 @@ +// The demo coach (see "The demo coach" in docs/AUTHENTICATION.md): a coach with +// example data, so that the staff and the students who review the project can +// look at the gymnast pages and save and delete things without typing an +// address. It is a coach of the test login (`devLogin`), so it needs no +// password. `npm run db:demo` makes it and puts the example data back +// (backend/src/seed/demo.ts), and the login page has a button for it. +// +// This file must not import anything: it is compiled by the backend and by +// the frontend. + +export const DEMO_EMAIL = 'demo@wag-planner.example' +export const DEMO_NAME = 'Demo Coach' + +/** The gymnasts the demo coach starts with, and gets back from `npm run db:demo`. */ +export const DEMO_GYMNASTS = ['Emma Berg', 'Sofie Hansen', 'Nora Lie'] diff --git a/src/App.flow.test.tsx b/src/App.flow.test.tsx new file mode 100644 index 0000000..4ab4d9b --- /dev/null +++ b/src/App.flow.test.tsx @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +// The whole path of a coach, as one test: log in, save a gymnast, reload the page, +// log out, log in again and see the gymnast, delete it. The app and the real +// Apollo Client run as in the browser; only the server is replaced by a stub that +// keeps the state of the login and the gymnasts, as the real server does, so a +// reload (a new client with an empty cache) must ask it again. + +import { ApolloProvider } from '@apollo/client/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestClient, ok, type Handler } from '@/tests/apollo' +import App from './App' + +const BASE = import.meta.env.BASE_URL + +/** + * The server: who is logged in and the gymnasts of the coach. The functions + * that the app calls to log in and out change this state and reset the client, + * as the real ones do (src/lib/apollo/client.ts). + */ +const server = vi.hoisted(() => ({ + loggedIn: false, + gymnasts: [] as { id: string; name: string }[], + nextId: 1, + client: null as null | { resetStore: () => Promise }, +})) + +vi.mock('@/lib/apollo/client', async (importOriginal) => ({ + ...(await importOriginal()), + devLogin: vi.fn(async () => { + server.loggedIn = true + await server.client?.resetStore() + return { id: 'coach-1', name: 'Demo Coach', email: 'demo@example.com' } + }), + logout: vi.fn(async () => { + server.loggedIn = false + await server.client?.resetStore() + }), +})) + +const coach = { + __typename: 'Coach', + id: 'coach-1', + name: 'Demo Coach', + email: 'demo@example.com', +} + +const handler: Handler = ({ operationName, variables }) => { + switch (operationName) { + case 'Me': + return ok({ me: server.loggedIn ? coach : null }) + case 'YourGymnasts': + return ok({ + me: server.loggedIn + ? { + __typename: 'Coach', + id: coach.id, + gymnasts: [...server.gymnasts] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((g) => ({ __typename: 'Gymnast', ...g })), + } + : null, + }) + case 'CreateGymnast': { + const gymnast = { + id: `gymnast-${server.nextId++}`, + name: String(variables['name']), + } + server.gymnasts.push(gymnast) + return ok({ createGymnast: { __typename: 'Gymnast', ...gymnast } }) + } + case 'DeleteGymnast': + server.gymnasts = server.gymnasts.filter((g) => g.id !== variables['id']) + return ok({ deleteGymnast: variables['id'] }) + case 'SearchCatalog': + // Logging out leaves for the Elements page, which searches. It is not what + // this test is about, so the catalog is empty. + return ok({ + searchCatalog: { + __typename: 'CatalogConnection', + totalCount: 0, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + endCursor: null, + }, + edges: [], + }, + }) + default: + throw new Error(`The stub does not know ${operationName}.`) + } +} + +/** Opens the app as a new page load does: a new client with an empty cache. */ +function openApp(path: string) { + window.history.replaceState({}, '', `${BASE}${path}`) + const { client } = createTestClient(handler) + server.client = client + render( + + + + + , + ) +} + +const pathname = () => window.location.pathname.slice(BASE.length - 1) + +beforeEach(() => { + vi.stubEnv('VITE_DEV_LOGIN', 'true') + server.loggedIn = false + server.gymnasts = [] + server.nextId = 1 + window.localStorage.clear() +}) + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() + window.history.replaceState({}, '', BASE) +}) + +describe('a coach’s way through the app', () => { + it('logs in, saves a gymnast, keeps it after a reload and after a new login, and deletes it', async () => { + // 1. A visitor who is not logged in is asked to log in, and the menu agrees. + openApp('gymnasts') + expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy() + expect(pathname()).toBe('/login') + expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy() + expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull() + + // 2. One click logs in as the demo coach, and lands on the page they wanted. + fireEvent.click( + screen.getByRole('button', { name: 'Log in as the demo coach' }), + ) + expect( + await screen.findByRole('heading', { name: 'Your gymnasts' }), + ).toBeTruthy() + expect(pathname()).toBe('/gymnasts') + expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy() + expect(screen.getByText('You haven’t added any gymnasts yet.')).toBeTruthy() + + // 3. A gymnast is saved. + fireEvent.click(screen.getByRole('button', { name: 'Add gymnast' })) + fireEvent.change(screen.getByLabelText('Gymnast name'), { + target: { value: 'Anna Lie' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save gymnast' })) + expect( + await screen.findByRole('heading', { name: 'Anna Lie' }), + ).toBeTruthy() + expect(server.gymnasts.map((g) => g.name)).toEqual(['Anna Lie']) + + // 4. A reload: a new client that knows nothing, a saved login. The coach stays + // on the page, the menu still says Log out, and the gymnast is there. + cleanup() + openApp('gymnasts') + expect( + await screen.findByRole('heading', { name: 'Anna Lie' }), + ).toBeTruthy() + expect(pathname()).toBe('/gymnasts') + expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy() + + // 5. Logging out leaves the page and the menu says Log in. + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) + expect(await screen.findByRole('link', { name: 'Log in' })).toBeTruthy() + expect(pathname()).toBe('/elements') + expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull() + + // 6. Logging in again shows the same gymnast: it is saved for the coach, on the server. + fireEvent.click(screen.getByRole('link', { name: 'Log in' })) + fireEvent.click( + await screen.findByRole('button', { name: 'Log in as the demo coach' }), + ) + expect( + await screen.findByRole('heading', { name: 'Anna Lie' }), + ).toBeTruthy() + expect(pathname()).toBe('/gymnasts') + + // 7. The gymnast is deleted after a confirmation, and the page says so. + fireEvent.click(screen.getByRole('button', { name: 'Delete Anna Lie' })) + const dialog = await screen.findByRole('alertdialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) + await waitFor(() => + expect(screen.queryByRole('heading', { name: 'Anna Lie' })).toBeNull(), + ) + expect(screen.getByText('Anna Lie deleted.')).toBeTruthy() + expect(screen.getByText('You haven’t added any gymnasts yet.')).toBeTruthy() + expect(server.gymnasts).toEqual([]) + }) +}) diff --git a/src/App.test.tsx b/src/App.test.tsx index 34958c7..f380c2e 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -3,7 +3,7 @@ import { ApolloProvider } from '@apollo/client/react'; import { fireEvent, render, screen } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createTestClient } from '@/tests/apollo'; +import { createTestClient, ok, type Handler } from '@/tests/apollo'; import App from './App'; /** @@ -11,8 +11,8 @@ import App from './App'; * client is used; only the network is replaced by a request that never * answers, so the pages stay in their loading state. */ -const renderApp = () => { - const { client } = createTestClient(() => new Promise(() => {})); +const renderApp = (handler: Handler = () => new Promise(() => {})) => { + const { client } = createTestClient(handler); return render( @@ -102,18 +102,51 @@ describe('App', () => { } }); - it('guards the Gymnasts route and makes it available after login', () => { + it('sends a visitor who is not logged in from the Gymnasts route to the login page', async () => { window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`); - renderApp(); + renderApp(() => ok({ me: null })); - // Logged out, /gymnasts goes to the start page, which is the Elements page. - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`); + expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy(); + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}login`); expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull(); + expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy(); + }); - fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + it('keeps a logged-in coach on the Gymnasts route after a reload, and the menu agrees with the page', async () => { + window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`); + renderApp((operation) => + ok({ + me: { + __typename: 'Coach', + id: 'coach-1', + name: 'Anna', + email: 'anna@example.com', + ...(operation.operationName === 'YourGymnasts' && { + gymnasts: [{ __typename: 'Gymnast', id: 'g1', name: 'Emma Berg' }], + }), + }, + }), + ); - fireEvent.click(screen.getByRole('link', { name: 'Gymnasts' })); + expect( + await screen.findByRole('heading', { name: 'Your gymnasts' }), + ).toBeTruthy(); + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`); + // The menu and the page read the same answer. + expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy(); + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); + expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy(); + expect(await screen.findByText('Emma Berg')).toBeTruthy(); + }); + + it('does not send a visitor away before the server has said who is logged in', async () => { + window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`); + renderApp(); // the server never answers - expect(screen.getByRole('heading', { name: 'Your gymnasts' })).toBeTruthy(); + expect(await screen.findByText('Checking your login…')).toBeTruthy(); + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`); + // Neither "Log in" nor "Log out" is true yet. + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull(); }); }); diff --git a/src/App.tsx b/src/App.tsx index 10645b5..f06c5ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { Navbar } from './components/navbar/Navbar'; import { SkipLink } from './components/skipLink/SkipLink'; import { ProtectedRoute } from './components/ProtectedRoute'; +import { AuthProvider } from './components/auth/AuthProvider'; +import LoginPage from './components/auth/LoginPage'; import ElementDetailPage from './components/elements/ElementDetailPage'; import ElementsPage from './components/elements/ElementsPage'; @@ -31,7 +33,6 @@ const NotFoundPage = () => ( ); export function App() { - const [isLoggedIn, setIsLoggedIn] = useState(false); const [isDarkMode, setIsDarkMode] = useState( () => window.localStorage.getItem('theme') === 'dark', ); @@ -46,11 +47,9 @@ export function App() { }, [isDarkMode]); return ( - <> + setIsDarkMode((current) => !current)} /> @@ -63,16 +62,17 @@ export function App() { } /> } /> + } /> {/* Protected routes requiring login */} - }> + }> } /> } /> - + ); } diff --git a/src/components/ProtectedRoute.test.tsx b/src/components/ProtectedRoute.test.tsx new file mode 100644 index 0000000..15be7ce --- /dev/null +++ b/src/components/ProtectedRoute.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import { AuthContext, type AuthState } from '@/components/auth/useAuth' +import { ProtectedRoute } from './ProtectedRoute' + +afterEach(cleanup) + +const states = { + loading: { status: 'loading', coach: null }, + anonymous: { status: 'anonymous', coach: null }, + authenticated: { + status: 'authenticated', + coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' }, + }, +} satisfies Record + +/** Where the visitor ended up, and what the login page was told about where they came from. */ +function LoginStub() { + const location = useLocation() + const from = ( + location.state as { from?: { pathname: string; search: string } } | null + )?.from + return ( +

+ login, from {from?.pathname} + {from?.search} +

+ ) +} + +function renderAt(state: AuthState, entry = '/gymnasts?tab=1') { + render( + + + + } /> + }> + the gymnasts

} /> +
+
+
+
, + ) +} + +describe('ProtectedRoute', () => { + it('shows the page to a coach who is logged in', () => { + renderAt(states.authenticated) + + expect(screen.getByText('the gymnasts')).toBeTruthy() + }) + + it('sends a visitor who is not logged in to the login page, and remembers the page they wanted', () => { + renderAt(states.anonymous) + + expect(screen.getByTestId('login').textContent).toBe( + 'login, from /gymnasts?tab=1', + ) + expect(screen.queryByText('the gymnasts')).toBeNull() + }) + + it('waits while the server has not said who is logged in, and does not send anyone away', () => { + renderAt(states.loading) + + expect(screen.getByRole('status').textContent).toBe('Checking your login…') + expect(screen.queryByTestId('login')).toBeNull() + expect(screen.queryByText('the gymnasts')).toBeNull() + }) +}) diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 45b3799..92f82d1 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,18 +1,34 @@ -import React from 'react'; -import { Navigate, Outlet } from 'react-router-dom'; +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { useAuth } from '@/components/auth/useAuth'; interface ProtectedRouteProps { - isLoggedIn: boolean; + /** Where an anonymous visitor is sent. The page they wanted is kept, so the login can send them back. */ redirectPath?: string; } -export const ProtectedRoute: React.FC = ({ - isLoggedIn, - redirectPath = '/', -}) => { - if (!isLoggedIn) { - return ; +/** + * The pages inside are for a logged-in coach. It reads the same `useAuth()` as + * the menu and the pages, so they cannot disagree. While the answer from the + * server is on its way the visitor is not sent away: a reload of /gymnasts with + * a saved session must stay on /gymnasts. + */ +export const ProtectedRoute = ({ + redirectPath = '/login', +}: ProtectedRouteProps) => { + const { status } = useAuth(); + const location = useLocation(); + + if (status === 'loading') { + return ( +
+

Checking your login…

+
+ ); + } + + if (status === 'anonymous') { + return ; } return ; -}; \ No newline at end of file +}; diff --git a/src/components/auth/AuthProvider.tsx b/src/components/auth/AuthProvider.tsx new file mode 100644 index 0000000..8af3140 --- /dev/null +++ b/src/components/auth/AuthProvider.tsx @@ -0,0 +1,26 @@ +import { useMemo, type ReactNode } from 'react' +import { useQuery } from '@apollo/client/react' +import { ME_QUERY } from '@/lib/authOperations' +import { AuthContext, type AuthState } from './useAuth' + +/** + * The one place that asks the server who is logged in (`me`). Everything that + * depends on the login reads `useAuth()`: the menu, the protected pages and the + * gymnasts page. Logging in and out resets the Apollo store (see + * src/lib/apollo/client.ts), which asks this question again, so all of them + * change at once. + * + * A network error is shown as "anonymous": the page then asks the visitor to + * log in, and the pages that need the server show their own error. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const { data, loading } = useQuery(ME_QUERY) + + const state = useMemo(() => { + if (data?.me) return { status: 'authenticated', coach: data.me } + if (loading && !data) return { status: 'loading', coach: null } + return { status: 'anonymous', coach: null } + }, [data, loading]) + + return {children} +} diff --git a/src/components/auth/LoginPage.test.tsx b/src/components/auth/LoginPage.test.tsx new file mode 100644 index 0000000..98c07d4 --- /dev/null +++ b/src/components/auth/LoginPage.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AuthContext, type AuthState } from '@/components/auth/useAuth' +import { devLogin } from '@/lib/apollo/client' +import { ApiError } from '@/lib/apollo/api' +import { DEMO_EMAIL } from '../../../shared/demo' +import LoginPage from './LoginPage' + +// The real function would call the server. The page only has to call devLogin. +vi.mock('@/lib/apollo/client', () => ({ devLogin: vi.fn() })) + +const logIn = vi.mocked(devLogin) + +afterEach(() => { + cleanup() + vi.resetAllMocks() + vi.unstubAllEnvs() +}) + +const anonymous: AuthState = { status: 'anonymous', coach: null } +const coach = { id: 'c1', name: 'Demo Coach', email: DEMO_EMAIL } + +/** Shows the address, so a test can see where the visitor went. */ +function Address() { + const location = useLocation() + return ( + {location.pathname + location.search} + ) +} + +function renderPage({ + auth = anonymous, + entry = '/login' as string | { pathname: string; state: unknown }, +} = {}) { + render( + + + + } /> + somewhere else

} /> +
+
+ + , + ) +} + +const address = () => screen.getByTestId('address').textContent + +describe('LoginPage: a build without the test login', () => { + it('says that login is not turned on, and shows no form that cannot work', () => { + vi.stubEnv('VITE_DEV_LOGIN', '') + renderPage() + + expect( + screen.getByRole('heading', { level: 1, name: 'Log in' }), + ).toBeTruthy() + expect(screen.getByText(/not turned on/)).toBeTruthy() + expect(screen.queryByRole('button', { name: /demo coach/ })).toBeNull() + expect(screen.queryByRole('form', { name: 'Test login' })).toBeNull() + }) +}) + +describe('LoginPage: a build with the test login', () => { + it('has the demo coach and a test coach of the visitor’s own, each under a heading', () => { + vi.stubEnv('VITE_DEV_LOGIN', 'true') + renderPage() + + expect( + screen.getByRole('heading', { + level: 2, + name: 'Try it with the demo coach', + }), + ).toBeTruthy() + expect( + screen.getByRole('button', { name: 'Log in as the demo coach' }), + ).toBeTruthy() + expect( + screen.getByRole('heading', { + level: 2, + name: 'Or use your own test coach', + }), + ).toBeTruthy() + expect(screen.getByRole('form', { name: 'Test login' })).toBeTruthy() + // The test login says what it is, so nobody enters anything private. + expect(screen.getByText(/anyone can log in as anyone/)).toBeTruthy() + }) + + it('logs in as the demo coach with one click and goes to the gymnasts', async () => { + vi.stubEnv('VITE_DEV_LOGIN', 'true') + logIn.mockResolvedValue(coach) + renderPage() + + fireEvent.click( + screen.getByRole('button', { name: 'Log in as the demo coach' }), + ) + + await waitFor(() => expect(address()).toBe('/gymnasts')) + expect(logIn).toHaveBeenCalledExactlyOnceWith(DEMO_EMAIL) + }) + + it('goes back to the page the visitor wanted, with its search, after the login', async () => { + vi.stubEnv('VITE_DEV_LOGIN', 'true') + logIn.mockResolvedValue(coach) + renderPage({ + entry: { + pathname: '/login', + state: { from: { pathname: '/gymnasts', search: '?x=1' } }, + }, + }) + + fireEvent.click( + screen.getByRole('button', { name: 'Log in as the demo coach' }), + ) + + await waitFor(() => expect(address()).toBe('/gymnasts?x=1')) + }) + + it('does not follow a "from" that leaves the site', async () => { + vi.stubEnv('VITE_DEV_LOGIN', 'true') + logIn.mockResolvedValue(coach) + renderPage({ + entry: { + pathname: '/login', + state: { from: { pathname: '//evil.example' } }, + }, + }) + + fireEvent.click( + screen.getByRole('button', { name: 'Log in as the demo coach' }), + ) + + await waitFor(() => expect(address()).toBe('/gymnasts')) + }) + + it('logs in as a test coach of the visitor’s own', async () => { + vi.stubEnv('VITE_DEV_LOGIN', 'true') + logIn.mockResolvedValue({ ...coach, email: 'emma@example.com' }) + renderPage() + + fireEvent.change(screen.getByLabelText('Your e-mail address'), { + target: { value: 'emma@example.com' }, + }) + fireEvent.click( + screen.getByRole('button', { name: 'Log in as test coach' }), + ) + + await waitFor(() => expect(address()).toBe('/gymnasts')) + expect(logIn).toHaveBeenCalledWith('emma@example.com') + }) + + it('tells the visitor when the server has the test login turned off, and stays on the page', async () => { + vi.stubEnv('VITE_DEV_LOGIN', 'true') + logIn.mockRejectedValue( + new ApiError('NOT_IMPLEMENTED', 'devLogin is not available yet.'), + ) + renderPage() + + fireEvent.click( + screen.getByRole('button', { name: 'Log in as the demo coach' }), + ) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'This is not available yet.', + ) + expect(address()).toBe('/login') + // The button works again, for another try. + expect( + screen.getByRole('button', { + name: 'Log in as the demo coach', + }).disabled, + ).toBe(false) + }) + + it('makes only one login when the button is clicked twice', async () => { + vi.stubEnv('VITE_DEV_LOGIN', 'true') + logIn.mockReturnValue(new Promise(() => {})) + renderPage() + + const button = screen.getByRole('button', { + name: 'Log in as the demo coach', + }) + fireEvent.click(button) + fireEvent.click(button) + + expect(logIn).toHaveBeenCalledOnce() + expect( + screen.getByRole('button', { name: 'Logging in…' }) + .disabled, + ).toBe(true) + }) +}) + +describe('LoginPage: who is already logged in', () => { + it('sends a coach who is logged in on to the gymnasts, and shows no form', () => { + vi.stubEnv('VITE_DEV_LOGIN', 'true') + renderPage({ auth: { status: 'authenticated', coach } }) + + expect(address()).toBe('/gymnasts') + expect( + screen.queryByRole('button', { name: 'Log in as the demo coach' }), + ).toBeNull() + }) +}) + +describe('LoginPage: plain http', () => { + it('does not warn on localhost, where nothing leaves the computer', () => { + vi.stubEnv('VITE_DEV_LOGIN', 'true') + renderPage() + + // jsdom serves the page from http://localhost. + expect(screen.queryByTestId('insecure-connection')).toBeNull() + }) +}) diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx new file mode 100644 index 0000000..ad10ddc --- /dev/null +++ b/src/components/auth/LoginPage.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react' +import { Navigate, useLocation, useNavigate } from 'react-router-dom' +import { TriangleAlert } from 'lucide-react' +import { useAuth } from '@/components/auth/useAuth' +import DevLoginButton from '@/components/gymnasts/DevLoginButton' +import { Button } from '@/components/ui/button' +import { devLogin } from '@/lib/apollo/client' +import { describeError } from '@/lib/apollo/errors' +import { isDevLoginEnabled } from '@/lib/devLogin' +import { isInsecureConnection } from '@/lib/insecureConnection' +import { DEMO_EMAIL } from '../../../shared/demo' + +/** Where the visitor goes after logging in when they did not come from a protected page. */ +const DEFAULT_TARGET = '/gymnasts' + +const HINT = 'text-sm text-(--text-color-dark)' + +/** + * Where to go after the login: the page the visitor wanted (ProtectedRoute puts + * it in the state of the navigation), or the gymnasts. It is only used when it + * is a path on this site, so a link cannot send the visitor somewhere else. + */ +function targetOf(state: unknown): string { + const from = ( + state as { from?: { pathname?: string; search?: string } } | null + )?.from + const path = from?.pathname + if (!path || !path.startsWith('/') || path.startsWith('//')) { + return DEFAULT_TARGET + } + return path + (from?.search ?? '') +} + +/** + * The login page. The only login the app has is the test login (`devLogin`): + * Sign in with Google needs https, and the course server has only http. Why we + * chose this, how it is guarded, and what a real system would do instead is in + * docs/AUTHENTICATION.md. The page offers two ways in: the demo coach, which has + * example gymnasts, and a coach of the visitor's own, made from an e-mail + * address. When a build has no test login (VITE_DEV_LOGIN is not "true") the + * page says so, and does not show a form that cannot work. + */ +export default function LoginPage() { + const auth = useAuth() + const location = useLocation() + const navigate = useNavigate() + const target = targetOf(location.state) + const [isLoggingIn, setIsLoggingIn] = useState(false) + const [error, setError] = useState('') + + // Already logged in (also right after the login): go on to where the visitor + // was going. + if (auth.status === 'authenticated') { + return + } + + async function logInAsDemo() { + if (isLoggingIn) return + setIsLoggingIn(true) + setError('') + try { + await devLogin(DEMO_EMAIL) + void navigate(target, { replace: true }) + } catch (problem) { + setError(describeError(problem).message) + } finally { + setIsLoggingIn(false) + } + } + + return ( +
+
+

Log in

+

+ Log in to see and change your gymnasts. +

+ + {isInsecureConnection() && ( +
+
+ )} + + {isDevLoginEnabled() ? ( + <> +
+

Try it with the demo coach

+

+ The demo coach has example gymnasts. You can add and delete + things; everyone who uses the demo coach shares the same data. +

+ + {error && ( +

+ {error} +

+ )} +
+ +
+

Or use your own test coach

+ { + void navigate(target, { replace: true }) + }} + /> +
+ + ) : ( +

+ Login is not turned on in this version of the app. +

+ )} +
+
+ ) +} diff --git a/src/components/auth/useAuth.ts b/src/components/auth/useAuth.ts new file mode 100644 index 0000000..e6bf3b4 --- /dev/null +++ b/src/components/auth/useAuth.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from 'react' +import type { Coach } from '@/lib/authOperations' + +/** + * Who is logged in, as the server says it: + * - `loading`: the first answer is on its way, so the page does not know yet. + * A page must wait and not send the visitor away or show "Log in". + * - `authenticated`: `coach` is the logged-in coach. + * - `anonymous`: nobody is logged in, or the session has ended. + */ +export type AuthState = + | { status: 'loading'; coach: null } + | { status: 'authenticated'; coach: Coach } + | { status: 'anonymous'; coach: null } + +export const AuthContext = createContext(null) + +/** The state of the login. Every part of the app that needs it reads it here, so there is one source. */ +export function useAuth(): AuthState { + const state = useContext(AuthContext) + if (!state) throw new Error('useAuth must be used inside an AuthProvider.') + return state +} diff --git a/src/components/gymnasts/DeleteGymnastButton.test.tsx b/src/components/gymnasts/DeleteGymnastButton.test.tsx new file mode 100644 index 0000000..0d6e086 --- /dev/null +++ b/src/components/gymnasts/DeleteGymnastButton.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +// The button runs on the real Apollo Client, with a stub for `fetch` (see +// src/tests/apollo.ts). + +import { ApolloProvider } from '@apollo/client/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTestClient, failure, ok, type Handler } from '@/tests/apollo' +import DeleteGymnastButton from './DeleteGymnastButton' + +afterEach(cleanup) + +const gymnast = { id: 'gymnast-1', name: 'Emma Berg' } + +function renderButton(handler: Handler) { + const onDeleted = vi.fn() + const onFailed = vi.fn() + const { client, calls } = createTestClient(handler) + render( + + + , + ) + return { + onDeleted, + onFailed, + deletes: () => calls.filter((c) => c.operationName === 'DeleteGymnast'), + } +} + +const trigger = () => screen.getByRole('button', { name: 'Delete Emma Berg' }) +const confirmButton = () => screen.getByRole('button', { name: 'Delete' }) + +describe('DeleteGymnastButton', () => { + it('is named after the gymnast, so a list of them can be told apart', () => { + renderButton(() => ok({ deleteGymnast: gymnast.id })) + + expect(trigger()).toBeTruthy() + expect(screen.queryByRole('alertdialog')).toBeNull() + }) + + it('asks before it deletes, and says that the routines go too and that it cannot be undone', async () => { + const page = renderButton(() => ok({ deleteGymnast: gymnast.id })) + + fireEvent.click(trigger()) + + const dialog = await screen.findByRole('alertdialog') + expect(dialog.textContent).toContain('Delete Emma Berg?') + expect(dialog.textContent).toContain('routines') + expect(dialog.textContent).toContain('cannot be undone') + expect(page.deletes()).toHaveLength(0) + }) + + it('closes with Cancel and deletes nothing', async () => { + const page = renderButton(() => ok({ deleteGymnast: gymnast.id })) + fireEvent.click(trigger()) + await screen.findByRole('alertdialog') + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(page.deletes()).toHaveLength(0) + expect(page.onDeleted).not.toHaveBeenCalled() + }) + + it('deletes the gymnast when it is confirmed, closes, and tells the page', async () => { + const page = renderButton(() => ok({ deleteGymnast: gymnast.id })) + fireEvent.click(trigger()) + await screen.findByRole('alertdialog') + + fireEvent.click(confirmButton()) + + await waitFor(() => + expect(page.onDeleted).toHaveBeenCalledWith('Emma Berg'), + ) + expect(page.deletes()).toHaveLength(1) + expect(page.deletes()[0]?.variables).toEqual({ id: 'gymnast-1' }) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(page.onFailed).not.toHaveBeenCalled() + }) + + it('tells the page why when the server refuses, and does not say that it was deleted', async () => { + const page = renderButton(() => + failure('NOT_FOUND', 'The gymnast does not exist.'), + ) + fireEvent.click(trigger()) + await screen.findByRole('alertdialog') + + fireEvent.click(confirmButton()) + + await waitFor(() => + expect(page.onFailed).toHaveBeenCalledWith('We could not find that.'), + ) + expect(page.onDeleted).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/gymnasts/DeleteGymnastButton.tsx b/src/components/gymnasts/DeleteGymnastButton.tsx new file mode 100644 index 0000000..b655012 --- /dev/null +++ b/src/components/gymnasts/DeleteGymnastButton.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react' +import { useMutation } from '@apollo/client/react' +import { AlertDialog } from '@base-ui/react/alert-dialog' +import { Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { describeError } from '@/lib/apollo/errors' +import { + DELETE_GYMNAST_MUTATION, + GYMNASTS_QUERY, +} from '@/lib/gymnastOperations' +import type { GymnastSummary } from '@/lib/gymnasts' + +/** + * Deletes a gymnast, after the coach has confirmed it in a dialog. Deleting + * cannot be undone and takes the gymnast's routines and list of elements with + * it, so a click on the icon only opens the question. The dialog is the Base UI + * alert dialog: the focus stays inside it, Escape closes it, and it is read as + * a dialog with a title and a description. The button is named after the + * gymnast, so a list of them can be told apart by a screen reader. + * + * `onDeleted` is called with the name when the server has deleted the gymnast, + * and `onFailed` with the message when it has not. The page tells the visitor + * and moves the focus, because this button is gone by then. + */ +export default function DeleteGymnastButton({ + gymnast, + onDeleted, + onFailed, +}: { + gymnast: GymnastSummary + onDeleted: (name: string) => void + onFailed: (message: string) => void +}) { + const [open, setOpen] = useState(false) + // The list is fetched again: the gymnast is gone from it, and the API sorts it. + const [deleteGymnast, { loading }] = useMutation(DELETE_GYMNAST_MUTATION, { + refetchQueries: [GYMNASTS_QUERY], + }) + + async function confirm() { + try { + await deleteGymnast({ variables: { id: gymnast.id } }) + setOpen(false) + onDeleted(gymnast.name) + } catch (problem) { + setOpen(false) + onFailed(describeError(problem).message) + } + } + + return ( + + + } + > + + + + + + Delete {gymnast.name}? + + + This also deletes the routines and the list of elements of{' '} + {gymnast.name}. It cannot be undone. + +
+ } + > + Cancel + + +
+
+
+
+ ) +} diff --git a/src/components/gymnasts/GymnastList.test.tsx b/src/components/gymnasts/GymnastList.test.tsx index e3b933b..476f339 100644 --- a/src/components/gymnasts/GymnastList.test.tsx +++ b/src/components/gymnasts/GymnastList.test.tsx @@ -1,17 +1,24 @@ +import { ApolloProvider } from "@apollo/client/react"; import { renderToString } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { createTestClient } from "@/tests/apollo"; import GymnastList from "./GymnastList"; +// Each row has a delete button, which uses the client for its mutation. +const { client } = createTestClient(() => new Promise(() => {})); +const renderList = (gymnasts: { id: string; name: string }[]) => + renderToString( + + {}} onFailed={() => {}} /> + , + ); + describe("GymnastList", () => { it("renders names with initials", () => { - const markup = renderToString( - , - ); + const markup = renderList([ + { id: "one", name: "Ingrid Haugen" }, + { id: "two", name: "Emma Berg" }, + ]); expect(markup).toContain("Ingrid Haugen"); expect(markup).toContain("IH"); @@ -20,13 +27,13 @@ describe("GymnastList", () => { }); it("has a heading for the list", () => { - const markup = renderToString(); + const markup = renderList([]); expect(markup).toMatch(/]*>Gymnast list<\/h2>/); }); it("announces the empty list state", () => { - const markup = renderToString(); + const markup = renderList([]); expect(markup).toContain('role="status"'); expect(markup).toContain("You haven’t added any gymnasts yet."); diff --git a/src/components/gymnasts/GymnastList.tsx b/src/components/gymnasts/GymnastList.tsx index 39f56d0..27f4e48 100644 --- a/src/components/gymnasts/GymnastList.tsx +++ b/src/components/gymnasts/GymnastList.tsx @@ -3,8 +3,12 @@ import type { GymnastSummary } from "@/lib/gymnasts"; export default function GymnastList({ gymnasts, + onDeleted, + onFailed, }: { gymnasts: GymnastSummary[]; + onDeleted: (name: string) => void; + onFailed: (message: string) => void; }) { return (
{gymnasts.map((gymnast) => ( - + ))} )} diff --git a/src/components/gymnasts/GymnastRow.tsx b/src/components/gymnasts/GymnastRow.tsx index 2a955f8..b03b92f 100644 --- a/src/components/gymnasts/GymnastRow.tsx +++ b/src/components/gymnasts/GymnastRow.tsx @@ -1,7 +1,16 @@ +import DeleteGymnastButton from "@/components/gymnasts/DeleteGymnastButton"; import type { GymnastSummary } from "@/lib/gymnasts"; import { getGymnastInitials } from "@/lib/gymnasts"; -export default function GymnastRow({ gymnast }: { gymnast: GymnastSummary }) { +export default function GymnastRow({ + gymnast, + onDeleted, + onFailed, +}: { + gymnast: GymnastSummary; + onDeleted: (name: string) => void; + onFailed: (message: string) => void; +}) { return (
  • @@ -12,7 +21,12 @@ export default function GymnastRow({ gymnast }: { gymnast: GymnastSummary }) { {getGymnastInitials(gymnast.name)}

    {gymnast.name}

    +
  • ); -} \ No newline at end of file +} diff --git a/src/components/gymnasts/YourGymnastsPage.test.tsx b/src/components/gymnasts/YourGymnastsPage.test.tsx index 031196a..28a9a67 100644 --- a/src/components/gymnasts/YourGymnastsPage.test.tsx +++ b/src/components/gymnasts/YourGymnastsPage.test.tsx @@ -4,7 +4,9 @@ import { ApolloProvider } from '@apollo/client/react' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import { AuthProvider } from '@/components/auth/AuthProvider' import { createTestClient, deferred, @@ -29,11 +31,32 @@ const coachWith = (names: string[]) => }, }) -function renderPage(handler: Handler) { - const { client, calls } = createTestClient(handler) +/** + * The page inside the same providers as in the app. `Me` (who is logged in) is + * answered here and the gymnasts by `handler`, as the server does. + */ +function renderPage(handler: Handler, { loggedIn = true } = {}) { + const withMe: Handler = (operation) => + operation.operationName === 'Me' + ? ok({ + me: loggedIn + ? { + __typename: 'Coach', + id: 'coach-1', + name: 'Anna', + email: 'anna@example.com', + } + : null, + }) + : handler(operation) + const { client, calls } = createTestClient(withMe) render( - + + + + + , ) return { @@ -43,7 +66,9 @@ function renderPage(handler: Handler) { } const listedNames = () => - screen.getAllByRole('listitem').map((item) => item.textContent) + screen + .getAllByRole('listitem') + .map((item) => item.querySelector('h2')?.textContent) async function addGymnast(name: string) { fireEvent.click(await screen.findByRole('button', { name: 'Add gymnast' })) @@ -62,14 +87,14 @@ describe('YourGymnastsPage: loading the gymnasts', () => { expect(screen.getByText('Loading your gymnasts…')).toBeTruthy() answer.resolve(coachWith(['Emma Berg'])) - expect(await screen.findByText(/Emma Berg/)).toBeTruthy() + expect(await screen.findByText('Emma Berg')).toBeTruthy() expect(screen.queryByText('Loading your gymnasts…')).toBeNull() }) it('lists the gymnasts in the order the API gives them', async () => { renderPage(() => coachWith(['Zara Moe', 'Anna Lie', 'Mia Dahl'])) - await screen.findByText(/Zara Moe/) + await screen.findByText('Zara Moe') const items = listedNames() expect(items).toHaveLength(3) expect(items[0]).toContain('Zara Moe') @@ -99,12 +124,12 @@ describe('YourGymnastsPage: loading the gymnasts', () => { expect(screen.queryByRole('button', { name: 'Add gymnast' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Try again' })) - expect(await screen.findByText(/Emma Berg/)).toBeTruthy() + expect(await screen.findByText('Emma Berg')).toBeTruthy() expect(screen.queryByRole('alert')).toBeNull() }) it('does not show an empty list when nobody is logged in', async () => { - renderPage(() => ok({ me: null })) + renderPage(() => ok({ me: null }), { loggedIn: false }) expect( await screen.findByText('Log in to see and add your gymnasts.'), @@ -113,25 +138,12 @@ describe('YourGymnastsPage: loading the gymnasts', () => { expect(screen.queryByRole('button', { name: 'Add gymnast' })).toBeNull() }) - it('offers the test login only in a build made with VITE_DEV_LOGIN=true', async () => { - try { - vi.stubEnv('VITE_DEV_LOGIN', 'true') - renderPage(() => ok({ me: null })) - expect( - await screen.findByRole('button', { name: 'Log in as test coach' }), - ).toBeTruthy() - cleanup() - - for (const value of ['', 'false']) { - vi.stubEnv('VITE_DEV_LOGIN', value) - renderPage(() => ok({ me: null })) - await screen.findByText('Log in to see and add your gymnasts.') - expect(screen.queryByRole('button', { name: /test coach/ })).toBeNull() - cleanup() - } - } finally { - vi.unstubAllEnvs() - } + it('links to the login page when nobody is logged in, and has no login form of its own', async () => { + renderPage(() => ok({ me: null }), { loggedIn: false }) + + const link = await screen.findByRole('link', { name: 'Log in' }) + expect(link.getAttribute('href')).toBe('/login') + expect(screen.queryByRole('form', { name: 'Test login' })).toBeNull() }) }) @@ -157,7 +169,7 @@ describe('YourGymnastsPage: adding a gymnast', () => { it('sends the normalized name, closes the form and shows the gymnast in the API order', async () => { const page = renderPage(serverWith(['Emma Berg', 'Zara Moe'])) - await screen.findByText(/Emma Berg/) + await screen.findByText('Emma Berg') await addGymnast(' Anna Lie ') @@ -166,11 +178,7 @@ describe('YourGymnastsPage: adding a gymnast', () => { expect(page.created()).toHaveLength(1) expect(page.created()[0]?.variables).toEqual({ name: 'Anna Lie' }) const items = listedNames() - expect(items.map((text) => text?.slice(2))).toEqual([ - 'Anna Lie', - 'Emma Berg', - 'Zara Moe', - ]) + expect(items).toEqual(['Anna Lie', 'Emma Berg', 'Zara Moe']) expect(screen.getByText('Anna Lie added to your gymnasts.')).toBeTruthy() expect(screen.queryByLabelText('Gymnast name')).toBeNull() }) @@ -247,7 +255,7 @@ describe('YourGymnastsPage: adding a gymnast', () => { ? answer.promise : coachWith(['Emma Berg']), ) - await screen.findByText(/Emma Berg/) + await screen.findByText('Emma Berg') await addGymnast('Anna Lie') const saving = await screen.findByRole('button', { diff --git a/src/components/gymnasts/YourGymnastsPage.tsx b/src/components/gymnasts/YourGymnastsPage.tsx index 2e99f2f..a8e5e42 100644 --- a/src/components/gymnasts/YourGymnastsPage.tsx +++ b/src/components/gymnasts/YourGymnastsPage.tsx @@ -1,11 +1,11 @@ import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery } from "@apollo/client/react"; +import { Link } from "react-router-dom"; import { Plus, X } from "lucide-react"; +import { useAuth } from "@/components/auth/useAuth"; import { Button } from "@/components/ui/button"; -import DevLoginButton from "@/components/gymnasts/DevLoginButton"; import GymnastList from "@/components/gymnasts/GymnastList"; import { describeError } from "@/lib/apollo/errors"; -import { isDevLoginEnabled } from "@/lib/devLogin"; import { CREATE_GYMNAST_MUTATION, GYMNASTS_QUERY, @@ -35,9 +35,13 @@ export default function YourGymnastsPage() { // create a second gymnast. The state of the mutation changes one render too late. const isSubmitting = useRef(false); - // `me` is null when nobody is logged in or the session has ended. That is not - // the same as a coach with no gymnasts, so it must not look like an empty list. - const isLoggedOut = data?.me === null; + // The same answer as the menu reads. Nobody logged in, or a session that has + // ended, is not the same as a coach with no gymnasts, so it must not look like + // an empty list. (A visitor who is not logged in does not get here from the + // menu: ProtectedRoute sends them to the login page. This is for a session + // that ends while the page is open.) + const isLoggedOut = useAuth().status === "anonymous"; + const [deleteError, setDeleteError] = useState(""); const loadProblem = loadError ? describeError(loadError) : null; useEffect(() => { @@ -50,6 +54,14 @@ export default function YourGymnastsPage() { } }, [isFormOpen]); + // The button of the deleted gymnast is gone, so the focus goes to the button + // that adds one, and the visitor is told what happened. + function onDeleted(deletedName: string) { + setDeleteError(""); + setAnnouncement(`${deletedName} deleted.`); + addButton.current?.focus(); + } + function closeForm() { setIsFormOpen(false); setName(""); @@ -203,12 +215,29 @@ export default function YourGymnastsPage() {

    Log in to see and add your gymnasts.

    - {/* Until the real login is merged. Only in a build made with VITE_DEV_LOGIN=true. */} - {isDevLoginEnabled() && } + )} - {data?.me && } + {deleteError && ( +

    + {deleteError} +

    + )} + + {data?.me && ( + + )} ); diff --git a/src/components/navbar/Navbar.test.tsx b/src/components/navbar/Navbar.test.tsx index 157b893..977c92a 100644 --- a/src/components/navbar/Navbar.test.tsx +++ b/src/components/navbar/Navbar.test.tsx @@ -1,9 +1,31 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from '@testing-library/react'; -import { BrowserRouter } from 'react-router-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { BrowserRouter, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthContext, type AuthState } from '@/components/auth/useAuth'; +import { logout } from '@/lib/apollo/client'; import { Navbar } from './Navbar'; +// The real functions would call the server. The Navbar only has to call logout. +vi.mock('@/lib/apollo/client', () => ({ + logout: vi.fn(async () => undefined), +})); + +const states: Record = { + loading: { status: 'loading', coach: null }, + anonymous: { status: 'anonymous', coach: null }, + authenticated: { + status: 'authenticated', + coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' }, + }, +}; + +/** Shows the address, so a test can see where the navigation went. */ +function Address() { + const location = useLocation(); + return {location.pathname}; +} + beforeEach(() => { window.history.replaceState({}, '', '/'); }); @@ -13,20 +35,24 @@ afterEach(() => { }); const renderNavbar = ({ - isLoggedIn = false, + auth = 'anonymous', isDarkMode = false, - setIsLoggedIn = vi.fn(), onToggleDarkMode = vi.fn(), +}: { + auth?: keyof typeof states; + isDarkMode?: boolean; + onToggleDarkMode?: () => void; } = {}) => render( - - - , + + + +
    + + , ); describe('Navbar', () => { @@ -51,7 +77,7 @@ describe('Navbar', () => { }); it('shows the Gymnasts link only when logged in', () => { - renderNavbar({ isLoggedIn: true }); + renderNavbar({ auth: 'authenticated' }); expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy(); }); @@ -153,12 +179,37 @@ describe('Navbar', () => { expect(themeToggle.querySelector('span')?.style.transform).toBe('translateX(0)'); }); - it('requests the opposite login state when the button is clicked', () => { - const setIsLoggedIn = vi.fn(); - renderNavbar({ setIsLoggedIn }); + it('has a Log in link to the login page when nobody is logged in', () => { + renderNavbar({ auth: 'anonymous' }); + + expect(screen.getByRole('link', { name: 'Log in' }).getAttribute('href')).toBe( + '/login', + ); + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull(); + }); + + it('has a Log out button, and no Log in, when a coach is logged in', () => { + renderNavbar({ auth: 'authenticated' }); + + expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy(); + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); + }); + + it('says neither Log in nor Log out until the server has answered', () => { + renderNavbar({ auth: 'loading' }); + + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull(); + expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull(); + }); + + it('logs out and leaves the page that is for coaches', async () => { + window.history.replaceState({}, '', '/gymnasts'); + renderNavbar({ auth: 'authenticated' }); - fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + fireEvent.click(screen.getByRole('button', { name: 'Log out' })); - expect(setIsLoggedIn).toHaveBeenCalledWith(true); + await waitFor(() => expect(logout).toHaveBeenCalledOnce()); + expect(screen.getByTestId('address').textContent).toBe('/elements'); }); }); diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index a413049..ffe806e 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -1,23 +1,35 @@ import React, { useState } from 'react'; import { Menu, X } from 'lucide-react'; -import { NavLink } from 'react-router-dom'; +import { Link, NavLink, useNavigate } from 'react-router-dom'; +import { useAuth } from '@/components/auth/useAuth'; +import { logout } from '@/lib/apollo/client'; interface NavbarProps { - isLoggedIn: boolean; - setIsLoggedIn: (value: boolean) => void; isDarkMode: boolean; onToggleDarkMode: () => void; } export const Navbar: React.FC = ({ - isLoggedIn, - setIsLoggedIn, isDarkMode, onToggleDarkMode, }) => { const [menuOpen, setMenuOpen] = useState(false); + // The same answer as the pages read: who is logged in, as the server says it. + const { status } = useAuth(); + const isLoggedIn = status === 'authenticated'; + const navigate = useNavigate(); + + async function logOut() { + setMenuOpen(false); + // Leave a page that is for coaches first, so the visitor is not sent to the + // login page by the page they are leaving. + void navigate('/elements'); + await logout(); + } const focusRing = 'focus-visible:outline-2 focus-visible:outline-(--text-color) focus-visible:outline-offset-4'; + const authButton = + 'inline-flex min-h-6 min-w-6 shrink items-center whitespace-nowrap rounded-corner border border-border-color bg-transparent px-[clamp(11px,12px,13px)] py-xs font-text text-[clamp(var(--font-size-row-title),1.2vw,1rem)] text-text-color'; const navLinks = [ { path: '/elements', label: 'Search' }, { path: '/calculator', label: 'Calculator' }, @@ -81,13 +93,21 @@ export const Navbar: React.FC = ({ - + {/* While the server has not answered, neither "Log in" nor "Log out" is true. */} + {status !== 'loading' && + (isLoggedIn ? ( + + ) : ( + + Log in + + ))} diff --git a/src/components/gymnasts/DeleteGymnastButton.test.tsx b/src/components/gymnasts/DeleteGymnastButton.test.tsx index 0d6e086..7b93bc9 100644 --- a/src/components/gymnasts/DeleteGymnastButton.test.tsx +++ b/src/components/gymnasts/DeleteGymnastButton.test.tsx @@ -9,6 +9,7 @@ import { render, screen, waitFor, + within, } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { createTestClient, failure, ok, type Handler } from '@/tests/apollo' @@ -39,7 +40,7 @@ function renderButton(handler: Handler) { } const trigger = () => screen.getByRole('button', { name: 'Delete Emma Berg' }) -const confirmButton = () => screen.getByRole('button', { name: 'Delete' }) +const confirmButton = () => screen.getByRole('button', { name: 'Yes, delete' }) describe('DeleteGymnastButton', () => { it('is named after the gymnast, so a list of them can be told apart', () => { @@ -55,18 +56,17 @@ describe('DeleteGymnastButton', () => { fireEvent.click(trigger()) const dialog = await screen.findByRole('alertdialog') - expect(dialog.textContent).toContain('Delete Emma Berg?') - expect(dialog.textContent).toContain('routines') - expect(dialog.textContent).toContain('cannot be undone') + expect(dialog.textContent).toContain('Delete gymnast') + expect(dialog.textContent).toContain('You’ll permanently lose Emma Berg’s:') expect(page.deletes()).toHaveLength(0) }) - it('closes with Cancel and deletes nothing', async () => { + it('closes with “No, keep it” and deletes nothing', async () => { const page = renderButton(() => ok({ deleteGymnast: gymnast.id })) fireEvent.click(trigger()) await screen.findByRole('alertdialog') - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })) await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) expect(page.deletes()).toHaveLength(0) @@ -104,3 +104,131 @@ describe('DeleteGymnastButton', () => { expect(page.onDeleted).not.toHaveBeenCalled() }) }) + +describe('DeleteGymnastButton: how it looks', () => { + it('is 48 by 48 CSS pixels, so it can be pressed on a phone (WCAG 2.2 asks for 24 at AA and 44 at AAA)', () => { + renderButton(() => ok({ deleteGymnast: gymnast.id })) + + const classes = trigger().className.split(/\s+/) + expect(classes).toContain('size-12') // 3rem = 48px + expect(classes).not.toContain('size-8') // the smaller size of the button, merged away + }) + + it('has a red icon that is bigger than the text beside it, and the red is a theme value (so it works in dark mode)', () => { + renderButton(() => ok({ deleteGymnast: gymnast.id })) + + expect(trigger().className).toContain('text-(--danger)') + expect(trigger().querySelector('svg')?.getAttribute('class')).toContain( + 'size-7', + ) // 28px + }) +}) + +describe('DeleteGymnastButton: how the dialog looks', () => { + async function openDialog() { + renderButton(() => ok({ deleteGymnast: gymnast.id })) + fireEvent.click(trigger()) + return screen.findByRole('alertdialog') + } + + it('has a warning icon, the title “Delete gymnast”, and then the text', async () => { + const dialog = await openDialog() + + expect(dialog.querySelector('svg')).toBeTruthy() + expect(dialog.querySelector('svg')?.getAttribute('aria-hidden')).toBe( + 'true', + ) + expect( + screen.getByRole('alertdialog', { name: 'Delete gymnast' }), + ).toBeTruthy() + const order = [...dialog.querySelectorAll('svg, [id]')].map( + (el) => el.tagName, + ) + expect(order[0]).toBe('svg') // the icon comes first + }) + + it('has the same rounding as the other cards (rounded-lg), buttons as pills like the rest of the site', async () => { + const dialog = await openDialog() + + expect(dialog.className).toContain('rounded-lg') + for (const name of ['No, keep it', 'Yes, delete']) { + expect(screen.getByRole('button', { name }).className).toContain( + 'rounded-full', + ) + } + }) + + it('has “No, keep it” first, so the first thing the keyboard reaches does not delete', async () => { + const dialog = await openDialog() + + const buttons = [...dialog.querySelectorAll('button')].map( + (b) => b.textContent, + ) + expect(buttons).toEqual(['No, keep it', 'Yes, delete']) + }) + + it('has “No, keep it” with an edge in a theme color, so it can be seen on the card in both themes', async () => { + await openDialog() + const keep = screen.getByRole('button', { name: 'No, keep it' }) + + expect(keep.className).toContain('border-2') + expect(keep.className).toContain('border-(--secondary-button-border)') + expect(keep.className).toContain('bg-(--secondary-button-bg)') + expect(keep.className).toContain('text-(--secondary-button-text)') + }) + + it('keeps the red of the site on “Yes, delete” and the shadow behind both buttons', async () => { + await openDialog() + const yes = screen.getByRole('button', { name: 'Yes, delete' }) + const keep = screen.getByRole('button', { name: 'No, keep it' }) + + expect(yes.className).toContain('bg-(--danger)') + expect(yes.className).toContain('text-(--danger-contrast)') + expect(yes.className).toContain('shadow-(--box-shadow)') + expect(keep.className).toContain('shadow-(--box-shadow)') + }) + + it('dims the page behind the dialog', async () => { + await openDialog() + + expect(document.querySelector('[class*="bg-black/40"]')).toBeTruthy() + }) +}) + +describe('DeleteGymnastButton: what is lost', () => { + it('says whose they are, and lists everything that is deleted with the gymnast', async () => { + renderButton(() => ok({ deleteGymnast: gymnast.id })) + fireEvent.click(trigger()) + const dialog = await screen.findByRole('alertdialog') + + const items = within(dialog).getAllByRole('listitem') + expect(items.map((item) => item.textContent)).toEqual([ + 'Profile', + 'Routines', + 'List of elements', + ]) + }) + + it('puts the list in a div, because a list cannot be inside a paragraph', async () => { + renderButton(() => ok({ deleteGymnast: gymnast.id })) + fireEvent.click(trigger()) + const dialog = await screen.findByRole('alertdialog') + + const list = dialog.querySelector('ul')! + expect(list.closest('p')).toBeNull() + // The dialog is still described by that text, for a screen reader. + const describedBy = dialog.getAttribute('aria-describedby')! + expect(document.getElementById(describedBy)?.contains(list)).toBe(true) + }) + + it('keeps the list left-aligned in the middle of the card, so the bullets line up', async () => { + renderButton(() => ok({ deleteGymnast: gymnast.id })) + fireEvent.click(trigger()) + const dialog = await screen.findByRole('alertdialog') + + const classes = dialog.querySelector('ul')!.className + expect(classes).toContain('text-left') + expect(classes).toContain('list-disc') + expect(classes).toContain('mx-auto') + }) +}) diff --git a/src/components/gymnasts/DeleteGymnastButton.tsx b/src/components/gymnasts/DeleteGymnastButton.tsx index b655012..2bb4894 100644 --- a/src/components/gymnasts/DeleteGymnastButton.tsx +++ b/src/components/gymnasts/DeleteGymnastButton.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useMutation } from '@apollo/client/react' import { AlertDialog } from '@base-ui/react/alert-dialog' -import { Trash2 } from 'lucide-react' +import { Trash2, TriangleAlert } from 'lucide-react' import { Button } from '@/components/ui/button' import { describeError } from '@/lib/apollo/errors' import { @@ -56,36 +56,66 @@ export default function DeleteGymnastButton({ type="button" variant="ghost" size="icon" - className="ml-auto shrink-0" + // 48 by 48 CSS pixels. WCAG 2.2 asks for at least 24 by 24 (2.5.8, AA) + // and 44 by 44 for the enhanced level (2.5.5, AAA); Material Design uses 48. + // The icon is 28 pixels, red on the card, and the red turns into a + // tint of itself on hover. + className="ml-auto size-12 shrink-0 rounded-full text-(--danger) hover:bg-[color-mix(in_srgb,var(--danger)_14%,transparent)] hover:text-(--danger) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--danger)" /> } > -