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;