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:
@@ -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 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<FamilyRole>(principal.FindFirstValue(JwtTokenService.FamilyRoleClaim)!);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<YesChefDb>();
|
||||
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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user