feat: UI to connect phones via CalDAV app passwords

This commit is contained in:
Jannis committed 2026-10-10 22:18:08 +02:00
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>
+1
View File
@@ -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));
}
}