improvements

This commit is contained in:
Alex Zinn
2025-08-31 18:09:07 -04:00
parent b6717e0cb1
commit ae7f3989fc
49 changed files with 838 additions and 91 deletions

View File

@@ -18,9 +18,9 @@ export default tseslint.config([
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'error', 'error',
{ {
'argsIgnorePattern': '^ignored', argsIgnorePattern: '^ignored',
'varsIgnorePattern': '^ignored', varsIgnorePattern: '^ignored',
'caughtErrorsIgnorePattern': '^ignored' caughtErrorsIgnorePattern: '^ignored'
} }
] ]
} }

View File

@@ -1,18 +1,124 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <!-- Performance Monitoring -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Phenix Customer Portal" />
<script> <script>
__phenixPageLoadTime = new Date().getTime(); window.__phenixPageLoadTime = new Date().getTime();
</script>
<!-- Basic Meta Tags -->
<meta charset="utf-8" />
<title>Customer Portal - Phenix</title>
<meta name="description" content="Phenix Real Time Solutions Customer Portal" />
<meta name="author" content="Phenix Real Time Solutions" />
<!-- Mobile & Display -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<!-- Favicon -->
<link rel="shortcut icon" href="./phenix.ico" />
<link rel="icon" type="image/x-icon" href="./phenix.ico" />
<!-- Base Path Configuration for React Router -->
<script>
(function () {
'use strict';
// Define main application routes
const APP_ROUTES = ['channels', 'login', 'rooms', 'analytics', 'qos', 'dashboard'];
// Get current URL path and split into segments
const currentPath = window.location.pathname;
const pathSegments = currentPath.split('/').filter(segment => segment.length > 0);
// Find the first segment that matches an app route
const routeIndex = pathSegments.findIndex(segment => APP_ROUTES.includes(segment));
// Calculate base path for the application
let basePath = '/';
if (routeIndex >= 0) {
// Extract segments up to (but not including) the route
// Special handling for login route
const includeRouteSegment = pathSegments[routeIndex] === 'login';
const baseSegments = pathSegments.slice(0, routeIndex + (includeRouteSegment ? 1 : 0));
if (baseSegments.length > 0) {
basePath = '/' + baseSegments.join('/') + '/';
}
}
// Set the base href for the application
document.write(`<base href="${basePath}" />`);
})();
</script>
<!-- Browser Compatibility Check -->
<script>
(function () {
'use strict';
// Redirect unsupported browsers
const isUnsupportedBrowser = /MSIE|Trident/.test(window.navigator.userAgent);
if (isUnsupportedBrowser) {
const searchParams = window.location.search || '';
window.location.href = '/unsupported-browser.html' + searchParams;
}
})();
</script>
<!-- Google Analytics -->
<script>
(function () {
'use strict';
// Skip analytics for local environments
const hostname = window.location.hostname;
const isLocal = hostname.startsWith('local') || hostname.endsWith('-local') || hostname.includes('localhost');
if (isLocal) {
return;
}
// Determine environment and set GA ID
const isStaging = hostname.startsWith('stg-') || hostname.endsWith('-stg') || hostname.includes('-stg-') || hostname === 'stg';
const GA_MEASUREMENT_ID = isStaging ? 'G-TXBY6KV20H' : 'G-0RYP945E1S';
// Initialize dataLayer
window.dataLayer = window.dataLayer || [];
// Load Google Analytics script
const script = document.createElement('script');
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
script.async = true;
document.head.appendChild(script);
// Initialize gtag
function gtag() {
window.dataLayer.push(arguments);
}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', GA_MEASUREMENT_ID);
})();
</script> </script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phenix Customer Portal</title>
</head> </head>
<body> <body>
<!-- Fallback for users with JavaScript disabled -->
<!-- <noscript>
<div style="text-align: center; padding: 2rem; font-family: Arial, sans-serif;">
<h1>JavaScript Required</h1>
<p>You need to enable JavaScript to use the Phenix Customer Portal.</p>
<p>Please enable JavaScript in your browser settings and refresh this page.</p>
</div>
</noscript> -->
<!-- React App Root -->
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -38,6 +38,7 @@
"eslint-plugin-react-refresh": "0.4.20", "eslint-plugin-react-refresh": "0.4.20",
"globals": "16.3.0", "globals": "16.3.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"react-router-dom": "7.8.2",
"typescript": "5.9.2", "typescript": "5.9.2",
"typescript-eslint": "8.41.0", "typescript-eslint": "8.41.0",
"vite": "7.1.3", "vite": "7.1.3",

View File

