add UI flow for the buzzer steps
All checks were successful
Build and push image for doorman / docker (push) Successful in 56s
Build and push image for doorman / deploy-portainer (push) Successful in 26s

This commit is contained in:
Martin Dimitrov 2024-03-02 14:17:14 -08:00
parent d92305e504
commit eb7d37a01c
13 changed files with 389 additions and 23 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -12,6 +12,7 @@
"dependencies": {
"crypto": "^1.0.1",
"express-fingerprint": "^1.2.2",
"node-fetch": "^3.3.2"
"node-fetch": "^3.3.2",
"react-otp-input": "^3.1.1"
}
}

View File

@ -7,8 +7,10 @@
<meta name="theme-color" content="#000000" />
<meta
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
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -0,0 +1,4 @@
body {
margin: 0px;
padding: 0px;
}

View File

@ -1,25 +1,11 @@
import { Header } from '@cloudscape-design/components';
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AlertContent, AlertContext } from './contexts/AlertContext';
import { Outlet } from 'react-router-dom';
function App() {
const navigate = useNavigate();
const location = useLocation();
const [ content, setContent ] = useState<AlertContent>({});
return (
<div className="App">
<AlertContext.Provider value={
{
content,
setContent,
}
}>
<Header>Doorman 2.0</Header>
</AlertContext.Provider>
</div>
<>
<Outlet/>
</>
);
}

View 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>
</>
);
}

View 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"
/>
);
}

View File

@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import App from './App';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { AuthPage, loader as authpageloader } from './pages/AuthPage';
import { DoorPage, loader as doorpageloader } from './pages/DoorPage';
const root = ReactDOM.createRoot(
@ -18,7 +19,12 @@ const router = createBrowserRouter([
{
path: "action/:action",
loader: authpageloader,
element: <AuthPage />
element: <AuthPage />,
},
{
path: "door/:door",
loader: doorpageloader,
element: <DoorPage />,
}
]
}

View 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>
)
},
]}
/>
}
/>
);
}

View File

@ -60,6 +60,6 @@ export const replaceDoorRandomKey = async (door: string) => {
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 });
}

View File

@ -2,7 +2,7 @@ import express from "express";
import { getRedisClient } from "../clients/db/RedisDbProvider";
import { doorStatusKey } from "../types/RedisKeys";
import { HandleAuthMode } from "../middlewares/DoorAuthModes";
import { getDoorSettingNumber, getDoorSettingString } from "../util/EnvConfigUtil";
import { getAuthModes, getDoorSettingNumber, getDoorSettingString } from "../util/EnvConfigUtil";
import { IDoorConfig } from "../types/IDoorConfig";
import { TimeLockVerify } from "../middlewares/TimeLockMiddleware";
import { IDoorStatus } from "../types/IDoorStatus";
@ -10,6 +10,21 @@ import { IDoorStatus } from "../types/IDoorStatus";
const router = express.Router();
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) => {
const isOpen = await client.get(doorStatusKey(req.params.id));

View File

@ -0,0 +1,7 @@
import { IAuthMode } from "./IAuthMode";
export interface IDoorResponse {
id: string,
authModes: IAuthMode[];
timeout: number;
}