WIP: support user/pass as auth method instead of current apikey usage
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
|
||||
<div class="navbar-menu" :class="{ 'is-active': showMenu }">
|
||||
<div class="navbar-start" v-if="hasAPISettings && !showConnection">
|
||||
<div class="navbar-start">
|
||||
<NuxtLink class="navbar-item" to="/backends" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-server"/></span>
|
||||
<span>Backends</span>
|
||||
@@ -54,22 +54,19 @@
|
||||
|
||||
<div class="navbar-dropdown">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/tools/plex_token" @click.native="(e) => changeRoute(e)"
|
||||
v-if="hasAPISettings">
|
||||
<NuxtLink class="navbar-item" to="/tools/plex_token" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-key"/></span>
|
||||
<span>Plex Token</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/tools/sub_users" @click.native="(e) => changeRoute(e)"
|
||||
v-if="hasAPISettings">
|
||||
<NuxtLink class="navbar-item" to="/tools/sub_users" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-users"/></span>
|
||||
<span>Sub Users</span>
|
||||
</NuxtLink>
|
||||
|
||||
<hr class="navbar-divider">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/processes" @click.native="(e) => changeRoute(e)"
|
||||
v-if="hasAPISettings">
|
||||
<NuxtLink class="navbar-item" to="/processes" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-microchip"/></span>
|
||||
<span>Processes</span>
|
||||
</NuxtLink>
|
||||
@@ -123,7 +120,7 @@
|
||||
</a>
|
||||
<div class="navbar-dropdown">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)" v-if="hasAPISettings">
|
||||
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-terminal"/></span>
|
||||
<span>Console</span>
|
||||
</NuxtLink>
|
||||
@@ -152,19 +149,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end pr-3">
|
||||
<template v-if="hasAPISettings && !showConnection">
|
||||
<div class="navbar-item">
|
||||
<NuxtLink class="button is-dark" v-tooltip="'Guides'" to="/help">
|
||||
<span class="icon"><i class="fas fa-circle-question"/></span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="hasAPISettings && !showConnection">
|
||||
<div class="navbar-item">
|
||||
<button class="button is-dark" @click="showUserSelection = !showUserSelection" v-tooltip="'Change User'">
|
||||
<span class="icon"><i class="fas fa-users"/></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="navbar-item">
|
||||
<button class="button is-dark" @click="showConnection = !showConnection"
|
||||
@@ -172,14 +167,20 @@
|
||||
<span class="icon"><i class="fas fa-cog"/></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item">
|
||||
<button class="button is-dark" @click="logout" v-tooltip="'Logout'">
|
||||
<span class="icon"><i class="fas fa-sign-out"/></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div>
|
||||
<div>
|
||||
<no-api v-if="!hasAPISettings"/>
|
||||
<Connection v-if="showConnection" @update="data => handleConnection(data)"/>
|
||||
<NuxtPage v-if="!showConnection && hasAPISettings"/>
|
||||
<NuxtPage v-if="!showConnection"/>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline is-mobile mt-3">
|
||||
@@ -229,13 +230,13 @@ import request from '~/utils/request'
|
||||
import Markdown from '~/components/Markdown'
|
||||
import UserSelection from '~/components/UserSelection'
|
||||
import Connection from '~/components/Connection'
|
||||
import { useAuthStore } from '~/store/auth'
|
||||
|
||||
const selectedTheme = useStorage('theme', 'auto')
|
||||
const showUserSelection = ref(false)
|
||||
const showConnection = ref(false)
|
||||
|
||||
const api_url = useStorage('api_user', 'main')
|
||||
const api_token = useStorage('api_token', '')
|
||||
const auth = useAuthStore()
|
||||
const bg_enable = useStorage('bg_enable', true)
|
||||
const bg_opacity = useStorage('bg_opacity', 0.95)
|
||||
const api_version = ref()
|
||||
@@ -299,12 +300,6 @@ const applyPreferredColorScheme = scheme => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
applyPreferredColorScheme(selectedTheme.value)
|
||||
|
||||
if ('' === api_token.value || '' === api_url.value) {
|
||||
showConnection.value = true
|
||||
return
|
||||
}
|
||||
|
||||
await getVersion()
|
||||
await loadImage()
|
||||
} catch (e) {
|
||||
@@ -336,8 +331,6 @@ const getVersion = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const hasAPISettings = computed(() => '' !== api_token.value && '' !== api_url.value)
|
||||
|
||||
const closeOverlay = () => loadFile.value = ''
|
||||
|
||||
const openMenu = e => {
|
||||
@@ -366,7 +359,7 @@ const handleConnection = data => {
|
||||
}
|
||||
|
||||
watch(bgImage, async v => {
|
||||
if (false === hasAPISettings.value || false === bg_enable.value) {
|
||||
if (false === bg_enable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -392,12 +385,11 @@ watch(bgImage, async v => {
|
||||
}, {immediate: true})
|
||||
|
||||
watch(bg_opacity, v => {
|
||||
if (false === hasAPISettings.value || false === bg_enable.value) {
|
||||
if (false === bg_enable.value) {
|
||||
return
|
||||
}
|
||||
document.querySelector('body').setAttribute("style", `opacity: ${v}`)
|
||||
})
|
||||
watch(hasAPISettings, async () => await loadImage())
|
||||
watch(breakpoints.active(), async () => await loadImage())
|
||||
watch(bg_enable, async v => {
|
||||
if (true === v) {
|
||||
@@ -423,7 +415,7 @@ watch(bg_enable, async v => {
|
||||
})
|
||||
|
||||
const loadImage = async () => {
|
||||
if (!bg_enable.value || !hasAPISettings) {
|
||||
if (!bg_enable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -456,4 +448,15 @@ const loadImage = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
|
||||
if (!confirm('Logout?')) {
|
||||
return false
|
||||
}
|
||||
|
||||
auth.logout()
|
||||
|
||||
await navigateTo('/auth')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
9
frontend/layouts/guest.vue
Normal file
9
frontend/layouts/guest.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import 'assets/css/bulma.css'
|
||||
import 'assets/css/style.css'
|
||||
import 'assets/css/all.css'
|
||||
</script>
|
||||
36
frontend/middleware/auth.global.js
Normal file
36
frontend/middleware/auth.global.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAuthStore } from '~/store/auth'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
let next_check = 0
|
||||
|
||||
export default defineNuxtRouteMiddleware(async to => {
|
||||
if (to.fullPath.startsWith('/auth') || to.fullPath.startsWith('/v1/api')) {
|
||||
return
|
||||
}
|
||||
|
||||
const { authenticated } = storeToRefs(useAuthStore())
|
||||
const token = useStorage('token', null)
|
||||
|
||||
if (token.value) {
|
||||
if (Date.now() > next_check) {
|
||||
console.debug('Validating user token...')
|
||||
const { validate } = useAuthStore()
|
||||
if (!await validate(token.value)) {
|
||||
token.value = null
|
||||
abortNavigation()
|
||||
console.error('Token is invalid, redirecting to login page...')
|
||||
return navigateTo('/auth')
|
||||
}
|
||||
console.debug('Token is valid.')
|
||||
next_check = Date.now() + 1000 * 60 * 5
|
||||
}
|
||||
|
||||
authenticated.value = true
|
||||
}
|
||||
|
||||
if (!token.value && to?.name !== 'auth') {
|
||||
abortNavigation()
|
||||
return navigateTo('/auth')
|
||||
}
|
||||
})
|
||||
@@ -9,7 +9,7 @@ try {
|
||||
extraNitro = {
|
||||
devProxy: {
|
||||
'/v1/api/': {
|
||||
target: API_URL,
|
||||
target: API_URL + '/v1/api/',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
@@ -20,23 +20,28 @@ try {
|
||||
|
||||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
devtools: {enabled: true},
|
||||
devtools: { enabled: true },
|
||||
|
||||
devServer: {
|
||||
port: 8081,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
domain: '/',
|
||||
version: '1.0.0',
|
||||
}
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
"meta": [
|
||||
{"charset": "utf-8"},
|
||||
{"name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0"},
|
||||
{"name": "theme-color", "content": "#000000"}
|
||||
{ "charset": "utf-8" },
|
||||
{ "name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0" },
|
||||
{ "name": "theme-color", "content": "#000000" }
|
||||
],
|
||||
},
|
||||
buildAssetsDir: "assets",
|
||||
pageTransition: {name: 'page', mode: 'out-in'}
|
||||
pageTransition: { name: 'page', mode: 'out-in' }
|
||||
},
|
||||
|
||||
router: {
|
||||
@@ -48,6 +53,7 @@ export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@vueuse/nuxt',
|
||||
'floating-vue/nuxt',
|
||||
'@pinia/nuxt',
|
||||
],
|
||||
|
||||
nitro: {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
},
|
||||
"web-types": "./web-types.json",
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@pinia/nuxt": "^0.11.0",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"@vueuse/nuxt": "^13.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -23,6 +25,7 @@
|
||||
"marked-base-url": "^1.1.3",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^3.17.1",
|
||||
"pinia": "^3.0.2",
|
||||
"plyr": "^3.7.8",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.5.1",
|
||||
|
||||
144
frontend/pages/auth.vue
Normal file
144
frontend/pages/auth.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="hero is-dark is-fullheight">
|
||||
<div class="hero-body">
|
||||
<div class="container" style="background-color: unset !important;">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-6-tablet is-6-desktop is-4-widescreen">
|
||||
<div class="box" v-if="error">
|
||||
<span class="icon"><i class="fa fa-info" /></span>
|
||||
<span class="has-text-danger">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<form method="post" @submit.prevent="formValidate()" class="box">
|
||||
<div class="field">
|
||||
<label for="user-id" class="label">
|
||||
{{ signup ? 'Create an account' : 'Login' }}
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<input id="user-id" type="text" placeholder="Username" class="input" required
|
||||
autocomplete="username" name="username" v-model="user.username" autofocus>
|
||||
<span class="icon is-left"><i class="fa fa-user" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="user-password" class="label">Password</label>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded has-icons-left">
|
||||
<input class="input" id="user-password" v-model="user.password"
|
||||
required placeholder="Password"
|
||||
:type="false === form_expose ? 'password' : 'text'">
|
||||
<span class="icon is-left"><i class="fa fa-lock" /></span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-primary"
|
||||
@click="form_expose = !form_expose">
|
||||
<span class="icon" v-if="!form_expose"><i
|
||||
class="fas fa-eye" /></span>
|
||||
<span class="icon" v-else><i class="fas fa-eye-slash" /></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button type="submit" class="button is-fullwidth is-dark is-light">
|
||||
<span class="icon"><i class="fa fa-sign-in" /></span>
|
||||
<span>
|
||||
{{ signup ? 'Create an account' : 'Login' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { useAuthStore } from '~/store/auth'
|
||||
|
||||
definePageMeta({ name: "auth", layout: 'guest' })
|
||||
useHead({ title: 'Login / Signup' })
|
||||
|
||||
const error = ref('')
|
||||
const form_expose = ref(false)
|
||||
const signup = ref(false)
|
||||
const token = useStorage('api_token', '')
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const user = ref({ username: '', password: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
const req = await request('/system/auth/has_user')
|
||||
if (204 === req.status) {
|
||||
signup.value = true
|
||||
}
|
||||
});
|
||||
|
||||
const formValidate = async () => {
|
||||
if (user.value.username.length < 1) {
|
||||
error.value = 'Username is required.'
|
||||
return false
|
||||
}
|
||||
|
||||
if (user.value.password.length < 1) {
|
||||
error.value = 'Password is required.'
|
||||
return false
|
||||
}
|
||||
|
||||
if (false === /^[a-z_0-9]+$/.test(user.value.username)) {
|
||||
error.value = 'Username can only contain lowercase letters, numbers and underscores.'
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
if (signup.value) {
|
||||
return await do_signup()
|
||||
}
|
||||
|
||||
return await do_login()
|
||||
}
|
||||
|
||||
const do_login = async () => {
|
||||
try {
|
||||
await auth.login(user.value.username, user.value.password)
|
||||
if (auth.authenticated) {
|
||||
notification('success', 'Success', 'Login successful. Redirecting...')
|
||||
return await navigateTo('/')
|
||||
}
|
||||
throw new Error('Login failed. Please check your username and password.')
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e)
|
||||
error.value = e.message
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const do_signup = async () => {
|
||||
try {
|
||||
const state = await auth.signup(user.value.username, user.value.password)
|
||||
if (false === state) {
|
||||
error.value = 'Failed to create an account.'
|
||||
return false
|
||||
}
|
||||
return await do_login()
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e)
|
||||
error.value = e.message
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12 is-clearfix is-unselectable">
|
||||
<span class="title is-4">
|
||||
<span class="icon"><i class="fas fa-globe" :class="{'fa-spin': isLoading }"/> </span>
|
||||
<span class="icon"><i class="fas fa-globe" :class="{ 'fa-spin': isLoading }" /> </span>
|
||||
<NuxtLink to="/logs">Logs</NuxtLink>
|
||||
: {{ filename }}
|
||||
</span>
|
||||
@@ -12,46 +12,46 @@
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button v-if="!autoScroll" @click="scrollToBottom" class="button is-primary"
|
||||
v-tooltip.bottom="'Go to bottom'">
|
||||
v-tooltip.bottom="'Go to bottom'">
|
||||
<span class="icon"><i class="fas fa-arrow-down"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control has-icons-left" v-if="toggleFilter">
|
||||
<input type="search" v-model.lazy="query" class="input" id="filter" placeholder="Filter">
|
||||
<span class="icon is-left"><i class="fas fa-filter"/></span>
|
||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button class="button is-danger is-light" v-tooltip.bottom="'Filter log lines.'"
|
||||
@click="toggleFilter = !toggleFilter">
|
||||
<span class="icon"><i class="fas fa-filter"/></span>
|
||||
@click="toggleFilter = !toggleFilter">
|
||||
<span class="icon"><i class="fas fa-filter" /></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-danger" v-tooltip.bottom="'Delete Logfile.'" @click="deleteFile">
|
||||
<span class="icon"><i class="fas fa-trash"/></span>
|
||||
<span class="icon"><i class="fas fa-trash" /></span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-purple is-light" v-tooltip.bottom="'Download the entire logfile.'"
|
||||
@click="downloadFile" :class="{ 'is-loading': isDownloading }">
|
||||
<span class="icon"><i class="fas fa-download"/></span>
|
||||
@click="downloadFile" :class="{ 'is-loading': isDownloading }">
|
||||
<span class="icon"><i class="fas fa-download" /></span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-warning" @click="wrapLines = !wrapLines" v-tooltip.bottom="'Toggle wrap line'">
|
||||
<span class="icon"><i class="fas fa-text-width"/></span>
|
||||
<span class="icon"><i class="fas fa-text-width" /></span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button class="button" v-tooltip.bottom="'Copy showing logs'"
|
||||
@click="() => copyText(filterItems.map(i => i.text).join('\n'))">
|
||||
<span class="icon"><i class="fas fa-copy"/></span>
|
||||
@click="() => copyText(filterItems.map(i => i.text).join('\n'))">
|
||||
<span class="icon"><i class="fas fa-copy" /></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
@@ -67,34 +67,35 @@
|
||||
<div class="column is-12">
|
||||
<div class="logbox is-grid" ref="logContainer" v-if="!error" @scroll.passive="handleScroll">
|
||||
<code id="logView" class="p-1 logline is-block" :class="{ 'is-pre-wrap': wrapLines, 'is-pre': !wrapLines }">
|
||||
<span class="is-block m-0 notification is-info is-dark has-text-centered" v-if="reachedEnd && !query">
|
||||
<span class="notification-title">
|
||||
<span class="icon"><i class="fas fa-exclamation-triangle"/></span>
|
||||
No more logs available for this file.
|
||||
</span>
|
||||
</span>
|
||||
<span v-for="item in filterItems" :key="item.id" class="is-block">
|
||||
<span v-if="item.date">[<span class="has-tooltip" :title="item.date">{{ formatDate(item.date) }}</span>]: </span>
|
||||
<span v-if="item?.item_id"><span class="is-clickable has-tooltip" @click="goto_history_item(item)"><span
|
||||
class="icon"><i class="fas fa-history"/></span><span>View</span></span> </span>
|
||||
<span>{{ item.text }}</span>
|
||||
</span>
|
||||
<span class="is-block" v-if="filterItems.length < 1">
|
||||
<span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query">
|
||||
<span class="notification-title is-danger">
|
||||
<span class="icon"><i class="fas fa-filter"/></span>
|
||||
No logs match this query: <u>{{ query }}</u>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="has-text-danger">No logs available</span></span>
|
||||
</span>
|
||||
</code>
|
||||
<span class="is-block m-0 notification is-info is-dark has-text-centered" v-if="reachedEnd && !query">
|
||||
<span class="notification-title">
|
||||
<span class="icon"><i class="fas fa-exclamation-triangle" /></span>
|
||||
No more logs available for this file.
|
||||
</span>
|
||||
</span>
|
||||
<span v-for="item in filterItems" :key="item.id" class="is-block">
|
||||
<span v-if="item.date">[<span class="has-tooltip" :title="item.date">{{ formatDate(item.date)
|
||||
}}</span>]: </span>
|
||||
<span v-if="item?.item_id"><span class="is-clickable has-tooltip" @click="goto_history_item(item)"><span
|
||||
class="icon"><i class="fas fa-history" /></span><span>View</span></span> </span>
|
||||
<span>{{ item.text }}</span>
|
||||
</span>
|
||||
<span class="is-block" v-if="filterItems.length < 1">
|
||||
<span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query">
|
||||
<span class="notification-title is-danger">
|
||||
<span class="icon"><i class="fas fa-filter" /></span>
|
||||
No logs match this query: <u>{{ query }}</u>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="has-text-danger">No logs available</span></span>
|
||||
</span>
|
||||
</code>
|
||||
<div ref="bottomMarker"></div>
|
||||
</div>
|
||||
|
||||
<Message v-if="error" title="API Error" message_class="has-background-warning-90 has-text-dark" :message="error"
|
||||
:use-close="true" @close="router.push('/logs')"/>
|
||||
:use-close="true" @close="router.push('/logs')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,11 +108,11 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#logView > span:nth-child(even) {
|
||||
#logView>span:nth-child(even) {
|
||||
color: #ffc9d4;
|
||||
}
|
||||
|
||||
#logView > span:nth-child(odd) {
|
||||
#logView>span:nth-child(odd) {
|
||||
color: #e3c981;
|
||||
}
|
||||
|
||||
@@ -143,14 +144,15 @@ div.logbox pre {
|
||||
<script setup>
|
||||
import Message from '~/components/Message'
|
||||
import moment from 'moment'
|
||||
import {useStorage} from '@vueuse/core'
|
||||
import {disableOpacity, enableOpacity, goto_history_item, notification, parse_api_response} from '~/utils/index'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { disableOpacity, enableOpacity, goto_history_item, notification, parse_api_response } from '~/utils/index'
|
||||
import request from '~/utils/request'
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||
|
||||
const router = useRouter()
|
||||
const filename = useRoute().params.filename
|
||||
|
||||
useHead({title: `Logs : ${filename}`})
|
||||
useHead({ title: `Logs : ${filename}` })
|
||||
|
||||
const query = ref()
|
||||
const data = ref([])
|
||||
@@ -165,12 +167,7 @@ const reachedEnd = ref(false)
|
||||
const offset = ref(0)
|
||||
let scrollTimeout = null
|
||||
|
||||
const bg_enable = useStorage('bg_enable', true)
|
||||
const bg_opacity = useStorage('bg_opacity', 0.95)
|
||||
|
||||
const api_path = useStorage('api_path', '/v1/api')
|
||||
const api_url = useStorage('api_url', '')
|
||||
const api_token = useStorage('api_token', '')
|
||||
const token = useStorage('token', '')
|
||||
|
||||
watch(toggleFilter, async () => {
|
||||
if (!toggleFilter.value) {
|
||||
@@ -194,6 +191,8 @@ const logContainer = ref(null)
|
||||
/** @type {Ref<HTMLPreElement|null>} */
|
||||
const bottomMarker = ref(null)
|
||||
|
||||
const ctrl = new AbortController();
|
||||
|
||||
const loadContent = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
@@ -234,7 +233,7 @@ const loadContent = async () => {
|
||||
// Auto-scroll only if the user was already at the bottom
|
||||
await nextTick(() => {
|
||||
if (autoScroll.value && bottomMarker.value) {
|
||||
bottomMarker.value.scrollIntoView({behavior: 'auto'})
|
||||
bottomMarker.value.scrollIntoView({ behavior: 'auto' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -274,7 +273,7 @@ const scrollToBottom = () => {
|
||||
autoScroll.value = true
|
||||
nextTick(() => {
|
||||
if (bottomMarker.value) {
|
||||
bottomMarker.value.scrollIntoView({behavior: 'smooth'})
|
||||
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -298,32 +297,46 @@ const watchLog = () => {
|
||||
}
|
||||
|
||||
// noinspection JSValidateTypes
|
||||
stream.value = new EventSource(`${api_url.value}${api_path.value}/log/${filename}?stream=1&apikey=${api_token.value}`)
|
||||
stream.value.addEventListener('data', async (e) => {
|
||||
let lines = e.data.split(/\n/g);
|
||||
for (let x = 0; x < lines.length; x++) {
|
||||
try {
|
||||
const line = String(lines[x])
|
||||
if (!line.trim()) {
|
||||
continue
|
||||
}
|
||||
data.value.push(JSON.parse(line))
|
||||
|
||||
await nextTick(() => {
|
||||
if (autoScroll.value && bottomMarker.value) {
|
||||
bottomMarker.value.scrollIntoView({behavior: 'smooth'})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
stream.value = fetchEventSource(`/v1/api/log/${filename}?stream=1`, {
|
||||
onmessage: async evt => {
|
||||
if ('data' !== evt.event) {
|
||||
return
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let lines = evt.data.split(/\n/g);
|
||||
|
||||
for (let x = 0; x < lines.length; x++) {
|
||||
try {
|
||||
const line = String(lines[x])
|
||||
if (!line.trim()) {
|
||||
continue
|
||||
}
|
||||
data.value.push(JSON.parse(line))
|
||||
|
||||
await nextTick(() => {
|
||||
if (autoScroll.value && bottomMarker.value) {
|
||||
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Token ${token.value}`,
|
||||
},
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
}
|
||||
|
||||
const closeStream = () => {
|
||||
if (stream.value) {
|
||||
stream.value.close()
|
||||
try {
|
||||
stream.value.close()
|
||||
ctrl.abort()
|
||||
} catch (e) {
|
||||
}
|
||||
stream.value = null
|
||||
}
|
||||
}
|
||||
@@ -362,7 +375,7 @@ const deleteFile = async () => {
|
||||
try {
|
||||
closeStream();
|
||||
|
||||
const response = await request(`/log/${filename}`, {method: 'DELETE'})
|
||||
const response = await request(`/log/${filename}`, { method: 'DELETE' })
|
||||
|
||||
if (response.ok) {
|
||||
notification('success', 'Information', `Logfile '${filename}' has been deleted.`)
|
||||
@@ -377,7 +390,7 @@ const deleteFile = async () => {
|
||||
json = await response.json()
|
||||
} catch (e) {
|
||||
json = {
|
||||
error: {code: response.status, message: response.statusText}
|
||||
error: { code: response.status, message: response.statusText }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
91
frontend/store/auth.js
Normal file
91
frontend/store/auth.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const state = reactive({
|
||||
token: null, authenticated: false, loading: false,
|
||||
});
|
||||
|
||||
const actions = {
|
||||
async signup(username, password) {
|
||||
if (!username || !password) {
|
||||
throw new Error('Please provide a valid username and password');
|
||||
}
|
||||
|
||||
const req = await request('/system/auth/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: username, password: password })
|
||||
})
|
||||
|
||||
if (201 === req.status) {
|
||||
return true
|
||||
}
|
||||
|
||||
const json = await parse_api_response(req)
|
||||
throw new Error(json.error.message);
|
||||
},
|
||||
async login(username, password) {
|
||||
if (!username || !password) {
|
||||
throw new Error('Please provide a valid username and password');
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await request(`/system/auth/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: username, password: password }),
|
||||
});
|
||||
|
||||
const json = await parse_api_response(response)
|
||||
|
||||
if (200 !== response.status) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
if (!json?.token) {
|
||||
throw new Error('Error. API did not return a token.');
|
||||
}
|
||||
|
||||
const token = useStorage('token', null);
|
||||
token.value = json.token;
|
||||
|
||||
this.token = json.token;
|
||||
|
||||
this.authenticated = true;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}, async logout() {
|
||||
const token = useStorage('api_token', null);
|
||||
this.authenticated = false;
|
||||
token.value = null;
|
||||
return true
|
||||
}, async validate(token) {
|
||||
try {
|
||||
const response = await request('/system/auth/me', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Token ' + token,
|
||||
}
|
||||
});
|
||||
|
||||
if (200 !== response.status) {
|
||||
this.token = null;
|
||||
this.authenticated = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.token = token;
|
||||
this.authenticated = true;
|
||||
return true
|
||||
} catch (e) {
|
||||
this.token = null;
|
||||
this.authenticated = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...toRefs(state), ...actions };
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
class Cache {
|
||||
supportedEngines = ['session', 'local']
|
||||
namespace = ''
|
||||
|
||||
constructor(engine = 'session', namespace = '') {
|
||||
if (!this.supportedEngines.includes(engine)) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {useStorage} from "@vueuse/core";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
const api_path = useStorage('api_path', '/v1/api')
|
||||
const api_url = useStorage('api_url', '')
|
||||
const api_token = useStorage('api_token', '')
|
||||
const token = useStorage('token', '')
|
||||
const api_user = useStorage('api_user', 'main')
|
||||
|
||||
/**
|
||||
@@ -15,18 +13,18 @@ const api_user = useStorage('api_user', 'main')
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export default async function request(url, options = {}) {
|
||||
if (!api_token.value) {
|
||||
throw new Error('API token is not set');
|
||||
}
|
||||
options = options || {};
|
||||
options.method = options.method || 'GET';
|
||||
options.headers = options.headers || {};
|
||||
if (options.headers['Authorization'] === undefined) {
|
||||
options.headers['Authorization'] = 'Bearer ' + api_token.value;
|
||||
|
||||
if (options.headers['Authorization'] === undefined && token.value) {
|
||||
options.headers['Authorization'] = 'Token ' + token.value;
|
||||
}
|
||||
|
||||
if (options.headers['Content-Type'] === undefined) {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (options.headers['Accept'] === undefined) {
|
||||
options.headers['Accept'] = 'application/json';
|
||||
}
|
||||
@@ -35,6 +33,6 @@ export default async function request(url, options = {}) {
|
||||
options.headers['X-User'] = api_user.value;
|
||||
}
|
||||
|
||||
return fetch(`${api_url.value}${api_path.value}${url}`, options);
|
||||
return fetch(`/v1/api${url}`, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -653,6 +653,11 @@
|
||||
semver "^7.5.3"
|
||||
tar "^7.4.0"
|
||||
|
||||
"@microsoft/fetch-event-source@^2.0.1":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz#9ceecc94b49fbaa15666e38ae8587f64acce007d"
|
||||
integrity sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==
|
||||
|
||||
"@napi-rs/wasm-runtime@^0.2.9":
|
||||
version "0.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.9.tgz#7278122cf94f3b36d8170a8eee7d85356dfa6a96"
|
||||
@@ -909,6 +914,34 @@
|
||||
unimport "^5.0.0"
|
||||
untyped "^2.0.0"
|
||||
|
||||
"@nuxt/kit@^3.9.0":
|
||||
version "3.17.3"
|
||||
resolved "https://registry.yarnpkg.com/@nuxt/kit/-/kit-3.17.3.tgz#58a54b0155b1a3b277f434de024d7d5e738c38e2"
|
||||
integrity sha512-aw6u6mT3TnM/MmcCRDMv3i9Sbm5/ZMSJgDl+N+WsrWNDIQ2sWmsqdDkjb/HyXF20SNwc2891hRBkaQr3hG2mhA==
|
||||
dependencies:
|
||||
c12 "^3.0.3"
|
||||
consola "^3.4.2"
|
||||
defu "^6.1.4"
|
||||
destr "^2.0.5"
|
||||
errx "^0.1.0"
|
||||
exsolve "^1.0.5"
|
||||
ignore "^7.0.4"
|
||||
jiti "^2.4.2"
|
||||
klona "^2.0.6"
|
||||
knitwork "^1.2.0"
|
||||
mlly "^1.7.4"
|
||||
ohash "^2.0.11"
|
||||
pathe "^2.0.3"
|
||||
pkg-types "^2.1.0"
|
||||
scule "^1.3.0"
|
||||
semver "^7.7.1"
|
||||
std-env "^3.9.0"
|
||||
tinyglobby "^0.2.13"
|
||||
ufo "^1.6.1"
|
||||
unctx "^2.4.1"
|
||||
unimport "^5.0.1"
|
||||
untyped "^2.0.0"
|
||||
|
||||
"@nuxt/schema@3.17.1", "@nuxt/schema@^3.16.2":
|
||||
version "3.17.1"
|
||||
resolved "https://registry.yarnpkg.com/@nuxt/schema/-/schema-3.17.1.tgz#91a7a47adb82eb445e261e9602bd8de41074a91f"
|
||||
@@ -1132,6 +1165,13 @@
|
||||
"@parcel/watcher-win32-ia32" "2.5.1"
|
||||
"@parcel/watcher-win32-x64" "2.5.1"
|
||||
|
||||
"@pinia/nuxt@^0.11.0":
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@pinia/nuxt/-/nuxt-0.11.0.tgz#f97d0c4cbaa3a8b6606eee562ec5c9098fcd1905"
|
||||
integrity sha512-QGFlUAkeVAhPCTXacrtNP4ti24sGEleVzmxcTALY9IkS6U5OUox7vmNL1pkqBeW39oSNq/UC5m40ofDEPHB1fg==
|
||||
dependencies:
|
||||
"@nuxt/kit" "^3.9.0"
|
||||
|
||||
"@pkgjs/parseargs@^0.11.0":
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
|
||||
@@ -1579,6 +1619,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
|
||||
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
|
||||
|
||||
"@vue/devtools-api@^7.7.2":
|
||||
version "7.7.6"
|
||||
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-7.7.6.tgz#4af5dbc77bcc8543f0a8e6f029f598ed978d6c7d"
|
||||
integrity sha512-b2Xx0KvXZObePpXPYHvBRRJLDQn5nhKjXh7vUhMEtWxz1AYNFOVIsh5+HLP8xDGL7sy+Q7hXeUxPHB/KgbtsPw==
|
||||
dependencies:
|
||||
"@vue/devtools-kit" "^7.7.6"
|
||||
|
||||
"@vue/devtools-core@^7.7.2":
|
||||
version "7.7.6"
|
||||
resolved "https://registry.yarnpkg.com/@vue/devtools-core/-/devtools-core-7.7.6.tgz#7e2ef93c05af809e5ed159ffc1b910f030976b83"
|
||||
@@ -4750,6 +4797,13 @@ picomatch@^4.0.2:
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab"
|
||||
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
|
||||
|
||||
pinia@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/pinia/-/pinia-3.0.2.tgz#0616c2e1b39915f253c7626db3c81b7cdad695da"
|
||||
integrity sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==
|
||||
dependencies:
|
||||
"@vue/devtools-api" "^7.7.2"
|
||||
|
||||
pkg-types@^1.0.3, pkg-types@^1.3.0:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
|
||||
@@ -5953,6 +6007,26 @@ unimport@^5.0.0:
|
||||
unplugin "^2.2.2"
|
||||
unplugin-utils "^0.2.4"
|
||||
|
||||
unimport@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/unimport/-/unimport-5.0.1.tgz#c823ace5819fc810c25435450b22ebc4ab8b11f9"
|
||||
integrity sha512-1YWzPj6wYhtwHE+9LxRlyqP4DiRrhGfJxdtH475im8ktyZXO3jHj/3PZ97zDdvkYoovFdi0K4SKl3a7l92v3sQ==
|
||||
dependencies:
|
||||
acorn "^8.14.1"
|
||||
escape-string-regexp "^5.0.0"
|
||||
estree-walker "^3.0.3"
|
||||
local-pkg "^1.1.1"
|
||||
magic-string "^0.30.17"
|
||||
mlly "^1.7.4"
|
||||
pathe "^2.0.3"
|
||||
picomatch "^4.0.2"
|
||||
pkg-types "^2.1.0"
|
||||
scule "^1.3.0"
|
||||
strip-literal "^3.0.0"
|
||||
tinyglobby "^0.2.13"
|
||||
unplugin "^2.3.2"
|
||||
unplugin-utils "^0.2.4"
|
||||
|
||||
unixify@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090"
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Libs\TokenUtil;
|
||||
use App\Libs\Traits\APITraits;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Throwable;
|
||||
|
||||
final class Auth
|
||||
{
|
||||
@@ -30,6 +31,64 @@ final class Auth
|
||||
return api_response(empty($user) || empty($password) ? Status::NO_CONTENT : Status::OK);
|
||||
}
|
||||
|
||||
#[Get(self::URL . '/me[/]', name: 'system.auth.me')]
|
||||
public function me(iRequest $request): iResponse
|
||||
{
|
||||
$user = Config::get('system.user');
|
||||
$pass = Config::get('system.password');
|
||||
|
||||
if (empty($user) || empty($pass)) {
|
||||
return api_error('System user or password is not configured.', Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
foreach ($request->getHeader('Authorization') as $auth) {
|
||||
[$type, $value] = explode(' ', $auth, 2);
|
||||
$type = strtolower(trim($type));
|
||||
|
||||
if (false === in_array($type, ['bearer', 'token'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tokens[$type] = trim($value);
|
||||
}
|
||||
|
||||
if (empty($tokens['token'])) {
|
||||
return api_error('This endpoint only works with user tokens.', Status::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$token = rawurldecode($tokens['token']);
|
||||
|
||||
try {
|
||||
$decoded = TokenUtil::decode($token);
|
||||
if (false === $decoded) {
|
||||
throw new \RuntimeException('Failed to decode token.');
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return api_error('Failed to decode token.', Status::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$parts = explode('.', $decoded, 2);
|
||||
if (2 !== count($parts)) {
|
||||
return api_error('Invalid token.', Status::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
[$signature, $payload] = $parts;
|
||||
|
||||
if (false === TokenUtil::verify($payload, $signature)) {
|
||||
return api_error('Invalid token.', Status::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
|
||||
return api_response(Status::OK, [
|
||||
'username' => ag($payload, 'username', '??'),
|
||||
'created_at' => makeDate(ag($payload, 'iat', 0)),
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return api_error('Failed to decode payload.', Status::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
#[Post(self::URL . '/signup[/]', name: 'system.auth.signup')]
|
||||
public function do_signup(iRequest $request): iResponse
|
||||
{
|
||||
|
||||
@@ -99,7 +99,7 @@ final class AuthorizationMiddleware implements MiddlewareInterface
|
||||
*
|
||||
* @return bool True if the tken is valid. False otherwise.
|
||||
*/
|
||||
private function validateToken(?string $token): bool
|
||||
public static function validateToken(?string $token): bool
|
||||
{
|
||||
if (empty($token)) {
|
||||
return false;
|
||||
|
||||
@@ -20,7 +20,7 @@ final class ServeStatic implements LoggerAwareInterface
|
||||
use LoggerAwareTrait;
|
||||
|
||||
private finfo|null $mimeType = null;
|
||||
|
||||
|
||||
private const array CONTENT_TYPE = [
|
||||
'css' => 'text/css; charset=utf-8',
|
||||
'js' => 'text/javascript; charset=utf-8',
|
||||
|
||||
Reference in New Issue
Block a user