diff --git a/.env.example b/.env.example
index 331c5a7..9a7002f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,8 @@
NEXT_PUBLIC_BASE_PATH=/hugin
+NEXT_PUBLIC_KEYCLOAK_BASE_URL=https://your-keycloak-url.org
+NEXT_PUBLIC_KEYCLOAK_REALM=your-realm
+NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=your-client-id
+
CATALOGUE_API_PATH=http://localhost:8087/bikube
DATABASE_URL=''
+AUTH_API_PATH=http://localhost:8088/auth
diff --git a/.github/workflows/ci_pipeline.yml b/.github/workflows/ci_pipeline.yml
index 79f9854..132cb47 100644
--- a/.github/workflows/ci_pipeline.yml
+++ b/.github/workflows/ci_pipeline.yml
@@ -71,7 +71,8 @@ jobs:
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets: |
kv/team/text/data/harbor * ;
- kv/team/text/data/hugin-stage *
+ kv/team/text/data/hugin-stage * ;
+ kv/team/text/data/keycloak-nbauth-tekst *
- name: Log in to Harbor
uses: docker/login-action@v3
@@ -102,6 +103,9 @@ jobs:
echo "KEYCLOAK_TEKST_CLIENT_ID=${{ steps.import-secrets.outputs.KEYCLOAK_TEKST_CLIENT_ID }}" >> .env.production
echo "KEYCLOAK_TEKST_CLIENT_SECRET=${{ steps.import-secrets.outputs.KEYCLOAK_TEKST_CLIENT_SECRET }}" >> .env.production
echo "KEYCLOAK_TEKST_URL=${{ steps.import-secrets.outputs.KEYCLOAK_TEKST_URL }}" >> .env.production
+ echo "NEXT_PUBLIC_KEYCLOAK_BASE_URL=${{ steps.import-secrets.outputs.KEYCLOAK_BASE_URL }}" >> .env.production
+ echo "NEXT_PUBLIC_KEYCLOAK_REALM=${{ steps.import-secrets.outputs.KEYCLOAK_REALM }}" >> .env.production
+ echo "NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=${{ steps.import-secrets.outputs.KEYCLOAK_CLIENT_ID }}" >> .env.production
- name: Build and push image
uses: docker/build-push-action@v5
diff --git a/README.md b/README.md
index 25411a0..5fe88cb 100644
--- a/README.md
+++ b/README.md
@@ -8,14 +8,18 @@ For å kjøre lokalt må du sette de nødvendige miljøvariablene:
cp .env.example .env.local
```
-| Variabelnavn | Standardverdi | Beskrivelse |
-|------------------------------|------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
-| NEXT_PUBLIC_BASE_PATH | /hugin | Base path for applikasjonen |
-| CATALOGUE_API_PATH | http://localhost:8087/bikube | Sti til [katalog APIet ](https://github.com/NationalLibraryOfNorway/bikube)
Må starte med `http://` eller `https://` |
-| DATABASE_URL | | URL til databasen (se mer info i eget avsnitt under) |
-| KEYCLOAK_TEKST_URL | | Url til keycloak-tekst (inkl. realm om open-idconnect, eks. https://mysite.com/authn/realms/myRealm/protocol/openid-connect |
-| KEYCLOAK_TEKST_CLIENT_ID | | Client ID i keycloak-tekst |
-| KEYCLOAK_TEKST_CLIENT_SECRET | | Client secret i keycloak-tekst |
+| Variabelnavn | Standardverdi | Beskrivelse |
+|--------------------------------|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
+| NEXT_PUBLIC_BASE_PATH | /hugin | Base path for applikasjonen |
+| CATALOGUE_API_PATH | http://localhost:8087/bikube | Sti til [katalog APIet ](https://github.com/NationalLibraryOfNorway/bikube)
Må starte med `http://` eller `https://` |
+| DATABASE_URL | | URL til databasen (se mer info i eget avsnitt under) |
+| AUTH_API_PATH | http://localhost:8080/tekst-auth | Sti til [autentiserings APIet](https://github.com/NationalLibraryOfNorway/tekst-auth)
Må starte med `http://` eller `https://` |
+| NEXT_PUBLIC_KEYCLOAK_BASE_URL | | URL til keycloak |
+| NEXT_PUBLIC_KEYCLOAK_REALM | | Keycloak-realmen |
+| NEXT_PUBLIC_KEYCLOAK_CLIENT_ID | | Keycloak-klienten |
+| KEYCLOAK_TEKST_URL | | Url til keycloak-tekst (inkl. realm om open-idconnect, eks. https://mysite.com/authn/realms/myRealm/protocol/openid-connect |
+| KEYCLOAK_TEKST_CLIENT_ID | | Client ID i keycloak-tekst |
+| KEYCLOAK_TEKST_CLIENT_SECRET | | Client secret i keycloak-tekst |
Deretter må du kjøre følgende kommandoer:
```bash
diff --git a/__tests__/components/Header.test.tsx b/__tests__/components/Header.test.tsx
index a0e5848..3369426 100644
--- a/__tests__/components/Header.test.tsx
+++ b/__tests__/components/Header.test.tsx
@@ -12,10 +12,6 @@ test('Header should have logo and Hugin-text', () => {
expect(screen.getByRole('img')).toBeTruthy();
});
-test('Header should have login button', () => {
- expect(screen.getByText('Logg inn')).toBeTruthy();
-});
-
test('Header should not as default not show search bar', () => {
expect(screen.queryByRole('searchbox')).toBeFalsy();
});
diff --git a/next.config.mjs b/next.config.mjs
index 26a3478..195286c 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -9,7 +9,7 @@ const nextConfig = {
source: `${process.env.NEXT_PUBLIC_BASE_PATH}/api/catalog/:path*`,
destination: `${process.env.CATALOGUE_API_PATH}/:path*`,
basePath: false
- },
+ }
];
}
};
diff --git a/src/app/AuthProvider.tsx b/src/app/AuthProvider.tsx
new file mode 100644
index 0000000..880d444
--- /dev/null
+++ b/src/app/AuthProvider.tsx
@@ -0,0 +1,124 @@
+'use client';
+
+import {createContext, useCallback, useContext, useEffect, useState} from 'react';
+import {useRouter} from 'next/navigation';
+import keycloakConfig from '@/lib/keycloak';
+import {User} from '@/models/UserToken';
+import {refresh, signIn, signOut} from '@/services/auth.data';
+
+interface IAuthContext {
+ authenticated: boolean;
+ user?: User;
+ logout?: () => void;
+}
+
+const AuthContext = createContext({
+ authenticated: false,
+ logout: () => {}
+});
+
+export const AuthProvider = ({children}: { children: React.ReactNode }) => {
+ const router = useRouter();
+
+ const [authenticated, setAuthenticated] = useState(false);
+ const [user, setUser] = useState();
+ const [intervalId, setIntervalId] = useState();
+
+ const handleNotAuthenticated = useCallback(() => {
+ setAuthenticated(false);
+ setUser(undefined);
+ if (intervalId) {
+ clearInterval(intervalId);
+ }
+ const currentUrl = window.location.href;
+ window.location.assign(`${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` +
+ `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`);
+ }, [intervalId]);
+
+ useEffect(() => {
+ const codeInParams = new URLSearchParams(window.location.search).get('code');
+ if (codeInParams) {
+ const redirectUrl = new URLSearchParams({redirectUrl: trimRedirectUrl(window.location.href)}).toString();
+ void signIn(codeInParams, redirectUrl).then((token: User) => {
+ handleIsAuthenticated(token);
+ router.push('/');
+ }).catch((e: Error) => {
+ console.error('Failed to sign in: ', e.message);
+ handleNotAuthenticated();
+ });
+ } else if (user) {
+ if (user.expires && new Date(user.expires) > new Date()) {
+ handleIsAuthenticated(user);
+ }
+ } else {
+ handleNotAuthenticated();
+ const currentUrl = window.location.href;
+ window.location.assign(`${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth` +
+ `?client_id=${keycloakConfig.clientId}&redirect_uri=${currentUrl}&response_type=code&scope=openid`);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleIsAuthenticated = (newUser: User) => {
+ if (newUser) {
+ setUser(newUser);
+ setAuthenticated(true);
+ }
+ };
+
+ const refreshToken = useCallback(async () => {
+ return refresh();
+ }, []);
+
+ const setIntervalToRefreshAccessToken = useCallback(async () => {
+ if (user?.expires && !intervalId) {
+ const expiryTime = new Date(user?.expires).getTime() - Date.now();
+ if (expiryTime < 1000 * 60 * 4.75) {
+ await refreshToken();
+ }
+ setIntervalId(window.setInterval(() => {
+ void refreshToken().then((newUser: User) => {
+ handleIsAuthenticated(newUser);
+ })
+ .catch((e: Error) => {
+ console.error('Failed to refresh token: ', e.message);
+ handleNotAuthenticated();
+ });
+ }, (1000 * 60 * 4.75))); // Refresh every 4.75 minutes (fifteen seconds before expiry)
+ }
+ }, [handleNotAuthenticated, intervalId, refreshToken, user?.expires]);
+
+ useEffect(() => {
+ void setIntervalToRefreshAccessToken();
+ }, [setIntervalToRefreshAccessToken]);
+
+ const trimRedirectUrl= (returnUrl: string): string => {
+ returnUrl = returnUrl.split('?')[0];
+ if (returnUrl.at(-1) === '/') {
+ returnUrl = returnUrl.slice(0, -1);
+ }
+ return returnUrl;
+ };
+
+ const logout = async () => {
+ await signOut()
+ .then(() => {
+ handleNotAuthenticated();
+ });
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useAuth = () => useContext(AuthContext);
\ No newline at end of file
diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts
new file mode 100644
index 0000000..58b5609
--- /dev/null
+++ b/src/app/api/auth/refresh/route.ts
@@ -0,0 +1,30 @@
+import {NextResponse} from 'next/server';
+import {User, UserToken} from '@/models/UserToken';
+import {getRefreshToken, setUserCookie} from '@/utils/cookieUtils';
+
+// POST api/auth/refresh
+export async function POST(): Promise {
+ const refreshToken = getRefreshToken();
+ if (!refreshToken) {
+ return NextResponse.json({error: 'No user token found'}, {status: 401});
+ }
+
+ const data = await fetch(`${process.env.AUTH_API_PATH}/refresh`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: refreshToken
+ });
+
+ const newToken = await data.json() as UserToken;
+
+ if (!newToken || !newToken.name || !newToken.expires) {
+ return NextResponse.json({error: 'Failed to refresh token'}, {status: 500});
+ }
+
+ setUserCookie(newToken);
+
+ const user: User = {name: newToken.name, expires: newToken.expires};
+ return NextResponse.json(user, {status: 200});
+}
\ No newline at end of file
diff --git a/src/app/api/auth/signin/route.ts b/src/app/api/auth/signin/route.ts
new file mode 100644
index 0000000..3297b86
--- /dev/null
+++ b/src/app/api/auth/signin/route.ts
@@ -0,0 +1,43 @@
+import {NextRequest, NextResponse} from 'next/server';
+import {User, UserToken} from '@/models/UserToken';
+import {ProblemDetail} from '@/models/ProblemDetail';
+import {setUserCookie} from '@/utils/cookieUtils';
+
+interface LoginRequest {
+ code: string;
+ redirectUrl: string;
+}
+
+// POST api/auth/signin
+export async function POST(req: NextRequest): Promise {
+ const {code, redirectUrl} = await req.json() as LoginRequest;
+ const data = await fetch(`${process.env.AUTH_API_PATH}/login?${redirectUrl}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: code
+ })
+ .then(async response => {
+ if (!response.ok) {
+ const problemDetail = await response.json() as ProblemDetail;
+ return NextResponse.json({error: problemDetail.detail}, {status: problemDetail.status});
+ }
+ return response;
+ });
+
+ if (data instanceof NextResponse) {
+ return data;
+ }
+
+ const userToken = await data.json() as UserToken;
+
+ if (!userToken || !userToken.name || !userToken.expires) {
+ return NextResponse.json({error: 'Failed to authenticate'}, {status: 500});
+ }
+
+ setUserCookie(userToken);
+
+ const user: User = {name: userToken.name, expires: userToken.expires};
+ return NextResponse.json(user, {status: 200});
+}
diff --git a/src/app/api/auth/signout/route.ts b/src/app/api/auth/signout/route.ts
new file mode 100644
index 0000000..331af02
--- /dev/null
+++ b/src/app/api/auth/signout/route.ts
@@ -0,0 +1,26 @@
+import {NextResponse} from 'next/server';
+import {deleteUserToken, getRefreshToken} from '@/utils/cookieUtils';
+
+// POST api/auth/signout
+export async function POST(): Promise {
+ const refreshToken = getRefreshToken();
+ if (!refreshToken) {
+ return NextResponse.json({error: 'No user token found'}, {status: 401});
+ }
+
+ return await fetch(`${process.env.AUTH_API_PATH}/logout`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: refreshToken
+ }).then(res => {
+ if (!res.ok) {
+ return NextResponse.json({error: 'Failed to logout'}, {status: res.status});
+ }
+ deleteUserToken();
+ return NextResponse.json({message: 'Logged out successfully'}, {status: 200});
+ }).catch((error: Error) => {
+ return NextResponse.json({error: `Failed to logout: ${error.message}`}, {status: 500});
+ });
+}
diff --git a/src/app/api/title/[id]/box/route.ts b/src/app/api/title/[id]/box/route.ts
index bc24f96..e00081f 100644
--- a/src/app/api/title/[id]/box/route.ts
+++ b/src/app/api/title/[id]/box/route.ts
@@ -59,7 +59,6 @@ export async function POST(req: NextRequest, params: IdParams): Promise {
- console.log('PATCH /title/[id]/box');
const id = +params.params.id;
const { boxId } = await req.json() as {boxId: string; startDate: string};
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index 8b01cc5..26220aa 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -1,11 +1,14 @@
'use client';
import {NextUIProvider} from '@nextui-org/react';
+import {AuthProvider} from '@/app/AuthProvider';
export function Providers({children}: { children: React.ReactNode }) {
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 6c4e42f..330621b 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -2,12 +2,15 @@
import {Link, Navbar, NavbarBrand, NavbarContent, NavbarItem} from '@nextui-org/react';
import React from 'react';
-import {Button} from '@nextui-org/button';
import {usePathname, useRouter} from 'next/navigation';
import SearchBar from '@/components/SearchBar';
import Image from 'next/image';
+import LogoutButton from '@/components/LogoutButton';
+import {useAuth} from '@/app/AuthProvider';
+import {UserDetails} from '@/components/UserDetails';
export default function Header() {
+ const { authenticated , user } = useAuth();
const router = useRouter();
const pathname = usePathname() || '';
@@ -33,14 +36,12 @@ export default function Header() {
}
-
+ { authenticated ? (
+ <>
+
+
+ >
+ ) : <>>}
diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx
new file mode 100644
index 0000000..f923975
--- /dev/null
+++ b/src/components/LogoutButton.tsx
@@ -0,0 +1,19 @@
+import {useAuth} from '@/app/AuthProvider';
+import {Button} from '@nextui-org/react';
+import {FaSignOutAlt} from 'react-icons/fa';
+
+const LogoutButton = () => {
+ const { logout } = useAuth();
+
+ return (
+ }
+ onClick={logout}
+ >
+ Logg ut
+
+ );
+};
+
+export default LogoutButton;
\ No newline at end of file
diff --git a/src/components/UserDetails.tsx b/src/components/UserDetails.tsx
new file mode 100644
index 0000000..3b66c64
--- /dev/null
+++ b/src/components/UserDetails.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import {User} from '@nextui-org/user';
+import {useEffect, useState} from 'react';
+
+interface UserDetailsProps {
+ name: string;
+ className?: string;
+}
+
+export const UserDetails: React.FC = ({ name, className }) => {
+ const [initials, setInitials] = useState('');
+
+ useEffect(() => {
+ const tempInitials = name.split(' ').map(n => n[0]?.toUpperCase()).join('');
+ setInitials(tempInitials);
+ }, [name]);
+
+ return (
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/lib/keycloak.ts b/src/lib/keycloak.ts
new file mode 100644
index 0000000..7cf876a
--- /dev/null
+++ b/src/lib/keycloak.ts
@@ -0,0 +1,7 @@
+const keycloakConfig = {
+ url: process.env.NEXT_PUBLIC_KEYCLOAK_BASE_URL!,
+ realm: process.env.NEXT_PUBLIC_KEYCLOAK_REALM!,
+ clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID!
+};
+
+export default keycloakConfig;
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..07c6db2
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,38 @@
+import {NextRequest, NextResponse} from 'next/server';
+import {getUserToken} from '@/utils/cookieUtils';
+import {UserToken} from '@/models/UserToken';
+
+const protectedPaths = ['/api/title', '/api/box', '/api/newspaper'];
+const requiredRoles = ['T_relation_avis']; // TODO: Fiks rolle når den er opprettet
+
+export default function middleware(req: NextRequest) {
+ const path = req.nextUrl.pathname;
+ const isProtected = protectedPaths.some(protectedPath => path.includes(protectedPath));
+ const userToken = getUserToken();
+ const authorized = isAuthorized(userToken);
+
+ if (!isProtected) {
+ return NextResponse.next();
+ }
+
+ if (isProtected && authorized) {
+ return NextResponse.next();
+ }
+
+ return NextResponse.json({error: 'Unauthorized'}, {status: 401});
+}
+
+function isAuthorized(token?: UserToken) {
+ if (token) {
+ if (token.refreshExpires.getTime() > Date.now()) {
+ return requiredRoles.some(role => token.groups.includes(role));
+ }
+ }
+ return false;
+}
+
+
+export const config = {
+ // Run on all routes except these
+ matcher: ['/((?!_next/static|_next/image|.*\\.png$|.*\\.ico$|.*\\.svg$|api/auth).*)']
+};
\ No newline at end of file
diff --git a/src/models/CatalogMissingNewspaperDto.ts b/src/models/CatalogMissingNewspaperDto.ts
index 28b4f63..95a84c8 100644
--- a/src/models/CatalogMissingNewspaperDto.ts
+++ b/src/models/CatalogMissingNewspaperDto.ts
@@ -1,5 +1,6 @@
import {newspaper} from '@prisma/client';
import {createCatalogDateString} from '@/utils/dateUtils';
+import {getUserName} from '@/utils/cookieUtils';
export interface CatalogMissingNewspaperDto {
titleCatalogueId: string;
@@ -17,7 +18,7 @@ export function createCatalogMissingNewspaperDtoFromIssue(
return {
titleCatalogueId: titleId,
date: createCatalogDateString(issue.date),
- username: 'hugin stage', // TODO replace with actual username when auth is present
+ username: getUserName() ?? '',
notes: issue.notes ?? '',
// eslint-disable-next-line id-denylist
number: issue.edition ?? ''
diff --git a/src/models/CatalogNewspaperDto.ts b/src/models/CatalogNewspaperDto.ts
index 113b68f..e1971bf 100644
--- a/src/models/CatalogNewspaperDto.ts
+++ b/src/models/CatalogNewspaperDto.ts
@@ -1,5 +1,6 @@
import {newspaper} from '@prisma/client';
import {createCatalogDateString} from '@/utils/dateUtils';
+import {getUserName} from '@/utils/cookieUtils';
export interface CatalogNewspaperDto {
titleCatalogueId: string;
@@ -21,7 +22,7 @@ export function createCatalogNewspaperDtoFromIssue(
return {
titleCatalogueId: titleId,
date: createCatalogDateString(issue.date),
- username: 'hugin stage', // TODO replace with actual username when auth is present
+ username: getUserName() ?? '',
digital: false,
containerId: issue.box_id,
notes: issue.notes ?? '',
diff --git a/src/models/CatalogNewspaperEditDto.ts b/src/models/CatalogNewspaperEditDto.ts
index b416eab..77af047 100644
--- a/src/models/CatalogNewspaperEditDto.ts
+++ b/src/models/CatalogNewspaperEditDto.ts
@@ -1,4 +1,5 @@
import {newspaper} from '@prisma/client';
+import {getUserName} from '@/utils/cookieUtils';
export interface CatalogNewspaperEditDto {
@@ -14,7 +15,7 @@ export function createCatalogNewspaperEditDtoFromIssue(
): CatalogNewspaperEditDto {
return {
manifestationId: issue.catalog_id,
- username: 'Hugin stage', // TODO replace with actual username when auth is present
+ username: getUserName() ?? '',
notes: issue.notes ?? '',
// eslint-disable-next-line id-denylist
number: issue.edition ?? ''
diff --git a/src/models/ProblemDetail.ts b/src/models/ProblemDetail.ts
new file mode 100644
index 0000000..ca4748f
--- /dev/null
+++ b/src/models/ProblemDetail.ts
@@ -0,0 +1,10 @@
+interface ProblemDetail {
+ type: string;
+ title: string;
+ status: number;
+ detail: string;
+ instance: string;
+ timestamp: string;
+}
+
+export type { ProblemDetail };
\ No newline at end of file
diff --git a/src/models/UserToken.ts b/src/models/UserToken.ts
new file mode 100644
index 0000000..2975de0
--- /dev/null
+++ b/src/models/UserToken.ts
@@ -0,0 +1,36 @@
+interface SerializedUserToken {
+ groups: string[];
+ name: string;
+ accessToken: string;
+ expires: string;
+ refreshToken: string;
+ refreshExpires: string;
+}
+
+interface UserToken {
+ groups: string[];
+ name: string;
+ accessToken: string;
+ expires: Date;
+ refreshToken: string;
+ refreshExpires: Date;
+}
+
+const userTokenBuilder = (userToken: SerializedUserToken): UserToken => {
+ return {
+ groups: userToken.groups,
+ name: userToken.name,
+ accessToken: userToken.accessToken,
+ expires: new Date(userToken.expires),
+ refreshToken: userToken.refreshToken,
+ refreshExpires: new Date(userToken.refreshExpires)
+ };
+};
+
+interface User {
+ name: string;
+ expires: Date;
+}
+
+export type { User, UserToken, SerializedUserToken };
+export { userTokenBuilder };
\ No newline at end of file
diff --git a/src/services/auth.data.ts b/src/services/auth.data.ts
new file mode 100644
index 0000000..bbefa86
--- /dev/null
+++ b/src/services/auth.data.ts
@@ -0,0 +1,49 @@
+import {User} from '@/models/UserToken';
+import {ProblemDetail} from '@/models/ProblemDetail';
+import {NextResponse} from 'next/server';
+
+export async function signIn(code: string, redirectUrl: string): Promise {
+ const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/auth/signin`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({code, redirectUrl})
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Failed to authenticate');
+ }
+ return response;
+ });
+ return await data.json() as User;
+}
+
+export async function signOut(): Promise {
+ return await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/auth/signout`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ })
+ .then(async response => {
+ if (!response.ok) {
+ const problemDetail = await response.json() as ProblemDetail;
+ return NextResponse.json({error: problemDetail.detail}, {status: problemDetail.status});
+ }
+ return NextResponse.json({message: 'Logged out successfully'}, {status: 204});
+ })
+ .catch((error: Error) => {
+ return NextResponse.json({error: `Failed to logout: ${error.message}`}, {status: 500});
+ });
+}
+
+export async function refresh(): Promise {
+ const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/auth/refresh`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+ return await data.json() as User;
+}
\ No newline at end of file
diff --git a/src/utils/cookieUtils.ts b/src/utils/cookieUtils.ts
new file mode 100644
index 0000000..bf11d75
--- /dev/null
+++ b/src/utils/cookieUtils.ts
@@ -0,0 +1,31 @@
+import {cookies} from 'next/headers';
+import {SerializedUserToken, UserToken, userTokenBuilder} from '@/models/UserToken';
+
+export function getUserToken(): UserToken | undefined {
+ const userCookieValue = cookies().get('user')?.value;
+ if (!userCookieValue) {
+ return undefined;
+ }
+ return userTokenBuilder(JSON.parse(userCookieValue) as SerializedUserToken);
+}
+
+export function getRefreshToken(): string | undefined {
+ return getUserToken()?.refreshToken;
+}
+
+export function getUserName(): string | undefined {
+ return getUserToken()?.name;
+}
+
+export function deleteUserToken() {
+ cookies().delete('user');
+}
+
+export function setUserCookie(user: UserToken) {
+ cookies().set('user', JSON.stringify(user), {
+ httpOnly: true,
+ secure: true,
+ sameSite: 'lax',
+ path: '/'
+ });
+}
\ No newline at end of file