587 lines
17 KiB
Vue
587 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, computed, watch } from 'vue';
|
|
import { useRoute } from 'vue-router';
|
|
import { useUserStore } from '@KTXC/stores/userStore';
|
|
import { authenticationService } from '@KTXC/services/authenticationService';
|
|
import type { AuthenticationMethod, VerifyResponse } from '@KTXC/types/authenticationTypes';
|
|
import { Form } from 'vee-validate';
|
|
|
|
const route = useRoute();
|
|
const userStore = useUserStore();
|
|
|
|
// Login flow phases
|
|
type LoginPhase = 'identity' | 'method' | 'mfa';
|
|
|
|
// Form state
|
|
const identity = ref('');
|
|
const authResponse = ref(''); // password, code, etc.
|
|
const showPassword = ref(false);
|
|
const rememberMe = ref(false);
|
|
|
|
// Auth state
|
|
const session = ref<string | null>(null);
|
|
const phase = ref<LoginPhase>('identity');
|
|
const allMethods = ref<AuthenticationMethod[]>([]);
|
|
const availableMethods = ref<AuthenticationMethod[]>([]);
|
|
const selectedMethod = ref<AuthenticationMethod | null>(null);
|
|
const loading = ref(true);
|
|
const submitting = ref(false);
|
|
const ssoLoading = ref<string | null>(null);
|
|
const error = ref<string | null>(null);
|
|
const challengeSent = ref(false);
|
|
|
|
// Separate methods by type
|
|
const redirectMethods = computed(() =>
|
|
allMethods.value.filter(m => m.method === 'redirect')
|
|
);
|
|
|
|
const nonRedirectMethods = computed(() =>
|
|
availableMethods.value.filter(m => m.method !== 'redirect')
|
|
);
|
|
|
|
const hasNonRedirectMethods = computed(() =>
|
|
allMethods.value.some(m => m.method !== 'redirect')
|
|
);
|
|
|
|
// Computed UI states
|
|
const showIdentityForm = computed(() => phase.value === 'identity');
|
|
const showAuthForm = computed(() => phase.value === 'method' && selectedMethod.value !== null);
|
|
const showMfaForm = computed(() => phase.value === 'mfa');
|
|
const showSsoButtons = computed(() => redirectMethods.value.length > 0 && phase.value === 'identity');
|
|
|
|
// Title based on phase
|
|
const pageTitle = computed(() => {
|
|
switch (phase.value) {
|
|
case 'identity': return 'Login';
|
|
case 'method': return 'Verify Your Identity';
|
|
case 'mfa': return 'Additional Verification';
|
|
default: return 'Login';
|
|
}
|
|
});
|
|
|
|
// Input label/type based on selected method
|
|
const authInputLabel = computed(() => {
|
|
if (!selectedMethod.value) return 'Password';
|
|
return selectedMethod.value.method === 'credential' ? 'Password' : 'Verification Code';
|
|
});
|
|
|
|
const authInputType = computed(() => {
|
|
if (!selectedMethod.value) return 'password';
|
|
return selectedMethod.value.method === 'credential' ? 'password' : 'text';
|
|
});
|
|
|
|
// Validation rules
|
|
const identityRules = [
|
|
(v: string) => !!v.trim() || 'Email is required',
|
|
(v: string) => !/\s/.test(v.trim()) || 'Email must not contain spaces',
|
|
(v: string) => /.+@.+\..+/.test(v.trim()) || 'Email must be valid'
|
|
];
|
|
|
|
const authResponseRules = [
|
|
(v: string) => !!v || 'This field is required',
|
|
];
|
|
|
|
// Initialize authentication
|
|
onMounted(async () => {
|
|
try {
|
|
// Check for error from SSO callback
|
|
const errorParam = route.query.error as string;
|
|
if (errorParam) {
|
|
error.value = decodeURIComponent(errorParam);
|
|
}
|
|
|
|
// Check for MFA session from redirect
|
|
const mfaSession = route.query.session as string;
|
|
if (mfaSession) {
|
|
session.value = mfaSession;
|
|
await loadSessionStatus();
|
|
} else {
|
|
// Initialize new auth session
|
|
await initSession();
|
|
}
|
|
|
|
// Auto-trigger SSO if provider param present
|
|
const providerParam = route.query.provider as string;
|
|
if (providerParam && phase.value === 'identity') {
|
|
const provider = allMethods.value.find(m => m.id === providerParam);
|
|
if (provider && provider.method === 'redirect') {
|
|
await initiateSsoLogin(provider.id);
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
error.value = e.message || 'Failed to initialize authentication';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
});
|
|
|
|
// Watch for method selection changes (for challenge-based methods)
|
|
watch(selectedMethod, async (newMethod) => {
|
|
if (newMethod && newMethod.method === 'challenge' && !challengeSent.value) {
|
|
// Initiate challenge for methods that need it (SMS, email, TOTP)
|
|
await initiateChallenge(newMethod.id);
|
|
}
|
|
});
|
|
|
|
// Initialize a new session
|
|
async function initSession() {
|
|
const result = await authenticationService.start();
|
|
session.value = result.session;
|
|
allMethods.value = result.methods;
|
|
phase.value = 'identity';
|
|
}
|
|
|
|
// Load existing session status (for MFA continuation)
|
|
async function loadSessionStatus() {
|
|
if (!session.value) return;
|
|
|
|
try {
|
|
const status = await authenticationService.getStatus(session.value);
|
|
|
|
if (status.state === 'secondary_pending') {
|
|
phase.value = 'mfa';
|
|
availableMethods.value = status.methods;
|
|
if (availableMethods.value.length > 0) {
|
|
selectedMethod.value = availableMethods.value[0];
|
|
}
|
|
} else if (status.state === 'identified') {
|
|
// Session has identity, show method selection
|
|
phase.value = 'method';
|
|
availableMethods.value = status.methods;
|
|
autoSelectMethod();
|
|
} else {
|
|
// Session not in expected state, reinitialize
|
|
await initSession();
|
|
}
|
|
} catch (e) {
|
|
// Session expired or invalid, reinitialize
|
|
await initSession();
|
|
}
|
|
}
|
|
|
|
// Handle identity submission (step 1)
|
|
async function handleIdentitySubmit(_values: any, { setErrors }: any) {
|
|
if (!session.value) return;
|
|
|
|
error.value = null;
|
|
submitting.value = true;
|
|
|
|
try {
|
|
const result = await authenticationService.identify(session.value, identity.value.trim());
|
|
|
|
session.value = result.session;
|
|
availableMethods.value = result.methods;
|
|
phase.value = 'method';
|
|
|
|
autoSelectMethod();
|
|
} catch (e: any) {
|
|
const errorMessage = e.message || 'Failed to continue. Please try again.';
|
|
setErrors({ apiError: errorMessage });
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
}
|
|
|
|
// Auto-select method if only one available
|
|
function autoSelectMethod() {
|
|
const methods = nonRedirectMethods.value;
|
|
if (methods.length === 1) {
|
|
selectedMethod.value = methods[0];
|
|
} else if (methods.length > 1) {
|
|
// Default to credential if available
|
|
const credMethod = methods.find(m => m.method === 'credential');
|
|
selectedMethod.value = credMethod || methods[0];
|
|
}
|
|
}
|
|
|
|
// Handle method selection
|
|
function selectMethod(method: AuthenticationMethod) {
|
|
selectedMethod.value = method;
|
|
authResponse.value = '';
|
|
challengeSent.value = false;
|
|
}
|
|
|
|
// Initiate a challenge (for SMS, email, TOTP)
|
|
async function initiateChallenge(methodId: string) {
|
|
if (!session.value) return;
|
|
|
|
try {
|
|
await authenticationService.beginChallenge(session.value, methodId);
|
|
challengeSent.value = true;
|
|
} catch (e: any) {
|
|
// Challenge initiation failed - show generic error
|
|
error.value = e.message || 'Failed to send verification code.';
|
|
}
|
|
}
|
|
|
|
// Handle authentication response submission (step 2)
|
|
async function handleAuthSubmit(_values: any, { setErrors }: any) {
|
|
if (!session.value || !selectedMethod.value) return;
|
|
|
|
error.value = null;
|
|
submitting.value = true;
|
|
|
|
try {
|
|
const result = await authenticationService.verify(
|
|
session.value,
|
|
selectedMethod.value.id,
|
|
authResponse.value
|
|
// identity is already in session from identify() step
|
|
);
|
|
|
|
handleVerifyResponse(result);
|
|
} catch (e: any) {
|
|
const errorMessage = e.message || 'Verification failed. Please try again.';
|
|
setErrors({ apiError: errorMessage });
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
}
|
|
|
|
// Handle MFA verification (step 3)
|
|
async function handleMfaSubmit(_values: any, { setErrors }: any) {
|
|
if (!session.value || !selectedMethod.value) return;
|
|
|
|
error.value = null;
|
|
submitting.value = true;
|
|
|
|
try {
|
|
const result = await authenticationService.verify(
|
|
session.value,
|
|
selectedMethod.value.id,
|
|
authResponse.value
|
|
);
|
|
|
|
handleVerifyResponse(result);
|
|
} catch (e: any) {
|
|
const errorMessage = e.message || 'Verification failed. Please try again.';
|
|
setErrors({ apiError: errorMessage });
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
}
|
|
|
|
// Handle verify response
|
|
function handleVerifyResponse(result: VerifyResponse) {
|
|
if (result.status === 'success' && result.user) {
|
|
// Authentication complete
|
|
userStore.setAuth(result.user);
|
|
window.location.replace('/');
|
|
} else if (result.status === 'pending') {
|
|
// MFA required
|
|
phase.value = 'mfa';
|
|
availableMethods.value = result.methods || [];
|
|
if (result.session) {
|
|
session.value = result.session;
|
|
}
|
|
if (availableMethods.value.length > 0) {
|
|
selectedMethod.value = availableMethods.value[0];
|
|
}
|
|
authResponse.value = '';
|
|
challengeSent.value = false;
|
|
} else if (result.error) {
|
|
error.value = result.error;
|
|
}
|
|
}
|
|
|
|
// Initiate SSO login
|
|
async function initiateSsoLogin(methodId: string) {
|
|
if (!session.value) return;
|
|
|
|
ssoLoading.value = methodId;
|
|
error.value = null;
|
|
|
|
try {
|
|
const result = await authenticationService.beginRedirect(session.value, methodId, '/');
|
|
window.location.href = result.redirect_url;
|
|
} catch (e: any) {
|
|
error.value = e.message || 'Failed to initiate SSO login';
|
|
ssoLoading.value = null;
|
|
}
|
|
}
|
|
|
|
// Go back to identity phase
|
|
function backToIdentity() {
|
|
phase.value = 'identity';
|
|
selectedMethod.value = null;
|
|
authResponse.value = '';
|
|
challengeSent.value = false;
|
|
error.value = null;
|
|
|
|
// Reinitialize session
|
|
initSession();
|
|
}
|
|
|
|
function getMethodIcon(method: AuthenticationMethod): string {
|
|
if (method.icon) return method.icon;
|
|
switch (method.method) {
|
|
case 'credential': return 'mdi-key';
|
|
case 'challenge': return 'mdi-shield-check';
|
|
case 'redirect': return 'mdi-login';
|
|
default: return 'mdi-shield-check';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="d-flex justify-space-between align-center">
|
|
<h3 class="text-h3 text-center mb-0">{{ pageTitle }}</h3>
|
|
</div>
|
|
|
|
<!-- Loading state -->
|
|
<div v-if="loading" class="mt-7 text-center">
|
|
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
|
<p class="mt-2 text-medium-emphasis">Loading login options...</p>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<!-- Error Alert -->
|
|
<v-alert v-if="error" type="error" class="mt-4" closable @click:close="error = null">
|
|
{{ error }}
|
|
</v-alert>
|
|
|
|
<!-- Phase 1: Identity Input -->
|
|
<template v-if="showIdentityForm">
|
|
<!-- Identity Form -->
|
|
<Form
|
|
v-if="hasNonRedirectMethods"
|
|
@submit="handleIdentitySubmit"
|
|
class="loginForm"
|
|
:class="{ 'mt-7': !showSsoButtons }"
|
|
v-slot="{ errors, isSubmitting }"
|
|
>
|
|
<div class="mb-6">
|
|
<v-label>Email Address</v-label>
|
|
<v-text-field
|
|
v-model="identity"
|
|
:rules="identityRules"
|
|
class="mt-2"
|
|
required
|
|
hide-details="auto"
|
|
variant="outlined"
|
|
color="primary"
|
|
autocomplete="email"
|
|
autofocus
|
|
></v-text-field>
|
|
</div>
|
|
|
|
<v-btn
|
|
color="primary"
|
|
:loading="isSubmitting || submitting"
|
|
block
|
|
class="mt-5"
|
|
variant="flat"
|
|
size="large"
|
|
type="submit"
|
|
>
|
|
Continue
|
|
</v-btn>
|
|
|
|
<!-- SSO Buttons -->
|
|
<div v-if="showSsoButtons" class="mt-7">
|
|
<v-btn
|
|
v-for="method in redirectMethods"
|
|
:key="method.id"
|
|
color="secondary"
|
|
:loading="ssoLoading === method.id"
|
|
:disabled="ssoLoading !== null"
|
|
block
|
|
class="mb-3"
|
|
variant="outlined"
|
|
size="large"
|
|
@click="initiateSsoLogin(method.id)"
|
|
>
|
|
<v-icon v-if="method.icon" start>{{ method.icon }}</v-icon>
|
|
{{ method.label }}
|
|
</v-btn>
|
|
</div>
|
|
|
|
<div v-if="errors.apiError" class="mt-2">
|
|
<v-alert color="error">{{ errors.apiError }}</v-alert>
|
|
</div>
|
|
</Form>
|
|
|
|
<!-- No providers -->
|
|
<div v-if="!hasNonRedirectMethods && !showSsoButtons" class="mt-7 text-center">
|
|
<v-alert type="warning">
|
|
No login methods are currently available. Please contact your administrator.
|
|
</v-alert>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Phase 2: Method Selection & Authentication -->
|
|
<template v-else-if="showAuthForm && !showMfaForm">
|
|
<p class="text-body-1 text-medium-emphasis mt-4 mb-2">
|
|
Signing in as <strong>{{ identity }}</strong>
|
|
</p>
|
|
|
|
<!-- Method Selector (if multiple methods) -->
|
|
<div v-if="nonRedirectMethods.length > 1" class="mb-4">
|
|
<v-label class="mb-2">Choose verification method</v-label>
|
|
<v-btn-toggle
|
|
:model-value="selectedMethod?.id"
|
|
mandatory
|
|
variant="outlined"
|
|
divided
|
|
class="w-100"
|
|
>
|
|
<v-btn
|
|
v-for="method in nonRedirectMethods"
|
|
:key="method.id"
|
|
:value="method.id"
|
|
@click="selectMethod(method)"
|
|
class="flex-grow-1"
|
|
>
|
|
<v-icon start>{{ getMethodIcon(method) }}</v-icon>
|
|
{{ method.label }}
|
|
</v-btn>
|
|
</v-btn-toggle>
|
|
</div>
|
|
|
|
<!-- Auth Response Form -->
|
|
<Form
|
|
@submit="handleAuthSubmit"
|
|
class="loginForm mt-4"
|
|
v-slot="{ errors, isSubmitting }"
|
|
>
|
|
<div class="mb-6">
|
|
<v-label>{{ authInputLabel }}</v-label>
|
|
<v-text-field
|
|
v-model="authResponse"
|
|
:rules="authResponseRules"
|
|
:type="authInputType === 'password' && !showPassword ? 'password' : 'text'"
|
|
class="mt-2"
|
|
required
|
|
hide-details="auto"
|
|
variant="outlined"
|
|
color="primary"
|
|
:autocomplete="selectedMethod?.method === 'credential' ? 'current-password' : 'one-time-code'"
|
|
:inputmode="selectedMethod?.method !== 'credential' ? 'numeric' : undefined"
|
|
autofocus
|
|
>
|
|
<template v-if="authInputType === 'password'" v-slot:append-inner>
|
|
<v-btn
|
|
variant="text"
|
|
density="compact"
|
|
:icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
|
@click="showPassword = !showPassword"
|
|
></v-btn>
|
|
</template>
|
|
</v-text-field>
|
|
</div>
|
|
|
|
<div v-if="selectedMethod?.method === 'credential'" class="d-flex align-center mt-4 mb-7 mb-sm-0">
|
|
<v-checkbox
|
|
v-model="rememberMe"
|
|
label="Keep me logged in"
|
|
color="primary"
|
|
hide-details
|
|
></v-checkbox>
|
|
<div class="ml-auto">
|
|
<router-link to="/forgot-password" class="text-primary text-decoration-none">
|
|
Forgot Password?
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<v-btn
|
|
color="primary"
|
|
:loading="isSubmitting || submitting"
|
|
block
|
|
class="mt-5"
|
|
variant="flat"
|
|
size="large"
|
|
type="submit"
|
|
>
|
|
{{ selectedMethod?.method === 'credential' ? 'Login' : 'Verify' }}
|
|
</v-btn>
|
|
|
|
<v-btn
|
|
variant="text"
|
|
block
|
|
class="mt-3"
|
|
@click="backToIdentity"
|
|
>
|
|
Use different account
|
|
</v-btn>
|
|
|
|
<div v-if="errors.apiError" class="mt-2">
|
|
<v-alert color="error">{{ errors.apiError }}</v-alert>
|
|
</div>
|
|
</Form>
|
|
</template>
|
|
|
|
<!-- Phase 3: MFA Verification -->
|
|
<template v-if="showMfaForm">
|
|
<p class="text-body-1 text-medium-emphasis mt-4 mb-6">
|
|
Additional verification is required. Please enter the code from your {{ selectedMethod?.label || 'authenticator' }}.
|
|
</p>
|
|
|
|
<!-- MFA Method Selector (if multiple) -->
|
|
<v-select
|
|
v-if="availableMethods.length > 1"
|
|
v-model="selectedMethod"
|
|
:items="availableMethods"
|
|
item-title="label"
|
|
item-value="id"
|
|
return-object
|
|
label="Verification Method"
|
|
variant="outlined"
|
|
class="mb-4"
|
|
></v-select>
|
|
|
|
<Form @submit="handleMfaSubmit" v-slot="{ errors, isSubmitting }">
|
|
<div class="mb-6">
|
|
<v-label>Verification Code</v-label>
|
|
<v-text-field
|
|
v-model="authResponse"
|
|
:rules="authResponseRules"
|
|
type="text"
|
|
class="mt-2"
|
|
required
|
|
hide-details="auto"
|
|
variant="outlined"
|
|
color="primary"
|
|
autocomplete="one-time-code"
|
|
inputmode="numeric"
|
|
autofocus
|
|
></v-text-field>
|
|
</div>
|
|
|
|
<v-btn
|
|
color="primary"
|
|
:loading="isSubmitting || submitting"
|
|
block
|
|
class="mt-5"
|
|
variant="flat"
|
|
size="large"
|
|
type="submit"
|
|
>
|
|
Verify
|
|
</v-btn>
|
|
|
|
<v-btn
|
|
variant="text"
|
|
block
|
|
class="mt-3"
|
|
@click="backToIdentity"
|
|
>
|
|
Back to Login
|
|
</v-btn>
|
|
|
|
<div v-if="errors.apiError" class="mt-2">
|
|
<v-alert color="error">{{ errors.apiError }}</v-alert>
|
|
</div>
|
|
</Form>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
.loginForm {
|
|
.v-text-field .v-field--active input {
|
|
font-weight: 500;
|
|
}
|
|
}
|
|
</style>
|