diff --git a/.env.development b/.env.development index 2d03c83..2821c16 100644 --- a/.env.development +++ b/.env.development @@ -1,5 +1,5 @@ -OIDC_AUTHORITY_URL=http://localhost:3000/ -OIDC_CLIENT_ID=hCOEcK3ntyBym-uLQkogX6w8457kicVlZbY0PQZJusw +OIDC_AUTHORITY_URL=https://auth-staging.initlab.org/ +OIDC_CLIENT_ID=270767587204382721@access_control PORTIER_URL=http://localhost:4000/ MQTT_PROXY_URL=https://mqtt.initlab.org/ PRESENCE_URL=http://localhost:3000/ diff --git a/package.json b/package.json index b65a053..8d6726b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "date-fns": "^4.1.0", "i18next": "^23.15.1", "i18next-http-backend": "^2.6.1", - "js-pkce": "^1.4.0", "js-yaml": "^4.1.0", "oidc-client-ts": "^3.0.1", "prop-types": "^15.8.1", diff --git a/src/App.jsx b/src/App.jsx index 8b68d58..1cd2140 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,14 +7,12 @@ import NavBar from './layout/NavBar.jsx'; import Footer from './layout/Footer.jsx'; import Sensors from './pages/Sensors.jsx'; import Doors from './pages/Doors.jsx'; -import OauthCallback from './pages/OauthCallback.jsx'; -import Logout from './pages/Logout.jsx'; -import Login from './pages/Login.jsx'; -import RequireLoggedIn from './widgets/Route/RequireLoggedIn.jsx'; import ActionLog from './pages/ActionLog.jsx'; import Lights from './pages/Lights.jsx'; import Hvac from './pages/Hvac.jsx'; import { useVariant } from './hooks/useVariant.js'; +import { useAuth } from 'react-oidc-context'; +import i18n from './i18n.js'; function App() { const variant = useVariant(); @@ -25,26 +23,29 @@ function App() { } }, [variant]); + const auth = useAuth(); + + useEffect(function() { + if (!auth.isAuthenticated) { + return ; + } + + (async () => { + await i18n.changeLanguage(auth.user.profile?.locale ?? 'bg'); + })(); + }, [auth]); + return (<>
} /> - - - } /> - - - } /> - - - } /> + } /> + } /> + } /> } /> } /> - } /> - } /> - } />
diff --git a/src/config.js b/src/config.js index 59af4f3..003c7aa 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,17 @@ +import { WebStorageStateStore } from 'oidc-client-ts'; + export const oidc = { authority: import.meta.env.OIDC_AUTHORITY_URL, client_id: import.meta.env.OIDC_CLIENT_ID, - redirect_uri: window.location.protocol + '//' + window.location.host + import.meta.env.BASE_URL + 'oauth-callback', + redirect_uri: `${window.location.protocol}//${window.location.host}${import.meta.env.BASE_URL}`, + scope: 'openid profile offline_access urn:zitadel:iam:org:project:id:zitadel:aud urn:zitadel:iam:org:projects:roles', + onSigninCallback: () => { + window.history.replaceState({}, document.title, window.location.pathname); + }, + userStore: new WebStorageStateStore({ + store: window.localStorage, + }), + loadUserInfo: true, }; export const sensors = { diff --git a/src/hooks/useActionLog.js b/src/hooks/useActionLog.js index 4c0fa5d..d666d6c 100644 --- a/src/hooks/useActionLog.js +++ b/src/hooks/useActionLog.js @@ -1,5 +1,5 @@ import { useAuthenticatedSWR } from './useAuthenticatedSWR.js'; -export function useActionLog(config) { - return useAuthenticatedSWR(import.meta.env.PORTIER_URL.concat('api/actionLog/0/0'), config); +export function useActionLog(config = {}) { + return useAuthenticatedSWR(`${import.meta.env.PORTIER_URL}api/actionLog/0/0`, config); } diff --git a/src/hooks/useAuthStorage.js b/src/hooks/useAuthStorage.js deleted file mode 100644 index afb7ace..0000000 --- a/src/hooks/useAuthStorage.js +++ /dev/null @@ -1,95 +0,0 @@ -import { useLocalStorage } from '@uidotdev/usehooks'; -import { scopes } from '../oauth.js'; - -const STORAGE_KEY = 'tokens'; - -const ACCESS_TOKEN_KEY = 'accessToken'; -const ACCESS_TOKEN_EXPIRE_KEY = 'accessTokenExpire'; -const REFRESH_TOKEN_KEY = 'refreshToken'; - -const defaultTokensValue = {}; - -function parseTokenResponse(tokenResponse) { - if (Object.prototype.hasOwnProperty.call(tokenResponse, 'error') && Object.prototype.hasOwnProperty.call(tokenResponse, 'error_description')) { - throw new Error(tokenResponse.error_description); - } - - if ( - !Object.prototype.hasOwnProperty.call(tokenResponse, 'access_token') || - !Object.prototype.hasOwnProperty.call(tokenResponse, 'created_at') || - !Object.prototype.hasOwnProperty.call(tokenResponse, 'expires_in') || - !Object.prototype.hasOwnProperty.call(tokenResponse, 'refresh_token') || - !Object.prototype.hasOwnProperty.call(tokenResponse, 'scope') || - !Object.prototype.hasOwnProperty.call(tokenResponse, 'token_type') || - tokenResponse.token_type !== 'Bearer' || - tokenResponse.scope !== scopes - ) { - throw new Error('Incomplete response, missing fields'); - } - - return { - [ACCESS_TOKEN_KEY]: tokenResponse.access_token, - [ACCESS_TOKEN_EXPIRE_KEY]: (tokenResponse.created_at + tokenResponse.expires_in) * 1_000, - [REFRESH_TOKEN_KEY]: tokenResponse.refresh_token, - }; -} - -export function useAuthStorage() { - const [ tokens, setTokens ] = useLocalStorage(STORAGE_KEY, defaultTokensValue); - - function updateTokens(tokenResponse) { - setTokens(parseTokenResponse(tokenResponse)); - } - - function clearTokens() { - setTokens(); - } - - return { - accessToken: tokens?.[ACCESS_TOKEN_KEY], - accessTokenExpire: tokens?.[ACCESS_TOKEN_EXPIRE_KEY], - refreshToken: tokens?.[REFRESH_TOKEN_KEY], - updateTokens, - clearTokens, - }; -} - -function getStorageItem(key) { - return JSON.parse(localStorage.getItem(STORAGE_KEY))?.[key]; -} - -function setStorageValue(value) { - const key = STORAGE_KEY; - const newValue = JSON.stringify(value || defaultTokensValue); - localStorage.setItem(key, newValue); - window.dispatchEvent(new StorageEvent('storage', { - key, - newValue, - })); -} - -export function getAccessToken() { - return getStorageItem(ACCESS_TOKEN_KEY); -} - -function getAccessTokenExpire() { - return getStorageItem(ACCESS_TOKEN_EXPIRE_KEY); -} - -export function isAccessTokenExpired() { - const accessTokenExpire = getAccessTokenExpire(); - - return typeof accessTokenExpire !== 'number' || accessTokenExpire < Date.now(); -} - -export function getRefreshToken() { - return getStorageItem(REFRESH_TOKEN_KEY); -} - -export function updateTokens(tokenResponse) { - setStorageValue(parseTokenResponse(tokenResponse)); -} - -export function clearTokens() { - setStorageValue(); -} diff --git a/src/hooks/useAuthenticatedSWR.js b/src/hooks/useAuthenticatedSWR.js index 0f9bbc9..c4d2d3e 100644 --- a/src/hooks/useAuthenticatedSWR.js +++ b/src/hooks/useAuthenticatedSWR.js @@ -1,10 +1,10 @@ import useSWR from 'swr'; import { authenticatedFetcher } from '../utils/swr.js'; -import { useAuthStorage } from './useAuthStorage.js'; +import { useAuth } from 'react-oidc-context'; -export function useAuthenticatedSWR(key, config) { - const { accessToken } = useAuthStorage(); - const hasAccessToken = !!accessToken; +export function useAuthenticatedSWR(key, config = {}) { + const auth = useAuth(); + const token = auth.user?.access_token; - return useSWR(hasAccessToken ? key : null, authenticatedFetcher, config); + return useSWR([key, token], authenticatedFetcher, config); } diff --git a/src/hooks/useAuthenticatedSWRMutation.js b/src/hooks/useAuthenticatedSWRMutation.js new file mode 100644 index 0000000..1b2b961 --- /dev/null +++ b/src/hooks/useAuthenticatedSWRMutation.js @@ -0,0 +1,12 @@ +import useSWRMutation from 'swr/mutation'; +import { authenticatedFetcher } from '../utils/swr.js'; +import { useAuth } from 'react-oidc-context'; + +export function useAuthenticatedSWRMutation(key, config = {}) { + const auth = useAuth(); + const token = auth.user?.access_token; + + return useSWRMutation([key, token, { + method: 'POST', + }], authenticatedFetcher, config); +} diff --git a/src/hooks/useCurrentUser.js b/src/hooks/useCurrentUser.js deleted file mode 100644 index 04c3390..0000000 --- a/src/hooks/useCurrentUser.js +++ /dev/null @@ -1,5 +0,0 @@ -import { useAuthenticatedSWR } from './useAuthenticatedSWR.js'; - -export function useCurrentUser(config) { - return useAuthenticatedSWR(import.meta.env.OIDC_AUTHORITY_URL.concat('api/current_user'), config); -} diff --git a/src/hooks/useDeviceAction.js b/src/hooks/useDeviceAction.js index 1d78aab..d33cc24 100644 --- a/src/hooks/useDeviceAction.js +++ b/src/hooks/useDeviceAction.js @@ -1,24 +1,7 @@ -import { authenticatedFetcher } from '../utils/swr.js'; +import { useAuthenticatedSWRMutation } from './useAuthenticatedSWRMutation.js'; export default function useDeviceAction(deviceId, action) { - const url = import.meta.env.PORTIER_URL.concat('api/device/').concat(deviceId).concat('/').concat(action); - let error = null; + const url = `${import.meta.env.PORTIER_URL}api/device/${deviceId}/${action}`; - async function execute() { - try { - return await authenticatedFetcher(url, { - method: 'POST', - }); - } - catch (e) { - error = { - status: e.status, - }; - } - } - - return { - execute, - error, - }; + return useAuthenticatedSWRMutation(url); } diff --git a/src/hooks/useDevices.js b/src/hooks/useDevices.js index 607791d..ad3c067 100644 --- a/src/hooks/useDevices.js +++ b/src/hooks/useDevices.js @@ -1,5 +1,5 @@ import { useAuthenticatedSWR } from './useAuthenticatedSWR.js'; -export function useDevices(config) { - return useAuthenticatedSWR(import.meta.env.PORTIER_URL.concat('api/devices'), config); +export function useDevices(config = {}) { + return useAuthenticatedSWR(`${import.meta.env.PORTIER_URL}api/devices`, config); } diff --git a/src/hooks/useMqttStatus.js b/src/hooks/useMqttStatus.js index 952f482..21e3d94 100644 --- a/src/hooks/useMqttStatus.js +++ b/src/hooks/useMqttStatus.js @@ -2,5 +2,5 @@ import useSWR from 'swr'; import { fetcher } from '../utils/swr.js'; export function useMqttStatus(config) { - return useSWR(import.meta.env.MQTT_PROXY_URL.concat('status'), fetcher, config); + return useSWR(`${import.meta.env.MQTT_PROXY_URL}status`, fetcher, config); } diff --git a/src/hooks/usePresentUsers.js b/src/hooks/usePresentUsers.js index 93f8003..09069f4 100644 --- a/src/hooks/usePresentUsers.js +++ b/src/hooks/usePresentUsers.js @@ -2,5 +2,5 @@ import useSWR from 'swr'; import { fetcher } from '../utils/swr.js'; export function usePresentUsers(config) { - return useSWR(import.meta.env.PRESENCE_URL.concat('api/users/present'), fetcher, config); + return useSWR(`${import.meta.env.PRESENCE_URL}api/users/present`, fetcher, config); } diff --git a/src/hooks/useRememberPage.js b/src/hooks/useRememberPage.js deleted file mode 100644 index a5ae833..0000000 --- a/src/hooks/useRememberPage.js +++ /dev/null @@ -1,54 +0,0 @@ -import { useLocation, useNavigate } from 'react-router-dom'; - -const STATE_KEY = 'from'; -const STORAGE_KEY = 'redirectAfterLogin'; - -export function useRememberPage() { - const location = useLocation(); - const navigate = useNavigate(); - - function getPreviousPath() { - return location.state?.[STATE_KEY]?.pathname; - } - - function storePreviousPath() { - const previousPath = getPreviousPath(); - - if (previousPath) { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ - path: previousPath, - expiresAt: Date.now() + 30 * 60 * 1_000, - })); - } - else { - localStorage.removeItem(STORAGE_KEY); - } - } - - function navigateToPreviousPath(returnPath = '/') { - const redirectInfo = localStorage.getItem(STORAGE_KEY); - - if (redirectInfo) { - const { - path, - expiresAt, - } = JSON.parse(redirectInfo); - - if (expiresAt > Date.now()) { - returnPath = path; - } - - localStorage.removeItem(STORAGE_KEY); - } - - navigate(returnPath, { - replace: true, - }); - } - - return { - getPreviousPath, - storePreviousPath, - navigateToPreviousPath, - }; -} diff --git a/src/i18n.js b/src/i18n.js index 4bf10ec..82f03d6 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -9,7 +9,7 @@ i18n .init({ fallbackLng: 'bg', backend: { - loadPath: import.meta.env.BASE_URL + 'locales/{{lng}}.yaml', + loadPath: `${import.meta.env.BASE_URL}locales/{{lng}}.yaml`, parse: data => load(data), }, interpolation: { diff --git a/src/layout/NavBar.jsx b/src/layout/NavBar.jsx index ae59a17..bd46f33 100644 --- a/src/layout/NavBar.jsx +++ b/src/layout/NavBar.jsx @@ -1,5 +1,4 @@ -import { useEffect } from 'react'; -import { NavLink, useLocation } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; import { Container, Image, Nav, Navbar, NavDropdown } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; @@ -7,29 +6,19 @@ import './NavBar.css'; import initLabLogo from '../assets/initlab/logo.svg'; import colibriLogo from '../assets/colibri/logo.png'; import DoorClosedIcon from '../widgets/icons/DoorClosedIcon.jsx'; -import i18n from '../i18n.js'; import { useVariant } from '../hooks/useVariant.js'; -import { useCurrentUser } from '../hooks/useCurrentUser.js'; import RequireRole from '../widgets/RequireRole.jsx'; +import LoadingIcon from '../widgets/icons/LoadingIcon.jsx'; +import { useAuth } from 'react-oidc-context'; const NavBar = () => { const {t} = useTranslation(); - const backendUrl = import.meta.env.OIDC_AUTHORITY_URL; - const { - data: user, - } = useCurrentUser(); + const auth = useAuth(); + const oidcAuthorityUrl = import.meta.env.OIDC_AUTHORITY_URL; const variant = useVariant(); const isInitLab = variant === 'initlab'; const isColibri = variant === 'colibri'; - useEffect(function() { - if (user?.locale) { - i18n.changeLanguage(user.locale).then(() => {}); - } - }, [user]); - - const location = useLocation(); - return ( { - + {' '} {t('views.navigation.labbers')} - {user ? + {auth.isLoading ? + + : (auth.isAuthenticated ? {' '} {t('views.navigation.account')} } className="ms-0 ms-lg-auto"> - + {t('views.navigation.view_edit')} {isInitLab && <> - + {t('views.navigation.network_devices')} - - {t('views.navigation.oauth_application_management')} - - + {t('views.navigation.oauth_token_management')} } - + void auth.removeUser()}> {t('views.navigation.sign_out')} - : + : void auth.signinRedirect()} className="ms-0 ms-lg-auto"> {' '} {t('views.navigation.sign_in')} - } + )} diff --git a/src/oauth.js b/src/oauth.js deleted file mode 100644 index ca7ecad..0000000 --- a/src/oauth.js +++ /dev/null @@ -1,59 +0,0 @@ -import PKCE from 'js-pkce'; -import { clearTokens, getAccessToken, getRefreshToken, updateTokens } from './hooks/useAuthStorage.js'; - -const clientId = import.meta.env.OIDC_CLIENT_ID; -const baseUrl = import.meta.env.OIDC_AUTHORITY_URL + 'oauth/'; - -const urls = { - authorize: baseUrl + 'authorize', - token: baseUrl + 'token', - revoke: baseUrl + 'revoke', -}; - -export const scopes = ['account_data_read'].join(' '); - -const pkce = new PKCE({ - client_id: clientId, - redirect_uri: window.location.protocol + '//' + window.location.host + import.meta.env.BASE_URL + 'oauth-callback', - authorization_endpoint: urls.authorize, - token_endpoint: urls.token, - requested_scopes: scopes, -}); - -export const authorizeUrl = () => pkce.authorizeUrl(); -export const exchangeForAccessToken = async () => pkce.exchangeForAccessToken(window.location.href); - -export async function revokeToken(token) { - return fetch(urls.revoke, { - method: 'POST', - headers: { - 'Authorization': 'Bearer ' + getAccessToken(), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: clientId, - token, - }), - }); -} - -export async function refreshTokenIfNeeded() { - const refreshToken = getRefreshToken(); - - if (!refreshToken) { - clearTokens(); - console.error('No refresh token found'); - return false; - } - - try { - updateTokens(await pkce.refreshAccessToken(refreshToken)); - } - catch (e) { - clearTokens(); - console.error(e); - return false; - } - - return true; -} diff --git a/src/pages/ActionLog.jsx b/src/pages/ActionLog.jsx index 88a43cf..af618cc 100644 --- a/src/pages/ActionLog.jsx +++ b/src/pages/ActionLog.jsx @@ -7,6 +7,7 @@ import { useActionLog } from '../hooks/useActionLog.js'; import LoadingIcon from '../widgets/icons/LoadingIcon.jsx'; import ErrorMessage from '../widgets/ErrorMessage.jsx'; import ActionLogEntry from '../widgets/ActionLog/ActionLogEntry.jsx'; +import { withAuthenticationRequired } from 'react-oidc-context'; const ActionLog = () => { const { t } = useTranslation(); @@ -23,7 +24,7 @@ const ActionLog = () => { }); if (!hasAccess) { - return (); + return (); } return (<> @@ -51,4 +52,6 @@ const ActionLog = () => { ); }; -export default ActionLog; +const AuthenticatedActionLog = withAuthenticationRequired(ActionLog); + +export default AuthenticatedActionLog; diff --git a/src/pages/Devices.jsx b/src/pages/Devices.jsx index ef97702..3746bee 100644 --- a/src/pages/Devices.jsx +++ b/src/pages/Devices.jsx @@ -58,7 +58,7 @@ const Devices = ({ }) : - {t('views.' + deviceType + '.no_access')} + {t(`views.${deviceType}.no_access`)} } diff --git a/src/pages/Doors.jsx b/src/pages/Doors.jsx index 10a4fb1..44fe313 100644 --- a/src/pages/Doors.jsx +++ b/src/pages/Doors.jsx @@ -1,8 +1,11 @@ import { getDoorActions } from '../utils/device.js'; import Devices from './Devices.jsx'; +import { withAuthenticationRequired } from 'react-oidc-context'; const Doors = () => { return (); }; -export default Doors; +const AuthenticatedDoors = withAuthenticationRequired(Doors); + +export default AuthenticatedDoors; diff --git a/src/pages/Lights.jsx b/src/pages/Lights.jsx index e627a50..ea854c4 100644 --- a/src/pages/Lights.jsx +++ b/src/pages/Lights.jsx @@ -1,8 +1,11 @@ import { getLightActions } from '../utils/device.js'; import Devices from './Devices.jsx'; +import { withAuthenticationRequired } from 'react-oidc-context'; const Lights = () => { return (); }; -export default Lights; +const AuthenticatedLights = withAuthenticationRequired(Lights); + +export default AuthenticatedLights; diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx deleted file mode 100644 index 6912dd7..0000000 --- a/src/pages/Login.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { Col, Row } from 'react-bootstrap'; - -import LoadingIcon from '../widgets/icons/LoadingIcon.jsx'; -import { authorizeUrl } from '../oauth.js'; -import { useRememberPage } from '../hooks/useRememberPage.js'; - -const Login = () => { - const flag = useRef(false); - const { storePreviousPath } = useRememberPage(); - - useEffect(() => { - if (flag.current) { - return; - } - - flag.current = true; - - storePreviousPath(); - window.location.replace(authorizeUrl()); - }, [storePreviousPath]); - - return ( - - - - ); -}; - -export default Login; diff --git a/src/pages/Logout.jsx b/src/pages/Logout.jsx deleted file mode 100644 index 2052e5e..0000000 --- a/src/pages/Logout.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Col, Row } from 'react-bootstrap'; - -import LoadingIcon from '../widgets/icons/LoadingIcon.jsx'; -import { getAccessToken, getRefreshToken, useAuthStorage } from '../hooks/useAuthStorage.js'; -import { useRememberPage } from '../hooks/useRememberPage.js'; -import { revokeToken } from '../oauth.js'; - -const Logout = () => { - const flag = useRef(false); - const navigate = useNavigate(); - const { clearTokens } = useAuthStorage(); - const { getPreviousPath } = useRememberPage(); - - useEffect(() => { - if (flag.current) { - return; - } - - flag.current = true; - - (async () => { - await revokeToken(getRefreshToken()); - await revokeToken(getAccessToken()); - clearTokens(); - navigate(getPreviousPath() || '/', { - replace: true, - }); - })(); - }, [clearTokens, getPreviousPath, navigate]); - - return ( - - - - ); -}; - -export default Logout; diff --git a/src/pages/OauthCallback.jsx b/src/pages/OauthCallback.jsx deleted file mode 100644 index b1644f5..0000000 --- a/src/pages/OauthCallback.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Col, Row } from 'react-bootstrap'; - -import { exchangeForAccessToken } from '../oauth.js'; -import { useAuthStorage } from '../hooks/useAuthStorage.js'; -import LoadingIcon from '../widgets/icons/LoadingIcon.jsx'; -import { useRememberPage } from '../hooks/useRememberPage.js'; - -const OauthCallback = () => { - const flag = useRef(false); - const [errorMessage, setErrorMessage] = useState(null); - const { updateTokens } = useAuthStorage(); - const { navigateToPreviousPath } = useRememberPage(); - - useEffect(() => { - if (flag.current) { - return; - } - - flag.current = true; - - (async () => { - const tokenResponse = await exchangeForAccessToken(); - - try { - updateTokens(tokenResponse); - navigateToPreviousPath(); - } - catch (e) { - setErrorMessage(e.message); - } - })(); - }, [navigateToPreviousPath, updateTokens]); - - return (errorMessage || - - - - ); -}; - -export default OauthCallback; diff --git a/src/pages/Sensors.jsx b/src/pages/Sensors.jsx index 208c570..430a97e 100644 --- a/src/pages/Sensors.jsx +++ b/src/pages/Sensors.jsx @@ -22,8 +22,8 @@ const Sensors = () => { {grafana.panels.map(panelId => -