diff --git a/src/Components/Authorization/src/AuthorizeRouteView.cs b/src/Components/Authorization/src/AuthorizeRouteView.cs index f44afadff3ea..2521957b86d7 100644 --- a/src/Components/Authorization/src/AuthorizeRouteView.cs +++ b/src/Components/Authorization/src/AuthorizeRouteView.cs @@ -51,6 +51,14 @@ public AuthorizeRouteView() [Parameter] public RenderFragment? NotAuthorized { get; set; } + /// + /// The page type that will be displayed if the user is not authorized. + /// The page type must implement . + /// + [Parameter] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] + public Type? NotAuthorizedPage { get; set; } + /// /// The content that will be displayed while asynchronous authorization is in progress. /// @@ -111,8 +119,39 @@ private void RenderContentInDefaultLayout(RenderTreeBuilder builder, RenderFragm private void RenderNotAuthorizedInDefaultLayout(RenderTreeBuilder builder, AuthenticationState authenticationState) { - var content = NotAuthorized ?? _defaultNotAuthorizedContent; - RenderContentInDefaultLayout(builder, content(authenticationState)); + if (NotAuthorizedPage is not null) + { + if (!typeof(IComponent).IsAssignableFrom(NotAuthorizedPage)) + { + throw new InvalidOperationException($"The type {NotAuthorizedPage.FullName} " + + $"does not implement {typeof(IComponent).FullName}."); + } + + RenderPageInDefaultLayout(builder, NotAuthorizedPage); + } + else + { + var content = NotAuthorized ?? _defaultNotAuthorizedContent; + RenderContentInDefaultLayout(builder, content(authenticationState)); + } + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2111:RequiresUnreferencedCode", + Justification = "OpenComponent already has the right set of attributes")] + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2110:RequiresUnreferencedCode", + Justification = "OpenComponent already has the right set of attributes")] + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2118:RequiresUnreferencedCode", + Justification = "OpenComponent already has the right set of attributes")] + private void RenderPageInDefaultLayout(RenderTreeBuilder builder, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type pageType) + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(LayoutView.Layout), DefaultLayout); + builder.AddComponentParameter(2, nameof(LayoutView.ChildContent), (RenderFragment)(pageBuilder => + { + pageBuilder.OpenComponent(0, pageType); + pageBuilder.CloseComponent(); + })); + builder.CloseComponent(); } private void RenderAuthorizingInDefaultLayout(RenderTreeBuilder builder) diff --git a/src/Components/Authorization/src/PublicAPI.Unshipped.txt b/src/Components/Authorization/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..dc6a95dda497 100644 --- a/src/Components/Authorization/src/PublicAPI.Unshipped.txt +++ b/src/Components/Authorization/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Components.Authorization.AuthorizeRouteView.NotAuthorizedPage.get -> System.Type? +Microsoft.AspNetCore.Components.Authorization.AuthorizeRouteView.NotAuthorizedPage.set -> void diff --git a/src/Components/Authorization/test/AuthorizeRouteViewTest.cs b/src/Components/Authorization/test/AuthorizeRouteViewTest.cs index 912301e8b893..678a01521bdf 100644 --- a/src/Components/Authorization/test/AuthorizeRouteViewTest.cs +++ b/src/Components/Authorization/test/AuthorizeRouteViewTest.cs @@ -367,6 +367,95 @@ public void UpdatesOutputWhenRouteDataChanges() }); } + [Fact] + public void WhenNotAuthorized_RendersNotAuthorizedPageInsideLayout() + { + // Arrange + var routeData = new RouteData(typeof(TestPageRequiringAuthorization), EmptyParametersDictionary); + _testAuthorizationService.NextResult = AuthorizationResult.Failed(); + + // Act + _renderer.RenderRootComponent(_authorizeRouteViewComponentId, ParameterView.FromDictionary(new Dictionary + { + { nameof(AuthorizeRouteView.RouteData), routeData }, + { nameof(AuthorizeRouteView.DefaultLayout), typeof(TestLayout) }, + { nameof(AuthorizeRouteView.NotAuthorizedPage), typeof(TestNotAuthorizedPage) }, + })); + + // Assert: renders layout containing the NotAuthorizedPage component + var batch = _renderer.Batches.Single(); + var layoutDiff = batch.GetComponentDiffs().Single(); + Assert.Collection(layoutDiff.Edits, + edit => AssertPrependText(batch, edit, "Layout starts here"), + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Component(batch.ReferenceFrames[edit.ReferenceFrameIndex]); + }, + edit => AssertPrependText(batch, edit, "Layout ends here")); + + // Assert: renders the not authorized page content + var pageDiff = batch.GetComponentDiffs().Single(); + Assert.Collection(pageDiff.Edits, + edit => AssertPrependText(batch, edit, "This is the not authorized page")); + } + + [Fact] + public void WhenNotAuthorized_NotAuthorizedPageTakesPriorityOverNotAuthorizedContent() + { + // Arrange: set both NotAuthorizedPage and NotAuthorized content + var routeData = new RouteData(typeof(TestPageRequiringAuthorization), EmptyParametersDictionary); + _testAuthorizationService.NextResult = AuthorizationResult.Failed(); + _authenticationStateProvider.CurrentAuthStateTask = Task.FromResult(new AuthenticationState( + new ClaimsPrincipal(new TestIdentity { Name = "Bert" }))); + + RenderFragment customNotAuthorized = + state => builder => builder.AddContent(0, $"Go away, {state.User.Identity.Name}"); + + // Act + _renderer.RenderRootComponent(_authorizeRouteViewComponentId, ParameterView.FromDictionary(new Dictionary + { + { nameof(AuthorizeRouteView.RouteData), routeData }, + { nameof(AuthorizeRouteView.DefaultLayout), typeof(TestLayout) }, + { nameof(AuthorizeRouteView.NotAuthorizedPage), typeof(TestNotAuthorizedPage) }, + { nameof(AuthorizeRouteView.NotAuthorized), customNotAuthorized }, + })); + + // Assert: renders layout containing the NotAuthorizedPage component (not the custom content) + var batch = _renderer.Batches.Single(); + var layoutDiff = batch.GetComponentDiffs().Single(); + Assert.Collection(layoutDiff.Edits, + edit => AssertPrependText(batch, edit, "Layout starts here"), + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Component(batch.ReferenceFrames[edit.ReferenceFrameIndex]); + }, + edit => AssertPrependText(batch, edit, "Layout ends here")); + } + + [Fact] + public void WhenNotAuthorized_ThrowsForInvalidNotAuthorizedPageType() + { + // Arrange: set NotAuthorizedPage to a type that doesn't implement IComponent + var routeData = new RouteData(typeof(TestPageRequiringAuthorization), EmptyParametersDictionary); + _testAuthorizationService.NextResult = AuthorizationResult.Failed(); + + // Act & Assert + var exception = Assert.Throws(() => + { + _renderer.RenderRootComponent(_authorizeRouteViewComponentId, ParameterView.FromDictionary(new Dictionary + { + { nameof(AuthorizeRouteView.RouteData), routeData }, + { nameof(AuthorizeRouteView.DefaultLayout), typeof(TestLayout) }, + { nameof(AuthorizeRouteView.NotAuthorizedPage), typeof(string) }, // string doesn't implement IComponent + })); + }); + + Assert.Contains("does not implement", exception.Message); + Assert.Contains("IComponent", exception.Message); + } + private static void AssertPrependText(CapturedBatch batch, RenderTreeEdit edit, string text) { Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); @@ -387,6 +476,14 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } + class TestNotAuthorizedPage : ComponentBase + { + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, "This is the not authorized page"); + } + } + class TestLayout : LayoutComponentBase { protected override void BuildRenderTree(RenderTreeBuilder builder) diff --git a/src/Components/test/E2ETest/Tests/AuthTest.cs b/src/Components/test/E2ETest/Tests/AuthTest.cs index e1f0fc12cada..519a9196e009 100644 --- a/src/Components/test/E2ETest/Tests/AuthTest.cs +++ b/src/Components/test/E2ETest/Tests/AuthTest.cs @@ -220,6 +220,16 @@ public void Router_RequireRole_NotAuthorized() AssertExpectedLayoutUsed(); } + [Fact] + public void AuthorizeRouteView_NotAuthorizedPage_DisplaysPage() + { + SignInAs(null, null); + var appElement = MountAndNavigateToAuthTestWithNotAuthorizedPage(PageRequiringAuthorization); + Browser.Equal("You are not authorized to access this resource. This is the NotAuthorizedPage component.", () => + appElement.FindElement(By.CssSelector("#not-authorized-page-content")).Text); + AssertExpectedLayoutUsed(); + } + private void AssertExpectedLayoutUsed() { Browser.Exists(By.Id("auth-links")); @@ -234,6 +244,15 @@ protected IWebElement MountAndNavigateToAuthTest(string authLinkText) return appElement; } + protected IWebElement MountAndNavigateToAuthTestWithNotAuthorizedPage(string authLinkText) + { + Navigate(ServerPathBase); + var appElement = Browser.MountTestComponent(); + Browser.Exists(By.Id("auth-links")); + appElement.FindElement(By.LinkText(authLinkText)).Click(); + return appElement; + } + private void SignInAs(string userName, string roles, bool useSeparateTab = false) => Browser.SignInAs(new Uri(_serverFixture.RootUri, "/subdir"), userName, roles, useSeparateTab); } diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthHomeWithNotAuthorizedPage.razor b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthHomeWithNotAuthorizedPage.razor new file mode 100644 index 000000000000..bc0b0f6b7175 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthHomeWithNotAuthorizedPage.razor @@ -0,0 +1,7 @@ +@page "/AuthHomeWithNotAuthorizedPage" + +

