feat: Vor-/Nachname, geteilte Listen zeigen Eigentuemer

Backend:
- User.first_name / User.last_name (nullable, Auto-Migrate fuegt sie an)
  full_name/display_name als Properties + in to_dict
- TaskList.owner-Relationship ergaenzt (fehlte, daher wurden geteilte
  Listen beim Empfaenger nicht korrekt aufgeloest)
- /auth/me GET + PUT (Profil bearbeiten: Vorname, Nachname, E-Mail)
- /users/search findet jetzt auch nach Vor-/Nachname und liefert
  full_name/display_name mit
- list_tasklists/list_calendars/list_addressbooks liefern
  owner_full_name und owner_display_name

Frontend:
- Sidebars bei Kontakten/Kalender/Aufgaben: "(geteilt von <Voller Name>)"
  mit Fallback auf Username
- User-Search-Popup zeigt vollen Namen neben Username
- SettingsView: Vorname/Nachname/E-Mail bearbeiten

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-14 15:34:22 +02:00
parent e4dd555bd1
commit 4d67819cac
9 changed files with 138 additions and 13 deletions
+8 -2
View File
@@ -20,7 +20,10 @@
<input type="checkbox" v-model="visibleCalendars[cal.id]" @change="refreshEvents" />
<div class="calendar-color" :style="{ background: cal.color }"></div>
<span class="cal-name">{{ cal.name }}</span>
<span v-if="cal.permission !== 'owner'" class="shared-label">(geteilt)</span>
<span v-if="cal.permission !== 'owner'" class="shared-label"
:title="`Geteilt von ${cal.owner_display_name || cal.owner_name}`">
(geteilt von {{ cal.owner_display_name || cal.owner_name }})
</span>
<Button icon="pi pi-ellipsis-v" text size="small" @click.stop="openCalendarMenu(cal)" />
</div>
</aside>
@@ -276,7 +279,9 @@
<div v-if="shareSearchResults.length" class="user-search-popup">
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
@click="shareUsername = u.username; shareSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
<i class="pi pi-user"></i>
<span>{{ u.username }}</span>
<small v-if="u.full_name" class="user-fullname">{{ u.full_name }}</small>
</div>
</div>
</div>
@@ -1292,6 +1297,7 @@ onUnmounted(() => {
max-height: 160px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; display: flex; gap: 0.5rem; align-items: center; }
.user-result:hover { background: var(--p-primary-50); }
.user-fullname { color: var(--p-text-muted-color); font-size: 0.75rem; margin-left: auto; }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; flex-wrap: wrap; }
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
+8 -2
View File
@@ -20,7 +20,10 @@
@click="selectBook(book.id)">
<span class="book-color" :style="{ background: book.color }"></span>
<span class="book-name">{{ book.name }}</span>
<span v-if="book.permission !== 'owner'" class="shared-label">(geteilt)</span>
<span v-if="book.permission !== 'owner'" class="shared-label"
:title="`Geteilt von ${book.owner_display_name || book.owner_name}`">
(geteilt von {{ book.owner_display_name || book.owner_name }})
</span>
<span class="count">{{ book.contact_count }}</span>
<Button icon="pi pi-ellipsis-v" text size="small" class="book-menu"
@click.stop="openBookMenu(book)" />
@@ -112,7 +115,9 @@
<div v-if="shareSearchResults.length" class="user-search-popup">
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
@click="shareUsername = u.username; shareSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
<i class="pi pi-user"></i>
<span>{{ u.username }}</span>
<small v-if="u.full_name" class="user-fullname">{{ u.full_name }}</small>
</div>
</div>
</div>
@@ -875,6 +880,7 @@ watch(selectedBookId, loadContacts)
box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; }
.user-result:hover { background: var(--p-primary-50); }
.user-fullname { color: var(--p-text-muted-color); font-size: 0.75rem; margin-left: auto; }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; flex-wrap: wrap; }
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
+55 -4
View File
@@ -12,15 +12,31 @@
<span class="label">Benutzername:</span>
<span>{{ auth.user?.username }}</span>
</div>
<div class="info-row">
<span class="label">E-Mail:</span>
<span>{{ auth.user?.email || 'Nicht angegeben' }}</span>
</div>
<div class="info-row">
<span class="label">Rolle:</span>
<Tag :value="auth.user?.role" :severity="auth.user?.role === 'admin' ? 'danger' : 'info'" />
</div>
</div>
<p class="hint" style="margin:0.75rem 0 0.5rem;font-size:0.8rem;color:var(--p-text-muted-color)">
Vor- und Nachname werden anderen Benutzern angezeigt, wenn du etwas mit ihnen teilst.
</p>
<form @submit.prevent="saveProfile" class="profile-form">
<div class="field-row">
<div class="field">
<label>Vorname</label>
<InputText v-model="profile.first_name" fluid />
</div>
<div class="field">
<label>Nachname</label>
<InputText v-model="profile.last_name" fluid />
</div>
</div>
<div class="field">
<label>E-Mail</label>
<InputText v-model="profile.email" type="email" fluid />
</div>
<Button type="submit" label="Profil speichern" :loading="profileLoading" size="small" />
</form>
</div>
<!-- Change Password -->
@@ -192,6 +208,36 @@ function downloadClient(client) {
window.location.href = `/api/clients/${client.platform}/download`
}
// --- Profile (Vorname/Nachname/E-Mail) ---
const profile = ref({ first_name: '', last_name: '', email: '' })
const profileLoading = ref(false)
async function loadProfile() {
try {
const res = await apiClient.get('/auth/me')
profile.value = {
first_name: res.data.first_name || '',
last_name: res.data.last_name || '',
email: res.data.email || '',
}
auth.user = { ...auth.user, ...res.data }
} catch { /* ignore */ }
}
async function saveProfile() {
profileLoading.value = true
try {
const res = await apiClient.put('/auth/me', profile.value)
auth.user = { ...auth.user, ...res.data }
toast.add({ severity: 'success', summary: 'Profil gespeichert', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler',
detail: err.response?.data?.error || err.message, life: 4000 })
} finally {
profileLoading.value = false
}
}
// --- Password change ---
const currentPassword = ref('')
const newPassword = ref('')
@@ -334,6 +380,7 @@ async function doDeleteAccount() {
onMounted(async () => {
loadAccounts()
loadProfile()
try {
const res = await apiClient.get('/clients')
availableClients.value = res.data.clients
@@ -352,6 +399,10 @@ onMounted(async () => {
.section-header h3 { margin: 0; }
.settings-info { display: flex; flex-direction: column; gap: 0.5rem; }
.info-row { display: flex; align-items: center; gap: 0.5rem; }
.profile-form { display: flex; flex-direction: column; gap: 0.5rem; max-width: 540px; }
.profile-form .field-row { display: flex; gap: 0.75rem; }
.profile-form .field-row .field { flex: 1; }
.profile-form .field label { display: block; font-size: 0.8rem; margin-bottom: 0.25rem; }
.info-row .label { font-weight: 500; min-width: 120px; }
.password-form { max-width: 400px; }
.password-form .field { margin-bottom: 1rem; }
+8 -2
View File
@@ -21,7 +21,10 @@
@click="selectedListId = tl.id">
<span class="list-color" :style="{ background: tl.color }"></span>
<span class="list-name">{{ tl.name }}</span>
<span v-if="tl.permission !== 'owner'" class="shared-label">(geteilt)</span>
<span v-if="tl.permission !== 'owner'" class="shared-label"
:title="`Geteilt von ${tl.owner_display_name || tl.owner_name}`">
(geteilt von {{ tl.owner_display_name || tl.owner_name }})
</span>
<span class="count">{{ tl.task_count }}</span>
<Button icon="pi pi-ellipsis-v" text size="small" class="list-menu"
@click.stop="openListMenu(tl)" />
@@ -119,7 +122,9 @@
<div v-if="shareSearchResults.length" class="user-search-popup">
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
@click="shareUsername = u.username; shareSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
<i class="pi pi-user"></i>
<span>{{ u.username }}</span>
<small v-if="u.full_name" class="user-fullname">{{ u.full_name }}</small>
</div>
</div>
</div>
@@ -686,6 +691,7 @@ watch(selectedListId, loadTasks)
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem;
display: flex; gap: 0.5rem; align-items: center; }
.user-result:hover { background: var(--p-primary-50); }
.user-fullname { color: var(--p-text-muted-color); font-size: 0.75rem; margin-left: auto; }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; flex-wrap: wrap; }
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }