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 refreshTokenValue = ref<string | null>(null)
|
||||
const username = ref<string | null>(null)
|
||||
const displayName = ref<string | null>(null)
|
||||
const expiresAt = ref<string | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
@@ -15,6 +16,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
token.value = localStorage.getItem('purrse_token')
|
||||
refreshTokenValue.value = localStorage.getItem('purrse_refresh_token')
|
||||
username.value = localStorage.getItem('purrse_username')
|
||||
displayName.value = localStorage.getItem('purrse_display_name')
|
||||
expiresAt.value = localStorage.getItem('purrse_expires_at')
|
||||
}
|
||||
|
||||
@@ -22,10 +24,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
token.value = auth.token
|
||||
refreshTokenValue.value = auth.refreshToken
|
||||
username.value = auth.username
|
||||
displayName.value = auth.displayName
|
||||
expiresAt.value = auth.expiresAt
|
||||
localStorage.setItem('purrse_token', auth.token)
|
||||
localStorage.setItem('purrse_refresh_token', auth.refreshToken)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -45,16 +53,27 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
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() {
|
||||
token.value = null
|
||||
refreshTokenValue.value = null
|
||||
username.value = null
|
||||
displayName.value = null
|
||||
expiresAt.value = null
|
||||
localStorage.removeItem('purrse_token')
|
||||
localStorage.removeItem('purrse_refresh_token')
|
||||
localStorage.removeItem('purrse_username')
|
||||
localStorage.removeItem('purrse_display_name')
|
||||
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
|
||||
expiresAt: 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 {
|
||||
|
||||
@@ -10,206 +10,456 @@
|
||||
<v-tabs-window v-model="activeTab" class="mt-4">
|
||||
<!-- Tab 1: Profile -->
|
||||
<v-tabs-window-item value="profile">
|
||||
<v-card>
|
||||
<v-card-title>Profile</v-card-title>
|
||||
<!-- 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">
|
||||
<!-- 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">
|
||||
<v-btn color="primary" prepend-icon="mdi-bank-plus" @click="startPlaidLink" :loading="plaidLoading" :disabled="!plaidCredentials.isConfigured">
|
||||
Connect via Plaid
|
||||
</v-btn>
|
||||
<v-btn color="secondary" prepend-icon="mdi-link-plus" @click="showSimpleFinDialog = true">
|
||||
Connect via SimpleFIN
|
||||
</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>
|
||||
<!-- 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-chip
|
||||
:color="conn.isActive ? 'success' : 'grey'"
|
||||
<v-btn
|
||||
size="small"
|
||||
class="mr-2"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
prepend-icon="mdi-link-plus"
|
||||
@click="showSimpleFinDialog = true"
|
||||
>
|
||||
{{ conn.isActive ? 'Active' : 'Inactive' }}
|
||||
</v-chip>
|
||||
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="text-body-2 text-medium-emphasis mb-2">
|
||||
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">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-select
|
||||
v-model="syncIntervals[conn.id]"
|
||||
v-model="simpleFinGlobalInterval"
|
||||
:items="intervalOptions"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
label="Sync Interval"
|
||||
label="Sync Interval (all SimpleFIN)"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="max-width: 200px"
|
||||
@update:model-value="() => updateConnectionInterval(conn)"
|
||||
style="max-width: 260px"
|
||||
@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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -525,16 +775,101 @@
|
||||
|
||||
<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 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 router = useRouter()
|
||||
|
||||
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 connections = ref<SyncConnection[]>([])
|
||||
const accounts = ref<Account[]>([])
|
||||
@@ -542,6 +877,20 @@ 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' })
|
||||
@@ -662,6 +1011,11 @@ async function loadConnections() {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
const { notifications } = useNotifications()
|
||||
watch(notifications, (notifs) => {
|
||||
@@ -849,6 +1266,16 @@ watch(notifications, (notifs) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -959,6 +1386,6 @@ async function testAiConnection() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs(), loadAiSettings()])
|
||||
await Promise.all([loadProfile(), loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs(), loadAiSettings()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -43,4 +43,40 @@ public class AuthController : ControllerBase
|
||||
await _authService.RevokeRefreshTokenAsync(userId);
|
||||
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)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(12);
|
||||
@@ -92,7 +140,7 @@ public class AuthService : IAuthService
|
||||
user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(30);
|
||||
_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)
|
||||
|
||||
@@ -2,5 +2,16 @@ namespace Purrse.Core.DTOs;
|
||||
|
||||
public record RegisterRequest(string Username, string Email, 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 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> RefreshTokenAsync(string refreshToken);
|
||||
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 string Username { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -11,6 +11,7 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
builder.ToTable("users");
|
||||
builder.HasKey(u => u.Id);
|
||||
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.PasswordHash).HasMaxLength(255).IsRequired();
|
||||
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")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
|
||||
Reference in New Issue
Block a user