This router uses NotAuthorizedPage for unauthorized access. Select an auth test below.

+ + diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouterWithNotAuthorizedPage.razor b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouterWithNotAuthorizedPage.razor new file mode 100644 index 000000000000..a7a67daeaad5 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouterWithNotAuthorizedPage.razor @@ -0,0 +1,33 @@ +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Routing +@inject NavigationManager NavigationManager + +@* + This router is independent of any other router that may exist within the same project. + It exists to test the AuthorizeRouteView.NotAuthorizedPage feature. +*@ + + + + + Authorizing... + + + +

There's nothing here

+
+
+ +@code { + protected override void OnInitialized() + { + // Start at AuthHomeWithNotAuthorizedPage, not at any other component in the same app + var absoluteUriPath = new Uri(NavigationManager.Uri).GetLeftPart(UriPartial.Path); + var relativeUri = NavigationManager.ToBaseRelativePath(absoluteUriPath); + if (relativeUri == string.Empty) + { + NavigationManager.NavigateTo("AuthHomeWithNotAuthorizedPage"); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/NotAuthorizedPage.razor b/src/Components/test/testassets/BasicTestApp/AuthTest/NotAuthorizedPage.razor new file mode 100644 index 000000000000..6d78e4dddcd4 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/AuthTest/NotAuthorizedPage.razor @@ -0,0 +1,4 @@ +@page "/not-authorized-page" +
+ You are not authorized to access this resource. This is the NotAuthorizedPage component. +
diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index f9cc718f7ca1..4b872f8a7adc 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -11,6 +11,7 @@ +