Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -271,6 +272,8 @@ function App() {
element={<GameDetail favorites={favorites} onToggleFavorite={toggleFavorite} />}
/>

<Route path="/login" element={<Login />} />

{/* Ukjente adresser sendes til forsiden */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/apollo.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
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
* to ganger gir bare én forespørsel. URL-en er lik for dev og deploy.
* 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.
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/auth/token.ts
Original file line number Diff line number Diff line change
@@ -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);
13 changes: 13 additions & 0 deletions frontend/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,13 +29,24 @@ export function Header({
showFavorites,
onToggleFavorites,
}: HeaderProps) {
const { user, logOut } = useAuth();
return (
<header className="header">
<Logo onHome={onHome} />
<div className="header__search">
<GameSearch key={searchResetKey} onSearch={onSearch} onSelectGame={onSelectGame} />
</div>
<nav className="header__nav" aria-label="Meny">
{user ? (
<button type="button" className="text-button" onClick={logOut}>
logg ut ({user.username})
</button>
) : (
<Link to="/login" className="text-button">
logg inn
</Link>
)}

<button
type="button"
className="text-button"
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/graphql/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { gql, type TypedDocumentNode } from '@apollo/client';

/** En innlogget bruker slik backend returnerer den */
export interface User {
id: string;
username: string;
}

/** Det login og register tar inn */
interface AuthVars {
username: string;
password: string;
}

/** Henter brukeren som eier tokenet. Gir null hvis ingen er innlogget. */
export const ME: TypedDocumentNode<{ me: User | null }> = 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
}
}
}
`;
40 changes: 40 additions & 0 deletions frontend/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
4 changes: 4 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
111 changes: 111 additions & 0 deletions frontend/src/pages/Login/Login.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading