Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<VersionPrefix Condition="'$(VersionPrefix)' == ''">0.1.0</VersionPrefix>
<VersionSuffix Condition="'$(VersionSuffix)' == ''">rc.7</VersionSuffix>
<VersionSuffix Condition="'$(VersionSuffix)' == ''">rc.8</VersionSuffix>

<Version Condition="'$(Version)' == '' and '$(VersionSuffix)' == ''">$(VersionPrefix)</Version>
<Version Condition="'$(Version)' == '' and '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix)</Version>
Expand Down
2 changes: 1 addition & 1 deletion docs/branding.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ environment:
Branding__EmailFromDisplayName: Acme Service Desk
```

`ApplicationUrl` is the canonical base for generated application links. Use an HTTPS public URL in production; it must be an absolute HTTP(S) URL. Host logos and favicons on a publicly reachable HTTPS endpoint, or retain the bundled `/branding/` artwork.
`ApplicationUrl`, organization/site URL, and support URL must be absolute HTTP(S) URLs. Asset URLs for the main logo, compact logo, and favicon may instead be safe root-relative application paths such as `/branding/rateldesk-wordmark.webp`; protocol-relative and non-HTTP schemes are rejected. The administrator editor retains database overrides separately from effective values, so saving one field does not turn inherited defaults into overrides. Use **Use default** on a field to remove only that database override.

The bundled browser defaults are `/branding/rateldesk-mark.webp`, `/branding/rateldesk-wordmark.webp`, and `/branding/rateldesk-splash.webp`; PNG counterparts are included for transparent-image fallback. The email default is the mail-client-compatible `/email-brand/rateldesk-email-wordmark.png`. `LogoUrl`, `CompactLogoUrl`, and `FaviconUrl` continue to override these upstream defaults, and tenant branding remains more specific where configured.

Expand Down
2 changes: 1 addition & 1 deletion docs/releases.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Releases

RatelDesk has one repository-owned release line. The root `Directory.Build.props` is the source of truth: maintainers update `VersionPrefix` when preparing the next release. All application projects inherit that value. `VersionSuffix` creates prereleases without changing the release line; for example, `-p:VersionPrefix=0.2.0 -p:VersionSuffix=beta.1` evaluates to `0.2.0-beta.1`.
RatelDesk has one repository-owned release line. The root `Directory.Build.props` is the source of truth: maintainers update `VersionPrefix` when preparing the next release. All application projects inherit that value. `VersionSuffix` creates prereleases without changing the release line; for example, `-p:VersionPrefix=0.2.0 -p:VersionSuffix=beta.1` evaluates to `0.2.0-beta.1`. The current prepared prerelease is `0.1.0-rc.8`.

## Build identity

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<AuthorizeView Roles="Tenant.Roles.Assign,HelpdeskAdmin">
<MudNavLink Href="/tenant-administration" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Groups">Tenant members</MudNavLink>
<AuthorizeView Roles="HelpdeskAdmin" Context="tenantRoleAdmin">
<NotAuthorized>
<MudNavLink Href="/admin/roles" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Security">Tenant roles</MudNavLink>
</NotAuthorized>
</AuthorizeView>
</AuthorizeView>
<AuthorizeView Roles="Tenant.Settings.Manage,HelpdeskAdmin">
<MudNavLink Href="/tenant-settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Apartment">Tenant settings</MudNavLink>
<AuthorizeView Roles="HelpdeskAdmin" Context="instanceAdmin">
<NotAuthorized>
<AuthorizeView Roles="Tenant.Roles.Assign,Tenant.Settings.Manage" Context="delegatedAdmin">
<MudNavGroup Title="Administration" Icon="@Icons.Material.Filled.AdminPanelSettings" Expanded="false">
<MudNavGroup Title="Accounts &amp; Orgs" Icon="@Icons.Material.Filled.AccountTree" Expanded="false">
<MudNavLink Href="/admin/organizations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Apartment">Organizations</MudNavLink>
</MudNavGroup>
<AuthorizeView Roles="Tenant.Roles.Assign" Context="roleAdministrator">
<MudNavLink Href="/admin/roles" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Security">Roles &amp; Permissions</MudNavLink>
</AuthorizeView>
</MudNavGroup>
</AuthorizeView>
</NotAuthorized>
</AuthorizeView>
20 changes: 20 additions & 0 deletions src/HelpDesk.NewWeb/Components/Pages/Admin/BrandingField.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<MudItem xs="12" md="6">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudTextField Value="Value" ValueChanged="ValueChanged" Label="@Label" Disabled="Disabled" Class="flex-grow-1" />
@if (!Disabled)
{
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="() => OnUseDefault.InvokeAsync(Field)">Use default</MudButton>
}
</MudStack>
<MudText Typo="Typo.caption" Color="@(Disabled ? Color.Warning : Color.Secondary)">@Source</MudText>
</MudItem>

@code {
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
[Parameter, EditorRequired] public string Field { get; set; } = string.Empty;
[Parameter] public string Value { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public string Source { get; set; } = "Default";
[Parameter] public bool Disabled { get; set; }
[Parameter] public EventCallback<string> OnUseDefault { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,14 @@
return true;
}

var status = customer.AuthStatus?.InviteStatus ?? CustomerInviteStatus.NotInvited;
return status is CustomerInviteStatus.Active or CustomerInviteStatus.Disabled;
var auth = customer.AuthStatus;
var status = auth?.InviteStatus ?? CustomerInviteStatus.NotInvited;
return status switch
{
CustomerInviteStatus.Pending or CustomerInviteStatus.Failed or CustomerInviteStatus.Expired => auth?.CanResend != true,
CustomerInviteStatus.Active or CustomerInviteStatus.Disabled => true,
_ => auth?.CanInvite != true
};
}

private static string GetInviteActionLabel(CustomerDto customer)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
@using System.Net.Http.Json
@inject IHttpClientFactory HttpClientFactory

<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2">Organization roles</MudText>
@if (!string.IsNullOrWhiteSpace(error))
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="my-2">@error <MudButton Variant="Variant.Text" Size="Size.Small" OnClick="LoadAsync">Retry</MudButton></MudAlert>
}
else if (loading)
{
<MudProgressLinear Indeterminate="true" Class="my-2" />
}
else
{
<MudStack Spacing="0" Class="mt-2">
@foreach (var role in roles)
{
<MudCheckBox T="bool" Value="@selectedRoleKeys.Contains(role.Key)" ValueChanged="enabled => SetRole(role.Key, enabled)" Label="@role.Name" />
}
</MudStack>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Disabled="@(!dirty || saving)" OnClick="SaveAsync" Class="mt-2">@(saving ? "Saving…" : "Save organization roles")</MudButton>
}

@code {
[Parameter, EditorRequired] public string OrganizationId { get; set; } = string.Empty;
[Parameter, EditorRequired] public string UserId { get; set; } = string.Empty;
private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi");
private readonly List<RoleOption> roles = [];
private HashSet<string> originalRoleKeys = new(StringComparer.OrdinalIgnoreCase);
private HashSet<string> selectedRoleKeys = new(StringComparer.OrdinalIgnoreCase);
private bool loading = true;
private bool saving;
private string? error;
private bool dirty => !originalRoleKeys.SetEquals(selectedRoleKeys);

protected override Task OnInitializedAsync() => LoadAsync();
private async Task LoadAsync()
{
loading = true;
error = null;
try
{
var roleResponse = await Api.GetFromJsonAsync<List<RoleOption>>($"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(OrganizationId)}/users/roles") ?? [];
var membership = await Api.GetFromJsonAsync<Membership>($"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(OrganizationId)}/users/{Uri.EscapeDataString(UserId)}/assignments");
roles.Clear();
roles.AddRange(roleResponse);
originalRoleKeys = membership?.RoleKeys.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new(StringComparer.OrdinalIgnoreCase);
selectedRoleKeys = new HashSet<string>(originalRoleKeys, StringComparer.OrdinalIgnoreCase);
}
catch (HttpRequestException)
{
error = "Organization roles could not be loaded for this account.";
}
finally { loading = false; }
}
private void SetRole(string key, bool enabled) { if (enabled) selectedRoleKeys.Add(key); else selectedRoleKeys.Remove(key); }
private async Task SaveAsync()
{
saving = true;
error = null;
try
{
using var response = await Api.PutAsJsonAsync($"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(OrganizationId)}/users/{Uri.EscapeDataString(UserId)}/assignments", new ReplaceMembership(selectedRoleKeys.Order(StringComparer.OrdinalIgnoreCase).ToArray()));
if (!response.IsSuccessStatusCode) { error = await response.Content.ReadAsStringAsync(); return; }
await LoadAsync();
}
catch (HttpRequestException) { error = "Organization roles could not be saved."; }
finally { saving = false; }
}
private sealed record RoleOption(string Key, string Name);
private sealed record Membership(string UserId, string OrganizationId, IReadOnlyList<string> RoleKeys);
private sealed record ReplaceMembership(IReadOnlyList<string> RoleKeys);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,40 +16,48 @@

<DialogContent>
<MudForm @ref="form" Model="model" OnValidSubmit="Submit" Class="mt-2">
<MudTextField @bind-Value="model.Name" Label="Name" Required="true" />
<MudTextField @bind-Value="model.Email" Label="Email" Required="true" />
<MudTabs>
<MudTabPanel Text="Details">
<MudTextField @bind-Value="model.Name" Label="Name" Required="true" />
<MudTextField @bind-Value="model.Email" Label="Email" Required="true" />

<MudSelect T="string" @bind-Value="model.OrganizationId" Label="Organization" Required="true">
<MudSelect T="string" @bind-Value="model.OrganizationId" Label="Organization" Required="true" Disabled="@(authStatus?.IsLinkedLogin == true)">
@foreach (var org in Organizations)
{
<MudSelectItem T="string" Value="@org.Id">@org.Name</MudSelectItem>
}
</MudSelect>
</MudSelect>
@if (authStatus?.IsLinkedLogin == true)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">A linked login keeps its current organization access. Use the explicit access workflow to move it.</MudText>
}

<!-- Correct MudSwitch binding -->
<MudSwitch T="bool"
<MudSwitch T="bool"
@bind-Value="model.Enabled"
For="@(() => model.Enabled)"
Label="Enabled" />

<MudDivider Class="my-4" />
Label="Enabled" />
</MudTabPanel>

<MudText Typo="Typo.subtitle2">Authentication / Helpdesk Access</MudText>
<MudStack Spacing="1" Class="mt-2">
<MudTabPanel Text="Access &amp; roles">
<MudText Typo="Typo.subtitle2">Account access</MudText>
<MudStack Spacing="1" Class="mt-2">
<MudChip T="string" Dense="true" Color="@GetAuthStatusColor(authStatus?.InviteStatus)" Variant="Variant.Outlined">
@(authStatus?.StatusText ?? "Not invited")
</MudChip>
@if (authStatus is not null)
{
<MudText Typo="Typo.caption">Authentik user: @(authStatus.AuthentikUserId ?? "Not linked")</MudText>
<MudText Typo="Typo.caption">@(authStatus.IdentitySummary ?? "No linked login")</MudText>
<MudText Typo="Typo.caption">Account type: @(authStatus.AuthProviderType ?? "None")</MudText>
<MudText Typo="Typo.caption">Last invite: @FormatDate(authStatus.InviteSentAtUtc)</MudText>
<MudText Typo="Typo.caption">Last login: @FormatDate(authStatus.LastLoginAtUtc)</MudText>
@if (!string.IsNullOrWhiteSpace(authStatus.LastAuthError))
{
<MudAlert Severity="Severity.Error" Dense="true">@authStatus.LastAuthError</MudAlert>
}
}
<MudStack Row="true" Spacing="1">
@if (!string.Equals(authStatus?.AuthProviderType, "Local", StringComparison.OrdinalIgnoreCase) && authStatus?.HasMultipleIdentityLinks != true)
{
<MudStack Row="true" Spacing="1">
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
Disabled="@IsInviteDisabled"
Expand All @@ -64,8 +72,32 @@
OnClick="DisableLogin">
Disable access
</MudButton>
</MudStack>
</MudStack>
</MudStack>
}
else if (string.Equals(authStatus?.AuthProviderType, "Local", StringComparison.OrdinalIgnoreCase))
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Local activation and account lifecycle actions are managed from the organization member editor.</MudText>
@if (authStatus.InviteStatus == CustomerInviteStatus.Pending)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="GenerateLocalActivationToken">Generate activation token</MudButton>
}
@if (!string.IsNullOrWhiteSpace(localActivationToken))
{
<MudAlert Severity="Severity.Warning" Dense="true">Copy this activation token now. It is shown only in this dialog.</MudAlert>
<MudTextField Value="@localActivationToken" Label="Activation token" ReadOnly="true" />
}
}
@if (!string.IsNullOrWhiteSpace(authStatus?.LocalAccountId))
{
<CustomerRoleAccessEditor OrganizationId="@Customer.OrganizationId" UserId="@authStatus.LocalAccountId" />
}
else if (authStatus?.IsLinkedLogin == true)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">This provider-managed identity has no supported local role-assignment workflow.</MudText>
}
</MudStack>
</MudTabPanel>
</MudTabs>
</MudForm>
</DialogContent>

Expand All @@ -85,6 +117,7 @@
private MudForm form = default!;
private EditCustomerModel model = new();
private CustomerAuthStatusDto? authStatus;
private string? localActivationToken;

protected override void OnParametersSet()
{
Expand Down Expand Up @@ -150,6 +183,14 @@

private Task DisableLogin() => RunAuthAction("disable-login", "Login access disabled.");

private async Task GenerateLocalActivationToken()
{
using var response = await HelpdeskApi.PostAsync($"api/v1/customers/{Customer.Id}/auth/local-activation-token", null);
if (!response.IsSuccessStatusCode) { Snackbar.Add(await ReadProblemMessageAsync(response), Severity.Error); return; }
var token = await response.Content.ReadFromJsonAsync<LocalActivationTokenResponse>();
localActivationToken = token?.ActivationToken;
}

private async Task RunAuthAction(string route, string successMessage)
{
try
Expand All @@ -172,7 +213,12 @@
}
}

private bool IsInviteDisabled => !model.Enabled || authStatus?.InviteStatus is CustomerInviteStatus.Active or CustomerInviteStatus.Disabled;
private bool IsInviteDisabled => !model.Enabled || authStatus?.InviteStatus switch
{
CustomerInviteStatus.Pending or CustomerInviteStatus.Failed or CustomerInviteStatus.Expired => authStatus.CanResend != true,
CustomerInviteStatus.Active or CustomerInviteStatus.Disabled => true,
_ => authStatus?.CanInvite != true
};

private static async Task<string> ReadProblemMessageAsync(HttpResponseMessage response)
{
Expand All @@ -194,6 +240,7 @@
}

private sealed record ProblemResponse(string? Title, string? Detail);
private sealed record LocalActivationTokenResponse(string UserId, string Email, string ActivationToken);

private string InviteButtonText => authStatus?.InviteStatus switch
{
Expand Down
Loading
Loading