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 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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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