Skip to content

Commit b150ea4

Browse files
committed
feat: Implement login functionality and API path management
- Added LoginForm component for user authentication. - Introduced RequireAuth component to protect routes. - Created ApiPaths component to display available API paths. - Added ApiPathDetailDrawer for detailed view of selected API paths. - Updated routing to include login and API paths. - Integrated Redux for authentication state management. - Enhanced DebugConsole with device filtering and search capabilities. - Refactored TopNav to dynamically display available apps based on authentication. - Added selectors and slice for authentication and debug console state management.
1 parent 610788f commit b150ea4

19 files changed

Lines changed: 638 additions & 198 deletions

src/App.tsx

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { Suspense } from "react";
22
import { useDispatch, useSelector } from "react-redux";
33
import { Navigate, Route, Routes } from "react-router-dom";
4+
import { ApiPaths } from './features/ApiPaths';
45
import ConfigFile from "./features/ConfigFile";
56
import DebugConsole from "./features/DebugConsole/DebugConsole";
67
import DeviceList from "./features/DeviceList";
78
import ErrorBoundary from "./features/ErrorBoundary";
89
import InitializationExceptions from "./features/InitializationExceptions";
10+
import LoginForm from "./features/LoginForm";
911
import MainLayout from "./features/MainLayout";
1012
import MobileControl from './features/MobileControl';
13+
import RequireAuth from "./features/RequireAuth";
1114
import Routing from './features/Routing';
1215
import Types from "./features/Types";
1316
import Versions from "./features/Versions";
@@ -48,26 +51,34 @@ function App() {
4851
<ErrorBoundary>
4952
<Suspense fallback={null}>
5053
<Routes>
51-
<Route path="/" element={<Navigate to="/app01/versions" replace />} />
54+
<Route path="/" element={<Navigate to="/login" replace />} />
55+
<Route path="/login" element={<MainLayout isConnected={isConnected} />}>
56+
<Route index element={<LoginForm />} />
57+
</Route>
58+
5259
<Route path=":appId" element={<MainLayout isConnected={isConnected} />}>
53-
<Route path="versions" element={<Versions />} />
54-
<Route path="initializationExceptions" element={<InitializationExceptions />} />
55-
<Route path="config" element={<ConfigFile />} />
56-
<Route path="devices" element={<DeviceList />} />
57-
<Route path="types" element={<Types />} />
58-
<Route path="routing" element={<Routing />} />
59-
<Route path="mobileControl" element={<MobileControl />} />
60-
<Route
61-
path="console"
62-
element={
63-
<DebugConsole
64-
isConnected={isConnected}
65-
join={join}
66-
stop={stop}
67-
clear={clear}
68-
/>
69-
}
70-
/>
60+
<Route path="login" element={<LoginForm />} />
61+
<Route element={<RequireAuth />}>
62+
<Route path="versions" element={<Versions />} />
63+
<Route path="apiPaths" element={<ApiPaths />} />
64+
<Route path="initializationExceptions" element={<InitializationExceptions />} />
65+
<Route path="config" element={<ConfigFile />} />
66+
<Route path="devices" element={<DeviceList />} />
67+
<Route path="types" element={<Types />} />
68+
<Route path="routing" element={<Routing />} />
69+
<Route path="mobileControl" element={<MobileControl />} />
70+
<Route
71+
path="console"
72+
element={
73+
<DebugConsole
74+
isConnected={isConnected}
75+
join={join}
76+
stop={stop}
77+
clear={clear}
78+
/>
79+
}
80+
/>
81+
</Route>
7182
</Route>
7283
</Routes>
7384
</Suspense>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Offcanvas } from "react-bootstrap";
2+
import { Route } from "../store/apiSlice";
3+
4+
const ApiPathDetailDrawer = ({
5+
show,
6+
route,
7+
handleClose,
8+
url,
9+
}: ApiPathDetailDrawerProps) => {
10+
if (!route) return null;
11+
12+
return (
13+
<Offcanvas
14+
show={show}
15+
onHide={handleClose}
16+
placement="end"
17+
backdrop={false}
18+
className="right-drawer shadow-sm border p-3"
19+
>
20+
<Offcanvas.Header closeButton>
21+
<Offcanvas.Title>API Path Detail</Offcanvas.Title>
22+
</Offcanvas.Header>
23+
<Offcanvas.Body>
24+
<div className="d-flex flex-column gap-3">
25+
<div>
26+
<h5>Name</h5>
27+
{route.Name}
28+
</div>
29+
<div>
30+
<h5>URL</h5>
31+
<a href={`${url}/${route.Url}`} target="_blank" rel="noopener noreferrer">
32+
{`${url}/${route.Url}`}
33+
</a>
34+
</div>
35+
{route.DataTokens?.Name && (
36+
<div>
37+
<h5>Data Token Name</h5>
38+
{route.DataTokens.Name}
39+
</div>
40+
)}
41+
</div>
42+
</Offcanvas.Body>
43+
</Offcanvas>
44+
);
45+
};
46+
47+
export default ApiPathDetailDrawer;
48+
49+
interface ApiPathDetailDrawerProps {
50+
show: boolean;
51+
route: Route | undefined;
52+
handleClose: () => void;
53+
url: string;
54+
}

