Make User nullable in AuthComponentBase. Add utility method to make sure it's populated. Refactor as necessary.

This commit is contained in:
Jared Goodwin 2023-08-01 15:52:08 -07:00
parent b8153c03c3
commit ce9d65a236
22 changed files with 234 additions and 112 deletions

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Remotely.Server.Services;
using Remotely.Shared.Entities;
using System.Threading.Tasks;
@ -7,23 +8,26 @@ namespace Remotely.Server.Auth;
public class OrganizationAdminRequirementHandler : AuthorizationHandler<OrganizationAdminRequirement>
{
private readonly UserManager<RemotelyUser> _userManager;
private readonly IDataService _dataService;
public OrganizationAdminRequirementHandler(UserManager<RemotelyUser> userManager)
public OrganizationAdminRequirementHandler(IDataService dataService)
{
_userManager = userManager;
_dataService = dataService;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, OrganizationAdminRequirement requirement)
{
if (context.User.Identity?.IsAuthenticated != true)
if (context.User.Identity?.IsAuthenticated != true ||
string.IsNullOrWhiteSpace(context.User.Identity.Name))
{
context.Fail();
return;
}
var user = await _userManager.GetUserAsync(context.User);
if (user?.IsAdministrator != true)
var userResult = await _dataService.GetUserByName(context.User.Identity.Name);
if (!userResult.IsSuccess ||
!userResult.Value.IsAdministrator)
{
context.Fail();
return;

View File

@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Remotely.Server.Services;
using Remotely.Shared.Entities;
using System.Threading.Tasks;
@ -9,28 +10,31 @@ namespace Remotely.Server.Auth;
public class ServerAdminRequirementHandler : AuthorizationHandler<ServerAdminRequirement>
{
private readonly UserManager<RemotelyUser> _userManager;
private readonly IDataService _dataService;
public ServerAdminRequirementHandler(UserManager<RemotelyUser> userManager)
public ServerAdminRequirementHandler(IDataService dataService)
{
_userManager = userManager;
_dataService = dataService;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, ServerAdminRequirement requirement)
{
if (context.User.Identity?.IsAuthenticated != true)
if (context.User.Identity?.IsAuthenticated != true ||
string.IsNullOrWhiteSpace(context.User.Identity.Name))
{
context.Fail();
return;
}
var user = await _userManager.GetUserAsync(context.User);
if (user?.IsServerAdmin != true)
var userResult = await _dataService.GetUserByName(context.User.Identity.Name);
if (!userResult.IsSuccess ||
!userResult.Value.IsServerAdmin)
{
context.Fail();
return;
}
context.Succeed(requirement);
}
}

View File

@ -11,21 +11,25 @@ namespace Remotely.Server.Auth;
public class TwoFactorRequiredHandler : AuthorizationHandler<TwoFactorRequiredRequirement>
{
private readonly UserManager<RemotelyUser> _userManager;
private readonly IDataService _dataService;
private readonly IApplicationConfig _appConfig;
public TwoFactorRequiredHandler(UserManager<RemotelyUser> userManager, IApplicationConfig appConfig)
public TwoFactorRequiredHandler(IDataService dataService, IApplicationConfig appConfig)
{
_userManager = userManager;
_dataService = dataService;
_appConfig = appConfig;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, TwoFactorRequiredRequirement requirement)
{
if (context.User.Identity?.IsAuthenticated == true && _appConfig.Require2FA)
if (context.User.Identity?.IsAuthenticated == true &&
context.User.Identity.Name is not null &&
_appConfig.Require2FA)
{
var user = await _userManager.GetUserAsync(context.User);
if (user?.TwoFactorEnabled != true)
var userResult = await _dataService.GetUserByName(context.User.Identity.Name);
if (!userResult.IsSuccess ||
!userResult.Value.TwoFactorEnabled)
{
context.Fail();
return;

View File

@ -63,10 +63,8 @@
{
await base.OnInitializedAsync();
if (IsAuthenticated)
{
GetAlerts();
}
EnsureUserSet();
GetAlerts();
}
private async Task ClearAlert(Alert alert)
@ -77,12 +75,14 @@
private async Task ClearAllAlerts()
{
EnsureUserSet();
await DataService.DeleteAllAlerts(User.OrganizationID, User.UserName);
_alerts.Clear();
}
private void GetAlerts()
{
EnsureUserSet();
_alerts.Clear();
var alerts = DataService.GetAlerts(User.Id);
if (alerts.Any())

View File

@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using Remotely.Server.Services;
using Remotely.Shared.Entities;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
@ -12,62 +13,34 @@ namespace Remotely.Server.Components;
[Authorize]
public class AuthComponentBase : ComponentBase
{
private readonly ManualResetEventSlim _initSignal = new();
private RemotelyUser? _user;
private string? _userName;
public bool IsAuthenticated { get; private set; }
public bool IsUserSet => _user is not null;
public RemotelyUser User
{
get
{
if (_initSignal.Wait(TimeSpan.FromSeconds(5)) && _user is not null)
{
return _user;
}
// This should never happen, since AuthBasedComponent is only
// used on components that require authentication. This was easier
// than making this explicitly nullable and refactoring everywhere.
Logger.LogError("Failed to resolve user.");
throw new InvalidOperationException("Failed to resolve user.");
}
private set => _user = value;
}
public string UserName
{
get
{
if (_initSignal.Wait(TimeSpan.FromSeconds(5)) && _userName is not null)
{
return _userName;
}
Logger.LogError("Failed to resolve user.");
throw new InvalidOperationException("Failed to resolve user.");
}
private set => _userName = value;
}
[Inject]
protected IAuthService AuthService { get; set; } = null!;
[Inject]
private ILogger<AuthComponentBase> Logger { get; init; } = null!;
protected RemotelyUser? User { get; private set; }
protected string? UserName => User?.UserName;
[MemberNotNull(nameof(User), nameof(UserName))]
protected void EnsureUserSet()
{
if (User is null)
{
throw new InvalidOperationException("User has not been set.");
}
if (UserName is null)
{
throw new InvalidOperationException("UserName has not been set.");
}
}
protected override async Task OnInitializedAsync()
{
IsAuthenticated = await AuthService.IsAuthenticated();
var userResult = await AuthService.GetUser();
if (userResult.IsSuccess)
{
_user = userResult.Value;
_userName = userResult.Value.UserName ?? string.Empty;
User = userResult.Value;
}
_initSignal.Set();
await base.OnInitializedAsync();
}
}

View File

@ -18,23 +18,27 @@
@code {
protected override void OnAfterRender(bool firstRender)
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (AppConfig.Require2FA && !User.TwoFactorEnabled)
if (User is not null &&
AppConfig.Require2FA &&
!User.TwoFactorEnabled)
{
NavManager.NavigateTo("/TwoFactorRequired");
}
base.OnAfterRender(firstRender);
await base.OnAfterRenderAsync(firstRender);
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
var isAuthenticated = await AuthService.IsAuthenticated();
var userResult = await AuthService.GetUser();
// This handles a weird edge case when the user has been
// deleted but still has an authentication cookie in their
// browser.
if (IsAuthenticated == true && !IsUserSet)
if (isAuthenticated == true && !userResult.IsSuccess)
{
await SignInManager.SignOutAsync();
NavManager.NavigateTo("/");

View File

@ -79,6 +79,7 @@ public partial class DeviceCard : AuthComponentBase, IDisposable
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
EnsureUserSet();
_theme = await AppState.GetEffectiveTheme();
_currentVersion = UpgradeService.GetCurrentVersion();
_deviceGroups = DataService.GetDeviceGroups(UserName);
@ -180,6 +181,7 @@ public partial class DeviceCard : AuthComponentBase, IDisposable
}
private async Task HandleValidSubmit()
{
EnsureUserSet();
if (!DataService.DoesUserHaveAccessToDevice(Device.ID, User))
{
ToastService.ShowToast("Unauthorized.", classString: "bg-warning");
@ -199,6 +201,8 @@ public partial class DeviceCard : AuthComponentBase, IDisposable
private async Task OnFileInputChanged(InputFileChangeEventArgs args)
{
EnsureUserSet();
ToastService.ShowToast("File upload started.");
var fileId = await DataService.AddSharedFile(args.File, User.OrganizationID, OnFileInputProgress);

View File

@ -74,6 +74,8 @@ public partial class DevicesFrame : AuthComponentBase, IDisposable
{
await base.OnInitializedAsync();
EnsureUserSet();
CircuitConnection.MessageReceived += CircuitConnection_MessageReceived;
AppState.PropertyChanged += AppState_PropertyChanged;
@ -283,6 +285,8 @@ public partial class DevicesFrame : AuthComponentBase, IDisposable
private void LoadDevices()
{
EnsureUserSet();
lock (_devicesLock)
{
_allDevices.Clear();
@ -346,6 +350,8 @@ public partial class DevicesFrame : AuthComponentBase, IDisposable
private async Task WakeDevices()
{
EnsureUserSet();
var offlineDevices = DataService
.GetDevicesForUser(UserName)
.Where(x => !x.IsOnline);

View File

@ -64,6 +64,8 @@ public partial class Terminal : AuthComponentBase, IDisposable
private EventCallback<SavedScript> RunQuickScript =>
EventCallback.Factory.Create<SavedScript>(this, async script =>
{
EnsureUserSet();
var scriptRun = new ScriptRun
{
OrganizationID = User.OrganizationID,
@ -285,6 +287,8 @@ public partial class Terminal : AuthComponentBase, IDisposable
private async Task ShowQuickScripts()
{
EnsureUserSet();
var quickScripts = await DataService.GetQuickScripts(User.Id);
if (quickScripts?.Any() != true)
{
@ -344,6 +348,8 @@ public partial class Terminal : AuthComponentBase, IDisposable
}
private bool TryMatchShellShortcuts()
{
EnsureUserSet();
var currentText = InputText?.Trim()?.ToLower();
if (string.IsNullOrWhiteSpace(currentText))

View File

@ -63,7 +63,7 @@ public partial class RunScript : AuthComponentBase
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
EnsureUserSet();
_deviceGroups = DataService.GetDeviceGroups(UserName);
_devices = DataService
.GetDevicesForUser(UserName)
@ -107,6 +107,8 @@ public partial class RunScript : AuthComponentBase
private async Task ExecuteScript()
{
EnsureUserSet();
if (_selectedScript is null)
{
ToastService.ShowToast("You must select a script.", classString: "bg-warning");
@ -163,6 +165,8 @@ public partial class RunScript : AuthComponentBase
private async Task ScriptSelected(ScriptTreeNode viewModel)
{
EnsureUserSet();
if (viewModel.Script is not null)
{
var scriptResult = await DataService.GetSavedScript(User.Id, viewModel.Script.Id);

View File

@ -36,11 +36,35 @@ public partial class SavedScripts : AuthComponentBase
[Inject]
public IModalService ModalService { get; set; } = null!;
private bool CanModifyScript => _selectedScript.Id == Guid.Empty ||
_selectedScript.CreatorId == User.Id || User.IsAdministrator;
private bool CanModifyScript
{
get
{
if (User is null)
{
return false;
}
private bool CanDeleteScript => !string.IsNullOrWhiteSpace(_selectedScript.CreatorId) &&
(_selectedScript.CreatorId == User.Id || User.IsAdministrator);
return
_selectedScript.Id == Guid.Empty ||
_selectedScript.CreatorId == User.Id || User.IsAdministrator;
}
}
private bool CanDeleteScript
{
get
{
if (User is null)
{
return false;
}
return
!string.IsNullOrWhiteSpace(_selectedScript.CreatorId) &&
(_selectedScript.CreatorId == User.Id || User.IsAdministrator);
}
}
protected override void OnAfterRender(bool firstRender)
{
@ -53,6 +77,8 @@ public partial class SavedScripts : AuthComponentBase
private async Task OnValidSubmit(EditContext context)
{
EnsureUserSet();
if (_selectedScript is null)
{
return;
@ -63,7 +89,7 @@ public partial class SavedScripts : AuthComponentBase
ToastService.ShowToast("You can't modify other people's scripts.", classString: "bg-warning");
return;
}
await DataService.AddOrUpdateSavedScript(_selectedScript, User.Id);
await ParentPage.RefreshScripts();
ToastService.ShowToast("Script saved.");
@ -102,6 +128,7 @@ public partial class SavedScripts : AuthComponentBase
private async Task ScriptSelected(ScriptTreeNode viewModel)
{
EnsureUserSet();
if (viewModel.Script is not null)
{
var result = await DataService.GetSavedScript(User.Id, viewModel.Script.Id);

View File

@ -48,25 +48,39 @@ public partial class ScriptSchedules : AuthComponentBase
[Inject]
private IToastService ToastService { get; set; } = null!;
private bool CanModifySchedule =>
_selectedSchedule.CreatorId == User.Id ||
User.IsAdministrator;
private bool CanModifySchedule
{
get
{
EnsureUserSet();
return
_selectedSchedule.CreatorId == User.Id ||
User.IsAdministrator;
}
}
private bool CanDeleteSchedule =>
_selectedSchedule.CreatorId == User.Id ||
User.IsAdministrator;
private bool CanDeleteSchedule
{
get
{
EnsureUserSet();
return
_selectedSchedule.CreatorId == User.Id ||
User.IsAdministrator;
}
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
if (IsAuthenticated)
{
_deviceGroups = DataService.GetDeviceGroups(UserName);
_devices = DataService
.GetDevicesForUser(UserName)
.OrderBy(x => x.DeviceName)
.ToArray();
}
EnsureUserSet();
_deviceGroups = DataService.GetDeviceGroups(UserName);
_devices = DataService
.GetDevicesForUser(UserName)
.OrderBy(x => x.DeviceName)
.ToArray();
await RefreshSchedules();
}
@ -140,6 +154,8 @@ public partial class ScriptSchedules : AuthComponentBase
private async Task OnValidSubmit(EditContext context)
{
EnsureUserSet();
if (_selectedSchedule is null)
{
return;
@ -226,6 +242,8 @@ public partial class ScriptSchedules : AuthComponentBase
private async Task ScriptSelected(ScriptTreeNode viewModel)
{
EnsureUserSet();
if (viewModel.Script is not null)
{
var result = await DataService.GetSavedScript(User.Id, viewModel.Script.Id);

View File

@ -18,7 +18,11 @@
<AlertBanner Message="@_alertMessage" />
}
@if (User?.IsAdministrator == true)
@if (_isLoading)
{
<h5>Loading...</h5>
}
else if (User is not null)
{
if (!string.IsNullOrWhiteSpace(_newKeySecret))
{
@ -71,26 +75,25 @@
</tbody>
</table>
}
else
{
<h5 class="text-muted">Only organization administrators can view this page.</h5>
}
@code {
private readonly List<ApiToken> _apiTokens = new();
private string _alertMessage = string.Empty;
private string _createKeyName = string.Empty;
private string _newKeySecret = string.Empty;
private bool _isLoading = true;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
RefreshData();
_isLoading = false;
}
private async Task CreateNewKey()
{
EnsureUserSet();
var secret = RandomGenerator.GenerateString(48);
var secretHash = new PasswordHasher<string>().HashPassword(string.Empty, secret);
@ -107,6 +110,8 @@ else
private async Task DeleteKey(string keyId)
{
EnsureUserSet();
var result = await JsInterop.Confirm("Are you sure you want to delete this key?");
if (!result)
{
@ -125,6 +130,7 @@ else
private void RefreshData()
{
EnsureUserSet();
_apiTokens.Clear();
_apiTokens.AddRange(DataService.GetAllApiTokens(User.Id));
_createKeyName = string.Empty;
@ -135,6 +141,7 @@ else
private async Task RenameKey(string keyId)
{
EnsureUserSet();
var newName = await JsInterop.Prompt("New key name");
if (!string.IsNullOrWhiteSpace(newName))
{

View File

@ -92,6 +92,8 @@
private async Task HandleValidSubmit(EditContext context)
{
EnsureUserSet();
await DataService.UpdateBrandingInfo(
User.OrganizationID,
_inputModel.ProductName,
@ -168,6 +170,7 @@
private async Task ResetBranding()
{
EnsureUserSet();
var result = await JsInterop.Confirm("Are you sure you want to reset branding to default?");
if (result)
{

View File

@ -29,11 +29,15 @@
</div>
</div>
}
else if (_isLoading)
{
<h5>Loading...</h5>
}
else if (_device is null)
{
<h3>Device not found.</h3>
}
else if (!DataService.DoesUserHaveAccessToDevice(_device.ID, User))
else if (!_userHasAccess)
{
<h3>Unauthorized.</h3>
}
@ -168,7 +172,7 @@ else
<InputSelect @bind-Value="_device.DeviceGroupID" class="form-control">
<option value="">None</option>
@foreach (var group in DataService.GetDeviceGroups(UserName))
@foreach (var group in _deviceGroups)
{
<option @key="group.ID" value="@group.ID">@group.Name</option>
}

View File

@ -22,7 +22,10 @@ public partial class DeviceDetails : AuthComponentBase
private string? _alertMessage;
private Device? _device;
private bool _userHasAccess;
private string? _inputDeviceId;
private bool _isLoading = true;
private DeviceGroup[] _deviceGroups = Array.Empty<DeviceGroup>();
[Parameter]
public string ActiveTab { get; set; } = string.Empty;
@ -54,12 +57,15 @@ public partial class DeviceDetails : AuthComponentBase
{
await base.OnInitializedAsync();
EnsureUserSet();
if (!string.IsNullOrWhiteSpace(DeviceId))
{
var deviceResult = await DataService.GetDevice(DeviceId);
if (deviceResult.IsSuccess)
{
_device = deviceResult.Value;
_userHasAccess = DataService.DoesUserHaveAccessToDevice(_device.ID, User);
}
else
{
@ -67,7 +73,9 @@ public partial class DeviceDetails : AuthComponentBase
}
}
_deviceGroups = DataService.GetDeviceGroups(UserName);
CircuitConnection.MessageReceived += CircuitConnection_MessageReceived;
_isLoading = false;
}
private void CircuitConnection_MessageReceived(object? sender, Models.CircuitEvent e)
@ -130,6 +138,8 @@ public partial class DeviceDetails : AuthComponentBase
return;
}
EnsureUserSet();
_scriptResults.Clear();
if (User.IsAdministrator)

View File

@ -4,8 +4,11 @@
<h3 class="mb-3">Manage Organization</h3>
@if (User?.IsAdministrator == true)
@if (_isLoading)
{
<h5>Loading...</h5>
}
else if (User is not null)
{
<div class="row">
<div class="col-sm-8">
@ -195,9 +198,4 @@
</div>
</div>
}
else
{
<h5 class="text-muted">Only organization administrators can view this page.</h5>
}
}

View File

@ -25,6 +25,7 @@ public partial class ManageOrganization : AuthComponentBase
private readonly List<RemotelyUser> _orgUsers = new();
private bool _inviteAsAdmin;
private string _inviteEmail = string.Empty;
private bool _isLoading = true;
private string _newDeviceGroupName = string.Empty;
private Organization? _organization;
private string _selectedDeviceGroupId = string.Empty;
@ -55,10 +56,14 @@ public partial class ManageOrganization : AuthComponentBase
await base.OnInitializedAsync();
await RefreshData();
_isLoading = false;
}
private async Task CreateNewDeviceGroup()
{
EnsureUserSet();
if (!User.IsAdministrator)
{
return;
@ -93,6 +98,8 @@ public partial class ManageOrganization : AuthComponentBase
return;
}
EnsureUserSet();
if (!User.IsServerAdmin)
{
return;
@ -109,6 +116,8 @@ public partial class ManageOrganization : AuthComponentBase
private async Task DeleteInvite(InviteLink invite)
{
EnsureUserSet();
if (!User.IsAdministrator)
{
return;
@ -127,6 +136,8 @@ public partial class ManageOrganization : AuthComponentBase
private async Task DeleteSelectedDeviceGroup()
{
EnsureUserSet();
if (!User.IsAdministrator)
{
return;
@ -150,6 +161,8 @@ public partial class ManageOrganization : AuthComponentBase
private async Task DeleteUser(RemotelyUser user)
{
EnsureUserSet();
if (!User.IsAdministrator)
{
return;
@ -208,6 +221,8 @@ public partial class ManageOrganization : AuthComponentBase
return;
}
EnsureUserSet();
if (!User.IsAdministrator)
{
return;
@ -237,6 +252,8 @@ public partial class ManageOrganization : AuthComponentBase
private async Task RefreshData()
{
EnsureUserSet();
var orgResult = await DataService.GetOrganizationByUserName(UserName);
if (!orgResult.IsSuccess)
{
@ -256,6 +273,8 @@ public partial class ManageOrganization : AuthComponentBase
}
private async Task ResetPassword(RemotelyUser user)
{
EnsureUserSet();
if (!User.IsAdministrator)
{
return;
@ -275,6 +294,8 @@ public partial class ManageOrganization : AuthComponentBase
private async Task SendInvite()
{
EnsureUserSet();
if (!User.IsAdministrator)
{
return;
@ -352,6 +373,8 @@ public partial class ManageOrganization : AuthComponentBase
private async Task SetUserIsAdmin(ChangeEventArgs args, RemotelyUser orgUser)
{
EnsureUserSet();
if (!User.IsAdministrator)
{
return;

View File

@ -62,6 +62,8 @@ public partial class ScriptsPage : AuthComponentBase
public async Task RefreshScripts()
{
EnsureUserSet();
_treeNodes.Clear();
_allScripts = await DataService.GetSavedScriptsWithoutContent(User.Id, User.OrganizationID);
@ -119,6 +121,13 @@ public partial class ScriptsPage : AuthComponentBase
private void RefreshTreeNodes()
{
if (User is null)
{
return;
}
EnsureUserSet();
_treeNodes.Clear();
foreach (var script in _allScripts)

View File

@ -66,7 +66,7 @@
@foreach (var user in UserList)
{
<div @key="user.Id">
<input type="checkbox" disabled="@(user.Id == User.Id)" checked="@user.IsServerAdmin" @onchange="ev => SetIsServerAdmin(ev, user)" />
<input type="checkbox" disabled="@(user.Id == User?.Id)" checked="@user.IsServerAdmin" @onchange="ev => SetIsServerAdmin(ev, user)" />
<span class="ml-2 align-top" style="line-height:1.3em">@user.UserName</span>
</div>
}

View File

@ -188,6 +188,13 @@ public partial class ServerConfig : AuthComponentBase
{
get
{
if (User is null)
{
return Enumerable.Empty<RemotelyUser>();
}
EnsureUserSet();
return _userList.Where(x =>
(!_showAdminsOnly || x.IsServerAdmin) &&
(!_showMyOrgAdminsOnly || x.OrganizationID == User.OrganizationID));
@ -197,7 +204,7 @@ public partial class ServerConfig : AuthComponentBase
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
EnsureUserSet();
if (!User.IsServerAdmin)
{
return;
@ -338,8 +345,8 @@ public partial class ServerConfig : AuthComponentBase
private async Task SaveAndTestSmtpSettings()
{
EnsureUserSet();
await SaveInputToAppSettings();
if (string.IsNullOrWhiteSpace(User.Email))
{
ToastService.ShowToast2("User email is not set.", Enums.ToastType.Warning);
@ -417,6 +424,9 @@ public partial class ServerConfig : AuthComponentBase
{
return;
}
EnsureUserSet();
DataService.SetIsServerAdmin(user.Id, isAdmin, User.Id);
ToastService.ShowToast("Server admins updated.");
}
@ -443,6 +453,8 @@ public partial class ServerConfig : AuthComponentBase
private async Task UpdateAllDevices()
{
EnsureUserSet();
if (!User.IsServerAdmin)
{
return;

View File

@ -97,11 +97,13 @@
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
_options = User.UserOptions ?? new();
_options = User?.UserOptions ?? new();
}
private Task HandleValidSubmit()
{
EnsureUserSet();
if (!_options.CommandModeShortcutBash.StartsWith("/"))
{
_options.CommandModeShortcutBash = "/" + _options.CommandModeShortcutBash;