diff --git a/docs/architecture.md b/docs/architecture.md index 62a51b3..3edd8f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -210,6 +210,12 @@ Cel: potwierdzić, że podejście działa, zanim cokolwiek ruszymy w Way. - RunWay: zadania zawsze posortowane (na końcu po id, stabilne stronicowanie), relacje zadanie ↔ cykl skonfigurowane jawnie zamiast `[ForeignKey]` po obu stronach; model bez zmian wobec migracji, bez ostrzeżeń EF przy starcie. - Wszystkie dema w Production (`--no-launch-profile`) w Chrome, Edge, Firefoxie i WebKicie: Shell 37/37, ConfigWay 14/14, CultureWay 17/17, RunWay 17/17, Showcase 32/32. +- **2.1.0 (2026-10-17, gałąź `feat/shell-2.1`), dla Polyglota jako jednej aplikacji w panelu:** + - `ShellOptions.Notice`: notka nad każdym Way (panel i strona Way), np. „publiczne demo”. + - `WayModule.IsHidden`: Way bez pozycji w menu panelu, otwierany pod `#/{id}/` (np. konto z linku pod nazwą użytkownika); panel przekierowuje na pierwszy widoczny Way. + - `UseWayShell("/")`: panel w korzeniu aplikacji. Konwencja na grupie zawęża trasę catch-all Reiho do samego `/` (pliki panelu i tak idą z `AssetsPath`); bez tego każdy nieznany adres dostawał 404 panelu albo przekierowanie na logowanie (klient API z kluczem: 302 zamiast 404). Inne endpointy hosta odpowiadają jak dotąd. `MapWay` w korzeniu dalej zabronione. + - Kontrakt modułu bez zmian (2). Testy: 53 × 3 TFM, npm 31; demo Shella (notka, ukryty Way „Account”) 40/40 × 4 przeglądarki. + ### Faza 7: wydanie - Dokumentacja (README Shella, sekcje „Using with other Ways” w README Way, przewodnik migracji opcji). - Reiho 2.1.0 przed Shellem. Potem jednym rzutem 2.0.0: Way.Shell → ConfigWay → CultureWay → RunWay → LogWay; następnie hosty (Polyglot, Logle) na nowych paczkach. diff --git a/src/Way.Shell.Demo/Program.cs b/src/Way.Shell.Demo/Program.cs index 56416ee..1b0b3be 100644 --- a/src/Way.Shell.Demo/Program.cs +++ b/src/Way.Shell.Demo/Program.cs @@ -11,10 +11,11 @@ { o.Title = "Way.Shell demo"; o.HomeUrl = "~/"; + o.Notice = "A public demo: sign in as anyone from the home page."; o.Links.Add(new ShellLink("Home", "~/") { Icon = "home" }); o.Links.Add(new ShellLink("GitHub", "https://github.com/kododo-dev/WayShell")); o.User = context => context.User.Identity is { IsAuthenticated: true, Name: { } name } - ? new ShellUser(name) { SignOutUrl = "~/logout" } + ? new ShellUser(name) { AccountUrl = "~/admin/#/account/", SignOutUrl = "~/logout" } : null; }); @@ -58,6 +59,8 @@ app.UseDemoWay("hello", "Hello", "/hello", "dashboard").AllowAnonymous(); app.UseDemoWay("reports", "Reports", "/reports", "logs").RequireAuthorization("Admin"); +// Not in the panel's menu: the user's name links to it. +app.UseDemoWay("account", "Account", "/account", "people", hidden: true).RequireAuthorization(); app.UseWayShell("/admin").RequireAuthorization(); app.Run(); @@ -65,9 +68,9 @@ /// What a Way library's Use…Way() does: mount the shell with its module and map its API on the group. internal static class DemoWay { - public static RouteGroupBuilder UseDemoWay(this IEndpointRouteBuilder endpoints, string id, string name, string path, string icon) + public static RouteGroupBuilder UseDemoWay(this IEndpointRouteBuilder endpoints, string id, string name, string path, string icon, bool hidden = false) { - var group = endpoints.MapWay(new WayModule(id, name, typeof(DemoWay).Assembly, "UI/dist") { Icon = icon }, path); + var group = endpoints.MapWay(new WayModule(id, name, typeof(DemoWay).Assembly, "UI/dist") { Icon = icon, IsHidden = hidden }, path); // As Reiho maps requests: POST {path}/api/{Request}. group.MapPost("/api/Hello", (HttpContext c) => new { way = id, user = c.User.Identity?.Name, time = DateTimeOffset.Now.ToString("T") }); group.MapPost("/api/Expire", () => Results.Unauthorized()); diff --git a/src/Way.Shell.Demo/e2e/check.mjs b/src/Way.Shell.Demo/e2e/check.mjs index f567c8d..0a6a26f 100644 --- a/src/Way.Shell.Demo/e2e/check.mjs +++ b/src/Way.Shell.Demo/e2e/check.mjs @@ -40,6 +40,12 @@ const menuOf = (page) => page.locator('nav .MuiListItemText-primary').allTextCon record('page header of the module', await visible(page.getByRole('heading', { name: 'Demo: hello' }))); record('user at the bottom of the menu', await visible(page.getByTestId('shell-user').filter({ hasText: 'bob' }))); record('host links in the menu', await visible(page.getByRole('link', { name: 'GitHub' }))); + record('notice above the Way', await visible(page.getByTestId('shell-notice').filter({ hasText: 'A public demo' }))); + await page.getByTestId('shell-user').getByRole('link', { name: 'bob' }).click(); + record('hidden Way opens from the user link', (await apiOk(page, 'account')) && page.url().endsWith('#/account/'), page.url()); + record('hidden Way is not in the menu', (await menuOf(page)).join(',') === 'Hello,Reports', (await menuOf(page)).join(',')); + await page.goto(`${base}/admin/#/hello/`); + await apiOk(page, 'hello'); const wayMenu = page.getByTestId('demo-menu'); record("Way's own items in the side menu", await visible(wayMenu.getByText('Overview'))); diff --git a/src/Way.Shell.Tests/ShellPageTests.cs b/src/Way.Shell.Tests/ShellPageTests.cs index 9663e1f..809636f 100644 --- a/src/Way.Shell.Tests/ShellPageTests.cs +++ b/src/Way.Shell.Tests/ShellPageTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -53,6 +54,8 @@ public async Task Settings_DescribeTheWayAndItsModule() Assert.Equal("config", settings.GetProperty("wayId").GetString()); Assert.Equal("Config", settings.GetProperty("title").GetString()); Assert.Equal("config", way.GetProperty("id").GetString()); + Assert.False(way.GetProperty("hidden").GetBoolean()); + Assert.Equal(JsonValueKind.Null, settings.GetProperty("notice").ValueKind); Assert.Equal("/config/", way.GetProperty("url").GetString()); Assert.Equal("/config/api", way.GetProperty("apiBase").GetString()); Assert.Matches("^/config/_way/way.js\\?v=[0-9a-f]{12}$", way.GetProperty("moduleUrl").GetString()); @@ -66,6 +69,7 @@ public async Task Settings_CarryTheHostOptions() { o.Title = "My "; o.HomeUrl = "/"; + o.Notice = "A public demo"; o.Language = "pl"; o.Links.Add(new ShellLink("Docs", "/docs") { Icon = "home" }); o.User = _ => new ShellUser("Ada") { AccountUrl = "/account", SignOutUrl = "/logout" }; @@ -78,6 +82,7 @@ public async Task Settings_CarryTheHostOptions() Assert.Contains("""""", html); Assert.Equal("My ", settings.GetProperty("title").GetString()); Assert.Equal("/", settings.GetProperty("homeUrl").GetString()); + Assert.Equal("A public demo", settings.GetProperty("notice").GetString()); Assert.Equal("Docs", settings.GetProperty("links")[0].GetProperty("title").GetString()); Assert.Equal("home", settings.GetProperty("links")[0].GetProperty("icon").GetString()); Assert.Equal("/account", settings.GetProperty("user").GetProperty("accountUrl").GetString()); @@ -246,6 +251,51 @@ public async Task UnknownFile_UnderAWay_Is404() Assert.Equal(HttpStatusCode.NotFound, (await app.GetAsync("/config/_way/nothing.js")).StatusCode); } + [Fact] + public async Task HiddenWay_IsMarked_ForThePanelsMenu() + { + await using var app = await ShellApp.StartAsync(app => + { + app.MapWay(ShellApp.Module("config", "Config"), "/config").AllowAnonymous(); + app.MapWay(ShellApp.Module("account", "Account") with { IsHidden = true }, "/account").AllowAnonymous(); + app.UseWayShell("/admin").AllowAnonymous(); + }); + + var ways = ShellApp.SettingsOf(await (await app.GetAsync("/admin/")).Content.ReadAsStringAsync()).GetProperty("ways"); + + Assert.False(ways[0].GetProperty("hidden").GetBoolean()); + Assert.True(ways[1].GetProperty("hidden").GetBoolean()); + } + + [Theory] + [InlineData(null, "/")] + [InlineData("/app", "/app/")] + public async Task Panel_AtTheRoot_IsTheHomePage_NextToTheHostsEndpoints(string? pathBase, string baseHref) + { + await using var app = await ShellApp.StartAsync(app => + { + app.MapGet("/login", () => "sign in"); + app.MapWay(ShellApp.Module("config", "Config"), "/config").AllowAnonymous(); + app.UseWayShell("/").RequireAuthorization(); + }, pathBase: pathBase); + var prefix = pathBase ?? ""; + + var response = await app.GetAsync($"{prefix}/", user: "ada"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains($"""""", html); + Assert.Equal("panel", ShellApp.SettingsOf(html).GetProperty("mode").GetString()); + Assert.Equal($"{prefix}/config/api", ShellApp.SettingsOf(html).GetProperty("ways")[0].GetProperty("apiBase").GetString()); + Assert.Equal("sign in", await (await app.GetAsync($"{prefix}/login")).Content.ReadAsStringAsync()); + Assert.Equal("single", ShellApp.SettingsOf(await (await app.GetAsync($"{prefix}/config/")).Content.ReadAsStringAsync()).GetProperty("mode").GetString()); + // Only the page: addresses the host does not map stay 404, not the panel's sign-in. + Assert.Equal(HttpStatusCode.Unauthorized, (await app.GetAsync($"{prefix}/")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await app.GetAsync($"{prefix}/nothing")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await app.GetAsync($"{prefix}/index.html", user: "ada")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await app.Client.PostAsync($"{prefix}/nothing", null)).StatusCode); + } + [Fact] public void SameWay_CannotBeMountedTwice() { diff --git a/src/Way.Shell/.nuget/README.md b/src/Way.Shell/.nuget/README.md index d405b2d..ec2a634 100644 --- a/src/Way.Shell/.nuget/README.md +++ b/src/Way.Shell/.nuget/README.md @@ -26,6 +26,8 @@ app.UseCultureWay("/translations").RequireAuthorization(); app.UseWayShell("/admin").RequireAuthorization(); // lists the Ways the user may open ``` +The panel can also be the app's home page, `app.UseWayShell("/")`: it answers only at `/` itself, so every other address stays the app's. `ShellOptions.Notice` shows a note above every Way (for example on a public demo), and a Way with `IsHidden = true` opens in the panel without an item in its menu, e.g. an account page the user's name links to (`AccountUrl = "~/#/account/"`). + Outside the Development environment a Way or the panel answers `403` until you decide on its authorization: `RequireAuthorization(...)`, `AllowAnonymous()`, a fallback policy, or `ShellOptions.AllowAnonymous` for public demos. Undecided ones are reported in the log at startup. ## In a Way library diff --git a/src/Way.Shell/ShellEndpoints.cs b/src/Way.Shell/ShellEndpoints.cs index f5f3f66..da4bd88 100644 --- a/src/Way.Shell/ShellEndpoints.cs +++ b/src/Way.Shell/ShellEndpoints.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -59,18 +60,26 @@ public RouteGroupBuilder MapWay(WayModule module, string path) /// /// Mounts the panel at : one page with every Way mounted in the app that the current /// user may open, as decided by the authorization on each Way. Protect the panel itself on the returned group, - /// e.g. app.UseWayShell("/admin").RequireAuthorization("Admin"). + /// e.g. app.UseWayShell("/admin").RequireAuthorization("Admin"). At / the panel is the app's home + /// page and answers only there; every other address stays the host's. /// public RouteGroupBuilder UseWayShell(string path = "/admin") { var registry = Registry(endpoints); MapAssets(endpoints, registry); - var panelPath = Normalize(path); - var group = endpoints.MapGroup(panelPath).WithMetadata(new ShellPanelMetadata()); + var panelPath = Normalize(path, allowRoot: true); + var group = endpoints.MapGroup(panelPath.Length == 0 ? "/" : panelPath).WithMetadata(new ShellPanelMetadata()); ShellAccess.Protect(group, "The Way.Shell panel"); group.MapEmbeddedSpa(typeof(ShellEndpoints).Assembly, o => ShellPage.Configure(o, panelPath, context => VisibleWays(context, registry, current: null), _ => "Admin", "panel", wayId: null)); + if (panelPath.Length == 0) + // The page is all the panel serves (its files come from AssetsPath): at the root, Reiho's catch-all would + // answer every address the host does not map, with the panel's 404 or its sign-in instead of the host's. + ((IEndpointConventionBuilder)group).Add(endpoint => + { + if (endpoint is RouteEndpointBuilder route) route.RoutePattern = RoutePatternFactory.Parse("/"); + }); return group; } } @@ -102,11 +111,13 @@ private static void MapAssets(IEndpointRouteBuilder endpoints, WayRegistry regis .MapEmbeddedSpa(typeof(ShellEndpoints).Assembly, o => o.RootPath = ShellPage.AssetsRootPath); } - internal static string Normalize(string path) + /// /segments without a trailing slash; the root is "" where . + internal static string Normalize(string path, bool allowRoot = false) { ArgumentException.ThrowIfNullOrWhiteSpace(path); var trimmed = "/" + path.Trim().Trim('/'); - return trimmed == "/" ? throw new ArgumentException("A Way cannot be mounted at the root of the app.", nameof(path)) : trimmed; + if (trimmed != "/") return trimmed; + return allowRoot ? "" : throw new ArgumentException("A Way cannot be mounted at the root of the app.", nameof(path)); } } diff --git a/src/Way.Shell/ShellOptions.cs b/src/Way.Shell/ShellOptions.cs index c818472..17a2141 100644 --- a/src/Way.Shell/ShellOptions.cs +++ b/src/Way.Shell/ShellOptions.cs @@ -17,6 +17,11 @@ public sealed class ShellOptions /// Language of the UI, e.g. en or pl. Default: the browser's. public string? Language { get; set; } + /// + /// A note shown above every Way, e.g. that the app is a public demo whose data is reset. No note when not set. + /// + public string? Notice { get; set; } + /// Links of the host, at the bottom of the shell's menu. public IList Links { get; } = []; diff --git a/src/Way.Shell/ShellPage.cs b/src/Way.Shell/ShellPage.cs index bad6b54..b1ca9e0 100644 --- a/src/Way.Shell/ShellPage.cs +++ b/src/Way.Shell/ShellPage.cs @@ -66,6 +66,7 @@ private static ShellSettings Settings(HttpContext context, IReadOnlyList links(context))).Select(l => l with { Url = Resolve(l.Url)! })], User: user is null ? null : user with { AccountUrl = Resolve(user.AccountUrl), SignOutUrl = Resolve(user.SignOutUrl) }, @@ -75,6 +76,7 @@ private static ShellSettings Settings(HttpContext context, IReadOnlyList Imports, string E internal sealed record ShellSettings( string Title, string? HomeUrl, + string? Notice, string? Language, ShellLink[] Links, ShellUser? User, @@ -131,6 +134,7 @@ internal sealed record WayInfo( string Id, string Name, string? Icon, + bool Hidden, string Url, string ApiBase, string ModuleUrl, diff --git a/src/Way.Shell/WayModule.cs b/src/Way.Shell/WayModule.cs index 9411ae2..47a2127 100644 --- a/src/Way.Shell/WayModule.cs +++ b/src/Way.Shell/WayModule.cs @@ -48,6 +48,12 @@ public WayModule(string id, string name, Assembly assembly, string resourceRoot) /// public bool IsSensitive { get; init; } + /// + /// The panel does not list the Way in its menu; it still opens at #/{id}/, e.g. from a link in the host's + /// options (~/#/account/ as ) or from another Way. + /// + public bool IsHidden { get; init; } + [GeneratedRegex("^[a-z][a-z0-9-]*$")] private static partial Regex IdPattern(); } diff --git a/src/way-shell/src/app/ShellApp.tsx b/src/way-shell/src/app/ShellApp.tsx index d408643..9d76540 100644 --- a/src/way-shell/src/app/ShellApp.tsx +++ b/src/way-shell/src/app/ShellApp.tsx @@ -102,6 +102,7 @@ export function ShellApp({ settings }: { settings: ShellSettings }) { ); const paper = { width: DRAWER_WIDTH, boxSizing: 'border-box' as const, bgcolor: 'background.paper' }; const panel = settings.mode === 'panel'; + const home = settings.ways.find((w) => !w.hidden) ?? settings.ways[0]; return ( @@ -132,6 +133,11 @@ export function ShellApp({ settings }: { settings: ShellSettings }) { )} + {settings.notice && ( + + {settings.notice} + + )} {panel ? ( <> @@ -139,8 +145,8 @@ export function ShellApp({ settings }: { settings: ShellSettings }) { + home ? ( + ) : ( {shellText(language, 'nothingToOpen')} ) diff --git a/src/way-shell/src/app/SideMenu.tsx b/src/way-shell/src/app/SideMenu.tsx index 758ddcb..9c5bebf 100644 --- a/src/way-shell/src/app/SideMenu.tsx +++ b/src/way-shell/src/app/SideMenu.tsx @@ -58,7 +58,7 @@ function WayList({ settings, onNavigate }: { settings: ShellSettings; onNavigate const current = useLocation().pathname.split('/')[1]; return ( - {settings.ways.map((w) => { + {settings.ways.filter((w) => !w.hidden).map((w) => { const Icon = iconFor(w.icon); const active = w.id === current; return ( diff --git a/src/way-shell/src/app/settings.ts b/src/way-shell/src/app/settings.ts index 33b7587..f3a495c 100644 --- a/src/way-shell/src/app/settings.ts +++ b/src/way-shell/src/app/settings.ts @@ -6,6 +6,8 @@ export interface WayInfo { name: string; /** Name of a Material icon for the panel's menu, e.g. `Settings`. */ icon: string | null; + /** Not listed in the panel's menu, still opened at `#/{id}/`. */ + hidden?: boolean; /** The Way's own page (single mode), with the app's path base. */ url: string; apiBase: string; @@ -27,6 +29,8 @@ export interface ShellLink { export interface ShellSettings { title: string; homeUrl: string | null; + /** A note above every Way, e.g. that the app is a public demo. */ + notice?: string | null; language: string | null; links: ShellLink[]; user: ShellUser | null;