Working version of whole form

This commit is contained in:
ansible user/allowed to read system logs 2023-07-27 19:35:39 -07:00
parent 3741b37ff4
commit eb2752c4e0
20 changed files with 625 additions and 42 deletions

View file

@ -6,6 +6,7 @@
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/base": "^5.0.0-beta.8", "@mui/base": "^5.0.0-beta.8",
"@mui/icons-material": "^5.14.1",
"@mui/material": "^5.14.2", "@mui/material": "^5.14.2",
"@types/node": "^16.18.39", "@types/node": "^16.18.39",
"@types/react": "^18.2.16", "@types/react": "^18.2.16",

29
client/src/api/bridge.ts Normal file
View file

@ -0,0 +1,29 @@
export const getBridgeByStateToken = (callback: CallableFunction) => {
fetch('api/bridge')
.then(res => res.json())
.then((res) => {
console.log('bridge result', res)
if (res.status === 'success'){
console.log('successful get bridge')
callback(res.data)
}
})
}
export const setBridgeLabel = (label:string, callback: CallableFunction) => {
fetch('api/bridge',{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
Label: label
})
}).then(res => res.json())
.then((res) => {
if (res.status === "success") {
callback()
}
})
}

22
client/src/api/slack.ts Normal file
View file

@ -0,0 +1,22 @@
export const getSlackInstallURL = (callback: CallableFunction) => {
fetch('api/slack/install')
.then(res => res.json())
.then(res => {
console.log('Got slack url', res);
callback(res.data.url, res.data.state_token)
})
}
export const getSlackChannels = (callback: CallableFunction) => {
fetch('api/slack/channels')
.then(res => res.json())
.then(res => {
console.log('Got slack channels', res);
if (res.status === "success"){
callback(res.data.channels.sort())
}
})
}

View file

@ -0,0 +1,26 @@
import {Grid} from "@mui/material";
import {TextField} from "@mui/material";
export const JoinBridge = ({
bridge, setBridge
}) => {
const onSetLabel = (label) => {
setBridge({...bridge, Label:label})
}
return (
<Grid container spacing={2} columns={2}>
<Grid item xs={1}>
</Grid>
<Grid item xs={1}>
<TextField
onChange={(event) => {onSetLabel(event.target.value)}}>
</TextField>
</Grid>
</Grid>
)
}

View file

