diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..60a915d Binary files /dev/null and b/.DS_Store differ diff --git a/ChatbotSprites.png b/ChatbotSprites.png new file mode 100644 index 0000000..bbc59f4 Binary files /dev/null and b/ChatbotSprites.png differ diff --git a/ideas.md b/ideas.md new file mode 100644 index 0000000..6c3a0df --- /dev/null +++ b/ideas.md @@ -0,0 +1,73 @@ +# Brainstorming & Zukunfts-Ideen für den Timetracker + +Der Timetracker ist durch die Umstellung auf eine Hosted Blazor WebAssembly (.NET 10) Architektur mit PostgreSQL-Unterstützung extrem solide und zukunftssicher aufgestellt. Hier sind einige spannende, innovative Produktideen, um das Tool auf das nächste Level zu heben: + +--- + +## 🚀 1. PWA-Support (Progressive Web App) und Offline-Modus +* **Offline-Erfassung**: Da die App in Blazor WebAssembly im Browser läuft, lässt sie sich leicht zu einer PWA umrüsten. Zeiterfassungen könnten lokal im `IndexedDB`-Speicher des Browsers gesichert werden, wenn keine Internetverbindung besteht, und bei Wiederverbindung automatisch mit der PostgreSQL-Datenbank synchronisiert werden. +* **Mobiles App-Feeling**: Installation direkt auf dem Homescreen (Android/iOS) ohne App-Store-Zwang. +* **Web-Push Notifications**: Tägliche Erinnerungen, falls man vergisst, sich auszustempeln oder Pausen einzutragen. + +--- + +## 🤖 2. KI-Assistent & Smart-Suggestions +* **Kalender- & Git-Sync**: Einbindung einer KI-Schnittstelle, die Zugriff auf Terminkalender (Google/Outlook) oder lokale Git-Commits hat. Sie schlägt am Ende des Tages automatisch passende Buchungen vor (z. B. *"Du hast von 10-11 Uhr am Feature X gearbeitet. Möchtest du das buchen?"*). +* **Ausreißer-Erkennung**: KI-gestützte Warnungen, wenn Zeiten untypisch erfasst wurden (z. B. 12 Stunden gearbeitet ohne Pause). + +--- + +## 📅 3. Kalender-Integration & Feiertags-Automatisierung +* **Direktes Feiertags-Abonnement**: Automatische Erkennung des Bundeslandes und Import der Feiertage ohne manuellen Klick auf "Von API laden" zu Beginn des Jahres. +* **Kalender-Export (iCal)**: Generierung eines persönlichen iCal-Feeds, damit der User seine gebuchten Urlaubstage und Überstunden in seinem primären Kalender (Google Calendar, Apple Calendar) sieht. + +--- + +## 👥 4. Team-Features & Urlaubsplaner +* **Wer-ist-da-Dashboard**: Ein Live-Statusboard für Teams, auf dem man sieht, wer gerade eingestempelt ist, wer Pause macht oder wer heute Urlaub hat/krankgeschrieben ist. +* **Gemeinsamer Urlaubskalender**: Ein Kalender für das gesamte Team, um Urlaubsüberschneidungen direkt zu erkennen und Freigabeprozesse durch Admins zu ermöglichen. +* **Rollenkonzepte**: Einführung von Teamleitern (können Zeiten der Mitglieder freigeben) und Standard-Mitarbeitern. + +--- + +## 📊 5. Reporting & Abrechnung (Billing) +* **Rechnungserstellung**: Verknüpfung von erfassten Zeiten mit Stundensätzen, um mit einem Klick professionelle PDF-Rechnungen an Kunden zu erstellen. +* **Auswertungen**: Interaktive Diagramme (z.B. mittels MudBlazor Charts) zur Analyse von Arbeitszeiten, Überstundenentwicklung und Krankheitstagen über das Jahr verteilt. + +--- + +## 🔌 6. Integrationen (Jira, GitHub, Slack) +* **Git-Hooks / CLI**: Automatisches Starten/Stoppen von Timern per CLI oder bei Git-Commits. +* **Slack / Teams Chatbot**: Schnelles Ein- und Ausstempeln direkt aus dem Chat-Client heraus über Befehle wie `/track start` oder `/track pause`. + +--- + +## 🔒 7. Sicherheit & DSGVO +* **Audit Logs**: Transparente Historie darüber, wer wann welche Zeiten geändert hat (besonders wichtig bei Prüfungen). +* **Automatisches Löschkonzept**: Möglichkeit zur Einhaltung von Löschfristen erfasster Daten nach Beendigung des Arbeitsverhältnisses. + +--- + +## ✉️ 8. E-Mail-Verifizierung & Inhouse-Mailserver +* **Sicherer Registrierungs-Flow**: E-Mail-Verifizierung bei Neuregistrierung mittels eines 6-stelligen, zeitlich begrenzten Bestätigungscodes. Erst nach Eingabe des Codes wird das Benutzerkonto aktiviert und der Login erlaubt. +* **Inhouse SMTP-Lösungen**: + * **Lokale Entwicklung**: Einbindung eines SMTP-Sandboxes wie **Mailpit** (oder MailHog) direkt in die `docker-compose.yml`, um E-Mails sicher lokal zu fangen und im Browser zu sichten. + * **Produktion**: Konfiguration für inhouse SMTP-Relays oder Aufbau eines vollverschlüsselten **docker-mailserver** Containers für den komplett eigenständigen, datenschutzkonformen E-Mail-Versand. + +--- + +## 🏢 9. B2B-Fähigkeit & Azure AD (Entra ID) Integration +* **Generisches Single Sign-On (SSO)**: Integration von OpenID Connect (OIDC) über Umgebungsvariablen. Dadurch können Unternehmen das Tool nahtlos an Microsoft Entra ID (Azure AD), Okta, Keycloak oder Google Workspace anbinden. +* **White-Labeling & Customizing (Single-Tenant)**: Möglichkeit, Firmenlogos, Firmennamen und Standardeinstellungen (z.B. Feiertags-Bundesland) pro Docker-Compose-Instanz flexibel anzupassen. +* **Optionale SaaS-Erweiterung (Multi-Tenant)**: Zukünftiger Ausbau für mehrmandantenfähigen Betrieb mit Datenisolation auf Tabellenebene (`TenantId`), dynamischer SSO-Zuordnung per E-Mail-Domain und zentraler Rechnungsabwicklung (z.B. Stripe). + +--- + +## 💬 10. Gamifizierter Assistent & KI-Chatbot +* **Regelbasierter Gamification-Widget (Quick-Win)**: Ein kleiner, charismatischer Assistent/Avatar im UI, der basierend auf den aktuellen Arbeitsstatistiken motivierende, freche oder informative Sprüche anzeigt (z.B. bei vielen Überstunden, fehlenden Pausen oder anstehendem Urlaub). +* **LLM-gestützter KI-Assistent (Smart-Assistant)**: + * **Analysen**: Ein Chat-Fenster, in dem der Benutzer Fragen zu seinen Arbeitszeiten stellen kann (z.B. *"Wie viele Überstunden habe ich diesen Monat angesammelt?"* oder *"Wann habe ich letzte Woche Dienstag angefangen?"*). + * **Interaktion & Aktionen**: Die KI kann durch Function Calling direkt Aktionen ausführen (z.B. *"Trage mir für morgen 8 Stunden Arbeit ein"* oder *"Trage Urlaub für KW 28 ein"*). + * **Technologie**: Anbindung über ein einfaches HttpClient-Modul an die Google Gemini API, OpenAI oder lokal über ein Ollama-Docker-Container. + + diff --git a/timebot.png b/timebot.png new file mode 100644 index 0000000..762bc97 Binary files /dev/null and b/timebot.png differ diff --git a/timetracker.Client/Components/Layout/ChatbotWidget.razor b/timetracker.Client/Components/Layout/ChatbotWidget.razor new file mode 100644 index 0000000..6498226 --- /dev/null +++ b/timetracker.Client/Components/Layout/ChatbotWidget.razor @@ -0,0 +1,297 @@ +@inject ITimetrackerService TrackerService +@inject AuthenticationStateProvider AuthStateProvider +@inject IJSRuntime JS +@using System.Security.Claims +@using timetracker.Shared + +@if (_isOpen) +{ +
+ +
+
+ Timebot +
+ Timebot + Online +
+
+ +
+ + +
+ @foreach (var msg in _messages) + { +
+ @if (msg.Sender == "Bot") + { + Timebot + } +
+ @if (msg.IsTyping) + { +
+ +
+ } + else + { + @((MarkupString)FormatMessageText(msg.Content)) + } +
+
+ } +
+ + +
+ + + + +
+ + + +
+} + + +
+ Timebot Launcher + @if (_unreadCount > 0 && !_isOpen) + { + @_unreadCount + } +
+ +@code { + private bool _isOpen = false; + private string _userInput = ""; + private int _unreadCount = 1; + private List _messages = new(); + + private int _userId; + private string _username = "Marc"; + private AppSettings _settings = new(); + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier); + if (claim != null) + { + _userId = int.Parse(claim.Value); + _username = authState.User.Identity?.Name ?? "Marc"; + if (!string.IsNullOrEmpty(_username)) + { + _username = char.ToUpper(_username[0]) + _username.Substring(1); + } + _settings = await TrackerService.GetSettingsAsync(_userId); + } + + _messages.Add(new ChatMessage + { + Sender = "Bot", + Content = $"Hallo {_username}! 👋 Ich bin dein Timebot. Ich habe ein Auge auf deine Zeiten geworfen.\n\nWie kann ich dir heute helfen?", + Timestamp = DateTime.Now + }); + } + + private void ToggleChat() + { + _isOpen = !_isOpen; + if (_isOpen) + { + _unreadCount = 0; + _ = ScrollToBottom(); + } + } + + private async Task HandleKeyPress(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_userInput)) + { + await SendMessage(); + } + } + + private async Task SendMessage() + { + if (string.IsNullOrWhiteSpace(_userInput)) return; + var text = _userInput; + _userInput = ""; + + _messages.Add(new ChatMessage { Sender = "User", Content = text, Timestamp = DateTime.Now }); + await ScrollToBottom(); + + await RespondTo(text); + } + + private async Task AskQuestion(string key) + { + string userQuestion = ""; + if (key == "Überstunden") userQuestion = "Wie viele Überstunden habe ich?"; + else if (key == "Woche") userQuestion = "Wie sieht meine Woche aus?"; + else if (key == "Urlaub") userQuestion = "Wie hilft mir der Urlaubs-Maximizer?"; + else if (key == "Hilfe") userQuestion = "Welche Fragen kann ich dir stellen?"; + + _messages.Add(new ChatMessage { Sender = "User", Content = userQuestion, Timestamp = DateTime.Now }); + await ScrollToBottom(); + + await RespondTo(key); + } + + private async Task RespondTo(string query) + { + // Add typing indicator + var typingMsg = new ChatMessage { Sender = "Bot", IsTyping = true, Timestamp = DateTime.Now }; + _messages.Add(typingMsg); + await ScrollToBottom(); + + // Simulate thinking/typing delay + await Task.Delay(1000); + _messages.Remove(typingMsg); + + string responseText = ""; + var normalized = query.ToLower(); + + if (normalized.Contains("überstunden") || normalized == "überstunden" || normalized.Contains("saldo") || normalized.Contains("gleitzeit")) + { + var settings = await TrackerService.GetSettingsAsync(_userId); + var overtime = await TrackerService.GetTotalOvertimeAsync(_userId, settings); + var overtimeStr = FormatTs(overtime, sign: true); + + if (overtime >= TimeSpan.Zero) + { + responseText = $"Dein Gleitzeitkonto sieht super aus! Du hast aktuell **{overtimeStr} Std.** auf deinem Gleitzeitkonto angesammelt.\n\nSehr stark! Hast du mit der Extrazeit schon etwas vor oder bist du einfach nur voll am Hustlen? 🚀🔥"; + } + else + { + responseText = $"Dein Gleitzeitkonto steht aktuell bei **{overtimeStr} Std.**\n\nDu bist also ein kleines bisschen im Minus. Aber kein Grund zur Sorge: Das lässt sich mit ein paar Extra-Minuten in den nächsten Tagen leicht wieder ausgleichen! 💪"; + } + } + else if (normalized.Contains("woche") || normalized == "woche" || normalized.Contains("arbeitszeit") || normalized.Contains("stunden")) + { + var today = DateOnly.FromDateTime(DateTime.Today); + var monday = GetMonday(today); + var settings = await TrackerService.GetSettingsAsync(_userId); + var weekDays = await TrackerService.GetWeekAsync(_userId, monday); + + double workedHours = 0; + double targetHours = 0; + + foreach (var date in Enumerable.Range(0, 7).Select(i => monday.AddDays(i))) + { + bool isWorkDay = settings.IsWorkDay(date.DayOfWeek); + if (isWorkDay) + { + targetHours += settings.DailyTargetHours; + } + + var wd = weekDays.FirstOrDefault(d => d.Date == date); + if (wd != null && wd.StartTime != null && wd.EndTime != null) + { + var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan(); + if (gross > TimeSpan.Zero) + { + var breakTotal = wd.Breaks + .Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime) + .Aggregate(TimeSpan.Zero, (s, b) => + s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan())); + workedHours += (gross - breakTotal).TotalHours; + } + } + } + + var workedTs = TimeSpan.FromHours(workedHours); + var targetTs = TimeSpan.FromHours(targetHours); + var percent = targetHours > 0 ? (int)Math.Round(workedHours / targetHours * 100) : 0; + + responseText = $"Diese Woche hast du bereits **{FormatTs(workedTs)}** von geplanten **{FormatTs(targetTs)}** gearbeitet.\n\nDu hast diese Woche also schon **{percent}%** deines Solls erfüllt. Weiter so! ⏰"; + } + else if (normalized.Contains("urlaub") || normalized == "urlaub" || normalized.Contains("maximizer") || normalized.Contains("brückentag")) + { + responseText = $"Der **Urlaubs-Maximizer** analysiert das Kalenderjahr, gesetzliche Feiertage und Brückentage, um dir die besten Zeiträume für Urlaub vorzuschlagen.\n\nDu findest die Auswertung direkt in der Sidebar unter **'Urlaubs-Maximizer'**. So machst du aus wenigen Urlaubstagen maximale Freizeit! 🏖️✈️"; + } + else if (normalized.Contains("hilfe") || normalized == "hilfe" || normalized.Contains("hallo") || normalized.Contains("hi") || normalized.Contains("helo") || normalized.Contains("huhu") || normalized == "hilfe") + { + responseText = $"Ich kann dir Auskunft über deine Zeiten geben. Frage mich zum Beispiel:\n\n" + + $"• *'Wie viele Überstunden habe ich?'*\n" + + $"• *'Wie sieht meine Woche aus?'*\n" + + $"• *'Was macht der Urlaubs-Maximizer?'*\n\n" + + $"Oder tippe einfach deine Frage ein! 💡"; + } + else + { + responseText = $"Entschuldige, diese Frage kann ich leider noch nicht beantworten. 🤖\n\nFrage mich gerne nach deinem **Überstunden-Saldo**, deiner **Wochen-Arbeitszeit** oder dem **Urlaubs-Maximizer**!"; + } + + _messages.Add(new ChatMessage { Sender = "Bot", Content = responseText, Timestamp = DateTime.Now }); + await ScrollToBottom(); + } + + private async Task ScrollToBottom() + { + try + { + await JS.InvokeVoidAsync("eval", "setTimeout(() => { const el = document.getElementById('timebot-body-el'); if(el) el.scrollTop = el.scrollHeight; }, 50);"); + } + catch + { + // Prerender-Pass ignorieren + } + } + + private string FormatMessageText(string text) + { + // Simple helper to replace markdown bold (**text**) with HTML bold (text) + // and newlines (\n) with
+ if (string.IsNullOrEmpty(text)) return ""; + + var formatted = text; + + // Escape HTML first to prevent XSS + formatted = System.Net.WebUtility.HtmlEncode(formatted); + + // Replace back escaped ** formatting + // Simple regex replace for **text** + var regex = new System.Text.RegularExpressions.Regex(@"\*\*(.*?)\*\*"); + formatted = regex.Replace(formatted, "$1"); + + // Replace newlines + formatted = formatted.Replace("\n", "
"); + + return formatted; + } + + private static DateOnly GetMonday(DateOnly date) + { + int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7; + return date.AddDays(-diff); + } + + private static string FormatTs(TimeSpan ts, bool sign = false) + { + if (ts == TimeSpan.Zero && sign) return "±0:00"; + var prefix = sign ? (ts >= TimeSpan.Zero ? "+" : "−") : (ts < TimeSpan.Zero ? "−" : ""); + var abs = ts.Duration(); + return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}"; + } + + private sealed class ChatMessage + { + public string Sender { get; set; } = "Bot"; + public string Content { get; set; } = ""; + public DateTime Timestamp { get; set; } = DateTime.Now; + public bool IsTyping { get; set; } = false; + } +} diff --git a/timetracker.Client/Components/Layout/ChatbotWidget.razor.css b/timetracker.Client/Components/Layout/ChatbotWidget.razor.css new file mode 100644 index 0000000..823d646 --- /dev/null +++ b/timetracker.Client/Components/Layout/ChatbotWidget.razor.css @@ -0,0 +1,334 @@ +/* Floating Launcher Button */ +.timebot-launcher { + position: fixed; + bottom: 24px; + right: 24px; + width: 64px; + height: 64px; + border-radius: 50%; + background: linear-gradient(135deg, #ffffff 0%, #f1f3f9 100%); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + cursor: pointer; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + border: 2px solid #3F51B5; +} + +.timebot-launcher:hover { + transform: scale(1.1) translateY(-4px); + box-shadow: 0 12px 28px rgba(63, 81, 181, 0.4); + border-color: #1A237E; +} + +.timebot-launcher img { + width: 48px; + height: 48px; + object-fit: contain; +} + +.timebot-launcher.open { + transform: scale(0.9) rotate(90deg); + opacity: 0.8; +} + +.timebot-badge { + position: absolute; + top: -4px; + right: -4px; + background-color: #ff3b30; + color: white; + font-size: 11px; + font-weight: bold; + padding: 3px 7px; + border-radius: 10px; + border: 2px solid white; +} + +/* Chat Window */ +.timebot-chat-window { + position: fixed; + bottom: 100px; + right: 24px; + width: 360px; + height: 520px; + max-height: 80vh; + border-radius: 20px; + background: rgba(32, 33, 36, 0.95); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 9999; + animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; + color: #f1f3f9; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Header */ +.timebot-header { + background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); + padding: 14px 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.timebot-header-info { + display: flex; + align-items: center; + gap: 12px; +} + +.timebot-header-avatar { + width: 36px; + height: 36px; + background: rgba(255, 255, 255, 0.15); + border-radius: 50%; + padding: 2px; + object-fit: contain; +} + +.timebot-header-text { + display: flex; + flex-direction: column; +} + +.timebot-header-title { + font-weight: 700; + font-size: 15px; + color: white; + letter-spacing: 0.3px; +} + +.timebot-header-status { + font-size: 11px; + color: rgba(255, 255, 255, 0.75); + display: flex; + align-items: center; + gap: 4px; +} + +.status-dot { + width: 6px; + height: 6px; + background-color: #4cd964; + border-radius: 50%; + display: inline-block; + box-shadow: 0 0 8px #4cd964; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(76, 217, 100, 0.7); } + 70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(76, 217, 100, 0); } + 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(76, 217, 100, 0); } +} + +.timebot-close-btn { + background: transparent; + border: none; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.timebot-close-btn:hover { + opacity: 1; +} + +/* Chat Body */ +.timebot-body { + flex: 1; + padding: 16px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + background: rgba(24, 25, 28, 0.4); +} + +.timebot-msg-row { + display: flex; + gap: 8px; + max-width: 85%; +} + +.timebot-msg-row.bot-row { + align-self: flex-start; +} + +.timebot-msg-row.user-row { + align-self: flex-end; + flex-direction: row-reverse; +} + +.timebot-msg-avatar { + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + padding: 2px; + align-self: flex-end; + object-fit: contain; +} + +.timebot-msg-bubble { + padding: 10px 14px; + border-radius: 16px; + font-size: 13.5px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.bot-bubble { + background: rgba(255, 255, 255, 0.08); + color: #e3e3e7; + border-bottom-left-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.04); +} + +.user-bubble { + background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); + color: white; + border-bottom-right-radius: 4px; + box-shadow: 0 4px 12px rgba(63, 81, 181, 0.2); +} + +/* Typing Indicator */ +.typing-indicator { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 0; +} + +.typing-indicator span { + width: 6px; + height: 6px; + background-color: #a8b2c1; + border-radius: 50%; + display: inline-block; + animation: bounce 1.4s infinite both; +} + +.typing-indicator span:nth-child(1) { animation-delay: -0.32s; } +.typing-indicator span:nth-child(2) { animation-delay: -0.16s; } + +@keyframes bounce { + 0%, 80%, 100% { transform: scale(0); } + 40% { transform: scale(1.0); } +} + +/* Suggestions */ +.timebot-suggestions { + display: flex; + gap: 6px; + padding: 8px 12px; + background: rgba(18, 18, 20, 0.6); + border-top: 1px solid rgba(255, 255, 255, 0.06); + overflow-x: auto; + scrollbar-width: none; /* Scrollbar hidden for visual aesthetics */ +} + +.timebot-suggestions::-webkit-scrollbar { + display: none; +} + +.timebot-suggestions button { + flex-shrink: 0; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #cbd5e1; + padding: 6px 12px; + border-radius: 14px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; + font-weight: 500; +} + +.timebot-suggestions button:hover { + background: rgba(63, 81, 181, 0.15); + border-color: #3F51B5; + color: white; + transform: translateY(-1px); +} + +/* Footer */ +.timebot-footer { + padding: 12px; + display: flex; + align-items: center; + gap: 8px; + background: rgba(32, 33, 36, 0.98); + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.timebot-input { + flex: 1; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + color: white; + padding: 10px 14px; + border-radius: 12px; + font-size: 13px; + outline: none; + transition: all 0.2s; +} + +.timebot-input:focus { + border-color: #3F51B5; + background: rgba(255, 255, 255, 0.1); + box-shadow: 0 0 0 2px rgba(63, 81, 181, 0.2); +} + +.timebot-send-btn { + background: white; + border: none; + border-radius: 10px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; +} + +.timebot-send-btn:hover { + background: #e3e6ff; + transform: scale(1.05); +} + +.timebot-send-btn:disabled { + background: rgba(255, 255, 255, 0.04); + cursor: not-allowed; + transform: none; +} + +.timebot-send-btn:disabled ::deep .mud-icon-root { + color: rgba(255, 255, 255, 0.2) !important; +} diff --git a/timetracker.Client/Components/Layout/MainLayout.razor b/timetracker.Client/Components/Layout/MainLayout.razor index e2caf38..6c0990a 100644 --- a/timetracker.Client/Components/Layout/MainLayout.razor +++ b/timetracker.Client/Components/Layout/MainLayout.razor @@ -29,6 +29,12 @@ @Body + + + + + + @code { diff --git a/timetracker.Client/Components/Pages/Changelog.razor b/timetracker.Client/Components/Pages/Changelog.razor index 18b2e1f..1a206ea 100644 --- a/timetracker.Client/Components/Pages/Changelog.razor +++ b/timetracker.Client/Components/Pages/Changelog.razor @@ -68,12 +68,17 @@ private readonly List _releases = [ - new("1.3", "09.06.2026", true, + new("1.4", "08.06.2026", true, + [ + new("Neu", "Timebot implementiert"), + new("Upgrade", "Security hardening") + ]), + new("1.3", "08.06.2026", false, [ new("Fix", "Browser-Tab hat statischen Text angezeigt und sich nicht dynamisch an die jeweilige Seite angepasst"), ]), - new("1.2", "08.06.2026", true, + new("1.2", "08.06.2026", false, [ new("Upgrade", "Architektur-Migration auf Hosted Blazor WebAssembly (.NET 10) mit sauberer Projektstruktur (Client, Server, Shared)"), new("Neu", "Unterstützung für PostgreSQL-Datenbanken als produktive und skalierbare Alternative zu SQLite"), diff --git a/timetracker.Client/Components/Pages/Login.razor b/timetracker.Client/Components/Pages/Login.razor index c7ff18f..617d1bc 100644 --- a/timetracker.Client/Components/Pages/Login.razor +++ b/timetracker.Client/Components/Pages/Login.razor @@ -106,6 +106,7 @@ @bind-Value="_registerModel.Password" Required="true" HelperText="Mindestens 6 Zeichen" /> + RegisterAsync(string username, string password) + public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null) { - var response = await _http.PostAsJsonAsync("api/auth/register", new { Username = username, Password = password }); + var response = await _http.PostAsJsonAsync("api/auth/register", new { Username = username, Password = password, Honeypot = honeypot }); if (response.IsSuccessStatusCode) { var userInfo = await response.Content.ReadFromJsonAsync(); diff --git a/timetracker.Client/Services/ClientTimetrackerService.cs b/timetracker.Client/Services/ClientTimetrackerService.cs index f8b5ee5..c8e2c1a 100644 --- a/timetracker.Client/Services/ClientTimetrackerService.cs +++ b/timetracker.Client/Services/ClientTimetrackerService.cs @@ -15,7 +15,7 @@ public class ClientTimetrackerService : ITimetrackerService public async Task> GetWeekAsync(int userId, DateOnly monday) { - return await _http.GetFromJsonAsync>($"api/tracker/week?userId={userId}&monday={monday:yyyy-MM-dd}") ?? []; + return await _http.GetFromJsonAsync>($"api/tracker/week?monday={monday:yyyy-MM-dd}") ?? []; } public async Task UpsertWorkDayAsync(WorkDay workDay) @@ -29,7 +29,7 @@ public class ClientTimetrackerService : ITimetrackerService { return _cachedSettings; } - var settings = await _http.GetFromJsonAsync($"api/tracker/settings/{userId}"); + var settings = await _http.GetFromJsonAsync("api/tracker/settings"); _cachedSettings = settings ?? new AppSettings { UserId = userId }; return _cachedSettings; } @@ -42,7 +42,7 @@ public class ClientTimetrackerService : ITimetrackerService public async Task> GetVacationDaysAsync(int userId, int year) { - return await _http.GetFromJsonAsync>($"api/tracker/vacation/{userId}/{year}") ?? []; + return await _http.GetFromJsonAsync>($"api/tracker/vacation/{year}") ?? []; } public async Task AddVacationDayAsync(VacationDay vacationDay) @@ -52,12 +52,12 @@ public class ClientTimetrackerService : ITimetrackerService public async Task RemoveVacationDayAsync(int userId, int id) { - await _http.DeleteAsync($"api/tracker/vacation/{userId}/{id}"); + await _http.DeleteAsync($"api/tracker/vacation/{id}"); } public async Task GetTotalOvertimeAsync(int userId, AppSettings settings) { - var response = await _http.PostAsJsonAsync($"api/tracker/overtime/{userId}", settings); + var response = await _http.PostAsJsonAsync("api/tracker/overtime", settings); if (response.IsSuccessStatusCode) { var hours = await response.Content.ReadFromJsonAsync(); @@ -68,6 +68,6 @@ public class ClientTimetrackerService : ITimetrackerService public async Task> GetMonthAsync(int userId, int year, int month) { - return await _http.GetFromJsonAsync>($"api/tracker/month?userId={userId}&year={year}&month={month}") ?? []; + return await _http.GetFromJsonAsync>($"api/tracker/month?year={year}&month={month}") ?? []; } } diff --git a/timetracker.Client/wwwroot/timebot.png b/timetracker.Client/wwwroot/timebot.png new file mode 100644 index 0000000..762bc97 Binary files /dev/null and b/timetracker.Client/wwwroot/timebot.png differ diff --git a/timetracker.Server/Components/App.razor b/timetracker.Server/Components/App.razor index 85f0cca..a95d06d 100644 --- a/timetracker.Server/Components/App.razor +++ b/timetracker.Server/Components/App.razor @@ -9,7 +9,7 @@ - + diff --git a/timetracker.Server/Data/AuthService.cs b/timetracker.Server/Data/AuthService.cs index 3f4e1a0..fd6168b 100644 --- a/timetracker.Server/Data/AuthService.cs +++ b/timetracker.Server/Data/AuthService.cs @@ -55,7 +55,7 @@ public class AuthService(IDbContextFactory factory, UserNo return null; } - public async Task<(User? User, string? Error)> RegisterAsync(string username, string password) { + public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null) { if (string.IsNullOrWhiteSpace(username) || username.Length < 3) return (null, "Benutzername muss mindestens 3 Zeichen lang sein."); if (string.IsNullOrWhiteSpace(password) || password.Length < 6) diff --git a/timetracker.Server/Program.cs b/timetracker.Server/Program.cs index 3a83529..463c472 100644 --- a/timetracker.Server/Program.cs +++ b/timetracker.Server/Program.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.HttpOverrides; @@ -19,6 +20,9 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc options.LogoutPath = "/auth/logout"; options.ExpireTimeSpan = TimeSpan.FromDays(30); options.SlidingExpiration = true; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; // Cookie-Konfiguration für APIs & WASM options.Events.OnRedirectToLogin = context => { @@ -60,6 +64,22 @@ builder.Services.AddRazorComponents() builder.Services.AddMudServices(); builder.Services.AddHttpClient(); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy("auth-limit", httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() + ?? httpContext.Request.Headers["X-Forwarded-For"].ToString() + ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + Window = TimeSpan.FromMinutes(1), + PermitLimit = 5, + QueueLimit = 0 + })); +}); + var dbProvider = builder.Configuration["DB_PROVIDER"] ?? "SQLite"; if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase)) @@ -120,6 +140,7 @@ if (app.Configuration.GetValue("EnableHttpsRedirect", !app.Environment.IsDevelop } app.UseStaticFiles(); +app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); @@ -165,10 +186,16 @@ app.MapPost("/api/auth/login", async (HttpContext ctx, [FromBody] LoginRequest r new AuthenticationProperties { IsPersistent = true }); return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username }); -}); +}).RequireRateLimiting("auth-limit"); -app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] LoginRequest req, IAuthService authService) => +app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] RegisterRequest req, IAuthService authService) => { + if (!string.IsNullOrEmpty(req.Honeypot)) + { + // Silently reject bots + return Results.BadRequest("Registrierung fehlgeschlagen."); + } + var (user, error) = await authService.RegisterAsync(req.Username, req.Password); if (user == null) return Results.BadRequest(error ?? "Registrierung fehlgeschlagen."); @@ -183,7 +210,7 @@ app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] LoginReques new AuthenticationProperties { IsPersistent = true }); return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username }); -}); +}).RequireRateLimiting("auth-limit"); app.MapGet("/auth/logout", async (HttpContext ctx) => { @@ -219,8 +246,11 @@ usersApi.MapPut("/{userId:int}/rename", async (int userId, [FromBody] RenameRequ // ── Timetracker-API-Endpoints (Protected) ───────────────────────────────────── var trackerApi = app.MapGroup("/api/tracker").RequireAuthorization(); -trackerApi.MapGet("/week", async ([FromQuery] int userId, [FromQuery] string monday, ITimetrackerService trackerService) => +trackerApi.MapGet("/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService) => { + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + if (DateOnly.TryParse(monday, out var date)) { var days = await trackerService.GetWeekAsync(userId, date); @@ -229,50 +259,78 @@ trackerApi.MapGet("/week", async ([FromQuery] int userId, [FromQuery] string mon return Results.BadRequest("Ungültiges Datum."); }); -trackerApi.MapPost("/workday", async ([FromBody] WorkDay workDay, ITimetrackerService trackerService) => +trackerApi.MapPost("/workday", async (ClaimsPrincipal claimsPrincipal, [FromBody] WorkDay workDay, ITimetrackerService trackerService) => { + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + + workDay.UserId = userId; // Enforce owner await trackerService.UpsertWorkDayAsync(workDay); return Results.Ok(); }); -trackerApi.MapGet("/settings/{userId:int}", async (int userId, ITimetrackerService trackerService) => +trackerApi.MapGet("/settings", async (ClaimsPrincipal claimsPrincipal, ITimetrackerService trackerService) => { + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + var settings = await trackerService.GetSettingsAsync(userId); return Results.Ok(settings); }); -trackerApi.MapPost("/settings", async ([FromBody] AppSettings settings, ITimetrackerService trackerService) => +trackerApi.MapPost("/settings", async (ClaimsPrincipal claimsPrincipal, [FromBody] AppSettings settings, ITimetrackerService trackerService) => { + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + + settings.UserId = userId; // Enforce owner await trackerService.SaveSettingsAsync(settings); return Results.Ok(); }); -trackerApi.MapGet("/vacation/{userId:int}/{year:int}", async (int userId, int year, ITimetrackerService trackerService) => +trackerApi.MapGet("/vacation/{year:int}", async (ClaimsPrincipal claimsPrincipal, int year, ITimetrackerService trackerService) => { + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + var days = await trackerService.GetVacationDaysAsync(userId, year); return Results.Ok(days); }); -trackerApi.MapPost("/vacation", async ([FromBody] VacationDay vacationDay, ITimetrackerService trackerService) => +trackerApi.MapPost("/vacation", async (ClaimsPrincipal claimsPrincipal, [FromBody] VacationDay vacationDay, ITimetrackerService trackerService) => { + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + + vacationDay.UserId = userId; // Enforce owner await trackerService.AddVacationDayAsync(vacationDay); return Results.Ok(); }); -trackerApi.MapDelete("/vacation/{userId:int}/{id:int}", async (int userId, int id, ITimetrackerService trackerService) => +trackerApi.MapDelete("/vacation/{id:int}", async (ClaimsPrincipal claimsPrincipal, int id, ITimetrackerService trackerService) => { + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + await trackerService.RemoveVacationDayAsync(userId, id); return Results.NoContent(); }); -trackerApi.MapPost("/overtime/{userId:int}", async (int userId, [FromBody] AppSettings settings, ITimetrackerService trackerService) => +trackerApi.MapPost("/overtime", async (ClaimsPrincipal claimsPrincipal, [FromBody] AppSettings settings, ITimetrackerService trackerService) => { + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + + settings.UserId = userId; // Enforce owner var ts = await trackerService.GetTotalOvertimeAsync(userId, settings); return Results.Ok(ts.TotalHours); }); -trackerApi.MapGet("/month", async ([FromQuery] int userId, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService) => +trackerApi.MapGet("/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService) => { + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + var days = await trackerService.GetMonthAsync(userId, year, month); return Results.Ok(days); }); @@ -302,4 +360,5 @@ app.Run(); // ── Models for Request Bodies ────────────────────────────────────────────────── public record LoginRequest(string Username, string Password); +public record RegisterRequest(string Username, string Password, string? Honeypot = null); public record RenameRequest(string Username); diff --git a/timetracker.Shared/IAuthService.cs b/timetracker.Shared/IAuthService.cs index e99f167..03520b7 100644 --- a/timetracker.Shared/IAuthService.cs +++ b/timetracker.Shared/IAuthService.cs @@ -6,5 +6,5 @@ public interface IAuthService Task> GetAllUsersAsync(); Task DeleteUserAsync(int userId); Task RenameUserAsync(int userId, string newUsername); - Task<(User? User, string? Error)> RegisterAsync(string username, string password); + Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null); } diff --git a/wasm.md b/wasm.md deleted file mode 100644 index 68bc983..0000000 --- a/wasm.md +++ /dev/null @@ -1,183 +0,0 @@ -# Blazor WASM Migration – Aufwandsanalyse - -> Analysiert am 08.06.2026 -> Aktueller Stack: Blazor Server (.NET 10, InteractiveServer, SQLite, MudBlazor 9.4) - ---- - -## TL;DR - -Eine vollständige Migration auf Blazor WebAssembly ist **technisch möglich**, aber ein **erheblicher Umbau**. Der Aufwand liegt bei schätzungsweise **3–5 Wochen** für einen Entwickler. Der Hauptgrund: Blazor WASM läuft vollständig im Browser – kein direkter DB-Zugriff, keine Cookie-Auth serverseitig, keine gemeinsamen In-Process-Events zwischen Nutzern. - -**Empfehlung:** Hybrid-Modell (Blazor Auto oder WASM + ASP.NET Core API auf demselben Server) ist der pragmatischste Weg. - ---- - -## Was WASM bringt - -| Vorteil | Erklärung | -|---|---| -| Keine SignalR-Verbindung nötig | WASM lädt den Code einmalig herunter, kein dauerhafter WebSocket | -| Besser skalierbar | Server hält keine Circuits pro User im Speicher | -| Offline-fähig (theoretisch) | Progressive Web App möglich | -| Geringere Serverauslastung | UI-Logik läuft im Browser | - -**Aber Achtung:** Der initiale Download ist größer (~10–20 MB .NET Runtime im Browser), erste Ladezeit spürbar langsamer als Blazor Server. - ---- - -## Aktuelle Blocker (nach Schwere) - -### 🔴 KRITISCH – muss komplett umgebaut werden - -#### 1. Datenbankzugriff – EF Core / SQLite - -Alle drei Services greifen direkt per `IDbContextFactory` auf die SQLite-DB zu: - -- `AuthService` → Login, Registrierung, User-CRUD -- `TimetrackerService` → Arbeitstage, Pausen, Einstellungen, Urlaub, Überstunden -- `HolidayService` → Feiertage speichern/lesen - -**WASM kann nicht direkt auf eine Datenbank zugreifen.** Browser hat keinen Dateisystemzugriff. - -→ **Lösung:** Alle Service-Methoden als REST-API-Endpoints auf dem Server exponieren. WASM-Client ruft diese per `HttpClient` auf. - -**Aufwand:** ~10–15 Tage (je nach gewähltem API-Stil: Minimal API vs. Controller) - ---- - -#### 2. Cookie-Authentifizierung - -Aktuell läuft der gesamte Auth-Flow serverseitig: -- `POST /auth/login` → `HttpContext.SignInAsync()` → Cookie setzen -- `GET /auth/logout` → `HttpContext.SignOutAsync()` -- Blazor-Komponenten lesen den Cookie über `CascadingAuthenticationState` - -In WASM gibt es keinen direkten Zugriff auf `HttpContext`. Die Cookie-Auth muss weiterhin **serverseitig** verbleiben, aber das Login-Formular muss über die API kommunizieren. - -→ **Lösung A (einfacher):** Cookie-Auth beibehalten, aber Login-Formular als API-Call (kein HTML-Form-Post mehr). WASM erhält Auth-State über einen `/auth/me`-Endpoint. -→ **Lösung B (aufwändiger):** JWT-Token-Auth einführen, Token im Browser-Speicher halten. - -**Empfehlung:** Lösung A, da Cookie-Auth bereits implementiert ist und sicherer gegen XSS ist als localStorage-JWTs. - -**Aufwand:** ~3–4 Tage - ---- - -#### 3. Projektstruktur – neue Build-Konfiguration - -Aktuell: 1 Projekt (`Microsoft.NET.Sdk.Web`), alles in einem. - -WASM benötigt mindestens: -- **`timetracker.Client`** – Blazor WASM-Projekt (läuft im Browser) -- **`timetracker.Server`** – ASP.NET Core-Projekt (hostet API + WASM-App) -- Optional: **`timetracker.Shared`** – DTOs/Models geteilt zwischen Client & Server - -Alle `@inject`-Direktiven, die auf Server-Services zeigen, müssen im Client auf `HttpClient`-basierte Services umgestellt werden. - -**Aufwand:** ~3–4 Tage (Projektaufspaltung, DI-Umverdrahtung) - ---- - -### ⚠️ MITTEL – umbaubar, aber aufwändig - -#### 4. UserNotificationService (Live-Updates) - -Aktuell: Singleton mit C#-Events, der zwischen Blazor Server-Circuits kommuniziert: -- Admin löscht User → Event feuert → alle aktiven `MainLayout`-Instanzen reagieren -- Neuer User registriert → `AdminUsers` aktualisiert sich live - -In WASM gibt es keine gemeinsamen In-Process-Events. Jeder Browser ist isoliert. - -→ **Lösung:** **SignalR Hub** einführen. Server sendet Push-Nachrichten an alle verbundenen Clients. - -``` -[Browser A – Admin] [Server] [Browser B – normaler User] - löscht User C → Hub.Clients.All.SendAsync → empfängt "UserDeleted(C)" - → NavigateTo("/auth/logout") -``` - -**Aufwand:** ~2–3 Tage (Hub einrichten, Client-seitig `HubConnection` einbauen) - ---- - -#### 5. Formular-basiertes Login/Register - -Login und Registrierung verwenden aktuell native HTML-Forms (`method="post"`) die direkt an `/auth/login` und `/auth/register` posten. Das funktioniert in WASM nicht mehr sauber, weil WASM SPA-Navigation nutzt. - -→ **Lösung:** Login-Formular auf `@code`-basierten `HttpClient.PostAsync`-Call umstellen, Redirect über `NavigationManager`. - -**Aufwand:** ~1 Tag - ---- - -#### 6. Docker-Deployment - -Aktuell: 1 Container, alles drin. - -WASM + API = zwei Artefakte: -- Die WASM-App sind statische Dateien (HTML/JS/WASM) -- Die API läuft als ASP.NET Core-Server - -→ **Lösung A (einfach):** Hosted WASM – API-Server liefert auch die WASM-Dateien aus (1 Container, 1 Dockerfile, wie heute) -→ **Lösung B (komplex):** Getrennte Container (Nginx für WASM, API separat) - -**Empfehlung:** Lösung A (Hosted WASM) – minimaler Deployment-Overhead. - -**Aufwand:** ~0,5 Tage bei Lösung A - ---- - -### ✅ KEIN UMBAU NÖTIG - -| Komponente | Warum kein Umbau | -|---|---| -| **MudBlazor 9.4** | Vollständig WASM-kompatibel | -| **Alle `.razor`-Seiten (UI)** | Markup bleibt identisch, nur `@inject` ändert sich | -| **Entity Models** (User, WorkDay, etc.) | Können ins Shared-Projekt verschoben werden | -| **HttpClient für Feiertage** (nager.at) | Funktioniert in WASM nativ | -| **Business-Logik in Komponenten** | Bleibt unverändert | -| **CSS / MudBlazor Theme** | Unverändert | - ---- - -## Gesamtaufwand-Schätzung - -| Aufgabe | Geschätzter Aufwand | -|---|---| -| Projektstruktur aufteilen (Client/Server/Shared) | 2–3 Tage | -| REST-API für TimetrackerService | 4–5 Tage | -| REST-API für AuthService + HolidayService | 2–3 Tage | -| Cookie-Auth auf API-Flow umstellen | 2–3 Tage | -| Client-Services (HttpClient-basiert) erstellen | 3–4 Tage | -| SignalR für Live-Notifications | 2–3 Tage | -| Login/Register-Formulare anpassen | 1 Tag | -| Docker/Deployment anpassen | 0,5–1 Tag | -| Testen & Debugging | 2–3 Tage | -| **Gesamt** | **~18–26 Tage** | - ---- - -## Empfohlene Alternative: Blazor Auto - -.NET 8+ bietet **Blazor Auto**-Rendermodus: Seiten starten als Server-Rendering, wechseln dann automatisch zu WASM sobald die Runtime heruntergeladen ist. Das gibt sofortige Interaktivität (Server) + langfristig WASM-Performance. - -**Vorteil:** DB-Zugriff und Auth bleiben wie heute, kein API-Layer nötig. -**Aufwand:** ~3–5 Tage (Projektstruktur, Shared-Projekt, WASM-Client-Projekt anlegen). - -**Für dieses Projekt sehr empfehlenswert**, da: -1. Minimaler Umbau der bestehenden Logik -2. Spürbare Performance-Verbesserung nach erstem Laden -3. Kein SignalR-Umbau notwendig (Server-Circuit bleibt beim ersten Laden aktiv) - ---- - -## Fazit - -| Option | Aufwand | Performance-Gewinn | Empfehlung | -|---|---|---|---| -| Blazor WASM (pure) | ~20–25 Tage | Hoch (nach erstem Laden) | Nur bei hoher Nutzerzahl sinnvoll | -| Blazor Auto | ~3–5 Tage | Mittel-Hoch | ✅ **Empfohlen** | -| Blazor Server (wie heute) | 0 | – | Gut für kleine Nutzerzahl | - -Für den aktuellen Use-Case (internes Uni-Tool, überschaubare Nutzerzahl) ist **Blazor Auto** das beste Kosten-Nutzen-Verhältnis. Eine vollständige WASM-Migration lohnt sich erst, wenn die App deutlich mehr parallele User hat oder offline-Fähigkeit gefordert wird.