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:
parent
042a067e81
commit
113fe7140f
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue