mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-25 11:59:11 +00:00
feat: add audit logging via webhook
This commit is contained in:
parent
5dce2fc949
commit
ad8dd18d02
13 changed files with 485 additions and 22 deletions
|
@ -1,8 +1,20 @@
|
||||||
import { GuildDataUpdate, SessionData, UserGuildPermissions } from '@roleypoly/types';
|
import { sendAuditLog, validateAuditLogWebhook } from '@roleypoly/api/utils/audit-log';
|
||||||
|
import {
|
||||||
|
GuildDataUpdate,
|
||||||
|
SessionData,
|
||||||
|
UserGuildPermissions,
|
||||||
|
WebhookValidationStatus,
|
||||||
|
} from '@roleypoly/types';
|
||||||
import { withSession } from '../utils/api-tools';
|
import { withSession } from '../utils/api-tools';
|
||||||
import { getGuildData } from '../utils/guild';
|
import { getGuildData } from '../utils/guild';
|
||||||
import { GuildData } from '../utils/kv';
|
import { GuildData } from '../utils/kv';
|
||||||
import { lowPermissions, missingParameters, notFound, ok } from '../utils/responses';
|
import {
|
||||||
|
invalid,
|
||||||
|
lowPermissions,
|
||||||
|
missingParameters,
|
||||||
|
notFound,
|
||||||
|
ok,
|
||||||
|
} from '../utils/responses';
|
||||||
|
|
||||||
export const UpdateGuild = withSession(
|
export const UpdateGuild = withSession(
|
||||||
(session: SessionData) =>
|
(session: SessionData) =>
|
||||||
|
@ -27,13 +39,42 @@ export const UpdateGuild = withSession(
|
||||||
return lowPermissions();
|
return lowPermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldGuildData = await getGuildData(guildID);
|
||||||
const newGuildData = {
|
const newGuildData = {
|
||||||
...(await getGuildData(guildID)),
|
...oldGuildData,
|
||||||
...guildUpdate,
|
...guildUpdate,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (oldGuildData.auditLogWebhook !== newGuildData.auditLogWebhook) {
|
||||||
|
try {
|
||||||
|
const validationStatus = await validateAuditLogWebhook(
|
||||||
|
guild,
|
||||||
|
newGuildData.auditLogWebhook
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validationStatus !== WebhookValidationStatus.Ok) {
|
||||||
|
if (validationStatus === WebhookValidationStatus.NoneSet) {
|
||||||
|
newGuildData.auditLogWebhook = null;
|
||||||
|
} else {
|
||||||
|
return invalid({
|
||||||
|
what: 'webhookValidationStatus',
|
||||||
|
webhookValidationStatus: validationStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await GuildData.put(guildID, newGuildData);
|
await GuildData.put(guildID, newGuildData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendAuditLog(oldGuildData, guildUpdate, session.user);
|
||||||
|
} catch (e) {
|
||||||
|
// Catching errors here because this isn't a critical task, and could simply fail due to operator error.
|
||||||
|
}
|
||||||
|
|
||||||
return ok();
|
return ok();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -11,6 +11,8 @@
|
||||||
"@roleypoly/misc-utils": "*",
|
"@roleypoly/misc-utils": "*",
|
||||||
"@roleypoly/types": "*",
|
"@roleypoly/types": "*",
|
||||||
"@roleypoly/worker-emulator": "*",
|
"@roleypoly/worker-emulator": "*",
|
||||||
|
"@types/deep-equal": "^1.0.1",
|
||||||
|
"deep-equal": "^2.0.5",
|
||||||
"ksuid": "^2.0.0",
|
"ksuid": "^2.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"ts-loader": "^8.3.0"
|
"ts-loader": "^8.3.0"
|
||||||
|
|
226
packages/api/utils/audit-log.ts
Normal file
226
packages/api/utils/audit-log.ts
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
import { userAgent } from '@roleypoly/api/utils/api-tools';
|
||||||
|
import { uiPublicURI } from '@roleypoly/api/utils/config';
|
||||||
|
import {
|
||||||
|
Category,
|
||||||
|
DiscordUser,
|
||||||
|
GuildData,
|
||||||
|
GuildDataUpdate,
|
||||||
|
GuildSlug,
|
||||||
|
WebhookValidationStatus,
|
||||||
|
} from '@roleypoly/types';
|
||||||
|
import deepEqual from 'deep-equal';
|
||||||
|
import { sortBy, uniq } from 'lodash';
|
||||||
|
|
||||||
|
type WebhookEmbed = {
|
||||||
|
fields: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
inline: boolean;
|
||||||
|
}[];
|
||||||
|
timestamp: string;
|
||||||
|
title: string;
|
||||||
|
color: number;
|
||||||
|
author?: {
|
||||||
|
name: string;
|
||||||
|
icon_url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebhookPayload = {
|
||||||
|
username: string;
|
||||||
|
avatar_url: string;
|
||||||
|
embeds: WebhookEmbed[];
|
||||||
|
provider: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChangeHandler = (
|
||||||
|
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
|
||||||
|
newValue: GuildData[keyof GuildDataUpdate]
|
||||||
|
) => WebhookEmbed[];
|
||||||
|
|
||||||
|
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
|
||||||
|
message: (oldValue, newValue) => [
|
||||||
|
{
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
color: 0x453e3d,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Old Message',
|
||||||
|
value: oldValue as string,
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'New Message',
|
||||||
|
value: newValue as string,
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: `Server message was updated...`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
auditLogWebhook: (oldValue, newValue) => [
|
||||||
|
{
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
color: 0x5d5352,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Old Webhook ID',
|
||||||
|
value: !oldValue ? '*unset*' : (oldValue as string).split('/')[5],
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'New Webhook ID',
|
||||||
|
value: !newValue ? '*unset*' : (newValue as string).split('/')[5],
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: `Audit Log webhook URL was changed...`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
categories: (oldValue, newValue) => [
|
||||||
|
{
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
color: 0xab9b9a,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Changed Categories',
|
||||||
|
value: getChangedCategories(
|
||||||
|
oldValue as Category[],
|
||||||
|
newValue as Category[]
|
||||||
|
).join('\n'),
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: `Categories were changed...`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendAuditLog = async (
|
||||||
|
guild: GuildData,
|
||||||
|
guildUpdate: GuildDataUpdate,
|
||||||
|
user: DiscordUser
|
||||||
|
) => {
|
||||||
|
const auditLogWebhooks = uniq([
|
||||||
|
guild.auditLogWebhook || '',
|
||||||
|
guildUpdate.auditLogWebhook || '',
|
||||||
|
]).filter((webhook) => webhook !== '');
|
||||||
|
|
||||||
|
if (auditLogWebhooks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(guildUpdate) as (keyof GuildDataUpdate)[];
|
||||||
|
const webhookPayload: WebhookPayload = {
|
||||||
|
username: 'Roleypoly (Audit Log)',
|
||||||
|
avatar_url: `https://next.roleypoly.com/logo192.png`, //TODO: change to roleypoly.com when swapped.
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
fields: [],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
title: `${user.username}#${user.discriminator} has edited Roleypoly settings`,
|
||||||
|
color: 0x332d2d,
|
||||||
|
author: {
|
||||||
|
name: user.username,
|
||||||
|
icon_url: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
provider: {
|
||||||
|
name: 'Roleypoly',
|
||||||
|
url: uiPublicURI,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let key of keys) {
|
||||||
|
if (deepEqual(guildUpdate[key], guild[key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = changeHandlers[key];
|
||||||
|
if (!handler) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeFields = handler(guild[key], guildUpdate[key]);
|
||||||
|
webhookPayload.embeds.push(...changeFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webhookPayload.embeds.length === 1) {
|
||||||
|
// No changes, don't bother sending
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colors are in order already, so use them to order the embeds.
|
||||||
|
webhookPayload.embeds = sortBy(webhookPayload.embeds, 'color');
|
||||||
|
|
||||||
|
const doWebhook = (webhook: string) =>
|
||||||
|
fetch(webhook, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(webhookPayload),
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'user-agent': userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(auditLogWebhooks.map((webhook) => doWebhook(webhook)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateAuditLogWebhook = async (
|
||||||
|
guild: GuildSlug,
|
||||||
|
webhook: string | null
|
||||||
|
): Promise<WebhookValidationStatus> => {
|
||||||
|
if (!webhook) {
|
||||||
|
return WebhookValidationStatus.NoneSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(webhook);
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.hostname !== 'discord.com' ||
|
||||||
|
url.protocol !== 'https:' ||
|
||||||
|
url.pathname.startsWith('api/webhooks/')
|
||||||
|
) {
|
||||||
|
return WebhookValidationStatus.NotDiscordURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(webhook, { method: 'GET' });
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return WebhookValidationStatus.DoesNotExist;
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookData = await response.json();
|
||||||
|
|
||||||
|
if (webhookData.guild_id !== guild.id) {
|
||||||
|
return WebhookValidationStatus.NotSameGuild;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WebhookValidationStatus.Ok;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangedCategories = (oldCategories: Category[], newCategories: Category[]) => {
|
||||||
|
const addedCategories = newCategories.filter(
|
||||||
|
(c) => !oldCategories.find((o) => o.id === c.id)
|
||||||
|
);
|
||||||
|
const removedCategories = oldCategories.filter(
|
||||||
|
(c) => !newCategories.find((o) => o.id === c.id)
|
||||||
|
);
|
||||||
|
const changedCategories = newCategories.filter(
|
||||||
|
(c) =>
|
||||||
|
oldCategories.find((o) => o.id === c.id) &&
|
||||||
|
!deepEqual(
|
||||||
|
oldCategories.find((o) => o.id === c.id),
|
||||||
|
newCategories.find((o) => o.id === c.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...addedCategories.map((c) => `➕ **Added** ${c.name}`),
|
||||||
|
...removedCategories.map((c) => `➖ **Removed** ${c.name}`),
|
||||||
|
...changedCategories.map((c) => `🔧 **Changed** ${c.name}`),
|
||||||
|
];
|
||||||
|
};
|
|
@ -128,17 +128,22 @@ export const updateGuildMemberRoles = async (
|
||||||
|
|
||||||
export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
||||||
const guildData = await GuildData.get<GuildDataT>(id);
|
const guildData = await GuildData.get<GuildDataT>(id);
|
||||||
|
const empty = {
|
||||||
|
id,
|
||||||
|
message: '',
|
||||||
|
categories: [],
|
||||||
|
features: Features.None,
|
||||||
|
auditLogWebhook: null,
|
||||||
|
};
|
||||||
|
|
||||||
if (!guildData) {
|
if (!guildData) {
|
||||||
return {
|
return empty;
|
||||||
id,
|
|
||||||
message: '',
|
|
||||||
categories: [],
|
|
||||||
features: Features.None,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return guildData;
|
return {
|
||||||
|
...empty,
|
||||||
|
...guildData,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
|
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
|
||||||
|
|
|
@ -14,3 +14,6 @@ export const conflict = () => respond({ error: 'conflict' }, { status: 409 });
|
||||||
|
|
||||||
export const rateLimited = () =>
|
export const rateLimited = () =>
|
||||||
respond({ error: 'rate limit hit, enhance your calm' }, { status: 419 });
|
respond({ error: 'rate limit hit, enhance your calm' }, { status: 419 });
|
||||||
|
|
||||||
|
export const invalid = (obj: any = {}) =>
|
||||||
|
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });
|
||||||
|
|
|
@ -1,14 +1,22 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Space as SpaceComponent } from './Space';
|
import { LinedSpace, Space } from './Space';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Atoms',
|
title: 'Atoms/Space',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Space = () => (
|
export const space = () => (
|
||||||
<>
|
<>
|
||||||
hello world
|
hello world
|
||||||
<SpaceComponent />
|
<Space />
|
||||||
|
but im over here
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const linedSpace = () => (
|
||||||
|
<>
|
||||||
|
hello world
|
||||||
|
<LinedSpace />
|
||||||
but im over here
|
but im over here
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,17 @@
|
||||||
import styled from 'styled-components';
|
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
|
||||||
export const Space = styled.div`
|
export const Space = styled.div`
|
||||||
height: 15px;
|
height: 15px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const LinedSpace = styled.div<{ width?: number }>`
|
||||||
|
height: 7.5px;
|
||||||
|
margin-top: 7.5px;
|
||||||
|
border-top: 1px solid ${palette.taupe300};
|
||||||
|
${(props) =>
|
||||||
|
props.width &&
|
||||||
|
css`
|
||||||
|
width: ${props.width}px;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||||
|
import { FaderOpacity } from '@roleypoly/design-system/atoms/fader';
|
||||||
|
import { TextInput } from '@roleypoly/design-system/atoms/text-input';
|
||||||
|
import { AmbientLarge, Text } from '@roleypoly/design-system/atoms/typography';
|
||||||
|
import { MessageBox } from '@roleypoly/design-system/organisms/role-picker/RolePicker.styled';
|
||||||
|
import { GuildData, WebhookValidationStatus } from '@roleypoly/types';
|
||||||
|
import { GoAlert, GoInfo } from 'react-icons/go';
|
||||||
|
import ReactTooltip from 'react-tooltip';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onChange: (guildData: GuildData) => void;
|
||||||
|
guildData: GuildData;
|
||||||
|
validationStatus: WebhookValidationStatus | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServerUtilities = (props: Props) => {
|
||||||
|
return (
|
||||||
|
<MessageBox>
|
||||||
|
<Text>
|
||||||
|
(optional) Webhook URL for Audit Logging{' '}
|
||||||
|
<GoInfo
|
||||||
|
data-for="server-utilities"
|
||||||
|
data-tip="Reports changes made in the editor to a Webhook integration within your Discord server."
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholder="https://discord.com/api/webhooks/000000000000000000/..."
|
||||||
|
value={props.guildData.auditLogWebhook || ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
props.onChange({ ...props.guildData, auditLogWebhook: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FaderOpacity isVisible={props.validationStatus !== WebhookValidationStatus.Ok}>
|
||||||
|
<ValidationStatus validationStatus={props.validationStatus} />
|
||||||
|
</FaderOpacity>
|
||||||
|
<ReactTooltip id="server-utilities" />
|
||||||
|
</MessageBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ValidationStatus = (props: Pick<Props, 'validationStatus'>) => {
|
||||||
|
switch (props.validationStatus) {
|
||||||
|
case WebhookValidationStatus.NotDiscordURL:
|
||||||
|
return (
|
||||||
|
<AmbientLarge>
|
||||||
|
<Alert /> URL must be to a Discord webhook, starting with
|
||||||
|
"https://discord.com/api/webhooks/".
|
||||||
|
</AmbientLarge>
|
||||||
|
);
|
||||||
|
case WebhookValidationStatus.NotSameGuild:
|
||||||
|
return (
|
||||||
|
<AmbientLarge>
|
||||||
|
<Alert /> Webhook must be in the same guild you are currently editing.
|
||||||
|
</AmbientLarge>
|
||||||
|
);
|
||||||
|
case WebhookValidationStatus.DoesNotExist:
|
||||||
|
return (
|
||||||
|
<AmbientLarge>
|
||||||
|
<Alert /> This webhook doesn't exist.
|
||||||
|
</AmbientLarge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <AmbientLarge> </AmbientLarge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Alert = styled(GoAlert)`
|
||||||
|
color: ${palette.red400};
|
||||||
|
position: relative;
|
||||||
|
top: 2px;
|
||||||
|
`;
|
|
@ -1,10 +1,16 @@
|
||||||
import { Space } from '@roleypoly/design-system/atoms/space';
|
import { LinedSpace, Space } from '@roleypoly/design-system/atoms/space';
|
||||||
import { EditableServerMessage } from '@roleypoly/design-system/molecules/editable-server-message';
|
import { EditableServerMessage } from '@roleypoly/design-system/molecules/editable-server-message';
|
||||||
import { ServerMasthead } from '@roleypoly/design-system/molecules/server-masthead';
|
import { ServerMasthead } from '@roleypoly/design-system/molecules/server-masthead';
|
||||||
|
import { ServerUtilities } from '@roleypoly/design-system/molecules/server-utilities/ServerUtilities';
|
||||||
import { SecondaryEditing } from '@roleypoly/design-system/organisms/masthead';
|
import { SecondaryEditing } from '@roleypoly/design-system/organisms/masthead';
|
||||||
import { Container } from '@roleypoly/design-system/organisms/role-picker/RolePicker.styled';
|
import { Container } from '@roleypoly/design-system/organisms/role-picker/RolePicker.styled';
|
||||||
import { ServerCategoryEditor } from '@roleypoly/design-system/organisms/server-category-editor';
|
import { ServerCategoryEditor } from '@roleypoly/design-system/organisms/server-category-editor';
|
||||||
import { Category, PresentableGuild } from '@roleypoly/types';
|
import {
|
||||||
|
Category,
|
||||||
|
GuildData,
|
||||||
|
PresentableGuild,
|
||||||
|
WebhookValidationStatus,
|
||||||
|
} from '@roleypoly/types';
|
||||||
import deepEqual from 'deep-equal';
|
import deepEqual from 'deep-equal';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
@ -13,17 +19,26 @@ export type EditorShellProps = {
|
||||||
onGuildChange?: (guild: PresentableGuild) => void;
|
onGuildChange?: (guild: PresentableGuild) => void;
|
||||||
onCategoryChange?: (category: Category) => void;
|
onCategoryChange?: (category: Category) => void;
|
||||||
onMessageChange?: (message: PresentableGuild['data']['message']) => void;
|
onMessageChange?: (message: PresentableGuild['data']['message']) => void;
|
||||||
|
errors: {
|
||||||
|
webhookValidation: WebhookValidationStatus;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditorShell = (props: EditorShellProps) => {
|
export const EditorShell = (props: EditorShellProps) => {
|
||||||
const [guild, setGuild] = React.useState<PresentableGuild>(props.guild);
|
const [guild, setGuild] = React.useState<PresentableGuild>(props.guild);
|
||||||
|
const [errors, setErrors] = React.useState<EditorShellProps['errors']>(props.errors);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setGuild(props.guild);
|
setGuild(props.guild);
|
||||||
}, [props.guild]);
|
}, [props.guild]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setErrors(props.errors);
|
||||||
|
}, [props.errors]);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setGuild(props.guild);
|
setGuild(props.guild);
|
||||||
|
setErrors({ webhookValidation: WebhookValidationStatus.Ok });
|
||||||
};
|
};
|
||||||
|
|
||||||
const replaceCategories = (categories: Category[]) => {
|
const replaceCategories = (categories: Category[]) => {
|
||||||
|
@ -38,6 +53,12 @@ export const EditorShell = (props: EditorShellProps) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateGuildData = (data: PresentableGuild['data']) => {
|
||||||
|
setGuild((currentGuild) => {
|
||||||
|
return { ...currentGuild, data };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const doSubmit = () => {
|
const doSubmit = () => {
|
||||||
props.onGuildChange?.(guild);
|
props.onGuildChange?.(guild);
|
||||||
};
|
};
|
||||||
|
@ -65,7 +86,48 @@ export const EditorShell = (props: EditorShellProps) => {
|
||||||
/>
|
/>
|
||||||
<Space />
|
<Space />
|
||||||
<ServerCategoryEditor guild={guild} onChange={replaceCategories} />
|
<ServerCategoryEditor guild={guild} onChange={replaceCategories} />
|
||||||
|
<LinedSpace />
|
||||||
|
<ServerUtilities
|
||||||
|
guildData={guild.data}
|
||||||
|
onChange={updateGuildData}
|
||||||
|
validationStatus={validateWebhook(
|
||||||
|
guild.data.auditLogWebhook,
|
||||||
|
errors.webhookValidation
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateWebhook = (
|
||||||
|
webhook: GuildData['auditLogWebhook'],
|
||||||
|
validationStatus: WebhookValidationStatus
|
||||||
|
) => {
|
||||||
|
if (!webhook) {
|
||||||
|
return WebhookValidationStatus.NoneSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(webhook);
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.hostname !== 'discord.com' ||
|
||||||
|
url.protocol !== 'https:' ||
|
||||||
|
url.pathname.startsWith('api/webhooks/')
|
||||||
|
) {
|
||||||
|
return WebhookValidationStatus.NotDiscordURL;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return WebhookValidationStatus.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
validationStatus !== WebhookValidationStatus.Ok &&
|
||||||
|
validationStatus !== WebhookValidationStatus.NoneSet
|
||||||
|
) {
|
||||||
|
return validationStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WebhookValidationStatus.Ok;
|
||||||
|
};
|
||||||
|
|
|
@ -14,6 +14,7 @@ export default {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
args: {
|
args: {
|
||||||
|
errors: { validationStatus: 0 },
|
||||||
guilds: mastheadSlugs,
|
guilds: mastheadSlugs,
|
||||||
user: user,
|
user: user,
|
||||||
guild: guildEnum.guilds[0],
|
guild: guildEnum.guilds[0],
|
||||||
|
|
|
@ -11,7 +11,9 @@ export const EditorTemplate = (
|
||||||
props;
|
props;
|
||||||
return (
|
return (
|
||||||
<AppShell {...appShellProps} activeGuildId={guild.id} small double>
|
<AppShell {...appShellProps} activeGuildId={guild.id} small double>
|
||||||
<EditorShell guild={guild} onGuildChange={onGuildChange} />
|
<EditorShell guild={guild} onGuildChange={onGuildChange} errors={props.errors} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EditorErrors = EditorShellProps['errors'];
|
||||||
|
|
|
@ -20,6 +20,7 @@ export type GuildData = {
|
||||||
message: string;
|
message: string;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
features: Features;
|
features: Features;
|
||||||
|
auditLogWebhook: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GuildDataUpdate = Omit<Omit<GuildData, 'features'>, 'id'>;
|
export type GuildDataUpdate = Omit<Omit<GuildData, 'features'>, 'id'>;
|
||||||
|
@ -48,3 +49,11 @@ export type GuildSlug = {
|
||||||
icon: string;
|
icon: string;
|
||||||
permissionLevel: UserGuildPermissions;
|
permissionLevel: UserGuildPermissions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum WebhookValidationStatus {
|
||||||
|
Ok,
|
||||||
|
NoneSet,
|
||||||
|
DoesNotExist,
|
||||||
|
NotSameGuild,
|
||||||
|
NotDiscordURL,
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { navigate, Redirect } from '@reach/router';
|
import { navigate, Redirect } from '@reach/router';
|
||||||
import { EditorTemplate } from '@roleypoly/design-system/templates/editor';
|
import { EditorErrors, EditorTemplate } from '@roleypoly/design-system/templates/editor';
|
||||||
import { GenericLoadingTemplate } from '@roleypoly/design-system/templates/generic-loading';
|
import { GenericLoadingTemplate } from '@roleypoly/design-system/templates/generic-loading';
|
||||||
import {
|
import {
|
||||||
GuildDataUpdate,
|
GuildDataUpdate,
|
||||||
PresentableGuild,
|
PresentableGuild,
|
||||||
UserGuildPermissions,
|
UserGuildPermissions,
|
||||||
|
WebhookValidationStatus,
|
||||||
} from '@roleypoly/types';
|
} from '@roleypoly/types';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useAppShellProps } from '../contexts/app-shell/AppShellContext';
|
import { useAppShellProps } from '../contexts/app-shell/AppShellContext';
|
||||||
|
@ -25,6 +26,9 @@ const Editor = (props: EditorProps) => {
|
||||||
|
|
||||||
const [guild, setGuild] = React.useState<PresentableGuild | null | false>(null);
|
const [guild, setGuild] = React.useState<PresentableGuild | null | false>(null);
|
||||||
const [pending, setPending] = React.useState(false);
|
const [pending, setPending] = React.useState(false);
|
||||||
|
const [errors, setErrors] = React.useState<EditorErrors>({
|
||||||
|
webhookValidation: WebhookValidationStatus.Ok,
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const shouldPullUncached = (): boolean => {
|
const shouldPullUncached = (): boolean => {
|
||||||
|
@ -87,6 +91,7 @@ const Editor = (props: EditorProps) => {
|
||||||
const updatePayload: GuildDataUpdate = {
|
const updatePayload: GuildDataUpdate = {
|
||||||
message: guild.data.message,
|
message: guild.data.message,
|
||||||
categories: guild.data.categories,
|
categories: guild.data.categories,
|
||||||
|
auditLogWebhook: guild.data.auditLogWebhook,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await authedFetch(`/update-guild/${serverID}`, {
|
const response = await authedFetch(`/update-guild/${serverID}`, {
|
||||||
|
@ -96,16 +101,31 @@ const Editor = (props: EditorProps) => {
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setGuild(guild);
|
setGuild(guild);
|
||||||
setPending(false);
|
navigate(`/s/${props.serverID}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(`/s/${props.serverID}`);
|
if (response.status === 400) {
|
||||||
|
const error = await response.json();
|
||||||
|
if (error.data.what === 'webhookValidationStatus') {
|
||||||
|
setErrors((errors) => ({
|
||||||
|
...errors,
|
||||||
|
webhookValidation: error.data.webhookValidationStatus,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title title={`Editing ${guild.guild.name} - Roleypoly`} />
|
<Title title={`Editing ${guild.guild.name} - Roleypoly`} />
|
||||||
<EditorTemplate {...appShellProps} guild={guild} onGuildChange={onGuildChange} />
|
<EditorTemplate
|
||||||
|
{...appShellProps}
|
||||||
|
guild={guild}
|
||||||
|
onGuildChange={onGuildChange}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue