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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL="https://res.cloudinary.com/placeholder-
NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL="https://via.placeholder.com/600"
NEXT_PUBLIC_ALGOLIA_APP_ID = "changeme"
NEXT_PUBLIC_ALGOLIA_PUBLIC_API_KEY = "changeme"
NODE_ENV="development"
NODE_ENV="development"
NEXT_PUBLIC_AUTH_TOKEN_SS_KEY="auth-token"
NEXT_PUBLIC_REFRESH_TOKEN_LS_KEY="refresh-token"
NEXT_PUBLIC_SESSION_TOKEN_LS_KEY="session-token"
NEXT_PUBLIC_AUTH_KEY_TIMEOUT="300000"
38 changes: 37 additions & 1 deletion src/components/Header/Navbar.component.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from 'next/link';

import { useEffect, useState } from 'react';
import { hasCredentials } from '../../utils/auth';
import Cart from './Cart.component';
import AlgoliaSearchBox from '../AlgoliaSearch/AlgoliaSearchBox.component';
import MobileSearch from '../AlgoliaSearch/MobileSearch.component';
Expand All @@ -9,6 +10,12 @@ import MobileSearch from '../AlgoliaSearch/MobileSearch.component';
* Includes mobile menu.
*/
const Navbar = () => {
const [loggedIn, setLoggedIn] = useState(false);

useEffect(() => {
setLoggedIn(hasCredentials());
}, []);

return (
<header className="border-b border-gray-200">
<nav id="header" className="top-0 z-50 w-full bg-white">
Expand Down Expand Up @@ -51,6 +58,35 @@ const Navbar = () => {
</Link>
<div className="flex items-center space-x-3">
<AlgoliaSearchBox />
{loggedIn ? (
<Link href="/min-konto">
<span className="text-base uppercase tracking-wider group relative">
<span className="relative inline-block">
<span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
Min konto
</span>
</span>
</Link>
) : (
<>
<Link href="/logg-inn">
<span className="text-base uppercase tracking-wider group relative">
<span className="relative inline-block">
<span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
Logg inn
</span>
</span>
</Link>
<Link href="/registrer">
<span className="text-base uppercase tracking-wider group relative">
<span className="relative inline-block">
<span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
Registrer
</span>
</span>
</Link>
</>
)}
<Cart />
</div>
</div>
Expand Down
61 changes: 61 additions & 0 deletions src/components/User/CustomerAccount.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useQuery } from '@apollo/client';
import { GET_CUSTOMER_ORDERS } from '../../utils/gql/GQL_QUERIES';
import LoadingSpinner from '../LoadingSpinner/LoadingSpinner.component';

interface Order {
id: string;
orderNumber: number;
status: string;
total: string;
date: string;
}

/**
* Customer account component that displays user's orders
* @function CustomerAccount
* @returns {JSX.Element} - Rendered component with order history
*/
const CustomerAccount = () => {
const { loading, error, data } = useQuery(GET_CUSTOMER_ORDERS);

if (loading) return <LoadingSpinner />;
if (error) return <p>Error: {error.message}</p>;

const orders = data?.customer?.orders?.nodes;

return (
<div>
<h1 className="text-2xl font-bold mb-4">Mine ordre</h1>
{orders && orders.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full bg-white">
<thead>
<tr>
<th className="py-2 px-4 border-b">Ordrenummer</th>
<th className="py-2 px-4 border-b">Dato</th>
<th className="py-2 px-4 border-b">Status</th>
<th className="py-2 px-4 border-b">Total</th>
</tr>
</thead>
<tbody>
{orders.map((order: Order) => (
<tr key={order.id}>
<td className="py-2 px-4 border-b">{order.orderNumber}</td>
<td className="py-2 px-4 border-b">
{new Date(order.date).toLocaleDateString()}
</td>
<td className="py-2 px-4 border-b">{order.status}</td>
<td className="py-2 px-4 border-b">{order.total}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p>Du har ingen ordre.</p>
)}
</div>
);
};

export default CustomerAccount;
85 changes: 85 additions & 0 deletions src/components/User/UserLogin.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { login } from '../../utils/auth';
import { InputField } from '../Input/InputField.component';
import LoadingSpinner from '../LoadingSpinner/LoadingSpinner.component';
import Button from '../UI/Button.component';
import { useRouter } from 'next/router';

interface ILoginData {
username: string;
password: string;
}

/**
* User login component that handles user authentication
* @function UserLogin
* @returns {JSX.Element} - Rendered component with login form
*/
const UserLogin = () => {
const methods = useForm<ILoginData>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();

const onSubmit = async (data: ILoginData) => {
setLoading(true);
setError(null);
try {
const result = await login(data.username, data.password);
if (result.success && result.status === 'SUCCESS') {
router.push('/min-konto');
} else {
throw new Error('Failed to login');
}
} catch (error: unknown) {
if (error instanceof Error) {
setError(error.message);
} else {
setError('An unknown error occurred.');
}
console.error('Login error:', error);
} finally {
setLoading(false);
}
};

return (
<section className="text-gray-700 container p-4 py-2 mx-auto mb-[8rem] md:mb-0">
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<div className="mx-auto lg:w-1/2 flex flex-wrap">
<InputField
inputName="username"
inputLabel="Brukernavn eller e-post"
type="text"
customValidation={{ required: true }}
/>
<InputField
inputName="password"
inputLabel="Passord"
type="password"
customValidation={{ required: true }}
/>

{error && (
<div className="w-full p-2 text-red-600 text-sm text-center">
{error}
</div>
)}

<div className="w-full p-2">
<div className="mt-4 flex justify-center">
<Button variant="primary" buttonDisabled={loading}>
{loading ? <LoadingSpinner /> : 'Logg inn'}
</Button>
</div>
</div>
</div>
</form>
</FormProvider>
</section>
);
};

export default UserLogin;
25 changes: 25 additions & 0 deletions src/components/User/withAuth.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useRouter } from 'next/router';
import { useEffect, ComponentType } from 'react';
import { hasCredentials } from '../../utils/auth';

const withAuth = <P extends object>(WrappedComponent: ComponentType<P>) => {
const Wrapper = (props: P) => {
const router = useRouter();

useEffect(() => {
if (!hasCredentials()) {
router.push('/logg-inn');
}
}, [router]);

if (!hasCredentials()) {
return null; // or a loading spinner
}

return <WrappedComponent {...props} />;
};

return Wrapper;
};

export default withAuth;
16 changes: 16 additions & 0 deletions src/pages/logg-inn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Layout from '@/components/Layout/Layout.component';
import UserLogin from '@/components/User/UserLogin.component';

import type { NextPage } from 'next';

const LoginPage: NextPage = () => {
return (
<Layout title="Logg inn">
<div className="container mx-auto px-4 py-8">
<UserLogin />
</div>
</Layout>
);
};

export default LoginPage;
17 changes: 17 additions & 0 deletions src/pages/min-konto.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Layout from '@/components/Layout/Layout.component';
import CustomerAccount from '@/components/User/CustomerAccount.component';
import withAuth from '@/components/User/withAuth.component';

import type { NextPage } from 'next';

const CustomerAccountPage: NextPage = () => {
return (
<Layout title="Min konto">
<div className="container mx-auto px-4 py-8">
<CustomerAccount />
</div>
</Layout>
);
};

export default withAuth(CustomerAccountPage);
24 changes: 17 additions & 7 deletions src/utils/apollo/ApolloClient.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
/*eslint complexity: ["error", 6]*/
/*eslint complexity: ["error", 8]*/

import {
ApolloClient,
InMemoryCache,
createHttpLink,
ApolloLink,
} from '@apollo/client';
import { getAuthToken } from '../auth';

const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds

/**
* Middleware operation
* If we have a session token in localStorage, add it to the GraphQL request as a Session header.
*/
export const middleware = new ApolloLink((operation, forward) => {
export const middleware = new ApolloLink(async (operation, forward) => {
/**
* If session data exist in local storage, set value as session header.
* Here we also delete the session if it is older than 7 days
Expand All @@ -22,6 +23,8 @@ export const middleware = new ApolloLink((operation, forward) => {
? JSON.parse(localStorage.getItem('woo-session'))
: null;

const headers = {};

if (sessionData && sessionData.token && sessionData.createdTime) {
const { token, createdTime } = sessionData;

Expand All @@ -32,14 +35,21 @@ export const middleware = new ApolloLink((operation, forward) => {
localStorage.setItem('woocommerce-cart', JSON.stringify({}));
} else {
// If it's not, use the token
operation.setContext(() => ({
headers: {
'woocommerce-session': `Session ${token}`,
},
}));
headers['woocommerce-session'] = `Session ${token}`;
}
}

if (process.browser) {
const authToken = await getAuthToken();
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
}

operation.setContext({
headers,
});

return forward(operation);
});

Expand Down
56 changes: 56 additions & 0 deletions src/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { LOGIN_USER } from './gql/GQL_MUTATIONS';

// Cookie-based authentication - no token storage needed
export function hasCredentials() {
if (typeof window === 'undefined') {
return false; // Server-side, no credentials available
}

// With cookie-based auth, we'll check if user is logged in through a query
// For now, we'll return false and let components handle the check
return false;
}

export async function getAuthToken() {
// Cookie-based auth doesn't need JWT tokens
return null;
}

export async function login(username: string, password: string) {
try {
const client = new ApolloClient({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL,
cache: new InMemoryCache(),
credentials: 'include', // Include cookies in requests
});

const { data } = await client.mutate({
mutation: LOGIN_USER,
variables: { username, password },
});

const loginResult = data.loginWithCookies;

if (loginResult.status !== 'SUCCESS') {
throw new Error('Login failed');
}

// On successful login, cookies are automatically set by the server
return { success: true, status: loginResult.status };
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(error.message);
}
throw new Error('An unknown error occurred during login.');
}
}

export async function logout() {
// For cookie-based auth, we might need a logout mutation
// For now, we can clear any client-side state
if (typeof window !== 'undefined') {
// Redirect to login or home page after logout
window.location.href = '/';
}
}
Loading
Loading