mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 20:01:17 +00:00
Add channel editing interface
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
dc21293e35
commit
1a425416b8
6 changed files with 466 additions and 9 deletions
12
botEditor.go
12
botEditor.go
|
@ -3,21 +3,23 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getAuthorizationFromRequest(r *http.Request) (string, error) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
_, token, hadPrefix := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||
if !hadPrefix {
|
||||
return "", fmt.Errorf("no authorization provided")
|
||||
}
|
||||
|
||||
_, user, _, _, err := editorTokenService.ValidateLoginToken(token) //nolint:dogsled // Required at other places
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting authorized user: %w", err)
|
||||
}
|
||||
|
||||
if user == "" {
|
||||
user = "API-User"
|
||||
}
|
||||
|
||||
return user, errors.Wrap(err, "getting authorized user")
|
||||
return user, nil
|
||||
}
|
||||
|
|
220
src/components/channelOverview.vue
Normal file
220
src/components/channelOverview.vue
Normal file
|
@ -0,0 +1,220 @@
|
|||
<template>
|
||||
<div class="container my-3">
|
||||
<div class="row justify-content-center mb-3">
|
||||
<div class="col-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i class="fas fa-hashtag fa-fw me-1" />
|
||||
</span>
|
||||
<input
|
||||
v-model="inputAddChannel.text"
|
||||
type="text"
|
||||
:class="inputAddChannelClasses"
|
||||
@keypress.enter="addChannel"
|
||||
>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
:disabled="!inputAddChannel.valid"
|
||||
@click="addChannel"
|
||||
>
|
||||
<i class="fas fa-plus fa-fw me-1" />
|
||||
{{ $t('channel.btnAdd') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("channel.table.colChannel") }}</th>
|
||||
<th>{{ $t("channel.table.colPermissions") }}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="channel in channels"
|
||||
:key="channel.name"
|
||||
>
|
||||
<td class="align-content-center">
|
||||
<i class="fas fa-hashtag fa-fw me-1" />
|
||||
{{ channel.name }}
|
||||
</td>
|
||||
<td class="align-content-center">
|
||||
<i
|
||||
v-if="channel.numScopesGranted === 0"
|
||||
class="fas fa-triangle-exclamation fa-fw me-1 text-danger"
|
||||
:title="$t('channel.table.titleNoPermissions')"
|
||||
/>
|
||||
<i
|
||||
v-else-if="channel.numScopesGranted < numExtendedScopes"
|
||||
class="fas fa-triangle-exclamation fa-fw me-1 text-warning"
|
||||
:title="$t('channel.table.titlePartialPermissions')"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-circle-check fa-fw me-1 text-success"
|
||||
:title="$t('channel.table.titleAllPermissions')"
|
||||
/>
|
||||
{{ $t('channel.table.textPermissions', {
|
||||
avail: numExtendedScopes,
|
||||
granted: channel.numScopesGranted
|
||||
}) }}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<RouterLink
|
||||
:to="{ name:'channelPermissions', params: { channel: channel.name } }"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<i class="fas fa-pencil-alt fa-fw" />
|
||||
</RouterLink>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
@click="removeChannel(channel.name)"
|
||||
>
|
||||
<i class="fas fa-minus fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import BusEventTypes from '../helpers/busevents'
|
||||
import { defineComponent } from 'vue'
|
||||
import { successToast } from '../helpers/toasts'
|
||||
|
||||
export default defineComponent({
|
||||
computed: {
|
||||
channels(): Array<any> {
|
||||
return this.generalConfig.channels?.map((name: string) => ({
|
||||
name,
|
||||
numScopesGranted: (this.generalConfig.channel_scopes[name] || [])
|
||||
.filter((scope: string) => Object.keys(this.authURLs.available_extended_scopes).includes(scope))
|
||||
.length,
|
||||
}))
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name))
|
||||
},
|
||||
|
||||
inputAddChannelClasses(): string {
|
||||
const classes = ['form-control']
|
||||
|
||||
if (this.inputAddChannel.valid) {
|
||||
classes.push('is-valid')
|
||||
} else if (this.inputAddChannel.text) {
|
||||
classes.push('is-invalid')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
},
|
||||
|
||||
numExtendedScopes(): number {
|
||||
return Object.keys(this.authURLs.available_extended_scopes || {}).length
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
authURLs: {} as any,
|
||||
generalConfig: {} as any,
|
||||
inputAddChannel: {
|
||||
text: '',
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Adds the channel entered into the input field to the list
|
||||
*/
|
||||
addChannel(): Promise<void> | undefined {
|
||||
if (!this.inputAddChannel.valid) {
|
||||
return
|
||||
}
|
||||
|
||||
const channel = this.inputAddChannel.text
|
||||
|
||||
return this.updateGeneralConfig({
|
||||
...this.generalConfig,
|
||||
channels: [
|
||||
...this.generalConfig.channels.filter((chan: string) => chan !== channel),
|
||||
channel,
|
||||
],
|
||||
})
|
||||
?.then(() => {
|
||||
this.inputAddChannel.text = ''
|
||||
this.bus.emit(BusEventTypes.Toast, successToast(this.$t('channel.toastChannelAdded')))
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches auth-URLs from the backend
|
||||
*/
|
||||
fetchAuthURLs(): Promise<void> | undefined {
|
||||
return this.$root?.fetchJSON('config-editor/auth-urls')
|
||||
.then((data: any) => {
|
||||
this.authURLs = data
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches the general config object from the backend
|
||||
*/
|
||||
fetchGeneralConfig(): Promise<void> | undefined {
|
||||
return this.$root?.fetchJSON('config-editor/general')
|
||||
.then((data: any) => {
|
||||
this.generalConfig = data
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Tells backend to remove a channel
|
||||
*/
|
||||
removeChannel(channel: string): Promise<void> | undefined {
|
||||
return this.updateGeneralConfig({
|
||||
...this.generalConfig,
|
||||
channels: this.generalConfig.channels.filter((chan: string) => chan !== channel),
|
||||
})
|
||||
?.then(() => this.bus.emit(BusEventTypes.Toast, successToast(this.$t('channel.toastChannelRemoved'))))
|
||||
},
|
||||
|
||||
/**
|
||||
* Writes general config back to backend
|
||||
*
|
||||
* @param config Configuration object to write (MUST contain all config)
|
||||
*/
|
||||
updateGeneralConfig(config: any): Promise<void> | undefined {
|
||||
return this.$root?.fetchJSON('config-editor/general', {
|
||||
body: JSON.stringify(config),
|
||||
method: 'PUT',
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Reload config after it changed
|
||||
this.bus.on(BusEventTypes.ConfigReload, () => this.fetchGeneralConfig())
|
||||
|
||||
// Do initial fetches
|
||||
this.fetchAuthURLs()
|
||||
this.fetchGeneralConfig()
|
||||
},
|
||||
|
||||
name: 'TwitchBotEditorChannelOverview',
|
||||
|
||||
watch: {
|
||||
'inputAddChannel.text'(to) {
|
||||
this.inputAddChannel.valid = to.match(/^[a-zA-Z0-9_]{4,25}$/)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
201
src/components/channelPermissions.vue
Normal file
201
src/components/channelPermissions.vue
Normal file
|
@ -0,0 +1,201 @@
|
|||
<template>
|
||||
<div class="container my-3">
|
||||
<div class="row justify-content-center mb-3">
|
||||
<div class="col-8">
|
||||
<p v-html="$t('channel.permissionStart', { channel })" />
|
||||
<div
|
||||
v-for="perm in permissions"
|
||||
:key="perm.scope"
|
||||
class="form-check form-switch"
|
||||
>
|
||||
<input
|
||||
:id="`switch${perm.scope}`"
|
||||
v-model="granted[perm.scope]"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
>
|
||||
<label
|
||||
class="form-check-label"
|
||||
:for="`switch${perm.scope}`"
|
||||
>{{ perm.description }}</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input
|
||||
id="switch_all"
|
||||
v-model="allPermissions"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
>
|
||||
<label
|
||||
class="form-check-label"
|
||||
for="switch_all"
|
||||
>{{ $t('channel.permissionsAll') }}</label>
|
||||
</div>
|
||||
<div class="input-group mt-4">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
:value="permissionsURL || ''"
|
||||
:disabled="!permissionsURL"
|
||||
readonly
|
||||
>
|
||||
<button
|
||||
ref="copyBtn"
|
||||
class="btn btn-primary"
|
||||
:disabled="!authURLs?.update_bot_token"
|
||||
@click="copyAuthURL"
|
||||
>
|
||||
<i class="fas fa-clipboard fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-circle-info fa-fw me-1" />
|
||||
{{ $t('channel.permissionInfoHeader') }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p v-html="$t('channel.permissionIntro')" />
|
||||
<ul>
|
||||
<li
|
||||
v-for="(bpt, idx) in $tm('channel.permissionIntroBullets')"
|
||||
:key="`idx${idx}`"
|
||||
>
|
||||
{{ bpt }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import BusEventTypes from '../helpers/busevents'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
computed: {
|
||||
allPermissions: {
|
||||
get(): boolean {
|
||||
return this.extendedScopeNames
|
||||
.filter((scope: string) => !this.granted[scope])
|
||||
.length === 0
|
||||
},
|
||||
|
||||
set(all: boolean): void {
|
||||
this.granted = Object.fromEntries(this.extendedScopeNames
|
||||
.map((scope: string) => [scope, all]))
|
||||
},
|
||||
},
|
||||
|
||||
extendedScopeNames(): Array<string> {
|
||||
return Object.entries(this.authURLs.available_extended_scopes || {})
|
||||
.map(e => e[0])
|
||||
},
|
||||
|
||||
permissions(): Array<any> {
|
||||
return Object.entries(this.authURLs.available_extended_scopes || {}).map(e => ({
|
||||
description: e[1],
|
||||
scope: e[0],
|
||||
}))
|
||||
},
|
||||
|
||||
permissionsURL(): string {
|
||||
if (!this.authURLs.update_channel_scopes) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const scopes = Object.entries(this.granted).filter(e => e[1])
|
||||
.map(e => e[0])
|
||||
|
||||
const u = new URL(this.authURLs.update_channel_scopes)
|
||||
u.searchParams.set('scope', scopes.join(' '))
|
||||
return u.toString()
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
authURLs: {} as any,
|
||||
generalConfig: {} as any,
|
||||
granted: {} as any,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Copies auth-url for the bot into clipboard and gives user feedback
|
||||
* by colorizing copy-button for a short moment
|
||||
*/
|
||||
copyAuthURL(): void {
|
||||
navigator.clipboard.writeText(this.permissionsURL)
|
||||
.then(() => {
|
||||
const btn = this.$refs.copyBtn as Element
|
||||
btn.classList.replace('btn-primary', 'btn-success')
|
||||
window.setTimeout(() => btn.classList.replace('btn-success', 'btn-primary'), 2500)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches auth-URLs from the backend
|
||||
*/
|
||||
fetchAuthURLs(): Promise<void> | undefined {
|
||||
return this.$root?.fetchJSON('config-editor/auth-urls')
|
||||
.then((data: any) => {
|
||||
this.authURLs = data
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches the general config object from the backend
|
||||
*/
|
||||
fetchGeneralConfig(): Promise<void> | undefined {
|
||||
return this.$root?.fetchJSON('config-editor/general')
|
||||
.then((data: any) => {
|
||||
this.generalConfig = data
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the granted scopes into the object for easier display
|
||||
* of the permission switches
|
||||
*/
|
||||
loadScopes(): void {
|
||||
this.granted = Object.fromEntries((this.generalConfig.channel_scopes[this.channel] || []).map((scope: string) => [scope, true]))
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Reload config after it changed
|
||||
this.bus.on(BusEventTypes.ConfigReload, () => this.fetchGeneralConfig()?.then(() => this.loadScopes()))
|
||||
|
||||
// Socket-reconnect could mean we need new auth-urls as the state
|
||||
// may have changed due to bot-restart
|
||||
this.bus.on(BusEventTypes.NotifySocketConnected, () => this.fetchAuthURLs())
|
||||
|
||||
// Do initial fetches
|
||||
this.fetchAuthURLs()
|
||||
this.fetchGeneralConfig()?.then(() => this.loadScopes())
|
||||
},
|
||||
|
||||
name: 'TwitchBotEditorChannelPermissions',
|
||||
|
||||
props: {
|
||||
channel: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
channel() {
|
||||
this.loadScopes()
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
|
@ -9,6 +9,29 @@
|
|||
],
|
||||
"heading": "Updating Bot-Authorization"
|
||||
},
|
||||
"channel": {
|
||||
"btnAdd": "Add Channel",
|
||||
"permissionsAll": "…do all of the above!",
|
||||
"permissionInfoHeader": "Explanation",
|
||||
"permissionIntro": "In order to access non-public information as channel-point redemptions or take actions limited to the channel owner the bot needs additional permissions. The <strong>owner</strong> of the channel needs to grant those!",
|
||||
"permissionIntroBullets": [
|
||||
"Select permissions on the left side",
|
||||
"Copy the URL provided below",
|
||||
"Pass the URL to the channel owner and tell them to open it with their personal account logged in",
|
||||
"The bot will display a message containing the updated account"
|
||||
],
|
||||
"permissionStart": "For <strong>#{channel}</strong> the bot should be able to…",
|
||||
"table": {
|
||||
"colChannel": "Channel",
|
||||
"colPermissions": "Permissions",
|
||||
"textPermissions": "{granted} of {avail} granted",
|
||||
"titleAllPermissions": "Bot can use all available extra features",
|
||||
"titleNoPermissions": "Bot can not use features aside of chatting",
|
||||
"titlePartialPermissions": "Bot can use some extra features"
|
||||
},
|
||||
"toastChannelAdded": "Channel added",
|
||||
"toastChannelRemoved": "Channel removed"
|
||||
},
|
||||
"dashboard": {
|
||||
"activeRaffles": {
|
||||
"caption": "Active",
|
||||
|
|
|
@ -114,6 +114,14 @@ const app = createApp({
|
|||
|
||||
parseResponseFromJSON(resp: Response): Promise<any> {
|
||||
this.check403(resp)
|
||||
|
||||
if (resp.status === 204) {
|
||||
// We can't expect content here
|
||||
return new Promise(resolve => {
|
||||
resolve({})
|
||||
})
|
||||
}
|
||||
|
||||
return resp.json()
|
||||
},
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import BothAuth from './components/botauth.vue'
|
||||
import ChannelOverview from './components/channelOverview.vue'
|
||||
import ChannelPermissions from './components/channelPermissions.vue'
|
||||
import Dashboard from './components/dashboard.vue'
|
||||
|
||||
const routes = [
|
||||
|
@ -8,23 +10,24 @@ const routes = [
|
|||
|
||||
// General settings
|
||||
{ component: BothAuth, name: 'botAuth', path: '/bot-auth' },
|
||||
{ component: {}, name: 'channels', path: '/channels' },
|
||||
{ component: ChannelOverview, name: 'channels', path: '/channels' },
|
||||
{ component: ChannelPermissions, name: 'channelPermissions', path: '/channels/:channel/permissions', props: true },
|
||||
{ component: {}, name: 'editors', path: '/editors' },
|
||||
{ component: {}, name: 'tokens', path: '/tokens' },
|
||||
|
||||
// Auto-Messages
|
||||
{ component: {}, name: 'autoMessagesList', path: '/auto-messages' },
|
||||
{ component: {}, name: 'autoMessageEdit', path: '/auto-messages/edit/{id}' },
|
||||
{ component: {}, name: 'autoMessageEdit', path: '/auto-messages/edit/:id' },
|
||||
{ component: {}, name: 'autoMessageNew', path: '/auto-messages/new' },
|
||||
|
||||
// Rules
|
||||
{ component: {}, name: 'rulesList', path: '/rules' },
|
||||
{ component: {}, name: 'rulesEdit', path: '/rules/edit/{id}' },
|
||||
{ component: {}, name: 'rulesEdit', path: '/rules/edit/:id' },
|
||||
{ component: {}, name: 'rulesNew', path: '/rules/new' },
|
||||
|
||||
// Raffles
|
||||
{ component: {}, name: 'rafflesList', path: '/raffles' },
|
||||
{ component: {}, name: 'rafflesEdit', path: '/raffles/edit/{id}' },
|
||||
{ component: {}, name: 'rafflesEdit', path: '/raffles/edit/:id' },
|
||||
{ component: {}, name: 'rafflesNew', path: '/raffles/new' },
|
||||
] as RouteRecordRaw[]
|
||||
|
||||
|
|
Loading…
Reference in a new issue