mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-24 19:39:11 +00:00
feat: add access control
This commit is contained in:
parent
9c07ff0e54
commit
3f45153b66
47 changed files with 1084 additions and 164 deletions
|
@ -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 { DiscordUser, GuildSlug, PresentableGuild, SessionData } from '@roleypoly/types';
|
||||||
import { respond, withSession } from '../utils/api-tools';
|
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 });
|
const fail = () => respond({ error: 'guild not found' }, { status: 404 });
|
||||||
|
|
||||||
|
@ -30,24 +32,28 @@ export const GetPickerData = withSession(
|
||||||
return fail();
|
return fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberRolesP = getGuildMemberRoles({
|
const memberP = getGuildMember({
|
||||||
serverID: guildID,
|
serverID: guildID,
|
||||||
userID,
|
userID,
|
||||||
});
|
});
|
||||||
|
|
||||||
const guildDataP = getGuildData(guildID);
|
const guildDataP = getGuildData(guildID);
|
||||||
|
|
||||||
const [guildData, memberRoles] = await Promise.all([guildDataP, memberRolesP]);
|
const [guildData, member] = await Promise.all([guildDataP, memberP]);
|
||||||
if (!memberRoles) {
|
if (!member) {
|
||||||
return fail();
|
return fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!memberPassesAccessControl(checkGuild, member, guildData.accessControl)) {
|
||||||
|
return accessControlViolation();
|
||||||
|
}
|
||||||
|
|
||||||
const presentableGuild: PresentableGuild = {
|
const presentableGuild: PresentableGuild = {
|
||||||
id: guildID,
|
id: guildID,
|
||||||
guild: checkGuild,
|
guild: checkGuild,
|
||||||
roles: guild.roles,
|
roles: guild.roles,
|
||||||
member: {
|
member: {
|
||||||
roles: memberRoles,
|
roles: member.roles,
|
||||||
},
|
},
|
||||||
data: guildData,
|
data: guildData,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control';
|
||||||
|
import { accessControlViolation } from '@roleypoly/api/utils/responses';
|
||||||
import {
|
import {
|
||||||
GuildData,
|
GuildData,
|
||||||
Member,
|
Member,
|
||||||
|
@ -14,8 +16,8 @@ import { botToken } from '../utils/config';
|
||||||
import {
|
import {
|
||||||
getGuild,
|
getGuild,
|
||||||
getGuildData,
|
getGuildData,
|
||||||
getGuildMemberRoles,
|
getGuildMember,
|
||||||
updateGuildMemberRoles,
|
updateGuildMember,
|
||||||
} from '../utils/guild';
|
} from '../utils/guild';
|
||||||
|
|
||||||
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
|
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
|
||||||
|
@ -45,18 +47,24 @@ export const UpdateRoles = withSession(
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const guildMemberRoles = await getGuildMemberRoles(
|
const guildMember = await getGuildMember(
|
||||||
{ serverID: guildID, userID },
|
{ serverID: guildID, userID },
|
||||||
{ skipCachePull: true }
|
{ skipCachePull: true }
|
||||||
);
|
);
|
||||||
if (!guildMemberRoles) {
|
if (!guildMember) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const guildData = await getGuildData(guildID);
|
||||||
|
|
||||||
|
if (!memberPassesAccessControl(guildCheck, guildMember, guildData.accessControl)) {
|
||||||
|
return accessControlViolation();
|
||||||
|
}
|
||||||
|
|
||||||
const newRoles = calculateNewRoles({
|
const newRoles = calculateNewRoles({
|
||||||
currentRoles: guildMemberRoles,
|
currentRoles: guildMember.roles,
|
||||||
guildRoles: guild.roles,
|
guildRoles: guild.roles,
|
||||||
guildData: await getGuildData(guildID),
|
guildData,
|
||||||
updateRequest,
|
updateRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -84,7 +92,8 @@ export const UpdateRoles = withSession(
|
||||||
roles: patchMemberRoles.roles,
|
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);
|
return respond(updatedMember);
|
||||||
}
|
}
|
||||||
|
|
50
packages/api/utils/access-control.ts
Normal file
50
packages/api/utils/access-control.ts
Normal file
|
@ -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;
|
||||||
|
};
|
|
@ -108,6 +108,10 @@ export const discordFetch = async <T>(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CacheLayerOptions = {
|
||||||
|
skipCachePull?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const cacheLayer =
|
export const cacheLayer =
|
||||||
<Identity, Data>(
|
<Identity, Data>(
|
||||||
kv: WrappedKVNamespace,
|
kv: WrappedKVNamespace,
|
||||||
|
@ -115,10 +119,7 @@ export const cacheLayer =
|
||||||
missHandler: (identity: Identity) => Promise<Data | null>,
|
missHandler: (identity: Identity) => Promise<Data | null>,
|
||||||
ttlSeconds?: number
|
ttlSeconds?: number
|
||||||
) =>
|
) =>
|
||||||
async (
|
async (identity: Identity, options: CacheLayerOptions = {}): Promise<Data | null> => {
|
||||||
identity: Identity,
|
|
||||||
options: { skipCachePull?: boolean } = {}
|
|
||||||
): Promise<Data | null> => {
|
|
||||||
const key = keyFactory(identity);
|
const key = keyFactory(identity);
|
||||||
|
|
||||||
if (!options.skipCachePull) {
|
if (!options.skipCachePull) {
|
||||||
|
|
|
@ -97,6 +97,23 @@ const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
|
||||||
title: `Categories were changed...`,
|
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 (
|
export const sendAuditLog = async (
|
||||||
|
@ -224,3 +241,26 @@ const getChangedCategories = (oldCategories: Category[], newCategories: Category
|
||||||
...changedCategories.map((c) => `🔧 **Changed** ${c.name}`),
|
...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 ✔'
|
||||||
|
}`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
|
@ -11,13 +11,21 @@ import {
|
||||||
Guild,
|
Guild,
|
||||||
GuildData as GuildDataT,
|
GuildData as GuildDataT,
|
||||||
GuildSlug,
|
GuildSlug,
|
||||||
|
Member,
|
||||||
OwnRoleInfo,
|
OwnRoleInfo,
|
||||||
Role,
|
Role,
|
||||||
RoleSafety,
|
RoleSafety,
|
||||||
SessionData,
|
SessionData,
|
||||||
UserGuildPermissions,
|
UserGuildPermissions,
|
||||||
} from '@roleypoly/types';
|
} 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 { botClientID, botToken } from './config';
|
||||||
import { GuildData, Guilds } from './kv';
|
import { GuildData, Guilds } from './kv';
|
||||||
import { useRateLimiter } from './rate-limiting';
|
import { useRateLimiter } from './rate-limiting';
|
||||||
|
@ -73,6 +81,8 @@ export const getGuild = cacheLayer(
|
||||||
return role.position;
|
return role.position;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
const guildData = await getGuildData(id);
|
||||||
|
|
||||||
const roles = guildRaw.roles.map<Role>((role) => ({
|
const roles = guildRaw.roles.map<Role>((role) => ({
|
||||||
id: role.id,
|
id: role.id,
|
||||||
name: role.name,
|
name: role.name,
|
||||||
|
@ -80,7 +90,7 @@ export const getGuild = cacheLayer(
|
||||||
managed: role.managed,
|
managed: role.managed,
|
||||||
position: role.position,
|
position: role.position,
|
||||||
permissions: role.permissions,
|
permissions: role.permissions,
|
||||||
safety: calculateRoleSafety(role, highestRolePosition),
|
safety: calculateRoleSafety(role, highestRolePosition, guildData),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Filters the raw guild data into data we actually want
|
// Filters the raw guild data into data we actually want
|
||||||
|
@ -105,14 +115,20 @@ type GuildMemberIdentity = {
|
||||||
type APIMember = {
|
type APIMember = {
|
||||||
// Only relevant stuff, again.
|
// Only relevant stuff, again.
|
||||||
roles: string[];
|
roles: string[];
|
||||||
|
pending: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const guildMemberRolesIdentity = ({ serverID, userID }: GuildMemberIdentity) =>
|
export const getGuildMemberRoles = async (
|
||||||
`guilds/${serverID}/members/${userID}/roles`;
|
{ serverID, userID }: GuildMemberIdentity,
|
||||||
|
opts?: CacheLayerOptions
|
||||||
|
) => (await getGuildMember({ serverID, userID }, opts))?.roles;
|
||||||
|
|
||||||
export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>(
|
const guildMemberIdentity = ({ serverID, userID }: GuildMemberIdentity) =>
|
||||||
|
`guilds/${serverID}/members/${userID}`;
|
||||||
|
|
||||||
|
export const getGuildMember = cacheLayer<GuildMemberIdentity, Member>(
|
||||||
Guilds,
|
Guilds,
|
||||||
guildMemberRolesIdentity,
|
guildMemberIdentity,
|
||||||
async ({ serverID, userID }) => {
|
async ({ serverID, userID }) => {
|
||||||
const discordMember = await discordFetch<APIMember>(
|
const discordMember = await discordFetch<APIMember>(
|
||||||
`/guilds/${serverID}/members/${userID}`,
|
`/guilds/${serverID}/members/${userID}`,
|
||||||
|
@ -124,16 +140,16 @@ export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return discordMember.roles;
|
return {
|
||||||
|
roles: discordMember.roles,
|
||||||
|
pending: discordMember.pending,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
60 * 5 // 5 minute TTL
|
60 * 5 // 5 minute TTL
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updateGuildMemberRoles = async (
|
export const updateGuildMember = async (identity: GuildMemberIdentity) => {
|
||||||
identity: GuildMemberIdentity,
|
await getGuildMember(identity, { skipCachePull: true });
|
||||||
roles: Role['id'][]
|
|
||||||
) => {
|
|
||||||
await Guilds.put(guildMemberRolesIdentity(identity), roles, 60 * 5);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
||||||
|
@ -144,6 +160,11 @@ export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
||||||
categories: [],
|
categories: [],
|
||||||
features: Features.None,
|
features: Features.None,
|
||||||
auditLogWebhook: null,
|
auditLogWebhook: null,
|
||||||
|
accessControl: {
|
||||||
|
allowList: [],
|
||||||
|
blockList: [],
|
||||||
|
blockPending: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!guildData) {
|
if (!guildData) {
|
||||||
|
@ -156,7 +177,11 @@ export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
|
const calculateRoleSafety = (
|
||||||
|
role: Role | APIRole,
|
||||||
|
highestBotRolePosition: number,
|
||||||
|
guildData: GuildDataT
|
||||||
|
) => {
|
||||||
let safety = RoleSafety.Safe;
|
let safety = RoleSafety.Safe;
|
||||||
|
|
||||||
if (role.managed) {
|
if (role.managed) {
|
||||||
|
@ -175,6 +200,13 @@ const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: numbe
|
||||||
safety |= RoleSafety.DangerousPermissions;
|
safety |= RoleSafety.DangerousPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
guildData.accessControl.allowList.includes(role.id) ||
|
||||||
|
guildData.accessControl.blockList.includes(role.id)
|
||||||
|
) {
|
||||||
|
safety |= RoleSafety.AccessControl;
|
||||||
|
}
|
||||||
|
|
||||||
return safety;
|
return safety;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -45,6 +45,11 @@ export const transformLegacyGuild = (guild: LegacyGuildData): GuildData => {
|
||||||
message: guild.message,
|
message: guild.message,
|
||||||
features: Features.LegacyGuild,
|
features: Features.LegacyGuild,
|
||||||
auditLogWebhook: null,
|
auditLogWebhook: null,
|
||||||
|
accessControl: {
|
||||||
|
allowList: [],
|
||||||
|
blockList: [],
|
||||||
|
blockPending: true,
|
||||||
|
},
|
||||||
categories: sortBy(Object.values(guild.categories), 'position').map(
|
categories: sortBy(Object.values(guild.categories), 'position').map(
|
||||||
(category, idx) => ({
|
(category, idx) => ({
|
||||||
...category,
|
...category,
|
||||||
|
|
|
@ -8,12 +8,15 @@ export const missingParameters = () =>
|
||||||
export const lowPermissions = () =>
|
export const lowPermissions = () =>
|
||||||
respond({ error: 'no permissions for this action' }, { status: 403 });
|
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 notFound = () => respond({ error: 'not found' }, { status: 404 });
|
||||||
|
|
||||||
export const conflict = () => respond({ error: 'conflict' }, { status: 409 });
|
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: 429 });
|
||||||
|
|
||||||
export const invalid = (obj: any = {}) =>
|
export const invalid = (obj: any = {}) =>
|
||||||
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });
|
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });
|
||||||
|
|
|
@ -20,7 +20,7 @@ export const Container = styled.div<ContainerProps>`
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: ${(props: ContainerProps) => props.size};
|
font-size: ${(props: ContainerProps) => (props.size || 48) * 0.45}px;
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.deliberatelyEmpty &&
|
props.deliberatelyEmpty &&
|
||||||
css`
|
css`
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { IconHelper } from './IconHelper';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Atoms/Icon Helper',
|
||||||
|
component: IconHelper,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const iconHelper = (args) => <IconHelper {...args} />;
|
|
@ -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]};
|
||||||
|
`;
|
7
packages/design-system/atoms/icon-helper/IconHelper.tsx
Normal file
7
packages/design-system/atoms/icon-helper/IconHelper.tsx
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { IconHelperLevel, IconHelperStyled } from './IconHelper.styled';
|
||||||
|
|
||||||
|
export const IconHelper = (props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
level?: IconHelperLevel;
|
||||||
|
}) => <IconHelperStyled level={props.level || 'none'}>{props.children}</IconHelperStyled>;
|
1
packages/design-system/atoms/icon-helper/index.ts
Normal file
1
packages/design-system/atoms/icon-helper/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './IconHelper';
|
|
@ -82,6 +82,10 @@ export const Role = (props: Props) => {
|
||||||
|
|
||||||
const disabledReason = (role: RPCRole) => {
|
const disabledReason = (role: RPCRole) => {
|
||||||
switch (role.safety) {
|
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:
|
case RoleSafety.HigherThanBot:
|
||||||
return `This role is above Roleypoly's own role.`;
|
return `This role is above Roleypoly's own role.`;
|
||||||
case RoleSafety.DangerousPermissions:
|
case RoleSafety.DangerousPermissions:
|
||||||
|
|
|
@ -134,9 +134,9 @@ export const roleWikiData = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const guild: Guild = {
|
export const guild: Guild = {
|
||||||
name: 'emoji megaporium',
|
name: 'Roleypoly',
|
||||||
id: '421896162539470888',
|
id: '386659935687147521',
|
||||||
icon: '3372fd895ed913b55616c5e49cd50e60',
|
icon: 'ffee638c73ff9c972554f64ca34d67ee',
|
||||||
roles: [],
|
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 = {
|
export const guildData: GuildData = {
|
||||||
id: 'aaa',
|
id: '386659935687147521',
|
||||||
message: 'henlo worl!!',
|
message: 'henlo worl!!',
|
||||||
categories: [mockCategory, mockCategorySingle],
|
categories: [mockCategory, mockCategorySingle],
|
||||||
features: Features.None,
|
features: Features.None,
|
||||||
auditLogWebhook: null,
|
auditLogWebhook: null,
|
||||||
|
accessControl: {
|
||||||
|
blockList: [blockedRole.id],
|
||||||
|
allowList: [allowedRole.id],
|
||||||
|
blockPending: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const user: DiscordUser = {
|
export const user: DiscordUser = {
|
||||||
|
@ -206,14 +231,14 @@ export const guildEnum: GuildEnumeration = {
|
||||||
roles: [...roleCategory, ...roleCategory2],
|
roles: [...roleCategory, ...roleCategory2],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bbb',
|
id: '386659935687147521',
|
||||||
guild: guildMap['Roleypoly'],
|
guild: guildMap['Roleypoly'],
|
||||||
member: {
|
member: {
|
||||||
...member,
|
...member,
|
||||||
roles: ['unsafe2'],
|
roles: ['unsafe2'],
|
||||||
},
|
},
|
||||||
data: guildData,
|
data: guildData,
|
||||||
roles: [...roleCategory, ...roleCategory2],
|
roles: [...roleCategory, ...roleCategory2, blockedRole, allowedRole],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ccc',
|
id: 'ccc',
|
||||||
|
@ -232,6 +257,8 @@ export const guildEnum: GuildEnumeration = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const presentableGuild = guildEnum.guilds[1];
|
||||||
|
|
||||||
export const mastheadSlugs: GuildSlug[] = guildEnum.guilds.map<GuildSlug>(
|
export const mastheadSlugs: GuildSlug[] = guildEnum.guilds.map<GuildSlug>(
|
||||||
(guild, idx) => ({
|
(guild, idx) => ({
|
||||||
id: guild.guild.id,
|
id: guild.guild.id,
|
||||||
|
|
|
@ -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) => <EditableRoleList {...args} />;
|
|
@ -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;
|
||||||
|
`};
|
||||||
|
`;
|
|
@ -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 (
|
||||||
|
<EditableRoleListStyled>
|
||||||
|
{props.selectedRoles.length !== 0 ? (
|
||||||
|
<>
|
||||||
|
{sortBy(
|
||||||
|
props.roles.filter((r) => props.selectedRoles.includes(r.id)),
|
||||||
|
'position'
|
||||||
|
).map((role) => (
|
||||||
|
<Role
|
||||||
|
key={role.id}
|
||||||
|
role={role}
|
||||||
|
selected={false}
|
||||||
|
type="delete"
|
||||||
|
onClick={handleRoleDelete(role)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<RoleAddButton onClick={handleSearchOpen} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<RoleAddButton long onClick={handleSearchOpen} />
|
||||||
|
)}
|
||||||
|
<RoleSearchPopover
|
||||||
|
isOpen={searchOpen}
|
||||||
|
onExit={() => setSearchOpen(false)}
|
||||||
|
unselectedRoles={props.unselectedRoles}
|
||||||
|
onSelect={handleRoleAdd}
|
||||||
|
/>
|
||||||
|
</EditableRoleListStyled>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RoleAddButton = (props: { onClick: () => void; long?: boolean }) => (
|
||||||
|
<AddRoleButton
|
||||||
|
data-tip="Add a role to the category"
|
||||||
|
onClick={props.onClick}
|
||||||
|
long={props.long}
|
||||||
|
>
|
||||||
|
{props.long && <>Add a role </>}
|
||||||
|
<GoPlus />
|
||||||
|
</AddRoleButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RoleSearchPopover = (props: {
|
||||||
|
onSelect: (role: RoleT) => void;
|
||||||
|
onExit: (type: string) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
unselectedRoles: RoleT[];
|
||||||
|
}) => {
|
||||||
|
const [searchTerm, setSearchTerm] = React.useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
position="top left"
|
||||||
|
active={props.isOpen}
|
||||||
|
canDefocus
|
||||||
|
onExit={props.onExit}
|
||||||
|
headContent={null}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<RoleSearch
|
||||||
|
onSelect={props.onSelect}
|
||||||
|
roles={props.unselectedRoles}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onSearchUpdate={setSearchTerm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './EditableRoleList';
|
|
@ -1,16 +1,13 @@
|
||||||
import { Button } from '@roleypoly/design-system/atoms/button';
|
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 { TextInput } from '@roleypoly/design-system/atoms/text-input';
|
||||||
import { Toggle } from '@roleypoly/design-system/atoms/toggle';
|
import { Toggle } from '@roleypoly/design-system/atoms/toggle';
|
||||||
import { Text } from '@roleypoly/design-system/atoms/typography';
|
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 { Category as CategoryT, CategoryType, Role as RoleT } from '@roleypoly/types';
|
||||||
import { sortBy, uniq } from 'lodash';
|
|
||||||
import * as React from 'react';
|
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 ReactTooltip from 'react-tooltip';
|
||||||
import { AddRoleButton, Box, RoleContainer, Section } from './EditorCategory.styled';
|
import { Box, Section } from './EditorCategory.styled';
|
||||||
|
|
||||||
export type CategoryProps = {
|
export type CategoryProps = {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -23,25 +20,12 @@ export type CategoryProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditorCategory = (props: CategoryProps) => {
|
export const EditorCategory = (props: CategoryProps) => {
|
||||||
const [searchOpen, setSearchOpen] = React.useState(false);
|
|
||||||
|
|
||||||
const updateValue = <T extends keyof CategoryT>(key: T, value: CategoryT[T]) => {
|
const updateValue = <T extends keyof CategoryT>(key: T, value: CategoryT[T]) => {
|
||||||
props.onChange({ ...props.category, [key]: value });
|
props.onChange({ ...props.category, [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRoleDelete = (role: RoleT) => () => {
|
const handleRoleListUpdate = (roles: RoleT['id'][]) => () => {
|
||||||
const updatedRoles = props.category.roles.filter((r) => r !== role.id);
|
updateValue('roles', roles);
|
||||||
updateValue('roles', updatedRoles);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRoleAdd = (role: RoleT) => {
|
|
||||||
const updatedRoles = uniq([...props.category.roles, role.id]);
|
|
||||||
updateValue('roles', updatedRoles);
|
|
||||||
setSearchOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchOpen = () => {
|
|
||||||
setSearchOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -92,81 +76,15 @@ export const EditorCategory = (props: CategoryProps) => {
|
||||||
<div>
|
<div>
|
||||||
<Text>Roles</Text>
|
<Text>Roles</Text>
|
||||||
</div>
|
</div>
|
||||||
<RoleContainer>
|
<EditableRoleList
|
||||||
{props.roles.length > 0 ? (
|
roles={props.roles}
|
||||||
<>
|
unselectedRoles={props.unselectedRoles}
|
||||||
{sortBy(props.roles, 'position').map((role) => (
|
selectedRoles={props.category.roles}
|
||||||
<Role
|
onChange={handleRoleListUpdate}
|
||||||
key={role.id}
|
/>
|
||||||
role={role}
|
|
||||||
selected={false}
|
|
||||||
type="delete"
|
|
||||||
onClick={handleRoleDelete(role)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<RoleAddButton onClick={handleSearchOpen} tooltipId={props.category.id} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<RoleAddButton
|
|
||||||
long
|
|
||||||
onClick={handleSearchOpen}
|
|
||||||
tooltipId={props.category.id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<RoleSearchPopover
|
|
||||||
isOpen={searchOpen}
|
|
||||||
onExit={() => setSearchOpen(false)}
|
|
||||||
unselectedRoles={props.unselectedRoles}
|
|
||||||
onSelect={handleRoleAdd}
|
|
||||||
/>
|
|
||||||
</RoleContainer>
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<ReactTooltip id={props.category.id} />
|
<ReactTooltip id={props.category.id} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RoleAddButton = (props: {
|
|
||||||
onClick: () => void;
|
|
||||||
tooltipId: string;
|
|
||||||
long?: boolean;
|
|
||||||
}) => (
|
|
||||||
<AddRoleButton
|
|
||||||
data-tip="Add a role to the category"
|
|
||||||
data-for={props.tooltipId}
|
|
||||||
onClick={props.onClick}
|
|
||||||
long={props.long}
|
|
||||||
>
|
|
||||||
{props.long && <>Add a role </>}
|
|
||||||
<GoPlus />
|
|
||||||
</AddRoleButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
const RoleSearchPopover = (props: {
|
|
||||||
onSelect: (role: RoleT) => void;
|
|
||||||
onExit: (type: string) => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
unselectedRoles: RoleT[];
|
|
||||||
}) => {
|
|
||||||
const [searchTerm, setSearchTerm] = React.useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
position="top left"
|
|
||||||
active={props.isOpen}
|
|
||||||
canDefocus
|
|
||||||
onExit={props.onExit}
|
|
||||||
headContent={null}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<RoleSearch
|
|
||||||
onSelect={props.onSelect}
|
|
||||||
roles={props.unselectedRoles}
|
|
||||||
searchTerm={searchTerm}
|
|
||||||
onSearchUpdate={setSearchTerm}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
@ -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: <GoGear />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editorUtilityShell = (args) => (
|
||||||
|
<EditorUtilityShell {...args}>
|
||||||
|
<p>
|
||||||
|
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?
|
||||||
|
</p>
|
||||||
|
</EditorUtilityShell>
|
||||||
|
);
|
|
@ -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%;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
`;
|
|
@ -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: <T>(output: T) => void;
|
||||||
|
onExit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorUtilityShell = (
|
||||||
|
props: EditorUtilityProps & {
|
||||||
|
children: React.ReactNode;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
hasChanges: boolean;
|
||||||
|
}
|
||||||
|
) => (
|
||||||
|
<Shell>
|
||||||
|
<HeadBox>
|
||||||
|
<Title>
|
||||||
|
{props.icon}
|
||||||
|
{props.title}
|
||||||
|
</Title>
|
||||||
|
<GoBack>
|
||||||
|
{props.hasChanges ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
icon={<GoCheck />}
|
||||||
|
onClick={() => {
|
||||||
|
props.onSubmit(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save Changes & Exit
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="small" color="silent" icon={<GoReply />} onClick={props.onExit}>
|
||||||
|
<BreakpointText
|
||||||
|
large={`Go back to ${props.guildSlug.name}`}
|
||||||
|
small="Go Back"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Avatar
|
||||||
|
hash={props.guildSlug.icon}
|
||||||
|
src={avatarUtils.avatarHash(props.guildSlug.id, props.guildSlug.icon, 'icons')}
|
||||||
|
>
|
||||||
|
{avatarUtils.initialsFromName(props.guildSlug.name)}
|
||||||
|
</Avatar>
|
||||||
|
</GoBack>
|
||||||
|
</HeadBox>
|
||||||
|
<Content>{props.children}</Content>
|
||||||
|
</Shell>
|
||||||
|
);
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './EditorUtilityShell';
|
|
@ -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) => <EditorAccessControl {...args} />;
|
|
@ -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;
|
||||||
|
`;
|
|
@ -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 (
|
||||||
|
<EditorUtilityShell
|
||||||
|
guildSlug={props.guild.guild}
|
||||||
|
title="Access Control"
|
||||||
|
icon={<GoShield />}
|
||||||
|
hasChanges={hasChanges}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onExit={props.onExit}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<IconHelper level="chrome">
|
||||||
|
<GoInfo />
|
||||||
|
</IconHelper>
|
||||||
|
Admins and Role Managers are exempt from all of these limits. Please note,
|
||||||
|
this settings page is in order of precedence.
|
||||||
|
</p>
|
||||||
|
<Space />
|
||||||
|
<div>
|
||||||
|
<LargeText>
|
||||||
|
Block pending members from using Roleypoly
|
||||||
|
<IconHelper level="error">
|
||||||
|
<GoThumbsdown />
|
||||||
|
</IconHelper>
|
||||||
|
</LargeText>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
{/* <RoleContainer> */}
|
||||||
|
<Toggle
|
||||||
|
state={accessControl.blockPending}
|
||||||
|
onChange={handleChange('blockPending')}
|
||||||
|
>
|
||||||
|
If a user is behind Discord's{' '}
|
||||||
|
<Link href="https://support.discord.com/hc/en-us/articles/1500000466882-Rules-Screening-FAQ">
|
||||||
|
Membership Screening
|
||||||
|
</Link>{' '}
|
||||||
|
feature, they can <b>not</b> use Roleypoly.
|
||||||
|
</Toggle>
|
||||||
|
{/* </RoleContainer> */}
|
||||||
|
<p>
|
||||||
|
<AmbientLarge>
|
||||||
|
<IconHelper level="chrome">
|
||||||
|
<GoInfo />
|
||||||
|
</IconHelper>
|
||||||
|
This only applies to Discord servers with Community features enabled.
|
||||||
|
</AmbientLarge>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Space />
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<LargeText>
|
||||||
|
Block roles from using Roleypoly
|
||||||
|
<IconHelper level="error">
|
||||||
|
<GoThumbsdown />
|
||||||
|
</IconHelper>
|
||||||
|
</LargeText>
|
||||||
|
<br />
|
||||||
|
<Text>
|
||||||
|
If there are roles in this list, any server member <b>with</b> a role in the
|
||||||
|
list can <b>not</b> use Roleypoly.
|
||||||
|
<br />
|
||||||
|
<IconHelper level="info">
|
||||||
|
<GoInfo />
|
||||||
|
</IconHelper>
|
||||||
|
Blocked roles take precedence over the allowed roles.
|
||||||
|
</Text>
|
||||||
|
</p>
|
||||||
|
<RoleContainer>
|
||||||
|
<EditableRoleList
|
||||||
|
roles={props.guild.roles}
|
||||||
|
unselectedRoles={rolesNotInBlocked}
|
||||||
|
selectedRoles={accessControl.blockList}
|
||||||
|
onChange={handleChange('blockList')}
|
||||||
|
/>
|
||||||
|
</RoleContainer>
|
||||||
|
<p>
|
||||||
|
<AmbientLarge>
|
||||||
|
<IconHelper level="chrome">
|
||||||
|
<GoInfo />
|
||||||
|
</IconHelper>
|
||||||
|
If your Discord server has a "muted" or "visitor" role, this setting is
|
||||||
|
meant to complement it.
|
||||||
|
</AmbientLarge>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Space />
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<LargeText>
|
||||||
|
Allow these roles to use Roleypoly
|
||||||
|
<IconHelper level="success">
|
||||||
|
<GoThumbsup />
|
||||||
|
</IconHelper>
|
||||||
|
</LargeText>
|
||||||
|
<br />
|
||||||
|
<Text>
|
||||||
|
If there are roles in this list, any server member <b>without</b> a role in
|
||||||
|
the list can <b>not</b> use Roleypoly.
|
||||||
|
<br />
|
||||||
|
<IconHelper level="warn">
|
||||||
|
<GoAlert />
|
||||||
|
</IconHelper>
|
||||||
|
This can disrupt use of the bot, so be careful!
|
||||||
|
</Text>
|
||||||
|
</p>
|
||||||
|
<RoleContainer>
|
||||||
|
<EditableRoleList
|
||||||
|
roles={props.guild.roles}
|
||||||
|
unselectedRoles={rolesNotInAllowed}
|
||||||
|
selectedRoles={accessControl.allowList}
|
||||||
|
onChange={handleChange('allowList')}
|
||||||
|
/>
|
||||||
|
</RoleContainer>
|
||||||
|
<p>
|
||||||
|
<AmbientLarge>
|
||||||
|
<IconHelper level="chrome">
|
||||||
|
<GoInfo />
|
||||||
|
</IconHelper>
|
||||||
|
If your Discord server uses a "role gating" system, this setting is
|
||||||
|
meant to complement it.
|
||||||
|
</AmbientLarge>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</EditorUtilityShell>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './EditorAccessControl';
|
|
@ -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) => (
|
||||||
|
<BreakpointsProvider>
|
||||||
|
{story()}
|
||||||
|
<ReactTooltip />
|
||||||
|
</BreakpointsProvider>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
args: {
|
||||||
|
errors: { validationStatus: 0 },
|
||||||
|
guilds: mastheadSlugs,
|
||||||
|
user: user,
|
||||||
|
guild: guildEnum.guilds[1],
|
||||||
|
guildSlug: roleypolyGuild,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editorAccessControl = (args) => <EditorAccessControlTemplate {...args} />;
|
|
@ -0,0 +1,2 @@
|
||||||
|
import styled from 'styled-components';
|
||||||
|
export const EditorAccessControlStyled = styled.div``;
|
|
@ -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<AppShellProps, 'children'>
|
||||||
|
) => {
|
||||||
|
const { guildSlug, guild, onSubmit, onExit, ...appShellProps } = props;
|
||||||
|
return (
|
||||||
|
<AppShell {...appShellProps} activeGuildId={guild.id} small>
|
||||||
|
<EditorAccessControl
|
||||||
|
guildSlug={guildSlug}
|
||||||
|
guild={guild}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onExit={onExit}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './EditorAccessControl';
|
|
@ -1,8 +0,0 @@
|
||||||
import { EditorUtility } from './EditorUtility';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: 'Templates/Editor Utility',
|
|
||||||
component: EditorUtility,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const editorUtility = (args) => <EditorUtility {...args} />;
|
|
|
@ -1,2 +0,0 @@
|
||||||
import styled from 'styled-components';
|
|
||||||
export const EditorUtilityStyled = styled.div``;
|
|
|
@ -1,5 +0,0 @@
|
||||||
import { EditorUtilityStyled } from './EditorUtility.styled';
|
|
||||||
|
|
||||||
export const EditorUtility = () => (
|
|
||||||
<EditorUtilityStyled>EditorUtility</EditorUtilityStyled>
|
|
||||||
);
|
|
|
@ -1 +0,0 @@
|
||||||
export * from './EditorUtility';
|
|
|
@ -42,6 +42,11 @@ export const errorMessages: { [code: string]: ErrorMessage } = {
|
||||||
japanese: `...but it didn't believe me. :( ごめんなさい`,
|
japanese: `...but it didn't believe me. :( ごめんなさい`,
|
||||||
friendlyCode: 'Yo.',
|
friendlyCode: 'Yo.',
|
||||||
},
|
},
|
||||||
|
accessControlViolation: {
|
||||||
|
english: `You're not allowed to pick roles on that server.`,
|
||||||
|
japanese: `このサーバーでは許可されていない権限です`,
|
||||||
|
friendlyCode: 'Hold up!',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMessageFromCode = (
|
export const getMessageFromCode = (
|
||||||
|
|
|
@ -21,6 +21,13 @@ export type GuildData = {
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
features: Features;
|
features: Features;
|
||||||
auditLogWebhook: string | null;
|
auditLogWebhook: string | null;
|
||||||
|
accessControl: GuildAccessControl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GuildAccessControl = {
|
||||||
|
allowList: Role['id'][];
|
||||||
|
blockList: Role['id'][];
|
||||||
|
blockPending: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GuildDataUpdate = Omit<Omit<GuildData, 'features'>, 'id'>;
|
export type GuildDataUpdate = Omit<Omit<GuildData, 'features'>, 'id'>;
|
||||||
|
|
|
@ -3,6 +3,7 @@ export enum RoleSafety {
|
||||||
HigherThanBot = 1 << 1,
|
HigherThanBot = 1 << 1,
|
||||||
DangerousPermissions = 1 << 2,
|
DangerousPermissions = 1 << 2,
|
||||||
ManagedRole = 1 << 3,
|
ManagedRole = 1 << 3,
|
||||||
|
AccessControl = 1 << 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Role = {
|
export type Role = {
|
||||||
|
|
|
@ -11,6 +11,7 @@ export type Member = {
|
||||||
roles: string[];
|
roles: string[];
|
||||||
nick?: string;
|
nick?: string;
|
||||||
user?: DiscordUser;
|
user?: DiscordUser;
|
||||||
|
pending?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoleypolyUser = {
|
export type RoleypolyUser = {
|
||||||
|
|
|
@ -9,6 +9,7 @@ import PickerPage from '../pages/picker';
|
||||||
const WhyNoRoles = React.lazy(() => import('../pages/help/why-no-roles'));
|
const WhyNoRoles = React.lazy(() => import('../pages/help/why-no-roles'));
|
||||||
const ServersPage = React.lazy(() => import('../pages/servers'));
|
const ServersPage = React.lazy(() => import('../pages/servers'));
|
||||||
const EditorPage = React.lazy(() => import('../pages/editor'));
|
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 MachineryNewSession = React.lazy(() => import('../pages/machinery/new-session'));
|
||||||
const MachineryLogout = React.lazy(() => import('../pages/machinery/logout'));
|
const MachineryLogout = React.lazy(() => import('../pages/machinery/logout'));
|
||||||
|
@ -35,6 +36,10 @@ export const AppRouter = () => {
|
||||||
<RouteWrapper component={ServersPage} path="/servers" />
|
<RouteWrapper component={ServersPage} path="/servers" />
|
||||||
<RouteWrapper component={PickerPage} path="/s/:serverID" />
|
<RouteWrapper component={PickerPage} path="/s/:serverID" />
|
||||||
<RouteWrapper component={EditorPage} path="/s/:serverID/edit" />
|
<RouteWrapper component={EditorPage} path="/s/:serverID/edit" />
|
||||||
|
<RouteWrapper
|
||||||
|
component={AccessControlPage}
|
||||||
|
path="/s/:serverID/edit/access-control"
|
||||||
|
/>
|
||||||
|
|
||||||
<RouteWrapper component={ErrorPage} path="/error" />
|
<RouteWrapper component={ErrorPage} path="/error" />
|
||||||
<RouteWrapper component={ErrorPage} path="/error/:identity" />
|
<RouteWrapper component={ErrorPage} path="/error/:identity" />
|
||||||
|
|
116
packages/web/src/contexts/guild/GuildContext.tsx
Normal file
116
packages/web/src/contexts/guild/GuildContext.tsx
Normal file
|
@ -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<T extends PresentableGuild | GuildSlug> = {
|
||||||
|
user: string;
|
||||||
|
guild: T;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GuildContextT = {
|
||||||
|
getFullGuild: (
|
||||||
|
id: string,
|
||||||
|
uncached?: boolean
|
||||||
|
) => Promise<PresentableGuild | null | false>;
|
||||||
|
getGuildSlug: (id: string) => Promise<GuildSlug | null>;
|
||||||
|
uncacheGuild: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GuildContext = React.createContext<GuildContextT>({
|
||||||
|
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<GuildSlug>;
|
||||||
|
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<PresentableGuild>;
|
||||||
|
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<GuildSlug> = {
|
||||||
|
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<PresentableGuild> = {
|
||||||
|
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 (
|
||||||
|
<GuildContext.Provider value={guildContextValue}>
|
||||||
|
{props.children}
|
||||||
|
</GuildContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -4,6 +4,7 @@ import ReactDOM from 'react-dom';
|
||||||
import { AppRouter } from './app-router/AppRouter';
|
import { AppRouter } from './app-router/AppRouter';
|
||||||
import { ApiContextProvider } from './contexts/api/ApiContext';
|
import { ApiContextProvider } from './contexts/api/ApiContext';
|
||||||
import { AppShellPropsProvider } from './contexts/app-shell/AppShellContext';
|
import { AppShellPropsProvider } from './contexts/app-shell/AppShellContext';
|
||||||
|
import { GuildProvider } from './contexts/guild/GuildContext';
|
||||||
import { RecentGuildsProvider } from './contexts/recent-guilds/RecentGuildsContext';
|
import { RecentGuildsProvider } from './contexts/recent-guilds/RecentGuildsContext';
|
||||||
import { SessionContextProvider } from './contexts/session/SessionContext';
|
import { SessionContextProvider } from './contexts/session/SessionContext';
|
||||||
|
|
||||||
|
@ -26,6 +27,7 @@ ReactDOM.render(
|
||||||
RecentGuildsProvider,
|
RecentGuildsProvider,
|
||||||
AppShellPropsProvider,
|
AppShellPropsProvider,
|
||||||
BreakpointsProvider,
|
BreakpointsProvider,
|
||||||
|
GuildProvider,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
|
|
|
@ -4,12 +4,14 @@ import { GenericLoadingTemplate } from '@roleypoly/design-system/templates/gener
|
||||||
import { GuildSlug } from '@roleypoly/types';
|
import { GuildSlug } from '@roleypoly/types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useApiContext } from '../../contexts/api/ApiContext';
|
import { useApiContext } from '../../contexts/api/ApiContext';
|
||||||
|
import { useGuildContext } from '../../contexts/guild/GuildContext';
|
||||||
import { useSessionContext } from '../../contexts/session/SessionContext';
|
import { useSessionContext } from '../../contexts/session/SessionContext';
|
||||||
import { Title } from '../../utils/metaTitle';
|
import { Title } from '../../utils/metaTitle';
|
||||||
|
|
||||||
const Login = (props: { path: string }) => {
|
const Login = (props: { path: string }) => {
|
||||||
const { apiUrl, fetch } = useApiContext();
|
const { apiUrl } = useApiContext();
|
||||||
const { isAuthenticated } = useSessionContext();
|
const { isAuthenticated } = useSessionContext();
|
||||||
|
const { getGuildSlug } = useGuildContext();
|
||||||
// If ?r is in query, then let's render the slug page
|
// If ?r is in query, then let's render the slug page
|
||||||
// If not, redirect.
|
// If not, redirect.
|
||||||
const [guildSlug, setGuildSlug] = React.useState<GuildSlug | null>(null);
|
const [guildSlug, setGuildSlug] = React.useState<GuildSlug | null>(null);
|
||||||
|
@ -32,9 +34,8 @@ const Login = (props: { path: string }) => {
|
||||||
localStorage.setItem('rp_postauth_redirect', `/s/${redirectServerID}`);
|
localStorage.setItem('rp_postauth_redirect', `/s/${redirectServerID}`);
|
||||||
|
|
||||||
const fetchGuildSlug = async (id: string) => {
|
const fetchGuildSlug = async (id: string) => {
|
||||||
const response = await fetch(`/get-slug/${id}`);
|
const slug = await getGuildSlug(id);
|
||||||
if (response.status === 200) {
|
if (slug) {
|
||||||
const slug = await response.json();
|
|
||||||
setGuildSlug(slug);
|
setGuildSlug(slug);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -44,7 +45,7 @@ const Login = (props: { path: string }) => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
redirectTo(`/s/${redirectServerID}`);
|
redirectTo(`/s/${redirectServerID}`);
|
||||||
}
|
}
|
||||||
}, [apiUrl, fetch, isAuthenticated]);
|
}, [apiUrl, getGuildSlug, isAuthenticated]);
|
||||||
|
|
||||||
if (guildSlug === null) {
|
if (guildSlug === null) {
|
||||||
return <GenericLoadingTemplate>Sending you to Discord...</GenericLoadingTemplate>;
|
return <GenericLoadingTemplate>Sending you to Discord...</GenericLoadingTemplate>;
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} 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';
|
||||||
|
import { useGuildContext } from '../contexts/guild/GuildContext';
|
||||||
import { useRecentGuilds } from '../contexts/recent-guilds/RecentGuildsContext';
|
import { useRecentGuilds } from '../contexts/recent-guilds/RecentGuildsContext';
|
||||||
import { useSessionContext } from '../contexts/session/SessionContext';
|
import { useSessionContext } from '../contexts/session/SessionContext';
|
||||||
import { Title } from '../utils/metaTitle';
|
import { Title } from '../utils/metaTitle';
|
||||||
|
@ -22,6 +23,7 @@ const Editor = (props: EditorProps) => {
|
||||||
const { session, authedFetch, isAuthenticated } = useSessionContext();
|
const { session, authedFetch, isAuthenticated } = useSessionContext();
|
||||||
const { pushRecentGuild } = useRecentGuilds();
|
const { pushRecentGuild } = useRecentGuilds();
|
||||||
const appShellProps = useAppShellProps();
|
const appShellProps = useAppShellProps();
|
||||||
|
const { getFullGuild } = useGuildContext();
|
||||||
|
|
||||||
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);
|
||||||
|
@ -38,20 +40,18 @@ const Editor = (props: EditorProps) => {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
const fetchGuild = async () => {
|
const fetchGuild = async () => {
|
||||||
const skipCache = shouldPullUncached() ? '?__no_cache' : '';
|
const guild = await getFullGuild(serverID, shouldPullUncached());
|
||||||
const response = await authedFetch(`/get-picker-data/${serverID}${skipCache}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (guild === null) {
|
||||||
setGuild(false);
|
setGuild(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setGuild(data);
|
setGuild(guild);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchGuild();
|
fetchGuild();
|
||||||
}, [serverID, authedFetch]);
|
}, [serverID, getFullGuild]);
|
||||||
|
|
||||||
React.useCallback((serverID) => pushRecentGuild(serverID), [pushRecentGuild])(serverID);
|
React.useCallback((serverID) => pushRecentGuild(serverID), [pushRecentGuild])(serverID);
|
||||||
|
|
||||||
|
@ -84,10 +84,11 @@ const Editor = (props: EditorProps) => {
|
||||||
|
|
||||||
setPending(true);
|
setPending(true);
|
||||||
|
|
||||||
const updatePayload: GuildDataUpdate = {
|
const updatePayload: Partial<GuildDataUpdate> = {
|
||||||
message: guild.data.message,
|
message: guild.data.message,
|
||||||
categories: guild.data.categories,
|
categories: guild.data.categories,
|
||||||
auditLogWebhook: guild.data.auditLogWebhook,
|
auditLogWebhook:
|
||||||
|
'https://discord.com/api/webhooks/864658054930759696/vE91liQYwmW4nS6fiT0cMfhe_dpPLBkDXOPynDNLdXZT1KdkDKm8wa4h4E4RPw0GDcJR',
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await authedFetch(`/update-guild/${serverID}`, {
|
const response = await authedFetch(`/update-guild/${serverID}`, {
|
||||||
|
|
91
packages/web/src/pages/editor/access-control.tsx
Normal file
91
packages/web/src/pages/editor/access-control.tsx
Normal file
|
@ -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<PresentableGuild | null | false>(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 <Redirect to={`/auth/login?r=${props.serverID}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <Redirect to={`/s/${props.serverID}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the guild isn't loaded, render a loading placeholder
|
||||||
|
if (guild === null) {
|
||||||
|
return <GenericLoadingTemplate />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the guild is not found, redirect to the picker page
|
||||||
|
if (guild === false) {
|
||||||
|
return <Redirect to={`/s/${props.serverID}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (accessControl: GuildAccessControl) => {
|
||||||
|
const updatePayload: Partial<GuildDataUpdate> = {
|
||||||
|
accessControl,
|
||||||
|
};
|
||||||
|
|
||||||
|
await authedFetch(`/update-guild/${props.serverID}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(updatePayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
uncacheGuild(props.serverID);
|
||||||
|
navigate(`/s/${props.serverID}/edit`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditorAccessControlTemplate
|
||||||
|
guild={guild}
|
||||||
|
guildSlug={guild.guild}
|
||||||
|
onSubmit={(data: any) => onSubmit(data)}
|
||||||
|
onExit={() => navigate(`/s/${props.serverID}/edit`)}
|
||||||
|
{...appShellProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccessControlPage;
|
|
@ -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 { GenericLoadingTemplate } from '@roleypoly/design-system/templates/generic-loading';
|
||||||
import { RolePickerTemplate } from '@roleypoly/design-system/templates/role-picker';
|
import { RolePickerTemplate } from '@roleypoly/design-system/templates/role-picker';
|
||||||
import { ServerSetupTemplate } from '@roleypoly/design-system/templates/server-setup';
|
import { ServerSetupTemplate } from '@roleypoly/design-system/templates/server-setup';
|
||||||
import { PresentableGuild, RoleUpdate, UserGuildPermissions } from '@roleypoly/types';
|
import { PresentableGuild, RoleUpdate, UserGuildPermissions } 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';
|
||||||
|
import { useGuildContext } from '../contexts/guild/GuildContext';
|
||||||
import { useRecentGuilds } from '../contexts/recent-guilds/RecentGuildsContext';
|
import { useRecentGuilds } from '../contexts/recent-guilds/RecentGuildsContext';
|
||||||
import { useSessionContext } from '../contexts/session/SessionContext';
|
import { useSessionContext } from '../contexts/session/SessionContext';
|
||||||
import { Title } from '../utils/metaTitle';
|
import { Title } from '../utils/metaTitle';
|
||||||
|
@ -19,6 +20,7 @@ const Picker = (props: PickerProps) => {
|
||||||
const { session, authedFetch, isAuthenticated } = useSessionContext();
|
const { session, authedFetch, isAuthenticated } = useSessionContext();
|
||||||
const { pushRecentGuild } = useRecentGuilds();
|
const { pushRecentGuild } = useRecentGuilds();
|
||||||
const appShellProps = useAppShellProps();
|
const appShellProps = useAppShellProps();
|
||||||
|
const { getFullGuild } = useGuildContext();
|
||||||
|
|
||||||
const [pickerData, setPickerData] = React.useState<PresentableGuild | null | false>(
|
const [pickerData, setPickerData] = React.useState<PresentableGuild | null | false>(
|
||||||
null
|
null
|
||||||
|
@ -27,10 +29,14 @@ const Picker = (props: PickerProps) => {
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const fetchPickerData = async () => {
|
const fetchPickerData = async () => {
|
||||||
const response = await authedFetch(`/get-picker-data/${props.serverID}`);
|
const data = await getFullGuild(props.serverID);
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (data === false) {
|
||||||
|
redirectTo('/error/accessControlViolation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data === null) {
|
||||||
setPickerData(false);
|
setPickerData(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -39,7 +45,7 @@ const Picker = (props: PickerProps) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchPickerData();
|
fetchPickerData();
|
||||||
}, [props.serverID, authedFetch, pushRecentGuild]);
|
}, [props.serverID, getFullGuild]);
|
||||||
|
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
(serverID) => pushRecentGuild(serverID),
|
(serverID) => pushRecentGuild(serverID),
|
||||||
|
|
Loading…
Add table
Reference in a new issue