All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- 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>
254 lines
8.1 KiB
Vue
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>
|