Skip to content

Commit 72ae203

Browse files
committed
github: add "2FA" prompt dialog
Add new Avalonia-based "two-factor" auth code prompt dialog for GitHub.
1 parent a18ea94 commit 72ae203

File tree

9 files changed

+456
-1
lines changed

9 files changed

+456
-1
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.CommandLine;
4+
using System.CommandLine.Invocation;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using GitHub.UI.ViewModels;
8+
using GitHub.UI.Views;
9+
using Microsoft.Git.CredentialManager;
10+
using Microsoft.Git.CredentialManager.UI;
11+
12+
namespace GitHub.UI.Commands
13+
{
14+
public class TwoFactorCommand : HelperCommand
15+
{
16+
public TwoFactorCommand(ICommandContext context)
17+
: base(context, "2fa", "Show two-factor prompt.")
18+
{
19+
AddOption(
20+
new Option("--sms", "Two-factor code was sent via SMS.")
21+
);
22+
23+
Handler = CommandHandler.Create<bool>(ExecuteAsync);
24+
}
25+
26+
private async Task<int> ExecuteAsync(bool sms)
27+
{
28+
var viewModel = new TwoFactorViewModel(Context.Environment)
29+
{
30+
IsSms = sms
31+
};
32+
33+
await AvaloniaUi.ShowViewAsync<TwoFactorView>(viewModel, GetParentHandle(), CancellationToken.None);
34+
35+
if (!viewModel.WindowResult)
36+
{
37+
throw new Exception("User cancelled dialog.");
38+
}
39+
40+
WriteResult(new Dictionary<string, string>
41+
{
42+
["code"] = viewModel.Code
43+
});
44+
45+
return 0;
46+
}
47+
}
48+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<UserControl xmlns="https://github.com/avaloniaui"
2+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
4+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
5+
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
6+
x:Class="GitHub.UI.Controls.SixDigitInput">
7+
<UserControl.Styles>
8+
<Style Selector="TextBox">
9+
<Setter Property="Width" Value="36"/>
10+
<Setter Property="Height" Value="46"/>
11+
<Setter Property="MinWidth" Value="36"/>
12+
<Setter Property="Padding" Value="6"/>
13+
<Setter Property="Margin" Value="0,0,8,0"/>
14+
<Setter Property="HorizontalContentAlignment" Value="Center"/>
15+
<Setter Property="VerticalContentAlignment" Value="Center"/>
16+
<Setter Property="FontSize" Value="20"/>
17+
<Setter Property="MaxLength" Value="1"/>
18+
</Style>
19+
</UserControl.Styles>
20+
<StackPanel Orientation="Horizontal">
21+
<TextBox x:Name="one"/>
22+
<TextBox x:Name="two"/>
23+
<TextBox x:Name="three"/>
24+
<TextBox x:Name="four"/>
25+
<TextBox x:Name="five"/>
26+
<TextBox x:Name="six" Margin="0"/>
27+
</StackPanel>
28+
</UserControl>
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using Avalonia;
5+
using Avalonia.Controls;
6+
using Avalonia.Data;
7+
using Avalonia.Input;
8+
using Avalonia.Input.Platform;
9+
using Avalonia.Interactivity;
10+
using Avalonia.Markup.Xaml;
11+
using Microsoft.Git.CredentialManager.UI.Controls;
12+
13+
namespace GitHub.UI.Controls
14+
{
15+
public class SixDigitInput : UserControl, IFocusable
16+
{
17+
public static readonly DirectProperty<SixDigitInput, string> TextProperty =
18+
AvaloniaProperty.RegisterDirect<SixDigitInput, string>(
19+
nameof(Text),
20+
o => o.Text,
21+
(o, v) => o.Text = v,
22+
defaultBindingMode: BindingMode.TwoWay);
23+
24+
private PlatformHotkeyConfiguration _keyMap;
25+
private IClipboard _clipboard;
26+
private bool _ignoreTextBoxUpdate;
27+
private TextBox[] _textBoxes;
28+
private string _text;
29+
30+
public SixDigitInput()
31+
{
32+
InitializeComponent();
33+
}
34+
35+
private void InitializeComponent()
36+
{
37+
AvaloniaXamlLoader.Load(this);
38+
39+
_keyMap = AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>();
40+
_clipboard = AvaloniaLocator.Current.GetService<IClipboard>();
41+
_textBoxes = new[]
42+
{
43+
this.FindControl<TextBox>("one"),
44+
this.FindControl<TextBox>("two"),
45+
this.FindControl<TextBox>("three"),
46+
this.FindControl<TextBox>("four"),
47+
this.FindControl<TextBox>("five"),
48+
this.FindControl<TextBox>("six"),
49+
};
50+
51+
foreach (TextBox textBox in _textBoxes)
52+
{
53+
SetUpTextBox(textBox);
54+
}
55+
}
56+
57+
public string Text
58+
{
59+
get => _text;
60+
set
61+
{
62+
SetAndRaise(TextProperty, ref _text, value);
63+
if (!_ignoreTextBoxUpdate) SetTextBoxes(value);
64+
}
65+
}
66+
67+
private void SetTextBoxes(string text)
68+
{
69+
if (string.IsNullOrWhiteSpace(text))
70+
{
71+
foreach (TextBox textBox in _textBoxes)
72+
{
73+
textBox.Text = string.Empty;
74+
}
75+
}
76+
else
77+
{
78+
IEnumerable<char> digits = text.Where(char.IsDigit);
79+
string digitsStr = string.Join(string.Empty, digits).PadRight(6);
80+
for (int i = 0; i < digitsStr.Length; i++)
81+
{
82+
_textBoxes[i].Text = digitsStr.Substring(i, 1);
83+
}
84+
}
85+
}
86+
87+
public void SetFocus()
88+
{
89+
KeyboardDevice.Instance.SetFocusedElement(_textBoxes[0], NavigationMethod.Tab, KeyModifiers.None);
90+
}
91+
92+
private void SetUpTextBox(TextBox textBox)
93+
{
94+
textBox.AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
95+
96+
void OnPreviewKeyDown(object sender, KeyEventArgs e)
97+
{
98+
// Handle paste
99+
if (_keyMap.Paste.Any(x => x.Matches(e)))
100+
{
101+
OnPaste();
102+
e.Handled = true;
103+
}
104+
// Handle keyboard navigation
105+
else if (e.Key == Key.Left || e.Key == Key.Right || e.Key == Key.Back)
106+
{
107+
e.Handled = e.Key == Key.Right ? MoveNext() : MovePrevious();
108+
if (e.Key == Key.Back)
109+
{
110+
textBox.Text = string.Empty;
111+
}
112+
}
113+
// Only allow 0-9, Tab, Escape, and Delete
114+
else if (e.Key != Key.D0 &&
115+
e.Key != Key.D1 &&
116+
e.Key != Key.D2 &&
117+
e.Key != Key.D3 &&
118+
e.Key != Key.D4 &&
119+
e.Key != Key.D5 &&
120+
e.Key != Key.D6 &&
121+
e.Key != Key.D7 &&
122+
e.Key != Key.D8 &&
123+
e.Key != Key.D9 &&
124+
e.Key != Key.NumPad0 &&
125+
e.Key != Key.NumPad1 &&
126+
e.Key != Key.NumPad2 &&
127+
e.Key != Key.NumPad3 &&
128+
e.Key != Key.NumPad4 &&
129+
e.Key != Key.NumPad5 &&
130+
e.Key != Key.NumPad6 &&
131+
e.Key != Key.NumPad7 &&
132+
e.Key != Key.NumPad8 &&
133+
e.Key != Key.NumPad9 &&
134+
e.Key != Key.Tab &&
135+
e.Key != Key.Escape &&
136+
e.Key != Key.Delete)
137+
{
138+
e.Handled = true;
139+
}
140+
};
141+
142+
textBox.PropertyChanged += (s, e) =>
143+
{
144+
if (e.Property.Name == nameof(TextBox.Text))
145+
{
146+
try
147+
{
148+
_ignoreTextBoxUpdate = true;
149+
Text = string.Join(string.Empty, _textBoxes.Select(x => x.Text));
150+
}
151+
finally
152+
{
153+
_ignoreTextBoxUpdate = false;
154+
}
155+
156+
if (e.NewValue is string value && value.Length > 0)
157+
{
158+
MoveNext();
159+
}
160+
}
161+
};
162+
}
163+
164+
private void OnPaste()
165+
{
166+
string text = _clipboard.GetTextAsync().GetAwaiter().GetResult();
167+
Text = text;
168+
}
169+
170+
private bool MoveNext() => MoveFocus(true);
171+
172+
private bool MovePrevious() => MoveFocus(false);
173+
174+
private bool MoveFocus(bool next)
175+
{
176+
// Get currently focused text box
177+
if (FocusManager.Instance.Current is TextBox textBox)
178+
{
179+
int textBoxIndex = Array.IndexOf(_textBoxes, textBox);
180+
if (textBoxIndex > -1)
181+
{
182+
int nextIndex = next
183+
? Math.Min(_textBoxes.Length - 1, textBoxIndex + 1)
184+
: Math.Max(0, textBoxIndex - 1);
185+
186+
KeyboardDevice.Instance.SetFocusedElement(_textBoxes[nextIndex], NavigationMethod.Tab, KeyModifiers.None);
187+
return true;
188+
}
189+
}
190+
191+
return false;
192+
}
193+
}
194+
}

src/shared/GitHub.UI/Controls/TesterWindow.axaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,19 @@
4646
<Button Classes="accent" Content="Show" Click="ShowCredentials" />
4747
</StackPanel>
4848
</TabItem>
49+
50+
<TabItem Header="Two-factor">
51+
<StackPanel>
52+
<Grid RowDefinitions="Auto" ColumnDefinitions="Auto,*">
53+
<Label Grid.Row="0" Grid.Column="0"
54+
Content="Options" />
55+
<StackPanel Grid.Row="0" Grid.Column="1"
56+
Orientation="Horizontal" VerticalAlignment="Center">
57+
<CheckBox Content="SMS" x:Name="2faSms" MinWidth="80" />
58+
</StackPanel>
59+
</Grid>
60+
<Button Classes="accent" Content="Show" Click="ShowTwoFactorCode" />
61+
</StackPanel>
62+
</TabItem>
4963
</TabControl>
5064
</Window>

src/shared/GitHub.UI/Controls/TesterWindow.axaml.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ private void ShowCredentials(object sender, RoutedEventArgs e)
5555
var vm = new CredentialsViewModel(_environment)
5656
{
5757
ShowBrowserLogin = this.FindControl<CheckBox>("useBrowser").IsChecked ?? false,
58-
ShowDeviceLogin = this.FindControl<CheckBox>("useDevice").IsChecked ?? false,
5958
ShowTokenLogin = this.FindControl<CheckBox>("usePat").IsChecked ?? false,
6059
ShowBasicLogin = this.FindControl<CheckBox>("useBasic").IsChecked ?? false,
6160
EnterpriseUrl = this.FindControl<TextBox>("enterpriseUrl").Text,
@@ -65,5 +64,16 @@ private void ShowCredentials(object sender, RoutedEventArgs e)
6564
var window = new DialogWindow(view) {DataContext = vm};
6665
window.ShowDialog(this);
6766
}
67+
68+
private void ShowTwoFactorCode(object sender, RoutedEventArgs e)
69+
{
70+
var vm = new TwoFactorViewModel(_environment)
71+
{
72+
IsSms = this.FindControl<CheckBox>("2faSms").IsChecked ?? false,
73+
};
74+
var view = new TwoFactorView();
75+
var window = new DialogWindow(view) {DataContext = vm};
76+
window.ShowDialog(this);
77+
}
6878
}
6979
}

src/shared/GitHub.UI/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ private static void AppMain(object o)
4848
using (var app = new HelperApplication(context))
4949
{
5050
app.RegisterCommand(new CredentialsCommand(context));
51+
app.RegisterCommand(new TwoFactorCommand(context));
5152

5253
// Run!
5354
int exitCode = app.RunAsync(args)

0 commit comments

Comments
 (0)