diff --git a/client/package.json b/client/package.json index 4212c53..9a1392d 100644 --- a/client/package.json +++ b/client/package.json @@ -6,6 +6,7 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@mui/base": "^5.0.0-beta.8", + "@mui/icons-material": "^5.14.1", "@mui/material": "^5.14.2", "@types/node": "^16.18.39", "@types/react": "^18.2.16", diff --git a/client/src/api/bridge.ts b/client/src/api/bridge.ts new file mode 100644 index 0000000..f5818ee --- /dev/null +++ b/client/src/api/bridge.ts @@ -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() + } + }) +} \ No newline at end of file diff --git a/client/src/api/slack.ts b/client/src/api/slack.ts new file mode 100644 index 0000000..641561f --- /dev/null +++ b/client/src/api/slack.ts @@ -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()) + } + }) + +} \ No newline at end of file diff --git a/client/src/components/join/joinBridge.tsx b/client/src/components/join/joinBridge.tsx new file mode 100644 index 0000000..34beeaf --- /dev/null +++ b/client/src/components/join/joinBridge.tsx @@ -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 ( + + + + + + {onSetLabel(event.target.value)}}> + + + + +) + +} \ No newline at end of file diff --git a/client/src/components/join/joinForm.tsx b/client/src/components/join/joinForm.tsx index b8e69f3..8e766ba 100644 --- a/client/src/components/join/joinForm.tsx +++ b/client/src/components/join/joinForm.tsx @@ -1,25 +1,166 @@ 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 {JoinStep} from './joinStep'; 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 { group: Group } +interface stepCompleteType { + login: boolean; + bridge: boolean; + channel: boolean; +} + export const JoinForm = ({group}: JoinFormProps) => { - const [platform, setPlatform] = useState(); + const [platform, setPlatform] = useState(); + const [channels, setChannels] = useState(); + const [selectedChannel, setSelectedChannel] = useState(); + const [bridge, setBridge] = useState<{ + Label: string; + Protocol: string; + team_name: string; + }>(); + const [stepComplete, setStepComplete] = useState({ + 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 ( <>
Joining group: {group.name}
- - + + + + + + + Label: + + + + + + + + + + + +
+ + Select Platform + + + +
+ + + Label: + + + + + + + + + +
+ ) } \ No newline at end of file diff --git a/client/src/components/join/joinPlatform.tsx b/client/src/components/join/joinPlatform.tsx index 90d04b6..74b01a6 100644 --- a/client/src/components/join/joinPlatform.tsx +++ b/client/src/components/join/joinPlatform.tsx @@ -2,42 +2,125 @@ 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 InputLabel from '@mui/material/InputLabel'; import MenuItem from '@mui/material/MenuItem'; import FormControl from '@mui/material/FormControl'; 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"; -const PLATFORMS = { - 'Slack': JoinSlack +enum PLATFORMS { + Slack = 'Slack' } export const JoinPlatform = ({ - platformSetter + platformSetter, + bridgeSetter, + completeSetter }) => { - const [platform, setPlatform] = useState() + const [platform, setPlatform] = useState(); + const [installLink, setInstallLink] = useState(); + const [stateToken, setStateToken] = useState(); + const [bridge, setBridge] = useState(); + + const pingTimeout = useRef(null); const handleSelect = (event) => { setPlatform(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(
- + Select Platform + { + installLink && platform == "Slack" ? + + :
+ }
) diff --git a/client/src/components/join/joinStep.tsx b/client/src/components/join/joinStep.tsx new file mode 100644 index 0000000..83adeb9 --- /dev/null +++ b/client/src/components/join/joinStep.tsx @@ -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( + + } + aria-controls="panel1bh-content" + id="panel1bh-header" + > + + { title } + + + { details } + + { + completed ? + + : + + } + + + + { children } + + + + ) + +} \ No newline at end of file diff --git a/client/src/sass/input.scss b/client/src/sass/input.scss index 22501ef..052e8fc 100644 --- a/client/src/sass/input.scss +++ b/client/src/sass/input.scss @@ -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 { min-width: 320px; width: 100%; diff --git a/client/src/sass/typography.scss b/client/src/sass/typography.scss index 4f4a696..d05b545 100644 --- a/client/src/sass/typography.scss +++ b/client/src/sass/typography.scss @@ -1,8 +1,12 @@ .section-header { color: $color-text; - font-size: 2rem; + font: { + size: 1.5rem; + weight: bold; + } margin: { - top: 1rem; + top: 2rem; + bottom: 1rem; } } diff --git a/example.env b/example.env index 2948efd..2731a88 100644 --- a/example.env +++ b/example.env @@ -11,6 +11,7 @@ POSTGRES_DB= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= SLACK_SIGNING_SECRET= +SLACK_STATE_SECRET= ADMIN_TOKEN= diff --git a/server/config/custom-environment-variables.ts b/server/config/custom-environment-variables.ts index 76a9255..5b322a8 100755 --- a/server/config/custom-environment-variables.ts +++ b/server/config/custom-environment-variables.ts @@ -10,7 +10,8 @@ export default { slackConfig: { client_id: 'SLACK_CLIENT_ID', client_secret: 'SLACK_CLIENT_SECRET', - signing_secret: 'SLACK_SIGNING_SECRET' + signing_secret: 'SLACK_SIGNING_SECRET', + state_secret: 'SLACK_STATE_SECRET' }, admin_token: 'ADMIN_TOKEN', cookies:{ diff --git a/server/src/app.ts b/server/src/app.ts index 0aedfe3..645a890 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,5 +1,3 @@ - - require('dotenv').config(); import express, { NextFunction, Request, Response } from 'express'; import config from 'config'; @@ -13,6 +11,7 @@ import {cookieMiddleware} from "./middleware/cookies"; import groupRoutes from "./routes/group.routes"; import slackRoutes from "./routes/slack.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('/bridge', bridgeRoutes) app.all('*', (req: Request, res: Response, next: NextFunction) => { next(new AppError(404, `Route ${req.originalUrl} not found`)); diff --git a/server/src/controllers/bridge.controller.ts b/server/src/controllers/bridge.controller.ts index e69de29..353859e 100644 --- a/server/src/controllers/bridge.controller.ts +++ b/server/src/controllers/bridge.controller.ts @@ -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' + }) + } +} \ No newline at end of file diff --git a/server/src/controllers/slack.controller.ts b/server/src/controllers/slack.controller.ts index cef9019..33c6f4c 100644 --- a/server/src/controllers/slack.controller.ts +++ b/server/src/controllers/slack.controller.ts @@ -11,11 +11,13 @@ const scopes = ['bot', 'channels:write', 'chat:write:bot', 'chat:write:user', 'u const bridgeRepository = AppDataSource.getRepository(Bridge) const groupRepository = AppDataSource.getRepository(Group) +const SLACK_COOKIE_NAME = "slack-oauth-state"; const slackConfig = config.get<{ client_id: string, client_secret: string, - signing_secret: string + signing_secret: string, + state_secret: string }>('slackConfig'); const installer = new InstallProvider({ @@ -23,9 +25,11 @@ const installer = new InstallProvider({ clientSecret: slackConfig.client_secret, authVersion: 'v1', scopes, - stateSecret: randomUUID(), - installationStore: new FileInstallationStore(), + // stateSecret: slackConfig.state_secret, + stateVerification: false, + // installationStore: new FileInstallationStore(), logLevel: LogLevel.DEBUG, + // stateCookieName: SLACK_COOKIE_NAME }) @@ -45,18 +49,24 @@ export const SlackInstallLinkHandler = async( req: Request, res: Response ) => { - let login_token = randomUUID() + let state_token = randomUUID() + req.session.state_token = state_token; - const url = await installer.generateInstallUrl({ - scopes, - metadata: {token: login_token, group: req.query.group} - }); + const url = await installer.generateInstallUrl( + { + 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({ status: 'success', data: { url, - login_token + state_token } }) @@ -72,27 +82,78 @@ export const SlackCallbackHandler = async( // using custom success and failure handlers const callbackOptions = { success: async (installation, installOptions, req, res) => { - console.log(installation, installOptions, req.body, req.content, req.query, req.params) - console.log(installation.team.id, installation.team.name, installation.bot.token); - let bridge = await bridgeRepository.create({ + // console.log(installation, installOptions, req.body, req.content, req.query, req.params) + // console.log(installation.team.id, installation.team.name, installation.bot.token); + let bridge_data = { 'Protocol': 'slack', - 'Label': installation.metadata.name, + 'Label': installation.team.name, 'team_id': installation.team.id, 'team_name': installation.team.name, + 'state_token': req.session.state_token, 'Token': installation.bot.token - }); - let result = await bridgeRepository.save(bridge); + } - - res.send(result); + // check if we have an entity + 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('

Success! Return to the chatbridge login window.

This tab will close in 3 seconds...

') }, 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 :('); }, } await installer.handleCallback(req, res, callbackOptions); -} \ No newline at end of file +} + +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!' + }) + } + +} diff --git a/server/src/entities/bridge.entity.ts b/server/src/entities/bridge.entity.ts index 0f4e098..e821bd2 100644 --- a/server/src/entities/bridge.entity.ts +++ b/server/src/entities/bridge.entity.ts @@ -27,6 +27,10 @@ export class Bridge extends Model { @Column({nullable:true}) team_name: string; + // Used to fetch the bridge data from the client while installing + @Column() + state_token: string; + @Column({ unique: true }) diff --git a/server/src/middleware/cookies.ts b/server/src/middleware/cookies.ts index f4d67b4..35a73c1 100644 --- a/server/src/middleware/cookies.ts +++ b/server/src/middleware/cookies.ts @@ -2,6 +2,11 @@ import cookieSession from 'cookie-session'; import config from 'config'; import {Request, Response, NextFunction} from "express"; 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<{ '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' + }) + } +} diff --git a/server/src/routes/bridge.routes.ts b/server/src/routes/bridge.routes.ts new file mode 100644 index 0000000..cbfe01c --- /dev/null +++ b/server/src/routes/bridge.routes.ts @@ -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 \ No newline at end of file diff --git a/server/src/routes/slack.routes.ts b/server/src/routes/slack.routes.ts index a830ece..e3087ae 100644 --- a/server/src/routes/slack.routes.ts +++ b/server/src/routes/slack.routes.ts @@ -1,16 +1,24 @@ import express from 'express'; import { - SlackInstallHandler, - SlackCallbackHandler + SlackInstallLinkHandler, + SlackCallbackHandler, + getChannelsHandler } from '../controllers/slack.controller' +import { + requireStateToken +} from "../middleware/cookies"; + const router = express.Router(); router.route('/install') - .get(SlackInstallHandler) + .get(SlackInstallLinkHandler) router.route('/oauth_redirect') .get(SlackCallbackHandler) +router.route('/channels') + .get(requireStateToken, getChannelsHandler) + export default router \ No newline at end of file diff --git a/server/src/schemas/bridge.schema.ts b/server/src/schemas/bridge.schema.ts new file mode 100644 index 0000000..545fb81 --- /dev/null +++ b/server/src/schemas/bridge.schema.ts @@ -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['body']; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 22b296e..d1435d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1942,6 +1942,13 @@ 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== +"@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": version "5.14.2" resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.14.2.tgz#13b113489a61021145d62e0383912ca487a46375"