src/features/ApiPaths.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { skipToken } from '@reduxjs/toolkit/query';
2+
import { useState } from 'react';
3+
import useAppParams from '../shared/hooks/useAppParams';
4+
import { Route, useGetPathsQuery } from '../store/apiSlice';
5+
import ApiPathDetailDrawer from './ApiPathDetailDrawer';
6+
7+
8+
export const ApiPaths = () => {
9+
const { appId } = useAppParams();
10+
const { data: apiPathData, isLoading } = useGetPathsQuery(appId ? { appId } : skipToken);
11+
const [showDrawer, setShowDrawer] = useState(false);
12+
const [selectedRoute, setSelectedRoute] = useState<Route>();
13+
14+
if (isLoading) return <div>Loading...</div>;
15+
16+
if (!apiPathData?.routes) return <div>No paths available</div>;
17+
18+
const sorted = [...apiPathData.routes].sort((a, b) => a.Name.localeCompare(b.Name));
19+
20+
function clickRow(route: Route) {
21+
setSelectedRoute(route);
22+
setShowDrawer(true);
23+
}
24+
25+
function handleClose() {
26+
setShowDrawer(false);
27+
setSelectedRoute(undefined);
28+
}
29+
30+
return (
31+
<div className="d-flex flex-column overflow-hidden h-100">
32+
<h2 className='mb-2'>Available API Paths</h2>
33+
<table className="table table-striped">
34+
<thead>
35+
<tr>
36+
<th>Name</th>
37+
<th>URL</th>
38+
</tr>
39+
</thead>
40+
<tbody>
41+
{sorted.map((path) => (
42+
<tr
43+
key={path.Name}
44+
onClick={() => clickRow(path)}
45+
className={'cursor-pointer hover' + (selectedRoute === path ? ' table-primary' : '')}
46+
>
47+
<td>{path.Name}</td>
48+
<td>{path.Url}</td>
49+
</tr>
50+
))}
51+
</tbody>
52+
</table>
53+
54+
<ApiPathDetailDrawer
55+
show={showDrawer}
56+
route={selectedRoute}
57+
handleClose={handleClose}
58+
url={apiPathData.url}
59+
/>
60+
</div>
61+
);
62+
}

src/features/DebugConsole/DebugFilters.tsx

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,44 @@
11

