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({
type: 'set-stream-censored',
isCensored,
})
}, [])
const [newInvite, setNewInvite] = useState()
const handleCreateInvite = useCallback(({ name, role }) => {
send( send(
JSON.stringify({ {
type: 'set-stream-censored', type: 'create-invite',
isCensored, 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,14 +539,16 @@ function App({ wsEndpoint }) {
</StyledGridLine> </StyledGridLine>
))} ))}
</div> </div>
<label> {(roleCan(role, 'dev-tools') || roleCan(role, 'browse')) && (
<input <label>
type="checkbox" <input
value={showDebug} type="checkbox"
onChange={handleChangeShowDebug} value={showDebug}
/> onChange={handleChangeShowDebug}
Show stream debug tools />
</label> Show stream debug tools
</label>
)}
</StyledDataContainer> </StyledDataContainer>
</Stack> </Stack>
<Stack flex="1" scroll={true}> <Stack flex="1" scroll={true}>
@@ -520,30 +559,72 @@ 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>
<h2>Custom Streams</h2> {roleCan(role, 'set-custom-streams') && (
<div> <>
{/* <h2>Custom Streams</h2>
<div>
{/*
Include an empty object at the end to create an extra input for a new custom stream. Include an empty object at the end to create an extra input for a new custom stream.
We need it to be part of the array (rather than JSX below) for DOM diffing to match the key and retain focus. We need it to be part of the array (rather than JSX below) for DOM diffing to match the key and retain focus.
*/} */}
{[...customStreams, { link: '', label: '', kind: 'video' }].map( {[...customStreams, { link: '', label: '', kind: 'video' }].map(
({ link, label, kind }, idx) => ( ({ link, label, kind }, idx) => (
<CustomStreamInput <CustomStreamInput
key={idx} key={idx}
idx={idx} idx={idx}
link={link} link={link}
label={label} label={label}
kind={kind} kind={kind}
onChange={handleChangeCustomStream} onChange={handleChangeCustomStream}
/> />
), ),
)} )}
</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,50 +796,63 @@ function GridInput({
<StyledGridButtons side="left"> <StyledGridButtons side="left">
{showDebug ? ( {showDebug ? (
<> <>
<StyledSmallButton onClick={handleBrowseClick} tabIndex={1}> {roleCan(role, 'browse') && (
<WindowIcon /> <StyledSmallButton onClick={handleBrowseClick} tabIndex={1}>
</StyledSmallButton> <WindowIcon />
<StyledSmallButton onClick={handleDevToolsClick} tabIndex={1}> </StyledSmallButton>
<LifeRingIcon /> )}
</StyledSmallButton> {roleCan(role, 'dev-tools') && (
<StyledSmallButton onClick={handleDevToolsClick} tabIndex={1}>
<LifeRingIcon />
</StyledSmallButton>
)}
</> </>
) : ( ) : (
<> <>
<StyledSmallButton onClick={handleReloadClick} tabIndex={1}> {roleCan(role, 'reload-view') && (
<ReloadIcon /> <StyledSmallButton onClick={handleReloadClick} tabIndex={1}>
</StyledSmallButton> <ReloadIcon />
<StyledSmallButton </StyledSmallButton>
isActive={isSwapping} )}
onClick={handleSwapClick} {roleCan(role, 'mutate-state-doc') && (
tabIndex={1} <StyledSmallButton
> isActive={isSwapping}
<SwapIcon /> onClick={handleSwapClick}
</StyledSmallButton> tabIndex={1}
>
<SwapIcon />
</StyledSmallButton>
)}
</> </>
)} )}
</StyledGridButtons> </StyledGridButtons>
)} )}
<StyledGridButtons side="right"> <StyledGridButtons side="right">
<StyledButton {roleCan(role, 'set-view-blurred') && (
isActive={isBlurred} <StyledButton
onClick={handleBlurClick} isActive={isBlurred}
tabIndex={1} onClick={handleBlurClick}
> tabIndex={1}
<NoVideoIcon /> >
</StyledButton> <NoVideoIcon />
<StyledButton </StyledButton>
isActive={isListening} )}
onClick={handleListeningClick} {roleCan(role, 'set-listening-view') && (
tabIndex={1} <StyledButton
> isActive={isListening}
<SoundIcon /> onClick={handleListeningClick}
</StyledButton> tabIndex={1}
>
<SoundIcon />
</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,
) )