mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-15 00:59:09 +00:00
feat: add access control
This commit is contained in:
parent
9c07ff0e54
commit
3f45153b66
47 changed files with 1084 additions and 164 deletions
|
@ -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 = () => {
|
|||
<RouteWrapper component={ServersPage} path="/servers" />
|
||||
<RouteWrapper component={PickerPage} path="/s/:serverID" />
|
||||
<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/: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 { 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,
|
||||
]}
|
||||
>
|
||||
<AppRouter />
|
||||
|
|
|
@ -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<GuildSlug | null>(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 <GenericLoadingTemplate>Sending you to Discord...</GenericLoadingTemplate>;
|
||||
|
|
|
@ -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<PresentableGuild | null | false>(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<GuildDataUpdate> = {
|
||||
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}`, {
|
||||
|
|
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 { 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<PresentableGuild | null | false>(
|
||||
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),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue