Private
Public Access
1
0

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:
Catherine Renelle
2026-02-09 22:44:52 -05:00
parent 0562bb97b7
commit 1ceeca0fb6
12 changed files with 2250 additions and 175 deletions
+20 -1
View File
@@ -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 }
}) })
+19
View File
@@ -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 {
+599 -172
View File
@@ -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();
}
} }
+49 -1
View File
@@ -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)
+12 -1
View File
@@ -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);
} }
+1
View File
@@ -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)