mirror of
https://github.com/Stone-Red-Code/EchoHub.git
synced 2026-09-04 09:06:07 +02:00
feat: implement session persistence with "Remember Me" functionality and logout support
This commit is contained in:
@@ -43,6 +43,16 @@
|
|||||||
- Connection state is color-coded: green (Connected), red (Disconnected), yellow (transitional states like Connecting, Reconnecting, Authenticating)
|
- 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`
|
- 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 Protection
|
||||||
|
|
||||||
- `#general` channel is now auto-recreated if somehow missing (both in `GetChannels` endpoint and `JoinChannel` flow)
|
- `#general` channel is now auto-recreated if somehow missing (both in `GetChannels` endpoint and `JoinChannel` flow)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
_mainWindow.OnConnectRequested += HandleConnect;
|
_mainWindow.OnConnectRequested += HandleConnect;
|
||||||
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
||||||
|
_mainWindow.OnLogoutRequested += HandleLogout;
|
||||||
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
||||||
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
||||||
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
||||||
@@ -401,12 +402,55 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating..."));
|
InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating..."));
|
||||||
|
|
||||||
var loginResponse = result.IsRegister
|
LoginResponse loginResponse;
|
||||||
? await _apiClient.RegisterAsync(result.Username, result.Password)
|
|
||||||
: await _apiClient.LoginAsync(result.Username, result.Password);
|
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;
|
_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
|
// Fetch encryption key for E2E message encryption
|
||||||
InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key..."));
|
InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key..."));
|
||||||
try
|
try
|
||||||
@@ -489,6 +533,38 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Disconnect error", "Disconnect");
|
}, "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)
|
private void HandleMessageSubmitted(string channelName, string content)
|
||||||
{
|
{
|
||||||
if (!IsConnected)
|
if (!IsConnected)
|
||||||
@@ -734,7 +810,11 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var serverLines = _config.SavedServers
|
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();
|
.ToList();
|
||||||
|
|
||||||
MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK");
|
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,
|
Name = new Uri(result.ServerUrl).Host,
|
||||||
Url = result.ServerUrl,
|
Url = result.ServerUrl,
|
||||||
Username = result.Username,
|
Username = result.Username,
|
||||||
Token = _apiClient!.Token,
|
RefreshToken = result.RememberMe ? _apiClient!.RefreshToken : null,
|
||||||
|
RememberMe = result.RememberMe,
|
||||||
LastConnected = DateTimeOffset.Now
|
LastConnected = DateTimeOffset.Now
|
||||||
};
|
};
|
||||||
ConfigManager.SaveServer(savedServer);
|
ConfigManager.SaveServer(savedServer);
|
||||||
_config = ConfigManager.Load();
|
_config = ConfigManager.Load();
|
||||||
Log.Information("Connected successfully to {Url}", result.ServerUrl);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public class SavedServer
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public required string Url { get; set; }
|
public required string Url { get; set; }
|
||||||
public string? Username { 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; }
|
public DateTimeOffset LastConnected { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public sealed class ApiClient : IDisposable
|
|||||||
public string? RefreshToken => _refreshToken;
|
public string? RefreshToken => _refreshToken;
|
||||||
public string BaseUrl { get; }
|
public string BaseUrl { get; }
|
||||||
|
|
||||||
|
public event Action? OnTokensRefreshed;
|
||||||
|
|
||||||
public ApiClient(string baseUrl)
|
public ApiClient(string baseUrl)
|
||||||
{
|
{
|
||||||
BaseUrl = baseUrl.TrimEnd('/');
|
BaseUrl = baseUrl.TrimEnd('/');
|
||||||
@@ -68,6 +70,19 @@ public sealed class ApiClient : IDisposable
|
|||||||
SetTokens(result);
|
SetTokens(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<LoginResponse> 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<LoginResponse>()
|
||||||
|
?? throw new InvalidOperationException("Token refresh returned empty response.");
|
||||||
|
|
||||||
|
SetTokens(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LogoutAsync()
|
public async Task LogoutAsync()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(_refreshToken))
|
if (!string.IsNullOrEmpty(_refreshToken))
|
||||||
@@ -313,6 +328,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
_refreshToken = result.RefreshToken;
|
_refreshToken = result.RefreshToken;
|
||||||
_expiresAt = result.ExpiresAt;
|
_expiresAt = result.ExpiresAt;
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||||
|
OnTokensRefreshed?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ namespace EchoHub.Client.UI;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the connect dialog.
|
/// Result returned from the connect dialog.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
||||||
@@ -27,11 +29,12 @@ public sealed class ConnectDialog
|
|||||||
savedServers ??= [];
|
savedServers ??= [];
|
||||||
|
|
||||||
var hasSavedServers = savedServers.Count > 0;
|
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 };
|
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
||||||
|
|
||||||
int yOffset = 0;
|
int yOffset = 0;
|
||||||
|
SavedServer? selectedSavedServer = null;
|
||||||
|
|
||||||
// -- Saved Servers section (if any) -----------------------------------
|
// -- Saved Servers section (if any) -----------------------------------
|
||||||
ListView? savedServerList = null;
|
ListView? savedServerList = null;
|
||||||
@@ -46,7 +49,11 @@ public sealed class ConnectDialog
|
|||||||
dialog.Add(savedLabel);
|
dialog.Add(savedLabel);
|
||||||
|
|
||||||
var serverDisplayNames = savedServers
|
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();
|
.ToList();
|
||||||
|
|
||||||
savedServerList = new ListView
|
savedServerList = new ListView
|
||||||
@@ -115,17 +122,34 @@ public sealed class ConnectDialog
|
|||||||
Secret = true
|
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
|
var displayLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "Display Name:",
|
Text = "Display Name:",
|
||||||
X = 1,
|
X = 1,
|
||||||
Y = yOffset + 7
|
Y = yOffset + 9
|
||||||
};
|
};
|
||||||
var displayField = new TextField
|
var displayField = new TextField
|
||||||
{
|
{
|
||||||
Text = "",
|
Text = "",
|
||||||
X = 15,
|
X = 15,
|
||||||
Y = yOffset + 7,
|
Y = yOffset + 9,
|
||||||
Width = Dim.Fill(2)
|
Width = Dim.Fill(2)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,21 +158,21 @@ public sealed class ConnectDialog
|
|||||||
Text = "Login",
|
Text = "Login",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 20,
|
X = Pos.Center() - 20,
|
||||||
Y = yOffset + 9
|
Y = yOffset + 11
|
||||||
};
|
};
|
||||||
|
|
||||||
var registerButton = new Button
|
var registerButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Register",
|
Text = "Register",
|
||||||
X = Pos.Center() - 5,
|
X = Pos.Center() - 5,
|
||||||
Y = yOffset + 9
|
Y = yOffset + 11
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 10,
|
X = Pos.Center() + 10,
|
||||||
Y = yOffset + 9
|
Y = yOffset + 11
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wire saved server selection to auto-fill fields
|
// Wire saved server selection to auto-fill fields
|
||||||
@@ -159,15 +183,32 @@ public sealed class ConnectDialog
|
|||||||
var index = e.NewValue;
|
var index = e.NewValue;
|
||||||
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
|
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
|
||||||
{
|
{
|
||||||
var server = savedServers[index.Value];
|
selectedSavedServer = savedServers[index.Value];
|
||||||
urlField.Text = server.Url;
|
urlField.Text = selectedSavedServer.Url;
|
||||||
userField.Text = server.Username ?? "";
|
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
|
// Pre-fill with the first saved server
|
||||||
|
selectedSavedServer = savedServers[0];
|
||||||
urlField.Text = savedServers[0].Url;
|
urlField.Text = savedServers[0].Url;
|
||||||
userField.Text = savedServers[0].Username ?? "";
|
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) =>
|
loginButton.Accepting += (s, e) =>
|
||||||
@@ -175,15 +216,34 @@ public sealed class ConnectDialog
|
|||||||
var url = urlField.Text?.Trim() ?? string.Empty;
|
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||||
var user = userField.Text?.Trim() ?? string.Empty;
|
var user = userField.Text?.Trim() ?? string.Empty;
|
||||||
var pass = passField.Text ?? 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;
|
e.Handled = true;
|
||||||
return;
|
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;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -193,6 +253,7 @@ public sealed class ConnectDialog
|
|||||||
var url = urlField.Text?.Trim() ?? string.Empty;
|
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||||
var user = userField.Text?.Trim() ?? string.Empty;
|
var user = userField.Text?.Trim() ?? string.Empty;
|
||||||
var pass = passField.Text ?? 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) || string.IsNullOrEmpty(pass))
|
||||||
{
|
{
|
||||||
@@ -201,7 +262,7 @@ public sealed class ConnectDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
result = new ConnectDialogResult(url, user, pass, IsRegister: true);
|
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -214,7 +275,8 @@ public sealed class ConnectDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
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)
|
if (hasSavedServers && savedServerList is not null)
|
||||||
savedServerList.SetFocus();
|
savedServerList.SetFocus();
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnDisconnectRequested;
|
public event Action? OnDisconnectRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to logout (disconnect + revoke session).
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnLogoutRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user requests to open their profile panel.
|
/// Fired when the user requests to open their profile panel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -322,6 +327,7 @@ public sealed class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
||||||
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.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 Line(),
|
||||||
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
|
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),
|
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
|
||||||
|
|||||||
Reference in New Issue
Block a user