diff --git a/bun.lockb b/bun.lockb index 2d74fb8..267862f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/doorman-api/functions/api/door/edit.js b/packages/doorman-api/functions/api/door/edit.js new file mode 100644 index 0000000..2ed179b --- /dev/null +++ b/packages/doorman-api/functions/api/door/edit.js @@ -0,0 +1,103 @@ + +/** + * Edit API for doors + */ + +exports.handler = async function(context, event, callback) { + const response = new Twilio.Response(); + + let door = event.door; + let approvalId = event.approvalId; + let newConfig = event.newConfig; + + const ddbPath = Runtime.getFunctions()['common/ddb'].path; + const discordPath = Runtime.getFunctions()['common/discord'].path; + const ddb = require(ddbPath); + const discord = require(discordPath); + const client = ddb.createDDBClient(context); + + // approve path + if (door && approvalId) { + const newConfig = await client.send(ddb.getDoorConfigUpdateCommand(door)); + + if (!newConfig || newConfig.Item.approvalId.S !== approvalId) { + response.setStatusCode(400); + return callback(null, response); + } + + await client.send(ddb.replaceDoorConfigWithUpdateItem(newConfig)); + + const hostUrl = event.request.headers.host; + const scheme = hostUrl.startsWith("localhost") ? "http://": "https://"; + + // send update to discord users + const updateMessage = `Configuration change \`${approvalId}\` was approved @ Door "${door}"`; + + const discordPromises = newConfig.Item.discordUsers.SS.map((user) => { + return discord.sendMessageToUser( + context, + user, + updateMessage, + ).catch(e => console.error(e)) + }); + + await Promise.all(discordPromises); + + response.setStatusCode(200); + return callback(null, response); + } + + if (!door || !newConfig) { + response.setStatusCode(400); + return callback(null, response); + } + + newConfig = JSON.parse(event.newConfig); + + const config = await client.send(ddb.getDoorConfigCommand(door)); + + if (!config.Item) { + response.setStatusCode(404); + return callback(null, response); + } + + // set to old PIN if it is missing + if (newConfig.pin === "") { + newConfig.pin = config.Item.pin.S; + } + + const input = ddb.putDoorUpdateConfigCommand(door, newConfig); + + const update = await client.send(input); + + newConfig.discordUser = undefined; + newConfig.fallbackNumber = undefined; + newConfig.status = undefined; + + const hostUrl = event.request.headers.host; + const scheme = hostUrl.startsWith("localhost") ? "http://": "https://"; + + const approvalUrl = `${scheme + hostUrl}/api/door/edit?door=${door}&approvalId=${input.input.Item.approvalId.S}`; + console.log(approvalUrl); + + // send update to discord users + const approvalMessage = `Configuration change requested @ Door "${door}" [click to approve it](${approvalUrl})\`\`\`${JSON.stringify(newConfig, null, 4)}\`\`\``; + + const discordPromises = config.Item.discordUsers.SS.map((user) => { + return discord.sendMessageToUser( + context, + user, + approvalMessage, + ).catch(e => console.error(e)) + }); + + await Promise.all(discordPromises); + + response + .setStatusCode(200) + .appendHeader('Content-Type', 'application/json') + .setBody({ msg: update }); + + await client.destroy(); + return callback(null, response); +}; diff --git a/packages/doorman-api/functions/api/door/info.js b/packages/doorman-api/functions/api/door/info.js index 694d92c..3580dd1 100644 --- a/packages/doorman-api/functions/api/door/info.js +++ b/packages/doorman-api/functions/api/door/info.js @@ -41,6 +41,7 @@ exports.handler = async function(context, event, callback) { .setBody({ err: "This buzzer is not registered properly" }); } else { if (buzzer) { + // respond to twilio CLIENT response .setStatusCode(200) .appendHeader('Content-Type', 'application/json') @@ -56,10 +57,20 @@ exports.handler = async function(context, event, callback) { .then(async (lock) => { const status = ddb.isLockOpen(lock) ? "OPEN": "CLOSED"; + // respond to UI response .setStatusCode(200) .appendHeader('Content-Type', 'application/json') - .setBody({ id: door, timeout: config.Item.timeout.N, status, buzzerCode: config.Item.buzzerCode.S }); + .setBody({ + id: door, + timeout: config.Item.timeout.N, + buzzer: config.Item.buzzer.S, + status, + buzzerCode: config.Item.buzzerCode.S, + fallbackNumbers: config.Item.fallbackNumbers.SS, + pressKey: config.Item.pressKey.S, + discordUsers: config.Item?.discordUsers?.SS || [], + }); }).catch((e) => { console.log(e); diff --git a/packages/doorman-api/functions/common/ddb.private.js b/packages/doorman-api/functions/common/ddb.private.js index 62bf239..632fa5d 100644 --- a/packages/doorman-api/functions/common/ddb.private.js +++ b/packages/doorman-api/functions/common/ddb.private.js @@ -1,4 +1,5 @@ -const { DynamoDBClient, GetItemCommand, DeleteItemCommand, PutItemCommand } = require("@aws-sdk/client-dynamodb"); +const { randomUUID } = require("crypto"); +const { DynamoDBClient, GetItemCommand, DeleteItemCommand, PutItemCommand, UpdateItemCommand } = require("@aws-sdk/client-dynamodb"); exports.createDDBClient = (context) => { return new DynamoDBClient({ @@ -91,3 +92,86 @@ exports.setLockStatusCommand = (door, timeoutSeconds, fingerprintObj) => { } }); }; + +exports.putDoorUpdateConfigCommand = (door, config) => { + return new PutItemCommand({ + TableName: "doorman", + Item: { + "PK": { + S: "door-" + door, + }, + "SK": { + S: "config-update", + }, + "buzzer": { + S: config.buzzer, + }, + "buzzerCode": { + S: config.buzzerCode, + }, + "discordUsers": { + SS: config.discordUsers, + }, + "fallbackNumbers": { + SS: config.fallbackNumbers, + }, + "pin": { + S: config.pin, + }, + "pressKey": { + S: config.pressKey, + }, + "timeout": { + N: `${config.timeout}`, + }, + "greeting": { + S: config.greeting, + }, + "approvalId": { + S: randomUUID().toString(), + } + } + }); +}; + +exports.getDoorConfigCommand = (door) => { + return new GetItemCommand({ + TableName: "doorman", + Key: { + "PK": { + S: `door-${door}`, + }, + "SK": { + S: "config-update", + }, + }, + }); +}; + +exports.getDoorConfigUpdateCommand = (door) => { + return new GetItemCommand({ + TableName: "doorman", + Key: { + "PK": { + S: `door-${door}`, + }, + "SK": { + S: "config-update", + }, + }, + }); +}; + +exports.replaceDoorConfigWithUpdateItem = (newConfigItem) => { + const newItem = { + ...newConfigItem.Item, + SK: { S: "config" }, + }; + + delete newItem.approvalId; + + return new PutItemCommand({ + TableName: "doorman", + Item: newItem, + }); +}; diff --git a/packages/doorman-ui/src/components/InputTokenGroup.tsx b/packages/doorman-ui/src/components/InputTokenGroup.tsx new file mode 100644 index 0000000..5440005 --- /dev/null +++ b/packages/doorman-ui/src/components/InputTokenGroup.tsx @@ -0,0 +1,27 @@ +import { Button, ColumnLayout, Input, SpaceBetween, TokenGroup, TokenGroupProps } from "@cloudscape-design/components"; + +export type InputTokenGroupProps = { + type?: React.HTMLInputTypeAttribute; + registerInput: any, + tokenGroupProps: TokenGroupProps, + onAdd: () => void, + onDismiss: (i: number) => void, +}; + +export const InputTokenGroup = (props: InputTokenGroupProps) => { + return ( + + + + + + props.onDismiss(e.detail.itemIndex)} + {...props.tokenGroupProps} + /> + + ); +}; diff --git a/packages/doorman-ui/src/index.tsx b/packages/doorman-ui/src/index.tsx index 69df590..4695693 100644 --- a/packages/doorman-ui/src/index.tsx +++ b/packages/doorman-ui/src/index.tsx @@ -4,7 +4,8 @@ import App from './App'; import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import { DoorPage, loader as doorpageloader } from './pages/DoorPage'; import { ErrorPage } from './pages/ErrorPage'; - +import { QueryRouter } from './routers/QueryRouter'; +import { EditPage } from './pages/EditPage'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement @@ -19,7 +20,13 @@ const router = createBrowserRouter([ { path: "", loader: doorpageloader, - element: + element: + , + door: + }} + /> } ] } @@ -30,4 +37,3 @@ root.render( ); - diff --git a/packages/doorman-ui/src/pages/DoorPage.tsx b/packages/doorman-ui/src/pages/DoorPage.tsx index 18af4b1..13d69a7 100644 --- a/packages/doorman-ui/src/pages/DoorPage.tsx +++ b/packages/doorman-ui/src/pages/DoorPage.tsx @@ -1,6 +1,6 @@ -import React, { ReactElement, ReactNode, useEffect, useState } from "react"; +import { ReactNode, useEffect, useState } from "react"; import { useLoaderData, useNavigate, useSearchParams } from "react-router-dom"; -import { AppLayout, BreadcrumbGroup, Container, Flashbar, FlashbarProps, Header, SideNavigation, SpaceBetween, TextContent, Wizard } from "@cloudscape-design/components"; +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"; @@ -9,27 +9,21 @@ import { DoorResponse } from "../types/DoorResponse"; export async function loader({ params, request }: any) { const door = new URL(request.url).searchParams.get('door'); + if (!door) { + return {}; + } + const response = await fetch(`/api/door/info?door=${door}`).then(res => res.json()); console.log(response); - if (response.msg) { + if (response.err) { throw new Error("Not a valid door"); } return response as DoorResponse; } -interface SelectOption { - label?: string; - value?: string; -} - -const selectOptions: SelectOption[] = [ - { label: "backup", value: "backup" }, - { label: "key", value: "key" }, -]; - export function DoorPage() { const doorResponse = useLoaderData() as DoorResponse; const navigate = useNavigate(); @@ -106,7 +100,7 @@ export function DoorPage() { @@ -272,4 +266,4 @@ export function DoorPage() { } /> ); -} +}; diff --git a/packages/doorman-ui/src/pages/EditPage.tsx b/packages/doorman-ui/src/pages/EditPage.tsx new file mode 100644 index 0000000..00b7592 --- /dev/null +++ b/packages/doorman-ui/src/pages/EditPage.tsx @@ -0,0 +1,145 @@ +import { AppLayout, BreadcrumbGroup, Button, Container, Form, FormField, Header, Link, SpaceBetween } from "@cloudscape-design/components"; +import { useLoaderData, useSearchParams } from "react-router-dom"; +import { DoorResponse } from "../types/DoorResponse"; +import { useForm } from "react-hook-form"; +import { InputTokenGroup } from "../components/InputTokenGroup"; + +export type DoorEditForm = DoorResponse & { pin: string, fallbackNumber: string, discordUser: string, greeting: string }; + +export const EditPage = () => { + const doorResponse = useLoaderData() as DoorResponse; + const door = doorResponse.id; + const { register, setValue, setError, getValues, watch, formState, clearErrors } = useForm({ + defaultValues: doorResponse, + mode: "all", + }); + + const fallbackNumbers = watch("fallbackNumbers"); + const discordUsers = watch("discordUsers"); + + const fallbackNumbersError = formState.errors.fallbackNumbers?.message; + const discordUsersError = formState.errors.discordUsers?.message; + + return ( + + } + content={ +
+ + + + } + header={
Edit Door
} + > + + Door - {door} + + } + > + + + + + + + + + + + + + + + ({ label: n }))}} + onAdd={() => { + clearErrors("fallbackNumbers"); + setValue("fallbackNumbers", [...fallbackNumbers, getValues().fallbackNumber]) + setValue("fallbackNumber", "") + }} + onDismiss={(i) => { + clearErrors("fallbackNumbers"); + + if (fallbackNumbers.length === 1) { + setError("fallbackNumbers", { message: "Can't remove last entry", type: "value" }) + return; + } + fallbackNumbers.splice(i, 1); + setValue("fallbackNumbers", [...fallbackNumbers]); + }} + /> + + + Discord users to receive notifications + + } + > + ({ label: n }))}} + onAdd={() => { + clearErrors("discordUsers"); + setValue("discordUsers", [...discordUsers, getValues().discordUser]) + 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]); + }} + /> + + +