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
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions src/Way.Shell.Demo/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down Expand Up @@ -58,16 +59,18 @@

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();

/// <summary>What a Way library's Use…Way() does: mount the shell with its module and map its API on the group.</summary>
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());
Expand Down
6 changes: 6 additions & 0 deletions src/Way.Shell.Demo/e2e/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')));
Expand Down
50 changes: 50 additions & 0 deletions src/Way.Shell.Tests/ShellPageTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -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());
Expand All @@ -66,6 +69,7 @@ public async Task Settings_CarryTheHostOptions()
{
o.Title = "My <app>";
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" };
Expand All @@ -78,6 +82,7 @@ public async Task Settings_CarryTheHostOptions()
Assert.Contains("""<html lang="pl">""", html);
Assert.Equal("My <app>", 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());
Expand Down Expand Up @@ -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($"""<base href="{baseHref}">""", 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()
{
Expand Down
2 changes: 2 additions & 0 deletions src/Way.Shell/.nuget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions src/Way.Shell/ShellEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,18 +60,26 @@ public RouteGroupBuilder MapWay(WayModule module, string path)
/// <summary>
/// Mounts the panel at <paramref name="path"/>: 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. <c>app.UseWayShell("/admin").RequireAuthorization("Admin")</c>.
/// e.g. <c>app.UseWayShell("/admin").RequireAuthorization("Admin")</c>. At <c>/</c> the panel is the app's home
/// page and answers only there; every other address stays the host's.
/// </summary>
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;
}
}
Expand Down Expand Up @@ -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)
/// <summary><c>/segments</c> without a trailing slash; the root is <c>""</c> where <paramref name="allowRoot"/>.</summary>
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));
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/Way.Shell/ShellOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public sealed class ShellOptions
/// <summary>Language of the UI, e.g. <c>en</c> or <c>pl</c>. Default: the browser's.</summary>
public string? Language { get; set; }

/// <summary>
/// A note shown above every Way, e.g. that the app is a public demo whose data is reset. No note when not set.
/// </summary>
public string? Notice { get; set; }

/// <summary>Links of the host, at the bottom of the shell's menu.</summary>
public IList<ShellLink> Links { get; } = [];

Expand Down
4 changes: 4 additions & 0 deletions src/Way.Shell/ShellPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ private static ShellSettings Settings(HttpContext context, IReadOnlyList<Registe
return new ShellSettings(
Title: options.Title ?? defaultTitle,
HomeUrl: Resolve(options.HomeUrl),
Notice: options.Notice,
Language: options.Language,
Links: [.. options.Links.Concat(options.DynamicLinks.SelectMany(links => 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) },
Expand All @@ -75,6 +76,7 @@ private static ShellSettings Settings(HttpContext context, IReadOnlyList<Registe
Id: w.Module.Id,
Name: w.Module.Name,
Icon: w.Module.Icon,
Hidden: w.Module.IsHidden,
Url: $"{pathBase}{w.Path}/",
ApiBase: $"{pathBase}{w.Path}/api",
ModuleUrl: $"{pathBase}{w.Path}/{ShellEndpoints.ModulePath}/way.js?v={w.Version}",
Expand Down Expand Up @@ -120,6 +122,7 @@ private sealed record ShellManifest(Dictionary<string, string> Imports, string E
internal sealed record ShellSettings(
string Title,
string? HomeUrl,
string? Notice,
string? Language,
ShellLink[] Links,
ShellUser? User,
Expand All @@ -131,6 +134,7 @@ internal sealed record WayInfo(
string Id,
string Name,
string? Icon,
bool Hidden,
string Url,
string ApiBase,
string ModuleUrl,
Expand Down
6 changes: 6 additions & 0 deletions src/Way.Shell/WayModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ public WayModule(string id, string name, Assembly assembly, string resourceRoot)
/// </summary>
public bool IsSensitive { get; init; }

/// <summary>
/// The panel does not list the Way in its menu; it still opens at <c>#/{id}/</c>, e.g. from a link in the host's
/// options (<c>~/#/account/</c> as <see cref="ShellUser.AccountUrl"/>) or from another Way.
/// </summary>
public bool IsHidden { get; init; }

[GeneratedRegex("^[a-z][a-z0-9-]*$")]
private static partial Regex IdPattern();
}
10 changes: 8 additions & 2 deletions src/way-shell/src/app/ShellApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ThemeProvider theme={theme}>
Expand Down Expand Up @@ -132,15 +133,20 @@ export function ShellApp({ settings }: { settings: ShellSettings }) {
</Toolbar>
</AppBar>
)}
{settings.notice && (
<Alert severity="info" variant="outlined" role="note" sx={{ m: 2, mb: 0, flexShrink: 0 }} data-testid="shell-notice">
{settings.notice}
</Alert>
)}
<Routes>
{panel ? (
<>
<Route path="/:wayId/*" element={<PanelWay settings={settings} themeMode={themeMode} language={language} />} />
<Route
path="*"
element={
settings.ways.length ? (
<Navigate to={`/${settings.ways[0].id}/`} replace />
home ? (
<Navigate to={`/${home.id}/`} replace />
) : (
<Alert severity="info" sx={{ m: 3 }}>{shellText(language, 'nothingToOpen')}</Alert>
)
Expand Down
2 changes: 1 addition & 1 deletion src/way-shell/src/app/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function WayList({ settings, onNavigate }: { settings: ShellSettings; onNavigate
const current = useLocation().pathname.split('/')[1];
return (
<List component="nav" dense sx={{ py: 1, flexShrink: 0 }}>
{settings.ways.map((w) => {
{settings.ways.filter((w) => !w.hidden).map((w) => {
const Icon = iconFor(w.icon);
const active = w.id === current;
return (
Expand Down
4 changes: 4 additions & 0 deletions src/way-shell/src/app/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading