Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
37 changes: 37 additions & 0 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ public event EventHandler<NotFoundEventArgs> OnNotFound

private EventHandler<NotFoundEventArgs>? _notFound;

/// <summary>
/// An event that fires when access to a page is forbidden.
/// </summary>
public event EventHandler<ForbiddenEventArgs> OnForbidden
{
add
{
AssertInitialized();
_forbidden += value;
}
remove
{
AssertInitialized();
_forbidden -= value;
}
}

private EventHandler<ForbiddenEventArgs>? _forbidden;

// For the baseUri it's worth storing as a System.Uri so we can do operations
// on that type. System.Uri gives us access to the original string anyway.
private Uri? _baseUri;
Expand Down Expand Up @@ -214,6 +233,24 @@ private void NotFoundCore()
}
}

/// <summary>
/// Handles setting the Forbidden state.
/// </summary>
public void Forbidden() => ForbiddenCore();

private void ForbiddenCore()
{
if (_forbidden is null)
{
// global router doesn't exist, no events were registered
return;
}
else
{
_forbidden.Invoke(this, new ForbiddenEventArgs());
}
}

/// <summary>
/// Called to initialize BaseURI and current URI before these values are used for the first time.
/// Override <see cref="EnsureInitialized" /> and call this method to dynamically calculate these values.
Expand Down
8 changes: 8 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
#nullable enable
Microsoft.AspNetCore.Components.NavigationManager.Forbidden() -> void
Microsoft.AspNetCore.Components.NavigationManager.OnForbidden -> System.EventHandler<Microsoft.AspNetCore.Components.Routing.ForbiddenEventArgs!>!
Microsoft.AspNetCore.Components.Routing.ForbiddenEventArgs
Microsoft.AspNetCore.Components.Routing.ForbiddenEventArgs.ForbiddenEventArgs() -> void
Microsoft.AspNetCore.Components.Routing.ForbiddenEventArgs.Path.get -> string?
Microsoft.AspNetCore.Components.Routing.ForbiddenEventArgs.Path.set -> void
Microsoft.AspNetCore.Components.Routing.Router.ForbiddenPage.get -> System.Type?
Microsoft.AspNetCore.Components.Routing.Router.ForbiddenPage.set -> void
15 changes: 15 additions & 0 deletions src/Components/Components/src/Routing/ForbiddenEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Routing;

/// <summary>
/// <see cref="EventArgs" /> for <see cref="NavigationManager.OnForbidden" />.
/// </summary>
public sealed class ForbiddenEventArgs : EventArgs
{
/// <summary>
/// Gets the path of ForbiddenPage. If the path is set, it indicates that a subscriber has handled the rendering of the Forbidden contents.
/// </summary>
public string? Path { get; set; }
}
78 changes: 78 additions & 0 deletions src/Components/Components/src/Routing/Router.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ static readonly IReadOnlyDictionary<string, object> _emptyParametersDictionary
bool _navigationInterceptionEnabled;
ILogger<Router> _logger;
string _notFoundPageRoute;
string _forbiddenPageRoute;

private string _updateScrollPositionForHashLastLocation;
private bool _updateScrollPositionForHash;
Expand Down Expand Up @@ -81,6 +82,13 @@ static readonly IReadOnlyDictionary<string, object> _emptyParametersDictionary
[DynamicallyAccessedMembers(LinkerFlags.Component)]
public Type? NotFoundPage { get; set; }

/// <summary>
/// Gets or sets the page content to display when access to a page is forbidden.
/// </summary>
[Parameter]
[DynamicallyAccessedMembers(LinkerFlags.Component)]
public Type? ForbiddenPage { get; set; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong. The router should not be involved in this feature. It should all be part of AuthorizeRouteView. For starters we only want the ForbiddenPageType. We don't want a way to trigger Forbidden from Navigation manager.

@copilot redesign the fix based on these constraints. You also need to add E2E tests for it. See src/Components/Agents.md

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redesigned the implementation based on your feedback. The feature is now solely in AuthorizeRouteView with a NotAuthorizedPage property. All Router, NavigationManager, and endpoint changes have been reverted.

Key changes (commit 0fa988e):

  • Added NotAuthorizedPage parameter to AuthorizeRouteView that renders a specified component when authorization fails
  • Added IComponent validation with clear error message
  • Added unit tests and E2E tests for the new feature


/// <summary>
/// Gets or sets the content to display when a match is found for the requested route.
/// </summary>
Expand Down Expand Up @@ -117,6 +125,7 @@ public void Attach(RenderHandle renderHandle)
_locationAbsolute = NavigationManager.Uri;
NavigationManager.LocationChanged += OnLocationChanged;
NavigationManager.OnNotFound += OnNotFound;
NavigationManager.OnForbidden += OnForbidden;
RoutingStateProvider = ServiceProvider.GetService<IRoutingStateProvider>();

if (HotReloadManager.Default.MetadataUpdateSupported)
Expand Down Expand Up @@ -171,6 +180,28 @@ public async Task SetParametersAsync(ParameterView parameters)
}
}

if (ForbiddenPage != null)
{
if (!typeof(IComponent).IsAssignableFrom(ForbiddenPage))
{
throw new InvalidOperationException($"The type {ForbiddenPage.FullName} " +
$"does not implement {typeof(IComponent).FullName}.");
}

var routeAttributes = ForbiddenPage.GetCustomAttributes(typeof(RouteAttribute), inherit: true);
if (routeAttributes.Length == 0)
{
throw new InvalidOperationException($"The type {ForbiddenPage.FullName} " +
$"does not have a {typeof(RouteAttribute).FullName} applied to it.");
}

var routeAttribute = (RouteAttribute)routeAttributes[0];
if (routeAttribute.Template != null)
{
_forbiddenPageRoute = routeAttribute.Template;
}
}

if (!_onNavigateCalled)
{
_onNavigateCalled = true;
Expand All @@ -187,6 +218,7 @@ public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
NavigationManager.OnNotFound -= OnNotFound;
NavigationManager.OnForbidden -= OnForbidden;
if (HotReloadManager.Default.MetadataUpdateSupported)
{
HotReloadManager.Default.OnDeltaApplied -= ClearRouteCaches;
Expand Down Expand Up @@ -409,6 +441,26 @@ private void OnNotFound(object sender, NotFoundEventArgs args)
}
}

private void OnForbidden(object sender, ForbiddenEventArgs args)
{
bool renderContentIsProvided = ForbiddenPage != null || args.Path != null;
if (_renderHandle.IsInitialized && renderContentIsProvided)
{
if (!string.IsNullOrEmpty(args.Path))
{
// The path can be set by a subscriber not defined in blazor framework.
_renderHandle.Render(builder => RenderComponentByRoute(builder, args.Path));
}
else
{
// Having the path set signals to the endpoint renderer that router handled rendering.
args.Path = _forbiddenPageRoute;
RenderForbidden();
}
Log.DisplayingForbidden(_logger, args.Path);
}
}

internal void RenderComponentByRoute(RenderTreeBuilder builder, string route)
{
var componentType = FindComponentTypeByRoute(route);
Expand Down Expand Up @@ -466,6 +518,29 @@ private void RenderNotFound()
});
}

private void RenderForbidden()
{
_renderHandle.Render(builder =>
{
if (ForbiddenPage != null)
{
builder.OpenComponent<RouteView>(0);
builder.AddAttribute(1, nameof(RouteView.RouteData),
new RouteData(ForbiddenPage, _emptyParametersDictionary));
builder.CloseComponent();
}
else
{
DefaultForbiddenContent(builder);
}
});
}

private static void DefaultForbiddenContent(RenderTreeBuilder builder)
{
builder.AddContent(0, "Forbidden");
}

async Task IHandleAfterRender.OnAfterRenderAsync()
{
if (!_navigationInterceptionEnabled)
Expand Down Expand Up @@ -495,6 +570,9 @@ private static partial class Log

[LoggerMessage(4, LogLevel.Debug, $"Displaying contents of {{displayedContentPath}} on request", EventName = "DisplayingNotFoundOnRequest")]
internal static partial void DisplayingNotFound(ILogger logger, string displayedContentPath);

[LoggerMessage(5, LogLevel.Debug, $"Displaying contents of {{displayedContentPath}} on forbidden request", EventName = "DisplayingForbiddenOnRequest")]
internal static partial void DisplayingForbidden(ILogger logger, string displayedContentPath);
#pragma warning restore CS0618 // Type or member is obsolete
}
}
38 changes: 38 additions & 0 deletions src/Components/Components/test/NavigationManagerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,44 @@ public void OnNotFoundSubscriptionIsTriggeredWhenNotFoundCalled()
// Assert
Assert.True(notFoundTriggered, "The OnNotFound event was not triggered as expected.");
}

[Fact]
public void OnForbiddenSubscriptionIsTriggeredWhenForbiddenCalled()
{
// Arrange
var baseUri = "scheme://host/";
var testNavManager = new TestNavigationManager(baseUri);
bool forbiddenTriggered = false;
testNavManager.OnForbidden += (sender, args) => forbiddenTriggered = true;

// Simulate a component triggered Forbidden
testNavManager.Forbidden();

// Assert
Assert.True(forbiddenTriggered, "The OnForbidden event was not triggered as expected.");
}

[Fact]
public void OnForbiddenEventArgsPathCanBeSet()
{
// Arrange
var baseUri = "scheme://host/";
var testNavManager = new TestNavigationManager(baseUri);
ForbiddenEventArgs receivedArgs = null;

testNavManager.OnForbidden += (sender, args) =>
{
args.Path = "/forbidden-page";
receivedArgs = args;
};

// Simulate a component triggered Forbidden
testNavManager.Forbidden();

// Assert
Assert.NotNull(receivedArgs);
Assert.Equal("/forbidden-page", receivedArgs.Path);
}

private class TestNavigationManager : NavigationManager
{
Expand Down
Loading
Loading