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:
Stefan Hacker 2026-04-11 15:41:42 +02:00
parent 042a067e81
commit 113fe7140f
5 changed files with 278 additions and 12 deletions

View File

@ -76,7 +76,7 @@ def registration_status():
"""Check if public registration is allowed."""
from app.models.settings import AppSettings
is_first_user = User.query.count() == 0
public_registration = AppSettings.get_bool('public_registration', default=False)
public_registration = AppSettings.get_bool('public_registration', default=True)
return jsonify({
'allowed': is_first_user or public_registration,
'is_first_user': is_first_user,
@ -87,9 +87,19 @@ def registration_status():
def register():
from app.models.settings import AppSettings
# Check if registration is allowed
is_first_user = User.query.count() == 0
if not is_first_user and not AppSettings.get_bool('public_registration', default=False):
# Check invite token (works even if public registration is off)
invite_token = request.args.get('invite') or (request.get_json() or {}).get('invite_token')
valid_invite = False
if invite_token:
from app.models.settings import AppSettings as _S
stored = _S.get(f'invite_{invite_token}', '')
if stored == 'valid':
valid_invite = True
# Check if registration is allowed
if not is_first_user and not valid_invite and not AppSettings.get_bool('public_registration', default=True):
return jsonify({'error': 'Oeffentliche Registrierung ist deaktiviert'}), 403
data = request.get_json()
@ -129,6 +139,10 @@ def register():
db.session.add(user)
db.session.commit()
# Invalidate invite token if used
if valid_invite and invite_token:
AppSettings.set(f'invite_{invite_token}', 'used')
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)

View File

@ -138,7 +138,13 @@ def delete_user(user_id):
@admin_required
def get_settings():
return jsonify({
'public_registration': AppSettings.get_bool('public_registration', default=False),
'public_registration': AppSettings.get_bool('public_registration', default=True),
'system_smtp_host': AppSettings.get('system_smtp_host', ''),
'system_smtp_port': int(AppSettings.get('system_smtp_port', '587')),
'system_smtp_ssl': AppSettings.get_bool('system_smtp_ssl', default=True),
'system_smtp_username': AppSettings.get('system_smtp_username', ''),
'system_smtp_password_set': bool(AppSettings.get('system_smtp_password', '')),
'system_email_from': AppSettings.get('system_email_from', ''),
}), 200
@ -148,9 +154,110 @@ def update_settings():
data = request.get_json()
if 'public_registration' in data:
AppSettings.set('public_registration', str(data['public_registration']).lower())
for key in ['system_smtp_host', 'system_smtp_port', 'system_smtp_ssl',
'system_smtp_username', 'system_email_from']:
if key in data:
AppSettings.set(key, str(data[key]))
if 'system_smtp_password' in data and data['system_smtp_password']:
AppSettings.set('system_smtp_password', data['system_smtp_password'])
return jsonify({'message': 'Einstellungen gespeichert'}), 200
@api_bp.route('/settings/test-email', methods=['POST'])
@admin_required
def test_system_email():
"""Test system SMTP connection."""
import smtplib
host = AppSettings.get('system_smtp_host', '')
port = int(AppSettings.get('system_smtp_port', '587'))
ssl = AppSettings.get_bool('system_smtp_ssl', default=True)
username = AppSettings.get('system_smtp_username', '')
password = AppSettings.get('system_smtp_password', '')
from_addr = AppSettings.get('system_email_from', '')
if not host or not username or not password:
return jsonify({'error': 'SMTP-Einstellungen unvollstaendig'}), 400
try:
if ssl and port == 465:
server = smtplib.SMTP_SSL(host, port, timeout=10)
else:
server = smtplib.SMTP(host, port, timeout=10)
server.starttls()
server.login(username, password)
server.quit()
return jsonify({'message': 'SMTP-Verbindung erfolgreich'}), 200
except Exception as e:
return jsonify({'error': f'Verbindungsfehler: {str(e)}'}), 400
# --- Invite Links ---
@api_bp.route('/settings/invite', methods=['POST'])
@admin_required
def create_invite_link():
"""Generate a one-time registration link."""
import secrets
token = secrets.token_urlsafe(32)
AppSettings.set(f'invite_{token}', 'valid')
data = request.get_json() or {}
send_email = data.get('send_to_email', '')
result = {
'token': token,
'url': f'/register?invite={token}',
}
# Optionally send via system email
if send_email:
import smtplib
from email.mime.text import MIMEText
host = AppSettings.get('system_smtp_host', '')
port = int(AppSettings.get('system_smtp_port', '587'))
ssl = AppSettings.get_bool('system_smtp_ssl', default=True)
username = AppSettings.get('system_smtp_username', '')
password = AppSettings.get('system_smtp_password', '')
from_addr = AppSettings.get('system_email_from', '')
if not host or not password:
return jsonify({**result, 'email_sent': False,
'email_error': 'System-Email nicht konfiguriert'}), 200
# Build full URL from request
base_url = request.host_url.rstrip('/')
full_url = f'{base_url}/register?invite={token}'
body = (
f'Du wurdest zur Mini-Cloud eingeladen!\n\n'
f'Klicke auf folgenden Link, um dich zu registrieren:\n'
f'{full_url}\n\n'
f'Dieser Link ist nur einmal verwendbar.'
)
msg = MIMEText(body, 'plain', 'utf-8')
msg['From'] = from_addr or username
msg['To'] = send_email
msg['Subject'] = 'Einladung zur Mini-Cloud'
try:
if ssl and port == 465:
server = smtplib.SMTP_SSL(host, port, timeout=10)
else:
server = smtplib.SMTP(host, port, timeout=10)
server.starttls()
server.login(username, password)
server.sendmail(from_addr or username, [send_email], msg.as_string())
server.quit()
result['email_sent'] = True
except Exception as e:
result['email_sent'] = False
result['email_error'] = str(e)
return jsonify(result), 201
# --- User search (for sharing dialogs) ---
@api_bp.route('/users/search', methods=['GET'])

View File

@ -19,9 +19,10 @@ export const useAuthStore = defineStore('auth', () => {
return response.data
}
async function register(username, password, email) {
async function register(username, password, email, inviteToken) {
const payload = { username, password }
if (email) payload.email = email
if (inviteToken) payload.invite_token = inviteToken
const response = await apiClient.post('/auth/register', payload)
user.value = response.data.user
accessToken.value = response.data.access_token

View File

@ -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>

View File

@ -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'