feat(settings): add first-run Settings dialog and ribbon entry

- SettingsDialog (WPF, RTL): General tab (URL/username/API key/locale) +
  About tab; PasswordBox-bound API key, Test Connection probe, Save/Cancel
- SettingsViewModel: loads from SettingsManager + DpapiCredentialStore,
  Test Connection spins a transient HttpClient + EspoCrmClient and reports
  401 vs network failures separately; Save persists then signals dialog
  close
- Ribbon: second "הגדרות" button in Marcus-Law group; "File to EspoCRM"
  now offers to open Settings inline when not configured
- AddInHost.LaunchSettings tears down and rebuilds CRM/filing/retry stack
  so new credentials take effect immediately (CrmClientChanged event)
- Tag .taskmaster/tasks/tasks.json: Task #3 in-review with implementation
  notes (was already locally modified)

Core/UI/Tests green (38 tests passing); VSTO host project still requires
VS Installer Repair to compile locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-11 15:37:39 +03:00
parent 923a0b657f
commit aa65546fb1
8 changed files with 371 additions and 6 deletions
+3 -2
View File
@@ -35,8 +35,9 @@
"dependencies": [
2
],
"status": "pending",
"subtasks": []
"status": "in-review",
"subtasks": [],
"implementationNotes": "Implemented 2026-05-11 (commits e527aa8, 14ed1f6). Deltas from spec: (1) Used MessageBox for summary instead of UWP toast — simpler, can upgrade later. (2) Tests written under OutlookAddin.Tests/Services/ rather than /Core/. (3) Dialog placed in OutlookAddin.UI/Dialogs/ rather than /Views/. (4) Retry queue keeps items on disk after max attempts (3) instead of moving to failed/ — operator can inspect in retry-queue/ folder. (5) MailItem extraction lives in OutlookAddin/Services/MailItemExtractor.cs (host project, since Outlook interop). Build verified for Core/UI/Tests (38 tests green); host project requires VS Installer Repair to compile (VSTO targets still missing on dev machine). Outstanding: first-run settings dialog for credential entry — host shows \"תוסף עדיין לא מוגדר\" if creds.dat absent."
},
{
"id": 4,
@@ -0,0 +1,93 @@
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.SettingsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="הגדרות תוסף EspoCRM"
Height="520" Width="560"
MinHeight="400" MinWidth="500"
FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
ResizeMode="CanResize">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TabControl Grid.Row="0" x:Name="Tabs">
<TabItem Header="כללי">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="כתובת שרת EspoCRM" Margin="0,0,0,2" />
<TextBox Grid.Row="1" Text="{Binding EspoCrmUrl, UpdateSourceTrigger=PropertyChanged}"
Padding="6,4" Margin="0,0,0,10" />
<TextBlock Grid.Row="2" Text="שם משתמש" Margin="0,0,0,2" />
<TextBox Grid.Row="3" Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"
Padding="6,4" Margin="0,0,0,10" />
<TextBlock Grid.Row="4" Text="מפתח API" Margin="0,0,0,2" />
<PasswordBox Grid.Row="5" x:Name="ApiKeyBox" Padding="6,4" Margin="0,0,0,10" />
<TextBlock Grid.Row="6" Text="שפת ממשק" Margin="0,0,0,2" />
<ComboBox Grid.Row="7" SelectedValue="{Binding Locale}" SelectedValuePath="Tag"
Padding="6,4" Margin="0,0,0,10">
<ComboBoxItem Content="עברית" Tag="he-IL" />
<ComboBoxItem Content="English" Tag="en-US" />
</ComboBox>
<TextBlock Grid.Row="8" Text="{Binding StatusMessage}"
Foreground="{Binding StatusBrush}"
TextWrapping="Wrap"
VerticalAlignment="Top"
Margin="0,4,0,0" />
<StackPanel Grid.Row="9" Orientation="Horizontal" Margin="0,10,0,0">
<Button Command="{Binding TestConnectionCommand}"
Content="בדוק חיבור"
Padding="14,5"
MinWidth="120" />
</StackPanel>
</Grid>
</TabItem>
<TabItem Header="אודות">
<StackPanel Margin="16">
<TextBlock Text="Marcus-Law OutlookAddin" FontWeight="Bold" FontSize="16" />
<TextBlock Text="תוסף EspoCRM ל-Microsoft Outlook" Margin="0,4,0,12" />
<TextBlock Text="{Binding VersionLine}" Foreground="Gray" />
<TextBlock Margin="0,16,0,0" TextWrapping="Wrap"
Text="לוגים: %LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" />
<TextBlock TextWrapping="Wrap"
Text="הגדרות: %APPDATA%\MarcusLaw\OutlookAddin\settings.json" />
</StackPanel>
</TabItem>
</TabControl>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Left" Margin="0,12,0,0">
<Button Command="{Binding SaveCommand}"
IsDefault="True"
Content="שמור"
Padding="20,5"
Margin="0,0,8,0"
MinWidth="100" />
<Button Command="{Binding CancelCommand}"
IsCancel="True"
Content="ביטול"
Padding="20,5"
MinWidth="100" />
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,44 @@
using System;
using System.Windows;
using MarcusLaw.OutlookAddin.UI.ViewModels;
namespace MarcusLaw.OutlookAddin.UI.Dialogs
{
public partial class SettingsDialog : Window
{
private SettingsViewModel? _viewModel;
public SettingsDialog()
{
InitializeComponent();
}
public SettingsDialog(SettingsViewModel viewModel) : this()
{
AttachViewModel(viewModel);
}
public void AttachViewModel(SettingsViewModel viewModel)
{
if (_viewModel != null)
{
_viewModel.RequestClose -= OnRequestClose;
_viewModel.ApiKeyReader = null;
}
_viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
DataContext = _viewModel;
_viewModel.RequestClose += OnRequestClose;
_viewModel.ApiKeyReader = () => ApiKeyBox.Password;
if (!string.IsNullOrEmpty(_viewModel.InitialApiKey))
{
ApiKeyBox.Password = _viewModel.InitialApiKey;
}
}
private void OnRequestClose(object? sender, EventArgs e)
{
DialogResult = _viewModel?.DialogResult;
Close();
}
}
}
@@ -16,6 +16,10 @@
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OutlookAddin.Core\OutlookAddin.Core.csproj" />
</ItemGroup>
@@ -0,0 +1,169 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarcusLaw.OutlookAddin.Core.Configuration;
using MarcusLaw.OutlookAddin.Core.Security;
using MarcusLaw.OutlookAddin.Core.Services;
using Serilog;
namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
public sealed partial class SettingsViewModel : ObservableObject
{
private static readonly Brush NeutralBrush = Brushes.Gray;
private static readonly Brush ErrorBrush = Brushes.Firebrick;
private static readonly Brush SuccessBrush = Brushes.SeaGreen;
private readonly AppSettings _settings;
private readonly SettingsManager _settingsManager;
private readonly DpapiCredentialStore _credentialStore;
private readonly ILogger _logger;
[ObservableProperty]
private string espoCrmUrl = string.Empty;
[ObservableProperty]
private string username = string.Empty;
[ObservableProperty]
private string locale = "he-IL";
[ObservableProperty]
private string? statusMessage;
[ObservableProperty]
private Brush statusBrush = NeutralBrush;
public string InitialApiKey { get; }
public string VersionLine { get; }
public Func<string>? ApiKeyReader { get; set; }
public bool? DialogResult { get; private set; }
public event EventHandler? RequestClose;
public SettingsViewModel(
AppSettings settings,
SettingsManager settingsManager,
DpapiCredentialStore credentialStore,
ILogger logger)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_settingsManager = settingsManager ?? throw new ArgumentNullException(nameof(settingsManager));
_credentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
EspoCrmUrl = settings.EspoCrmUrl;
Locale = string.IsNullOrWhiteSpace(settings.Locale) ? "he-IL" : settings.Locale;
var creds = _credentialStore.Load();
Username = creds?.Username ?? string.Empty;
InitialApiKey = creds?.ApiKey ?? string.Empty;
var version = typeof(SettingsViewModel).Assembly.GetName().Version?.ToString() ?? "1.0.0";
VersionLine = $"גרסה {version}";
}
[RelayCommand]
private async Task TestConnectionAsync()
{
StatusBrush = NeutralBrush;
StatusMessage = "בודק חיבור…";
if (!Uri.TryCreate(EspoCrmUrl, UriKind.Absolute, out var baseUrl))
{
Fail("כתובת שרת לא תקינה");
return;
}
var apiKey = ReadApiKey();
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(apiKey))
{
Fail("יש להזין שם משתמש ומפתח API");
return;
}
HttpClient? http = null;
try
{
http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
var probe = new EspoCrmClient(http, baseUrl.ToString(), Username, apiKey, _logger);
var user = await probe.TestConnectionAsync().ConfigureAwait(true);
Succeed($"התחברות תקינה — משתמש: {user.UserName ?? user.Id}");
}
catch (EspoCrmAuthorizationException)
{
Fail("מפתח ה-API או שם המשתמש שגויים");
}
catch (Exception ex)
{
_logger.Warning(ex, "TestConnection failed");
Fail("שגיאת חיבור: " + ex.Message);
}
finally
{
http?.Dispose();
}
}
[RelayCommand]
private void Save()
{
if (!Uri.TryCreate(EspoCrmUrl, UriKind.Absolute, out _))
{
Fail("כתובת שרת לא תקינה");
return;
}
var apiKey = ReadApiKey();
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(apiKey))
{
Fail("יש להזין שם משתמש ומפתח API");
return;
}
try
{
_settings.EspoCrmUrl = EspoCrmUrl.TrimEnd('/');
_settings.Locale = string.IsNullOrWhiteSpace(Locale) ? "he-IL" : Locale;
_settingsManager.Save(_settings);
_credentialStore.Save(Username.Trim(), apiKey);
_logger.Information("Settings saved (user={User}, url={Url})", Username, _settings.EspoCrmUrl);
DialogResult = true;
RequestClose?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.Error(ex, "Settings save failed");
Fail("שמירת ההגדרות נכשלה: " + ex.Message);
}
}
[RelayCommand]
private void Cancel()
{
DialogResult = false;
RequestClose?.Invoke(this, EventArgs.Empty);
}
private string ReadApiKey() => ApiKeyReader?.Invoke() ?? string.Empty;
private void Fail(string message)
{
StatusBrush = ErrorBrush;
StatusMessage = message;
}
private void Succeed(string message)
{
StatusBrush = SuccessBrush;
StatusMessage = message;
}
}
}
@@ -67,5 +67,10 @@ namespace OutlookAddin.Ribbon
{
_ = Globals.ThisAddIn.AddInHost.LaunchFileToCaseAsync();
}
public void OnOpenSettings(IRibbonControl control)
{
Globals.ThisAddIn.AddInHost.LaunchSettings();
}
}
}
@@ -12,6 +12,13 @@
imageMso="MeetingForwardToManager"
onAction="OnFileToEspoCrm"
getEnabled="OnGetFileToEspoCrmEnabled" />
<button id="OpenEspoCrmSettings"
label="הגדרות"
screentip="הגדרות תוסף EspoCRM"
supertip="עריכת כתובת השרת, שם המשתמש, מפתח ה-API והעדפות נוספות."
size="large"
imageMso="ServerPropertiesDialog"
onAction="OnOpenSettings" />
</group>
</tab>
</tabs>
+46 -4
View File
@@ -55,18 +55,24 @@ namespace OutlookAddin.Services
public bool IsConfigured => CrmClient != null && FilingService != null;
public event EventHandler? CrmClientChanged;
private void TryInitializeCrm()
{
DisposeCrmStack();
var creds = CredentialStore.Load();
if (creds == null || string.IsNullOrWhiteSpace(creds.Username) || string.IsNullOrWhiteSpace(creds.ApiKey))
{
Logger.Information("AddInHost: no stored credentials — CRM features disabled until first-run setup");
CrmClientChanged?.Invoke(this, EventArgs.Empty);
return;
}
if (!Uri.TryCreate(Settings.EspoCrmUrl, UriKind.Absolute, out var baseUrl))
{
Logger.Warning("AddInHost: settings.EspoCrmUrl is not a valid URL: {Url}", Settings.EspoCrmUrl);
CrmClientChanged?.Invoke(this, EventArgs.Empty);
return;
}
@@ -78,13 +84,45 @@ namespace OutlookAddin.Services
_retryProcessor = new RetryQueueProcessor(RetryQueue, CrmClient, Logger);
_retryProcessor.Start();
Logger.Information("AddInHost: CRM client initialized for {BaseUrl}", baseUrl);
CrmClientChanged?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
Logger.Error(ex, "AddInHost: failed to initialize CRM client");
DisposeCrmStack();
CrmClientChanged?.Invoke(this, EventArgs.Empty);
}
}
private void DisposeCrmStack()
{
try { _retryProcessor?.Dispose(); } catch { /* ignore */ }
_retryProcessor = null;
try { _httpClient?.Dispose(); } catch { /* ignore */ }
_httpClient = null;
CrmClient = null;
FilingService = null;
}
/// <summary>
/// Opens the Settings dialog. On Save, tears down and rebuilds the CRM
/// client/filing/retry stack so the new credentials take effect immediately.
/// </summary>
public bool LaunchSettings()
{
var vm = new SettingsViewModel(Settings, SettingsManager, CredentialStore, Logger);
var dialog = new SettingsDialog(vm);
var ok = dialog.ShowDialog();
if (ok != true) return false;
// Re-read settings from disk in case fields were normalized.
var refreshed = SettingsManager.Load();
Settings.EspoCrmUrl = refreshed.EspoCrmUrl;
Settings.Locale = refreshed.Locale;
TryInitializeCrm();
return true;
}
/// <summary>
/// Entry point invoked by the ribbon "File to EspoCRM" button. Reads the
/// current Explorer selection, opens the search dialog, files on success,
@@ -94,12 +132,16 @@ namespace OutlookAddin.Services
{
if (!IsConfigured)
{
MessageBox.Show(
"התוסף עדיין לא מוגדר. יש להזין כתובת EspoCRM, שם משתמש ומפתח API בהגדרות.",
var result = MessageBox.Show(
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxButton.YesNo,
MessageBoxImage.Information);
return;
if (result == MessageBoxResult.Yes)
{
LaunchSettings();
}
if (!IsConfigured) return;
}
var mailItems = ReadCurrentSelection();