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
63 changes: 63 additions & 0 deletions client/src/graphql/auth.ts
Original file line number Diff line number Diff line change
@@ -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}
`;
108 changes: 56 additions & 52 deletions client/src/lib/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<User | null>(null);
// kontoene som finnes, nye legges til når noen lager bruker
const [accounts, setAccounts] = useState<Account[]>(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 (
<AuthContext
value={{ user, loading: false, continueAsGuest, login, register, logout }}
value={{ user, loading, continueAsGuest, login, register, logout }}
>
{children}
</AuthContext>
Expand Down
8 changes: 4 additions & 4 deletions client/src/lib/authContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>;
login: (username: string, password: string) => Promise<string | null>;
register: (username: string, password: string) => Promise<string | null>;
logout: () => Promise<void>;
};
// felles boks for innlogget bruker
export const AuthContext = createContext<AuthContextValue | null>(null);
12 changes: 7 additions & 5 deletions client/src/routes/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(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(() => {
Expand Down
6 changes: 3 additions & 3 deletions client/src/routes/ProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
11 changes: 6 additions & 5 deletions client/src/routes/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function RegisterPage() {
const navigate = useNavigate();
const { register } = useCurrentUser();

function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
async function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
// hindrer at siden lastes på nytt
event.preventDefault();

Expand All @@ -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('/');
}
}

Expand Down
9 changes: 5 additions & 4 deletions client/src/routes/TestUserLoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function TestUserLoginPage() {
// feilmelding hvis noe er feil
const [error, setError] = useState('');

function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
async function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
// hindrer at siden lastes på nytt
event.preventDefault();

Expand All @@ -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('/');
}
}

Expand Down
3 changes: 3 additions & 0 deletions client/src/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ export type User = {
id: string;
name: string;
authProvider: AuthProvider;
username?: string;
languagePref?: string;
unitSystem?: string;
};
3 changes: 2 additions & 1 deletion server/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
DATABASE_URL=
PORT=
PORT=
JWT_SECRET=
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isGuest" BOOLEAN NOT NULL DEFAULT false;
3 changes: 2 additions & 1 deletion server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
59 changes: 59 additions & 0 deletions server/src/auth/session.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading