feat: UI to connect phones via CalDAV app passwords
This commit is contained in:
1 parent
58b3194878
commit
49a0fb8ec5
9 files changed
+473
No files matched your search
@@ -19,6 +19,15 @@
|
||||
<MudText Typo="Typo.body2" Class="mb-2">Erinnerungen auf diesem Gerät ein- oder ausschalten.</MudText>
|
||||
<MudButton Href="konto/benachrichtigungen" Variant="Variant.Outlined" Color="Color.Primary" Class="fp-settings-notifications"
|
||||
StartIcon="@Icons.Material.Filled.Notifications">Benachrichtigungen einstellen</MudButton>
|
||||
|
||||
<MudDivider Class="my-4" />
|
||||
|
||||
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Kalender freigeben / Mit Handy verbinden</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
Familienkalender in iPhone, Mac, Android oder Thunderbird einbinden – mit Zwei-Wege-Abgleich über App-Passwörter.
|
||||
</MudText>
|
||||
<MudButton Href="konto/kalender-verbinden" Variant="Variant.Outlined" Color="Color.Primary" Class="fp-settings-caldav"
|
||||
StartIcon="@Icons.Material.Filled.Sync">Kalender verbinden</MudButton>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
@page "/konto/kalender-verbinden"
|
||||
@using FamilyPlanner.Domain.Calendar
|
||||
@using FamilyPlanner.Shared.Calendar
|
||||
@inject ICalDavApi Api
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>Kalender mit dem Handy verbinden – Familienplaner</PageTitle>
|
||||
|
||||
<div class="d-flex align-center gap-2 mb-1">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="konto/einstellungen" aria-label="Zurück zu den Einstellungen" />
|
||||
<MudText Typo="Typo.h4" HtmlTag="h1">Kalender mit dem Handy verbinden</MudText>
|
||||
</div>
|
||||
<MudText Typo="Typo.body1" Class="mb-4">
|
||||
Mit der Kalender-Synchronisation (CalDAV) erscheinen die Familienkalender in der Kalender-App deines iPhones, Macs, Androids oder in Thunderbird –
|
||||
und Termine, die du dort anlegst oder änderst, landen automatisch im Familienplaner (Zwei-Wege-Abgleich). Du siehst und bearbeitest dort genau das,
|
||||
was du auch hier darfst.
|
||||
</MudText>
|
||||
|
||||
@if (_info is { IsSecure: false })
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mb-4 fp-caldav-insecure" role="alert">
|
||||
Diese Seite wurde ohne HTTPS geöffnet. Kalender-Apps senden das App-Passwort bei jeder Abfrage mit – verbinde sie nur über eine
|
||||
https-Adresse, sonst kann es im Netzwerk mitgelesen werden.
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (_secret is { } secret)
|
||||
{
|
||||
<MudPaper Outlined="true" Class="pa-4 mb-4 fp-caldav-secret">
|
||||
<MudText Typo="Typo.h6" HtmlTag="h2">Dein neues App-Passwort: @secret.Credential.Label</MudText>
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="my-2">
|
||||
Das Passwort ist nur jetzt sichtbar. Trage es gleich in der Kalender-App ein. Es ersetzt dein normales Passwort <b>nicht</b> – dafür
|
||||
kannst du es jederzeit widerrufen.
|
||||
</MudAlert>
|
||||
<MudTextField T="string" Value="@secret.Secret" Label="App-Passwort" ReadOnly="true" Variant="Variant.Outlined" InputId="caldav-secret"
|
||||
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.ContentCopy" AdornmentAriaLabel="App-Passwort kopieren"
|
||||
OnAdornmentClick="() => CopyAsync(secret.Secret)" />
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Outlined="true" Class="pa-4 fp-caldav-info">
|
||||
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Verbindungsdaten</MudText>
|
||||
@if (_info is null)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="2">
|
||||
<MudTextField T="string" Value="@_info.ServerUrl" Label="Server-Adresse" ReadOnly="true" Variant="Variant.Outlined" InputId="caldav-url"
|
||||
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.ContentCopy" AdornmentAriaLabel="Server-Adresse kopieren"
|
||||
OnAdornmentClick="() => CopyAsync(_info.ServerUrl)" />
|
||||
<MudTextField T="string" Value="@_info.UserName" Label="Benutzername" ReadOnly="true" Variant="Variant.Outlined" InputId="caldav-user"
|
||||
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.ContentCopy" AdornmentAriaLabel="Benutzername kopieren"
|
||||
OnAdornmentClick="() => CopyAsync(_info.UserName)" />
|
||||
<MudText Typo="Typo.body2">Passwort: ein App-Passwort (siehe unten) – nicht dein normales Passwort.</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Outlined="true" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Neues App-Passwort</MudText>
|
||||
<EditForm Model="this" OnSubmit="CreateAsync" FormName="caldav-create" id="caldav-form">
|
||||
<MudStack Spacing="2">
|
||||
@if (_error is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" role="alert">@_error</MudAlert>
|
||||
}
|
||||
<MudTextField @bind-Value="_label" Label="Für welches Gerät?" Placeholder="z. B. iPhone von Anna" Variant="Variant.Outlined"
|
||||
MaxLength="CalDavCredential.LabelMaxLength" InputId="caldav-label" Immediate="true" />
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Disabled="_busy"
|
||||
StartIcon="@Icons.Material.Filled.Key" Class="fp-caldav-create">App-Passwort erstellen</MudButton>
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mt-6 mb-2">Meine App-Passwörter</MudText>
|
||||
@if (_credentials is null)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else if (_credentials.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="fp-caldav-empty">Noch keine App-Passwörter.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable T="CalDavCredentialDto" Items="_credentials" Breakpoint="Breakpoint.Sm" Elevation="0" Outlined="true" Dense="true" Class="fp-caldav-table">
|
||||
<HeaderContent>
|
||||
<MudTh>Gerät</MudTh>
|
||||
<MudTh>Erstellt</MudTh>
|
||||
<MudTh>Zuletzt benutzt</MudTh>
|
||||
<MudTh>Status</MudTh>
|
||||
<MudTh><span class="sr-only">Aktionen</span></MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Gerät">@context.Label <span class="fp-token-prefix">(@context.SecretPrefix…)</span></MudTd>
|
||||
<MudTd DataLabel="Erstellt">@UserManagement.FormatDate(context.CreatedAt)</MudTd>
|
||||
<MudTd DataLabel="Zuletzt benutzt">@(context.LastUsedAt is { } at ? UserManagement.FormatDate(at) + (string.IsNullOrWhiteSpace(context.LastUsedClient) ? string.Empty : $" – {context.LastUsedClient}") : "noch nie")</MudTd>
|
||||
<MudTd DataLabel="Status">
|
||||
@if (context.RevokedAt is null)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Outlined">aktiv</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">widerrufen</MudChip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right">
|
||||
@if (context.RevokedAt is null)
|
||||
{
|
||||
<MudButton Size="Size.Small" Color="Color.Error" OnClick="() => RevokeAsync(context)" StartIcon="@Icons.Material.Filled.LinkOff"
|
||||
Class="fp-caldav-revoke">Widerrufen</MudButton>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mt-6 mb-2">So geht's</MudText>
|
||||
<MudPaper Outlined="true" Class="pa-4">
|
||||
<MudExpansionPanels Elevation="0" MultiExpansion="true">
|
||||
<MudExpansionPanel Text="iPhone / iPad" Expanded="true">
|
||||
<ol class="fp-steps">
|
||||
<li><b>Einstellungen › Apps › Kalender › Kalender-Accounts › Account hinzufügen › Andere › CalDAV-Account hinzufügen</b>
|
||||
(ältere iOS-Versionen: Einstellungen › Kalender › Accounts).</li>
|
||||
<li><b>Server:</b> die Server-Adresse von oben, <b>Benutzername:</b> dein Benutzername, <b>Passwort:</b> das App-Passwort.</li>
|
||||
<li>„Weiter“ tippen – die Familienkalender erscheinen in der Kalender-App. Neue Termine dort einem dieser Kalender zuordnen.</li>
|
||||
</ol>
|
||||
</MudExpansionPanel>
|
||||
<MudExpansionPanel Text="Mac">
|
||||
<ol class="fp-steps">
|
||||
<li><b>Systemeinstellungen › Internetaccounts › Account hinzufügen › Anderer Account › CalDAV-Account</b>.</li>
|
||||
<li>Kontotyp „Manuell“, Benutzername, App-Passwort und die Server-Adresse eintragen.</li>
|
||||
</ol>
|
||||
</MudExpansionPanel>
|
||||
<MudExpansionPanel Text="Android (DAVx5)">
|
||||
<ol class="fp-steps">
|
||||
<li>Die App <b>DAVx5</b> installieren, „Konto hinzufügen“ › <b>Mit URL und Benutzername anmelden</b>.</li>
|
||||
<li>Server-Adresse, Benutzername und App-Passwort eintragen, Kalender zum Abgleich auswählen.</li>
|
||||
<li>Die Kalender erscheinen dann in der Google-Kalender-App oder jeder anderen Kalender-App des Geräts.</li>
|
||||
</ol>
|
||||
</MudExpansionPanel>
|
||||
<MudExpansionPanel Text="Thunderbird">
|
||||
<ol class="fp-steps">
|
||||
<li>Kalender › <b>Neuer Kalender › Im Netzwerk</b> › Format <b>CalDAV</b>.</li>
|
||||
<li>Als Ort die Server-Adresse eintragen, mit dem Benutzernamen fortfahren und das App-Passwort eingeben.</li>
|
||||
<li>Thunderbird listet die Familienkalender – die gewünschten auswählen und abonnieren.</li>
|
||||
</ol>
|
||||
</MudExpansionPanel>
|
||||
<MudExpansionPanel Text="Outlook">
|
||||
<MudText Typo="Typo.body2">
|
||||
Outlook kann kein CalDAV – eine Zwei-Wege-Synchronisation ist dort nicht möglich. Du kannst die Familienkalender aber
|
||||
<b>nur lesend</b> abonnieren: <MudLink Href="kalender/abonnieren">Kalender abonnieren</MudLink> (Outlook aktualisiert Abos nur alle paar Stunden).
|
||||
</MudText>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
<MudText Typo="Typo.body2" Class="mt-3">
|
||||
Gut zu wissen: Es werden Termine (keine Aufgaben) abgeglichen. Erinnerungen, Teilnehmer und Anhänge aus der Kalender-App werden nicht
|
||||
gespeichert. Wiederholungen, die der Familienplaner nicht kennt (z. B. stündlich), werden beim Speichern abgelehnt.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private CalDavInfoDto? _info;
|
||||
private List<CalDavCredentialDto>? _credentials;
|
||||
private CalDavCredentialSecretDto? _secret;
|
||||
private string? _label;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_info = (await Api.GetInfoAsync()).Value;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync() => _credentials = (await Api.GetCredentialsAsync()).Value ?? [];
|
||||
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
_error = null;
|
||||
var request = new CreateCalDavCredentialRequest(_label ?? string.Empty);
|
||||
var validation = new CreateCalDavCredentialRequestValidator().Validate(request);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
_error = string.Join(" ", validation.Errors.Select(e => e.ErrorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
var result = await Api.CreateCredentialAsync(request);
|
||||
_busy = false;
|
||||
if (result.Value is null)
|
||||
{
|
||||
_error = result.Problem?.Message ?? "Das App-Passwort konnte nicht erstellt werden.";
|
||||
return;
|
||||
}
|
||||
|
||||
_secret = result.Value;
|
||||
_label = null;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task RevokeAsync(CalDavCredentialDto credential)
|
||||
{
|
||||
if (await DialogService.ShowMessageBoxAsync("App-Passwort widerrufen?",
|
||||
$"„{credential.Label}“ kann sich danach nicht mehr verbinden. Die Kalender-App auf diesem Gerät muss neu eingerichtet werden.",
|
||||
yesText: "Widerrufen", cancelText: "Abbrechen") != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await Api.RevokeCredentialAsync(credential.Id);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
Snackbar.Add(result.Problem?.Message ?? "Fehler", Severity.Error);
|
||||
}
|
||||
|
||||
if (_secret?.Credential.Id == credential.Id)
|
||||
{
|
||||
_secret = null;
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task CopyAsync(string text)
|
||||
{
|
||||
var copied = false;
|
||||
try
|
||||
{
|
||||
copied = await JS.InvokeAsync<bool>("familyPlanner.clipboard.copy", text);
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
}
|
||||
|
||||
Snackbar.Add(copied ? "Kopiert." : "Kopieren nicht möglich – bitte markieren und kopieren.", copied ? Severity.Success : Severity.Warning);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
</div>
|
||||
<MudDivider />
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Link" OnClick="@(() => Navigation.NavigateTo("kalender/abonnieren"))">Kalender abonnieren</MudMenuItem>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Sync" OnClick="@(() => Navigation.NavigateTo("konto/kalender-verbinden"))">Mit Handy verbinden</MudMenuItem>
|
||||
@if (CanCreate)
|
||||
{
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.UploadFile" OnClick="ImportAsync">.ics importieren</MudMenuItem>
|
||||
|
||||
@@ -36,6 +36,7 @@ builder.Services.AddScoped<IShoppingApi, ShoppingApi>();
|
||||
builder.Services.AddScoped<IMealsApi, MealsApi>();
|
||||
builder.Services.AddScoped<ITasksApi, TasksApi>();
|
||||
builder.Services.AddScoped<ICalendarApi, CalendarApi>();
|
||||
builder.Services.AddScoped<ICalDavApi, CalDavApi>();
|
||||
builder.Services.AddScoped<IDashboardApi, DashboardApi>();
|
||||
builder.Services.AddScoped<IChatApi, ChatApi>();
|
||||
builder.Services.AddScoped<IAiApi, AiApi>();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Net.Http.Json;
|
||||
using FamilyPlanner.Shared.Calendar;
|
||||
|
||||
namespace FamilyPlanner.Web.Client.Services;
|
||||
|
||||
/// <summary>Verbindungsdaten und App-Passwörter für die Kalender-Synchronisation (CalDAV, ADR-0017).</summary>
|
||||
public interface ICalDavApi
|
||||
{
|
||||
Task<ApiResult<CalDavInfoDto>> GetInfoAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<List<CalDavCredentialDto>>> GetCredentialsAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<CalDavCredentialSecretDto>> CreateCredentialAsync(CreateCalDavCredentialRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<bool>> RevokeCredentialAsync(Guid id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class CalDavApi(HttpClient http) : ICalDavApi
|
||||
{
|
||||
public Task<ApiResult<CalDavInfoDto>> GetInfoAsync(CancellationToken ct = default) =>
|
||||
SendAsync<CalDavInfoDto>(() => http.GetAsync("api/caldav/info", ct), ct);
|
||||
|
||||
public Task<ApiResult<List<CalDavCredentialDto>>> GetCredentialsAsync(CancellationToken ct = default) =>
|
||||
SendAsync<List<CalDavCredentialDto>>(() => http.GetAsync("api/caldav/credentials", ct), ct);
|
||||
|
||||
public Task<ApiResult<CalDavCredentialSecretDto>> CreateCredentialAsync(CreateCalDavCredentialRequest request, CancellationToken ct = default) =>
|
||||
SendAsync<CalDavCredentialSecretDto>(() => http.PostAsJsonAsync("api/caldav/credentials", request, ct), ct);
|
||||
|
||||
public async Task<ApiResult<bool>> RevokeCredentialAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var result = await SendAsync<object>(() => http.DeleteAsync($"api/caldav/credentials/{id}", ct), ct);
|
||||
return result.Problem is null ? ApiResult<bool>.Ok(true) : ApiResult<bool>.Fail(result.Problem);
|
||||
}
|
||||
|
||||
private static async Task<ApiResult<T>> SendAsync<T>(Func<Task<HttpResponseMessage>> send, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await send();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return ApiResult<T>.Fail(await ApiClient.ReadProblemAsync(response, ct));
|
||||
}
|
||||
|
||||
return response.StatusCode == System.Net.HttpStatusCode.NoContent
|
||||
? ApiResult<T>.Ok(default)
|
||||
: ApiResult<T>.Ok(await response.Content.ReadFromJsonAsync<T>(ct));
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return ApiResult<T>.Fail(ApiProblem.Offline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,13 @@ public sealed class CalDavProtocolTests
|
||||
Assert.Equal(uid, DavPath.Parse(href).Uid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_treats_proxy_double_encoded_slash_as_slash()
|
||||
{
|
||||
Assert.Equal("a/b", DavPath.Parse(DavPath.Calendar(CalendarId) + "a%252Fb.ics").Uid); // YARP: %2F wird zu %252F
|
||||
Assert.Equal("a/b", DavPath.Parse(DavPath.Calendar(CalendarId) + "a%2Fb.ics").Uid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_accepts_absolute_urls_and_queries_and_decodes_only_once()
|
||||
{
|
||||
|
||||
@@ -28,6 +28,15 @@ public sealed class AccountSettingsPageTests : BunitContext
|
||||
Assert.Equal("konto/benachrichtigungen", cut.Find(".fp-settings-notifications").GetAttribute("href"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Links_to_the_calendar_phone_connection()
|
||||
{
|
||||
var cut = Render<AccountSettings>();
|
||||
|
||||
Assert.Equal("konto/kalender-verbinden", cut.Find(".fp-settings-caldav").GetAttribute("href"));
|
||||
Assert.Contains("Mit Handy verbinden", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choosing_dark_updates_the_theme_state()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@* Testhülle: MudBlazor-Provider + Seite „Kalender mit dem Handy verbinden“. *@
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<FamilyPlanner.Web.Client.Pages.CalDavConnect />
|
||||
@@ -0,0 +1,138 @@
|
||||
using FamilyPlanner.Shared.Calendar;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Calendar;
|
||||
|
||||
/// <summary>Seite „Kalender mit dem Handy verbinden“ (CalDAV, ADR-0017): Verbindungsdaten, App-Passwort einmalig anzeigen, Widerruf, Anleitungen.</summary>
|
||||
public sealed class CalDavConnectTests : BunitContext
|
||||
{
|
||||
private readonly FakeCalDavApi _api = new();
|
||||
|
||||
public CalDavConnectTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
Services.AddMudServices();
|
||||
Services.AddSingleton<ICalDavApi>(_api);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shows_server_url_user_name_and_instructions_for_all_clients()
|
||||
{
|
||||
var cut = Render<CalDavConnectHost>();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("https://familie.example/caldav/", cut.Find("#caldav-url").GetAttribute("value")));
|
||||
Assert.Equal("anna", cut.Find("#caldav-user").GetAttribute("value"));
|
||||
Assert.Contains("CalDAV-Account hinzufügen", cut.Markup); // iPhone
|
||||
Assert.Contains("DAVx5", cut.Markup);
|
||||
Assert.Contains("Thunderbird", cut.Markup);
|
||||
Assert.Contains("Outlook kann kein CalDAV", cut.Markup);
|
||||
Assert.Contains("nicht dein normales Passwort", cut.Markup);
|
||||
Assert.Empty(cut.FindAll(".fp-caldav-insecure"));
|
||||
Assert.Contains("Noch keine App-Passwörter", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warns_when_page_is_not_served_over_https()
|
||||
{
|
||||
_api.Info = new CalDavInfoDto("http://192.168.0.5/caldav/", "anna", false);
|
||||
|
||||
var cut = Render<CalDavConnectHost>();
|
||||
|
||||
cut.WaitForElement(".fp-caldav-insecure");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Creating_a_password_requires_a_label_shows_the_secret_once_and_lists_it()
|
||||
{
|
||||
var cut = Render<CalDavConnectHost>();
|
||||
cut.WaitForElement("#caldav-form");
|
||||
|
||||
cut.Find("#caldav-form").Submit();
|
||||
cut.WaitForAssertion(() => Assert.Contains("Bezeichnung", cut.Find("[role=alert]").TextContent));
|
||||
Assert.Empty(_api.Created);
|
||||
|
||||
cut.Find("#caldav-label").Input("iPhone von Anna");
|
||||
cut.Find("#caldav-form").Submit();
|
||||
|
||||
cut.WaitForElement(".fp-caldav-secret");
|
||||
Assert.Equal("iPhone von Anna", Assert.Single(_api.Created).Label);
|
||||
Assert.Equal("abcd-efgh-jkmn-pqrs-tuvw", cut.Find("#caldav-secret").GetAttribute("value"));
|
||||
Assert.Contains("nur jetzt sichtbar", cut.Find(".fp-caldav-secret").TextContent);
|
||||
Assert.Contains("iPhone von Anna", cut.Find(".fp-caldav-table").TextContent);
|
||||
Assert.Contains("(abcd…)", cut.Find(".fp-caldav-table").TextContent);
|
||||
Assert.Contains("noch nie", cut.Find(".fp-caldav-table").TextContent);
|
||||
Assert.DoesNotContain("abcd-efgh-jkmn-pqrs-tuvw", cut.Find(".fp-caldav-table").InnerHtml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_errors_such_as_the_limit_are_shown()
|
||||
{
|
||||
_api.CreateError = "Es sind höchstens 10 App-Passwörter gleichzeitig möglich.";
|
||||
var cut = Render<CalDavConnectHost>();
|
||||
cut.WaitForElement("#caldav-form");
|
||||
|
||||
cut.Find("#caldav-label").Input("Noch eins");
|
||||
cut.Find("#caldav-form").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("höchstens 10", cut.Find("[role=alert]").TextContent));
|
||||
Assert.Empty(cut.FindAll(".fp-caldav-secret"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Revoking_asks_first_and_marks_the_password_revoked()
|
||||
{
|
||||
_api.Credentials.Add(new CalDavCredentialDto(Guid.NewGuid(), "Thunderbird PC", "wxyz", DateTimeOffset.UtcNow.AddDays(-3), DateTimeOffset.UtcNow.AddHours(-1), "Thunderbird/128", null));
|
||||
var cut = Render<CalDavConnectHost>();
|
||||
cut.WaitForElement(".fp-caldav-table");
|
||||
Assert.Contains("Thunderbird/128", cut.Find(".fp-caldav-table").TextContent);
|
||||
|
||||
cut.Find(".fp-caldav-revoke").Click();
|
||||
cut.WaitForElement(".mud-message-box");
|
||||
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Widerrufen")).Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal([_api.Credentials[0].Id], _api.Revoked));
|
||||
cut.WaitForAssertion(() => Assert.Contains("widerrufen", cut.Find(".fp-caldav-table").TextContent));
|
||||
Assert.Empty(cut.FindAll(".fp-caldav-revoke"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeCalDavApi : ICalDavApi
|
||||
{
|
||||
public CalDavInfoDto Info { get; set; } = new("https://familie.example/caldav/", "anna", true);
|
||||
|
||||
public List<CalDavCredentialDto> Credentials { get; } = [];
|
||||
|
||||
public List<CreateCalDavCredentialRequest> Created { get; } = [];
|
||||
|
||||
public List<Guid> Revoked { get; } = [];
|
||||
|
||||
public string? CreateError { get; set; }
|
||||
|
||||
public Task<ApiResult<CalDavInfoDto>> GetInfoAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<CalDavInfoDto>.Ok(Info));
|
||||
|
||||
public Task<ApiResult<List<CalDavCredentialDto>>> GetCredentialsAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<List<CalDavCredentialDto>>.Ok(Credentials.ToList()));
|
||||
|
||||
public Task<ApiResult<CalDavCredentialSecretDto>> CreateCredentialAsync(CreateCalDavCredentialRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (CreateError is not null)
|
||||
{
|
||||
return Task.FromResult(ApiResult<CalDavCredentialSecretDto>.Fail(new ApiProblem { Status = 409, Detail = CreateError }));
|
||||
}
|
||||
|
||||
Created.Add(request);
|
||||
var credential = new CalDavCredentialDto(Guid.NewGuid(), request.Label, "abcd", DateTimeOffset.UtcNow, null, null, null);
|
||||
Credentials.Add(credential);
|
||||
return Task.FromResult(ApiResult<CalDavCredentialSecretDto>.Ok(new CalDavCredentialSecretDto(credential, "abcd-efgh-jkmn-pqrs-tuvw")));
|
||||
}
|
||||
|
||||
public Task<ApiResult<bool>> RevokeCredentialAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Revoked.Add(id);
|
||||
var index = Credentials.FindIndex(c => c.Id == id);
|
||||
Credentials[index] = Credentials[index] with { RevokedAt = DateTimeOffset.UtcNow };
|
||||
return Task.FromResult(ApiResult<bool>.Ok(true));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user