add new door onboarding
Some checks failed
Build and push image for doorman-homeassistant / docker (push) Successful in 31s
Build and push Doorman UI / API / docker (push) Failing after 32s
Build and push image for doorman-homeassistant / deploy-gitainer (push) Successful in 6s

This commit is contained in:
Martin Dimitrov 2025-06-06 15:28:13 -07:00
parent e99d820abf
commit c56b90714f
8 changed files with 430 additions and 65 deletions

View File

@ -0,0 +1,220 @@
import { ServerlessEventObject, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types";
import { UserAgentHeader } from "../../../utils/blockUserAgent";
import { getMetricFromRegistry, withMetrics } from "../../../common/DoormanHandler";
import { TwilioContext } from "../../../types/TwilioContext";
import { OnboardMetrics, registerMetrics } from "../../../metrics/OnboardMetrics";
import { createOnboardDoorConfig, DOOR_CONFIG_SK, DoorConfigSchema, getDoorConfigID, getOnboardDoorId, OnboardDoorReq, OnboardDoorReqSchema } from "../../../schema/DoorConfig";
import { createDynaBridgeClient } from "../../../utils/ddb";
import DiscordOauth2 from "discord-oauth2";
import { DoorAliasSchema, getDoorAliasID } from "../../../schema/DoorAlias";
import { Counter } from "prom-client";
export interface OnboardRequest extends ServerlessEventObject<{}, UserAgentHeader> {
newConfig?: string;
// for oauth redirect
code?: string;
state?: string;
}
export interface DiscordOnboardingState {
name: string;
id: string;
apiRedirect: string;
}
const ONBOARDING_SCOPE = ['identify', 'email', 'guilds.join'];
export const handler: ServerlessFunctionSignature<TwilioContext, OnboardRequest> = withMetrics('onboard', async (context, event, callback, metricsRegistry) => {
const response = new Twilio.Response();
response.appendHeader('Content-Type', 'application/json');
registerMetrics(metricsRegistry);
const db = createDynaBridgeClient(context);
// return oauth link
const oauth = new DiscordOauth2({
clientId: context.DISCORD_CLIENT_ID,
clientSecret: context.DISCORD_CLIENT_SECRET,
redirectUri: context.DOORMAN_URL,
});
// create door config route
if (event.code && event.state) {
const code = event.code;
let discordState: DiscordOnboardingState;
try {
discordState = JSON.parse(event.state);
} catch (e) {
response.setStatusCode(400);
response.setBody({ err: "invalid state" });
return callback(null, response);
}
const config = await db.entities.onboardDoorConfig.findById(getOnboardDoorId(discordState.name));
if (!config || config.nonce !== discordState.id) {
response.setStatusCode(404)
.setBody({ err: "approval not found" });
return callback(null, response);
}
console.log("getting discord token");
let token: DiscordOauth2.TokenRequestResult;
try {
token = await oauth.tokenRequest({
code,
scope: ONBOARDING_SCOPE,
grantType: "authorization_code",
});
} catch (err) {
console.log("something went wrong with discord authorization");
response.setStatusCode(401);
response.setBody({ err });
return callback(null, response);
}
const user = await oauth.getUser(token.access_token);
const profileId = user.id;
console.log(profileId);
if (!profileId) {
response.setStatusCode(404);
response.setBody({ err: "profile not found" });
return callback(null, response);
}
console.log('trying to join Doorman server');
// join doorman discord server
await fetch(
`https://discord.com/api/v8/guilds/${context.DISCORD_GUILD_ID}/members/${profileId}`,
{
method: 'PUT',
headers: {
'Authorization': 'Bot ' + context.DISCORD_BOT_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
'access_token': token.access_token,
})
}).catch(err => console.error(err));
type ActionType = Parameters<typeof db.transaction>[0][0];
// door alias
const doorAlias = DoorAliasSchema.parse({
name: config.name,
PK: config.buzzer,
});
const createDoorAlias: ActionType = {
action: "Put",
type: "doorAlias",
entity: doorAlias,
};
// door config
const doorConfig = DoorConfigSchema.parse({
...config,
discordUsers: [profileId],
PK: `door-${config.name}`,
SK: DOOR_CONFIG_SK,
});
const createDoorConfig: ActionType = {
action: "Put",
type: "doorConfig",
entity: doorConfig,
}
// delete onboarding config
const deleteOnboardingConfig: ActionType = {
action: "Delete",
type: "onboardDoorConfig",
entity: config,
};
// and all must succeed or none succeed
try {
await db.transaction([createDoorAlias, createDoorConfig, deleteOnboardingConfig]);
response.setStatusCode(200);
response.setBody({ redirect: context.DOORMAN_URL + `?door=${config.name}` });
} catch (e) {
getMetricFromRegistry<Counter>(metricsRegistry, OnboardMetrics.TRANSACTION_CONFLICT).inc(1);
console.error(e);
response.setStatusCode(409);
response.setBody({ err: "something went wrong during onboarding" });
}
return callback(null, response);
}
let newConfig = event.newConfig;
if (!newConfig) {
response.setStatusCode(400);
return callback(null, response);
}
let newConfigParsed: OnboardDoorReq;
console.log("parsing config");
try {
newConfigParsed = OnboardDoorReqSchema.parse(JSON.parse(newConfig));
} catch (err) {
response.setStatusCode(400);
response.setBody({ err });
return callback(null, response);
}
const newConfigObj = createOnboardDoorConfig(newConfigParsed);
// check if this name or buzzer is already registered?
const existingAlias = await db.entities.doorAlias.findById(getDoorAliasID(newConfigObj.buzzer));
if (existingAlias) {
getMetricFromRegistry<Counter>(metricsRegistry, OnboardMetrics.BUZZER_CONFLICT).inc({ buzzer: newConfigObj.buzzer }, 1);
response.setStatusCode(409);
response.setBody({ err: `A buzzer is already registered with the number ${newConfigObj.buzzer}` });
return callback(null, response);
}
const existingConfig = await db.entities.doorConfig.findById(getDoorConfigID(newConfigObj.name));
if (existingConfig) {
getMetricFromRegistry<Counter>(metricsRegistry, OnboardMetrics.NAME_CONFLICT).inc({ name: newConfigObj.name }, 1);
response.setStatusCode(409);
response.setBody({ err: `A buzzer is already registered with the name ${newConfigObj.name}` });
return callback(null, response);
}
// TODO: check here for any race condition on onboarding. Techncially 2 ppl could conflict on name or number
// and would fail after the discord auth step resulting in TRANSACTION_CONFLICT
// save to DB, this is a middle step until discord auth comes in
await db.entities.onboardDoorConfig.save(newConfigObj);
const discordState: DiscordOnboardingState = {
name: newConfigObj.name,
id: newConfigObj.nonce,
apiRedirect: '/api/door/onboard',
}
const redirect = oauth.generateAuthUrl({
scope: ONBOARDING_SCOPE,
responseType: 'code',
state: JSON.stringify(discordState),
});
response.setStatusCode(200);
response.setBody({ redirect });
return callback(null, response);
});

