feat: support updating roles (closes #83)

This commit is contained in:
41666 2020-12-18 19:55:58 -05:00
parent c7381c3d66
commit 1a59972398
10 changed files with 272 additions and 23 deletions

View file

@ -33,6 +33,7 @@
"chroma-js": "^2.1.0",
"isomorphic-unfetch": "^3.1.0",
"ksuid": "^2.0.0",
"lodash": "^4.17.20",
"next": "^10.0.3",
"nookies": "^2.5.0",
"react": "^17.0.1",
@ -60,6 +61,7 @@
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/express": "^4.17.9",
"@types/jest": "^26.0.19",
"@types/lodash": "^4.14.165",
"@types/minimist": "^1.2.1",
"@types/node": "^14.14.14",
"@types/react": "^17.0.0",

View file

@ -0,0 +1,141 @@
import { difference, groupBy, keyBy, union } from 'lodash';
import {
GuildData,
Member,
Role,
RoleSafety,
RoleTransaction,
RoleUpdate,
SessionData,
TransactionType,
} from 'roleypoly/common/types';
import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools';
import { botToken } from '../utils/config';
import {
getGuild,
getGuildData,
getGuildMemberRoles,
updateGuildMemberRoles,
} from '../utils/guild';
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
export const UpdateRoles = withSession(
({ guilds, user: { id: userID } }: SessionData) => async (request: Request) => {
const updateRequest = (await request.json()) as RoleUpdate;
const [, , guildID] = new URL(request.url).pathname.split('/');
if (!guildID) {
return respond({ error: 'guild ID missing from URL' }, { status: 400 });
}
if (updateRequest.transactions.length === 0) {
return respond(
{ error: 'must have as least one transaction' },
{ status: 400 }
);
}
const guildCheck = guilds.find((guild) => guild.id === guildID);
if (!guildCheck) {
return notFound();
}
const guild = await getGuild(guildID);
if (!guild) {
return notFound();
}
const guildMemberRoles = await getGuildMemberRoles(
{ serverID: guildID, userID },
{ skipCachePull: true }
);
if (!guildMemberRoles) {
return notFound();
}
const newRoles = calculateNewRoles({
currentRoles: guildMemberRoles,
guildRoles: guild.roles,
guildData: await getGuildData(guildID),
updateRequest,
});
const patchMemberRoles = await discordFetch<Member>(
`/guilds/${guildID}/members/${userID}`,
botToken,
AuthType.Bot,
{
method: 'PATCH',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
roles: newRoles,
}),
}
);
if (!patchMemberRoles) {
return respond({ error: 'discord rejected the request' }, { status: 500 });
}
const updatedMember: Member = {
roles: patchMemberRoles.roles,
};
await updateGuildMemberRoles(
{ serverID: guildID, userID },
patchMemberRoles.roles
);
return respond(updatedMember);
}
);
const calculateNewRoles = ({
currentRoles,
guildData,
guildRoles,
updateRequest,
}: {
currentRoles: string[];
guildRoles: Role[];
guildData: GuildData;
updateRequest: RoleUpdate;
}): string[] => {
const roleMap = keyBy(guildRoles, 'id');
// These roles were ones changed between knownState (role picker page load/cache) and current (fresh from discord).
// We could cause issues, so we'll re-add them later.
// const diffRoles = difference(updateRequest.knownState, currentRoles);
// Only these are safe
const allSafeRoles = guildData.categories.reduce<string[]>(
(categorizedRoles, category) =>
!category.hidden
? [
...categorizedRoles,
...category.roles.filter(
(roleID) => roleMap[roleID]?.safety === RoleSafety.Safe
),
]
: categorizedRoles,
[]
);
const safeTransactions = updateRequest.transactions.filter((tx: RoleTransaction) =>
allSafeRoles.includes(tx.id)
);
const changesByAction = groupBy(safeTransactions, 'action');
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map((tx) => tx.id);
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
(tx) => tx.id
);
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
return final;
};

View file

