diff --git a/.env b/.env index ae2999f..1b4611c 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -BACKEND_URL= -DEVICE_BACKEND_URL= -MQTT_BACKEND_URL= -OAUTH_CLIENT_ID= +OIDC_AUTHORITY_URL= +OIDC_CLIENT_ID= +PORTIER_URL= +MQTT_PROXY_URL= diff --git a/.env.development b/.env.development index b95b071..a99a94a 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,4 @@ -BACKEND_URL=http://localhost:3000/ -DEVICE_BACKEND_URL=http://localhost:4000/ -MQTT_BACKEND_URL=https://mqtt.initlab.org/ -OAUTH_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/ diff --git a/.env.production b/.env.production index b4f0a05..cd52384 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,4 @@ -BACKEND_URL=https://fauna.initlab.org/ -DEVICE_BACKEND_URL=https://portier.initlab.org/ -MQTT_BACKEND_URL=https://mqtt.initlab.org/ -OAUTH_CLIENT_ID=YueB6ct6SKPN8Ar72G0LC1QFxW9meUDQIOHdAu5mfCE +OIDC_AUTHORITY_URL=https://fauna.initlab.org/ +OIDC_CLIENT_ID=YueB6ct6SKPN8Ar72G0LC1QFxW9meUDQIOHdAu5mfCE +PORTIER_URL=https://portier.initlab.org/ +MQTT_PROXY_URL=https://mqtt.initlab.org/ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index acae0a3..cabab4c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,7 +36,7 @@ jobs: with: cmd: build env: - OAUTH_CLIENT_ID: ERBrlPWjf98L_nI8Moxr7Aqjy4no1eRU-zSOROc2RbU + OIDC_CLIENT_ID: ERBrlPWjf98L_nI8Moxr7Aqjy4no1eRU-zSOROc2RbU - name: Upload production-ready build files uses: actions/upload-artifact@v3 diff --git a/package.json b/package.json index 381d05a..6e054b3 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,14 @@ "date-fns": "^3.6.0", "i18next": "^23.11.5", "i18next-http-backend": "^2.5.2", - "js-pkce": "^1.4.0", "js-yaml": "^4.1.0", + "oidc-client-ts": "^3.0.1", "prop-types": "^15.8.1", "react": "^18.3.1", "react-bootstrap": "^2.10.2", "react-dom": "^18.3.1", "react-i18next": "^14.1.2", + "react-oidc-context": "^3.1.0", "react-redux": "^9.1.2", "react-router-dom": "^6.23.1", "sass": "~1.77.4", diff --git a/src/App.jsx b/src/App.jsx index 44590c4..acdbad2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,15 +4,13 @@ import Footer from './layout/Footer'; import { Route, Routes } from 'react-router-dom'; import Sensors from './pages/Sensors'; import Doors from './pages/Doors.jsx'; -import OauthCallback from './pages/OauthCallback.jsx'; -import Logout from './pages/Logout.jsx'; import { Container } from 'react-bootstrap'; -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 { useVariant } from './hooks/useVariant.js'; import { useEffect } from 'react'; +import { useGetUserInfoQuery } from './features/apiSlice.js'; +import i18n from './i18n.js'; function App() { const variant = useVariant(); @@ -23,23 +21,28 @@ function App() { } }, [variant]); + const { + data: user, + } = useGetUserInfoQuery(); + + useEffect(function() { + if (user?.locale) { + (async () => { + await i18n.changeLanguage(user.locale); + })(); + } + }, [user?.locale]); + return (<>
} /> - - - } /> - - - } /> + } /> + } /> } /> } /> - } /> - } /> - } />
diff --git a/src/app/store.js b/src/app/store.js index 216b652..c4a2470 100644 --- a/src/app/store.js +++ b/src/app/store.js @@ -1,22 +1,20 @@ import { configureStore } from '@reduxjs/toolkit'; import { setupListeners } from '@reduxjs/toolkit/query'; import { - anonymousApiSlice, anonymousMqttApiSlice, - authenticatedApiSlice, - authenticatedDeviceApiSlice + authenticatedOidcAuthorityApiSlice, + authenticatedPortierApiSlice, } from '../features/apiSlice'; export const store = configureStore({ reducer: { - [anonymousApiSlice.reducerPath]: anonymousApiSlice.reducer, + [authenticatedPortierApiSlice.reducerPath]: authenticatedPortierApiSlice.reducer, + [authenticatedOidcAuthorityApiSlice.reducerPath]: authenticatedOidcAuthorityApiSlice.reducer, [anonymousMqttApiSlice.reducerPath]: anonymousMqttApiSlice.reducer, - [authenticatedApiSlice.reducerPath]: authenticatedApiSlice.reducer, - [authenticatedDeviceApiSlice.reducerPath]: authenticatedDeviceApiSlice.reducer, }, middleware: getDefaultMiddleware => - getDefaultMiddleware().concat(anonymousApiSlice.middleware).concat(anonymousMqttApiSlice.middleware) - .concat(authenticatedApiSlice.middleware).concat(authenticatedDeviceApiSlice.middleware), + getDefaultMiddleware().concat(authenticatedPortierApiSlice.middleware) + .concat(authenticatedOidcAuthorityApiSlice.middleware).concat(anonymousMqttApiSlice.middleware), }); setupListeners(store.dispatch); diff --git a/src/config.js b/src/config.js index e95b6d0..28fdf96 100644 --- a/src/config.js +++ b/src/config.js @@ -1,3 +1,18 @@ +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, + 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, + }), +}; + export const sensors = { 'sensors/big-room/temperature': { type: 'Temperature', diff --git a/src/features/apiSlice.js b/src/features/apiSlice.js index b2db772..b25a602 100644 --- a/src/features/apiSlice.js +++ b/src/features/apiSlice.js @@ -1,113 +1,57 @@ +import { oidc as oidcConfig } from '../config.js'; +import { User } from 'oidc-client-ts'; import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; -import { getAccessToken, isAccessTokenExpired } from '../hooks/useAuthStorage.js'; -import { refreshTokenIfNeeded } from '../oauth.js'; -const apiBaseUrl = import.meta.env.BACKEND_URL + 'api/'; -const deviceApiBaseUrl = import.meta.env.DEVICE_BACKEND_URL + 'api/'; -const mqttApiBaseUrl = import.meta.env.MQTT_BACKEND_URL; +function getAccessToken() { + const oidcStorage = localStorage.getItem(`oidc.user:${oidcConfig.authority}:${oidcConfig.client_id}`) -const anonymousBaseQuery = fetchBaseQuery({ - baseUrl: apiBaseUrl, - prepareHeaders: headers => { - headers.set('accept', 'application/json'); - - return headers; - }, -}); - -const anonymousMqttBaseQuery = fetchBaseQuery({ - baseUrl: mqttApiBaseUrl, -}); - -const authenticatedBaseQuery = fetchBaseQuery({ - baseUrl: apiBaseUrl, - prepareHeaders: headers => { - headers.set('accept', 'application/json'); - - const token = getAccessToken(); - - if (token) { - headers.set('authorization', 'Bearer ' + token); - } - - return headers; - }, -}); - -const authenticatedDeviceBaseQuery = fetchBaseQuery({ - baseUrl: deviceApiBaseUrl, - prepareHeaders: headers => { - headers.set('accept', 'application/json'); - - const token = getAccessToken(); - - if (token) { - headers.set('authorization', 'Bearer ' + token); - } - - return headers; - }, -}); - -const authenticatedBaseQueryWithReauth = async (args, api, extraOptions) => { - if (isAccessTokenExpired()) { - await refreshTokenIfNeeded(); + if (!oidcStorage) { + return null; } - let result = await authenticatedBaseQuery(args, api, extraOptions); - - if (result?.error?.status === 401 && await refreshTokenIfNeeded()) { - return authenticatedBaseQuery(args, api, extraOptions); - } + const user = User.fromStorageString(oidcStorage); - return result; -}; + return user?.access_token; +} -const authenticatedDeviceBaseQueryWithReauth = async (args, api, extraOptions) => { - if (isAccessTokenExpired()) { - await refreshTokenIfNeeded(); - } +function prepareHeaders(headers) { + headers.set('accept', 'application/json'); - let result = await authenticatedDeviceBaseQuery(args, api, extraOptions); + const token = getAccessToken(); - if (result?.error?.status === 401 && await refreshTokenIfNeeded()) { - return authenticatedDeviceBaseQuery(args, api, extraOptions); + if (token) { + headers.set('authorization', 'Bearer ' + token); } - return result; -}; + return headers; +} -const query = builder => url => builder.query({ - query: () => url, +const portierBaseUrl = import.meta.env.PORTIER_URL + 'api/'; +const oidcAuthorityBaseUrl = import.meta.env.OIDC_AUTHORITY_URL; +const mqttProxyBaseUrl = import.meta.env.MQTT_PROXY_URL; + +const authenticatedPortierBaseQuery = fetchBaseQuery({ + baseUrl: portierBaseUrl, + prepareHeaders, }); -export const anonymousApiSlice = createApi({ - reducerPath: 'anonymousApi', - baseQuery: anonymousBaseQuery, - endpoints: builder => ({ - getPresentUsers: query(builder)('users/present'), - }), +const authenticatedOidcAuthorityBaseQuery = fetchBaseQuery({ + baseUrl: oidcAuthorityBaseUrl, + prepareHeaders, }); -export const anonymousMqttApiSlice = createApi({ - reducerPath: 'anonymousMqttApi', - baseQuery: anonymousMqttBaseQuery, - endpoints: builder => ({ - getStatus: query(builder)('status'), - }), +const anonymousMqttProxyBaseQuery = fetchBaseQuery({ + baseUrl: mqttProxyBaseUrl, }); -export const authenticatedApiSlice = createApi({ - reducerPath: 'authenticatedApi', - baseQuery: authenticatedBaseQueryWithReauth, - endpoints: builder => ({ - getCurrentUser: query(builder)('current_user'), - }), + +const query = builder => url => builder.query({ + query: () => url, }); -export const authenticatedDeviceApiSlice = createApi({ - reducerPath: 'authenticatedDeviceApi', - baseQuery: authenticatedDeviceBaseQueryWithReauth, +export const authenticatedPortierApiSlice = createApi({ + reducerPath: 'authenticatedPortierApi', + baseQuery: authenticatedPortierBaseQuery, endpoints: builder => ({ getDevices: query(builder)('devices'), deviceAction: builder.mutation({ @@ -122,20 +66,32 @@ export const authenticatedDeviceApiSlice = createApi({ }), }); -export const { - useGetPresentUsersQuery, -} = anonymousApiSlice; - -export const { - useGetStatusQuery, -} = anonymousMqttApiSlice; +export const authenticatedOidcAuthorityApiSlice = createApi({ + reducerPath: 'authenticatedOidcAuthorityApi', + baseQuery: authenticatedOidcAuthorityBaseQuery, + endpoints: builder => ({ + getUserInfo: query(builder)('oidc/v1/userinfo'), + }), +}); -export const { - useGetCurrentUserQuery, -} = authenticatedApiSlice; +export const anonymousMqttApiSlice = createApi({ + reducerPath: 'anonymousMqttApi', + baseQuery: anonymousMqttProxyBaseQuery, + endpoints: builder => ({ + getStatus: query(builder)('status'), + }), +}); export const { useGetDevicesQuery, useDeviceActionMutation, useGetActionLogQuery, -} = authenticatedDeviceApiSlice; +} = authenticatedPortierApiSlice; + +export const { + useGetUserInfoQuery, +} = authenticatedOidcAuthorityApiSlice; + +export const { + useGetStatusQuery, +} = anonymousMqttApiSlice; diff --git a/src/hooks/useAuthStorage.js b/src/hooks/useAuthStorage.js deleted file mode 100644 index ecf991f..0000000 --- a/src/hooks/useAuthStorage.js +++ /dev/null @@ -1,95 +0,0 @@ -import { scopes } from '../oauth.js'; -import { useLocalStorage } from '@uidotdev/usehooks'; - -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 (tokenResponse.hasOwnProperty('error') && tokenResponse.hasOwnProperty('error_description')) { - throw new Error(tokenResponse.error_description); - } - - if ( - !tokenResponse.hasOwnProperty('access_token') || - !tokenResponse.hasOwnProperty('created_at') || - !tokenResponse.hasOwnProperty('expires_in') || - !tokenResponse.hasOwnProperty('refresh_token') || - !tokenResponse.hasOwnProperty('scope') || - !tokenResponse.hasOwnProperty('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/useCurrentUser.js b/src/hooks/useCurrentUser.js index 77b9075..09571fa 100644 --- a/src/hooks/useCurrentUser.js +++ b/src/hooks/useCurrentUser.js @@ -1,18 +1,19 @@ -import { useAuthStorage } from './useAuthStorage.js'; -import { useGetCurrentUserQuery } from '../features/apiSlice.js'; +import { useAuth } from 'react-oidc-context'; +import { useGetUserInfoQuery } from '../features/apiSlice.js'; export function useCurrentUser() { - const { accessToken } = useAuthStorage(); - const hasAccessToken = !!accessToken; - const queryResult = useGetCurrentUserQuery(undefined, { - skip: !hasAccessToken, + const auth = useAuth(); + const { + data: user, + isError, + isLoading, + } = useGetUserInfoQuery(undefined, { + skip: !auth.isAuthenticated, }); - const user = queryResult.isSuccess ? queryResult.data : {}; - return { - hasAccessToken, - user, - isLoggedIn: user.hasOwnProperty('id'), - ...queryResult, - }; + if (!auth.isAuthenticated || isLoading || isError) { + return null; + } + + return user; } 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/layout/NavBar.jsx b/src/layout/NavBar.jsx index f7b4c9b..47c0069 100644 --- a/src/layout/NavBar.jsx +++ b/src/layout/NavBar.jsx @@ -3,34 +3,21 @@ import './NavBar.css'; import initLabLogo from '../assets/initlab/logo.svg'; import colibriLogo from '../assets/colibri/logo.png'; import { useTranslation } from 'react-i18next'; -import { NavLink, useLocation } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; import DoorClosedIcon from '../widgets/icons/DoorClosedIcon.jsx'; -import { useEffect } from 'react'; -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.BACKEND_URL; - const { - hasAccessToken, - user, - isSuccess, - } = 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 (isSuccess) { - i18n.changeLanguage(user.locale).then(() => {}); - } - }, [user.locale, isSuccess]); - - const location = useLocation(); - return ( { - + {' '} {t('views.navigation.labbers')} - {hasAccessToken ? - {' '} - {t('views.navigation.account')} - } className="ms-0 ms-lg-auto"> - + {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/main.jsx b/src/main.jsx index ffae317..24f6520 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import { store } from './app/store'; import { BrowserRouter } from 'react-router-dom'; +import { oidc as oidcConfig } from './config'; +import { AuthProvider } from 'react-oidc-context'; import './i18n'; import App from './App'; @@ -14,7 +16,9 @@ ReactDOM.createRoot(document.getElementById('root')).render( - + + + , diff --git a/src/oauth.js b/src/oauth.js deleted file mode 100644 index 340e1e4..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.OAUTH_CLIENT_ID; -const baseUrl = import.meta.env.BACKEND_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 383b1b6..908dfdb 100644 --- a/src/pages/ActionLog.jsx +++ b/src/pages/ActionLog.jsx @@ -8,6 +8,7 @@ import LoadingIcon from '../widgets/icons/LoadingIcon.jsx'; import ErrorMessage from '../widgets/ErrorMessage.jsx'; import ActionLogEntry from '../widgets/ActionLog/ActionLogEntry.jsx'; import { useNetworkState } from '@uidotdev/usehooks'; +import { withAuthenticationRequired } from 'react-oidc-context'; const ActionLog = () => { const { t } = useTranslation(); @@ -32,7 +33,7 @@ const ActionLog = () => { }); if (!hasAccess) { - return (); + return (); } return (<> @@ -64,4 +65,4 @@ const ActionLog = () => { ); }; -export default ActionLog; +export default withAuthenticationRequired(ActionLog); diff --git a/src/pages/Devices.jsx b/src/pages/Devices.jsx index 75ca312..60ffd00 100644 --- a/src/pages/Devices.jsx +++ b/src/pages/Devices.jsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'; import ErrorMessage from '../widgets/ErrorMessage.jsx'; import { useVariant } from '../hooks/useVariant.js'; import PropTypes from 'prop-types'; -import { useNetworkState } from '@uidotdev/usehooks'; +// import { useNetworkState } from '@uidotdev/usehooks'; const Devices = ({ deviceType, @@ -15,9 +15,9 @@ const Devices = ({ }) => { const { t } = useTranslation(); - const { - online, - } = useNetworkState(); + // const { + // online, + // } = useNetworkState(); const { data: devices, @@ -26,7 +26,7 @@ const Devices = ({ isSuccess, isError, } = useGetDevicesQuery(undefined, { - pollingInterval: online === false ? 0 : 2_000, + // pollingInterval: online === false ? 0 : 2_000, }); const filteredDevices = useMemo(() => diff --git a/src/pages/Doors.jsx b/src/pages/Doors.jsx index 82195a7..639fd87 100644 --- a/src/pages/Doors.jsx +++ b/src/pages/Doors.jsx @@ -1,9 +1,10 @@ import React from 'react'; import { getDoorActions } from '../utils/device.js'; import Devices from './Devices.jsx'; +import { withAuthenticationRequired } from 'react-oidc-context'; const Doors = () => { return (); }; -export default Doors; +export default withAuthenticationRequired(Doors); diff --git a/src/pages/Lights.jsx b/src/pages/Lights.jsx index 072b7e3..4f604d2 100644 --- a/src/pages/Lights.jsx +++ b/src/pages/Lights.jsx @@ -1,9 +1,10 @@ import React from 'react'; import { getLightActions } from '../utils/device.js'; import Devices from './Devices.jsx'; +import { withAuthenticationRequired } from 'react-oidc-context'; const Lights = () => { return (); }; -export default Lights; +export default withAuthenticationRequired(Lights); diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx deleted file mode 100644 index 7e6adb2..0000000 --- a/src/pages/Login.jsx +++ /dev/null @@ -1,29 +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 e26bf20..0000000 --- a/src/pages/Logout.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useEffect, useRef } from 'react'; -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 { useNavigate } from 'react-router-dom'; -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 4260458..0000000 --- a/src/pages/OauthCallback.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { exchangeForAccessToken } from '../oauth.js'; -import { useAuthStorage } from '../hooks/useAuthStorage.js'; -import { Col, Row } from 'react-bootstrap'; -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/widgets/DeviceActionButton/DeviceActionButton.jsx b/src/widgets/DeviceActionButton/DeviceActionButton.jsx index c17c14a..0d1caff 100644 --- a/src/widgets/DeviceActionButton/DeviceActionButton.jsx +++ b/src/widgets/DeviceActionButton/DeviceActionButton.jsx @@ -3,7 +3,6 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import './DeviceActionButton.scss'; import { useDeviceActionMutation } from '../../features/apiSlice.js'; -import RedirectToLogin from '../RedirectToLogin.jsx'; import { sleep } from '../../utils/time.js'; const types = { @@ -39,10 +38,7 @@ const DeviceActionButton = ({ }) => { const [ disabled, setDisabled ] = useState(false); - const [ execute, { - isError, - error, - } ] = useDeviceActionMutation(); + const [ execute ] = useDeviceActionMutation(); const {t} = useTranslation(); const type = types?.[action] || { @@ -71,7 +67,6 @@ const DeviceActionButton = ({
{label}
- {isError && [401, 403].includes(error.status) && } ); }; diff --git a/src/widgets/PresentUsersWrapper/PresentUsersWrapper.jsx b/src/widgets/PresentUsersWrapper/PresentUsersWrapper.jsx index d0f4ec0..75f1e96 100644 --- a/src/widgets/PresentUsersWrapper/PresentUsersWrapper.jsx +++ b/src/widgets/PresentUsersWrapper/PresentUsersWrapper.jsx @@ -1,30 +1,38 @@ import { Col, Row } from 'react-bootstrap'; import PresentUsers from '../PresentUsers/PresentUsers'; import { useTranslation } from 'react-i18next'; -import { useGetPresentUsersQuery } from '../../features/apiSlice.js'; +// import { useGetPresentUsersQuery } from '../../features/apiSlice.js'; import LoadingIcon from '../icons/LoadingIcon.jsx'; import { useMemo } from 'react'; import { format, formatISO } from 'date-fns'; import ErrorMessage from '../ErrorMessage.jsx'; -import { useNetworkState } from '@uidotdev/usehooks'; +// import { useNetworkState } from '@uidotdev/usehooks'; const PresentUsersWrapper = () => { const { t } = useTranslation(); - const { - online, - } = useNetworkState(); + // const { + // online, + // } = useNetworkState(); - const { - data: users, - error, - isLoading, - isSuccess, - isError, - fulfilledTimeStamp, - } = useGetPresentUsersQuery(undefined, { - pollingInterval: online === false ? 0 : 60_000, - }); + // TODO + // const { + // data: users, + // error, + // isLoading, + // isSuccess, + // isError, + // fulfilledTimeStamp, + // } = useGetPresentUsersQuery(undefined, { + // pollingInterval: online === false ? 0 : 60_000, + // }); + // noinspection JSMismatchedCollectionQueryUpdate + const users = []; + const error = null; + const isLoading = false; + const isSuccess = true; + const isError = false; + const fulfilledTimeStamp = useMemo(() => new Date(), []); const fulfilledTime = useMemo(() => new Date(fulfilledTimeStamp), [fulfilledTimeStamp]); const usersCount = isSuccess ? users.length : 0; diff --git a/src/widgets/RedirectToLogin.jsx b/src/widgets/RedirectToLogin.jsx deleted file mode 100644 index 5533ef0..0000000 --- a/src/widgets/RedirectToLogin.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Navigate, useLocation } from 'react-router-dom'; - -const RedirectToLogin = () => { - const location = useLocation(); - - return (); -}; - -export default RedirectToLogin; diff --git a/src/widgets/RequireRole.jsx b/src/widgets/RequireRole.jsx index 8c5a7dd..ebd29c9 100644 --- a/src/widgets/RequireRole.jsx +++ b/src/widgets/RequireRole.jsx @@ -4,12 +4,11 @@ const RequireRole = ({ children, roles, }) => { - const { - user, - } = useCurrentUser(); + const user = useCurrentUser(); + const userRoles = Object.keys(user?.['urn:zitadel:iam:org:project:roles'] ?? {}); const searchRoles = typeof roles === 'string' ? [roles] : Array.from(roles); - const isAllowed = (user.roles || []).some(userRole => searchRoles.includes(userRole)); + const isAllowed = userRoles.some(userRole => searchRoles.includes(userRole)); return isAllowed ? children : null; }; diff --git a/src/widgets/Route/RequireLoggedIn.jsx b/src/widgets/Route/RequireLoggedIn.jsx deleted file mode 100644 index c1e808c..0000000 --- a/src/widgets/Route/RequireLoggedIn.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useCurrentUser } from '../../hooks/useCurrentUser.js'; -import LoadingIcon from '../icons/LoadingIcon.jsx'; -import RedirectToLogin from '../RedirectToLogin.jsx'; -import React from 'react'; -import { Col, Row } from 'react-bootstrap'; - -export function RequireLoggedIn({ - children, -}) { - const { - isLoading, - isError, - isLoggedIn, - } = useCurrentUser(); - - if (isLoading) { - return ( - - - - ); - } - - if (isError) { - return (); - } - - if (!isLoggedIn) { - return (); - } - - return children; -} diff --git a/vite.config.js b/vite.config.js index 78c979f..58fd721 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,10 +8,10 @@ export default defineConfig({ sourcemap: true, }, envPrefix: [ - 'BACKEND_URL', - 'DEVICE_BACKEND_URL', - 'MQTT_BACKEND_URL', - 'OAUTH_CLIENT_ID', + 'OIDC_AUTHORITY_URL', + 'OIDC_CLIENT_ID', + 'PORTIER_URL', + 'MQTT_PROXY_URL', ], plugins: [ react(), diff --git a/yarn.lock b/yarn.lock index d488807..20b7b98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2165,11 +2165,6 @@ cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -crypto-js@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" - integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== - csstype@^3.0.2: version "3.1.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" @@ -3242,13 +3237,6 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" -js-pkce@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/js-pkce/-/js-pkce-1.4.0.tgz#4b2275c3f8bbd7d4e0f859ff51eea027c8dbbdb3" - integrity sha512-ztPzIgjQ+hY2uvwsTi625Yt/123NODAInGg2Y7aQLlKpNpD16A3WePjKyY2+B8WCoZy1cGG4xC9C68Dt2ZB8iQ== - dependencies: - crypto-js "^4.0.0" - "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3322,6 +3310,11 @@ jsonfile@^6.0.1: object.assign "^4.1.4" object.values "^1.1.6" +jwt-decode@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b" + integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== + keyv@^4.5.3: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -3528,6 +3521,13 @@ object.values@^1.1.6, object.values@^1.1.7, object.values@^1.2.0: define-properties "^1.2.1" es-object-atoms "^1.0.0" +oidc-client-ts@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-3.0.1.tgz#be264fb87c89f74f73863646431c32cd06f5ceb7" + integrity sha512-xX8unZNtmtw3sOz4FPSqDhkLFnxCDsdo2qhFEH2opgWnF/iXMFoYdBQzkwCxAZVgt3FT3DnuBY3k80EZHT0RYg== + dependencies: + jwt-decode "^4.0.0" + once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -3703,6 +3703,11 @@ react-lifecycles-compat@^3.0.4: resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== +react-oidc-context@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/react-oidc-context/-/react-oidc-context-3.1.0.tgz#1047ee859b12793132854d583eaf0160b8a05d4f" + integrity sha512-ceQztvDfdl28mbr0So31XF/tCJamyF1+nm4AQNIE/nub+Xs9PLtDqLy/+75Yx1ahI0/n3nsq0R2qcP0R2Laa3Q== + react-redux@^9.1.2: version "9.1.2" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.1.2.tgz#deba38c64c3403e9abd0c3fbeab69ffd9d8a7e4b"