feat: Registrierung default AN, Einladungslinks, System-Email
- Registrierung ist standardmaessig aktiviert (erster User = Admin) - Einmal-Registrierungslinks: Admin kann Links generieren die auch bei deaktivierter Registrierung funktionieren, nach Nutzung ungueltig - Optional Link per System-Email versenden - System-SMTP in Admin-Einstellungen konfigurierbar: Server, Port, SSL, Benutzername, Passwort, Absender-Adresse - SMTP-Verbindungstest-Button - Register-Seite akzeptiert ?invite=TOKEN aus der URL Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,14 +6,73 @@
|
||||
|
||||
<!-- App Settings -->
|
||||
<div class="admin-section">
|
||||
<h3>Einstellungen</h3>
|
||||
<h3>Registrierung</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<strong>Oeffentliche Registrierung</strong>
|
||||
<p>Wenn aktiviert, koennen sich neue Benutzer selbst registrieren. Andernfalls kann nur ein Admin neue Benutzer anlegen.</p>
|
||||
<p>Wenn aktiviert, koennen sich neue Benutzer selbst registrieren. Andernfalls kann nur ein Admin neue Benutzer anlegen oder Einladungslinks versenden.</p>
|
||||
</div>
|
||||
<InputSwitch v-model="publicRegistration" @change="saveSettings" />
|
||||
</div>
|
||||
|
||||
<div class="invite-section">
|
||||
<h4>Einladungslink generieren</h4>
|
||||
<p class="hint">Einmal-Links funktionieren auch bei deaktivierter oeffentlicher Registrierung.</p>
|
||||
<div class="invite-row">
|
||||
<InputText v-model="inviteEmail" placeholder="E-Mail zum Versenden (optional)" fluid />
|
||||
<Button label="Link erstellen" icon="pi pi-link" size="small" @click="createInvite" :loading="inviteLoading" />
|
||||
</div>
|
||||
<div v-if="inviteResult" class="invite-result">
|
||||
<div class="invite-url">
|
||||
<code>{{ fullInviteUrl }}</code>
|
||||
<Button icon="pi pi-copy" text size="small" @click="copyInviteLink" />
|
||||
</div>
|
||||
<Message v-if="inviteResult.email_sent" severity="success" :closable="false">
|
||||
Einladung an {{ inviteEmail }} gesendet
|
||||
</Message>
|
||||
<Message v-if="inviteResult.email_sent === false" severity="warn" :closable="false">
|
||||
Link erstellt, aber E-Mail konnte nicht gesendet werden: {{ inviteResult.email_error }}
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Email -->
|
||||
<div class="admin-section">
|
||||
<h3>System-E-Mail (SMTP)</h3>
|
||||
<p class="hint">Wird fuer Einladungslinks und System-Benachrichtigungen verwendet.</p>
|
||||
<div class="smtp-form">
|
||||
<div class="field-row">
|
||||
<div class="field flex-grow">
|
||||
<label>SMTP-Server</label>
|
||||
<InputText v-model="smtpForm.system_smtp_host" placeholder="smtp.beispiel.de" fluid />
|
||||
</div>
|
||||
<div class="field" style="width: 100px">
|
||||
<label>Port</label>
|
||||
<InputText v-model.number="smtpForm.system_smtp_port" type="number" fluid />
|
||||
</div>
|
||||
<div class="field" style="width: 80px">
|
||||
<label>SSL</label>
|
||||
<InputSwitch v-model="smtpForm.system_smtp_ssl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Benutzername</label>
|
||||
<InputText v-model="smtpForm.system_smtp_username" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Passwort {{ smtpPasswordSet ? '(gesetzt - leer lassen = nicht aendern)' : '' }}</label>
|
||||
<Password v-model="smtpForm.system_smtp_password" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Absender-Adresse</label>
|
||||
<InputText v-model="smtpForm.system_email_from" placeholder="noreply@beispiel.de" fluid />
|
||||
</div>
|
||||
<div class="smtp-actions">
|
||||
<Button label="Speichern" icon="pi pi-save" size="small" @click="saveSmtp" />
|
||||
<Button label="Verbindung testen" icon="pi pi-check-circle" size="small" outlined @click="testSmtp" :loading="smtpTesting" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management -->
|
||||
@@ -94,7 +153,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import apiClient from '../api/client'
|
||||
import DataTable from 'primevue/datatable'
|
||||
@@ -111,7 +170,21 @@ import Message from 'primevue/message'
|
||||
const toast = useToast()
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const publicRegistration = ref(false)
|
||||
const publicRegistration = ref(true)
|
||||
|
||||
// Invite links
|
||||
const inviteEmail = ref('')
|
||||
const inviteLoading = ref(false)
|
||||
const inviteResult = ref(null)
|
||||
const fullInviteUrl = computed(() => inviteResult.value ? `${window.location.origin}${inviteResult.value.url}` : '')
|
||||
|
||||
// System SMTP
|
||||
const smtpForm = ref({
|
||||
system_smtp_host: '', system_smtp_port: 587, system_smtp_ssl: true,
|
||||
system_smtp_username: '', system_smtp_password: '', system_email_from: '',
|
||||
})
|
||||
const smtpPasswordSet = ref(false)
|
||||
const smtpTesting = ref(false)
|
||||
|
||||
const showUserDialog = ref(false)
|
||||
const editingUser = ref(null)
|
||||
@@ -139,7 +212,13 @@ async function loadSettings() {
|
||||
try {
|
||||
const res = await apiClient.get('/settings')
|
||||
publicRegistration.value = res.data.public_registration
|
||||
} catch { /* first load, default false */ }
|
||||
smtpForm.value.system_smtp_host = res.data.system_smtp_host || ''
|
||||
smtpForm.value.system_smtp_port = res.data.system_smtp_port || 587
|
||||
smtpForm.value.system_smtp_ssl = res.data.system_smtp_ssl
|
||||
smtpForm.value.system_smtp_username = res.data.system_smtp_username || ''
|
||||
smtpForm.value.system_email_from = res.data.system_email_from || ''
|
||||
smtpPasswordSet.value = res.data.system_smtp_password_set
|
||||
} catch { /* first load, defaults */ }
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
@@ -151,6 +230,54 @@ async function saveSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Invite links ---
|
||||
async function createInvite() {
|
||||
inviteLoading.value = true
|
||||
inviteResult.value = null
|
||||
try {
|
||||
const payload = {}
|
||||
if (inviteEmail.value.trim()) payload.send_to_email = inviteEmail.value.trim()
|
||||
const res = await apiClient.post('/settings/invite', payload)
|
||||
inviteResult.value = res.data
|
||||
toast.add({ severity: 'success', summary: 'Einladungslink erstellt', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
} finally {
|
||||
inviteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyInviteLink() {
|
||||
navigator.clipboard.writeText(fullInviteUrl.value)
|
||||
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
|
||||
}
|
||||
|
||||
// --- System SMTP ---
|
||||
async function saveSmtp() {
|
||||
try {
|
||||
await apiClient.put('/settings', smtpForm.value)
|
||||
toast.add({ severity: 'success', summary: 'SMTP-Einstellungen gespeichert', life: 3000 })
|
||||
await loadSettings()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function testSmtp() {
|
||||
smtpTesting.value = true
|
||||
try {
|
||||
// Save first, then test
|
||||
await apiClient.put('/settings', smtpForm.value)
|
||||
const res = await apiClient.post('/settings/test-email')
|
||||
toast.add({ severity: 'success', summary: res.data.message, life: 5000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'SMTP-Test fehlgeschlagen', detail: err.response?.data?.error, life: 8000 })
|
||||
} finally {
|
||||
smtpTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
function openNewUser() {
|
||||
editingUser.value = null
|
||||
userForm.value = { username: '', email: '', password: '', role: 'user', storage_quota_mb: 5120, is_active: true }
|
||||
@@ -240,4 +367,15 @@ onMounted(() => {
|
||||
.setting-info p { margin: 0; font-size: 0.85rem; color: var(--p-text-muted-color); }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.field-row { display: flex; gap: 0.75rem; align-items: flex-end; }
|
||||
.flex-grow { flex: 1; }
|
||||
.hint { font-size: 0.85rem; color: var(--p-text-muted-color); margin: 0 0 0.75rem; }
|
||||
.invite-section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--p-surface-200); }
|
||||
.invite-section h4 { margin: 0 0 0.25rem; font-size: 0.95rem; }
|
||||
.invite-row { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.invite-result { margin-top: 0.75rem; }
|
||||
.invite-url { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import axios from 'axios'
|
||||
import InputText from 'primevue/inputtext'
|
||||
@@ -81,6 +81,7 @@ import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
@@ -89,8 +90,13 @@ const password = ref('')
|
||||
const password2 = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const inviteToken = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
// Check for invite token in URL
|
||||
inviteToken.value = route.query.invite || ''
|
||||
if (inviteToken.value) return // Invite links always work
|
||||
|
||||
try {
|
||||
const res = await axios.get('/api/auth/registration-status')
|
||||
if (!res.data.allowed) {
|
||||
@@ -109,7 +115,7 @@ async function handleRegister() {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.register(username.value, password.value, email.value || undefined)
|
||||
await auth.register(username.value, password.value, email.value || undefined, inviteToken.value || undefined)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Registrierung fehlgeschlagen'
|
||||
|
||||
Reference in New Issue
Block a user