From c56b90714f6bbcb1c80c15cf6316a7aa69ac6c99 Mon Sep 17 00:00:00 2001 From: Martin Dimitrov Date: Fri, 6 Jun 2025 15:28:13 -0700 Subject: [PATCH] add new door onboarding --- .../src/functions/api/door/onboard.ts | 220 ++++++++++++++++++ .../doorman-api/src/metrics/OnboardMetrics.ts | 27 +++ packages/doorman-api/src/schema/DoorConfig.ts | 36 +++ packages/doorman-api/src/utils/ddb.ts | 3 +- packages/doorman-ui/src/index.tsx | 5 +- packages/doorman-ui/src/pages/EditPage.tsx | 168 ++++++++----- packages/doorman-ui/src/pages/ErrorPage.tsx | 4 +- .../doorman-ui/src/pages/RedirectPage.tsx | 32 +++ 8 files changed, 430 insertions(+), 65 deletions(-) create mode 100644 packages/doorman-api/src/functions/api/door/onboard.ts create mode 100644 packages/doorman-api/src/metrics/OnboardMetrics.ts create mode 100644 packages/doorman-ui/src/pages/RedirectPage.tsx diff --git a/packages/doorman-api/src/functions/api/door/onboard.ts b/packages/doorman-api/src/functions/api/door/onboard.ts new file mode 100644 index 0000000..d1ef747 --- /dev/null +++ b/packages/doorman-api/src/functions/api/door/onboard.ts @@ -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 = 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[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(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(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(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); +}); diff --git a/packages/doorman-api/src/metrics/OnboardMetrics.ts b/packages/doorman-api/src/metrics/OnboardMetrics.ts new file mode 100644 index 0000000..f8bb5b9 --- /dev/null +++ b/packages/doorman-api/src/metrics/OnboardMetrics.ts @@ -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", + })); +} + diff --git a/packages/doorman-api/src/schema/DoorConfig.ts b/packages/doorman-api/src/schema/DoorConfig.ts index 3a696d7..be76665 100644 --- a/packages/doorman-api/src/schema/DoorConfig.ts +++ b/packages/doorman-api/src/schema/DoorConfig.ts @@ -68,3 +68,39 @@ export const createEditDoorConfig = (door: string, newConfig: EditDoorConfigReq) 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; + +export const OnboardDoorConfigEntity: DynaBridgeEntity = { + 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; + +export const createOnboardDoorConfig = (req: OnboardDoorReq): OnboardDoorConfig => { + return { + ...req, + PK: req.name, + SK: ONBOARD_DOOR_CONFIG_SK, + nonce: randomUUID(), + }; +} diff --git a/packages/doorman-api/src/utils/ddb.ts b/packages/doorman-api/src/utils/ddb.ts index ea08565..d23c970 100644 --- a/packages/doorman-api/src/utils/ddb.ts +++ b/packages/doorman-api/src/utils/ddb.ts @@ -1,6 +1,6 @@ import { TwilioContext } from "../types/TwilioContext"; import { DynaBridge } from "dynabridge"; -import { DoorConfigEntity, EditDoorConfigEntity } from "../schema/DoorConfig"; +import { DoorConfigEntity, EditDoorConfigEntity, OnboardDoorConfigEntity } from "../schema/DoorConfig"; import { DoorAliasEntity } from "../schema/DoorAlias"; import { LockStatusEntity } from "../schema/LockStatus"; @@ -11,6 +11,7 @@ export const createDynaBridgeClient = (context: TwilioContext) => { editDoorConfig: EditDoorConfigEntity, doorAlias: DoorAliasEntity, lockStatus: LockStatusEntity, + onboardDoorConfig: OnboardDoorConfigEntity, }, { serialize: (entity) => entity, deserialize: (entity) => { diff --git a/packages/doorman-ui/src/index.tsx b/packages/doorman-ui/src/index.tsx index 4695693..f30daa7 100644 --- a/packages/doorman-ui/src/index.tsx +++ b/packages/doorman-ui/src/index.tsx @@ -6,6 +6,7 @@ import { DoorPage, loader as doorpageloader } from './pages/DoorPage'; import { ErrorPage } from './pages/ErrorPage'; import { QueryRouter } from './routers/QueryRouter'; import { EditPage } from './pages/EditPage'; +import { RedirectPage } from './pages/RedirectPage'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement @@ -24,7 +25,9 @@ const router = createBrowserRouter([ , - door: + door: , + onboard: , + state: }} /> } diff --git a/packages/doorman-ui/src/pages/EditPage.tsx b/packages/doorman-ui/src/pages/EditPage.tsx index cf7b3ce..4b1af6f 100644 --- a/packages/doorman-ui/src/pages/EditPage.tsx +++ b/packages/doorman-ui/src/pages/EditPage.tsx @@ -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 { DoorResponse } from "../types/DoorResponse"; 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 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 door = doorResponse.id; - const { register, setValue, setError, getValues, watch, formState, clearErrors, control } = useForm({ - defaultValues: doorResponse, + const { setValue, setError, getValues, watch, formState, clearErrors, control, register } = useForm({ + defaultValues: { + ...doorResponse, + isConfirmed: !isOnboarding, + }, mode: "all", }); @@ -45,13 +55,15 @@ export const EditPage = () => { return newAlert.id as string; } - - const fallbackNumbers = watch("fallbackNumbers"); - const discordUsers = watch("discordUsers"); + const fallbackNumbers = watch("fallbackNumbers") || []; + const discordUsers = watch("discordUsers") || []; const fallbackNumbersError = formState.errors.fallbackNumbers?.message; const discordUsersError = formState.errors.discordUsers?.message; + const backUrl = isOnboarding? '/': `?door=${door}`; + const apiRoute = isOnboarding ? ONBOARD_ROUTE: EDIT_ROUTE; + return ( { breadcrumbs={ } @@ -75,21 +87,44 @@ export const EditPage = () => {
- } - header={
Edit Door
} + header={ +
+ {isOnboarding? "Onboard Door": "Edit Door"} +
+ } > - - Door - {door} - - } - > + - - + + - + + + + - + - + - + { }} /> - - Discord users to receive notifications - - } - > - ({ label: n }))}} - onAdd={() => { - const newValue = getValues().discordUser; - if (newValue.length === 0) { - return; - } - clearErrors("discordUsers"); - setValue("discordUsers", [...discordUsers, newValue]); - setValue("discordUser", ""); - }} - onDismiss={(i) => { - clearErrors("discordUsers"); + {!isOnboarding && + + Discord users to receive notifications + + } + > + ({ label: n }))}} + onAdd={() => { + const newValue = getValues().discordUser; + if (newValue.length === 0) { + return; + } + clearErrors("discordUsers"); + setValue("discordUsers", [...discordUsers, newValue]); + setValue("discordUser", ""); + }} + onDismiss={(i) => { + clearErrors("discordUsers"); - if (discordUsers.length === 1) { - setError("discordUsers", { message: "Can't remove last entry" }) - return; - } - discordUsers.splice(i, 1); - setValue("discordUsers", [...discordUsers]); - }} - /> - + if (discordUsers.length === 1) { + setError("discordUsers", { message: "Can't remove last entry" }) + return; + } + discordUsers.splice(i, 1); + setValue("discordUsers", [...discordUsers]); + }} + /> + + } + {isOnboarding && + + + + } diff --git a/packages/doorman-ui/src/pages/ErrorPage.tsx b/packages/doorman-ui/src/pages/ErrorPage.tsx index f34fdeb..5e65a08 100644 --- a/packages/doorman-ui/src/pages/ErrorPage.tsx +++ b/packages/doorman-ui/src/pages/ErrorPage.tsx @@ -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 = () => { return ( @@ -11,7 +11,7 @@ export const ErrorPage = () => {
Doorman
- This isn't a valid door, please double check your URL + This isn't a valid door, please double check your URL, or Onboard to Doorman
} diff --git a/packages/doorman-ui/src/pages/RedirectPage.tsx b/packages/doorman-ui/src/pages/RedirectPage.tsx new file mode 100644 index 0000000..ae68a10 --- /dev/null +++ b/packages/doorman-ui/src/pages/RedirectPage.tsx @@ -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 ; +} \ No newline at end of file