Add profile management (display name, email, password change, account deletion)
Full-stack profile section: DisplayName column on User model, profile CRUD endpoints on AuthController, and complete Settings > Profile tab UI with profile editing, password change, account info, and account deletion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const token = ref<string | null>(null)
|
const token = ref<string | null>(null)
|
||||||
const refreshTokenValue = ref<string | null>(null)
|
const refreshTokenValue = ref<string | null>(null)
|
||||||
const username = ref<string | null>(null)
|
const username = ref<string | null>(null)
|
||||||
|
const displayName = ref<string | null>(null)
|
||||||
const expiresAt = ref<string | null>(null)
|
const expiresAt = ref<string | null>(null)
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
@@ -15,6 +16,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
token.value = localStorage.getItem('purrse_token')
|
token.value = localStorage.getItem('purrse_token')
|
||||||
refreshTokenValue.value = localStorage.getItem('purrse_refresh_token')
|
refreshTokenValue.value = localStorage.getItem('purrse_refresh_token')
|
||||||
username.value = localStorage.getItem('purrse_username')
|
username.value = localStorage.getItem('purrse_username')
|
||||||
|
displayName.value = localStorage.getItem('purrse_display_name')
|
||||||
expiresAt.value = localStorage.getItem('purrse_expires_at')
|
expiresAt.value = localStorage.getItem('purrse_expires_at')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,10 +24,16 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
token.value = auth.token
|
token.value = auth.token
|
||||||
refreshTokenValue.value = auth.refreshToken
|
refreshTokenValue.value = auth.refreshToken
|
||||||
username.value = auth.username
|
username.value = auth.username
|
||||||
|
displayName.value = auth.displayName
|
||||||
expiresAt.value = auth.expiresAt
|
expiresAt.value = auth.expiresAt
|
||||||
localStorage.setItem('purrse_token', auth.token)
|
localStorage.setItem('purrse_token', auth.token)
|
||||||
localStorage.setItem('purrse_refresh_token', auth.refreshToken)
|
localStorage.setItem('purrse_refresh_token', auth.refreshToken)
|
||||||
localStorage.setItem('purrse_username', auth.username)
|
localStorage.setItem('purrse_username', auth.username)
|
||||||
|
if (auth.displayName) {
|
||||||
|
localStorage.setItem('purrse_display_name', auth.displayName)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('purrse_display_name')
|
||||||
|
}
|
||||||
localStorage.setItem('purrse_expires_at', auth.expiresAt)
|
localStorage.setItem('purrse_expires_at', auth.expiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,16 +53,27 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
setAuth(data)
|
setAuth(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDisplayName(name: string | null) {
|
||||||
|
displayName.value = name
|
||||||
|
if (name) {
|
||||||
|
localStorage.setItem('purrse_display_name', name)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('purrse_display_name')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
token.value = null
|
token.value = null
|
||||||
refreshTokenValue.value = null
|
refreshTokenValue.value = null
|
||||||
username.value = null
|
username.value = null
|
||||||
|
displayName.value = null
|
||||||
expiresAt.value = null
|
expiresAt.value = null
|
||||||
localStorage.removeItem('purrse_token')
|
localStorage.removeItem('purrse_token')
|
||||||
localStorage.removeItem('purrse_refresh_token')
|
localStorage.removeItem('purrse_refresh_token')
|
||||||
localStorage.removeItem('purrse_username')
|
localStorage.removeItem('purrse_username')
|
||||||
|
localStorage.removeItem('purrse_display_name')
|
||||||
localStorage.removeItem('purrse_expires_at')
|
localStorage.removeItem('purrse_expires_at')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, username, expiresAt, isAuthenticated, initialize, login, register, refreshToken, logout }
|
return { token, username, displayName, expiresAt, isAuthenticated, initialize, login, register, refreshToken, setDisplayName, logout }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ export interface AuthResponse {
|
|||||||
refreshToken: string
|
refreshToken: string
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
username: string
|
username: string
|
||||||
|
displayName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
username: string
|
||||||
|
displayName: string | null
|
||||||
|
email: string
|
||||||
|
createdAt: string
|
||||||
|
lastLoginAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
displayName?: string | null
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangePasswordRequest {
|
||||||
|
currentPassword: string
|
||||||
|
newPassword: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
|
|||||||
@@ -10,206 +10,456 @@
|
|||||||
<v-tabs-window v-model="activeTab" class="mt-4">
|
<v-tabs-window v-model="activeTab" class="mt-4">
|
||||||
<!-- Tab 1: Profile -->
|
<!-- Tab 1: Profile -->
|
||||||
<v-tabs-window-item value="profile">
|
<v-tabs-window-item value="profile">
|
||||||
<v-card>
|
<!-- Profile Info Card -->
|
||||||
<v-card-title>Profile</v-card-title>
|
<v-card class="mb-6">
|
||||||
|
<v-card-title>Profile Information</v-card-title>
|
||||||
<v-card-text>
|
<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
|
<v-text-field
|
||||||
:model-value="authStore.username"
|
:model-value="authStore.username"
|
||||||
label="Username"
|
label="Username"
|
||||||
readonly
|
readonly
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
class="mb-4"
|
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-text>
|
||||||
</v-card>
|
</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>
|
</v-tabs-window-item>
|
||||||
|
|
||||||
<!-- Tab 2: Bank Connections -->
|
<!-- Tab 2: Bank Connections -->
|
||||||
<v-tabs-window-item value="connections">
|
<v-tabs-window-item value="connections">
|
||||||
<!-- Plaid API Credentials -->
|
|
||||||
<v-card class="mb-4">
|
|
||||||
<v-card-title class="d-flex align-center">
|
|
||||||
<v-icon class="mr-2">mdi-key</v-icon>
|
|
||||||
Plaid API Credentials
|
|
||||||
<v-chip v-if="plaidCredentials.isConfigured" color="success" size="small" class="ml-2">Configured</v-chip>
|
|
||||||
<v-chip v-else color="warning" size="small" class="ml-2">Not configured</v-chip>
|
|
||||||
</v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
<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>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<div class="d-flex justify-end mb-4 ga-2">
|
<!-- SimpleFIN Section -->
|
||||||
<v-btn color="primary" prepend-icon="mdi-bank-plus" @click="startPlaidLink" :loading="plaidLoading" :disabled="!plaidCredentials.isConfigured">
|
<v-card class="mb-6">
|
||||||
Connect via Plaid
|
<v-card-title class="d-flex align-center flex-wrap ga-2">
|
||||||
</v-btn>
|
<v-icon class="mr-1">mdi-link-variant</v-icon>
|
||||||
<v-btn color="secondary" prepend-icon="mdi-link-plus" @click="showSimpleFinDialog = true">
|
SimpleFIN
|
||||||
Connect via SimpleFIN
|
<v-chip color="green" size="small">{{ simpleFinConnections.length }}</v-chip>
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-alert v-if="connections.length === 0 && !loading" type="info" variant="tonal" class="mb-4">
|
|
||||||
No bank connections yet. Configure your API credentials above, then click a button to connect your bank.
|
|
||||||
</v-alert>
|
|
||||||
|
|
||||||
<v-card v-for="conn in connections" :key="conn.id" class="mb-4">
|
|
||||||
<v-card-title class="d-flex align-center">
|
|
||||||
<v-icon class="mr-2">{{ conn.provider === 'Plaid' ? 'mdi-bank' : 'mdi-link-variant' }}</v-icon>
|
|
||||||
{{ conn.institutionName }}
|
|
||||||
<v-chip :color="conn.provider === 'Plaid' ? 'blue' : 'green'" size="small" class="ml-2">
|
|
||||||
{{ conn.provider }}
|
|
||||||
</v-chip>
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-chip
|
<v-btn
|
||||||
:color="conn.isActive ? 'success' : 'grey'"
|
|
||||||
size="small"
|
size="small"
|
||||||
class="mr-2"
|
variant="tonal"
|
||||||
|
color="secondary"
|
||||||
|
prepend-icon="mdi-link-plus"
|
||||||
|
@click="showSimpleFinDialog = true"
|
||||||
>
|
>
|
||||||
{{ conn.isActive ? 'Active' : 'Inactive' }}
|
Connect
|
||||||
</v-chip>
|
</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-title>
|
||||||
|
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="text-body-2 text-medium-emphasis mb-2">
|
<div class="d-flex align-center mb-4">
|
||||||
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }}
|
|
||||||
<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-select
|
||||||
v-model="syncIntervals[conn.id]"
|
v-model="simpleFinGlobalInterval"
|
||||||
:items="intervalOptions"
|
:items="intervalOptions"
|
||||||
item-title="text"
|
item-title="text"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
label="Sync Interval"
|
label="Sync Interval (all SimpleFIN)"
|
||||||
density="compact"
|
density="compact"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
hide-details
|
hide-details
|
||||||
style="max-width: 200px"
|
style="max-width: 260px"
|
||||||
@update:model-value="() => updateConnectionInterval(conn)"
|
@update:model-value="updateSimpleFinGlobalInterval"
|
||||||
/>
|
/>
|
||||||
<v-spacer />
|
|
||||||
<v-btn
|
|
||||||
variant="tonal"
|
|
||||||
color="primary"
|
|
||||||
prepend-icon="mdi-sync"
|
|
||||||
:loading="syncingConnections[conn.id]"
|
|
||||||
@click="syncNow(conn)"
|
|
||||||
>
|
|
||||||
Sync Now
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
v-if="conn.provider === 'SimpleFIN'"
|
|
||||||
variant="tonal"
|
|
||||||
color="secondary"
|
|
||||||
prepend-icon="mdi-refresh"
|
|
||||||
:loading="resyncingConnections[conn.id]"
|
|
||||||
@click="resyncFromScratch(conn)"
|
|
||||||
>
|
|
||||||
Re-sync
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
variant="tonal"
|
|
||||||
:color="conn.isActive ? 'warning' : 'success'"
|
|
||||||
@click="toggleConnection(conn)"
|
|
||||||
>
|
|
||||||
{{ conn.isActive ? 'Disable' : 'Enable' }}
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
variant="tonal"
|
|
||||||
color="error"
|
|
||||||
prepend-icon="mdi-delete"
|
|
||||||
@click="confirmDeleteConnection(conn)"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
</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-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
@@ -525,16 +775,101 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useNotifications } from '@/composables/useNotifications'
|
import { useNotifications } from '@/composables/useNotifications'
|
||||||
import { bankSyncApi } from '@/services/bankSync'
|
import { bankSyncApi } from '@/services/bankSync'
|
||||||
import { accountsApi } from '@/services/accounts'
|
import { accountsApi } from '@/services/accounts'
|
||||||
import { aiCategorizationApi } from '@/services/aiCategorization'
|
import { aiCategorizationApi } from '@/services/aiCategorization'
|
||||||
import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCredentials, AiCategorizationSettings, OllamaConnectionTestResult, BankSyncNotification } from '@/types'
|
import api from '@/services/api'
|
||||||
|
import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCredentials, AiCategorizationSettings, OllamaConnectionTestResult, BankSyncNotification, UserProfile } from '@/types'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const activeTab = ref('connections')
|
const activeTab = ref('connections')
|
||||||
|
|
||||||
|
// 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 loading = ref(false)
|
||||||
const connections = ref<SyncConnection[]>([])
|
const connections = ref<SyncConnection[]>([])
|
||||||
const accounts = ref<Account[]>([])
|
const accounts = ref<Account[]>([])
|
||||||
@@ -542,6 +877,20 @@ const syncIntervals = reactive<Record<string, number>>({})
|
|||||||
const syncingConnections = reactive<Record<string, boolean>>({})
|
const syncingConnections = reactive<Record<string, boolean>>({})
|
||||||
const resyncingConnections = 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
|
// Plaid credentials
|
||||||
const plaidCredentials = ref<PlaidCredentials>({ isConfigured: false, environment: 'sandbox' })
|
const plaidCredentials = ref<PlaidCredentials>({ isConfigured: false, environment: 'sandbox' })
|
||||||
const plaidForm = reactive({ clientId: '', secret: '', environment: 'sandbox' })
|
const plaidForm = reactive({ clientId: '', secret: '', environment: 'sandbox' })
|
||||||
@@ -662,6 +1011,11 @@ async function loadConnections() {
|
|||||||
const { data } = await bankSyncApi.getConnections()
|
const { data } = await bankSyncApi.getConnections()
|
||||||
connections.value = data
|
connections.value = data
|
||||||
data.forEach(c => { syncIntervals[c.id] = c.syncIntervalMinutes })
|
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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -839,6 +1193,69 @@ async function resyncFromScratch(conn: SyncConnection) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Handle sync results via SignalR notification
|
||||||
const { notifications } = useNotifications()
|
const { notifications } = useNotifications()
|
||||||
watch(notifications, (notifs) => {
|
watch(notifications, (notifs) => {
|
||||||
@@ -849,6 +1266,16 @@ watch(notifications, (notifs) => {
|
|||||||
syncResultMessage.value = `Synced ${data.institutionName}: ${data.transactionsImported} imported, ${data.transactionsSkipped} skipped`
|
syncResultMessage.value = `Synced ${data.institutionName}: ${data.transactionsImported} imported, ${data.transactionsSkipped} skipped`
|
||||||
showSyncResult.value = true
|
showSyncResult.value = true
|
||||||
loadConnections()
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -959,6 +1386,6 @@ async function testAiConnection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs(), loadAiSettings()])
|
await Promise.all([loadProfile(), loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs(), loadAiSettings()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -43,4 +43,40 @@ public class AuthController : ControllerBase
|
|||||||
await _authService.RevokeRefreshTokenAsync(userId);
|
await _authService.RevokeRefreshTokenAsync(userId);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet("profile")]
|
||||||
|
public async Task<ActionResult<UserProfileResponse>> GetProfile()
|
||||||
|
{
|
||||||
|
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
var profile = await _authService.GetProfileAsync(userId);
|
||||||
|
return Ok(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPut("profile")]
|
||||||
|
public async Task<ActionResult<UserProfileResponse>> UpdateProfile(UpdateProfileRequest request)
|
||||||
|
{
|
||||||
|
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
var profile = await _authService.UpdateProfileAsync(userId, request);
|
||||||
|
return Ok(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("change-password")]
|
||||||
|
public async Task<ActionResult> ChangePassword(ChangePasswordRequest request)
|
||||||
|
{
|
||||||
|
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
await _authService.ChangePasswordAsync(userId, request);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpDelete("account")]
|
||||||
|
public async Task<ActionResult> DeleteAccount()
|
||||||
|
{
|
||||||
|
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
await _authService.DeleteAccountAsync(userId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,54 @@ public class AuthService : IAuthService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileResponse> GetProfileAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FindAsync(userId)
|
||||||
|
?? throw new KeyNotFoundException("User not found");
|
||||||
|
|
||||||
|
return new UserProfileResponse(user.Username, user.DisplayName, user.Email, user.CreatedAt, user.LastLoginAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileResponse> UpdateProfileAsync(Guid userId, UpdateProfileRequest request)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FindAsync(userId)
|
||||||
|
?? throw new KeyNotFoundException("User not found");
|
||||||
|
|
||||||
|
if (request.DisplayName != null)
|
||||||
|
user.DisplayName = request.DisplayName;
|
||||||
|
|
||||||
|
if (request.Email != null && request.Email != user.Email)
|
||||||
|
{
|
||||||
|
if (await _db.Users.AnyAsync(u => u.Email == request.Email && u.Id != userId))
|
||||||
|
throw new InvalidOperationException("Email already in use");
|
||||||
|
user.Email = request.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new UserProfileResponse(user.Username, user.DisplayName, user.Email, user.CreatedAt, user.LastLoginAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ChangePasswordAsync(Guid userId, ChangePasswordRequest request)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FindAsync(userId)
|
||||||
|
?? throw new KeyNotFoundException("User not found");
|
||||||
|
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
|
||||||
|
throw new InvalidOperationException("Current password is incorrect");
|
||||||
|
|
||||||
|
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAccountAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FindAsync(userId)
|
||||||
|
?? throw new KeyNotFoundException("User not found");
|
||||||
|
|
||||||
|
_db.Users.Remove(user);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private AuthResponse GenerateAuthResponse(User user)
|
private AuthResponse GenerateAuthResponse(User user)
|
||||||
{
|
{
|
||||||
var expiresAt = DateTime.UtcNow.AddHours(12);
|
var expiresAt = DateTime.UtcNow.AddHours(12);
|
||||||
@@ -92,7 +140,7 @@ public class AuthService : IAuthService
|
|||||||
user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(30);
|
user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(30);
|
||||||
_db.SaveChanges();
|
_db.SaveChanges();
|
||||||
|
|
||||||
return new AuthResponse(token, refreshToken, expiresAt, user.Username);
|
return new AuthResponse(token, refreshToken, expiresAt, user.Username, user.DisplayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GenerateJwtToken(User user, DateTime expiresAt)
|
private string GenerateJwtToken(User user, DateTime expiresAt)
|
||||||
|
|||||||
@@ -2,5 +2,16 @@ namespace Purrse.Core.DTOs;
|
|||||||
|
|
||||||
public record RegisterRequest(string Username, string Email, string Password);
|
public record RegisterRequest(string Username, string Email, string Password);
|
||||||
public record LoginRequest(string Username, string Password);
|
public record LoginRequest(string Username, string Password);
|
||||||
public record AuthResponse(string Token, string RefreshToken, DateTime ExpiresAt, string Username);
|
public record AuthResponse(string Token, string RefreshToken, DateTime ExpiresAt, string Username, string? DisplayName);
|
||||||
public record RefreshRequest(string RefreshToken);
|
public record RefreshRequest(string RefreshToken);
|
||||||
|
|
||||||
|
public record UserProfileResponse(
|
||||||
|
string Username,
|
||||||
|
string? DisplayName,
|
||||||
|
string Email,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? LastLoginAt);
|
||||||
|
|
||||||
|
public record UpdateProfileRequest(string? DisplayName, string? Email);
|
||||||
|
|
||||||
|
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||||
|
|||||||
@@ -8,4 +8,8 @@ public interface IAuthService
|
|||||||
Task<AuthResponse> LoginAsync(LoginRequest request);
|
Task<AuthResponse> LoginAsync(LoginRequest request);
|
||||||
Task<AuthResponse> RefreshTokenAsync(string refreshToken);
|
Task<AuthResponse> RefreshTokenAsync(string refreshToken);
|
||||||
Task RevokeRefreshTokenAsync(Guid userId);
|
Task RevokeRefreshTokenAsync(Guid userId);
|
||||||
|
Task<UserProfileResponse> GetProfileAsync(Guid userId);
|
||||||
|
Task<UserProfileResponse> UpdateProfileAsync(Guid userId, UpdateProfileRequest request);
|
||||||
|
Task ChangePasswordAsync(Guid userId, ChangePasswordRequest request);
|
||||||
|
Task DeleteAccountAsync(Guid userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public class User
|
|||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string Username { get; set; } = string.Empty;
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
public string PasswordHash { get; set; } = string.Empty;
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
|
|||||||
builder.ToTable("users");
|
builder.ToTable("users");
|
||||||
builder.HasKey(u => u.Id);
|
builder.HasKey(u => u.Id);
|
||||||
builder.Property(u => u.Username).HasMaxLength(100).IsRequired();
|
builder.Property(u => u.Username).HasMaxLength(100).IsRequired();
|
||||||
|
builder.Property(u => u.DisplayName).HasMaxLength(100);
|
||||||
builder.Property(u => u.Email).HasMaxLength(255).IsRequired();
|
builder.Property(u => u.Email).HasMaxLength(255).IsRequired();
|
||||||
builder.Property(u => u.PasswordHash).HasMaxLength(255).IsRequired();
|
builder.Property(u => u.PasswordHash).HasMaxLength(255).IsRequired();
|
||||||
builder.HasIndex(u => u.Username).IsUnique();
|
builder.HasIndex(u => u.Username).IsUnique();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Purrse.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDisplayName : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "DisplayName",
|
||||||
|
table: "users",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DisplayName",
|
||||||
|
table: "users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -998,6 +998,10 @@ namespace Purrse.Data.Migrations
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(255)
|
.HasMaxLength(255)
|
||||||
|
|||||||
Reference in New Issue
Block a user