DbFactory
+@inject RegionHR.Web.Services.UnitScopeService Scope
OpenHR - Tidrapporter
@@ -77,7 +78,17 @@ else
await using var db = await DbFactory.CreateDbContextAsync();
try
{
- var timesheets = await db.Timesheets
+ // Enhets-scoping: en chef ser bara sin egen enhets tidrapporter; HR/Admin ser alla.
+ // Fail-closed — kan chefens enhet inte härledas (tom mängd) visas inga tidrapporter.
+ var scope = await Scope.GetEmployeeIdsInScopeAsync();
+ IQueryable tsQuery = db.Timesheets;
+ if (scope is not null)
+ {
+ var scopeIds = scope.ToList();
+ tsQuery = tsQuery.Where(t => scopeIds.Contains(t.AnstallId));
+ }
+
+ var timesheets = await tsQuery
.OrderByDescending(t => t.Ar).ThenByDescending(t => t.Manad)
.Take(50).ToListAsync();
diff --git a/src/Web/Services/RouteAccessPolicy.cs b/src/Web/Services/RouteAccessPolicy.cs
index 471ee34..f748673 100644
--- a/src/Web/Services/RouteAccessPolicy.cs
+++ b/src/Web/Services/RouteAccessPolicy.cs
@@ -30,8 +30,13 @@ public static class RouteAccessPolicy
///
/// Öppna rutter (även oinloggade). Endast dessa nås utan session.
+ /// /auth = federerad inloggnings-landning (OIDC-completion sker innan sessionen
+ /// finns; sidan ligger dessutom på EmptyLayout utanför behörighetsvakten).
+ /// /utbildning/extern = publik utbildningsportal för externa deltagare där en
+ /// personlig token är bäraren (ingen inloggning) — policyn speglar det så att den
+ /// förblir en sann single-source-of-truth.
///
- private static readonly string[] OpenRoutes = { "/login", "/trust", "/error" };
+ private static readonly string[] OpenRoutes = { "/login", "/trust", "/error", "/auth", "/utbildning/extern" };
///
/// Prefix-regler. En regel matchar en path om path == prefix eller path börjar
@@ -76,6 +81,14 @@ private static (string Prefix, string[] Roles)[] BuildRules()
("/medarbetarsamtal", ChefHrAdmin),
("/journeys", ChefHrAdmin),
("/rekrytering", ChefHrAdmin),
+
+ // Rapporter: operativa team-/enhetsrapporter är chef-nåbara (och enhets-scopade
+ // i vyn). Men löne-/pekvitetsrapporter och lagstadgad SCB/SKR-statistik är
+ // HR/Admin-domän — en chef ska aldrig nå organisationens löner via rapportvägen.
+ ("/rapporter/lonetransparens", HrAdmin),
+ ("/rapporter/lonekartering", HrAdmin),
+ ("/rapporter/statistik", HrAdmin),
+ ("/rapporter/scb", HrAdmin),
("/rapporter", ChefHrAdmin),
("/vms", ChefHrAdmin),
@@ -88,6 +101,10 @@ private static (string Prefix, string[] Roles)[] BuildRules()
("/kompetens/endorsements", AllaInloggade),
("/formaner", AllaInloggade),
("/resor", AllaInloggade),
+ // Utbildning är självservice för alla — MEN administrationen av externa
+ // deltagare (access-tokens) är en kursadministrativ HR/Admin-funktion, inte
+ // "personligt". (Den publika token-portalen /utbildning/extern är öppen ovan.)
+ ("/utbildning/externa", HrAdmin),
("/utbildning", AllaInloggade),
};
// Mest specifik (längsta) prefix först.
diff --git a/src/Web/Services/UnitScopeService.cs b/src/Web/Services/UnitScopeService.cs
index 6924ae6..076a354 100644
--- a/src/Web/Services/UnitScopeService.cs
+++ b/src/Web/Services/UnitScopeService.cs
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using RegionHR.Infrastructure.Persistence;
+using RegionHR.SharedKernel.Domain;
namespace RegionHR.Web.Services;
@@ -49,27 +50,41 @@ public UnitScopeService(AuthService auth, IDbContextFactory d
///
/// Mängden anställd-id (Guid) som den inloggade får se.
- /// Returnerar null när ingen scoping ska ske (Admin/HR, eller en chef
- /// utan enhetskoppling) — anropande vy visar då alla. För en chef med känd
- /// enhet returneras de anställda vars aktiva anställning ligger på den enheten.
+ /// Semantik (entydig och fail-closed):
+ ///
+ /// - null = ingen scoping ska ske (Admin/HR) → anropande vy visar alla.
+ /// - en icke-null mängd = exakt de anställda den inloggade får se. Mängden kan
+ /// vara tom — då ska vyn visa INGET (aldrig "alla").
+ ///
+ /// En chef vars enhet inte kan härledas får en TOM mängd (fail-closed): hellre
+ /// visa inget än att läcka hela organisationen (kritiskt vid ~11 000 anställda).
+ /// Frågan körs på SQL-sidan och laddar aldrig hela Employee-tabellen i minnet.
///
public async Task?> GetEmployeeIdsInScopeAsync()
{
if (!IsUnitScoped)
- return null;
+ return null; // Admin/HR: ingen scoping.
var unitId = await GetCurrentUnitIdAsync();
if (unitId is null)
- return null; // Chef utan enhetskoppling → behåll nuvarande beteende (visa alla).
+ return new HashSet(); // Fail-closed: chef utan härledbar enhet ser inget.
await using var db = await _dbFactory.CreateDbContextAsync();
var idag = DateOnly.FromDateTime(DateTime.Today);
- var employees = await db.Employees.Include(e => e.Anstallningar).ToListAsync();
+ var oid = OrganizationId.From(unitId.Value);
+
+ // Hämta bara id för anställda med en aktiv anställning på chefens enhet.
+ // Where-villkoret översätts till SQL (samma mönster som AnstallningService),
+ // så vi materialiserar aldrig hela personaltabellen.
+ var ids = await db.Employees
+ .Where(e => e.Anstallningar.Any(a =>
+ a.EnhetId == oid &&
+ a.Giltighetsperiod.Start <= idag &&
+ (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag)))
+ .Select(e => e.Id)
+ .ToListAsync();
- return employees
- .Where(e => e.AktivAnstallning(idag)?.EnhetId.Value == unitId.Value)
- .Select(e => e.Id.Value)
- .ToHashSet();
+ return ids.Select(id => id.Value).ToHashSet();
}
///
diff --git a/src/Web/wwwroot/css/mobile.css b/src/Web/wwwroot/css/mobile.css
index c2d6e4a..6da5c7b 100644
--- a/src/Web/wwwroot/css/mobile.css
+++ b/src/Web/wwwroot/css/mobile.css
@@ -11,8 +11,10 @@
/* Forms full width */
.mud-input, .mud-select { width: 100% !important; }
- /* AppBar: hide search on mobile */
- .mud-appbar > div:nth-child(2) { display: none; }
+ /* AppBar: hide the omnisearch on mobile (hamburger + icons stannar).
+ Klass-baserad selector (ohr-appbar-search i TopBar) — robust mot att
+ hamburger-knappen läggs till som första barn i appbaren. */
+ .ohr-appbar-search { display: none !important; }
/* Reduce padding */
.mud-container { padding-left: 8px !important; padding-right: 8px !important; }
@@ -33,7 +35,31 @@
@media (max-width: 960px) {
/* Medium: 2 KPI cards per row */
.mud-grid > .mud-item-xs-3 { flex-basis: 50% !important; max-width: 50% !important; }
+}
+
+/* ── Responsiva tabeller (MudTable / MudDataGrid) ──
+ MudTable stackar redan till kort under Xs (via DataLabel), men för
+ tabeller utan DataLabel och för medelstora skärmar säkrar vi horisontell
+ scroll så inga kolumner klipps bort. Ligger utanför media query så att
+ scroll-behållaren alltid finns; min-width tvingar scroll bara på små skärmar. */
+.mud-table-container,
+.mud-datagrid .mud-table-container {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+@media (max-width: 600px) {
+ /* Data-täta tabeller (icke-stackade) blir scrollbara i stället för hopträngda. */
+ .ohr-scroll-table .mud-table-container table { min-width: 640px; }
+}
- /* Hide less important table columns */
- .hide-mobile { display: none; }
+/* ── Kolumn-döljande hjälpklasser (opt-in i sidmarkup på /) ── */
+@media (max-width: 600px) {
+ .hide-sm-down { display: none !important; }
+}
+
+@media (max-width: 960px) {
+ /* Dölj mindre viktiga kolumner/element på liten skärm. */
+ .hide-mobile,
+ .hide-md-down { display: none !important; }
}
diff --git a/src/Web/wwwroot/css/pwa-enhancements.css b/src/Web/wwwroot/css/pwa-enhancements.css
index 58e9fd9..c7cefae 100644
--- a/src/Web/wwwroot/css/pwa-enhancements.css
+++ b/src/Web/wwwroot/css/pwa-enhancements.css
@@ -158,6 +158,7 @@
}
.ohr-bottom-nav-item {
+ position: relative;
display: flex;
flex-direction: column;
align-items: center;
@@ -228,6 +229,13 @@
.mud-main-content {
padding-bottom: 80px !important;
}
+
+ /* Lyft OpenHR-assistentens FAB ovanför bottennavigeringen på mobil så de
+ inte överlappar. Kräver att OhrAssistant-wrappern får klassen
+ .ohr-assistant-fab (se wiring_behov) — regeln ligger redo. */
+ .ohr-assistant-fab {
+ bottom: calc(72px + env(safe-area-inset-bottom, 0)) !important;
+ }
}
diff --git a/src/Web/wwwroot/icon-192.png b/src/Web/wwwroot/icon-192.png
new file mode 100644
index 0000000..2c9f70b
Binary files /dev/null and b/src/Web/wwwroot/icon-192.png differ
diff --git a/src/Web/wwwroot/icon-512.png b/src/Web/wwwroot/icon-512.png
new file mode 100644
index 0000000..c15d9e9
Binary files /dev/null and b/src/Web/wwwroot/icon-512.png differ
diff --git a/tests/Web.Tests/Pages/HomeDashboardVariantTests.cs b/tests/Web.Tests/Pages/HomeDashboardVariantTests.cs
new file mode 100644
index 0000000..d8081e7
--- /dev/null
+++ b/tests/Web.Tests/Pages/HomeDashboardVariantTests.cs
@@ -0,0 +1,50 @@
+using RegionHR.Web.Components.Pages;
+using RegionHR.Web.Services;
+using Xunit;
+
+namespace RegionHR.Web.Tests.Pages;
+
+///
+/// Låser den rollmedvetna startsidan (Home.razor): varje roll ska mötas av sin
+/// EGEN variant. Det viktigaste testet är att en Anställd (eller en okänd/saknad
+/// roll) ALDRIG får HR-/org-varianten — det var det kända problemet där en
+/// anställds startsida visade en HR-dashboard med organisationsövergripande KPI:er.
+///
+public class HomeDashboardVariantTests
+{
+ [Theory]
+ [InlineData(RouteAccessPolicy.Admin, Home.DashboardVariant.Admin)]
+ [InlineData(RouteAccessPolicy.HR, Home.DashboardVariant.HR)]
+ [InlineData(RouteAccessPolicy.Chef, Home.DashboardVariant.Chef)]
+ [InlineData(RouteAccessPolicy.Anstalld, Home.DashboardVariant.Personal)]
+ public void VariantFor_maps_each_role_to_its_own_view(string role, Home.DashboardVariant expected)
+ {
+ Assert.Equal(expected, Home.VariantFor(role));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("Okänd")]
+ [InlineData("Administrator")] // nästan-men-inte "Admin" → får inte tolkas som Admin
+ public void VariantFor_defaults_to_Personal_for_unknown_or_missing_role(string? role)
+ {
+ // Fail-safe: en icke-igenkänd roll får den personliga vyn, aldrig org-KPI:er.
+ Assert.Equal(Home.DashboardVariant.Personal, Home.VariantFor(role));
+ }
+
+ [Fact]
+ public void Anstalld_never_gets_the_HR_or_Admin_org_view()
+ {
+ var variant = Home.VariantFor(RouteAccessPolicy.Anstalld);
+ Assert.NotEqual(Home.DashboardVariant.HR, variant);
+ Assert.NotEqual(Home.DashboardVariant.Admin, variant);
+ Assert.Equal(Home.DashboardVariant.Personal, variant);
+ }
+
+ [Fact]
+ public void Chef_gets_unit_scoped_chef_view_not_the_HR_org_view()
+ {
+ Assert.Equal(Home.DashboardVariant.Chef, Home.VariantFor(RouteAccessPolicy.Chef));
+ }
+}
diff --git a/tests/Web.Tests/RouteAccessPolicyTests.cs b/tests/Web.Tests/RouteAccessPolicyTests.cs
index 05873f0..5bf5495 100644
--- a/tests/Web.Tests/RouteAccessPolicyTests.cs
+++ b/tests/Web.Tests/RouteAccessPolicyTests.cs
@@ -200,4 +200,56 @@ public void UnknownRoute_IsDenied_ForEveryone()
Assert.False(RouteAccessPolicy.IsAllowed("/nagon/helt/okand/rutt", Admin));
Assert.Empty(RouteAccessPolicy.AllowedRolesFor("/nagon/helt/okand/rutt"));
}
+
+ // ── Federerad inloggnings-landning (OIDC) + publik utbildningsportal: öppna (pre-login) ──
+ [Theory]
+ [InlineData("/auth/oidc/complete")]
+ [InlineData("/utbildning/extern/abc123-token")]
+ public void LoginLandingAndPublicPortal_AreOpen(string path)
+ {
+ Assert.True(RouteAccessPolicy.IsOpen(path));
+ Assert.True(RouteAccessPolicy.IsAllowed(path, null));
+ Assert.True(RouteAccessPolicy.IsAllowed(path, Anstalld));
+ }
+
+ // ── Löne-/pekvitetsrapporter + lagstadgad SCB/SKR-statistik: HR/Admin, ALDRIG chef ──
+ [Theory]
+ [InlineData("/rapporter/lonetransparens")]
+ [InlineData("/rapporter/lonetransparens/00000000-0000-0000-0000-000000000001")]
+ [InlineData("/rapporter/lonekartering")]
+ [InlineData("/rapporter/statistik")]
+ [InlineData("/rapporter/scb")]
+ public void SalaryAndStatutoryReports_AreHrAdminOnly(string path)
+ {
+ Assert.False(RouteAccessPolicy.IsAllowed(path, Anstalld));
+ Assert.False(RouteAccessPolicy.IsAllowed(path, Chef));
+ Assert.True(RouteAccessPolicy.IsAllowed(path, Hr));
+ Assert.True(RouteAccessPolicy.IsAllowed(path, Admin));
+ }
+
+ // ── Operativa enhetsrapporter förblir chef-nåbara (scopas till enheten i vyn) ──
+ [Theory]
+ [InlineData("/rapporter")]
+ [InlineData("/rapporter/analytics")]
+ [InlineData("/rapporter/flight-risk")]
+ [InlineData("/rapporter/kpi")]
+ public void OperationalReports_RemainChefAccessible(string path)
+ {
+ Assert.True(RouteAccessPolicy.IsAllowed(path, Chef));
+ Assert.False(RouteAccessPolicy.IsAllowed(path, Anstalld));
+ }
+
+ // ── Administration av externa utbildningsdeltagare: HR/Admin (ej självservice) ──
+ // Självservice-utbildningen (/utbildning, /utbildning/elearning) är fortsatt öppen för alla.
+ [Fact]
+ public void ExternalParticipantAdmin_IsHrAdminOnly_ButSelfServiceTrainingStaysOpen()
+ {
+ Assert.False(RouteAccessPolicy.IsAllowed("/utbildning/externa", Anstalld));
+ Assert.False(RouteAccessPolicy.IsAllowed("/utbildning/externa", Chef));
+ Assert.True(RouteAccessPolicy.IsAllowed("/utbildning/externa", Hr));
+ Assert.True(RouteAccessPolicy.IsAllowed("/utbildning/externa", Admin));
+
+ Assert.True(RouteAccessPolicy.IsAllowed("/utbildning", Anstalld));
+ Assert.True(RouteAccessPolicy.IsAllowed("/utbildning/elearning", Anstalld));
+ }
}