Skip to content

Commit 641f1bc

Browse files
feat: Sign in Window views & view models (#20)
Adds views for the Sign In dialog window, and a backing view model. Co-authored-by: Dean Sheather <[email protected]>
1 parent 88a4a97 commit 641f1bc

11 files changed

+470
-7
lines changed

App/App.xaml.cs

+8-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ namespace Coder.Desktop.App;
1212
public partial class App : Application
1313
{
1414
private readonly IServiceProvider _services;
15-
private TrayWindow? _trayWindow;
1615
private readonly bool _handleClosedEvents = true;
1716

1817
public App()
@@ -21,6 +20,11 @@ public App()
2120
services.AddSingleton<ICredentialManager, CredentialManager>();
2221
services.AddSingleton<IRpcController, RpcController>();
2322

23+
// SignInWindow views and view models
24+
services.AddTransient<SignInViewModel>();
25+
services.AddTransient<SignInWindow>();
26+
27+
// TrayWindow views and view models
2428
services.AddTransient<TrayWindowDisconnectedViewModel>();
2529
services.AddTransient<TrayWindowDisconnectedPage>();
2630
services.AddTransient<TrayWindowLoginRequiredViewModel>();
@@ -42,14 +46,14 @@ public App()
4246

4347
protected override void OnLaunched(LaunchActivatedEventArgs args)
4448
{
45-
_trayWindow = _services.GetRequiredService<TrayWindow>();
46-
_trayWindow.Closed += (sender, args) =>
49+
var trayWindow = _services.GetRequiredService<TrayWindow>();
50+
trayWindow.Closed += (sender, args) =>
4751
{
4852
// TODO: wire up HandleClosedEvents properly
4953
if (_handleClosedEvents)
5054
{
5155
args.Handled = true;
52-
_trayWindow.AppWindow.Hide();
56+
trayWindow.AppWindow.Hide();
5357
}
5458
};
5559
}

App/DisplayScale.cs

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using Microsoft.UI.Xaml;
4+
using WinRT.Interop;
5+
6+
namespace Coder.Desktop.App;
7+
8+
/// <summary>
9+
/// A static utility class to house methods related to the visual scale of the display monitor.
10+
/// </summary>
11+
public static class DisplayScale
12+
{
13+
public static double WindowScale(Window win)
14+
{
15+
var hwnd = WindowNative.GetWindowHandle(win);
16+
var dpi = NativeApi.GetDpiForWindow(hwnd);
17+
if (dpi == 0) return 1; // assume scale of 1
18+
return dpi / 96.0; // 96 DPI == 1
19+
}
20+
21+
public class NativeApi
22+
{
23+
[DllImport("user32.dll")]
24+
public static extern int GetDpiForWindow(IntPtr hwnd);
25+
}
26+
}

App/Services/CredentialManager.cs

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT
6767
try
6868
{
6969
var sdkClient = new CoderApiClient(uri);
70+
sdkClient.SetSessionToken(apiToken);
7071
// TODO: we should probably perform a version check here too,
7172
// rather than letting the service do it on Start
7273
_ = await sdkClient.GetBuildInfo(ct);

App/ViewModels/SignInViewModel.cs

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using Coder.Desktop.App.Services;
5+
using Coder.Desktop.App.Views;
6+
using CommunityToolkit.Mvvm.ComponentModel;
7+
using CommunityToolkit.Mvvm.Input;
8+
using Microsoft.UI.Xaml;
9+
10+
namespace Coder.Desktop.App.ViewModels;
11+
12+
/// <summary>
13+
/// The View Model backing the sign in window and all its associated pages.
14+
/// </summary>
15+
public partial class SignInViewModel : ObservableObject
16+
{
17+
private readonly ICredentialManager _credentialManager;
18+
19+
[ObservableProperty]
20+
[NotifyPropertyChangedFor(nameof(CoderUrlError))]
21+
[NotifyPropertyChangedFor(nameof(GenTokenUrl))]
22+
public partial string CoderUrl { get; set; } = string.Empty;
23+
24+
[ObservableProperty]
25+
[NotifyPropertyChangedFor(nameof(CoderUrlError))]
26+
public partial bool CoderUrlTouched { get; set; } = false;
27+
28+
[ObservableProperty]
29+
[NotifyPropertyChangedFor(nameof(ApiTokenError))]
30+
public partial string ApiToken { get; set; } = string.Empty;
31+
32+
[ObservableProperty]
33+
[NotifyPropertyChangedFor(nameof(ApiTokenError))]
34+
public partial bool ApiTokenTouched { get; set; } = false;
35+
36+
[ObservableProperty]
37+
public partial string? SignInError { get; set; } = null;
38+
39+
[ObservableProperty]
40+
public partial bool SignInLoading { get; set; } = false;
41+
42+
public string? CoderUrlError => CoderUrlTouched ? _coderUrlError : null;
43+
44+
private string? _coderUrlError
45+
{
46+
get
47+
{
48+
if (!Uri.TryCreate(CoderUrl, UriKind.Absolute, out var uri))
49+
return "Invalid URL";
50+
if (uri.Scheme is not "http" and not "https")
51+
return "Must be a HTTP or HTTPS URL";
52+
if (uri.PathAndQuery != "/")
53+
return "Must be a root URL with no path or query";
54+
return null;
55+
}
56+
}
57+
58+
public string? ApiTokenError => ApiTokenTouched ? _apiTokenError : null;
59+
60+
private string? _apiTokenError => string.IsNullOrWhiteSpace(ApiToken) ? "Invalid token" : null;
61+
62+
public Uri GenTokenUrl
63+
{
64+
get
65+
{
66+
// In case somehow the URL is invalid, just default to coder.com.
67+
// The HyperlinkButton will crash the entire app if the URL is
68+
// invalid.
69+
try
70+
{
71+
var baseUri = new Uri(CoderUrl.Trim());
72+
var cliAuthUri = new Uri(baseUri, "/cli-auth");
73+
return cliAuthUri;
74+
}
75+
catch
76+
{
77+
return new Uri("https://coder.com");
78+
}
79+
}
80+
}
81+
82+
public SignInViewModel(ICredentialManager credentialManager)
83+
{
84+
_credentialManager = credentialManager;
85+
}
86+
87+
public void CoderUrl_FocusLost(object sender, RoutedEventArgs e)
88+
{
89+
CoderUrlTouched = true;
90+
}
91+
92+
public void ApiToken_FocusLost(object sender, RoutedEventArgs e)
93+
{
94+
ApiTokenTouched = true;
95+
}
96+
97+
[RelayCommand]
98+
public void UrlPage_Next(SignInWindow signInWindow)
99+
{
100+
CoderUrlTouched = true;
101+
if (_coderUrlError != null) return;
102+
signInWindow.NavigateToTokenPage();
103+
}
104+
105+
[RelayCommand]
106+
public void TokenPage_Back(SignInWindow signInWindow)
107+
{
108+
ApiToken = "";
109+
signInWindow.NavigateToUrlPage();
110+
}
111+
112+
[RelayCommand]
113+
public async Task TokenPage_SignIn(SignInWindow signInWindow)
114+
{
115+
CoderUrlTouched = true;
116+
ApiTokenTouched = true;
117+
if (_coderUrlError != null || _apiTokenError != null) return;
118+
119+
try
120+
{
121+
SignInLoading = true;
122+
SignInError = null;
123+
124+
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
125+
await _credentialManager.SetCredentials(CoderUrl.Trim(), ApiToken.Trim(), cts.Token);
126+
127+
signInWindow.Close();
128+
}
129+
catch (Exception e)
130+
{
131+
SignInError = $"Failed to sign in: {e}";
132+
}
133+
finally
134+
{
135+
SignInLoading = false;
136+
}
137+
}
138+
}
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
1-
using CommunityToolkit.Mvvm.ComponentModel;
1+
using System;
2+
using Coder.Desktop.App.Views;
23
using CommunityToolkit.Mvvm.Input;
4+
using Microsoft.Extensions.DependencyInjection;
35

46
namespace Coder.Desktop.App.ViewModels;
57

6-
public partial class TrayWindowLoginRequiredViewModel : ObservableObject
8+
public partial class TrayWindowLoginRequiredViewModel
79
{
10+
private readonly IServiceProvider _services;
11+
12+
private SignInWindow? _signInWindow;
13+
14+
public TrayWindowLoginRequiredViewModel(IServiceProvider services)
15+
{
16+
_services = services;
17+
}
18+
819
[RelayCommand]
920
public void Login()
1021
{
11-
// TODO: open the login window
22+
// This is safe against concurrent access since it all happens in the
23+
// UI thread.
24+
if (_signInWindow != null)
25+
{
26+
_signInWindow.Activate();
27+
return;
28+
}
29+
30+
_signInWindow = _services.GetRequiredService<SignInWindow>();
31+
_signInWindow.Closed += (_, _) => _signInWindow = null;
32+
_signInWindow.Activate();
1233
}
1334
}

App/Views/Pages/SignInTokenPage.xaml

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
3+
<Page
4+
x:Class="Coder.Desktop.App.Views.Pages.SignInTokenPage"
5+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
6+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
7+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
8+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
9+
mc:Ignorable="d"
10+
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
11+
12+
<StackPanel
13+
Orientation="Vertical"
14+
HorizontalAlignment="Stretch"
15+
VerticalAlignment="Top"
16+
Padding="20"
17+
Spacing="10">
18+
19+
<TextBlock
20+
Text="Coder Desktop"
21+
FontSize="24"
22+
VerticalAlignment="Center"
23+
HorizontalAlignment="Center" />
24+
25+
<Grid>
26+
<Grid.ColumnDefinitions>
27+
<ColumnDefinition Width="Auto" />
28+
<ColumnDefinition Width="*" />
29+
</Grid.ColumnDefinitions>
30+
<Grid.RowDefinitions>
31+
<RowDefinition Height="1*" />
32+
<RowDefinition Height="1*" />
33+
<RowDefinition Height="1*" />
34+
<RowDefinition Height="1*" />
35+
<RowDefinition Height="1*" />
36+
</Grid.RowDefinitions>
37+
38+
<TextBlock
39+
Grid.Column="0"
40+
Grid.Row="0"
41+
Text="Server URL"
42+
HorizontalAlignment="Right"
43+
Padding="10" />
44+
45+
<TextBlock
46+
Grid.Column="1"
47+
Grid.Row="0"
48+
HorizontalAlignment="Stretch"
49+
VerticalAlignment="Center"
50+
Padding="10"
51+
Text="{x:Bind ViewModel.CoderUrl, Mode=OneWay}" />
52+
53+
<TextBlock
54+
Grid.Column="0"
55+
Grid.Row="2"
56+
Text="Session Token"
57+
HorizontalAlignment="Right"
58+
Padding="10" />
59+
60+
<TextBox
61+
Grid.Column="1"
62+
Grid.Row="2"
63+
HorizontalAlignment="Stretch"
64+
PlaceholderText="Paste your token here"
65+
LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}"
66+
Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" />
67+
68+
<TextBlock
69+
Grid.Column="1"
70+
Grid.Row="3"
71+
Text="{x:Bind ViewModel.ApiTokenError, Mode=OneWay}"
72+
Foreground="Red" />
73+
74+
<HyperlinkButton
75+
Grid.Column="1"
76+
Grid.Row="4"
77+
Content="Generate a token via the Web UI"
78+
NavigateUri="{x:Bind ViewModel.GenTokenUrl, Mode=OneWay}" />
79+
</Grid>
80+
81+
<StackPanel
82+
Orientation="Horizontal"
83+
HorizontalAlignment="Center"
84+
Spacing="10">
85+
86+
<Button
87+
Content="Back" HorizontalAlignment="Right"
88+
Command="{x:Bind ViewModel.TokenPage_BackCommand, Mode=OneWay}"
89+
CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" />
90+
91+
<Button
92+
Content="Sign In"
93+
HorizontalAlignment="Left"
94+
Style="{StaticResource AccentButtonStyle}"
95+
Command="{x:Bind ViewModel.TokenPage_SignInCommand, Mode=OneWay}"
96+
CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" />
97+
</StackPanel>
98+
99+
<TextBlock
100+
Text="{x:Bind ViewModel.SignInError, Mode=OneWay}"
101+
HorizontalAlignment="Center"
102+
Foreground="Red" />
103+
</StackPanel>
104+
</Page>
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using Coder.Desktop.App.ViewModels;
2+
using Microsoft.UI.Xaml.Controls;
3+
4+
namespace Coder.Desktop.App.Views.Pages;
5+
6+
/// <summary>
7+
/// A sign in page to accept the user's Coder token.
8+
/// </summary>
9+
public sealed partial class SignInTokenPage : Page
10+
{
11+
public readonly SignInViewModel ViewModel;
12+
public readonly SignInWindow SignInWindow;
13+
14+
public SignInTokenPage(SignInWindow parent, SignInViewModel viewModel)
15+
{
16+
InitializeComponent();
17+
ViewModel = viewModel;
18+
SignInWindow = parent;
19+
}
20+
}

0 commit comments

Comments
 (0)