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:
parent
113fe7140f
commit
7220a2ef75
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue