From d4cf35e04e830746f9ebec0f05c961c59114d1b7 Mon Sep 17 00:00:00 2001 From: HueByte Date: Sat, 21 Feb 2026 16:51:20 +0100 Subject: [PATCH] feat: implement session persistence with "Remember Me" functionality and logout support --- docs/changelog/v0.2.5.md | 10 +++ src/EchoHub.Client/AppOrchestrator.cs | 104 ++++++++++++++++++++-- src/EchoHub.Client/Config/ClientConfig.cs | 3 +- src/EchoHub.Client/Services/ApiClient.cs | 16 ++++ src/EchoHub.Client/UI/ConnectDialog.cs | 94 +++++++++++++++---- src/EchoHub.Client/UI/MainWindow.cs | 6 ++ 6 files changed, 211 insertions(+), 22 deletions(-) diff --git a/docs/changelog/v0.2.5.md b/docs/changelog/v0.2.5.md index 5922090..22aa5b2 100644 --- a/docs/changelog/v0.2.5.md +++ b/docs/changelog/v0.2.5.md @@ -43,6 +43,16 @@ - Connection state is color-coded: green (Connected), red (Disconnected), yellow (transitional states like Connecting, Reconnecting, Authenticating) - Current channel shows its type: `#channel - public` or `#channel - private` +### Remember Me (Session Persistence) + +- "Remember me" checkbox in the connect dialog — saves a 30-day refresh token so users can reconnect without entering their password +- Saved servers with active sessions show `[session]` indicator in the connect dialog and saved servers list +- Token-based login: selecting a saved server with a session lets you click Login with an empty password +- Graceful expiry handling: if the saved session is expired or revoked, shows an error and prompts for password +- Refresh token rotation: rotated tokens are automatically persisted to config so the session stays valid across refreshes +- New "Logout" menu item (Server menu): revokes the refresh token server-side and clears the saved session +- Removed dead `SavedServer.Token` field (stored 15-min access token that was never read back) + ### #general Channel Protection - `#general` channel is now auto-recreated if somehow missing (both in `GetChannels` endpoint and `JoinChannel` flow) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 698b5da..6589acb 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -78,6 +78,7 @@ public sealed class AppOrchestrator : IDisposable { _mainWindow.OnConnectRequested += HandleConnect; _mainWindow.OnDisconnectRequested += HandleDisconnect; + _mainWindow.OnLogoutRequested += HandleLogout; _mainWindow.OnMessageSubmitted += HandleMessageSubmitted; _mainWindow.OnChannelSelected += HandleChannelSelected; _mainWindow.OnProfileRequested += HandleProfileRequested; @@ -401,12 +402,55 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating...")); - var loginResponse = result.IsRegister - ? await _apiClient.RegisterAsync(result.Username, result.Password) - : await _apiClient.LoginAsync(result.Username, result.Password); + LoginResponse loginResponse; + + if (result.SavedRefreshToken is not null) + { + try + { + loginResponse = await _apiClient.LoginWithRefreshTokenAsync(result.SavedRefreshToken); + Log.Information("Authenticated via saved session for {User}", loginResponse.Username); + } + catch (Exception ex) + { + Log.Warning(ex, "Saved session expired or revoked"); + ClearSavedToken(result.ServerUrl); + InvokeUI(() => + { + _mainWindow.UpdateStatusBar("Disconnected"); + MessageBox.ErrorQuery(_app, "Session Expired", + "Your saved session has expired or was revoked.\nPlease log in with your password.", "OK"); + }); + _apiClient.Dispose(); + _apiClient = null; + return; + } + } + else if (result.IsRegister) + { + loginResponse = await _apiClient.RegisterAsync(result.Username, result.Password); + } + else + { + loginResponse = await _apiClient.LoginAsync(result.Username, result.Password); + } _currentUsername = loginResponse.Username; + // Persist rotated refresh tokens for Remember Me + _apiClient.OnTokensRefreshed += () => + { + if (_apiClient?.RefreshToken is null) return; + var config = ConfigManager.Load(); + var server = config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase)); + if (server is not null && server.RememberMe) + { + server.RefreshToken = _apiClient.RefreshToken; + ConfigManager.Save(config); + } + }; + // Fetch encryption key for E2E message encryption InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key...")); try @@ -489,6 +533,38 @@ public sealed class AppOrchestrator : IDisposable }, "Disconnect error", "Disconnect"); } + private void HandleLogout() + { + Log.Information("Logging out from server"); + + RunAsync(async () => + { + if (_apiClient is not null) + { + var baseUrl = _apiClient.BaseUrl; + await _apiClient.LogoutAsync(); + ClearSavedToken(baseUrl); + } + + if (_connection is not null) + { + await _connection.DisconnectAsync(); + await _connection.DisposeAsync(); + _connection = null; + } + + _apiClient?.Dispose(); + _apiClient = null; + _joinedChannels.Clear(); + + InvokeUI(() => + { + _mainWindow.ClearAll(); + _mainWindow.UpdateStatusBar("Disconnected"); + }); + }, "Logout error", "Logout"); + } + private void HandleMessageSubmitted(string channelName, string content) { if (!IsConnected) @@ -734,7 +810,11 @@ public sealed class AppOrchestrator : IDisposable } var serverLines = _config.SavedServers - .Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}") + .Select(s => + { + var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session saved]" : ""; + return $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}{session}"; + }) .ToList(); MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK"); @@ -1003,11 +1083,25 @@ public sealed class AppOrchestrator : IDisposable Name = new Uri(result.ServerUrl).Host, Url = result.ServerUrl, Username = result.Username, - Token = _apiClient!.Token, + RefreshToken = result.RememberMe ? _apiClient!.RefreshToken : null, + RememberMe = result.RememberMe, LastConnected = DateTimeOffset.Now }; ConfigManager.SaveServer(savedServer); _config = ConfigManager.Load(); Log.Information("Connected successfully to {Url}", result.ServerUrl); } + + private void ClearSavedToken(string serverUrl) + { + var config = ConfigManager.Load(); + var server = config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase)); + if (server is not null) + { + server.RefreshToken = null; + ConfigManager.Save(config); + _config = config; + } + } } diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 80052a7..02154bd 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -20,7 +20,8 @@ public class SavedServer public required string Name { get; set; } public required string Url { get; set; } public string? Username { get; set; } - public string? Token { get; set; } + public string? RefreshToken { get; set; } + public bool RememberMe { get; set; } public DateTimeOffset LastConnected { get; set; } } diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index aa693f0..fea3b2a 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -18,6 +18,8 @@ public sealed class ApiClient : IDisposable public string? RefreshToken => _refreshToken; public string BaseUrl { get; } + public event Action? OnTokensRefreshed; + public ApiClient(string baseUrl) { BaseUrl = baseUrl.TrimEnd('/'); @@ -68,6 +70,19 @@ public sealed class ApiClient : IDisposable SetTokens(result); } + public async Task LoginWithRefreshTokenAsync(string refreshToken) + { + var request = new RefreshRequest(refreshToken); + var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); + await EnsureSuccessAsync(response); + + var result = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Token refresh returned empty response."); + + SetTokens(result); + return result; + } + public async Task LogoutAsync() { if (!string.IsNullOrEmpty(_refreshToken)) @@ -313,6 +328,7 @@ public sealed class ApiClient : IDisposable _refreshToken = result.RefreshToken; _expiresAt = result.ExpiresAt; _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); + OnTokensRefreshed?.Invoke(); } /// diff --git a/src/EchoHub.Client/UI/ConnectDialog.cs b/src/EchoHub.Client/UI/ConnectDialog.cs index 614911d..a3f770e 100644 --- a/src/EchoHub.Client/UI/ConnectDialog.cs +++ b/src/EchoHub.Client/UI/ConnectDialog.cs @@ -9,7 +9,9 @@ namespace EchoHub.Client.UI; /// /// Result returned from the connect dialog. /// -public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister); +public record ConnectDialogResult( + string ServerUrl, string Username, string Password, + bool IsRegister, bool RememberMe, string? SavedRefreshToken); /// /// A Terminal.Gui dialog for entering server connection and authentication details. @@ -27,11 +29,12 @@ public sealed class ConnectDialog savedServers ??= []; var hasSavedServers = savedServers.Count > 0; - var dialogHeight = hasSavedServers ? 20 : 16; + var dialogHeight = hasSavedServers ? 22 : 18; var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight }; int yOffset = 0; + SavedServer? selectedSavedServer = null; // -- Saved Servers section (if any) ----------------------------------- ListView? savedServerList = null; @@ -46,7 +49,11 @@ public sealed class ConnectDialog dialog.Add(savedLabel); var serverDisplayNames = savedServers - .Select(s => $"{s.Name} ({s.Username ?? "?"})") + .Select(s => + { + var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session]" : ""; + return $"{s.Name} ({s.Username ?? "?"}){session}"; + }) .ToList(); savedServerList = new ListView @@ -115,17 +122,34 @@ public sealed class ConnectDialog Secret = true }; + var tokenHintLabel = new Label + { + Text = "Session saved \u2014 password optional", + X = 15, + Y = yOffset + 6, + Width = Dim.Fill(2), + Visible = false + }; + + var rememberMeCheckbox = new CheckBox + { + Text = "Remember me", + X = 15, + Y = yOffset + 7, + Value = CheckState.UnChecked + }; + var displayLabel = new Label { Text = "Display Name:", X = 1, - Y = yOffset + 7 + Y = yOffset + 9 }; var displayField = new TextField { Text = "", X = 15, - Y = yOffset + 7, + Y = yOffset + 9, Width = Dim.Fill(2) }; @@ -134,21 +158,21 @@ public sealed class ConnectDialog Text = "Login", IsDefault = true, X = Pos.Center() - 20, - Y = yOffset + 9 + Y = yOffset + 11 }; var registerButton = new Button { Text = "Register", X = Pos.Center() - 5, - Y = yOffset + 9 + Y = yOffset + 11 }; var cancelButton = new Button { Text = "Cancel", X = Pos.Center() + 10, - Y = yOffset + 9 + Y = yOffset + 11 }; // Wire saved server selection to auto-fill fields @@ -159,15 +183,32 @@ public sealed class ConnectDialog var index = e.NewValue; if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count) { - var server = savedServers[index.Value]; - urlField.Text = server.Url; - userField.Text = server.Username ?? ""; + selectedSavedServer = savedServers[index.Value]; + urlField.Text = selectedSavedServer.Url; + userField.Text = selectedSavedServer.Username ?? ""; + rememberMeCheckbox.Value = selectedSavedServer.RememberMe + ? CheckState.Checked : CheckState.UnChecked; + + if (!string.IsNullOrEmpty(selectedSavedServer.RefreshToken)) + { + passField.Text = ""; + tokenHintLabel.Visible = true; + } + else + { + tokenHintLabel.Visible = false; + } } }; // Pre-fill with the first saved server + selectedSavedServer = savedServers[0]; urlField.Text = savedServers[0].Url; userField.Text = savedServers[0].Username ?? ""; + rememberMeCheckbox.Value = savedServers[0].RememberMe + ? CheckState.Checked : CheckState.UnChecked; + if (!string.IsNullOrEmpty(savedServers[0].RefreshToken)) + tokenHintLabel.Visible = true; } loginButton.Accepting += (s, e) => @@ -175,15 +216,34 @@ public sealed class ConnectDialog var url = urlField.Text?.Trim() ?? string.Empty; var user = userField.Text?.Trim() ?? string.Empty; var pass = passField.Text ?? string.Empty; + var rememberMe = rememberMeCheckbox.Value == CheckState.Checked; - if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user)) { - MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK"); + MessageBox.ErrorQuery(app, "Validation", "Server URL and username are required.", "OK"); e.Handled = true; return; } - result = new ConnectDialogResult(url, user, pass, IsRegister: false); + // Determine if we can use a saved token + string? savedRefreshToken = null; + if (string.IsNullOrEmpty(pass) + && selectedSavedServer is not null + && !string.IsNullOrEmpty(selectedSavedServer.RefreshToken) + && string.Equals(selectedSavedServer.Url, url, StringComparison.OrdinalIgnoreCase) + && string.Equals(selectedSavedServer.Username, user, StringComparison.OrdinalIgnoreCase)) + { + savedRefreshToken = selectedSavedServer.RefreshToken; + } + + if (string.IsNullOrEmpty(pass) && savedRefreshToken is null) + { + MessageBox.ErrorQuery(app, "Validation", "Password is required.", "OK"); + e.Handled = true; + return; + } + + result = new ConnectDialogResult(url, user, pass, IsRegister: false, rememberMe, savedRefreshToken); e.Handled = true; app.RequestStop(); }; @@ -193,6 +253,7 @@ public sealed class ConnectDialog var url = urlField.Text?.Trim() ?? string.Empty; var user = userField.Text?.Trim() ?? string.Empty; var pass = passField.Text ?? string.Empty; + var rememberMe = rememberMeCheckbox.Value == CheckState.Checked; if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) { @@ -201,7 +262,7 @@ public sealed class ConnectDialog return; } - result = new ConnectDialogResult(url, user, pass, IsRegister: true); + result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null); e.Handled = true; app.RequestStop(); }; @@ -214,7 +275,8 @@ public sealed class ConnectDialog }; dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField, - displayLabel, displayField, loginButton, registerButton, cancelButton); + tokenHintLabel, rememberMeCheckbox, displayLabel, displayField, + loginButton, registerButton, cancelButton); if (hasSavedServers && savedServerList is not null) savedServerList.SetFocus(); diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 1c45e8c..ce8d919 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -85,6 +85,11 @@ public sealed class MainWindow : Runnable /// public event Action? OnDisconnectRequested; + /// + /// Fired when the user requests to logout (disconnect + revoke session). + /// + public event Action? OnLogoutRequested; + /// /// Fired when the user requests to open their profile panel. /// @@ -322,6 +327,7 @@ public sealed class MainWindow : Runnable { new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty), new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty), + new MenuItem("_Logout", "Logout and clear session", () => OnLogoutRequested?.Invoke(), Key.Empty), new Line(), new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty), new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),