Private
Public Access
1
0
Files
Purrse/frontend/src/views/settings/SettingsView.vue
T
Catherine Renelle ac08e21e55 Default Settings page to Profile tab
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:56:48 -05:00

1452 lines
50 KiB
Vue

<template>
<div>
<v-tabs v-model="activeTab" color="primary">
<v-tab value="profile">Profile</v-tab>
<v-tab value="connections">Bank Connections</v-tab>
<v-tab value="history">Sync History</v-tab>
<v-tab value="ai">AI</v-tab>
</v-tabs>
<v-tabs-window v-model="activeTab" class="mt-4">
<!-- Tab 1: Profile -->
<v-tabs-window-item value="profile">
<!-- Profile Info Card -->
<v-card class="mb-6">
<v-card-title>Profile Information</v-card-title>
<v-card-text>
<v-text-field
v-model="profileForm.displayName"
label="Display Name"
variant="outlined"
class="mb-4"
placeholder="Enter a display name..."
/>
<v-text-field
v-model="profileForm.email"
label="Email"
variant="outlined"
class="mb-4"
type="email"
/>
<v-text-field
:model-value="authStore.username"
label="Username"
readonly
variant="outlined"
class="mb-4"
hint="Username cannot be changed"
persistent-hint
/>
<v-btn color="primary" :loading="profileSaving" @click="saveProfile">Save</v-btn>
</v-card-text>
</v-card>
<!-- Change Password Card -->
<v-card class="mb-6">
<v-card-title>Change Password</v-card-title>
<v-card-text>
<v-text-field
v-model="passwordForm.currentPassword"
label="Current Password"
variant="outlined"
type="password"
class="mb-4"
/>
<v-text-field
v-model="passwordForm.newPassword"
label="New Password"
variant="outlined"
type="password"
class="mb-4"
/>
<v-text-field
v-model="passwordForm.confirmPassword"
label="Confirm New Password"
variant="outlined"
type="password"
class="mb-4"
:error-messages="passwordForm.confirmPassword && passwordForm.newPassword !== passwordForm.confirmPassword ? ['Passwords do not match'] : []"
/>
<v-btn
color="primary"
:loading="passwordChanging"
:disabled="!passwordForm.currentPassword || !passwordForm.newPassword || passwordForm.newPassword !== passwordForm.confirmPassword"
@click="changePassword"
>
Change Password
</v-btn>
</v-card-text>
</v-card>
<!-- Account Info Card -->
<v-card class="mb-6">
<v-card-title>Account Info</v-card-title>
<v-card-text>
<div class="text-body-1 mb-2">
<strong>Member since:</strong> {{ profile ? new Date(profile.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : '-' }}
</div>
<div class="text-body-1">
<strong>Last login:</strong> {{ profile?.lastLoginAt ? new Date(profile.lastLoginAt).toLocaleString() : 'Never' }}
</div>
</v-card-text>
</v-card>
<!-- Danger Zone Card -->
<v-card variant="outlined" color="error">
<v-card-title class="text-error">Danger Zone</v-card-title>
<v-card-text>
<p class="text-body-2 mb-4">Permanently delete your account and all associated data. This action cannot be undone.</p>
<v-btn color="error" variant="tonal" @click="showDeleteAccountDialog = true">Delete Account</v-btn>
</v-card-text>
</v-card>
<!-- Delete Account Confirmation Dialog -->
<v-dialog v-model="showDeleteAccountDialog" max-width="400">
<v-card>
<v-card-title class="text-error">Delete Account</v-card-title>
<v-card-text>
Are you sure you want to permanently delete your account? All your data including accounts, transactions, and settings will be removed. This cannot be undone.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDeleteAccountDialog = false">Cancel</v-btn>
<v-btn color="error" :loading="accountDeleting" @click="deleteAccount">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Profile Snackbars -->
<v-snackbar v-model="showProfileSuccess" :timeout="4000" color="success">
{{ profileSuccessMessage }}
</v-snackbar>
<v-snackbar v-model="showProfileError" :timeout="6000" color="error">
{{ profileErrorMessage }}
</v-snackbar>
</v-tabs-window-item>
<!-- Tab 2: Bank Connections -->
<v-tabs-window-item value="connections">
<!-- SimpleFIN Section -->
<v-card class="mb-6">
<v-card-title class="d-flex align-center flex-wrap ga-2">
<v-icon class="mr-1">mdi-link-variant</v-icon>
SimpleFIN
<v-chip color="green" size="small">{{ simpleFinConnections.length }}</v-chip>
<v-spacer />
<v-btn
size="small"
variant="tonal"
color="secondary"
prepend-icon="mdi-link-plus"
@click="showSimpleFinDialog = true"
>
Connect
</v-btn>
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-sync"
:loading="syncingAllSimpleFin"
:disabled="simpleFinConnections.filter(c => c.isActive).length === 0"
@click="syncAllSimpleFin"
>
Sync All
</v-btn>
<v-btn
size="small"
variant="tonal"
color="secondary"
prepend-icon="mdi-refresh"
:loading="resyncingAllSimpleFin"
:disabled="simpleFinConnections.filter(c => c.isActive).length === 0"
@click="resyncAllSimpleFin"
>
Re-sync All
</v-btn>
</v-card-title>
<v-card-text>
<div class="d-flex align-center mb-4">
<v-select
v-model="simpleFinGlobalInterval"
:items="intervalOptions"
item-title="text"
item-value="value"
label="Sync Interval (all SimpleFIN)"
density="compact"
variant="outlined"
hide-details
style="max-width: 260px"
@update:model-value="updateSimpleFinGlobalInterval"
/>
</div>
<v-alert v-if="simpleFinConnections.length === 0 && !loading" type="info" variant="tonal" class="mb-4">
No SimpleFIN connections yet. Click Connect to add one.
</v-alert>
<v-card v-for="conn in simpleFinConnections" :key="conn.id" variant="outlined" class="mb-3">
<v-card-title class="d-flex align-center text-subtitle-1 py-2">
{{ conn.institutionName }}
<v-spacer />
<v-chip
:color="conn.isActive ? 'success' : 'grey'"
size="x-small"
class="mr-2"
>
{{ conn.isActive ? 'Active' : 'Inactive' }}
</v-chip>
<v-btn
variant="text"
:color="conn.isActive ? 'warning' : 'success'"
size="small"
@click="toggleConnection(conn)"
>
{{ conn.isActive ? 'Disable' : 'Enable' }}
</v-btn>
<v-btn
variant="text"
color="error"
size="small"
icon="mdi-delete"
@click="confirmDeleteConnection(conn)"
/>
</v-card-title>
<v-card-text class="pt-0">
<div class="text-body-2 text-medium-emphasis mb-2">
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }}
<v-chip v-if="syncingConnections[conn.id]" size="x-small" color="info" class="ml-2">
Syncing...
</v-chip>
<span v-if="conn.lastSyncError" class="text-error ml-2">{{ conn.lastSyncError }}</span>
</div>
<v-table density="compact" v-if="conn.linkedAccounts.length > 0">
<thead>
<tr>
<th>External Account</th>
<th>Type</th>
<th>Balance</th>
<th>Purrse Account</th>
<th>Enabled</th>
<th>Last Synced</th>
</tr>
</thead>
<tbody>
<tr v-for="la in conn.linkedAccounts" :key="la.id">
<td>{{ la.externalAccountName }}</td>
<td>{{ la.externalAccountType }}</td>
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td>
<td>
<v-select
:model-value="la.accountId"
:items="accountOptions"
item-title="text"
item-value="value"
density="compact"
variant="outlined"
hide-details
clearable
placeholder="Select account..."
@update:model-value="(val: string | null) => updateLinkedAccount(la, val)"
/>
</td>
<td>
<v-switch
:model-value="la.isEnabled"
density="compact"
hide-details
color="primary"
@update:model-value="(val: boolean | null) => toggleLinkedAccount(la, val ?? false)"
/>
</td>
<td class="text-body-2">{{ la.lastSyncAt ? new Date(la.lastSyncAt).toLocaleString() : 'Never' }}</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
<!-- Plaid Section -->
<v-card class="mb-6">
<v-card-title class="d-flex align-center flex-wrap ga-2">
<v-icon class="mr-1">mdi-bank</v-icon>
Plaid
<v-chip color="blue" size="small">{{ plaidConnections.length }}</v-chip>
<v-spacer />
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-bank-plus"
:loading="plaidLoading"
:disabled="!plaidCredentials.isConfigured"
@click="startPlaidLink"
>
Connect
</v-btn>
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-sync"
:loading="syncingAllPlaid"
:disabled="plaidConnections.filter(c => c.isActive).length === 0"
@click="syncAllPlaid"
>
Sync All
</v-btn>
</v-card-title>
<v-card-text>
<!-- Plaid API Credentials -->
<div class="mb-4">
<div class="d-flex align-center mb-2">
<v-icon size="small" class="mr-1">mdi-key</v-icon>
<span class="text-subtitle-2">API Credentials</span>
<v-chip v-if="plaidCredentials.isConfigured" color="success" size="x-small" class="ml-2">Configured</v-chip>
<v-chip v-else color="warning" size="x-small" class="ml-2">Not configured</v-chip>
</div>
<p class="text-body-2 text-medium-emphasis mb-3">
Enter your Plaid developer credentials. Get them from your Plaid dashboard.
</p>
<v-row dense>
<v-col cols="12" sm="4">
<v-text-field
v-model="plaidForm.clientId"
label="Client ID"
variant="outlined"
density="compact"
:placeholder="plaidCredentials.isConfigured ? '(saved)' : ''"
/>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
v-model="plaidForm.secret"
label="Secret"
variant="outlined"
density="compact"
type="password"
:placeholder="plaidCredentials.isConfigured ? '(saved)' : ''"
/>
</v-col>
<v-col cols="12" sm="2">
<v-select
v-model="plaidForm.environment"
:items="plaidEnvironments"
label="Environment"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" sm="2" class="d-flex align-center">
<v-btn
color="primary"
:loading="plaidCredsSaving"
:disabled="!plaidForm.clientId || !plaidForm.secret"
@click="savePlaidCredentials"
>
Save
</v-btn>
</v-col>
</v-row>
</div>
<v-divider class="mb-4" />
<v-alert v-if="plaidConnections.length === 0 && !loading" type="info" variant="tonal" class="mb-4">
No Plaid connections yet. Configure your API credentials above, then click Connect.
</v-alert>
<v-card v-for="conn in plaidConnections" :key="conn.id" variant="outlined" class="mb-3">
<v-card-title class="d-flex align-center text-subtitle-1 py-2">
{{ conn.institutionName }}
<v-spacer />
<v-chip
:color="conn.isActive ? 'success' : 'grey'"
size="x-small"
class="mr-2"
>
{{ conn.isActive ? 'Active' : 'Inactive' }}
</v-chip>
<v-btn
variant="text"
:color="conn.isActive ? 'warning' : 'success'"
size="small"
@click="toggleConnection(conn)"
>
{{ conn.isActive ? 'Disable' : 'Enable' }}
</v-btn>
<v-btn
variant="text"
color="error"
size="small"
icon="mdi-delete"
@click="confirmDeleteConnection(conn)"
/>
</v-card-title>
<v-card-text class="pt-0">
<div class="text-body-2 text-medium-emphasis mb-2">
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }}
<v-chip v-if="syncingConnections[conn.id]" size="x-small" color="info" class="ml-2">
Syncing...
</v-chip>
<span v-if="conn.lastSyncError" class="text-error ml-2">{{ conn.lastSyncError }}</span>
</div>
<v-table density="compact" v-if="conn.linkedAccounts.length > 0">
<thead>
<tr>
<th>External Account</th>
<th>Type</th>
<th>Balance</th>
<th>Purrse Account</th>
<th>Enabled</th>
<th>Last Synced</th>
</tr>
</thead>
<tbody>
<tr v-for="la in conn.linkedAccounts" :key="la.id">
<td>{{ la.externalAccountName }}</td>
<td>{{ la.externalAccountType }}</td>
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td>
<td>
<v-select
:model-value="la.accountId"
:items="accountOptions"
item-title="text"
item-value="value"
density="compact"
variant="outlined"
hide-details
clearable
placeholder="Select account..."
@update:model-value="(val: string | null) => updateLinkedAccount(la, val)"
/>
</td>
<td>
<v-switch
:model-value="la.isEnabled"
density="compact"
hide-details
color="primary"
@update:model-value="(val: boolean | null) => toggleLinkedAccount(la, val ?? false)"
/>
</td>
<td class="text-body-2">{{ la.lastSyncAt ? new Date(la.lastSyncAt).toLocaleString() : 'Never' }}</td>
</tr>
</tbody>
</v-table>
<div class="d-flex align-center mt-3 ga-2">
<v-select
v-model="syncIntervals[conn.id]"
:items="intervalOptions"
item-title="text"
item-value="value"
label="Sync Interval"
density="compact"
variant="outlined"
hide-details
style="max-width: 200px"
@update:model-value="() => updateConnectionInterval(conn)"
/>
</div>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
<!-- Account Mapping Dialog -->
<v-dialog v-model="showMappingDialog" max-width="700" persistent>
<v-card>
<v-card-title>Map Bank Accounts</v-card-title>
<v-card-text>
<p class="mb-4">Map your external bank accounts to Purrse accounts, or auto-create new ones.</p>
<v-table density="compact">
<thead>
<tr>
<th>External Account</th>
<th>Type</th>
<th>Balance</th>
<th>Purrse Account</th>
<th>Auto-create</th>
</tr>
</thead>
<tbody>
<tr v-for="la in newLinkedAccounts" :key="la.id">
<td>{{ la.externalAccountName }}</td>
<td>{{ la.externalAccountType }}</td>
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td>
<td>
<v-select
v-model="mappingSelections[la.id]"
:items="accountOptions"
item-title="text"
item-value="value"
density="compact"
variant="outlined"
hide-details
clearable
:disabled="autoCreateSelections[la.id]"
placeholder="Select..."
/>
</td>
<td>
<v-checkbox
v-model="autoCreateSelections[la.id]"
density="compact"
hide-details
/>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showMappingDialog = false">Skip</v-btn>
<v-btn color="primary" @click="saveMappings" :loading="mappingSaving">Save Mappings</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- SimpleFIN Dialog -->
<v-dialog v-model="showSimpleFinDialog" max-width="500">
<v-card>
<v-card-title>Connect via SimpleFIN</v-card-title>
<v-card-text>
<p class="mb-4">
Visit <strong>beta-bridge.simplefin.org</strong> to get a setup token for your bank, then paste it below.
</p>
<v-text-field
v-model="simpleFinToken"
label="Setup Token"
variant="outlined"
placeholder="Paste your SimpleFIN setup token..."
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showSimpleFinDialog = false">Cancel</v-btn>
<v-btn color="primary" @click="connectSimpleFin" :loading="simpleFinLoading" :disabled="!simpleFinToken">
Connect
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirmation -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title>Delete Connection</v-card-title>
<v-card-text>
Are you sure you want to delete the connection to <strong>{{ deleteTarget?.institutionName }}</strong>?
This will remove all linked account mappings and sync history.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" @click="deleteConnection" :loading="deleteLoading">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbars -->
<v-snackbar v-model="showSyncResult" :timeout="5000" color="success">
{{ syncResultMessage }}
</v-snackbar>
<v-snackbar v-model="showError" :timeout="6000" color="error">
{{ errorMessage }}
</v-snackbar>
</v-tabs-window-item>
<!-- Tab 3: Sync History -->
<v-tabs-window-item value="history">
<v-card>
<v-card-title class="d-flex align-center">
Sync History
<v-spacer />
<v-select
v-model="logFilterConnectionId"
:items="connectionFilterOptions"
item-title="text"
item-value="value"
label="Filter by connection"
density="compact"
variant="outlined"
hide-details
clearable
style="max-width: 300px"
@update:model-value="loadSyncLogs"
/>
</v-card-title>
<v-data-table
:headers="logHeaders"
:items="syncLogs"
:loading="logsLoading"
density="compact"
items-per-page="25"
>
<template v-slot:item.startedAt="{ item }">
{{ new Date(item.startedAt).toLocaleString() }}
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="statusColor(item.status)"
size="small"
>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.duration="{ item }">
{{ item.duration || '-' }}
</template>
<template v-slot:item.errorMessage="{ item }">
<span v-if="item.errorMessage" class="text-error text-body-2">{{ item.errorMessage }}</span>
<span v-else>-</span>
</template>
</v-data-table>
</v-card>
</v-tabs-window-item>
<!-- Tab 4: AI -->
<v-tabs-window-item value="ai">
<v-alert type="info" variant="tonal" class="mb-4">
AI features use a local Ollama LLM. Ollama runs locally on your machine — no data is sent to external services.
</v-alert>
<!-- Card 1: AI Configuration -->
<v-card class="mb-4">
<v-card-title>AI Configuration</v-card-title>
<v-card-text>
<v-row dense>
<v-col cols="12" sm="6">
<v-text-field
v-model="aiSettings.ollamaUrl"
label="Ollama URL"
variant="outlined"
density="compact"
placeholder="http://localhost:11434"
/>
</v-col>
<v-col cols="12" sm="6">
<v-combobox
v-model="aiSettings.modelName"
:items="availableModels"
label="Model"
variant="outlined"
density="compact"
placeholder="llama3.1:8b"
/>
</v-col>
</v-row>
<v-btn
variant="tonal"
color="secondary"
:loading="aiTesting"
@click="testAiConnection"
>
Test Connection
</v-btn>
<v-alert
v-if="aiTestResult"
:type="aiTestResult.success ? 'success' : 'error'"
variant="tonal"
class="mt-4"
closable
@click:close="aiTestResult = null"
>
<template v-if="aiTestResult.success">
Connected to Ollama. Available models: {{ aiTestResult.availableModels?.join(', ') || 'none' }}
</template>
<template v-else>
Connection failed: {{ aiTestResult.errorMessage }}
</template>
</v-alert>
</v-card-text>
</v-card>
<!-- Card 2: AI Categorization -->
<v-card class="mb-4">
<v-card-title>AI Categorization</v-card-title>
<v-card-text>
<v-switch
v-model="aiSettings.isEnabled"
label="Enable AI Categorization"
color="primary"
hide-details
class="mb-4"
:disabled="!isOllamaConfigured"
/>
<v-alert v-if="!isOllamaConfigured" type="warning" variant="tonal" class="mb-4">
Configure Ollama connection above to enable AI features
</v-alert>
<v-row dense>
<v-col cols="12" sm="6">
<div class="text-body-2 mb-1">Confidence Threshold: {{ aiSettings.confidenceThreshold.toFixed(2) }}</div>
<v-slider
v-model="aiSettings.confidenceThreshold"
:min="0.1"
:max="1.0"
:step="0.05"
thumb-label
color="primary"
hide-details
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="aiSettings.timeoutSeconds"
label="Timeout (seconds)"
variant="outlined"
density="compact"
type="number"
:min="5"
:max="120"
/>
</v-col>
</v-row>
<v-checkbox
v-model="aiSettings.updatePayeeDefaults"
label="Auto-update payee default categories (learning loop)"
color="primary"
hide-details
class="mb-4"
/>
<v-divider class="mb-4" />
<div class="text-subtitle-2 mb-2">Prompt Templates</div>
<p class="text-body-2 text-medium-emphasis mb-4">
Customize the prompts sent to the AI model. Use placeholders to control where dynamic data is inserted:
<strong>{categories}</strong> = category list,
<strong>{examples}</strong> = recent categorized transactions,
<strong>{transactions}</strong> = uncategorized transactions to classify.
</p>
<v-textarea
v-model="aiSettings.systemPrompt"
label="System Prompt"
variant="outlined"
rows="8"
class="mb-4"
/>
<v-textarea
v-model="aiSettings.userPromptTemplate"
label="User Prompt Template"
variant="outlined"
rows="5"
class="mb-4"
/>
<v-btn
variant="tonal"
color="secondary"
@click="resetPromptsToDefaults"
>
Reset to Defaults
</v-btn>
</v-card-text>
</v-card>
<!-- Card 3: AI Chat -->
<v-card class="mb-4">
<v-card-title>AI Chat</v-card-title>
<v-card-text>
<v-switch
v-model="aiSettings.isChatEnabled"
label="Enable AI Chat"
color="primary"
hide-details
class="mb-4"
:disabled="!isOllamaConfigured"
/>
<v-alert v-if="!isOllamaConfigured" type="warning" variant="tonal" class="mb-4">
Configure Ollama connection above to enable AI features
</v-alert>
<div class="text-body-2 mb-1">Context Size: {{ aiSettings.chatContextSize.toLocaleString() }} tokens</div>
<v-slider
v-model="aiSettings.chatContextSize"
:min="2048"
:max="131072"
:step="2048"
thumb-label
color="primary"
hide-details
class="mb-1"
/>
<div class="text-caption text-medium-emphasis mb-4">
Estimated VRAM: ~{{ Math.round((aiSettings.chatContextSize / 1024) * 75) }} MB Increase if the AI loses context in long chats. Decrease if Ollama runs out of memory.
</div>
<v-text-field
v-model="aiSettings.chatBotName"
label="Chatbot Name"
variant="outlined"
density="compact"
placeholder="Purrse AI"
hint="Custom name shown in the chat panel header and used by the AI"
persistent-hint
clearable
/>
</v-card-text>
</v-card>
<v-btn
color="primary"
:loading="aiSaving"
@click="saveAiSettings"
>
Save AI Settings
</v-btn>
<v-snackbar v-model="showAiSuccess" :timeout="4000" color="success">
AI settings saved successfully
</v-snackbar>
<v-snackbar v-model="showAiError" :timeout="6000" color="error">
{{ aiErrorMessage }}
</v-snackbar>
</v-tabs-window-item>
</v-tabs-window>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useNotifications } from '@/composables/useNotifications'
import { bankSyncApi } from '@/services/bankSync'
import { accountsApi } from '@/services/accounts'
import { aiCategorizationApi } from '@/services/aiCategorization'
import api from '@/services/api'
import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCredentials, AiCategorizationSettings, OllamaConnectionTestResult, BankSyncNotification, UserProfile } from '@/types'
const authStore = useAuthStore()
const router = useRouter()
const activeTab = ref('profile')
// Profile
const profile = ref<UserProfile | null>(null)
const profileForm = reactive({ displayName: '', email: '' })
const passwordForm = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' })
const profileSaving = ref(false)
const passwordChanging = ref(false)
const accountDeleting = ref(false)
const showDeleteAccountDialog = ref(false)
const showProfileSuccess = ref(false)
const profileSuccessMessage = ref('')
const showProfileError = ref(false)
const profileErrorMessage = ref('')
async function loadProfile() {
try {
const { data } = await api.get<UserProfile>('/auth/profile')
profile.value = data
profileForm.displayName = data.displayName || ''
profileForm.email = data.email
} catch (err: any) {
profileErrorMessage.value = err.response?.data?.error || 'Failed to load profile'
showProfileError.value = true
}
}
async function saveProfile() {
profileSaving.value = true
try {
const { data } = await api.put<UserProfile>('/auth/profile', {
displayName: profileForm.displayName || null,
email: profileForm.email
})
profile.value = data
profileForm.displayName = data.displayName || ''
profileForm.email = data.email
authStore.setDisplayName(data.displayName)
profileSuccessMessage.value = 'Profile updated'
showProfileSuccess.value = true
} catch (err: any) {
profileErrorMessage.value = err.response?.data?.error || 'Failed to update profile'
showProfileError.value = true
} finally {
profileSaving.value = false
}
}
async function changePassword() {
passwordChanging.value = true
try {
await api.post('/auth/change-password', {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
})
passwordForm.currentPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
profileSuccessMessage.value = 'Password changed successfully'
showProfileSuccess.value = true
} catch (err: any) {
profileErrorMessage.value = err.response?.data?.error || 'Failed to change password'
showProfileError.value = true
} finally {
passwordChanging.value = false
}
}
async function deleteAccount() {
accountDeleting.value = true
try {
await api.delete('/auth/account')
showDeleteAccountDialog.value = false
authStore.logout()
router.push('/login')
} catch (err: any) {
profileErrorMessage.value = err.response?.data?.error || 'Failed to delete account'
showProfileError.value = true
} finally {
accountDeleting.value = false
}
}
const loading = ref(false)
const connections = ref<SyncConnection[]>([])
const accounts = ref<Account[]>([])
const syncIntervals = reactive<Record<string, number>>({})
const syncingConnections = reactive<Record<string, boolean>>({})
const resyncingConnections = reactive<Record<string, boolean>>({})
// Provider-separated computed lists
const simpleFinConnections = computed(() =>
connections.value.filter(c => c.provider === 'SimpleFIN')
)
const plaidConnections = computed(() =>
connections.value.filter(c => c.provider === 'Plaid')
)
// Section-level loading states
const syncingAllSimpleFin = ref(false)
const resyncingAllSimpleFin = ref(false)
const syncingAllPlaid = ref(false)
const simpleFinGlobalInterval = ref(360)
// Plaid credentials
const plaidCredentials = ref<PlaidCredentials>({ isConfigured: false, environment: 'sandbox' })
const plaidForm = reactive({ clientId: '', secret: '', environment: 'sandbox' })
const plaidCredsSaving = ref(false)
const plaidEnvironments = ['sandbox', 'development', 'production']
// Plaid link
const plaidLoading = ref(false)
// SimpleFIN
const showSimpleFinDialog = ref(false)
const simpleFinToken = ref('')
const simpleFinLoading = ref(false)
// Mapping dialog
const showMappingDialog = ref(false)
const mappingSaving = ref(false)
const newConnectionId = ref('')
const newLinkedAccounts = ref<LinkedAccountInfo[]>([])
const mappingSelections = reactive<Record<string, string | null>>({})
const autoCreateSelections = reactive<Record<string, boolean>>({})
// Delete
const showDeleteDialog = ref(false)
const deleteTarget = ref<SyncConnection | null>(null)
const deleteLoading = ref(false)
// Sync result
const showSyncResult = ref(false)
const syncResultMessage = ref('')
// Error display
const showError = ref(false)
const errorMessage = ref('')
// Sync logs
const syncLogs = ref<SyncLogEntry[]>([])
const logsLoading = ref(false)
const logFilterConnectionId = ref<string | null>(null)
const accountOptions = computed(() =>
accounts.value
.filter(a => a.isActive && !a.isClosed)
.map(a => ({ text: `${a.name} (${a.type})`, value: a.id }))
)
const connectionFilterOptions = computed(() =>
connections.value.map(c => ({ text: c.institutionName, value: c.id }))
)
const intervalOptions = [
{ text: '1 hour', value: 60 },
{ text: '3 hours', value: 180 },
{ text: '6 hours', value: 360 },
{ text: '12 hours', value: 720 },
{ text: '24 hours', value: 1440 },
]
const logHeaders = [
{ title: 'Time', key: 'startedAt', sortable: true },
{ title: 'Institution', key: 'institutionName', sortable: true },
{ title: 'Account', key: 'linkedAccountName', sortable: false },
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Imported', key: 'transactionsImported', sortable: false },
{ title: 'Skipped', key: 'transactionsSkipped', sortable: false },
{ title: 'Duration', key: 'duration', sortable: false },
{ title: 'Error', key: 'errorMessage', sortable: false },
]
function statusColor(status: string) {
switch (status) {
case 'Success': return 'success'
case 'PartialSuccess': return 'warning'
case 'Failed': return 'error'
case 'Running': return 'info'
default: return 'grey'
}
}
function formatCurrency(value: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value)
}
async function loadPlaidCredentials() {
try {
const { data } = await bankSyncApi.getPlaidCredentials()
plaidCredentials.value = data
plaidForm.environment = data.environment
} catch {
// Not configured yet
}
}
async function savePlaidCredentials() {
plaidCredsSaving.value = true
try {
const { data } = await bankSyncApi.savePlaidCredentials({
clientId: plaidForm.clientId,
secret: plaidForm.secret,
environment: plaidForm.environment
})
plaidCredentials.value = data
plaidForm.clientId = ''
plaidForm.secret = ''
syncResultMessage.value = 'Plaid credentials saved'
showSyncResult.value = true
} catch (err: any) {
errorMessage.value = err.response?.data?.error || 'Failed to save Plaid credentials'
showError.value = true
} finally {
plaidCredsSaving.value = false
}
}
async function loadConnections() {
loading.value = true
try {
const { data } = await bankSyncApi.getConnections()
connections.value = data
data.forEach(c => { syncIntervals[c.id] = c.syncIntervalMinutes })
// Initialize global SimpleFIN interval from first SimpleFIN connection
const firstSf = data.find(c => c.provider === 'SimpleFIN')
if (firstSf) {
simpleFinGlobalInterval.value = firstSf.syncIntervalMinutes
}
} finally {
loading.value = false
}
}
async function loadAccounts() {
const { data } = await accountsApi.getAll()
accounts.value = data
}
async function loadSyncLogs() {
logsLoading.value = true
try {
const { data } = await bankSyncApi.getSyncLogs(logFilterConnectionId.value || undefined)
syncLogs.value = data
} finally {
logsLoading.value = false
}
}
async function startPlaidLink() {
plaidLoading.value = true
try {
const { data: linkData } = await bankSyncApi.createLinkToken()
if (!linkData.linkToken) {
errorMessage.value = 'Plaid returned an empty link token. Check your Plaid credentials in appsettings.json.'
showError.value = true
plaidLoading.value = false
return
}
// Dynamically load Plaid Link script
await loadPlaidScript()
const plaid = (window as any).Plaid.create({
token: linkData.linkToken,
onSuccess: async (publicToken: string) => {
try {
const { data: conn } = await bankSyncApi.completePlaidLink(publicToken)
connections.value.unshift(conn)
syncIntervals[conn.id] = conn.syncIntervalMinutes
openMappingDialog(conn)
} catch (err: any) {
errorMessage.value = err.response?.data?.error || 'Failed to complete Plaid link'
showError.value = true
}
},
onExit: () => {
plaidLoading.value = false
}
})
plaid.open()
} catch (err: any) {
errorMessage.value = err.response?.data?.error || 'Failed to create Plaid link token. Check your Plaid credentials.'
showError.value = true
plaidLoading.value = false
}
}
function loadPlaidScript(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as any).Plaid) {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://cdn.plaid.com/link/v2/stable/link-initialize.js'
script.onload = () => resolve()
script.onerror = () => reject(new Error('Failed to load Plaid Link script'))
document.head.appendChild(script)
})
}
async function connectSimpleFin() {
simpleFinLoading.value = true
try {
const { data: newConnections } = await bankSyncApi.connectSimpleFin(simpleFinToken.value)
for (const conn of newConnections) {
connections.value.unshift(conn)
syncIntervals[conn.id] = conn.syncIntervalMinutes
}
showSimpleFinDialog.value = false
simpleFinToken.value = ''
if (newConnections.length > 0) {
openMappingDialog(newConnections[0])
}
} catch (err: any) {
errorMessage.value = err.response?.data?.error || 'Failed to connect via SimpleFIN. Check your setup token.'
showError.value = true
} finally {
simpleFinLoading.value = false
}
}
function openMappingDialog(conn: SyncConnection) {
newConnectionId.value = conn.id
newLinkedAccounts.value = conn.linkedAccounts
conn.linkedAccounts.forEach(la => {
mappingSelections[la.id] = null
autoCreateSelections[la.id] = false
})
showMappingDialog.value = true
}
async function saveMappings() {
mappingSaving.value = true
try {
for (const la of newLinkedAccounts.value) {
const accountId = mappingSelections[la.id]
const autoCreate = autoCreateSelections[la.id]
if (accountId || autoCreate) {
await bankSyncApi.linkAccount(newConnectionId.value, {
linkedAccountId: la.id,
accountId: autoCreate ? null : accountId,
autoCreate
})
}
}
showMappingDialog.value = false
await loadConnections()
await loadAccounts()
} finally {
mappingSaving.value = false
}
}
async function updateLinkedAccount(la: LinkedAccountInfo, accountId: string | null) {
await bankSyncApi.updateLinkedAccount(la.id, { accountId, isEnabled: la.isEnabled })
await loadConnections()
}
async function toggleLinkedAccount(la: LinkedAccountInfo, enabled: boolean) {
await bankSyncApi.updateLinkedAccount(la.id, { accountId: la.accountId, isEnabled: enabled })
await loadConnections()
}
async function updateConnectionInterval(conn: SyncConnection) {
await bankSyncApi.updateConnection(conn.id, {
isActive: conn.isActive,
syncIntervalMinutes: syncIntervals[conn.id]
})
}
async function toggleConnection(conn: SyncConnection) {
await bankSyncApi.updateConnection(conn.id, {
isActive: !conn.isActive,
syncIntervalMinutes: conn.syncIntervalMinutes
})
await loadConnections()
}
async function syncNow(conn: SyncConnection) {
syncingConnections[conn.id] = true
try {
await bankSyncApi.syncNow(conn.id)
} catch (err: any) {
syncingConnections[conn.id] = false
errorMessage.value = err.response?.data?.error || 'Sync failed'
showError.value = true
}
}
async function resyncFromScratch(conn: SyncConnection) {
resyncingConnections[conn.id] = true
try {
await bankSyncApi.resetSync(conn.id)
await bankSyncApi.syncNow(conn.id)
syncingConnections[conn.id] = true
} catch (err: any) {
errorMessage.value = err.response?.data?.error || 'Re-sync failed'
showError.value = true
} finally {
resyncingConnections[conn.id] = false
}
}
async function syncAllSimpleFin() {
const active = simpleFinConnections.value.filter(c => c.isActive)
if (active.length === 0) return
syncingAllSimpleFin.value = true
try {
for (const conn of active) {
syncingConnections[conn.id] = true
await bankSyncApi.syncNow(conn.id)
}
} catch (err: any) {
errorMessage.value = err.response?.data?.error || 'SimpleFIN sync failed'
showError.value = true
}
}
async function resyncAllSimpleFin() {
const active = simpleFinConnections.value.filter(c => c.isActive)
if (active.length === 0) return
resyncingAllSimpleFin.value = true
try {
for (const conn of active) {
await bankSyncApi.resetSync(conn.id)
syncingConnections[conn.id] = true
await bankSyncApi.syncNow(conn.id)
}
} catch (err: any) {
errorMessage.value = err.response?.data?.error || 'SimpleFIN re-sync failed'
showError.value = true
} finally {
resyncingAllSimpleFin.value = false
}
}
async function syncAllPlaid() {
const active = plaidConnections.value.filter(c => c.isActive)
if (active.length === 0) return
syncingAllPlaid.value = true
try {
for (const conn of active) {
syncingConnections[conn.id] = true
await bankSyncApi.syncNow(conn.id)
}
} catch (err: any) {
errorMessage.value = err.response?.data?.error || 'Plaid sync failed'
showError.value = true
}
}
async function updateSimpleFinGlobalInterval() {
try {
for (const conn of simpleFinConnections.value) {
syncIntervals[conn.id] = simpleFinGlobalInterval.value
await bankSyncApi.updateConnection(conn.id, {
isActive: conn.isActive,
syncIntervalMinutes: simpleFinGlobalInterval.value
})
}
} catch (err: any) {
errorMessage.value = err.response?.data?.error || 'Failed to update sync interval'
showError.value = true
}
}
// Handle sync results via SignalR notification
const { notifications } = useNotifications()
watch(notifications, (notifs) => {
const latest = notifs[0]
if (latest && !latest.read && latest.type === 'BankSyncComplete') {
const data = latest.data as BankSyncNotification
syncingConnections[data.connectionId] = false
syncResultMessage.value = `Synced ${data.institutionName}: ${data.transactionsImported} imported, ${data.transactionsSkipped} skipped`
showSyncResult.value = true
loadConnections()
// Clear section-level loading if all connections in the group are done
if (syncingAllSimpleFin.value) {
const stillSyncing = simpleFinConnections.value.some(c => syncingConnections[c.id])
if (!stillSyncing) syncingAllSimpleFin.value = false
}
if (syncingAllPlaid.value) {
const stillSyncing = plaidConnections.value.some(c => syncingConnections[c.id])
if (!stillSyncing) syncingAllPlaid.value = false
}
}
})
function confirmDeleteConnection(conn: SyncConnection) {
deleteTarget.value = conn
showDeleteDialog.value = true
}
async function deleteConnection() {
if (!deleteTarget.value) return
deleteLoading.value = true
try {
await bankSyncApi.deleteConnection(deleteTarget.value.id)
connections.value = connections.value.filter(c => c.id !== deleteTarget.value!.id)
showDeleteDialog.value = false
} finally {
deleteLoading.value = false
}
}
// AI Categorization
const aiSettings = reactive<AiCategorizationSettings>({
isEnabled: false,
ollamaUrl: 'http://localhost:11434',
modelName: 'llama3.1:8b',
confidenceThreshold: 0.7,
timeoutSeconds: 30,
updatePayeeDefaults: true,
isChatEnabled: false,
systemPrompt: null,
userPromptTemplate: null,
chatContextSize: 16384,
chatBotName: null
})
const isOllamaConfigured = computed(() =>
!!aiSettings.ollamaUrl?.trim() && !!aiSettings.modelName?.trim()
)
const aiSaving = ref(false)
const aiTesting = ref(false)
const aiTestResult = ref<OllamaConnectionTestResult | null>(null)
const availableModels = ref<string[]>([])
const showAiSuccess = ref(false)
const showAiError = ref(false)
const aiErrorMessage = ref('')
const defaultSystemPrompt = `You are a personal finance transaction categorizer. Classify each transaction into the most appropriate category.
You MUST respond with valid JSON in exactly this format:
{
"classifications": [
{"index": 0, "categoryName": "Groceries", "confidence": 0.9},
{"index": 1, "categoryName": "Utilities", "confidence": 0.85}
]
}
Rules:
- "index" must be the transaction number (integer) from the list below
- "categoryName" must be one of the exact category names listed below
- "confidence" must be a number between 0 and 1
- Include one entry for every transaction
Available categories:
{categories}`
const defaultUserPromptTemplate = `{examples}Classify these uncategorized transactions:
{transactions}`
function resetPromptsToDefaults() {
aiSettings.systemPrompt = defaultSystemPrompt
aiSettings.userPromptTemplate = defaultUserPromptTemplate
}
async function loadAiSettings() {
try {
const { data } = await aiCategorizationApi.getSettings()
Object.assign(aiSettings, data)
aiSettings.systemPrompt = aiSettings.systemPrompt || defaultSystemPrompt
aiSettings.userPromptTemplate = aiSettings.userPromptTemplate || defaultUserPromptTemplate
} catch {
// Not configured yet — defaults are fine
aiSettings.systemPrompt = defaultSystemPrompt
aiSettings.userPromptTemplate = defaultUserPromptTemplate
}
}
async function saveAiSettings() {
aiSaving.value = true
try {
const { data } = await aiCategorizationApi.updateSettings({ ...aiSettings })
Object.assign(aiSettings, data)
showAiSuccess.value = true
} catch (err: any) {
aiErrorMessage.value = err.response?.data?.error || 'Failed to save AI settings'
showAiError.value = true
} finally {
aiSaving.value = false
}
}
async function testAiConnection() {
aiTesting.value = true
aiTestResult.value = null
try {
const { data } = await aiCategorizationApi.testConnection()
aiTestResult.value = data
if (data.availableModels && data.availableModels.length > 0) {
availableModels.value = data.availableModels
}
} catch (err: any) {
aiTestResult.value = { success: false, errorMessage: err.response?.data?.error || 'Connection test failed', availableModels: null }
} finally {
aiTesting.value = false
}
}
onMounted(async () => {
await Promise.all([loadProfile(), loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs(), loadAiSettings()])
})
</script>