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 ( + + ); +}; + +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