View File

@ -0,0 +1,27 @@
import { Counter, Registry, Summary } from "prom-client";
export enum OnboardMetrics {
BUZZER_CONFLICT = "BuzzerConflict",
NAME_CONFLICT = "NameConflict",
TRANSACTION_CONFLICT = "TransactionConflict",
}
export const registerMetrics = (metricsRegistry: Registry) => {
metricsRegistry.registerMetric(new Counter({
name: OnboardMetrics.BUZZER_CONFLICT,
help: "Onboarding was attepted with a buzzer number that is already registered",
labelNames: ['buzzer'],
}));
metricsRegistry.registerMetric(new Counter({
name: OnboardMetrics.NAME_CONFLICT,
help: "Onboarding was attepted with a name that is already registered",
labelNames: ['name'],
}));
metricsRegistry.registerMetric(new Counter({
name: OnboardMetrics.TRANSACTION_CONFLICT,
help: "There was a conflict in the transactions after discord authorization. This likely means two onboardings conflicted",
}));
}

View File

@ -68,3 +68,39 @@ export const createEditDoorConfig = (door: string, newConfig: EditDoorConfigReq)
approvalId: randomUUID().toString(), approvalId: randomUUID().toString(),
}; };
} }
// onboard a new door
export const ONBOARD_DOOR_CONFIG_SK = "onboard-config";
export const OnboardDoorConfigSchema = DoorConfigSchema.omit({ discordUsers: true }).extend({
PK: z.string(), // this is the door name
SK: z.literal(ONBOARD_DOOR_CONFIG_SK).default(ONBOARD_DOOR_CONFIG_SK),
name: z.string(),
nonce: z.string(), // random UUID, used to check the oauth is actually from this user who initiated it
});
export type OnboardDoorConfig = z.infer<typeof OnboardDoorConfigSchema>;
export const OnboardDoorConfigEntity: DynaBridgeEntity<OnboardDoorConfig> = {
tableName: "doorman",
id: ["PK", "SK"],
};
export const getOnboardDoorId = (code: string): string[] => {
return [
code,
ONBOARD_DOOR_CONFIG_SK,
];
}
export const OnboardDoorReqSchema = OnboardDoorConfigSchema.omit({ PK: true, SK: true, nonce: true });
export type OnboardDoorReq = z.infer<typeof OnboardDoorReqSchema>;
export const createOnboardDoorConfig = (req: OnboardDoorReq): OnboardDoorConfig => {
return {
...req,
PK: req.name,
SK: ONBOARD_DOOR_CONFIG_SK,
nonce: randomUUID(),
};
}

