mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-25 03:49:11 +00:00
feat: support updating roles (closes #83)
This commit is contained in:
parent
c7381c3d66
commit
1a59972398
10 changed files with 272 additions and 23 deletions
|
@ -33,6 +33,7 @@
|
||||||
"chroma-js": "^2.1.0",
|
"chroma-js": "^2.1.0",
|
||||||
"isomorphic-unfetch": "^3.1.0",
|
"isomorphic-unfetch": "^3.1.0",
|
||||||
"ksuid": "^2.0.0",
|
"ksuid": "^2.0.0",
|
||||||
|
"lodash": "^4.17.20",
|
||||||
"next": "^10.0.3",
|
"next": "^10.0.3",
|
||||||
"nookies": "^2.5.0",
|
"nookies": "^2.5.0",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
|
@ -60,6 +61,7 @@
|
||||||
"@types/enzyme-adapter-react-16": "^1.0.6",
|
"@types/enzyme-adapter-react-16": "^1.0.6",
|
||||||
"@types/express": "^4.17.9",
|
"@types/express": "^4.17.9",
|
||||||
"@types/jest": "^26.0.19",
|
"@types/jest": "^26.0.19",
|
||||||
|
"@types/lodash": "^4.14.165",
|
||||||
"@types/minimist": "^1.2.1",
|
"@types/minimist": "^1.2.1",
|
||||||
"@types/node": "^14.14.14",
|
"@types/node": "^14.14.14",
|
||||||
"@types/react": "^17.0.0",
|
"@types/react": "^17.0.0",
|
||||||
|
|
141
src/backend-worker/handlers/update-roles.ts
Normal file
141
src/backend-worker/handlers/update-roles.ts
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import { difference, groupBy, keyBy, union } from 'lodash';
|
||||||
|
import {
|
||||||
|
GuildData,
|
||||||
|
Member,
|
||||||
|
Role,
|
||||||
|
RoleSafety,
|
||||||
|
RoleTransaction,
|
||||||
|
RoleUpdate,
|
||||||
|
SessionData,
|
||||||
|
TransactionType,
|
||||||
|
} from 'roleypoly/common/types';
|
||||||
|
import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools';
|
||||||
|
import { botToken } from '../utils/config';
|
||||||
|
import {
|
||||||
|
getGuild,
|
||||||
|
getGuildData,
|
||||||
|
getGuildMemberRoles,
|
||||||
|
updateGuildMemberRoles,
|
||||||
|
} from '../utils/guild';
|
||||||
|
|
||||||
|
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
|
||||||
|
|
||||||
|
export const UpdateRoles = withSession(
|
||||||
|
({ guilds, user: { id: userID } }: SessionData) => async (request: Request) => {
|
||||||
|
const updateRequest = (await request.json()) as RoleUpdate;
|
||||||
|
const [, , guildID] = new URL(request.url).pathname.split('/');
|
||||||
|
|
||||||
|
if (!guildID) {
|
||||||
|
return respond({ error: 'guild ID missing from URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateRequest.transactions.length === 0) {
|
||||||
|
return respond(
|
||||||
|
{ error: 'must have as least one transaction' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildCheck = guilds.find((guild) => guild.id === guildID);
|
||||||
|
if (!guildCheck) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await getGuild(guildID);
|
||||||
|
if (!guild) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildMemberRoles = await getGuildMemberRoles(
|
||||||
|
{ serverID: guildID, userID },
|
||||||
|
{ skipCachePull: true }
|
||||||
|
);
|
||||||
|
if (!guildMemberRoles) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRoles = calculateNewRoles({
|
||||||
|
currentRoles: guildMemberRoles,
|
||||||
|
guildRoles: guild.roles,
|
||||||
|
guildData: await getGuildData(guildID),
|
||||||
|
updateRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchMemberRoles = await discordFetch<Member>(
|
||||||
|
`/guilds/${guildID}/members/${userID}`,
|
||||||
|
botToken,
|
||||||
|
AuthType.Bot,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
roles: newRoles,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!patchMemberRoles) {
|
||||||
|
return respond({ error: 'discord rejected the request' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMember: Member = {
|
||||||
|
roles: patchMemberRoles.roles,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateGuildMemberRoles(
|
||||||
|
{ serverID: guildID, userID },
|
||||||
|
patchMemberRoles.roles
|
||||||
|
);
|
||||||
|
|
||||||
|
return respond(updatedMember);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculateNewRoles = ({
|
||||||
|
currentRoles,
|
||||||
|
guildData,
|
||||||
|
guildRoles,
|
||||||
|
updateRequest,
|
||||||
|
}: {
|
||||||
|
currentRoles: string[];
|
||||||
|
guildRoles: Role[];
|
||||||
|
guildData: GuildData;
|
||||||
|
updateRequest: RoleUpdate;
|
||||||
|
}): string[] => {
|
||||||
|
const roleMap = keyBy(guildRoles, 'id');
|
||||||
|
|
||||||
|
// These roles were ones changed between knownState (role picker page load/cache) and current (fresh from discord).
|
||||||
|
// We could cause issues, so we'll re-add them later.
|
||||||
|
// const diffRoles = difference(updateRequest.knownState, currentRoles);
|
||||||
|
|
||||||
|
// Only these are safe
|
||||||
|
const allSafeRoles = guildData.categories.reduce<string[]>(
|
||||||
|
(categorizedRoles, category) =>
|
||||||
|
!category.hidden
|
||||||
|
? [
|
||||||
|
...categorizedRoles,
|
||||||
|
...category.roles.filter(
|
||||||
|
(roleID) => roleMap[roleID]?.safety === RoleSafety.Safe
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: categorizedRoles,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const safeTransactions = updateRequest.transactions.filter((tx: RoleTransaction) =>
|
||||||
|
allSafeRoles.includes(tx.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const changesByAction = groupBy(safeTransactions, 'action');
|
||||||
|
|
||||||
|
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map((tx) => tx.id);
|
||||||
|
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
|
||||||
|
(tx) => tx.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
|
||||||
|
|
||||||
|
return final;
|
||||||
|
};
|
|
@ -6,6 +6,7 @@ import { GetSlug } from './handlers/get-slug';
|
||||||
import { LoginBounce } from './handlers/login-bounce';
|
import { LoginBounce } from './handlers/login-bounce';
|
||||||
import { LoginCallback } from './handlers/login-callback';
|
import { LoginCallback } from './handlers/login-callback';
|
||||||
import { RevokeSession } from './handlers/revoke-session';
|
import { RevokeSession } from './handlers/revoke-session';
|
||||||
|
import { UpdateRoles } from './handlers/update-roles';
|
||||||
import { Router } from './router';
|
import { Router } from './router';
|
||||||
import { respond } from './utils/api-tools';
|
import { respond } from './utils/api-tools';
|
||||||
import { uiPublicURI } from './utils/config';
|
import { uiPublicURI } from './utils/config';
|
||||||
|
@ -24,6 +25,7 @@ router.add('POST', 'revoke-session', RevokeSession);
|
||||||
// Main biz logic
|
// Main biz logic
|
||||||
router.add('GET', 'get-slug', GetSlug);
|
router.add('GET', 'get-slug', GetSlug);
|
||||||
router.add('GET', 'get-picker-data', GetPickerData);
|
router.add('GET', 'get-picker-data', GetPickerData);
|
||||||
|
router.add('PATCH', 'update-roles', UpdateRoles);
|
||||||
|
|
||||||
// Root users only
|
// Root users only
|
||||||
router.add('GET', 'x-create-roleypoly-data', CreateRoleypolyData);
|
router.add('GET', 'x-create-roleypoly-data', CreateRoleypolyData);
|
||||||
|
|
|
@ -52,9 +52,6 @@ export class Router {
|
||||||
if (handler) {
|
if (handler) {
|
||||||
try {
|
try {
|
||||||
const response = await handler(request);
|
const response = await handler(request);
|
||||||
|
|
||||||
// this.wrapCORS(request, response);
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
|
@ -18,7 +18,7 @@ export const addCORS = (init: ResponseInit = {}) => ({
|
||||||
headers: {
|
headers: {
|
||||||
...(init.headers || {}),
|
...(init.headers || {}),
|
||||||
'access-control-allow-origin': uiPublicURI,
|
'access-control-allow-origin': uiPublicURI,
|
||||||
'access-control-allow-method': '*',
|
'access-control-allow-methods': '*',
|
||||||
'access-control-allow-headers': '*',
|
'access-control-allow-headers': '*',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -80,15 +80,26 @@ export enum AuthType {
|
||||||
export const discordFetch = async <T>(
|
export const discordFetch = async <T>(
|
||||||
url: string,
|
url: string,
|
||||||
auth: string,
|
auth: string,
|
||||||
authType: AuthType = AuthType.Bearer
|
authType: AuthType = AuthType.Bearer,
|
||||||
|
init?: RequestInit
|
||||||
): Promise<T | null> => {
|
): Promise<T | null> => {
|
||||||
const response = await fetch('https://discord.com/api/v8' + url, {
|
const response = await fetch('https://discord.com/api/v8' + url, {
|
||||||
|
...(init || {}),
|
||||||
headers: {
|
headers: {
|
||||||
|
...(init?.headers || {}),
|
||||||
authorization: `${AuthType[authType]} ${auth}`,
|
authorization: `${AuthType[authType]} ${auth}`,
|
||||||
'user-agent': userAgent,
|
'user-agent': userAgent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status >= 400) {
|
||||||
|
console.error('discordFetch failed', {
|
||||||
|
url,
|
||||||
|
authType,
|
||||||
|
payload: await response.text(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
RoleSafety,
|
RoleSafety,
|
||||||
} from 'roleypoly/common/types';
|
} from 'roleypoly/common/types';
|
||||||
import { evaluatePermission, permissions } from 'roleypoly/common/utils/hasPermission';
|
import { evaluatePermission, permissions } from 'roleypoly/common/utils/hasPermission';
|
||||||
import { cacheLayer, discordFetch } from './api-tools';
|
import { AuthType, cacheLayer, discordFetch } from './api-tools';
|
||||||
import { botClientID, botToken } from './config';
|
import { botClientID, botToken } from './config';
|
||||||
import { GuildData, Guilds } from './kv';
|
import { GuildData, Guilds } from './kv';
|
||||||
|
|
||||||
|
@ -92,14 +92,17 @@ type APIMember = {
|
||||||
roles: string[];
|
roles: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const guildMemberRolesIdentity = ({ serverID, userID }) =>
|
||||||
|
`guilds/${serverID}/members/${userID}`;
|
||||||
|
|
||||||
export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>(
|
export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>(
|
||||||
Guilds,
|
Guilds,
|
||||||
({ serverID, userID }) => `guilds/${serverID}/members/${userID}`,
|
guildMemberRolesIdentity,
|
||||||
async ({ serverID, userID }) => {
|
async ({ serverID, userID }) => {
|
||||||
const discordMember = await discordFetch<APIMember>(
|
const discordMember = await discordFetch<APIMember>(
|
||||||
`/guilds/${serverID}/members/${userID}`,
|
`/guilds/${serverID}/members/${userID}`,
|
||||||
botToken,
|
botToken,
|
||||||
'Bot'
|
AuthType.Bot
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!discordMember) {
|
if (!discordMember) {
|
||||||
|
@ -111,6 +114,13 @@ export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>
|
||||||
60 * 5 // 5 minute TTL
|
60 * 5 // 5 minute TTL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const updateGuildMemberRoles = async (
|
||||||
|
identity: GuildMemberIdentity,
|
||||||
|
roles: Role['id'][]
|
||||||
|
) => {
|
||||||
|
await Guilds.put(guildMemberRolesIdentity(identity), roles, 60 * 5);
|
||||||
|
};
|
||||||
|
|
||||||
export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
||||||
const guildData = await GuildData.get<GuildDataT>(id);
|
const guildData = await GuildData.get<GuildDataT>(id);
|
||||||
|
|
||||||
|
|
|
@ -19,3 +19,19 @@ export type Role = {
|
||||||
export type OwnRoleInfo = {
|
export type OwnRoleInfo = {
|
||||||
highestRolePosition: number;
|
highestRolePosition: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum TransactionType {
|
||||||
|
None = 0,
|
||||||
|
Remove = 1 << 1,
|
||||||
|
Add = 1 << 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleTransaction = {
|
||||||
|
id: string;
|
||||||
|
action: TransactionType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleUpdate = {
|
||||||
|
knownState: Role['id'][];
|
||||||
|
transactions: RoleTransaction[];
|
||||||
|
};
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { isEqual, xor } from 'lodash';
|
||||||
import NextLink from 'next/link';
|
import NextLink from 'next/link';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { GoInfo } from 'react-icons/go';
|
import { GoInfo } from 'react-icons/go';
|
||||||
|
@ -34,20 +35,17 @@ export type RolePickerProps = {
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const arrayMatches = (a: any[], b: any[]) => {
|
|
||||||
return (
|
|
||||||
a === b ||
|
|
||||||
(a.length === b.length &&
|
|
||||||
a.every((x) => b.includes(x)) &&
|
|
||||||
b.every((x) => a.includes(x)))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RolePicker = (props: RolePickerProps) => {
|
export const RolePicker = (props: RolePickerProps) => {
|
||||||
const [selectedRoles, updateSelectedRoles] = React.useState<string[]>(
|
const [selectedRoles, updateSelectedRoles] = React.useState<string[]>(
|
||||||
props.member.roles
|
props.member.roles
|
||||||
);
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isEqual(props.member.roles, selectedRoles)) {
|
||||||
|
updateSelectedRoles(props.member.roles);
|
||||||
|
}
|
||||||
|
}, [props.member.roles]);
|
||||||
|
|
||||||
const handleChange = (category: Category) => (role: Role) => (newState: boolean) => {
|
const handleChange = (category: Category) => (role: Role) => (newState: boolean) => {
|
||||||
if (category.type === CategoryType.Single) {
|
if (category.type === CategoryType.Single) {
|
||||||
updateSelectedRoles(
|
updateSelectedRoles(
|
||||||
|
@ -114,7 +112,7 @@ export const RolePicker = (props: RolePickerProps) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FaderOpacity
|
<FaderOpacity
|
||||||
isVisible={!arrayMatches(selectedRoles, props.member.roles)}
|
isVisible={xor(selectedRoles, props.member.roles).length !== 0}
|
||||||
>
|
>
|
||||||
<ResetSubmit
|
<ResetSubmit
|
||||||
onSubmit={() => props.onSubmit(selectedRoles)}
|
onSubmit={() => props.onSubmit(selectedRoles)}
|
||||||
|
|
|
@ -1,7 +1,15 @@
|
||||||
import { NextPage, NextPageContext } from 'next';
|
import { NextPage, NextPageContext } from 'next';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { PresentableGuild, UserGuildPermissions } from 'roleypoly/common/types';
|
import {
|
||||||
|
Member,
|
||||||
|
PresentableGuild,
|
||||||
|
Role,
|
||||||
|
RoleTransaction,
|
||||||
|
RoleUpdate,
|
||||||
|
TransactionType,
|
||||||
|
UserGuildPermissions,
|
||||||
|
} from 'roleypoly/common/types';
|
||||||
import { apiFetch } from 'roleypoly/common/utils/isomorphicFetch';
|
import { apiFetch } from 'roleypoly/common/utils/isomorphicFetch';
|
||||||
import { RolePickerTemplate } from 'roleypoly/design-system/templates/role-picker';
|
import { RolePickerTemplate } from 'roleypoly/design-system/templates/role-picker';
|
||||||
import { useAppShellProps } from 'roleypoly/providers/appShellData';
|
import { useAppShellProps } from 'roleypoly/providers/appShellData';
|
||||||
|
@ -10,9 +18,70 @@ type Props = {
|
||||||
data: PresentableGuild;
|
data: PresentableGuild;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createUpdatePayload = (
|
||||||
|
oldRoles: Role['id'][],
|
||||||
|
newRoles: Role['id'][]
|
||||||
|
): RoleTransaction[] => {
|
||||||
|
const transactions: RoleTransaction[] = [];
|
||||||
|
|
||||||
|
// Removes: old roles not in new roles
|
||||||
|
for (let oldID of oldRoles) {
|
||||||
|
if (!newRoles.includes(oldID)) {
|
||||||
|
transactions.push({
|
||||||
|
id: oldID,
|
||||||
|
action: TransactionType.Remove,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds: new roles not in old roles
|
||||||
|
for (let newID of newRoles) {
|
||||||
|
if (!oldRoles.includes(newID)) {
|
||||||
|
transactions.push({
|
||||||
|
id: newID,
|
||||||
|
action: TransactionType.Add,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions;
|
||||||
|
};
|
||||||
|
|
||||||
const RolePickerPage: NextPage<Props> = (props) => {
|
const RolePickerPage: NextPage<Props> = (props) => {
|
||||||
const { appShellProps } = useAppShellProps();
|
const { appShellProps } = useAppShellProps();
|
||||||
|
|
||||||
|
const [isPending, updatePending] = React.useState(false);
|
||||||
|
const [memberRoles, updateMemberRoles] = React.useState(props.data.member.roles);
|
||||||
|
|
||||||
|
const handlePickerSubmit = (guildID: string, oldRoles: Role['id'][]) => async (
|
||||||
|
newRoles: Role['id'][]
|
||||||
|
) => {
|
||||||
|
if (isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePending(true);
|
||||||
|
|
||||||
|
const payload: RoleUpdate = {
|
||||||
|
knownState: oldRoles,
|
||||||
|
transactions: createUpdatePayload(oldRoles, newRoles),
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchedMember = await apiFetch<Member>(`/update-roles/${guildID}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!patchedMember) {
|
||||||
|
console.error('role update failed', patchedMember);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePending(false);
|
||||||
|
updateMemberRoles(patchedMember.roles);
|
||||||
|
console.log('accepted', { patchedMember });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
|
@ -24,12 +93,10 @@ const RolePickerPage: NextPage<Props> = (props) => {
|
||||||
guild={props.data.guild}
|
guild={props.data.guild}
|
||||||
roles={props.data.roles}
|
roles={props.data.roles}
|
||||||
guildData={props.data.data}
|
guildData={props.data.data}
|
||||||
member={props.data.member}
|
member={{ ...props.data.member, roles: memberRoles }}
|
||||||
editable={props.data.guild.permissionLevel !== UserGuildPermissions.User}
|
editable={props.data.guild.permissionLevel !== UserGuildPermissions.User}
|
||||||
activeGuildId={props.data.id}
|
activeGuildId={props.data.id}
|
||||||
onSubmit={(i) => {
|
onSubmit={handlePickerSubmit(props.data.id, memberRoles)}
|
||||||
console.log(i);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2519,6 +2519,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||||
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
|
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
|
||||||
|
|
||||||
|
"@types/lodash@^4.14.165":
|
||||||
|
version "4.14.165"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f"
|
||||||
|
integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg==
|
||||||
|
|
||||||
"@types/markdown-to-jsx@^6.11.0":
|
"@types/markdown-to-jsx@^6.11.0":
|
||||||
version "6.11.3"
|
version "6.11.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.3.tgz#cdd1619308fecbc8be7e6a26f3751260249b020e"
|
resolved "https://registry.yarnpkg.com/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.3.tgz#cdd1619308fecbc8be7e6a26f3751260249b020e"
|
||||||
|
|
Loading…
Add table
Reference in a new issue