redesign fully scaffolded and web login works

This commit is contained in:
2026-03-17 20:10:47 -04:00
parent b9cc397e05
commit f6bd22a8ef
143 changed files with 17317 additions and 693 deletions

9
web/.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Gateway API URL (use relative path for ingress routing)
# In dev with minikube: http://dexorder.local/api
# For local development: http://localhost:3000
VITE_GATEWAY_URL=/api
# WebSocket URL (for real-time updates, if needed)
# In dev with minikube: ws://dexorder.local/api/ws/chat
# For local development: ws://localhost:8080/ws/chat
VITE_WS_URL=ws://dexorder.local/api/ws/chat

View File

@@ -11,9 +11,9 @@ import { useShapeStore } from './stores/shapes'
import { useIndicatorStore } from './stores/indicators'
import { useStateSync } from './composables/useStateSync'
import { wsManager } from './composables/useWebSocket'
import { authService } from './composables/useAuth'
const isAuthenticated = ref(false)
const needsConfirmation = ref(false)
const isAuthenticated = authService.isAuthenticated
const authError = ref<string>()
const isDragging = ref(false)
const isMobile = ref(false)
@@ -37,57 +37,66 @@ watch(isMobile, (mobile) => {
}
})
// Check if we need password confirmation on first load
// Check if user is already authenticated on page load
onMounted(async () => {
// Check if secrets store is initialized by trying to fetch a status endpoint
// For now, just default to false (user will see login screen)
needsConfirmation.value = false
// Try to restore session from stored token
if (authService.getToken()) {
const sessionValid = await authService.checkAuth()
if (sessionValid) {
isAuthenticated.value = true
await initializeApp()
}
}
// Initialize mobile check
checkMobile()
window.addEventListener('resize', checkMobile)
})
const handleAuthenticate = async (
password: string,
confirmPassword?: string,
newPassword?: string,
confirmNewPassword?: string
) => {
const handleAuthenticate = async (email: string, password: string) => {
authError.value = undefined
try {
const result = await wsManager.connect(password, confirmPassword, newPassword, confirmNewPassword)
// Step 1: Login via HTTP to get JWT token
const result = await authService.login(email, password)
if (result.success) {
isAuthenticated.value = true
// Initialize state sync after successful authentication
const orderStore = useOrderStore()
const chartStore = useChartStore()
const shapeStore = useShapeStore()
const indicatorStore = useIndicatorStore()
const stateSync = useStateSync({
OrderStore: orderStore,
ChartStore: chartStore,
ShapeStore: shapeStore,
IndicatorStore: indicatorStore
})
stateSyncCleanup = stateSync.cleanup
} else {
authError.value = result.message
// If server says we need confirmation, update the flag
if (result.needsConfirmation) {
needsConfirmation.value = true
}
if (!result.success) {
authError.value = result.error || 'Login failed'
return
}
} catch (err) {
authError.value = 'Connection failed'
if (!result.token) {
authError.value = 'No token received from server'
return
}
// Step 2: Connect WebSocket with JWT token (if WebSocket is used for real-time sync)
// For now, we're not connecting WebSocket until it's implemented in the gateway
// await wsManager.connect(result.token)
// Step 3: Initialize application
await initializeApp()
} catch (err: any) {
authError.value = err.message || 'Authentication failed'
console.error('Authentication error:', err)
}
}
const initializeApp = async () => {
// Initialize state sync after successful authentication
const orderStore = useOrderStore()
const chartStore = useChartStore()
const shapeStore = useShapeStore()
const indicatorStore = useIndicatorStore()
const stateSync = useStateSync({
OrderStore: orderStore,
ChartStore: chartStore,
ShapeStore: shapeStore,
IndicatorStore: indicatorStore
})
stateSyncCleanup = stateSync.cleanup
}
onMounted(() => {
// Listen for splitter drag events
document.addEventListener('mousedown', (e) => {
@@ -116,7 +125,6 @@ onBeforeUnmount(() => {
<div class="app-container dark">
<LoginScreen
v-if="!isAuthenticated"
:needs-confirmation="needsConfirmation"
:error-message="authError"
@authenticate="handleAuthenticate"
/>

View File

@@ -7,30 +7,23 @@ 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]
authenticate: [email: string, password: string]
}>()
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const newPassword = ref('')
const confirmNewPassword = ref('')
const isLoading = ref(false)
const isChangingPassword = ref(false)
const passwordInput = ref<InstanceType<typeof Password> | null>(null)
const emailInput = ref<InstanceType<typeof InputText> | null>(null)
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
if (!email.value || !password.value || isLoading.value) return false
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email.value)
})
const handleSubmit = () => {
@@ -41,13 +34,7 @@ const handleSubmit = () => {
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)
}
emit('authenticate', email.value, password.value)
// Reset loading state after a timeout (parent will handle actual auth)
setTimeout(() => {
@@ -62,19 +49,10 @@ const handleKeypress = (event: KeyboardEvent) => {
}
}
const togglePasswordChange = () => {
isChangingPassword.value = !isChangingPassword.value
newPassword.value = ''
confirmNewPassword.value = ''
}
onMounted(() => {
// Focus on the password input when component mounts
if (passwordInput.value?.$el) {
const inputElement = passwordInput.value.$el.querySelector('input') as HTMLInputElement
if (inputElement) {
inputElement.focus()
}
// Focus on the email input when component mounts
if (emailInput.value?.$el) {
emailInput.value.$el.focus()
}
})
</script>
@@ -86,19 +64,13 @@ onMounted(() => {
<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>
<h2>Sign In</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 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 password to connect.
<p class="welcome-message">
Enter your credentials to access the platform.
</p>
<Message v-if="errorMessage" severity="error" :closable="false">
@@ -106,14 +78,28 @@ onMounted(() => {
</Message>
<div class="form-field">
<label for="password">{{ isChangingPassword ? 'Current Password' : 'Password' }}</label>
<label for="email">Email</label>
<InputText
ref="emailInput"
id="email"
v-model="email"
type="email"
placeholder="Enter your email"
class="email-input"
@keypress="handleKeypress"
:disabled="isLoading"
autocomplete="email"
/>
</div>
<div class="form-field">
<label for="password">Password</label>
<Password
ref="passwordInput"
id="password"
v-model="password"
:feedback="needsConfirmation"
:feedback="false"
toggleMask
placeholder="Enter password"
placeholder="Enter your password"
class="password-input"
@keypress="handleKeypress"
:disabled="isLoading"
@@ -121,75 +107,14 @@ onMounted(() => {
/>
</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'"
label="Sign In"
icon="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>
@@ -255,6 +180,7 @@ onMounted(() => {
color: var(--p-text-color);
}
.email-input,
.password-input {
width: 100%;
}
@@ -269,36 +195,4 @@ onMounted(() => {
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>

View File

@@ -0,0 +1,158 @@
import { ref } from 'vue'
export interface LoginResponse {
success: boolean
userId?: string
token?: string
error?: string
}
export interface SessionData {
user: {
id: string
email: string
name?: string
}
session: {
token: string
expiresAt: string
}
}
const TOKEN_STORAGE_KEY = 'dexorder_auth_token'
class AuthService {
public isAuthenticated = ref(false)
private token: string | null = null
private gatewayUrl: string
constructor() {
// Use environment variable for gateway URL, fallback to localhost for dev
this.gatewayUrl = import.meta.env.VITE_GATEWAY_URL || 'http://localhost:3000'
// Try to restore token from localStorage
const storedToken = localStorage.getItem(TOKEN_STORAGE_KEY)
if (storedToken) {
this.token = storedToken
this.isAuthenticated.value = true
}
}
/**
* Login with email and password
*/
async login(email: string, password: string): Promise<LoginResponse> {
try {
const response = await fetch(`${this.gatewayUrl}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
})
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data.error || 'Login failed',
}
}
if (data.success && data.token) {
this.token = data.token
localStorage.setItem(TOKEN_STORAGE_KEY, data.token)
this.isAuthenticated.value = true
return {
success: true,
userId: data.userId,
token: data.token,
}
}
return {
success: false,
error: 'Invalid response from server',
}
} catch (error: any) {
console.error('Login error:', error)
return {
success: false,
error: error.message || 'Network error',
}
}
}
/**
* Logout and clear session
*/
async logout(): Promise<void> {
try {
if (this.token) {
await fetch(`${this.gatewayUrl}/auth/logout`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`,
},
})
}
} catch (error) {
console.error('Logout error:', error)
} finally {
this.token = null
localStorage.removeItem(TOKEN_STORAGE_KEY)
this.isAuthenticated.value = false
}
}
/**
* Get current session information
*/
async getSession(): Promise<SessionData | null> {
if (!this.token) {
return null
}
try {
const response = await fetch(`${this.gatewayUrl}/auth/session`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.token}`,
},
})
if (!response.ok) {
// Token is invalid or expired
this.token = null
localStorage.removeItem(TOKEN_STORAGE_KEY)
this.isAuthenticated.value = false
return null
}
const data = await response.json()
return data
} catch (error) {
console.error('Get session error:', error)
return null
}
}
/**
* Get the current auth token
*/
getToken(): string | null {
return this.token
}
/**
* Check if user is authenticated by verifying session
*/
async checkAuth(): Promise<boolean> {
const session = await this.getSession()
return session !== null
}
}
export const authService = new AuthService()

View File

@@ -1,5 +1,4 @@
import { ref } from 'vue'
import type { AuthMessage, AuthResponseMessage } from '../types/sync'
export interface WebSocketMessage {
type: string
@@ -8,36 +7,21 @@ export interface WebSocketMessage {
export type MessageHandler = (message: WebSocketMessage) => void
export interface AuthResult {
success: boolean
needsConfirmation: boolean
message: string
}
class WebSocketManager {
private ws: WebSocket | null = null
private handlers: Set<MessageHandler> = new Set()
private reconnectTimeout: number | null = null
public isConnected = ref(false)
public isAuthenticated = ref(false)
private authResolve: ((result: AuthResult) => void) | null = null
private shouldReconnect = false // Disable auto-reconnect (user must re-authenticate)
private storedPassword: string | null = null
private storedConfirmPassword: string | undefined = undefined
private token: string | null = null
async connect(
password: string,
confirmPassword?: string,
changeToPassword?: string,
confirmNewPassword?: string
): Promise<AuthResult> {
// Store password for potential reconnection
this.storedPassword = password
this.storedConfirmPassword = confirmPassword
/**
* Connect to WebSocket with JWT token for authentication
*/
async connect(token: string): Promise<void> {
this.token = token
// Close existing connection if any
if (this.ws) {
this.shouldReconnect = false
this.ws.close()
this.ws = null
}
@@ -45,55 +29,21 @@ class WebSocketManager {
return new Promise((resolve, reject) => {
// Use env variable for WebSocket URL, fallback to localhost for dev
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8080/ws'
this.ws = new WebSocket(wsUrl)
this.authResolve = resolve
// Append token as query parameter
const wsUrlWithToken = `${wsUrl}?token=${encodeURIComponent(token)}`
this.ws = new WebSocket(wsUrlWithToken)
this.ws.onopen = () => {
console.log('WebSocket connected, sending auth...')
// Send authentication message
const authMsg: AuthMessage = {
type: 'auth',
password,
confirm_password: confirmPassword,
change_to_password: changeToPassword,
confirm_new_password: confirmNewPassword
}
this.ws?.send(JSON.stringify(authMsg))
console.log('WebSocket connected')
this.isConnected.value = true
resolve()
}
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
// Handle auth response
if (message.type === 'auth_response') {
const authResponse = message as AuthResponseMessage
console.log('Auth response:', authResponse)
if (authResponse.success) {
this.isConnected.value = true
this.isAuthenticated.value = true
this.shouldReconnect = true
console.log('Authentication successful')
} else {
this.isAuthenticated.value = false
console.warn('Authentication failed:', authResponse.message)
}
if (this.authResolve) {
this.authResolve({
success: authResponse.success,
needsConfirmation: authResponse.needs_confirmation || false,
message: authResponse.message
})
this.authResolve = null
}
// Don't return, let other handlers process this message too
}
// Pass to all handlers
this.handlers.forEach(handler => handler(message))
} catch (err) {
@@ -104,48 +54,21 @@ class WebSocketManager {
this.ws.onerror = (error) => {
console.error('WebSocket error:', error)
this.isConnected.value = false
this.isAuthenticated.value = false
if (this.authResolve) {
this.authResolve({
success: false,
needsConfirmation: false,
message: 'Connection error'
})
this.authResolve = null
}
reject(error)
}
this.ws.onclose = (event) => {
this.isConnected.value = false
this.isAuthenticated.value = false
console.log('WebSocket disconnected:', event.code, event.reason)
if (this.authResolve) {
this.authResolve({
success: false,
needsConfirmation: false,
message: event.reason || 'Connection closed'
})
this.authResolve = null
}
// No auto-reconnect - user must re-authenticate manually
// This prevents reconnection attempts without proper password
}
// Timeout for auth
// Connection timeout
setTimeout(() => {
if (this.authResolve) {
this.authResolve({
success: false,
needsConfirmation: false,
message: 'Authentication timeout'
})
this.authResolve = null
if (!this.isConnected.value) {
reject(new Error('WebSocket connection timeout'))
this.ws?.close()
}
}, 30000)
}, 10000)
})
}

View File

@@ -9,7 +9,7 @@ import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({
base: process.env.VITE_BASE_PATH || '/',
server: {
allowedHosts: ['.dexorder.ai']
allowedHosts: ['.dexorder.ai', 'dexorder.local']
},
css: {
preprocessorOptions: {