diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c56c5da..04c1b11 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import { FilterSidebar } from './components/FilterSidebar/FilterSidebar'; import { FILTER_OPTIONS } from './graphql/filterOptions'; import { useDebounce } from './hooks/useDebounce'; import { defaultFilters, toFilterInput, type GameFilters } from './types/filters'; +import { Login } from './pages/Login/Login'; //dette er antall spill per side const PAGE_SIZE = 20; @@ -271,6 +272,8 @@ function App() { element={} /> + } /> + {/* Ukjente adresser sendes til forsiden */} } /> diff --git a/frontend/src/apollo.ts b/frontend/src/apollo.ts index 418c1c5..e85a9dc 100644 --- a/frontend/src/apollo.ts +++ b/frontend/src/apollo.ts @@ -1,4 +1,6 @@ -import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; +import { ApolloClient, ApolloLink, HttpLink, InMemoryCache } from '@apollo/client'; +import { SetContextLink } from '@apollo/client/link/context'; +import { getToken } from './auth/token'; /** * Felles GraphQL-klient for hele appen. Svar caches i minnet, så samme søk @@ -6,8 +8,23 @@ import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; * Se vite.config.ts. */ +// Sender selve forespørselen til backend +const httpLink = new HttpLink({ uri: `${import.meta.env.BASE_URL}graphql` }); + +// Legger tokenet i headeren på hver forespørsel, hvis brukeren er innlogget +const authLink = new SetContextLink((prevContext) => { + const token = getToken(); + return { + headers: { + ...prevContext.headers, + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }; +}); + export const client = new ApolloClient({ - link: new HttpLink({ uri: `${import.meta.env.BASE_URL}graphql` }), + // Rekkefølgen betyr noe: først legges tokenet på, så sendes forespørselen + link: ApolloLink.from([authLink, httpLink]), cache: new InMemoryCache({ typePolicies: { // Sjangre og plattformer er begge "Facet" og kan ha samme id. diff --git a/frontend/src/auth/token.ts b/frontend/src/auth/token.ts new file mode 100644 index 0000000..2a3334d --- /dev/null +++ b/frontend/src/auth/token.ts @@ -0,0 +1,8 @@ +/** + * Henter nøkkel, og legger inn mulighet for å lagre og slette nøkkelen + */ + +const KEY = 'token'; +export const getToken = () => localStorage.getItem(KEY); +export const saveToken = (token: string) => localStorage.setItem(KEY, token); +export const removeToken = () => localStorage.removeItem(KEY); diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index c9dd7f3..5a7405e 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -3,6 +3,8 @@ import { GameSearch } from '../GameSearch/GameSearch'; import type { Suggestion } from '../../graphql/searchSuggestions'; import { ThemeToggle } from '../ThemeToggle/ThemeToggle'; import './Header.css'; +import { useAuth } from '../../hooks/useAuth'; +import { Link } from 'react-router'; interface HeaderProps { onSearch: (term: string) => void; @@ -27,6 +29,7 @@ export function Header({ showFavorites, onToggleFavorites, }: HeaderProps) { + const { user, logOut } = useAuth(); return ( @@ -34,6 +37,16 @@ export function Header({ + {user ? ( + + logg ut ({user.username}) + + ) : ( + + logg inn + + )} + = gql` + query Me { + me { + id + username + } + } +`; + +/** Logger inn og får tilbake et token og brukeren */ +export const LOGIN: TypedDocumentNode<{ login: { token: string; user: User } }, AuthVars> = gql` + mutation Login($username: String!, $password: String!) { + login(username: $username, password: $password) { + token + user { + id + username + } + } + } +`; + +/** Registrerer og får tilbake et token og brukeren */ +export const REGISTER: TypedDocumentNode<{ register: { token: string; user: User } }, AuthVars> = + gql` + mutation Register($username: String!, $password: String!) { + register(username: $username, password: $password) { + token + user { + id + username + } + } + } + `; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..e178c94 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,40 @@ +import { useApolloClient, useMutation, useQuery } from '@apollo/client/react'; +import { LOGIN, ME, REGISTER } from '../graphql/auth'; +import { removeToken, saveToken } from '../auth/token'; + +/** + * Innlogget bruker og funksjoner for å logge inn, registrere og logge ut. + * ME-spørringen sier hvem som er innlogget. Når tokenet endres, nullstilles + * Apollo-cachen, så alle spørringer hentes på nytt med riktig bruker. + */ +export function useAuth() { + const client = useApolloClient(); + const { data, loading } = useQuery(ME); + const [loginMutation] = useMutation(LOGIN); + const [registerMutation] = useMutation(REGISTER); + + // Kaster en feil ved feil brukernavn/passord. Den fanges i skjemaet. + const logIn = async (username: string, password: string) => { + const { data } = await loginMutation({ variables: { username, password } }); + if (data) { + saveToken(data.login.token); + await client.resetStore(); + } + }; + + /* Lager ny bruker og logger inn med en gang. Kaster feil hvis brukernavnet er tatt. */ + const register = async (username: string, password: string) => { + const { data } = await registerMutation({ variables: { username, password } }); + if (data) { + saveToken(data.register.token); + await client.resetStore(); + } + }; + + const logOut = async () => { + removeToken(); + await client.resetStore(); + }; + + return { user: data?.me ?? null, loading, logIn, register, logOut }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d4ab213..2a4ff35 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -8,6 +8,8 @@ --accent: #2c6370; /* fra Damp-logoen */ --accent-bg: rgba(44, 99, 112, 0.1); --accent-border: rgba(44, 99, 112, 0.5); + --error: #a33b2b; /* dempet rød som passer det varme papiret */ + --error-bg: rgba(163, 59, 43, 0.08); --social-bg: rgba(239, 234, 224, 0.5); --shadow: rgba(31, 27, 22, 0.1) 0 10px 15px -3px, rgba(31, 27, 22, 0.05) 0 4px 6px -2px; @@ -47,6 +49,8 @@ --accent: #6fa3ae; /* fra Damp-logoen (mørk versjon) */ --accent-bg: rgba(111, 163, 174, 0.15); --accent-border: rgba(111, 163, 174, 0.5); + --error: #e3907f; /* lysere rød, så den er lesbar på mørk bakgrunn */ + --error-bg: rgba(227, 144, 127, 0.08); --social-bg: rgba(35, 38, 37, 0.5); --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; } diff --git a/frontend/src/pages/Login/Login.css b/frontend/src/pages/Login/Login.css new file mode 100644 index 0000000..2b00923 --- /dev/null +++ b/frontend/src/pages/Login/Login.css @@ -0,0 +1,111 @@ +/* Innloggingssiden: smalt skjema i samme papirstil som søkefeltet */ +.login { + max-width: 360px; +} + +.login__form { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 24px; +} + +/* Luft over hvert felt, unntatt det første */ +.login__form .label:not(:first-child) { + margin-top: 16px; +} + +/* Felt med tynn ramme i samme farge som linjene ellers i appen */ +.login__input { + padding: 8px 12px; + font: inherit; + color: var(--text-h); + background: transparent; + border: 2px solid var(--border); + border-radius: 0; +} + +/* Fokus: rammen blir mørk, så det er tydelig hvilket felt du skriver i */ +.login__input:focus-visible { + outline: none; + border-color: var(--text-h); +} + +/* Feilmelding: boks i samme form som feltene, med feilfarge og en liten "feil"-merkelapp */ +.login__error { + display: flex; + align-items: baseline; + gap: 10px; + margin-top: 16px; + padding: 10px 12px; + font-size: 15px; + color: var(--error); + background: var(--error-bg); + border: 2px solid var(--error); +} + +/* Merkelappen i samme monospace-versaler som overskriftene ellers i appen */ +.login__error::before { + content: 'feil'; + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* Hovedknappen: fylt, så den skiller seg fra tekstknappene */ +.login__submit { + align-self: flex-start; + margin-top: 24px; + padding: 8px 20px; + font: inherit; + font-family: var(--mono); + color: var(--bg); + background: var(--text-h); + border: 0; + cursor: pointer; +} + +.login__submit:hover { + background: var(--accent); +} + +.login__submit:focus-visible { + outline: 2px solid var(--text-h); + outline-offset: 4px; +} + +.login__switch { + font-family: var(--mono); + font-size: 14px; +} + +/* Passordfeltet og vis-knappen deler én ramme */ +.login__password { + display: flex; + border: 2px solid var(--border); +} + +/* Rammen blir mørk når feltet eller knappen har fokus */ +.login__password:focus-within { + border-color: var(--text-h); +} + +/* Feltet inni har ingen egen ramme, rammen ligger på boksen rundt */ +.login__password .login__input { + flex: 1; + min-width: 0; + border: 0; +} + +.login__show { + padding: 0 12px; + font-family: var(--mono); + font-size: 14px; +} + +/* Overskriften i samme versalstil som "Søkeresultater", litt større enn feltnavnene */ +.login__title { + margin: 8px 0 24px; + font-size: 15px; +} \ No newline at end of file diff --git a/frontend/src/pages/Login/Login.tsx b/frontend/src/pages/Login/Login.tsx new file mode 100644 index 0000000..f330f92 --- /dev/null +++ b/frontend/src/pages/Login/Login.tsx @@ -0,0 +1,98 @@ +import { useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router'; +import { useAuth } from '../../hooks/useAuth'; +import './Login.css'; + +/** Innloggingssiden på /login. Sender brukeren til forsiden når innloggingen lykkes. */ +export function Login() { + const { logIn, register } = useAuth(); + const navigate = useNavigate(); + + // Det brukeren skriver i feltene + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + // Feilmeldingen fra backend, f.eks. "Ugyldig brukernavn eller passord" + const [error, setError] = useState(null); + // Om skjemaet logger inn eller lager ny bruker + const [mode, setMode] = useState<'login' | 'register'>('login'); + // Om passordet vises som tekst eller prikker + const [showPassword, setShowPassword] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); // hindrer at nettleseren laster siden på nytt + setError(null); + try { + if (mode === 'login') await logIn(username, password); + else await register(username, password); + navigate('/'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Noe gikk galt'); + } + }; + + const isLogin = mode === 'login'; + + return ( + + {isLogin ? 'Logg inn' : 'Registrer deg'} + + + Brukernavn + + setUsername(e.target.value)} + required + className="login__input" + /> + + + Passord + + + setPassword(e.target.value)} + required + className="login__input" + /> + setShowPassword((v) => !v)} + > + {showPassword ? 'skjul' : 'vis'} + + + + {error && ( + + {error} + + )} + + + {isLogin ? 'Logg inn' : 'Lag bruker'} + + + + { + setMode(isLogin ? 'register' : 'login'); + setError(null); // gammel feilmelding passer ikke lenger + }} + > + {isLogin ? 'Har du ikke bruker? Registrer deg' : 'Har du bruker? Logg inn'} + + + ); +}
+ {error} +