Add channel editing interface

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-08-14 15:53:09 +02:00
parent ad837a05a3
commit 4b556fb2d4
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
6 changed files with 466 additions and 9 deletions

View file

@ -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
}

View 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>

View 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>

View file

@ -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&hellip;",
"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",

View file

@ -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()
},

View file

@ -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[]