launch edit api
Some checks failed
Build and push image for doorman-homeassistant / docker (push) Successful in 28s
Build and push Doorman UI / API / docker (push) Failing after 17s
Build and push image for doorman-homeassistant / deploy-gitainer (push) Successful in 22s

This commit is contained in:
Martin Dimitrov 2024-11-12 21:46:45 -08:00
parent 9337846ad0
commit 25438f7901
11 changed files with 418 additions and 25 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -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);
};

View File

@ -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);

View File

@ -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,
});
};

View File

@ -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 (
<SpaceBetween size="xs">
<ColumnLayout columns={2}>
<input
type={props.type}
{...props.registerInput}
/>
<Button onClick={() => props.onAdd()}>Add</Button>
</ColumnLayout>
<TokenGroup
onDismiss={(e) => props.onDismiss(e.detail.itemIndex)}
{...props.tokenGroupProps}
/>
</SpaceBetween>
);
};

View File

@ -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: <DoorPage />
element:
<QueryRouter
mapping={{
edit: <EditPage />,
door: <DoorPage />
}}
/>
}
]
}
@ -30,4 +37,3 @@ root.render(
<RouterProvider router={router} />
</React.StrictMode>
);

View File

@ -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() {
<BreadcrumbGroup
items={[
{ text: 'Door', href: '#' },
{ text: door, href: `?door=${door}&edit=true` },
{ text: door, href: `?edit&door=${door}` },
{ text: 'Unlock', href: '#' },
]}
/>
@ -272,4 +266,4 @@ export function DoorPage() {
}
/>
);
}
};

View File

@ -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<DoorEditForm>({
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 (
<AppLayout
contentType="form"
navigationHide
toolsHide
breadcrumbs={
<BreadcrumbGroup
items={[
{ text: 'Door', href: '#' },
{ text: door, href: `?edit&door=${door}` },
]}
/>
}
content={
<Form
actions={
<SpaceBetween direction="horizontal" size="xs">
<Button formAction="none" variant="link" href={`?door=${door}`}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => {
fetch(`/api/door/edit?door=${door}&newConfig=${encodeURIComponent(JSON.stringify(getValues()))}`)
.then(res => res.json())
.then(res => {
console.log(res);
})
}}
>
Submit
</Button>
</SpaceBetween>
}
header={<Header variant="h1">Edit Door</Header>}
>
<Container
header={
<Header variant="h2">
Door - {door}
</Header>
}
>
<SpaceBetween direction="vertical" size="l">
<FormField label="Buzzer Number (read-only)" constraintText="The phone number of your buzzer system">
<input readOnly {...register("buzzer")} />
</FormField>
<FormField label="PIN" constraintText={"The code to unlock the buzzer"}>
<input type="password" {...register("pin")}/>
</FormField>
<FormField label="Buzzer Code" constraintText={"The number that you dial on your buzzer"}>
<input {...register("buzzerCode")} />
</FormField>
<FormField label="Timeout" constraintText="Time in seconds for the door to remain unlocked">
<input type="number" {...register("timeout", {
valueAsNumber: true,
})} />
</FormField>
<FormField
errorText={fallbackNumbersError}
label="Fallback numbers"
constraintText="Phone numbers to dial through in case door is not unlocked"
>
<InputTokenGroup
registerInput={register("fallbackNumber")}
tokenGroupProps={{ items: fallbackNumbers.map(n => ({ 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]);
}}
/>
</FormField>
<FormField
label="Discord Users"
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
</>
}
>
<InputTokenGroup
type="string"
registerInput={register("discordUser")}
tokenGroupProps={{ items: discordUsers.map(n => ({ 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]);
}}
/>
</FormField>
<FormField label="Welcome Message" constraintText="Message to display after a successful unlock">
<textarea {...register("greeting")}/>
</FormField>
</SpaceBetween>
</Container>
</Form>
}
/>
);
};

View File

@ -0,0 +1,24 @@
import React from "react";
import { ReactNode } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
export interface IQueryRouterProps {
mapping: Record<string, ReactNode>;
};
export const QueryRouter = ({ mapping }: IQueryRouterProps) => {
const [params] = useSearchParams();
let element = null;
for (const key of params.keys()) {
if (mapping[key]) {
element = mapping[key];
break;
}
}
if (element === null) {
throw new Error("missing mapping");
}
return element;
};

View File

@ -1,5 +0,0 @@
export enum Action {
DOWNLOAD = "download",
UPLOAD = "upload",
DELETE = "delete",
}

View File

@ -2,4 +2,8 @@ export interface DoorResponse {
id: string,
timeout: number;
buzzerCode: string;
fallbackNumbers: string[];
pressKey: string;
discordUsers: string[];
buzzer: string;
};