@ -1,25 +1,166 @@
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Accordion from '@mui/material/Accordion';
import AccordionDetails from '@mui/material/AccordionDetails';
import AccordionSummary from '@mui/material/AccordionSummary';
import {Group} from "../../types/group"; import {Group} from "../../types/group";
import {JoinStep} from './joinStep';
import {JoinPlatform} from "./joinPlatform"; import {JoinPlatform} from "./joinPlatform";
import {useState} from "react"; import {useState, useEffect} from "react";
import Grid from '@mui/material/Grid';
import TextField from "@mui/material/TextField";
import Button from '@mui/material/Button';
import {setBridgeLabel} from "../../api/bridge";
import {getSlackChannels} from "../../api/slack";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select"
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
export interface JoinFormProps { export interface JoinFormProps {
group: Group group: Group
} }
interface stepCompleteType {
login: boolean;
bridge: boolean;
channel: boolean;
}
export const JoinForm = ({group}: JoinFormProps) => { export const JoinForm = ({group}: JoinFormProps) => {
const [platform, setPlatform] = useState(); const [platform, setPlatform] = useState<string>();
const [channels, setChannels] = useState<string[]>();
const [selectedChannel, setSelectedChannel] = useState<string>();
const [bridge, setBridge] = useState<{
Label: string;
Protocol: string;
team_name: string;
}>();
const [stepComplete, setStepComplete] = useState<stepCompleteType>({
login: false,
bridge: false,
channel: false
});
useEffect(() => {
if (bridge !== undefined){
setStepComplete({...stepComplete, login:true})
}
if (channels === undefined){
getSlackChannels(setChannels)
}
}, [bridge])
const onSetBridge = (evt) => {
setBridge({...bridge, Label:evt.target.value})
}
const updateBridgeLabel = () => {
setBridgeLabel(bridge.Label, () => {setStepComplete({...stepComplete, bridge:true})})
}
const handleSelectChannel = (evt) => {
}
const joinChannel = () => {
}
return ( return (
<> <>
<header className={'section-header'}> <header className={'section-header'}>
Joining group: <code>{group.name}</code> Joining group: <code>{group.name}</code>
</header> </header>
<JoinPlatform <JoinStep
platformSetter = {setPlatform} title={"1) Login"}
></JoinPlatform> details={"Select your chat platform"}
</> id={'login'}
completed={stepComplete.login}
>
<JoinPlatform
platformSetter = {setPlatform}
bridgeSetter = {setBridge}
completeSetter= {setStepComplete}
/>
</JoinStep>
<JoinStep
title={"2) Set Bridge Label"}
details={"A short identifier shown before your messages"}
id={'bridge'}
disabled={!stepComplete.login}
completed={stepComplete.bridge}
>
<Grid container spacing={2} columns={4} alignItems="center">
<Grid item xs={1}>
<Typography>Label:</Typography>
</Grid>
<Grid item xs={2}>
<TextField
value={bridge?.Label}
onChange={onSetBridge}>
</TextField>
</Grid>
<Grid item xs={1}>
<Button
variant={"outlined"}
onClick={updateBridgeLabel}>
Update Label!
</Button>
</Grid>
</Grid>
</JoinStep>
<JoinStep
title={"3) Select a channel!"}
details={"The bot will join :)"}
id={'bridge'}
disabled={!stepComplete.login}
completed={stepComplete.channel}
>
<div className={"list-row"}>
<FormControl sx={{width: "50%"}}>
<InputLabel>Select Platform</InputLabel>
<Select
// value={platform}
onChange={(evt:any) => {setSelectedChannel(evt.target.value)}}
label={"Select Channel"}
>
{
channels ?
channels.map(chan => {
return(<MenuItem value={chan} key={chan}>{chan}</MenuItem>)
})
: undefined
}
</Select>
</FormControl>
<Button
variant={"outlined"}
onClick={joinChannel}>
Join Channel
</Button>
</div>
<Grid container spacing={2} columns={2} alignItems="center">
<Grid item xs={1}>
<Typography>Label:</Typography>
</Grid>
<Grid item xs={2}>
<TextField
value={bridge?.Label}
onChange={onSetBridge}>
</TextField>
</Grid>
<Grid item xs={1}>
<Button
variant={"outlined"}
onClick={updateBridgeLabel}>
Update Label!
</Button>
</Grid>
</Grid>
</JoinStep>
</>
) )
} }

View file

