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.
This commit is contained in:
Josh Rogers
2026-05-08 21:29:15 -05:00
parent d4db819e72
commit de5c18f3e6
9 changed files with 660 additions and 18 deletions
@@ -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<FamilyEndpoints.FamilyDto>("/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<FamilyEndpoints.FamilyDto>("/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<FamilyEndpoints.FamilyDto>("/api/family");
var asMember = await member.Client.GetFromJsonAsync<FamilyEndpoints.FamilyDto>("/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<FamilyEndpoints.InviteCodeResponse>();
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);
}
}
@@ -29,11 +29,12 @@ public class JwtTokenServiceTests
var service = BuildService(); var service = BuildService();
var user = new User { Id = 42, Name = "alice", PasswordHash = "x" }; 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.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 == 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.FamilyIdClaim).Value).IsEqualTo("7");
await Assert.That(jwt.Claims.First(c => c.Type == JwtTokenService.FamilyRoleClaim).Value).IsEqualTo("Admin");
} }
[Test] [Test]
@@ -42,7 +43,7 @@ public class JwtTokenServiceTests
var service = BuildService(); var service = BuildService();
var user = new User { Id = 1, Name = "bob", PasswordHash = "x" }; 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 expectedExpiry = DateTime.UtcNow.AddDays(30);
var delta = (jwt.ValidTo - expectedExpiry).Duration(); var delta = (jwt.ValidTo - expectedExpiry).Duration();
@@ -55,7 +56,7 @@ public class JwtTokenServiceTests
var service = BuildService(); var service = BuildService();
var user = new User { Id = 7, Name = "carol", PasswordHash = "x" }; 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 validator = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters var parameters = new TokenValidationParameters
@@ -78,7 +79,7 @@ public class JwtTokenServiceTests
public async Task GenerateToken_with_different_secret_fails_validation() public async Task GenerateToken_with_different_secret_fails_validation()
{ {
var service = BuildService(); 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 validator = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters var parameters = new TokenValidationParameters
+26 -8
View File
@@ -9,7 +9,7 @@ public static class AuthEndpoints
{ {
public record RegisterRequest(string Name, string Password, string FamilyCode); public record RegisterRequest(string Name, string Password, string FamilyCode);
public record LoginRequest(string Name, string Password); 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) public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
{ {
@@ -30,16 +30,21 @@ public static class AuthEndpoints
db.Users.Add(user); db.Users.Add(user);
await db.SaveChangesAsync(); 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 db.FamilyMemberships.Add(new FamilyMembership
{ {
UserId = user.Id, UserId = user.Id,
FamilyId = family.Id, FamilyId = family.Id,
Role = FamilyRole.Member, Role = role,
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var token = jwt.GenerateToken(user, family.Id); var token = jwt.GenerateToken(user, family.Id, role);
return Results.Ok(new AuthResponse(token, user.Name)); return Results.Ok(new AuthResponse(token, user.Name, role.ToString()));
}); });
group.MapPost("/login", async (LoginRequest request, YesChefDb db, JwtTokenService jwt) => group.MapPost("/login", async (LoginRequest request, YesChefDb db, JwtTokenService jwt) =>
@@ -61,16 +66,29 @@ public static class AuthEndpoints
if (membership is null) if (membership is null)
return Results.Unauthorized(); return Results.Unauthorized();
var token = jwt.GenerateToken(user, membership.FamilyId); var token = jwt.GenerateToken(user, membership.FamilyId, membership.Role);
return Results.Ok(new AuthResponse(token, user.Name)); 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 userId = http.User.GetUserId();
var name = http.User.GetUserName(); var name = http.User.GetUserName();
var familyId = http.User.GetFamilyId(); 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(); }).RequireAuthorization();
return group; return group;
@@ -1,4 +1,5 @@
using System.Security.Claims; using System.Security.Claims;
using YesChef.Api.Entities;
namespace YesChef.Api.Auth; namespace YesChef.Api.Auth;
@@ -12,4 +13,7 @@ public static class ClaimsPrincipalExtensions
public static int GetFamilyId(this ClaimsPrincipal principal) => public static int GetFamilyId(this ClaimsPrincipal principal) =>
int.Parse(principal.FindFirstValue(JwtTokenService.FamilyIdClaim)!); int.Parse(principal.FindFirstValue(JwtTokenService.FamilyIdClaim)!);
public static FamilyRole GetFamilyRole(this ClaimsPrincipal principal) =>
Enum.Parse<FamilyRole>(principal.FindFirstValue(JwtTokenService.FamilyRoleClaim)!);
} }
@@ -9,8 +9,9 @@ namespace YesChef.Api.Auth;
public class JwtTokenService(IConfiguration config) public class JwtTokenService(IConfiguration config)
{ {
public const string FamilyIdClaim = "family_id"; 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 key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Secret"]!));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); 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.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Name), new Claim(ClaimTypes.Name, user.Name),
new Claim(FamilyIdClaim, familyId.ToString()), new Claim(FamilyIdClaim, familyId.ToString()),
new Claim(FamilyRoleClaim, role.ToString()),
}; };
var token = new JwtSecurityToken( var token = new JwtSecurityToken(
@@ -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<MemberDto> 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<FamilyRole>(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<byte> bytes = stackalloc byte[10];
RandomNumberGenerator.Fill(bytes);
Span<char> chars = stackalloc char[10];
for (var i = 0; i < chars.Length; i++)
chars[i] = InviteAlphabet[bytes[i] % InviteAlphabet.Length];
return new string(chars);
}
}
+36 -4
View File
@@ -6,6 +6,7 @@ using Microsoft.IdentityModel.Tokens;
using YesChef.Api.Auth; using YesChef.Api.Auth;
using YesChef.Api.Data; using YesChef.Api.Data;
using YesChef.Api.Entities; using YesChef.Api.Entities;
using YesChef.Api.Features.Families;
using YesChef.Api.Features.Recipes; using YesChef.Api.Features.Recipes;
using YesChef.Api.Features.ShoppingLists; using YesChef.Api.Features.ShoppingLists;
using YesChef.Api.Features.Stores; 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. // Reject tokens whose user is no longer a member of the claimed family.
// Catches admin-initiated removal between login and the next request. // 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; var principal = context.Principal;
if (principal is null) { context.Fail("Missing principal."); return; } if (principal is null) { context.Fail("Missing principal."); return; }
@@ -55,14 +58,24 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
} }
var db = context.HttpContext.RequestServices.GetRequiredService<YesChefDb>(); var db = context.HttpContext.RequestServices.GetRequiredService<YesChefDb>();
var stillMember = await db.FamilyMemberships var membership = await db.FamilyMemberships
.AnyAsync(m => m.UserId == userId && m.FamilyId == familyId); .AsNoTracking()
if (!stillMember) context.Fail("Family membership revoked."); .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(); builder.Services.AddSignalR();
var app = builder.Build(); var app = builder.Build();
@@ -79,6 +92,24 @@ using (var scope = app.Services.CreateScope())
db.Families.Add(new Family { Name = "Family", InviteCode = inviteCode }); db.Families.Add(new Family { Name = "Family", InviteCode = inviteCode });
await db.SaveChangesAsync(); 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(); app.UseAuthentication();
@@ -91,6 +122,7 @@ app.MapGet("/health", async (YesChefDb db) =>
}); });
app.MapGroup("/api/auth").MapAuthEndpoints(); app.MapGroup("/api/auth").MapAuthEndpoints();
app.MapGroup("/api/family").MapFamilyEndpoints().RequireAuthorization();
app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthorization(); app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthorization();
app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization(); app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization();
app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization(); app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization();
+2 -1
View File
@@ -17,7 +17,8 @@
const navItems = [ const navItems = [
{ href: '/lists', label: 'Lists', icon: '📋' }, { href: '/lists', label: 'Lists', icon: '📋' },
{ href: '/recipes', label: 'Recipes', icon: '📖' }, { href: '/recipes', label: 'Recipes', icon: '📖' },
{ href: '/stores', label: 'Stores', icon: '🏪' } { href: '/stores', label: 'Stores', icon: '🏪' },
{ href: '/family', label: 'Family', icon: '👪' }
]; ];
function logout() { function logout() {
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast.svelte';
type Role = 'Admin' | 'Member';
interface Member {
userId: number;
name: string;
role: Role;
joinedAt: string;
}
interface FamilyView {
id: number;
name: string;
inviteCode: string | null;
myRole: Role;
members: Member[];
}
interface Me {
id: number;
name: string;
familyId: number;
familyName: string;
role: Role;
}
let family = $state<FamilyView | null>(null);
let me = $state<Me | null>(null);
let loading = $state(true);
let pendingRemove = $state<Member | null>(null);
let pendingRegenerate = $state(false);
const isAdmin = $derived(family?.myRole === 'Admin');
onMount(async () => {
await refresh();
loading = false;
});
async function refresh() {
[family, me] = await Promise.all([
api<FamilyView>('/api/family'),
api<Me>('/api/auth/me')
]);
}
async function copyInviteCode() {
if (!family?.inviteCode) return;
try {
await navigator.clipboard.writeText(family.inviteCode);
toast.success('Invite code copied');
} catch {
toast.error('Could not copy — long-press to select instead');
}
}
async function regenerateInviteCode() {
pendingRegenerate = false;
try {
const res = await api<{ inviteCode: string }>(
'/api/family/invite-code/regenerate',
{ method: 'POST' }
);
if (family) family.inviteCode = res.inviteCode;
toast.success('New invite code generated');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to regenerate code');
}
}
async function setRole(member: Member, role: Role) {
try {
await api(`/api/family/members/${member.userId}/role`, {
method: 'PUT',
body: JSON.stringify({ role })
});
await refresh();
toast.success(role === 'Admin' ? `${member.name} promoted to admin` : `${member.name} is now a member`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to change role');
}
}
async function confirmRemove() {
if (!pendingRemove) return;
const target = pendingRemove;
pendingRemove = null;
try {
await api(`/api/family/members/${target.userId}`, { method: 'DELETE' });
await refresh();
toast.success(`${target.name} removed from the family`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to remove member');
}
}
</script>
<div>
<h2 class="mb-1 text-2xl font-bold">{family?.name ?? 'Family'}</h2>
<p class="mb-6 text-sm text-gray-500">
{#if family}
{family.members.length} member{family.members.length === 1 ? '' : 's'}
{/if}
</p>
{#if loading}
<p class="py-8 text-center text-gray-400">Loading...</p>
{:else if !family}
<p class="py-8 text-center text-gray-400">Family not found.</p>
{:else}
{#if isAdmin && family.inviteCode}
<section class="mb-6 rounded-lg bg-white p-4 shadow-sm">
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
Invite code
</h3>
<div class="flex items-center gap-2">
<code class="flex-1 rounded bg-gray-100 px-3 py-2 font-mono text-lg tracking-widest">
{family.inviteCode}
</code>
<button
type="button"
onclick={copyInviteCode}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
>
Copy
</button>
</div>
<button
type="button"
onclick={() => (pendingRegenerate = true)}
class="mt-3 text-sm font-medium text-danger"
>
Regenerate code
</button>
<p class="mt-2 text-xs text-gray-500">
Share this code with people you want to join. Regenerating immediately invalidates the old code.
</p>
</section>
{/if}
<section>
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">Members</h3>
<ul class="space-y-2">
{#each family.members as member (member.userId)}
{@const isMe = member.userId === me?.id}
<li class="flex items-center gap-3 rounded-lg bg-white px-4 py-3 shadow-sm">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{member.name}</span>
{#if isMe}
<span class="text-xs text-gray-400">(you)</span>
{/if}
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold {member.role === 'Admin'
? 'bg-primary/10 text-primary'
: 'bg-gray-100 text-gray-600'}"
>
{member.role}
</span>
</div>
</div>
{#if isAdmin}
<div class="flex items-center gap-2 text-sm">
{#if member.role === 'Member'}
<button
type="button"
onclick={() => setRole(member, 'Admin')}
class="font-medium text-primary"
>
Promote
</button>
{:else}
<button
type="button"
onclick={() => setRole(member, 'Member')}
class="font-medium text-gray-600"
>
Demote
</button>
{/if}
{#if !isMe}
<button
type="button"
onclick={() => (pendingRemove = member)}
class="font-medium text-danger"
>
Remove
</button>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
</div>
{#if pendingRemove}
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="remove-member-title"
>
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
<h3 id="remove-member-title" class="mb-2 text-lg font-semibold">Remove member?</h3>
<p class="mb-5 text-sm text-gray-600">
Remove <span class="font-medium">{pendingRemove.name}</span> from the family? They'll lose access immediately.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (pendingRemove = null)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
>
Cancel
</button>
<button
type="button"
onclick={confirmRemove}
class="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white"
>
Remove
</button>
</div>
</div>
</div>
{/if}
{#if pendingRegenerate}
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="regenerate-code-title"
>
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
<h3 id="regenerate-code-title" class="mb-2 text-lg font-semibold">Regenerate invite code?</h3>
<p class="mb-5 text-sm text-gray-600">
The current code will stop working immediately. Anyone you've already shared it with will need the new one.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (pendingRegenerate = false)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
>
Cancel
</button>
<button
type="button"
onclick={regenerateInviteCode}
class="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white"
>
Regenerate
</button>
</div>
</div>
</div>
{/if}