|
|
|
|
<!--
|
|
|
|
|
* login.vue
|
|
|
|
|
* Copyright (C) 2024 veypi <i@veypi.com>
|
|
|
|
|
* 2024-05-31 17:10
|
|
|
|
|
* Distributed under terms of the MIT license.
|
|
|
|
|
-->
|
|
|
|
|
<template>
|
|
|
|
|
<div class="login-page flex items-center justify-center">
|
|
|
|
|
<div class="login box">
|
|
|
|
|
<div class="header flex items-center justify-start gap-2">
|
|
|
|
|
<div class="voa-logo"></div>
|
|
|
|
|
<div class="txt">OneAuth</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Transition name="box" mode="out-in">
|
|
|
|
|
<div class="newbie content" v-if="aOpt === 'newbie'">
|
|
|
|
|
<div :check="checks.u" class="username mt-8">
|
|
|
|
|
<input @change="check" v-model="data.username" autocomplete="username"
|
|
|
|
|
placeholder="username, phone or Email">
|
|
|
|
|
</div>
|
|
|
|
|
<div :check="checks.p" class="password">
|
|
|
|
|
<input @change="check" v-model="data.password" autocomplete="password" type='password'
|
|
|
|
|
placeholder="password">
|
|
|
|
|
</div>
|
|
|
|
|
<div :check="checks.p2" class="password">
|
|
|
|
|
<input @change="check" v-model="data.confirm" autocomplete="password" type='password'
|
|
|
|
|
placeholder="password">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex">
|
|
|
|
|
<button @click="aOpt = ''" class='ok voa-btn back'>
|
|
|
|
|
back
|
|
|
|
|
</button>
|
|
|
|
|
<button @click="register" class='ok voa-btn'>
|
|
|
|
|
register
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="oh_no content" v-else-if="aOpt === 'oh_no'">
|
|
|
|
|
<div class="username mt-8">
|
|
|
|
|
<input v-model="data.username" autocomplete="username" placeholder="username, phone or Email">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex">
|
|
|
|
|
<button @click="aOpt = ''" class='ok back voa-btn'>
|
|
|
|
|
back
|
|
|
|
|
</button>
|
|
|
|
|
<button @click="reset" class='ok voa-btn'>
|
|
|
|
|
confirm
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="login content" v-else-if='isValid'>
|
|
|
|
|
<div class="flex mt-10 h-full" v-if="app.id">
|
|
|
|
|
<div class="flex flex-col items-center w-1/2 justify-center">
|
|
|
|
|
<img class="rounded-full h-44 w-44" :src="oaer.local().icon">
|
|
|
|
|
<div class="mt-4 text-2xl">{{ oaer.local().nickname || oaer.local().username }}</div>
|
|
|
|
|
<div class="mt-4"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-col w-1/2 gap-4">
|
|
|
|
|
<div class="flex items-center justify-start gap-4">
|
|
|
|
|
<img class="rounded-full h-16 w-16" :src="app.icon">
|
|
|
|
|
<div>{{ app.name }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex-grow">
|
|
|
|
|
<div>您正在授权登录 <span class="font-bold text-xl">{{ app.name }}</span> </div>
|
|
|
|
|
|
|
|
|
|
<div class="mt-8 ml-8 flex flex-col gap-4">
|
|
|
|
|
<div class="auth-line">
|
|
|
|
|
<UToggle color="primary" :model-value="true" disabled />
|
|
|
|
|
<div class='auth-info'>Basic User Info</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="auth-line">
|
|
|
|
|
<UToggle color="primary" v-model="app_perm.fs[0]" />
|
|
|
|
|
<div class='auth-info flex'>
|
|
|
|
|
<UInput v-if="app_perm.fs[0]" :padded="false" v-model="app_perm.fs[1]"
|
|
|
|
|
placeholder="userfile auth scope" variant="none" class="w-full border-b-black border-b-2" />
|
|
|
|
|
<span v-else>userfile permission</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex">
|
|
|
|
|
<button style="" @click="signout" class='ok back voa-btn'>
|
|
|
|
|
Sign out
|
|
|
|
|
</button>
|
|
|
|
|
<button @click="redirect()" class='ok voa-btn'>
|
|
|
|
|
Sign in
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="text-sm text-gray-600 text-center">
|
|
|
|
|
Authorizing will redirect to {{ app.init_url }}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex mt-10 h-full justify-center items-center flex-col" v-else>
|
|
|
|
|
<img class="rounded-full h-44 w-44" :src="oaer.local().icon">
|
|
|
|
|
<div class="mt-4 text-2xl">{{ oaer.local().nickname || oaer.local().username }}</div>
|
|
|
|
|
<div class="flex-grow"></div>
|
|
|
|
|
<div class="flex w-1/2">
|
|
|
|
|
<button style="" @click="signout" class='ok back voa-btn'>
|
|
|
|
|
Sign out
|
|
|
|
|
</button>
|
|
|
|
|
<button @click="redirect()" class='ok voa-btn'>
|
|
|
|
|
Sign in
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="login content flex flex-col justify-between" v-else>
|
|
|
|
|
<div :check="checks.u" class="username mt-8">
|
|
|
|
|
<input @change="check" v-model="data.username" autocomplete="username"
|
|
|
|
|
placeholder="username, phone or Email">
|
|
|
|
|
</div>
|
|
|
|
|
<div :check="checks.p" class="password">
|
|
|
|
|
<input @change="check" v-model="data.password" autocomplete="password" type='password'
|
|
|
|
|
placeholder="password">
|
|
|
|
|
</div>
|
|
|
|
|
<button @click="signin" class='ok voa-btn'>
|
|
|
|
|
Sign in
|
|
|
|
|
</button>
|
|
|
|
|
<div class="last">
|
|
|
|
|
<div class="icos">
|
|
|
|
|
<div class="github"></div>
|
|
|
|
|
<div class="wechat"></div>
|
|
|
|
|
<div class="google"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="txt">
|
|
|
|
|
<div @click="aOpt = 'newbie'">Create Account</div>
|
|
|
|
|
<div @click="aOpt = 'oh_no'">Forgot Password?</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Transition>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
|
|
|
import msg from '@veypi/msg';
|
|
|
|
|
import * as crypto from 'crypto-js'
|
|
|
|
|
import oaer from '@veypi/oaer'
|
|
|
|
|
import type { models } from '#imports';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
definePageMeta({
|
|
|
|
|
layout: false,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
const isValid = ref(oaer.isValid())
|
|
|
|
|
const uuid = ref(route.query.uuid as string)
|
|
|
|
|
const app = ref<models.App>({} as models.App)
|
|
|
|
|
const app_perm = ref<{ [key: string]: [boolean, string, number] }>({ 'fs': [true, '/', 4], 'app': [true, '', 1], 'user': [true, '', 1] })
|
|
|
|
|
|
|
|
|
|
const auto_redirect = () => {
|
|
|
|
|
if (isValid.value) {
|
|
|
|
|
if (uuid.value) {
|
|
|
|
|
api.app.Get(uuid.value).then(e => {
|
|
|
|
|
app.value = e
|
|
|
|
|
console.log(oaer.local())
|
|
|
|
|
api.token.List({ limit: 1, app_id: uuid.value, user_id: oaer.local().id }).then(e => {
|
|
|
|
|
console.log(e)
|
|
|
|
|
})
|
|
|
|
|
}).catch(e => {
|
|
|
|
|
if (e.code === 40401) {
|
|
|
|
|
msg.Warn('参数错误: 该应用不存在')
|
|
|
|
|
uuid.value = ''
|
|
|
|
|
redirect()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
console.warn(e)
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
redirect()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let data = ref({
|
|
|
|
|
username: '',
|
|
|
|
|
password: '',
|
|
|
|
|
confirm: '',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let uReg = /^[\w]{5,}$/
|
|
|
|
|
let pReg = /^[\w@_#]{6,}$/
|
|
|
|
|
let checks = ref({ 'u': true, 'p': true, 'p2': true })
|
|
|
|
|
let enable_check = ref(false)
|
|
|
|
|
const check = () => {
|
|
|
|
|
if (!enable_check.value) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
checks.value.u = !Boolean(!data.value.username || !uReg.test(data.value.username))
|
|
|
|
|
checks.value.p = !Boolean(!data.value.password || !pReg.test(data.value.password))
|
|
|
|
|
checks.value.p2 = !Boolean(data.value.confirm !== data.value.password)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deriveKey(password: string, salt: any) {
|
|
|
|
|
return crypto.PBKDF2(password, salt, {
|
|
|
|
|
keySize: 256 / 32, iterations:
|
|
|
|
|
100, hasher: crypto.algo.SHA256
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const signout = () => {
|
|
|
|
|
oaer.logout()
|
|
|
|
|
isValid.value = false
|
|
|
|
|
}
|
|
|
|
|
const signin = () => {
|
|
|
|
|
enable_check.value = true
|
|
|
|
|
check()
|
|
|
|
|
if (!checks.value.u || !checks.value.p) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
api.token.TokenSalt({ username: data.value.username }).then(e => {
|
|
|
|
|
let id = e.id
|
|
|
|
|
let key = deriveKey(data.value.password, e.salt)
|
|
|
|
|
let salt = crypto.lib.WordArray.random(128 / 8)
|
|
|
|
|
let opts = {
|
|
|
|
|
iv: salt,
|
|
|
|
|
mode: crypto.mode.CBC,
|
|
|
|
|
padding: crypto.pad.Pkcs7
|
|
|
|
|
}
|
|
|
|
|
let p = crypto.AES.encrypt(e.id, key, opts)
|
|
|
|
|
api.token.Post({
|
|
|
|
|
user_id: id, code: p.toString(), salt:
|
|
|
|
|
salt.toString()
|
|
|
|
|
}).then(e => {
|
|
|
|
|
oaer.init('', '', e).then(() => {
|
|
|
|
|
isValid.value = true
|
|
|
|
|
auto_redirect()
|
|
|
|
|
// redirect("")
|
|
|
|
|
}).catch((e) => {
|
|
|
|
|
console.warn(e)
|
|
|
|
|
msg.Warn('登录失败:' + (e?.err || e))
|
|
|
|
|
})
|
|
|
|
|
}).catch(e => {
|
|
|
|
|
msg.Warn('登录失败:' + (e?.err || e))
|
|
|
|
|
})
|
|
|
|
|
}).catch(e => {
|
|
|
|
|
if (e.code === 40401) {
|
|
|
|
|
msg.Warn('user not exist')
|
|
|
|
|
} else {
|
|
|
|
|
console.warn(e)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const register = () => {
|
|
|
|
|
enable_check.value = true
|
|
|
|
|
check()
|
|
|
|
|
if (!checks.value.u || !checks.value.p || !checks.value.p2) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let salt = crypto.lib.WordArray.random(128 / 8).toString()
|
|
|
|
|
let key = deriveKey(data.value.password, salt)
|
|
|
|
|
api.user.Post({
|
|
|
|
|
username: data.value.username,
|
|
|
|
|
salt: salt,
|
|
|
|
|
code: key.toString(crypto.enc.Hex)
|
|
|
|
|
}).then(() => {
|
|
|
|
|
msg.Info('注册成功')
|
|
|
|
|
aOpt.value = ''
|
|
|
|
|
}).catch(e => {
|
|
|
|
|
console.log(e)
|
|
|
|
|
msg.Warn('注册失败:' + (e.err || e))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const reset = () => {
|
|
|
|
|
enable_check.value = true
|
|
|
|
|
check()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let aOpt = ref('' as '' | 'newbie' | 'oh_no')
|
|
|
|
|
|
|
|
|
|
function redirect(url?: string) {
|
|
|
|
|
if (url === 'undefined') {
|
|
|
|
|
url = ''
|
|
|
|
|
}
|
|
|
|
|
if (route.query.redirect) {
|
|
|
|
|
url = route.query.redirect as string
|
|
|
|
|
}
|
|
|
|
|
if (uuid.value) {
|
|
|
|
|
api.app.Get(uuid.value).then((app) => {
|
|
|
|
|
if (uuid.value === oaer.logic().oa_id) {
|
|
|
|
|
oaer.goto(url || app.init_url || '/')
|
|
|
|
|
} else {
|
|
|
|
|
let perm = []
|
|
|
|
|
for (let i in app_perm.value) {
|
|
|
|
|
let p = app_perm.value[i]
|
|
|
|
|
if (p[0]) {
|
|
|
|
|
perm.push({
|
|
|
|
|
name: i,
|
|
|
|
|
tid: p[1],
|
|
|
|
|
level: p[2]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
api.token.Post({
|
|
|
|
|
refresh: oaer.logic().token.refresh.raw(),
|
|
|
|
|
app_id: uuid.value,
|
|
|
|
|
over_perm: JSON.stringify(perm)
|
|
|
|
|
}).then(e => {
|
|
|
|
|
url = url || app.init_url
|
|
|
|
|
// let data = JSON.parse(Base64.decode(e.split('.')[1]))
|
|
|
|
|
// console.log(data)
|
|
|
|
|
e = encodeURIComponent(e)
|
|
|
|
|
if (url.indexOf('$token') >= 0) {
|
|
|
|
|
url = url.replaceAll('$token', e)
|
|
|
|
|
}
|
|
|
|
|
oaer.goto(url, { 'token': e })
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
} else if (url) {
|
|
|
|
|
oaer.goto(url)
|
|
|
|
|
} else {
|
|
|
|
|
oaer.goto('/')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
auto_redirect()
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
.login-page {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
height: 100vh;
|
|
|
|
|
width: 100vw;
|
|
|
|
|
background-color: #fafafa;
|
|
|
|
|
background-image: url("../assets/img/bg.svg");
|
|
|
|
|
background-size: cover;
|
|
|
|
|
background-position: center;
|
|
|
|
|
/* backdrop-filter: blur(5px); */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.auth-line {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
|
|
|
|
.auth-info {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.box {
|
|
|
|
|
user-select: none;
|
|
|
|
|
position: sticky;
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
width: 50%;
|
|
|
|
|
max-width: 50rem;
|
|
|
|
|
min-width: 20rem;
|
|
|
|
|
height: 50%;
|
|
|
|
|
|
|
|
|
|
&::before {
|
|
|
|
|
content: "";
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
border-radius: 2rem;
|
|
|
|
|
background-color: rgba(200, 200, 200, 0.2);
|
|
|
|
|
backdrop-filter: blur(20px);
|
|
|
|
|
/* 模糊效果 */
|
|
|
|
|
z-index: -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header {
|
|
|
|
|
line-height: 2rem;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 4rem;
|
|
|
|
|
|
|
|
|
|
.voa-logo {
|
|
|
|
|
height: 4rem;
|
|
|
|
|
width: 4rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.txt {
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.content {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
height: calc(100% - 4rem);
|
|
|
|
|
|
|
|
|
|
.username,
|
|
|
|
|
.password {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
|
|
|
|
input {
|
|
|
|
|
height: 2.5rem;
|
|
|
|
|
line-height: 2.5rem;
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
width: calc(100% - 2rem);
|
|
|
|
|
margin: 0 1rem;
|
|
|
|
|
border: none;
|
|
|
|
|
outline: none;
|
|
|
|
|
background: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input:-webkit-autofill,
|
|
|
|
|
input:autofill,
|
|
|
|
|
input:-webkit-autofill:hover,
|
|
|
|
|
input:-webkit-autofill:focus,
|
|
|
|
|
input:-webkit-autofill:active {
|
|
|
|
|
// box-shadow: inset 0 0 0 100px rgba(200, 200, 200, 0.2) !important;
|
|
|
|
|
background-color: #0f0 !important;
|
|
|
|
|
transition: background-color 15000s ease-in-out 0s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&::after {
|
|
|
|
|
content: "";
|
|
|
|
|
position: absolute;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
left: 1rem;
|
|
|
|
|
width: calc(100% - 2rem);
|
|
|
|
|
height: 0.1em;
|
|
|
|
|
background-color: #000;
|
|
|
|
|
transition: all 0.3s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&:hover::after {
|
|
|
|
|
left: 0%;
|
|
|
|
|
width: 100%;
|
|
|
|
|
background-color: #00ffff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&[check='false']::after {
|
|
|
|
|
background-color: #f00 !important;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.ok {
|
|
|
|
|
line-height: 3rem;
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
height: 3rem;
|
|
|
|
|
margin: 0rem auto;
|
|
|
|
|
width: 40%;
|
|
|
|
|
background: #73f7ca;
|
|
|
|
|
border-radius: 1.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.back {
|
|
|
|
|
background: #ccc;
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.box-enter-active,
|
|
|
|
|
.box-leave-active {
|
|
|
|
|
transition: all 0.3s ease-out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.box-enter-from {
|
|
|
|
|
transform: translateX(-20px);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.box-leave-to {
|
|
|
|
|
transform: translateX(20px);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.login {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.last {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
padding: 0 1rem;
|
|
|
|
|
height: 3rem;
|
|
|
|
|
|
|
|
|
|
.icos {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
|
|
|
|
div {
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
height: 2rem;
|
|
|
|
|
width: 2rem;
|
|
|
|
|
background-size: cover;
|
|
|
|
|
background-position: center;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.github {
|
|
|
|
|
background-image: url("../assets/img/github.svg");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.google {
|
|
|
|
|
background-image: url("../assets/img/google.svg");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.wechat {
|
|
|
|
|
background-image: url("../assets/img/wechat.svg");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.txt {
|
|
|
|
|
|
|
|
|
|
height: 1.5rem;
|
|
|
|
|
line-height: 1.5rem;
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
|
|
|
|
div {
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.voa-logo {
|
|
|
|
|
background-image: url("../assets/img/favicon.svg");
|
|
|
|
|
background-size: cover;
|
|
|
|
|
background-position: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.voa-btn {
|
|
|
|
|
position: relative;
|
|
|
|
|
text-align: center;
|
|
|
|
|
display: block;
|
|
|
|
|
border: none;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
|
|
&::after {
|
|
|
|
|
content: "";
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
border-radius: inherit;
|
|
|
|
|
transition: 0.3s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&:active::after {
|
|
|
|
|
box-shadow: 0 1px 0px 0px rgba(0, 0, 0, 0.5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&:active {
|
|
|
|
|
opacity: 0.8;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|