add UI flow for the buzzer steps
This commit is contained in:
parent
d92305e504
commit
eb7d37a01c
@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"express-fingerprint": "^1.2.2",
|
"express-fingerprint": "^1.2.2",
|
||||||
"node-fetch": "^3.3.2"
|
"node-fetch": "^3.3.2",
|
||||||
|
"react-otp-input": "^3.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,10 @@
|
|||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Web site created using create-react-app"
|
content="Doorman, buzzer tool"
|
||||||
/>
|
/>
|
||||||
|
<title>Doorman</title>
|
||||||
|
<link rel="stylesheet" href="/global.css">
|
||||||
<!--
|
<!--
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
|||||||
BIN
packages/client/public/Insulin_Nation_Low_Kramer.gif
Normal file
BIN
packages/client/public/Insulin_Nation_Low_Kramer.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
4
packages/client/public/global.css
Normal file
4
packages/client/public/global.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
body {
|
||||||
|
margin: 0px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
@ -1,25 +1,11 @@
|
|||||||
import { Header } from '@cloudscape-design/components';
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { AlertContent, AlertContext } from './contexts/AlertContext';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const [ content, setContent ] = useState<AlertContent>({});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<>
|
||||||
<AlertContext.Provider value={
|
<Outlet/>
|
||||||
{
|
</>
|
||||||
content,
|
|
||||||
setContent,
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
<Header>Doorman 2.0</Header>
|
|
||||||
</AlertContext.Provider>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
packages/client/src/components/AuthComponent.tsx
Normal file
44
packages/client/src/components/AuthComponent.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Button, FormField, Input, SpaceBetween } from "@cloudscape-design/components";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
// COPIED FROM SERVER
|
||||||
|
export enum IAuthMode {
|
||||||
|
FIXED_PIN = "Fixed Pin",
|
||||||
|
RANDOM_ROTATING_KEY = "Random Rotating Key",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface IAuthComponentProps {
|
||||||
|
door: string,
|
||||||
|
authMode: IAuthMode,
|
||||||
|
secret: string | null;
|
||||||
|
onUnlock: () => void;
|
||||||
|
onError: (res: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthComponent = ({ door, secret, authMode, onError, onUnlock }: IAuthComponentProps) => {
|
||||||
|
const [ key, setKey ] = useState(secret);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SpaceBetween size='l'>
|
||||||
|
<FormField label={"PIN"} constraintText={"Enter the PIN to unlock the door"}>
|
||||||
|
<Input readOnly={secret !== null} value={key || ""} onChange={({ detail }) => setKey(detail.value)} />
|
||||||
|
</FormField>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
fetch(`/api/door/${door}/auth?key=${key}&rotatingKey=${key}`)
|
||||||
|
.then(async res => {
|
||||||
|
if (res.status !== 200) {
|
||||||
|
onError(await res.json());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onUnlock();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Unlock the door
|
||||||
|
</Button>
|
||||||
|
</SpaceBetween>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
packages/client/src/components/CountdownBar.tsx
Normal file
41
packages/client/src/components/CountdownBar.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { ProgressBar } from "@cloudscape-design/components";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface ICountdownBarProps {
|
||||||
|
timeSeconds: number;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onExpiry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CountdownBar = ({ timeSeconds, onCancel, onExpiry }: ICountdownBarProps) => {
|
||||||
|
const [ countdown, setCountdown ] = useState<number>(timeSeconds);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCountdown(countdown - 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown === 0) {
|
||||||
|
onExpiry ? onExpiry(): null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCountdown(countdown - 1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProgressBar
|
||||||
|
label={"Authentication Timeout"}
|
||||||
|
description={"Dial the buzzer before the time expires"}
|
||||||
|
additionalInfo={`Authentication expires after ${timeSeconds}s to keep the door secure. Please dial the buzzer within the time remaining or it won't unlock`}
|
||||||
|
value={(countdown / timeSeconds) * 100}
|
||||||
|
variant="flash"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||||
import { AuthPage, loader as authpageloader } from './pages/AuthPage';
|
import { AuthPage, loader as authpageloader } from './pages/AuthPage';
|
||||||
|
import { DoorPage, loader as doorpageloader } from './pages/DoorPage';
|
||||||
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
@ -18,7 +19,12 @@ const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: "action/:action",
|
path: "action/:action",
|
||||||
loader: authpageloader,
|
loader: authpageloader,
|
||||||
element: <AuthPage />
|
element: <AuthPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "door/:door",
|
||||||
|
loader: doorpageloader,
|
||||||
|
element: <DoorPage />,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
260
packages/client/src/pages/DoorPage.tsx
Normal file
260
packages/client/src/pages/DoorPage.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import React, { ReactElement, ReactNode, useEffect, useState } from "react";
|
||||||
|
import { useLoaderData, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { AppLayout, BreadcrumbGroup, Container, Flashbar, FlashbarProps, Header, SideNavigation, SpaceBetween, TextContent, Wizard } from "@cloudscape-design/components";
|
||||||
|
import { AuthComponent, IAuthMode } from "../components/AuthComponent";
|
||||||
|
import OtpInput from 'react-otp-input';
|
||||||
|
import type { IDoorResponse } from "../../../server/src/types/IDoorResponse";
|
||||||
|
import { CountdownBar } from "../components/CountdownBar";
|
||||||
|
|
||||||
|
export async function loader({ params }: any) {
|
||||||
|
const response = await fetch(`/api/door/${params.door}`).then(res => res.json());
|
||||||
|
|
||||||
|
if (response.msg) {
|
||||||
|
throw new Error("Not a valid door");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response as IDoorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
label?: string;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectOptions: SelectOption[] = [
|
||||||
|
{ label: "backup", value: "backup" },
|
||||||
|
{ label: "key", value: "key" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DoorPage() {
|
||||||
|
const doorResponse = useLoaderData() as IDoorResponse;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const door = doorResponse.id;
|
||||||
|
const [ step, setStep ] = useState(0);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const secret = searchParams.get('key') || searchParams.get('rotatingKey');
|
||||||
|
const [ alerts, setAlerts ] = useState<FlashbarProps.MessageDefinition[]>([]);
|
||||||
|
const [ polling, setPolling ] = useState(false);
|
||||||
|
const [ inProgressAlert, setInProgressAlert ] = useState("");
|
||||||
|
|
||||||
|
const dismissAlert = (id: string) => {
|
||||||
|
setAlerts(alerts => alerts.filter(alert => alert.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAlert = (type: FlashbarProps.Type, content: ReactNode): FlashbarProps.MessageDefinition => {
|
||||||
|
const id = `${Math.random()}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
dismissible: type !== 'in-progress',
|
||||||
|
onDismiss: () => dismissAlert(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addAlert = (type: FlashbarProps.Type, content: ReactNode): string => {
|
||||||
|
const newAlert = createAlert(type, content);
|
||||||
|
setAlerts([
|
||||||
|
newAlert,
|
||||||
|
...alerts,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return newAlert.id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!polling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
const response = await fetch(`/api/door/${door}`).then(res => res.json());
|
||||||
|
|
||||||
|
// polling assumes that the door was opened and whatever closed it was the buzzer system...
|
||||||
|
// ie. state transition from OPEN to CLOSED before timeout means that twilio opened the door
|
||||||
|
// TODO: this may be a bad assumption in the future but works for now
|
||||||
|
if (response.status === "CLOSED") {
|
||||||
|
setStep(3);
|
||||||
|
setPolling(false);
|
||||||
|
setAlerts(alerts =>
|
||||||
|
[createAlert("success", "Buzzer successfully unlocked"), ...alerts.filter(alert => alert.id !== inProgressAlert)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [polling]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout
|
||||||
|
contentType="wizard"
|
||||||
|
breadcrumbs={
|
||||||
|
<BreadcrumbGroup
|
||||||
|
items={[
|
||||||
|
{ text: 'Door', href: '#' },
|
||||||
|
{ text: door, href: '#' },
|
||||||
|
{ text: 'Unlock', href: '#' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
notifications={
|
||||||
|
<Flashbar
|
||||||
|
stackItems={true}
|
||||||
|
items={alerts}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
navigation={
|
||||||
|
<SideNavigation
|
||||||
|
header={{
|
||||||
|
href: '#',
|
||||||
|
text: 'Doorman',
|
||||||
|
}}
|
||||||
|
items={[]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<Wizard
|
||||||
|
i18nStrings={{
|
||||||
|
stepNumberLabel: stepNumber =>
|
||||||
|
`Step ${stepNumber}`,
|
||||||
|
collapsedStepsLabel: (stepNumber, stepsCount) =>
|
||||||
|
`Step ${stepNumber} of ${stepsCount}`,
|
||||||
|
skipToButtonLabel: (step, stepNumber) =>
|
||||||
|
`Skip to ${step.title}`,
|
||||||
|
navigationAriaLabel: "Steps",
|
||||||
|
cancelButton: "Cancel",
|
||||||
|
previousButton: "Previous",
|
||||||
|
nextButton: "Next",
|
||||||
|
submitButton: "Finish",
|
||||||
|
optional: "optional"
|
||||||
|
}}
|
||||||
|
activeStepIndex={step}
|
||||||
|
onNavigate={({ detail }) => {
|
||||||
|
if (detail.requestedStepIndex === 2) {
|
||||||
|
addAlert("error", "You must unlock the door to proceed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.requestedStepIndex >= 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep(detail.requestedStepIndex);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setStep(0);
|
||||||
|
}}
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: "Get to the buzzer",
|
||||||
|
description:"Before unlocking this door, you should first arrive to the buzzer",
|
||||||
|
content: (
|
||||||
|
<Container
|
||||||
|
header={
|
||||||
|
<Header variant="h2">
|
||||||
|
Get to the buzzer
|
||||||
|
</Header>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Once you arrive at the buzzer, advance to the next step!
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Authenticate",
|
||||||
|
description: "This door is locked by a software system, unlocking it will allow you access",
|
||||||
|
content: (
|
||||||
|
<Container
|
||||||
|
header={
|
||||||
|
<Header variant="h2">
|
||||||
|
Authenticate
|
||||||
|
</Header>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AuthComponent
|
||||||
|
authMode={IAuthMode.FIXED_PIN}
|
||||||
|
door={door}
|
||||||
|
secret={secret}
|
||||||
|
onError={(res) => {
|
||||||
|
addAlert("error", "Authentication failed, double check the credentials");
|
||||||
|
}}
|
||||||
|
onUnlock={() => {
|
||||||
|
setStep(2);
|
||||||
|
const inprogAlert = addAlert("in-progress", (
|
||||||
|
<CountdownBar
|
||||||
|
timeSeconds={doorResponse.timeout}
|
||||||
|
onExpiry={() => {
|
||||||
|
setPolling(false);
|
||||||
|
addAlert("error", "Authentication expired: please try again. If this was a one time link, you need to request a new one");
|
||||||
|
setStep(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
setInProgressAlert(inprogAlert);
|
||||||
|
setPolling(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Dial code",
|
||||||
|
description: "Type in the buzzer code and the system should automatically allow you access to the building",
|
||||||
|
content: (
|
||||||
|
<Container
|
||||||
|
header={
|
||||||
|
<Header variant="h2">
|
||||||
|
Dial code
|
||||||
|
</Header>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SpaceBetween size='l' alignItems="center">
|
||||||
|
<TextContent>
|
||||||
|
<p>Enter the buzzer code at the front entrance</p>
|
||||||
|
</TextContent>
|
||||||
|
<OtpInput
|
||||||
|
inputStyle={{
|
||||||
|
margin: '1rem',
|
||||||
|
height: '3rem',
|
||||||
|
width: '3rem',
|
||||||
|
fontSize: '2rem',
|
||||||
|
}}
|
||||||
|
value={'2207'}
|
||||||
|
numInputs={4}
|
||||||
|
onChange={() => null}
|
||||||
|
renderSeparator={() => <span>-</span>}
|
||||||
|
renderInput={(props) => <input readOnly {...props} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</SpaceBetween>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Enter the building",
|
||||||
|
description: "The buzzer allowed you access, come on up!",
|
||||||
|
content: (
|
||||||
|
<Container
|
||||||
|
header={
|
||||||
|
<Header variant="h2">
|
||||||
|
Welcome
|
||||||
|
</Header>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SpaceBetween size='l' alignItems="center">
|
||||||
|
<TextContent>
|
||||||
|
That's it! Enter the building and hit floor 11 on the elevator. We are unit 1105, just to the left off the elevator
|
||||||
|
</TextContent>
|
||||||
|
<img src="/Insulin_Nation_Low_Kramer.gif"/>
|
||||||
|
</SpaceBetween>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -60,6 +60,6 @@ export const replaceDoorRandomKey = async (door: string) => {
|
|||||||
|
|
||||||
await client.put(doorRotatingKey(door), newKey);
|
await client.put(doorRotatingKey(door), newKey);
|
||||||
|
|
||||||
const message = `New key for door ${door}! Unlock link: ${Bun.env.BASE_DOMAIN}/api/door/${door}/auth?rotatingKey=${newKey}`;
|
const message = `New key for door ${door}! Unlock link: ${Bun.env.BASE_DOMAIN}/door/${door}?rotatingKey=${newKey}`;
|
||||||
await fetch(Bun.env.ROTATING_KEY_NTFY, { method: "POST", body: message });
|
await fetch(Bun.env.ROTATING_KEY_NTFY, { method: "POST", body: message });
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@ import express from "express";
|
|||||||
import { getRedisClient } from "../clients/db/RedisDbProvider";
|
import { getRedisClient } from "../clients/db/RedisDbProvider";
|
||||||
import { doorStatusKey } from "../types/RedisKeys";
|
import { doorStatusKey } from "../types/RedisKeys";
|
||||||
import { HandleAuthMode } from "../middlewares/DoorAuthModes";
|
import { HandleAuthMode } from "../middlewares/DoorAuthModes";
|
||||||
import { getDoorSettingNumber, getDoorSettingString } from "../util/EnvConfigUtil";
|
import { getAuthModes, getDoorSettingNumber, getDoorSettingString } from "../util/EnvConfigUtil";
|
||||||
import { IDoorConfig } from "../types/IDoorConfig";
|
import { IDoorConfig } from "../types/IDoorConfig";
|
||||||
import { TimeLockVerify } from "../middlewares/TimeLockMiddleware";
|
import { TimeLockVerify } from "../middlewares/TimeLockMiddleware";
|
||||||
import { IDoorStatus } from "../types/IDoorStatus";
|
import { IDoorStatus } from "../types/IDoorStatus";
|
||||||
@ -10,6 +10,21 @@ import { IDoorStatus } from "../types/IDoorStatus";
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const client = await getRedisClient();
|
const client = await getRedisClient();
|
||||||
|
|
||||||
|
router.get('/:id', async(req, res) => {
|
||||||
|
const doorId = req.params.id;
|
||||||
|
const authModes = getAuthModes(doorId);
|
||||||
|
const timeout = getDoorSettingNumber(doorId, IDoorConfig.OPEN_TIMEOUT)|| 60;
|
||||||
|
|
||||||
|
if (authModes.length === 0) {
|
||||||
|
res.status(404).json({ msg: `Door ${doorId} not found` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await client.get(doorStatusKey(doorId)) ? IDoorStatus.OPEN: IDoorStatus.CLOSED;
|
||||||
|
|
||||||
|
res.status(200).json({ id: doorId, authModes, timeout, status });
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:id/status', TimeLockVerify, async(req, res) => {
|
router.get('/:id/status', TimeLockVerify, async(req, res) => {
|
||||||
const isOpen = await client.get(doorStatusKey(req.params.id));
|
const isOpen = await client.get(doorStatusKey(req.params.id));
|
||||||
|
|
||||||
|
|||||||
7
packages/server/src/types/IDoorResponse.ts
Normal file
7
packages/server/src/types/IDoorResponse.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { IAuthMode } from "./IAuthMode";
|
||||||
|
|
||||||
|
export interface IDoorResponse {
|
||||||
|
id: string,
|
||||||
|
authModes: IAuthMode[];
|
||||||
|
timeout: number;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user