feat: implement session persistence with "Remember Me" functionality and logout support

This commit is contained in:
HueByte
2026-02-21 17:29:16 +01:00
parent 23abb9b0bb
commit d4cf35e04e
6 changed files with 211 additions and 22 deletions
+78 -16
View File
@@ -9,7 +9,9 @@ namespace EchoHub.Client.UI;
/// <summary>
/// Result returned from the connect dialog.
/// </summary>
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);
/// <summary>
/// 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();
+6
View File
@@ -85,6 +85,11 @@ public sealed class MainWindow : Runnable
/// </summary>
public event Action? OnDisconnectRequested;
/// <summary>
/// Fired when the user requests to logout (disconnect + revoke session).
/// </summary>
public event Action? OnLogoutRequested;
/// <summary>
/// Fired when the user requests to open their profile panel.
/// </summary>
@@ -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),