launch edit api
This commit is contained in:
parent
9337846ad0
commit
25438f7901
103
packages/doorman-api/functions/api/door/edit.js
Normal file
103
packages/doorman-api/functions/api/door/edit.js
Normal 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);
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
27
packages/doorman-ui/src/components/InputTokenGroup.tsx
Normal file
27
packages/doorman-ui/src/components/InputTokenGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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() {
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
145
packages/doorman-ui/src/pages/EditPage.tsx
Normal file
145
packages/doorman-ui/src/pages/EditPage.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
24
packages/doorman-ui/src/routers/QueryRouter.tsx
Normal file
24
packages/doorman-ui/src/routers/QueryRouter.tsx
Normal 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;
|
||||
};
|
||||
@ -1,5 +0,0 @@
|
||||
export enum Action {
|
||||
DOWNLOAD = "download",
|
||||
UPLOAD = "upload",
|
||||
DELETE = "delete",
|
||||
}
|
||||
@ -2,4 +2,8 @@ export interface DoorResponse {
|
||||
id: string,
|
||||
timeout: number;
|
||||
buzzerCode: string;
|
||||
fallbackNumbers: string[];
|
||||
pressKey: string;
|
||||
discordUsers: string[];
|
||||
buzzer: string;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user