622 lines
32 KiB
JavaScript
622 lines
32 KiB
JavaScript
import { login, verify2FA, resend2FA } from '/mobile/shared/auth.js';
|
|
|
|
export default {
|
|
name: 'LoginScreen',
|
|
emits: ['login', 'set-theme'],
|
|
props: {
|
|
theme: {
|
|
type: String,
|
|
default: 'system'
|
|
}
|
|
},
|
|
|
|
setup(props, { emit }) {
|
|
const { ref, onMounted, onUnmounted, nextTick } = Vue;
|
|
|
|
const username = ref('');
|
|
const password = ref('');
|
|
const rememberMe = ref(true);
|
|
const showPassword = ref(false);
|
|
const show2FA = ref(false);
|
|
const otpCode = ref('');
|
|
const otpDigits = ref(['', '', '', '', '']);
|
|
const deliveryMethod = ref('');
|
|
const maskedTarget = ref('');
|
|
const resendCooldown = ref(0);
|
|
const error = ref('');
|
|
const success = ref('');
|
|
const loading = ref(false);
|
|
const showThemePicker = ref(false);
|
|
let otpInputRefs = [];
|
|
let otpAbortController = null;
|
|
let resendTimer = null;
|
|
|
|
const handleSubmit = async () => {
|
|
if (!username.value || !password.value) {
|
|
error.value = 'Bitte Benutzername und Passwort eingeben';
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
error.value = '';
|
|
|
|
try {
|
|
const result = await login({
|
|
username: username.value,
|
|
password: password.value,
|
|
rememberMe: rememberMe.value
|
|
});
|
|
|
|
if (result.requires2FA) {
|
|
// Show 2FA verification screen
|
|
show2FA.value = true;
|
|
deliveryMethod.value = result.deliveryMethod;
|
|
maskedTarget.value = result.maskedTarget;
|
|
success.value = result.message;
|
|
error.value = '';
|
|
|
|
// Start resend cooldown
|
|
startResendCooldown();
|
|
|
|
// Focus first OTP input after render
|
|
await nextTick();
|
|
focusOtpInput(0);
|
|
|
|
// Try Web OTP API for SMS
|
|
if (result.deliveryMethod === 'sms') {
|
|
startWebOTP();
|
|
}
|
|
} else if (result.success) {
|
|
// Direct login success (no 2FA) - notify parent
|
|
emit('login', { _2faSuccess: true, user: result.user });
|
|
} else {
|
|
error.value = result.message || 'Login fehlgeschlagen';
|
|
}
|
|
} catch (e) {
|
|
error.value = 'Ein Fehler ist aufgetreten';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Handle 2FA verification
|
|
const handleVerify2FA = async () => {
|
|
const code = otpDigits.value.join('');
|
|
|
|
if (code.length !== 5) {
|
|
error.value = 'Bitte gib den 5-stelligen Code ein';
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
error.value = '';
|
|
success.value = '';
|
|
|
|
try {
|
|
const result = await verify2FA(code);
|
|
|
|
if (result.success) {
|
|
// Emit the successful result to parent (which handles navigation)
|
|
emit('login', { _2faSuccess: true, user: result.user });
|
|
} else {
|
|
error.value = result.message || 'Ungültiger Code';
|
|
|
|
if (result.expired || result.codeExpired) {
|
|
// Session or code expired - go back to login
|
|
resetTo2FA();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
error.value = 'Ein Fehler ist aufgetreten';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Handle resend 2FA code
|
|
const handleResend = async () => {
|
|
if (resendCooldown.value > 0) return;
|
|
|
|
loading.value = true;
|
|
error.value = '';
|
|
|
|
try {
|
|
const result = await resend2FA();
|
|
|
|
if (result.success) {
|
|
success.value = result.message || 'Neuer Code wurde gesendet';
|
|
startResendCooldown();
|
|
|
|
// Clear OTP inputs
|
|
otpDigits.value = ['', '', '', '', ''];
|
|
focusOtpInput(0);
|
|
|
|
// Restart Web OTP if SMS
|
|
if (deliveryMethod.value === 'sms') {
|
|
startWebOTP();
|
|
}
|
|
} else {
|
|
error.value = result.message || 'Code konnte nicht gesendet werden';
|
|
|
|
if (result.expired) {
|
|
resetTo2FA();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
error.value = 'Ein Fehler ist aufgetreten';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const backToLogin = () => {
|
|
show2FA.value = false;
|
|
otpDigits.value = ['', '', '', '', ''];
|
|
otpInputRefs = [];
|
|
error.value = '';
|
|
success.value = '';
|
|
abortWebOTP();
|
|
};
|
|
|
|
const resetTo2FA = () => {
|
|
show2FA.value = false;
|
|
password.value = '';
|
|
otpDigits.value = ['', '', '', '', ''];
|
|
otpInputRefs = [];
|
|
error.value = 'Sitzung abgelaufen. Bitte erneut anmelden.';
|
|
};
|
|
|
|
// Start resend cooldown (30 seconds)
|
|
const startResendCooldown = () => {
|
|
resendCooldown.value = 30;
|
|
if (resendTimer) clearInterval(resendTimer);
|
|
resendTimer = setInterval(() => {
|
|
resendCooldown.value--;
|
|
if (resendCooldown.value <= 0) {
|
|
clearInterval(resendTimer);
|
|
}
|
|
}, 1000);
|
|
};
|
|
|
|
const focusOtpInput = (index) => {
|
|
if (otpInputRefs.length === 0) {
|
|
otpInputRefs = Array.from(document.querySelectorAll('.otp-input'));
|
|
}
|
|
if (otpInputRefs[index]) {
|
|
otpInputRefs[index].focus();
|
|
}
|
|
};
|
|
|
|
const handleOtpInput = (index, event) => {
|
|
const value = event.target.value;
|
|
|
|
// Only allow digits
|
|
if (!/^\d*$/.test(value)) {
|
|
event.target.value = otpDigits.value[index];
|
|
return;
|
|
}
|
|
|
|
// Handle paste of full code
|
|
if (value.length > 1) {
|
|
const digits = value.replace(/\D/g, '').slice(0, 5).split('');
|
|
digits.forEach((digit, i) => {
|
|
if (i < 5) otpDigits.value[i] = digit;
|
|
});
|
|
focusOtpInput(Math.min(digits.length, 4));
|
|
|
|
// Auto-submit if complete
|
|
if (otpDigits.value.join('').length === 5) {
|
|
handleVerify2FA();
|
|
}
|
|
return;
|
|
}
|
|
|
|
otpDigits.value[index] = value;
|
|
|
|
// Move to next input
|
|
if (value && index < 4) {
|
|
focusOtpInput(index + 1);
|
|
}
|
|
|
|
// Auto-submit when complete
|
|
if (otpDigits.value.join('').length === 5) {
|
|
handleVerify2FA();
|
|
}
|
|
};
|
|
|
|
const handleOtpKeydown = (index, event) => {
|
|
// Handle backspace
|
|
if (event.key === 'Backspace' && !otpDigits.value[index] && index > 0) {
|
|
focusOtpInput(index - 1);
|
|
}
|
|
};
|
|
|
|
const handleOtpPaste = (event) => {
|
|
event.preventDefault();
|
|
const pastedData = event.clipboardData.getData('text');
|
|
const digits = pastedData.replace(/\D/g, '').slice(0, 5).split('');
|
|
|
|
digits.forEach((digit, i) => {
|
|
if (i < 5) otpDigits.value[i] = digit;
|
|
});
|
|
|
|
focusOtpInput(Math.min(digits.length, 4));
|
|
|
|
// Auto-submit if complete
|
|
if (otpDigits.value.join('').length === 5) {
|
|
handleVerify2FA();
|
|
}
|
|
};
|
|
|
|
// Web OTP API for automatic SMS code detection (Android)
|
|
const startWebOTP = async () => {
|
|
if (!('OTPCredential' in window)) {
|
|
console.log('Web OTP API not supported');
|
|
return;
|
|
}
|
|
|
|
abortWebOTP();
|
|
otpAbortController = new AbortController();
|
|
|
|
try {
|
|
const otp = await navigator.credentials.get({
|
|
otp: { transport: ['sms'] },
|
|
signal: otpAbortController.signal
|
|
});
|
|
|
|
if (otp && otp.code) {
|
|
// Extract 5-digit code from SMS
|
|
const code = otp.code.replace(/\D/g, '').slice(0, 5);
|
|
if (code.length === 5) {
|
|
code.split('').forEach((digit, i) => {
|
|
otpDigits.value[i] = digit;
|
|
});
|
|
// Auto-submit
|
|
handleVerify2FA();
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err.name !== 'AbortError') {
|
|
console.log('Web OTP error:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const abortWebOTP = () => {
|
|
if (otpAbortController) {
|
|
otpAbortController.abort();
|
|
otpAbortController = null;
|
|
}
|
|
};
|
|
|
|
// Theme picker
|
|
const selectTheme = (newTheme) => {
|
|
emit('set-theme', newTheme);
|
|
showThemePicker.value = false;
|
|
};
|
|
|
|
// Cleanup
|
|
onUnmounted(() => {
|
|
abortWebOTP();
|
|
if (resendTimer) clearInterval(resendTimer);
|
|
});
|
|
|
|
return {
|
|
// Login state
|
|
username,
|
|
password,
|
|
rememberMe,
|
|
showPassword,
|
|
|
|
// 2FA state
|
|
show2FA,
|
|
otpDigits,
|
|
deliveryMethod,
|
|
maskedTarget,
|
|
resendCooldown,
|
|
|
|
// General state
|
|
error,
|
|
success,
|
|
loading,
|
|
showThemePicker,
|
|
|
|
// Methods
|
|
handleSubmit,
|
|
handleVerify2FA,
|
|
handleResend,
|
|
backToLogin,
|
|
handleOtpInput,
|
|
handleOtpKeydown,
|
|
handleOtpPaste,
|
|
selectTheme
|
|
};
|
|
},
|
|
|
|
template: `
|
|
<div class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
|
<!-- Animated Network Background -->
|
|
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
|
<!-- Fiber grid pattern -->
|
|
<div class="absolute inset-0 opacity-40" style="background-image:
|
|
linear-gradient(to right, rgba(0, 83, 132, 0.15) 1px, transparent 1px),
|
|
linear-gradient(to bottom, rgba(0, 83, 132, 0.15) 1px, transparent 1px);
|
|
background-size: 50px 50px;"></div>
|
|
|
|
<!-- Glowing nodes with enhanced animation -->
|
|
<div class="absolute w-3 h-3 bg-cyan-400 rounded-full network-node" style="top: 12%; left: 18%; box-shadow: 0 0 25px 8px rgba(34, 211, 238, 0.6);"></div>
|
|
<div class="absolute w-2.5 h-2.5 bg-blue-400 rounded-full network-node" style="top: 22%; left: 78%; animation-delay: 0.5s; box-shadow: 0 0 20px 6px rgba(96, 165, 250, 0.6);"></div>
|
|
<div class="absolute w-3 h-3 bg-cyan-300 rounded-full network-node-slow" style="top: 72%; left: 12%; animation-delay: 1s; box-shadow: 0 0 25px 8px rgba(103, 232, 249, 0.6);"></div>
|
|
<div class="absolute w-2.5 h-2.5 bg-blue-300 rounded-full network-node" style="top: 82%; left: 85%; animation-delay: 0.3s; box-shadow: 0 0 20px 6px rgba(147, 197, 253, 0.6);"></div>
|
|
<div class="absolute w-2 h-2 bg-cyan-400 rounded-full network-node-slow" style="top: 42%; left: 8%; animation-delay: 0.7s; box-shadow: 0 0 15px 5px rgba(34, 211, 238, 0.5);"></div>
|
|
<div class="absolute w-2.5 h-2.5 bg-blue-400 rounded-full network-node" style="top: 32%; left: 92%; animation-delay: 1.2s; box-shadow: 0 0 20px 6px rgba(96, 165, 250, 0.6);"></div>
|
|
<div class="absolute w-2 h-2 bg-cyan-300 rounded-full network-node" style="top: 58%; left: 88%; animation-delay: 0.9s; box-shadow: 0 0 15px 5px rgba(103, 232, 249, 0.5);"></div>
|
|
<div class="absolute w-2 h-2 bg-blue-300 rounded-full network-node-slow" style="top: 88%; left: 45%; animation-delay: 0.4s; box-shadow: 0 0 15px 5px rgba(147, 197, 253, 0.5);"></div>
|
|
<div class="absolute w-1.5 h-1.5 bg-cyan-400 rounded-full network-node" style="top: 5%; left: 55%; animation-delay: 1.5s; box-shadow: 0 0 12px 4px rgba(34, 211, 238, 0.5);"></div>
|
|
<div class="absolute w-1.5 h-1.5 bg-blue-400 rounded-full network-node-slow" style="top: 95%; left: 25%; animation-delay: 0.8s; box-shadow: 0 0 12px 4px rgba(96, 165, 250, 0.5);"></div>
|
|
|
|
<!-- Connection lines (SVG) with animations -->
|
|
<svg class="absolute inset-0 w-full h-full network-lines" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
|
|
<defs>
|
|
<linearGradient id="lineGrad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" style="stop-color:rgb(34, 211, 238);stop-opacity:0" />
|
|
<stop offset="50%" style="stop-color:rgb(34, 211, 238);stop-opacity:1" />
|
|
<stop offset="100%" style="stop-color:rgb(34, 211, 238);stop-opacity:0" />
|
|
</linearGradient>
|
|
<linearGradient id="lineGrad2" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" style="stop-color:rgb(96, 165, 250);stop-opacity:0" />
|
|
<stop offset="50%" style="stop-color:rgb(96, 165, 250);stop-opacity:1" />
|
|
<stop offset="100%" style="stop-color:rgb(96, 165, 250);stop-opacity:0" />
|
|
</linearGradient>
|
|
<linearGradient id="lineGrad3" x1="0%" y1="100%" x2="100%" y2="0%">
|
|
<stop offset="0%" style="stop-color:rgb(103, 232, 249);stop-opacity:0" />
|
|
<stop offset="50%" style="stop-color:rgb(103, 232, 249);stop-opacity:1" />
|
|
<stop offset="100%" style="stop-color:rgb(103, 232, 249);stop-opacity:0" />
|
|
</linearGradient>
|
|
</defs>
|
|
<!-- Main network connections -->
|
|
<line x1="18%" y1="12%" x2="78%" y2="22%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
|
|
<line x1="78%" y1="22%" x2="85%" y2="82%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
|
|
<line x1="18%" y1="12%" x2="12%" y2="72%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
|
|
<line x1="12%" y1="72%" x2="85%" y2="82%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
|
|
<line x1="8%" y1="42%" x2="18%" y2="12%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
|
|
<line x1="92%" y1="32%" x2="78%" y2="22%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
|
|
<!-- Additional connections -->
|
|
<line x1="12%" y1="72%" x2="45%" y2="88%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
|
|
<line x1="45%" y1="88%" x2="85%" y2="82%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
|
|
<line x1="88%" y1="58%" x2="92%" y2="32%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
|
|
<line x1="88%" y1="58%" x2="85%" y2="82%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
|
|
<line x1="8%" y1="42%" x2="12%" y2="72%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
|
|
<line x1="55%" y1="5%" x2="78%" y2="22%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
|
|
<line x1="55%" y1="5%" x2="18%" y2="12%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
|
|
<line x1="25%" y1="95%" x2="45%" y2="88%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
|
|
<line x1="25%" y1="95%" x2="12%" y2="72%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
|
|
<!-- Cross connections -->
|
|
<line x1="18%" y1="12%" x2="88%" y2="58%" stroke="url(#lineGrad2)" stroke-width="1"/>
|
|
<line x1="8%" y1="42%" x2="78%" y2="22%" stroke="url(#lineGrad3)" stroke-width="1"/>
|
|
<line x1="12%" y1="72%" x2="92%" y2="32%" stroke="url(#lineGrad1)" stroke-width="1"/>
|
|
</svg>
|
|
|
|
<!-- Flowing data lines overlay -->
|
|
<svg class="absolute inset-0 w-full h-full opacity-50" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
|
|
<line x1="18%" y1="12%" x2="78%" y2="22%" stroke="rgb(34, 211, 238)" stroke-width="2" class="network-line-flow"/>
|
|
<line x1="12%" y1="72%" x2="85%" y2="82%" stroke="rgb(96, 165, 250)" stroke-width="2" class="network-line-flow" style="animation-delay: 2s;"/>
|
|
<line x1="8%" y1="42%" x2="18%" y2="12%" stroke="rgb(103, 232, 249)" stroke-width="2" class="network-line-flow" style="animation-delay: 4s;"/>
|
|
<line x1="55%" y1="5%" x2="78%" y2="22%" stroke="rgb(34, 211, 238)" stroke-width="2" class="network-line-flow" style="animation-delay: 1s;"/>
|
|
<line x1="45%" y1="88%" x2="85%" y2="82%" stroke="rgb(96, 165, 250)" stroke-width="2" class="network-line-flow" style="animation-delay: 3s;"/>
|
|
</svg>
|
|
|
|
<!-- Subtle radial glow -->
|
|
<div class="absolute inset-0" style="background: radial-gradient(ellipse at 30% 20%, rgba(0, 83, 132, 0.2) 0%, transparent 50%);"></div>
|
|
<div class="absolute inset-0" style="background: radial-gradient(ellipse at 70% 80%, rgba(34, 211, 238, 0.15) 0%, transparent 40%);"></div>
|
|
<div class="absolute inset-0" style="background: radial-gradient(ellipse at 90% 40%, rgba(96, 165, 250, 0.1) 0%, transparent 35%);"></div>
|
|
</div>
|
|
|
|
<!-- Theme Picker Modal -->
|
|
<transition name="fade">
|
|
<div v-if="showThemePicker" class="fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center p-4">
|
|
<div class="bg-white dark:bg-slate-800 rounded-2xl p-6 w-full max-w-xs text-center shadow-2xl border border-slate-200 dark:border-slate-700">
|
|
<h3 class="font-bold text-lg mb-2 text-slate-800 dark:text-white">Willkommen!</h3>
|
|
<p class="text-sm text-slate-600 dark:text-slate-300 mb-6">Wähle dein bevorzugtes Farbschema.</p>
|
|
<div class="flex flex-col space-y-3">
|
|
<button @click="selectTheme('light')" class="w-full px-4 py-3 bg-slate-100 text-slate-800 font-semibold rounded-xl hover:bg-slate-200 transition flex items-center justify-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
</svg>
|
|
Hell
|
|
</button>
|
|
<button @click="selectTheme('dark')" class="w-full px-4 py-3 bg-slate-700 text-white font-semibold rounded-xl hover:bg-slate-600 transition flex items-center justify-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
</svg>
|
|
Dunkel
|
|
</button>
|
|
<button @click="selectTheme('system')" class="w-full mt-1 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition">
|
|
Automatisch (System)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Login/2FA Form Container -->
|
|
<div class="relative bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-2xl shadow-2xl p-6 w-full max-w-sm border border-white/20 dark:border-slate-700/50">
|
|
<div class="mb-5">
|
|
<img src="/assets/images/xinon-full-transparent.png" class="h-14 mx-auto dark:hidden" alt="Logo">
|
|
<img src="/assets/images/xinon-full-transparent-white.png" class="h-14 mx-auto hidden dark:block" alt="Logo">
|
|
</div>
|
|
|
|
<!-- 2FA Verification Screen -->
|
|
<template v-if="show2FA">
|
|
<div class="text-center mb-6">
|
|
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<svg v-if="deliveryMethod === 'sms'" xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
|
</svg>
|
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="text-xl font-bold text-slate-800 dark:text-white">
|
|
Verifizierung
|
|
</h2>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-2">
|
|
Code wurde gesendet an<br>
|
|
<span class="font-medium text-slate-700 dark:text-slate-300">{{ maskedTarget }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- OTP Input -->
|
|
<div class="mb-6">
|
|
<div class="flex justify-center space-x-2">
|
|
<input
|
|
v-for="(digit, index) in otpDigits"
|
|
:key="index"
|
|
type="text"
|
|
inputmode="numeric"
|
|
:autocomplete="index === 0 ? 'one-time-code' : 'off'"
|
|
maxlength="5"
|
|
class="otp-input w-12 h-14 text-center text-2xl font-bold border-2 border-slate-300 rounded-lg focus:border-primary focus:ring-2 focus:ring-primary/30 transition dark:bg-slate-700 dark:border-slate-600 dark:text-white"
|
|
:value="digit"
|
|
@input="handleOtpInput(index, $event)"
|
|
@keydown="handleOtpKeydown(index, $event)"
|
|
@paste="handleOtpPaste"
|
|
>
|
|
</div>
|
|
<p class="text-xs text-slate-400 dark:text-slate-500 text-center mt-3">
|
|
Code ist 5 Minuten gültig
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Success Message -->
|
|
<div v-if="success" class="mb-4 p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
|
|
<p class="text-sm text-green-600 dark:text-green-400">{{ success }}</p>
|
|
</div>
|
|
|
|
<!-- Error Message -->
|
|
<div v-if="error" class="mb-4 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
|
|
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
|
</div>
|
|
|
|
<!-- Verify Button -->
|
|
<button
|
|
@click="handleVerify2FA"
|
|
:disabled="loading || otpDigits.join('').length !== 5"
|
|
class="w-full py-3 px-4 bg-primary text-white font-bold rounded-lg hover:bg-primary/90 focus:ring-4 focus:ring-primary/30 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
|
>
|
|
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
{{ loading ? 'Wird verifiziert...' : 'Verifizieren' }}
|
|
</button>
|
|
|
|
<!-- Resend and Back buttons -->
|
|
<div class="mt-4 flex flex-col items-center space-y-3">
|
|
<button
|
|
@click="handleResend"
|
|
:disabled="resendCooldown > 0 || loading"
|
|
class="text-sm text-primary hover:underline disabled:text-slate-400 disabled:no-underline"
|
|
>
|
|
{{ resendCooldown > 0 ? 'Neuer Code in ' + resendCooldown + 's' : 'Neuen Code senden' }}
|
|
</button>
|
|
<button
|
|
@click="backToLogin"
|
|
class="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
|
|
>
|
|
Zurück zur Anmeldung
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Login Form -->
|
|
<template v-else>
|
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
|
Benutzername
|
|
</label>
|
|
<input
|
|
v-model="username"
|
|
type="text"
|
|
autocomplete="username"
|
|
autocapitalize="none"
|
|
class="w-full p-3 border border-slate-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-primary transition dark:bg-slate-700 dark:border-slate-600 dark:text-white"
|
|
placeholder="Benutzername eingeben"
|
|
>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
|
Passwort
|
|
</label>
|
|
<div class="relative">
|
|
<input
|
|
v-model="password"
|
|
:type="showPassword ? 'text' : 'password'"
|
|
autocomplete="current-password"
|
|
class="w-full p-3 pr-12 border border-slate-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-primary transition dark:bg-slate-700 dark:border-slate-600 dark:text-white"
|
|
placeholder="Passwort eingeben"
|
|
>
|
|
<button
|
|
type="button"
|
|
@click="showPassword = !showPassword"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
|
>
|
|
<svg v-if="!showPassword" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
</svg>
|
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Beautiful Toggle Switch -->
|
|
<div class="flex items-center justify-between py-1">
|
|
<span class="text-sm text-slate-600 dark:text-slate-300">Angemeldet bleiben</span>
|
|
<button
|
|
type="button"
|
|
@click="rememberMe = !rememberMe"
|
|
:class="[
|
|
'relative w-11 h-6 rounded-full transition-colors duration-200',
|
|
rememberMe ? 'bg-primary' : 'bg-slate-300 dark:bg-slate-600'
|
|
]"
|
|
>
|
|
<span :class="[
|
|
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-200',
|
|
rememberMe ? 'left-5' : 'left-0.5'
|
|
]"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="error" class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-xl">
|
|
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
:disabled="loading"
|
|
class="w-full py-3 px-4 bg-primary text-white font-bold rounded-xl hover:bg-primary/90 focus:ring-4 focus:ring-primary/30 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
|
>
|
|
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
{{ loading ? 'Wird angemeldet...' : 'Anmelden' }}
|
|
</button>
|
|
</form>
|
|
</template>
|
|
|
|
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 text-center">
|
|
<p class="text-xs text-slate-400 dark:text-slate-500">
|
|
powered by <span class="font-semibold">XINON</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
};
|