diff --git a/eslint.config.js b/eslint.config.js index 667a401..64cb64c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,9 +18,9 @@ export default tseslint.config([ '@typescript-eslint/no-unused-vars': [ 'error', { - 'argsIgnorePattern': '^ignored', - 'varsIgnorePattern': '^ignored', - 'caughtErrorsIgnorePattern': '^ignored' + argsIgnorePattern: '^ignored', + varsIgnorePattern: '^ignored', + caughtErrorsIgnorePattern: '^ignored' } ] } diff --git a/index.html b/index.html index a52eeb4..8e12da2 100644 --- a/index.html +++ b/index.html @@ -1,18 +1,124 @@ - - - - + + + + + Customer Portal - Phenix + + + + + + + + + + + + + + + + + + + - - Phenix Customer Portal + + + + +
- \ No newline at end of file + diff --git a/package.json b/package.json index 8996a05..1e4688d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "eslint-plugin-react-refresh": "0.4.20", "globals": "16.3.0", "prettier": "3.6.2", + "react-router-dom": "7.8.2", "typescript": "5.9.2", "typescript-eslint": "8.41.0", "vite": "7.1.3", diff --git a/src/App.tsx b/src/App.tsx index 2085e33..e07ed9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,47 +1,44 @@ import {useAppSelector, useAppDispatch} from 'hooks/store'; -import {WebSocketStatusViewComponent, LoginForm} from './components'; import AuthenticationService from './services/authentication.service'; import {useEffect, useState} from 'react'; -import {authenticationActions, selectError} from 'store/slices/Authentication.slice'; +import {authenticationActions, selectError, selectIsAuthenticated, selectCredentials} from 'store/slices/Authentication.slice'; import hostService from 'services/url.service'; import LoggerFactory from './services/logger/LoggerFactory'; +import Router from 'routers/router'; +import PCastApiService from './services/pcast-api.service'; +import { ApplicationCredentials } from '@techniker-me/pcast-api'; export default function App() { const dispatch = useAppDispatch(); const [isInitialized, setIsInitialized] = useState(false); + const error = useAppSelector(selectError); + const credentials = useAppSelector(selectCredentials); + const isAuthenticated = useAppSelector(selectIsAuthenticated); useEffect(() => { - // Initialize logger after all modules are loaded to avoid circular dependencies LoggerFactory.applyLoggerConfig(); - // Set WebSocket URI only once during initialization if (!isInitialized) { AuthenticationService.setWebSocketUri(hostService.getWebSocketUrl()); setIsInitialized(true); + AuthenticationService.status?.subscribe(status => dispatch(authenticationActions.setStatus(status))); } - - // Subscribe to WebSocket status changes - AuthenticationService.status?.subscribe(status => - dispatch(authenticationActions.setStatus(status)) - ); }, [dispatch, isInitialized]); - const error = useAppSelector(selectError); + useEffect(() => { + if (credentials.applicationId && credentials.secret && isAuthenticated) { + const appCredentials: ApplicationCredentials = { + id: credentials.applicationId, + secret: credentials.secret + }; + PCastApiService.initialize(hostService.getPcastBaseUrlOrigin(), appCredentials); + } + }, [credentials, isAuthenticated]); return ( <> {error &&
{error}
} -
- - -
+ ); } diff --git a/src/assets/images/background-1415x959.png b/src/assets/images/background-1415x959.png new file mode 100644 index 0000000..40caaef Binary files /dev/null and b/src/assets/images/background-1415x959.png differ diff --git a/src/assets/images/calendar-24x24.png b/src/assets/images/calendar-24x24.png new file mode 100644 index 0000000..263e3f3 Binary files /dev/null and b/src/assets/images/calendar-24x24.png differ diff --git a/src/assets/images/caret-down.svg b/src/assets/images/caret-down.svg new file mode 100644 index 0000000..95ce7d4 --- /dev/null +++ b/src/assets/images/caret-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/caret-up.svg b/src/assets/images/caret-up.svg new file mode 100644 index 0000000..4cb6f2a --- /dev/null +++ b/src/assets/images/caret-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/chart-down-50x33.png b/src/assets/images/chart-down-50x33.png new file mode 100644 index 0000000..7b9df1e Binary files /dev/null and b/src/assets/images/chart-down-50x33.png differ diff --git a/src/assets/images/chart-up-50x33.png b/src/assets/images/chart-up-50x33.png new file mode 100644 index 0000000..c3b857e Binary files /dev/null and b/src/assets/images/chart-up-50x33.png differ diff --git a/src/assets/images/icon/error.svg b/src/assets/images/icon/error.svg new file mode 100644 index 0000000..e13c93a --- /dev/null +++ b/src/assets/images/icon/error.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/icon/hash-plus.svg b/src/assets/images/icon/hash-plus.svg new file mode 100644 index 0000000..b6be2ae --- /dev/null +++ b/src/assets/images/icon/hash-plus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/icon/menu.svg b/src/assets/images/icon/menu.svg new file mode 100644 index 0000000..acc80d0 --- /dev/null +++ b/src/assets/images/icon/menu.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/icon/ok.svg b/src/assets/images/icon/ok.svg new file mode 100644 index 0000000..69da772 --- /dev/null +++ b/src/assets/images/icon/ok.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/icon/refresh.svg b/src/assets/images/icon/refresh.svg new file mode 100644 index 0000000..ef4e31f --- /dev/null +++ b/src/assets/images/icon/refresh.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/src/assets/images/logo-no-text.png b/src/assets/images/logo-no-text.png new file mode 100644 index 0000000..d000110 Binary files /dev/null and b/src/assets/images/logo-no-text.png differ diff --git a/src/assets/images/phenix-logo-101x41.png b/src/assets/images/phenix-logo-101x41.png new file mode 100755 index 0000000..93a3f7b Binary files /dev/null and b/src/assets/images/phenix-logo-101x41.png differ diff --git a/src/assets/images/phenix-offline-screen-1920x1080.gif b/src/assets/images/phenix-offline-screen-1920x1080.gif new file mode 100644 index 0000000..71620a2 Binary files /dev/null and b/src/assets/images/phenix-offline-screen-1920x1080.gif differ diff --git a/src/assets/images/search-150x150.png b/src/assets/images/search-150x150.png new file mode 100644 index 0000000..865cf13 Binary files /dev/null and b/src/assets/images/search-150x150.png differ diff --git a/src/assets/images/symbol-lock-24x24.png b/src/assets/images/symbol-lock-24x24.png new file mode 100755 index 0000000..4825e13 Binary files /dev/null and b/src/assets/images/symbol-lock-24x24.png differ diff --git a/src/assets/images/symbol-person-24x24.png b/src/assets/images/symbol-person-24x24.png new file mode 100755 index 0000000..d3940aa Binary files /dev/null and b/src/assets/images/symbol-person-24x24.png differ diff --git a/src/components/AuthorizedOnly/AuthorizedOnly.tsx b/src/components/AuthorizedOnly/AuthorizedOnly.tsx new file mode 100644 index 0000000..9ff4c4e --- /dev/null +++ b/src/components/AuthorizedOnly/AuthorizedOnly.tsx @@ -0,0 +1,68 @@ +import {JSX, useEffect, useRef} from 'react'; +import Routes from '../../routers/static-routes'; +import {useAppSelector} from '../../hooks/store'; +import {selectIsAuthenticated, selectIsAuthenticating} from '../../store/slices/Authentication.slice'; + +interface AuthorizedOnlyProps { + children: JSX.Element; + fallback?: JSX.Element; + redirectTo?: string; +} + +export function AuthorizedOnly({children, fallback, redirectTo = Routes.loginUrl()}: AuthorizedOnlyProps): JSX.Element { + const isAuthenticated = useAppSelector(selectIsAuthenticated); + const isAuthenticating = useAppSelector(selectIsAuthenticating); + const hasRedirected = useRef(false); + + useEffect(() => { + // If not authenticating and not authenticated, redirect to login + if (!isAuthenticating && !isAuthenticated && !hasRedirected.current) { + hasRedirected.current = true; + + // Build the redirect URL with current query parameters + const currentSearch = window.location.search; + const currentUrl = window.location.href; + + console.log('AuthorizedOnly: Redirecting to login'); + console.log('Current URL:', currentUrl); + console.log('Current search:', currentSearch); + console.log('Redirect to:', redirectTo); + + // Ensure we have the full path for redirect + const loginPath = redirectTo.startsWith('/') ? redirectTo : `/${redirectTo}`; + const redirectUrl = currentSearch ? `${loginPath}${currentSearch}` : loginPath; + + window.location.href = redirectUrl; + } + }, [isAuthenticated, isAuthenticating, redirectTo]); + + // Reset redirect flag when authentication state changes + useEffect(() => { + if (isAuthenticated) { + hasRedirected.current = false; + } + }, [isAuthenticated]); + + // Show fallback while checking authentication or redirecting + if (!isAuthenticated) { + return ( + fallback || ( +
+

Checking Authentication...

+

Please wait while we verify your credentials.

+
+ ) + ); + } + + // User is authenticated, render the protected content + return children; +} diff --git a/src/components/AuthorizedOnly/index.ts b/src/components/AuthorizedOnly/index.ts new file mode 100644 index 0000000..29851fd --- /dev/null +++ b/src/components/AuthorizedOnly/index.ts @@ -0,0 +1 @@ +export {AuthorizedOnly} from './AuthorizedOnly'; diff --git a/src/components/LoginForm/LoginForm.tsx b/src/components/LoginForm/LoginForm.tsx index f4ef22f..9548cf6 100644 --- a/src/components/LoginForm/LoginForm.tsx +++ b/src/components/LoginForm/LoginForm.tsx @@ -1,20 +1,31 @@ -import {JSX, useState} from 'react'; +import {JSX, useState, useEffect} from 'react'; import {useAppSelector, useAppDispatch} from '../../hooks'; -import {authenticateCredentialsThunk, selectIsAuthenticating, selectStatus} from '../../store/slices/Authentication.slice'; +import {useNavigate} from 'react-router-dom'; +import Routes from '../../routers/static-routes'; +import {authenticateCredentialsThunk, selectIsAuthenticated, selectIsAuthenticating, selectStatus} from '../../store/slices/Authentication.slice'; import {InputField} from '../InputField'; -export function LoginForm(): JSX.Element { +export function LoginForm(): JSX.Element | null { const dispatch = useAppDispatch(); + const navigate = useNavigate(); const [applicationId, setApplicationId] = useState('phenixrts.com-alex.zinn'); const [secret, setSecret] = useState('AMAsDzr.dIuGMZ.Zu52Dt~MQvP!DZwYg'); const isAuthenticating = useAppSelector(selectIsAuthenticating); const connectionStatus = useAppSelector(selectStatus); - - // Only allow authentication when WebSocket is Online + const isAuthenticated = useAppSelector(selectIsAuthenticated); + const isConnectionReady = connectionStatus === 'Online'; const isDisabled = isAuthenticating || !isConnectionReady; + // Redirect to dashboard if already authenticated + useEffect(() => { + if (isAuthenticated) { + const dashboardUrl = Routes.dashboardUrl() + window.location.search; + navigate(dashboardUrl, {replace: true}); + } + }, [isAuthenticated, navigate]); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -28,6 +39,11 @@ export function LoginForm(): JSX.Element { setSecret(''); }; + // Don't render login form if already authenticated + if (isAuthenticated) { + return null; + } + return ( <>
@@ -39,22 +55,11 @@ export function LoginForm(): JSX.Element { disabled={isDisabled} isRequired={true} /> - setSecret(value)} - disabled={isDisabled} - isRequired={true} - /> + setSecret(value)} disabled={isDisabled} isRequired={true} /> - {!isConnectionReady && ( -
- Waiting for WebSocket connection... -
- )} + {!isConnectionReady &&
Waiting for WebSocket connection...
} ); diff --git a/src/components/Navigation/Navigation.tsx b/src/components/Navigation/Navigation.tsx new file mode 100644 index 0000000..6635a32 --- /dev/null +++ b/src/components/Navigation/Navigation.tsx @@ -0,0 +1,45 @@ +import {JSX} from 'react'; +import {Link, useLocation} from 'react-router-dom'; + +export function Navigation(): JSX.Element { + const location = useLocation(); + + const navItems = [ + {path: '/dashboard', label: 'Dashboard'}, + {path: '/channels', label: 'Channels'} + ]; + + return ( + + ); +} diff --git a/src/components/Navigation/index.ts b/src/components/Navigation/index.ts new file mode 100644 index 0000000..23ea511 --- /dev/null +++ b/src/components/Navigation/index.ts @@ -0,0 +1 @@ +export {Navigation} from './Navigation'; diff --git a/src/components/ProtectedRoute/ProtectedRoute.tsx b/src/components/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 0000000..483fc69 --- /dev/null +++ b/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,16 @@ +import {JSX} from 'react'; +import {AuthorizedOnly} from '../AuthorizedOnly'; + +interface ProtectedRouteProps { + children: JSX.Element; + fallback?: JSX.Element; + redirectTo?: string; +} + +export function ProtectedRoute({children, fallback, redirectTo = '/login'}: ProtectedRouteProps): JSX.Element { + return ( + + {children} + + ); +} diff --git a/src/components/ProtectedRoute/index.ts b/src/components/ProtectedRoute/index.ts new file mode 100644 index 0000000..6f4b12a --- /dev/null +++ b/src/components/ProtectedRoute/index.ts @@ -0,0 +1 @@ +export {ProtectedRoute} from './ProtectedRoute'; diff --git a/src/components/QueryPreservingRedirect/QueryPreservingRedirect.tsx b/src/components/QueryPreservingRedirect/QueryPreservingRedirect.tsx new file mode 100644 index 0000000..3a2d048 --- /dev/null +++ b/src/components/QueryPreservingRedirect/QueryPreservingRedirect.tsx @@ -0,0 +1,42 @@ +import {JSX, useEffect} from 'react'; +import {useNavigate, useLocation} from 'react-router-dom'; + +interface QueryPreservingRedirectProps { + to: string; + replace?: boolean; +} + +export function QueryPreservingRedirect({to, replace = true}: QueryPreservingRedirectProps): JSX.Element | null { + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + // Preserve query parameters and hash + const currentSearch = location.search; + const currentHash = location.hash; + + // Build the redirect URL with preserved query parameters and hash + let redirectUrl = to; + if (currentSearch) { + redirectUrl += currentSearch; + } + if (currentHash) { + redirectUrl += currentHash; + } + + console.log('QueryPreservingRedirect: Root route redirect detected'); + console.log('Original path:', location.pathname); + console.log('Query parameters:', currentSearch || '(none)'); + console.log('Hash:', currentHash || '(none)'); + console.log('Redirecting to:', redirectUrl); + console.log('Full original URL:', window.location.href); + + // Small delay to ensure console logs are visible + setTimeout(() => { + navigate(redirectUrl, {replace}); + }, 100); + }, [navigate, location, to, replace]); + + // Return null since this is just a redirect component + return null; +} diff --git a/src/components/QueryPreservingRedirect/index.ts b/src/components/QueryPreservingRedirect/index.ts new file mode 100644 index 0000000..b1b9064 --- /dev/null +++ b/src/components/QueryPreservingRedirect/index.ts @@ -0,0 +1 @@ +export {QueryPreservingRedirect} from './QueryPreservingRedirect'; diff --git a/src/components/index.ts b/src/components/index.ts index b8464ae..80c5d9d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,3 +2,7 @@ export * from './InputField'; export * from './LoginForm'; export * from './FlexContainer'; export * from './WebSocketStatusView'; +export * from './AuthorizedOnly'; +export * from './ProtectedRoute'; +export * from './Navigation'; +export * from './QueryPreservingRedirect'; diff --git a/src/routers/router.tsx b/src/routers/router.tsx new file mode 100644 index 0000000..000372d --- /dev/null +++ b/src/routers/router.tsx @@ -0,0 +1,40 @@ +import {BrowserRouter, Routes, Route} from 'react-router-dom'; +import {LoginView, DashboardView, ChannelsView, TestView} from 'views'; +import {ProtectedRoute, QueryPreservingRedirect} from 'components'; + +export default function Router() { + return ( + + + {/* Public routes */} + } /> + + {/* Test route for debugging query parameters */} + } /> + + {/* Protected routes - require authentication */} + + + + } + /> + + + + + } + /> + + {/* Default redirects with query parameter preservation */} + } /> + } /> + + + ); +} diff --git a/src/routes/index.ts b/src/routers/static-routes.ts similarity index 97% rename from src/routes/index.ts rename to src/routers/static-routes.ts index b6dcd3d..cd8e398 100644 --- a/src/routes/index.ts +++ b/src/routers/static-routes.ts @@ -1,4 +1,6 @@ export default { + loginUrl: () => '/login', + dashboardUrl: () => '/dashboard', fetchChannelsUrl: () => '/pcast/channels', createChannelUrl: () => '/pcast/channel', deleteChannelUrl: () => '/pcast/channel', diff --git a/src/services/logger/Logger.ts b/src/services/logger/Logger.ts index 8fcc4f4..43dc1dd 100644 --- a/src/services/logger/Logger.ts +++ b/src/services/logger/Logger.ts @@ -131,7 +131,6 @@ export default class Logger { } if (args.length > 1) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any args = args.reduce((accumulator: any, currentValue: any, index: number, array: any[]) => { if (index + 1 === array.length && currentValue instanceof Error) { return accumulator + '\n' + this.toString(currentValue.stack); diff --git a/src/services/logger/LoggerFactory.ts b/src/services/logger/LoggerFactory.ts index 57ce4b4..25514f1 100644 --- a/src/services/logger/LoggerFactory.ts +++ b/src/services/logger/LoggerFactory.ts @@ -58,7 +58,7 @@ export default class LoggerFactory { static async applyTelemetryConfiguration(level: LoggingLevel): Promise { this._telemetryConfiguration.threshold = level || LoggerDefaults.defaultTelemetryLoggingLevel; this._telemetryConfiguration.url = hostService.getTelemetryUrl(); - this._telemetryConfiguration.environment = hostService.getBaseUrlOrigin(); + this._telemetryConfiguration.environment = hostService.getPcastBaseUrlOrigin(); // this._telemetryConfiguration.tenancy = await userStore.get('applicationId'); // this._telemetryConfiguration.userId = await userStore.get('applicationId'); // this._telemetryConfiguration.sessionId = await userStore.get('sessionId'); diff --git a/src/services/net/PhenixWebSocket.ts b/src/services/net/PhenixWebSocket.ts index 08e375c..c979afb 100644 --- a/src/services/net/PhenixWebSocket.ts +++ b/src/services/net/PhenixWebSocket.ts @@ -116,22 +116,22 @@ export class PhenixWebSocket implements IDisposable { this._logger.info('WebSocket connecting...'); this._status.value = PhenixWebSocketStatus.Connecting; }); - + this._websocketMQ.onEvent('connected', () => { this._logger.info('WebSocket connected successfully'); this._status.value = PhenixWebSocketStatus.Online; }); - + this._websocketMQ.onEvent('disconnected', () => { this._logger.info('WebSocket disconnected'); this._status.value = PhenixWebSocketStatus.Offline; }); - + this._websocketMQ.onEvent('reconnecting', () => { this._logger.info('WebSocket reconnecting...'); this._status.value = PhenixWebSocketStatus.Reconnecting; }); - + this._websocketMQ.onEvent('error', (error: unknown) => { this._logger.error('WebSocket error occurred:', error); this._status.value = PhenixWebSocketStatus.Error; diff --git a/src/services/pcast-api.service.ts b/src/services/pcast-api.service.ts new file mode 100644 index 0000000..42a7200 --- /dev/null +++ b/src/services/pcast-api.service.ts @@ -0,0 +1,25 @@ +import PCastApi, { ApplicationCredentials, Channel } from "@techniker-me/pcast-api"; + +class PCastApiService { + private static _instance: PCastApiService = new PCastApiService(); + private _pcastApi: PCastApi | null = null; + + public static getInstance(): PCastApiService { + return PCastApiService._instance; + } + + public async initialize(pcastUri: string, applicationCredentials: ApplicationCredentials): Promise { + console.log('initialize', pcastUri, applicationCredentials); + this._pcastApi = PCastApi.create(pcastUri, applicationCredentials); + } + + public get pcastApi(): PCastApi | null { + return this._pcastApi; + } + + public async fetchChannelsList(): Promise { + return this._pcastApi?.channels.list() || []; + } +} + +export default PCastApiService.getInstance(); \ No newline at end of file diff --git a/src/services/telemetry/TelemetryService.ts b/src/services/telemetry/TelemetryService.ts index 724f700..583ecf4 100644 --- a/src/services/telemetry/TelemetryService.ts +++ b/src/services/telemetry/TelemetryService.ts @@ -66,8 +66,6 @@ export default class TelemetryService { this._logs.unshift(logRecord); } - - // @ts-expect-error: Unused variable intentionally const ignored = this.sendLogsIfAble(); } @@ -116,10 +114,8 @@ export default class TelemetryService { .then(response => { this._isSending = false; - - // @ts-expect-error: Unused variable intentionally - + const ignored = this.sendLogsIfAble(); return response; @@ -127,10 +123,8 @@ export default class TelemetryService { .catch(() => { this._isSending = false; - - // @ts-expect-error: Unused variable intentionally - + const ignored = this.sendLogsIfAble(); }); } diff --git a/src/services/url.service.ts b/src/services/url.service.ts index 7d8c632..a364ef8 100644 --- a/src/services/url.service.ts +++ b/src/services/url.service.ts @@ -5,22 +5,17 @@ export type UrlType = 'pcast' | 'telemetry' | 'websocket'; export default class UrlService { - private static getBaseUrlOrigin(url: string = window?.location?.href, urlType: UrlType = 'pcast'): string { + public static getBaseUrlOrigin(url: string = window?.location?.href, urlType: UrlType = 'pcast'): string { if (!url) { throw new Error('Invalid URL'); } const baseURL = new URL(url); - const backendUrlParam = baseURL.searchParams.get('backend'); + const searchParams = new URLSearchParams(baseURL.search); + const backendUrlParam = searchParams.get('backend'); if (backendUrlParam) { - const backendUrl = new URL(backendUrlParam); - if (urlType === 'websocket') { - backendUrl.protocol = 'wss:'; - backendUrl.pathname = '/ws'; - } - - return backendUrl.toString(); + return backendUrlParam; } const segments = baseURL.hostname.split('.'); @@ -51,8 +46,6 @@ export default class UrlService { baseURL.hostname = segments.join('.'); - - return baseURL.origin; } @@ -71,6 +64,10 @@ export default class UrlService { } public static getWebSocketUrl(url: string = window?.location?.href): string { - return this.getBaseUrlOrigin(url, 'websocket'); + const baseUrl = new URL(this.getBaseUrlOrigin(url, 'pcast')); + baseUrl.protocol = 'wss:'; + baseUrl.pathname = '/ws'; + + return baseUrl.toString(); } } diff --git a/src/store/middlewares/PhenixWebSocket.middleware.ts b/src/store/middlewares/PhenixWebSocket.middleware.ts index 7197b9e..d41cb73 100644 --- a/src/store/middlewares/PhenixWebSocket.middleware.ts +++ b/src/store/middlewares/PhenixWebSocket.middleware.ts @@ -1,6 +1,6 @@ -import { Middleware } from '@reduxjs/toolkit'; +import {Middleware} from '@reduxjs/toolkit'; -const phenixWebSocketMiddleware: Middleware = (storeApi) => (next) => (action) => { +const phenixWebSocketMiddleware: Middleware = storeApi => next => action => { console.log('phenixWebSocketMiddleware', action); if (typeof action === 'function') { return action(storeApi.getState(), storeApi.dispatch); @@ -9,4 +9,4 @@ const phenixWebSocketMiddleware: Middleware = (storeApi) => (next) => (action) = return next(action); }; -export default phenixWebSocketMiddleware; \ No newline at end of file +export default phenixWebSocketMiddleware; diff --git a/src/store/slices/Authentication.slice.ts b/src/store/slices/Authentication.slice.ts index 09ea1bc..86657ce 100644 --- a/src/store/slices/Authentication.slice.ts +++ b/src/store/slices/Authentication.slice.ts @@ -9,6 +9,8 @@ export interface IAuthenticationState { isAuthenticating: boolean; error: string | null; status: PhenixWebSocketStatusType; + sessionId: string | null; + roles: string[]; } const initialAuthenticationState: IAuthenticationState = { @@ -17,7 +19,9 @@ const initialAuthenticationState: IAuthenticationState = { isAuthenticated: false, isAuthenticating: false, error: null, - status: 'Offline' + status: 'Offline', + sessionId: null, + roles: [] }; // Memoized selectors @@ -36,6 +40,11 @@ export const selectCredentials = createSelector([selectAuthentication], authenti secret: authentication.secret })); +export const selectSessionInfo = createSelector([selectAuthentication], authentication => ({ + sessionId: authentication.sessionId, + roles: authentication.roles +})); + const authenticateCredentialsThunk = createAsyncThunk( 'authentication/authenticate', async (credentials: {applicationId: string; secret: string}, {rejectWithValue}) => { @@ -43,6 +52,10 @@ const authenticateCredentialsThunk = createAsyncThunk( } ); +const signoutThunk = createAsyncThunk('authentication/signout', async (_, {rejectWithValue}) => { + return AuthenticationService.signout().catch(rejectWithValue); +}); + const authenticationSlice = createSlice({ name: 'authentication', initialState: {...initialAuthenticationState}, @@ -56,6 +69,8 @@ const authenticationSlice = createSlice({ state.applicationId = null; state.secret = null; state.error = null; + state.sessionId = null; + state.roles = []; }, error: (state, action) => { // Store only the error message string, not the Error object @@ -76,11 +91,6 @@ const authenticationSlice = createSlice({ state.isAuthenticating = true; state.error = null; }) - .addCase(authenticateCredentialsThunk.fulfilled, state => { - state.isAuthenticating = false; - state.error = null; - state.isAuthenticated = true; - }) .addCase(authenticateCredentialsThunk.rejected, (state, action) => { state.isAuthenticating = false; // Extract error message string instead of creating new Error object @@ -102,9 +112,58 @@ const authenticationSlice = createSlice({ state.isAuthenticated = false; state.applicationId = null; state.secret = null; + state.sessionId = null; + state.roles = []; + }) + .addCase(authenticateCredentialsThunk.fulfilled, (state, action) => { + state.isAuthenticating = false; + + if (action.payload.error) { + state.error = action.payload.error as string; + state.isAuthenticated = false; + } else if (action.payload.response && (action.payload.response as {status: string}).status === 'ok') { + state.isAuthenticated = true; + + + + // Safely extract sessionId and roles with type checking + if (action.payload.response && typeof action.payload.response === 'object') { + const response = action.payload.response as any; + if (response.sessionId && typeof response.sessionId === 'string') { + state.sessionId = response.sessionId; + } + if (Array.isArray(response.roles)) { + state.roles = response.roles; + } + } + } else { + state.error = 'Authentication failed'; + state.isAuthenticated = false; + } + }) + .addCase(signoutThunk.pending, state => { + state.isAuthenticating = true; + state.error = null; + }) + .addCase(signoutThunk.rejected, (state, action) => { + state.isAuthenticating = false; + state.error = action.payload as string; + state.isAuthenticated = false; + state.applicationId = null; + state.secret = null; + state.sessionId = null; + state.roles = []; + }) + .addCase(signoutThunk.fulfilled, state => { + state.isAuthenticated = false; + state.isAuthenticating = false; + state.applicationId = null; + state.secret = null; + state.sessionId = null; + state.roles = []; }); } }); export const {reducer: authenticationReducer, actions: authenticationActions} = authenticationSlice; -export {authenticateCredentialsThunk}; +export {authenticateCredentialsThunk, signoutThunk}; diff --git a/src/store/store.ts b/src/store/store.ts index 4fc30f1..86594c3 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,7 +1,6 @@ import {configureStore} from '@reduxjs/toolkit'; import reducer from './reducer'; -import phenixWebSocketMiddleware from './middlewares/PhenixWebSocket.middleware'; -export const store = configureStore({reducer, middleware: getDefaultMiddleware => getDefaultMiddleware().concat(phenixWebSocketMiddleware)}); +export const store = configureStore({reducer}); export type RootState = ReturnType; diff --git a/src/types/phenix-web-proto.d.ts b/src/types/phenix-web-proto.d.ts index fb6a54b..acffe4f 100644 --- a/src/types/phenix-web-proto.d.ts +++ b/src/types/phenix-web-proto.d.ts @@ -1,8 +1,8 @@ declare module 'phenix-web-proto' { - import { ILogger } from 'services/logger/LoggerInterface'; + import {ILogger} from 'services/logger/LoggerInterface'; export class MQWebSocket { - constructor(uri: string, logger: ILogger, protocols: any[], apiVersion?: string); + constructor(uri: string, logger: ILogger, protocols: unknown[], apiVersion?: string); onEvent(eventName: string, handler: (event: unknown) => void): void; onRequest(requestName: string, handler: (request: unknown) => void): void; sendRequest(type: string, message: unknown, callback?: (error: unknown, response: unknown) => void, settings?: unknown): void; diff --git a/src/views/ChannelsView.tsx b/src/views/ChannelsView.tsx new file mode 100644 index 0000000..8951236 --- /dev/null +++ b/src/views/ChannelsView.tsx @@ -0,0 +1,40 @@ +import {JSX} from 'react'; +import {Navigation} from '../components'; + +export function ChannelsView(): JSX.Element { + return ( + <> + +
+

Channels

+
+

This is a protected view that requires authentication.

+

If you can see this, you are successfully authenticated!

+
+ +
+

Channel List

+

No channels available at the moment.

+

This view demonstrates the AuthorizedOnly middleware in action.

+
+
+ + ); +} diff --git a/src/views/DashboardView.tsx b/src/views/DashboardView.tsx new file mode 100644 index 0000000..d63fd7c --- /dev/null +++ b/src/views/DashboardView.tsx @@ -0,0 +1,105 @@ +import {JSX} from 'react'; +import {useAppDispatch, useAppSelector} from '../hooks/store'; +import {selectCredentials, selectSessionInfo, authenticationActions} from '../store/slices/Authentication.slice'; +import {useNavigate} from 'react-router-dom'; +import {Navigation} from '../components'; + +export function DashboardView(): JSX.Element { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const credentials = useAppSelector(selectCredentials); + const sessionInfo = useAppSelector(selectSessionInfo); + + const handleLogout = () => { + dispatch(authenticationActions.signout()); + navigate('/login', {replace: true}); + }; + + return ( + <> + +
+
+

Dashboard

+ +
+ +
+

Welcome!

+

You are successfully authenticated.

+

+ Application ID: {credentials.applicationId || 'N/A'} +

+

+ Session ID: {sessionInfo.sessionId || 'N/A'} +

+

+ Roles: {sessionInfo.roles.length > 0 ? sessionInfo.roles.join(', ') : 'None'} +

+
+ +
+
+

Quick Actions

+
    +
  • View Channels
  • +
  • Check Analytics
  • +
  • Monitor QoS
  • +
  • Manage Rooms
  • +
+
+ +
+

Recent Activity

+

No recent activity to display.

+
+
+
+ + ); +} diff --git a/src/views/LoginView.tsx b/src/views/LoginView.tsx new file mode 100644 index 0000000..3f826a3 --- /dev/null +++ b/src/views/LoginView.tsx @@ -0,0 +1,17 @@ +import {JSX} from 'react'; +import {LoginForm} from 'components'; + +export function LoginView(): JSX.Element { + return ( +
+ +
+ ); +} diff --git a/src/views/TestView.tsx b/src/views/TestView.tsx new file mode 100644 index 0000000..3c9015d --- /dev/null +++ b/src/views/TestView.tsx @@ -0,0 +1,99 @@ +import {JSX} from 'react'; +import {useSearchParams} from 'react-router-dom'; + +export function TestView(): JSX.Element { + const [searchParams] = useSearchParams(); + + // Get all query parameters + const allParams = Array.from(searchParams.entries()); + + return ( +
+

Query Parameter Test Page

+ +
+

Current URL Information

+

+ Full URL: {window.location.href} +

+

+ Path: {window.location.pathname} +

+

+ Search: {window.location.search || '(none)'} +

+

+ Hash: {window.location.hash || '(none)'} +

+
+ +
+

Query Parameters

+ {allParams.length > 0 ? ( +
    + {allParams.map(([key, value]) => ( +
  • + {key}: {value} +
  • + ))} +
+ ) : ( +

No query parameters found

+ )} +
+ +
+

Test Instructions

+

To test query parameter preservation:

+
    +
  1. + Navigate to this page with query parameters: /test?param1=value1¶m2=value2 +
  2. +
  3. + If not authenticated, you should be redirected to: /login?param1=value1¶m2=value2 +
  4. +
  5. Check the console for debug information from AuthorizedOnly
  6. +
  7. After login, you should be able to return to the original URL with parameters
  8. +
+ +

Test URLs to try:

+
    +
  • + /test?tab=dashboard&filter=active +
  • +
  • + /test?user=123&view=detailed&sort=date +
  • +
  • + /test?lang=en&theme=dark&debug=true +
  • +
+
+
+ ); +} diff --git a/src/views/index.ts b/src/views/index.ts new file mode 100644 index 0000000..9029080 --- /dev/null +++ b/src/views/index.ts @@ -0,0 +1,4 @@ +export {LoginView} from './LoginView'; +export {DashboardView} from './DashboardView'; +export {ChannelsView} from './ChannelsView'; +export {TestView} from './TestView'; diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index ccd0f6f..3ea3814 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/index.ts","./src/components/flexcontainer/flexcontainercomponent.tsx","./src/components/flexcontainer/index.ts","./src/components/inputfield/inputfield.tsx","./src/components/inputfield/index.ts","./src/components/loginform/loginform.tsx","./src/components/loginform/index.ts","./src/components/websocketstatusview/websocketstatusviewcomponent.tsx","./src/components/websocketstatusview/index.ts","./src/config/index.ts","./src/hooks/index.ts","./src/hooks/store.ts","./src/lang/strings.ts","./src/lang/assertunreachable.ts","./src/routes/index.ts","./src/services/authentication.service.ts","./src/services/host-url.service.ts","./src/services/platform-detection.service.ts","./src/services/logger/appenders.ts","./src/services/logger/consoleappender.ts","./src/services/logger/iappender.ts","./src/services/logger/logger.ts","./src/services/logger/loggerdefaults.ts","./src/services/logger/loggerfactory.ts","./src/services/logger/loggerinterface.ts","./src/services/logger/logginglevelmapping.ts","./src/services/logger/loggingthreshold.ts","./src/services/net/phenixwebsocket.ts","./src/services/telemetry/telemetryapender.ts","./src/services/telemetry/telemetryconfiguration.ts","./src/services/telemetry/telemetryservice.ts","./src/store/index.ts","./src/store/reducer.ts","./src/store/store.ts","./src/store/middlewares/phenixwebsocket.middleware.ts","./src/store/slices/authentication.slice.ts","./src/store/slices/index.ts","./src/types/phenix-web-proto.d.ts"],"version":"5.9.2"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/index.ts","./src/components/authorizedonly/authorizedonly.tsx","./src/components/authorizedonly/index.ts","./src/components/flexcontainer/flexcontainercomponent.tsx","./src/components/flexcontainer/index.ts","./src/components/inputfield/inputfield.tsx","./src/components/inputfield/index.ts","./src/components/loginform/loginform.tsx","./src/components/loginform/index.ts","./src/components/navigation/navigation.tsx","./src/components/navigation/index.ts","./src/components/protectedroute/protectedroute.tsx","./src/components/protectedroute/index.ts","./src/components/querypreservingredirect/querypreservingredirect.tsx","./src/components/querypreservingredirect/index.ts","./src/components/websocketstatusview/websocketstatusviewcomponent.tsx","./src/components/websocketstatusview/index.ts","./src/config/index.ts","./src/hooks/index.ts","./src/hooks/store.ts","./src/lang/strings.ts","./src/lang/assertunreachable.ts","./src/routers/router.tsx","./src/routers/static-routes.ts","./src/services/authentication.service.ts","./src/services/pcast-api.service.ts","./src/services/platform-detection.service.ts","./src/services/url.service.ts","./src/services/logger/appenders.ts","./src/services/logger/consoleappender.ts","./src/services/logger/iappender.ts","./src/services/logger/logger.ts","./src/services/logger/loggerdefaults.ts","./src/services/logger/loggerfactory.ts","./src/services/logger/loggerinterface.ts","./src/services/logger/logginglevelmapping.ts","./src/services/logger/loggingthreshold.ts","./src/services/net/phenixwebsocket.ts","./src/services/telemetry/telemetryapender.ts","./src/services/telemetry/telemetryconfiguration.ts","./src/services/telemetry/telemetryservice.ts","./src/store/index.ts","./src/store/reducer.ts","./src/store/store.ts","./src/store/middlewares/phenixwebsocket.middleware.ts","./src/store/slices/authentication.slice.ts","./src/store/slices/index.ts","./src/types/phenix-web-proto.d.ts","./src/views/channelsview.tsx","./src/views/dashboardview.tsx","./src/views/loginview.tsx","./src/views/testview.tsx","./src/views/index.ts"],"version":"5.9.2"} \ No newline at end of file