@@ -1,47 +1,44 @@
import {useAppSelector, useAppDispatch} from 'hooks/store'; import {useAppSelector, useAppDispatch} from 'hooks/store';
import {WebSocketStatusViewComponent, LoginForm} from './components';
import AuthenticationService from './services/authentication.service'; import AuthenticationService from './services/authentication.service';
import {useEffect, useState} from 'react'; 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 hostService from 'services/url.service';
import LoggerFactory from './services/logger/LoggerFactory'; 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() { export default function App() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const error = useAppSelector(selectError);
const credentials = useAppSelector(selectCredentials);
const isAuthenticated = useAppSelector(selectIsAuthenticated);
useEffect(() => { useEffect(() => {
// Initialize logger after all modules are loaded to avoid circular dependencies
LoggerFactory.applyLoggerConfig(); LoggerFactory.applyLoggerConfig();
// Set WebSocket URI only once during initialization
if (!isInitialized) { if (!isInitialized) {
AuthenticationService.setWebSocketUri(hostService.getWebSocketUrl()); AuthenticationService.setWebSocketUri(hostService.getWebSocketUrl());
setIsInitialized(true); 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]); }, [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 ( return (
<> <>
{error && <div>{error}</div>} {error && <div>{error}</div>}
<div <Router />
style={{
width: '200px',
height: '100px',
display: 'flex',
flexDirection: 'column',
margin: 'auto'
}}>
<WebSocketStatusViewComponent />
<LoginForm />
</div>
</> </>
); );
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 29 14" height="6px" id="Layer_1" version="1.1" viewBox="0 0 29 14" width="29px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><polygon fill="#F9F9F9" points="0.15,0 14.5,14.35 28.85,0 "/></svg>

After

Width:  |  Height:  |  Size: 400 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 29 14" height="8px" id="Layer_1" version="1.1" viewBox="0 0 29 14" width="29px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><polygon fill="#f9f9f9" points="0.15,14 14.5,-0.35 28.85,14 "/></svg>

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 206.751 186.399" width="206.751pt" height="186.399pt"><defs><clipPath id="_clipPath_Wy0NWkkA4mKqtolphxnGK0Cf0TrWZqlo"><rect width="206.751" height="186.399"/></clipPath></defs><g clip-path="url(#_clipPath_Wy0NWkkA4mKqtolphxnGK0Cf0TrWZqlo)"><g><path d=" M 179.102 178.898 L 103.306 178.559 L 27.51 178.22 C 9.581 178.14 2.36 165.502 11.394 150.015 L 49.585 84.544 L 87.777 19.072 C 96.811 3.586 111.366 3.651 120.261 19.218 L 157.865 85.028 L 195.469 150.839 C 204.364 166.406 197.03 178.979 179.102 178.898 Z " fill="none" vector-effect="non-scaling-stroke" stroke-width="5" stroke="rgb(247,13,26)" stroke-linejoin="miter" stroke-linecap="square" stroke-miterlimit="3"/><line x1="103.306" y1="47.028" x2="103.306" y2="120.028" vector-effect="non-scaling-stroke" stroke-width="5" stroke="rgb(247,13,26)" stroke-linejoin="miter" stroke-linecap="square" stroke-miterlimit="3"/><line x1="103.306" y1="138.028" x2="103.306" y2="143.028" vector-effect="non-scaling-stroke" stroke-width="5" stroke="rgb(247,13,26)" stroke-linejoin="miter" stroke-linecap="square" stroke-miterlimit="3"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="19px" height="18px" viewBox="0 0 19 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="01-home-copy-7" transform="translate(-1214.000000, -84.000000)" fill="#FFFFFF" fill-rule="nonzero">
<g id="icon/white/add-channel" transform="translate(1211.000000, 80.000000)">
<path d="M18,14 C18.5522847,14 19,14.4477153 19,15 L18.999068,17 L21,17 C21.5522847,17 22,17.4477153 22,18 C22,18.5522847 21.5522847,19 21,19 L18.999068,19 L19,21 C19,21.5522847 18.5522847,22 18,22 C17.4477153,22 17,21.5522847 17,21 L16.999068,18.999 L15,19 C14.4477153,19 14,18.5522847 14,18 C14,17.4477153 14.4477153,17 15,17 L16.999068,16.999 L17,15 C17,14.4477153 17.4477153,14 18,14 Z M14,4 C14.4287818,4 14.8254386,4.13754318 15.1482567,4.37091574 C15.4794061,4.13462978 15.8829334,4 16.3080311,4 L18,4 C19.0836085,4 19.9620467,4.87843824 19.9620467,5.96204672 C19.9620467,6.23272939 19.9060382,6.5004841 19.7975435,6.748472 L19.687068,6.999 L19.763932,7 C20.8685015,7 21.763932,7.8954305 21.763932,9 C21.763932,9.31049019 21.6916418,9.61671632 21.5527864,9.89442719 L20.5527864,11.8944272 C20.3949049,12.2101902 20.1601982,12.4717535 19.8778882,12.6610569 C19.3634108,12.2470293 18.7105538,12 18,12 C17.1114527,12 16.3131288,12.3862919 15.7638055,13.0000983 L15.083068,13 L15.916068,11 L18.381966,11 C18.7607381,11 19.1070012,10.7859976 19.2763932,10.4472136 L19.5,10 C19.6706654,9.65866925 19.5323138,9.24361439 19.190983,9.07294902 C19.0950363,9.02497564 18.9892377,9 18.881966,9 L16.750068,9 L17.6153846,6.92307692 C17.7569961,6.58320938 17.5962778,6.19289353 17.2564103,6.05128205 C17.1751635,6.01742923 17.0880173,6 17,6 L16.6666667,6 C16.2629658,6 15.8988593,6.24273768 15.7435897,6.61538462 L14.749068,9 L12.750068,9 L13.6153846,6.92307692 C13.7569961,6.58320938 13.5962778,6.19289353 13.2564103,6.05128205 C13.1751635,6.01742923 13.0880173,6 13,6 L12.6666667,6 C12.2629658,6 11.8988593,6.24273768 11.7435897,6.61538462 L10.749068,9 L8.61803399,9 C8.23926193,9 7.89299881,9.21400238 7.7236068,9.5527864 L7.5,10 C7.32933463,10.3413307 7.46768625,10.7563856 7.80901699,10.927051 C7.90496374,10.9750244 8.01076227,11 8.11803399,11 L9.91606798,11 L9.08206798,13 L6.61803399,13 C6.23926193,13 5.89299881,13.2140024 5.7236068,13.5527864 L5.5,14 C5.32933463,14.3413307 5.46768625,14.7563856 5.80901699,14.927051 C5.90496374,14.9750244 6.01076227,15 6.11803399,15 L8.24906798,15 L7.38461538,17.0769231 C7.24300391,17.4167906 7.40372221,17.8071065 7.74358974,17.9487179 C7.82483652,17.9825708 7.91198265,18 8,18 L8.33333333,18 C8.73703418,18 9.1011407,17.7572623 9.25641026,17.3846154 L10.249068,15 L12.249068,15 L11.3846154,17.0769231 C11.2430039,17.4167906 11.4037222,17.8071065 11.7435897,17.9487179 C11.8248365,17.9825708 11.9119827,18 12,18 L12.0354373,18.0000497 C12.0120835,18.1633354 12,18.3302566 12,18.5 C12,19.037207 12.1210295,19.5461461 12.3373087,20.0010375 L11,20 C10.5712182,20 10.1745614,19.8624568 9.85174329,19.6290843 C9.52059393,19.8653702 9.11706659,20 8.69196885,20 L7,20 C5.91639152,20 5.03795328,19.1215618 5.03795328,18.0379533 C5.03795328,17.7672706 5.09396179,17.4995159 5.2024565,17.251528 L5.31206798,17 L5.23606798,17 C4.13149848,17 3.23606798,16.1045695 3.23606798,15 C3.23606798,14.6895098 3.30835816,14.3832837 3.4472136,14.1055728 L4.4472136,12.1055728 C4.62888297,11.7422341 4.91227191,11.4506579 5.25285994,11.2583596 C5.24175599,11.1747423 5.23606798,11.0880354 5.23606798,11 C5.23606798,10.6895098 5.30835816,10.3832837 5.4472136,10.1055728 L6.4472136,8.10557281 C6.78599762,7.42800475 7.47852386,7 8.23606798,7 L9.68706798,6.999 L10.4757165,5.19836233 C10.7942054,4.47038768 11.5134355,4 12.3080311,4 L14,4 Z M15,15 L14.9990501,15.0355804 C14.6965332,15.0789368 14.406503,15.160979 14.1341327,15.276534 L14.249068,15 L15,15 Z M13.916068,11 L13.082068,13 L11.083068,13 L11.916068,11 L13.916068,11 Z" id="Combined-Shape"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<path style="fill:#F9F9F9" d="M501.333,96H10.667C4.779,96,0,100.779,0,106.667s4.779,10.667,10.667,10.667h490.667c5.888,0,10.667-4.779,10.667-10.667
S507.221,96,501.333,96z"/>
</g>
</g>
<g>
<g>
<path style="fill:#F9F9F9" d="M501.333,245.333H10.667C4.779,245.333,0,250.112,0,256s4.779,10.667,10.667,10.667h490.667
c5.888,0,10.667-4.779,10.667-10.667S507.221,245.333,501.333,245.333z"/>
</g>
</g>
<g>
<g>
<path style="fill:#F9F9F9" d="M501.333,394.667H10.667C4.779,394.667,0,399.445,0,405.333C0,411.221,4.779,416,10.667,416h490.667
c5.888,0,10.667-4.779,10.667-10.667C512,399.445,507.221,394.667,501.333,394.667z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg fill="#00bc8c" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="250px" height="250px"><path fill="none" stroke="#00bc8c" stroke-miterlimit="10" stroke-width="2" d="M25,3C12.85,3,3,12.85,3,25s9.85,22,22,22 s22-9.85,22-22S37.15,3,25,3z"/><path fill="none" stroke="#00bc8c" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M16,24.444L24.143,32L35,16"/></svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg width="19px" height="18px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 341.333 341.333" style="enable-background:new 0 0 341.333 341.333;" xml:space="preserve">
<g>
<g>
<path fill="#ffffff" d="M341.227,149.333V0l-50.133,50.133C260.267,19.2,217.707,0,170.56,0C76.267,0,0.107,76.373,0.107,170.667
s76.16,170.667,170.453,170.667c79.467,0,146.027-54.4,164.907-128h-44.373c-17.6,49.707-64.747,85.333-120.533,85.333
c-70.72,0-128-57.28-128-128s57.28-128,128-128c35.307,0,66.987,14.72,90.133,37.867l-68.8,68.8H341.227z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

View File

@@ -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 || (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
color: '#666'
}}>
<h2>Checking Authentication...</h2>
<p>Please wait while we verify your credentials.</p>
</div>
)
);
}
// User is authenticated, render the protected content
return children;
}

View File

@@ -0,0 +1 @@
export {AuthorizedOnly} from './AuthorizedOnly';

View File

@@ -1,20 +1,31 @@
import {JSX, useState} from 'react'; import {JSX, useState, useEffect} from 'react';
import {useAppSelector, useAppDispatch} from '../../hooks'; 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'; import {InputField} from '../InputField';
export function LoginForm(): JSX.Element { export function LoginForm(): JSX.Element | null {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate();
const [applicationId, setApplicationId] = useState<string>('phenixrts.com-alex.zinn'); const [applicationId, setApplicationId] = useState<string>('phenixrts.com-alex.zinn');
const [secret, setSecret] = useState<string>('AMAsDzr.dIuGMZ.Zu52Dt~MQvP!DZwYg'); const [secret, setSecret] = useState<string>('AMAsDzr.dIuGMZ.Zu52Dt~MQvP!DZwYg');
const isAuthenticating = useAppSelector(selectIsAuthenticating); const isAuthenticating = useAppSelector(selectIsAuthenticating);
const connectionStatus = useAppSelector(selectStatus); const connectionStatus = useAppSelector(selectStatus);
const isAuthenticated = useAppSelector(selectIsAuthenticated);
// Only allow authentication when WebSocket is Online
const isConnectionReady = connectionStatus === 'Online'; const isConnectionReady = connectionStatus === 'Online';
const isDisabled = isAuthenticating || !isConnectionReady; 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<HTMLFormElement>) => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -28,6 +39,11 @@ export function LoginForm(): JSX.Element {
setSecret(''); setSecret('');
}; };
// Don't render login form if already authenticated
if (isAuthenticated) {
return null;
}
return ( return (
<> <>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
@@ -39,22 +55,11 @@ export function LoginForm(): JSX.Element {
disabled={isDisabled} disabled={isDisabled}
isRequired={true} isRequired={true}
/> />
<InputField <InputField type="password" placeholder="secret" value={secret} onValueChange={value => setSecret(value)} disabled={isDisabled} isRequired={true} />
type="password"
placeholder="secret"
value={secret}
onValueChange={value => setSecret(value)}
disabled={isDisabled}
isRequired={true}
/>
<button type="submit" disabled={isDisabled}> <button type="submit" disabled={isDisabled}>
{isConnectionReady ? 'Login' : 'Connecting...'} {isConnectionReady ? 'Login' : 'Connecting...'}
</button> </button>
{!isConnectionReady && ( {!isConnectionReady && <div style={{color: 'orange', fontSize: '12px', marginTop: '5px'}}>Waiting for WebSocket connection...</div>}
<div style={{color: 'orange', fontSize: '12px', marginTop: '5px'}}>
Waiting for WebSocket connection...
</div>
)}
</form> </form>
</> </>
); );

View File

@@ -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 (
<nav
style={{
background: '#f8f9fa',
padding: '1rem',
borderBottom: '1px solid #dee2e6',
marginBottom: '2rem'
}}>
<div
style={{
maxWidth: '1200px',
margin: '0 auto',
display: 'flex',
gap: '1rem'
}}>
{navItems.map(item => (
<Link
key={item.path}
to={item.path}
style={{
padding: '0.5rem 1rem',
textDecoration: 'none',
color: location.pathname === item.path ? '#007bff' : '#6c757d',
background: location.pathname === item.path ? '#e7f3ff' : 'transparent',
borderRadius: '4px',
border: location.pathname === item.path ? '1px solid #007bff' : '1px solid transparent'
}}>
{item.label}
</Link>
))}
</div>
</nav>
);
}

View File

@@ -0,0 +1 @@
export {Navigation} from './Navigation';

View File

@@ -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 (
<AuthorizedOnly fallback={fallback} redirectTo={redirectTo}>
{children}
</AuthorizedOnly>
);
}

