Add login and auth handling

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-06-13 16:39:41 +02:00
parent bb46ba5d0e
commit 0075da1eba
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
8 changed files with 429 additions and 55 deletions

7
package-lock.json generated
View file

@ -8,6 +8,7 @@
"@fortawesome/fontawesome-free": "^6.5.2", "@fortawesome/fontawesome-free": "^6.5.2",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"codejar": "^4.2.0", "codejar": "^4.2.0",
"mitt": "^3.0.1",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"vue": "^3.4.27", "vue": "^3.4.27",
"vue-router": "^4.3.3" "vue-router": "^4.3.3"
@ -3373,6 +3374,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",

View file

@ -3,6 +3,7 @@
"@fortawesome/fontawesome-free": "^6.5.2", "@fortawesome/fontawesome-free": "^6.5.2",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"codejar": "^4.2.0", "codejar": "^4.2.0",
"mitt": "^3.0.1",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"vue": "^3.4.27", "vue": "^3.4.27",
"vue-router": "^4.3.3" "vue-router": "^4.3.3"

View file

@ -0,0 +1,98 @@
<template>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a
class="navbar-brand"
href="#"
@click.prevent
>
<i class="fas fa-robot fa-fw me-1 text-info" />
Twitch-Bot
</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon" />
</button>
<div
id="navbarSupportedContent"
class="collapse navbar-collapse"
>
<ul class="navbar-nav me-auto mb-2 mb-lg-0" />
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li
v-if="isLoggedIn"
class="nav-item dropdown"
>
<a
class="nav-link d-flex align-items-center"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<img
class="rounded-circle nav-profile-image"
:src="profileImage"
>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a
class="dropdown-item"
href="#"
@click.prevent="logout"
>Sign-out</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
computed: {
profileImage(): string {
return this.$root.userInfo?.profile_image_url || ''
},
},
methods: {
logout() {
this.bus.emit('logout')
},
},
name: 'TwitchBotEditorHeadNav',
props: {
isLoggedIn: {
required: true,
type: Boolean,
},
},
})
</script>
<style>
.nav-profile-image {
max-width: 24px;
}
.navbar {
z-index: 1000;
}
</style>

View file

