Deaktivierung von Account und Notfication System hinzugefügt
This commit is contained in:
parent
0b414b0900
commit
4c3709b64e
190
AstraOS/Frontend/HTML/account_disabled.html
Normal file
190
AstraOS/Frontend/HTML/account_disabled.html
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AstraOS — Account deaktiviert</title>
|
||||||
|
<link rel="icon" href="/static/media/AstraOS_Logo.ico" type="image/x-icon">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||||
|
<style>
|
||||||
|
::-webkit-scrollbar { width: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: #0a0a0f; }
|
||||||
|
::-webkit-scrollbar-thumb { background-color: #4b5563; border-radius: 4px; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: radial-gradient(circle at top, #1b1b26, #0a0a0f 70%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #e6e6e6;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-bg {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
position: absolute;
|
||||||
|
width: 4px; height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(192, 132, 252, 0.15);
|
||||||
|
animation: float 20s infinite linear;
|
||||||
|
}
|
||||||
|
@keyframes float {
|
||||||
|
0% { transform: translateY(100vh) scale(1); }
|
||||||
|
100% { transform: translateY(-10vh) scale(1.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-glow {
|
||||||
|
box-shadow: 0 0 60px rgba(139, 92, 246, 0.08), 0 0 120px rgba(139, 92, 246, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseIcon {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.06); }
|
||||||
|
}
|
||||||
|
.pulse-icon {
|
||||||
|
animation: pulseIcon 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 1.25rem;
|
||||||
|
right: 1.25rem;
|
||||||
|
z-index: 99999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-white font-sans antialiased flex items-center justify-center min-h-screen">
|
||||||
|
|
||||||
|
<div class="animated-bg"></div>
|
||||||
|
|
||||||
|
<!-- Flash messages -->
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="toast-container" x-data>
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 5000)" x-show="show"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0 translate-x-8"
|
||||||
|
x-transition:enter-end="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave="transition ease-in duration-300"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="bg-gray-800 shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 p-4 max-w-sm border border-{{ 'red' if category == 'danger' else ('green' if category == 'success' else 'blue') }}-500/40">
|
||||||
|
<p class="text-sm text-gray-100">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="relative z-10 w-full max-w-md mx-auto px-4" x-data="{ showForm: false }" id="mainCard">
|
||||||
|
<div class="bg-black/50 backdrop-blur-xl rounded-2xl p-10 card-glow border border-purple-500/20 text-center">
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="pulse-icon inline-flex items-center justify-center w-20 h-20 rounded-full bg-red-500/10 border border-red-500/30 mb-6 mx-auto">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Account deaktiviert</h1>
|
||||||
|
<p class="text-gray-400 text-sm mb-8 leading-relaxed">
|
||||||
|
Dein AstraOS-Account wurde deaktiviert.<br>
|
||||||
|
Du hast aktuell keinen Zugang zum Panel.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Separator -->
|
||||||
|
<div class="border-t border-purple-500/20 mb-8"></div>
|
||||||
|
|
||||||
|
<!-- Options -->
|
||||||
|
<div class="space-y-3" x-show="!showForm">
|
||||||
|
<button @click="showForm = true"
|
||||||
|
class="w-full py-3 px-4 bg-purple-600/80 hover:bg-purple-600 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-2 hover:shadow-lg hover:shadow-purple-500/20">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
Reaktivierung anfragen
|
||||||
|
</button>
|
||||||
|
<a href="/login"
|
||||||
|
class="block w-full py-3 px-4 bg-gray-700/60 hover:bg-gray-700 text-gray-300 font-medium rounded-xl transition-all duration-200 text-center">
|
||||||
|
Zur Anmeldeseite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reactivation Form -->
|
||||||
|
<div x-show="showForm" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0" style="display: none;">
|
||||||
|
<h2 class="text-lg font-semibold text-purple-300 mb-4 text-left">Reaktivierung anfragen</h2>
|
||||||
|
<form action="/request_reactivation" method="POST" class="space-y-4 text-left">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">Dein Benutzername</label>
|
||||||
|
<input type="text" name="username" required placeholder="Dein AstraOS-Benutzername"
|
||||||
|
class="w-full px-3 py-2.5 bg-gray-800/80 border border-gray-700 rounded-lg text-white text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-purple-500 placeholder-gray-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Begründung
|
||||||
|
<span class="text-gray-500 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea name="reason" rows="3"
|
||||||
|
placeholder="Warum soll dein Account reaktiviert werden?"
|
||||||
|
class="w-full px-3 py-2.5 bg-gray-800/80 border border-gray-700 rounded-lg text-white text-sm resize-none
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-purple-500 placeholder-gray-600"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-indigo-950/50 border border-indigo-500/30 rounded-lg p-3 text-xs text-indigo-300">
|
||||||
|
Deine Anfrage wird dem Administrator gemeldet und per Discord zugestellt.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-1">
|
||||||
|
<button type="button" @click="showForm = false"
|
||||||
|
class="flex-1 py-2.5 rounded-lg text-gray-300 bg-gray-700/60 hover:bg-gray-700 transition text-sm font-medium">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="flex-1 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition text-sm">
|
||||||
|
Anfrage senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<p class="text-center text-gray-600 text-xs mt-6">
|
||||||
|
© 2026 AstraOS |
|
||||||
|
<a href="/impressum" class="hover:text-gray-400 transition">Impressum</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const bg = document.querySelector('.animated-bg');
|
||||||
|
if (bg) {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.classList.add('dot');
|
||||||
|
dot.style.left = `${Math.random() * 100}vw`;
|
||||||
|
dot.style.animationDelay = `${Math.random() * -20}s`;
|
||||||
|
dot.style.animationDuration = `${20 + Math.random() * 10}s`;
|
||||||
|
bg.appendChild(dot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gsap.from('#mainCard', { opacity: 0, y: 30, duration: 0.6, ease: 'power2.out' });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
288
AstraOS/Frontend/HTML/notifications.html
Normal file
288
AstraOS/Frontend/HTML/notifications.html
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
{% extends "sidebar.html" %}
|
||||||
|
|
||||||
|
{% block title %}AstraOS — Benachrichtigungen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="{
|
||||||
|
showSendModal: false,
|
||||||
|
sending: false,
|
||||||
|
form: { title: '', message: '', type: 'info' },
|
||||||
|
notifications: {{ notifications | tojson | safe }},
|
||||||
|
async sendNotification() {
|
||||||
|
if (!this.form.title || !this.form.message) return;
|
||||||
|
this.sending = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/notifications/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': document.querySelector('meta[name=csrf-token]').content },
|
||||||
|
body: JSON.stringify(this.form)
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showSendModal = false;
|
||||||
|
this.form = { title: '', message: '', type: 'info' };
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch(e) { alert('Netzwerkfehler'); }
|
||||||
|
this.sending = false;
|
||||||
|
},
|
||||||
|
async markRead(id, index) {
|
||||||
|
await fetch('/api/notifications/' + id + '/read', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': document.querySelector('meta[name=csrf-token]').content }
|
||||||
|
});
|
||||||
|
this.notifications[index].is_read = true;
|
||||||
|
this.updateBadge();
|
||||||
|
},
|
||||||
|
async takeAction(id, action, index) {
|
||||||
|
const resp = await fetch('/api/notifications/' + id + '/action', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': document.querySelector('meta[name=csrf-token]').content },
|
||||||
|
body: JSON.stringify({ action })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.notifications[index].action_status = action;
|
||||||
|
this.notifications[index].is_read = true;
|
||||||
|
this.updateBadge();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteNotif(id, index) {
|
||||||
|
if (!confirm('Diese Benachrichtigung wirklich loschen?')) return;
|
||||||
|
await fetch('/api/notifications/' + id + '/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': document.querySelector('meta[name=csrf-token]').content }
|
||||||
|
});
|
||||||
|
this.notifications.splice(index, 1);
|
||||||
|
},
|
||||||
|
updateBadge() {
|
||||||
|
const unread = this.notifications.filter(n => !n.is_read).length;
|
||||||
|
const badge = document.getElementById('notif-badge');
|
||||||
|
if (badge) { badge.textContent = unread; badge.style.display = unread > 0 ? 'flex' : 'none'; }
|
||||||
|
},
|
||||||
|
typeLabel(t) {
|
||||||
|
return { info: 'Info', warning: 'Warnung', action: 'Aktion', reactivation_request: 'Reaktivierungsanfrage' }[t] || t;
|
||||||
|
},
|
||||||
|
actionLabel(s) {
|
||||||
|
return { pending: 'Ausstehend', approved: 'Genehmigt', rejected: 'Abgelehnt' }[s] || s;
|
||||||
|
},
|
||||||
|
formatDate(d) {
|
||||||
|
if (!d) return 'N/A';
|
||||||
|
const dt = new Date(d['$date'] !== undefined ? d['$date'] : d);
|
||||||
|
return isNaN(dt.getTime()) ? String(d) : dt.toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
}" class="space-y-8">
|
||||||
|
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-4xl font-bold tracking-wide {% if maintenance_active %}text-yellow-300{% else %}text-purple-300{% endif %}">
|
||||||
|
Benachrichtigungen
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 mt-1 text-sm">
|
||||||
|
<span x-text="notifications.filter(n => !n.is_read).length"></span> ungelesen ·
|
||||||
|
<span x-text="notifications.length"></span> gesamt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button @click="showSendModal = true"
|
||||||
|
class="font-bold py-2 px-5 rounded-lg transition text-white flex items-center gap-2
|
||||||
|
{% if maintenance_active %}bg-yellow-600 hover:bg-yellow-700{% else %}bg-purple-600 hover:bg-purple-700{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
|
||||||
|
</svg>
|
||||||
|
Benachrichtigung senden
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Notification List -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<template x-if="notifications.length === 0">
|
||||||
|
<div class="text-center py-20 text-gray-500 bg-black/30 rounded-xl border {% if maintenance_active %}border-yellow-500/20{% else %}border-purple-500/20{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 mx-auto mb-4 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg">Keine Benachrichtigungen vorhanden.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-for="(notif, index) in notifications" :key="notif._id">
|
||||||
|
<div class="relative rounded-xl p-5 backdrop-blur-lg transition-all duration-300 border"
|
||||||
|
:class="notif.is_read
|
||||||
|
? 'bg-black/20 border-gray-700/40'
|
||||||
|
: '{% if maintenance_active %}bg-yellow-500/5 border-yellow-500/40 shadow-yellow-500/10{% else %}bg-purple-500/5 border-purple-500/40 shadow-purple-500/10{% endif %} shadow-lg'">
|
||||||
|
|
||||||
|
<!-- Unread dot -->
|
||||||
|
<div x-show="!notif.is_read"
|
||||||
|
class="absolute top-4 right-4 w-2.5 h-2.5 rounded-full {% if maintenance_active %}bg-yellow-400{% else %}bg-purple-400{% endif %} animate-pulse">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<!-- Type Icon -->
|
||||||
|
<div class="flex-shrink-0 w-11 h-11 rounded-full flex items-center justify-center text-xl"
|
||||||
|
:class="{
|
||||||
|
'bg-blue-500/20': notif.type === 'info',
|
||||||
|
'bg-orange-500/20': notif.type === 'warning',
|
||||||
|
'bg-red-500/20': notif.type === 'action',
|
||||||
|
'bg-yellow-500/20': notif.type === 'reactivation_request'
|
||||||
|
}">
|
||||||
|
<span x-text="{ info: '📢', warning: '⚠️', action: '🔔', reactivation_request: '🔓' }[notif.type] || '📢'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<!-- Title row -->
|
||||||
|
<div class="flex items-center gap-3 flex-wrap mb-1">
|
||||||
|
<h3 class="font-semibold text-white text-base" x-text="notif.title"></h3>
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
:class="{
|
||||||
|
'bg-blue-500/20 text-blue-300': notif.type === 'info',
|
||||||
|
'bg-orange-500/20 text-orange-300': notif.type === 'warning',
|
||||||
|
'bg-red-500/20 text-red-300': notif.type === 'action',
|
||||||
|
'bg-yellow-500/20 text-yellow-300': notif.type === 'reactivation_request'
|
||||||
|
}"
|
||||||
|
x-text="typeLabel(notif.type)">
|
||||||
|
</span>
|
||||||
|
<template x-if="notif.action_status">
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
:class="{
|
||||||
|
'bg-yellow-500/20 text-yellow-300': notif.action_status === 'pending',
|
||||||
|
'bg-green-500/20 text-green-300': notif.action_status === 'approved',
|
||||||
|
'bg-red-500/20 text-red-300': notif.action_status === 'rejected'
|
||||||
|
}"
|
||||||
|
x-text="actionLabel(notif.action_status)">
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<p class="text-gray-300 text-sm whitespace-pre-wrap mb-3" x-text="notif.message"></p>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="flex items-center gap-4 text-xs text-gray-500 flex-wrap">
|
||||||
|
<span>Von: <span class="text-gray-400" x-text="notif.created_by"></span></span>
|
||||||
|
<span>Am: <span class="text-gray-400" x-text="formatDate(notif.created_at)"></span></span>
|
||||||
|
<template x-if="notif.action_taken_by">
|
||||||
|
<span>Bearbeitet von: <span class="text-gray-400" x-text="notif.action_taken_by"></span></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex items-center gap-2 mt-3 flex-wrap">
|
||||||
|
<!-- Approve/Reject for pending action notifications -->
|
||||||
|
<template x-if="notif.requires_action && notif.action_status === 'pending'">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click.stop="takeAction(notif._id, 'approved', index)"
|
||||||
|
class="px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs font-semibold rounded-lg transition">
|
||||||
|
Genehmigen
|
||||||
|
</button>
|
||||||
|
<button @click.stop="takeAction(notif._id, 'rejected', index)"
|
||||||
|
class="px-3 py-1.5 bg-red-600 hover:bg-red-500 text-white text-xs font-semibold rounded-lg transition">
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Mark as read -->
|
||||||
|
<template x-if="!notif.is_read">
|
||||||
|
<button @click.stop="markRead(notif._id, index)"
|
||||||
|
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs rounded-lg transition">
|
||||||
|
Als gelesen markieren
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button @click.stop="deleteNotif(notif._id, index)"
|
||||||
|
class="px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 text-red-400 text-xs rounded-lg transition ml-auto">
|
||||||
|
Loschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Send Notification Modal -->
|
||||||
|
<div x-show="showSendModal" @keydown.escape.window="showSendModal = false"
|
||||||
|
class="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50"
|
||||||
|
style="display: none;">
|
||||||
|
<div @click.away="showSendModal = false"
|
||||||
|
class="bg-gray-900 rounded-xl shadow-2xl p-8 w-full max-w-lg
|
||||||
|
{% if maintenance_active %}border border-yellow-500/50{% else %}border border-purple-500/50{% endif %}">
|
||||||
|
|
||||||
|
<h3 class="text-2xl font-bold mb-6 {% if maintenance_active %}text-yellow-300{% else %}text-purple-300{% endif %}">
|
||||||
|
Benachrichtigung senden
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Type -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">Typ</label>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<button type="button" @click="form.type = 'info'"
|
||||||
|
:class="form.type === 'info' ? '{% if maintenance_active %}bg-yellow-600 border-yellow-500{% else %}bg-purple-600 border-purple-500{% endif %} text-white' : 'bg-gray-800 text-gray-300 border-gray-700 hover:border-gray-500'"
|
||||||
|
class="px-3 py-1.5 rounded-lg border text-xs font-medium transition">
|
||||||
|
Info
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="form.type = 'warning'"
|
||||||
|
:class="form.type === 'warning' ? 'bg-orange-600 border-orange-500 text-white' : 'bg-gray-800 text-gray-300 border-gray-700 hover:border-gray-500'"
|
||||||
|
class="px-3 py-1.5 rounded-lg border text-xs font-medium transition">
|
||||||
|
Warnung
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="form.type = 'action'"
|
||||||
|
:class="form.type === 'action' ? 'bg-red-600 border-red-500 text-white' : 'bg-gray-800 text-gray-300 border-gray-700 hover:border-gray-500'"
|
||||||
|
class="px-3 py-1.5 rounded-lg border text-xs font-medium transition">
|
||||||
|
Aktion (mit Approve/Reject-Buttons)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p x-show="form.type === 'action'" class="text-xs text-yellow-400 mt-1">
|
||||||
|
Aktions-Benachrichtigungen enthalten interaktive Buttons im Discord und im Panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">Titel</label>
|
||||||
|
<input type="text" x-model="form.title" placeholder="Kurzer, pragnanter Titel..."
|
||||||
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm
|
||||||
|
focus:outline-none focus:ring-2 {% if maintenance_active %}focus:ring-yellow-500{% else %}focus:ring-purple-500{% endif %}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">Nachricht</label>
|
||||||
|
<textarea x-model="form.message" rows="4" placeholder="Detaillierte Nachricht..."
|
||||||
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm resize-none
|
||||||
|
focus:outline-none focus:ring-2 {% if maintenance_active %}focus:ring-yellow-500{% else %}focus:ring-purple-500{% endif %}"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Discord hint -->
|
||||||
|
<div class="bg-indigo-950/50 border border-indigo-500/30 rounded-lg p-3 text-xs text-indigo-300">
|
||||||
|
Der AstraOS Helper Discord Bot wird dem Owner automatisch eine DM senden.
|
||||||
|
<span x-show="form.type === 'action'"> Mit interaktiven Genehmigen/Ablehnen-Buttons!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button @click="showSendModal = false; form = { title: '', message: '', type: 'info' }"
|
||||||
|
class="px-4 py-2 rounded-lg text-gray-300 hover:bg-gray-700 transition text-sm">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button @click="sendNotification()" :disabled="sending || !form.title || !form.message"
|
||||||
|
class="px-5 py-2 font-semibold rounded-lg transition text-white text-sm flex items-center gap-2 disabled:opacity-50
|
||||||
|
{% if maintenance_active %}bg-yellow-600 hover:bg-yellow-700{% else %}bg-purple-600 hover:bg-purple-700{% endif %}">
|
||||||
|
<svg x-show="sending" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span x-text="sending ? 'Wird gesendet...' : 'Senden'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -228,12 +228,26 @@
|
||||||
📜 Logs
|
📜 Logs
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ url_for('maintenance_management') }}"
|
{% if 'maintenance_management' in session.get('permissions', []) %}
|
||||||
class="block px-3 py-1 rounded-md transition
|
<a href="{{ url_for('maintenance_management') }}"
|
||||||
{% if active_page == 'maintenance_management' %}{% if maintenance_active %}bg-yellow-500/10 text-yellow-300{% else %}bg-purple-500/10 text-purple-300{% endif %}{% else %}
|
class="block px-3 py-1 rounded-md transition
|
||||||
{% if maintenance_active %}hover:bg-yellow-500/10 hover:text-yellow-300{% else %}hover:bg-purple-500/10 hover:text-purple-300{% endif %}{% endif %}">
|
{% if active_page == 'maintenance_management' %}{% if maintenance_active %}bg-yellow-500/10 text-yellow-300{% else %}bg-purple-500/10 text-purple-300{% endif %}{% else %}
|
||||||
🔧 Maintenance
|
{% if maintenance_active %}hover:bg-yellow-500/10 hover:text-yellow-300{% else %}hover:bg-purple-500/10 hover:text-purple-300{% endif %}{% endif %}">
|
||||||
</a>
|
🔧 Maintenance
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'notifications' in session.get('permissions', []) %}
|
||||||
|
<a href="{{ url_for('notifications_page') }}"
|
||||||
|
class="block px-3 py-1 rounded-md transition flex items-center justify-between
|
||||||
|
{% if active_page == 'notifications' %}{% if maintenance_active %}bg-yellow-500/10 text-yellow-300{% else %}bg-purple-500/10 text-purple-300{% endif %}{% else %}
|
||||||
|
{% if maintenance_active %}hover:bg-yellow-500/10 hover:text-yellow-300{% else %}hover:bg-purple-500/10 hover:text-purple-300{% endif %}{% endif %}">
|
||||||
|
<span>🔔 Benachrichtigungen</span>
|
||||||
|
<span id="notif-badge"
|
||||||
|
class="{% if maintenance_active %}bg-yellow-500{% else %}bg-purple-500{% endif %} text-white text-xs font-bold rounded-full w-5 h-5 items-center justify-center hidden">
|
||||||
|
0
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -394,6 +408,26 @@
|
||||||
<script>document.body.classList.add('maintenance-active');</script>
|
<script>document.body.classList.add('maintenance-active');</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if 'notifications' in session.get('permissions', []) %}
|
||||||
|
<script>
|
||||||
|
// Load unread notification count for badge
|
||||||
|
function refreshNotifBadge() {
|
||||||
|
fetch('/api/notifications/unread_count')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const badge = document.getElementById('notif-badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = data.count;
|
||||||
|
badge.style.display = data.count > 0 ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
refreshNotifBadge();
|
||||||
|
setInterval(refreshNotifBadge, 30000);
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
gsap.to("#star", {
|
gsap.to("#star", {
|
||||||
rotation: 360,
|
rotation: 360,
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@
|
||||||
<div x-data="{
|
<div x-data="{
|
||||||
showAddUserModal: false,
|
showAddUserModal: false,
|
||||||
showDeleteModal: false,
|
showDeleteModal: false,
|
||||||
|
showDeactivateModal: false,
|
||||||
userToDelete: null,
|
userToDelete: null,
|
||||||
userToEdit: null,
|
userToEdit: null,
|
||||||
|
userToToggle: null,
|
||||||
usersData: {},
|
usersData: {},
|
||||||
init() {
|
init() {
|
||||||
this.usersData = JSON.parse(document.getElementById('users-data').textContent);
|
this.usersData = JSON.parse(document.getElementById('users-data').textContent);
|
||||||
|
|
@ -18,6 +20,15 @@
|
||||||
openEditModal(userId) {
|
openEditModal(userId) {
|
||||||
this.userToEdit = this.usersData[userId];
|
this.userToEdit = this.usersData[userId];
|
||||||
this.showAddUserModal = true;
|
this.showAddUserModal = true;
|
||||||
|
},
|
||||||
|
openDeactivateModal(userId, username, isActive) {
|
||||||
|
this.userToToggle = { id: userId, username: username, is_active: isActive };
|
||||||
|
if (isActive) {
|
||||||
|
this.showDeactivateModal = true;
|
||||||
|
} else {
|
||||||
|
// Direct activate — no reason needed
|
||||||
|
document.getElementById('toggle-form-' + userId).submit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
<div class="space-y-10">
|
<div class="space-y-10">
|
||||||
|
|
@ -35,12 +46,13 @@
|
||||||
<thead class="bg-black/20">
|
<thead class="bg-black/20">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Benutzername</th>
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Benutzername</th>
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Rolle</th>
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Rolle</th>
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Discord</th>
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Discord</th>
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Erstellt am</th>
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Status</th>
|
||||||
<th scope="col" class="relative px-6 py-3">
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Erstellt am</th>
|
||||||
<span class="sr-only">Aktionen</span>
|
<th scope="col" class="relative px-6 py-3">
|
||||||
</th>
|
<span class="sr-only">Aktionen</span>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y {% if maintenance_active %}divide-yellow-500/30{% else %}divide-purple-500/30{% endif %}">
|
<tbody class="divide-y {% if maintenance_active %}divide-yellow-500/30{% else %}divide-purple-500/30{% endif %}">
|
||||||
|
|
@ -55,9 +67,26 @@
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">Nicht verknüpft</span>
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">Nicht verknüpft</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||||
|
{% if user_table_item.get('is_active', True) %}
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-500/20 text-green-300">Aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-500/20 text-red-300">Deaktiviert</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">{{ user_table_item.creation_date.strftime('%d.%m.%Y') if user_table_item.creation_date else 'N/A' }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">{{ user_table_item.creation_date.strftime('%d.%m.%Y') if user_table_item.creation_date else 'N/A' }}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<button @click="openEditModal('{{ user_table_item._id | string }}')" class="mr-4 {% if maintenance_active %}text-yellow-400 hover:text-yellow-300{% else %}text-indigo-400 hover:text-indigo-300{% endif %}">Bearbeiten</button>
|
<button @click="openEditModal('{{ user_table_item._id | string }}')" class="mr-3 {% if maintenance_active %}text-yellow-400 hover:text-yellow-300{% else %}text-indigo-400 hover:text-indigo-300{% endif %}">Bearbeiten</button>
|
||||||
|
{% if session.get('user_id') != user_table_item._id | string %}
|
||||||
|
{% if user_table_item.get('is_active', True) %}
|
||||||
|
<button @click="openDeactivateModal('{{ user_table_item._id }}', '{{ user_table_item.username }}', true)" class="mr-3 text-orange-400 hover:text-orange-300">Deaktivieren</button>
|
||||||
|
{% else %}
|
||||||
|
<form id="toggle-form-{{ user_table_item._id }}" action="/toggle_user_status/{{ user_table_item._id }}" method="POST" class="inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
</form>
|
||||||
|
<button @click="openDeactivateModal('{{ user_table_item._id }}', '{{ user_table_item.username }}', false)" class="mr-3 text-green-400 hover:text-green-300">Aktivieren</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<button @click="userToDelete = '{{ user_table_item._id }}'; showDeleteModal = true" class="text-red-400 hover:text-red-300">Löschen</button>
|
<button @click="userToDelete = '{{ user_table_item._id }}'; showDeleteModal = true" class="text-red-400 hover:text-red-300">Löschen</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -121,6 +150,32 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Deactivate Confirm Modal -->
|
||||||
|
<div x-show="showDeactivateModal" @keydown.escape.window="showDeactivateModal = false"
|
||||||
|
class="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50"
|
||||||
|
style="display: none;">
|
||||||
|
<div @click.away="showDeactivateModal = false"
|
||||||
|
class="bg-gray-900 border border-orange-500/50 rounded-xl shadow-2xl p-8 w-full max-w-md">
|
||||||
|
<h3 class="text-2xl font-bold text-orange-400 mb-2">Benutzer deaktivieren</h3>
|
||||||
|
<p class="text-gray-300 mb-5">Benutzer <span class="font-semibold text-white" x-text="userToToggle ? userToToggle.username : ''"></span> wird deaktiviert. Der Account kann sich nicht mehr anmelden.</p>
|
||||||
|
<form :action="userToToggle ? '/toggle_user_status/' + userToToggle.id : '#'" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">Grund (optional)</label>
|
||||||
|
<input type="text" name="deactivation_reason" placeholder="z.B. Verstos gegen Regeln..."
|
||||||
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-4">
|
||||||
|
<button type="button" @click="showDeactivateModal = false"
|
||||||
|
class="px-4 py-2 rounded-lg text-gray-300 hover:bg-gray-700 transition">Abbrechen</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white font-semibold rounded-lg transition">Deaktivieren</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div x-show="showDeleteModal" @keydown.escape.window="showDeleteModal = false" class="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50" style="display: none;">
|
<div x-show="showDeleteModal" @keydown.escape.window="showDeleteModal = false" class="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50" style="display: none;">
|
||||||
<div @click.away="showDeleteModal = false" class="bg-gray-900 border border-red-500/50 rounded-xl shadow-2xl p-8 w-full max-w-md">
|
<div @click.away="showDeleteModal = false" class="bg-gray-900 border border-red-500/50 rounded-xl shadow-2xl p-8 w-full max-w-md">
|
||||||
<h3 class="text-2xl font-bold text-red-400 mb-4">Benutzer löschen</h3>
|
<h3 class="text-2xl font-bold text-red-400 mb-4">Benutzer löschen</h3>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ from werkzeug.utils import secure_filename
|
||||||
import requests
|
import requests
|
||||||
import socket
|
import socket
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import asyncio
|
||||||
|
import discord
|
||||||
|
from discord.ui import Button, View
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
async_mode = "eventlet"
|
async_mode = "eventlet"
|
||||||
|
|
@ -74,6 +77,7 @@ try:
|
||||||
ets2_dlcs_collection = db["ets2_dlcs_packages"]
|
ets2_dlcs_collection = db["ets2_dlcs_packages"]
|
||||||
ip_analyzer_results_collection = db["ip_analyzer_results"]
|
ip_analyzer_results_collection = db["ip_analyzer_results"]
|
||||||
age_verification_collection = db["age_verification"]
|
age_verification_collection = db["age_verification"]
|
||||||
|
notifications_collection = db["notifications"]
|
||||||
print("INFO: MongoDB-Verbindung erfolgreich hergestellt.")
|
print("INFO: MongoDB-Verbindung erfolgreich hergestellt.")
|
||||||
AVAILABLE_PERMISSIONS = [
|
AVAILABLE_PERMISSIONS = [
|
||||||
"view_dashboard",
|
"view_dashboard",
|
||||||
|
|
@ -92,7 +96,8 @@ try:
|
||||||
"view_ip_analyzer",
|
"view_ip_analyzer",
|
||||||
"view_age_verification",
|
"view_age_verification",
|
||||||
"manage_age_verification",
|
"manage_age_verification",
|
||||||
"view_ayrantracker"
|
"view_ayrantracker",
|
||||||
|
"notifications"
|
||||||
]
|
]
|
||||||
if roles_collection.count_documents({}) == 0:
|
if roles_collection.count_documents({}) == 0:
|
||||||
print("WARNUNG: Keine Rollen in der Datenbank gefunden. Erstelle Standard-Rollen...")
|
print("WARNUNG: Keine Rollen in der Datenbank gefunden. Erstelle Standard-Rollen...")
|
||||||
|
|
@ -126,9 +131,20 @@ try:
|
||||||
'creation_date': datetime.utcnow(),
|
'creation_date': datetime.utcnow(),
|
||||||
'discord_id': None,
|
'discord_id': None,
|
||||||
'discord_username': None,
|
'discord_username': None,
|
||||||
'discord_avatar': None
|
'discord_avatar': None,
|
||||||
|
'is_active': True,
|
||||||
|
'deactivation_reason': None,
|
||||||
|
'deactivated_at': None,
|
||||||
|
'deactivated_by': None
|
||||||
})
|
})
|
||||||
print("INFO: Standard-Admin-Benutzer ('admin', Passwort: '1234') mit Rolle 'Owner' wurde erstellt.")
|
print("INFO: Standard-Admin-Benutzer ('admin', Passwort: '1234') mit Rolle 'Owner' wurde erstellt.")
|
||||||
|
else:
|
||||||
|
# Bestehende User mit is_active-Feld migrieren falls fehlend
|
||||||
|
accounts_collection.update_many(
|
||||||
|
{'is_active': {'$exists': False}},
|
||||||
|
{'$set': {'is_active': True, 'deactivation_reason': None, 'deactivated_at': None, 'deactivated_by': None}}
|
||||||
|
)
|
||||||
|
print("INFO: Bestehende Accounts wurden mit is_active-Feld migriert.")
|
||||||
if dashboard_collection.count_documents({}) == 0:
|
if dashboard_collection.count_documents({}) == 0:
|
||||||
print("INFO: Leere dashboard_stats Collection - lege Standardstatistiken an.")
|
print("INFO: Leere dashboard_stats Collection - lege Standardstatistiken an.")
|
||||||
dashboard_collection.insert_many([
|
dashboard_collection.insert_many([
|
||||||
|
|
@ -150,7 +166,7 @@ except Exception as e:
|
||||||
discord_bots_collection = None
|
discord_bots_collection = None
|
||||||
ets2_dlcs_collection = None
|
ets2_dlcs_collection = None
|
||||||
ip_analyzer_results_collection = None
|
ip_analyzer_results_collection = None
|
||||||
print(f"FATAL: Verbindung zur MongoDB fehlgeschlagen: {e}")
|
notifications_collection = None
|
||||||
db = None
|
db = None
|
||||||
accounts_collection = None
|
accounts_collection = None
|
||||||
logs_collection = None
|
logs_collection = None
|
||||||
|
|
@ -255,6 +271,160 @@ def stream_bot_output(bot_id, process):
|
||||||
socketio.emit('status_update', {'bot_id': bot_id, 'status': 'stopped'}, room=bot_id)
|
socketio.emit('status_update', {'bot_id': bot_id, 'status': 'stopped'}, room=bot_id)
|
||||||
socketio.emit('console_output', {'bot_id': bot_id, 'type': 'system', 'data': '--- Bot-Prozess beendet ---'}, room=bot_id)
|
socketio.emit('console_output', {'bot_id': bot_id, 'type': 'system', 'data': '--- Bot-Prozess beendet ---'}, room=bot_id)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Discord Notification Bot (Lightweight Thread)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
discord_bot_client = None
|
||||||
|
discord_bot_loop = None
|
||||||
|
|
||||||
|
def _get_owner_discord_ids():
|
||||||
|
"""Returns a list of discord_ids for all users with the Owner role."""
|
||||||
|
if accounts_collection is None or roles_collection is None:
|
||||||
|
return []
|
||||||
|
owner_role = roles_collection.find_one({'name': 'Owner'})
|
||||||
|
if not owner_role:
|
||||||
|
return []
|
||||||
|
owners = accounts_collection.find({'role_id': owner_role['_id'], 'discord_id': {'$ne': None}})
|
||||||
|
return [o['discord_id'] for o in owners if o.get('discord_id')]
|
||||||
|
|
||||||
|
def send_discord_notification(notification_id: str, title: str, message: str, notification_type: str = 'info', requires_action: bool = False):
|
||||||
|
"""Send a Discord DM to the Owner with optional action buttons."""
|
||||||
|
global discord_bot_loop
|
||||||
|
if discord_bot_loop is None or not discord_bot_loop.is_running():
|
||||||
|
print("WARNUNG: Discord Bot Loop nicht aktiv, Benachrichtigung wird nicht gesendet.")
|
||||||
|
return
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
_async_send_notification(notification_id, title, message, notification_type, requires_action),
|
||||||
|
discord_bot_loop
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_send_notification(notification_id: str, title: str, message: str, notification_type: str, requires_action: bool):
|
||||||
|
global discord_bot_client
|
||||||
|
if discord_bot_client is None:
|
||||||
|
return
|
||||||
|
owner_discord_ids = _get_owner_discord_ids()
|
||||||
|
if not owner_discord_ids:
|
||||||
|
print("WARNUNG: Keine Owner-Discord-ID gefunden.")
|
||||||
|
return
|
||||||
|
|
||||||
|
type_colors = {
|
||||||
|
'info': discord.Color.blurple(),
|
||||||
|
'warning': discord.Color.orange(),
|
||||||
|
'action': discord.Color.red(),
|
||||||
|
'reactivation_request': discord.Color.yellow()
|
||||||
|
}
|
||||||
|
type_emojis = {
|
||||||
|
'info': '📢',
|
||||||
|
'warning': '⚠️',
|
||||||
|
'action': '🔔',
|
||||||
|
'reactivation_request': '🔓'
|
||||||
|
}
|
||||||
|
color = type_colors.get(notification_type, discord.Color.blurple())
|
||||||
|
emoji = type_emojis.get(notification_type, '📢')
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"{emoji} {title}",
|
||||||
|
description=message,
|
||||||
|
color=color,
|
||||||
|
timestamp=datetime.utcnow()
|
||||||
|
)
|
||||||
|
embed.set_footer(text="AstraOS Admin Panel")
|
||||||
|
|
||||||
|
for discord_id_str in owner_discord_ids:
|
||||||
|
try:
|
||||||
|
user = await discord_bot_client.fetch_user(int(discord_id_str))
|
||||||
|
if requires_action:
|
||||||
|
view = NotificationActionView(notification_id)
|
||||||
|
await user.send(embed=embed, view=view)
|
||||||
|
else:
|
||||||
|
await user.send(embed=embed)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FEHLER: Discord DM an {discord_id_str} fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
class NotificationActionView(View):
|
||||||
|
def __init__(self, notification_id: str):
|
||||||
|
super().__init__(timeout=None)
|
||||||
|
self.notification_id = notification_id
|
||||||
|
approve_btn = Button(
|
||||||
|
label="✅ Genehmigen",
|
||||||
|
style=discord.ButtonStyle.green,
|
||||||
|
custom_id=f"notif_approve_{notification_id}"
|
||||||
|
)
|
||||||
|
reject_btn = Button(
|
||||||
|
label="❌ Ablehnen",
|
||||||
|
style=discord.ButtonStyle.red,
|
||||||
|
custom_id=f"notif_reject_{notification_id}"
|
||||||
|
)
|
||||||
|
approve_btn.callback = self.approve_callback
|
||||||
|
reject_btn.callback = self.reject_callback
|
||||||
|
self.add_item(approve_btn)
|
||||||
|
self.add_item(reject_btn)
|
||||||
|
|
||||||
|
async def approve_callback(self, interaction: discord.Interaction):
|
||||||
|
await self._handle_action(interaction, 'approved')
|
||||||
|
|
||||||
|
async def reject_callback(self, interaction: discord.Interaction):
|
||||||
|
await self._handle_action(interaction, 'rejected')
|
||||||
|
|
||||||
|
async def _handle_action(self, interaction: discord.Interaction, action: str):
|
||||||
|
if notifications_collection is not None:
|
||||||
|
notifications_collection.update_one(
|
||||||
|
{'_id': ObjectId(self.notification_id)},
|
||||||
|
{'$set': {
|
||||||
|
'action_status': action,
|
||||||
|
'action_taken_by': f"Discord:{interaction.user.name}",
|
||||||
|
'action_taken_at': datetime.utcnow(),
|
||||||
|
'is_read': True
|
||||||
|
}}
|
||||||
|
)
|
||||||
|
# If it was a reactivation request, handle account activation
|
||||||
|
notif = notifications_collection.find_one({'_id': ObjectId(self.notification_id)})
|
||||||
|
if notif and notif.get('type') == 'reactivation_request' and action == 'approved':
|
||||||
|
user_id = notif.get('metadata', {}).get('user_id')
|
||||||
|
if user_id and accounts_collection is not None:
|
||||||
|
accounts_collection.update_one(
|
||||||
|
{'_id': ObjectId(user_id)},
|
||||||
|
{'$set': {'is_active': True, 'deactivation_reason': None, 'deactivated_at': None, 'deactivated_by': None}}
|
||||||
|
)
|
||||||
|
label = "✅ Genehmigt" if action == 'approved' else "❌ Abgelehnt"
|
||||||
|
color = discord.Color.green() if action == 'approved' else discord.Color.red()
|
||||||
|
result_embed = discord.Embed(
|
||||||
|
title=f"Aktion ausgeführt: {label}",
|
||||||
|
description=f"Du hast diese Anfrage **{label.lower()}**.",
|
||||||
|
color=color,
|
||||||
|
timestamp=datetime.utcnow()
|
||||||
|
)
|
||||||
|
result_embed.set_footer(text="AstraOS Admin Panel")
|
||||||
|
for child in self.children:
|
||||||
|
child.disabled = True
|
||||||
|
await interaction.response.edit_message(embed=result_embed, view=self)
|
||||||
|
|
||||||
|
def _run_discord_bot():
|
||||||
|
global discord_bot_client, discord_bot_loop
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
discord_bot_client = discord.Client(intents=intents)
|
||||||
|
|
||||||
|
@discord_bot_client.event
|
||||||
|
async def on_ready():
|
||||||
|
print(f"INFO: Discord Notification Bot eingeloggt als {discord_bot_client.user}")
|
||||||
|
|
||||||
|
token = os.environ.get('DISCORD_BOT_TOKEN')
|
||||||
|
if not token:
|
||||||
|
print("WARNUNG: DISCORD_BOT_TOKEN nicht gesetzt. Discord-Bot wird nicht gestartet.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
discord_bot_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(discord_bot_loop)
|
||||||
|
discord_bot_loop.run_until_complete(discord_bot_client.start(token))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FEHLER: Discord Bot konnte nicht gestartet werden: {e}")
|
||||||
|
|
||||||
|
_discord_bot_thread = threading.Thread(target=_run_discord_bot, daemon=True)
|
||||||
|
_discord_bot_thread.start()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
if os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
|
if os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
start_bots_on_startup()
|
start_bots_on_startup()
|
||||||
|
|
@ -448,6 +618,10 @@ def login_required(f):
|
||||||
flash("Benutzer nicht gefunden.", "danger")
|
flash("Benutzer nicht gefunden.", "danger")
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
if not user.get('is_active', True):
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for('account_disabled'))
|
||||||
|
|
||||||
if not user.get('discord_id') and request.endpoint not in ['link_discord', 'login_discord', 'callback_discord', 'logout', 'static']:
|
if not user.get('discord_id') and request.endpoint not in ['link_discord', 'login_discord', 'callback_discord', 'logout', 'static']:
|
||||||
return redirect(url_for('link_discord'))
|
return redirect(url_for('link_discord'))
|
||||||
|
|
||||||
|
|
@ -494,6 +668,8 @@ def login():
|
||||||
return render_template('login.html'), 500
|
return render_template('login.html'), 500
|
||||||
user = accounts_collection.find_one({'username': username})
|
user = accounts_collection.find_one({'username': username})
|
||||||
if user and check_password_hash(user['password'], password):
|
if user and check_password_hash(user['password'], password):
|
||||||
|
if not user.get('is_active', True):
|
||||||
|
return redirect(url_for('account_disabled'))
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
session['user_id'] = str(user['_id'])
|
session['user_id'] = str(user['_id'])
|
||||||
session['username'] = user['username']
|
session['username'] = user['username']
|
||||||
|
|
@ -758,6 +934,220 @@ def delete_user(user_id):
|
||||||
flash("Benutzer wurde erfolgreich gelöscht.", "success")
|
flash("Benutzer wurde erfolgreich gelöscht.", "success")
|
||||||
return redirect(url_for('user_management'))
|
return redirect(url_for('user_management'))
|
||||||
|
|
||||||
|
@app.route('/toggle_user_status/<user_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@permission_required('manage_users')
|
||||||
|
def toggle_user_status(user_id):
|
||||||
|
if accounts_collection is None:
|
||||||
|
flash("Datenbankverbindung nicht verfügbar.", "danger")
|
||||||
|
return redirect(url_for('user_management'))
|
||||||
|
if session.get('user_id') == user_id:
|
||||||
|
flash("Sie können Ihren eigenen Account nicht deaktivieren.", "danger")
|
||||||
|
return redirect(url_for('user_management'))
|
||||||
|
user = accounts_collection.find_one({'_id': ObjectId(user_id)})
|
||||||
|
if not user:
|
||||||
|
flash("Benutzer nicht gefunden.", "danger")
|
||||||
|
return redirect(url_for('user_management'))
|
||||||
|
new_status = not user.get('is_active', True)
|
||||||
|
update_data = {'is_active': new_status}
|
||||||
|
if not new_status:
|
||||||
|
reason = request.form.get('deactivation_reason', '')
|
||||||
|
update_data['deactivation_reason'] = reason if reason else None
|
||||||
|
update_data['deactivated_at'] = datetime.utcnow()
|
||||||
|
update_data['deactivated_by'] = session.get('username')
|
||||||
|
add_log("Benutzer deaktiviert", f"Benutzername: {user.get('username')}, Grund: {reason or 'Kein Grund angegeben'}")
|
||||||
|
flash(f"Benutzer '{user.get('username')}' wurde deaktiviert.", "success")
|
||||||
|
else:
|
||||||
|
update_data['deactivation_reason'] = None
|
||||||
|
update_data['deactivated_at'] = None
|
||||||
|
update_data['deactivated_by'] = None
|
||||||
|
add_log("Benutzer aktiviert", f"Benutzername: {user.get('username')}")
|
||||||
|
flash(f"Benutzer '{user.get('username')}' wurde wieder aktiviert.", "success")
|
||||||
|
accounts_collection.update_one({'_id': ObjectId(user_id)}, {'$set': update_data})
|
||||||
|
return redirect(url_for('user_management'))
|
||||||
|
|
||||||
|
@app.route('/account_disabled')
|
||||||
|
def account_disabled():
|
||||||
|
return render_template('account_disabled.html')
|
||||||
|
|
||||||
|
@app.route('/request_reactivation', methods=['POST'])
|
||||||
|
def request_reactivation():
|
||||||
|
if notifications_collection is None:
|
||||||
|
flash("Systemfehler. Bitte versuche es später erneut.", "danger")
|
||||||
|
return redirect(url_for('account_disabled'))
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
reason = request.form.get('reason', '').strip()
|
||||||
|
if not username:
|
||||||
|
flash("Benutzername ist erforderlich.", "danger")
|
||||||
|
return redirect(url_for('account_disabled'))
|
||||||
|
user = accounts_collection.find_one({'username': username}) if accounts_collection else None
|
||||||
|
if not user:
|
||||||
|
flash("Benutzername nicht gefunden.", "danger")
|
||||||
|
return redirect(url_for('account_disabled'))
|
||||||
|
if user.get('is_active', True):
|
||||||
|
flash("Dieser Account ist nicht deaktiviert.", "info")
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
# Check for existing pending request
|
||||||
|
existing = notifications_collection.find_one({
|
||||||
|
'type': 'reactivation_request',
|
||||||
|
'action_status': 'pending',
|
||||||
|
'metadata.user_id': str(user['_id'])
|
||||||
|
})
|
||||||
|
if existing:
|
||||||
|
flash("Es gibt bereits eine offene Anfrage für diesen Account. Bitte warte auf eine Antwort.", "info")
|
||||||
|
return redirect(url_for('account_disabled'))
|
||||||
|
notif_doc = {
|
||||||
|
'type': 'reactivation_request',
|
||||||
|
'title': f'Reaktivierungsanfrage: {username}',
|
||||||
|
'message': f'Der Benutzer **{username}** möchte seinen Account reaktivieren.\n\n'
|
||||||
|
f'**Begründung:** {reason if reason else "Keine Begründung angegeben"}\n\n'
|
||||||
|
f'**Deaktiviert am:** {user.get("deactivated_at", "Unbekannt")}\n'
|
||||||
|
f'**Deaktiviert von:** {user.get("deactivated_by", "Unbekannt")}\n'
|
||||||
|
f'**Deaktivierungsgrund:** {user.get("deactivation_reason") or "Kein Grund angegeben"}',
|
||||||
|
'created_at': datetime.utcnow(),
|
||||||
|
'created_by': 'system',
|
||||||
|
'is_read': False,
|
||||||
|
'requires_action': True,
|
||||||
|
'action_status': 'pending',
|
||||||
|
'action_taken_by': None,
|
||||||
|
'action_taken_at': None,
|
||||||
|
'metadata': {'user_id': str(user['_id']), 'username': username},
|
||||||
|
'discord_message_id': None
|
||||||
|
}
|
||||||
|
result = notifications_collection.insert_one(notif_doc)
|
||||||
|
send_discord_notification(
|
||||||
|
str(result.inserted_id),
|
||||||
|
notif_doc['title'],
|
||||||
|
notif_doc['message'],
|
||||||
|
notification_type='reactivation_request',
|
||||||
|
requires_action=True
|
||||||
|
)
|
||||||
|
flash("Deine Anfrage wurde gesendet. Ein Administrator wird sich darum kümmern.", "success")
|
||||||
|
return redirect(url_for('account_disabled'))
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Notifications Routes
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@app.route('/notifications')
|
||||||
|
@login_required
|
||||||
|
@permission_required('notifications')
|
||||||
|
def notifications_page():
|
||||||
|
if notifications_collection is None:
|
||||||
|
flash("Datenbankverbindung nicht verfügbar.", "danger")
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
all_notifications = list(notifications_collection.find().sort('created_at', -1))
|
||||||
|
for n in all_notifications:
|
||||||
|
n['_id'] = str(n['_id'])
|
||||||
|
unread_count = notifications_collection.count_documents({'is_read': False})
|
||||||
|
return render_template('notifications.html', active_page='notifications',
|
||||||
|
notifications=all_notifications, unread_count=unread_count)
|
||||||
|
|
||||||
|
@app.route('/api/notifications/unread_count')
|
||||||
|
@login_required
|
||||||
|
def api_notifications_unread_count():
|
||||||
|
if 'notifications' not in session.get('permissions', []):
|
||||||
|
return jsonify({'count': 0})
|
||||||
|
if notifications_collection is None:
|
||||||
|
return jsonify({'count': 0})
|
||||||
|
count = notifications_collection.count_documents({'is_read': False})
|
||||||
|
return jsonify({'count': count})
|
||||||
|
|
||||||
|
@app.route('/api/notifications/send', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@permission_required('notifications')
|
||||||
|
def api_send_notification():
|
||||||
|
if notifications_collection is None:
|
||||||
|
return jsonify({'success': False, 'error': 'DB nicht verfügbar'}), 500
|
||||||
|
data = request.get_json()
|
||||||
|
title = (data.get('title') or '').strip()
|
||||||
|
message = (data.get('message') or '').strip()
|
||||||
|
notif_type = data.get('type', 'info')
|
||||||
|
if not title or not message:
|
||||||
|
return jsonify({'success': False, 'error': 'Titel und Nachricht sind erforderlich.'}), 400
|
||||||
|
if notif_type not in ['info', 'warning', 'action']:
|
||||||
|
notif_type = 'info'
|
||||||
|
requires_action = notif_type == 'action'
|
||||||
|
notif_doc = {
|
||||||
|
'type': notif_type,
|
||||||
|
'title': title,
|
||||||
|
'message': message,
|
||||||
|
'created_at': datetime.utcnow(),
|
||||||
|
'created_by': session.get('username', 'Unbekannt'),
|
||||||
|
'is_read': False,
|
||||||
|
'requires_action': requires_action,
|
||||||
|
'action_status': 'pending' if requires_action else None,
|
||||||
|
'action_taken_by': None,
|
||||||
|
'action_taken_at': None,
|
||||||
|
'metadata': {},
|
||||||
|
'discord_message_id': None
|
||||||
|
}
|
||||||
|
result = notifications_collection.insert_one(notif_doc)
|
||||||
|
add_log("Benachrichtigung gesendet", f"Titel: {title}, Typ: {notif_type}")
|
||||||
|
send_discord_notification(
|
||||||
|
str(result.inserted_id),
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
notification_type=notif_type,
|
||||||
|
requires_action=requires_action
|
||||||
|
)
|
||||||
|
return jsonify({'success': True, 'id': str(result.inserted_id)})
|
||||||
|
|
||||||
|
@app.route('/api/notifications/<notif_id>/action', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@permission_required('notifications')
|
||||||
|
def api_notification_action(notif_id):
|
||||||
|
if notifications_collection is None:
|
||||||
|
return jsonify({'success': False, 'error': 'DB nicht verfügbar'}), 500
|
||||||
|
data = request.get_json()
|
||||||
|
action = data.get('action')
|
||||||
|
if action not in ['approved', 'rejected']:
|
||||||
|
return jsonify({'success': False, 'error': 'Ungültige Aktion'}), 400
|
||||||
|
notif = notifications_collection.find_one({'_id': ObjectId(notif_id)})
|
||||||
|
if not notif:
|
||||||
|
return jsonify({'success': False, 'error': 'Benachrichtigung nicht gefunden'}), 404
|
||||||
|
notifications_collection.update_one(
|
||||||
|
{'_id': ObjectId(notif_id)},
|
||||||
|
{'$set': {
|
||||||
|
'action_status': action,
|
||||||
|
'action_taken_by': session.get('username'),
|
||||||
|
'action_taken_at': datetime.utcnow(),
|
||||||
|
'is_read': True
|
||||||
|
}}
|
||||||
|
)
|
||||||
|
if notif.get('type') == 'reactivation_request' and action == 'approved':
|
||||||
|
user_id = notif.get('metadata', {}).get('user_id')
|
||||||
|
if user_id and accounts_collection is not None:
|
||||||
|
accounts_collection.update_one(
|
||||||
|
{'_id': ObjectId(user_id)},
|
||||||
|
{'$set': {'is_active': True, 'deactivation_reason': None, 'deactivated_at': None, 'deactivated_by': None}}
|
||||||
|
)
|
||||||
|
add_log("Account reaktiviert (via Panel)", f"User-ID: {user_id}")
|
||||||
|
add_log("Benachrichtigung bearbeitet", f"ID: {notif_id}, Aktion: {action}")
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
@app.route('/api/notifications/<notif_id>/read', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@permission_required('notifications')
|
||||||
|
def api_notification_mark_read(notif_id):
|
||||||
|
if notifications_collection is None:
|
||||||
|
return jsonify({'success': False}), 500
|
||||||
|
notifications_collection.update_one(
|
||||||
|
{'_id': ObjectId(notif_id)},
|
||||||
|
{'$set': {'is_read': True}}
|
||||||
|
)
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
@app.route('/api/notifications/<notif_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@permission_required('notifications')
|
||||||
|
def api_notification_delete(notif_id):
|
||||||
|
if notifications_collection is None:
|
||||||
|
return jsonify({'success': False}), 500
|
||||||
|
notifications_collection.delete_one({'_id': ObjectId(notif_id)})
|
||||||
|
add_log("Benachrichtigung gelöscht", f"ID: {notif_id}")
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
@app.route('/settings', methods=['GET'])
|
@app.route('/settings', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def settings():
|
def settings():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue