diff --git a/client/src/graphql/auth.ts b/client/src/graphql/auth.ts new file mode 100644 index 0000000..3f04d88 --- /dev/null +++ b/client/src/graphql/auth.ts @@ -0,0 +1,63 @@ +import { gql, type TypedDocumentNode } from '@apollo/client'; + +export type BackendUser = { + id: string; + username: string; + name: string; + languagePref: string; + unitSystem: string; + isGuest: boolean; +}; + +const USER_FIELDS = gql` + fragment UserFields on User { + id + username + name + languagePref + unitSystem + isGuest + } +`; + +export const ME: TypedDocumentNode<{ me: BackendUser | null }> = gql` + query Me { + me { + ...UserFields + } + } + ${USER_FIELDS} +`; + +export const LOGIN = gql` + mutation Login($username: String!, $password: String!) { + login(username: $username, password: $password) { + ...UserFields + } + } + ${USER_FIELDS} +`; + +export const REGISTER = gql` + mutation Register($username: String!, $password: String!) { + register(username: $username, password: $password) { + ...UserFields + } + } + ${USER_FIELDS} +`; + +export const LOGOUT = gql` + mutation Logout { + logout + } +`; + +export const CONTINUE_AS_GUEST = gql` + mutation ContinueAsGuest { + continueAsGuest { + ...UserFields + } + } + ${USER_FIELDS} +`; diff --git a/client/src/lib/AuthProvider.tsx b/client/src/lib/AuthProvider.tsx index a9db75f..bf4d844 100644 --- a/client/src/lib/AuthProvider.tsx +++ b/client/src/lib/AuthProvider.tsx @@ -1,76 +1,80 @@ -import { useState, type ReactNode } from 'react'; +import { type ReactNode } from 'react'; +import { CombinedGraphQLErrors } from '@apollo/client'; +import { useMutation, useQuery } from '@apollo/client/react'; import type { User } from '../types/user'; import { AuthContext } from './authContext'; +import { + CONTINUE_AS_GUEST, + LOGIN, + LOGOUT, + ME, + REGISTER, +} from '../graphql/auth'; -// midlertidige brukere til jeg får koblet meg til databasen -const MOCK_GUEST_USER: User = { - id: 'guest', - name: 'Gjest', - authProvider: 'GUEST', -}; -const MOCK_TEST_USER: User = { - id: 'test', - name: 'Testbruker', - authProvider: 'TEST', -}; - -// en konto er brukernavn, passord og brukeren som logges inn -type Account = { username: string; password: string; user: User }; - -// midlertidig liste over kontoer til backend er koblet på -const MOCK_ACCOUNTS: Account[] = [ - { username: 'test', password: 'test123', user: MOCK_TEST_USER }, -]; +// gjør om feil fra serveren til en melding som kan vises +function getErrorMessage(error: unknown) { + if (CombinedGraphQLErrors.is(error)) { + return error.errors[0]?.message ?? 'Noe gikk galt.'; + } + return 'Fikk ikke kontakt med serveren.'; +} type AuthProviderProps = { children: ReactNode }; export function AuthProvider({ children }: AuthProviderProps) { - const [user, setUser] = useState(null); - // kontoene som finnes, nye legges til når noen lager bruker - const [accounts, setAccounts] = useState(MOCK_ACCOUNTS); + // spør serveren hvem som er logget inn + const { data, loading, refetch } = useQuery(ME); + const [loginMutation] = useMutation(LOGIN); + const [registerMutation] = useMutation(REGISTER); + const [logoutMutation] = useMutation(LOGOUT); + const [guestMutation] = useMutation(CONTINUE_AS_GUEST); + + // innlogget bruker enten gjest eller vanlig bruker + const user: User | null = data?.me + ? { ...data.me, authProvider: data.me.isGuest ? 'GUEST' : 'PASSWORD' } + : null; - const continueAsGuest = () => { - setUser(MOCK_GUEST_USER); + // lager eller gjenbruker en gjestebruker + const continueAsGuest = async () => { + try { + await guestMutation(); + await refetch(); + return null; + } catch (error) { + return getErrorMessage(error); + } }; // logger inn hvis brukernavn og passord stemmer - const login = (username: string, password: string) => { - const account = accounts.find( - (account) => - account.username === username && account.password === password, - ); - - if (account) { - setUser(account.user); - return true; + const login = async (username: string, password: string) => { + try { + await loginMutation({ variables: { username, password } }); + await refetch(); + return null; + } catch (error) { + return getErrorMessage(error); } - return false; }; - // lager ny bruker og logger inn. false hvis brukernavnet er tatt - const register = (username: string, password: string) => { - if (accounts.some((account) => account.username === username)) { - return false; + // lager ny bruker og logger inn, feilmelding hvis brukernavnet er tatt + const register = async (username: string, password: string) => { + try { + await registerMutation({ variables: { username, password } }); + await refetch(); + return null; + } catch (error) { + return getErrorMessage(error); } - - const newUser: User = { - id: username, - name: username, - authProvider: 'PASSWORD', - }; - - setAccounts([...accounts, { username, password, user: newUser }]); - setUser(newUser); - return true; }; - const logout = () => { - setUser(null); + const logout = async () => { + await logoutMutation(); + await refetch(); }; return ( {children} diff --git a/client/src/lib/authContext.ts b/client/src/lib/authContext.ts index 8e11abc..016afef 100644 --- a/client/src/lib/authContext.ts +++ b/client/src/lib/authContext.ts @@ -5,10 +5,10 @@ import type { User } from '../types/user'; export type AuthContextValue = { user: User | null; loading: boolean; - continueAsGuest: () => void; - login: (username: string, password: string) => boolean; - register: (username: string, password: string) => boolean; - logout: () => void; + continueAsGuest: () => Promise; + login: (username: string, password: string) => Promise; + register: (username: string, password: string) => Promise; + logout: () => Promise; }; // felles boks for innlogget bruker export const AuthContext = createContext(null); diff --git a/client/src/routes/LoginPage.tsx b/client/src/routes/LoginPage.tsx index 749f529..ada21ee 100644 --- a/client/src/routes/LoginPage.tsx +++ b/client/src/routes/LoginPage.tsx @@ -18,19 +18,21 @@ export default function LoginPage() { } // logger inn som gjest og går til forside - function handleGuest() { - continueAsGuest(); - navigate('/'); + async function handleGuest() { + const errorMessage = await continueAsGuest(); + if (!errorMessage) { + navigate('/'); + } } - // for å måle bredden rundt google-knappen + // for å måle bredden rundt google knappen const googleBoxRef = useRef(null); const [googleButtonWidth, setGoogleButtonWidth] = useState(400); // måler hver gang den endrer størrelse useEffect(() => { const box = googleBoxRef.current; - // stopper hvis Google-knappen er skjult + // stopper hvis google knappen er skjult if (!box) return; const observer = new ResizeObserver(() => { diff --git a/client/src/routes/ProfilePage.tsx b/client/src/routes/ProfilePage.tsx index 611654d..98d8c9a 100644 --- a/client/src/routes/ProfilePage.tsx +++ b/client/src/routes/ProfilePage.tsx @@ -46,9 +46,9 @@ export default function ProfilePage() { const desktopTab: RecipeTab = activeTab === 'preferences' ? 'favorites' : activeTab; - function handleLogout() { - logout(); - navigate('/'); + async function handleLogout() { + await logout(); + navigate('/login'); } return ( diff --git a/client/src/routes/RegisterPage.tsx b/client/src/routes/RegisterPage.tsx index 5e6366f..7d00bd4 100644 --- a/client/src/routes/RegisterPage.tsx +++ b/client/src/routes/RegisterPage.tsx @@ -21,7 +21,7 @@ export default function RegisterPage() { const navigate = useNavigate(); const { register } = useCurrentUser(); - function handleSubmit(event: SubmitEvent) { + async function handleSubmit(event: SubmitEvent) { // hindrer at siden lastes på nytt event.preventDefault(); @@ -43,11 +43,12 @@ export default function RegisterPage() { return; } - // lager brukeren, false betyr at brukernavnet er tatt - if (register(username.trim(), password)) { - navigate('/'); + // lager brukeren og feilmelding hvis noe gikk galt + const errorMessage = await register(username.trim(), password); + if (errorMessage) { + setError(errorMessage); } else { - setError('Brukernavnet er allerede tatt.'); + navigate('/'); } } diff --git a/client/src/routes/TestUserLoginPage.tsx b/client/src/routes/TestUserLoginPage.tsx index aa70d92..8bf2729 100644 --- a/client/src/routes/TestUserLoginPage.tsx +++ b/client/src/routes/TestUserLoginPage.tsx @@ -18,7 +18,7 @@ export default function TestUserLoginPage() { // feilmelding hvis noe er feil const [error, setError] = useState(''); - function handleSubmit(event: SubmitEvent) { + async function handleSubmit(event: SubmitEvent) { // hindrer at siden lastes på nytt event.preventDefault(); @@ -29,10 +29,11 @@ export default function TestUserLoginPage() { } // sjekker brukernavn og passord - if (login(username, password)) { - navigate('/'); + const errorMessage = await login(username, password); + if (errorMessage) { + setError(errorMessage); } else { - setError('Feil brukernavn eller passord.'); + navigate('/'); } } diff --git a/client/src/types/user.ts b/client/src/types/user.ts index 1f6cbee..b45fd61 100644 --- a/client/src/types/user.ts +++ b/client/src/types/user.ts @@ -4,4 +4,7 @@ export type User = { id: string; name: string; authProvider: AuthProvider; + username?: string; + languagePref?: string; + unitSystem?: string; }; diff --git a/server/.env.example b/server/.env.example index 6a0f1a1..b34fa0b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,2 +1,3 @@ DATABASE_URL= -PORT= \ No newline at end of file +PORT= +JWT_SECRET= \ No newline at end of file diff --git a/server/package.json b/server/package.json index affc4e8..6569543 100644 --- a/server/package.json +++ b/server/package.json @@ -37,6 +37,7 @@ "@as-integrations/express5": "^1.1.2", "@prisma/client": "^6.19.3", "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.6", "express": "^5.2.1", "graphql": "^16.14.2", diff --git a/server/prisma/migrations/20261014155323_add_is_guest/migration.sql b/server/prisma/migrations/20261014155323_add_is_guest/migration.sql new file mode 100644 index 0000000..cf267c1 --- /dev/null +++ b/server/prisma/migrations/20261014155323_add_is_guest/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "isGuest" BOOLEAN NOT NULL DEFAULT false; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7fb82e6..1afe81f 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -12,7 +12,8 @@ datasource db { model User { id String @id @default(uuid()) username String @unique - passwordHash String? + passwordHash String? + isGuest Boolean @default(false) name String email String? @unique languagePref String @default("nb") diff --git a/server/src/auth/session.ts b/server/src/auth/session.ts new file mode 100644 index 0000000..5fc8925 --- /dev/null +++ b/server/src/auth/session.ts @@ -0,0 +1,59 @@ +import jwt from 'jsonwebtoken'; +import type { Request, Response } from 'express'; + +// navnet på cookien som holder innloggingen +const COOKIE_NAME = 'token'; +// hvor lenge man er logget inn i millisekunder +const SEVEN_DAYS_MS = 604800000; +// gjester er logget inn så lenge nettleseren tillater +const FOUR_HUNDRED_DAYS_MS = 400 * 24 * 60 * 60 * 1000; + +export type Context = { + req: Request; + res: Response; + userId: string | null; +}; + +// det som står i billetten +type Session = { userId: string; isGuest: boolean }; + +// henter nøkkelen fra .env og stopper hvis den mangler +function getSecret() { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error('JWT_SECRET mangler i server/.env'); + } + return secret; +} + +// lager en signert billett med bruker id og legger den i en cookie +export function setLoginCookie(res: Response, userId: string, isGuest = false) { + const maxAge = isGuest ? FOUR_HUNDRED_DAYS_MS : SEVEN_DAYS_MS; + const token = jwt.sign({ userId, isGuest }, getSecret(), { + expiresIn: maxAge / 1000, + }); + res.cookie(COOKIE_NAME, token, { + httpOnly: true, + sameSite: 'lax', + maxAge, + }); +} + +// fjerner cookien ved utlogging +export function clearLoginCookie(res: Response) { + res.clearCookie(COOKIE_NAME); +} + +// leser cookien og finner bruker id, null hvis ingen eller ugyldig +export function getSession(req: Request): Session | null { + const token = req.cookies?.[COOKIE_NAME]; + if (!token) return null; + + try { + const payload = jwt.verify(token, getSecret()) as Session; + return { userId: payload.userId, isGuest: payload.isGuest === true }; + } catch { + // billetten er utløpt eller noen har tullet med den + return null; + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 0ed4268..180b488 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,49 +1,67 @@ -import 'dotenv/config' -import express from 'express' -import cors from 'cors' -import { ApolloServer } from '@apollo/server' -import { expressMiddleware } from '@as-integrations/express5' -import { readFileSync } from 'fs' -import { join } from 'path' -import { recipeResolvers } from './schema/resolvers/recipe' -import uploadRouter from './routes/upload' - -const typeDefs = readFileSync( - join(__dirname, 'schema/typeDefs/recipe.graphql'), - 'utf-8', -) +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import cookieParser from 'cookie-parser'; +import { ApolloServer } from '@apollo/server'; +import { expressMiddleware } from '@as-integrations/express5'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { recipeResolvers } from './schema/resolvers/recipe'; +import { userResolvers } from './schema/resolvers/user'; +import { getSession, setLoginCookie, type Context } from './auth/session'; +import uploadRouter from './routes/upload'; -async function start() { - const app = express() +const typeDefs = ['recipe.graphql', 'user.graphql'].map((file) => + readFileSync(join(__dirname, 'schema/typeDefs', file), 'utf-8'), +); + +const resolvers = { + Query: { ...recipeResolvers.Query, ...userResolvers.Query }, + Mutation: { ...recipeResolvers.Mutation, ...userResolvers.Mutation }, +}; - const server = new ApolloServer({ - typeDefs, - resolvers: recipeResolvers, - }) +// for å teste med Apollo Sandbox +const corsOptions = { + origin: [ + 'https://sandbox.embed.apollographql.com', + 'https://studio.apollographql.com', + ], + credentials: true, +}; - await server.start() +async function start() { + const app = express(); + const server = new ApolloServer({ typeDefs, resolvers }); + await server.start(); // Gjør opplastede bilder tilgjengelige via URL - app.use( - '/uploads', - cors(), - express.static(join(process.cwd(), 'uploads')), - ) + app.use('/uploads', cors(), express.static(join(process.cwd(), 'uploads'))); // Endepunkt for bildeopplasting - app.use('/upload', cors(), uploadRouter) + app.use('/upload', cors(), uploadRouter); // Eksisterende GraphQL-endepunkt app.use( '/graphql', - cors(), + cors(corsOptions), + cookieParser(), express.json(), - expressMiddleware(server), - ) + expressMiddleware(server, { + // finner hvem som er logget inn for hver forespørsel + context: async ({ req, res }) => { + const session = getSession(req); + // fornyer cookies på gjestebruker hver gang dde besøker nettsiden + if (session?.isGuest) { + setLoginCookie(res, session.userId, true); + } + return { req, res, userId: session?.userId ?? null }; + }, + }), + ); - app.listen(3001, () => { - console.log('Server kjører på http://localhost:3001/graphql') - }) + app.listen(3001, () => + console.log('Server kjører på http://localhost:3001/graphql'), + ); } -start() +start(); diff --git a/server/src/schema/resolvers/user.ts b/server/src/schema/resolvers/user.ts new file mode 100644 index 0000000..e454e67 --- /dev/null +++ b/server/src/schema/resolvers/user.ts @@ -0,0 +1,102 @@ +import bcrypt from 'bcryptjs'; +import { GraphQLError } from 'graphql'; +import { prisma } from '../../db/client'; +import { + clearLoginCookie, + setLoginCookie, + type Context, +} from '../../auth/session'; +import { randomUUID } from 'crypto'; + +// samme krav som i frontend +const MIN_PASSWORD_LENGTH = 6; + +type Credentials = { username: string; password: string }; + +export const userResolvers = { + Query: { + // innlogget bruker og null hvis ingen er logget inn + me: async (_: unknown, __: unknown, context: Context) => { + if (!context.userId) return null; + return prisma.user.findUnique({ where: { id: context.userId } }); + }, + }, + + Mutation: { + // lager ny bruker og logger inn + register: async (_: unknown, args: Credentials, context: Context) => { + const username = args.username.trim(); + + // sjekker på nytt her + if (!username || args.password.length < MIN_PASSWORD_LENGTH) { + throw new GraphQLError('Ugyldig brukernavn eller passord.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + const existing = await prisma.user.findUnique({ where: { username } }); + if (existing) { + throw new GraphQLError('Brukernavnet er allerede tatt.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + // lagrer aldri passordet bare hashen + const passwordHash = await bcrypt.hash(args.password, 10); + const user = await prisma.user.create({ + data: { username, name: username, passwordHash }, + }); + + setLoginCookie(context.res, user.id); + return user; + }, + + // logger inn hvis brukernavn og passord stemmer + login: async (_: unknown, args: Credentials, context: Context) => { + const user = await prisma.user.findUnique({ + where: { username: args.username.trim() }, + }); + + const passwordIsCorrect = + user?.passwordHash != null && + (await bcrypt.compare(args.password, user.passwordHash)); + + if (!user || !passwordIsCorrect) { + throw new GraphQLError('Feil brukernavn eller passord.', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + } + + setLoginCookie(context.res, user.id); + return user; + }, + + // fjerner cookien + logout: (_: unknown, __: unknown, context: Context) => { + clearLoginCookie(context.res); + return true; + }, + + // lager en gjestebruker uten passord og logger inn + continueAsGuest: async (_: unknown, __: unknown, context: Context) => { + // gjenbruker gjesten hvis nettleseren allerede har en + if (context.userId) { + const current = await prisma.user.findUnique({ + where: { id: context.userId }, + }); + if (current?.isGuest) return current; + } + + const guest = await prisma.user.create({ + data: { + username: `gjest-${randomUUID()}`, + name: 'Gjest', + isGuest: true, + }, + }); + + setLoginCookie(context.res, guest.id, true); + return guest; + }, + }, +}; diff --git a/server/src/schema/typeDefs/user.graphql b/server/src/schema/typeDefs/user.graphql new file mode 100644 index 0000000..ebeb36c --- /dev/null +++ b/server/src/schema/typeDefs/user.graphql @@ -0,0 +1,20 @@ +type User { + id: ID! + username: String! + name: String! + languagePref: String! + unitSystem: String! + isGuest: Boolean! +} + +extend type Query { + # innlogget bruker og null hvis ingen er logget inn + me: User +} + +extend type Mutation { + register(username: String!, password: String!): User! + login(username: String!, password: String!): User! + logout: Boolean! + continueAsGuest: User! +}