22
import { skipToken } from '@reduxjs/toolkit/query';
33
import { useMemo } from 'react';
4-
import { FilterClearButton } from '../../shared/FilterClearButton';
5-
import { FilterDropdownSearchParams } from '../../shared/FilterDropdownSearchParams';
4+
import { Button } from 'react-bootstrap';
65
import useAppParams from '../../shared/hooks/useAppParams';
76
import { IdLabel } from '../../shared/types/IdLabel';
87
import { useGetDevicesQuery } from '../../store/apiSlice';
9-
import { debugConsts, debugSearchParams, logLevelOpts } from "./debugConsts";
8+
import { debugConsoleActions } from '../../store/debugConsole/debugConsoleSlice';
9+
import { useAppDispatch } from '../../store/hooks';
10+
import { debugConsts } from './debugConsts';
11+
import { DeviceFilterDropdown } from './DeviceFilterDropdown';
1012

1113

1214
export const DebugFilters = () => {
1315
const { appId } = useAppParams();
1416
const { data: devices } = useGetDevicesQuery(appId ? { appId } : skipToken);
17+
const dispatch = useAppDispatch();
1518

1619
const items = useMemo(() => {
17-
if (!devices) return [{ id: debugConsts.GLOBAL, label: "Global"}];
20+
if (!devices) return [{ id: debugConsts.GLOBAL, label: 'Global' }];
1821

19-
let fullList: IdLabel[] = [
20-
{ id: debugConsts.GLOBAL, label: "Global"}
21-
];
22+
const deviceItems: IdLabel[] = devices
23+
.map((d) => ({ id: d.Key, label: d.Name || d.Key }))
24+
.sort((a, b) => a.label.localeCompare(b.label));
2225

23-
devices.forEach((d) => {
24-
fullList.push({ id: d.Key, label: d.Name});
25-
});
26-
27-
return fullList;
26+
return [{ id: debugConsts.GLOBAL, label: 'Global' }, ...deviceItems];
2827
}, [devices]);
2928

30-
if (!devices) return null;
29+
if (!devices) return null;
3130

3231
return (
3332
<div className="row row-cols-sm-auto g-3 user-select-none">
3433
<div className="col-12 d-none d-lg-block">
35-
<FilterDropdownSearchParams
36-
paramName={debugConsts.DEVICE}
37-
buttonLabel="Devices"
38-
items={items}
39-
/>
40-
<FilterDropdownSearchParams
41-
paramName={debugConsts.LOG_LEVEL}
42-
buttonLabel="Log Level"
43-
items={logLevelOpts}
44-
/>
45-
<FilterClearButton allParams={Object.values(debugSearchParams)} />
34+
<DeviceFilterDropdown items={items} />
35+
<Button
36+
variant="outline"
37+
className="py-1 ms-1"
38+
onClick={() => dispatch(debugConsoleActions.clearAllFilters())}
39+
>
40+
Clear Filters
41+
</Button>
4642
</div>
4743
</div>
4844
);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { ChangeEvent, useState } from 'react';
2+
import { Badge } from 'react-bootstrap';
3+
import Dropdown from 'react-bootstrap/Dropdown';
4+
import Form from 'react-bootstrap/Form';
5+
import { IconDarkChevronDown } from '../../shared/icons';
6+
import { IdLabel } from '../../shared/types/IdLabel';
7+
import {
8+
selectCheckedDevices,
9+
selectDeviceLevels,
10+
} from '../../store/debugConsole/debugConsoleSelectors';
11+
import { debugConsoleActions } from '../../store/debugConsole/debugConsoleSlice';
12+
import { useAppDispatch, useAppSelector } from '../../store/hooks';
13+
import { logLevelOpts } from './debugConsts';
14+
15+
interface DeviceFilterDropdownProps {
16+
items: IdLabel[];
17+
}
18+
19+
export const DeviceFilterDropdown = ({ items }: DeviceFilterDropdownProps) => {
20+
const dispatch = useAppDispatch();
21+
const checkedDevices = useAppSelector(selectCheckedDevices);
22+
const deviceLevels = useAppSelector(selectDeviceLevels);
23+
const [openLevelDropdowns, setOpenLevelDropdowns] = useState<Record<string, boolean>>({});
24+
25+
function handleCheckChange(
26+
event: ChangeEvent<HTMLInputElement>,
27+
deviceId: string
28+
) {
29+
if (event.target.checked) {
30+
dispatch(debugConsoleActions.checkDevice(deviceId));
31+
} else {
32+
dispatch(debugConsoleActions.uncheckDevice(deviceId));
33+
}
34+
}
35+
36+
function handleLevelChange(deviceId: string, level: string) {
37+
dispatch(debugConsoleActions.setDeviceLevel({ deviceId, level }));
38+
setOpenLevelDropdowns((prev) => ({ ...prev, [deviceId]: false }));
39+
}
40+
41+
return (
42+
<Dropdown className="d-inline-block">
43+
<Dropdown.Toggle variant="outline" className="py-1" id="device-filter-dropdown">
44+
Devices
45+
{checkedDevices.length > 0 && (
46+
<Badge pill bg="primary" className="ms-1">
47+
{checkedDevices.length}
48+
</Badge>
49+
)}
50+
<IconDarkChevronDown className="ms-1" />
51+
</Dropdown.Toggle>
52+
53+
<Dropdown.Menu className="scroll-dropdown shadow">
54+
{items.map((item) => {
55+
const stringId = item.id.toString();
56+
const isChecked = checkedDevices.includes(stringId);
57+
const currentLevel = deviceLevels[stringId] ?? 'Information';
58+
59+
return (
60+
<Dropdown.Item
61+
key={stringId}
62+
as="div"
63+
className="d-flex align-items-center gap-2 px-2"
64+
onClick={(e) => e.stopPropagation()}
65+
>
66+
<Form.Check
67+
type="checkbox"
68+
id={`device-check-${stringId}`}
69+
label={item.label}
70+
checked={isChecked}
71+
onChange={(e) => handleCheckChange(e, stringId)}
72+
className="flex-grow-1 m-0"
73+
/>
74+
{isChecked && (
75+
<Dropdown
76+
show={openLevelDropdowns[stringId] ?? false}
77+
onToggle={(isOpen) =>
78+
setOpenLevelDropdowns((prev) => ({ ...prev, [stringId]: isOpen }))
79+
}
80+
className="ms-auto"
81+
>
82+
<Dropdown.Toggle
83+
variant="outline-secondary"
84+
size="sm"
85+
id={`level-toggle-${stringId}`}
86+
className="py-0 px-2"
87+
>
88+
{currentLevel}
89+
</Dropdown.Toggle>
90+
<Dropdown.Menu className="shadow">
91+
{logLevelOpts.map((opt) => (
92+
<Dropdown.Item
93+
key={opt.id}
94+
active={opt.id === currentLevel}
95+
onClick={(e) => {
96+
e.stopPropagation();
97+
handleLevelChange(stringId, opt.id.toString());
98+
}}
99+
>
100+
{opt.label}
101+
</Dropdown.Item>
102+
))} </Dropdown.Menu>
103+
</Dropdown>
104+
)}
105+
</Dropdown.Item>
106+
);
107+
})}
108+
</Dropdown.Menu>
109+
</Dropdown>
110+
);
111+
};

src/features/DebugConsole/debugConsts.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ const DEBUG = "Debug";
1717

1818
const LOG_LEVELS = [ERROR, WARNING, INFORMATION, FATAL, VERBOSE, DEBUG];
1919

20+
export const LOG_LEVEL_ORDER: Record<string, number> = {
21+
[VERBOSE]: 0,
22+
[DEBUG]: 1,
23+
[INFORMATION]: 2,
24+
[WARNING]: 3,
25+
[ERROR]: 4,
26+
[FATAL]: 5,
27+
};
28+
2029
export const logLevelOpts: IdLabel[] = [
2130
{ id: FATAL, label: "Fatal" },
2231
{ id: ERROR, label: "Error" },

0 commit comments

Comments
 (0)