feat: Admin kann Email-Konten pro Benutzer verwalten + Benutzersuche

- Admin-Benutzer-Dialog hat jetzt zwei Tabs: Allgemein + E-Mail-Konten
- Im E-Mail-Konten-Tab: Konten fuer jeden Benutzer hinzufuegen,
  bearbeiten und loeschen (ohne sich als Benutzer einloggen zu muessen)
- Benutzersuche in der Benutzerverwaltung (filtert nach Name und Email)
- Backend: /admin/users/<id>/email-accounts GET/POST und
  /admin/email-accounts/<id> PUT/DELETE Endpunkte

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-11 17:35:51 +02:00
parent 113fe7140f
commit 7220a2ef75
2 changed files with 305 additions and 30 deletions

View File

@ -10,9 +10,10 @@ from datetime import datetime, timezone
from flask import request, jsonify, current_app
from app.api import api_bp
from app.api.auth import token_required
from app.api.auth import token_required, admin_required
from app.extensions import db
from app.models.email_account import EmailAccount
from app.models.user import User
from app.services.crypto_service import encrypt_field, decrypt_field
@ -487,3 +488,88 @@ def delete_message(account_id, uid):
return jsonify({'message': 'Nachricht geloescht'}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
# --- Admin: manage email accounts for any user ---
@api_bp.route('/admin/users/<int:user_id>/email-accounts', methods=['GET'])
@admin_required
def admin_list_email_accounts(user_id):
user = db.session.get(User, user_id)
if not user:
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
accounts = EmailAccount.query.filter_by(user_id=user_id)\
.order_by(EmailAccount.sort_order).all()
return jsonify([a.to_dict() for a in accounts]), 200
@api_bp.route('/admin/users/<int:user_id>/email-accounts', methods=['POST'])
@admin_required
def admin_create_email_account(user_id):
user = db.session.get(User, user_id)
if not user:
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
data = request.get_json()
required = ['display_name', 'email_address', 'imap_host', 'smtp_host', 'username', 'password']
for field in required:
if not data.get(field):
return jsonify({'error': f'{field} erforderlich'}), 400
enc_key = request.headers.get('X-Encryption-Key', '')
if not enc_key:
return jsonify({'error': 'Verschluesselungs-Key erforderlich (X-Encryption-Key Header)'}), 400
account = EmailAccount(
user_id=user_id,
display_name=data['display_name'],
email_address=data['email_address'],
imap_host=data['imap_host'],
imap_port=data.get('imap_port', 993),
imap_ssl=data.get('imap_ssl', True),
smtp_host=data['smtp_host'],
smtp_port=data.get('smtp_port', 587),
smtp_ssl=data.get('smtp_ssl', True),
username=data['username'],
password_encrypted=encrypt_field(data['password'], enc_key),
is_default=data.get('is_default', False),
sort_order=data.get('sort_order', 0),
)
db.session.add(account)
db.session.commit()
return jsonify(account.to_dict()), 201
@api_bp.route('/admin/email-accounts/<int:account_id>', methods=['PUT'])
@admin_required
def admin_update_email_account(account_id):
account = db.session.get(EmailAccount, account_id)
if not account:
return jsonify({'error': 'Konto nicht gefunden'}), 404
data = request.get_json()
for field in ['display_name', 'email_address', 'imap_host', 'imap_port',
'imap_ssl', 'smtp_host', 'smtp_port', 'smtp_ssl',
'username', 'is_default', 'sort_order']:
if field in data:
setattr(account, field, data[field])
if 'password' in data and data['password']:
enc_key = request.headers.get('X-Encryption-Key', '')
if enc_key:
account.password_encrypted = encrypt_field(data['password'], enc_key)
db.session.commit()
return jsonify(account.to_dict()), 200
@api_bp.route('/admin/email-accounts/<int:account_id>', methods=['DELETE'])
@admin_required
def admin_delete_email_account(account_id):
account = db.session.get(EmailAccount, account_id)
if not account:
return jsonify({'error': 'Konto nicht gefunden'}), 404
db.session.delete(account)
db.session.commit()
return jsonify({'message': 'E-Mail-Konto geloescht'}), 200

View File

@ -79,10 +79,13 @@
<div class="admin-section">
<div class="section-header">
<h3>Benutzerverwaltung</h3>
<Button icon="pi pi-user-plus" label="Benutzer hinzufuegen" size="small" @click="openNewUser" />
<div class="header-actions">
<InputText v-model="userSearch" placeholder="Benutzer suchen..." class="search-input" />
<Button icon="pi pi-user-plus" label="Benutzer hinzufuegen" size="small" @click="openNewUser" />
</div>
</div>
<DataTable :value="users" :loading="loading" striped-rows>
<DataTable :value="filteredUsers" :loading="loading" striped-rows>
<Column field="id" header="ID" style="width: 60px" />
<Column field="username" header="Benutzername" />
<Column field="email" header="E-Mail">
@ -109,38 +112,123 @@
</div>
<!-- New/Edit User Dialog -->
<Dialog v-model:visible="showUserDialog" :header="editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'" modal :style="{ width: '450px' }">
<div class="field">
<label>Benutzername</label>
<InputText v-model="userForm.username" fluid :disabled="!!editingUser" />
</div>
<div class="field">
<label>E-Mail (optional)</label>
<InputText v-model="userForm.email" type="email" fluid />
</div>
<div class="field">
<label>{{ editingUser ? 'Neues Passwort (leer lassen = nicht aendern)' : 'Passwort' }}</label>
<Password v-model="userForm.password" :feedback="false" toggle-mask fluid />
</div>
<div class="field">
<label>Rolle</label>
<Select v-model="userForm.role" :options="roleOptions" optionLabel="label" optionValue="value" fluid />
</div>
<div class="field">
<label>Speicher-Quota (MB)</label>
<InputText v-model.number="userForm.storage_quota_mb" type="number" fluid />
</div>
<div v-if="editingUser" class="field">
<label>Aktiv</label>
<InputSwitch v-model="userForm.is_active" />
</div>
<Message v-if="userError" severity="error" :closable="false">{{ userError }}</Message>
<Dialog v-model:visible="showUserDialog" :header="editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'" modal :style="{ width: '600px' }">
<TabView>
<TabPanel header="Allgemein">
<div class="field">
<label>Benutzername</label>
<InputText v-model="userForm.username" fluid :disabled="!!editingUser" />
</div>
<div class="field">
<label>E-Mail (optional)</label>
<InputText v-model="userForm.email" type="email" fluid />
</div>
<div class="field">
<label>{{ editingUser ? 'Neues Passwort (leer lassen = nicht aendern)' : 'Passwort' }}</label>
<Password v-model="userForm.password" :feedback="false" toggle-mask fluid />
</div>
<div class="field">
<label>Rolle</label>
<Select v-model="userForm.role" :options="roleOptions" optionLabel="label" optionValue="value" fluid />
</div>
<div class="field">
<label>Speicher-Quota (MB)</label>
<InputText v-model.number="userForm.storage_quota_mb" type="number" fluid />
</div>
<div v-if="editingUser" class="field">
<label>Aktiv</label>
<InputSwitch v-model="userForm.is_active" />
</div>
<Message v-if="userError" severity="error" :closable="false">{{ userError }}</Message>
</TabPanel>
<TabPanel v-if="editingUser" header="E-Mail-Konten">
<div class="email-tab">
<div class="section-header">
<span>Konten fuer {{ editingUser.username }}</span>
<Button icon="pi pi-plus" label="Konto hinzufuegen" size="small" @click="openNewEmailAccount" />
</div>
<div v-if="!userEmailAccounts.length" class="empty-hint-small">
Keine E-Mail-Konten konfiguriert.
</div>
<div v-for="acc in userEmailAccounts" :key="acc.id" class="email-account-row">
<div class="acc-info">
<strong>{{ acc.display_name }}</strong>
<span>{{ acc.email_address }}</span>
</div>
<div class="acc-actions">
<Button icon="pi pi-pencil" text size="small" @click="openEditEmailAccount(acc)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="deleteEmailAccount(acc)" />
</div>
</div>
</div>
</TabPanel>
</TabView>
<template #footer>
<Button label="Abbrechen" text @click="showUserDialog = false" />
<Button :label="editingUser ? 'Speichern' : 'Erstellen'" @click="saveUser" :loading="userSaving" />
</template>
</Dialog>
<!-- Email Account Sub-Dialog -->
<Dialog v-model:visible="showEmailDialog" :header="editingEmailAcc ? 'E-Mail-Konto bearbeiten' : 'E-Mail-Konto hinzufuegen'" modal :style="{ width: '550px' }">
<div class="field">
<label>Anzeigename</label>
<InputText v-model="emailForm.display_name" placeholder="z.B. Arbeit, Privat" fluid autofocus />
</div>
<div class="field">
<label>E-Mail-Adresse</label>
<InputText v-model="emailForm.email_address" type="email" fluid />
</div>
<h4 class="section-title">Posteingang (IMAP)</h4>
<div class="field-row">
<div class="field flex-grow">
<label>Server</label>
<InputText v-model="emailForm.imap_host" fluid />
</div>
<div class="field" style="width: 90px">
<label>Port</label>
<InputText v-model.number="emailForm.imap_port" type="number" fluid />
</div>
<div class="field" style="width: 70px">
<label>SSL</label>
<InputSwitch v-model="emailForm.imap_ssl" />
</div>
</div>
<h4 class="section-title">Postausgang (SMTP)</h4>
<div class="field-row">
<div class="field flex-grow">
<label>Server</label>
<InputText v-model="emailForm.smtp_host" fluid />
</div>
<div class="field" style="width: 90px">
<label>Port</label>
<InputText v-model.number="emailForm.smtp_port" type="number" fluid />
</div>
<div class="field" style="width: 70px">
<label>SSL</label>
<InputSwitch v-model="emailForm.smtp_ssl" />
</div>
</div>
<h4 class="section-title">Anmeldedaten</h4>
<div class="field">
<label>Benutzername</label>
<InputText v-model="emailForm.username" fluid />
</div>
<div class="field">
<label>{{ editingEmailAcc ? 'Passwort (leer = nicht aendern)' : 'Passwort' }}</label>
<Password v-model="emailForm.password" :feedback="false" toggle-mask fluid />
</div>
<Message v-if="emailError" severity="error" :closable="false">{{ emailError }}</Message>
<template #footer>
<Button label="Abbrechen" text @click="showEmailDialog = false" />
<Button :label="editingEmailAcc ? 'Speichern' : 'Hinzufuegen'" @click="saveEmailAccount" :loading="emailSaving" />
</template>
</Dialog>
<!-- Delete Confirm -->
<Dialog v-model:visible="showDeleteConfirm" header="Benutzer loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du den Benutzer <strong>{{ deleteTarget?.username }}</strong> wirklich loeschen?</p>
@ -156,6 +244,7 @@
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import apiClient from '../api/client'
import { useAuthStore } from '../stores/auth'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
@ -166,8 +255,11 @@ import Password from 'primevue/password'
import Select from 'primevue/select'
import InputSwitch from 'primevue/inputswitch'
import Message from 'primevue/message'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
const toast = useToast()
const auth = useAuthStore()
const users = ref([])
const loading = ref(false)
const publicRegistration = ref(true)
@ -193,9 +285,30 @@ const userError = ref('')
const userSaving = ref(false)
const roleOptions = [{ label: 'Benutzer', value: 'user' }, { label: 'Admin', value: 'admin' }]
const userSearch = ref('')
const filteredUsers = computed(() => {
if (!userSearch.value) return users.value
const q = userSearch.value.toLowerCase()
return users.value.filter(u =>
u.username.toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q)
)
})
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
// Email accounts for edited user
const userEmailAccounts = ref([])
const showEmailDialog = ref(false)
const editingEmailAcc = ref(null)
const emailForm = ref({
display_name: '', email_address: '', imap_host: '', imap_port: 993, imap_ssl: true,
smtp_host: '', smtp_port: 587, smtp_ssl: true, username: '', password: '',
})
const emailError = ref('')
const emailSaving = ref(false)
async function loadUsers() {
loading.value = true
try {
@ -280,12 +393,13 @@ async function testSmtp() {
// --- Users ---
function openNewUser() {
editingUser.value = null
userEmailAccounts.value = []
userForm.value = { username: '', email: '', password: '', role: 'user', storage_quota_mb: 5120, is_active: true }
userError.value = ''
showUserDialog.value = true
}
function openEditUser(user) {
async function openEditUser(user) {
editingUser.value = user
userForm.value = {
username: user.username,
@ -297,6 +411,69 @@ function openEditUser(user) {
}
userError.value = ''
showUserDialog.value = true
// Load email accounts for this user
try {
const res = await apiClient.get(`/admin/users/${user.id}/email-accounts`)
userEmailAccounts.value = res.data
} catch { userEmailAccounts.value = [] }
}
// --- Email accounts for user ---
function openNewEmailAccount() {
editingEmailAcc.value = null
emailForm.value = {
display_name: '', email_address: '', imap_host: '', imap_port: 993, imap_ssl: true,
smtp_host: '', smtp_port: 587, smtp_ssl: true, username: '', password: '',
}
emailError.value = ''
showEmailDialog.value = true
}
function openEditEmailAccount(acc) {
editingEmailAcc.value = acc
emailForm.value = {
display_name: acc.display_name, email_address: acc.email_address,
imap_host: acc.imap_host, imap_port: acc.imap_port, imap_ssl: acc.imap_ssl,
smtp_host: acc.smtp_host, smtp_port: acc.smtp_port, smtp_ssl: acc.smtp_ssl,
username: acc.username, password: '',
}
emailError.value = ''
showEmailDialog.value = true
}
async function saveEmailAccount() {
emailError.value = ''
emailSaving.value = true
try {
const payload = { ...emailForm.value }
const headers = { 'X-Encryption-Key': auth.masterKeySalt || 'admin-key' }
if (editingEmailAcc.value) {
if (!payload.password) delete payload.password
await apiClient.put(`/admin/email-accounts/${editingEmailAcc.value.id}`, payload, { headers })
} else {
if (!payload.password) { emailError.value = 'Passwort erforderlich'; return }
await apiClient.post(`/admin/users/${editingUser.value.id}/email-accounts`, payload, { headers })
}
showEmailDialog.value = false
const res = await apiClient.get(`/admin/users/${editingUser.value.id}/email-accounts`)
userEmailAccounts.value = res.data
toast.add({ severity: 'success', summary: 'E-Mail-Konto gespeichert', life: 3000 })
} catch (err) {
emailError.value = err.response?.data?.error || 'Fehler'
} finally {
emailSaving.value = false
}
}
async function deleteEmailAccount(acc) {
try {
await apiClient.delete(`/admin/email-accounts/${acc.id}`)
userEmailAccounts.value = userEmailAccounts.value.filter(a => a.id !== acc.id)
toast.add({ severity: 'success', summary: 'Konto geloescht', life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function saveUser() {
@ -378,4 +555,16 @@ onMounted(() => {
.invite-url code { font-size: 0.8rem; word-break: break-all; background: var(--p-surface-100); padding: 0.375rem 0.5rem; border-radius: 4px; }
.smtp-form { max-width: 550px; }
.smtp-actions { display: flex; gap: 0.5rem; }
.header-actions { display: flex; gap: 0.5rem; align-items: center; }
.search-input { width: 220px; }
.email-tab { }
.email-account-row {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100);
}
.acc-info { display: flex; flex-direction: column; gap: 0.125rem; }
.acc-info span { font-size: 0.825rem; color: var(--p-text-muted-color); }
.acc-actions { display: flex; }
.empty-hint-small { padding: 1rem; color: var(--p-text-muted-color); font-size: 0.875rem; text-align: center; }
.section-title { margin: 1rem 0 0.5rem; font-size: 0.95rem; font-weight: 600; }
</style>