View File

@@ -0,0 +1 @@
export {ProtectedRoute} from './ProtectedRoute';

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export {QueryPreservingRedirect} from './QueryPreservingRedirect';

View File

@@ -2,3 +2,7 @@ export * from './InputField';
export * from './LoginForm'; export * from './LoginForm';
export * from './FlexContainer'; export * from './FlexContainer';
export * from './WebSocketStatusView'; export * from './WebSocketStatusView';
export * from './AuthorizedOnly';
export * from './ProtectedRoute';
export * from './Navigation';
export * from './QueryPreservingRedirect';

40
src/routers/router.tsx Normal file
View File

@@ -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 (
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginView />} />
{/* Test route for debugging query parameters */}
<Route path="/test" element={<TestView />} />
{/* Protected routes - require authentication */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardView />
</ProtectedRoute>
}
/>
<Route
path="/channels"
element={
<ProtectedRoute>
<ChannelsView />
</ProtectedRoute>
}
/>
{/* Default redirects with query parameter preservation */}
<Route path="/" element={<QueryPreservingRedirect to="/dashboard" />} />
<Route path="*" element={<QueryPreservingRedirect to="/dashboard" />} />
</Routes>
</BrowserRouter>
);
}

View File

@@ -1,4 +1,6 @@
export default { export default {
loginUrl: () => '/login',
dashboardUrl: () => '/dashboard',
fetchChannelsUrl: () => '/pcast/channels', fetchChannelsUrl: () => '/pcast/channels',
createChannelUrl: () => '/pcast/channel', createChannelUrl: () => '/pcast/channel',
deleteChannelUrl: () => '/pcast/channel', deleteChannelUrl: () => '/pcast/channel',

View File

@@ -131,7 +131,6 @@ export default class Logger {
} }
if (args.length > 1) { if (args.length > 1) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
args = args.reduce((accumulator: any, currentValue: any, index: number, array: any[]) => { args = args.reduce((accumulator: any, currentValue: any, index: number, array: any[]) => {
if (index + 1 === array.length && currentValue instanceof Error) { if (index + 1 === array.length && currentValue instanceof Error) {
return accumulator + '\n' + this.toString(currentValue.stack); return accumulator + '\n' + this.toString(currentValue.stack);

View File

@@ -58,7 +58,7 @@ export default class LoggerFactory {
static async applyTelemetryConfiguration(level: LoggingLevel): Promise<void> { static async applyTelemetryConfiguration(level: LoggingLevel): Promise<void> {
this._telemetryConfiguration.threshold = level || LoggerDefaults.defaultTelemetryLoggingLevel; this._telemetryConfiguration.threshold = level || LoggerDefaults.defaultTelemetryLoggingLevel;
this._telemetryConfiguration.url = hostService.getTelemetryUrl(); this._telemetryConfiguration.url = hostService.getTelemetryUrl();
this._telemetryConfiguration.environment = hostService.getBaseUrlOrigin(); this._telemetryConfiguration.environment = hostService.getPcastBaseUrlOrigin();
// this._telemetryConfiguration.tenancy = await userStore.get('applicationId'); // this._telemetryConfiguration.tenancy = await userStore.get('applicationId');
// this._telemetryConfiguration.userId = await userStore.get('applicationId'); // this._telemetryConfiguration.userId = await userStore.get('applicationId');
// this._telemetryConfiguration.sessionId = await userStore.get('sessionId'); // this._telemetryConfiguration.sessionId = await userStore.get('sessionId');

View File

@@ -116,22 +116,22 @@ export class PhenixWebSocket implements IDisposable {
this._logger.info('WebSocket connecting...'); this._logger.info('WebSocket connecting...');
this._status.value = PhenixWebSocketStatus.Connecting; this._status.value = PhenixWebSocketStatus.Connecting;
}); });
this._websocketMQ.onEvent('connected', () => { this._websocketMQ.onEvent('connected', () => {
this._logger.info('WebSocket connected successfully'); this._logger.info('WebSocket connected successfully');
this._status.value = PhenixWebSocketStatus.Online; this._status.value = PhenixWebSocketStatus.Online;
}); });
this._websocketMQ.onEvent('disconnected', () => { this._websocketMQ.onEvent('disconnected', () => {
this._logger.info('WebSocket disconnected'); this._logger.info('WebSocket disconnected');
this._status.value = PhenixWebSocketStatus.Offline; this._status.value = PhenixWebSocketStatus.Offline;
}); });
this._websocketMQ.onEvent('reconnecting', () => { this._websocketMQ.onEvent('reconnecting', () => {
this._logger.info('WebSocket reconnecting...'); this._logger.info('WebSocket reconnecting...');
this._status.value = PhenixWebSocketStatus.Reconnecting; this._status.value = PhenixWebSocketStatus.Reconnecting;
}); });
this._websocketMQ.onEvent('error', (error: unknown) => { this._websocketMQ.onEvent('error', (error: unknown) => {
this._logger.error('WebSocket error occurred:', error); this._logger.error('WebSocket error occurred:', error);
this._status.value = PhenixWebSocketStatus.Error; this._status.value = PhenixWebSocketStatus.Error;

View File

@@ -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<void> {
console.log('initialize', pcastUri, applicationCredentials);
this._pcastApi = PCastApi.create(pcastUri, applicationCredentials);
}
public get pcastApi(): PCastApi | null {
return this._pcastApi;
}
public async fetchChannelsList(): Promise<Channel[]> {
return this._pcastApi?.channels.list() || [];
}
}
export default PCastApiService.getInstance();

View File

@@ -66,8 +66,6 @@ export default class TelemetryService {
this._logs.unshift(logRecord); this._logs.unshift(logRecord);
} }
// @ts-expect-error: Unused variable intentionally // @ts-expect-error: Unused variable intentionally
const ignored = this.sendLogsIfAble(); const ignored = this.sendLogsIfAble();
} }
@@ -116,10 +114,8 @@ export default class TelemetryService {
.then(response => { .then(response => {
this._isSending = false; this._isSending = false;
// @ts-expect-error: Unused variable intentionally // @ts-expect-error: Unused variable intentionally
const ignored = this.sendLogsIfAble(); const ignored = this.sendLogsIfAble();
return response; return response;
@@ -127,10 +123,8 @@ export default class TelemetryService {
.catch(() => { .catch(() => {
this._isSending = false; this._isSending = false;
// @ts-expect-error: Unused variable intentionally // @ts-expect-error: Unused variable intentionally
const ignored = this.sendLogsIfAble(); const ignored = this.sendLogsIfAble();
}); });
} }

View File

@@ -5,22 +5,17 @@
export type UrlType = 'pcast' | 'telemetry' | 'websocket'; export type UrlType = 'pcast' | 'telemetry' | 'websocket';
export default class UrlService { 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) { if (!url) {
throw new Error('Invalid URL'); throw new Error('Invalid URL');
} }
const baseURL = new URL(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) { if (backendUrlParam) {
const backendUrl = new URL(backendUrlParam); return backendUrlParam;
if (urlType === 'websocket') {
backendUrl.protocol = 'wss:';
backendUrl.pathname = '/ws';
}
return backendUrl.toString();
} }
const segments = baseURL.hostname.split('.'); const segments = baseURL.hostname.split('.');
@@ -51,8 +46,6 @@ export default class UrlService {
baseURL.hostname = segments.join('.'); baseURL.hostname = segments.join('.');
return baseURL.origin; return baseURL.origin;
} }
@@ -71,6 +64,10 @@ export default class UrlService {
} }
public static getWebSocketUrl(url: string = window?.location?.href): string { 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();
} }
} }

View File

@@ -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); console.log('phenixWebSocketMiddleware', action);
if (typeof action === 'function') { if (typeof action === 'function') {
return action(storeApi.getState(), storeApi.dispatch); return action(storeApi.getState(), storeApi.dispatch);
@@ -9,4 +9,4 @@ const phenixWebSocketMiddleware: Middleware = (storeApi) => (next) => (action) =
return next(action); return next(action);
}; };
export default phenixWebSocketMiddleware; export default phenixWebSocketMiddleware;

View File

@@ -9,6 +9,8 @@ export interface IAuthenticationState {
isAuthenticating: boolean; isAuthenticating: boolean;
error: string | null; error: string | null;
status: PhenixWebSocketStatusType; status: PhenixWebSocketStatusType;
sessionId: string | null;
roles: string[];
} }
const initialAuthenticationState: IAuthenticationState = { const initialAuthenticationState: IAuthenticationState = {
@@ -17,7 +19,9 @@ const initialAuthenticationState: IAuthenticationState = {
isAuthenticated: false, isAuthenticated: false,
isAuthenticating: false, isAuthenticating: false,
error: null, error: null,
status: 'Offline' status: 'Offline',
sessionId: null,
roles: []
}; };
// Memoized selectors // Memoized selectors
@@ -36,6 +40,11 @@ export const selectCredentials = createSelector([selectAuthentication], authenti
secret: authentication.secret secret: authentication.secret
})); }));
export const selectSessionInfo = createSelector([selectAuthentication], authentication => ({
sessionId: authentication.sessionId,
roles: authentication.roles
}));
const authenticateCredentialsThunk = createAsyncThunk( const authenticateCredentialsThunk = createAsyncThunk(
'authentication/authenticate', 'authentication/authenticate',
async (credentials: {applicationId: string; secret: string}, {rejectWithValue}) => { 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({ const authenticationSlice = createSlice({
name: 'authentication', name: 'authentication',
initialState: {...initialAuthenticationState}, initialState: {...initialAuthenticationState},
@@ -56,6 +69,8 @@ const authenticationSlice = createSlice({
state.applicationId = null; state.applicationId = null;
state.secret = null; state.secret = null;
state.error = null; state.error = null;
state.sessionId = null;
state.roles = [];
}, },
error: (state, action) => { error: (state, action) => {
// Store only the error message string, not the Error object // Store only the error message string, not the Error object
@@ -76,11 +91,6 @@ const authenticationSlice = createSlice({
state.isAuthenticating = true; state.isAuthenticating = true;
state.error = null; state.error = null;
}) })
.addCase(authenticateCredentialsThunk.fulfilled, state => {
state.isAuthenticating = false;
state.error = null;
state.isAuthenticated = true;
})
.addCase(authenticateCredentialsThunk.rejected, (state, action) => { .addCase(authenticateCredentialsThunk.rejected, (state, action) => {
state.isAuthenticating = false; state.isAuthenticating = false;
// Extract error message string instead of creating new Error object // Extract error message string instead of creating new Error object
@@ -102,9 +112,58 @@ const authenticationSlice = createSlice({
state.isAuthenticated = false; state.isAuthenticated = false;
state.applicationId = null; state.applicationId = null;
state.secret = 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 const {reducer: authenticationReducer, actions: authenticationActions} = authenticationSlice;
export {authenticateCredentialsThunk}; export {authenticateCredentialsThunk, signoutThunk};

View File

@@ -1,7 +1,6 @@
import {configureStore} from '@reduxjs/toolkit'; import {configureStore} from '@reduxjs/toolkit';
import reducer from './reducer'; 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<typeof store.getState>; export type RootState = ReturnType<typeof store.getState>;

View File

@@ -1,8 +1,8 @@
declare module 'phenix-web-proto' { declare module 'phenix-web-proto' {
import { ILogger } from 'services/logger/LoggerInterface'; import {ILogger} from 'services/logger/LoggerInterface';
export class MQWebSocket { 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; onEvent(eventName: string, handler: (event: unknown) => void): void;
onRequest(requestName: string, handler: (request: 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; sendRequest(type: string, message: unknown, callback?: (error: unknown, response: unknown) => void, settings?: unknown): void;

View File

@@ -0,0 +1,40 @@
import {JSX} from 'react';
import {Navigation} from '../components';
export function ChannelsView(): JSX.Element {
return (
<>
<Navigation />
<div
style={{
padding: '2rem',
maxWidth: '1200px',
margin: '0 auto'
}}>
<h1>Channels</h1>
<div
style={{
background: '#f5f5f5',
padding: '1rem',
borderRadius: '8px',
marginBottom: '2rem'
}}>
<p>This is a protected view that requires authentication.</p>
<p>If you can see this, you are successfully authenticated!</p>
</div>
<div
style={{
background: 'white',
padding: '1rem',
borderRadius: '8px',
border: '1px solid #ddd'
}}>
<h3>Channel List</h3>
<p>No channels available at the moment.</p>
<p>This view demonstrates the AuthorizedOnly middleware in action.</p>
</div>
</div>
</>
);
}

105
src/views/DashboardView.tsx Normal file
View File

@@ -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 (
<>
<Navigation />
<div
style={{
padding: '2rem',
maxWidth: '1200px',
margin: '0 auto'
}}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2rem'
}}>
<h1>Dashboard</h1>
<button
onClick={handleLogout}
style={{
padding: '0.5rem 1rem',
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}>
Logout
</button>
</div>
<div
style={{
background: '#222',
padding: '1rem',
borderRadius: '8px',
marginBottom: '2rem'
}}>
<h3>Welcome!</h3>
<p>You are successfully authenticated.</p>
<p>
<strong>Application ID:</strong> {credentials.applicationId || 'N/A'}
</p>
<p>
<strong>Session ID:</strong> {sessionInfo.sessionId || 'N/A'}
</p>
<p>
<strong>Roles:</strong> {sessionInfo.roles.length > 0 ? sessionInfo.roles.join(', ') : 'None'}
</p>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1rem'
}}>
<div
style={{
background: 'white',
padding: '1rem',
borderRadius: '8px',
border: '1px solid #ddd'
}}>
<h4>Quick Actions</h4>
<ul>
<li>View Channels</li>
<li>Check Analytics</li>
<li>Monitor QoS</li>
<li>Manage Rooms</li>
</ul>
</div>
<div
style={{
background: 'white',
padding: '1rem',
borderRadius: '8px',
border: '1px solid #ddd'
}}>
<h4>Recent Activity</h4>
<p>No recent activity to display.</p>
</div>
</div>
</div>
</>
);
}

17
src/views/LoginView.tsx Normal file
View File

@@ -0,0 +1,17 @@
import {JSX} from 'react';
import {LoginForm} from 'components';
export function LoginView(): JSX.Element {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh'
}}>
<LoginForm />
</div>
);
}

99
src/views/TestView.tsx Normal file
View File

@@ -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 (
<div
style={{
padding: '2rem',
maxWidth: '800px',
margin: '0 auto'
}}>
<h1>Query Parameter Test Page</h1>
<div
style={{
background: '#f8f9fa',
padding: '1rem',
borderRadius: '8px',
marginBottom: '2rem',
border: '1px solid #dee2e6'
}}>
<h3>Current URL Information</h3>
<p>
<strong>Full URL:</strong> {window.location.href}
</p>
<p>
<strong>Path:</strong> {window.location.pathname}
</p>
<p>
<strong>Search:</strong> {window.location.search || '(none)'}
</p>
<p>
<strong>Hash:</strong> {window.location.hash || '(none)'}
</p>
</div>
<div
style={{
background: '#e7f3ff',
padding: '1rem',
borderRadius: '8px',
marginBottom: '2rem',
border: '1px solid #007bff'
}}>
<h3>Query Parameters</h3>
{allParams.length > 0 ? (
<ul style={{margin: 0, paddingLeft: '1.5rem'}}>
{allParams.map(([key, value]) => (
<li key={key}>
<strong>{key}:</strong> {value}
</li>
))}
</ul>
) : (
<p>No query parameters found</p>
)}
</div>
<div
style={{
background: '#fff3cd',
padding: '1rem',
borderRadius: '8px',
border: '1px solid #ffc107'
}}>
<h3>Test Instructions</h3>
<p>To test query parameter preservation:</p>
<ol>
<li>
Navigate to this page with query parameters: <code>/test?param1=value1&param2=value2</code>
</li>
<li>
If not authenticated, you should be redirected to: <code>/login?param1=value1&param2=value2</code>
</li>
<li>Check the console for debug information from AuthorizedOnly</li>
<li>After login, you should be able to return to the original URL with parameters</li>
</ol>
<h4>Test URLs to try:</h4>
<ul>
<li>
<code>/test?tab=dashboard&filter=active</code>
</li>
<li>
<code>/test?user=123&view=detailed&sort=date</code>
</li>
<li>
<code>/test?lang=en&theme=dark&debug=true</code>
</li>
</ul>
</div>
</div>
);
}

4
src/views/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export {LoginView} from './LoginView';
export {DashboardView} from './DashboardView';
export {ChannelsView} from './ChannelsView';
export {TestView} from './TestView';

View File

@@ -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"} {"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"}