auth
This commit is contained in:
217
web/src/components/ChartView.vue
Normal file
217
web/src/components/ChartView.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Card from 'primevue/card'
|
||||
import { createTradingViewDatafeed } from '../composables/useTradingViewDatafeed'
|
||||
import { useChartStore } from '../stores/chart'
|
||||
import type { IChartingLibraryWidget } from '../types/tradingview'
|
||||
|
||||
const chartContainer = ref<HTMLDivElement | null>(null)
|
||||
const chartStore = useChartStore()
|
||||
let tvWidget: IChartingLibraryWidget | null = null
|
||||
let datafeed: any = null
|
||||
let isUpdatingFromChart = false // Flag to prevent circular updates
|
||||
|
||||
onMounted(() => {
|
||||
if (!chartContainer.value) return
|
||||
|
||||
// Wait for TradingView library to load
|
||||
const initChart = () => {
|
||||
if (!window.TradingView) {
|
||||
setTimeout(initChart, 100)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
datafeed = createTradingViewDatafeed()
|
||||
|
||||
tvWidget = new window.TradingView.widget({
|
||||
symbol: chartStore.chart_state.symbol, // Use symbol from store
|
||||
datafeed: datafeed,
|
||||
interval: chartStore.chart_state.interval as any,
|
||||
container: chartContainer.value!,
|
||||
library_path: '/charting_library/',
|
||||
locale: 'en',
|
||||
disabled_features: [
|
||||
'use_localstorage_for_settings',
|
||||
'header_symbol_search',
|
||||
'symbol_search_hot_key'
|
||||
],
|
||||
enabled_features: ['study_templates'],
|
||||
fullscreen: false,
|
||||
autosize: true,
|
||||
theme: 'Dark',
|
||||
timezone: 'Etc/UTC'
|
||||
})
|
||||
|
||||
tvWidget.onChartReady(() => {
|
||||
console.log('TradingView chart ready')
|
||||
setupChartListeners()
|
||||
setupStoreWatchers()
|
||||
// Initialize visible range on chart load
|
||||
initializeVisibleRange()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize TradingView widget:', error)
|
||||
}
|
||||
}
|
||||
|
||||
initChart()
|
||||
})
|
||||
|
||||
function initializeVisibleRange() {
|
||||
if (!tvWidget) return
|
||||
|
||||
const chart = tvWidget.activeChart()
|
||||
const timeScale = chart.getVisibleRange()
|
||||
|
||||
if (timeScale) {
|
||||
const startTime = Math.floor(timeScale.from)
|
||||
const endTime = Math.floor(timeScale.to)
|
||||
|
||||
console.log('[ChartView] Initial visible range:', {
|
||||
from: new Date(startTime * 1000).toISOString(),
|
||||
to: new Date(endTime * 1000).toISOString()
|
||||
})
|
||||
|
||||
chartStore.chart_state.start_time = startTime
|
||||
chartStore.chart_state.end_time = endTime
|
||||
}
|
||||
}
|
||||
|
||||
function setupChartListeners() {
|
||||
if (!tvWidget) return
|
||||
|
||||
const chart = tvWidget.activeChart()
|
||||
|
||||
// Listen for symbol changes
|
||||
chart.onSymbolChanged().subscribe(null, () => {
|
||||
const symbolInfo = chart.symbolExt()
|
||||
if (symbolInfo && symbolInfo.ticker) {
|
||||
console.log('[ChartView] Symbol changed to:', symbolInfo.ticker)
|
||||
isUpdatingFromChart = true
|
||||
chartStore.chart_state.symbol = symbolInfo.ticker
|
||||
isUpdatingFromChart = false
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for interval changes
|
||||
chart.onIntervalChanged().subscribe(null, (interval: string) => {
|
||||
console.log('[ChartView] Interval changed to:', interval)
|
||||
isUpdatingFromChart = true
|
||||
chartStore.chart_state.interval = interval
|
||||
isUpdatingFromChart = false
|
||||
})
|
||||
|
||||
// Listen for visible range changes (when user pans/zooms)
|
||||
chart.onVisibleRangeChanged().subscribe(null, () => {
|
||||
const timeScale = chart.getVisibleRange()
|
||||
if (timeScale) {
|
||||
// Convert from seconds to seconds (TradingView uses seconds for visible range)
|
||||
const startTime = Math.floor(timeScale.from)
|
||||
const endTime = Math.floor(timeScale.to)
|
||||
|
||||
console.log('[ChartView] Visible range changed:', {
|
||||
from: new Date(startTime * 1000).toISOString(),
|
||||
to: new Date(endTime * 1000).toISOString()
|
||||
})
|
||||
|
||||
isUpdatingFromChart = true
|
||||
chartStore.chart_state.start_time = startTime
|
||||
chartStore.chart_state.end_time = endTime
|
||||
isUpdatingFromChart = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setupStoreWatchers() {
|
||||
if (!tvWidget) return
|
||||
|
||||
const chart = tvWidget.activeChart()
|
||||
|
||||
// Watch for external changes to symbol (e.g., from backend/agent)
|
||||
watch(
|
||||
() => chartStore.chart_state.symbol,
|
||||
(newSymbol) => {
|
||||
if (isUpdatingFromChart) return // Ignore updates that came from the chart itself
|
||||
|
||||
console.log('[ChartView] Store symbol changed externally to:', newSymbol)
|
||||
const currentSymbol = chart.symbolExt()
|
||||
if (currentSymbol && currentSymbol.ticker !== newSymbol) {
|
||||
chart.setSymbol(newSymbol, () => {
|
||||
console.log('[ChartView] Chart symbol updated to:', newSymbol)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch for external changes to interval
|
||||
watch(
|
||||
() => chartStore.chart_state.interval,
|
||||
(newInterval) => {
|
||||
if (isUpdatingFromChart) return
|
||||
|
||||
console.log('[ChartView] Store interval changed externally to:', newInterval)
|
||||
if (chart.resolution() !== newInterval) {
|
||||
chart.setResolution(newInterval, () => {
|
||||
console.log('[ChartView] Chart interval updated to:', newInterval)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Note: Visible range is typically only set by user interaction,
|
||||
// but we could add a watcher here if we want the backend to be able
|
||||
// to change the visible range programmatically
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (tvWidget) {
|
||||
tvWidget.remove()
|
||||
tvWidget = null
|
||||
}
|
||||
if (datafeed && typeof datafeed.destroy === 'function') {
|
||||
datafeed.destroy()
|
||||
datafeed = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="chart-card">
|
||||
<template #content>
|
||||
<div ref="chartContainer" class="chart-container"></div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart-card {
|
||||
height: 100% !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
background: var(--p-surface-0);
|
||||
}
|
||||
|
||||
.chart-card :deep(.p-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-card :deep(.p-card-content) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
309
web/src/components/ChatPanel.vue
Normal file
309
web/src/components/ChatPanel.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { register } from 'vue-advanced-chat'
|
||||
import Badge from 'primevue/badge'
|
||||
import { wsManager } from '../composables/useWebSocket'
|
||||
import type { WebSocketMessage } from '../composables/useWebSocket'
|
||||
|
||||
register()
|
||||
|
||||
const SESSION_ID = 'default'
|
||||
const CURRENT_USER_ID = 'user-123'
|
||||
const AGENT_ID = 'agent'
|
||||
const BACKEND_URL = 'http://localhost:8080'
|
||||
|
||||
// Chat state
|
||||
const messages = ref<any[]>([])
|
||||
const messagesLoaded = ref(false)
|
||||
const isConnected = wsManager.isConnected
|
||||
|
||||
// Reactive rooms that update based on WebSocket connection
|
||||
const rooms = computed(() => [{
|
||||
roomId: SESSION_ID,
|
||||
roomName: 'AI Agent',
|
||||
avatar: null,
|
||||
users: [
|
||||
{ _id: CURRENT_USER_ID, username: 'You' },
|
||||
{ _id: AGENT_ID, username: 'AI Agent', status: { state: isConnected.value ? 'online' : 'offline' } }
|
||||
],
|
||||
unreadCount: 0
|
||||
}])
|
||||
|
||||
// Streaming state
|
||||
let currentStreamingMessageId: string | null = null
|
||||
let streamingBuffer = ''
|
||||
|
||||
// Generate message ID
|
||||
const generateMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// Handle WebSocket messages
|
||||
const handleMessage = (data: WebSocketMessage) => {
|
||||
if (data.type === 'agent_chunk') {
|
||||
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
|
||||
|
||||
if (!currentStreamingMessageId) {
|
||||
// Start new streaming message
|
||||
currentStreamingMessageId = generateMessageId()
|
||||
streamingBuffer = data.content
|
||||
|
||||
messages.value = [...messages.value, {
|
||||
_id: currentStreamingMessageId,
|
||||
content: streamingBuffer,
|
||||
senderId: AGENT_ID,
|
||||
timestamp: timestamp,
|
||||
date: new Date().toLocaleDateString(),
|
||||
saved: false,
|
||||
distributed: false,
|
||||
seen: false
|
||||
}]
|
||||
} else {
|
||||
// Update existing streaming message
|
||||
streamingBuffer += data.content
|
||||
const msgIndex = messages.value.findIndex(m => m._id === currentStreamingMessageId)
|
||||
|
||||
if (msgIndex !== -1) {
|
||||
messages.value[msgIndex] = {
|
||||
...messages.value[msgIndex],
|
||||
content: streamingBuffer
|
||||
}
|
||||
messages.value = [...messages.value]
|
||||
}
|
||||
}
|
||||
|
||||
if (data.done) {
|
||||
// Mark message as complete
|
||||
const msgIndex = messages.value.findIndex(m => m._id === currentStreamingMessageId)
|
||||
if (msgIndex !== -1) {
|
||||
messages.value[msgIndex] = {
|
||||
...messages.value[msgIndex],
|
||||
saved: true,
|
||||
distributed: true,
|
||||
seen: true
|
||||
}
|
||||
messages.value = [...messages.value]
|
||||
}
|
||||
|
||||
currentStreamingMessageId = null
|
||||
streamingBuffer = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send message handler
|
||||
const sendMessage = async (event: any) => {
|
||||
// Extract data from CustomEvent.detail[0]
|
||||
const data = event.detail?.[0] || event
|
||||
|
||||
const content = data.content || ''
|
||||
const files = data.files
|
||||
const roomId = data.roomId
|
||||
|
||||
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
|
||||
const messageId = generateMessageId()
|
||||
|
||||
// Process files
|
||||
let attachments: string[] = []
|
||||
let messageFiles: any[] = []
|
||||
|
||||
if (files && files.length > 0) {
|
||||
// Upload files to backend
|
||||
for (const file of files) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
// Convert blob URL to actual file if needed
|
||||
const response = await fetch(file.url)
|
||||
const blob = await response.blob()
|
||||
const actualFile = new File([blob], file.name, { type: file.type })
|
||||
formData.append('file', actualFile)
|
||||
|
||||
const uploadResponse = await fetch(`${BACKEND_URL}/api/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Upload failed')
|
||||
}
|
||||
|
||||
const uploadData = await uploadResponse.json()
|
||||
const fileUrl = `${BACKEND_URL}${uploadData.url}`
|
||||
attachments.push(fileUrl)
|
||||
|
||||
messageFiles.push({
|
||||
name: file.name,
|
||||
size: uploadData.size,
|
||||
type: file.extension || file.type,
|
||||
url: fileUrl,
|
||||
preview: file.url // Keep the blob URL for preview
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('File upload error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message to UI
|
||||
const userMessage = {
|
||||
_id: messageId,
|
||||
content: content,
|
||||
senderId: CURRENT_USER_ID,
|
||||
timestamp: timestamp,
|
||||
date: new Date().toLocaleDateString(),
|
||||
saved: true,
|
||||
distributed: false,
|
||||
seen: false,
|
||||
files: messageFiles
|
||||
}
|
||||
|
||||
messages.value = [...messages.value, userMessage]
|
||||
|
||||
// Send to backend via WebSocket
|
||||
const wsMessage = {
|
||||
type: 'agent_user_message',
|
||||
session_id: roomId || SESSION_ID,
|
||||
content: content,
|
||||
attachments: attachments
|
||||
}
|
||||
wsManager.send(wsMessage)
|
||||
|
||||
// Mark as distributed
|
||||
setTimeout(() => {
|
||||
const msgIndex = messages.value.findIndex(m => m._id === messageId)
|
||||
if (msgIndex !== -1) {
|
||||
messages.value[msgIndex] = {
|
||||
...messages.value[msgIndex],
|
||||
distributed: true,
|
||||
seen: true
|
||||
}
|
||||
messages.value = [...messages.value]
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// Fetch messages handler (for pagination, not needed for simple chat)
|
||||
const fetchMessages = ({ room, options }: any) => {
|
||||
messagesLoaded.value = true
|
||||
}
|
||||
|
||||
// Open file handler
|
||||
const openFile = ({ file }: any) => {
|
||||
window.open(file.url, '_blank')
|
||||
}
|
||||
|
||||
// Theme configuration for dark mode
|
||||
const chatTheme = 'light'
|
||||
|
||||
// Styles to match PrimeVue theme
|
||||
const chatStyles = computed(() => JSON.stringify({
|
||||
general: {
|
||||
color: 'var(--p-surface-900)',
|
||||
colorSpinner: 'var(--p-primary-color)',
|
||||
borderStyle: '1px solid var(--p-surface-200)'
|
||||
},
|
||||
container: {
|
||||
background: 'var(--p-surface-0)'
|
||||
},
|
||||
header: {
|
||||
background: 'var(--p-surface-50)',
|
||||
colorRoomName: 'var(--p-surface-900)',
|
||||
colorRoomInfo: 'var(--p-surface-700)'
|
||||
},
|
||||
footer: {
|
||||
background: 'var(--p-surface-50)',
|
||||
borderStyleInput: '1px solid var(--p-surface-300)',
|
||||
backgroundInput: 'var(--p-surface-200)',
|
||||
colorInput: 'var(--p-surface-900)',
|
||||
colorPlaceholder: 'var(--p-surface-400)',
|
||||
colorIcons: 'var(--p-surface-400)'
|
||||
},
|
||||
content: {
|
||||
background: 'var(--p-surface-0)'
|
||||
},
|
||||
message: {
|
||||
background: 'var(--p-surface-100)',
|
||||
backgroundMe: 'var(--p-primary-color)',
|
||||
color: 'var(--p-surface-900)',
|
||||
colorMe: 'var(--p-primary-contrast-color)'
|
||||
}
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
wsManager.addHandler(handleMessage)
|
||||
// Mark messages as loaded after initialization
|
||||
messagesLoaded.value = true
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
wsManager.removeHandler(handleMessage)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<!--
|
||||
<div class="chat-header-custom">
|
||||
<span class="chat-title">AI Agent Chat</span>
|
||||
<Badge
|
||||
:value="isConnected ? 'Connected' : 'Disconnected'"
|
||||
:severity="isConnected ? 'success' : 'danger'"
|
||||
/>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<vue-advanced-chat
|
||||
height="calc(100vh - 60px)"
|
||||
:current-user-id="CURRENT_USER_ID"
|
||||
:rooms="JSON.stringify(rooms)"
|
||||
:messages="JSON.stringify(messages)"
|
||||
:messages-loaded="messagesLoaded"
|
||||
:room-id="SESSION_ID"
|
||||
:theme="chatTheme"
|
||||
:styles="chatStyles"
|
||||
:single-room="true"
|
||||
:rooms-list-opened="false"
|
||||
:show-add-room="false"
|
||||
:show-audio="false"
|
||||
:show-files="true"
|
||||
:show-emojis="true"
|
||||
:show-reaction-emojis="false"
|
||||
:accepted-files="'image/*,video/*,application/pdf'"
|
||||
@send-message="sendMessage"
|
||||
@fetch-messages="fetchMessages"
|
||||
@open-file="openFile"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
height: 100% !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--p-surface-0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-container :deep(.vac-container) {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.chat-container :deep(.vac-text-message) {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-header-custom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--p-surface-50);
|
||||
border-bottom: 1px solid var(--p-surface-200);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--p-surface-900);
|
||||
}
|
||||
</style>
|
||||
292
web/src/components/LoginScreen.vue
Normal file
292
web/src/components/LoginScreen.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Card from 'primevue/card'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
const props = defineProps<{
|
||||
needsConfirmation: boolean
|
||||
errorMessage?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
authenticate: [password: string, confirmPassword?: string, newPassword?: string, confirmNewPassword?: string]
|
||||
}>()
|
||||
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmNewPassword = ref('')
|
||||
const isLoading = ref(false)
|
||||
const isChangingPassword = ref(false)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!password.value || isLoading.value) return false
|
||||
if (props.needsConfirmation && password.value !== confirmPassword.value) return false
|
||||
if (isChangingPassword.value) {
|
||||
if (!newPassword.value || !confirmNewPassword.value) return false
|
||||
if (newPassword.value !== confirmNewPassword.value) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
// Prevent double-submit
|
||||
if (isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
if (props.needsConfirmation) {
|
||||
emit('authenticate', password.value, confirmPassword.value)
|
||||
} else if (isChangingPassword.value) {
|
||||
emit('authenticate', password.value, undefined, newPassword.value, confirmNewPassword.value)
|
||||
} else {
|
||||
emit('authenticate', password.value)
|
||||
}
|
||||
|
||||
// Reset loading state after a timeout (parent will handle actual auth)
|
||||
setTimeout(() => {
|
||||
isLoading.value = false
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const handleKeypress = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && canSubmit.value) {
|
||||
event.preventDefault() // Prevent form submission if this is in a form
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const togglePasswordChange = () => {
|
||||
isChangingPassword.value = !isChangingPassword.value
|
||||
newPassword.value = ''
|
||||
confirmNewPassword.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-screen">
|
||||
<div class="login-container">
|
||||
<Card class="login-card">
|
||||
<template #title>
|
||||
<div class="login-title">
|
||||
<i class="pi pi-lock" style="font-size: 2rem; margin-bottom: 0.5rem;"></i>
|
||||
<h2>{{ needsConfirmation ? 'Welcome' : 'Authentication' }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="login-content">
|
||||
<p v-if="needsConfirmation" class="welcome-message">
|
||||
This is your first time connecting. Please create a master password to secure your workspace.
|
||||
</p>
|
||||
<p v-else-if="isChangingPassword" class="welcome-message">
|
||||
Enter your current password and choose a new one.
|
||||
</p>
|
||||
<p v-else class="welcome-message">
|
||||
Enter your master password to connect.
|
||||
</p>
|
||||
|
||||
<Message v-if="errorMessage" severity="error" :closable="false">
|
||||
{{ errorMessage }}
|
||||
</Message>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="password">{{ isChangingPassword ? 'Current Password' : 'Password' }}</label>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="password"
|
||||
:feedback="needsConfirmation"
|
||||
toggleMask
|
||||
placeholder="Enter password"
|
||||
class="password-input"
|
||||
@keypress="handleKeypress"
|
||||
:disabled="isLoading"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="needsConfirmation" class="form-field">
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<Password
|
||||
id="confirm-password"
|
||||
v-model="confirmPassword"
|
||||
:feedback="false"
|
||||
toggleMask
|
||||
placeholder="Confirm password"
|
||||
class="password-input"
|
||||
@keypress="handleKeypress"
|
||||
:disabled="isLoading"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<small v-if="confirmPassword && password !== confirmPassword" class="p-error">
|
||||
Passwords do not match
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Password change fields -->
|
||||
<div v-if="isChangingPassword" class="password-change-section">
|
||||
<div class="form-field">
|
||||
<label for="new-password">New Password</label>
|
||||
<Password
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
:feedback="true"
|
||||
toggleMask
|
||||
placeholder="Enter new password"
|
||||
class="password-input"
|
||||
@keypress="handleKeypress"
|
||||
:disabled="isLoading"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="confirm-new-password">Confirm New Password</label>
|
||||
<Password
|
||||
id="confirm-new-password"
|
||||
v-model="confirmNewPassword"
|
||||
:feedback="false"
|
||||
toggleMask
|
||||
placeholder="Confirm new password"
|
||||
class="password-input"
|
||||
@keypress="handleKeypress"
|
||||
:disabled="isLoading"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<small v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="p-error">
|
||||
Passwords do not match
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="needsConfirmation ? 'Create & Connect' : isChangingPassword ? 'Change Password & Connect' : 'Connect'"
|
||||
:icon="isChangingPassword ? 'pi pi-key' : 'pi pi-sign-in'"
|
||||
@click="handleSubmit"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isLoading"
|
||||
class="connect-button"
|
||||
/>
|
||||
|
||||
<!-- Toggle password change link -->
|
||||
<div v-if="!needsConfirmation" class="change-password-link">
|
||||
<a @click="togglePasswordChange" href="#">
|
||||
{{ isChangingPassword ? 'Cancel password change' : 'Change password' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-screen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--p-surface-0) 0%, var(--p-surface-100) 100%);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.login-title h2 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.welcome-message {
|
||||
text-align: center;
|
||||
color: var(--p-text-secondary-color);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-weight: 600;
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
|
||||
.password-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.password-input :deep(.p-inputtext) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.connect-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.p-error {
|
||||
color: var(--p-red-500);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.password-change-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--p-surface-border);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.change-password-link {
|
||||
text-align: center;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.change-password-link a {
|
||||
color: var(--p-primary-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.change-password-link a:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user