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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user