From de5c18f3e60eff50bea0e40a10fe8ca98b396b7d Mon Sep 17 00:00:00 2001 From: Josh Rogers Date: Fri, 8 May 2026 21:29:15 -0500 Subject: [PATCH] Add per-family invite codes and admin roles First member into a family becomes Admin; subsequent members default to Member. JWTs carry a family_role claim that is refreshed from the DB on each request so promotions and demotions take effect immediately. New /api/family endpoints let admins view the roster, regenerate the invite code, change roles, and remove members. Last-admin and self-removal guards prevent locking the family out of management. The /family page exposes the same actions in the UI; the bottom nav now links there. Members see the roster but not the invite code. Existing deployments get a one-time backfill at startup: any family with members but no admin gets its earliest-joined member promoted. --- .../Features/FamilyEndpointsTests.cs | 185 ++++++++++++ .../Auth/JwtTokenServiceTests.cs | 9 +- src/backend/YesChef.Api/Auth/AuthEndpoints.cs | 34 ++- .../Auth/ClaimsPrincipalExtensions.cs | 4 + .../YesChef.Api/Auth/JwtTokenService.cs | 4 +- .../Features/Families/FamilyEndpoints.cs | 133 +++++++++ src/backend/YesChef.Api/Program.cs | 40 ++- src/frontend/src/routes/+layout.svelte | 3 +- src/frontend/src/routes/family/+page.svelte | 266 ++++++++++++++++++ 9 files changed, 660 insertions(+), 18 deletions(-) create mode 100644 src/backend/YesChef.Api.IntegrationTests/Features/FamilyEndpointsTests.cs create mode 100644 src/backend/YesChef.Api/Features/Families/FamilyEndpoints.cs create mode 100644 src/frontend/src/routes/family/+page.svelte diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/FamilyEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/FamilyEndpointsTests.cs new file mode 100644 index 0000000..b49597c --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Features/FamilyEndpointsTests.cs @@ -0,0 +1,185 @@ +using System.Net; +using System.Net.Http.Json; +using YesChef.Api.Entities; +using YesChef.Api.Features.Families; +using YesChef.Api.IntegrationTests.Infrastructure; + +namespace YesChef.Api.IntegrationTests.Features; + +public class FamilyEndpointsTests : AuthenticatedIntegrationTest +{ + // The default registered user is the first member of the bootstrap family + // and therefore the admin. Tests that need a non-admin register a second + // user via the same invite code to get a Member. + + [Test] + public async Task First_registrant_is_admin() + { + var family = await Client.GetFromJsonAsync("/api/family"); + + await Assert.That(family!.MyRole).IsEqualTo("Admin"); + await Assert.That(family.Members.Count).IsEqualTo(1); + await Assert.That(family.Members[0].Role).IsEqualTo("Admin"); + } + + [Test] + public async Task Second_registrant_is_member() + { + using var second = await Data.RegisterAsync("second-user"); + + var family = await second.Client.GetFromJsonAsync("/api/family"); + + await Assert.That(family!.MyRole).IsEqualTo("Member"); + await Assert.That(family.Members.Count).IsEqualTo(2); + } + + [Test] + public async Task Get_family_hides_invite_code_from_members() + { + using var member = await Data.RegisterAsync("member"); + + var asAdmin = await Client.GetFromJsonAsync("/api/family"); + var asMember = await member.Client.GetFromJsonAsync("/api/family"); + + await Assert.That(asAdmin!.InviteCode).IsNotNull(); + await Assert.That(asMember!.InviteCode).IsNull(); + } + + [Test] + public async Task Regenerate_invite_code_replaces_code_and_returns_new_one() + { + var beforeCode = await UseDbAsync(db => db.Families.Select(f => f.InviteCode).FirstAsync()); + + var response = await Client.PostAsync("/api/family/invite-code/regenerate", content: null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body!.InviteCode).IsNotEqualTo(beforeCode); + await Assert.That(body.InviteCode.Length).IsEqualTo(10); + var afterCode = await UseDbAsync(db => db.Families.Select(f => f.InviteCode).FirstAsync()); + await Assert.That(afterCode).IsEqualTo(body.InviteCode); + } + + [Test] + public async Task Regenerate_invite_code_forbidden_for_member() + { + using var member = await Data.RegisterAsync("member"); + + var response = await member.Client.PostAsync("/api/family/invite-code/regenerate", content: null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task Update_role_promotes_member_to_admin() + { + using var member = await Data.RegisterAsync("member"); + var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync()); + + var response = await Client.PutAsJsonAsync($"/api/family/members/{memberId}/role", + new FamilyEndpoints.UpdateRoleRequest("Admin")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + var role = await UseDbAsync(db => db.FamilyMemberships + .Where(m => m.UserId == memberId).Select(m => m.Role).SingleAsync()); + await Assert.That(role).IsEqualTo(FamilyRole.Admin); + } + + [Test] + public async Task Update_role_blocks_demoting_last_admin() + { + var response = await Client.PutAsJsonAsync($"/api/family/members/{User.Id}/role", + new FamilyEndpoints.UpdateRoleRequest("Member")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Update_role_allows_demoting_admin_when_other_admin_exists() + { + using var member = await Data.RegisterAsync("member"); + var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync()); + // promote so we have two admins + await Client.PutAsJsonAsync($"/api/family/members/{memberId}/role", + new FamilyEndpoints.UpdateRoleRequest("Admin")); + + // demote self — should succeed because the other admin remains + var response = await Client.PutAsJsonAsync($"/api/family/members/{User.Id}/role", + new FamilyEndpoints.UpdateRoleRequest("Member")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + } + + [Test] + public async Task Update_role_forbidden_for_member() + { + using var member = await Data.RegisterAsync("member"); + var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync()); + + var response = await member.Client.PutAsJsonAsync($"/api/family/members/{memberId}/role", + new FamilyEndpoints.UpdateRoleRequest("Admin")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task Remove_member_deletes_membership() + { + using var member = await Data.RegisterAsync("member"); + var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync()); + + var response = await Client.DeleteAsync($"/api/family/members/{memberId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + var stillMember = await UseDbAsync(db => db.FamilyMemberships.AnyAsync(m => m.UserId == memberId)); + await Assert.That(stillMember).IsFalse(); + } + + [Test] + public async Task Remove_member_revokes_their_token() + { + using var member = await Data.RegisterAsync("member"); + var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync()); + + var removal = await Client.DeleteAsync($"/api/family/members/{memberId}"); + await Assert.That(removal.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + + // The kicked member's existing JWT must now be rejected. + var meResponse = await member.Client.GetAsync("/api/auth/me"); + await Assert.That(meResponse.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task Remove_self_returns_409() + { + var response = await Client.DeleteAsync($"/api/family/members/{User.Id}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Member_demoted_loses_admin_access_on_next_request() + { + // Promote a second user to admin, then have the original admin demote + // them. The original admin's already-issued token should still work + // (still admin); the demoted user's token should no longer pass the + // admin policy on the next request. + using var second = await Data.RegisterAsync("second"); + var secondId = await UseDbAsync(db => db.Users.Where(u => u.Name == "second").Select(u => u.Id).SingleAsync()); + await Client.PutAsJsonAsync($"/api/family/members/{secondId}/role", + new FamilyEndpoints.UpdateRoleRequest("Admin")); + + // confirm second can now regenerate (admin-only) + var asAdmin = await second.Client.PostAsync("/api/family/invite-code/regenerate", content: null); + await Assert.That(asAdmin.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // demote second + var demote = await Client.PutAsJsonAsync($"/api/family/members/{secondId}/role", + new FamilyEndpoints.UpdateRoleRequest("Member")); + await Assert.That(demote.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + + // second's same token now fails the admin policy + var afterDemote = await second.Client.PostAsync("/api/family/invite-code/regenerate", content: null); + await Assert.That(afterDemote.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } +} diff --git a/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs b/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs index bc64452..3fd2e1e 100644 --- a/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs +++ b/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs @@ -29,11 +29,12 @@ public class JwtTokenServiceTests var service = BuildService(); var user = new User { Id = 42, Name = "alice", PasswordHash = "x" }; - var jwt = Decode(service.GenerateToken(user, familyId: 7)); + var jwt = Decode(service.GenerateToken(user, familyId: 7, role: FamilyRole.Admin)); await Assert.That(jwt.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value).IsEqualTo("42"); await Assert.That(jwt.Claims.First(c => c.Type == ClaimTypes.Name).Value).IsEqualTo("alice"); await Assert.That(jwt.Claims.First(c => c.Type == JwtTokenService.FamilyIdClaim).Value).IsEqualTo("7"); + await Assert.That(jwt.Claims.First(c => c.Type == JwtTokenService.FamilyRoleClaim).Value).IsEqualTo("Admin"); } [Test] @@ -42,7 +43,7 @@ public class JwtTokenServiceTests var service = BuildService(); var user = new User { Id = 1, Name = "bob", PasswordHash = "x" }; - var jwt = Decode(service.GenerateToken(user, familyId: 1)); + var jwt = Decode(service.GenerateToken(user, familyId: 1, role: FamilyRole.Member)); var expectedExpiry = DateTime.UtcNow.AddDays(30); var delta = (jwt.ValidTo - expectedExpiry).Duration(); @@ -55,7 +56,7 @@ public class JwtTokenServiceTests var service = BuildService(); var user = new User { Id = 7, Name = "carol", PasswordHash = "x" }; - var token = service.GenerateToken(user, familyId: 1); + var token = service.GenerateToken(user, familyId: 1, role: FamilyRole.Member); var validator = new JwtSecurityTokenHandler(); var parameters = new TokenValidationParameters @@ -78,7 +79,7 @@ public class JwtTokenServiceTests public async Task GenerateToken_with_different_secret_fails_validation() { var service = BuildService(); - var token = service.GenerateToken(new User { Id = 1, Name = "x", PasswordHash = "x" }, familyId: 1); + var token = service.GenerateToken(new User { Id = 1, Name = "x", PasswordHash = "x" }, familyId: 1, role: FamilyRole.Member); var validator = new JwtSecurityTokenHandler(); var parameters = new TokenValidationParameters diff --git a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs index 98a099d..c8ad7f6 100644 --- a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs +++ b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs @@ -9,7 +9,7 @@ public static class AuthEndpoints { public record RegisterRequest(string Name, string Password, string FamilyCode); public record LoginRequest(string Name, string Password); - public record AuthResponse(string Token, string Name); + public record AuthResponse(string Token, string Name, string Role); public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) { @@ -30,16 +30,21 @@ public static class AuthEndpoints db.Users.Add(user); await db.SaveChangesAsync(); + // First member into a family becomes Admin so somebody can manage it. + var familyHasAdmin = await db.FamilyMemberships + .AnyAsync(m => m.FamilyId == family.Id && m.Role == FamilyRole.Admin); + var role = familyHasAdmin ? FamilyRole.Member : FamilyRole.Admin; + db.FamilyMemberships.Add(new FamilyMembership { UserId = user.Id, FamilyId = family.Id, - Role = FamilyRole.Member, + Role = role, }); await db.SaveChangesAsync(); - var token = jwt.GenerateToken(user, family.Id); - return Results.Ok(new AuthResponse(token, user.Name)); + var token = jwt.GenerateToken(user, family.Id, role); + return Results.Ok(new AuthResponse(token, user.Name, role.ToString())); }); group.MapPost("/login", async (LoginRequest request, YesChefDb db, JwtTokenService jwt) => @@ -61,16 +66,29 @@ public static class AuthEndpoints if (membership is null) return Results.Unauthorized(); - var token = jwt.GenerateToken(user, membership.FamilyId); - return Results.Ok(new AuthResponse(token, user.Name)); + var token = jwt.GenerateToken(user, membership.FamilyId, membership.Role); + return Results.Ok(new AuthResponse(token, user.Name, membership.Role.ToString())); }); - group.MapGet("/me", (HttpContext http) => + group.MapGet("/me", async (HttpContext http, YesChefDb db) => { var userId = http.User.GetUserId(); var name = http.User.GetUserName(); var familyId = http.User.GetFamilyId(); - return Results.Ok(new { id = userId, name, familyId }); + // Pull role from DB, not the claim, so a user demoted mid-token-lifetime + // sees their actual current role on the next /me poll. + var membership = await db.FamilyMemberships + .Include(m => m.Family) + .FirstOrDefaultAsync(m => m.UserId == userId && m.FamilyId == familyId); + if (membership is null) return Results.Unauthorized(); + return Results.Ok(new + { + id = userId, + name, + familyId, + familyName = membership.Family.Name, + role = membership.Role.ToString(), + }); }).RequireAuthorization(); return group; diff --git a/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs b/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs index ee7f96a..d77ce08 100644 --- a/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs +++ b/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using YesChef.Api.Entities; namespace YesChef.Api.Auth; @@ -12,4 +13,7 @@ public static class ClaimsPrincipalExtensions public static int GetFamilyId(this ClaimsPrincipal principal) => int.Parse(principal.FindFirstValue(JwtTokenService.FamilyIdClaim)!); + + public static FamilyRole GetFamilyRole(this ClaimsPrincipal principal) => + Enum.Parse(principal.FindFirstValue(JwtTokenService.FamilyRoleClaim)!); } diff --git a/src/backend/YesChef.Api/Auth/JwtTokenService.cs b/src/backend/YesChef.Api/Auth/JwtTokenService.cs index c8693ff..9d25ac5 100644 --- a/src/backend/YesChef.Api/Auth/JwtTokenService.cs +++ b/src/backend/YesChef.Api/Auth/JwtTokenService.cs @@ -9,8 +9,9 @@ namespace YesChef.Api.Auth; public class JwtTokenService(IConfiguration config) { public const string FamilyIdClaim = "family_id"; + public const string FamilyRoleClaim = "family_role"; - public string GenerateToken(User user, int familyId) + public string GenerateToken(User user, int familyId, FamilyRole role) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); @@ -20,6 +21,7 @@ public class JwtTokenService(IConfiguration config) new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Name), new Claim(FamilyIdClaim, familyId.ToString()), + new Claim(FamilyRoleClaim, role.ToString()), }; var token = new JwtSecurityToken( diff --git a/src/backend/YesChef.Api/Features/Families/FamilyEndpoints.cs b/src/backend/YesChef.Api/Features/Families/FamilyEndpoints.cs new file mode 100644 index 0000000..5ae337b --- /dev/null +++ b/src/backend/YesChef.Api/Features/Families/FamilyEndpoints.cs @@ -0,0 +1,133 @@ +using System.Security.Cryptography; +using Microsoft.EntityFrameworkCore; +using YesChef.Api.Auth; +using YesChef.Api.Data; +using YesChef.Api.Entities; + +namespace YesChef.Api.Features.Families; + +public static class FamilyEndpoints +{ + public record MemberDto(int UserId, string Name, string Role, DateTime JoinedAt); + public record FamilyDto(int Id, string Name, string? InviteCode, string MyRole, IReadOnlyList Members); + public record UpdateRoleRequest(string Role); + public record InviteCodeResponse(string InviteCode); + + public static RouteGroupBuilder MapFamilyEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/", async (YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var myRole = http.User.GetFamilyRole(); + + var family = await db.Families.AsNoTracking().FirstOrDefaultAsync(f => f.Id == familyId); + if (family is null) return Results.NotFound(); + + var memberRows = await db.FamilyMemberships + .AsNoTracking() + .Where(m => m.FamilyId == familyId) + .Join(db.Users, m => m.UserId, u => u.Id, (m, u) => new { u.Id, u.Name, m.Role, m.JoinedAt }) + .OrderBy(r => r.JoinedAt).ThenBy(r => r.Id) + .ToListAsync(); + var members = memberRows + .Select(r => new MemberDto(r.Id, r.Name, r.Role.ToString(), r.JoinedAt)) + .ToList(); + + // Invite code is admin-only — members can see the family and roster + // but shouldn't be able to invite new members on their own. + var inviteCode = myRole == FamilyRole.Admin ? family.InviteCode : null; + return Results.Ok(new FamilyDto(family.Id, family.Name, inviteCode, myRole.ToString(), members)); + }); + + group.MapPost("/invite-code/regenerate", async (YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var family = await db.Families.FirstOrDefaultAsync(f => f.Id == familyId); + if (family is null) return Results.NotFound(); + + // Loop in the rare event of a collision with another family's code. + for (var attempt = 0; attempt < 5; attempt++) + { + var candidate = GenerateInviteCode(); + if (await db.Families.AnyAsync(f => f.InviteCode == candidate)) continue; + family.InviteCode = candidate; + await db.SaveChangesAsync(); + return Results.Ok(new InviteCodeResponse(candidate)); + } + return Results.Problem("Could not generate a unique invite code; try again."); + }).RequireAuthorization("FamilyAdmin"); + + group.MapPut("/members/{userId:int}/role", async (int userId, UpdateRoleRequest request, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var actingUserId = http.User.GetUserId(); + + if (!Enum.TryParse(request.Role, ignoreCase: true, out var newRole)) + return Results.BadRequest(new { error = $"Unknown role \"{request.Role}\"." }); + + var membership = await db.FamilyMemberships + .FirstOrDefaultAsync(m => m.FamilyId == familyId && m.UserId == userId); + if (membership is null) return Results.NotFound(); + + if (membership.Role == newRole) + return Results.NoContent(); + + // Block demoting the last admin (including self) — a family without + // an admin is a family no one can manage. + if (membership.Role == FamilyRole.Admin && newRole != FamilyRole.Admin) + { + var adminCount = await db.FamilyMemberships + .CountAsync(m => m.FamilyId == familyId && m.Role == FamilyRole.Admin); + if (adminCount <= 1) + return Results.Conflict(new { error = "Cannot remove the last admin. Promote another member first." }); + } + + membership.Role = newRole; + await db.SaveChangesAsync(); + _ = actingUserId; // intentionally unused; reserved for future audit log + return Results.NoContent(); + }).RequireAuthorization("FamilyAdmin"); + + group.MapDelete("/members/{userId:int}", async (int userId, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var actingUserId = http.User.GetUserId(); + + if (userId == actingUserId) + return Results.Conflict(new { error = "Use the leave-family flow to remove yourself." }); + + var membership = await db.FamilyMemberships + .FirstOrDefaultAsync(m => m.FamilyId == familyId && m.UserId == userId); + if (membership is null) return Results.NotFound(); + + // Same last-admin guard — even if some future flow lets an admin + // remove another admin, never let the family lose its last admin. + if (membership.Role == FamilyRole.Admin) + { + var adminCount = await db.FamilyMemberships + .CountAsync(m => m.FamilyId == familyId && m.Role == FamilyRole.Admin); + if (adminCount <= 1) + return Results.Conflict(new { error = "Cannot remove the last admin. Promote another member first." }); + } + + db.FamilyMemberships.Remove(membership); + await db.SaveChangesAsync(); + return Results.NoContent(); + }).RequireAuthorization("FamilyAdmin"); + + return group; + } + + // 10-char invite code drawn from an unambiguous alphabet (no 0/O/1/I/L). + private const string InviteAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + + private static string GenerateInviteCode() + { + Span bytes = stackalloc byte[10]; + RandomNumberGenerator.Fill(bytes); + Span chars = stackalloc char[10]; + for (var i = 0; i < chars.Length; i++) + chars[i] = InviteAlphabet[bytes[i] % InviteAlphabet.Length]; + return new string(chars); + } +} diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs index 68b3009..2fbad29 100644 --- a/src/backend/YesChef.Api/Program.cs +++ b/src/backend/YesChef.Api/Program.cs @@ -6,6 +6,7 @@ using Microsoft.IdentityModel.Tokens; using YesChef.Api.Auth; using YesChef.Api.Data; using YesChef.Api.Entities; +using YesChef.Api.Features.Families; using YesChef.Api.Features.Recipes; using YesChef.Api.Features.ShoppingLists; using YesChef.Api.Features.Stores; @@ -43,6 +44,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) { // Reject tokens whose user is no longer a member of the claimed family. // Catches admin-initiated removal between login and the next request. + // Also refreshes the role claim from the DB so a role change (promote/ + // demote) takes effect on the next request without forcing re-login. var principal = context.Principal; if (principal is null) { context.Fail("Missing principal."); return; } @@ -55,14 +58,24 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) } var db = context.HttpContext.RequestServices.GetRequiredService(); - var stillMember = await db.FamilyMemberships - .AnyAsync(m => m.UserId == userId && m.FamilyId == familyId); - if (!stillMember) context.Fail("Family membership revoked."); + var membership = await db.FamilyMemberships + .AsNoTracking() + .FirstOrDefaultAsync(m => m.UserId == userId && m.FamilyId == familyId); + if (membership is null) { context.Fail("Family membership revoked."); return; } + + var identity = (ClaimsIdentity)principal.Identity!; + foreach (var stale in identity.FindAll(JwtTokenService.FamilyRoleClaim).ToList()) + identity.RemoveClaim(stale); + identity.AddClaim(new Claim(JwtTokenService.FamilyRoleClaim, membership.Role.ToString())); }, }; }); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("FamilyAdmin", policy => + policy.RequireClaim(JwtTokenService.FamilyRoleClaim, nameof(FamilyRole.Admin))); +}); builder.Services.AddSignalR(); var app = builder.Build(); @@ -79,6 +92,24 @@ using (var scope = app.Services.CreateScope()) db.Families.Add(new Family { Name = "Family", InviteCode = inviteCode }); await db.SaveChangesAsync(); } + + // Backfill: promote the earliest-joined member of any family that has + // members but no admins. Covers deployments that pre-date role support. + var familiesNeedingAdmin = await db.Families + .Where(f => db.FamilyMemberships.Any(m => m.FamilyId == f.Id) + && !db.FamilyMemberships.Any(m => m.FamilyId == f.Id && m.Role == FamilyRole.Admin)) + .Select(f => f.Id) + .ToListAsync(); + foreach (var familyId in familiesNeedingAdmin) + { + var firstMember = await db.FamilyMemberships + .Where(m => m.FamilyId == familyId) + .OrderBy(m => m.JoinedAt).ThenBy(m => m.UserId) + .FirstAsync(); + firstMember.Role = FamilyRole.Admin; + } + if (familiesNeedingAdmin.Count > 0) + await db.SaveChangesAsync(); } app.UseAuthentication(); @@ -91,6 +122,7 @@ app.MapGet("/health", async (YesChefDb db) => }); app.MapGroup("/api/auth").MapAuthEndpoints(); +app.MapGroup("/api/family").MapFamilyEndpoints().RequireAuthorization(); app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthorization(); app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization(); app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization(); diff --git a/src/frontend/src/routes/+layout.svelte b/src/frontend/src/routes/+layout.svelte index b307374..03d7235 100644 --- a/src/frontend/src/routes/+layout.svelte +++ b/src/frontend/src/routes/+layout.svelte @@ -17,7 +17,8 @@ const navItems = [ { href: '/lists', label: 'Lists', icon: '📋' }, { href: '/recipes', label: 'Recipes', icon: '📖' }, - { href: '/stores', label: 'Stores', icon: '🏪' } + { href: '/stores', label: 'Stores', icon: '🏪' }, + { href: '/family', label: 'Family', icon: '👪' } ]; function logout() { diff --git a/src/frontend/src/routes/family/+page.svelte b/src/frontend/src/routes/family/+page.svelte new file mode 100644 index 0000000..448f9a7 --- /dev/null +++ b/src/frontend/src/routes/family/+page.svelte @@ -0,0 +1,266 @@ + + +
+

{family?.name ?? 'Family'}

+

+ {#if family} + {family.members.length} member{family.members.length === 1 ? '' : 's'} + {/if} +

+ + {#if loading} +

Loading...

+ {:else if !family} +

Family not found.

+ {:else} + {#if isAdmin && family.inviteCode} +
+

+ Invite code +

+
+ + {family.inviteCode} + + +
+ +

+ Share this code with people you want to join. Regenerating immediately invalidates the old code. +

+
+ {/if} + +
+

Members

+
    + {#each family.members as member (member.userId)} + {@const isMe = member.userId === me?.id} +
  • +
    +
    + {member.name} + {#if isMe} + (you) + {/if} + + {member.role} + +
    +
    + + {#if isAdmin} +
    + {#if member.role === 'Member'} + + {:else} + + {/if} + {#if !isMe} + + {/if} +
    + {/if} +
  • + {/each} +
+
+ {/if} +
+ +{#if pendingRemove} + +{/if} + +{#if pendingRegenerate} + +{/if}