Compare commits

...

5 Commits

Author SHA1 Message Date
838ca2cb23 remove cleanup for now
All checks were successful
Build and push image for doorman-homeassistant / docker (push) Successful in 43s
Build and push Doorman UI / API / docker (push) Successful in 2m34s
Build and push image for doorman-homeassistant / deploy-gitainer (push) Successful in 6s
2025-10-08 14:22:05 -07:00
975c47b174 add buzzer code identification to onboarding flow 2025-10-08 14:02:53 -07:00
1bca8cf9dc implement best efforts cleanup 2025-10-08 14:01:53 -07:00
29786f5780 add check OTP api 2025-10-08 13:52:19 -07:00
946ec544fe add audio instructions 2025-10-04 15:11:01 -07:00
8 changed files with 375 additions and 25 deletions

View File

@ -0,0 +1,53 @@
/**
* Try to get buzzer number for a given OTP
*/
import { ServerlessEventObject, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types";
import { TwilioContext } from "../../../types/TwilioContext";
import { createDynaBridgeClient } from "../../../utils/ddb";
import { withMetrics } from "../../../common/DoormanHandler";
import { z } from "zod";
import { UserAgentHeader } from "../../../utils/blockUserAgent";
import { setResponseJson } from "../../../utils/responseUtils";
import { getLogCallID } from "../../../schema/LogCall";
import { isTTLInFuture } from "../../../common/TTLHelper";
export const CheckOtpRequestSchema = z.object({
otp: z.string(),
});
export type CheckOtpRequest = z.infer<typeof CheckOtpRequestSchema>;
export interface CheckOtpRequestTwilio extends ServerlessEventObject<CheckOtpRequest, UserAgentHeader> { };
export const CheckOtpResponseSchema = z.object({
buzzer: z.string(),
});
export type CheckOtpResponse = z.infer<typeof CheckOtpResponseSchema>;
export const handler: ServerlessFunctionSignature<TwilioContext, CheckOtpRequestTwilio> = withMetrics("checkotp", async (context, event, callback, metricsRegistry) => {
const response = new Twilio.Response();
const req = CheckOtpRequestSchema.parse(event);
let otp = req.otp;
const db = createDynaBridgeClient(context);
const log = await db.entities.logCall.findById(getLogCallID(otp));
if (!log || !isTTLInFuture(log)) {
setResponseJson(response, 404, {
msg: "OTP expired or not found",
})
} else {
setResponseJson(response, 200, {
buzzer: log.caller,
});
}
// destroy the internal client after
// @ts-ignore
db.ddbClient.destroy();
return callback(null, response);
});

View File

@ -13,6 +13,8 @@ import { UserAgentHeader } from "../../../utils/blockUserAgent";
import { setResponseJson } from "../../../utils/responseUtils";
import { LOG_CALL_SK, LogCallSchema } from "../../../schema/LogCall";
import crypto from "crypto";
export const LogCallRequestSchema = z.object({
caller: z.string(),
});
@ -26,8 +28,24 @@ export const LogCallResponseSchema = z.object({
export type LogCallResponse = z.infer<typeof LogCallResponseSchema>;
function getCode() {
return `${Math.floor(Math.random() * 10000)}`.padStart(4, '0');
// hash is 4 digit number based on todays date + phone number caller
// cost saving so we don't generate a new OTP for every caller even if its the same caller
function getCode(caller: string) {
const hash = crypto.createHash("sha256");
const today = new Date();
hash.update(today.toLocaleDateString('en-US'));
hash.update(caller);
const hashHex = hash.digest('hex');
// 2. Convert the hexadecimal string to a BigInt
// This is necessary for large hash values that exceed JavaScript's Number limit.
const hashBigInt = BigInt(`0x${hashHex}`);
// 3. Convert the BigInt to a decimal string
const hashDecimal = hashBigInt.toString();
return hashDecimal.substring(hashDecimal.length - 4,);
};
export const handler: ServerlessFunctionSignature<TwilioContext, LogCallRequestTwilio> = withMetrics("logCall", async (context, event, callback, metricsRegistry) => {
@ -46,8 +64,16 @@ export const handler: ServerlessFunctionSignature<TwilioContext, LogCallRequestT
msg: "Onboarding is not open",
});
} else {
// TODO: best efforts cleanup
// console.log("Attempting best efforts cleanup of logged calls")
// const items = await db.entities.logCall.findAll();
// const toRemove = items.filter(item => item.SK === LOG_CALL_SK && !isTTLInFuture(item));
// console.log(`There are ${toRemove.length} old call logs to remove`);
// await db.entities.logCall.deleteBatch(toRemove);
// console.log("done cleaning up logged calls");
// log this caller
const otp = getCode();
const otp = getCode(caller);
const logCall = LogCallSchema.parse({
PK: otp,
SK: LOG_CALL_SK,

View File

@ -5,6 +5,7 @@ import { StatusResponse } from "../src/functions/api/door/status";
import { sleep } from "bun";
import { ONBOARDING_DOOR_NAME, ONBOARDING_DOOR_PIN } from "../src/schema/DoorConfig";
import { LogCallResponse } from "../src/functions/api/door/logCall";
import { CheckOtpResponse } from "../src/functions/api/door/checkOtp";
// these tests should only run locally
if (process.env.STAGE === 'staging') {
@ -109,7 +110,15 @@ describe("call log path works", () => {
expect(logCallRes.status).toBe(200);
const otp = (await logCallRes.json() as LogCallResponse).otp
expect(otp.length).toBe(4)
expect(otp.length).toBe(4);
const checkOtpRes = await fetch(baseUrl + `/api/door/checkOtp?otp=${otp}`);
expect(checkOtpRes.status).toBe(200);
// check OTP
const caller = (await checkOtpRes.json() as CheckOtpResponse).buzzer;
expect(caller).toBe(buzzerNumber);
});
test("call log after door closed, should not return OTP", async () => {

View File

@ -28,3 +28,15 @@ This will simulate a buzzer calling from 6133163433 (the test buzzer)
If you let it poll for 10s then it should respond with Twilio xml saying to dial fallback numbers.
If doorman-api is not running, it would always return with a `<Reject/>`
## adding new audio assets
Generate using this website: https://ttsmp3.com/
select US English / Salli for consistency with the voice.
After generated, go to this website: https://www.mp3louder.com/
boost the audio 3db and save it
put it in the doorman-client/assets folder with suffix `.protected.mp3`

View File

@ -5,9 +5,12 @@ export interface ICountdownBarProps {
timeSeconds: number;
onCancel?: () => void;
onExpiry?: () => void;
label?: string;
description?: string;
additionalInfo?: string;
}
export const CountdownBar = ({ timeSeconds, onCancel, onExpiry }: ICountdownBarProps) => {
export const CountdownBar = ({ timeSeconds, onCancel, onExpiry, label, description, additionalInfo }: ICountdownBarProps) => {
const [ countdown, setCountdown ] = useState<number>(timeSeconds);
useEffect(() => {
@ -26,16 +29,13 @@ export const CountdownBar = ({ timeSeconds, onCancel, onExpiry }: ICountdownBarP
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`}
label={label || "Authentication Timeout"}
description={description || "Dial the buzzer before the time expires"}
additionalInfo={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

@ -7,6 +7,7 @@ import { ErrorPage } from './pages/ErrorPage';
import { QueryRouter } from './routers/QueryRouter';
import { EditPage } from './pages/EditPage';
import { RedirectPage } from './pages/RedirectPage';
import { OnboardingPage } from './pages/OnboardingPage';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
@ -26,7 +27,8 @@ const router = createBrowserRouter([
mapping={{
edit: <EditPage />,
door: <DoorPage />,
onboard: <EditPage isOnboarding={true} />,
onboard: <OnboardingPage />,
onboardForm: <EditPage isOnboarding={true} />,
state: <RedirectPage />
}}
/>

View File

@ -20,9 +20,14 @@ export interface EditPageProps {
export const EditPage = ({ isOnboarding }: EditPageProps) => {
const doorResponse = useLoaderData() as DoorResponse;
const door = doorResponse.id;
const [params, setParams] = useSearchParams();
const buzzer = params.get("buzzer") || undefined;
const { setValue, setError, getValues, watch, formState, clearErrors, control, register } = useForm<DoorEditForm>({
defaultValues: {
...doorResponse,
buzzer,
isConfirmed: !isOnboarding,
},
mode: "all",
@ -62,7 +67,7 @@ export const EditPage = ({ isOnboarding }: EditPageProps) => {
const fallbackNumbersError = formState.errors.fallbackNumbers?.message;
const discordUsersError = formState.errors.discordUsers?.message;
const backUrl = isOnboarding? '/': `?door=${door}`;
const backUrl = isOnboarding? '/?onboard': `?door=${door}`;
const apiRoute = isOnboarding ? ONBOARD_ROUTE: EDIT_ROUTE;
return (
@ -74,7 +79,7 @@ export const EditPage = ({ isOnboarding }: EditPageProps) => {
<BreadcrumbGroup
items={[
{ text: 'Door', href: backUrl },
isOnboarding ? { text: "Onboard", href: "?onboard" }: { text: door, href: `?edit&door=${door}` },
isOnboarding ? { text: "Onboard Form", href: `?onboardForm&buzzer=${buzzer}` }: { text: door, href: `?edit&door=${door}` },
]}
/>
}
@ -146,11 +151,19 @@ export const EditPage = ({ isOnboarding }: EditPageProps) => {
>
<Container>
<SpaceBetween direction="vertical" size="l">
{isOnboarding &&
<FormField label="Register Doorman in Building" constraintText="Confirm that you added our number to your buzzer system: 604-757-1824" errorText={formState.errors.isConfirmed?.message}>
<label>
I have added Doorman's number to my buzzer
<input type="checkbox" {...register("isConfirmed")} />
</label>
</FormField>
}
<FormField label="Door Name" constraintText="Unique name for this Door, will be used in your Doorman URL">
<CInput readOnly={!isOnboarding} disabled={!isOnboarding} name="id" control={control} />
</FormField>
<FormField label="Buzzer Number" constraintText="Phone number that calls you when your buzzer is called">
<CInput readOnly={!isOnboarding} disabled={!isOnboarding} name="buzzer" control={control} />
<CInput readOnly={!!buzzer} disabled={!!buzzer} name="buzzer" control={control} />
</FormField>
<FormField label="PIN" constraintText={"Code to unlock this Door in Doorman"}>
<CInput type="password" name="pin" control={control} />
@ -233,14 +246,6 @@ export const EditPage = ({ isOnboarding }: EditPageProps) => {
<FormField label="Welcome Message" constraintText="Message to display after a successful unlock">
<CTextArea name="greeting" control={control} />
</FormField>
{isOnboarding &&
<FormField label="Register Doorman in Building" constraintText="Confirm that you added our number to your buzzer system: 604-757-1824" errorText={formState.errors.isConfirmed?.message}>
<label>
I have added Doorman's number to my buzzer
<input type="checkbox" {...register("isConfirmed")} />
</label>
</FormField>
}
</SpaceBetween>
</Container>
</Form>

View File

@ -0,0 +1,243 @@
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 OtpInput from 'react-otp-input';
import { CountdownBar } from "../components/CountdownBar";
import { fetchUrlEncoded } from "../helpers/FetchHelper";
import { readSync } from "fs";
export function OnboardingPage() {
const navigate = useNavigate();
const [ step, setStep ] = useState(0);
const [ alerts, setAlerts ] = useState<FlashbarProps.MessageDefinition[]>([]);
const [ otp, setOtp ] = useState("");
const [ buzzerNumber, setBuzzerNumber ] = useState("");
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 (otp.length === 4) {
// see if this is valid otp
fetchUrlEncoded('/api/door/checkOtp', {
otp,
}).then(res => res.json())
.then(res => {
if (res.msg) {
addAlert("error", res.msg);
return;
}
const apiNumber = res.buzzer;
setBuzzerNumber(apiNumber);
addAlert("success", `Successfully verified Buzzer as ${apiNumber}`);
setTimeout(() => {
setStep(3);
}, 1_000);
});
}
}, [otp]);
return (
<AppLayout
contentType="wizard"
navigationHide
toolsHide
breadcrumbs={
<BreadcrumbGroup
items={[
{ text: 'Door', href: '#' },
{ text: 'Onboarding', 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}
onSubmit={() => {
navigate(`/?onboardForm&buzzer=${buzzerNumber}`)
}}
onNavigate={({ detail }) => {
if (detail.requestedStepIndex < step) {
dismissInProgressAlerts();
setStep(detail.requestedStepIndex);
return;
}
if (detail.requestedStepIndex === 2) {
fetchUrlEncoded('/api/door/auth', {
door: "onboardingflag",
key: 1234
})
.then(res => res.json())
.then(res => {
if (res.status === "CLOSED" || !res.msg) {
addAlert('error', 'Something went wrong, please try again');
} else {
addAlert("in-progress", (
<CountdownBar
label="Onboarding Timeout"
description="Complete Onboarding before timeout"
additionalInfo="Onboarding times out after 15 minutes, please complete the onboarding before then"
timeSeconds={15 * 60}
onExpiry={() => {
addAlert("error", "Onboarding timed out: please try again");
setStep(1);
}}
/>
));
setStep(detail.requestedStepIndex);
}
});
return;
}
if (detail.requestedStepIndex === 3) {
if (buzzerNumber === "") {
addAlert('error', "Please enter 4 digit verification code to proceed");
return;
}
}
setStep(detail.requestedStepIndex);
}}
onCancel={() => {
setStep(0);
dismissInProgressAlerts();
}}
steps={[
{
title: "Set your Buzzer Number",
description:"Before onboarding Doorman, you need to configure your Buzzer number",
content: (
<Container
header={
<Header variant="h2">
Set your Buzzer Number
</Header>
}
>
This would usually involve contacting your building to change your Buzzer to call Doorman's phone number:
<Header variant="h3">604-757-1824</Header>
Once you have done this, continue along the onboarding process!
</Container>
)
},
{
title: "Get to the Buzzer",
description:"To continue onboarding this Door, you should go physically to the Buzzer",
content: (
<Container
header={
<Header variant="h2">
Get to the buzzer
</Header>
}
>
The next step will require you to dial your Buzzer so Doorman can identify who is calling. Once you arrive at your Buzzer, advance to the next step.
</Container>
)
},
{
title: "Identify",
description: "Enter the 4 digit code that you hear back from Doorman",
content: (
<Container
header={
<Header variant="h2">
Identify
</Header>
}
>
For this step, you need to dial your Buzzer. It should call Doorman, and you will hear a message with a generated 4 digit code.
<br/>
Enter the 4 digit code below to proceed
<OtpInput
containerStyle={{
width: "100%",
justifyContent: "center",
}}
inputStyle={{
margin: '0.5rem',
height: '3rem',
width: '2rem',
fontSize: '2rem',
}}
value={otp}
numInputs={4}
onChange={(e) => setOtp(e)}
renderInput={(props) => <input {...props} />}
/>
</Container>
)
},
{
title: "Complete Onboarding Form",
description: "Redirect to complete the rest of onboarding form",
content: (
<Container
header={
<Header variant="h2">
Complete Onboarding Form
</Header>
}
>
Your buzzer number was identified as:
<Header>{buzzerNumber}</Header>
Click Finish below to be redirected to complete the rest of the onboarding for Doorman
</Container>
)
},
]}
/>
}
/>
);
};