mirror of
https://github.com/streamwall/streamwall.git
synced 2025-12-06 01:45:37 -05:00
Add invite links with role based access control
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
144
src/node/auth.js
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
28
src/roles.js
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user