Add invite links with role based access control

This commit is contained in:
Max Goodhart
2020-08-23 23:25:36 -07:00
parent 83621cec9e
commit 5063a95ab7
8 changed files with 605 additions and 150 deletions

8
package-lock.json generated
View File

@@ -2948,6 +2948,14 @@
} }
} }
}, },
"base-x": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.8.tgz",
"integrity": "sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA==",
"requires": {
"safe-buffer": "^5.0.1"
}
},
"base64-js": { "base64-js": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",

View File

@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@repeaterjs/repeater": "^3.0.1", "@repeaterjs/repeater": "^3.0.1",
"base-x": "^3.0.8",
"chokidar": "^3.4.0", "chokidar": "^3.4.0",
"color": "^3.1.2", "color": "^3.1.2",
"dank-twitch-irc": "^3.3.0", "dank-twitch-irc": "^3.3.0",

144
src/node/auth.js Normal file
View File

@@ -0,0 +1,144 @@
import EventEmitter from 'events'
import { randomBytes, scrypt as scryptCb } from 'crypto'
import { promisify } from 'util'
import { validRoles } from '../roles'
const scrypt = promisify(scryptCb)
const base62 = require('base-x')(
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
)
function rand62(len) {
return base62.encode(randomBytes(len))
}
async function hashToken62(token, salt) {
const hashBuffer = await scrypt(token, salt, 24)
return base62.encode(hashBuffer)
}
// Wrapper for state data to facilitate role-scoped data access.
export class StateWrapper {
constructor(value) {
this._value = value
}
toJSON() {
return '<state data>'
}
view(role) {
const {
config,
auth,
streams,
customStreams,
views,
streamdelay,
} = this._value
const state = {
config,
streams,
customStreams,
views,
streamdelay,
}
if (role === 'admin') {
state.auth = auth
}
return state
}
update(value) {
this._value = { ...this._value, ...value }
}
// Unprivileged getter
get info() {
return this.view()
}
}
export class Auth extends EventEmitter {
constructor({ adminUsername, adminPassword }) {
super()
this.adminUsername = adminUsername
this.adminPassword = adminPassword
this.salt = rand62(16)
this.tokensById = new Map()
this.tokensByHash = new Map()
}
getState() {
const toTokenInfo = ({ id, name, role }) => ({ id, name, role })
return {
invites: [...this.tokensById.values()]
.filter((t) => t.kind === 'invite')
.map(toTokenInfo),
sessions: [...this.tokensById.values()]
.filter((t) => t.kind === 'session')
.map(toTokenInfo),
}
}
emitState() {
this.emit('state', this.getState())
}
admin() {
return { id: 'admin', kind: 'admin', name: 'admin', role: 'admin' }
}
async validateToken(secret) {
const tokenHash = await hashToken62(secret, this.salt)
const tokenData = this.tokensByHash.get(tokenHash)
if (!tokenData) {
return null
}
return {
id: tokenData.id,
kind: tokenData.kind,
role: tokenData.role,
name: tokenData.name,
}
}
async createToken({ kind, role, name }) {
if (!validRoles.has(role)) {
throw new Error(`invalid role: ${role}`)
}
let id = rand62(8)
// Regenerate in case of an id collision
while (this.tokensById.has(id)) {
id = rand62(8)
}
const secret = rand62(24)
const tokenHash = await hashToken62(secret, this.salt)
const tokenData = {
id,
tokenHash,
kind,
role,
name,
}
this.tokensById.set(id, tokenData)
this.tokensByHash.set(tokenHash, tokenData)
this.emitState()
console.log(`Created ${kind} token:`, { id, role, name })
return secret
}
deleteToken(tokenId) {
const tokenData = this.tokensById.get(tokenId)
if (!tokenData) {
return
}
this.tokensById.delete(tokenData.id)
this.tokensByHash.delete(tokenData.tokenHash)
this.emitState()
}
}

View File

@@ -2,7 +2,6 @@ import fs from 'fs'
import yargs from 'yargs' import yargs from 'yargs'
import TOML from '@iarna/toml' import TOML from '@iarna/toml'
import * as Y from 'yjs' import * as Y from 'yjs'
import { create as createJSONDiffPatch } from 'jsondiffpatch'
import { Repeater } from '@repeaterjs/repeater' import { Repeater } from '@repeaterjs/repeater'
import { app, shell, session, BrowserWindow } from 'electron' import { app, shell, session, BrowserWindow } from 'electron'
@@ -14,6 +13,7 @@ import {
markDataSource, markDataSource,
combineDataSources, combineDataSources,
} from './data' } from './data'
import { Auth, StateWrapper } from './auth'
import StreamWindow from './StreamWindow' import StreamWindow from './StreamWindow'
import TwitchBot from './TwitchBot' import TwitchBot from './TwitchBot'
import StreamdelayClient from './StreamdelayClient' import StreamdelayClient from './StreamdelayClient'
@@ -213,21 +213,27 @@ async function main() {
}) })
streamWindow.init() streamWindow.init()
const auth = new Auth({
adminUsername: argv.control.username,
adminPassword: argv.control.password,
})
let browseWindow = null let browseWindow = null
let twitchBot = null let twitchBot = null
let streamdelayClient = null let streamdelayClient = null
let clientState = { let clientState = new StateWrapper({
config: { config: {
width: argv.window.width, width: argv.window.width,
height: argv.window.height, height: argv.window.height,
gridCount: argv.grid.count, gridCount: argv.grid.count,
}, },
auth: auth.getState(),
streams: [], streams: [],
customStreams: [], customStreams: [],
views: [], views: [],
streamdelay: null, streamdelay: null,
} })
const stateDoc = new Y.Doc() const stateDoc = new Y.Doc()
const viewsState = stateDoc.getMap('views') const viewsState = stateDoc.getMap('views')
@@ -241,7 +247,7 @@ async function main() {
viewsState.observeDeep(() => { viewsState.observeDeep(() => {
const viewContentMap = new Map() const viewContentMap = new Map()
for (const [key, viewData] of viewsState) { for (const [key, viewData] of viewsState) {
const stream = clientState.streams.find( const stream = clientState.info.streams.find(
(s) => s._id === viewData.get('streamId'), (s) => s._id === viewData.get('streamId'),
) )
if (!stream) { if (!stream) {
@@ -257,7 +263,7 @@ async function main() {
const getInitialState = () => clientState const getInitialState = () => clientState
let broadcast = () => {} let broadcast = () => {}
const onMessage = (msg) => { const onMessage = async (msg, respond) => {
if (msg.type === 'set-listening-view') { if (msg.type === 'set-listening-view') {
streamWindow.setListeningView(msg.viewIdx) streamWindow.setListeningView(msg.viewIdx)
} else if (msg.type === 'set-view-blurred') { } else if (msg.type === 'set-view-blurred') {
@@ -294,26 +300,27 @@ async function main() {
} }
} else if (msg.type === 'set-stream-censored' && streamdelayClient) { } else if (msg.type === 'set-stream-censored' && streamdelayClient) {
streamdelayClient.setCensored(msg.isCensored) streamdelayClient.setCensored(msg.isCensored)
} else if (msg.type === 'create-invite') {
const secret = await auth.createToken({
kind: 'invite',
role: msg.role,
name: msg.name,
})
respond({ name: msg.name, secret })
} else if (msg.type === 'delete-token') {
auth.deleteToken(msg.tokenId)
} }
} }
const stateDiff = createJSONDiffPatch({
objectHash: (obj, idx) => obj._id || `$$index:${idx}`,
})
function updateState(newState) { function updateState(newState) {
const lastClientState = clientState clientState.update(newState)
clientState = { ...clientState, ...newState }
const delta = stateDiff.diff(lastClientState, clientState)
if (!delta) {
return
}
broadcast({ broadcast({
type: 'state-delta', type: 'state',
delta, state: clientState,
}) })
streamWindow.send('state', clientState) streamWindow.send('state', clientState.info)
if (twitchBot) { if (twitchBot) {
twitchBot.onState(clientState) twitchBot.onState(clientState.info)
} }
} }
@@ -325,8 +332,7 @@ async function main() {
url: argv.control.address, url: argv.control.address,
hostname: argv.control.hostname, hostname: argv.control.hostname,
port: argv.control.port, port: argv.control.port,
username: argv.control.username, auth,
password: argv.control.password,
getInitialState, getInitialState,
onMessage, onMessage,
stateDoc, stateDoc,
@@ -360,6 +366,10 @@ async function main() {
process.exit(0) process.exit(0)
}) })
auth.on('state', (authState) => {
updateState({ auth: authState })
})
const dataSources = [ const dataSources = [
...argv.data['json-url'].map((url) => ...argv.data['json-url'].map((url) =>
markDataSource(pollDataURL(url, argv.data.interval), 'json-url'), markDataSource(pollDataURL(url, argv.data.interval), 'json-url'),

View File

@@ -6,23 +6,26 @@ import http from 'http'
import https from 'https' import https from 'https'
import simpleCert from 'node-simple-cert' import simpleCert from 'node-simple-cert'
import Koa from 'koa' import Koa from 'koa'
import auth from 'koa-basic-auth' import basicAuth from 'koa-basic-auth'
import route from 'koa-route' import route from 'koa-route'
import serveStatic from 'koa-static' import serveStatic from 'koa-static'
import views from 'koa-views' import views from 'koa-views'
import websocket from 'koa-easy-ws' import websocket from 'koa-easy-ws'
import WebSocket from 'ws'
import * as Y from 'yjs' import * as Y from 'yjs'
import { create as createJSONDiffPatch } from 'jsondiffpatch'
import { roleCan } from '../roles'
const SESSION_COOKIE_NAME = 's'
const webDistPath = path.join(app.getAppPath(), 'web') const webDistPath = path.join(app.getAppPath(), 'web')
function initApp({ const stateDiff = createJSONDiffPatch({
username, objectHash: (obj, idx) => obj._id || `$$index:${idx}`,
password, })
baseURL,
getInitialState, function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
onMessage,
stateDoc,
}) {
const expectedOrigin = new URL(baseURL).origin const expectedOrigin = new URL(baseURL).origin
const sockets = new Set() const sockets = new Set()
@@ -31,15 +34,59 @@ function initApp({
// silence koa printing errors when websockets close early // silence koa printing errors when websockets close early
app.silent = true app.silent = true
app.use(auth({ name: username, pass: password }))
app.use(views(webDistPath, { extension: 'ejs' })) app.use(views(webDistPath, { extension: 'ejs' }))
app.use(serveStatic(webDistPath)) app.use(serveStatic(webDistPath))
app.use(websocket()) app.use(websocket())
app.use(
route.get('/invite/:token', async (ctx, token) => {
const tokenInfo = await auth.validateToken(token)
if (!tokenInfo || tokenInfo.kind !== 'invite') {
return ctx.throw(403)
}
const sessionToken = await auth.createToken({
kind: 'session',
name: tokenInfo.name,
role: tokenInfo.role,
})
ctx.cookies.set(SESSION_COOKIE_NAME, sessionToken, {
maxAge: 1 * 365 * 24 * 60 * 60 * 1000,
overwrite: true,
})
await auth.deleteToken(tokenInfo.id)
ctx.redirect('/')
}),
)
const basicAuthMiddleware = basicAuth({
name: auth.adminUsername,
pass: auth.adminPassword,
})
app.use(async (ctx, next) => {
const sessionCookie = ctx.cookies.get(SESSION_COOKIE_NAME)
if (sessionCookie) {
const tokenInfo = await auth.validateToken(sessionCookie)
if (tokenInfo && tokenInfo.kind === 'session') {
ctx.state.identity = tokenInfo
await next()
return
}
ctx.cookies.set(SESSION_COOKIE_NAME, '', {
maxAge: 0,
overwrite: true,
})
}
await basicAuthMiddleware(ctx, async () => {
ctx.state.identity = auth.admin()
await next()
})
})
app.use( app.use(
route.get('/', async (ctx) => { route.get('/', async (ctx) => {
await ctx.render('control', { await ctx.render('control', {
wsEndpoint: url.resolve(baseURL, 'ws').replace(/^http/, 'ws'), wsEndpoint: url.resolve(baseURL, 'ws').replace(/^http/, 'ws'),
role: ctx.state.identity.role,
}) })
}), }),
) )
@@ -52,8 +99,14 @@ function initApp({
return return
} }
const { identity } = ctx.state
const ws = await ctx.ws() const ws = await ctx.ws()
sockets.add(ws) sockets.add({
ws,
lastState: null,
identity,
})
ws.binaryType = 'arraybuffer' ws.binaryType = 'arraybuffer'
@@ -68,26 +121,50 @@ function initApp({
ws.on('message', (rawData) => { ws.on('message', (rawData) => {
if (rawData instanceof ArrayBuffer) { if (rawData instanceof ArrayBuffer) {
if (!roleCan(identity.role, 'mutate-state-doc')) {
console.warn(
`Unauthorized attempt to edit state doc by "${identity.name}"`,
)
return
}
Y.applyUpdate(stateDoc, new Uint8Array(rawData)) Y.applyUpdate(stateDoc, new Uint8Array(rawData))
return return
} }
let data let msg
try { try {
data = JSON.parse(rawData) msg = JSON.parse(rawData)
} catch (err) { } catch (err) {
console.warn('received unexpected ws data:', rawData) console.warn('received unexpected ws data:', rawData)
return return
} }
try { try {
onMessage(data) if (!roleCan(identity.role, msg.type)) {
console.warn(
`Unauthorized attempt to "${msg.type}" by "${identity.name}"`,
)
return
}
const respond = (responseData) => {
if (ws.readyState !== WebSocket.OPEN) {
return
}
ws.send(
JSON.stringify({
...responseData,
response: true,
id: msg.id,
}),
)
}
onMessage(msg, respond)
} catch (err) { } catch (err) {
console.error('failed to handle ws message:', data, err) console.error('failed to handle ws message:', data, err)
} }
}) })
const state = getInitialState() const state = getInitialState().view(identity.role)
ws.send(JSON.stringify({ type: 'state', state })) ws.send(JSON.stringify({ type: 'state', state }))
ws.send(Y.encodeStateAsUpdate(stateDoc)) ws.send(Y.encodeStateAsUpdate(stateDoc))
return return
@@ -96,15 +173,43 @@ function initApp({
}), }),
) )
const broadcast = (data) => { const broadcast = (origMsg) => {
for (const ws of sockets) { if (origMsg.type !== 'state') {
ws.send(JSON.stringify(data)) console.warn(`Unexpected ws broadcast type: ${origMsg.type}`)
return
}
for (const client of sockets) {
if (client.ws.readyState !== WebSocket.OPEN) {
continue
}
const state = origMsg.state.view(client.identity.role)
const delta = stateDiff.diff(client.lastState, state)
client.lastState = state
if (!delta) {
continue
}
client.ws.send(JSON.stringify({ type: 'state-delta', delta }))
} }
} }
stateDoc.on('update', (update) => { stateDoc.on('update', (update) => {
for (const ws of sockets) { for (const client of sockets) {
ws.send(update) if (client.ws.readyState !== WebSocket.OPEN) {
continue
}
client.ws.send(update)
}
})
auth.on('state', (state) => {
const tokenIds = new Set(state.sessions.map((t) => t.id))
for (const client of sockets) {
if (client.identity.role === 'admin') {
continue
}
if (!tokenIds.has(client.identity.id)) {
client.ws.close()
}
} }
}) })
@@ -118,8 +223,7 @@ export default async function initWebServer({
url: baseURL, url: baseURL,
hostname: overrideHostname, hostname: overrideHostname,
port: overridePort, port: overridePort,
username, auth,
password,
getInitialState, getInitialState,
onMessage, onMessage,
stateDoc, stateDoc,
@@ -133,8 +237,7 @@ export default async function initWebServer({
} }
const { app, broadcast } = initApp({ const { app, broadcast } = initApp({
username, auth,
password,
baseURL, baseURL,
getInitialState, getInitialState,
onMessage, onMessage,

28
src/roles.js Normal file
View File

@@ -0,0 +1,28 @@
const operatorActions = new Set([
'set-listening-view',
'set-view-blurred',
'set-custom-streams',
'reload-view',
'set-stream-censored',
'mutate-state-doc',
])
const monitorActions = new Set(['set-view-blurred', 'set-stream-censored'])
export const validRoles = new Set(['admin', 'operator', 'monitor'])
export function roleCan(role, action) {
if (role === 'admin') {
return true
}
if (role === 'operator' && operatorActions.has(action)) {
return true
}
if (role === 'monitor' && monitorActions.has(action)) {
return true
}
return false
}

View File

@@ -14,6 +14,7 @@
type="module" type="module"
id="main-script" id="main-script"
data-ws-endpoint="<%= wsEndpoint %>" data-ws-endpoint="<%= wsEndpoint %>"
data-role="<%= role %>"
crossorigin crossorigin
></script> ></script>
</body> </body>

View File

@@ -18,6 +18,7 @@ import { useHotkeys } from 'react-hotkeys-hook'
import '../index.css' import '../index.css'
import { idxInBox } from '../geometry' import { idxInBox } from '../geometry'
import { roleCan } from '../roles'
import SoundIcon from '../static/volume-up-solid.svg' import SoundIcon from '../static/volume-up-solid.svg'
import NoVideoIcon from '../static/video-slash-solid.svg' import NoVideoIcon from '../static/video-slash-solid.svg'
import ReloadIcon from '../static/redo-alt-solid.svg' import ReloadIcon from '../static/redo-alt-solid.svg'
@@ -88,6 +89,7 @@ function useStreamwallConnection(wsEndpoint) {
const [customStreams, setCustomStreams] = useState([]) const [customStreams, setCustomStreams] = useState([])
const [stateIdxMap, setStateIdxMap] = useState(new Map()) const [stateIdxMap, setStateIdxMap] = useState(new Map())
const [delayState, setDelayState] = useState() const [delayState, setDelayState] = useState()
const [authState, setAuthState] = useState()
useEffect(() => { useEffect(() => {
let lastStateData let lastStateData
@@ -107,7 +109,14 @@ function useStreamwallConnection(wsEndpoint) {
return return
} }
const msg = JSON.parse(ev.data) const msg = JSON.parse(ev.data)
if (msg.type === 'state' || msg.type === 'state-delta') { if (msg.response) {
const { responseMap } = wsRef.current
const responseCb = responseMap.get(msg.id)
if (responseCb) {
responseMap.delete(msg.id)
responseCb(msg)
}
} else if (msg.type === 'state' || msg.type === 'state-delta') {
let state let state
if (msg.type === 'state') { if (msg.type === 'state') {
state = msg.state state = msg.state
@@ -121,6 +130,7 @@ function useStreamwallConnection(wsEndpoint) {
streams: newStreams, streams: newStreams,
views, views,
streamdelay, streamdelay,
auth,
} = state } = state
const newStateIdxMap = new Map() const newStateIdxMap = new Map()
for (const viewState of views) { for (const viewState of views) {
@@ -152,15 +162,26 @@ function useStreamwallConnection(wsEndpoint) {
state: State.from(streamdelay.state), state: State.from(streamdelay.state),
}, },
) )
setAuthState(auth)
} else { } else {
console.warn('unexpected ws message', msg) console.warn('unexpected ws message', msg)
} }
}) })
wsRef.current = ws wsRef.current = { ws, msgId: 0, responseMap: new Map() }
}, []) }, [])
const send = useCallback((...args) => { const send = useCallback((msg, cb) => {
wsRef.current.send(...args) const { ws, msgId, responseMap } = wsRef.current
ws.send(
JSON.stringify({
...msg,
id: msgId,
}),
)
if (cb) {
responseMap.set(msgId, cb)
}
wsRef.current.msgId++
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -168,7 +189,7 @@ function useStreamwallConnection(wsEndpoint) {
if (origin === 'server') { if (origin === 'server') {
return return
} }
wsRef.current.send(update) wsRef.current.ws.send(update)
} }
function receiveUpdate(ev) { function receiveUpdate(ev) {
if (!(ev.data instanceof ArrayBuffer)) { if (!(ev.data instanceof ArrayBuffer)) {
@@ -177,10 +198,10 @@ function useStreamwallConnection(wsEndpoint) {
Y.applyUpdate(stateDoc, new Uint8Array(ev.data), 'server') Y.applyUpdate(stateDoc, new Uint8Array(ev.data), 'server')
} }
stateDoc.on('update', sendUpdate) stateDoc.on('update', sendUpdate)
wsRef.current.addEventListener('message', receiveUpdate) wsRef.current.ws.addEventListener('message', receiveUpdate)
return () => { return () => {
stateDoc.off('update', sendUpdate) stateDoc.off('update', sendUpdate)
wsRef.current.removeEventListener('message', receiveUpdate) wsRef.current.ws.removeEventListener('message', receiveUpdate)
} }
}, [stateDoc]) }, [stateDoc])
@@ -194,10 +215,11 @@ function useStreamwallConnection(wsEndpoint) {
customStreams, customStreams,
stateIdxMap, stateIdxMap,
delayState, delayState,
authState,
} }
} }
function App({ wsEndpoint }) { function App({ wsEndpoint, role }) {
const { const {
isConnected, isConnected,
send, send,
@@ -208,6 +230,7 @@ function App({ wsEndpoint }) {
customStreams, customStreams,
stateIdxMap, stateIdxMap,
delayState, delayState,
authState,
} = useStreamwallConnection(wsEndpoint) } = useStreamwallConnection(wsEndpoint)
const { gridCount } = config const { gridCount } = config
@@ -303,31 +326,25 @@ function App({ wsEndpoint }) {
) )
const handleSetListening = useCallback((idx, listening) => { const handleSetListening = useCallback((idx, listening) => {
send( send({
JSON.stringify({
type: 'set-listening-view', type: 'set-listening-view',
viewIdx: listening ? idx : null, viewIdx: listening ? idx : null,
}), })
)
}, []) }, [])
const handleSetBlurred = useCallback((idx, blurred) => { const handleSetBlurred = useCallback((idx, blurred) => {
send( send({
JSON.stringify({
type: 'set-view-blurred', type: 'set-view-blurred',
viewIdx: idx, viewIdx: idx,
blurred: blurred, blurred: blurred,
}), })
)
}, []) }, [])
const handleReloadView = useCallback((idx) => { const handleReloadView = useCallback((idx) => {
send( send({
JSON.stringify({
type: 'reload-view', type: 'reload-view',
viewIdx: idx, viewIdx: idx,
}), })
)
}, []) }, [])
const handleBrowse = useCallback( const handleBrowse = useCallback(
@@ -336,23 +353,19 @@ function App({ wsEndpoint }) {
if (!stream) { if (!stream) {
return return
} }
send( send({
JSON.stringify({
type: 'browse', type: 'browse',
url: stream.link, url: stream.link,
}), })
)
}, },
[streams], [streams],
) )
const handleDevTools = useCallback((idx) => { const handleDevTools = useCallback((idx) => {
send( send({
JSON.stringify({
type: 'dev-tools', type: 'dev-tools',
viewIdx: idx, viewIdx: idx,
}), })
)
}, []) }, [])
const handleClickId = useCallback( const handleClickId = useCallback(
@@ -383,23 +396,45 @@ function App({ wsEndpoint }) {
let newCustomStreams = [...customStreams] let newCustomStreams = [...customStreams]
newCustomStreams[idx] = customStream newCustomStreams[idx] = customStream
newCustomStreams = newCustomStreams.filter((s) => s.label || s.link) newCustomStreams = newCustomStreams.filter((s) => s.label || s.link)
send( send({
JSON.stringify({
type: 'set-custom-streams', type: 'set-custom-streams',
streams: newCustomStreams, streams: newCustomStreams,
}), })
)
}) })
const setStreamCensored = useCallback((isCensored) => { const setStreamCensored = useCallback((isCensored) => {
send( send({
JSON.stringify({
type: 'set-stream-censored', type: 'set-stream-censored',
isCensored, isCensored,
}), })
}, [])
const [newInvite, setNewInvite] = useState()
const handleCreateInvite = useCallback(({ name, role }) => {
send(
{
type: 'create-invite',
name,
role,
},
({ name, secret }) => {
setNewInvite({ name, secret })
},
) )
}, []) }, [])
const handleDeleteToken = useCallback((tokenId) => {
send({
type: 'delete-token',
tokenId,
})
}, [])
const preventLinkClick = useCallback((ev) => {
ev.preventDefault()
})
// Set up keyboard shortcuts. // Set up keyboard shortcuts.
useHotkeys( useHotkeys(
hotkeyTriggers.map((k) => `alt+${k}`).join(','), hotkeyTriggers.map((k) => `alt+${k}`).join(','),
@@ -456,6 +491,7 @@ function App({ wsEndpoint }) {
<div> <div>
connection status: {isConnected ? 'connected' : 'connecting...'} connection status: {isConnected ? 'connected' : 'connecting...'}
</div> </div>
<div>role: {role}</div>
{delayState && ( {delayState && (
<StreamDelayBox <StreamDelayBox
delayState={delayState} delayState={delayState}
@@ -485,6 +521,7 @@ function App({ wsEndpoint }) {
isHighlighted={isDragHighlighted} isHighlighted={isDragHighlighted}
isSwapping={idx === swapStartIdx} isSwapping={idx === swapStartIdx}
showDebug={showDebug} showDebug={showDebug}
role={role}
onMouseDown={handleDragStart} onMouseDown={handleDragStart}
onMouseEnter={setDragEnd} onMouseEnter={setDragEnd}
onFocus={handleFocusInput} onFocus={handleFocusInput}
@@ -502,6 +539,7 @@ function App({ wsEndpoint }) {
</StyledGridLine> </StyledGridLine>
))} ))}
</div> </div>
{(roleCan(role, 'dev-tools') || roleCan(role, 'browse')) && (
<label> <label>
<input <input
type="checkbox" type="checkbox"
@@ -510,6 +548,7 @@ function App({ wsEndpoint }) {
/> />
Show stream debug tools Show stream debug tools
</label> </label>
)}
</StyledDataContainer> </StyledDataContainer>
</Stack> </Stack>
<Stack flex="1" scroll={true}> <Stack flex="1" scroll={true}>
@@ -520,11 +559,14 @@ function App({ wsEndpoint }) {
<StreamLine <StreamLine
id={row._id} id={row._id}
row={row} row={row}
disabled={!roleCan(role, 'mutate-state-doc')}
onClickId={handleClickId} onClickId={handleClickId}
/> />
)) ))
: 'loading...'} : 'loading...'}
</div> </div>
{roleCan(role, 'set-custom-streams') && (
<>
<h2>Custom Streams</h2> <h2>Custom Streams</h2>
<div> <div>
{/* {/*
@@ -544,6 +586,45 @@ function App({ wsEndpoint }) {
), ),
)} )}
</div> </div>
</>
)}
{roleCan(role, 'edit-tokens') && authState && (
<>
<h2>Access</h2>
<div>
<CreateInviteInput onCreateInvite={handleCreateInvite} />
<h3>Invites</h3>
{newInvite && (
<StyledNewInviteBox>
Invite link created:{' '}
<a
href={`/invite/${newInvite.secret}`}
onClick={preventLinkClick}
>
"{newInvite.name}"
</a>
</StyledNewInviteBox>
)}
{authState.invites.map(({ id, name, role }) => (
<AuthTokenLine
id={id}
name={name}
role={role}
onDelete={handleDeleteToken}
/>
))}
<h3>Sessions</h3>
{authState.sessions.map(({ id, name, role }) => (
<AuthTokenLine
id={id}
name={name}
role={role}
onDelete={handleDeleteToken}
/>
))}
</div>
</>
)}
</StyledDataContainer> </StyledDataContainer>
</Stack> </Stack>
</Stack> </Stack>
@@ -596,6 +677,7 @@ function StreamDelayBox({ delayState, setStreamCensored }) {
function StreamLine({ function StreamLine({
id, id,
row: { label, source, title, link, notes, state, city }, row: { label, source, title, link, notes, state, city },
disabled,
onClickId, onClickId,
}) { }) {
// Use mousedown instead of click event so a potential destination grid input stays focused. // Use mousedown instead of click event so a potential destination grid input stays focused.
@@ -608,7 +690,11 @@ function StreamLine({
} }
return ( return (
<StyledStreamLine> <StyledStreamLine>
<StyledId onMouseDown={handleMouseDownId} color={idColor(id)}> <StyledId
disabled={disabled}
onMouseDown={disabled ? null : handleMouseDownId}
color={idColor(id)}
>
{id} {id}
</StyledId> </StyledId>
<div> <div>
@@ -640,6 +726,7 @@ function GridInput({
isHighlighted, isHighlighted,
isSwapping, isSwapping,
showDebug, showDebug,
role,
onMouseDown, onMouseDown,
onMouseEnter, onMouseEnter,
onFocus, onFocus,
@@ -709,18 +796,25 @@ function GridInput({
<StyledGridButtons side="left"> <StyledGridButtons side="left">
{showDebug ? ( {showDebug ? (
<> <>
{roleCan(role, 'browse') && (
<StyledSmallButton onClick={handleBrowseClick} tabIndex={1}> <StyledSmallButton onClick={handleBrowseClick} tabIndex={1}>
<WindowIcon /> <WindowIcon />
</StyledSmallButton> </StyledSmallButton>
)}
{roleCan(role, 'dev-tools') && (
<StyledSmallButton onClick={handleDevToolsClick} tabIndex={1}> <StyledSmallButton onClick={handleDevToolsClick} tabIndex={1}>
<LifeRingIcon /> <LifeRingIcon />
</StyledSmallButton> </StyledSmallButton>
)}
</> </>
) : ( ) : (
<> <>
{roleCan(role, 'reload-view') && (
<StyledSmallButton onClick={handleReloadClick} tabIndex={1}> <StyledSmallButton onClick={handleReloadClick} tabIndex={1}>
<ReloadIcon /> <ReloadIcon />
</StyledSmallButton> </StyledSmallButton>
)}
{roleCan(role, 'mutate-state-doc') && (
<StyledSmallButton <StyledSmallButton
isActive={isSwapping} isActive={isSwapping}
onClick={handleSwapClick} onClick={handleSwapClick}
@@ -728,11 +822,13 @@ function GridInput({
> >
<SwapIcon /> <SwapIcon />
</StyledSmallButton> </StyledSmallButton>
)}
</> </>
)} )}
</StyledGridButtons> </StyledGridButtons>
)} )}
<StyledGridButtons side="right"> <StyledGridButtons side="right">
{roleCan(role, 'set-view-blurred') && (
<StyledButton <StyledButton
isActive={isBlurred} isActive={isBlurred}
onClick={handleBlurClick} onClick={handleBlurClick}
@@ -740,6 +836,8 @@ function GridInput({
> >
<NoVideoIcon /> <NoVideoIcon />
</StyledButton> </StyledButton>
)}
{roleCan(role, 'set-listening-view') && (
<StyledButton <StyledButton
isActive={isListening} isActive={isListening}
onClick={handleListeningClick} onClick={handleListeningClick}
@@ -747,12 +845,14 @@ function GridInput({
> >
<SoundIcon /> <SoundIcon />
</StyledButton> </StyledButton>
)}
</StyledGridButtons> </StyledGridButtons>
<StyledGridInput <StyledGridInput
value={editingValue || spaceValue || ''} value={editingValue || spaceValue || ''}
color={idColor(spaceValue)} color={idColor(spaceValue)}
isError={isError} isError={isError}
isHighlighted={isHighlighted} isHighlighted={isHighlighted}
disabled={!roleCan(role, 'mutate-state-doc')}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
@@ -897,7 +997,7 @@ const StyledId = styled.div`
border-radius: 5px; border-radius: 5px;
width: 3em; width: 3em;
text-align: center; text-align: center;
cursor: pointer; cursor: ${({ disabled }) => (disabled ? 'normal' : 'pointer')};
` `
const StyledStreamLine = styled.div` const StyledStreamLine = styled.div`
@@ -906,12 +1006,72 @@ const StyledStreamLine = styled.div`
margin: 0.5em 0; margin: 0.5em 0;
` `
function CreateInviteInput({ onCreateInvite }) {
const [inviteName, setInviteName] = useState('')
const [inviteRole, setInviteRole] = useState('operator')
const handleChangeName = useCallback(
(ev) => {
setInviteName(ev.target.value)
},
[setInviteName],
)
const handleChangeRole = useCallback(
(ev) => {
setInviteRole(ev.target.value)
},
[setInviteRole],
)
const handleSubmit = useCallback(
(ev) => {
ev.preventDefault()
setInviteName('')
setInviteRole('operator')
onCreateInvite({ name: inviteName, role: inviteRole })
},
[onCreateInvite, inviteName, inviteRole],
)
return (
<div>
<form onSubmit={handleSubmit}>
<input
onChange={handleChangeName}
placeholder="Name"
value={inviteName}
/>
<select onChange={handleChangeRole} value={inviteRole}>
<option value="operator">operator</option>
<option value="monitor">monitor</option>
</select>
<button type="submit">create invite</button>
</form>
</div>
)
}
const StyledNewInviteBox = styled.div`
display: inline-block;
padding: 10px;
background: #dfd;
`
function AuthTokenLine({ id, role, name, onDelete }) {
const handleDeleteClick = useCallback(() => {
onDelete(id)
}, [id])
return (
<div>
<strong>{name}</strong>: {role}{' '}
<button onClick={handleDeleteClick}>revoke</button>
</div>
)
}
function main() { function main() {
const script = document.getElementById('main-script') const script = document.getElementById('main-script')
render( render(
<> <>
<GlobalStyle /> <GlobalStyle />
<App wsEndpoint={script.dataset.wsEndpoint} /> <App wsEndpoint={script.dataset.wsEndpoint} role={script.dataset.role} />
</>, </>,
document.body, document.body,
) )