View File

@ -1,6 +1,6 @@
import { TwilioContext } from "../types/TwilioContext"; import { TwilioContext } from "../types/TwilioContext";
import { DynaBridge } from "dynabridge"; import { DynaBridge } from "dynabridge";
import { DoorConfigEntity, EditDoorConfigEntity } from "../schema/DoorConfig"; import { DoorConfigEntity, EditDoorConfigEntity, OnboardDoorConfigEntity } from "../schema/DoorConfig";
import { DoorAliasEntity } from "../schema/DoorAlias"; import { DoorAliasEntity } from "../schema/DoorAlias";
import { LockStatusEntity } from "../schema/LockStatus"; import { LockStatusEntity } from "../schema/LockStatus";
@ -11,6 +11,7 @@ export const createDynaBridgeClient = (context: TwilioContext) => {
editDoorConfig: EditDoorConfigEntity, editDoorConfig: EditDoorConfigEntity,
doorAlias: DoorAliasEntity, doorAlias: DoorAliasEntity,
lockStatus: LockStatusEntity, lockStatus: LockStatusEntity,
onboardDoorConfig: OnboardDoorConfigEntity,
}, { }, {
serialize: (entity) => entity, serialize: (entity) => entity,
deserialize: (entity) => { deserialize: (entity) => {

View File

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

View File

@ -1,4 +1,4 @@
import { AppLayout, BreadcrumbGroup, Button, Container, Flashbar, FlashbarProps, Form, FormField, Header, Input, Link, SpaceBetween } from "@cloudscape-design/components"; import { Alert, AppLayout, BreadcrumbGroup, Button, Container, Flashbar, FlashbarProps, Form, FormField, Header, Input, Link, SpaceBetween } from "@cloudscape-design/components";
import { useLoaderData, useSearchParams } from "react-router-dom"; import { useLoaderData, useSearchParams } from "react-router-dom";
import { DoorResponse } from "../types/DoorResponse"; import { DoorResponse } from "../types/DoorResponse";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -7,13 +7,23 @@ import { ReactNode, useState } from "react";
import CInput from "react-hook-form-cloudscape/components/input"; import CInput from "react-hook-form-cloudscape/components/input";
import CTextArea from "react-hook-form-cloudscape/components/textarea"; import CTextArea from "react-hook-form-cloudscape/components/textarea";
export type DoorEditForm = DoorResponse & { pin: string, fallbackNumber: string, discordUser: string }; export type DoorEditForm = DoorResponse & { pin: string, fallbackNumber: string, discordUser: string, isConfirmed: boolean };
export const EditPage = () => { const EDIT_ROUTE = '/api/door/edit';
const ONBOARD_ROUTE = '/api/door/onboard';
export interface EditPageProps {
isOnboarding?: boolean;
}
export const EditPage = ({ isOnboarding }: EditPageProps) => {
const doorResponse = useLoaderData() as DoorResponse; const doorResponse = useLoaderData() as DoorResponse;
const door = doorResponse.id; const door = doorResponse.id;
const { register, setValue, setError, getValues, watch, formState, clearErrors, control } = useForm<DoorEditForm>({ const { setValue, setError, getValues, watch, formState, clearErrors, control, register } = useForm<DoorEditForm>({
defaultValues: doorResponse, defaultValues: {
...doorResponse,
isConfirmed: !isOnboarding,
},
mode: "all", mode: "all",
}); });
@ -45,13 +55,15 @@ export const EditPage = () => {
return newAlert.id as string; return newAlert.id as string;
} }
const fallbackNumbers = watch("fallbackNumbers") || [];
const fallbackNumbers = watch("fallbackNumbers"); const discordUsers = watch("discordUsers") || [];
const discordUsers = watch("discordUsers");
const fallbackNumbersError = formState.errors.fallbackNumbers?.message; const fallbackNumbersError = formState.errors.fallbackNumbers?.message;
const discordUsersError = formState.errors.discordUsers?.message; const discordUsersError = formState.errors.discordUsers?.message;
const backUrl = isOnboarding? '/': `?door=${door}`;
const apiRoute = isOnboarding ? ONBOARD_ROUTE: EDIT_ROUTE;
return ( return (
<AppLayout <AppLayout
contentType="form" contentType="form"
@ -60,8 +72,8 @@ export const EditPage = () => {
breadcrumbs={ breadcrumbs={
<BreadcrumbGroup <BreadcrumbGroup
items={[ items={[
{ text: 'Door', href: '#' }, { text: 'Door', href: backUrl },
{ text: door, href: `?edit&door=${door}` }, isOnboarding ? { text: "Onboard", href: "?onboard" }: { text: door, href: `?edit&door=${door}` },
]} ]}
/> />
} }
@ -75,21 +87,44 @@ export const EditPage = () => {
<Form <Form
actions={ actions={
<SpaceBetween direction="horizontal" size="xs"> <SpaceBetween direction="horizontal" size="xs">
<Button formAction="none" variant="link" href={`?door=${door}`}> <Button formAction="none" variant="link" href={backUrl}>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
if (isOnboarding && !getValues("isConfirmed")) {
setError("isConfirmed", {
type: "manual",
message: "Must confirm that Doorman number is configured",
});
return;
}
const form = { const form = {
...getValues(), ...getValues(),
id: undefined,
name: getValues("id"),
timeout: parseInt("" + getValues("timeout")) timeout: parseInt("" + getValues("timeout"))
}; };
fetch(`/api/door/edit?door=${door}&newConfig=${encodeURIComponent(JSON.stringify(form))}`) fetch(apiRoute + `?door=${door}&newConfig=${encodeURIComponent(JSON.stringify(form))}`)
.then(res => res.json()) .then(res => res.json())
.then(res => { .then(res => {
addAlert("success", `Created approval, check Discord notifcation from Doorman to confirm and approve the changes`); if (res.err) {
addAlert("error", res.err);
return;
}
if (!isOnboarding) {
addAlert("success", `Created approval, check Discord notifcation from Doorman to confirm and approve the changes`);
} else if (res.redirect) {
// redirect for discord login
addAlert("in-progress", `Created Door, you will now be redirected to Discord login to complete the onboarding`);
// redirect in 2 seconds
setTimeout(() => {
window.location = res.redirect;
}, 2_000);
}
}) })
}} }}
> >
@ -97,35 +132,36 @@ export const EditPage = () => {
</Button> </Button>
</SpaceBetween> </SpaceBetween>
} }
header={<Header variant="h1">Edit Door</Header>} header={
<Header variant="h1">
{isOnboarding? "Onboard Door": "Edit Door"}
</Header>
}
> >
<Container <Container>
header={
<Header variant="h2">
Door - {door}
</Header>
}
>
<SpaceBetween direction="vertical" size="l"> <SpaceBetween direction="vertical" size="l">
<FormField label="Buzzer Number (read-only)" constraintText="The phone number of your buzzer system"> <FormField label="Door Name" constraintText="Unique name for this Door, will be used in your Doorman URL">
<CInput readOnly disabled name="buzzer" control={control} /> <CInput readOnly={!isOnboarding} disabled={!isOnboarding} name="id" control={control} />
</FormField> </FormField>
<FormField label="PIN" constraintText={"The code to unlock the buzzer"}> <FormField label="Buzzer Number" constraintText="Phone number that calls you when your buzzer is called">
<CInput readOnly={!isOnboarding} disabled={!isOnboarding} name="buzzer" control={control} />
</FormField>
<FormField label="PIN" constraintText={"Code to unlock this Door in Doorman"}>
<CInput type="password" name="pin" control={control} /> <CInput type="password" name="pin" control={control} />
</FormField> </FormField>
<FormField label="Buzzer Code" constraintText={"The number that you dial on your buzzer"}> <FormField label="Buzzer Code" constraintText={"Extension that is used to reach your unit on your buzzer"}>
<CInput name="buzzerCode" control={control} /> <CInput name="buzzerCode" control={control} />
</FormField> </FormField>
<FormField label="Timeout" constraintText="Time in seconds for the door to remain unlocked"> <FormField label="Timeout" constraintText="Time in seconds for the door to remain unlocked from Doorman">
<CInput type="number" name="timeout" control={control} /> <CInput type="number" name="timeout" control={control} />
</FormField> </FormField>
<FormField label="Unlock key" constraintText="Key to press to buzz up on Intercom"> <FormField label="Unlock Key" constraintText="Phone digit pressed to grant access">
<CInput name="pressKey" control={control} /> <CInput name="pressKey" control={control} />
</FormField> </FormField>
<FormField <FormField
errorText={fallbackNumbersError} errorText={fallbackNumbersError}
label="Fallback numbers" label="Fallback numbers"
constraintText="Phone numbers to dial through in case door is not unlocked" constraintText="Phone numbers to dial through in case Door is not unlocked by Doorman"
> >
<InputTokenGroup <InputTokenGroup
name={"fallbackNumber"} name={"fallbackNumber"}
@ -152,43 +188,53 @@ export const EditPage = () => {
}} }}
/> />
</FormField> </FormField>
<FormField {!isOnboarding &&
label="Discord Users" <FormField
errorText={discordUsersError} label="Discord Users"
constraintText={ errorText={discordUsersError}
<> constraintText={
<Link fontSize={"body-s"} href="https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID#h_01HRSTXPS5H5D7JBY2QKKPVKNA">Discord users</Link> to receive notifications <>
</> <Link fontSize={"body-s"} href="https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID#h_01HRSTXPS5H5D7JBY2QKKPVKNA">Discord users</Link> to receive notifications
} </>
> }
<InputTokenGroup >
name={"discordUser"} <InputTokenGroup
control={control} name={"discordUser"}
tokenGroupProps={{ items: discordUsers.map(n => ({ label: n }))}} control={control}
onAdd={() => { tokenGroupProps={{ items: discordUsers.map(n => ({ label: n }))}}
const newValue = getValues().discordUser; onAdd={() => {
if (newValue.length === 0) { const newValue = getValues().discordUser;
return; if (newValue.length === 0) {
} return;
clearErrors("discordUsers"); }
setValue("discordUsers", [...discordUsers, newValue]); clearErrors("discordUsers");
setValue("discordUser", ""); setValue("discordUsers", [...discordUsers, newValue]);
}} setValue("discordUser", "");
onDismiss={(i) => { }}
clearErrors("discordUsers"); onDismiss={(i) => {
clearErrors("discordUsers");
if (discordUsers.length === 1) { if (discordUsers.length === 1) {
setError("discordUsers", { message: "Can't remove last entry" }) setError("discordUsers", { message: "Can't remove last entry" })
return; return;
} }
discordUsers.splice(i, 1); discordUsers.splice(i, 1);
setValue("discordUsers", [...discordUsers]); setValue("discordUsers", [...discordUsers]);
}} }}
/> />
</FormField> </FormField>
}
<FormField label="Welcome Message" constraintText="Message to display after a successful unlock"> <FormField label="Welcome Message" constraintText="Message to display after a successful unlock">
<CTextArea name="greeting" control={control} /> <CTextArea name="greeting" control={control} />
</FormField> </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> </SpaceBetween>
</Container> </Container>
</Form> </Form>

View File

@ -1,4 +1,4 @@
import { AppLayout, Container, Header, TextContent } from "@cloudscape-design/components" import { AppLayout, Container, Header, Link, TextContent } from "@cloudscape-design/components"
export const ErrorPage = () => { export const ErrorPage = () => {
return ( return (
@ -11,7 +11,7 @@ export const ErrorPage = () => {
<Header>Doorman</Header> <Header>Doorman</Header>
<img src="doorman.png" /> <img src="doorman.png" />
<TextContent> <TextContent>
This isn't a valid door, please double check your URL This isn't a valid door, please double check your URL, or <Link href="/?onboard">Onboard</Link> to Doorman
</TextContent> </TextContent>
</Container> </Container>
} }

View File

@ -0,0 +1,32 @@
import { useSearchParams } from "react-router-dom";
import type { DiscordOnboardingState } from "../../../doorman-api/src/functions/api/door/onboard";
import { Spinner } from "@cloudscape-design/components";
import { useEffect } from "react";
export const RedirectPage = () => {
const [params, setParams] = useSearchParams();
const state: DiscordOnboardingState = JSON.parse(params.get("state") as string);
const discordToken: string = params.get("code") as string;
// if this is not a redirect, then go home
if (!state || !discordToken) {
window.location.href = '/';
}
useEffect(() => {
fetch(state.apiRedirect + '?' + params.toString())
.then(res => res.json())
.then(res => {
if (res.err) {
console.error(res);
window.location.href = '/';
return;
}
window.location.href = res.redirect;
});
}, []);
return <Spinner />;
}