587 lines
15 KiB
Vue
587 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { computed, reactive, ref } from 'vue'
|
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
|
|
|
import PasswordField from '../components/ui/PasswordField.vue'
|
|
import { useAuthStore } from '../stores/auth'
|
|
import { ApiRequestError } from '../lib/http'
|
|
|
|
const authStore = useAuthStore()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const form = reactive({
|
|
login: '',
|
|
password: '',
|
|
})
|
|
|
|
const passwordForm = reactive({
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
})
|
|
|
|
const loginMode = ref<'password' | 'twitch'>('password')
|
|
const errorMessage = ref('')
|
|
const successMessage = ref('')
|
|
|
|
const passwordChangeRequired = computed(() => Boolean(authStore.session?.mustChangePassword))
|
|
|
|
const redirectTarget = computed(() => {
|
|
const redirect = Array.isArray(route.query.redirect)
|
|
? route.query.redirect[0]
|
|
: route.query.redirect
|
|
|
|
if (!redirect || !redirect.startsWith('/') || redirect.startsWith('/login')) {
|
|
return '/admin'
|
|
}
|
|
|
|
return redirect
|
|
})
|
|
|
|
async function submitLogin() {
|
|
errorMessage.value = ''
|
|
successMessage.value = ''
|
|
|
|
try {
|
|
await authStore.demoLogin({
|
|
login: form.login,
|
|
password: form.password,
|
|
})
|
|
} catch (error) {
|
|
try {
|
|
await authStore.teamLogin({
|
|
login: form.login,
|
|
password: form.password,
|
|
})
|
|
} catch (teamError) {
|
|
if (error instanceof ApiRequestError && error.status === 503) {
|
|
errorMessage.value = 'Der Demo-Login ist im Backend noch nicht vollständig konfiguriert.'
|
|
return
|
|
}
|
|
|
|
if (error instanceof ApiRequestError && error.status === 404 && teamError instanceof ApiRequestError && teamError.status === 404) {
|
|
errorMessage.value = 'Der Team-Login ist auf diesem Deployment nicht aktiviert.'
|
|
return
|
|
}
|
|
|
|
errorMessage.value = 'Login oder Passwort stimmt nicht.'
|
|
return
|
|
}
|
|
}
|
|
|
|
if (authStore.session?.mustChangePassword) {
|
|
passwordForm.currentPassword = form.password
|
|
form.password = ''
|
|
successMessage.value = 'Bitte setze jetzt ein eigenes Passwort für diesen Team-Login.'
|
|
return
|
|
}
|
|
|
|
await router.replace(redirectTarget.value)
|
|
}
|
|
|
|
async function startTwitchLogin() {
|
|
errorMessage.value = ''
|
|
successMessage.value = ''
|
|
|
|
try {
|
|
await authStore.startTwitchAuthorization({
|
|
purpose: 'team-login',
|
|
returnUrl: redirectTarget.value,
|
|
})
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error
|
|
? error.message
|
|
: 'Twitch-Login konnte nicht gestartet werden.'
|
|
}
|
|
}
|
|
|
|
async function submitPasswordChange() {
|
|
errorMessage.value = ''
|
|
successMessage.value = ''
|
|
|
|
if (passwordForm.newPassword.length < 10) {
|
|
errorMessage.value = 'Das neue Passwort muss mindestens 10 Zeichen lang sein.'
|
|
return
|
|
}
|
|
|
|
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
|
errorMessage.value = 'Die neuen Passwörter stimmen nicht überein.'
|
|
return
|
|
}
|
|
|
|
try {
|
|
await authStore.changePassword({
|
|
currentPassword: passwordForm.currentPassword,
|
|
newPassword: passwordForm.newPassword,
|
|
})
|
|
|
|
await router.replace(redirectTarget.value)
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof ApiRequestError
|
|
? error.message
|
|
: 'Das Passwort konnte nicht geändert werden.'
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="login-page">
|
|
<div class="login-page__stars" aria-hidden="true">
|
|
<span class="login-page__star login-page__star--one">✦</span>
|
|
<span class="login-page__star login-page__star--two">✧</span>
|
|
<span class="login-page__star login-page__star--three">✦</span>
|
|
<span class="login-page__star login-page__star--four">✧</span>
|
|
</div>
|
|
|
|
<RouterLink to="/" class="login-page__brand" aria-label="Zur Landingpage">
|
|
<span class="login-page__brand-icon">✦</span>
|
|
<span>VTuber Star Award</span>
|
|
</RouterLink>
|
|
|
|
<section class="login-card" aria-labelledby="login-title">
|
|
<div class="login-card__eyebrow">{{ passwordChangeRequired ? 'Erster Login' : 'Team Zugang' }}</div>
|
|
<h1 id="login-title">{{ passwordChangeRequired ? 'Passwort ändern' : 'Admin Login' }}</h1>
|
|
<p>
|
|
<template v-if="passwordChangeRequired">
|
|
Dieser Account wurde mit einem temporären Passwort erstellt. Bitte vergib jetzt ein eigenes Passwort.
|
|
</template>
|
|
<template v-else>
|
|
Melde dich mit Team-Login an oder nutze deinen gebundenen Twitch-Account.
|
|
</template>
|
|
</p>
|
|
|
|
<div v-if="!passwordChangeRequired" class="login-mode-toggle" role="tablist" aria-label="Login-Art">
|
|
<button
|
|
type="button"
|
|
:class="loginMode === 'password' ? 'login-mode-toggle__button login-mode-toggle__button--active' : 'login-mode-toggle__button'"
|
|
@click="loginMode = 'password'"
|
|
>
|
|
Login
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:class="loginMode === 'twitch' ? 'login-mode-toggle__button login-mode-toggle__button--active' : 'login-mode-toggle__button'"
|
|
@click="loginMode = 'twitch'"
|
|
>
|
|
Twitch
|
|
</button>
|
|
</div>
|
|
|
|
<form v-if="!passwordChangeRequired && loginMode === 'password'" class="login-form" @submit.prevent="submitLogin">
|
|
<label>
|
|
<span>Login</span>
|
|
<input
|
|
v-model.trim="form.login"
|
|
type="text"
|
|
autocomplete="username"
|
|
required
|
|
placeholder="E-Mail oder Username"
|
|
>
|
|
</label>
|
|
|
|
<label>
|
|
<span>Passwort</span>
|
|
<PasswordField
|
|
v-model="form.password"
|
|
variant="login"
|
|
autocomplete="current-password"
|
|
required
|
|
placeholder="Team-Passwort eingeben"
|
|
/>
|
|
</label>
|
|
|
|
<p v-if="errorMessage" class="login-form__error" role="alert">
|
|
{{ errorMessage }}
|
|
</p>
|
|
|
|
<p v-if="successMessage" class="login-form__success" role="status">
|
|
{{ successMessage }}
|
|
</p>
|
|
|
|
<button class="login-form__submit" type="submit" :disabled="authStore.loading">
|
|
<span v-if="authStore.loading">Sternentor öffnet...</span>
|
|
<span v-else>Admin Panel öffnen</span>
|
|
</button>
|
|
</form>
|
|
|
|
<form v-else-if="!passwordChangeRequired" class="login-form" @submit.prevent="startTwitchLogin">
|
|
<div class="login-form__oauth-note">
|
|
<strong>Offizieller Twitch Login</strong>
|
|
<span>Du wirst zu Twitch weitergeleitet und danach automatisch zurück ins Admin Panel gebracht.</span>
|
|
</div>
|
|
|
|
<p v-if="errorMessage" class="login-form__error" role="alert">
|
|
{{ errorMessage }}
|
|
</p>
|
|
|
|
<p v-if="successMessage" class="login-form__success" role="status">
|
|
{{ successMessage }}
|
|
</p>
|
|
|
|
<button class="login-form__submit" type="submit" :disabled="authStore.loading">
|
|
<span v-if="authStore.loading">Twitch wird geöffnet...</span>
|
|
<span v-else>Mit Twitch offiziell anmelden</span>
|
|
</button>
|
|
</form>
|
|
|
|
<form v-else class="login-form" @submit.prevent="submitPasswordChange">
|
|
<label>
|
|
<span>Aktuelles Passwort</span>
|
|
<PasswordField
|
|
v-model="passwordForm.currentPassword"
|
|
variant="login"
|
|
autocomplete="current-password"
|
|
required
|
|
placeholder="Temporäres Passwort"
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
<span>Neues Passwort</span>
|
|
<PasswordField
|
|
v-model="passwordForm.newPassword"
|
|
variant="login"
|
|
autocomplete="new-password"
|
|
required
|
|
minlength="10"
|
|
placeholder="Mindestens 10 Zeichen"
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
<span>Passwort wiederholen</span>
|
|
<PasswordField
|
|
v-model="passwordForm.confirmPassword"
|
|
variant="login"
|
|
autocomplete="new-password"
|
|
required
|
|
minlength="10"
|
|
placeholder="Neues Passwort bestätigen"
|
|
/>
|
|
</label>
|
|
|
|
<p v-if="errorMessage" class="login-form__error" role="alert">
|
|
{{ errorMessage }}
|
|
</p>
|
|
|
|
<p v-if="successMessage" class="login-form__success" role="status">
|
|
{{ successMessage }}
|
|
</p>
|
|
|
|
<button class="login-form__submit" type="submit" :disabled="authStore.loading">
|
|
<span v-if="authStore.loading">Passwort wird gespeichert...</span>
|
|
<span v-else>Passwort speichern</span>
|
|
</button>
|
|
</form>
|
|
|
|
<div class="login-card__footer">
|
|
<RouterLink to="/">Zurück zur Landingpage</RouterLink>
|
|
<span>Demo-Modus</span>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.login-page {
|
|
position: relative;
|
|
min-height: 100vh;
|
|
overflow: hidden;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 32px 18px;
|
|
background:
|
|
radial-gradient(circle at 18% 24%, rgba(255, 221, 238, 0.9), transparent 26%),
|
|
radial-gradient(circle at 82% 20%, rgba(210, 190, 255, 0.75), transparent 28%),
|
|
radial-gradient(circle at 50% 100%, rgba(255, 220, 151, 0.38), transparent 32%),
|
|
linear-gradient(145deg, #fbf6ff 0%, #f2e9ff 48%, #fff8f1 100%);
|
|
color: #3f3556;
|
|
font-family: 'Outfit', sans-serif;
|
|
}
|
|
|
|
.login-page::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 11% 8% auto;
|
|
height: 330px;
|
|
border-radius: 999px;
|
|
background: linear-gradient(90deg, rgba(139, 108, 219, 0.14), rgba(231, 177, 62, 0.16), rgba(247, 108, 173, 0.12));
|
|
filter: blur(42px);
|
|
transform: rotate(-6deg);
|
|
}
|
|
|
|
.login-page__brand {
|
|
position: absolute;
|
|
top: 28px;
|
|
left: 32px;
|
|
z-index: 2;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
color: #3f3556;
|
|
text-decoration: none;
|
|
font-family: 'Fredoka', sans-serif;
|
|
font-weight: 700;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.login-page__brand-icon {
|
|
display: inline-grid;
|
|
place-items: center;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 13px;
|
|
color: #fff;
|
|
background: linear-gradient(135deg, #8b6cdb, #e7b13e);
|
|
box-shadow: 0 14px 30px rgba(139, 108, 219, 0.26);
|
|
}
|
|
|
|
.login-page__stars {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.login-page__star {
|
|
position: absolute;
|
|
color: rgba(139, 108, 219, 0.7);
|
|
text-shadow: 0 0 22px rgba(139, 108, 219, 0.4);
|
|
animation: loginTwinkle 3s ease-in-out infinite;
|
|
}
|
|
|
|
.login-page__star--one {
|
|
top: 18%;
|
|
left: 22%;
|
|
font-size: 34px;
|
|
}
|
|
|
|
.login-page__star--two {
|
|
top: 21%;
|
|
right: 20%;
|
|
font-size: 28px;
|
|
animation-delay: 0.8s;
|
|
}
|
|
|
|
.login-page__star--three {
|
|
bottom: 20%;
|
|
left: 18%;
|
|
font-size: 22px;
|
|
color: rgba(231, 177, 62, 0.72);
|
|
animation-delay: 1.3s;
|
|
}
|
|
|
|
.login-page__star--four {
|
|
right: 17%;
|
|
bottom: 24%;
|
|
font-size: 24px;
|
|
color: rgba(247, 108, 173, 0.62);
|
|
animation-delay: 1.9s;
|
|
}
|
|
|
|
.login-card {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: min(100%, 520px);
|
|
padding: 42px;
|
|
border: 1px solid rgba(139, 108, 219, 0.18);
|
|
border-radius: 38px;
|
|
background: rgba(255, 255, 255, 0.72);
|
|
box-shadow: 0 28px 80px rgba(76, 48, 119, 0.16);
|
|
backdrop-filter: blur(22px);
|
|
}
|
|
|
|
.login-card__eyebrow {
|
|
color: #8b6cdb;
|
|
font-size: 13px;
|
|
font-weight: 900;
|
|
letter-spacing: 0.22em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.login-card h1 {
|
|
margin: 10px 0 12px;
|
|
font-family: 'Playfair Display', serif;
|
|
font-size: clamp(42px, 8vw, 70px);
|
|
line-height: 0.95;
|
|
color: #3f3556;
|
|
}
|
|
|
|
.login-card p {
|
|
margin: 0;
|
|
color: #736987;
|
|
font-size: 17px;
|
|
line-height: 1.65;
|
|
}
|
|
|
|
.login-form {
|
|
display: grid;
|
|
gap: 18px;
|
|
margin-top: 30px;
|
|
}
|
|
|
|
.login-mode-toggle {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 6px;
|
|
margin: 24px 0 18px;
|
|
padding: 6px;
|
|
border: 1px solid rgba(124, 86, 196, 0.16);
|
|
border-radius: 16px;
|
|
background: rgba(255, 255, 255, 0.65);
|
|
}
|
|
|
|
.login-mode-toggle__button {
|
|
min-height: 42px;
|
|
border: 0;
|
|
border-radius: 11px;
|
|
background: transparent;
|
|
color: #7c7192;
|
|
font-family: 'Outfit', sans-serif;
|
|
font-size: 14px;
|
|
font-weight: 800;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.login-mode-toggle__button--active {
|
|
background: #6f4fd1;
|
|
color: #fff;
|
|
box-shadow: 0 10px 22px rgba(111, 79, 209, 0.24);
|
|
}
|
|
|
|
.login-form label {
|
|
display: grid;
|
|
gap: 9px;
|
|
color: #65748b;
|
|
font-size: 12px;
|
|
font-weight: 900;
|
|
letter-spacing: 0.18em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.login-form input {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
border: 1.5px solid #ded4ff;
|
|
border-radius: 22px;
|
|
padding: 17px 18px;
|
|
background: rgba(255, 255, 255, 0.82);
|
|
color: #3f3556;
|
|
font: 700 17px 'Outfit', sans-serif;
|
|
outline: none;
|
|
box-shadow: 0 12px 32px rgba(139, 108, 219, 0.06);
|
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
|
}
|
|
|
|
.login-form input:focus {
|
|
border-color: #8b6cdb;
|
|
box-shadow: 0 0 0 5px rgba(139, 108, 219, 0.14);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.login-form__oauth-note {
|
|
display: grid;
|
|
gap: 6px;
|
|
padding: 16px;
|
|
border: 1px solid rgba(145, 70, 255, 0.16);
|
|
border-radius: 18px;
|
|
background: rgba(248, 245, 255, 0.88);
|
|
color: #6f6685;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.login-form__oauth-note strong {
|
|
color: #3f3556;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.login-form__error {
|
|
padding: 13px 15px;
|
|
border: 1px solid rgba(225, 29, 72, 0.18);
|
|
border-radius: 18px;
|
|
background: rgba(255, 241, 242, 0.9);
|
|
color: #be123c !important;
|
|
font-size: 14px !important;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.login-form__success {
|
|
padding: 13px 15px;
|
|
border: 1px solid rgba(22, 163, 74, 0.18);
|
|
border-radius: 18px;
|
|
background: rgba(240, 253, 244, 0.9);
|
|
color: #15803d !important;
|
|
font-size: 14px !important;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.login-form__submit {
|
|
min-height: 58px;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font: 900 17px 'Fredoka', sans-serif;
|
|
background: linear-gradient(135deg, #7c52dc 0%, #ad42e8 48%, #f76cad 100%);
|
|
box-shadow: 0 18px 40px rgba(139, 108, 219, 0.32);
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
|
|
}
|
|
|
|
.login-form__submit:hover:not(:disabled) {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 22px 48px rgba(139, 108, 219, 0.38);
|
|
}
|
|
|
|
.login-form__submit:disabled {
|
|
cursor: wait;
|
|
opacity: 0.72;
|
|
}
|
|
|
|
.login-card__footer {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-top: 24px;
|
|
color: #9286a7;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.login-card__footer a {
|
|
color: #7355c8;
|
|
text-decoration: none;
|
|
}
|
|
|
|
@keyframes loginTwinkle {
|
|
0%, 100% {
|
|
opacity: 0.45;
|
|
transform: scale(0.92) rotate(0deg);
|
|
}
|
|
|
|
50% {
|
|
opacity: 1;
|
|
transform: scale(1.08) rotate(8deg);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.login-page {
|
|
place-items: start center;
|
|
padding-top: 104px;
|
|
}
|
|
|
|
.login-page__brand {
|
|
left: 18px;
|
|
top: 20px;
|
|
}
|
|
|
|
.login-card {
|
|
padding: 30px 22px;
|
|
border-radius: 30px;
|
|
}
|
|
}
|
|
</style>
|