mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 11:51:17 +00:00
Add login and auth handling
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
bb46ba5d0e
commit
0075da1eba
8 changed files with 429 additions and 55 deletions
7
package-lock.json
generated
7
package-lock.json
generated
|
@ -8,6 +8,7 @@
|
|||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"bootstrap": "^5.3.3",
|
||||
"codejar": "^4.2.0",
|
||||
"mitt": "^3.0.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"vue": "^3.4.27",
|
||||
"vue-router": "^4.3.3"
|
||||
|
@ -3373,6 +3374,12 @@
|
|||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"bootstrap": "^5.3.3",
|
||||
"codejar": "^4.2.0",
|
||||
"mitt": "^3.0.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"vue": "^3.4.27",
|
||||
"vue-router": "^4.3.3"
|
||||
|
@ -18,4 +19,4 @@
|
|||
"eslint-plugin-vue": "^9.26.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
98
src/components/_headNav.vue
Normal file
98
src/components/_headNav.vue
Normal 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>
|
|
@ -1,60 +1,49 @@
|
|||
<template>
|
||||
<div class="h-100">
|
||||
<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 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>
|
||||
<head-nav :is-logged-in="true" />
|
||||
|
||||
<div class="layout">
|
||||
<div class="layoutNav bg-body-tertiary d-flex">
|
||||
<div class="nav flex-grow-1">
|
||||
|
||||
<div class="layoutNavHeading">Core</div>
|
||||
<a href="#" class="nav-link">
|
||||
<i class="fas fa-cog fa-fw me-1"></i>
|
||||
<div class="layoutNavHeading">
|
||||
Core
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="nav-link"
|
||||
>
|
||||
<i class="fas fa-cog fa-fw me-1" />
|
||||
General Settings
|
||||
</a>
|
||||
|
||||
<div class="layoutNavHeading">Chat Interaction</div>
|
||||
<a href="#" class="nav-link">
|
||||
<i class="fas fa-envelope-open-text fa-fw me-1"></i>
|
||||
<div class="layoutNavHeading">
|
||||
Chat Interaction
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="nav-link"
|
||||
>
|
||||
<i class="fas fa-envelope-open-text fa-fw me-1" />
|
||||
Auto-Messages
|
||||
</a>
|
||||
<a href="#" class="nav-link">
|
||||
<i class="fas fa-inbox fa-fw me-1"></i>
|
||||
<a
|
||||
href="#"
|
||||
class="nav-link"
|
||||
>
|
||||
<i class="fas fa-inbox fa-fw me-1" />
|
||||
Rules
|
||||
</a>
|
||||
|
||||
<div class="layoutNavHeading">Modules</div>
|
||||
<a href="#" class="nav-link">
|
||||
<i class="fas fa-dice fa-fw me-1"></i>
|
||||
<div class="layoutNavHeading">
|
||||
Modules
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="nav-link"
|
||||
>
|
||||
<i class="fas fa-dice fa-fw me-1" />
|
||||
Raffle
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="layoutContent">
|
||||
|
@ -67,7 +56,11 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import HeadNav from './_headNav.vue'
|
||||
|
||||
export default defineComponent({
|
||||
components: { HeadNav },
|
||||
|
||||
name: 'TwitchBotEditorApp',
|
||||
})
|
||||
</script>
|
||||
|
@ -114,6 +107,10 @@ export default defineComponent({
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.layoutNav>.nav>.nav-link.disabled {
|
||||
color: var(--bs-nav-link-disabled-color);
|
||||
}
|
||||
|
||||
.layoutNavHeading {
|
||||
color: color-mix(in srgb, var(--bs-body-color) 50%, transparent);
|
||||
font-size: 0.75rem;
|
||||
|
@ -121,12 +118,4 @@ export default defineComponent({
|
|||
padding: 1.75rem 1rem 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-profile-image {
|
||||
max-width: 24px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
|
|
69
src/components/login.vue
Normal file
69
src/components/login.vue
Normal 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>
|
49
src/helpers/configNotify.ts
Normal file
49
src/helpers/configNotify.ts
Normal 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
|
164
src/main.ts
164
src/main.ts
|
@ -1,24 +1,178 @@
|
|||
/* eslint-disable sort-imports */
|
||||
|
||||
import './style.scss'
|
||||
import 'bootstrap/dist/css/bootstrap.css'
|
||||
import '@fortawesome/fontawesome-free/css/all.css'
|
||||
import './style.scss' // Internal global styles
|
||||
import 'bootstrap/dist/css/bootstrap.css' // Bootstrap 5 Styles
|
||||
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 mitt from 'mitt'
|
||||
|
||||
import ConfigNotifyListener from './helpers/configNotify'
|
||||
|
||||
import router from './router'
|
||||
import App from './components/app.vue'
|
||||
import Login from './components/login.vue'
|
||||
|
||||
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',
|
||||
render() {
|
||||
return h(App)
|
||||
if (this.token) {
|
||||
return h(App)
|
||||
}
|
||||
|
||||
return h(Login)
|
||||
},
|
||||
|
||||
router,
|
||||
})
|
||||
|
||||
app.config.globalProperties.bus = mitt()
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
|
|
@ -3,6 +3,8 @@ import { createRouter, createMemoryHistory } from 'vue-router'
|
|||
//import AuthView from './components/auth.vue'
|
||||
//import ChatView from './components/chatview.vue'
|
||||
|
||||
const Root = {}
|
||||
|
||||
const routes = [
|
||||
// {
|
||||
// component: AuthView,
|
||||
|
@ -12,6 +14,11 @@ const routes = [
|
|||
// component: ChatView,
|
||||
// path: '/chat',
|
||||
// },
|
||||
{
|
||||
component: Root,
|
||||
name: 'root',
|
||||
path: '/',
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
|
Loading…
Reference in a new issue