@ -1,60 +1,49 @@
<template> <template>
<div class="h-100"> <div class="h-100">
<nav class="navbar navbar-expand-lg bg-body-tertiary"> <head-nav :is-logged-in="true" />
<div class="container-fluid">
<a class="navbar-brand" href="#" @click.prevent>
<i class="fas fa-robot fa-fw mr-1"></i>
Twitch-Bot
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"></ul>
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item dropdown">
<a class="nav-link d-flex align-items-center" href="#" role="button" data-bs-toggle="dropdown"
aria-expanded="false">
<img class="rounded-circle nav-profile-image" src="https://placehold.co/400">
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#">Sign-out</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<div class="layout"> <div class="layout">
<div class="layoutNav bg-body-tertiary d-flex"> <div class="layoutNav bg-body-tertiary d-flex">
<div class="nav flex-grow-1"> <div class="nav flex-grow-1">
<div class="layoutNavHeading">
<div class="layoutNavHeading">Core</div> Core
<a href="#" class="nav-link"> </div>
<i class="fas fa-cog fa-fw me-1"></i> <a
href="#"
class="nav-link"
>
<i class="fas fa-cog fa-fw me-1" />
General Settings General Settings
</a> </a>
<div class="layoutNavHeading">Chat Interaction</div> <div class="layoutNavHeading">
<a href="#" class="nav-link"> Chat Interaction
<i class="fas fa-envelope-open-text fa-fw me-1"></i> </div>
<a
href="#"
class="nav-link"
>
<i class="fas fa-envelope-open-text fa-fw me-1" />
Auto-Messages Auto-Messages
</a> </a>
<a href="#" class="nav-link"> <a
<i class="fas fa-inbox fa-fw me-1"></i> href="#"
class="nav-link"
>
<i class="fas fa-inbox fa-fw me-1" />
Rules Rules
</a> </a>
<div class="layoutNavHeading">Modules</div> <div class="layoutNavHeading">
<a href="#" class="nav-link"> Modules
<i class="fas fa-dice fa-fw me-1"></i> </div>
<a
href="#"
class="nav-link"
>
<i class="fas fa-dice fa-fw me-1" />
Raffle Raffle
</a> </a>
</div> </div>
</div> </div>
<div class="layoutContent"> <div class="layoutContent">
@ -67,7 +56,11 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import HeadNav from './_headNav.vue'
export default defineComponent({ export default defineComponent({
components: { HeadNav },
name: 'TwitchBotEditorApp', name: 'TwitchBotEditorApp',
}) })
</script> </script>
@ -114,6 +107,10 @@ export default defineComponent({
position: relative; position: relative;
} }
.layoutNav>.nav>.nav-link.disabled {
color: var(--bs-nav-link-disabled-color);
}
.layoutNavHeading { .layoutNavHeading {
color: color-mix(in srgb, var(--bs-body-color) 50%, transparent); color: color-mix(in srgb, var(--bs-body-color) 50%, transparent);
font-size: 0.75rem; font-size: 0.75rem;
@ -121,12 +118,4 @@ export default defineComponent({
padding: 1.75rem 1rem 0.75rem; padding: 1.75rem 1rem 0.75rem;
text-transform: uppercase; text-transform: uppercase;
} }
.nav-profile-image {
max-width: 24px;
}
.navbar {
z-index: 1000;
}
</style> </style>

69
src/components/login.vue Normal file
View file

@ -0,0 +1,69 @@
<template>
<div class="h-100">
<head-nav :is-logged-in="false" />
<div class="content d-flex align-items-center justify-content-center">
<button
class="btn btn-twitch"
:disabled="loading"
@click="openAuthURL"
>
<i class="fab fa-twitch fa-fw me-1" />
Login with Twitch
</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import HeadNav from './_headNav.vue'
export default defineComponent({
components: { HeadNav },
computed: {
authURL() {
const scopes = []
const params = new URLSearchParams()
params.set('client_id', this.$root.vars.TwitchClientID)
params.set('redirect_uri', window.location.href.split('#')[0].split('?')[0])
params.set('response_type', 'token')
params.set('scope', scopes.join(' '))
return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`
},
},
data() {
return {
loading: false,
}
},
methods: {
openAuthURL(): void {
window.location.href = this.authURL
},
},
mounted() {
this.$root.bus.on('login-processing', (loading: boolean) => {
this.loading = loading
})
},
name: 'TwitchBotEditorLogin',
})
</script>
<style scoped>
.btn-twitch {
background-color: #6441a5;
}
.content {
height: calc(100vh - 56px);
}
</style>

View file

@ -0,0 +1,49 @@
class ConfigNotifyListener {
private backoff: number = 100
private listener: Function
private socket: WebSocket | null = null
constructor(listener: Function) {
this.listener = listener
this.connect()
}
private connect(): void {
if (this.socket) {
this.socket.close()
this.socket = null
}
const baseURL = window.location.href.split('#')[0].replace(/^http/, 'ws')
this.socket = new WebSocket(`${baseURL}config-editor/notify-config`)
this.socket.onopen = () => {
console.debug('[notify] Socket connected')
}
this.socket.onmessage = evt => {
const msg = JSON.parse(evt.data)
console.debug(`[notify] Socket message received type=${msg.msg_type}`)
this.backoff = 100 // We've received a message, reset backoff
if (msg.msg_type !== 'ping') {
this.listener(msg.msg_type)
}
}
this.socket.onclose = evt => {
console.debug(`[notify] Socket was closed wasClean=${evt.wasClean}`)
this.updateBackoffAndReconnect()
}
}
private updateBackoffAndReconnect(): void {
this.backoff = Math.min(this.backoff * 1.5, 10000)
window.setTimeout(() => this.connect(), this.backoff)
}
}
export default ConfigNotifyListener

View file

@ -1,24 +1,178 @@
/* eslint-disable sort-imports */ /* eslint-disable sort-imports */
import './style.scss' import './style.scss' // Internal global styles
import 'bootstrap/dist/css/bootstrap.css' import 'bootstrap/dist/css/bootstrap.css' // Bootstrap 5 Styles
import '@fortawesome/fontawesome-free/css/all.css' import '@fortawesome/fontawesome-free/css/all.css' // All FA free icons
import 'bootstrap/dist/js/bootstrap.bundle' import 'bootstrap/dist/js/bootstrap.bundle' // Popper & Bootstrap globally available
import { createApp, h } from 'vue' import { createApp, h } from 'vue'
import mitt from 'mitt'
import ConfigNotifyListener from './helpers/configNotify'
import router from './router' import router from './router'
import App from './components/app.vue' import App from './components/app.vue'
import Login from './components/login.vue'
const app = createApp({ const app = createApp({
computed: {
fetchOpts(): RequestInit {
return {
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
}
},
tokenRenewAt(): Date | null {
if (this.tokenExpiresAt === null || this.tokenExpiresAt.getTime() < this.now.getTime()) {
// We don't know when it expires or it's expired, we can't renew
return null
}
// We renew 720sec before expiration (0.8 * 1h)
return new Date(this.tokenExpiresAt.getTime() - 720000)
}
},
data(): Object {
return {
now: new Date(),
token: '',
tokenExpiresAt: null as Date | null,
tokenUser: '',
userInfo: null as null | {},
tickers: {},
vars: {},
}
},
methods: {
/**
* Checks whether the API returned an 403 and in case it did triggers
* a logout and throws the user back into the login screen
*
* @param resp The response to the fetch request
* @returns The Response object from the resp parameter
*/
check403(resp: Response): Response {
if (resp.status === 403) {
// User token is not valid and therefore should be removed
// which essentially triggers a logout
this.logout()
}
return resp
},
loadVars(): Promise<void | Response> {
return fetch('editor/vars.json')
.then((resp: Response) => resp.json())
.then((data: any) => { this.vars = data })
},
login(token: string, expiresAt: Date, username: string): void {
this.token = token
this.tokenExpiresAt = expiresAt
this.tokenUser = username
window.localStorage.setItem('twitch-bot-token', JSON.stringify({ expiresAt, token, username }))
// Nuke the Twitch auth-response from the browser history
window.history.replaceState(null, '', window.location.href.split('#')[0])
fetch(`config-editor/user?user=${this.tokenUser}`, this.$root.fetchOpts)
.then((resp: Response) => this.$root.check403(resp))
.then((resp: Response) => resp.json())
.then((data: any) => {
this.userInfo = data
})
},
logout(): void {
window.localStorage.removeItem('twitch-bot-token')
this.token = ''
this.tokenExpiresAt = null
this.tokenUser = ''
},
registerTicker(id: string, func: TimerHandler, intervalMs: number): void {
this.unregisterTicker(id)
this.tickers[id] = window.setInterval(func, intervalMs)
},
renewToken(): void {
if (!this.tokenRenewAt || this.tokenRenewAt.getTime() > this.now.getTime()) {
return
}
fetch('config-editor/refreshToken', this.$root.fetchOpts)
.then((resp: Response) => this.$root.check403(resp))
.then((resp: Response) => resp.json())
.then((data: any) => this.login(data.token, new Date(data.expiresAt), data.user))
},
unregisterTicker(id: string): void {
if (this.tickers[id]) {
window.clearInterval(this.tickers[id])
}
},
},
mounted(): void {
this.bus.on('logout', this.logout)
this.$root.registerTicker('updateRootNow', () => { this.now = new Date() }, 30000)
this.$root.registerTicker('renewToken', () => this.renewToken(), 60000)
// Start background-listen for config updates
new ConfigNotifyListener((msgType: string) => { this.$root.bus.emit(msgType) })
this.loadVars()
const params = new URLSearchParams(window.location.hash.replace(/^[#/]+/, ''))
const authToken = params.get('access_token')
if (authToken) {
this.$root.bus.emit('login-processing', true)
fetch('config-editor/login', {
body: JSON.stringify({ token: authToken }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
.then((resp: Response): any => {
if (resp.status !== 200) {
throw new Error(`login failed, status=${resp.status}`)
}
return resp.json()
})
.then((data: any) => this.login(data.token, new Date(data.expiresAt), data.user))
} else {
const tokenData = window.localStorage.getItem('twitch-bot-token')
if (tokenData !== null) {
const data = JSON.parse(tokenData)
this.login(data.token, new Date(data.expiresAt), data.username)
}
}
},
name: 'TwitchBotEditor', name: 'TwitchBotEditor',
render() { render() {
if (this.token) {
return h(App) return h(App)
}
return h(Login)
}, },
router, router,
}) })
app.config.globalProperties.bus = mitt()
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')

View file

@ -3,6 +3,8 @@ import { createRouter, createMemoryHistory } from 'vue-router'
//import AuthView from './components/auth.vue' //import AuthView from './components/auth.vue'
//import ChatView from './components/chatview.vue' //import ChatView from './components/chatview.vue'
const Root = {}
const routes = [ const routes = [
// { // {
// component: AuthView, // component: AuthView,
@ -12,6 +14,11 @@ const routes = [
// component: ChatView, // component: ChatView,
// path: '/chat', // path: '/chat',
// }, // },
{
component: Root,
name: 'root',
path: '/',
}
] ]
const router = createRouter({ const router = createRouter({