@ -2,42 +2,125 @@
Select which platform you're joining from! Select which platform you're joining from!
*/ */
import React, {useState} from 'react'; import React, {useEffect, useRef, useState} from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import InputLabel from '@mui/material/InputLabel'; import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl'; import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select'; import Select from '@mui/material/Select';
import Button from "@mui/material/Button";
import {getSlackInstallURL} from "../../api/slack";
import {getBridgeByStateToken} from "../../api/bridge";
import {JoinSlack} from "../panels/joinSlack"; import {JoinSlack} from "../panels/joinSlack";
const PLATFORMS = { enum PLATFORMS {
'Slack': JoinSlack Slack = 'Slack'
} }
export const JoinPlatform = ({ export const JoinPlatform = ({
platformSetter platformSetter,
bridgeSetter,
completeSetter
}) => { }) => {
const [platform, setPlatform] = useState() const [platform, setPlatform] = useState<PLATFORMS>();
const [installLink, setInstallLink] = useState();
const [stateToken, setStateToken] = useState();
const [bridge, setBridge] = useState();
const pingTimeout = useRef(null);
const handleSelect = (event) => { const handleSelect = (event) => {
setPlatform(event.target.value); setPlatform(event.target.value);
platformSetter(event.target.value); platformSetter(event.target.value);
} }
useEffect(() => {
if (platform === "Slack") {
console.log('Getting slack URL')
getSlackInstallURL(handleInstallLink)
}
}, [platform])
const handleInstallLink = (url, state_token) => {
setInstallLink(url);
setStateToken(state_token);
// pingForBridge()
}
// const pingForBridge = () =>{
// if (bridge === undefined){
// console.log('bridge is', bridge)
// getBridgeByStateToken(setBridge);
// setTimeout(pingForBridge, 1000);
// }
// }
useEffect(() => {
const pingForBridge = () => {
if (bridge === undefined) {
console.log('bridge is', bridge);
getBridgeByStateToken(setBridge);
pingTimeout.current = setTimeout(pingForBridge, 1000);
}
}
if (stateToken !== undefined) {
if (bridge === undefined){
pingForBridge()
// pingTimeout.current = setInterval(pingForBridge, 1000)
}
}
return () => {clearInterval(pingTimeout.current)}
// if (stateToken !== undefined){
// if (bridge === undefined){
// console.log('bridge is', bridge)
// pingTimeout.current = setTimeout(() => {
// getBridgeByStateToken(setBridge);
// }, 1000)
// // setTimeout(pingForBridge, 1000);
// }
// }
}, [stateToken, bridge])
useEffect(() => {
bridgeSetter(bridge)
}, [bridge])
// useEffect(() => {
//
// }, [installLink])
const openInstallTab = () => {
window.open(installLink, '_blank').focus();
}
return( return(
<div className={"list-row"}> <div className={"list-row"}>
<FormControl fullWidth> <FormControl sx={{width: "50%"}}>
<InputLabel>Select Platform</InputLabel> <InputLabel>Select Platform</InputLabel>
<Select <Select
value={platform} // value={platform}
onChange={handleSelect} onChange={handleSelect}
label={"Select Platform"} label={"Select Platform"}
> >
<MenuItem value={'slack'}>Slack</MenuItem> <MenuItem value={'Slack'}>Slack</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
{
installLink && platform == "Slack" ?
<Button
variant={"outlined"}
onClick={openInstallTab}>
Add to Slack
</Button>
: <div style={{width: "50%"}}></div>
}
</div> </div>
) )

View file

@ -0,0 +1,60 @@
import * as React from 'react';
import Accordion from '@mui/material/Accordion';
import AccordionDetails from '@mui/material/AccordionDetails';
import AccordionSummary from '@mui/material/AccordionSummary';
import Typography from '@mui/material/Typography';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
import TaskAltIcon from '@mui/icons-material/TaskAlt';
import {useState} from "react";
export interface JoinStepProps {
children: any;
id: string;
title?: string;
details?: string;
completed?: boolean;
disabled?: boolean
}
export function JoinStep(
{
children,
id,
title = '',
details = '',
completed = false,
disabled = false
}: JoinStepProps){
return(
<Accordion
disabled={disabled}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1bh-content"
id="panel1bh-header"
>
<Typography sx={{ width: '33%', flexShrink: 0 }}>
{ title }
</Typography>
<Typography sx={{ color: 'text.secondary', flexGrow: 1 }}>
{ details }
</Typography>
{
completed ?
<TaskAltIcon color={"success"}/>
:
<RadioButtonUncheckedIcon/>
}
</AccordionSummary>
<AccordionDetails>
<Typography>
{ children }
</Typography>
</AccordionDetails>
</Accordion>
)
}

View file

@ -1,3 +1,19 @@
.list-row {
display: flex;
justify-content: space-around;
align-items: center;
gap: 2em;
&>div {
flex-basis: 50%;
}
.MuiButton-root {
flex-basis: 50%;
flex-grow: 0;
height: 56px;
}
}
.InputSlot { .InputSlot {
min-width: 320px; min-width: 320px;
width: 100%; width: 100%;

View file

@ -1,8 +1,12 @@
.section-header { .section-header {
color: $color-text; color: $color-text;
font-size: 2rem; font: {
size: 1.5rem;
weight: bold;
}
margin: { margin: {
top: 1rem; top: 2rem;
bottom: 1rem;
} }
} }

View file

@ -11,6 +11,7 @@ POSTGRES_DB=
SLACK_CLIENT_ID= SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET= SLACK_CLIENT_SECRET=
SLACK_SIGNING_SECRET= SLACK_SIGNING_SECRET=
SLACK_STATE_SECRET=
ADMIN_TOKEN= ADMIN_TOKEN=

View file

@ -10,7 +10,8 @@ export default {
slackConfig: { slackConfig: {
client_id: 'SLACK_CLIENT_ID', client_id: 'SLACK_CLIENT_ID',
client_secret: 'SLACK_CLIENT_SECRET', client_secret: 'SLACK_CLIENT_SECRET',
signing_secret: 'SLACK_SIGNING_SECRET' signing_secret: 'SLACK_SIGNING_SECRET',
state_secret: 'SLACK_STATE_SECRET'
}, },
admin_token: 'ADMIN_TOKEN', admin_token: 'ADMIN_TOKEN',
cookies:{ cookies:{

View file

@ -1,5 +1,3 @@
require('dotenv').config(); require('dotenv').config();
import express, { NextFunction, Request, Response } from 'express'; import express, { NextFunction, Request, Response } from 'express';
import config from 'config'; import config from 'config';
@ -13,6 +11,7 @@ import {cookieMiddleware} from "./middleware/cookies";
import groupRoutes from "./routes/group.routes"; import groupRoutes from "./routes/group.routes";
import slackRoutes from "./routes/slack.routes"; import slackRoutes from "./routes/slack.routes";
import authRoutes from "./routes/auth.routes"; import authRoutes from "./routes/auth.routes";
import bridgeRoutes from './routes/bridge.routes';
@ -40,6 +39,7 @@ AppDataSource.initialize()
// }); // });
app.use('/slack', slackRoutes); app.use('/slack', slackRoutes);
app.use('/bridge', bridgeRoutes)
app.all('*', (req: Request, res: Response, next: NextFunction) => { app.all('*', (req: Request, res: Response, next: NextFunction) => {
next(new AppError(404, `Route ${req.originalUrl} not found`)); next(new AppError(404, `Route ${req.originalUrl} not found`));

View file

@ -0,0 +1,65 @@
import {AppDataSource} from "../db/data-source";
import {Bridge} from "../entities/bridge.entity";
import {Request, Response} from "express";
import {UpdateBridgeInput} from "../schemas/bridge.schema";
const bridgeRepository = AppDataSource.getRepository(Bridge)
export const getBridgeHandler = async(
req: Request,
res: Response
) => {
if (req.session.state_token){
let bridge = await bridgeRepository.findOne({
where: {state_token: req.session.state_token},
select: {
Protocol: true,
Label: true,
team_name: true
}
})
if (!bridge){
res.status(404).json({
status: 'failure',
message: 'No matching bridge found'
})
return
}
res.status(200).json({
status: 'success',
data: bridge
})
} else {
res.status(403).json({
status: 'failure',
message: 'No state token found'
})
}
}
export const setBridgeHandler = async(
req: Request<{}, {}, UpdateBridgeInput>,
res: Response
) => {
if (req.session.state_token) {
let bridge = await bridgeRepository.findOneBy({state_token: req.session.state_token})
if (!bridge){
res.status(404).json({
status: 'failure',
message: 'No matching bridge found'
})
return
}
bridge.Label = req.body.Label;
bridge.save()
res.status(200).json({
status:'success'
})
} else {
res.status(403).json({
status: 'failure',
message: 'No state token found'
})
}
}

View file

@ -11,11 +11,13 @@ const scopes = ['bot', 'channels:write', 'chat:write:bot', 'chat:write:user', 'u
const bridgeRepository = AppDataSource.getRepository(Bridge) const bridgeRepository = AppDataSource.getRepository(Bridge)
const groupRepository = AppDataSource.getRepository(Group) const groupRepository = AppDataSource.getRepository(Group)
const SLACK_COOKIE_NAME = "slack-oauth-state";
const slackConfig = config.get<{ const slackConfig = config.get<{
client_id: string, client_id: string,
client_secret: string, client_secret: string,
signing_secret: string signing_secret: string,
state_secret: string
}>('slackConfig'); }>('slackConfig');
const installer = new InstallProvider({ const installer = new InstallProvider({
@ -23,9 +25,11 @@ const installer = new InstallProvider({
clientSecret: slackConfig.client_secret, clientSecret: slackConfig.client_secret,
authVersion: 'v1', authVersion: 'v1',
scopes, scopes,
stateSecret: randomUUID(), // stateSecret: slackConfig.state_secret,
installationStore: new FileInstallationStore(), stateVerification: false,
// installationStore: new FileInstallationStore(),
logLevel: LogLevel.DEBUG, logLevel: LogLevel.DEBUG,
// stateCookieName: SLACK_COOKIE_NAME
}) })
@ -45,18 +49,24 @@ export const SlackInstallLinkHandler = async(
req: Request, req: Request,
res: Response res: Response
) => { ) => {
let login_token = randomUUID() let state_token = randomUUID()
req.session.state_token = state_token;
const url = await installer.generateInstallUrl({ const url = await installer.generateInstallUrl(
scopes, {
metadata: {token: login_token, group: req.query.group} scopes,
}); metadata: {token: state_token, group: req.query.group}
},
true,
state_token
);
res.cookie(SLACK_COOKIE_NAME, state_token, { maxAge: 60*5 })
res.status(200).json({ res.status(200).json({
status: 'success', status: 'success',
data: { data: {
url, url,
login_token state_token
} }
}) })
@ -72,23 +82,36 @@ export const SlackCallbackHandler = async(
// using custom success and failure handlers // using custom success and failure handlers
const callbackOptions = { const callbackOptions = {
success: async (installation, installOptions, req, res) => { success: async (installation, installOptions, req, res) => {
console.log(installation, installOptions, req.body, req.content, req.query, req.params) // console.log(installation, installOptions, req.body, req.content, req.query, req.params)
console.log(installation.team.id, installation.team.name, installation.bot.token); // console.log(installation.team.id, installation.team.name, installation.bot.token);
let bridge = await bridgeRepository.create({ let bridge_data = {
'Protocol': 'slack', 'Protocol': 'slack',
'Label': installation.metadata.name, 'Label': installation.team.name,
'team_id': installation.team.id, 'team_id': installation.team.id,
'team_name': installation.team.name, 'team_name': installation.team.name,
'state_token': req.session.state_token,
'Token': installation.bot.token 'Token': installation.bot.token
}); }
let result = await bridgeRepository.save(bridge);
// check if we have an entity
res.send(result); let bridge = await bridgeRepository.findOneBy({Token: installation.bot.token})
let result
if (!bridge){
bridge = await bridgeRepository.create(bridge_data);
await bridgeRepository.save(bridge);
console.log('created bridge')
} else {
await bridgeRepository.update(
{Token: installation.bot.token},
{state_token: req.session.state_token})
console.log('updated bridge')
}
res.send('<html><body><h1>Success! Return to the chatbridge login window.</h1><h3>This tab will close in 3 seconds...</h3><script>window.setTimeout(window.close, 3000)</script></body>')
}, },
failure: (error, installOptions , req, res) => { failure: (error, installOptions , req, res) => {
res.send('failure'); console.log(error, installOptions, req.body, req.content, req.query, req.params)
res.send('failure. Something is broken about chatbridge :(');
}, },
} }
@ -96,3 +119,41 @@ export const SlackCallbackHandler = async(
await installer.handleCallback(req, res, callbackOptions); await installer.handleCallback(req, res, callbackOptions);
} }
export const getChannelsHandler = async(
req: Request,
res: Response
) => {
let bridge = await bridgeRepository.findOneBy({state_token: req.session.state_token})
console.log('bridge data', bridge)
try {
fetch('https://slack.com/api/conversations.list', {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${bridge.Token}`
}
}).then(result => result.json())
.then((result) => {
console.log('channels',res)
let channels = result.channels.map(
(chan: { name: string; }) => {
return chan.name
}
);
res.status(200).json({
status: 'success',
data: {
channels
}
})
})
} catch {
return res.status(502).json({
status: 'failure',
message: 'couldnt get lists!'
})
}
}

View file

@ -27,6 +27,10 @@ export class Bridge extends Model {
@Column({nullable:true}) @Column({nullable:true})
team_name: string; team_name: string;
// Used to fetch the bridge data from the client while installing
@Column()
state_token: string;
@Column({ @Column({
unique: true unique: true
}) })

View file

@ -2,6 +2,11 @@ import cookieSession from 'cookie-session';
import config from 'config'; import config from 'config';
import {Request, Response, NextFunction} from "express"; import {Request, Response, NextFunction} from "express";
import { tokenHasher, hashed_token } from "../auth"; import { tokenHasher, hashed_token } from "../auth";
import {AppDataSource} from "../db/data-source";
import {Bridge} from "../entities/bridge.entity";
const bridgeRepository = AppDataSource.getRepository(Bridge)
const cookieConfig = config.get<{ const cookieConfig = config.get<{
'key1': string, 'key1': string,
@ -33,3 +38,21 @@ export const requireAdmin = (req: Request, res: Response, next: NextFunction) =>
} }
} }
}; };
export const requireStateToken = async(req: Request, res: Response, next: NextFunction) => {
if (req.session.state_token) {
let bridge = await bridgeRepository.findOneBy({state_token: req.session.state_token})
if (!bridge){
return res.status(404).json({
status: 'failure',
message: 'No matching bridge found'
})
}
next()
} else {
return res.status(403).json({
status: 'failure',
message: 'No state token found'
})
}
}

View file

@ -0,0 +1,20 @@
import express from 'express';
import {
getBridgeHandler,
setBridgeHandler
} from "../controllers/bridge.controller";
import {
updateBridgeSchema
} from "../schemas/bridge.schema";
import { validate } from "../middleware/validate";
const router = express.Router();
router.route('/')
.get(getBridgeHandler)
.post(validate(updateBridgeSchema), setBridgeHandler)
export default router

View file

@ -1,16 +1,24 @@
import express from 'express'; import express from 'express';
import { import {
SlackInstallHandler, SlackInstallLinkHandler,
SlackCallbackHandler SlackCallbackHandler,
getChannelsHandler
} from '../controllers/slack.controller' } from '../controllers/slack.controller'
import {
requireStateToken
} from "../middleware/cookies";
const router = express.Router(); const router = express.Router();
router.route('/install') router.route('/install')
.get(SlackInstallHandler) .get(SlackInstallLinkHandler)
router.route('/oauth_redirect') router.route('/oauth_redirect')
.get(SlackCallbackHandler) .get(SlackCallbackHandler)
router.route('/channels')
.get(requireStateToken, getChannelsHandler)
export default router export default router

View file

@ -0,0 +1,11 @@
import { object, string, TypeOf } from 'zod';
export const updateBridgeSchema = object({
body: object({
Label: string({
required_error: "Label for bridge required to update bridge"
})
})
})
export type UpdateBridgeInput = TypeOf<typeof updateBridgeSchema>['body'];

View file

@ -1942,6 +1942,13 @@
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.2.tgz#d8fcacdb1d37e621fce33ea808180fa5a590f908" resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.2.tgz#d8fcacdb1d37e621fce33ea808180fa5a590f908"
integrity sha512-x+c/MgDL1t/IIy5lDbMlrDouFG5DYZbl3DP4dbbuhlpPFBnE9glYwmJEee/orVHQpOPwLxCAIWQs+2DKSaBVWQ== integrity sha512-x+c/MgDL1t/IIy5lDbMlrDouFG5DYZbl3DP4dbbuhlpPFBnE9glYwmJEee/orVHQpOPwLxCAIWQs+2DKSaBVWQ==
"@mui/icons-material@^5.14.1":
version "5.14.1"
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.14.1.tgz#2f145c15047a0c7f01353ce620cb88276dadba9e"
integrity sha512-xV/f26muQqtWzerzOIdGPrXoxp/OKaE2G2Wp9gnmG47mHua5Slup/tMc3fA4ZYUreGGrK6+tT81TEvt1Wsng8Q==
dependencies:
"@babel/runtime" "^7.22.6"
"@mui/material@^5.14.2": "@mui/material@^5.14.2":
version "5.14.2" version "5.14.2"
resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.14.2.tgz#13b113489a61021145d62e0383912ca487a46375" resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.14.2.tgz#13b113489a61021145d62e0383912ca487a46375"