@ -6,6 +6,7 @@ import { GetSlug } from './handlers/get-slug';
import { LoginBounce } from './handlers/login-bounce';
import { LoginCallback } from './handlers/login-callback';
import { RevokeSession } from './handlers/revoke-session';
import { UpdateRoles } from './handlers/update-roles';
import { Router } from './router';
import { respond } from './utils/api-tools';
import { uiPublicURI } from './utils/config';
@ -24,6 +25,7 @@ router.add('POST', 'revoke-session', RevokeSession);
// Main biz logic
router.add('GET', 'get-slug', GetSlug);
router.add('GET', 'get-picker-data', GetPickerData);
router.add('PATCH', 'update-roles', UpdateRoles);
// Root users only
router.add('GET', 'x-create-roleypoly-data', CreateRoleypolyData);

View file

@ -52,9 +52,6 @@ export class Router {
if (handler) {
try {
const response = await handler(request);
// this.wrapCORS(request, response);
return response;
} catch (e) {
console.error(e);

View file

@ -18,7 +18,7 @@ export const addCORS = (init: ResponseInit = {}) => ({
headers: {
...(init.headers || {}),
'access-control-allow-origin': uiPublicURI,
'access-control-allow-method': '*',
'access-control-allow-methods': '*',
'access-control-allow-headers': '*',
},
});
@ -80,15 +80,26 @@ export enum AuthType {
export const discordFetch = async <T>(
url: string,
auth: string,
authType: AuthType = AuthType.Bearer
authType: AuthType = AuthType.Bearer,
init?: RequestInit
): Promise<T | null> => {
const response = await fetch('https://discord.com/api/v8' + url, {
...(init || {}),
headers: {
...(init?.headers || {}),
authorization: `${AuthType[authType]} ${auth}`,
'user-agent': userAgent,
},
});
if (response.status >= 400) {
console.error('discordFetch failed', {
url,
authType,
payload: await response.text(),
});
}
if (response.ok) {
return (await response.json()) as T;
} else {

View file

@ -7,7 +7,7 @@ import {
RoleSafety,
} from 'roleypoly/common/types';
import { evaluatePermission, permissions } from 'roleypoly/common/utils/hasPermission';
import { cacheLayer, discordFetch } from './api-tools';
import { AuthType, cacheLayer, discordFetch } from './api-tools';
import { botClientID, botToken } from './config';
import { GuildData, Guilds } from './kv';
@ -92,14 +92,17 @@ type APIMember = {
roles: string[];
};
const guildMemberRolesIdentity = ({ serverID, userID }) =>
`guilds/${serverID}/members/${userID}`;
export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>(
Guilds,
({ serverID, userID }) => `guilds/${serverID}/members/${userID}`,
guildMemberRolesIdentity,
async ({ serverID, userID }) => {
const discordMember = await discordFetch<APIMember>(
`/guilds/${serverID}/members/${userID}`,
botToken,
'Bot'
AuthType.Bot
);
if (!discordMember) {
@ -111,6 +114,13 @@ export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>
60 * 5 // 5 minute TTL
);
export const updateGuildMemberRoles = async (
identity: GuildMemberIdentity,
roles: Role['id'][]
) => {
await Guilds.put(guildMemberRolesIdentity(identity), roles, 60 * 5);
};
export const getGuildData = async (id: string): Promise<GuildDataT> => {
const guildData = await GuildData.get<GuildDataT>(id);

View file

@ -19,3 +19,19 @@ export type Role = {
export type OwnRoleInfo = {
highestRolePosition: number;
};
export enum TransactionType {
None = 0,
Remove = 1 << 1,
Add = 1 << 2,
}
export type RoleTransaction = {
id: string;
action: TransactionType;
};
export type RoleUpdate = {
knownState: Role['id'][];
transactions: RoleTransaction[];
};

View file

@ -1,3 +1,4 @@
import { isEqual, xor } from 'lodash';
import NextLink from 'next/link';
import * as React from 'react';
import { GoInfo } from 'react-icons/go';
@ -34,20 +35,17 @@ export type RolePickerProps = {
editable: boolean;
};
const arrayMatches = (a: any[], b: any[]) => {
return (
a === b ||
(a.length === b.length &&
a.every((x) => b.includes(x)) &&
b.every((x) => a.includes(x)))
);
};
export const RolePicker = (props: RolePickerProps) => {
const [selectedRoles, updateSelectedRoles] = React.useState<string[]>(
props.member.roles
);
React.useEffect(() => {
if (!isEqual(props.member.roles, selectedRoles)) {
updateSelectedRoles(props.member.roles);
}
}, [props.member.roles]);
const handleChange = (category: Category) => (role: Role) => (newState: boolean) => {
if (category.type === CategoryType.Single) {
updateSelectedRoles(
@ -114,7 +112,7 @@ export const RolePicker = (props: RolePickerProps) => {
)}
</div>
<FaderOpacity
isVisible={!arrayMatches(selectedRoles, props.member.roles)}
isVisible={xor(selectedRoles, props.member.roles).length !== 0}
>
<ResetSubmit
onSubmit={() => props.onSubmit(selectedRoles)}

View file

@ -1,7 +1,15 @@
import { NextPage, NextPageContext } from 'next';
import Head from 'next/head';
import * as React from 'react';
import { PresentableGuild, UserGuildPermissions } from 'roleypoly/common/types';
import {
Member,
PresentableGuild,
Role,
RoleTransaction,
RoleUpdate,
TransactionType,
UserGuildPermissions,
} from 'roleypoly/common/types';
import { apiFetch } from 'roleypoly/common/utils/isomorphicFetch';
import { RolePickerTemplate } from 'roleypoly/design-system/templates/role-picker';
import { useAppShellProps } from 'roleypoly/providers/appShellData';
@ -10,9 +18,70 @@ type Props = {
data: PresentableGuild;
};
const createUpdatePayload = (
oldRoles: Role['id'][],
newRoles: Role['id'][]
): RoleTransaction[] => {
const transactions: RoleTransaction[] = [];
// Removes: old roles not in new roles
for (let oldID of oldRoles) {
if (!newRoles.includes(oldID)) {
transactions.push({
id: oldID,
action: TransactionType.Remove,
});
}
}
// Adds: new roles not in old roles
for (let newID of newRoles) {
if (!oldRoles.includes(newID)) {
transactions.push({
id: newID,
action: TransactionType.Add,
});
}
}
return transactions;
};
const RolePickerPage: NextPage<Props> = (props) => {
const { appShellProps } = useAppShellProps();
const [isPending, updatePending] = React.useState(false);
const [memberRoles, updateMemberRoles] = React.useState(props.data.member.roles);
const handlePickerSubmit = (guildID: string, oldRoles: Role['id'][]) => async (
newRoles: Role['id'][]
) => {
if (isPending) {
return;
}
updatePending(true);
const payload: RoleUpdate = {
knownState: oldRoles,
transactions: createUpdatePayload(oldRoles, newRoles),
};
const patchedMember = await apiFetch<Member>(`/update-roles/${guildID}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
if (!patchedMember) {
console.error('role update failed', patchedMember);
return;
}
updatePending(false);
updateMemberRoles(patchedMember.roles);
console.log('accepted', { patchedMember });
};
return (
<>
<Head>
@ -24,12 +93,10 @@ const RolePickerPage: NextPage<Props> = (props) => {
guild={props.data.guild}
roles={props.data.roles}
guildData={props.data.data}
member={props.data.member}
member={{ ...props.data.member, roles: memberRoles }}
editable={props.data.guild.permissionLevel !== UserGuildPermissions.User}
activeGuildId={props.data.id}
onSubmit={(i) => {
console.log(i);
}}
onSubmit={handlePickerSubmit(props.data.id, memberRoles)}
/>
</>
);

View file

@ -2519,6 +2519,11 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
"@types/lodash@^4.14.165":
version "4.14.165"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f"
integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg==
"@types/markdown-to-jsx@^6.11.0":
version "6.11.3"
resolved "https://registry.yarnpkg.com/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.3.tgz#cdd1619308fecbc8be7e6a26f3751260249b020e"