feat: Share-Dialog Fix, User-Sharing, Admin-Benutzerverwaltung, Registrierungs-Toggle

- Fix: Share-Dialog oeffnet sich jetzt auch bei bereits geteilten Dateien
- Neu: Dateien/Ordner direkt mit anderen Benutzern teilen (Lesen/Schreiben/Admin)
- Neu: Benutzersuche im Share-Dialog, bestehende Freigaben anzeigen/entfernen
- Neu: Admin kann Benutzer ueber die Weboberflaeche anlegen
- Neu: Admin kann Benutzer bearbeiten (Rolle, Quota, aktiv/inaktiv) und loeschen
- Neu: Schieberegler fuer oeffentliche Registrierung in den Admin-Einstellungen
- Neu: Register-Link auf Login-Seite nur sichtbar wenn Registrierung erlaubt
- Neu: Register-Seite leitet um wenn Registrierung deaktiviert
- Neu: AppSettings-Model fuer persistente App-Konfiguration
- Neu: /api/users/search Endpunkt fuer Benutzersuche in Share-Dialogen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-11 15:26:04 +02:00
parent e7170b7cc8
commit 35099de2c5
8 changed files with 459 additions and 34 deletions

View File

@ -71,8 +71,27 @@ def admin_required(f):
return decorated
@api_bp.route('/auth/registration-status', methods=['GET'])
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)
return jsonify({
'allowed': is_first_user or public_registration,
'is_first_user': is_first_user,
}), 200
@api_bp.route('/auth/register', methods=['POST'])
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):
return jsonify({'error': 'Oeffentliche Registrierung ist deaktiviert'}), 403
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten gesendet'}), 400

View File

@ -1,9 +1,12 @@
import os
from flask import request, jsonify
from app.api import api_bp
from app.api.auth import admin_required, token_required
from app.extensions import db
from app.models.user import User
from app.models.settings import AppSettings
@api_bp.route('/users', methods=['GET'])
@ -13,6 +16,51 @@ def list_users():
return jsonify([u.to_dict(include_email=True) for u in users]), 200
@api_bp.route('/users', methods=['POST'])
@admin_required
def create_user():
"""Admin creates a new user."""
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten gesendet'}), 400
username = data.get('username', '').strip()
password = data.get('password', '')
email = data.get('email', '').strip() or None
role = data.get('role', 'user')
if not username or not password:
return jsonify({'error': 'Benutzername und Passwort erforderlich'}), 400
if len(username) < 3:
return jsonify({'error': 'Benutzername muss mindestens 3 Zeichen lang sein'}), 400
if len(password) < 8:
return jsonify({'error': 'Passwort muss mindestens 8 Zeichen lang sein'}), 400
if role not in ('admin', 'user'):
return jsonify({'error': 'Rolle muss "admin" oder "user" sein'}), 400
if User.query.filter_by(username=username).first():
return jsonify({'error': 'Benutzername bereits vergeben'}), 409
if email and User.query.filter_by(email=email).first():
return jsonify({'error': 'Email-Adresse bereits vergeben'}), 409
user = User(
username=username,
email=email,
role=role,
master_key_salt=os.urandom(32),
storage_quota_mb=data.get('storage_quota_mb', 5120),
)
user.set_password(password)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict(include_email=True)), 201
@api_bp.route('/users/<int:user_id>', methods=['GET'])
@admin_required
def get_user(user_id):
@ -41,7 +89,6 @@ def update_user(user_id):
user.email = email
if 'role' in data and data['role'] in ('admin', 'user'):
# Prevent removing last admin
if user.role == 'admin' and data['role'] == 'user':
admin_count = User.query.filter_by(role='admin', is_active=True).count()
if admin_count <= 1:
@ -85,6 +132,46 @@ def delete_user(user_id):
return jsonify({'message': 'Benutzer geloescht'}), 200
# --- App Settings ---
@api_bp.route('/settings', methods=['GET'])
@admin_required
def get_settings():
return jsonify({
'public_registration': AppSettings.get_bool('public_registration', default=False),
}), 200
@api_bp.route('/settings', methods=['PUT'])
@admin_required
def update_settings():
data = request.get_json()
if 'public_registration' in data:
AppSettings.set('public_registration', str(data['public_registration']).lower())
return jsonify({'message': 'Einstellungen gespeichert'}), 200
# --- User search (for sharing dialogs) ---
@api_bp.route('/users/search', methods=['GET'])
@token_required
def search_users():
"""Search users by username - for sharing dialogs."""
query = request.args.get('q', '').strip()
if len(query) < 2:
return jsonify([]), 200
users = User.query.filter(
User.username.ilike(f'%{query}%'),
User.id != request.current_user.id,
User.is_active == True,
).limit(10).all()
return jsonify([{'id': u.id, 'username': u.username} for u in users]), 200
# --- Change password (non-admin, own account) ---
@api_bp.route('/auth/change-password', methods=['POST'])
@token_required
def change_password():

