From 3f45153b663f395faaeaddeb985d1064393fd8c8 Mon Sep 17 00:00:00 2001 From: Katalina Okano Date: Sun, 18 Jul 2021 01:57:03 -0400 Subject: [PATCH] feat: add access control --- packages/api/handlers/get-picker-data.ts | 16 +- packages/api/handlers/update-roles.ts | 23 ++- packages/api/utils/access-control.ts | 50 +++++ packages/api/utils/api-tools.ts | 9 +- packages/api/utils/audit-log.ts | 40 ++++ packages/api/utils/guild.ts | 58 ++++-- packages/api/utils/import-from-legacy.ts | 5 + packages/api/utils/responses.ts | 5 +- .../atoms/avatar/Avatar.styled.ts | 2 +- .../atoms/icon-helper/IconHelper.stories.tsx | 8 + .../atoms/icon-helper/IconHelper.styled.ts | 21 ++ .../atoms/icon-helper/IconHelper.tsx | 7 + .../design-system/atoms/icon-helper/index.ts | 1 + packages/design-system/atoms/role/Role.tsx | 4 + packages/design-system/fixtures/storyData.ts | 39 +++- .../EditableRoleList.stories.tsx | 16 ++ .../EditableRoleList.styled.ts | 43 ++++ .../editable-role-list/EditableRoleList.tsx | 103 ++++++++++ .../molecules/editable-role-list/index.ts | 1 + .../editor-category/EditorCategory.tsx | 104 ++-------- .../EditorUtilityShell.stories.tsx | 23 +++ .../EditorUtilityShell.styled.ts | 60 ++++++ .../EditorUtilityShell.tsx | 64 ++++++ .../molecules/editor-utility-shell/index.ts | 1 + .../EditorAccessControl.stories.tsx | 13 ++ .../EditorAccessControl.styled.ts | 8 + .../EditorAccessControl.tsx | 185 ++++++++++++++++++ .../organisms/editor-access-control/index.ts | 1 + .../EditorAccessControl.stories.tsx | 26 +++ .../EditorAccessControl.styled.ts | 2 + .../EditorAccessControl.tsx | 21 ++ .../templates/editor-access-control/index.ts | 1 + .../editor-utility/EditorUtility.stories.tsx | 8 - .../editor-utility/EditorUtility.styled.ts | 2 - .../editor-utility/EditorUtility.tsx | 5 - .../templates/editor-utility/index.ts | 1 - .../templates/errors/errorStrings.ts | 5 + packages/types/Guild.ts | 7 + packages/types/Role.ts | 1 + packages/types/User.ts | 1 + packages/web/src/app-router/AppRouter.tsx | 5 + .../web/src/contexts/guild/GuildContext.tsx | 116 +++++++++++ packages/web/src/index.tsx | 2 + packages/web/src/pages/auth/login.tsx | 11 +- packages/web/src/pages/editor.tsx | 17 +- .../web/src/pages/editor/access-control.tsx | 91 +++++++++ packages/web/src/pages/picker.tsx | 16 +- 47 files changed, 1084 insertions(+), 164 deletions(-) create mode 100644 packages/api/utils/access-control.ts create mode 100644 packages/design-system/atoms/icon-helper/IconHelper.stories.tsx create mode 100644 packages/design-system/atoms/icon-helper/IconHelper.styled.ts create mode 100644 packages/design-system/atoms/icon-helper/IconHelper.tsx create mode 100644 packages/design-system/atoms/icon-helper/index.ts create mode 100644 packages/design-system/molecules/editable-role-list/EditableRoleList.stories.tsx create mode 100644 packages/design-system/molecules/editable-role-list/EditableRoleList.styled.ts create mode 100644 packages/design-system/molecules/editable-role-list/EditableRoleList.tsx create mode 100644 packages/design-system/molecules/editable-role-list/index.ts create mode 100644 packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.stories.tsx create mode 100644 packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.styled.ts create mode 100644 packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.tsx create mode 100644 packages/design-system/molecules/editor-utility-shell/index.ts create mode 100644 packages/design-system/organisms/editor-access-control/EditorAccessControl.stories.tsx create mode 100644 packages/design-system/organisms/editor-access-control/EditorAccessControl.styled.ts create mode 100644 packages/design-system/organisms/editor-access-control/EditorAccessControl.tsx create mode 100644 packages/design-system/organisms/editor-access-control/index.ts create mode 100644 packages/design-system/templates/editor-access-control/EditorAccessControl.stories.tsx create mode 100644 packages/design-system/templates/editor-access-control/EditorAccessControl.styled.ts create mode 100644 packages/design-system/templates/editor-access-control/EditorAccessControl.tsx create mode 100644 packages/design-system/templates/editor-access-control/index.ts delete mode 100644 packages/design-system/templates/editor-utility/EditorUtility.stories.tsx delete mode 100644 packages/design-system/templates/editor-utility/EditorUtility.styled.ts delete mode 100644 packages/design-system/templates/editor-utility/EditorUtility.tsx delete mode 100644 packages/design-system/templates/editor-utility/index.ts create mode 100644 packages/web/src/contexts/guild/GuildContext.tsx create mode 100644 packages/web/src/pages/editor/access-control.tsx diff --git a/packages/api/handlers/get-picker-data.ts b/packages/api/handlers/get-picker-data.ts index 1bb6293..a85c146 100644 --- a/packages/api/handlers/get-picker-data.ts +++ b/packages/api/handlers/get-picker-data.ts @@ -1,6 +1,8 @@ +import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control'; +import { accessControlViolation } from '@roleypoly/api/utils/responses'; import { DiscordUser, GuildSlug, PresentableGuild, SessionData } from '@roleypoly/types'; import { respond, withSession } from '../utils/api-tools'; -import { getGuild, getGuildData, getGuildMemberRoles } from '../utils/guild'; +import { getGuild, getGuildData, getGuildMember } from '../utils/guild'; const fail = () => respond({ error: 'guild not found' }, { status: 404 }); @@ -30,24 +32,28 @@ export const GetPickerData = withSession( return fail(); } - const memberRolesP = getGuildMemberRoles({ + const memberP = getGuildMember({ serverID: guildID, userID, }); const guildDataP = getGuildData(guildID); - const [guildData, memberRoles] = await Promise.all([guildDataP, memberRolesP]); - if (!memberRoles) { + const [guildData, member] = await Promise.all([guildDataP, memberP]); + if (!member) { return fail(); } + if (!memberPassesAccessControl(checkGuild, member, guildData.accessControl)) { + return accessControlViolation(); + } + const presentableGuild: PresentableGuild = { id: guildID, guild: checkGuild, roles: guild.roles, member: { - roles: memberRoles, + roles: member.roles, }, data: guildData, }; diff --git a/packages/api/handlers/update-roles.ts b/packages/api/handlers/update-roles.ts index 9703741..2ab9888 100644 --- a/packages/api/handlers/update-roles.ts +++ b/packages/api/handlers/update-roles.ts @@ -1,3 +1,5 @@ +import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control'; +import { accessControlViolation } from '@roleypoly/api/utils/responses'; import { GuildData, Member, @@ -14,8 +16,8 @@ import { botToken } from '../utils/config'; import { getGuild, getGuildData, - getGuildMemberRoles, - updateGuildMemberRoles, + getGuildMember, + updateGuildMember, } from '../utils/guild'; const notFound = () => respond({ error: 'guild not found' }, { status: 404 }); @@ -45,18 +47,24 @@ export const UpdateRoles = withSession( return notFound(); } - const guildMemberRoles = await getGuildMemberRoles( + const guildMember = await getGuildMember( { serverID: guildID, userID }, { skipCachePull: true } ); - if (!guildMemberRoles) { + if (!guildMember) { return notFound(); } + const guildData = await getGuildData(guildID); + + if (!memberPassesAccessControl(guildCheck, guildMember, guildData.accessControl)) { + return accessControlViolation(); + } + const newRoles = calculateNewRoles({ - currentRoles: guildMemberRoles, + currentRoles: guildMember.roles, guildRoles: guild.roles, - guildData: await getGuildData(guildID), + guildData, updateRequest, }); @@ -84,7 +92,8 @@ export const UpdateRoles = withSession( roles: patchMemberRoles.roles, }; - await updateGuildMemberRoles({ serverID: guildID, userID }, patchMemberRoles.roles); + // Delete the cache by re-pulling... might be dangerous :) + await updateGuildMember({ serverID: guildID, userID }); return respond(updatedMember); } diff --git a/packages/api/utils/access-control.ts b/packages/api/utils/access-control.ts new file mode 100644 index 0000000..7cea295 --- /dev/null +++ b/packages/api/utils/access-control.ts @@ -0,0 +1,50 @@ +import { isRoot } from '@roleypoly/api/utils/api-tools'; +import { + GuildAccessControl, + GuildSlug, + Member, + UserGuildPermissions, +} from '@roleypoly/types'; +import { xor } from 'lodash'; + +export const memberPassesAccessControl = ( + guildSlug: GuildSlug, + member: Member, + accessControl: GuildAccessControl +): boolean => { + // Root has a bypass + if (isRoot(member.user?.id || '')) { + return true; + } + + // Admin and Manager has a bypass + if (guildSlug.permissionLevel !== UserGuildPermissions.User) { + return true; + } + + // Block pending members, "Welcome Screen" feature + if (accessControl.blockPending && member.pending) { + return false; + } + + // If member has roles in the blockList, block. + // Blocklist takes precedence over allowlist + // We use xor because xor([1, 3], [2, 3]) returns [3]), e.g. present in both lists + if ( + accessControl.blockList && + xor(member.roles, accessControl.blockList).length !== 0 + ) { + return false; + } + + // If there is an allowList, and the member is not in it, block. + // If thew allowList is empty, we bypass this. + if ( + accessControl.allowList && + xor(member.roles, accessControl.allowList).length === 0 + ) { + return false; + } + + return true; +}; diff --git a/packages/api/utils/api-tools.ts b/packages/api/utils/api-tools.ts index a9de84a..55f6b13 100644 --- a/packages/api/utils/api-tools.ts +++ b/packages/api/utils/api-tools.ts @@ -108,6 +108,10 @@ export const discordFetch = async ( } }; +export type CacheLayerOptions = { + skipCachePull?: boolean; +}; + export const cacheLayer = ( kv: WrappedKVNamespace, @@ -115,10 +119,7 @@ export const cacheLayer = missHandler: (identity: Identity) => Promise, ttlSeconds?: number ) => - async ( - identity: Identity, - options: { skipCachePull?: boolean } = {} - ): Promise => { + async (identity: Identity, options: CacheLayerOptions = {}): Promise => { const key = keyFactory(identity); if (!options.skipCachePull) { diff --git a/packages/api/utils/audit-log.ts b/packages/api/utils/audit-log.ts index e7c175f..5e1869e 100644 --- a/packages/api/utils/audit-log.ts +++ b/packages/api/utils/audit-log.ts @@ -97,6 +97,23 @@ const changeHandlers: Record = { title: `Categories were changed...`, }, ], + accessControl: (oldValue, newValue) => [ + { + timestamp: new Date().toISOString(), + color: 0xab9b9a, + fields: [ + { + name: 'Changed Access Control', + value: getChangedAccessControl( + oldValue as GuildDataUpdate['accessControl'], + newValue as GuildDataUpdate['accessControl'] + ).join('\n'), + inline: false, + }, + ], + title: `Access Control was changed...`, + }, + ], }; export const sendAuditLog = async ( @@ -224,3 +241,26 @@ const getChangedCategories = (oldCategories: Category[], newCategories: Category ...changedCategories.map((c) => `๐Ÿ”ง **Changed** ${c.name}`), ]; }; + +const getChangedAccessControl = ( + oldAccessControl: GuildDataUpdate['accessControl'], + newAccessControl: GuildDataUpdate['accessControl'] +) => { + const pendingChanged = newAccessControl.blockPending !== oldAccessControl.blockPending; + + return [ + `โœ… Allowed roles: ${ + newAccessControl.allowList.map((role) => `<@&${role}>`).join(', ') || `*all roles*` + }`, + `โŒ Blocked roles: ${ + newAccessControl.blockList.map((role) => `<@&${role}>`).join(', ') || `*no roles*` + }`, + ...(pendingChanged + ? [ + `๐Ÿ”ง Pending/Welcome Screening users are ${ + newAccessControl.blockPending ? 'blocked โŒ' : 'allowed โœ”' + }`, + ] + : []), + ]; +}; diff --git a/packages/api/utils/guild.ts b/packages/api/utils/guild.ts index cfefd7d..b7edc87 100644 --- a/packages/api/utils/guild.ts +++ b/packages/api/utils/guild.ts @@ -11,13 +11,21 @@ import { Guild, GuildData as GuildDataT, GuildSlug, + Member, OwnRoleInfo, Role, RoleSafety, SessionData, UserGuildPermissions, } from '@roleypoly/types'; -import { AuthType, cacheLayer, discordFetch, isRoot, withSession } from './api-tools'; +import { + AuthType, + cacheLayer, + CacheLayerOptions, + discordFetch, + isRoot, + withSession, +} from './api-tools'; import { botClientID, botToken } from './config'; import { GuildData, Guilds } from './kv'; import { useRateLimiter } from './rate-limiting'; @@ -73,6 +81,8 @@ export const getGuild = cacheLayer( return role.position; }, 0); + const guildData = await getGuildData(id); + const roles = guildRaw.roles.map((role) => ({ id: role.id, name: role.name, @@ -80,7 +90,7 @@ export const getGuild = cacheLayer( managed: role.managed, position: role.position, permissions: role.permissions, - safety: calculateRoleSafety(role, highestRolePosition), + safety: calculateRoleSafety(role, highestRolePosition, guildData), })); // Filters the raw guild data into data we actually want @@ -105,14 +115,20 @@ type GuildMemberIdentity = { type APIMember = { // Only relevant stuff, again. roles: string[]; + pending: boolean; }; -const guildMemberRolesIdentity = ({ serverID, userID }: GuildMemberIdentity) => - `guilds/${serverID}/members/${userID}/roles`; +export const getGuildMemberRoles = async ( + { serverID, userID }: GuildMemberIdentity, + opts?: CacheLayerOptions +) => (await getGuildMember({ serverID, userID }, opts))?.roles; -export const getGuildMemberRoles = cacheLayer( +const guildMemberIdentity = ({ serverID, userID }: GuildMemberIdentity) => + `guilds/${serverID}/members/${userID}`; + +export const getGuildMember = cacheLayer( Guilds, - guildMemberRolesIdentity, + guildMemberIdentity, async ({ serverID, userID }) => { const discordMember = await discordFetch( `/guilds/${serverID}/members/${userID}`, @@ -124,16 +140,16 @@ export const getGuildMemberRoles = cacheLayer return null; } - return discordMember.roles; + return { + roles: discordMember.roles, + pending: discordMember.pending, + }; }, 60 * 5 // 5 minute TTL ); -export const updateGuildMemberRoles = async ( - identity: GuildMemberIdentity, - roles: Role['id'][] -) => { - await Guilds.put(guildMemberRolesIdentity(identity), roles, 60 * 5); +export const updateGuildMember = async (identity: GuildMemberIdentity) => { + await getGuildMember(identity, { skipCachePull: true }); }; export const getGuildData = async (id: string): Promise => { @@ -144,6 +160,11 @@ export const getGuildData = async (id: string): Promise => { categories: [], features: Features.None, auditLogWebhook: null, + accessControl: { + allowList: [], + blockList: [], + blockPending: true, + }, }; if (!guildData) { @@ -156,7 +177,11 @@ export const getGuildData = async (id: string): Promise => { }; }; -const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => { +const calculateRoleSafety = ( + role: Role | APIRole, + highestBotRolePosition: number, + guildData: GuildDataT +) => { let safety = RoleSafety.Safe; if (role.managed) { @@ -175,6 +200,13 @@ const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: numbe safety |= RoleSafety.DangerousPermissions; } + if ( + guildData.accessControl.allowList.includes(role.id) || + guildData.accessControl.blockList.includes(role.id) + ) { + safety |= RoleSafety.AccessControl; + } + return safety; }; diff --git a/packages/api/utils/import-from-legacy.ts b/packages/api/utils/import-from-legacy.ts index eee2f22..1fc1009 100644 --- a/packages/api/utils/import-from-legacy.ts +++ b/packages/api/utils/import-from-legacy.ts @@ -45,6 +45,11 @@ export const transformLegacyGuild = (guild: LegacyGuildData): GuildData => { message: guild.message, features: Features.LegacyGuild, auditLogWebhook: null, + accessControl: { + allowList: [], + blockList: [], + blockPending: true, + }, categories: sortBy(Object.values(guild.categories), 'position').map( (category, idx) => ({ ...category, diff --git a/packages/api/utils/responses.ts b/packages/api/utils/responses.ts index 7838d6d..0ec921d 100644 --- a/packages/api/utils/responses.ts +++ b/packages/api/utils/responses.ts @@ -8,12 +8,15 @@ export const missingParameters = () => export const lowPermissions = () => respond({ error: 'no permissions for this action' }, { status: 403 }); +export const accessControlViolation = () => + respond({ error: 'member fails access control requirements' }, { status: 403 }); + export const notFound = () => respond({ error: 'not found' }, { status: 404 }); export const conflict = () => respond({ error: 'conflict' }, { status: 409 }); export const rateLimited = () => - respond({ error: 'rate limit hit, enhance your calm' }, { status: 419 }); + respond({ error: 'rate limit hit, enhance your calm' }, { status: 429 }); export const invalid = (obj: any = {}) => respond({ err: 'client sent something invalid', data: obj }, { status: 400 }); diff --git a/packages/design-system/atoms/avatar/Avatar.styled.ts b/packages/design-system/atoms/avatar/Avatar.styled.ts index 5b59c3f..e48828f 100644 --- a/packages/design-system/atoms/avatar/Avatar.styled.ts +++ b/packages/design-system/atoms/avatar/Avatar.styled.ts @@ -20,7 +20,7 @@ export const Container = styled.div` text-align: center; line-height: 1; overflow: hidden; - font-size: ${(props: ContainerProps) => props.size}; + font-size: ${(props: ContainerProps) => (props.size || 48) * 0.45}px; ${(props) => props.deliberatelyEmpty && css` diff --git a/packages/design-system/atoms/icon-helper/IconHelper.stories.tsx b/packages/design-system/atoms/icon-helper/IconHelper.stories.tsx new file mode 100644 index 0000000..0db6192 --- /dev/null +++ b/packages/design-system/atoms/icon-helper/IconHelper.stories.tsx @@ -0,0 +1,8 @@ +import { IconHelper } from './IconHelper'; + +export default { + title: 'Atoms/Icon Helper', + component: IconHelper, +}; + +export const iconHelper = (args) => ; diff --git a/packages/design-system/atoms/icon-helper/IconHelper.styled.ts b/packages/design-system/atoms/icon-helper/IconHelper.styled.ts new file mode 100644 index 0000000..bf0e042 --- /dev/null +++ b/packages/design-system/atoms/icon-helper/IconHelper.styled.ts @@ -0,0 +1,21 @@ +import { palette } from '@roleypoly/design-system/atoms/colors'; +import styled from 'styled-components'; + +const levelColors = { + error: palette.red400, + warn: palette.gold400, + info: palette.discord400, + chrome: palette.taupe400, + success: palette.green400, + none: 'unset', +}; + +export type IconHelperLevel = keyof typeof levelColors; + +export const IconHelperStyled = styled.span<{ + level: IconHelperLevel; +}>` + position: relative; + top: 0.12em; + color: ${(props) => levelColors[props.level]}; +`; diff --git a/packages/design-system/atoms/icon-helper/IconHelper.tsx b/packages/design-system/atoms/icon-helper/IconHelper.tsx new file mode 100644 index 0000000..96de4fc --- /dev/null +++ b/packages/design-system/atoms/icon-helper/IconHelper.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { IconHelperLevel, IconHelperStyled } from './IconHelper.styled'; + +export const IconHelper = (props: { + children: React.ReactNode; + level?: IconHelperLevel; +}) => {props.children}; diff --git a/packages/design-system/atoms/icon-helper/index.ts b/packages/design-system/atoms/icon-helper/index.ts new file mode 100644 index 0000000..e7dab12 --- /dev/null +++ b/packages/design-system/atoms/icon-helper/index.ts @@ -0,0 +1 @@ +export * from './IconHelper'; diff --git a/packages/design-system/atoms/role/Role.tsx b/packages/design-system/atoms/role/Role.tsx index 2ac29c8..403afe9 100644 --- a/packages/design-system/atoms/role/Role.tsx +++ b/packages/design-system/atoms/role/Role.tsx @@ -82,6 +82,10 @@ export const Role = (props: Props) => { const disabledReason = (role: RPCRole) => { switch (role.safety) { + case RoleSafety.ManagedRole: + return 'This role is managed by an integration/bot.'; + case RoleSafety.AccessControl: + return 'This role is part of the allow/block list for the server.'; case RoleSafety.HigherThanBot: return `This role is above Roleypoly's own role.`; case RoleSafety.DangerousPermissions: diff --git a/packages/design-system/fixtures/storyData.ts b/packages/design-system/fixtures/storyData.ts index 44bd94c..01b547f 100644 --- a/packages/design-system/fixtures/storyData.ts +++ b/packages/design-system/fixtures/storyData.ts @@ -134,9 +134,9 @@ export const roleWikiData = { }; export const guild: Guild = { - name: 'emoji megaporium', - id: '421896162539470888', - icon: '3372fd895ed913b55616c5e49cd50e60', + name: 'Roleypoly', + id: '386659935687147521', + icon: 'ffee638c73ff9c972554f64ca34d67ee', roles: [], }; @@ -169,12 +169,37 @@ export const guildMap: { [x: string]: GuildSlug } = { }, }; +const blockedRole: Role = { + id: 'blocked', + permissions: '0', + name: 'blocked', + color: 0xff0000, + position: 0, + managed: false, + safety: RoleSafety.Safe, +}; + +const allowedRole: Role = { + id: 'allowed', + permissions: '0', + name: 'allowed', + color: 0x00ff00, + position: 0, + managed: false, + safety: RoleSafety.Safe, +}; + export const guildData: GuildData = { - id: 'aaa', + id: '386659935687147521', message: 'henlo worl!!', categories: [mockCategory, mockCategorySingle], features: Features.None, auditLogWebhook: null, + accessControl: { + blockList: [blockedRole.id], + allowList: [allowedRole.id], + blockPending: true, + }, }; export const user: DiscordUser = { @@ -206,14 +231,14 @@ export const guildEnum: GuildEnumeration = { roles: [...roleCategory, ...roleCategory2], }, { - id: 'bbb', + id: '386659935687147521', guild: guildMap['Roleypoly'], member: { ...member, roles: ['unsafe2'], }, data: guildData, - roles: [...roleCategory, ...roleCategory2], + roles: [...roleCategory, ...roleCategory2, blockedRole, allowedRole], }, { id: 'ccc', @@ -232,6 +257,8 @@ export const guildEnum: GuildEnumeration = { ], }; +export const presentableGuild = guildEnum.guilds[1]; + export const mastheadSlugs: GuildSlug[] = guildEnum.guilds.map( (guild, idx) => ({ id: guild.guild.id, diff --git a/packages/design-system/molecules/editable-role-list/EditableRoleList.stories.tsx b/packages/design-system/molecules/editable-role-list/EditableRoleList.stories.tsx new file mode 100644 index 0000000..cc07279 --- /dev/null +++ b/packages/design-system/molecules/editable-role-list/EditableRoleList.stories.tsx @@ -0,0 +1,16 @@ +import { presentableGuild } from '../../fixtures/storyData'; +import { EditableRoleList } from './EditableRoleList'; + +export default { + title: 'Molecules/Editable Role List', + component: EditableRoleList, + args: { + roles: presentableGuild.roles, + selectedRoles: presentableGuild.data.categories[0].roles, + unselectedRoles: presentableGuild.roles.filter( + (r) => !presentableGuild.data.categories[0].roles.includes(r.id) + ), + }, +}; + +export const editableRoleList = (args) => ; diff --git a/packages/design-system/molecules/editable-role-list/EditableRoleList.styled.ts b/packages/design-system/molecules/editable-role-list/EditableRoleList.styled.ts new file mode 100644 index 0000000..2eb3e1d --- /dev/null +++ b/packages/design-system/molecules/editable-role-list/EditableRoleList.styled.ts @@ -0,0 +1,43 @@ +import { palette } from '@roleypoly/design-system/atoms/colors'; +import { transitions } from '@roleypoly/design-system/atoms/timings'; +import styled, { css } from 'styled-components'; + +export const EditableRoleListStyled = styled.div` + display: flex; + flex-wrap: wrap; + & > div { + margin: 2.5px; + } +`; + +export const AddRoleButton = styled.div<{ long?: boolean }>` + border: 2px solid ${palette.taupe500}; + color: ${palette.taupe500}; + border-radius: 24px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ${transitions.actionable}s ease-in-out; + + &:hover { + background-color: ${palette.taupe100}; + transform: translateY(-2px); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); + } + + &:active { + transform: translateY(0); + box-shadow: none; + } + + ${(props) => + props.long + ? css` + padding: 0 14px; + ` + : css` + width: 32px; + `}; +`; diff --git a/packages/design-system/molecules/editable-role-list/EditableRoleList.tsx b/packages/design-system/molecules/editable-role-list/EditableRoleList.tsx new file mode 100644 index 0000000..3eb17e3 --- /dev/null +++ b/packages/design-system/molecules/editable-role-list/EditableRoleList.tsx @@ -0,0 +1,103 @@ +import { Popover } from '@roleypoly/design-system/atoms/popover'; +import { Role } from '@roleypoly/design-system/atoms/role'; +import { RoleSearch } from '@roleypoly/design-system/molecules/role-search'; +import { Role as RoleT } from '@roleypoly/types'; +import { sortBy, uniq } from 'lodash'; +import React from 'react'; +import { GoPlus } from 'react-icons/go'; +import { AddRoleButton, EditableRoleListStyled } from './EditableRoleList.styled'; + +type Props = { + roles: RoleT[]; + selectedRoles: RoleT['id'][]; + unselectedRoles: RoleT[]; + onChange: (roles: RoleT['id'][]) => void; +}; + +export const EditableRoleList = (props: Props) => { + const [searchOpen, setSearchOpen] = React.useState(false); + + const handleRoleDelete = (role: RoleT) => () => { + const updatedRoles = props.selectedRoles.filter((r) => r !== role.id); + props.onChange(updatedRoles); + }; + + const handleRoleAdd = (role: RoleT) => { + const updatedRoles = uniq([...props.selectedRoles, role.id]); + props.onChange(updatedRoles); + setSearchOpen(false); + }; + + const handleSearchOpen = () => { + setSearchOpen(true); + }; + + return ( + + {props.selectedRoles.length !== 0 ? ( + <> + {sortBy( + props.roles.filter((r) => props.selectedRoles.includes(r.id)), + 'position' + ).map((role) => ( + + ))} + + + ) : ( + + )} + setSearchOpen(false)} + unselectedRoles={props.unselectedRoles} + onSelect={handleRoleAdd} + /> + + ); +}; + +const RoleAddButton = (props: { onClick: () => void; long?: boolean }) => ( + + {props.long && <>Add a role  } + + +); + +const RoleSearchPopover = (props: { + onSelect: (role: RoleT) => void; + onExit: (type: string) => void; + isOpen: boolean; + unselectedRoles: RoleT[]; +}) => { + const [searchTerm, setSearchTerm] = React.useState(''); + + return ( + + {() => ( + + )} + + ); +}; diff --git a/packages/design-system/molecules/editable-role-list/index.ts b/packages/design-system/molecules/editable-role-list/index.ts new file mode 100644 index 0000000..33cc92d --- /dev/null +++ b/packages/design-system/molecules/editable-role-list/index.ts @@ -0,0 +1 @@ +export * from './EditableRoleList'; diff --git a/packages/design-system/molecules/editor-category/EditorCategory.tsx b/packages/design-system/molecules/editor-category/EditorCategory.tsx index 9321f3a..0fd1f89 100644 --- a/packages/design-system/molecules/editor-category/EditorCategory.tsx +++ b/packages/design-system/molecules/editor-category/EditorCategory.tsx @@ -1,16 +1,13 @@ import { Button } from '@roleypoly/design-system/atoms/button'; -import { Popover } from '@roleypoly/design-system/atoms/popover'; -import { Role } from '@roleypoly/design-system/atoms/role'; import { TextInput } from '@roleypoly/design-system/atoms/text-input'; import { Toggle } from '@roleypoly/design-system/atoms/toggle'; import { Text } from '@roleypoly/design-system/atoms/typography'; -import { RoleSearch } from '@roleypoly/design-system/molecules/role-search'; +import { EditableRoleList } from '@roleypoly/design-system/molecules/editable-role-list'; import { Category as CategoryT, CategoryType, Role as RoleT } from '@roleypoly/types'; -import { sortBy, uniq } from 'lodash'; import * as React from 'react'; -import { GoHistory, GoPlus, GoTrashcan } from 'react-icons/go'; +import { GoHistory, GoTrashcan } from 'react-icons/go'; import ReactTooltip from 'react-tooltip'; -import { AddRoleButton, Box, RoleContainer, Section } from './EditorCategory.styled'; +import { Box, Section } from './EditorCategory.styled'; export type CategoryProps = { title: string; @@ -23,25 +20,12 @@ export type CategoryProps = { }; export const EditorCategory = (props: CategoryProps) => { - const [searchOpen, setSearchOpen] = React.useState(false); - const updateValue = (key: T, value: CategoryT[T]) => { props.onChange({ ...props.category, [key]: value }); }; - const handleRoleDelete = (role: RoleT) => () => { - const updatedRoles = props.category.roles.filter((r) => r !== role.id); - updateValue('roles', updatedRoles); - }; - - const handleRoleAdd = (role: RoleT) => { - const updatedRoles = uniq([...props.category.roles, role.id]); - updateValue('roles', updatedRoles); - setSearchOpen(false); - }; - - const handleSearchOpen = () => { - setSearchOpen(true); + const handleRoleListUpdate = (roles: RoleT['id'][]) => () => { + updateValue('roles', roles); }; return ( @@ -92,81 +76,15 @@ export const EditorCategory = (props: CategoryProps) => {
Roles
- - {props.roles.length > 0 ? ( - <> - {sortBy(props.roles, 'position').map((role) => ( - - ))} - - - ) : ( - - )} - setSearchOpen(false)} - unselectedRoles={props.unselectedRoles} - onSelect={handleRoleAdd} - /> - + ); }; - -const RoleAddButton = (props: { - onClick: () => void; - tooltipId: string; - long?: boolean; -}) => ( - - {props.long && <>Add a role  } - - -); - -const RoleSearchPopover = (props: { - onSelect: (role: RoleT) => void; - onExit: (type: string) => void; - isOpen: boolean; - unselectedRoles: RoleT[]; -}) => { - const [searchTerm, setSearchTerm] = React.useState(''); - - return ( - - {() => ( - - )} - - ); -}; diff --git a/packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.stories.tsx b/packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.stories.tsx new file mode 100644 index 0000000..a445b22 --- /dev/null +++ b/packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.stories.tsx @@ -0,0 +1,23 @@ +import { mastheadSlugs } from '@roleypoly/design-system/fixtures/storyData'; +import { GoGear } from 'react-icons/go'; +import { EditorUtilityShell } from './EditorUtilityShell'; + +export default { + title: 'Molecules/Editor Utility Shell', + component: EditorUtilityShell, + args: { + title: 'Utility Title', + guild: mastheadSlugs[0], + icon: , + }, +}; + +export const editorUtilityShell = (args) => ( + +

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, odit inventore? + Recusandae dolor minima quos, laboriosam alias iusto officiis culpa! Autem, odit ut. + Fugit quaerat esse explicabo quibusdam, ipsum maiores? +

+
+); diff --git a/packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.styled.ts b/packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.styled.ts new file mode 100644 index 0000000..9575de6 --- /dev/null +++ b/packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.styled.ts @@ -0,0 +1,60 @@ +import { onSmallScreen } from '@roleypoly/design-system/atoms/breakpoints'; +import { text900 } from '@roleypoly/design-system/atoms/typography'; +import styled, { css } from 'styled-components'; + +export const Shell = styled.div` + display: flex; + flex-direction: column; +`; + +export const HeadBox = styled.div` + ${text900} + + display: flex; + align-items: center; + justify-content: space-around; + + svg { + margin-right: 0.5em; + position: relative; + top: 0.125em; + } + + ${onSmallScreen( + css` + flex-direction: column; + justify-content: center; + align-items: center; + ` + )} +`; + +export const Content = styled.div` + width: 960px; + max-width: 90vw; + margin: 0 auto; + padding: 1.6em 0; +`; + +export const Title = styled.div` + ${onSmallScreen( + css` + order: 2; + flex: 1 1 100%; + ` + )} +`; + +export const GoBack = styled.div` + display: flex; + button { + margin-right: 0.5em; + } + + ${onSmallScreen( + css` + order: 1; + flex: 1 1 100%; + ` + )} +`; diff --git a/packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.tsx b/packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.tsx new file mode 100644 index 0000000..d304315 --- /dev/null +++ b/packages/design-system/molecules/editor-utility-shell/EditorUtilityShell.tsx @@ -0,0 +1,64 @@ +import { Avatar, utils as avatarUtils } from '@roleypoly/design-system/atoms/avatar'; +import { BreakpointText } from '@roleypoly/design-system/atoms/breakpoints'; +import { Button } from '@roleypoly/design-system/atoms/button'; +import { + Content, + GoBack, + HeadBox, + Shell, + Title, +} from '@roleypoly/design-system/molecules/editor-utility-shell/EditorUtilityShell.styled'; +import { GuildSlug } from '@roleypoly/types'; +import { GoCheck, GoReply } from 'react-icons/go'; + +export type EditorUtilityProps = { + guildSlug: GuildSlug; + onSubmit: (output: T) => void; + onExit: () => void; +}; + +export const EditorUtilityShell = ( + props: EditorUtilityProps & { + children: React.ReactNode; + icon: React.ReactNode; + title: string; + hasChanges: boolean; + } +) => ( + + + + {props.icon} + {props.title} + + + {props.hasChanges ? ( + + ) : ( + + )} + + {avatarUtils.initialsFromName(props.guildSlug.name)} + + + + {props.children} + +); diff --git a/packages/design-system/molecules/editor-utility-shell/index.ts b/packages/design-system/molecules/editor-utility-shell/index.ts new file mode 100644 index 0000000..017340c --- /dev/null +++ b/packages/design-system/molecules/editor-utility-shell/index.ts @@ -0,0 +1 @@ +export * from './EditorUtilityShell'; diff --git a/packages/design-system/organisms/editor-access-control/EditorAccessControl.stories.tsx b/packages/design-system/organisms/editor-access-control/EditorAccessControl.stories.tsx new file mode 100644 index 0000000..e5399e7 --- /dev/null +++ b/packages/design-system/organisms/editor-access-control/EditorAccessControl.stories.tsx @@ -0,0 +1,13 @@ +import { presentableGuild, roleypolyGuild } from '../../fixtures/storyData'; +import { EditorAccessControl } from './EditorAccessControl'; + +export default { + title: 'Organisms/Editor/Access Control', + component: EditorAccessControl, + args: { + guild: presentableGuild, + guildSlug: roleypolyGuild, + }, +}; + +export const accessControl = (args) => ; diff --git a/packages/design-system/organisms/editor-access-control/EditorAccessControl.styled.ts b/packages/design-system/organisms/editor-access-control/EditorAccessControl.styled.ts new file mode 100644 index 0000000..4a5a32f --- /dev/null +++ b/packages/design-system/organisms/editor-access-control/EditorAccessControl.styled.ts @@ -0,0 +1,8 @@ +import { palette } from '@roleypoly/design-system/atoms/colors'; +import styled from 'styled-components'; + +export const RoleContainer = styled.div` + border-radius: 4px; + border: 1px solid ${palette.taupe400}; + padding: 10px; +`; diff --git a/packages/design-system/organisms/editor-access-control/EditorAccessControl.tsx b/packages/design-system/organisms/editor-access-control/EditorAccessControl.tsx new file mode 100644 index 0000000..4b3677b --- /dev/null +++ b/packages/design-system/organisms/editor-access-control/EditorAccessControl.tsx @@ -0,0 +1,185 @@ +import { IconHelper } from '@roleypoly/design-system/atoms/icon-helper'; +import { Space } from '@roleypoly/design-system/atoms/space'; +import { Toggle } from '@roleypoly/design-system/atoms/toggle'; +import { + AmbientLarge, + LargeText, + Link, + Text, +} from '@roleypoly/design-system/atoms/typography'; +import { EditableRoleList } from '@roleypoly/design-system/molecules/editable-role-list'; +import { + EditorUtilityProps, + EditorUtilityShell, +} from '@roleypoly/design-system/molecules/editor-utility-shell'; +import { GuildAccessControl, PresentableGuild } from '@roleypoly/types'; +import deepEqual from 'deep-equal'; +import * as React from 'react'; +import { GoAlert, GoInfo, GoShield, GoThumbsdown, GoThumbsup } from 'react-icons/go'; +import { RoleContainer } from './EditorAccessControl.styled'; + +export type EditorAccessControlProps = { + guild: PresentableGuild; +} & EditorUtilityProps; + +export const EditorAccessControl = (props: EditorAccessControlProps) => { + const [accessControl, setAccessControl] = React.useState( + props.guild.data.accessControl + ); + + React.useEffect(() => { + setAccessControl(props.guild.data.accessControl); + }, [props.guild.data.accessControl]); + + const onSubmit = () => { + props.onSubmit(accessControl); + }; + + const handleChange = + (key: keyof GuildAccessControl) => + (value: GuildAccessControl[keyof GuildAccessControl]) => { + setAccessControl((prev) => ({ ...prev, [key]: value })); + }; + + const hasChanges = React.useMemo(() => { + return !deepEqual(accessControl, props.guild.data.accessControl); + }, [accessControl, props.guild.data.accessControl]); + + const rolesNotInBlocked = React.useMemo(() => { + return props.guild.roles.filter( + (role) => role.id !== props.guild.id && !accessControl.blockList.includes(role.id) + ); + }, [accessControl, props.guild.roles]); + + const rolesNotInAllowed = React.useMemo(() => { + return props.guild.roles.filter( + (role) => role.id !== props.guild.id && !accessControl.allowList.includes(role.id) + ); + }, [accessControl, props.guild.roles]); + + return ( + } + hasChanges={hasChanges} + onSubmit={onSubmit} + onExit={props.onExit} + > +

+ + + +  Admins and Role Managers are exempt from all of these limits. Please note, + this settings page is in order of precedence. +

+ +
+ + Block pending members from using Roleypoly   + + + + +
+
+ {/* */} + + If a user is behind Discord's{' '} + + Membership Screening + {' '} + feature, they can not use Roleypoly. + + {/* */} +

+ + + + +   This only applies to Discord servers with Community features enabled. + +

+
+ +
+

+ + Block roles from using Roleypoly   + + + + +
+ + If there are roles in this list, any server member with a role in the + list can not use Roleypoly. +
+ + + +  Blocked roles take precedence over the allowed roles. +
+

+ + + +

+ + + + +   If your Discord server has a "muted" or "visitor" role, this setting is + meant to complement it. + +

+
+ +
+

+ + Allow these roles to use Roleypoly   + + + + +
+ + If there are roles in this list, any server member without a role in + the list can not use Roleypoly. +
+ + + +  This can disrupt use of the bot, so be careful! +
+

+ + + +

+ + + + +  If your Discord server uses a "role gating" system, this setting is + meant to complement it. + +

+
+
+ ); +}; diff --git a/packages/design-system/organisms/editor-access-control/index.ts b/packages/design-system/organisms/editor-access-control/index.ts new file mode 100644 index 0000000..240243a --- /dev/null +++ b/packages/design-system/organisms/editor-access-control/index.ts @@ -0,0 +1 @@ +export * from './EditorAccessControl'; diff --git a/packages/design-system/templates/editor-access-control/EditorAccessControl.stories.tsx b/packages/design-system/templates/editor-access-control/EditorAccessControl.stories.tsx new file mode 100644 index 0000000..76c79ec --- /dev/null +++ b/packages/design-system/templates/editor-access-control/EditorAccessControl.stories.tsx @@ -0,0 +1,26 @@ +import ReactTooltip from 'react-tooltip'; +import { BreakpointsProvider } from '../../atoms/breakpoints'; +import { guildEnum, mastheadSlugs, roleypolyGuild, user } from '../../fixtures/storyData'; +import { EditorAccessControlTemplate } from './EditorAccessControl'; + +export default { + title: 'Templates/Editor Access Control', + component: EditorAccessControlTemplate, + decorators: [ + (story) => ( + + {story()} + + + ), + ], + args: { + errors: { validationStatus: 0 }, + guilds: mastheadSlugs, + user: user, + guild: guildEnum.guilds[1], + guildSlug: roleypolyGuild, + }, +}; + +export const editorAccessControl = (args) => ; diff --git a/packages/design-system/templates/editor-access-control/EditorAccessControl.styled.ts b/packages/design-system/templates/editor-access-control/EditorAccessControl.styled.ts new file mode 100644 index 0000000..81edb5d --- /dev/null +++ b/packages/design-system/templates/editor-access-control/EditorAccessControl.styled.ts @@ -0,0 +1,2 @@ +import styled from 'styled-components'; +export const EditorAccessControlStyled = styled.div``; diff --git a/packages/design-system/templates/editor-access-control/EditorAccessControl.tsx b/packages/design-system/templates/editor-access-control/EditorAccessControl.tsx new file mode 100644 index 0000000..4e733df --- /dev/null +++ b/packages/design-system/templates/editor-access-control/EditorAccessControl.tsx @@ -0,0 +1,21 @@ +import { AppShell, AppShellProps } from '@roleypoly/design-system/organisms/app-shell'; +import { + EditorAccessControl, + EditorAccessControlProps, +} from '@roleypoly/design-system/organisms/editor-access-control'; + +export const EditorAccessControlTemplate = ( + props: EditorAccessControlProps & Omit +) => { + const { guildSlug, guild, onSubmit, onExit, ...appShellProps } = props; + return ( + + + + ); +}; diff --git a/packages/design-system/templates/editor-access-control/index.ts b/packages/design-system/templates/editor-access-control/index.ts new file mode 100644 index 0000000..240243a --- /dev/null +++ b/packages/design-system/templates/editor-access-control/index.ts @@ -0,0 +1 @@ +export * from './EditorAccessControl'; diff --git a/packages/design-system/templates/editor-utility/EditorUtility.stories.tsx b/packages/design-system/templates/editor-utility/EditorUtility.stories.tsx deleted file mode 100644 index 7c9c6cd..0000000 --- a/packages/design-system/templates/editor-utility/EditorUtility.stories.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { EditorUtility } from './EditorUtility'; - -export default { - title: 'Templates/Editor Utility', - component: EditorUtility, -}; - -export const editorUtility = (args) => ; diff --git a/packages/design-system/templates/editor-utility/EditorUtility.styled.ts b/packages/design-system/templates/editor-utility/EditorUtility.styled.ts deleted file mode 100644 index 966a1a7..0000000 --- a/packages/design-system/templates/editor-utility/EditorUtility.styled.ts +++ /dev/null @@ -1,2 +0,0 @@ -import styled from 'styled-components'; -export const EditorUtilityStyled = styled.div``; diff --git a/packages/design-system/templates/editor-utility/EditorUtility.tsx b/packages/design-system/templates/editor-utility/EditorUtility.tsx deleted file mode 100644 index 541fb35..0000000 --- a/packages/design-system/templates/editor-utility/EditorUtility.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { EditorUtilityStyled } from './EditorUtility.styled'; - -export const EditorUtility = () => ( - EditorUtility -); diff --git a/packages/design-system/templates/editor-utility/index.ts b/packages/design-system/templates/editor-utility/index.ts deleted file mode 100644 index 18d4150..0000000 --- a/packages/design-system/templates/editor-utility/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './EditorUtility'; diff --git a/packages/design-system/templates/errors/errorStrings.ts b/packages/design-system/templates/errors/errorStrings.ts index 38a28da..c9542e1 100644 --- a/packages/design-system/templates/errors/errorStrings.ts +++ b/packages/design-system/templates/errors/errorStrings.ts @@ -42,6 +42,11 @@ export const errorMessages: { [code: string]: ErrorMessage } = { japanese: `...but it didn't believe me. :( ใ”ใ‚ใ‚“ใชใ•ใ„`, friendlyCode: 'Yo.', }, + accessControlViolation: { + english: `You're not allowed to pick roles on that server.`, + japanese: `ใ“ใฎใ‚ตใƒผใƒใƒผใงใฏ่จฑๅฏใ•ใ‚Œใฆใ„ใชใ„ๆจฉ้™ใงใ™`, + friendlyCode: 'Hold up!', + }, }; export const getMessageFromCode = ( diff --git a/packages/types/Guild.ts b/packages/types/Guild.ts index 0c7f6af..ee9a19f 100644 --- a/packages/types/Guild.ts +++ b/packages/types/Guild.ts @@ -21,6 +21,13 @@ export type GuildData = { categories: Category[]; features: Features; auditLogWebhook: string | null; + accessControl: GuildAccessControl; +}; + +export type GuildAccessControl = { + allowList: Role['id'][]; + blockList: Role['id'][]; + blockPending: boolean; }; export type GuildDataUpdate = Omit, 'id'>; diff --git a/packages/types/Role.ts b/packages/types/Role.ts index 3f5ca7a..f956034 100644 --- a/packages/types/Role.ts +++ b/packages/types/Role.ts @@ -3,6 +3,7 @@ export enum RoleSafety { HigherThanBot = 1 << 1, DangerousPermissions = 1 << 2, ManagedRole = 1 << 3, + AccessControl = 1 << 4, } export type Role = { diff --git a/packages/types/User.ts b/packages/types/User.ts index e6c25ce..0836c9a 100644 --- a/packages/types/User.ts +++ b/packages/types/User.ts @@ -11,6 +11,7 @@ export type Member = { roles: string[]; nick?: string; user?: DiscordUser; + pending?: boolean; }; export type RoleypolyUser = { diff --git a/packages/web/src/app-router/AppRouter.tsx b/packages/web/src/app-router/AppRouter.tsx index 0b13f64..7419793 100644 --- a/packages/web/src/app-router/AppRouter.tsx +++ b/packages/web/src/app-router/AppRouter.tsx @@ -9,6 +9,7 @@ import PickerPage from '../pages/picker'; const WhyNoRoles = React.lazy(() => import('../pages/help/why-no-roles')); const ServersPage = React.lazy(() => import('../pages/servers')); const EditorPage = React.lazy(() => import('../pages/editor')); +const AccessControlPage = React.lazy(() => import('../pages/editor/access-control')); const MachineryNewSession = React.lazy(() => import('../pages/machinery/new-session')); const MachineryLogout = React.lazy(() => import('../pages/machinery/logout')); @@ -35,6 +36,10 @@ export const AppRouter = () => { + diff --git a/packages/web/src/contexts/guild/GuildContext.tsx b/packages/web/src/contexts/guild/GuildContext.tsx new file mode 100644 index 0000000..1e8cd60 --- /dev/null +++ b/packages/web/src/contexts/guild/GuildContext.tsx @@ -0,0 +1,116 @@ +import { GuildSlug, PresentableGuild } from '@roleypoly/types'; +import React from 'react'; +import { useApiContext } from '../api/ApiContext'; +import { useSessionContext } from '../session/SessionContext'; + +const CACHE_HOLD_TIME = 2 * 60 * 1000; // 2 minutes + +type StoredGuild = { + user: string; + guild: T; + expiresAt: number; +}; + +type GuildContextT = { + getFullGuild: ( + id: string, + uncached?: boolean + ) => Promise; + getGuildSlug: (id: string) => Promise; + uncacheGuild: (id: string) => void; +}; + +export const GuildContext = React.createContext({ + getFullGuild: (id: string) => Promise.reject(new Error('Not implemented')), + getGuildSlug: (id: string) => Promise.reject(new Error('Not implemented')), + uncacheGuild: (id: string) => {}, +}); + +export const useGuildContext = () => React.useContext(GuildContext); + +export const GuildProvider = (props: { children: React.ReactNode }) => { + const { session, authedFetch } = useSessionContext(); + const { fetch } = useApiContext(); + + const guildContextValue: GuildContextT = { + getGuildSlug: async (id: string) => { + const cachedSlug = sessionStorage.getItem(`guild-slug-${id}`); + if (cachedSlug) { + const storedSlug = JSON.parse(cachedSlug) as StoredGuild; + if (storedSlug.user === session.user?.id && storedSlug.expiresAt > Date.now()) { + return storedSlug.guild; + } + } + + // Slug could also be cached via a PresentableGuild + const cachedGuild = sessionStorage.getItem(`guild-${id}`); + if (cachedGuild) { + const storedGuild = JSON.parse(cachedGuild) as StoredGuild; + if (storedGuild.user === session.user?.id && storedGuild.expiresAt > Date.now()) { + sessionStorage.setItem(`guild-slug-${id}`, JSON.stringify(storedGuild.guild)); + return storedGuild.guild.guild; + } + } + + const response = await fetch(`/get-slug/${id}`); + if (response.status !== 200) { + return null; + } + const slug = await response.json(); + + const storedSlug: StoredGuild = { + user: session.user?.id || 'none', + guild: slug, + expiresAt: Date.now() + CACHE_HOLD_TIME, + }; + + sessionStorage.setItem(`guild-slug-${id}`, JSON.stringify(storedSlug)); + + return slug; + }, + getFullGuild: async (id: string, uncached: boolean = false) => { + if (!uncached) { + const cachedGuild = sessionStorage.getItem(`guild-${id}`); + if (cachedGuild) { + const storedGuild = JSON.parse(cachedGuild); + if ( + storedGuild.user === session.user?.id && + storedGuild.expiresAt > Date.now() + ) { + return storedGuild.guild; + } + } + } + + const skipCache = uncached ? '?__no_cache' : ''; + const response = await authedFetch(`/get-picker-data/${id}${skipCache}`); + const guild: PresentableGuild = await response.json(); + + if (response.status !== 200) { + if (response.status === 403) { + return false; + } + + return null; + } + + const storedGuild: StoredGuild = { + user: session.user?.id || 'none', + guild, + expiresAt: Date.now() + CACHE_HOLD_TIME, + }; + + sessionStorage.setItem(`guild-${id}`, JSON.stringify(storedGuild)); + return guild; + }, + uncacheGuild: (id: string) => { + sessionStorage.removeItem(`guild-${id}`); + }, + }; + + return ( + + {props.children} + + ); +}; diff --git a/packages/web/src/index.tsx b/packages/web/src/index.tsx index 8fa717b..7d0db54 100644 --- a/packages/web/src/index.tsx +++ b/packages/web/src/index.tsx @@ -4,6 +4,7 @@ import ReactDOM from 'react-dom'; import { AppRouter } from './app-router/AppRouter'; import { ApiContextProvider } from './contexts/api/ApiContext'; import { AppShellPropsProvider } from './contexts/app-shell/AppShellContext'; +import { GuildProvider } from './contexts/guild/GuildContext'; import { RecentGuildsProvider } from './contexts/recent-guilds/RecentGuildsContext'; import { SessionContextProvider } from './contexts/session/SessionContext'; @@ -26,6 +27,7 @@ ReactDOM.render( RecentGuildsProvider, AppShellPropsProvider, BreakpointsProvider, + GuildProvider, ]} > diff --git a/packages/web/src/pages/auth/login.tsx b/packages/web/src/pages/auth/login.tsx index 27fa0e0..a7925ce 100644 --- a/packages/web/src/pages/auth/login.tsx +++ b/packages/web/src/pages/auth/login.tsx @@ -4,12 +4,14 @@ import { GenericLoadingTemplate } from '@roleypoly/design-system/templates/gener import { GuildSlug } from '@roleypoly/types'; import React from 'react'; import { useApiContext } from '../../contexts/api/ApiContext'; +import { useGuildContext } from '../../contexts/guild/GuildContext'; import { useSessionContext } from '../../contexts/session/SessionContext'; import { Title } from '../../utils/metaTitle'; const Login = (props: { path: string }) => { - const { apiUrl, fetch } = useApiContext(); + const { apiUrl } = useApiContext(); const { isAuthenticated } = useSessionContext(); + const { getGuildSlug } = useGuildContext(); // If ?r is in query, then let's render the slug page // If not, redirect. const [guildSlug, setGuildSlug] = React.useState(null); @@ -32,9 +34,8 @@ const Login = (props: { path: string }) => { localStorage.setItem('rp_postauth_redirect', `/s/${redirectServerID}`); const fetchGuildSlug = async (id: string) => { - const response = await fetch(`/get-slug/${id}`); - if (response.status === 200) { - const slug = await response.json(); + const slug = await getGuildSlug(id); + if (slug) { setGuildSlug(slug); } }; @@ -44,7 +45,7 @@ const Login = (props: { path: string }) => { if (isAuthenticated) { redirectTo(`/s/${redirectServerID}`); } - }, [apiUrl, fetch, isAuthenticated]); + }, [apiUrl, getGuildSlug, isAuthenticated]); if (guildSlug === null) { return Sending you to Discord...; diff --git a/packages/web/src/pages/editor.tsx b/packages/web/src/pages/editor.tsx index 5dfa9d6..0088f98 100644 --- a/packages/web/src/pages/editor.tsx +++ b/packages/web/src/pages/editor.tsx @@ -8,6 +8,7 @@ import { } from '@roleypoly/types'; import * as React from 'react'; import { useAppShellProps } from '../contexts/app-shell/AppShellContext'; +import { useGuildContext } from '../contexts/guild/GuildContext'; import { useRecentGuilds } from '../contexts/recent-guilds/RecentGuildsContext'; import { useSessionContext } from '../contexts/session/SessionContext'; import { Title } from '../utils/metaTitle'; @@ -22,6 +23,7 @@ const Editor = (props: EditorProps) => { const { session, authedFetch, isAuthenticated } = useSessionContext(); const { pushRecentGuild } = useRecentGuilds(); const appShellProps = useAppShellProps(); + const { getFullGuild } = useGuildContext(); const [guild, setGuild] = React.useState(null); const [pending, setPending] = React.useState(false); @@ -38,20 +40,18 @@ const Editor = (props: EditorProps) => { return false; }; const fetchGuild = async () => { - const skipCache = shouldPullUncached() ? '?__no_cache' : ''; - const response = await authedFetch(`/get-picker-data/${serverID}${skipCache}`); - const data = await response.json(); + const guild = await getFullGuild(serverID, shouldPullUncached()); - if (response.status !== 200) { + if (guild === null) { setGuild(false); return; } - setGuild(data); + setGuild(guild); }; fetchGuild(); - }, [serverID, authedFetch]); + }, [serverID, getFullGuild]); React.useCallback((serverID) => pushRecentGuild(serverID), [pushRecentGuild])(serverID); @@ -84,10 +84,11 @@ const Editor = (props: EditorProps) => { setPending(true); - const updatePayload: GuildDataUpdate = { + const updatePayload: Partial = { message: guild.data.message, categories: guild.data.categories, - auditLogWebhook: guild.data.auditLogWebhook, + auditLogWebhook: + 'https://discord.com/api/webhooks/864658054930759696/vE91liQYwmW4nS6fiT0cMfhe_dpPLBkDXOPynDNLdXZT1KdkDKm8wa4h4E4RPw0GDcJR', }; const response = await authedFetch(`/update-guild/${serverID}`, { diff --git a/packages/web/src/pages/editor/access-control.tsx b/packages/web/src/pages/editor/access-control.tsx new file mode 100644 index 0000000..6d81694 --- /dev/null +++ b/packages/web/src/pages/editor/access-control.tsx @@ -0,0 +1,91 @@ +import { navigate, Redirect } from '@reach/router'; +import { EditorAccessControlTemplate } from '@roleypoly/design-system/templates/editor-access-control'; +import { GenericLoadingTemplate } from '@roleypoly/design-system/templates/generic-loading'; +import { + GuildAccessControl, + GuildDataUpdate, + PresentableGuild, + UserGuildPermissions, +} from '@roleypoly/types'; +import React from 'react'; +import { useAppShellProps } from '../../contexts/app-shell/AppShellContext'; +import { useGuildContext } from '../../contexts/guild/GuildContext'; +import { useRecentGuilds } from '../../contexts/recent-guilds/RecentGuildsContext'; +import { useSessionContext } from '../../contexts/session/SessionContext'; + +const AccessControlPage = (props: { serverID: string; path: string }) => { + const { session, isAuthenticated, authedFetch } = useSessionContext(); + const { pushRecentGuild } = useRecentGuilds(); + const { getFullGuild, uncacheGuild } = useGuildContext(); + const appShellProps = useAppShellProps(); + + const [guild, setGuild] = React.useState(null); + + React.useEffect(() => { + const fetchGuild = async () => { + const guild = await getFullGuild(props.serverID); + + if (guild === null) { + setGuild(false); + return; + } + + setGuild(guild); + }; + + fetchGuild(); + }, [props.serverID, getFullGuild]); + + React.useCallback( + (serverID) => pushRecentGuild(serverID), + [pushRecentGuild] + )(props.serverID); + + // If the user is not authenticated, redirect to the login page. + if (!isAuthenticated) { + return ; + } + + // If the user is not an admin, they can't edit the guild + // so we redirect them to the picker + const guildSlug = session?.guilds?.find((guild) => guild.id === props.serverID); + if (guildSlug && guildSlug?.permissionLevel === UserGuildPermissions.User) { + return ; + } + + // If the guild isn't loaded, render a loading placeholder + if (guild === null) { + return ; + } + + // If the guild is not found, redirect to the picker page + if (guild === false) { + return ; + } + + const onSubmit = async (accessControl: GuildAccessControl) => { + const updatePayload: Partial = { + accessControl, + }; + + await authedFetch(`/update-guild/${props.serverID}`, { + method: 'PATCH', + body: JSON.stringify(updatePayload), + }); + + uncacheGuild(props.serverID); + navigate(`/s/${props.serverID}/edit`); + }; + + return ( + onSubmit(data)} + onExit={() => navigate(`/s/${props.serverID}/edit`)} + {...appShellProps} + /> + ); +}; + +export default AccessControlPage; diff --git a/packages/web/src/pages/picker.tsx b/packages/web/src/pages/picker.tsx index c9c577e..9e3c107 100644 --- a/packages/web/src/pages/picker.tsx +++ b/packages/web/src/pages/picker.tsx @@ -1,10 +1,11 @@ -import { Redirect } from '@reach/router'; +import { Redirect, redirectTo } from '@reach/router'; import { GenericLoadingTemplate } from '@roleypoly/design-system/templates/generic-loading'; import { RolePickerTemplate } from '@roleypoly/design-system/templates/role-picker'; import { ServerSetupTemplate } from '@roleypoly/design-system/templates/server-setup'; import { PresentableGuild, RoleUpdate, UserGuildPermissions } from '@roleypoly/types'; import * as React from 'react'; import { useAppShellProps } from '../contexts/app-shell/AppShellContext'; +import { useGuildContext } from '../contexts/guild/GuildContext'; import { useRecentGuilds } from '../contexts/recent-guilds/RecentGuildsContext'; import { useSessionContext } from '../contexts/session/SessionContext'; import { Title } from '../utils/metaTitle'; @@ -19,6 +20,7 @@ const Picker = (props: PickerProps) => { const { session, authedFetch, isAuthenticated } = useSessionContext(); const { pushRecentGuild } = useRecentGuilds(); const appShellProps = useAppShellProps(); + const { getFullGuild } = useGuildContext(); const [pickerData, setPickerData] = React.useState( null @@ -27,10 +29,14 @@ const Picker = (props: PickerProps) => { React.useEffect(() => { const fetchPickerData = async () => { - const response = await authedFetch(`/get-picker-data/${props.serverID}`); - const data = await response.json(); + const data = await getFullGuild(props.serverID); - if (response.status !== 200) { + if (data === false) { + redirectTo('/error/accessControlViolation'); + return; + } + + if (data === null) { setPickerData(false); return; } @@ -39,7 +45,7 @@ const Picker = (props: PickerProps) => { }; fetchPickerData(); - }, [props.serverID, authedFetch, pushRecentGuild]); + }, [props.serverID, getFullGuild]); React.useCallback( (serverID) => pushRecentGuild(serverID),