Add custom URL support to vue frontend

This commit is contained in:
2025-08-20 22:50:38 +02:00
parent 864f0a5a45
commit 8c0f8c6b44
8 changed files with 281 additions and 22 deletions

View File

@@ -145,6 +145,37 @@
</div>
</div>
<div class="setting-group">
<h3>Account</h3>
<div class="setting-item">
<label>Current Server</label>
<div class="server-info">
{{ currentServerUrl || 'Default' }}
</div>
</div>
<div class="setting-actions">
<BaseButton
type="button"
variant="secondary"
@click="handleLogout"
:disabled="isSaving"
>
Logout
</BaseButton>
<BaseButton
type="button"
variant="danger"
@click="showResetConfirm = true"
:disabled="isSaving"
>
Reset All Data
</BaseButton>
</div>
</div>
<div class="form-actions">
<BaseButton
type="button"
@@ -161,14 +192,42 @@
</BaseButton>
</div>
</form>
<!-- Reset Data Confirmation Dialog -->
<div v-if="showResetConfirm" class="confirm-overlay">
<div class="confirm-dialog">
<h3>Reset All Data</h3>
<p>This will permanently delete all local data including messages, settings, and authentication. This cannot be undone.</p>
<div class="confirm-actions">
<BaseButton
type="button"
variant="secondary"
@click="showResetConfirm = false"
>
Cancel
</BaseButton>
<BaseButton
type="button"
variant="danger"
@click="handleResetData"
:loading="isResetting"
>
Reset All Data
</BaseButton>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { useAudio } from '@/composables/useAudio'
import { clear } from 'idb-keyval'
import BaseButton from '@/components/base/BaseButton.vue'
import type { AppSettings } from '@/types'
@@ -176,12 +235,19 @@ const emit = defineEmits<{
close: []
}>()
const router = useRouter()
const appStore = useAppStore()
const authStore = useAuthStore()
const toastStore = useToastStore()
const { availableVoices, speak, setVoice } = useAudio()
const isSaving = ref(false)
const isResetting = ref(false)
const showResetConfirm = ref(false)
const selectedVoiceURI = ref('')
// Computed property for current server URL
const currentServerUrl = computed(() => authStore.serverUrl)
const localSettings = reactive<AppSettings>({
soundEnabled: true,
speechEnabled: true,
@@ -229,6 +295,43 @@ const handleSave = async () => {
}
}
const handleLogout = async () => {
try {
await authStore.clearAuth()
toastStore.success('Logged out successfully')
emit('close')
router.push('/auth')
} catch (error) {
console.error('Logout failed:', error)
toastStore.error('Logout failed')
}
}
const handleResetData = async () => {
isResetting.value = true
try {
// Clear all IndexedDB data
await clear()
// Clear stores
await authStore.clearAuth()
appStore.$reset()
toastStore.success('All data has been reset')
showResetConfirm.value = false
emit('close')
// Redirect to auth page
router.push('/auth')
} catch (error) {
console.error('Reset failed:', error)
toastStore.error('Failed to reset data')
} finally {
isResetting.value = false
}
}
onMounted(() => {
// Copy current settings to local state
Object.assign(localSettings, appStore.settings)
@@ -340,6 +443,63 @@ onMounted(() => {
border-top: 1px solid #e5e7eb;
}
.server-info {
padding: 0.5rem;
background: #f9fafb;
border-radius: 4px;
font-family: monospace;
font-size: 0.875rem;
color: #374151;
word-break: break-all;
}
.setting-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
background: white;
border-radius: 8px;
padding: 1.5rem;
max-width: 400px;
margin: 1rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.confirm-dialog h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #dc2626;
}
.confirm-dialog p {
margin: 0 0 1.5rem 0;
color: #6b7280;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.setting-group h3 {
@@ -360,5 +520,25 @@ onMounted(() => {
.form-actions {
border-top-color: #374151;
}
.server-info {
color: rgba(255, 255, 255, 0.87);
}
.confirm-overlay {
background: rgba(0, 0, 0, 0.8);
}
.confirm-dialog {
background: #1f2937;
}
.confirm-dialog h3 {
color: rgba(255, 255, 255, 0.87);
}
.confirm-dialog p {
color: rgba(255, 255, 255, 0.6);
}
}
</style>

View File

@@ -1,12 +1,14 @@
import { onMounted, onUnmounted } from 'vue'
import { websocketService } from '@/services/websocket'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { useAudio } from '@/composables/useAudio'
import type { Channel, ExtendedMessage, FileAttachment } from '@/types'
export function useWebSocket() {
const appStore = useAppStore()
const authStore = useAuthStore()
const toastStore = useToastStore()
const { announceMessage } = useAudio()
@@ -157,6 +159,11 @@ export function useWebSocket() {
}
onMounted(() => {
// Set custom server URL if available
if (authStore.serverUrl) {
websocketService.setServerUrl(authStore.serverUrl)
}
setupEventHandlers()
websocketService.connect()
})

View File

@@ -9,6 +9,11 @@ class ApiService {
console.log('API service token set:', token ? `${token.substring(0, 10)}...` : 'null')
}
setBaseUrl(url: string) {
this.baseUrl = url
console.log('API service base URL set:', url)
}
private getHeaders(): HeadersInit {
return {
'Authorization': this.token,

View File

@@ -6,20 +6,35 @@ class WebSocketService {
private maxReconnectAttempts = 5
private reconnectInterval = 1000
private eventHandlers: Map<string, ((data: any) => void)[]> = new Map()
private customServerUrl: string | null = null
setServerUrl(url: string) {
this.customServerUrl = url
}
connect() {
if (this.ws?.readyState === WebSocket.OPEN) {
return
}
// In development, connect to backend server (port 3000)
// In production, use same host as frontend
const isDev = import.meta.env.DEV
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = isDev ? 'localhost:3000' : window.location.host
const wsUrl = `${protocol}//${host}`
// Determine WebSocket URL
let wsUrl: string
if (this.customServerUrl) {
// Use custom server URL
const url = new URL(this.customServerUrl)
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
wsUrl = `${protocol}//${url.host}`
} else {
// Use default behavior
const isDev = import.meta.env.DEV
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = isDev ? 'localhost:3000' : window.location.host
wsUrl = `${protocol}//${host}`
}
try {
console.log('Connecting to WebSocket:', wsUrl)
this.ws = new WebSocket(wsUrl)
this.setupEventListeners()
} catch (error) {

View File

@@ -4,27 +4,58 @@ import { get, set } from 'idb-keyval'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const serverUrl = ref<string | null>(null)
const isAuthenticated = ref(false)
const setToken = async (newToken: string) => {
// Get default server URL based on environment
const getDefaultServerUrl = () => {
return import.meta.env.DEV ? 'http://localhost:3000' : ''
}
const setToken = async (newToken: string, customServerUrl?: string) => {
token.value = newToken
isAuthenticated.value = true
await set('auth_token', newToken)
// Set server URL or use default
const urlToUse = customServerUrl || getDefaultServerUrl()
serverUrl.value = urlToUse
// Save both token and server URL
await Promise.all([
set('auth_token', newToken),
set('server_url', urlToUse)
])
}
const setServerUrl = async (url: string) => {
serverUrl.value = url
await set('server_url', url)
}
const clearAuth = async () => {
token.value = null
serverUrl.value = null
isAuthenticated.value = false
await set('auth_token', null)
await Promise.all([
set('auth_token', null),
set('server_url', null)
])
}
const checkAuth = async () => {
try {
const storedToken = await get('auth_token')
const [storedToken, storedServerUrl] = await Promise.all([
get('auth_token'),
get('server_url')
])
if (storedToken) {
// Set server URL or use default
const urlToUse = storedServerUrl || getDefaultServerUrl()
serverUrl.value = urlToUse
// Verify token with backend
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
const response = await fetch(`${baseUrl}/check-token`, {
const response = await fetch(`${urlToUse}/check-token`, {
headers: { Authorization: storedToken }
})
@@ -42,15 +73,15 @@ export const useAuthStore = defineStore('auth', () => {
}
}
const authenticate = async (authToken: string): Promise<boolean> => {
const authenticate = async (authToken: string, customServerUrl?: string): Promise<boolean> => {
try {
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
const response = await fetch(`${baseUrl}/check-token`, {
const urlToUse = customServerUrl || getDefaultServerUrl()
const response = await fetch(`${urlToUse}/check-token`, {
headers: { Authorization: authToken }
})
if (response.ok) {
await setToken(authToken)
await setToken(authToken, urlToUse)
return true
} else {
await clearAuth()
@@ -65,10 +96,13 @@ export const useAuthStore = defineStore('auth', () => {
return {
token,
serverUrl,
isAuthenticated,
setToken,
setServerUrl,
clearAuth,
checkAuth,
authenticate
authenticate,
getDefaultServerUrl
}
})

View File

@@ -84,6 +84,7 @@ export interface AppSettings {
selectedVoiceURI: string | null
defaultChannelId: number | null
theme: 'light' | 'dark' | 'auto'
serverUrl?: string | null
}
// Audio Types

View File

@@ -7,6 +7,14 @@
</div>
<form @submit.prevent="handleAuth" class="auth-form">
<BaseInput
v-model="serverUrl"
type="url"
label="Server URL (optional)"
:placeholder="defaultServerUrl"
:disabled="isLoading"
/>
<BaseInput
v-model="token"
type="password"
@@ -47,10 +55,14 @@ const toastStore = useToastStore()
const { playSound } = useAudio()
const token = ref('')
const serverUrl = ref('')
const error = ref('')
const isLoading = ref(false)
const tokenInput = ref()
// Get default server URL for placeholder
const defaultServerUrl = authStore.getDefaultServerUrl()
const handleAuth = async () => {
if (!token.value.trim()) return
@@ -58,18 +70,20 @@ const handleAuth = async () => {
error.value = ''
try {
const success = await authStore.authenticate(token.value.trim())
// Use custom server URL if provided, otherwise use default
const customUrl = serverUrl.value.trim() || undefined
const success = await authStore.authenticate(token.value.trim(), customUrl)
if (success) {
await playSound('login')
toastStore.success('Authentication successful!')
router.push('/')
} else {
error.value = 'Invalid authentication token'
error.value = 'Invalid authentication token or server URL'
tokenInput.value?.focus()
}
} catch (err) {
error.value = 'Authentication failed. Please try again.'
error.value = 'Authentication failed. Please check your token and server URL.'
console.error('Auth error:', err)
} finally {
isLoading.value = false

View File

@@ -158,10 +158,13 @@ const toastStore = useToastStore()
const { sendMessage: sendMessageOffline } = useOfflineSync()
const { playWater, playSent, playSound, speak, stopSpeaking, isSpeaking } = useAudio()
// Set up services - ensure token is properly set
// Set up services - ensure token and URL are properly set
if (authStore.token) {
apiService.setToken(authStore.token)
}
if (authStore.serverUrl) {
apiService.setBaseUrl(authStore.serverUrl)
}
// Refs
const messagesContainer = ref()