View File

@ -4,6 +4,7 @@ from app.models.calendar import Calendar, CalendarEvent, CalendarShare
from app.models.contact import AddressBook, Contact, AddressBookShare
from app.models.email_account import EmailAccount
from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare
from app.models.settings import AppSettings
__all__ = [
'User',
@ -12,4 +13,5 @@ __all__ = [
'AddressBook', 'Contact', 'AddressBookShare',
'EmailAccount',
'PasswordFolder', 'PasswordEntry', 'PasswordShare',
'AppSettings',
]

View File

@ -0,0 +1,30 @@
from app.extensions import db
class AppSettings(db.Model):
__tablename__ = 'app_settings'
key = db.Column(db.String(100), primary_key=True)
value = db.Column(db.Text, nullable=False)
@staticmethod
def get(key, default=''):
setting = db.session.get(AppSettings, key)
return setting.value if setting else default
@staticmethod
def set(key, value):
setting = db.session.get(AppSettings, key)
if setting:
setting.value = str(value)
else:
setting = AppSettings(key=key, value=str(value))
db.session.add(setting)
db.session.commit()
@staticmethod
def get_bool(key, default=False):
val = AppSettings.get(key, '')
if val == '':
return default
return val.lower() in ('true', '1', 'yes')

View File

@ -4,12 +4,31 @@
<h2>Administration</h2>
</div>
<!-- App Settings -->
<div class="admin-section">
<h3>Benutzerverwaltung</h3>
<h3>Einstellungen</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>
</div>
<InputSwitch v-model="publicRegistration" @change="saveSettings" />
</div>
</div>
<!-- User Management -->
<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>
<DataTable :value="users" :loading="loading" striped-rows>
<Column field="id" header="ID" style="width: 60px" />
<Column field="username" header="Benutzername" />
<Column field="email" header="E-Mail" />
<Column field="email" header="E-Mail">
<template #body="{ data }">{{ data.email || '-' }}</template>
</Column>
<Column field="role" header="Rolle">
<template #body="{ data }">
<Tag :value="data.role" :severity="data.role === 'admin' ? 'danger' : 'info'" />
@ -21,32 +40,88 @@
</template>
</Column>
<Column field="storage_quota_mb" header="Quota (MB)" />
<Column header="Aktionen" style="width: 100px">
<Column header="Aktionen" style="width: 120px">
<template #body="{ data }">
<Button
icon="pi pi-pencil"
text
rounded
size="small"
@click="editUser(data)"
/>
<Button icon="pi pi-pencil" text rounded size="small" @click="openEditUser(data)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="confirmDeleteUser(data)" />
</template>
</Column>
</DataTable>
</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>
<template #footer>
<Button label="Abbrechen" text @click="showUserDialog = false" />
<Button :label="editingUser ? 'Speichern' : 'Erstellen'" @click="saveUser" :loading="userSaving" />
</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>
<template #footer>
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
<Button label="Loeschen" severity="danger" @click="doDeleteUser" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import apiClient from '../api/client'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Select from 'primevue/select'
import InputSwitch from 'primevue/inputswitch'
import Message from 'primevue/message'
const toast = useToast()
const users = ref([])
const loading = ref(false)
const publicRegistration = ref(false)
const showUserDialog = ref(false)
const editingUser = ref(null)
const userForm = ref({ username: '', email: '', password: '', role: 'user', storage_quota_mb: 5120, is_active: true })
const userError = ref('')
const userSaving = ref(false)
const roleOptions = [{ label: 'Benutzer', value: 'user' }, { label: 'Admin', value: 'admin' }]
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
async function loadUsers() {
loading.value = true
@ -54,22 +129,115 @@ async function loadUsers() {
const response = await apiClient.get('/users')
users.value = response.data
} catch (err) {
console.error('Fehler beim Laden der Benutzer:', err)
toast.add({ severity: 'error', summary: 'Fehler beim Laden', life: 5000 })
} finally {
loading.value = false
}
}
function editUser(user) {
// TODO: Edit dialog in Phase 8
console.log('Edit user:', user)
async function loadSettings() {
try {
const res = await apiClient.get('/settings')
publicRegistration.value = res.data.public_registration
} catch { /* first load, default false */ }
}
onMounted(loadUsers)
async function saveSettings() {
try {
await apiClient.put('/settings', { public_registration: publicRegistration.value })
toast.add({ severity: 'success', summary: 'Einstellungen gespeichert', life: 2000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function openNewUser() {
editingUser.value = null
userForm.value = { username: '', email: '', password: '', role: 'user', storage_quota_mb: 5120, is_active: true }
userError.value = ''
showUserDialog.value = true
}
function openEditUser(user) {
editingUser.value = user
userForm.value = {
username: user.username,
email: user.email || '',
password: '',
role: user.role,
storage_quota_mb: user.storage_quota_mb,
is_active: user.is_active,
}
userError.value = ''
showUserDialog.value = true
}
async function saveUser() {
userError.value = ''
userSaving.value = true
try {
if (editingUser.value) {
const payload = { ...userForm.value }
if (!payload.password) delete payload.password
delete payload.username
await apiClient.put(`/users/${editingUser.value.id}`, payload)
toast.add({ severity: 'success', summary: 'Benutzer aktualisiert', life: 3000 })
} else {
if (!userForm.value.password) {
userError.value = 'Passwort erforderlich'
return
}
await apiClient.post('/users', userForm.value)
toast.add({ severity: 'success', summary: 'Benutzer erstellt', life: 3000 })
}
showUserDialog.value = false
await loadUsers()
} catch (err) {
userError.value = err.response?.data?.error || 'Fehler beim Speichern'
} finally {
userSaving.value = false
}
}
function confirmDeleteUser(user) {
deleteTarget.value = user
showDeleteConfirm.value = true
}
async function doDeleteUser() {
if (!deleteTarget.value) return
try {
await apiClient.delete(`/users/${deleteTarget.value.id}`)
showDeleteConfirm.value = false
toast.add({ severity: 'success', summary: 'Benutzer geloescht', life: 3000 })
await loadUsers()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
onMounted(() => {
loadUsers()
loadSettings()
})
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header h2 { margin: 0 0 1.5rem; }
.admin-section {
background: var(--p-surface-0); border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem;
}
.admin-section h3 { margin: 0 0 1rem; font-size: 1.125rem; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.section-header h3 { margin: 0; }
.setting-row {
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: 0.75rem 0;
}
.setting-info { flex: 1; }
.setting-info strong { display: block; margin-bottom: 0.25rem; }
.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; }
</style>

View File

@ -107,27 +107,56 @@
</Dialog>
<!-- Share Dialog -->
<Dialog v-model:visible="showShare" header="Teilen" modal :style="{ width: '500px' }">
<Dialog v-model:visible="showShare" header="Teilen" modal :style="{ width: '550px' }">
<div v-if="shareFile" class="share-content">
<h4>{{ shareFile.name }}</h4>
<div class="share-form">
<div class="field">
<label>Passwort (optional)</label>
<Password v-model="sharePassword" :feedback="false" toggle-mask fluid />
<!-- User Sharing -->
<div class="share-section">
<h5>Mit Benutzer teilen</h5>
<div class="user-share-row">
<InputText v-model="shareUserQuery" placeholder="Benutzername suchen..." fluid @input="searchUsers" />
<Select v-model="shareUserPermission" :options="userPermOptions" optionLabel="label" optionValue="value" />
<Button label="Teilen" size="small" @click="shareWithUser" :disabled="!selectedShareUser" />
</div>
<div class="field">
<label>Ablaufdatum (optional)</label>
<InputText v-model="shareExpiry" type="date" fluid />
<div v-if="userSearchResults.length" class="user-search-results">
<div v-for="u in userSearchResults" :key="u.id"
class="user-result" :class="{ selected: selectedShareUser?.id === u.id }"
@click="selectedShareUser = u; shareUserQuery = u.username; userSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
</div>
</div>
<div v-if="filePermissions.length" class="existing-shares">
<div v-for="perm in filePermissions" :key="perm.id" class="share-perm-item">
<i class="pi pi-user"></i>
<span>{{ perm.username }}</span>
<Tag :value="permLabel(perm.permission)" size="small" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeUserShare(perm.id)" />
</div>
</div>
<Button label="Link erstellen" icon="pi pi-link" @click="createShare" :loading="shareLoading" />
</div>
<div v-if="shareLinks.length" class="existing-links">
<h4>Bestehende Links</h4>
<!-- Link Sharing -->
<div class="share-section">
<h5>Freigabe-Link erstellen</h5>
<div class="share-form">
<div class="field">
<label>Passwort (optional)</label>
<Password v-model="sharePassword" :feedback="false" toggle-mask fluid />
</div>
<div class="field">
<label>Ablaufdatum (optional)</label>
<InputText v-model="shareExpiry" type="date" fluid />
</div>
<Button label="Link erstellen" icon="pi pi-link" @click="createShare" :loading="shareLoading" />
</div>
</div>
<div v-if="shareLinks.length" class="share-section">
<h5>Bestehende Links</h5>
<div v-for="link in shareLinks" :key="link.id" class="share-link-item">
<div class="link-info">
<code>{{ window.location.origin }}/share/{{ link.token }}</code>
<code>{{ currentOrigin }}/share/{{ link.token }}</code>
<small>
{{ link.download_count }} Downloads
<template v-if="link.expires_at"> | Bis {{ formatDate(link.expires_at) }}</template>
@ -160,6 +189,7 @@ import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useFilesStore } from '../stores/files'
import { useToast } from 'primevue/usetoast'
import apiClient from '../api/client'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
@ -167,6 +197,7 @@ import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Tag from 'primevue/tag'
import Select from 'primevue/select'
const route = useRoute()
const router = useRouter()
@ -184,6 +215,13 @@ const shareFile = ref(null)
const sharePassword = ref('')
const shareExpiry = ref('')
const shareLinks = ref([])
const filePermissions = ref([])
const shareUserQuery = ref('')
const selectedShareUser = ref(null)
const shareUserPermission = ref('read')
const userSearchResults = ref([])
const userPermOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Schreiben', value: 'write' }, { label: 'Admin', value: 'admin' }]
const currentOrigin = window.location.origin
const shareLoading = ref(false)
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
@ -297,15 +335,69 @@ async function doRename() {
}
}
function permLabel(perm) {
return { read: 'Lesen', write: 'Schreiben', admin: 'Admin' }[perm] || perm
}
async function openShare(data) {
shareFile.value = data
sharePassword.value = ''
shareExpiry.value = ''
shareUserQuery.value = ''
selectedShareUser.value = null
userSearchResults.value = []
showShare.value = true
// Load existing links and permissions in parallel
try {
shareLinks.value = await filesStore.getShareLinks(data.id)
const [links, perms] = await Promise.all([
filesStore.getShareLinks(data.id),
apiClient.get(`/files/${data.id}/permissions`).then(r => r.data).catch(() => []),
])
shareLinks.value = links
filePermissions.value = perms
} catch {
shareLinks.value = []
filePermissions.value = []
}
}
let searchTimeout = null
function searchUsers() {
selectedShareUser.value = null
clearTimeout(searchTimeout)
if (shareUserQuery.value.length < 2) { userSearchResults.value = []; return }
searchTimeout = setTimeout(async () => {
try {
const res = await apiClient.get('/users/search', { params: { q: shareUserQuery.value } })
userSearchResults.value = res.data
} catch { userSearchResults.value = [] }
}, 300)
}
async function shareWithUser() {
if (!selectedShareUser.value || !shareFile.value) return
try {
await apiClient.post(`/files/${shareFile.value.id}/permissions`, {
user_id: selectedShareUser.value.id,
permission: shareUserPermission.value,
})
toast.add({ severity: 'success', summary: `Mit ${selectedShareUser.value.username} geteilt`, life: 3000 })
shareUserQuery.value = ''
selectedShareUser.value = null
const res = await apiClient.get(`/files/${shareFile.value.id}/permissions`)
filePermissions.value = res.data
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function removeUserShare(permId) {
if (!shareFile.value) return
try {
await apiClient.delete(`/files/${shareFile.value.id}/permissions/${permId}`)
filePermissions.value = filePermissions.value.filter(p => p.id !== permId)
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
@ -395,8 +487,16 @@ onMounted(() => {
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.share-content h4 { margin: 0 0 1rem; }
.share-form { margin-bottom: 1.5rem; }
.existing-links { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; }
.share-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--p-surface-200); }
.share-section:last-child { border-bottom: none; }
.share-section h5 { margin: 0 0 0.75rem; font-size: 0.9rem; }
.share-form { }
.user-share-row { display: flex; gap: 0.5rem; align-items: flex-start; }
.user-search-results { border: 1px solid var(--p-surface-200); border-radius: 6px; margin-top: 0.25rem; max-height: 150px; overflow-y: auto; }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
.user-result:hover, .user-result.selected { background: var(--p-primary-50); }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
.share-link-item {
display: flex; justify-content: space-between; align-items: center;
padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100);

View File

@ -43,7 +43,7 @@
/>
</form>
<div class="auth-footer">
<div v-if="registrationAllowed" class="auth-footer">
<router-link to="/register">Noch kein Konto? Registrieren</router-link>
</div>
</div>
@ -51,9 +51,10 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import axios from 'axios'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Button from 'primevue/button'
@ -66,6 +67,14 @@ const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const registrationAllowed = ref(false)
onMounted(async () => {
try {
const res = await axios.get('/api/auth/registration-status')
registrationAllowed.value = res.data.allowed
} catch { registrationAllowed.value = false }
})
async function handleLogin() {
error.value = ''

View File

@ -71,9 +71,10 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import axios from 'axios'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Button from 'primevue/button'
@ -89,6 +90,15 @@ const password2 = ref('')
const error = ref('')
const loading = ref(false)
onMounted(async () => {
try {
const res = await axios.get('/api/auth/registration-status')
if (!res.data.allowed) {
router.push('/login')
}
} catch { router.push('/login') }
})
async function handleRegister() {
error.value = ''