diff --git a/ui/app/Home.tsx b/ui/app/Home.tsx index eb0b9abb..c0783217 100644 --- a/ui/app/Home.tsx +++ b/ui/app/Home.tsx @@ -24,8 +24,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { siteConfig } from '@/config/site' -import { useAuth } from '@/context/AuthContext' - type FormTabKey = | 'thing' | 'location' @@ -56,7 +54,6 @@ export default function Home({ networks: any[] selectedNetwork?: string }) { - const { token } = useAuth() const [localThings, setLocalThings] = useState(things) useEffect(() => { @@ -102,11 +99,6 @@ export default function Home({ phenomenonTime?: string, range?: { start?: string | null; end?: string | null } ) => { - if (siteConfig.authorizationEnabled && !token) { - setObsError('Missing token') - return - } - let startIso = range?.start ?? null let endIso = range?.end ?? null @@ -132,7 +124,7 @@ export default function Home({ setObsError(null) try { const { observationData } = await getObservationsByDatastream( - token ?? undefined, + undefined, datastreamId, startIso ?? undefined, endIso ?? undefined diff --git a/ui/components/layout/Navbar.tsx b/ui/components/layout/Navbar.tsx index 2efbda83..febeeb93 100644 --- a/ui/components/layout/Navbar.tsx +++ b/ui/components/layout/Navbar.tsx @@ -36,10 +36,8 @@ import { siteConfig } from '@/config/site' import { useAuth } from '@/context/AuthContext' -import { getTokenUsername } from '@/lib/auth' - export default function Navbar() { - const { token, setToken } = useAuth() + const { authenticated, username, setSessionState } = useAuth() const { t } = useTranslation() const router = useRouter() @@ -73,12 +71,6 @@ export default function Navbar() { setSelectedLang(langCode) } - const username = useMemo(() => { - if (!siteConfig.authorizationEnabled) return null - if (!token) return null - return getTokenUsername(token) - }, [token]) - const initials = useMemo(() => { if (!username) return '' const parts = username.trim().split(/\s+/) @@ -89,9 +81,9 @@ export default function Navbar() { const handleLogout = async () => { try { - if (token) await logout(token) + await logout() } finally { - setToken(null) + setSessionState(false, null) router.push(siteConfig.authorizationEnabled ? '/login' : '/') } } @@ -148,7 +140,7 @@ export default function Navbar() { - {username && ( + {authenticated && username && (
{t('login.cheer')} {username} diff --git a/ui/context/AuthContext.tsx b/ui/context/AuthContext.tsx index 210fe994..dbd4d7e8 100644 --- a/ui/context/AuthContext.tsx +++ b/ui/context/AuthContext.tsx @@ -13,98 +13,73 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { refresh } from '@/services/auth' -import { deleteCookie, setCookie } from 'cookies-next' +import { getSession } from '@/services/auth' import React, { createContext, useContext, useEffect, useState } from 'react' import { siteConfig } from '@/config/site' -import { decodeTokenPayload } from '@/lib/auth' type AuthContextType = { - token: string | null - setToken: (token: string | null) => void + authenticated: boolean + username: string | null + setSessionState: (authenticated: boolean, username?: string | null) => void + refreshSession: () => Promise loading: boolean } const AuthContext = createContext({ - token: null, - setToken: () => {}, + authenticated: false, + username: null, + setSessionState: () => {}, + refreshSession: async () => {}, loading: true, }) //create the auth provider component export function AuthProvider({ children }: { children: React.ReactNode }) { - const [token, setTokenState] = useState(null) - const [loading, setLoading] = useState(false) - - //check token expiration and refresh if necessary - useEffect(() => { - if (!siteConfig.authorizationEnabled) return - if (!token) return - const payload = decodeTokenPayload(token) - if (!payload?.exp) return - - //set now to current time in seconds - const now = Math.floor(Date.now() / 1000) - - const timeLeft = payload.exp - now - - //if the token is about to expire in less than 2 minutes, refresh it - if (timeLeft < 120) { - refresh(token).then((newToken) => { - //if the refresh was successful, set the new token - if (newToken) setToken(newToken) - //if the refresh failed, clear the token - else setToken(null) - }) - } - }, [token]) + const [authenticated, setAuthenticated] = useState(false) + const [username, setUsername] = useState(null) + const [loading, setLoading] = useState(true) + + const setSessionState = ( + isAuthenticated: boolean, + nextUsername: string | null = null + ) => { + setAuthenticated(isAuthenticated) + setUsername(nextUsername) + } - //initialize token from local storage - useEffect(() => { + const refreshSession = async () => { if (!siteConfig.authorizationEnabled) { + setSessionState(true, null) setLoading(false) return } - if (typeof window !== 'undefined') { - //take the token from local storage if it exists - const storedToken = localStorage.getItem('token') - if (storedToken) setTokenState(storedToken) + setLoading(true) + try { + const session = await getSession() + setSessionState(!!session?.authenticated, session?.username ?? null) + } catch { + setSessionState(false, null) + } finally { setLoading(false) } - }, []) - - //set token in state and local storage - const setToken = (newToken: string | null) => { - setTokenState(newToken) - - if (!siteConfig.authorizationEnabled) { - return - } - - if (newToken) { - localStorage.setItem('token', newToken) - const payload = decodeTokenPayload(newToken) - const now = Math.floor(Date.now() / 1000) - const maxAge = - typeof payload?.exp === 'number' ? Math.max(payload.exp - now, 0) : undefined - - setCookie('token', newToken, { - httpOnly: false, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - ...(typeof maxAge === 'number' ? { maxAge } : {}), - path: '/', - }) - } else { - localStorage.removeItem('token') - deleteCookie('token') - } } + useEffect(() => { + refreshSession() + }, []) + return ( - + {children} ) diff --git a/ui/features/auth/components/Login.tsx b/ui/features/auth/components/Login.tsx index 80c9b47e..00a89d84 100644 --- a/ui/features/auth/components/Login.tsx +++ b/ui/features/auth/components/Login.tsx @@ -32,7 +32,6 @@ import { ModalFooter, ModalHeader, } from '@heroui/modal' -import { setCookie } from 'cookies-next' import 'flag-icons/css/flag-icons.min.css' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -44,7 +43,6 @@ import { LogoIstSOS, LogoOSGeo } from '@/components/icons' import { siteConfig } from '@/config/site' import { useAuth } from '@/context/AuthContext' -import { decodeTokenPayload } from '@/lib/auth' type LoginModalProps = { open: boolean @@ -52,7 +50,7 @@ type LoginModalProps = { } export default function Login({ open, onClose }: LoginModalProps) { - const { setToken } = useAuth() + const { refreshSession } = useAuth() const { t } = useTranslation() const router = useRouter() @@ -101,22 +99,8 @@ export default function Login({ open, onClose }: LoginModalProps) { try { const result = await login(username, password) - if (result?.access_token) { - setToken(result.access_token) - const payload = decodeTokenPayload(result.access_token) - const now = Math.floor(Date.now() / 1000) - const maxAge = - typeof payload?.exp === 'number' - ? Math.max(payload.exp - now, 0) - : 60 * 60 * 24 - - setCookie('token', result.access_token, { - httpOnly: false, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge, - path: '/', - }) + if (result?.success) { + await refreshSession() onClose() router.push('/') diff --git a/ui/features/forms/components/FormModal.tsx b/ui/features/forms/components/FormModal.tsx index 55114091..003feb24 100644 --- a/ui/features/forms/components/FormModal.tsx +++ b/ui/features/forms/components/FormModal.tsx @@ -47,7 +47,6 @@ import { ThingIcon, } from '@/components/icons' -import { useAuth } from '@/context/AuthContext' import { siteConfig } from '@/config/site' import { EntityFields, ExistingEntitySelect } from './wizard/fields' @@ -112,7 +111,6 @@ export default function FormModal({ }: FormProps) { const { t } = useTranslation() const router = useRouter() - const { token } = useAuth() const entityLabels = useMemo>( () => ({ @@ -215,11 +213,6 @@ export default function FormModal({ setSubmitError(null) - if (siteConfig.authorizationEnabled && !token) { - setSubmitError('Missing token') - return - } - if (requiresCommitMessage && !commitMessage.trim()) { setSubmitError(t('commit.message_required')) return @@ -232,40 +225,35 @@ export default function FormModal({ if (singleEntity === 'thing') { result = await createThing( - normalizeEntityPayload('thing', singleDraft.thing) as CreateThingPayload, - token ?? undefined + normalizeEntityPayload('thing', singleDraft.thing) as CreateThingPayload ) } else if (singleEntity === 'location') { result = await createLocation( normalizeEntityPayload( 'location', singleDraft.location - ) as CreateLocationPayload, - token ?? undefined + ) as CreateLocationPayload ) } else if (singleEntity === 'sensor') { result = await createSensor( normalizeEntityPayload( 'sensor', singleDraft.sensor - ) as CreateSensorPayload, - token ?? undefined + ) as CreateSensorPayload ) } else if (singleEntity === 'datastream') { result = await createDatastream( normalizeEntityPayload( 'datastream', singleDraft.datastream - ) as CreateDatastreamPayload, - token ?? undefined + ) as CreateDatastreamPayload ) } else { result = await createObservedProperty( normalizeEntityPayload( 'observedProperty', singleDraft.observedProperty - ) as CreateObservedPropertyPayload, - token ?? undefined + ) as CreateObservedPropertyPayload ) } diff --git a/ui/services/auth.ts b/ui/services/auth.ts index 48755c0f..c64eb80a 100644 --- a/ui/services/auth.ts +++ b/ui/services/auth.ts @@ -15,7 +15,52 @@ // limitations under the License. import { withAuthHeaders } from '@/services/fetch' +import { cookies } from 'next/headers' + import { siteConfig } from '@/config/site' +import { getTokenUsername, isTokenExpired } from '@/lib/auth' + +const TOKEN_COOKIE_NAME = 'token' + +const baseCookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/', +} + +function getMaxAgeFromToken(token: string) { + try { + const payloadBase64 = token.split('.')[1] + if (!payloadBase64) return undefined + + const payload = JSON.parse(Buffer.from(payloadBase64, 'base64').toString()) + const now = Math.floor(Date.now() / 1000) + return typeof payload?.exp === 'number' + ? Math.max(payload.exp - now, 0) + : undefined + } catch { + return undefined + } +} + +export async function getSession() { + if (!siteConfig.authorizationEnabled) { + return { authenticated: true, username: null } + } + + const cookieStore = await cookies() + const token = cookieStore.get(TOKEN_COOKIE_NAME)?.value ?? null + + if (!token || isTokenExpired(token)) { + return { authenticated: false, username: null } + } + + return { + authenticated: true, + username: getTokenUsername(token), + } +} export async function login(username: string, password: string) { try { @@ -30,23 +75,48 @@ export async function login(username: string, password: string) { }) if (!response.ok) throw new Error('Login failed') const data = await response.json() - return data + + if (!data?.access_token) { + throw new Error('Login response missing access token') + } + + const maxAge = getMaxAgeFromToken(data.access_token) + const cookieStore = await cookies() + cookieStore.set(TOKEN_COOKIE_NAME, data.access_token, { + ...baseCookieOptions, + ...(typeof maxAge === 'number' ? { maxAge } : {}), + }) + + return { + success: true, + username: getTokenUsername(data.access_token), + } } catch (e) { console.error(e) return null } } -export async function refresh(token: string) { +export async function refresh(token?: string | null) { if (!siteConfig.authorizationEnabled) return null try { const response = await fetch(`${siteConfig.api_root}/Refresh`, { method: 'POST', - headers: withAuthHeaders(token), + headers: await withAuthHeaders(token), }) if (!response.ok) throw new Error('Refresh failed') const data = await response.json() + + if (data?.access_token) { + const maxAge = getMaxAgeFromToken(data.access_token) + const cookieStore = await cookies() + cookieStore.set(TOKEN_COOKIE_NAME, data.access_token, { + ...baseCookieOptions, + ...(typeof maxAge === 'number' ? { maxAge } : {}), + }) + } + return data.access_token } catch (e) { console.error(e) @@ -54,18 +124,26 @@ export async function refresh(token: string) { } } -export async function logout(token: string) { +export async function logout(token?: string | null) { if (!siteConfig.authorizationEnabled) return true try { const response = await fetch(`${siteConfig.api_root}/Logout`, { method: 'POST', - headers: withAuthHeaders(token), + headers: await withAuthHeaders(token), }) if (!response.ok) throw new Error('Logout failed') + + const cookieStore = await cookies() + cookieStore.delete(TOKEN_COOKIE_NAME) + return true } catch (e) { console.error(e) + + const cookieStore = await cookies() + cookieStore.delete(TOKEN_COOKIE_NAME) + return false } } diff --git a/ui/services/datastreams.ts b/ui/services/datastreams.ts index 7d2fd743..d9ee8e9a 100644 --- a/ui/services/datastreams.ts +++ b/ui/services/datastreams.ts @@ -53,7 +53,7 @@ export async function createDatastream( ) { try { const { commitMessage, ...datastreamPayload } = payload - const headers = withAuthHeaders(token, { + const headers = await withAuthHeaders(token, { 'Content-Type': 'application/json', }) diff --git a/ui/services/fetch.ts b/ui/services/fetch.ts index b215dc1f..d91c7b7d 100644 --- a/ui/services/fetch.ts +++ b/ui/services/fetch.ts @@ -1,3 +1,5 @@ +"use server" + // Copyright 2026 SUPSI // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -11,17 +13,28 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { cookies } from 'next/headers' + import { siteConfig } from '@/config/site' -export function withAuthHeaders( +export async function resolveServerToken(token?: string | null) { + if (!siteConfig.authorizationEnabled) return null + if (token) return token + + const cookieStore = await cookies() + return cookieStore.get('token')?.value ?? null +} + +export async function withAuthHeaders( token?: string | null, headers: Record = {} ) { - if (!siteConfig.authorizationEnabled || !token) return headers + const resolvedToken = await resolveServerToken(token) + if (!siteConfig.authorizationEnabled || !resolvedToken) return headers return { ...headers, - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${resolvedToken}`, } } @@ -29,7 +42,7 @@ export const fetchData = async (endpoint: string, token?: string | null) => { try { const response = await fetch(endpoint, { method: 'GET', - headers: withAuthHeaders(token), + headers: await withAuthHeaders(token), }) if (!response.ok) { throw new Error( diff --git a/ui/services/locations.ts b/ui/services/locations.ts index b260a6c3..47b49df8 100644 --- a/ui/services/locations.ts +++ b/ui/services/locations.ts @@ -51,7 +51,7 @@ export async function createLocation( ) { try { const { commitMessage, ...locationPayload } = payload - const headers = withAuthHeaders(token, { + const headers = await withAuthHeaders(token, { 'Content-Type': 'application/json', }) diff --git a/ui/services/observedProperties.ts b/ui/services/observedProperties.ts index 1bfa2e10..0854029c 100644 --- a/ui/services/observedProperties.ts +++ b/ui/services/observedProperties.ts @@ -47,7 +47,7 @@ export async function createObservedProperty( ) { try { const { commitMessage, ...observedPropertyPayload } = payload - const headers = withAuthHeaders(token, { + const headers = await withAuthHeaders(token, { 'Content-Type': 'application/json', }) diff --git a/ui/services/sensors.ts b/ui/services/sensors.ts index e4584211..0105f952 100644 --- a/ui/services/sensors.ts +++ b/ui/services/sensors.ts @@ -46,7 +46,7 @@ export async function createSensor( ) { try { const { commitMessage, ...sensorPayload } = payload - const headers = withAuthHeaders(token, { + const headers = await withAuthHeaders(token, { 'Content-Type': 'application/json', }) diff --git a/ui/services/things.ts b/ui/services/things.ts index 3c898b5f..281ef333 100644 --- a/ui/services/things.ts +++ b/ui/services/things.ts @@ -79,7 +79,7 @@ export async function createThing( ) { try { const { commitMessage, ...thingPayload } = payload - const headers = withAuthHeaders(token, { + const headers = await withAuthHeaders(token, { 'Content-Type': 'application/json', })