275 lines
8.7 KiB
TypeScript

import { ReactNode, useEffect, useState } from "react";
import { useLoaderData, useNavigate, useSearchParams } from "react-router-dom";
import { AppLayout, BreadcrumbGroup, Container, Flashbar, FlashbarProps, Header, SpaceBetween, TextContent, Wizard } from "@cloudscape-design/components";
import { AuthComponent, IAuthMode } from "../components/AuthComponent";
import OtpInput from 'react-otp-input';
import { CountdownBar } from "../components/CountdownBar";
import { DoorResponse } from "../types/DoorResponse";
import { fetchUrlEncoded } from "../helpers/FetchHelper";
export async function loader({ params, request }: any) {
const door = new URL(request.url).searchParams.get('door');
if (!door) {
return {};
}
const res = await fetchUrlEncoded('/api/door/info', {
door,
}).then(res => res.json());
console.log(res);
if (res.msg) {
throw new Error("Not a valid door");
}
return res as DoorResponse;
}
export function DoorPage() {
const doorResponse = useLoaderData() as DoorResponse;
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 [ submitPin, setSubmitPin ] = useState(0);
const dismissAlert = (id: string) => {
setAlerts(alerts => alerts.filter(alert => alert.id !== id));
}
const dismissInProgressAlerts = () => {
setAlerts(alerts => alerts.filter(alert => alert.type !== 'in-progress'));
}
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(alerts => [
newAlert,
...alerts,
]);
return newAlert.id as string;
}
useEffect(() => {
if (!polling) {
return;
}
const timer = setInterval(async () => {
const response = await fetchUrlEncoded('/api/door/info', {
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.type !== 'in-progress')]
);
}
}, 2000);
return () => {
clearInterval(timer);
dismissInProgressAlerts();
}
}, [polling]);
return (
<AppLayout
contentType="wizard"
navigationHide
toolsHide
breadcrumbs={
<BreadcrumbGroup
items={[
{ text: 'Door', href: '#' },
{ text: door, href: `?edit&door=${door}` },
{ text: 'Unlock', href: '#' },
]}
/>
}
notifications={
<Flashbar
stackItems={true}
items={alerts}
/>
}
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 < step) {
dismissInProgressAlerts();
setSubmitPin(0);
setPolling(false);
setStep(detail.requestedStepIndex);
return;
}
if (detail.requestedStepIndex === 2) {
setSubmitPin(submitPin + 1);
return;
}
if (detail.requestedStepIndex >= 2) {
return;
}
setStep(detail.requestedStepIndex);
}}
onCancel={() => {
setStep(0);
dismissInProgressAlerts();
setSubmitPin(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}
runCheck={submitPin}
door={door}
secret={secret}
onUnlock={() => {
setStep(2);
dismissInProgressAlerts();
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);
}}
/>
));
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
containerStyle={{
width: "100%",
justifyContent: "space-around",
}}
inputStyle={{
margin: '0.5rem',
height: '3rem',
width: '2rem',
fontSize: '2rem',
}}
value={doorResponse.buzzerCode}
numInputs={doorResponse.buzzerCode.length}
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>
{doorResponse.greeting || "The door is unlocked!"}
</TextContent>
<img src="/Insulin_Nation_Low_Kramer.gif"/>
</SpaceBetween>
</Container>
)
},
]}
/>
}
/>
);
};