Files
corrosion-admin-panel/frontend/src/views/auth/LoginView.vue
Vantz Stockwell 8253680fbd
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
fix: License key format, login populates license, case-insensitive email
- admin.service.ts: createLicense() now uses CORR-XXXX-XXXX-XXXX format
  instead of raw hex hash
- admin.service.ts: getLicenses() flattens owner_email in response to
  match frontend expected shape
- auth.service.ts: Login/register responses now include full license
  object so frontend can populate auth store
- auth.service.ts: Email lookups are case-insensitive (LOWER()) to
  prevent duplicate accounts from case variations
- LoginView/RegisterView: Call setLicense() after setAuth()
- AdminLicenses: Handle null expires_at (was showing Dec 31, 1969),
  fix nullable types, fix query param name (per_page → limit)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:32:35 -05:00

254 lines
8.1 KiB
Vue

<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import type { AuthResponse } from '@/types'
const router = useRouter()
const api = useApi()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
// TOTP state
const showTotpInput = ref(false)
const totpCode = ref('')
const totpInputEl = ref<HTMLInputElement | null>(null)
async function handleLogin() {
error.value = ''
loading.value = true
try {
const response = await api.post<AuthResponse>('/auth/login', {
email: email.value,
password: password.value,
})
if (response.requires_totp) {
// Credentials verified — server is waiting for the TOTP code.
// Show the TOTP input and focus it.
showTotpInput.value = true
await nextTick()
totpInputEl.value?.focus()
return
}
authStore.setAuth(response)
if (response.license) {
authStore.setLicense(response.license)
}
router.push('/')
} catch (err: unknown) {
if (err instanceof Error) {
error.value = err.message
} else {
error.value = 'An unexpected error occurred'
}
} finally {
loading.value = false
}
}
async function handleTotpVerify() {
if (totpCode.value.length !== 6 || loading.value) return
error.value = ''
loading.value = true
try {
// Re-POST to /auth/login with the same credentials plus the TOTP code.
// The backend LoginDto accepts an optional totp_code field and returns full
// tokens when the code is valid.
const response = await api.post<AuthResponse>('/auth/login', {
email: email.value,
password: password.value,
totp_code: totpCode.value,
})
authStore.setAuth(response)
if (response.license) {
authStore.setLicense(response.license)
}
router.push('/')
} catch (err: unknown) {
totpCode.value = ''
if (err instanceof Error) {
error.value = err.message
} else {
error.value = 'Invalid authentication code. Please try again.'
}
// Keep the TOTP screen visible so the user can retry
} finally {
loading.value = false
}
}
function handleBackToLogin() {
showTotpInput.value = false
totpCode.value = ''
error.value = ''
}
</script>
<template>
<div class="min-h-screen bg-neutral-950 flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8">
<!-- Branding -->
<div class="text-center mb-8">
<img src="/logo-hero.png" alt="Corrosion Management" class="h-32 mx-auto mb-2" />
</div>
<!-- Error message -->
<div
v-if="error"
class="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"
>
{{ error }}
</div>
<!-- Login form -->
<form v-if="!showTotpInput" @submit.prevent="handleLogin" class="space-y-5">
<div>
<label for="email" class="block text-sm font-medium text-neutral-400 mb-1.5">
Email
</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
placeholder="admin@example.com"
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-neutral-400 mb-1.5">
Password
</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="current-password"
placeholder="Enter your password"
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<button
type="submit"
:disabled="loading"
class="w-full py-2.5 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
>
<svg
v-if="loading"
class="animate-spin 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"
/>
<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"
/>
</svg>
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
<!-- TOTP verification form -->
<div v-else class="space-y-5">
<div class="text-center">
<p class="text-sm text-neutral-400">
Enter the 6-digit code from your authenticator app.
</p>
</div>
<div>
<label for="totp" class="block text-sm font-medium text-neutral-400 mb-1.5">
Authentication Code
</label>
<input
id="totp"
ref="totpInputEl"
v-model="totpCode"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="000000"
maxlength="6"
@keydown.enter="handleTotpVerify"
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors text-center tracking-widest text-lg font-mono"
/>
</div>
<button
type="button"
:disabled="totpCode.length !== 6 || loading"
@click="handleTotpVerify"
class="w-full py-2.5 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
>
<svg
v-if="loading"
class="animate-spin 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"
/>
<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"
/>
</svg>
{{ loading ? 'Verifying...' : 'Verify Code' }}
</button>
<button
type="button"
:disabled="loading"
@click="handleBackToLogin"
class="w-full py-2 text-sm text-neutral-500 hover:text-neutral-300 transition-colors"
>
Back to sign in
</button>
</div>
<!-- Register link -->
<p v-if="!showTotpInput" class="mt-6 text-center text-sm text-neutral-500">
Don't have an account?
<router-link to="/register" class="text-oxide-400 hover:text-oxide-300 transition-colors">
Create one
</router-link>
</p>
</div>
</div>
</div>
</template>