Files
vtuber-awards/frontend/src/views/LoginView.vue
T
AzuTear aeade132f9
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 54s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m5s
Fix team profile auth recovery
2026-06-26 20:06:10 +02:00

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>