275 lines
8.7 KiB
TypeScript
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>
|
|
)
|
|
},
|
|
]}
|
|
/>
|
|
}
|
|
/>
|
|
);
|
|
};
|