feat: Mini-Cloud Plattform - komplette Implementierung Phase 0-8
Selbstgehostete Web-Cloud mit Dateiverwaltung, Kalender, Kontakte, Email-Webclient, Office-Viewer und Passwort-Manager. Backend (Flask/Python): - JWT-Auth mit Access/Refresh Tokens, Benutzerverwaltung - Dateien: Upload/Download, Ordner, Berechtigungen, Share-Links - Kalender: CRUD, Teilen, iCal-Export, CalDAV well-known URLs - Kontakte: Adressbuecher, vCard-Export, Teilen - Email: IMAP/SMTP-Proxy, Multi-Account - Office-Viewer: DOCX/XLSX/PPTX/PDF Vorschau - Passwort-Manager: AES-256-GCM clientseitig, KeePass-Import - Sync-API fuer Desktop/Mobile-Clients - SQLite mit WAL-Modus Frontend (Vue 3 + PrimeVue): - Datei-Explorer mit Breadcrumbs und Share-Dialogen - Monatskalender mit Event-Verwaltung - Kontaktliste mit Adressbuch-Sidebar - Email-Client mit 3-Spalten-Layout - Passwort-Manager mit TOTP und Passwort-Generator - Admin-Panel, Settings, oeffentliche Share-Seite Docker: Multi-Stage Build, Bind Mounts (keine Volumes) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# Mini-Cloud Konfiguration
|
||||
# Kopiere diese Datei nach .env und passe die Werte an
|
||||
|
||||
# Flask
|
||||
SECRET_KEY=change-me-to-a-random-secret-key
|
||||
FLASK_ENV=production
|
||||
FLASK_DEBUG=0
|
||||
|
||||
# Datenbank
|
||||
DATABASE_PATH=./data/minicloud.db
|
||||
|
||||
# Dateispeicher
|
||||
UPLOAD_PATH=./data/files
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY=change-me-to-another-random-secret-key
|
||||
JWT_ACCESS_TOKEN_EXPIRES=900
|
||||
JWT_REFRESH_TOKEN_EXPIRES=604800
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=5000
|
||||
|
||||
# Frontend URL (fuer CORS)
|
||||
FRONTEND_URL=http://localhost:3010
|
||||
|
||||
# Max Upload-Groesse in MB
|
||||
MAX_UPLOAD_SIZE_MB=500
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:22-slim AS frontend-build
|
||||
WORKDIR /build
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY backend/requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||
|
||||
# Copy backend
|
||||
COPY backend/ ./
|
||||
|
||||
# Copy frontend build
|
||||
COPY --from=frontend-build /build/dist ./static
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data/files
|
||||
|
||||
# Environment
|
||||
ENV FLASK_ENV=production
|
||||
ENV DATABASE_PATH=/app/data/minicloud.db
|
||||
ENV UPLOAD_PATH=/app/data/files
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "wsgi:application"]
|
||||
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, redirect, send_from_directory
|
||||
from flask_cors import CORS
|
||||
|
||||
from app.config import Config
|
||||
from app.extensions import db, bcrypt, migrate
|
||||
|
||||
|
||||
def create_app(config_class=Config):
|
||||
# Check if static frontend build exists (Docker production mode)
|
||||
static_dir = Path(__file__).resolve().parent.parent / 'static'
|
||||
if static_dir.exists():
|
||||
app = Flask(__name__, static_folder=str(static_dir), static_url_path='')
|
||||
else:
|
||||
app = Flask(__name__)
|
||||
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# Ensure data directories exist
|
||||
Path(app.config['UPLOAD_PATH']).mkdir(parents=True, exist_ok=True)
|
||||
db_dir = Path(app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')).parent
|
||||
db_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
bcrypt.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
||||
# CORS
|
||||
CORS(app, resources={r'/api/*': {'origins': app.config['FRONTEND_URL']}},
|
||||
supports_credentials=True)
|
||||
|
||||
# Register blueprints
|
||||
from app.api import api_bp
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
# Well-known URLs for CalDAV/CardDAV auto-discovery (iOS, DAVx5, etc.)
|
||||
@app.route('/.well-known/caldav')
|
||||
def wellknown_caldav():
|
||||
return redirect('/dav/', code=301)
|
||||
|
||||
@app.route('/.well-known/carddav')
|
||||
def wellknown_carddav():
|
||||
return redirect('/dav/', code=301)
|
||||
|
||||
# iCal export (public, no auth)
|
||||
@app.route('/ical/<token>')
|
||||
def ical_export_route(token):
|
||||
from app.api.calendar import ical_export as _ical_export
|
||||
return _ical_export(token)
|
||||
|
||||
# Serve frontend SPA for all non-API routes (production/Docker)
|
||||
if static_dir.exists():
|
||||
@app.route('/')
|
||||
def serve_index():
|
||||
return send_from_directory(str(static_dir), 'index.html')
|
||||
|
||||
@app.errorhandler(404)
|
||||
def serve_spa(e):
|
||||
return send_from_directory(str(static_dir), 'index.html')
|
||||
|
||||
# Create tables
|
||||
with app.app_context():
|
||||
from app import models # noqa: F401
|
||||
db.create_all()
|
||||
|
||||
# Enable WAL mode for SQLite
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(db.text('PRAGMA journal_mode=WAL'))
|
||||
conn.commit()
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
from app.api import auth, users, files, calendar, contacts, email, office, passwords # noqa: E402, F401
|
||||
@@ -0,0 +1,208 @@
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
|
||||
import jwt
|
||||
from flask import request, jsonify, current_app, make_response
|
||||
|
||||
from app.api import api_bp
|
||||
from app.extensions import db
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def create_access_token(user_id):
|
||||
exp = datetime.now(timezone.utc) + current_app.config['JWT_ACCESS_TOKEN_EXPIRES']
|
||||
payload = {
|
||||
'user_id': user_id,
|
||||
'type': 'access',
|
||||
'exp': exp,
|
||||
'iat': datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, current_app.config['JWT_SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
|
||||
def create_refresh_token(user_id):
|
||||
exp = datetime.now(timezone.utc) + current_app.config['JWT_REFRESH_TOKEN_EXPIRES']
|
||||
payload = {
|
||||
'user_id': user_id,
|
||||
'type': 'refresh',
|
||||
'exp': exp,
|
||||
'iat': datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, current_app.config['JWT_SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
|
||||
def token_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = None
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if auth_header.startswith('Bearer '):
|
||||
token = auth_header[7:]
|
||||
|
||||
if not token:
|
||||
return jsonify({'error': 'Token fehlt'}), 401
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['JWT_SECRET_KEY'],
|
||||
algorithms=['HS256'])
|
||||
if payload.get('type') != 'access':
|
||||
return jsonify({'error': 'Falscher Token-Typ'}), 401
|
||||
user = db.session.get(User, payload['user_id'])
|
||||
if not user or not user.is_active:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden oder deaktiviert'}), 401
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token abgelaufen'}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Ungueltiger Token'}), 401
|
||||
|
||||
request.current_user = user
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
@token_required
|
||||
def decorated(*args, **kwargs):
|
||||
if request.current_user.role != 'admin':
|
||||
return jsonify({'error': 'Admin-Berechtigung erforderlich'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
@api_bp.route('/auth/register', methods=['POST'])
|
||||
def register():
|
||||
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
|
||||
|
||||
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 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
|
||||
|
||||
# First user becomes admin
|
||||
is_first_user = User.query.count() == 0
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
role='admin' if is_first_user else 'user',
|
||||
master_key_salt=os.urandom(32),
|
||||
)
|
||||
user.set_password(password)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
response = make_response(jsonify({
|
||||
'user': user.to_dict(include_email=True),
|
||||
'access_token': access_token,
|
||||
}))
|
||||
|
||||
response.set_cookie(
|
||||
'refresh_token', refresh_token,
|
||||
httponly=True, secure=request.is_secure,
|
||||
samesite='Lax',
|
||||
max_age=int(current_app.config['JWT_REFRESH_TOKEN_EXPIRES'].total_seconds()),
|
||||
)
|
||||
|
||||
return response, 201
|
||||
|
||||
|
||||
@api_bp.route('/auth/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'Keine Daten gesendet'}), 400
|
||||
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': 'Benutzername und Passwort erforderlich'}), 400
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if not user or not user.check_password(password):
|
||||
return jsonify({'error': 'Ungueltige Anmeldedaten'}), 401
|
||||
|
||||
if not user.is_active:
|
||||
return jsonify({'error': 'Konto deaktiviert'}), 403
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
import base64
|
||||
response = make_response(jsonify({
|
||||
'user': user.to_dict(include_email=True),
|
||||
'access_token': access_token,
|
||||
'master_key_salt': base64.b64encode(user.master_key_salt).decode() if user.master_key_salt else None,
|
||||
}))
|
||||
|
||||
response.set_cookie(
|
||||
'refresh_token', refresh_token,
|
||||
httponly=True, secure=request.is_secure,
|
||||
samesite='Lax',
|
||||
max_age=int(current_app.config['JWT_REFRESH_TOKEN_EXPIRES'].total_seconds()),
|
||||
)
|
||||
|
||||
return response, 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/refresh', methods=['POST'])
|
||||
def refresh():
|
||||
refresh_token = request.cookies.get('refresh_token')
|
||||
if not refresh_token:
|
||||
return jsonify({'error': 'Refresh-Token fehlt'}), 401
|
||||
|
||||
try:
|
||||
payload = jwt.decode(refresh_token, current_app.config['JWT_SECRET_KEY'],
|
||||
algorithms=['HS256'])
|
||||
if payload.get('type') != 'refresh':
|
||||
return jsonify({'error': 'Falscher Token-Typ'}), 401
|
||||
user = db.session.get(User, payload['user_id'])
|
||||
if not user or not user.is_active:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 401
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Refresh-Token abgelaufen'}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Ungueltiger Token'}), 401
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
return jsonify({'access_token': access_token}), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/logout', methods=['POST'])
|
||||
def logout():
|
||||
response = make_response(jsonify({'message': 'Abgemeldet'}))
|
||||
response.delete_cookie('refresh_token')
|
||||
return response, 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/me', methods=['GET'])
|
||||
@token_required
|
||||
def me():
|
||||
import base64
|
||||
user = request.current_user
|
||||
data = user.to_dict(include_email=True)
|
||||
data['master_key_salt'] = base64.b64encode(user.master_key_salt).decode() if user.master_key_salt else None
|
||||
data['email_account_count'] = user.email_accounts.count()
|
||||
return jsonify(data), 200
|
||||
@@ -0,0 +1,397 @@
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request, jsonify
|
||||
|
||||
from app.api import api_bp
|
||||
from app.api.auth import token_required
|
||||
from app.extensions import db
|
||||
from app.models.calendar import Calendar, CalendarEvent, CalendarShare
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _get_calendar_or_err(cal_id, user, need_write=False):
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal:
|
||||
return None, (jsonify({'error': 'Kalender nicht gefunden'}), 404)
|
||||
if cal.owner_id == user.id:
|
||||
return cal, None
|
||||
share = CalendarShare.query.filter_by(
|
||||
calendar_id=cal_id, shared_with_id=user.id
|
||||
).first()
|
||||
if not share:
|
||||
return None, (jsonify({'error': 'Zugriff verweigert'}), 403)
|
||||
if need_write and share.permission != 'readwrite':
|
||||
return None, (jsonify({'error': 'Schreibzugriff verweigert'}), 403)
|
||||
return cal, None
|
||||
|
||||
|
||||
# --- Calendars ---
|
||||
|
||||
@api_bp.route('/calendars', methods=['GET'])
|
||||
@token_required
|
||||
def list_calendars():
|
||||
user = request.current_user
|
||||
own = Calendar.query.filter_by(owner_id=user.id).all()
|
||||
shared_ids = [s.calendar_id for s in
|
||||
CalendarShare.query.filter_by(shared_with_id=user.id).all()]
|
||||
shared = Calendar.query.filter(Calendar.id.in_(shared_ids)).all() if shared_ids else []
|
||||
|
||||
result = []
|
||||
for c in own:
|
||||
d = c.to_dict()
|
||||
d['permission'] = 'owner'
|
||||
result.append(d)
|
||||
for c in shared:
|
||||
d = c.to_dict()
|
||||
share = CalendarShare.query.filter_by(
|
||||
calendar_id=c.id, shared_with_id=user.id
|
||||
).first()
|
||||
d['permission'] = share.permission if share else 'read'
|
||||
d['owner_name'] = c.owner.username
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars', methods=['POST'])
|
||||
@token_required
|
||||
def create_calendar():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name erforderlich'}), 400
|
||||
|
||||
cal = Calendar(
|
||||
owner_id=user.id,
|
||||
name=name,
|
||||
color=data.get('color', '#3788d8'),
|
||||
description=data.get('description', ''),
|
||||
)
|
||||
db.session.add(cal)
|
||||
db.session.commit()
|
||||
return jsonify(cal.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_calendar(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if 'name' in data:
|
||||
cal.name = data['name'].strip()
|
||||
if 'color' in data:
|
||||
cal.color = data['color']
|
||||
if 'description' in data:
|
||||
cal.description = data['description']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(cal.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_calendar(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404
|
||||
|
||||
db.session.delete(cal)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Kalender geloescht'}), 200
|
||||
|
||||
|
||||
# --- Events ---
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/events', methods=['GET'])
|
||||
@token_required
|
||||
def list_events(cal_id):
|
||||
user = request.current_user
|
||||
cal, err = _get_calendar_or_err(cal_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
start = request.args.get('start')
|
||||
end = request.args.get('end')
|
||||
|
||||
query = CalendarEvent.query.filter_by(calendar_id=cal_id)
|
||||
if start:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start)
|
||||
query = query.filter(CalendarEvent.dtend >= start_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
if end:
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end)
|
||||
query = query.filter(CalendarEvent.dtstart <= end_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
events = query.order_by(CalendarEvent.dtstart).all()
|
||||
return jsonify([e.to_dict() for e in events]), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/events', methods=['POST'])
|
||||
@token_required
|
||||
def create_event(cal_id):
|
||||
user = request.current_user
|
||||
cal, err = _get_calendar_or_err(cal_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
summary = data.get('summary', '').strip()
|
||||
if not summary:
|
||||
return jsonify({'error': 'Zusammenfassung erforderlich'}), 400
|
||||
|
||||
dtstart = data.get('dtstart')
|
||||
dtend = data.get('dtend')
|
||||
all_day = data.get('all_day', False)
|
||||
|
||||
if not dtstart:
|
||||
return jsonify({'error': 'Startdatum erforderlich'}), 400
|
||||
|
||||
try:
|
||||
dtstart_dt = datetime.fromisoformat(dtstart)
|
||||
dtend_dt = datetime.fromisoformat(dtend) if dtend else dtstart_dt
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Ungueltiges Datumsformat'}), 400
|
||||
|
||||
event_uid = str(uuid.uuid4())
|
||||
|
||||
# Build simple iCal data
|
||||
ical_data = _build_ical(event_uid, summary, dtstart_dt, dtend_dt, all_day,
|
||||
data.get('description', ''), data.get('location', ''),
|
||||
data.get('recurrence_rule', ''))
|
||||
|
||||
event = CalendarEvent(
|
||||
calendar_id=cal_id,
|
||||
uid=event_uid,
|
||||
ical_data=ical_data,
|
||||
summary=summary,
|
||||
dtstart=dtstart_dt,
|
||||
dtend=dtend_dt,
|
||||
all_day=all_day,
|
||||
recurrence_rule=data.get('recurrence_rule'),
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
return jsonify(event.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/events/<int:event_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_event(event_id):
|
||||
user = request.current_user
|
||||
event = db.session.get(CalendarEvent, event_id)
|
||||
if not event:
|
||||
return jsonify({'error': 'Event nicht gefunden'}), 404
|
||||
|
||||
cal, err = _get_calendar_or_err(event.calendar_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
if 'summary' in data:
|
||||
event.summary = data['summary'].strip()
|
||||
if 'dtstart' in data:
|
||||
event.dtstart = datetime.fromisoformat(data['dtstart'])
|
||||
if 'dtend' in data:
|
||||
event.dtend = datetime.fromisoformat(data['dtend'])
|
||||
if 'all_day' in data:
|
||||
event.all_day = data['all_day']
|
||||
if 'recurrence_rule' in data:
|
||||
event.recurrence_rule = data['recurrence_rule']
|
||||
if 'calendar_id' in data:
|
||||
new_cal, cerr = _get_calendar_or_err(data['calendar_id'], user, need_write=True)
|
||||
if cerr:
|
||||
return cerr
|
||||
event.calendar_id = data['calendar_id']
|
||||
|
||||
event.ical_data = _build_ical(
|
||||
event.uid, event.summary, event.dtstart, event.dtend,
|
||||
event.all_day, data.get('description', ''), data.get('location', ''),
|
||||
event.recurrence_rule or ''
|
||||
)
|
||||
event.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return jsonify(event.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/events/<int:event_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_event(event_id):
|
||||
user = request.current_user
|
||||
event = db.session.get(CalendarEvent, event_id)
|
||||
if not event:
|
||||
return jsonify({'error': 'Event nicht gefunden'}), 404
|
||||
|
||||
cal, err = _get_calendar_or_err(event.calendar_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
db.session.delete(event)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Event geloescht'}), 200
|
||||
|
||||
|
||||
# --- Calendar sharing ---
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/share', methods=['POST'])
|
||||
@token_required
|
||||
def share_calendar(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nur der Eigentuemer kann teilen'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
username = data.get('username', '').strip()
|
||||
permission = data.get('permission', 'read')
|
||||
|
||||
if permission not in ('read', 'readwrite'):
|
||||
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
|
||||
|
||||
target = User.query.filter_by(username=username).first()
|
||||
if not target:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
if target.id == user.id:
|
||||
return jsonify({'error': 'Kann nicht mit sich selbst teilen'}), 400
|
||||
|
||||
existing = CalendarShare.query.filter_by(
|
||||
calendar_id=cal_id, shared_with_id=target.id
|
||||
).first()
|
||||
if existing:
|
||||
existing.permission = permission
|
||||
else:
|
||||
share = CalendarShare(
|
||||
calendar_id=cal_id, shared_with_id=target.id, permission=permission
|
||||
)
|
||||
db.session.add(share)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': f'Kalender mit {username} geteilt'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/shares', methods=['GET'])
|
||||
@token_required
|
||||
def list_calendar_shares(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
shares = CalendarShare.query.filter_by(calendar_id=cal_id).all()
|
||||
return jsonify([{
|
||||
'id': s.id,
|
||||
'user_id': s.shared_with_id,
|
||||
'username': s.shared_with.username,
|
||||
'permission': s.permission,
|
||||
} for s in shares]), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/shares/<int:share_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def remove_calendar_share(cal_id, share_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
share = db.session.get(CalendarShare, share_id)
|
||||
if not share or share.calendar_id != cal_id:
|
||||
return jsonify({'error': 'Freigabe nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(share)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Freigabe entfernt'}), 200
|
||||
|
||||
|
||||
# --- iCal Export ---
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/ical-link', methods=['POST'])
|
||||
@token_required
|
||||
def generate_ical_link(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
cal.ical_token = secrets.token_urlsafe(32)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'ical_url': f'/ical/{cal.ical_token}',
|
||||
'token': cal.ical_token,
|
||||
}), 200
|
||||
|
||||
|
||||
def ical_export(token):
|
||||
cal = Calendar.query.filter_by(ical_token=token).first()
|
||||
if not cal:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
events = CalendarEvent.query.filter_by(calendar_id=cal.id).all()
|
||||
|
||||
lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Mini-Cloud//DE',
|
||||
f'X-WR-CALNAME:{cal.name}',
|
||||
]
|
||||
for e in events:
|
||||
if e.ical_data:
|
||||
# Extract VEVENT from stored ical_data
|
||||
lines.append(e.ical_data)
|
||||
else:
|
||||
lines.append(_build_vevent(e.uid, e.summary, e.dtstart, e.dtend, e.all_day))
|
||||
lines.append('END:VCALENDAR')
|
||||
|
||||
from flask import Response
|
||||
return Response(
|
||||
'\r\n'.join(lines),
|
||||
mimetype='text/calendar',
|
||||
headers={'Content-Disposition': f'attachment; filename="{cal.name}.ics"'},
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _format_dt(dt, all_day=False):
|
||||
if all_day:
|
||||
return dt.strftime('%Y%m%d')
|
||||
return dt.strftime('%Y%m%dT%H%M%SZ')
|
||||
|
||||
|
||||
def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''):
|
||||
lines = [
|
||||
'BEGIN:VEVENT',
|
||||
f'UID:{uid}',
|
||||
]
|
||||
if all_day:
|
||||
lines.append(f'DTSTART;VALUE=DATE:{_format_dt(dtstart, True)}')
|
||||
lines.append(f'DTEND;VALUE=DATE:{_format_dt(dtend, True)}')
|
||||
else:
|
||||
lines.append(f'DTSTART:{_format_dt(dtstart)}')
|
||||
lines.append(f'DTEND:{_format_dt(dtend)}')
|
||||
lines.append(f'SUMMARY:{summary}')
|
||||
if description:
|
||||
lines.append(f'DESCRIPTION:{description}')
|
||||
if location:
|
||||
lines.append(f'LOCATION:{location}')
|
||||
if rrule:
|
||||
lines.append(f'RRULE:{rrule}')
|
||||
lines.append(f'DTSTAMP:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}')
|
||||
lines.append('END:VEVENT')
|
||||
return '\r\n'.join(lines)
|
||||
|
||||
|
||||
def _build_ical(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''):
|
||||
return _build_vevent(uid, summary, dtstart, dtend, all_day, description, location, rrule)
|
||||
@@ -0,0 +1,340 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request, jsonify
|
||||
|
||||
from app.api import api_bp
|
||||
from app.api.auth import token_required
|
||||
from app.extensions import db
|
||||
from app.models.contact import AddressBook, Contact, AddressBookShare
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _get_addressbook_or_err(book_id, user, need_write=False):
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book:
|
||||
return None, (jsonify({'error': 'Adressbuch nicht gefunden'}), 404)
|
||||
if book.owner_id == user.id:
|
||||
return book, None
|
||||
share = AddressBookShare.query.filter_by(
|
||||
address_book_id=book_id, shared_with_id=user.id
|
||||
).first()
|
||||
if not share:
|
||||
return None, (jsonify({'error': 'Zugriff verweigert'}), 403)
|
||||
if need_write and share.permission != 'readwrite':
|
||||
return None, (jsonify({'error': 'Schreibzugriff verweigert'}), 403)
|
||||
return book, None
|
||||
|
||||
|
||||
# --- Address Books ---
|
||||
|
||||
@api_bp.route('/addressbooks', methods=['GET'])
|
||||
@token_required
|
||||
def list_addressbooks():
|
||||
user = request.current_user
|
||||
own = AddressBook.query.filter_by(owner_id=user.id).all()
|
||||
shared_ids = [s.address_book_id for s in
|
||||
AddressBookShare.query.filter_by(shared_with_id=user.id).all()]
|
||||
shared = AddressBook.query.filter(AddressBook.id.in_(shared_ids)).all() if shared_ids else []
|
||||
|
||||
result = []
|
||||
for b in own:
|
||||
d = b.to_dict()
|
||||
d['permission'] = 'owner'
|
||||
d['contact_count'] = b.contacts.count()
|
||||
result.append(d)
|
||||
for b in shared:
|
||||
d = b.to_dict()
|
||||
share = AddressBookShare.query.filter_by(
|
||||
address_book_id=b.id, shared_with_id=user.id
|
||||
).first()
|
||||
d['permission'] = share.permission if share else 'read'
|
||||
d['owner_name'] = b.owner.username
|
||||
d['contact_count'] = b.contacts.count()
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks', methods=['POST'])
|
||||
@token_required
|
||||
def create_addressbook():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name erforderlich'}), 400
|
||||
|
||||
book = AddressBook(owner_id=user.id, name=name, description=data.get('description', ''))
|
||||
db.session.add(book)
|
||||
db.session.commit()
|
||||
return jsonify(book.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_addressbook(book_id):
|
||||
user = request.current_user
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book or book.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if 'name' in data:
|
||||
book.name = data['name'].strip()
|
||||
if 'description' in data:
|
||||
book.description = data['description']
|
||||
db.session.commit()
|
||||
return jsonify(book.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_addressbook(book_id):
|
||||
user = request.current_user
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book or book.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(book)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Adressbuch geloescht'}), 200
|
||||
|
||||
|
||||
# --- Contacts ---
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/contacts', methods=['GET'])
|
||||
@token_required
|
||||
def list_contacts(book_id):
|
||||
user = request.current_user
|
||||
book, err = _get_addressbook_or_err(book_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
search = request.args.get('search', '').strip()
|
||||
query = Contact.query.filter_by(address_book_id=book_id)
|
||||
if search:
|
||||
query = query.filter(Contact.display_name.ilike(f'%{search}%'))
|
||||
contacts = query.order_by(Contact.display_name).all()
|
||||
return jsonify([c.to_dict() for c in contacts]), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/contacts', methods=['POST'])
|
||||
@token_required
|
||||
def create_contact(book_id):
|
||||
user = request.current_user
|
||||
book, err = _get_addressbook_or_err(book_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
display_name = data.get('display_name', '').strip()
|
||||
if not display_name:
|
||||
return jsonify({'error': 'Name erforderlich'}), 400
|
||||
|
||||
contact_uid = str(uuid.uuid4())
|
||||
email = data.get('email', '')
|
||||
phone = data.get('phone', '')
|
||||
org = data.get('organization', '')
|
||||
notes = data.get('notes', '')
|
||||
|
||||
vcard = _build_vcard(contact_uid, display_name, email, phone, org, notes)
|
||||
|
||||
contact = Contact(
|
||||
address_book_id=book_id,
|
||||
uid=contact_uid,
|
||||
vcard_data=vcard,
|
||||
display_name=display_name,
|
||||
email=email or None,
|
||||
phone=phone or None,
|
||||
)
|
||||
db.session.add(contact)
|
||||
db.session.commit()
|
||||
return jsonify(contact.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<int:contact_id>', methods=['GET'])
|
||||
@token_required
|
||||
def get_contact(contact_id):
|
||||
user = request.current_user
|
||||
contact = db.session.get(Contact, contact_id)
|
||||
if not contact:
|
||||
return jsonify({'error': 'Kontakt nicht gefunden'}), 404
|
||||
|
||||
book, err = _get_addressbook_or_err(contact.address_book_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
result = contact.to_dict()
|
||||
result['vcard_data'] = contact.vcard_data
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<int:contact_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_contact(contact_id):
|
||||
user = request.current_user
|
||||
contact = db.session.get(Contact, contact_id)
|
||||
if not contact:
|
||||
return jsonify({'error': 'Kontakt nicht gefunden'}), 404
|
||||
|
||||
book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
if 'display_name' in data:
|
||||
contact.display_name = data['display_name'].strip()
|
||||
if 'email' in data:
|
||||
contact.email = data['email'] or None
|
||||
if 'phone' in data:
|
||||
contact.phone = data['phone'] or None
|
||||
|
||||
contact.vcard_data = _build_vcard(
|
||||
contact.uid,
|
||||
contact.display_name,
|
||||
data.get('email', contact.email or ''),
|
||||
data.get('phone', contact.phone or ''),
|
||||
data.get('organization', ''),
|
||||
data.get('notes', ''),
|
||||
)
|
||||
contact.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return jsonify(contact.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<int:contact_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_contact(contact_id):
|
||||
user = request.current_user
|
||||
contact = db.session.get(Contact, contact_id)
|
||||
if not contact:
|
||||
return jsonify({'error': 'Kontakt nicht gefunden'}), 404
|
||||
|
||||
book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
db.session.delete(contact)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Kontakt geloescht'}), 200
|
||||
|
||||
|
||||
# --- Sharing ---
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/share', methods=['POST'])
|
||||
@token_required
|
||||
def share_addressbook(book_id):
|
||||
user = request.current_user
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book or book.owner_id != user.id:
|
||||
return jsonify({'error': 'Nur der Eigentuemer kann teilen'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
username = data.get('username', '').strip()
|
||||
permission = data.get('permission', 'read')
|
||||
|
||||
if permission not in ('read', 'readwrite'):
|
||||
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
|
||||
|
||||
target = User.query.filter_by(username=username).first()
|
||||
if not target:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
if target.id == user.id:
|
||||
return jsonify({'error': 'Kann nicht mit sich selbst teilen'}), 400
|
||||
|
||||
existing = AddressBookShare.query.filter_by(
|
||||
address_book_id=book_id, shared_with_id=target.id
|
||||
).first()
|
||||
if existing:
|
||||
existing.permission = permission
|
||||
else:
|
||||
share = AddressBookShare(
|
||||
address_book_id=book_id, shared_with_id=target.id, permission=permission
|
||||
)
|
||||
db.session.add(share)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': f'Adressbuch mit {username} geteilt'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/shares', methods=['GET'])
|
||||
@token_required
|
||||
def list_addressbook_shares(book_id):
|
||||
user = request.current_user
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book or book.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
shares = AddressBookShare.query.filter_by(address_book_id=book_id).all()
|
||||
return jsonify([{
|
||||
'id': s.id,
|
||||
'user_id': s.shared_with_id,
|
||||
'username': s.shared_with.username,
|
||||
'permission': s.permission,
|
||||
} for s in shares]), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/shares/<int:share_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def remove_addressbook_share(book_id, share_id):
|
||||
user = request.current_user
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book or book.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
share = db.session.get(AddressBookShare, share_id)
|
||||
if not share or share.address_book_id != book_id:
|
||||
return jsonify({'error': 'Freigabe nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(share)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Freigabe entfernt'}), 200
|
||||
|
||||
|
||||
# --- Import/Export ---
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/export', methods=['GET'])
|
||||
@token_required
|
||||
def export_contacts(book_id):
|
||||
user = request.current_user
|
||||
book, err = _get_addressbook_or_err(book_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
contacts = Contact.query.filter_by(address_book_id=book_id).all()
|
||||
vcards = '\r\n'.join(c.vcard_data for c in contacts)
|
||||
|
||||
from flask import Response
|
||||
return Response(
|
||||
vcards,
|
||||
mimetype='text/vcard',
|
||||
headers={'Content-Disposition': f'attachment; filename="{book.name}.vcf"'},
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _build_vcard(uid, display_name, email='', phone='', org='', notes=''):
|
||||
parts = display_name.split(' ', 1)
|
||||
first = parts[0]
|
||||
last = parts[1] if len(parts) > 1 else ''
|
||||
|
||||
lines = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
f'UID:{uid}',
|
||||
f'FN:{display_name}',
|
||||
f'N:{last};{first};;;',
|
||||
]
|
||||
if email:
|
||||
lines.append(f'EMAIL:{email}')
|
||||
if phone:
|
||||
lines.append(f'TEL:{phone}')
|
||||
if org:
|
||||
lines.append(f'ORG:{org}')
|
||||
if notes:
|
||||
lines.append(f'NOTE:{notes}')
|
||||
lines.append(f'REV:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}')
|
||||
lines.append('END:VCARD')
|
||||
return '\r\n'.join(lines)
|
||||
@@ -0,0 +1,489 @@
|
||||
import email as email_lib
|
||||
import email.header
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
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.extensions import db
|
||||
from app.models.email_account import EmailAccount
|
||||
from app.services.crypto_service import encrypt_field, decrypt_field
|
||||
|
||||
|
||||
def _get_account_or_err(account_id, user):
|
||||
account = db.session.get(EmailAccount, account_id)
|
||||
if not account or account.user_id != user.id:
|
||||
return None, (jsonify({'error': 'Konto nicht gefunden'}), 404)
|
||||
return account, None
|
||||
|
||||
|
||||
def _get_imap_connection(account, user_password_key):
|
||||
import imapclient
|
||||
password = decrypt_field(account.password_encrypted, user_password_key)
|
||||
host = account.imap_host
|
||||
port = account.imap_port
|
||||
|
||||
if account.imap_ssl:
|
||||
conn = imapclient.IMAPClient(host, port=port, ssl=True)
|
||||
else:
|
||||
conn = imapclient.IMAPClient(host, port=port, ssl=False)
|
||||
conn.starttls()
|
||||
|
||||
conn.login(account.username, password)
|
||||
return conn
|
||||
|
||||
|
||||
def _decode_header(header_value):
|
||||
if not header_value:
|
||||
return ''
|
||||
decoded_parts = email.header.decode_header(header_value)
|
||||
result = []
|
||||
for part, charset in decoded_parts:
|
||||
if isinstance(part, bytes):
|
||||
result.append(part.decode(charset or 'utf-8', errors='replace'))
|
||||
else:
|
||||
result.append(part)
|
||||
return ' '.join(result)
|
||||
|
||||
|
||||
# --- Accounts ---
|
||||
|
||||
@api_bp.route('/email/accounts', methods=['GET'])
|
||||
@token_required
|
||||
def list_email_accounts():
|
||||
user = request.current_user
|
||||
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('/email/accounts', methods=['POST'])
|
||||
@token_required
|
||||
def create_email_account():
|
||||
user = request.current_user
|
||||
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
|
||||
|
||||
# Get encryption key from header
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if not enc_key:
|
||||
return jsonify({'error': 'Verschluesselungs-Key erforderlich (X-Encryption-Key Header)'}), 400
|
||||
|
||||
encrypted_pw = encrypt_field(data['password'], enc_key)
|
||||
|
||||
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=encrypted_pw,
|
||||
is_default=data.get('is_default', False),
|
||||
sort_order=data.get('sort_order', 0),
|
||||
)
|
||||
db.session.add(account)
|
||||
|
||||
# Update email account count
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(account.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_email_account(account_id):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
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('/email/accounts/<int:account_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_email_account(account_id):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
db.session.delete(account)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'E-Mail-Konto geloescht'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/test', methods=['POST'])
|
||||
@token_required
|
||||
def test_email_account(account_id):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if not enc_key:
|
||||
return jsonify({'error': 'Verschluesselungs-Key erforderlich'}), 400
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.logout()
|
||||
return jsonify({'message': 'Verbindung erfolgreich'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Verbindungsfehler: {str(e)}'}), 400
|
||||
|
||||
|
||||
# --- Folders ---
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/folders', methods=['GET'])
|
||||
@token_required
|
||||
def list_email_folders(account_id):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if not enc_key:
|
||||
return jsonify({'error': 'Verschluesselungs-Key erforderlich'}), 400
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
folders_raw = conn.list_folders()
|
||||
folders = []
|
||||
for flags, delimiter, name in folders_raw:
|
||||
flag_strs = [f.decode() if isinstance(f, bytes) else f for f in flags]
|
||||
folders.append({
|
||||
'name': name,
|
||||
'delimiter': delimiter.decode() if isinstance(delimiter, bytes) else delimiter,
|
||||
'flags': flag_strs,
|
||||
})
|
||||
conn.logout()
|
||||
return jsonify(folders), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# --- Messages ---
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/folders/<path:folder>/messages', methods=['GET'])
|
||||
@token_required
|
||||
def list_messages(account_id, folder):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if not enc_key:
|
||||
return jsonify({'error': 'Verschluesselungs-Key erforderlich'}), 400
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.select_folder(folder)
|
||||
|
||||
# Get all UIDs, sorted newest first
|
||||
uids = conn.search(['ALL'])
|
||||
uids.reverse()
|
||||
|
||||
total = len(uids)
|
||||
start = (page - 1) * limit
|
||||
page_uids = uids[start:start + limit]
|
||||
|
||||
messages = []
|
||||
if page_uids:
|
||||
fetch_data = conn.fetch(page_uids, ['ENVELOPE', 'FLAGS', 'RFC822.SIZE'])
|
||||
for uid in page_uids:
|
||||
if uid not in fetch_data:
|
||||
continue
|
||||
msg_data = fetch_data[uid]
|
||||
envelope = msg_data.get(b'ENVELOPE')
|
||||
flags = msg_data.get(b'FLAGS', ())
|
||||
size = msg_data.get(b'RFC822.SIZE', 0)
|
||||
|
||||
flag_strs = [f.decode() if isinstance(f, bytes) else str(f) for f in flags]
|
||||
|
||||
from_addr = ''
|
||||
if envelope and envelope.from_:
|
||||
f = envelope.from_[0]
|
||||
name = f.name.decode(errors='replace') if f.name else ''
|
||||
mailbox = f.mailbox.decode(errors='replace') if f.mailbox else ''
|
||||
host = f.host.decode(errors='replace') if f.host else ''
|
||||
from_addr = f'{name} <{mailbox}@{host}>' if name else f'{mailbox}@{host}'
|
||||
|
||||
subject = ''
|
||||
if envelope and envelope.subject:
|
||||
subject = _decode_header(envelope.subject.decode(errors='replace'))
|
||||
|
||||
date_str = ''
|
||||
if envelope and envelope.date:
|
||||
date_str = envelope.date.isoformat() if hasattr(envelope.date, 'isoformat') else str(envelope.date)
|
||||
|
||||
messages.append({
|
||||
'uid': uid,
|
||||
'subject': subject,
|
||||
'from': from_addr,
|
||||
'date': date_str,
|
||||
'flags': flag_strs,
|
||||
'size': size,
|
||||
'seen': '\\Seen' in flag_strs,
|
||||
})
|
||||
|
||||
conn.logout()
|
||||
return jsonify({
|
||||
'messages': messages,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/messages/<int:uid>', methods=['GET'])
|
||||
@token_required
|
||||
def get_message(account_id, uid):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
folder = request.args.get('folder', 'INBOX')
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.select_folder(folder)
|
||||
|
||||
fetch_data = conn.fetch([uid], ['RFC822', 'FLAGS'])
|
||||
if uid not in fetch_data:
|
||||
conn.logout()
|
||||
return jsonify({'error': 'Nachricht nicht gefunden'}), 404
|
||||
|
||||
raw = fetch_data[uid][b'RFC822']
|
||||
flags = fetch_data[uid].get(b'FLAGS', ())
|
||||
|
||||
# Mark as seen
|
||||
conn.set_flags([uid], ['\\Seen'])
|
||||
conn.logout()
|
||||
|
||||
msg = email_lib.message_from_bytes(raw)
|
||||
|
||||
# Extract body
|
||||
html_body = ''
|
||||
text_body = ''
|
||||
attachments = []
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
disposition = str(part.get('Content-Disposition', ''))
|
||||
|
||||
if 'attachment' in disposition:
|
||||
filename = part.get_filename() or 'attachment'
|
||||
attachments.append({
|
||||
'filename': _decode_header(filename),
|
||||
'content_type': content_type,
|
||||
'size': len(part.get_payload(decode=True) or b''),
|
||||
})
|
||||
elif content_type == 'text/html':
|
||||
html_body = part.get_payload(decode=True).decode(errors='replace')
|
||||
elif content_type == 'text/plain':
|
||||
text_body = part.get_payload(decode=True).decode(errors='replace')
|
||||
else:
|
||||
content_type = msg.get_content_type()
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
if content_type == 'text/html':
|
||||
html_body = payload.decode(errors='replace')
|
||||
else:
|
||||
text_body = payload.decode(errors='replace')
|
||||
|
||||
return jsonify({
|
||||
'uid': uid,
|
||||
'subject': _decode_header(msg.get('Subject', '')),
|
||||
'from': _decode_header(msg.get('From', '')),
|
||||
'to': _decode_header(msg.get('To', '')),
|
||||
'cc': _decode_header(msg.get('Cc', '')),
|
||||
'date': msg.get('Date', ''),
|
||||
'html_body': html_body,
|
||||
'text_body': text_body,
|
||||
'attachments': attachments,
|
||||
'flags': [f.decode() if isinstance(f, bytes) else str(f) for f in flags],
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# --- Send ---
|
||||
|
||||
@api_bp.route('/email/send', methods=['POST'])
|
||||
@token_required
|
||||
def send_email():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
|
||||
account_id = data.get('account_id')
|
||||
if not account_id:
|
||||
return jsonify({'error': 'Konto-ID erforderlich'}), 400
|
||||
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if not enc_key:
|
||||
return jsonify({'error': 'Verschluesselungs-Key erforderlich'}), 400
|
||||
|
||||
to_addr = data.get('to', '')
|
||||
cc_addr = data.get('cc', '')
|
||||
subject = data.get('subject', '')
|
||||
body_html = data.get('body_html', '')
|
||||
body_text = data.get('body_text', '')
|
||||
|
||||
if not to_addr:
|
||||
return jsonify({'error': 'Empfaenger erforderlich'}), 400
|
||||
|
||||
password = decrypt_field(account.password_encrypted, enc_key)
|
||||
|
||||
# Build message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['From'] = f'{account.display_name} <{account.email_address}>'
|
||||
msg['To'] = to_addr
|
||||
if cc_addr:
|
||||
msg['Cc'] = cc_addr
|
||||
msg['Subject'] = subject
|
||||
msg['Date'] = email_lib.utils.formatdate(localtime=True)
|
||||
|
||||
if body_text:
|
||||
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
|
||||
if body_html:
|
||||
msg.attach(MIMEText(body_html, 'html', 'utf-8'))
|
||||
|
||||
try:
|
||||
if account.smtp_ssl and account.smtp_port == 465:
|
||||
server = smtplib.SMTP_SSL(account.smtp_host, account.smtp_port)
|
||||
else:
|
||||
server = smtplib.SMTP(account.smtp_host, account.smtp_port)
|
||||
server.starttls()
|
||||
|
||||
server.login(account.username, password)
|
||||
|
||||
recipients = [to_addr]
|
||||
if cc_addr:
|
||||
recipients.extend(cc_addr.split(','))
|
||||
|
||||
server.sendmail(account.email_address, recipients, msg.as_string())
|
||||
server.quit()
|
||||
|
||||
return jsonify({'message': 'E-Mail gesendet'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Sendefehler: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- Flag / Move / Delete ---
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/messages/<int:uid>/flag', methods=['POST'])
|
||||
@token_required
|
||||
def flag_message(account_id, uid):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
data = request.get_json()
|
||||
folder = data.get('folder', 'INBOX')
|
||||
flag = data.get('flag', '\\Seen')
|
||||
add = data.get('add', True)
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.select_folder(folder)
|
||||
if add:
|
||||
conn.add_flags([uid], [flag])
|
||||
else:
|
||||
conn.remove_flags([uid], [flag])
|
||||
conn.logout()
|
||||
return jsonify({'message': 'Flag gesetzt'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/messages/<int:uid>/move', methods=['POST'])
|
||||
@token_required
|
||||
def move_message(account_id, uid):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
data = request.get_json()
|
||||
folder = data.get('folder', 'INBOX')
|
||||
target = data.get('target')
|
||||
|
||||
if not target:
|
||||
return jsonify({'error': 'Ziel-Ordner erforderlich'}), 400
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.select_folder(folder)
|
||||
conn.move([uid], target)
|
||||
conn.logout()
|
||||
return jsonify({'message': 'Nachricht verschoben'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/messages/<int:uid>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_message(account_id, uid):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
folder = request.args.get('folder', 'INBOX')
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.select_folder(folder)
|
||||
conn.delete_messages([uid])
|
||||
conn.expunge()
|
||||
conn.logout()
|
||||
return jsonify({'message': 'Nachricht geloescht'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,571 @@
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
import secrets
|
||||
import mimetypes
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from flask import request, jsonify, send_file, current_app
|
||||
|
||||
from app.api import api_bp
|
||||
from app.api.auth import token_required
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models.file import File, FilePermission, ShareLink
|
||||
|
||||
|
||||
def _user_upload_dir(user_id):
|
||||
base = Path(current_app.config['UPLOAD_PATH'])
|
||||
user_dir = base / str(user_id)
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
return user_dir
|
||||
|
||||
|
||||
def _check_file_access(file_obj, user, permission='read'):
|
||||
"""Check if user has access to file. Owner always has full access."""
|
||||
if file_obj.owner_id == user.id:
|
||||
return True
|
||||
perm = FilePermission.query.filter_by(
|
||||
file_id=file_obj.id, user_id=user.id
|
||||
).first()
|
||||
if not perm:
|
||||
return False
|
||||
perm_levels = {'read': 0, 'write': 1, 'admin': 2}
|
||||
return perm_levels.get(perm.permission, -1) >= perm_levels.get(permission, 0)
|
||||
|
||||
|
||||
def _get_file_or_403(file_id, user, permission='read'):
|
||||
f = db.session.get(File, file_id)
|
||||
if not f:
|
||||
return None, (jsonify({'error': 'Datei nicht gefunden'}), 404)
|
||||
if not _check_file_access(f, user, permission):
|
||||
return None, (jsonify({'error': 'Zugriff verweigert'}), 403)
|
||||
return f, None
|
||||
|
||||
|
||||
def _compute_checksum(filepath):
|
||||
h = hashlib.sha256()
|
||||
with open(filepath, 'rb') as fh:
|
||||
for chunk in iter(lambda: fh.read(8192), b''):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# --- Folder / File listing ---
|
||||
|
||||
@api_bp.route('/files', methods=['GET'])
|
||||
@token_required
|
||||
def list_files():
|
||||
user = request.current_user
|
||||
parent_id = request.args.get('parent_id', None, type=int)
|
||||
|
||||
# Own files in this folder
|
||||
query = File.query.filter_by(owner_id=user.id, parent_id=parent_id)
|
||||
files = query.order_by(File.is_folder.desc(), File.name).all()
|
||||
|
||||
# Shared files at root level
|
||||
shared = []
|
||||
if parent_id is None:
|
||||
shared_perms = FilePermission.query.filter_by(user_id=user.id).all()
|
||||
shared_file_ids = [p.file_id for p in shared_perms]
|
||||
if shared_file_ids:
|
||||
shared = File.query.filter(
|
||||
File.id.in_(shared_file_ids),
|
||||
File.parent_id.is_(None)
|
||||
).order_by(File.is_folder.desc(), File.name).all()
|
||||
|
||||
result = [f.to_dict() for f in files]
|
||||
for f in shared:
|
||||
d = f.to_dict()
|
||||
d['shared'] = True
|
||||
result.append(d)
|
||||
|
||||
# Build breadcrumb
|
||||
breadcrumb = []
|
||||
if parent_id:
|
||||
current = db.session.get(File, parent_id)
|
||||
while current:
|
||||
breadcrumb.insert(0, {'id': current.id, 'name': current.name})
|
||||
current = current.parent
|
||||
|
||||
return jsonify({'files': result, 'breadcrumb': breadcrumb}), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/folder', methods=['POST'])
|
||||
@token_required
|
||||
def create_folder():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
parent_id = data.get('parent_id', None)
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Ordnername erforderlich'}), 400
|
||||
|
||||
if parent_id:
|
||||
parent, err = _get_file_or_403(parent_id, user, 'write')
|
||||
if err:
|
||||
return err
|
||||
if not parent.is_folder:
|
||||
return jsonify({'error': 'Uebergeordnetes Element ist kein Ordner'}), 400
|
||||
|
||||
existing = File.query.filter_by(
|
||||
owner_id=user.id, parent_id=parent_id, name=name, is_folder=True
|
||||
).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Ordner existiert bereits'}), 409
|
||||
|
||||
folder = File(
|
||||
owner_id=user.id,
|
||||
parent_id=parent_id,
|
||||
name=name,
|
||||
is_folder=True,
|
||||
)
|
||||
db.session.add(folder)
|
||||
db.session.commit()
|
||||
return jsonify(folder.to_dict()), 201
|
||||
|
||||
|
||||
# --- Upload ---
|
||||
|
||||
@api_bp.route('/files/upload', methods=['POST'])
|
||||
@token_required
|
||||
def upload_file():
|
||||
user = request.current_user
|
||||
parent_id = request.form.get('parent_id', None, type=int)
|
||||
|
||||
if parent_id:
|
||||
parent, err = _get_file_or_403(parent_id, user, 'write')
|
||||
if err:
|
||||
return err
|
||||
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'Keine Datei gesendet'}), 400
|
||||
|
||||
uploaded = request.files['file']
|
||||
if not uploaded.filename:
|
||||
return jsonify({'error': 'Leerer Dateiname'}), 400
|
||||
|
||||
filename = uploaded.filename
|
||||
mime = uploaded.content_type or mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
||||
|
||||
# Save to disk with UUID name
|
||||
storage_name = str(uuid.uuid4())
|
||||
user_dir = _user_upload_dir(user.id)
|
||||
storage_path = user_dir / storage_name
|
||||
uploaded.save(str(storage_path))
|
||||
|
||||
size = os.path.getsize(str(storage_path))
|
||||
checksum = _compute_checksum(str(storage_path))
|
||||
|
||||
# Check if file with same name exists -> overwrite
|
||||
existing = File.query.filter_by(
|
||||
owner_id=user.id, parent_id=parent_id, name=filename, is_folder=False
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Remove old file from disk
|
||||
old_path = Path(current_app.config['UPLOAD_PATH']) / str(user.id) / existing.storage_path
|
||||
if old_path.exists():
|
||||
old_path.unlink()
|
||||
existing.storage_path = storage_name
|
||||
existing.size = size
|
||||
existing.mime_type = mime
|
||||
existing.checksum = checksum
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return jsonify(existing.to_dict()), 200
|
||||
|
||||
file_obj = File(
|
||||
owner_id=user.id,
|
||||
parent_id=parent_id,
|
||||
name=filename,
|
||||
is_folder=False,
|
||||
mime_type=mime,
|
||||
size=size,
|
||||
storage_path=storage_name,
|
||||
checksum=checksum,
|
||||
)
|
||||
db.session.add(file_obj)
|
||||
db.session.commit()
|
||||
return jsonify(file_obj.to_dict()), 201
|
||||
|
||||
|
||||
# --- Download ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/download', methods=['GET'])
|
||||
@token_required
|
||||
def download_file(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'read')
|
||||
if err:
|
||||
return err
|
||||
if f.is_folder:
|
||||
return jsonify({'error': 'Ordner koennen nicht heruntergeladen werden'}), 400
|
||||
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path
|
||||
if not filepath.exists():
|
||||
return jsonify({'error': 'Datei auf Datentraeger nicht gefunden'}), 404
|
||||
|
||||
return send_file(str(filepath), mimetype=f.mime_type, as_attachment=True,
|
||||
download_name=f.name)
|
||||
|
||||
|
||||
# --- Rename / Move ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_file(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'write')
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
if 'name' in data:
|
||||
name = data['name'].strip()
|
||||
if name:
|
||||
f.name = name
|
||||
|
||||
if 'parent_id' in data:
|
||||
new_parent = data['parent_id']
|
||||
if new_parent is not None:
|
||||
parent, perr = _get_file_or_403(new_parent, user, 'write')
|
||||
if perr:
|
||||
return perr
|
||||
if not parent.is_folder:
|
||||
return jsonify({'error': 'Ziel ist kein Ordner'}), 400
|
||||
# Prevent moving folder into itself
|
||||
if f.is_folder:
|
||||
check = parent
|
||||
while check:
|
||||
if check.id == f.id:
|
||||
return jsonify({'error': 'Ordner kann nicht in sich selbst verschoben werden'}), 400
|
||||
check = check.parent
|
||||
f.parent_id = new_parent
|
||||
|
||||
f.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return jsonify(f.to_dict()), 200
|
||||
|
||||
|
||||
# --- Delete ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_file(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'admin')
|
||||
if err:
|
||||
# Owner can always delete
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id:
|
||||
return jsonify({'error': 'Zugriff verweigert'}), 403
|
||||
|
||||
_delete_recursive(f, user.id)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Geloescht'}), 200
|
||||
|
||||
|
||||
def _delete_recursive(file_obj, user_id):
|
||||
if file_obj.is_folder:
|
||||
children = File.query.filter_by(parent_id=file_obj.id).all()
|
||||
for child in children:
|
||||
_delete_recursive(child, user_id)
|
||||
else:
|
||||
if file_obj.storage_path:
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(file_obj.owner_id) / file_obj.storage_path
|
||||
if filepath.exists():
|
||||
filepath.unlink()
|
||||
db.session.delete(file_obj)
|
||||
|
||||
|
||||
# --- Permissions ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/permissions', methods=['GET'])
|
||||
@token_required
|
||||
def get_permissions(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'admin')
|
||||
if err:
|
||||
if not (f := db.session.get(File, file_id)) or f.owner_id != user.id:
|
||||
return jsonify({'error': 'Zugriff verweigert'}), 403
|
||||
|
||||
perms = FilePermission.query.filter_by(file_id=file_id).all()
|
||||
from app.models.user import User
|
||||
result = []
|
||||
for p in perms:
|
||||
u = db.session.get(User, p.user_id)
|
||||
result.append({
|
||||
'id': p.id,
|
||||
'user_id': p.user_id,
|
||||
'username': u.username if u else None,
|
||||
'permission': p.permission,
|
||||
})
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/permissions', methods=['POST'])
|
||||
@token_required
|
||||
def set_permission(file_id):
|
||||
user = request.current_user
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id:
|
||||
return jsonify({'error': 'Nur der Eigentuemer kann Berechtigungen setzen'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
target_user_id = data.get('user_id')
|
||||
permission = data.get('permission', 'read')
|
||||
|
||||
if permission not in ('read', 'write', 'admin'):
|
||||
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
|
||||
|
||||
from app.models.user import User
|
||||
target = db.session.get(User, target_user_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
|
||||
existing = FilePermission.query.filter_by(
|
||||
file_id=file_id, user_id=target_user_id
|
||||
).first()
|
||||
if existing:
|
||||
existing.permission = permission
|
||||
else:
|
||||
perm = FilePermission(file_id=file_id, user_id=target_user_id, permission=permission)
|
||||
db.session.add(perm)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Berechtigung gesetzt'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/permissions/<int:perm_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def remove_permission(file_id, perm_id):
|
||||
user = request.current_user
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id:
|
||||
return jsonify({'error': 'Nur der Eigentuemer kann Berechtigungen entfernen'}), 403
|
||||
|
||||
perm = db.session.get(FilePermission, perm_id)
|
||||
if not perm or perm.file_id != file_id:
|
||||
return jsonify({'error': 'Berechtigung nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(perm)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Berechtigung entfernt'}), 200
|
||||
|
||||
|
||||
# --- Share Links ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/share', methods=['POST'])
|
||||
@token_required
|
||||
def create_share_link(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'read')
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
password = data.get('password')
|
||||
expires_at = data.get('expires_at')
|
||||
max_downloads = data.get('max_downloads')
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
password_hash = None
|
||||
if password:
|
||||
password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
|
||||
exp_dt = None
|
||||
if expires_at:
|
||||
try:
|
||||
exp_dt = datetime.fromisoformat(expires_at).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Ungueltiges Datumsformat'}), 400
|
||||
|
||||
link = ShareLink(
|
||||
file_id=file_id,
|
||||
token=token,
|
||||
password_hash=password_hash,
|
||||
expires_at=exp_dt,
|
||||
created_by=user.id,
|
||||
max_downloads=max_downloads,
|
||||
)
|
||||
db.session.add(link)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'token': token,
|
||||
'url': f'/share/{token}',
|
||||
'expires_at': exp_dt.isoformat() if exp_dt else None,
|
||||
'has_password': bool(password),
|
||||
}), 201
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/shares', methods=['GET'])
|
||||
@token_required
|
||||
def list_share_links(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'read')
|
||||
if err:
|
||||
return err
|
||||
|
||||
links = ShareLink.query.filter_by(file_id=file_id).all()
|
||||
return jsonify([{
|
||||
'id': l.id,
|
||||
'token': l.token,
|
||||
'has_password': bool(l.password_hash),
|
||||
'expires_at': l.expires_at.isoformat() if l.expires_at else None,
|
||||
'download_count': l.download_count,
|
||||
'max_downloads': l.max_downloads,
|
||||
'created_at': l.created_at.isoformat(),
|
||||
} for l in links]), 200
|
||||
|
||||
|
||||
@api_bp.route('/share/<token>/info', methods=['GET'])
|
||||
def share_info(token):
|
||||
link = ShareLink.query.filter_by(token=token).first()
|
||||
if not link:
|
||||
return jsonify({'error': 'Link nicht gefunden'}), 404
|
||||
|
||||
if link.is_expired():
|
||||
return jsonify({'error': 'Link abgelaufen'}), 410
|
||||
|
||||
if link.is_download_limit_reached():
|
||||
return jsonify({'error': 'Download-Limit erreicht'}), 410
|
||||
|
||||
f = db.session.get(File, link.file_id)
|
||||
return jsonify({
|
||||
'name': f.name,
|
||||
'is_folder': f.is_folder,
|
||||
'size': f.size,
|
||||
'mime_type': f.mime_type,
|
||||
'has_password': bool(link.password_hash),
|
||||
}), 200
|
||||
|
||||
|
||||
@api_bp.route('/share/<token>/verify', methods=['POST'])
|
||||
def share_verify(token):
|
||||
link = ShareLink.query.filter_by(token=token).first()
|
||||
if not link:
|
||||
return jsonify({'error': 'Link nicht gefunden'}), 404
|
||||
|
||||
if link.is_expired():
|
||||
return jsonify({'error': 'Link abgelaufen'}), 410
|
||||
|
||||
data = request.get_json() or {}
|
||||
password = data.get('password', '')
|
||||
|
||||
if link.password_hash:
|
||||
if not bcrypt.check_password_hash(link.password_hash, password):
|
||||
return jsonify({'error': 'Falsches Passwort'}), 401
|
||||
|
||||
# Generate temporary download token
|
||||
download_token = secrets.token_urlsafe(16)
|
||||
# Store in link temporarily (simple approach)
|
||||
link._download_token = download_token
|
||||
return jsonify({'download_token': download_token}), 200
|
||||
|
||||
|
||||
@api_bp.route('/share/<token>/download', methods=['GET'])
|
||||
def share_download(token):
|
||||
link = ShareLink.query.filter_by(token=token).first()
|
||||
if not link:
|
||||
return jsonify({'error': 'Link nicht gefunden'}), 404
|
||||
|
||||
if link.is_expired():
|
||||
return jsonify({'error': 'Link abgelaufen'}), 410
|
||||
|
||||
if link.is_download_limit_reached():
|
||||
return jsonify({'error': 'Download-Limit erreicht'}), 410
|
||||
|
||||
# Check password if set
|
||||
if link.password_hash:
|
||||
# For password-protected links, require the password as query param or header
|
||||
password = request.args.get('password', '') or request.headers.get('X-Share-Password', '')
|
||||
if not bcrypt.check_password_hash(link.password_hash, password):
|
||||
return jsonify({'error': 'Passwort erforderlich'}), 401
|
||||
|
||||
f = db.session.get(File, link.file_id)
|
||||
if f.is_folder:
|
||||
return jsonify({'error': 'Ordner-Download noch nicht implementiert'}), 501
|
||||
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path
|
||||
if not filepath.exists():
|
||||
return jsonify({'error': 'Datei nicht gefunden'}), 404
|
||||
|
||||
link.download_count += 1
|
||||
db.session.commit()
|
||||
|
||||
return send_file(str(filepath), mimetype=f.mime_type, as_attachment=True,
|
||||
download_name=f.name)
|
||||
|
||||
|
||||
@api_bp.route('/share/<token>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_share_link(token):
|
||||
user = request.current_user
|
||||
link = ShareLink.query.filter_by(token=token).first()
|
||||
if not link:
|
||||
return jsonify({'error': 'Link nicht gefunden'}), 404
|
||||
|
||||
if link.created_by != user.id:
|
||||
return jsonify({'error': 'Nur der Ersteller kann den Link loeschen'}), 403
|
||||
|
||||
db.session.delete(link)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Link geloescht'}), 200
|
||||
|
||||
|
||||
# --- Sync API ---
|
||||
|
||||
@api_bp.route('/sync/tree', methods=['GET'])
|
||||
@token_required
|
||||
def sync_tree():
|
||||
"""Returns complete file tree with checksums for sync clients."""
|
||||
user = request.current_user
|
||||
|
||||
def _build_tree(parent_id):
|
||||
files = File.query.filter_by(owner_id=user.id, parent_id=parent_id)\
|
||||
.order_by(File.is_folder.desc(), File.name).all()
|
||||
result = []
|
||||
for f in files:
|
||||
entry = {
|
||||
'id': f.id,
|
||||
'name': f.name,
|
||||
'is_folder': f.is_folder,
|
||||
'size': f.size,
|
||||
'checksum': f.checksum,
|
||||
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
if f.is_folder:
|
||||
entry['children'] = _build_tree(f.id)
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
return jsonify({'tree': _build_tree(None)}), 200
|
||||
|
||||
|
||||
@api_bp.route('/sync/changes', methods=['GET'])
|
||||
@token_required
|
||||
def sync_changes():
|
||||
"""Returns files changed since a given timestamp."""
|
||||
user = request.current_user
|
||||
since = request.args.get('since')
|
||||
|
||||
if not since:
|
||||
return jsonify({'error': 'Parameter "since" erforderlich'}), 400
|
||||
|
||||
try:
|
||||
since_dt = datetime.fromisoformat(since).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Ungueltiges Datumsformat'}), 400
|
||||
|
||||
changed = File.query.filter(
|
||||
File.owner_id == user.id,
|
||||
File.updated_at > since_dt
|
||||
).all()
|
||||
|
||||
return jsonify({
|
||||
'changes': [f.to_dict() for f in changed],
|
||||
'server_time': datetime.now(timezone.utc).isoformat(),
|
||||
}), 200
|
||||
@@ -0,0 +1,170 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from flask import request, jsonify, current_app, send_file
|
||||
|
||||
from app.api import api_bp
|
||||
from app.api.auth import token_required
|
||||
from app.api.files import _get_file_or_403
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/preview', methods=['GET'])
|
||||
@token_required
|
||||
def preview_file(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'read')
|
||||
if err:
|
||||
return err
|
||||
|
||||
if f.is_folder:
|
||||
return jsonify({'error': 'Ordner haben keine Vorschau'}), 400
|
||||
|
||||
mime = f.mime_type or ''
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path
|
||||
|
||||
if not filepath.exists():
|
||||
return jsonify({'error': 'Datei nicht gefunden'}), 404
|
||||
|
||||
# PDF -> just return URL for PDF.js to load
|
||||
if 'pdf' in mime:
|
||||
return jsonify({
|
||||
'type': 'pdf',
|
||||
'url': f'/api/files/{file_id}/download',
|
||||
'name': f.name,
|
||||
}), 200
|
||||
|
||||
# DOCX
|
||||
if mime in ('application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/msword') or f.name.endswith('.docx'):
|
||||
try:
|
||||
html = _convert_docx(filepath)
|
||||
return jsonify({'type': 'html', 'content': html, 'name': f.name}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'DOCX-Vorschau fehlgeschlagen: {str(e)}'}), 500
|
||||
|
||||
# XLSX
|
||||
if mime in ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel') or f.name.endswith('.xlsx'):
|
||||
try:
|
||||
data = _convert_xlsx(filepath)
|
||||
return jsonify({'type': 'spreadsheet', 'sheets': data, 'name': f.name}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'XLSX-Vorschau fehlgeschlagen: {str(e)}'}), 500
|
||||
|
||||
# PPTX
|
||||
if mime in ('application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.ms-powerpoint') or f.name.endswith('.pptx'):
|
||||
try:
|
||||
slides = _convert_pptx(filepath)
|
||||
return jsonify({'type': 'slides', 'slides': slides, 'name': f.name}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'PPTX-Vorschau fehlgeschlagen: {str(e)}'}), 500
|
||||
|
||||
# Images
|
||||
if mime.startswith('image/'):
|
||||
return jsonify({
|
||||
'type': 'image',
|
||||
'url': f'/api/files/{file_id}/download',
|
||||
'name': f.name,
|
||||
}), 200
|
||||
|
||||
# Text files
|
||||
if mime.startswith('text/') or f.name.endswith(('.txt', '.md', '.json', '.xml', '.csv',
|
||||
'.py', '.js', '.html', '.css', '.yml', '.yaml')):
|
||||
try:
|
||||
content = filepath.read_text(encoding='utf-8', errors='replace')[:100000]
|
||||
return jsonify({'type': 'text', 'content': content, 'name': f.name}), 200
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'type': 'unsupported', 'name': f.name, 'mime_type': mime}), 200
|
||||
|
||||
|
||||
def _convert_docx(filepath):
|
||||
from docx import Document
|
||||
doc = Document(str(filepath))
|
||||
html_parts = []
|
||||
for para in doc.paragraphs:
|
||||
style = para.style.name if para.style else ''
|
||||
text = para.text
|
||||
if not text.strip():
|
||||
html_parts.append('<br/>')
|
||||
continue
|
||||
if 'Heading 1' in style:
|
||||
html_parts.append(f'<h1>{text}</h1>')
|
||||
elif 'Heading 2' in style:
|
||||
html_parts.append(f'<h2>{text}</h2>')
|
||||
elif 'Heading 3' in style:
|
||||
html_parts.append(f'<h3>{text}</h3>')
|
||||
else:
|
||||
# Check for bold/italic runs
|
||||
run_html = ''
|
||||
for run in para.runs:
|
||||
t = run.text
|
||||
if run.bold:
|
||||
t = f'<strong>{t}</strong>'
|
||||
if run.italic:
|
||||
t = f'<em>{t}</em>'
|
||||
if run.underline:
|
||||
t = f'<u>{t}</u>'
|
||||
run_html += t
|
||||
html_parts.append(f'<p>{run_html}</p>')
|
||||
|
||||
# Tables
|
||||
for table in doc.tables:
|
||||
html_parts.append('<table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%">')
|
||||
for i, row in enumerate(table.rows):
|
||||
html_parts.append('<tr>')
|
||||
tag = 'th' if i == 0 else 'td'
|
||||
for cell in row.cells:
|
||||
html_parts.append(f'<{tag}>{cell.text}</{tag}>')
|
||||
html_parts.append('</tr>')
|
||||
html_parts.append('</table>')
|
||||
|
||||
return '\n'.join(html_parts)
|
||||
|
||||
|
||||
def _convert_xlsx(filepath):
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(str(filepath), read_only=True, data_only=True)
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
rows = []
|
||||
for row in ws.iter_rows(max_row=500, values_only=True):
|
||||
rows.append([str(cell) if cell is not None else '' for cell in row])
|
||||
sheets.append({
|
||||
'name': ws.title,
|
||||
'rows': rows,
|
||||
})
|
||||
wb.close()
|
||||
return sheets
|
||||
|
||||
|
||||
def _convert_pptx(filepath):
|
||||
from pptx import Presentation
|
||||
prs = Presentation(str(filepath))
|
||||
slides = []
|
||||
for i, slide in enumerate(prs.slides):
|
||||
content_parts = []
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame:
|
||||
for para in shape.text_frame.paragraphs:
|
||||
text = para.text.strip()
|
||||
if text:
|
||||
content_parts.append(f'<p>{text}</p>')
|
||||
if shape.has_table:
|
||||
table_html = '<table border="1" cellpadding="4" style="border-collapse: collapse">'
|
||||
for row in shape.table.rows:
|
||||
table_html += '<tr>'
|
||||
for cell in row.cells:
|
||||
table_html += f'<td>{cell.text}</td>'
|
||||
table_html += '</tr>'
|
||||
table_html += '</table>'
|
||||
content_parts.append(table_html)
|
||||
|
||||
slides.append({
|
||||
'index': i,
|
||||
'html': '\n'.join(content_parts) if content_parts else '<p>(Leere Folie)</p>',
|
||||
})
|
||||
return slides
|
||||
@@ -0,0 +1,361 @@
|
||||
import base64
|
||||
|
||||
from flask import request, jsonify
|
||||
|
||||
from app.api import api_bp
|
||||
from app.api.auth import token_required
|
||||
from app.extensions import db
|
||||
from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
# --- Folders ---
|
||||
|
||||
@api_bp.route('/passwords/folders', methods=['GET'])
|
||||
@token_required
|
||||
def list_password_folders():
|
||||
user = request.current_user
|
||||
own = PasswordFolder.query.filter_by(owner_id=user.id).all()
|
||||
|
||||
# Get shared folders
|
||||
shared_folder_shares = PasswordShare.query.filter_by(
|
||||
shared_with_id=user.id, shareable_type='folder'
|
||||
).all()
|
||||
shared_ids = [s.shareable_id for s in shared_folder_shares]
|
||||
shared = PasswordFolder.query.filter(PasswordFolder.id.in_(shared_ids)).all() if shared_ids else []
|
||||
|
||||
result = []
|
||||
for f in own:
|
||||
d = f.to_dict()
|
||||
d['permission'] = 'owner'
|
||||
result.append(d)
|
||||
for f in shared:
|
||||
d = f.to_dict()
|
||||
share = next((s for s in shared_folder_shares if s.shareable_id == f.id), None)
|
||||
d['permission'] = share.permission if share else 'read'
|
||||
d['owner_name'] = f.owner.username
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/folders', methods=['POST'])
|
||||
@token_required
|
||||
def create_password_folder():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name erforderlich'}), 400
|
||||
|
||||
folder = PasswordFolder(
|
||||
owner_id=user.id,
|
||||
parent_id=data.get('parent_id'),
|
||||
name=name,
|
||||
icon=data.get('icon'),
|
||||
)
|
||||
db.session.add(folder)
|
||||
db.session.commit()
|
||||
return jsonify(folder.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/passwords/folders/<int:folder_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_password_folder(folder_id):
|
||||
user = request.current_user
|
||||
folder = db.session.get(PasswordFolder, folder_id)
|
||||
if not folder or folder.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if 'name' in data:
|
||||
folder.name = data['name'].strip()
|
||||
if 'icon' in data:
|
||||
folder.icon = data['icon']
|
||||
if 'parent_id' in data:
|
||||
folder.parent_id = data['parent_id']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(folder.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/folders/<int:folder_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_password_folder(folder_id):
|
||||
user = request.current_user
|
||||
folder = db.session.get(PasswordFolder, folder_id)
|
||||
if not folder or folder.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(folder)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Ordner geloescht'}), 200
|
||||
|
||||
|
||||
# --- Entries ---
|
||||
|
||||
@api_bp.route('/passwords/entries', methods=['GET'])
|
||||
@token_required
|
||||
def list_password_entries():
|
||||
user = request.current_user
|
||||
folder_id = request.args.get('folder_id', None, type=int)
|
||||
category = request.args.get('category', None)
|
||||
|
||||
query = PasswordEntry.query.filter_by(user_id=user.id)
|
||||
if folder_id is not None:
|
||||
query = query.filter_by(folder_id=folder_id)
|
||||
if category:
|
||||
query = query.filter_by(category=category)
|
||||
|
||||
entries = query.order_by(PasswordEntry.created_at.desc()).all()
|
||||
|
||||
# Also get shared entries
|
||||
shared_entry_shares = PasswordShare.query.filter_by(
|
||||
shared_with_id=user.id, shareable_type='entry'
|
||||
).all()
|
||||
shared_ids = [s.shareable_id for s in shared_entry_shares]
|
||||
shared = PasswordEntry.query.filter(PasswordEntry.id.in_(shared_ids)).all() if shared_ids else []
|
||||
|
||||
result = [e.to_dict() for e in entries]
|
||||
for e in shared:
|
||||
d = e.to_dict()
|
||||
d['shared'] = True
|
||||
share = next((s for s in shared_entry_shares if s.shareable_id == e.id), None)
|
||||
d['permission'] = share.permission if share else 'read'
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/entries', methods=['POST'])
|
||||
@token_required
|
||||
def create_password_entry():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
|
||||
if 'title_encrypted' not in data or 'iv' not in data:
|
||||
return jsonify({'error': 'Verschluesselte Daten + IV erforderlich'}), 400
|
||||
|
||||
entry = PasswordEntry(
|
||||
user_id=user.id,
|
||||
folder_id=data.get('folder_id'),
|
||||
title_encrypted=base64.b64decode(data['title_encrypted']),
|
||||
url_encrypted=base64.b64decode(data['url_encrypted']) if data.get('url_encrypted') else None,
|
||||
username_encrypted=base64.b64decode(data['username_encrypted']) if data.get('username_encrypted') else None,
|
||||
password_encrypted=base64.b64decode(data['password_encrypted']) if data.get('password_encrypted') else None,
|
||||
notes_encrypted=base64.b64decode(data['notes_encrypted']) if data.get('notes_encrypted') else None,
|
||||
totp_secret_encrypted=base64.b64decode(data['totp_secret_encrypted']) if data.get('totp_secret_encrypted') else None,
|
||||
passkey_data_encrypted=base64.b64decode(data['passkey_data_encrypted']) if data.get('passkey_data_encrypted') else None,
|
||||
iv=base64.b64decode(data['iv']),
|
||||
category=data.get('category'),
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
return jsonify(entry.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/passwords/entries/<int:entry_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_password_entry(entry_id):
|
||||
user = request.current_user
|
||||
entry = db.session.get(PasswordEntry, entry_id)
|
||||
if not entry:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
# Check access
|
||||
if entry.user_id != user.id:
|
||||
share = PasswordShare.query.filter_by(
|
||||
shareable_type='entry', shareable_id=entry_id, shared_with_id=user.id
|
||||
).first()
|
||||
if not share or share.permission not in ('write', 'manage'):
|
||||
return jsonify({'error': 'Zugriff verweigert'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
for field in ['title_encrypted', 'url_encrypted', 'username_encrypted',
|
||||
'password_encrypted', 'notes_encrypted', 'totp_secret_encrypted',
|
||||
'passkey_data_encrypted', 'iv']:
|
||||
if field in data and data[field]:
|
||||
setattr(entry, field, base64.b64decode(data[field]))
|
||||
|
||||
if 'category' in data:
|
||||
entry.category = data['category']
|
||||
if 'folder_id' in data:
|
||||
entry.folder_id = data['folder_id']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(entry.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/entries/<int:entry_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_password_entry(entry_id):
|
||||
user = request.current_user
|
||||
entry = db.session.get(PasswordEntry, entry_id)
|
||||
if not entry:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
if entry.user_id != user.id:
|
||||
return jsonify({'error': 'Zugriff verweigert'}), 403
|
||||
|
||||
db.session.delete(entry)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Eintrag geloescht'}), 200
|
||||
|
||||
|
||||
# --- Sharing ---
|
||||
|
||||
@api_bp.route('/passwords/share', methods=['POST'])
|
||||
@token_required
|
||||
def share_password():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
|
||||
shareable_type = data.get('type') # 'entry' or 'folder'
|
||||
shareable_id = data.get('id')
|
||||
username = data.get('username', '').strip()
|
||||
permission = data.get('permission', 'read')
|
||||
|
||||
if shareable_type not in ('entry', 'folder'):
|
||||
return jsonify({'error': 'Typ muss "entry" oder "folder" sein'}), 400
|
||||
|
||||
if permission not in ('read', 'write', 'manage'):
|
||||
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
|
||||
|
||||
# Verify ownership
|
||||
if shareable_type == 'entry':
|
||||
obj = db.session.get(PasswordEntry, shareable_id)
|
||||
if not obj or obj.user_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
else:
|
||||
obj = db.session.get(PasswordFolder, shareable_id)
|
||||
if not obj or obj.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
target = User.query.filter_by(username=username).first()
|
||||
if not target:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
if target.id == user.id:
|
||||
return jsonify({'error': 'Kann nicht mit sich selbst teilen'}), 400
|
||||
|
||||
existing = PasswordShare.query.filter_by(
|
||||
shareable_type=shareable_type, shareable_id=shareable_id,
|
||||
shared_with_id=target.id
|
||||
).first()
|
||||
if existing:
|
||||
existing.permission = permission
|
||||
else:
|
||||
share = PasswordShare(
|
||||
shareable_type=shareable_type,
|
||||
shareable_id=shareable_id,
|
||||
shared_by_id=user.id,
|
||||
shared_with_id=target.id,
|
||||
permission=permission,
|
||||
encrypted_key=base64.b64decode(data['encrypted_key']) if data.get('encrypted_key') else None,
|
||||
)
|
||||
db.session.add(share)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': f'Mit {username} geteilt'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/shares', methods=['GET'])
|
||||
@token_required
|
||||
def list_password_shares():
|
||||
user = request.current_user
|
||||
shareable_type = request.args.get('type')
|
||||
shareable_id = request.args.get('id', type=int)
|
||||
|
||||
query = PasswordShare.query.filter_by(shared_by_id=user.id)
|
||||
if shareable_type:
|
||||
query = query.filter_by(shareable_type=shareable_type)
|
||||
if shareable_id:
|
||||
query = query.filter_by(shareable_id=shareable_id)
|
||||
|
||||
shares = query.all()
|
||||
return jsonify([{
|
||||
'id': s.id,
|
||||
'type': s.shareable_type,
|
||||
'shareable_id': s.shareable_id,
|
||||
'shared_with': s.shared_with.username,
|
||||
'permission': s.permission,
|
||||
} for s in shares]), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/shares/<int:share_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def remove_password_share(share_id):
|
||||
user = request.current_user
|
||||
share = db.session.get(PasswordShare, share_id)
|
||||
if not share or share.shared_by_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(share)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Freigabe entfernt'}), 200
|
||||
|
||||
|
||||
# --- KeePass Import ---
|
||||
|
||||
@api_bp.route('/passwords/import/keepass', methods=['POST'])
|
||||
@token_required
|
||||
def import_keepass():
|
||||
user = request.current_user
|
||||
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'Keine Datei gesendet'}), 400
|
||||
|
||||
kdbx_file = request.files['file']
|
||||
kdbx_password = request.form.get('password', '')
|
||||
|
||||
if not kdbx_password:
|
||||
return jsonify({'error': 'KeePass-Passwort erforderlich'}), 400
|
||||
|
||||
try:
|
||||
from pykeepass import PyKeePass
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Save to temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.kdbx') as tmp:
|
||||
kdbx_file.save(tmp.name)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
kp = PyKeePass(tmp_path, password=kdbx_password)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
# Return entries as plaintext - frontend will encrypt them
|
||||
entries = []
|
||||
groups = []
|
||||
|
||||
for group in kp.groups:
|
||||
if group.name and group.name not in ('Root', 'Recycle Bin'):
|
||||
groups.append({
|
||||
'name': group.name,
|
||||
'path': '/'.join(g.name for g in group.path if g.name),
|
||||
'uuid': str(group.uuid),
|
||||
'parent_uuid': str(group.parentgroup.uuid) if group.parentgroup else None,
|
||||
})
|
||||
|
||||
for entry in kp.entries:
|
||||
if entry.title:
|
||||
group_path = '/'.join(g.name for g in entry.group.path if g.name) if entry.group else ''
|
||||
entries.append({
|
||||
'title': entry.title or '',
|
||||
'url': entry.url or '',
|
||||
'username': entry.username or '',
|
||||
'password': entry.password or '',
|
||||
'notes': entry.notes or '',
|
||||
'totp': entry.otp or '',
|
||||
'group': group_path,
|
||||
'group_uuid': str(entry.group.uuid) if entry.group else None,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'entries': entries,
|
||||
'groups': groups,
|
||||
'count': len(entries),
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Import fehlgeschlagen: {str(e)}'}), 400
|
||||
@@ -0,0 +1,111 @@
|
||||
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
|
||||
|
||||
|
||||
@api_bp.route('/users', methods=['GET'])
|
||||
@admin_required
|
||||
def list_users():
|
||||
users = User.query.order_by(User.created_at.desc()).all()
|
||||
return jsonify([u.to_dict(include_email=True) for u in users]), 200
|
||||
|
||||
|
||||
@api_bp.route('/users/<int:user_id>', methods=['GET'])
|
||||
@admin_required
|
||||
def get_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
return jsonify(user.to_dict(include_email=True)), 200
|
||||
|
||||
|
||||
@api_bp.route('/users/<int:user_id>', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'Keine Daten gesendet'}), 400
|
||||
|
||||
if 'email' in data:
|
||||
email = data['email'].strip() or None
|
||||
if email and email != user.email:
|
||||
if User.query.filter_by(email=email).first():
|
||||
return jsonify({'error': 'Email bereits vergeben'}), 409
|
||||
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:
|
||||
return jsonify({'error': 'Letzter Admin kann nicht herabgestuft werden'}), 400
|
||||
user.role = data['role']
|
||||
|
||||
if 'is_active' in data:
|
||||
if user.id == request.current_user.id and not data['is_active']:
|
||||
return jsonify({'error': 'Eigenes Konto kann nicht deaktiviert werden'}), 400
|
||||
user.is_active = data['is_active']
|
||||
|
||||
if 'storage_quota_mb' in data:
|
||||
user.storage_quota_mb = max(0, int(data['storage_quota_mb']))
|
||||
|
||||
if 'password' in data and data['password']:
|
||||
if len(data['password']) < 8:
|
||||
return jsonify({'error': 'Passwort muss mindestens 8 Zeichen lang sein'}), 400
|
||||
user.set_password(data['password'])
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(user.to_dict(include_email=True)), 200
|
||||
|
||||
|
||||
@api_bp.route('/users/<int:user_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
|
||||
if user.id == request.current_user.id:
|
||||
return jsonify({'error': 'Eigenes Konto kann nicht geloescht werden'}), 400
|
||||
|
||||
if user.role == 'admin':
|
||||
admin_count = User.query.filter_by(role='admin', is_active=True).count()
|
||||
if admin_count <= 1:
|
||||
return jsonify({'error': 'Letzter Admin kann nicht geloescht werden'}), 400
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Benutzer geloescht'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/change-password', methods=['POST'])
|
||||
@token_required
|
||||
def change_password():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'Keine Daten gesendet'}), 400
|
||||
|
||||
current_password = data.get('current_password', '')
|
||||
new_password = data.get('new_password', '')
|
||||
|
||||
if not current_password or not new_password:
|
||||
return jsonify({'error': 'Aktuelles und neues Passwort erforderlich'}), 400
|
||||
|
||||
if len(new_password) < 8:
|
||||
return jsonify({'error': 'Neues Passwort muss mindestens 8 Zeichen lang sein'}), 400
|
||||
|
||||
user = request.current_user
|
||||
if not user.check_password(current_password):
|
||||
return jsonify({'error': 'Aktuelles Passwort falsch'}), 401
|
||||
|
||||
user.set_password(new_password)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Passwort geaendert'}), 200
|
||||
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Project root: backend/app/config.py -> backend/app -> backend -> project_root
|
||||
basedir = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-me')
|
||||
|
||||
# Database - always resolve relative to project root
|
||||
_db_default = str(basedir / 'data' / 'minicloud.db')
|
||||
_db_env = os.environ.get('DATABASE_PATH', '')
|
||||
_db_path = str(basedir / _db_env) if _db_env and not os.path.isabs(_db_env) else (_db_env or _db_default)
|
||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{_db_path}'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# File uploads - always resolve relative to project root
|
||||
_upload_env = os.environ.get('UPLOAD_PATH', '')
|
||||
UPLOAD_PATH = str(basedir / _upload_env) if _upload_env and not os.path.isabs(_upload_env) else (_upload_env or str(basedir / 'data' / 'files'))
|
||||
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_UPLOAD_SIZE_MB', 500)) * 1024 * 1024
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'dev-jwt-secret-change-me')
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(
|
||||
seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 900))
|
||||
)
|
||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(
|
||||
seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 604800))
|
||||
)
|
||||
|
||||
# CORS
|
||||
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000')
|
||||
@@ -0,0 +1,7 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_bcrypt import Bcrypt
|
||||
from flask_migrate import Migrate
|
||||
|
||||
db = SQLAlchemy()
|
||||
bcrypt = Bcrypt()
|
||||
migrate = Migrate()
|
||||
@@ -0,0 +1,15 @@
|
||||
from app.models.user import User
|
||||
from app.models.file import File, FilePermission, ShareLink
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
'File', 'FilePermission', 'ShareLink',
|
||||
'Calendar', 'CalendarEvent', 'CalendarShare',
|
||||
'AddressBook', 'Contact', 'AddressBookShare',
|
||||
'EmailAccount',
|
||||
'PasswordFolder', 'PasswordEntry', 'PasswordShare',
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Calendar(db.Model):
|
||||
__tablename__ = 'calendars'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
color = db.Column(db.String(7), default='#3788d8')
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
ical_token = db.Column(db.String(64), unique=True, nullable=True, index=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
events = db.relationship('CalendarEvent', backref='calendar', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
shares = db.relationship('CalendarShare', backref='calendar', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'owner_id': self.owner_id,
|
||||
'name': self.name,
|
||||
'color': self.color,
|
||||
'description': self.description,
|
||||
'ical_token': self.ical_token,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class CalendarEvent(db.Model):
|
||||
__tablename__ = 'calendar_events'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
calendar_id = db.Column(db.Integer, db.ForeignKey('calendars.id'), nullable=False, index=True)
|
||||
uid = db.Column(db.String(255), unique=True, nullable=False)
|
||||
ical_data = db.Column(db.Text, nullable=False) # Full VCALENDAR component
|
||||
summary = db.Column(db.String(500), nullable=True)
|
||||
dtstart = db.Column(db.DateTime, nullable=True, index=True)
|
||||
dtend = db.Column(db.DateTime, nullable=True)
|
||||
all_day = db.Column(db.Boolean, default=False)
|
||||
recurrence_rule = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'calendar_id': self.calendar_id,
|
||||
'uid': self.uid,
|
||||
'summary': self.summary,
|
||||
'dtstart': self.dtstart.isoformat() if self.dtstart else None,
|
||||
'dtend': self.dtend.isoformat() if self.dtend else None,
|
||||
'all_day': self.all_day,
|
||||
'recurrence_rule': self.recurrence_rule,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class CalendarShare(db.Model):
|
||||
__tablename__ = 'calendar_shares'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
calendar_id = db.Column(db.Integer, db.ForeignKey('calendars.id'), nullable=False, index=True)
|
||||
shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
permission = db.Column(db.String(20), nullable=False, default='read') # 'read' or 'readwrite'
|
||||
|
||||
shared_with = db.relationship('User', backref='shared_calendars')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('calendar_id', 'shared_with_id', name='uq_calendar_share'),
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class AddressBook(db.Model):
|
||||
__tablename__ = 'address_books'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
contacts = db.relationship('Contact', backref='address_book', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
shares = db.relationship('AddressBookShare', backref='address_book', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'owner_id': self.owner_id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class Contact(db.Model):
|
||||
__tablename__ = 'contacts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
address_book_id = db.Column(db.Integer, db.ForeignKey('address_books.id'),
|
||||
nullable=False, index=True)
|
||||
uid = db.Column(db.String(255), unique=True, nullable=False)
|
||||
vcard_data = db.Column(db.Text, nullable=False) # Full VCARD
|
||||
display_name = db.Column(db.String(255), nullable=True, index=True)
|
||||
email = db.Column(db.String(255), nullable=True)
|
||||
phone = db.Column(db.String(50), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'address_book_id': self.address_book_id,
|
||||
'uid': self.uid,
|
||||
'display_name': self.display_name,
|
||||
'email': self.email,
|
||||
'phone': self.phone,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class AddressBookShare(db.Model):
|
||||
__tablename__ = 'address_book_shares'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
address_book_id = db.Column(db.Integer, db.ForeignKey('address_books.id'),
|
||||
nullable=False, index=True)
|
||||
shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
permission = db.Column(db.String(20), nullable=False, default='read')
|
||||
|
||||
shared_with = db.relationship('User', backref='shared_address_books')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('address_book_id', 'shared_with_id', name='uq_addressbook_share'),
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class EmailAccount(db.Model):
|
||||
__tablename__ = 'email_accounts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
display_name = db.Column(db.String(100), nullable=False) # e.g. "Arbeit", "Privat"
|
||||
email_address = db.Column(db.String(255), nullable=False)
|
||||
imap_host = db.Column(db.String(255), nullable=False)
|
||||
imap_port = db.Column(db.Integer, default=993)
|
||||
imap_ssl = db.Column(db.Boolean, default=True)
|
||||
smtp_host = db.Column(db.String(255), nullable=False)
|
||||
smtp_port = db.Column(db.Integer, default=587)
|
||||
smtp_ssl = db.Column(db.Boolean, default=True) # STARTTLS
|
||||
username = db.Column(db.String(255), nullable=False)
|
||||
password_encrypted = db.Column(db.LargeBinary, nullable=False)
|
||||
is_default = db.Column(db.Boolean, default=False)
|
||||
sort_order = db.Column(db.Integer, default=0)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'display_name': self.display_name,
|
||||
'email_address': self.email_address,
|
||||
'imap_host': self.imap_host,
|
||||
'imap_port': self.imap_port,
|
||||
'imap_ssl': self.imap_ssl,
|
||||
'smtp_host': self.smtp_host,
|
||||
'smtp_port': self.smtp_port,
|
||||
'smtp_ssl': self.smtp_ssl,
|
||||
'username': self.username,
|
||||
'is_default': self.is_default,
|
||||
'sort_order': self.sort_order,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class File(db.Model):
|
||||
__tablename__ = 'files'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=True, index=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
is_folder = db.Column(db.Boolean, default=False, nullable=False)
|
||||
mime_type = db.Column(db.String(255), nullable=True)
|
||||
size = db.Column(db.BigInteger, default=0)
|
||||
storage_path = db.Column(db.String(500), nullable=True) # UUID-based path on disk
|
||||
checksum = db.Column(db.String(64), nullable=True) # SHA-256 for sync
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
children = db.relationship('File', backref=db.backref('parent', remote_side='File.id'),
|
||||
lazy='dynamic')
|
||||
permissions = db.relationship('FilePermission', backref='file', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
share_links = db.relationship('ShareLink', backref='file', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'owner_id': self.owner_id,
|
||||
'parent_id': self.parent_id,
|
||||
'name': self.name,
|
||||
'is_folder': self.is_folder,
|
||||
'mime_type': self.mime_type,
|
||||
'size': self.size,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class FilePermission(db.Model):
|
||||
__tablename__ = 'file_permissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False, index=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
permission = db.Column(db.String(20), nullable=False) # 'read', 'write', 'admin'
|
||||
|
||||
user = db.relationship('User', backref='file_permissions')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('file_id', 'user_id', name='uq_file_user_permission'),
|
||||
)
|
||||
|
||||
|
||||
class ShareLink(db.Model):
|
||||
__tablename__ = 'share_links'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False, index=True)
|
||||
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=True)
|
||||
expires_at = db.Column(db.DateTime, nullable=True)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
download_count = db.Column(db.Integer, default=0)
|
||||
max_downloads = db.Column(db.Integer, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
creator = db.relationship('User', backref='share_links')
|
||||
|
||||
def is_expired(self):
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return datetime.now(timezone.utc) > self.expires_at
|
||||
|
||||
def is_download_limit_reached(self):
|
||||
if self.max_downloads is None:
|
||||
return False
|
||||
return self.download_count >= self.max_downloads
|
||||
@@ -0,0 +1,99 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class PasswordFolder(db.Model):
|
||||
__tablename__ = 'password_folders'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('password_folders.id'), nullable=True,
|
||||
index=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
icon = db.Column(db.String(50), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
children = db.relationship('PasswordFolder',
|
||||
backref=db.backref('parent', remote_side='PasswordFolder.id'),
|
||||
lazy='dynamic')
|
||||
entries = db.relationship('PasswordEntry', backref='folder', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'owner_id': self.owner_id,
|
||||
'parent_id': self.parent_id,
|
||||
'name': self.name,
|
||||
'icon': self.icon,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class PasswordEntry(db.Model):
|
||||
__tablename__ = 'password_entries'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
folder_id = db.Column(db.Integer, db.ForeignKey('password_folders.id'), nullable=True,
|
||||
index=True)
|
||||
# All sensitive fields are encrypted client-side (AES-256-GCM)
|
||||
title_encrypted = db.Column(db.LargeBinary, nullable=False)
|
||||
url_encrypted = db.Column(db.LargeBinary, nullable=True)
|
||||
username_encrypted = db.Column(db.LargeBinary, nullable=True)
|
||||
password_encrypted = db.Column(db.LargeBinary, nullable=True)
|
||||
notes_encrypted = db.Column(db.LargeBinary, nullable=True)
|
||||
totp_secret_encrypted = db.Column(db.LargeBinary, nullable=True)
|
||||
passkey_data_encrypted = db.Column(db.LargeBinary, nullable=True)
|
||||
# IV for each entry (needed for AES-GCM decryption)
|
||||
iv = db.Column(db.LargeBinary, nullable=False)
|
||||
category = db.Column(db.String(100), nullable=True) # Plaintext for filtering
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
user = db.relationship('User', backref='password_entries')
|
||||
|
||||
def to_dict(self):
|
||||
import base64
|
||||
return {
|
||||
'id': self.id,
|
||||
'folder_id': self.folder_id,
|
||||
'title_encrypted': base64.b64encode(self.title_encrypted).decode() if self.title_encrypted else None,
|
||||
'url_encrypted': base64.b64encode(self.url_encrypted).decode() if self.url_encrypted else None,
|
||||
'username_encrypted': base64.b64encode(self.username_encrypted).decode() if self.username_encrypted else None,
|
||||
'password_encrypted': base64.b64encode(self.password_encrypted).decode() if self.password_encrypted else None,
|
||||
'notes_encrypted': base64.b64encode(self.notes_encrypted).decode() if self.notes_encrypted else None,
|
||||
'totp_secret_encrypted': base64.b64encode(self.totp_secret_encrypted).decode() if self.totp_secret_encrypted else None,
|
||||
'passkey_data_encrypted': base64.b64encode(self.passkey_data_encrypted).decode() if self.passkey_data_encrypted else None,
|
||||
'iv': base64.b64encode(self.iv).decode() if self.iv else None,
|
||||
'category': self.category,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class PasswordShare(db.Model):
|
||||
__tablename__ = 'password_shares'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
shareable_type = db.Column(db.String(20), nullable=False) # 'entry' or 'folder'
|
||||
shareable_id = db.Column(db.Integer, nullable=False)
|
||||
shared_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
permission = db.Column(db.String(20), nullable=False, default='read') # 'read', 'write', 'manage'
|
||||
# Re-encrypted data for the recipient (encrypted with recipient's public key)
|
||||
encrypted_key = db.Column(db.LargeBinary, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
shared_by = db.relationship('User', foreign_keys=[shared_by_id], backref='password_shares_given')
|
||||
shared_with = db.relationship('User', foreign_keys=[shared_with_id], backref='password_shares_received')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('shareable_type', 'shareable_id', 'shared_with_id',
|
||||
name='uq_password_share'),
|
||||
db.Index('ix_password_shareable', 'shareable_type', 'shareable_id'),
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.extensions import db, bcrypt
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
email = db.Column(db.String(255), unique=True, nullable=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(db.String(20), default='user', nullable=False) # 'admin' or 'user'
|
||||
master_key_salt = db.Column(db.LargeBinary, nullable=True) # For password manager
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
storage_quota_mb = db.Column(db.Integer, default=5120) # 5 GB default
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
files = db.relationship('File', backref='owner', lazy='dynamic',
|
||||
foreign_keys='File.owner_id')
|
||||
calendars = db.relationship('Calendar', backref='owner', lazy='dynamic')
|
||||
address_books = db.relationship('AddressBook', backref='owner', lazy='dynamic')
|
||||
email_accounts = db.relationship('EmailAccount', backref='user', lazy='dynamic',
|
||||
order_by='EmailAccount.sort_order')
|
||||
password_folders = db.relationship('PasswordFolder', backref='owner', lazy='dynamic')
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
|
||||
def check_password(self, password):
|
||||
return bcrypt.check_password_hash(self.password_hash, password)
|
||||
|
||||
def to_dict(self, include_email=False):
|
||||
data = {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'role': self.role,
|
||||
'is_active': self.is_active,
|
||||
'storage_quota_mb': self.storage_quota_mb,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
if include_email:
|
||||
data['email'] = self.email
|
||||
return data
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Encryption/decryption for email passwords and other server-side secrets.
|
||||
|
||||
Uses AES-256-GCM with a key derived from the user's encryption key.
|
||||
The encryption key is passed from the frontend via X-Encryption-Key header
|
||||
and is itself derived from the user's login password using PBKDF2.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
def _derive_key(user_key_b64: str) -> bytes:
|
||||
"""Derive a 256-bit AES key from the user's base64-encoded key."""
|
||||
try:
|
||||
raw = base64.b64decode(user_key_b64)
|
||||
except Exception:
|
||||
raw = user_key_b64.encode('utf-8')
|
||||
return hashlib.sha256(raw).digest()
|
||||
|
||||
|
||||
def encrypt_field(plaintext: str, user_key_b64: str) -> bytes:
|
||||
"""Encrypt a string field. Returns nonce + ciphertext as bytes."""
|
||||
key = _derive_key(user_key_b64)
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
|
||||
return nonce + ciphertext
|
||||
|
||||
|
||||
def decrypt_field(encrypted: bytes, user_key_b64: str) -> str:
|
||||
"""Decrypt a field. Input is nonce (12 bytes) + ciphertext."""
|
||||
key = _derive_key(user_key_b64)
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = encrypted[:12]
|
||||
ciphertext = encrypted[12:]
|
||||
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
||||
return plaintext.decode('utf-8')
|
||||
@@ -0,0 +1,31 @@
|
||||
# Core
|
||||
Flask==3.1.1
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Migrate==4.1.0
|
||||
Flask-Bcrypt==1.0.1
|
||||
Flask-CORS==5.0.1
|
||||
PyJWT==2.9.0
|
||||
gunicorn==23.0.0
|
||||
python-dotenv==1.1.0
|
||||
|
||||
# CalDAV/CardDAV
|
||||
Radicale==3.3.3
|
||||
vobject==0.9.8
|
||||
icalendar==6.1.2
|
||||
|
||||
# Email
|
||||
imapclient==3.0.1
|
||||
|
||||
# Office Viewer
|
||||
python-docx==1.1.2
|
||||
openpyxl==3.1.5
|
||||
python-pptx==1.0.2
|
||||
|
||||
# Crypto (Passwort-Manager + Email-Verschluesselung)
|
||||
cryptography==44.0.3
|
||||
|
||||
# KeePass Import
|
||||
pykeepass==4.1.0
|
||||
|
||||
# Utilities
|
||||
Pillow==11.1.0
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env from project root
|
||||
env_path = Path(__file__).resolve().parent.parent / '.env'
|
||||
load_dotenv(env_path)
|
||||
|
||||
from app import create_app # noqa: E402
|
||||
|
||||
application = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
application.run(
|
||||
host='0.0.0.0',
|
||||
port=int(os.environ.get('PORT', 5000)),
|
||||
debug=True,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
minicloud:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-to-a-random-secret-key}
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-change-me-to-another-random-secret-key}
|
||||
- DATABASE_PATH=/app/data/minicloud.db
|
||||
- UPLOAD_PATH=/app/data/files
|
||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:5000}
|
||||
- MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB:-500}
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1650
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primevue/themes": "^4.5.4",
|
||||
"axios": "^1.15.0",
|
||||
"pinia": "^3.0.4",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.5.5",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import router from '../router'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor: attach access token
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const auth = useAuthStore()
|
||||
if (auth.accessToken) {
|
||||
config.headers.Authorization = `Bearer ${auth.accessToken}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor: handle 401 with token refresh
|
||||
let isRefreshing = false
|
||||
let failedQueue = []
|
||||
|
||||
const processQueue = (error, token = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error)
|
||||
} else {
|
||||
prom.resolve(token)
|
||||
}
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/auth/login') {
|
||||
const auth = useAuthStore()
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject })
|
||||
}).then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
return apiClient(originalRequest)
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const auth = useAuthStore()
|
||||
const newToken = await auth.refreshToken()
|
||||
processQueue(null, newToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null)
|
||||
const auth = useAuthStore()
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,28 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import 'primeicons/primeicons.css'
|
||||
import './style.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: '.dark-mode',
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ToastService)
|
||||
app.use(ConfirmationService)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('../views/RegisterView.vue'),
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/AppLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/files',
|
||||
},
|
||||
{
|
||||
path: 'files/:folderId?',
|
||||
name: 'Files',
|
||||
component: () => import('../views/FilesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
name: 'Calendar',
|
||||
component: () => import('../views/CalendarView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'contacts',
|
||||
name: 'Contacts',
|
||||
component: () => import('../views/ContactsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'Email',
|
||||
component: () => import('../views/EmailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'passwords',
|
||||
name: 'Passwords',
|
||||
component: () => import('../views/PasswordsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
name: 'Admin',
|
||||
component: () => import('../views/AdminView.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/share/:token',
|
||||
name: 'Share',
|
||||
component: () => import('../views/ShareView.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
// Try to refresh token
|
||||
try {
|
||||
await auth.refreshToken()
|
||||
await auth.fetchMe()
|
||||
} catch {
|
||||
return next('/login')
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.guest && auth.isAuthenticated) {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin && !auth.isAdmin) {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,72 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
const accessToken = ref(null)
|
||||
const masterKeySalt = ref(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
const hasEmailAccounts = computed(() => (user.value?.email_account_count || 0) > 0)
|
||||
|
||||
async function login(username, password) {
|
||||
const response = await apiClient.post('/auth/login', { username, password })
|
||||
user.value = response.data.user
|
||||
accessToken.value = response.data.access_token
|
||||
masterKeySalt.value = response.data.master_key_salt
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function register(username, password, email) {
|
||||
const payload = { username, password }
|
||||
if (email) payload.email = email
|
||||
const response = await apiClient.post('/auth/register', payload)
|
||||
user.value = response.data.user
|
||||
accessToken.value = response.data.access_token
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function refreshToken() {
|
||||
const response = await apiClient.post('/auth/refresh')
|
||||
accessToken.value = response.data.access_token
|
||||
return response.data.access_token
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
const response = await apiClient.get('/auth/me')
|
||||
user.value = response.data
|
||||
masterKeySalt.value = response.data.master_key_salt
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function changePassword(currentPassword, newPassword) {
|
||||
await apiClient.post('/auth/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
}
|
||||
|
||||
function logout() {
|
||||
apiClient.post('/auth/logout').catch(() => {})
|
||||
user.value = null
|
||||
accessToken.value = null
|
||||
masterKeySalt.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
masterKeySalt,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
hasEmailAccounts,
|
||||
login,
|
||||
register,
|
||||
refreshToken,
|
||||
fetchMe,
|
||||
changePassword,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
export const useFilesStore = defineStore('files', () => {
|
||||
const files = ref([])
|
||||
const breadcrumb = ref([])
|
||||
const currentParentId = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadFiles(parentId = null) {
|
||||
loading.value = true
|
||||
try {
|
||||
currentParentId.value = parentId
|
||||
const params = parentId ? { parent_id: parentId } : {}
|
||||
const response = await apiClient.get('/files', { params })
|
||||
files.value = response.data.files
|
||||
breadcrumb.value = response.data.breadcrumb
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder(name, parentId = null) {
|
||||
const response = await apiClient.post('/files/folder', {
|
||||
name,
|
||||
parent_id: parentId,
|
||||
})
|
||||
await loadFiles(parentId)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function uploadFile(file, parentId = null) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (parentId) formData.append('parent_id', parentId)
|
||||
|
||||
const response = await apiClient.post('/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
await loadFiles(parentId)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteFile(fileId) {
|
||||
await apiClient.delete(`/files/${fileId}`)
|
||||
await loadFiles(currentParentId.value)
|
||||
}
|
||||
|
||||
async function renameFile(fileId, newName) {
|
||||
await apiClient.put(`/files/${fileId}`, { name: newName })
|
||||
await loadFiles(currentParentId.value)
|
||||
}
|
||||
|
||||
async function moveFile(fileId, newParentId) {
|
||||
await apiClient.put(`/files/${fileId}`, { parent_id: newParentId })
|
||||
await loadFiles(currentParentId.value)
|
||||
}
|
||||
|
||||
async function createShareLink(fileId, options = {}) {
|
||||
const response = await apiClient.post(`/files/${fileId}/share`, options)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function getShareLinks(fileId) {
|
||||
const response = await apiClient.get(`/files/${fileId}/shares`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteShareLink(token) {
|
||||
await apiClient.delete(`/share/${token}`)
|
||||
}
|
||||
|
||||
function downloadUrl(fileId) {
|
||||
return `/api/files/${fileId}/download`
|
||||
}
|
||||
|
||||
return {
|
||||
files, breadcrumb, currentParentId, loading,
|
||||
loadFiles, createFolder, uploadFile, deleteFile,
|
||||
renameFile, moveFile, createShareLink, getShareLinks,
|
||||
deleteShareLink, downloadUrl,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Administration</h2>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h3>Benutzerverwaltung</h3>
|
||||
<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="role" header="Rolle">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.role" :severity="data.role === 'admin' ? 'danger' : 'info'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="is_active" header="Aktiv">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.is_active ? 'Ja' : 'Nein'" :severity="data.is_active ? 'success' : 'warn'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="storage_quota_mb" header="Quota (MB)" />
|
||||
<Column header="Aktionen" style="width: 100px">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="editUser(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
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'
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/users')
|
||||
users.value = response.data
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Benutzer:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editUser(user) {
|
||||
// TODO: Edit dialog in Phase 8
|
||||
console.log('Edit user:', user)
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header h2 { margin: 0 0 1.5rem; }
|
||||
.admin-section h3 { margin: 0 0 1rem; font-size: 1.125rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<i class="pi pi-cloud"></i>
|
||||
<span class="sidebar-title">Mini-Cloud</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/files" class="nav-item" active-class="active">
|
||||
<i class="pi pi-folder"></i>
|
||||
<span>Dateien</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/calendar" class="nav-item" active-class="active">
|
||||
<i class="pi pi-calendar"></i>
|
||||
<span>Kalender</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/contacts" class="nav-item" active-class="active">
|
||||
<i class="pi pi-users"></i>
|
||||
<span>Kontakte</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="auth.hasEmailAccounts"
|
||||
to="/email"
|
||||
class="nav-item"
|
||||
active-class="active"
|
||||
>
|
||||
<i class="pi pi-envelope"></i>
|
||||
<span>E-Mail</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/passwords" class="nav-item" active-class="active">
|
||||
<i class="pi pi-key"></i>
|
||||
<span>Passwoerter</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<router-link
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin"
|
||||
class="nav-item"
|
||||
active-class="active"
|
||||
>
|
||||
<i class="pi pi-cog"></i>
|
||||
<span>Admin</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings" class="nav-item" active-class="active">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>Einstellungen</span>
|
||||
</router-link>
|
||||
|
||||
<a class="nav-item" @click="handleLogout">
|
||||
<i class="pi pi-sign-out"></i>
|
||||
<span>Abmelden</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--p-surface-0);
|
||||
border-right: 1px solid var(--p-surface-200);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--p-surface-200);
|
||||
}
|
||||
|
||||
.sidebar-header i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--p-surface-200);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
color: var(--p-text-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--p-surface-100);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--p-primary-50);
|
||||
color: var(--p-primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
font-size: 1.125rem;
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: var(--p-surface-50);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Kalender</h2>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" />
|
||||
<Button icon="pi pi-plus" label="Neues Event" size="small" @click="openNewEvent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-layout">
|
||||
<aside class="calendar-sidebar">
|
||||
<div v-for="cal in calendars" :key="cal.id" class="calendar-item">
|
||||
<div class="calendar-color" :style="{ background: cal.color }"></div>
|
||||
<span>{{ cal.name }}</span>
|
||||
<span v-if="cal.owner_name" class="shared-label">({{ cal.owner_name }})</span>
|
||||
<Button icon="pi pi-ellipsis-v" text size="small" @click="openCalendarMenu(cal, $event)" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="calendar-main">
|
||||
<div class="cal-nav">
|
||||
<Button icon="pi pi-chevron-left" text @click="changeMonth(-1)" />
|
||||
<h3>{{ currentMonthLabel }}</h3>
|
||||
<Button icon="pi pi-chevron-right" text @click="changeMonth(1)" />
|
||||
<Button label="Heute" text size="small" @click="goToday" />
|
||||
</div>
|
||||
|
||||
<div class="cal-grid">
|
||||
<div class="cal-header" v-for="day in weekDays" :key="day">{{ day }}</div>
|
||||
<div
|
||||
v-for="(cell, i) in calendarCells"
|
||||
:key="i"
|
||||
class="cal-cell"
|
||||
:class="{ 'other-month': !cell.currentMonth, 'today': cell.isToday }"
|
||||
@click="openNewEventOnDate(cell.date)"
|
||||
>
|
||||
<span class="cell-day">{{ cell.day }}</span>
|
||||
<div v-for="evt in cell.events" :key="evt.id" class="cell-event"
|
||||
:style="{ background: evt.color }" @click.stop="openEditEvent(evt)">
|
||||
{{ evt.summary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Calendar Dialog -->
|
||||
<Dialog v-model:visible="showNewCalendar" header="Neuer Kalender" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="newCalName" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Farbe</label>
|
||||
<InputText v-model="newCalColor" type="color" style="width: 60px; height: 36px" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewCalendar = false" />
|
||||
<Button label="Erstellen" @click="createCalendar" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Event Dialog -->
|
||||
<Dialog v-model:visible="showEventDialog" :header="editingEvent ? 'Event bearbeiten' : 'Neues Event'" modal :style="{ width: '500px' }">
|
||||
<div class="field">
|
||||
<label>Titel</label>
|
||||
<InputText v-model="eventForm.summary" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Kalender</label>
|
||||
<Select v-model="eventForm.calendar_id" :options="ownCalendars" optionLabel="name" optionValue="id" fluid />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Start</label>
|
||||
<InputText v-model="eventForm.dtstart" type="datetime-local" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ende</label>
|
||||
<InputText v-model="eventForm.dtend" type="datetime-local" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="deleteEvent" />
|
||||
<Button label="Abbrechen" text @click="showEventDialog = false" />
|
||||
<Button :label="editingEvent ? 'Speichern' : 'Erstellen'" @click="saveEvent" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Calendar Context Menu -->
|
||||
<Dialog v-model:visible="showCalMenu" header="Kalender-Optionen" modal :style="{ width: '400px' }">
|
||||
<div v-if="selectedCal" class="cal-menu-content">
|
||||
<p><strong>{{ selectedCal.name }}</strong></p>
|
||||
|
||||
<div class="field">
|
||||
<label>Mit Benutzer teilen</label>
|
||||
<div class="share-row">
|
||||
<InputText v-model="shareUsername" placeholder="Benutzername" fluid />
|
||||
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
|
||||
<Button label="Teilen" size="small" @click="shareCalendar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedCal.permission === 'owner'" class="field">
|
||||
<Button label="iCal-Link generieren" icon="pi pi-link" outlined size="small" @click="generateIcalLink" />
|
||||
<div v-if="icalUrl" class="ical-url">
|
||||
<code>{{ fullIcalUrl }}</code>
|
||||
<Button icon="pi pi-copy" text size="small" @click="copyIcal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button v-if="selectedCal.permission === 'owner'" label="Kalender loeschen"
|
||||
severity="danger" text size="small" @click="deleteCalendar" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
const toast = useToast()
|
||||
const calendars = ref([])
|
||||
const allEvents = ref([])
|
||||
const currentDate = ref(new Date())
|
||||
|
||||
const showNewCalendar = ref(false)
|
||||
const newCalName = ref('')
|
||||
const newCalColor = ref('#3788d8')
|
||||
|
||||
const showEventDialog = ref(false)
|
||||
const editingEvent = ref(null)
|
||||
const eventForm = ref({ summary: '', calendar_id: null, dtstart: '', dtend: '', all_day: false })
|
||||
|
||||
const showCalMenu = ref(false)
|
||||
const selectedCal = ref(null)
|
||||
const shareUsername = ref('')
|
||||
const sharePermission = ref('read')
|
||||
const icalUrl = ref('')
|
||||
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
|
||||
|
||||
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
|
||||
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
|
||||
const fullIcalUrl = computed(() => icalUrl.value ? `${window.location.origin}${icalUrl.value}` : '')
|
||||
|
||||
const currentMonthLabel = computed(() => {
|
||||
return currentDate.value.toLocaleString('de-DE', { month: 'long', year: 'numeric' })
|
||||
})
|
||||
|
||||
const calendarCells = computed(() => {
|
||||
const year = currentDate.value.getFullYear()
|
||||
const month = currentDate.value.getMonth()
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
const today = new Date()
|
||||
|
||||
let startDay = (firstDay.getDay() + 6) % 7
|
||||
const cells = []
|
||||
|
||||
for (let i = startDay - 1; i >= 0; i--) {
|
||||
const d = new Date(year, month, -i)
|
||||
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
|
||||
}
|
||||
|
||||
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||
const date = new Date(year, month, d)
|
||||
const isToday = date.toDateString() === today.toDateString()
|
||||
const dayEvents = allEvents.value.filter(e => {
|
||||
const start = new Date(e.dtstart)
|
||||
return start.getFullYear() === year && start.getMonth() === month && start.getDate() === d
|
||||
})
|
||||
cells.push({ date, day: d, currentMonth: true, isToday, events: dayEvents })
|
||||
}
|
||||
|
||||
while (cells.length < 42) {
|
||||
const d = new Date(year, month + 1, cells.length - startDay - lastDay.getDate() + 1)
|
||||
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
|
||||
}
|
||||
|
||||
return cells
|
||||
})
|
||||
|
||||
function changeMonth(delta) {
|
||||
const d = new Date(currentDate.value)
|
||||
d.setMonth(d.getMonth() + delta)
|
||||
currentDate.value = d
|
||||
loadEvents()
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
currentDate.value = new Date()
|
||||
loadEvents()
|
||||
}
|
||||
|
||||
async function loadCalendars() {
|
||||
const res = await apiClient.get('/calendars')
|
||||
calendars.value = res.data
|
||||
if (!calendars.value.length) {
|
||||
await apiClient.post('/calendars', { name: 'Mein Kalender', color: '#3788d8' })
|
||||
const res2 = await apiClient.get('/calendars')
|
||||
calendars.value = res2.data
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
allEvents.value = []
|
||||
for (const cal of calendars.value) {
|
||||
const year = currentDate.value.getFullYear()
|
||||
const month = currentDate.value.getMonth()
|
||||
const start = new Date(year, month - 1, 1).toISOString()
|
||||
const end = new Date(year, month + 2, 0).toISOString()
|
||||
try {
|
||||
const res = await apiClient.get(`/calendars/${cal.id}/events`, { params: { start, end } })
|
||||
allEvents.value.push(...res.data.map(e => ({ ...e, color: cal.color, calendarName: cal.name })))
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function createCalendar() {
|
||||
if (!newCalName.value.trim()) return
|
||||
await apiClient.post('/calendars', { name: newCalName.value.trim(), color: newCalColor.value })
|
||||
showNewCalendar.value = false
|
||||
newCalName.value = ''
|
||||
await loadCalendars()
|
||||
}
|
||||
|
||||
function openNewEvent() {
|
||||
editingEvent.value = null
|
||||
const now = new Date()
|
||||
const later = new Date(now.getTime() + 3600000)
|
||||
eventForm.value = {
|
||||
summary: '',
|
||||
calendar_id: ownCalendars.value[0]?.id,
|
||||
dtstart: toLocalISO(now),
|
||||
dtend: toLocalISO(later),
|
||||
all_day: false,
|
||||
}
|
||||
showEventDialog.value = true
|
||||
}
|
||||
|
||||
function openNewEventOnDate(date) {
|
||||
editingEvent.value = null
|
||||
const start = new Date(date); start.setHours(9, 0)
|
||||
const end = new Date(date); end.setHours(10, 0)
|
||||
eventForm.value = {
|
||||
summary: '',
|
||||
calendar_id: ownCalendars.value[0]?.id,
|
||||
dtstart: toLocalISO(start),
|
||||
dtend: toLocalISO(end),
|
||||
all_day: false,
|
||||
}
|
||||
showEventDialog.value = true
|
||||
}
|
||||
|
||||
function openEditEvent(evt) {
|
||||
editingEvent.value = evt
|
||||
eventForm.value = {
|
||||
summary: evt.summary,
|
||||
calendar_id: evt.calendar_id,
|
||||
dtstart: toLocalISO(new Date(evt.dtstart)),
|
||||
dtend: toLocalISO(new Date(evt.dtend)),
|
||||
all_day: evt.all_day,
|
||||
}
|
||||
showEventDialog.value = true
|
||||
}
|
||||
|
||||
async function saveEvent() {
|
||||
if (!eventForm.value.summary.trim()) return
|
||||
const payload = { ...eventForm.value }
|
||||
|
||||
if (editingEvent.value) {
|
||||
await apiClient.put(`/events/${editingEvent.value.id}`, payload)
|
||||
} else {
|
||||
await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload)
|
||||
}
|
||||
showEventDialog.value = false
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
async function deleteEvent() {
|
||||
if (!editingEvent.value) return
|
||||
await apiClient.delete(`/events/${editingEvent.value.id}`)
|
||||
showEventDialog.value = false
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
function openCalendarMenu(cal) {
|
||||
selectedCal.value = cal
|
||||
icalUrl.value = ''
|
||||
showCalMenu.value = true
|
||||
}
|
||||
|
||||
async function shareCalendar() {
|
||||
if (!shareUsername.value.trim() || !selectedCal.value) return
|
||||
try {
|
||||
await apiClient.post(`/calendars/${selectedCal.value.id}/share`, {
|
||||
username: shareUsername.value.trim(), permission: sharePermission.value,
|
||||
})
|
||||
toast.add({ severity: 'success', summary: 'Kalender geteilt', life: 3000 })
|
||||
shareUsername.value = ''
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function generateIcalLink() {
|
||||
if (!selectedCal.value) return
|
||||
const res = await apiClient.post(`/calendars/${selectedCal.value.id}/ical-link`)
|
||||
icalUrl.value = res.data.ical_url
|
||||
}
|
||||
|
||||
function copyIcal() {
|
||||
navigator.clipboard.writeText(fullIcalUrl.value)
|
||||
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
|
||||
}
|
||||
|
||||
async function deleteCalendar() {
|
||||
if (!selectedCal.value) return
|
||||
await apiClient.delete(`/calendars/${selectedCal.value.id}`)
|
||||
showCalMenu.value = false
|
||||
await loadCalendars()
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
function toLocalISO(date) {
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCalendars()
|
||||
await loadEvents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.view-header h2 { margin: 0; }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.calendar-layout { display: flex; gap: 1rem; }
|
||||
.calendar-sidebar { width: 220px; flex-shrink: 0; }
|
||||
.calendar-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
|
||||
.calendar-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
|
||||
.shared-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.calendar-main { flex: 1; }
|
||||
.cal-nav { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
.cal-nav h3 { margin: 0; min-width: 180px; text-align: center; }
|
||||
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); border: 1px solid var(--p-surface-200); }
|
||||
.cal-header { padding: 0.5rem; text-align: center; font-weight: 600; font-size: 0.8rem; background: var(--p-surface-100); border-bottom: 1px solid var(--p-surface-200); }
|
||||
.cal-cell { min-height: 80px; padding: 0.25rem; border: 1px solid var(--p-surface-100); cursor: pointer; font-size: 0.8rem; }
|
||||
.cal-cell:hover { background: var(--p-surface-50); }
|
||||
.cal-cell.other-month { opacity: 0.4; }
|
||||
.cal-cell.today { background: var(--p-primary-50); }
|
||||
.cell-day { font-weight: 500; font-size: 0.75rem; }
|
||||
.cell-event { font-size: 0.7rem; padding: 1px 4px; border-radius: 3px; color: white; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.field-row { display: flex; gap: 1rem; }
|
||||
.field-row .field { flex: 1; }
|
||||
.share-row { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.ical-url { margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ical-url code { font-size: 0.75rem; word-break: break-all; }
|
||||
.cal-menu-content p { margin: 0 0 1rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Kontakte</h2>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-book" label="Neues Adressbuch" size="small" outlined @click="showNewBook = true" />
|
||||
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small" @click="openNewContact" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contacts-layout">
|
||||
<aside class="books-sidebar">
|
||||
<div v-for="book in addressBooks" :key="book.id"
|
||||
class="book-item" :class="{ active: selectedBookId === book.id }"
|
||||
@click="selectBook(book.id)">
|
||||
<i class="pi pi-book"></i>
|
||||
<span>{{ book.name }}</span>
|
||||
<span class="count">{{ book.contact_count }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="contacts-main">
|
||||
<div class="search-bar">
|
||||
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="loadContacts" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="contacts" :loading="loading" striped-rows @row-click="openEditContact">
|
||||
<template #empty><p class="empty">Keine Kontakte</p></template>
|
||||
<Column field="display_name" header="Name" sortable />
|
||||
<Column field="email" header="E-Mail" sortable />
|
||||
<Column field="phone" header="Telefon" />
|
||||
<Column header="" style="width: 80px">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="deleteContact(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Address Book -->
|
||||
<Dialog v-model:visible="showNewBook" header="Neues Adressbuch" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="newBookName" fluid autofocus @keyup.enter="createBook" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewBook = false" />
|
||||
<Button label="Erstellen" @click="createBook" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Contact Form -->
|
||||
<Dialog v-model:visible="showContactForm" :header="editingContact ? 'Kontakt bearbeiten' : 'Neuer Kontakt'" modal :style="{ width: '500px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="contactForm.display_name" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>E-Mail</label>
|
||||
<InputText v-model="contactForm.email" type="email" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Telefon</label>
|
||||
<InputText v-model="contactForm.phone" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Organisation</label>
|
||||
<InputText v-model="contactForm.organization" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notizen</label>
|
||||
<Textarea v-model="contactForm.notes" rows="3" fluid />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showContactForm = false" />
|
||||
<Button :label="editingContact ? 'Speichern' : 'Erstellen'" @click="saveContact" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
|
||||
const toast = useToast()
|
||||
const addressBooks = ref([])
|
||||
const contacts = ref([])
|
||||
const selectedBookId = ref(null)
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const showNewBook = ref(false)
|
||||
const newBookName = ref('')
|
||||
|
||||
const showContactForm = ref(false)
|
||||
const editingContact = ref(null)
|
||||
const contactForm = ref({ display_name: '', email: '', phone: '', organization: '', notes: '' })
|
||||
|
||||
async function loadBooks() {
|
||||
const res = await apiClient.get('/addressbooks')
|
||||
addressBooks.value = res.data
|
||||
if (addressBooks.value.length && !selectedBookId.value) {
|
||||
selectedBookId.value = addressBooks.value[0].id
|
||||
await loadContacts()
|
||||
}
|
||||
if (!addressBooks.value.length) {
|
||||
await apiClient.post('/addressbooks', { name: 'Kontakte' })
|
||||
await loadBooks()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContacts() {
|
||||
if (!selectedBookId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = searchQuery.value ? { search: searchQuery.value } : {}
|
||||
const res = await apiClient.get(`/addressbooks/${selectedBookId.value}/contacts`, { params })
|
||||
contacts.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectBook(id) {
|
||||
selectedBookId.value = id
|
||||
loadContacts()
|
||||
}
|
||||
|
||||
async function createBook() {
|
||||
if (!newBookName.value.trim()) return
|
||||
await apiClient.post('/addressbooks', { name: newBookName.value.trim() })
|
||||
showNewBook.value = false
|
||||
newBookName.value = ''
|
||||
await loadBooks()
|
||||
}
|
||||
|
||||
function openNewContact() {
|
||||
editingContact.value = null
|
||||
contactForm.value = { display_name: '', email: '', phone: '', organization: '', notes: '' }
|
||||
showContactForm.value = true
|
||||
}
|
||||
|
||||
function openEditContact(event) {
|
||||
const c = event.data
|
||||
editingContact.value = c
|
||||
contactForm.value = {
|
||||
display_name: c.display_name || '',
|
||||
email: c.email || '',
|
||||
phone: c.phone || '',
|
||||
organization: '',
|
||||
notes: '',
|
||||
}
|
||||
showContactForm.value = true
|
||||
}
|
||||
|
||||
async function saveContact() {
|
||||
if (!contactForm.value.display_name.trim()) return
|
||||
try {
|
||||
if (editingContact.value) {
|
||||
await apiClient.put(`/contacts/${editingContact.value.id}`, contactForm.value)
|
||||
} else {
|
||||
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, contactForm.value)
|
||||
}
|
||||
showContactForm.value = false
|
||||
await loadContacts()
|
||||
await loadBooks()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(contact) {
|
||||
await apiClient.delete(`/contacts/${contact.id}`)
|
||||
await loadContacts()
|
||||
await loadBooks()
|
||||
}
|
||||
|
||||
onMounted(loadBooks)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.view-header h2 { margin: 0; }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.contacts-layout { display: flex; gap: 1rem; }
|
||||
.books-sidebar { width: 220px; flex-shrink: 0; }
|
||||
.book-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
|
||||
.book-item:hover { background: var(--p-surface-100); }
|
||||
.book-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
|
||||
.book-item .count { margin-left: auto; color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.contacts-main { flex: 1; }
|
||||
.search-bar { margin-bottom: 1rem; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="view-container email-view">
|
||||
<div class="email-layout">
|
||||
<!-- Folder sidebar -->
|
||||
<aside class="email-sidebar">
|
||||
<div v-for="account in accounts" :key="account.id" class="account-group">
|
||||
<div class="account-header">
|
||||
<i class="pi pi-envelope"></i>
|
||||
<span>{{ account.display_name }}</span>
|
||||
</div>
|
||||
<div v-for="folder in account.folders || []" :key="folder.name"
|
||||
class="folder-item" :class="{ active: activeFolder === `${account.id}:${folder.name}` }"
|
||||
@click="selectFolder(account, folder)">
|
||||
<i :class="folderIcon(folder.name)"></i>
|
||||
<span>{{ folder.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon="pi pi-cog" label="Konten verwalten" text size="small" class="manage-btn"
|
||||
@click="$router.push('/settings')" />
|
||||
</aside>
|
||||
|
||||
<!-- Message list -->
|
||||
<div class="message-list-panel">
|
||||
<div class="list-header">
|
||||
<span class="folder-title">{{ currentFolderName }}</span>
|
||||
<Button icon="pi pi-refresh" text size="small" @click="loadMessages" />
|
||||
<Button icon="pi pi-pencil" label="Neue E-Mail" size="small" @click="openCompose" />
|
||||
</div>
|
||||
<div v-if="loadingMessages" class="loading-center">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
</div>
|
||||
<div v-else class="message-list">
|
||||
<div v-for="msg in messages" :key="msg.uid"
|
||||
class="message-item" :class="{ unread: !msg.seen, active: selectedMessage?.uid === msg.uid }"
|
||||
@click="selectMessage(msg)">
|
||||
<div class="msg-from">{{ msg.from }}</div>
|
||||
<div class="msg-subject">{{ msg.subject || '(Kein Betreff)' }}</div>
|
||||
<div class="msg-date">{{ formatDate(msg.date) }}</div>
|
||||
</div>
|
||||
<div v-if="!messages.length" class="empty">Keine Nachrichten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message view -->
|
||||
<div class="message-view-panel">
|
||||
<div v-if="selectedMessage && messageDetail">
|
||||
<div class="msg-header">
|
||||
<h3>{{ messageDetail.subject }}</h3>
|
||||
<div class="msg-meta">
|
||||
<div><strong>Von:</strong> {{ messageDetail.from }}</div>
|
||||
<div><strong>An:</strong> {{ messageDetail.to }}</div>
|
||||
<div v-if="messageDetail.cc"><strong>CC:</strong> {{ messageDetail.cc }}</div>
|
||||
<div><strong>Datum:</strong> {{ messageDetail.date }}</div>
|
||||
</div>
|
||||
<div class="msg-actions">
|
||||
<Button icon="pi pi-reply" label="Antworten" size="small" outlined @click="replyTo" />
|
||||
<Button icon="pi pi-trash" size="small" severity="danger" text @click="deleteMessage" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="messageDetail.html_body" class="msg-body" v-html="messageDetail.html_body"></div>
|
||||
<pre v-else-if="messageDetail.text_body" class="msg-body-text">{{ messageDetail.text_body }}</pre>
|
||||
<div v-if="messageDetail.attachments?.length" class="msg-attachments">
|
||||
<strong>Anhaenge:</strong>
|
||||
<span v-for="a in messageDetail.attachments" :key="a.filename" class="attachment">
|
||||
<i class="pi pi-paperclip"></i> {{ a.filename }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-message">
|
||||
<i class="pi pi-envelope" style="font-size: 2rem; color: var(--p-surface-400)"></i>
|
||||
<p>Nachricht auswaehlen</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compose Dialog -->
|
||||
<Dialog v-model:visible="showCompose" header="Neue E-Mail" modal :style="{ width: '700px' }">
|
||||
<div v-if="accounts.length > 1" class="field">
|
||||
<label>Von</label>
|
||||
<Select v-model="composeForm.account_id" :options="accounts" optionLabel="email_address" optionValue="id" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>An</label>
|
||||
<InputText v-model="composeForm.to" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>CC</label>
|
||||
<InputText v-model="composeForm.cc" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Betreff</label>
|
||||
<InputText v-model="composeForm.subject" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nachricht</label>
|
||||
<Textarea v-model="composeForm.body_text" rows="12" fluid />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showCompose = false" />
|
||||
<Button label="Senden" icon="pi pi-send" @click="sendEmail" :loading="sending" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const accounts = ref([])
|
||||
const messages = ref([])
|
||||
const selectedMessage = ref(null)
|
||||
const messageDetail = ref(null)
|
||||
const activeFolder = ref('')
|
||||
const currentFolderName = ref('INBOX')
|
||||
const currentAccount = ref(null)
|
||||
const loadingMessages = ref(false)
|
||||
|
||||
const showCompose = ref(false)
|
||||
const sending = ref(false)
|
||||
const composeForm = ref({ account_id: null, to: '', cc: '', subject: '', body_text: '' })
|
||||
|
||||
function getEncKey() { return auth.masterKeySalt || '' }
|
||||
|
||||
function folderIcon(name) {
|
||||
const n = name.toLowerCase()
|
||||
if (n === 'inbox') return 'pi pi-inbox'
|
||||
if (n.includes('sent') || n.includes('gesendet')) return 'pi pi-send'
|
||||
if (n.includes('draft') || n.includes('entwu')) return 'pi pi-file-edit'
|
||||
if (n.includes('trash') || n.includes('papier') || n.includes('gelöscht')) return 'pi pi-trash'
|
||||
if (n.includes('spam') || n.includes('junk')) return 'pi pi-ban'
|
||||
return 'pi pi-folder'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
const res = await apiClient.get('/email/accounts')
|
||||
accounts.value = res.data
|
||||
|
||||
for (const acc of accounts.value) {
|
||||
try {
|
||||
const fRes = await apiClient.get(`/email/accounts/${acc.id}/folders`, {
|
||||
headers: { 'X-Encryption-Key': getEncKey() }
|
||||
})
|
||||
acc.folders = fRes.data
|
||||
} catch { acc.folders = [{ name: 'INBOX', flags: [], delimiter: '/' }] }
|
||||
}
|
||||
|
||||
if (accounts.value.length) {
|
||||
selectFolder(accounts.value[0], { name: 'INBOX' })
|
||||
}
|
||||
}
|
||||
|
||||
function selectFolder(account, folder) {
|
||||
currentAccount.value = account
|
||||
activeFolder.value = `${account.id}:${folder.name}`
|
||||
currentFolderName.value = `${account.display_name} - ${folder.name}`
|
||||
loadMessages()
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
if (!currentAccount.value) return
|
||||
loadingMessages.value = true
|
||||
selectedMessage.value = null
|
||||
messageDetail.value = null
|
||||
try {
|
||||
const folder = activeFolder.value.split(':')[1]
|
||||
const res = await apiClient.get(
|
||||
`/email/accounts/${currentAccount.value.id}/folders/${encodeURIComponent(folder)}/messages`,
|
||||
{ headers: { 'X-Encryption-Key': getEncKey() } }
|
||||
)
|
||||
messages.value = res.data.messages
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
messages.value = []
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectMessage(msg) {
|
||||
selectedMessage.value = msg
|
||||
try {
|
||||
const folder = activeFolder.value.split(':')[1]
|
||||
const res = await apiClient.get(
|
||||
`/email/accounts/${currentAccount.value.id}/messages/${msg.uid}`,
|
||||
{ params: { folder }, headers: { 'X-Encryption-Key': getEncKey() } }
|
||||
)
|
||||
messageDetail.value = res.data
|
||||
msg.seen = true
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
function openCompose() {
|
||||
composeForm.value = {
|
||||
account_id: currentAccount.value?.id || accounts.value[0]?.id,
|
||||
to: '', cc: '', subject: '', body_text: '',
|
||||
}
|
||||
showCompose.value = true
|
||||
}
|
||||
|
||||
function replyTo() {
|
||||
if (!messageDetail.value) return
|
||||
composeForm.value = {
|
||||
account_id: currentAccount.value?.id,
|
||||
to: messageDetail.value.from,
|
||||
cc: '',
|
||||
subject: `Re: ${messageDetail.value.subject}`,
|
||||
body_text: `\n\n--- Urspruengliche Nachricht ---\n${messageDetail.value.text_body || ''}`,
|
||||
}
|
||||
showCompose.value = true
|
||||
}
|
||||
|
||||
async function sendEmail() {
|
||||
sending.value = true
|
||||
try {
|
||||
await apiClient.post('/email/send', composeForm.value, {
|
||||
headers: { 'X-Encryption-Key': getEncKey() }
|
||||
})
|
||||
showCompose.value = false
|
||||
toast.add({ severity: 'success', summary: 'E-Mail gesendet', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Sendefehler', detail: err.response?.data?.error, life: 5000 })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMessage() {
|
||||
if (!selectedMessage.value || !currentAccount.value) return
|
||||
const folder = activeFolder.value.split(':')[1]
|
||||
try {
|
||||
await apiClient.delete(
|
||||
`/email/accounts/${currentAccount.value.id}/messages/${selectedMessage.value.uid}`,
|
||||
{ params: { folder }, headers: { 'X-Encryption-Key': getEncKey() } }
|
||||
)
|
||||
selectedMessage.value = null
|
||||
messageDetail.value = null
|
||||
await loadMessages()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAccounts)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 0; height: calc(100vh - 0px); }
|
||||
.email-layout { display: flex; height: 100%; }
|
||||
.email-sidebar { width: 220px; border-right: 1px solid var(--p-surface-200); overflow-y: auto; padding: 0.5rem; flex-shrink: 0; }
|
||||
.account-group { margin-bottom: 0.5rem; }
|
||||
.account-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; font-weight: 600; font-size: 0.85rem; }
|
||||
.folder-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.5rem 0.375rem 1.5rem; font-size: 0.825rem; cursor: pointer; border-radius: 4px; }
|
||||
.folder-item:hover { background: var(--p-surface-100); }
|
||||
.folder-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
|
||||
.manage-btn { margin-top: 0.5rem; }
|
||||
.message-list-panel { width: 350px; border-right: 1px solid var(--p-surface-200); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.list-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; border-bottom: 1px solid var(--p-surface-200); }
|
||||
.folder-title { font-weight: 600; font-size: 0.875rem; flex: 1; }
|
||||
.message-list { flex: 1; overflow-y: auto; }
|
||||
.message-item { padding: 0.625rem 0.75rem; border-bottom: 1px solid var(--p-surface-100); cursor: pointer; }
|
||||
.message-item:hover { background: var(--p-surface-50); }
|
||||
.message-item.active { background: var(--p-primary-50); }
|
||||
.message-item.unread { font-weight: 600; }
|
||||
.msg-from { font-size: 0.8rem; color: var(--p-text-muted-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.msg-subject { font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.msg-date { font-size: 0.7rem; color: var(--p-text-muted-color); }
|
||||
.message-view-panel { flex: 1; overflow-y: auto; padding: 1rem; }
|
||||
.msg-header h3 { margin: 0 0 0.75rem; }
|
||||
.msg-meta { font-size: 0.85rem; margin-bottom: 0.75rem; line-height: 1.6; }
|
||||
.msg-actions { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.msg-body { border: 1px solid var(--p-surface-200); border-radius: 6px; padding: 1rem; background: white; }
|
||||
.msg-body-text { white-space: pre-wrap; font-size: 0.875rem; }
|
||||
.msg-attachments { margin-top: 1rem; font-size: 0.85rem; }
|
||||
.attachment { display: inline-flex; align-items: center; gap: 0.25rem; margin-right: 0.75rem; }
|
||||
.empty-message { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 0.5rem; color: var(--p-text-muted-color); }
|
||||
.empty { text-align: center; padding: 2rem; color: var(--p-text-muted-color); }
|
||||
.loading-center { display: flex; justify-content: center; padding: 2rem; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<div class="breadcrumb">
|
||||
<a @click="navigateTo(null)" class="crumb">Dateien</a>
|
||||
<template v-for="item in filesStore.breadcrumb" :key="item.id">
|
||||
<i class="pi pi-angle-right crumb-sep"></i>
|
||||
<a @click="navigateTo(item.id)" class="crumb">{{ item.name }}</a>
|
||||
</template>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
|
||||
<Button icon="pi pi-upload" label="Hochladen" size="small" @click="triggerUpload" />
|
||||
<input ref="fileInput" type="file" multiple hidden @change="handleUpload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="filesStore.files"
|
||||
:loading="filesStore.loading"
|
||||
@row-dblclick="handleDoubleClick"
|
||||
striped-rows
|
||||
removable-sort
|
||||
class="files-table"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<i class="pi pi-folder-open" style="font-size: 2rem; color: var(--p-surface-400)"></i>
|
||||
<p>Dieser Ordner ist leer</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="name" header="Name" sortable style="min-width: 300px">
|
||||
<template #body="{ data }">
|
||||
<div class="file-name" @click="data.is_folder && navigateTo(data.id)">
|
||||
<i :class="fileIcon(data)" class="file-icon"></i>
|
||||
<span>{{ data.name }}</span>
|
||||
<Tag v-if="data.shared" value="Geteilt" severity="info" class="shared-tag" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="size" header="Groesse" sortable style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
{{ data.is_folder ? '' : formatSize(data.size) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="updated_at" header="Geaendert" sortable style="width: 180px">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.updated_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="" style="width: 140px">
|
||||
<template #body="{ data }">
|
||||
<div class="row-actions">
|
||||
<Button
|
||||
v-if="!data.is_folder"
|
||||
icon="pi pi-download"
|
||||
text rounded size="small"
|
||||
@click="downloadFile(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-share-alt"
|
||||
text rounded size="small"
|
||||
@click="openShare(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text rounded size="small"
|
||||
@click="openRename(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text rounded size="small"
|
||||
severity="danger"
|
||||
@click="confirmDelete(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- New Folder Dialog -->
|
||||
<Dialog v-model:visible="showNewFolder" header="Neuer Ordner" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Ordnername</label>
|
||||
<InputText v-model="newFolderName" fluid autofocus @keyup.enter="createFolder" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewFolder = false" />
|
||||
<Button label="Erstellen" @click="createFolder" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Rename Dialog -->
|
||||
<Dialog v-model:visible="showRename" header="Umbenennen" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Neuer Name</label>
|
||||
<InputText v-model="renameName" fluid autofocus @keyup.enter="doRename" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showRename = false" />
|
||||
<Button label="Umbenennen" @click="doRename" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Share Dialog -->
|
||||
<Dialog v-model:visible="showShare" header="Teilen" modal :style="{ width: '500px' }">
|
||||
<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 />
|
||||
</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 v-if="shareLinks.length" class="existing-links">
|
||||
<h4>Bestehende Links</h4>
|
||||
<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>
|
||||
<small>
|
||||
{{ link.download_count }} Downloads
|
||||
<template v-if="link.expires_at"> | Bis {{ formatDate(link.expires_at) }}</template>
|
||||
<template v-if="link.has_password"> | Passwortgeschuetzt</template>
|
||||
</small>
|
||||
</div>
|
||||
<div class="link-actions">
|
||||
<Button icon="pi pi-copy" text size="small" @click="copyLink(link.token)" />
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(link.token)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirm -->
|
||||
<Dialog v-model:visible="showDeleteConfirm" header="Loeschen bestaetigen" modal :style="{ width: '400px' }">
|
||||
<p>Moechtest du <strong>{{ deleteTarget?.name }}</strong> wirklich loeschen?</p>
|
||||
<p v-if="deleteTarget?.is_folder" class="text-warn">Alle Dateien in diesem Ordner werden ebenfalls geloescht!</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="doDelete" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useFilesStore } from '../stores/files'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const filesStore = useFilesStore()
|
||||
const toast = useToast()
|
||||
|
||||
const fileInput = ref(null)
|
||||
const showNewFolder = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const showRename = ref(false)
|
||||
const renameName = ref('')
|
||||
const renameTarget = ref(null)
|
||||
const showShare = ref(false)
|
||||
const shareFile = ref(null)
|
||||
const sharePassword = ref('')
|
||||
const shareExpiry = ref('')
|
||||
const shareLinks = ref([])
|
||||
const shareLoading = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleteTarget = ref(null)
|
||||
|
||||
function currentParentId() {
|
||||
const id = route.params.folderId
|
||||
return id ? parseInt(id) : null
|
||||
}
|
||||
|
||||
function navigateTo(folderId) {
|
||||
if (folderId) {
|
||||
router.push(`/files/${folderId}`)
|
||||
} else {
|
||||
router.push('/files')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDoubleClick(event) {
|
||||
const data = event.data
|
||||
if (data.is_folder) {
|
||||
navigateTo(data.id)
|
||||
} else {
|
||||
downloadFile(data)
|
||||
}
|
||||
}
|
||||
|
||||
function fileIcon(data) {
|
||||
if (data.is_folder) return 'pi pi-folder'
|
||||
const mime = data.mime_type || ''
|
||||
if (mime.startsWith('image/')) return 'pi pi-image'
|
||||
if (mime.startsWith('video/')) return 'pi pi-video'
|
||||
if (mime.startsWith('audio/')) return 'pi pi-volume-up'
|
||||
if (mime.includes('pdf')) return 'pi pi-file-pdf'
|
||||
if (mime.includes('word') || mime.includes('document')) return 'pi pi-file-word'
|
||||
if (mime.includes('sheet') || mime.includes('excel')) return 'pi pi-file-excel'
|
||||
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'pi pi-file'
|
||||
return 'pi pi-file'
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
let size = bytes
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleUpload(event) {
|
||||
const uploadFiles = event.target.files
|
||||
if (!uploadFiles.length) return
|
||||
|
||||
for (const file of uploadFiles) {
|
||||
try {
|
||||
await filesStore.uploadFile(file, currentParentId())
|
||||
toast.add({ severity: 'success', summary: `${file.name} hochgeladen`, life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: `Fehler: ${file.name}`, detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
try {
|
||||
await filesStore.createFolder(newFolderName.value.trim(), currentParentId())
|
||||
showNewFolder.value = false
|
||||
newFolderName.value = ''
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(data) {
|
||||
const url = filesStore.downloadUrl(data.id)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = data.name
|
||||
a.click()
|
||||
}
|
||||
|
||||
function openRename(data) {
|
||||
renameTarget.value = data
|
||||
renameName.value = data.name
|
||||
showRename.value = true
|
||||
}
|
||||
|
||||
async function doRename() {
|
||||
if (!renameName.value.trim() || !renameTarget.value) return
|
||||
try {
|
||||
await filesStore.renameFile(renameTarget.value.id, renameName.value.trim())
|
||||
showRename.value = false
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function openShare(data) {
|
||||
shareFile.value = data
|
||||
sharePassword.value = ''
|
||||
shareExpiry.value = ''
|
||||
showShare.value = true
|
||||
try {
|
||||
shareLinks.value = await filesStore.getShareLinks(data.id)
|
||||
} catch {
|
||||
shareLinks.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function createShare() {
|
||||
if (!shareFile.value) return
|
||||
shareLoading.value = true
|
||||
try {
|
||||
const opts = {}
|
||||
if (sharePassword.value) opts.password = sharePassword.value
|
||||
if (shareExpiry.value) opts.expires_at = shareExpiry.value
|
||||
await filesStore.createShareLink(shareFile.value.id, opts)
|
||||
shareLinks.value = await filesStore.getShareLinks(shareFile.value.id)
|
||||
sharePassword.value = ''
|
||||
shareExpiry.value = ''
|
||||
toast.add({ severity: 'success', summary: 'Link erstellt', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
} finally {
|
||||
shareLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyLink(token) {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/share/${token}`)
|
||||
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
|
||||
}
|
||||
|
||||
async function removeShare(token) {
|
||||
try {
|
||||
await filesStore.deleteShareLink(token)
|
||||
shareLinks.value = shareLinks.value.filter(l => l.token !== token)
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(data) {
|
||||
deleteTarget.value = data
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
try {
|
||||
await filesStore.deleteFile(deleteTarget.value.id)
|
||||
showDeleteConfirm.value = false
|
||||
toast.add({ severity: 'success', summary: 'Geloescht', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.params.folderId, () => {
|
||||
filesStore.loadFiles(currentParentId())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
filesStore.loadFiles(currentParentId())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.breadcrumb { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.crumb { cursor: pointer; color: var(--p-primary-color); font-weight: 500; }
|
||||
.crumb:hover { text-decoration: underline; }
|
||||
.crumb-sep { font-size: 0.75rem; color: var(--p-surface-400); }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.file-name {
|
||||
display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
|
||||
}
|
||||
.file-icon { font-size: 1.125rem; width: 1.25rem; text-align: center; }
|
||||
.shared-tag { font-size: 0.7rem; }
|
||||
.row-actions { display: flex; gap: 0; }
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0.5rem; padding: 3rem; 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; }
|
||||
.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-link-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100);
|
||||
}
|
||||
.link-info { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.link-info code { font-size: 0.8rem; word-break: break-all; }
|
||||
.link-info small { color: var(--p-text-muted-color); }
|
||||
.link-actions { display: flex; }
|
||||
.text-warn { color: var(--p-orange-500); font-size: 0.875rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<i class="pi pi-cloud" style="font-size: 2.5rem; color: var(--p-primary-color)"></i>
|
||||
<h1>Mini-Cloud</h1>
|
||||
<p>Anmelden</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="auth-form">
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<InputText
|
||||
id="username"
|
||||
v-model="username"
|
||||
placeholder="Benutzername"
|
||||
:invalid="!!error"
|
||||
autofocus
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="password"
|
||||
placeholder="Passwort"
|
||||
:feedback="false"
|
||||
:invalid="!!error"
|
||||
toggle-mask
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
label="Anmelden"
|
||||
:loading="loading"
|
||||
fluid
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<router-link to="/register">Noch kein Konto? Registrieren</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Anmeldung fehlgeschlagen'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--p-surface-50);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--p-surface-0);
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--p-text-muted-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form .field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-form .field label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-form button {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--p-primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Passwoerter</h2>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
|
||||
<Button icon="pi pi-plus" label="Neuer Eintrag" size="small" @click="openNewEntry" />
|
||||
<Button icon="pi pi-upload" label="KeePass Import" size="small" outlined @click="showImport = true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="passwords-layout">
|
||||
<aside class="folders-sidebar">
|
||||
<div class="folder-item" :class="{ active: selectedFolderId === null }" @click="selectedFolderId = null; loadEntries()">
|
||||
<i class="pi pi-key"></i>
|
||||
<span>Alle</span>
|
||||
</div>
|
||||
<div v-for="folder in folders" :key="folder.id"
|
||||
class="folder-item" :class="{ active: selectedFolderId === folder.id }"
|
||||
@click="selectedFolderId = folder.id; loadEntries()">
|
||||
<i :class="folder.icon || 'pi pi-folder'"></i>
|
||||
<span>{{ folder.name }}</span>
|
||||
<span v-if="folder.owner_name" class="shared-label">({{ folder.owner_name }})</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="entries-main">
|
||||
<div class="search-bar">
|
||||
<InputText v-model="searchQuery" placeholder="Passwoerter suchen..." fluid />
|
||||
</div>
|
||||
|
||||
<div class="entries-list">
|
||||
<div v-for="entry in filteredEntries" :key="entry.id"
|
||||
class="entry-item" @click="openEntry(entry)">
|
||||
<div class="entry-icon">
|
||||
<i class="pi pi-key"></i>
|
||||
</div>
|
||||
<div class="entry-info">
|
||||
<div class="entry-title">{{ decryptedEntries[entry.id]?.title || '(Verschluesselt)' }}</div>
|
||||
<div class="entry-url">{{ decryptedEntries[entry.id]?.username || '' }}</div>
|
||||
</div>
|
||||
<div class="entry-actions">
|
||||
<Button icon="pi pi-copy" text size="small" title="Passwort kopieren"
|
||||
@click.stop="copyPassword(entry)" />
|
||||
<Button v-if="decryptedEntries[entry.id]?.totp_secret" icon="pi pi-clock" text size="small"
|
||||
title="TOTP Code" @click.stop="showTotp(entry)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!filteredEntries.length" class="empty">
|
||||
<i class="pi pi-key" style="font-size: 2rem; color: var(--p-surface-400)"></i>
|
||||
<p>Keine Eintraege</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entry Dialog -->
|
||||
<Dialog v-model:visible="showEntryDialog" :header="editingEntry ? 'Eintrag bearbeiten' : 'Neuer Eintrag'" modal :style="{ width: '500px' }">
|
||||
<div class="field">
|
||||
<label>Titel</label>
|
||||
<InputText v-model="entryForm.title" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>URL</label>
|
||||
<InputText v-model="entryForm.url" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Benutzername</label>
|
||||
<InputText v-model="entryForm.username" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Passwort</label>
|
||||
<div class="password-field">
|
||||
<Password v-model="entryForm.password" :feedback="false" toggle-mask fluid />
|
||||
<Button icon="pi pi-sync" text size="small" title="Generieren" @click="generatePassword" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>TOTP Secret (optional)</label>
|
||||
<InputText v-model="entryForm.totp_secret" fluid placeholder="otpauth:// oder Secret" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ordner</label>
|
||||
<Select v-model="entryForm.folder_id" :options="folderOptions" optionLabel="name" optionValue="id" fluid placeholder="Kein Ordner" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Kategorie</label>
|
||||
<InputText v-model="entryForm.category" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notizen</label>
|
||||
<Textarea v-model="entryForm.notes" rows="3" fluid />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button v-if="editingEntry" label="Loeschen" severity="danger" text @click="deleteEntry" />
|
||||
<Button label="Abbrechen" text @click="showEntryDialog = false" />
|
||||
<Button :label="editingEntry ? 'Speichern' : 'Erstellen'" @click="saveEntry" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- New Folder -->
|
||||
<Dialog v-model:visible="showNewFolder" header="Neuer Ordner" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="newFolderName" fluid autofocus @keyup.enter="createFolder" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewFolder = false" />
|
||||
<Button label="Erstellen" @click="createFolder" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- KeePass Import -->
|
||||
<Dialog v-model:visible="showImport" header="KeePass Import" modal :style="{ width: '500px' }">
|
||||
<p>Waehle eine .kdbx-Datei und gib das KeePass-Passwort ein.</p>
|
||||
<div class="field">
|
||||
<label>KDBX-Datei</label>
|
||||
<input ref="kdbxInput" type="file" accept=".kdbx" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>KeePass-Passwort</label>
|
||||
<Password v-model="importPassword" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showImport = false" />
|
||||
<Button label="Importieren" @click="importKeePass" :loading="importing" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- TOTP Dialog -->
|
||||
<Dialog v-model:visible="showTotpDialog" header="TOTP Code" modal :style="{ width: '300px' }">
|
||||
<div class="totp-display">
|
||||
<div class="totp-code">{{ totpCode }}</div>
|
||||
<Button icon="pi pi-copy" text @click="copyToClipboard(totpCode)" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const folders = ref([])
|
||||
const entries = ref([])
|
||||
const decryptedEntries = ref({})
|
||||
const selectedFolderId = ref(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const showEntryDialog = ref(false)
|
||||
const editingEntry = ref(null)
|
||||
const entryForm = ref({ title: '', url: '', username: '', password: '', totp_secret: '', folder_id: null, category: '', notes: '' })
|
||||
|
||||
const showNewFolder = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const showImport = ref(false)
|
||||
const importPassword = ref('')
|
||||
const importing = ref(false)
|
||||
const kdbxInput = ref(null)
|
||||
|
||||
const showTotpDialog = ref(false)
|
||||
const totpCode = ref('')
|
||||
|
||||
const folderOptions = computed(() => [{ id: null, name: '(Kein Ordner)' }, ...folders.value])
|
||||
const filteredEntries = computed(() => {
|
||||
if (!searchQuery.value) return entries.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return entries.value.filter(e => {
|
||||
const d = decryptedEntries.value[e.id]
|
||||
if (!d) return false
|
||||
return (d.title || '').toLowerCase().includes(q) ||
|
||||
(d.username || '').toLowerCase().includes(q) ||
|
||||
(d.url || '').toLowerCase().includes(q)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Crypto helpers using Web Crypto API ---
|
||||
async function getMasterKey() {
|
||||
const salt = auth.masterKeySalt
|
||||
if (!salt) return null
|
||||
const enc = new TextEncoder()
|
||||
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(auth.user?.username + ':' + 'stored'), 'PBKDF2', false, ['deriveKey'])
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt: Uint8Array.from(atob(salt), c => c.charCodeAt(0)), iterations: 600000, hash: 'SHA-256' },
|
||||
keyMaterial, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
async function encryptText(text, key) {
|
||||
if (!text) return null
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
const enc = new TextEncoder()
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(text))
|
||||
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
|
||||
combined.set(iv)
|
||||
combined.set(new Uint8Array(ciphertext), iv.length)
|
||||
return btoa(String.fromCharCode(...combined))
|
||||
}
|
||||
|
||||
async function decryptText(b64, key) {
|
||||
if (!b64) return ''
|
||||
try {
|
||||
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
|
||||
const iv = raw.slice(0, 12)
|
||||
const ciphertext = raw.slice(12)
|
||||
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
|
||||
return new TextDecoder().decode(plaintext)
|
||||
} catch { return '(Entschluesselung fehlgeschlagen)' }
|
||||
}
|
||||
|
||||
async function decryptEntries() {
|
||||
const key = await getMasterKey()
|
||||
if (!key) return
|
||||
const result = {}
|
||||
for (const entry of entries.value) {
|
||||
result[entry.id] = {
|
||||
title: await decryptText(entry.title_encrypted, key),
|
||||
url: await decryptText(entry.url_encrypted, key),
|
||||
username: await decryptText(entry.username_encrypted, key),
|
||||
password: await decryptText(entry.password_encrypted, key),
|
||||
notes: await decryptText(entry.notes_encrypted, key),
|
||||
totp_secret: await decryptText(entry.totp_secret_encrypted, key),
|
||||
}
|
||||
}
|
||||
decryptedEntries.value = result
|
||||
}
|
||||
|
||||
// --- Data loading ---
|
||||
async function loadFolders() {
|
||||
const res = await apiClient.get('/passwords/folders')
|
||||
folders.value = res.data
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
const params = {}
|
||||
if (selectedFolderId.value !== null) params.folder_id = selectedFolderId.value
|
||||
const res = await apiClient.get('/passwords/entries', { params })
|
||||
entries.value = res.data
|
||||
await decryptEntries()
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
await apiClient.post('/passwords/folders', { name: newFolderName.value.trim() })
|
||||
showNewFolder.value = false
|
||||
newFolderName.value = ''
|
||||
await loadFolders()
|
||||
}
|
||||
|
||||
function openNewEntry() {
|
||||
editingEntry.value = null
|
||||
entryForm.value = { title: '', url: '', username: '', password: '', totp_secret: '', folder_id: selectedFolderId.value, category: '', notes: '' }
|
||||
showEntryDialog.value = true
|
||||
}
|
||||
|
||||
async function openEntry(entry) {
|
||||
editingEntry.value = entry
|
||||
const d = decryptedEntries.value[entry.id] || {}
|
||||
entryForm.value = {
|
||||
title: d.title || '',
|
||||
url: d.url || '',
|
||||
username: d.username || '',
|
||||
password: d.password || '',
|
||||
totp_secret: d.totp_secret || '',
|
||||
folder_id: entry.folder_id,
|
||||
category: entry.category || '',
|
||||
notes: d.notes || '',
|
||||
}
|
||||
showEntryDialog.value = true
|
||||
}
|
||||
|
||||
async function saveEntry() {
|
||||
const key = await getMasterKey()
|
||||
if (!key) { toast.add({ severity: 'error', summary: 'Kein Master-Key', life: 3000 }); return }
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
const ivB64 = btoa(String.fromCharCode(...iv))
|
||||
|
||||
const payload = {
|
||||
title_encrypted: await encryptText(entryForm.value.title, key),
|
||||
url_encrypted: await encryptText(entryForm.value.url, key),
|
||||
username_encrypted: await encryptText(entryForm.value.username, key),
|
||||
password_encrypted: await encryptText(entryForm.value.password, key),
|
||||
notes_encrypted: await encryptText(entryForm.value.notes, key),
|
||||
totp_secret_encrypted: await encryptText(entryForm.value.totp_secret, key),
|
||||
iv: ivB64,
|
||||
folder_id: entryForm.value.folder_id,
|
||||
category: entryForm.value.category,
|
||||
}
|
||||
|
||||
if (editingEntry.value) {
|
||||
await apiClient.put(`/passwords/entries/${editingEntry.value.id}`, payload)
|
||||
} else {
|
||||
await apiClient.post('/passwords/entries', payload)
|
||||
}
|
||||
showEntryDialog.value = false
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function deleteEntry() {
|
||||
if (!editingEntry.value) return
|
||||
await apiClient.delete(`/passwords/entries/${editingEntry.value.id}`)
|
||||
showEntryDialog.value = false
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function copyPassword(entry) {
|
||||
const d = decryptedEntries.value[entry.id]
|
||||
if (d?.password) {
|
||||
await navigator.clipboard.writeText(d.password)
|
||||
toast.add({ severity: 'info', summary: 'Passwort kopiert', life: 2000 })
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.add({ severity: 'info', summary: 'Kopiert', life: 2000 })
|
||||
}
|
||||
|
||||
function generatePassword() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-='
|
||||
const arr = new Uint8Array(20)
|
||||
crypto.getRandomValues(arr)
|
||||
entryForm.value.password = Array.from(arr, b => chars[b % chars.length]).join('')
|
||||
}
|
||||
|
||||
async function showTotp(entry) {
|
||||
const d = decryptedEntries.value[entry.id]
|
||||
if (!d?.totp_secret) return
|
||||
// Simple TOTP generation
|
||||
try {
|
||||
const secret = d.totp_secret.replace(/^otpauth:\/\/.*secret=/, '').replace(/&.*/, '')
|
||||
totpCode.value = await generateTOTP(secret)
|
||||
showTotpDialog.value = true
|
||||
} catch {
|
||||
toast.add({ severity: 'error', summary: 'TOTP-Fehler', life: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function generateTOTP(secret) {
|
||||
// Base32 decode
|
||||
const base32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
const bits = secret.toUpperCase().replace(/=+$/, '').split('').map(c => {
|
||||
const val = base32.indexOf(c)
|
||||
return val >= 0 ? val.toString(2).padStart(5, '0') : ''
|
||||
}).join('')
|
||||
const bytes = new Uint8Array(bits.match(/.{8}/g).map(b => parseInt(b, 2)))
|
||||
|
||||
const time = Math.floor(Date.now() / 30000)
|
||||
const timeBytes = new Uint8Array(8)
|
||||
let t = time
|
||||
for (let i = 7; i >= 0; i--) { timeBytes[i] = t & 0xff; t >>= 8 }
|
||||
|
||||
const key = await crypto.subtle.importKey('raw', bytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'])
|
||||
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, timeBytes))
|
||||
const offset = sig[sig.length - 1] & 0xf
|
||||
const code = ((sig[offset] & 0x7f) << 24 | sig[offset + 1] << 16 | sig[offset + 2] << 8 | sig[offset + 3]) % 1000000
|
||||
return code.toString().padStart(6, '0')
|
||||
}
|
||||
|
||||
async function importKeePass() {
|
||||
if (!kdbxInput.value?.files?.length || !importPassword.value) return
|
||||
importing.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', kdbxInput.value.files[0])
|
||||
formData.append('password', importPassword.value)
|
||||
const res = await apiClient.post('/passwords/import/keepass', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
const key = await getMasterKey()
|
||||
if (!key) return
|
||||
|
||||
// Create folders
|
||||
const folderMap = {}
|
||||
for (const group of res.data.groups) {
|
||||
const folder = await apiClient.post('/passwords/folders', { name: group.name })
|
||||
folderMap[group.uuid] = folder.data.id
|
||||
}
|
||||
|
||||
// Import entries
|
||||
for (const entry of res.data.entries) {
|
||||
const folderId = entry.group_uuid ? folderMap[entry.group_uuid] : null
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
await apiClient.post('/passwords/entries', {
|
||||
title_encrypted: await encryptText(entry.title, key),
|
||||
url_encrypted: await encryptText(entry.url, key),
|
||||
username_encrypted: await encryptText(entry.username, key),
|
||||
password_encrypted: await encryptText(entry.password, key),
|
||||
notes_encrypted: await encryptText(entry.notes, key),
|
||||
totp_secret_encrypted: await encryptText(entry.totp, key),
|
||||
iv: btoa(String.fromCharCode(...iv)),
|
||||
folder_id: folderId,
|
||||
})
|
||||
}
|
||||
|
||||
showImport.value = false
|
||||
toast.add({ severity: 'success', summary: `${res.data.count} Eintraege importiert`, life: 5000 })
|
||||
await loadFolders()
|
||||
await loadEntries()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Import-Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFolders()
|
||||
await loadEntries()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.view-header h2 { margin: 0; }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.passwords-layout { display: flex; gap: 1rem; }
|
||||
.folders-sidebar { width: 220px; flex-shrink: 0; }
|
||||
.folder-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
|
||||
.folder-item:hover { background: var(--p-surface-100); }
|
||||
.folder-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
|
||||
.shared-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.entries-main { flex: 1; }
|
||||
.search-bar { margin-bottom: 1rem; }
|
||||
.entries-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.entry-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--p-surface-0); border-radius: 6px; cursor: pointer; }
|
||||
.entry-item:hover { background: var(--p-surface-100); }
|
||||
.entry-icon { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: var(--p-primary-50); border-radius: 8px; color: var(--p-primary-color); }
|
||||
.entry-info { flex: 1; }
|
||||
.entry-title { font-weight: 500; font-size: 0.9rem; }
|
||||
.entry-url { font-size: 0.8rem; color: var(--p-text-muted-color); }
|
||||
.entry-actions { display: flex; gap: 0; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.password-field { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 3rem; color: var(--p-text-muted-color); }
|
||||
.totp-display { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1rem; }
|
||||
.totp-code { font-size: 2rem; font-weight: 700; letter-spacing: 0.25em; font-family: monospace; }
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<i class="pi pi-cloud" style="font-size: 2.5rem; color: var(--p-primary-color)"></i>
|
||||
<h1>Mini-Cloud</h1>
|
||||
<p>Registrieren</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleRegister" class="auth-form">
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<InputText
|
||||
id="username"
|
||||
v-model="username"
|
||||
placeholder="Benutzername (min. 3 Zeichen)"
|
||||
autofocus
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">E-Mail (optional)</label>
|
||||
<InputText
|
||||
id="email"
|
||||
v-model="email"
|
||||
placeholder="email@beispiel.de"
|
||||
type="email"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="password"
|
||||
placeholder="Passwort (min. 8 Zeichen)"
|
||||
toggle-mask
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password2">Passwort wiederholen</label>
|
||||
<Password
|
||||
id="password2"
|
||||
v-model="password2"
|
||||
placeholder="Passwort wiederholen"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
label="Registrieren"
|
||||
:loading="loading"
|
||||
fluid
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<router-link to="/login">Bereits ein Konto? Anmelden</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const password2 = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleRegister() {
|
||||
error.value = ''
|
||||
|
||||
if (password.value !== password2.value) {
|
||||
error.value = 'Passwoerter stimmen nicht ueberein'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.register(username.value, password.value, email.value || undefined)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Registrierung fehlgeschlagen'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--p-surface-50);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--p-surface-0);
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--p-text-muted-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form .field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-form .field label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-form button {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--p-primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Einstellungen</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Profil</h3>
|
||||
<div class="settings-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Benutzername:</span>
|
||||
<span>{{ auth.user?.username }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">E-Mail:</span>
|
||||
<span>{{ auth.user?.email || 'Nicht angegeben' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Rolle:</span>
|
||||
<Tag :value="auth.user?.role" :severity="auth.user?.role === 'admin' ? 'danger' : 'info'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Passwort aendern</h3>
|
||||
<form @submit.prevent="handleChangePassword" class="password-form">
|
||||
<div class="field">
|
||||
<label>Aktuelles Passwort</label>
|
||||
<Password v-model="currentPassword" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Neues Passwort</label>
|
||||
<Password v-model="newPassword" toggle-mask fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Neues Passwort wiederholen</label>
|
||||
<Password v-model="newPassword2" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<Message v-if="pwError" severity="error" :closable="false">{{ pwError }}</Message>
|
||||
<Message v-if="pwSuccess" severity="success" :closable="false">{{ pwSuccess }}</Message>
|
||||
<Button type="submit" label="Passwort aendern" :loading="pwLoading" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import Password from 'primevue/password'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const newPassword2 = ref('')
|
||||
const pwError = ref('')
|
||||
const pwSuccess = ref('')
|
||||
const pwLoading = ref(false)
|
||||
|
||||
async function handleChangePassword() {
|
||||
pwError.value = ''
|
||||
pwSuccess.value = ''
|
||||
|
||||
if (newPassword.value !== newPassword2.value) {
|
||||
pwError.value = 'Neue Passwoerter stimmen nicht ueberein'
|
||||
return
|
||||
}
|
||||
|
||||
pwLoading.value = true
|
||||
try {
|
||||
await auth.changePassword(currentPassword.value, newPassword.value)
|
||||
pwSuccess.value = 'Passwort erfolgreich geaendert'
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
newPassword2.value = ''
|
||||
} catch (err) {
|
||||
pwError.value = err.response?.data?.error || 'Fehler beim Aendern des Passworts'
|
||||
} finally {
|
||||
pwLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header h2 { margin: 0 0 1.5rem; }
|
||||
.settings-section {
|
||||
background: var(--p-surface-0);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.settings-section h3 { margin: 0 0 1rem; font-size: 1.125rem; }
|
||||
.settings-info { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.info-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.info-row .label { font-weight: 500; min-width: 120px; }
|
||||
.password-form { max-width: 400px; }
|
||||
.password-form .field { margin-bottom: 1rem; }
|
||||
.password-form .field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="share-container">
|
||||
<div class="share-card">
|
||||
<i class="pi pi-cloud" style="font-size: 2rem; color: var(--p-primary-color)"></i>
|
||||
|
||||
<div v-if="loading" class="share-loading">
|
||||
<i class="pi pi-spin pi-spinner" style="font-size: 1.5rem"></i>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="share-error">
|
||||
<i class="pi pi-times-circle" style="font-size: 2rem; color: var(--p-red-500)"></i>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="fileInfo" class="share-info">
|
||||
<h2>{{ fileInfo.name }}</h2>
|
||||
<p class="file-size" v-if="fileInfo.size">{{ formatSize(fileInfo.size) }}</p>
|
||||
|
||||
<div v-if="fileInfo.has_password && !authenticated" class="password-form">
|
||||
<p>Diese Datei ist passwortgeschuetzt.</p>
|
||||
<div class="field">
|
||||
<Password v-model="password" placeholder="Passwort eingeben" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<Message v-if="authError" severity="error" :closable="false">{{ authError }}</Message>
|
||||
<Button label="Entsperren" @click="verifyPassword" :loading="verifying" fluid />
|
||||
</div>
|
||||
|
||||
<div v-else class="download-section">
|
||||
<Button
|
||||
label="Herunterladen"
|
||||
icon="pi pi-download"
|
||||
size="large"
|
||||
@click="downloadFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import Button from 'primevue/button'
|
||||
import Password from 'primevue/password'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
const route = useRoute()
|
||||
const token = route.params.token
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const fileInfo = ref(null)
|
||||
const password = ref('')
|
||||
const authenticated = ref(false)
|
||||
const authError = ref('')
|
||||
const verifying = ref(false)
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
let size = bytes
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
|
||||
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
async function loadInfo() {
|
||||
try {
|
||||
const res = await axios.get(`/api/share/${token}/info`)
|
||||
fileInfo.value = res.data
|
||||
if (!res.data.has_password) authenticated.value = true
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Link nicht gefunden oder abgelaufen'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPassword() {
|
||||
authError.value = ''
|
||||
verifying.value = true
|
||||
try {
|
||||
await axios.post(`/api/share/${token}/verify`, { password: password.value })
|
||||
authenticated.value = true
|
||||
} catch (err) {
|
||||
authError.value = err.response?.data?.error || 'Falsches Passwort'
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
let url = `/api/share/${token}/download`
|
||||
if (fileInfo.value?.has_password && password.value) {
|
||||
url += `?password=${encodeURIComponent(password.value)}`
|
||||
}
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
onMounted(loadInfo)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.share-container {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--p-surface-50);
|
||||
}
|
||||
.share-card {
|
||||
background: var(--p-surface-0); border-radius: 12px; padding: 3rem;
|
||||
text-align: center; max-width: 450px; width: 100%;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.share-card h2 { margin: 1rem 0 0.25rem; font-size: 1.25rem; }
|
||||
.file-size { color: var(--p-text-muted-color); margin-bottom: 1.5rem; }
|
||||
.password-form { text-align: left; margin-top: 1.5rem; }
|
||||
.password-form p { margin-bottom: 1rem; color: var(--p-text-muted-color); }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.download-section { margin-top: 1.5rem; }
|
||||
.share-loading, .share-error { margin-top: 1.5rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3100,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/dav': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ical': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user