Add store invitations table (#7519)
What changed, and why it matters
This commit adds a new 'store invitations' feature to BTCPay Server. Store owners can now invite users by email with a time-limited link instead of adding them immediately. The change introduces new database tables, API endpoints, web pages, email notifications, and security checks. It also tightens some existing rules: only server admins can skip the invitation flow, and the last store owner cannot have their role changed or be removed. The code appears to be a deliberate security-hardening feature rather than a fix for an active vulnerability, but it changes who can gain store access and how.
Reviewers should verify: (1) The `CanModifyProfile` policy is the correct minimum privilege for accepting/declining invitations and cannot be satisfied by a store-less user in unexpected ways. (2) The `RequireInvitation=false` bypass is enforced server-side in both UI and API. (3) The `DeleteStoreInvitation` calls in `AddStoreUser` and `UpdateStoreUserRole` reliably race-proof token reuse. (4) The `StoreInvitationRow.HashToken` collision resistance and token entropy (24 bytes base58) are adequate. (5) The new Razor views using `ViewLocalizer` encode all dynamic parameters per the added SKILL.md guidance. (6) The `LockSubscription` + `DisableNonAdminCreateUserApi` interactions do not allow non-admins to create accounts via the invitation flow when disabled.
Security signals we found
New authentication/authorization boundary for invitation acceptance (CanModifyProfile)
Invitation tokens are SHA-256 hashed at rest
Invitation links are time-limited (24 hours) and invalidated on resend
Direct membership add/update deletes outstanding invitations to prevent token reuse overwriting roles
Only server admins can bypass invitation requirement via RequireInvitation=false
Last-owner protection enforced in role updates
New user creation path in Greenfield API gated by CanCreateUser when LockSubscription is enabled
Razor localization guidance added to avoid XSS via ViewLocalizer
Evidence from the diff
The commit implements a store invitation workflow. Key changes: (1) New store_invitations table with SHA-256 hashed tokens, 24-hour expiry, and foreign keys to Users/Stores/StoreRoles. (2) New Greenfield API endpoints under /api/v1/invitations/{token} and /api/v1/stores/{storeId}/users/.../invitation for accept/decline/resend/cancel. (3) New UI routes under ~/invitations/{token} protected by CanModifyProfile. (4) AddStoreUser now defaults to RequireInvitation=true; skipping invitations requires CanModifyServerSettings. (5) UpdateStoreUserRole is split from AddOrUpdateStoreUser and prevents orphaning the last owner. (6) Invitations are deleted on direct membership add/update to prevent stale tokens from overwriting roles. (7) Token generation uses 24 random bytes base58-encoded. (8) Added email/notification triggers for invitation created/accepted. (9) Razor localization skill doc added to prevent XSS in localized HTML strings.
Changed components
BTCPayServer.Client (StoreUsers API client models)BTCPayServer.Data (StoreInvitation entity, migration)BTCPayServer/Controllers/GreenField/GreenfieldStoreUsersController.csBTCPayServer/Controllers/UIStoresController.Users.csBTCPayServer/Controllers/UIUserStoresController.csBTCPayServer/Services/Stores/StoreRepository.Invitation.csBTCPayServer/Services/Stores/StoreRepository.csBTCPayServer/Services/CallbackGenerator.csBTCPayServer/Plugins/Emails (invitation email triggers and hosted service)BTCPayServer/Services/Notifications/Blobs/StoreInvitationNotification.csBTCPayServer/Views/UIStores/StoreUsers.cshtmlBTCPayServer/Views/UIUserStores/AcceptStoreInvitation.cshtmlBTCPayServer/Views/UIStores/StoreInvitationSent.cshtmlInspect captured patch +2380 / −358
### .agents/skills/razor-localization/SKILL.md
@@ -0,0 +1,44 @@
+---
+name: razor-localization
+description: Use when editing or reviewing Razor `.cshtml` files containing parameterized localized strings. Choose StringLocalizer for plain text and safely encode ViewLocalizer parameters when localized strings contain HTML.
+---
+
+# Razor Localization
+
+Apply these rules to parameterized localizable strings in Razor views.
+
+## Plain Text
+
+Use `StringLocalizer` when the localized string does not contain HTML. Razor encodes the resulting localized string when rendering it.
+
+```razor
+@StringLocalizer["{0} has been invited as {1}.", Model.Email, Model.Role]
+```
+
+Do not use `ViewLocalizer` merely because a string has parameters.
+
+## HTML
+
+Use `ViewLocalizer` only when the localized string intentionally contains HTML. Encode every dynamic parameter with `Html.Encode` before passing it to `ViewLocalizer`.
+
+```razor
+@ViewLocalizer["You have been invited to join <strong>{0}</strong> as {1}.",
+ Html.Encode(Model.StoreName), Html.Encode(Model.Role)]
+```
+
+Never pass user-controlled or otherwise dynamic strings directly to `ViewLocalizer`:
+
+```razor
+@* Unsafe *@
+@ViewLocalizer["Welcome to <strong>{0}</strong>.", Model.StoreName]
+```
+
+Generated HTML values such as `Html.ActionLink(...)` are intentional HTML and should not be encoded.
+
+## Review Checklist
+
+- Use `StringLocalizer` for parameterized strings without HTML.
+- Use `ViewLocalizer` only when the localized resource contains intentional HTML.
+- Wrap every dynamic `ViewLocalizer` parameter in `Html.Encode(...)`.
+- Do not encode intentional HTML values returned by HTML helpers.
+- Check every added or modified `ViewLocalizer` call before completing a Razor change.
### BTCPayServer.Client/BTCPayServerClient.StoreUsers.cs
@@ -13,7 +13,7 @@ public virtual async Task<List<RoleData>> GetStoreRoles(string storeId, Cancella
{
return await SendHttpRequest<List<RoleData>>($"api/v1/stores/{storeId}/roles", null, HttpMethod.Get,token);
}
-
+
public virtual async Task<IEnumerable<StoreUserData>> GetStoreUsers(string storeId, CancellationToken token = default)
{
return await SendHttpRequest<IEnumerable<StoreUserData>>($"api/v1/stores/{storeId}/users", null, HttpMethod.Get, token);
@@ -24,15 +24,46 @@ public virtual async Task RemoveStoreUser(string storeId, string userId, Cancell
await SendHttpRequest($"api/v1/stores/{storeId}/users/{userId}", null, HttpMethod.Delete, token);
}
- public virtual async Task AddStoreUser(string storeId, StoreUserData request, CancellationToken token = default)
+ public virtual async Task<AddStoreUserResult>
+ AddStoreUser(string storeId, AddStoreUserDataRequest request, CancellationToken token = default)
{
if (request == null) throw new ArgumentNullException(nameof(request));
- await SendHttpRequest<StoreUserData>($"api/v1/stores/{storeId}/users", request, HttpMethod.Post, token);
+ return await SendHttpRequest<AddStoreUserResult>($"api/v1/stores/{storeId}/users", request, HttpMethod.Post, token);
+ }
+
+ public virtual async Task AcceptStoreInvitation(string invitationToken, CancellationToken token = default)
+ {
+ await SendHttpRequest($"api/v1/invitations/{invitationToken}", null, HttpMethod.Post, token);
+ }
+
+ public virtual async Task<IEnumerable<StoreInvitationData>> GetStoreInvitations(string storeId, CancellationToken token = default)
+ {
+ return await SendHttpRequest<IEnumerable<StoreInvitationData>>($"api/v1/stores/{storeId}/users/invitations", null, HttpMethod.Get, token);
+ }
+
+ public virtual async Task<StoreInvitationData> GetStoreInvitation(string invitationToken, CancellationToken token = default)
+ {
+ return await SendHttpRequest<StoreInvitationData>($"api/v1/invitations/{invitationToken}", null, HttpMethod.Get, token);
+ }
+
+ public virtual async Task<AddStoreUserResult.InvitationResult> ResendStoreInvitation(string storeId, string userId, CancellationToken token = default)
+ {
+ return await SendHttpRequest<AddStoreUserResult.InvitationResult>($"api/v1/stores/{storeId}/users/{userId}/invitation", null, HttpMethod.Post, token);
+ }
+
+ public virtual async Task CancelStoreInvitation(string storeId, string userId, CancellationToken token = default)
+ {
+ await SendHttpRequest($"api/v1/stores/{storeId}/users/{userId}/invitation", null, HttpMethod.Delete, token);
+ }
+
+ public virtual async Task DeclineStoreInvitation(string invitationToken, CancellationToken token = default)
+ {
+ await SendHttpRequest($"api/v1/invitations/{invitationToken}", null, HttpMethod.Delete, token);
}
- public virtual async Task UpdateStoreUser(string storeId, string userId, StoreUserData request, CancellationToken token = default)
+ public virtual async Task UpdateStoreUser(string storeId, string userId, StoreUserDataRequest request, CancellationToken token = default)
{
if (request == null) throw new ArgumentNullException(nameof(request));
- await SendHttpRequest<StoreUserData>($"api/v1/stores/{storeId}/users/{userId}", request, HttpMethod.Put, token);
+ await SendHttpRequest<StoreUserDataRequest>($"api/v1/stores/{storeId}/users/{userId}", request, HttpMethod.Put, token);
}
}
### BTCPayServer.Client/Models/StoreData.cs
@@ -1,4 +1,7 @@
+using System;
using System.Collections.Generic;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{
@@ -10,12 +13,53 @@ public class StoreData : StoreBaseData
public string Id { get; set; }
}
- public class StoreUserData : ApplicationUserData
+ public class StoreUserData
{
+ public string Id { get; set; }
+ public string Email { get; set; }
+ public string RoleId { get; set; }
+ }
+
+ public class StoreUserDataRequest
+ {
+ public string Id { get; set; }
/// <summary>
/// the store role of the user
/// </summary>
public string StoreRole { get; set; }
+ [JsonExtensionData]
+ public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
+ }
+ public class AddStoreUserDataRequest : StoreUserDataRequest
+ {
+ public bool? RequireInvitation { get; set; }
+ }
+
+ public class AddStoreUserResult
+ {
+ public class InvitationResult
+ {
+ public string Token { get; set; }
+ public string Link { get; set; }
+ }
+ public InvitationResult StoreInvitation {
+ get;
+ set;
+ }
+ }
+
+ public class StoreInvitationData
+ {
+ public string StoreId { get; set; }
+ public string StoreName { get; set; }
+ public string UserId { get; set; }
+ public string UserEmail { get; set; }
+ public string RoleId { get; set; }
+ public string InvitedByUserId { get; set; }
+ public DateTimeOffset Created { get; set; }
+ public DateTimeOffset ExpiresAt { get; set; }
+ public bool IsExpired { get; set; }
+ public bool IsForCurrentUser { get; set; }
}
public class RoleData
### BTCPayServer.Data/Data/StoreInvitation.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace BTCPayServer.Data
+{
+ public record StoreInvitationRow(
+ string StoreId,
+ string UserId,
+ string RoleId,
+ string InvitedByUserId,
+ DateTime Created,
+ DateTime ExpiresAt)
+ {
+ public static readonly TimeSpan Lifetime = TimeSpan.FromHours(24);
+ public bool IsExpired() => ExpiresAt < DateTime.UtcNow;
+ public static string HashToken(string token)
+ => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant();
+ }
+ public sealed record StoreInvitationExtendedRow(
+ string StoreId,
+ string UserId,
+ string RoleId,
+ string InvitedByUserId,
+ DateTime Created,
+ DateTime ExpiresAt,
+ string UserEmail,
+ string StoreName) : StoreInvitationRow(StoreId, UserId, RoleId, InvitedByUserId, Created, ExpiresAt);
+}
### BTCPayServer.Data/Migrations/20260828051627_StoreInvitations.cs
@@ -0,0 +1,36 @@
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BTCPayServer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260828051627_StoreInvitations")]
+ public partial class StoreInvitations : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("""
+ CREATE TABLE store_invitations (
+ user_id text NOT NULL,
+ store_id text NOT NULL,
+ role_id text NULL,
+ invited_by_user_id text NULL,
+ created timestamp with time zone NOT NULL,
+ expires_at timestamp with time zone NOT NULL,
+ token_hash text NOT NULL,
+ CONSTRAINT pk_store_invitations PRIMARY KEY (user_id, store_id),
+ CONSTRAINT fk_store_invitations_user_id FOREIGN KEY (user_id) REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE,
+ CONSTRAINT fk_store_invitations_store_id FOREIGN KEY (store_id) REFERENCES "Stores" ("Id") ON DELETE CASCADE,
+ CONSTRAINT fk_store_invitations_role_id FOREIGN KEY (role_id) REFERENCES "StoreRoles" ("Id") ON DELETE CASCADE,
+ CONSTRAINT uq_store_invitations_token_hash UNIQUE (token_hash)
+ );
+ CREATE INDEX ix_store_invitations_store_id ON store_invitations (store_id);
+ CREATE INDEX ix_store_invitations_role_id ON store_invitations (role_id);
+ """);
+ }
+ }
+}
### BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "10.0.6")
+ .HasAnnotation("ProductVersion", "10.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -848,12 +848,12 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTimeOffset?>("Expiry")
.HasColumnType("timestamp with time zone");
- b.PrimitiveCollection<string[]>("OutpointsUsed")
- .HasColumnType("text[]");
-
b.Property<string>("NoSignatureTransactionId")
.HasColumnType("text");
+ b.PrimitiveCollection<string[]>("OutpointsUsed")
+ .HasColumnType("text[]");
+
b.Property<int>("State")
.HasColumnType("integer");
@@ -871,10 +871,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasKey("Id");
- b.HasIndex("StoreId");
-
b.HasIndex("NoSignatureTransactionId");
+ b.HasIndex("StoreId");
+
b.ToTable("PendingTransactions");
});
### BTCPayServer.Tests/GreenfieldAPITests.cs
@@ -235,7 +235,7 @@ await AssertHttpError(403,
await Assert.ThrowsAsync<GreenfieldAPIException>(() => newUserClient.GetInvoices(store.Id));
// if user is a guest or owner, then it should be ok
- await unrestricted.AddStoreUser(store.Id, new StoreUserData() { Id = newUser.Id });
+ await unrestricted.AddStoreUser(store.Id, new AddStoreUserDataRequest { Id = newUser.Id, RequireInvitation = false });
await newUserClient.GetInvoices(store.Id);
}
@@ -3289,18 +3289,16 @@ await client.UpdateCurrentUser(new UpdateApplicationUserRequest
var users = await client.GetStoreUsers(user.StoreId);
var storeUser = Assert.Single(users);
Assert.Equal(user.UserId, storeUser.Id);
- Assert.Equal(user.UserId, storeUser.AdditionalData["userId"].ToString());
- Assert.Equal(ownerRole.Id, storeUser.StoreRole);
- Assert.Equal(ownerRole.Id, storeUser.AdditionalData["role"].ToString());
+ Assert.Equal(ownerRole.Id, storeUser.RoleId);
Assert.Equal(user.Email, storeUser.Email);
- Assert.Equal("The Admin", storeUser.Name);
- Assert.Equal("avatar.jpg", storeUser.ImageUrl);
var manager = tester.NewAccount();
await manager.GrantAccessAsync();
var employee = tester.NewAccount();
await employee.GrantAccessAsync();
var guest = tester.NewAccount();
await guest.GrantAccessAsync();
+ var invited = tester.NewAccount();
+ await invited.GrantAccessAsync();
var managerClient = await manager.CreateClient(Policies.CanModifyStoreSettings);
var employeeClient = await employee.CreateClient(Policies.CanModifyStoreSettings);
@@ -3309,70 +3307,138 @@ await client.UpdateCurrentUser(new UpdateApplicationUserRequest
//test no access to api when unrelated to store at all
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await managerClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await managerClient.GetStoreUsers(user.StoreId));
- await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.AddStoreUser(user.StoreId, new StoreUserData()));
+ await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.AddStoreUser(user.StoreId, new AddStoreUserDataRequest()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStoreUsers(user.StoreId));
- await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.AddStoreUser(user.StoreId, new StoreUserData()));
+ await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.AddStoreUser(user.StoreId, new AddStoreUserDataRequest()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.RemoveStoreUser(user.StoreId, user.UserId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStoreUsers(user.StoreId));
- await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.AddStoreUser(user.StoreId, new StoreUserData()));
+ await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.AddStoreUser(user.StoreId, new AddStoreUserDataRequest()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.RemoveStoreUser(user.StoreId, user.UserId));
// add users to store
- await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = managerRole.Id, Id = manager.UserId });
- await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = employeeRole.Id, Id = employee.UserId });
+ await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest { StoreRole = managerRole.Id, Id = manager.UserId, RequireInvitation = false });
+ await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest { StoreRole = employeeRole.Id, Id = employee.UserId, RequireInvitation = false });
// add with email
- await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = guestRole.Id, Id = guest.Email });
+ await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest { StoreRole = guestRole.Id, Id = guest.Email, RequireInvitation = false });
+
+ // invite and accept with profile permission
+ var invitedClient = await invited.CreateClient(Policies.CanModifyProfile, Policies.CanViewStoreSettings);
+ var invitedClientWithoutProfilePermission = await invited.CreateClient(Policies.CanViewStoreSettings);
+ await AssertPermissionError(Policies.CanViewStoreSettings, async () => await invitedClient.GetStoreUsers(user.StoreId));
+ var invite = await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest { StoreRole = managerRole.Id, Id = invited.UserId });
+ var pending = Assert.Single(await client.GetStoreInvitations(user.StoreId));
+ Assert.Equal(invited.UserId, pending.UserId);
+ Assert.Equal(invited.Email, pending.UserEmail);
+ Assert.Equal(managerRole.Id, pending.RoleId);
+ Assert.False(pending.IsExpired);
+
+ var invitationDetails = await invitedClient.GetStoreInvitation(invite.StoreInvitation.Token);
+ Assert.Equal(user.StoreId, invitationDetails.StoreId);
+ Assert.Equal(invited.UserId, invitationDetails.UserId);
+ Assert.True(invitationDetails.IsForCurrentUser);
+
+ var otherProfileClient = await guest.CreateClient(Policies.CanModifyProfile);
+ await AssertAPIError("store-invitation-not-found", async () => await otherProfileClient.GetStoreInvitation(invite.StoreInvitation.Token));
+ await AssertAPIError("store-invitation-not-found", async () => await otherProfileClient.AcceptStoreInvitation(invite.StoreInvitation.Token));
+ await AssertAPIError("store-invitation-not-found", async () => await otherProfileClient.DeclineStoreInvitation(invite.StoreInvitation.Token));
+
+ var resent = await client.ResendStoreInvitation(user.StoreId, invited.UserId);
+ Assert.NotEqual(invite.StoreInvitation.Token, resent.Token);
+ await AssertAPIError("store-invitation-not-found", async () => await invitedClient.GetStoreInvitation(invite.StoreInvitation.Token));
+ await AssertAPIError("store-invitation-not-found", async () => await invitedClient.DeclineStoreInvitation(invite.StoreInvitation.Token));
+ await invitedClient.DeclineStoreInvitation(resent.Token);
+ Assert.Empty(await client.GetStoreInvitations(user.StoreId));
+
+ invite = await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest { StoreRole = managerRole.Id, Id = invited.UserId });
+ await AssertPermissionError(Policies.CanModifyProfile, async () => await invitedClientWithoutProfilePermission.AcceptStoreInvitation(invite.StoreInvitation.Token));
+ await invitedClient.AcceptStoreInvitation(invite.StoreInvitation.Token);
+ var storeUsersAfterInvitation = await invitedClient.GetStoreUsers(user.StoreId);
+ var invitedStoreUser = storeUsersAfterInvitation.Single(u => u.Id == invited.UserId);
+ Assert.Equal(managerRole.Id, invitedStoreUser.RoleId);
+
+ var cancelled = tester.NewAccount();
+ await cancelled.GrantAccessAsync();
+ await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest { StoreRole = guestRole.Id, Id = cancelled.UserId });
+ await client.CancelStoreInvitation(user.StoreId, cancelled.UserId);
+ Assert.Empty(await client.GetStoreInvitations(user.StoreId));
+
+ // A failed membership insert must not consume the invitation.
+ var conflicting = tester.NewAccount();
+ await conflicting.GrantAccessAsync();
+ var conflictingClient = await conflicting.CreateClient(Policies.CanModifyProfile);
+ var conflictingInvite = await client.AddStoreUser(user.StoreId,
+ new AddStoreUserDataRequest { StoreRole = guestRole.Id, Id = conflicting.UserId });
+ await using (var ctx = tester.PayTester.GetService<ApplicationDbContextFactory>().CreateContext())
+ {
+ ctx.UserStore.Add(new UserStore
+ {
+ StoreDataId = user.StoreId,
+ ApplicationUserId = conflicting.UserId,
+ StoreRoleId = guestRole.Id
+ });
+ await ctx.SaveChangesAsync();
+ }
+ await AssertAPIError("already-store-user", async () =>
+ await conflictingClient.AcceptStoreInvitation(conflictingInvite.StoreInvitation.Token));
+ Assert.Equal(conflicting.UserId, Assert.Single(await client.GetStoreInvitations(user.StoreId)).UserId);
+ await client.CancelStoreInvitation(user.StoreId, conflicting.UserId);
+
+ var unknownEmail = $"{Guid.NewGuid():N}@example.com";
+ await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest { StoreRole = guestRole.Id, Id = unknownEmail });
+ var unknownInvitation = Assert.Single(await client.GetStoreInvitations(user.StoreId));
+ Assert.Equal(unknownEmail, unknownInvitation.UserEmail);
+ await client.CancelStoreInvitation(user.StoreId, unknownInvitation.UserId);
// test unknown user
- await AssertAPIError("user-not-found", async () => await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = managerRole.Id, Id = "unknown" }));
- await AssertAPIError("user-not-found", async () => await client.UpdateStoreUser(user.StoreId, "unknown", new StoreUserData { StoreRole = ownerRole.Id }));
+ await AssertAPIError("user-not-found", async () => await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest { StoreRole = managerRole.Id, Id = "unknown" }));
+ await AssertAPIError("user-not-found", async () => await client.UpdateStoreUser(user.StoreId, "unknown", new StoreUserDataRequest { StoreRole = ownerRole.Id }));
await AssertAPIError("user-not-found", async () => await client.RemoveStoreUser(user.StoreId, "unknown"));
//test no access to api for employee
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStoreUsers(user.StoreId));
- await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.AddStoreUser(user.StoreId, new StoreUserData()));
+ await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.AddStoreUser(user.StoreId, new AddStoreUserDataRequest()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.RemoveStoreUser(user.StoreId, user.UserId));
//test no access to api for guest
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStoreUsers(user.StoreId));
- await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.AddStoreUser(user.StoreId, new StoreUserData()));
+ await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.AddStoreUser(user.StoreId, new AddStoreUserDataRequest()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.RemoveStoreUser(user.StoreId, user.UserId));
//test access to api for manager
await managerClient.GetStore(user.StoreId);
await managerClient.GetStoreUsers(user.StoreId);
- await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.AddStoreUser(user.StoreId, new StoreUserData()));
+ await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.AddStoreUser(user.StoreId, new AddStoreUserDataRequest()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
// updates
- await client.UpdateStoreUser(user.StoreId, employee.UserId, new StoreUserData { StoreRole = managerRole.Id });
+ await client.UpdateStoreUser(user.StoreId, employee.UserId, new StoreUserDataRequest { StoreRole = managerRole.Id });
await employeeClient.GetStore(user.StoreId);
- await AssertAPIError("store-user-role-orphaned", async () => await client.UpdateStoreUser(user.StoreId, user.UserId, new StoreUserData { StoreRole = managerRole.Id }));
+ await AssertAPIError("store-user-role-orphaned", async () => await client.UpdateStoreUser(user.StoreId, user.UserId, new StoreUserDataRequest { StoreRole = managerRole.Id }));
// remove
await client.RemoveStoreUser(user.StoreId, employee.UserId);
await AssertHttpError(403, async () => await employeeClient.GetStore(user.StoreId));
await AssertAPIError("store-user-role-orphaned", async () => await client.RemoveStoreUser(user.StoreId, user.UserId));
// test duplicate add
- await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = ownerRole.Id, Id = employee.UserId });
- await AssertAPIError("duplicate-store-user-role", async () =>
- await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = ownerRole.Id, Id = employee.UserId }));
+ await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest { StoreRole = ownerRole.Id, Id = employee.UserId, RequireInvitation = false });
+ await AssertAPIError("already-store-user", async () =>
+ await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest { StoreRole = ownerRole.Id, Id = employee.UserId, RequireInvitation = false }));
await employeeClient.RemoveStoreUser(user.StoreId, user.UserId);
//test no access to api when unrelated to store at all
await user.MakeAdmin(false);
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await client.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await client.GetStoreUsers(user.StoreId));
- await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.AddStoreUser(user.StoreId, new StoreUserData()));
+ await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.AddStoreUser(user.StoreId, new AddStoreUserDataRequest()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.RemoveStoreUser(user.StoreId, user.UserId));
await AssertAPIError("store-user-role-orphaned", async () => await employeeClient.RemoveStoreUser(user.StoreId, employee.UserId));
### BTCPayServer.Tests/PlaywrightTester.cs
@@ -57,7 +57,7 @@ public async Task StartAsync()
Headless = !string.IsNullOrEmpty(headless) && bool.Parse(headless),
ExecutablePath = conf["PLAYWRIGHT_EXECUTABLE"],
SlowMo = 0, // 50 if you want to slow down
- Args = ["--disable-frame-rate-limit"] // Fix slowness on linux (https://github.com/microsoft/playwright/issues/34625#issuecomment-2822015672)
+ Args = ["--disable-frame-rate-limit"], // Fix slowness on linux (https://github.com/microsoft/playwright/issues/34625#issuecomment-2822015672)
});
var context = await Browser.NewContextAsync();
Page = await context.NewPageAsync();
@@ -360,18 +360,26 @@ public async Task GoToHome()
else { await GoToUrl("/"); }
}
- public async Task AddUserToStore(string storeId, string email, string role)
+ public async Task AddUserToStore(string storeId, string email, string role, bool requireInvitation = false)
{
var addUser = Page.Locator("#AddUser");
if (!await addUser.IsVisibleAsync())
{
await GoToStore(storeId, StoreNavPages.Users);
}
+ if (!requireInvitation)
+ await Page.Locator(".store-users__require-invitation").UncheckAsync();
+
await Page.FillAsync("#Email", email);
await Page.SelectOptionAsync("#Role", role);
+ var requireInvitationToggle = Page.Locator(".store-users__require-invitation");
+ var canSkipInvitation = await requireInvitationToggle.IsVisibleAsync();
await Page.ClickAsync("#AddUser");
- await FindAlertMessage(partialText: "The user has been added successfully");
+ if (requireInvitation || !canSkipInvitation)
+ await Expect(Page.Locator("#StoreInvitationSent")).ToHaveCountAsync(1);
+ else
+ await FindAlertMessage(partialText: "The user has been added successfully");
}
public async Task LogIn(string user, string password = "123456")
### BTCPayServer.Tests/RolesTests.cs
@@ -41,51 +41,22 @@ public async Task CanChangeUserRoles()
await s.AddUserToStore(storeId, employee, "Employee");
// Should successfully change the role
- var userRows = await s.Page.Locator("#StoreUsersList tr").AllAsync();
- Assert.Equal(2, userRows.Count);
- ILocator employeeRow = null;
- foreach (var row in userRows)
- {
- if ((await row.InnerTextAsync()).Contains(employee, StringComparison.InvariantCultureIgnoreCase)) employeeRow = row;
- }
-
- Assert.NotNull(employeeRow);
- await employeeRow.Locator("a[data-bs-target='#EditModal']").ClickAsync();
- Assert.Equal(employee, await s.Page.InnerTextAsync("#EditUserEmail"));
- await s.Page.SelectOptionAsync("#EditUserRole", "Manager");
- await s.Page.ClickAsync("#EditContinue");
+ var userRows = s.Page.Locator(".store-users__row");
+ await Expect(userRows).ToHaveCountAsync(2);
+ var employeeRow = userRows.Filter(new() { HasText = employee });
+ await Expect(employeeRow).ToHaveCountAsync(1);
+ await employeeRow.Locator(".store-users__role-form select").SelectOptionAsync("Manager");
await s.FindAlertMessage(partialText: $"The role of {employee} has been changed to Manager.");
- // Should not see a message when not changing role
- userRows = await s.Page.Locator("#StoreUsersList tr").AllAsync();
- Assert.Equal(2, userRows.Count);
- employeeRow = null;
- foreach (var row in userRows)
- {
- if ((await row.InnerTextAsync()).Contains(employee, StringComparison.InvariantCultureIgnoreCase)) employeeRow = row;
- }
-
- Assert.NotNull(employeeRow);
- await employeeRow.Locator("a[data-bs-target='#EditModal']").ClickAsync();
- Assert.Equal(employee, await s.Page.InnerTextAsync("#EditUserEmail"));
- await s.Page.ClickAsync("#EditContinue");
- await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error, "The user already has the role Manager.");
-
- // Should not change last owner
- userRows = await s.Page.Locator("#StoreUsersList tr").AllAsync();
- Assert.Equal(2, userRows.Count);
- ILocator ownerRow = null;
- foreach (var row in userRows)
- {
- if ((await row.InnerTextAsync()).Contains(owner, StringComparison.InvariantCultureIgnoreCase)) ownerRow = row;
- }
+ userRows = s.Page.Locator(".store-users__row");
+ employeeRow = userRows.Filter(new() { HasText = employee });
+ await Expect(employeeRow.Locator(".store-users__role-form select")).ToHaveValueAsync("Manager");
- Assert.NotNull(ownerRow);
- await ownerRow.Locator("a[data-bs-target='#EditModal']").ClickAsync();
- Assert.Equal(owner, await s.Page.InnerTextAsync("#EditUserEmail"));
- await s.Page.SelectOptionAsync("#EditUserRole", "Employee");
- await s.Page.ClickAsync("#EditContinue");
- await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error, "The user is the last owner. Their role cannot be changed.");
+ // The last owner's role cannot be edited.
+ var ownerRow = userRows.Filter(new() { HasText = owner });
+ await Expect(ownerRow).ToHaveCountAsync(1);
+ await Expect(ownerRow.Locator(".store-users__role")).ToContainTextAsync("Owner");
+ await Expect(ownerRow.Locator(".store-users__role-form")).ToHaveCountAsync(0);
}
[Fact]
@@ -264,10 +235,7 @@ public async Task CanUseRoleManager()
await s.Page.Locator("#Email").FillAsync(s.AsTestAccount().Email);
await s.Page.Locator("#Role").SelectOptionAsync("Owner");
await s.Page.ClickAsync("#AddUser");
- Assert.Contains("The user already has the role Owner.", await s.Page.Locator(".validation-summary-errors").TextContentAsync());
- await s.Page.Locator("#Role").SelectOptionAsync("Manager");
- await s.Page.ClickAsync("#AddUser");
- Assert.Contains("The user is the last owner. Their role cannot be changed.", await s.Page.Locator(".validation-summary-errors").TextContentAsync());
+ await Expect(s.Page.Locator(".validation-summary-errors")).ToContainTextAsync("The user already has access to this store.");
await s.GoToStore(StoreNavPages.Roles);
await s.ClickPagePrimary();
### BTCPayServer.Tests/StoreInvitationTests.cs
@@ -0,0 +1,287 @@
+using System;
+using System.Threading.Tasks;
+using BTCPayServer.Services;
+using BTCPayServer.Views.Stores;
+using Microsoft.Playwright;
+using Xunit;
+using static Microsoft.Playwright.Assertions;
+
+namespace BTCPayServer.Tests;
+
+[Collection(nameof(NonParallelizableCollectionDefinition))]
+public class StoreInvitationTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testOutputHelper)
+{
+ [Fact]
+ [Trait("Playwright", "Playwright")]
+ public async Task CanInviteExistingUserToStore()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+
+ // The first registered user is always made a server admin, so burn that slot before
+ // creating the two plain users this scenario is about.
+ await s.RegisterNewUser(true);
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var invitee = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ await s.RegisterNewUser();
+ var (_, storeId) = await s.CreateNewStore();
+ await s.GoToStore(storeId, StoreNavPages.Users);
+
+ // A store owner is not offered the bypass at all.
+ await Expect(s.Page.Locator(".store-users__require-invitation")).ToHaveCountAsync(0);
+
+ // Sending an invite lands on a wizard-layout confirmation carrying the link and QR.
+ var invitationUrl = await InviteToStore(s, invitee);
+ Assert.Contains("/invitations/", invitationUrl);
+
+ // The owner is not the invitee, so their own link explains itself instead of 404ing.
+ await s.GoToUrl(invitationUrl);
+ await Expect(s.Page.Locator(".store-invitation__wrong-user")).ToHaveCountAsync(1);
+ await Expect(s.Page.Locator(".store-invitation__accept")).ToHaveCountAsync(0);
+ await s.GoToStore(storeId, StoreNavPages.Users);
+
+ // The invitee shows up in the single users table as pending, not as a member.
+ var row = s.Page.Locator($"#StoreUsersList tr:has-text('{invitee}')");
+ await Expect(row).ToHaveCountAsync(1);
+ await Expect(row.Locator(".store-users__status")).ToContainTextAsync("Pending");
+
+ // The invitee has no access until they accept.
+ await s.Logout();
+ await s.LogIn(invitee);
+ await s.GoToUrl($"/stores/{storeId}");
+ Assert.Contains("ReturnUrl", s.Page.Url);
+
+ // Accepting happens on the invitation link, which uses the wizard layout.
+ await s.GoToUrl(invitationUrl);
+ await Expect(s.Page.Locator("#mainNav")).ToHaveCountAsync(0);
+ await s.Page.ClickAsync(".store-invitation__accept");
+ await s.FindAlertMessage(partialText: "You have joined");
+ // Employee has no store settings permission, so acceptance lands on the store list.
+ // That page must stay reachable for such a role, or joining ends in an access error.
+ Assert.EndsWith("/stores", s.Page.Url);
+ Assert.DoesNotContain("403", s.Page.Url);
+ await s.GoToUrl($"/stores/{storeId}/invoices");
+ Assert.DoesNotContain("ReturnUrl", s.Page.Url);
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright")]
+ public async Task CanDeclineAndCancelStoreInvitations()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+
+ await s.RegisterNewUser(true);
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var invitee = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var owner = await s.RegisterNewUser();
+ var (_, storeId) = await s.CreateNewStore();
+ await s.GoToStore(storeId, StoreNavPages.Users);
+ var invitationUrl = await InviteToStore(s, invitee);
+
+ // The invitee declines: the row disappears for the store too.
+ await s.Logout();
+ await s.LogIn(invitee);
+ await s.GoToUrl(invitationUrl);
+ await s.Page.ClickAsync(".store-invitation__decline");
+ await s.FindAlertMessage(partialText: "Invitation declined");
+
+ await s.Logout();
+ await s.LogIn(owner);
+ await s.GoToStore(storeId, StoreNavPages.Users);
+ await Expect(s.Page.Locator($"#StoreUsersList tr:has-text('{invitee}')")).ToHaveCountAsync(0);
+
+ // Re-invite, then the store withdraws it.
+ await InviteToStore(s, invitee);
+ await s.Page.ClickAsync(".store-users__cancel");
+ await s.FindAlertMessage(partialText: "Invitation cancelled");
+ await Expect(s.Page.Locator($"#StoreUsersList tr:has-text('{invitee}')")).ToHaveCountAsync(0);
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright")]
+ public async Task CanResendStoreInvitation()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+
+ await s.RegisterNewUser(true);
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var invitee = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ await s.RegisterNewUser();
+ var (_, storeId) = await s.CreateNewStore();
+ await s.GoToStore(storeId, StoreNavPages.Users);
+ var first = await InviteToStore(s, invitee);
+
+ await s.Page.ClickAsync(".store-users__resend");
+ await Expect(s.Page.Locator("#StoreInvitationSent")).ToHaveCountAsync(1);
+ var resent = await s.Page.Locator("#InvitationUrl").GetAttributeAsync("data-text");
+ Assert.NotEqual(first, resent);
+ await s.Page.ClickAsync("#BackToStoreUsers");
+
+ // Resending replaces the pending invitation, invalidating any previously shared link.
+ await s.Logout();
+ await s.LogIn(invitee);
+ await s.GoToUrl(first, ignoreResponse: true);
+ await Expect(s.Page.Locator(".store-invitation__accept")).ToHaveCountAsync(0);
+
+ await s.GoToUrl(resent!);
+ await Expect(s.Page.Locator(".store-invitation__accept")).ToHaveCountAsync(1);
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright")]
+ public async Task ServerAdminCanAddUserWithoutInvitation()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+
+ var invitee = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ await s.RegisterNewUser(true);
+ var (_, storeId) = await s.CreateNewStore();
+ await s.GoToStore(storeId, StoreNavPages.Users);
+
+ // Requiring an invitation is the default, even for a server admin.
+ var requireInvitation = s.Page.Locator(".store-users__require-invitation");
+ await Expect(requireInvitation).ToHaveCountAsync(1);
+ Assert.True(await requireInvitation.IsCheckedAsync());
+
+ await requireInvitation.UncheckAsync();
+ await s.AddUserToStore(storeId, invitee, "Guest");
+ var row = s.Page.Locator($"#StoreUsersList tr:has-text('{invitee}')");
+ await Expect(row.Locator(".store-users__status")).ToContainTextAsync("Active");
+
+ // Ticking it makes even an admin go through the invitation.
+ var second = await CreateOtherUser(s);
+ await s.GoToStore(storeId, StoreNavPages.Users);
+ await requireInvitation.CheckAsync();
+ await InviteToStore(s, second, "Guest");
+
+ // Granting membership directly while an invitation is outstanding drops the invitation,
+ // so accepting it later cannot overwrite the role that was just set.
+ await requireInvitation.UncheckAsync();
+ await s.AddUserToStore(storeId, second, "Manager");
+ var secondRow = s.Page.Locator($"#StoreUsersList tr:has-text('{second}')");
+ await Expect(secondRow).ToHaveCountAsync(1);
+ await Expect(secondRow.Locator(".store-users__status")).ToContainTextAsync("Active");
+
+ // A new account keeps the server invitation flow and receives store access after signup.
+ await s.GoToStore(storeId, StoreNavPages.Users);
+ await s.Page.FillAsync("#Email", $"{Guid.NewGuid().ToString()[..12]}@example.com");
+ await s.Page.ClickAsync("#AddUser");
+ var alert = await s.FindAlertMessage(partialText: "The user has been added successfully");
+ var accountInvitationUrl = await alert.Locator("a.alert-link[href*='/invite/']").GetAttributeAsync("href");
+ Assert.NotNull(accountInvitationUrl);
+ Assert.DoesNotContain("/invitation/", accountInvitationUrl);
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright")]
+ public async Task NonAdminStoreOwnerCanCreateAndAddNewUser()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+
+ // The first registered user is always a server admin, so create the store with a plain user.
+ await s.RegisterNewUser(true);
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ await s.RegisterNewUser();
+ Assert.False(s.IsAdmin);
+
+ var (_, storeId) = await s.CreateNewStore();
+ await s.GoToStore(storeId, StoreNavPages.Users);
+ await Expect(s.Page.Locator(".store-users__require-invitation")).ToHaveCountAsync(0);
+
+ var email = $"{Guid.NewGuid().ToString()[..12]}@example.com";
+ await s.Page.FillAsync("#Email", email);
+ await s.Page.SelectOptionAsync("#Role", "Guest");
+ await s.Page.ClickAsync("#AddUser");
+
+ var alert = await s.FindAlertMessage(partialText: "The user has been added successfully");
+ var accountInvitationUrl = await alert.Locator("a.alert-link[href*='/invite/']").GetAttributeAsync("href");
+ Assert.NotNull(accountInvitationUrl);
+ Assert.DoesNotContain("/invitation/", accountInvitationUrl);
+
+ var row = s.Page.Locator(".store-users__row").Filter(new() { HasText = email });
+ await Expect(row).ToHaveCountAsync(1);
+ await Expect(row.Locator(".store-users__status")).ToContainTextAsync("Active");
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright")]
+ public async Task StoreOwnerCanSkipInvitationWhenServerAllowsIt()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+
+ var settings = s.Server.PayTester.GetService<SettingsRepository>();
+ await settings.UpdateSetting(new PoliciesSettings { AllowStoreOwnersToSkipInvitation = true });
+
+ await s.RegisterNewUser(true);
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var invitee = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ await s.RegisterNewUser();
+ var (_, storeId) = await s.CreateNewStore();
+ await s.GoToStore(storeId, StoreNavPages.Users);
+
+ // The store owner is offered the bypass now, but inviting is still what they get by default.
+ var requireInvitation = s.Page.Locator(".store-users__require-invitation");
+ await Expect(requireInvitation).ToHaveCountAsync(1);
+ Assert.True(await requireInvitation.IsCheckedAsync());
+
+ await requireInvitation.UncheckAsync();
+ await s.AddUserToStore(storeId, invitee, "Guest");
+ var row = s.Page.Locator($"#StoreUsersList tr:has-text('{invitee}')");
+ await Expect(row.Locator(".store-users__status")).ToContainTextAsync("Active");
+ }
+
+ /// <summary>Sends an invite and returns its link, leaving the browser back on the users page.</summary>
+ private static async Task<string> InviteToStore(PlaywrightTester s, string email, string role = "Employee")
+ {
+ await s.Page.FillAsync("#Email", email);
+ await s.Page.SelectOptionAsync("#Role", role);
+ await s.Page.ClickAsync("#AddUser");
+ await Expect(s.Page.Locator("#StoreInvitationSent")).ToHaveCountAsync(1);
+ var url = await s.Page.Locator("#InvitationUrl").GetAttributeAsync("data-text");
+ await s.Page.ClickAsync("#BackToStoreUsers");
+ return url;
+ }
+
+ private static async Task<string> CreateOtherUser(PlaywrightTester s)
+ {
+ var current = s.CreatedUser;
+ var password = s.Password;
+ await s.Logout();
+ await s.GoToRegister();
+ var other = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.LogIn(current, password);
+ return other;
+ }
+}
### BTCPayServer/Controllers/GreenField/GreenfieldStoreUsersController.cs
@@ -1,140 +1,257 @@
-using System.Collections.Generic;
+using System;
+using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
+using BTCPayServer.Events;
+using BTCPayServer.Plugins.Emails.Services;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
-using Newtonsoft.Json.Linq;
using static BTCPayServer.Services.Stores.StoreRepository;
-using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
- public class GreenfieldStoreUsersController : ControllerBase
+ public class GreenfieldStoreUsersController(
+ StoreRepository storeRepository,
+ UserManager<ApplicationUser> userManager,
+ CallbackGenerator callbackGenerator,
+ IAuthorizationService authorizationService,
+ PoliciesSettings policiesSettings,
+ EventAggregator eventAggregator,
+ EmailSenderFactory emailSenderFactory)
+ : ControllerBase
{
- private readonly IAuthorizationService _authorizationService;
- private readonly StoreRepository _storeRepository;
- private readonly UserManager<ApplicationUser> _userManager;
- private readonly CallbackGenerator _callbackGenerator;
- private readonly UriResolver _uriResolver;
-
- public GreenfieldStoreUsersController(
- StoreRepository storeRepository,
- UserManager<ApplicationUser> userManager,
- CallbackGenerator callbackGenerator,
- UriResolver uriResolver,
- IAuthorizationService authorizationService)
+ [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpGet("~/api/v1/stores/{storeId}/users")]
+ public async Task<IActionResult> GetStoreUsers(string storeId)
+ => Ok((await storeRepository.GetStoreUsers(storeId))
+ .Select(u => new StoreUserData()
+ {
+ Id = u.Id,
+ Email = u.Email,
+ RoleId = u.StoreRole.Id,
+ }));
+
+ [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpGet("~/api/v1/stores/{storeId}/users/invitations")]
+ public async Task<IActionResult> GetStoreInvitations(string storeId)
+ => Ok((await storeRepository.GetStoreInvitations(storeId)).Select(ToStoreInvitationData));
+
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpPost("~/api/v1/stores/{storeId}/users/{idOrEmail}/invitation")]
+ public async Task<IActionResult> ResendStoreInvitation(string storeId, string idOrEmail)
{
- _storeRepository = storeRepository;
- _userManager = userManager;
- _callbackGenerator = callbackGenerator;
- _authorizationService = authorizationService;
- _uriResolver = uriResolver;
+ var user = await userManager.FindByIdOrEmail(idOrEmail);
+ if (user is null)
+ return UserNotFound();
+ var invitation = await storeRepository.ResendStoreInvitation(storeId, user.Id);
+ if (invitation is null)
+ return StoreInvitationNotFound();
+ return Ok(await NotifyStoreInvitation(invitation));
}
- [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [HttpGet("~/api/v1/stores/{storeId}/users")]
- public async Task<IActionResult> GetStoreUsers()
- => Ok(await ToAPI(HttpContext.GetStoreData()));
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpDelete("~/api/v1/stores/{storeId}/users/{idOrEmail}/invitation")]
+ public async Task<IActionResult> CancelStoreInvitation(string storeId, string idOrEmail)
+ {
+ var user = await userManager.FindByIdOrEmail(idOrEmail);
+ if (user is null)
+ return UserNotFound();
+ return await storeRepository.DeleteStoreInvitation(storeId, user.Id)
+ ? Ok()
+ : StoreInvitationNotFound();
+ }
+
+ [Authorize(Policy = Policies.CanModifyProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpGet("~/api/v1/invitations/{token}")]
+ public async Task<IActionResult> GetStoreInvitation(string token)
+ {
+ var invitation = await storeRepository.GetStoreInvitationByToken(token, userId: User.GetId());
+ return invitation is null? StoreInvitationNotFound()
+ : Ok(ToStoreInvitationData(invitation));
+ }
+
+ [Authorize(Policy = Policies.CanModifyProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpDelete("~/api/v1/invitations/{token}")]
+ public async Task<IActionResult> DeclineStoreInvitation(string token)
+ => await storeRepository.DeleteStoreInvitationByToken(User.GetId(), token)
+ ? Ok()
+ : StoreInvitationNotFound();
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/stores/{storeId}/users/{idOrEmail}")]
public async Task<IActionResult> RemoveStoreUser(string storeId, string idOrEmail)
{
- var user = await _userManager.FindByIdOrEmail(idOrEmail);
+ var user = await userManager.FindByIdOrEmail(idOrEmail);
if (user == null)
return UserNotFound();
- return await _storeRepository.RemoveStoreUser(storeId, user.Id)
+ return await storeRepository.RemoveStoreUser(storeId, user.Id)
? Ok()
: this.CreateAPIError(409, "store-user-role-orphaned", "Removing this user would result in the store having no owner.");
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/users")]
- [HttpPut("~/api/v1/stores/{storeId}/users/{idOrEmail?}")]
- public async Task<IActionResult> AddOrUpdateStoreUser(string storeId, StoreUserData request, string idOrEmail = null)
+ public async Task<IActionResult> AddStoreUser(string storeId, AddStoreUserDataRequest request)
{
- // Deprecated properties
- request.StoreRole ??= request.AdditionalData.TryGetValue("role", out var role) ? role.ToString() : null;
- request.Id ??= request.AdditionalData.TryGetValue("userId", out var userId) ? userId.ToString() : null;
+ var requireInvitation = request.RequireInvitation ?? true;
+ if (!requireInvitation && !(await authorizationService.AuthorizeAsync(User, null, Policies.CanModifyServerSettings)).Succeeded)
+ return this.CreateAPIPermissionError(Policies.CanModifyServerSettings, "You are not allowed to add users without invitation");
- var user = await _userManager.FindByIdOrEmail(idOrEmail ?? request.Id);
- if (user == null)
- return UserNotFound();
+ var (error, user, roleId) = await GetStoreUserRequest(storeId, request, null, createUser: true);
+ if (error is not null)
+ return error;
- if (await _userManager.IsInRoleAsync(user, Roles.ServerAdmin) &&
- !(await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded)
- return this.CreateAPIPermissionError(Policies.CanModifyServerSettings,
- "Only a server admin can add or update the store membership of a server admin");
+ var storeUser = await storeRepository.GetStoreUser(storeId, user.Id);
+ if (storeUser is not null)
+ return AlreadyStoreUser();
- StoreRoleId roleId = null;
- if (request.StoreRole is not null)
+ if (!requireInvitation)
{
- roleId = await _storeRepository.ResolveStoreRoleId(storeId, request.StoreRole);
- if (roleId is null)
- ModelState.AddModelError(nameof(request.StoreRole), "The role id provided does not exist");
+ return await storeRepository.AddStoreUser(storeId, user.Id, roleId)
+ ? Ok(new AddStoreUserResult())
+ : this.CreateAPIError(409, "duplicate-store-user-role", "The user is already added to the store");
+ }
+ else
+ {
+ var res = await storeRepository.CreateStoreInvitation(storeId, user.Id, roleId, User.GetId());
+ return res switch
+ {
+ CreateStoreInvitationResult.AlreadyMember => AlreadyStoreUser(),
+ CreateStoreInvitationResult.InvalidRole => this.CreateAPIError(400, "invalid-role", "The role is invalid"),
+ CreateStoreInvitationResult.Success s => Ok(new AddStoreUserResult { StoreInvitation = await NotifyStoreInvitation(s.Invitation) }),
+ _ => throw new InvalidOperationException(res.ToString())
+ };
}
+ }
- if (!ModelState.IsValid)
- return this.CreateValidationError(ModelState);
+ private IActionResult AlreadyStoreUser() => this.CreateAPIError(409, "already-store-user", "The user is already added to the store");
- AddOrUpdateStoreUserResult res;
- if (string.IsNullOrEmpty(idOrEmail))
+ [Authorize(Policy = Policies.CanModifyProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpPost("~/api/v1/invitations/{token}")]
+ public async Task<IActionResult> AcceptStoreInvitation(string token)
+ {
+ var res = await storeRepository.AcceptStoreInvitation(User.GetId(), token);
+ return res switch
{
- res = await _storeRepository.AddStoreUser(storeId, user.Id, roleId) ? new AddOrUpdateStoreUserResult.Success() : new AddOrUpdateStoreUserResult.DuplicateRole(roleId);
- }
- else
+ AddOrUpdateStoreUserResult.Success => Ok(),
+ AddOrUpdateStoreUserResult.DuplicateRole => AlreadyStoreUser(),
+ AddOrUpdateStoreUserResult.Expired => this.CreateAPIError(410, "store-invitation-expired", "The invitation has expired"),
+ AddOrUpdateStoreUserResult.InvalidRole => this.CreateAPIError(400, "invalid-role", "The role is invalid"),
+ null => this.CreateAPIError(404, "store-invitation-not-found", "The invitation was not found"),
+ _ => throw new InvalidOperationException(res.ToString())
+ };
+ }
+
+ private IActionResult StoreInvitationNotFound()
+ => this.CreateAPIError(404, "store-invitation-not-found", "The invitation was not found");
+
+ private StoreInvitationData ToStoreInvitationData(StoreInvitationExtendedRow i)
+ => new()
{
- res = await _storeRepository.AddOrUpdateStoreUser(storeId, user.Id, roleId);
- }
+ StoreId = i.StoreId,
+ StoreName = i.StoreName,
+ UserId = i.UserId,
+ UserEmail = i.UserEmail,
+ RoleId = i.RoleId,
+ InvitedByUserId = i.InvitedByUserId,
+ Created = i.Created,
+ ExpiresAt = i.ExpiresAt,
+ IsExpired = i.IsExpired(),
+ IsForCurrentUser = i.UserId == User.GetId()
+ };
+
+ private async Task<AddStoreUserResult.InvitationResult> NotifyStoreInvitation(GeneratedInvitation invitation)
+ {
+ var link = callbackGenerator.StoreInvitationLink(invitation.Token);
+ await storeRepository.NotifyStoreInvitation(invitation.Invitation, link);
+ return new AddStoreUserResult.InvitationResult { Token = invitation.Token, Link = link };
+ }
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpPut("~/api/v1/stores/{storeId}/users/{idOrEmail?}")]
+ public async Task<IActionResult> UpdateStoreUser(string storeId, StoreUserDataRequest request, string idOrEmail = null)
+ {
+ var (error, user, roleId) = await GetStoreUserRequest(storeId, request, idOrEmail);
+ if (error is not null)
+ return error;
+ var res = await storeRepository.UpdateStoreUserRole(storeId, user.Id, roleId);
return res switch
{
AddOrUpdateStoreUserResult.Success => Ok(),
- AddOrUpdateStoreUserResult.DuplicateRole _ => this.CreateAPIError(409, "duplicate-store-user-role", "The user is already added to the store"),
- // AddOrUpdateStoreUserResult.InvalidRole
- // AddOrUpdateStoreUserResult.LastOwner
+ AddOrUpdateStoreUserResult.DuplicateRole _ => Ok(),
_ => this.CreateAPIError(409, "store-user-role-orphaned", "Removing this user would result in the store having no owner."),
};
}
- private async Task<IEnumerable<StoreUserData>> ToAPI(StoreData store)
+ private async Task<(IActionResult error, ApplicationUser user, StoreRoleId roleId)> GetStoreUserRequest(string storeId, StoreUserDataRequest request,
+ string idOrEmail, bool createUser = false)
{
- var storeUsers = new List<StoreUserData>();
- var canManageStoreUsers = (await _authorizationService.AuthorizeAsync(User, store.Id, Policies.CanModifyStoreSettings)).Succeeded;
- foreach (var storeUser in store.UserStores)
+ // Deprecated properties
+ request.StoreRole ??= request.AdditionalData.TryGetValue("role", out var role) ? role.ToString() : null;
+ request.Id ??= request.AdditionalData.TryGetValue("userId", out var userId) ? userId.ToString() : null;
+
+ StoreRoleId roleId = null;
+ if (request.StoreRole is not null)
{
- var user = await _userManager.FindByIdOrEmail(storeUser.ApplicationUserId);
- if (user == null)
- continue;
- var data = await UserService.ForAPI<StoreUserData>(user, [], _callbackGenerator, _uriResolver, Request, canManageStoreUsers);
- data.StoreRole = storeUser.StoreRoleId;
-
- // Deprecated properties
- data.AdditionalData["userId"] = new JValue(storeUser.ApplicationUserId);
- data.AdditionalData["role"] = new JValue(storeUser.StoreRoleId);
- /////
-
- storeUsers.Add(data);
+ roleId = await storeRepository.ResolveStoreRoleId(storeId, request.StoreRole);
+ if (roleId is null)
+ ModelState.AddModelError(nameof(request.StoreRole), "The role id provided does not exist");
}
- return storeUsers;
- }
- private IActionResult UserNotFound()
- {
- return this.CreateAPIError(404, "user-not-found", "The user was not found");
+ if (!ModelState.IsValid)
+ return (this.CreateValidationError(ModelState), null, null);
+
+ var id = idOrEmail ?? request.Id;
+ var user = await userManager.FindByIdOrEmail(id);
+ if (user is null && createUser && MailboxAddressValidator.IsMailboxAddress(id))
+ {
+ if (policiesSettings.LockSubscription &&
+ !(await authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanCreateUser))).Succeeded)
+ return (this.CreateAPIPermissionError(Policies.CanCreateUser), null, null);
+
+ user = new ApplicationUser
+ {
+ UserName = id,
+ Email = id,
+ RequiresEmailConfirmation = policiesSettings.RequiresConfirmedEmail,
+ RequiresApproval = policiesSettings.RequiresUserApproval,
+ Created = DateTimeOffset.UtcNow
+ };
+ var creation = await userManager.CreateAsync(user);
+ if (!creation.Succeeded)
+ {
+ foreach (var identityError in creation.Errors)
+ ModelState.AddModelError(nameof(request.Id), identityError.Description);
+ return (this.CreateValidationError(ModelState), null, null);
+ }
+
+ var currentUser = await userManager.GetUserAsync(User);
+ if (currentUser is not null)
+ {
+ var evt = await UserEvent.Registered.Create(user, currentUser, callbackGenerator, await emailSenderFactory.IsComplete());
+ eventAggregator.Publish(evt);
+ }
+ }
+ if (user is null)
+ return (UserNotFound(), null, null);
+ request.Id = user.Id;
+
+ return (null, user, roleId);
}
+
+ private IActionResult UserNotFound() => this.CreateAPIError(404, "user-not-found", "The user was not found");
}
}
### BTCPayServer/Controllers/UIStoresController.Users.cs
@@ -9,6 +9,8 @@
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models.StoreViewModels;
+using BTCPayServer.Plugins.Emails;
+using BTCPayServer.Plugins.Emails.Controllers;
using BTCPayServer.Security;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
@@ -27,13 +29,6 @@ public async Task<IActionResult> StoreUsers()
await FillUsers(vm);
return View(vm);
}
-
- enum StoreUsersAction
- {
- Added,
- Updated,
- Invited
- }
[HttpPost("{storeId}/users")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[RateLimitsFilter(ZoneLimits.Register, Scope = RateLimitsScope.RemoteAddress)]
@@ -52,96 +47,108 @@ public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel
return View(vm);
}
- StoreUsersAction action;
- string? inviteInfo = null;
- var user = await _userManager.FindByEmailAsync(vm.Email);
- if (user is null)
+ if (vm.Command == "Add")
{
- action = StoreUsersAction.Invited;
- if (!_policiesSettings.LockSubscription || await IsAdmin())
+ string? serverInviteInfo = null;
+ bool newAccount = false;
+ var user = await _userManager.FindByEmailAsync(vm.Email);
+ if (user is null)
{
- user = new ApplicationUser
+ if (await CanCreateUser())
{
- UserName = vm.Email,
- Email = vm.Email,
- RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
- RequiresApproval = _policiesSettings.RequiresUserApproval,
- Created = DateTimeOffset.UtcNow
- };
- var currentUser = await _userManager.GetUserAsync(HttpContext.User);
-
- if (currentUser is not null &&
- (await _userManager.CreateAsync(user)) is { Succeeded: true })
- {
- var invitationEmail = await _emailSenderFactory.IsComplete();
- var evt = (UserEvent.Invited)await UserEvent.Registered.Create(user!, currentUser, _callbackGenerator, invitationEmail);
- _eventAggregator.Publish(evt);
- inviteInfo = invitationEmail
- ? StringLocalizer["An invitation email has been sent.<br/>You may alternatively share this link with them: <a class='alert-link' href='{0}'>{0}</a>", evt.InvitationLink]
- : StringLocalizer["An invitation email has not been sent, because the server does not have an email server configured.<br/> You need to share this link with them: <a class='alert-link' href='{0}'>{0}</a>", evt.InvitationLink];
- user = await _userManager.FindByEmailAsync(vm.Email);
+ user = new ApplicationUser
+ {
+ UserName = vm.Email,
+ Email = vm.Email,
+ RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
+ RequiresApproval = _policiesSettings.RequiresUserApproval,
+ Created = DateTimeOffset.UtcNow
+ };
+ var currentUser = await _userManager.GetUserAsync(HttpContext.User);
+ if (currentUser is not null &&
+ (await _userManager.CreateAsync(user)) is { Succeeded: true })
+ {
+ var invitationEmail = await _emailSenderFactory.IsComplete();
+ var evt = (UserEvent.Invited)await UserEvent.Registered.Create(user!, currentUser, _callbackGenerator, invitationEmail);
+ _eventAggregator.Publish(evt);
+ var emailSettingsLink = await CanModifyServerSettings() ? EmailSettingsLink() : null;
+ serverInviteInfo = invitationEmail
+ ? StringLocalizer["An invitation email has been sent.<br/>You may alternatively share this link with them: <a class='alert-link' href='{0}'>{0}</a>", evt.InvitationLink]
+ : emailSettingsLink is null
+ ? StringLocalizer["An invitation email has not been sent, because the server does not have an email server configured.<br/> You need to share this link with them: <a class='alert-link' href='{0}'>{0}</a>", evt.InvitationLink]
+ : StringLocalizer["An invitation email has not been sent, because the server does not have an <a class='alert-link' href='{1}'>email server</a> configured.<br/> You need to share this link with them: <a class='alert-link' href='{0}'>{0}</a>", evt.InvitationLink, emailSettingsLink];
+ newAccount = true;
+ }
}
}
- }
- else
- {
- action = (await _storeRepo.GetStoreUser(storeId, user.Id)) is not null
- ? StoreUsersAction.Updated
- : StoreUsersAction.Added;
- }
- if (user is null)
- {
- ModelState.AddModelError(nameof(vm.Email), StringLocalizer["User not found"]);
- return View(vm);
- }
+ if (user is null)
+ {
+ ModelState.AddModelError(nameof(vm.Email), StringLocalizer["User not found"]);
+ return View(vm);
+ }
- if (await _userManager.IsInRoleAsync(user, Roles.ServerAdmin) && !await IsServerAdmin())
- {
- ModelState.AddModelError(nameof(vm.Email), StringLocalizer["Only a server admin can add or update the store membership of a server admin"]);
- return View(vm);
+ var requireInvitation = !vm.CanSkipInvitation || vm.RequireInvitation;
+ if (requireInvitation && !newAccount)
+ {
+ var invitedBy = _userManager.GetUserId(User);
+ var inviteRes = await _storeRepo.CreateStoreInvitation(CurrentStore.Id, user.Id, roleId, invitedBy);
+ if (inviteRes is not CreateStoreInvitationResult.Success created)
+ {
+ ModelState.AddModelError(nameof(vm.Email), StringLocalizer["The user could not be invited: {0}", inviteRes.ToString()]);
+ return View(vm);
+ }
+ var link = _callbackGenerator.StoreInvitationLink(created.Invitation.Token);
+ await _storeRepo.NotifyStoreInvitation(created.Invitation.Invitation, link);
+ return View("StoreInvitationSent", new StoreInvitationSentViewModel
+ {
+ StoreId = CurrentStore.Id,
+ Email = user.Email,
+ Role = roleId.Role,
+ InvitationUrl = link,
+ EmailSent = await _emailSenderFactory.IsComplete(),
+ Expiry = created.Invitation.Invitation.ExpiresAt,
+ JustSent = true
+ });
+ }
+ else
+ {
+ // It might fail, but this is a harmless corner case
+ await _storeRepo.AddStoreUser(CurrentStore.Id, user.Id, roleId);
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Success,
+ AllowDismiss = false,
+ Message = StringLocalizer["The user has been added successfully."].Value,
+ Html = serverInviteInfo
+ });
+ return RedirectToAction(nameof(StoreUsers));
+ }
}
-
- var res = await _storeRepo.AddOrUpdateStoreUser(CurrentStore.Id, user.Id, roleId);
- if (res is AddOrUpdateStoreUserResult.Success)
+ else // if (vm.Command == "Update:<UserId>")
{
+ var user = await _userManager.FindByIdAsync(vm.Command.Split(':').LastOrDefault() ?? "");
+ if (user is null)
+ return NotFound();
+ await _storeRepo.UpdateStoreUserRole(CurrentStore.Id, user.Id, roleId);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false,
- Message = action switch
- {
- StoreUsersAction.Added => StringLocalizer["The user has been added successfully."].Value,
- StoreUsersAction.Updated => StringLocalizer["The user has been updated successfully."].Value,
- StoreUsersAction.Invited => null,
- _ => throw new ArgumentOutOfRangeException(action.ToString())
- },
- Html = action switch
- {
- StoreUsersAction.Invited => inviteInfo,
- _ => null
- }
+ Message = StringLocalizer["The user has been updated successfully."].Value
});
return RedirectToAction(nameof(StoreUsers));
}
- else
- {
- ModelState.AddModelError(nameof(vm.Email),
- action switch
- {
- StoreUsersAction.Updated => StringLocalizer["The user could not be updated: {0}", res.ToString()],
- StoreUsersAction.Added => StringLocalizer["The user could not be added: {0}", res.ToString()],
- StoreUsersAction.Invited => StringLocalizer["The user could not be invited: {0}", res.ToString()],
- _ => throw new ArgumentOutOfRangeException(action.ToString())
- });
- return View(vm);
- }
}
- private async Task<bool> IsAdmin()
- => (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanCreateUser))).Succeeded;
+ /// <summary>Server email settings, which only a server admin can reach.</summary>
+ private string? EmailSettingsLink()
+ => Url.Action(nameof(UIServerEmailController.ServerEmailSettings), "UIServerEmail", new { area = EmailsPlugin.Area });
- private async Task<bool> IsServerAdmin()
+ private async Task<bool> CanCreateUser()
+ => !_policiesSettings.LockSubscription || (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanCreateUser))).Succeeded;
+
+ private async Task<bool> CanModifyServerSettings()
=> (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded;
[HttpPost("{storeId}/users/{userId}")]
@@ -150,16 +157,11 @@ public async Task<IActionResult> UpdateStoreUser(string storeId, string userId,
{
var roleId = await _storeRepo.ResolveStoreRoleId(storeId, vm.Role);
var storeUsers = await _storeRepo.GetStoreUsers(storeId);
- var user = storeUsers.First(user => user.Id == userId);
-
- var applicationUser = await _userManager.FindByIdAsync(userId);
- if (applicationUser is not null && await _userManager.IsInRoleAsync(applicationUser, Roles.ServerAdmin) && !await IsServerAdmin())
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Only a server admin can add or update the store membership of a server admin."].Value;
- return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
- }
+ var user = storeUsers.FirstOrDefault(user => user.Id == userId);
+ if (user is null)
+ return NotFound();
- var res = await _storeRepo.AddOrUpdateStoreUser(storeId, userId, roleId);
+ var res = await _storeRepo.UpdateStoreUserRole(storeId, userId, roleId);
if (res is AddOrUpdateStoreUserResult.Success)
{
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The role of {0} has been changed to {1}.", user.Email, vm.Role].Value;
@@ -171,6 +173,42 @@ public async Task<IActionResult> UpdateStoreUser(string storeId, string userId,
return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
}
+ [HttpPost("{storeId}/users/{userId}/invitation/resend")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> ResendStoreInvitation(string storeId, string userId)
+ {
+ var invitation = await _storeRepo.ResendStoreInvitation(storeId, userId);
+ if (invitation is null)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["There is no pending invitation for this user."].Value;
+ return RedirectToAction(nameof(StoreUsers), new { storeId });
+ }
+ var link = _callbackGenerator.StoreInvitationLink(invitation.Token);
+ await _storeRepo.NotifyStoreInvitation(invitation.Invitation, link);
+ var invitedUser = await _userManager.FindByIdAsync(userId);
+ return View("StoreInvitationSent", new StoreInvitationSentViewModel
+ {
+ StoreId = storeId,
+ Email = invitedUser?.Email,
+ Role = StoreRoleId.Parse(invitation.Invitation.RoleId).Role,
+ InvitationUrl = link,
+ EmailSent = await _emailSenderFactory.IsComplete(),
+ Expiry = invitation.Invitation.ExpiresAt,
+ JustSent = true
+ });
+ }
+
+ [HttpPost("{storeId}/users/{userId}/invitation/cancel")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> CancelStoreInvitation(string storeId, string userId)
+ {
+ if (await _storeRepo.DeleteStoreInvitation(storeId, userId))
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Invitation cancelled."].Value;
+ else
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["There is no pending invitation for this user."].Value;
+ return RedirectToAction(nameof(StoreUsers), new { storeId });
+ }
+
[HttpPost("{storeId}/users/{userId}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteStoreUser(string storeId, string userId)
@@ -184,15 +222,31 @@ public async Task<IActionResult> DeleteStoreUser(string storeId, string userId)
private async Task FillUsers(StoreUsersViewModel vm)
{
+ var currentUserId = _userManager.GetUserId(User);
var users = await _storeRepo.GetStoreUsers(CurrentStore.Id);
+ var owners = users.Count(u => u.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings));
vm.StoreId = CurrentStore.Id;
vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel
{
Email = u.Email,
- Name = u.UserBlob.Name,
ImageUrl = u.UserBlob.ImageUrl,
Id = u.Id,
- Role = u.StoreRole.Role
+ Role = u.StoreRole.Role,
+ IsCurrentUser = u.Id == currentUserId,
+ IsLocked = owners == 1 && u.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings)
}).ToList();
+
+ var invitations = await _storeRepo.GetStoreInvitations(CurrentStore.Id);
+ vm.Users.AddRange(invitations.Select(i => new StoreUsersViewModel.StoreUserViewModel
+ {
+ Id = i.UserId,
+ Email = i.UserEmail,
+ Role = StoreRoleId.Parse(i.RoleId).Role,
+ InvitedAt = i.Created,
+ Expiry = i.ExpiresAt,
+ IsExpired = i.IsExpired()
+ }));
+
+ vm.CanSkipInvitation = _policiesSettings.AllowStoreOwnersToSkipInvitation || await CanModifyServerSettings();
}
}
### BTCPayServer/Controllers/UIUserStoresController.cs
@@ -19,6 +19,7 @@
namespace BTCPayServer.Controllers
{
[Route("stores")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class UIUserStoresController : Controller
{
private readonly StoreRepository _repo;
@@ -66,6 +67,84 @@ public IActionResult ListStores(bool archived = false)
return View(vm);
}
+ // Deliberately not gated on a store policy: the invitee is not a member of the store yet.
+ [HttpGet("~/invitations/{token}")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyProfile)]
+ [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
+ public async Task<IActionResult> AcceptStoreInvitation(string token)
+ {
+ var vm = await BuildInvitationViewModel(token);
+ if (vm is null)
+ return NotFound();
+ return View(vm);
+ }
+
+ [HttpPost("~/invitations/{token}")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyProfile)]
+ [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
+ public async Task<IActionResult> AcceptStoreInvitation(string token, string command)
+ {
+ var vm = await BuildInvitationViewModel(token);
+ if (vm is null)
+ return NotFound();
+ if (vm.IsForAnotherUser)
+ return View(vm);
+
+ if (command == "decline")
+ {
+ await _repo.DeleteStoreInvitationByToken(User.GetId(), token);
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Invitation declined."].Value;
+ return RedirectToAction(nameof(ListStores));
+ }
+
+ var result = await _repo.AcceptStoreInvitation(User.GetId(), token);
+ if (result is StoreRepository.AddOrUpdateStoreUserResult.Success or StoreRepository.AddOrUpdateStoreUserResult.DuplicateRole)
+ {
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["You have joined {0}.", vm.StoreName].Value;
+ var storeUser = await _repo.GetStoreUser(vm.StoreId, User.GetId());
+ var role = storeUser?.StoreRoleId is { } roleId ? await _repo.GetStoreRole(StoreRoleId.Parse(roleId)) : null;
+ return role?.Permissions.Contains(Policies.CanViewStoreSettings) is true
+ ? RedirectToAction("Index", "UIStores", new { storeId = vm.StoreId })
+ : RedirectToAction(nameof(ListStores));
+ }
+ if (result is null)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["This invitation is no longer available."].Value;
+ return RedirectToAction(nameof(ListStores));
+ }
+ vm.Error = result.ToString();
+ return View(vm);
+ }
+
+ private async Task<StoreInvitationViewModel> BuildInvitationViewModel(string token)
+ {
+ // We do not pass the user id on purpose. The client verifies whether the invitation is valid for the current user.
+ var invitation = await _repo.GetStoreInvitationByToken(token);
+ if (invitation is null)
+ return null;
+
+ var vm = new StoreInvitationViewModel
+ {
+ Token = token,
+ StoreId = invitation.StoreId,
+ StoreName = invitation.StoreName,
+ InvitedEmail = invitation.UserEmail,
+ };
+ // Only the invited account may act on the link. Anyone else signed in, typically the
+ // owner testing their own link, gets told whose invitation it is rather than a 404.
+ if (invitation.UserId != User.GetId())
+ {
+ vm.InvitedEmail = invitation.UserEmail;
+ vm.IsForAnotherUser = true;
+ return vm;
+ }
+ vm.Role = StoreRoleId.Parse(invitation.RoleId).Role;
+ vm.Expiry = invitation.ExpiresAt;
+ vm.IsExpired = invitation.IsExpired();
+ return vm;
+ }
+
+
[HttpGet("create")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettingsUnscoped)]
public async Task<IActionResult> CreateStore(bool skipWizard)
### BTCPayServer/Events/StoreUserInvitationEvent.cs
@@ -0,0 +1,24 @@
+#nullable enable
+
+using BTCPayServer.Data;
+
+namespace BTCPayServer.Events;
+
+public abstract class StoreUserInvitationEvent(StoreInvitationRow invitation)
+{
+ public StoreInvitationRow Invitation { get; } = invitation;
+
+ public class Created(StoreInvitationRow invitation, string? invitationsLink)
+ : StoreUserInvitationEvent(invitation)
+ {
+ public string? InvitationsLink { get; } = invitationsLink;
+ protected override string ToString() => $"{base.ToString()} has been invited";
+ }
+
+ public class Accepted(StoreInvitationRow invitation) : StoreUserInvitationEvent(invitation)
+ {
+ protected override string ToString() => $"{base.ToString()} accepted the invitation";
+ }
+
+ protected new virtual string ToString() => $"StoreUserInvitationEvent: User {Invitation.UserId}, Store {Invitation.StoreId}";
+}
### BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -448,6 +448,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_expired_cleanup
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
+ services.AddSingleton<INotificationHandler, StoreInvitationNotification.Handler>();
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
services.AddSingleton<INotificationHandler, ExternalPayoutTransactionNotification.Handler>();
### BTCPayServer/Models/StoreViewModels/StoreInvitationSentViewModel.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace BTCPayServer.Models.StoreViewModels;
+
+public class StoreInvitationSentViewModel
+{
+ public string StoreId { get; set; }
+ public string Email { get; set; }
+ public string Role { get; set; }
+ public string InvitationUrl { get; set; }
+ public bool EmailSent { get; set; }
+ public DateTimeOffset Expiry { get; set; }
+
+ /// <summary>False when simply looking the link up again, which sends nothing.</summary>
+ public bool JustSent { get; set; }
+}
### BTCPayServer/Models/StoreViewModels/StoreInvitationViewModel.cs
@@ -0,0 +1,19 @@
+using System;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace BTCPayServer.Models.StoreViewModels;
+
+public class StoreInvitationViewModel
+{
+ public string Token { get; set; }
+ public string StoreId { get; set; }
+ public string StoreName { get; set; }
+ public string Role { get; set; }
+ public DateTimeOffset Expiry { get; set; }
+ public bool IsExpired { get; set; }
+ public string Error { get; set; }
+
+ [BindNever]
+ public bool IsForAnotherUser { get; set; }
+ public string InvitedEmail { get; set; }
+}
### BTCPayServer/Models/StoreViewModels/StoreUsersViewModel.cs
@@ -1,5 +1,7 @@
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace BTCPayServer.Models.StoreViewModels
{
@@ -9,24 +11,45 @@ public class StoreUserViewModel
{
[Display(Name = "Email")]
public string Email { get; set; }
-
+
[Display(Name = "Role")]
public string Role { get; set; }
-
- [Display(Name = "Name")]
- public string Name { get; set; }
-
+
public string ImageUrl { get; set; }
public string Id { get; set; }
+
+ /// <summary>Null for members, set for rows that are still an invitation.</summary>
+ public DateTimeOffset? InvitedAt { get; set; }
+ public DateTimeOffset? Expiry { get; set; }
+ public bool IsPending => InvitedAt.HasValue;
+ public bool IsExpired { get; set; }
+
+ /// <summary>The signed-in user, so the row can be badged and protected.</summary>
+ public bool IsCurrentUser { get; set; }
+
+ /// <summary>Last owner: their role cannot be changed and they cannot be removed.</summary>
+ public bool IsLocked { get; set; }
}
+
[Required]
[EmailAddress]
- [Display(Name = "Email")]
+ [Display(Name = "Email address")]
public string Email { get; set; }
public string StoreId { get; set; }
-
+
[Display(Name = "Role")]
public string Role { get; set; }
- public List<StoreUserViewModel> Users { get; set; }
+
+ [Display(Name = "Require the user to accept the invitation")]
+ public bool RequireInvitation { get; set; } = true;
+
+ [BindNever]
+ public bool CanSkipInvitation { get; set; }
+
+
+ /// <summary>Members and pending invitations in one list, members first.</summary>
+ public List<StoreUserViewModel> Users { get; set; } = new();
+
+ public string Command { get; set; }
}
}
### BTCPayServer/Plugins/Emails/EmailsPlugin.cs
@@ -25,6 +25,7 @@ public override void Execute(IServiceCollection services)
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
services.AddTransient<EmailTriggerViewModels>();
services.AddSingleton<IHostedService, UserEventHostedService>();
+ services.AddSingleton<IHostedService, StoreInvitationEventHostedService>();
services.AddMigration<ApplicationDbContext, Migrations.DefaultServerEmailRulesMigration>();
services.AddMigration("20251223_emailsettingsmigration", """
INSERT INTO "Settings" ("Id", "Value")
@@ -51,6 +52,10 @@ DELETE FROM "Settings"
ConfigureEmailSearch(services);
RegisterServerEmailTriggers(services);
+ foreach (var storeTrigger in StoreMailTriggers.GetViewModels())
+ {
+ services.AddSingleton(storeTrigger);
+ }
}
private static void ConfigureEmailSearch(IServiceCollection services)
@@ -207,6 +212,25 @@ private void RegisterServerEmailTriggers(IServiceCollection services)
};
vms.Add(vm);
+ vm = new EmailTriggerViewModel()
+ {
+ Trigger = ServerMailTriggers.StoreInvitePending,
+ DefaultEmail = new()
+ {
+ To = ["{User.MailboxAddress}"],
+ Subject = "Invitation to join {StoreInvitation.StoreName}",
+ Body = CreateEmailBody($"You have been invited to join <b>{{StoreInvitation.StoreName}}</b> as {{StoreInvitation.Role}}.<br/><br/>{CallToAction("View invitation", "{StoreInvitation.Link}")}"),
+ },
+ PlaceHolders = new()
+ {
+ new ("{StoreInvitation.StoreName}", "The name of the store the user is invited to"),
+ new ("{StoreInvitation.Role}", "The role the user is invited with"),
+ new ("{StoreInvitation.Link}", "The link where the user can accept or decline the invitation"),
+ },
+ Description = "User: Store invitation",
+ };
+ vms.Add(vm);
+
var commonPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
{
new("{Admins.MailboxAddresses}", "The email addresses of the admins separated by a comma"),
### BTCPayServer/Plugins/Emails/HostedServices/StoreInvitationEventHostedService.cs
@@ -0,0 +1,99 @@
+#nullable enable
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.Events;
+using BTCPayServer.HostedServices;
+using BTCPayServer.Logging;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Notifications;
+using BTCPayServer.Services.Notifications.Blobs;
+using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.Emails.HostedServices;
+
+public class StoreInvitationEventHostedService(
+ EventAggregator eventAggregator,
+ IServiceScopeFactory serviceScopeFactory,
+ NotificationSender notificationSender,
+ Logs logs)
+ : EventHostedServiceBase(eventAggregator, logs)
+{
+ protected override void SubscribeToEvents()
+ {
+ SubscribeAny<StoreUserInvitationEvent>();
+ }
+
+ protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
+ {
+ switch (evt)
+ {
+ case StoreUserInvitationEvent.Created created:
+ await OnCreated(created);
+ break;
+ case StoreUserInvitationEvent.Accepted accepted:
+ await OnAccepted(accepted);
+ break;
+ }
+ }
+
+ private async Task OnCreated(StoreUserInvitationEvent.Created evt)
+ {
+ using var scope = serviceScopeFactory.CreateScope();
+ var storeRepo = scope.ServiceProvider.GetRequiredService<StoreRepository>();
+ var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
+
+ var store = await storeRepo.FindStore(evt.Invitation.StoreId);
+ var user = await userManager.FindByIdAsync(evt.Invitation.UserId);
+ if (store is null || user is null)
+ return;
+
+ await notificationSender.SendNotification(new UserScope(evt.Invitation.UserId),
+ new StoreInvitationNotification(store.Id, store.StoreName, evt.InvitationsLink));
+
+ if (string.IsNullOrWhiteSpace(evt.InvitationsLink))
+ return;
+
+ var role = StoreRoleId.Parse(evt.Invitation.RoleId).Role;
+ var model = new JObject
+ {
+ ["User"] = new JObject
+ {
+ ["Name"] = user.UserName,
+ ["Email"] = user.Email,
+ ["MailboxAddress"] = user.GetMailboxAddress().ToString()
+ },
+ ["StoreInvitation"] = new JObject
+ {
+ ["StoreName"] = store.StoreName,
+ ["Role"] = role,
+ ["Link"] = evt.InvitationsLink
+ }
+ };
+ EventAggregator.Publish(new TriggerEvent(null, ServerMailTriggers.StoreInvitePending, model, null));
+ }
+
+ private async Task OnAccepted(StoreUserInvitationEvent.Accepted evt)
+ {
+ using var scope = serviceScopeFactory.CreateScope();
+ var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
+ var user = await userManager.FindByIdAsync(evt.Invitation.UserId);
+ if (user is null)
+ return;
+
+ var model = new JObject
+ {
+ ["JoinedUser"] = new JObject
+ {
+ ["Name"] = user.UserName,
+ ["Email"] = user.Email,
+ ["MailboxAddress"] = user.GetMailboxAddress().ToString(),
+ ["Role"] = StoreRoleId.Parse(evt.Invitation.RoleId).Role
+ }
+ };
+ EventAggregator.Publish(new TriggerEvent(evt.Invitation.StoreId, StoreMailTriggers.UserJoined, model, null));
+ }
+}
### BTCPayServer/Plugins/Emails/ServerMailTriggers.cs
@@ -9,4 +9,5 @@ public class ServerMailTriggers
public const string ApprovalPending = "SRV-ApprovalPending";
public const string EmailConfirm = "SRV-EmailConfirmation";
public const string ApprovalRequest = "SRV-ApprovalRequest";
+ public const string StoreInvitePending = "SRV-StoreInvitePending";
}
### BTCPayServer/Plugins/Emails/StoreMailTriggers.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using BTCPayServer.Plugins.Emails.Views;
+
+namespace BTCPayServer.Plugins.Emails;
+
+public static class StoreMailTriggers
+{
+ public const string UserJoined = "StoreUserJoined";
+
+ public static IEnumerable<EmailTriggerViewModel> GetViewModels()
+ {
+ yield return new EmailTriggerViewModel
+ {
+ Trigger = UserJoined,
+ Description = "Store: User joined",
+ DefaultEmail = new EmailTriggerViewModel.Default
+ {
+ To = ["{JoinedUser.MailboxAddress}"],
+ Subject = "Welcome to {Store.Name}",
+ Body = EmailsPlugin.CreateEmail("You now have access to {Store.Name} with the role {JoinedUser.Role}.")
+ },
+ PlaceHolders =
+ [
+ new("{JoinedUser.Name}", "The name of the user who joined the store"),
+ new("{JoinedUser.Email}", "The email of the user who joined the store"),
+ new("{JoinedUser.MailboxAddress}", "The formatted mailbox address of the user who joined the store"),
+ new("{JoinedUser.Role}", "The role the user joined the store with")
+ ]
+ };
+ }
+}
### BTCPayServer/Services/CallbackGenerator.cs
@@ -33,6 +33,10 @@ public string ForLNUrlAuth(ApplicationUser user, byte[] r)
public RequestBaseUrl GetRequestBaseUrl()
=> BaseUrl ?? httpContextAccessor.HttpContext?.Request.GetRequestBaseUrl() ?? throw new InvalidOperationException($"You should be in a HttpContext to call this method");
+ public string StoreInvitationLink(string token)
+ => LinkGenerator.GetUriByAction(nameof(UIUserStoresController.AcceptStoreInvitation), "UIUserStores",
+ new { token }, GetRequestBaseUrl());
+
public string StoreUsersLink(string storeId)
=> LinkGenerator.GetUriByAction(nameof(UIStoresController.StoreUsers), "UIStores",
new { storeId }, GetRequestBaseUrl());
### BTCPayServer/Services/Notifications/Blobs/StoreInvitationNotification.cs
@@ -0,0 +1,55 @@
+using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.Configuration;
+using BTCPayServer.Controllers;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Localization;
+
+namespace BTCPayServer.Services.Notifications.Blobs;
+
+internal class StoreInvitationNotification : BaseNotification
+{
+ private const string TYPE = "storeinvitation";
+ public string StoreId { get; set; }
+ public string StoreName { get; set; }
+ public string InvitationUrl { get; set; }
+ public override string Identifier => TYPE;
+ public override string NotificationType => TYPE;
+
+ public StoreInvitationNotification()
+ {
+ }
+
+ public StoreInvitationNotification(string storeId, string storeName, string invitationUrl)
+ {
+ StoreId = storeId;
+ StoreName = storeName;
+ InvitationUrl = invitationUrl;
+ }
+
+ internal class Handler : NotificationHandler<StoreInvitationNotification>
+ {
+ private readonly LinkGenerator _linkGenerator;
+ private readonly BTCPayServerOptions _options;
+ private IStringLocalizer StringLocalizer { get; }
+
+ public Handler(LinkGenerator linkGenerator, BTCPayServerOptions options, IStringLocalizer stringLocalizer)
+ {
+ _linkGenerator = linkGenerator;
+ _options = options;
+ StringLocalizer = stringLocalizer;
+ }
+
+ public override string NotificationType => TYPE;
+
+ public override (string identifier, string name)[] Meta => [(TYPE, StringLocalizer["Store invitation"])];
+
+ protected override void FillViewModel(StoreInvitationNotification notification, NotificationViewModel vm)
+ {
+ vm.Identifier = notification.Identifier;
+ vm.Type = notification.NotificationType;
+ vm.Body = StringLocalizer["You have been invited to join the store {0}.", notification.StoreName];
+ vm.ActionLink = notification.InvitationUrl ?? _linkGenerator.GetPathByAction(
+ nameof(UIUserStoresController.ListStores), "UIUserStores", null, _options.RootPath);
+ }
+ }
+}
### BTCPayServer/Services/PoliciesSettings.cs
@@ -65,6 +65,9 @@ public bool AllowSearchEngines
[Display(Name = "Non-admins cannot access the User Creation API Endpoint")]
public bool DisableNonAdminCreateUserApi { get; set; }
+ [Display(Name = "Store owners can add users to their store without an invitation")]
+ public bool AllowStoreOwnersToSkipInvitation { get; set; }
+
[JsonIgnore]
[Display(Name = "Non-admins can access the User Creation API Endpoint")]
public bool EnableNonAdminCreateUserApi
### BTCPayServer/Services/Stores/StoreRepository.Invitation.cs
@@ -0,0 +1,259 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Client;
+using BTCPayServer.Data;
+using BTCPayServer.Events;
+using Dapper;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+using NBitcoin;
+using NBitcoin.DataEncoders;
+
+namespace BTCPayServer.Services.Stores;
+
+public partial class StoreRepository
+{
+ public record CreateStoreInvitationResult
+ {
+ public record Success(GeneratedInvitation Invitation) : CreateStoreInvitationResult;
+
+ public record InvalidRole : CreateStoreInvitationResult
+ {
+ public override string ToString() => "The roleId doesn't exist";
+ }
+
+ public record AlreadyMember : CreateStoreInvitationResult
+ {
+ public override string ToString() => "The user already has access to this store.";
+ }
+ }
+
+ public async Task<CreateStoreInvitationResult> CreateStoreInvitation(string storeId, string userId, StoreRoleId? roleId, string? invitedByUserId)
+ {
+ ArgumentNullException.ThrowIfNull(storeId);
+ AssertStoreRoleIfNeeded(storeId, roleId);
+ roleId ??= await GetDefaultRole();
+ if (await GetStoreRole(roleId) is null)
+ return new CreateStoreInvitationResult.InvalidRole();
+
+ await using var ctx = _ContextFactory.CreateContext();
+ if (await ctx.UserStore.AnyAsync(u => u.StoreDataId == storeId && u.ApplicationUserId == userId))
+ return new CreateStoreInvitationResult.AlreadyMember();
+
+ var created = DateTimeOffset.UtcNow;
+ var token = GenerateInvitationToken();
+ var expiresAt = created + StoreInvitationRow.Lifetime;
+ await ctx.Database.GetDbConnection().ExecuteAsync(
+ """
+ INSERT INTO store_invitations (store_id, user_id, role_id, invited_by_user_id, created, expires_at, token_hash)
+ VALUES (@storeId, @userId, @roleId, @invitedByUserId, @created, @expiresAt, @tokenHash)
+ ON CONFLICT (store_id, user_id)
+ DO UPDATE SET
+ invited_by_user_id = @invitedByUserId,
+ created = @created,
+ expires_at = @expiresAt,
+ token_hash = @tokenHash,
+ role_id = @roleId
+ """,
+ new
+ {
+ storeId,
+ userId,
+ roleId = roleId.Id,
+ invitedByUserId,
+ created,
+ expiresAt,
+ tokenHash = StoreInvitationRow.HashToken(token)
+ });
+
+ var invitation = new StoreInvitationRow(storeId, userId, roleId.Id, invitedByUserId, created.UtcDateTime, expiresAt.UtcDateTime);
+ return new CreateStoreInvitationResult.Success(new(invitation, token));
+ }
+
+ public async Task<StoreInvitationExtendedRow[]> GetStoreInvitations(string storeId)
+ {
+ await using var ctx = _ContextFactory.CreateContext();
+ return (await ctx.Database.GetDbConnection()
+ .QueryAsync<StoreInvitationExtendedRow>($"""
+ SELECT {StoreInvitationExtendedRowColumns}
+ FROM store_invitations
+ JOIN "AspNetUsers" u ON u."Id" = user_id
+ JOIN "Stores" s ON s."Id" = store_id
+ WHERE store_id = @storeId
+ ORDER BY created
+ """, new { storeId})).ToArray();
+ }
+
+ private const string StoreInvitationRowColumns = """
+ store_id AS "StoreId",
+ user_id AS "UserId",
+ role_id AS "RoleId",
+ invited_by_user_id AS "InvitedByUserId",
+ created AS "Created",
+ expires_at AS "ExpiresAt"
+ """;
+ private const string StoreInvitationExtendedRowColumns = """
+ store_id AS "StoreId",
+ user_id AS "UserId",
+ role_id AS "RoleId",
+ invited_by_user_id AS "InvitedByUserId",
+ created AS "Created",
+ expires_at AS "ExpiresAt",
+ u."Email" AS "UserEmail",
+ s."StoreName"
+ """;
+
+ public async Task<GeneratedInvitation?> ResendStoreInvitation(string storeId, string userId)
+ {
+ await using var ctx = _ContextFactory.CreateContext();
+ var token = GenerateInvitationToken();
+ var created = DateTimeOffset.UtcNow;
+ var invitation = await ctx.Database.GetDbConnection().QuerySingleOrDefaultAsync<StoreInvitationRow>(
+ $"""
+ UPDATE store_invitations
+ SET created = @created,
+ expires_at = @expiresAt,
+ token_hash = @tokenHash
+ WHERE store_id = @storeId AND user_id = @userId
+ RETURNING {StoreInvitationRowColumns}
+ """,
+ new
+ {
+ storeId,
+ userId,
+ created,
+ expiresAt = created + StoreInvitationRow.Lifetime,
+ tokenHash = StoreInvitationRow.HashToken(token)
+ });
+ return invitation is null
+ ? null
+ : new(invitation, token);
+ }
+
+ public async Task<AddOrUpdateStoreUserResult?> AcceptStoreInvitation(string userId, string token)
+ {
+ ArgumentNullException.ThrowIfNull(userId);
+
+ var tokenHash = StoreInvitationRow.HashToken(token);
+ AddOrUpdateStoreUserResult? result = null;
+ StoreInvitationRow? acceptedInvitation = null;
+ await using var strategyCtx = _ContextFactory.CreateContext();
+ await strategyCtx.Database.CreateExecutionStrategy().ExecuteAsync(async () =>
+ {
+ result = null;
+ acceptedInvitation = null;
+ await using var ctx = _ContextFactory.CreateContext();
+ await using var tx = await ctx.Database.BeginTransactionAsync();
+ var invitation = await ctx.Database.GetDbConnection()
+ .QueryFirstOrDefaultAsync<StoreInvitationRow>($"""
+ DELETE FROM store_invitations WHERE user_id = @userId AND token_hash = @tokenHash
+ RETURNING {StoreInvitationRowColumns}
+ """,
+ new { userId, tokenHash }, tx.GetDbTransaction());
+ if (invitation is null)
+ return;
+ if (invitation.IsExpired())
+ {
+ await tx.CommitAsync();
+ result = new AddOrUpdateStoreUserResult.Expired();
+ return;
+ }
+ if (!await ctx.StoreRoles.AnyAsync(r => r.Id == invitation.RoleId))
+ {
+ result = new AddOrUpdateStoreUserResult.InvalidRole();
+ return;
+ }
+
+ ctx.UserStore.Add(new UserStore { StoreDataId = invitation.StoreId, ApplicationUserId = userId, StoreRoleId = invitation.RoleId });
+ try
+ {
+ await ctx.SaveChangesAsync();
+ await tx.CommitAsync();
+ }
+ catch (DbUpdateException)
+ {
+ result = new AddOrUpdateStoreUserResult.DuplicateRole(StoreRoleId.Parse(invitation.RoleId));
+ return;
+ }
+ result = new AddOrUpdateStoreUserResult.Success();
+ acceptedInvitation = invitation;
+ });
+
+ if (acceptedInvitation is not null)
+ {
+ await using var ctx = _ContextFactory.CreateContext();
+ await ctx.Users.UpdateStoreNoActiveUserForStores([acceptedInvitation.StoreId]);
+ _eventAggregator.Publish(new StoreUserEvent.Added(acceptedInvitation.StoreId, userId, acceptedInvitation.RoleId));
+ _eventAggregator.Publish(new StoreUserInvitationEvent.Accepted(acceptedInvitation));
+ }
+ return result;
+ }
+
+ private static string GenerateInvitationToken()
+ => Encoders.Base58.EncodeData(RandomUtils.GetBytes(24));
+
+ /// <summary>Announces an invitation once the caller has built its link.</summary>
+ public Task NotifyStoreInvitation(StoreInvitationRow invitation, string invitationLink)
+ {
+ _eventAggregator.Publish(new StoreUserInvitationEvent.Created(invitation, invitationLink));
+ return Task.CompletedTask;
+ }
+
+ public async Task<StoreInvitationExtendedRow?> GetStoreInvitationByToken(string token, string? storeId = null, string? userId = null)
+ {
+ if (string.IsNullOrEmpty(token))
+ return null;
+
+ List<string> conditions = new();
+ DynamicParameters parameters = new();
+ conditions.Add("token_hash = @tokenHash");
+ parameters.Add("tokenHash", StoreInvitationRow.HashToken(token));
+ if (storeId is not null)
+ {
+ conditions.Add("store_id = @storeId");
+ parameters.Add("storeId", storeId);
+ }
+ if (userId is not null)
+ {
+ conditions.Add("user_id = @userId");
+ parameters.Add("userId", userId);
+ }
+
+ await using var ctx = _ContextFactory.CreateContext();
+ return await ctx.Database.GetDbConnection()
+ .QueryFirstOrDefaultAsync<StoreInvitationExtendedRow>($"""
+ SELECT {StoreInvitationExtendedRowColumns}
+ FROM store_invitations
+ JOIN "AspNetUsers" u ON u."Id" = user_id
+ JOIN "Stores" s ON s."Id" = store_id
+ WHERE {string.Join(" AND ", conditions)}
+ """, parameters);
+ }
+
+ public record GeneratedInvitation(StoreInvitationRow Invitation, string Token);
+
+ public async Task<bool> DeleteStoreInvitation(string storeId, string userId)
+ {
+ await using var ctx = _ContextFactory.CreateContext();
+ return await ctx.Database.GetDbConnection().ExecuteAsync(
+ """
+ DELETE FROM store_invitations
+ WHERE store_id = @storeId AND user_id = @userId
+ """,
+ new { storeId, userId }) > 0;
+ }
+
+ public async Task<bool> DeleteStoreInvitationByToken(string userId, string token)
+ {
+ await using var ctx = _ContextFactory.CreateContext();
+ return await ctx.Database.GetDbConnection().ExecuteAsync(
+ """
+ DELETE FROM store_invitations
+ WHERE user_id = @userId AND token_hash = @tokenHash
+ """,
+ new { userId, tokenHash = StoreInvitationRow.HashToken(token) }) > 0;
+ }
+}
### BTCPayServer/Services/Stores/StoreRepository.cs
@@ -24,7 +24,7 @@
namespace BTCPayServer.Services.Stores
{
- public class StoreRepository : IStoreRepository
+ public partial class StoreRepository : IStoreRepository
{
private readonly ApplicationDbContextFactory _ContextFactory;
private readonly EventAggregator _eventAggregator;
@@ -342,6 +342,7 @@ public async Task<bool> AddStoreUser(string storeId, string userId, StoreRoleId?
{
await ctx.SaveChangesAsync();
await ctx.Users.UpdateStoreNoActiveUserForStores([storeId]);
+ await DeleteStoreInvitation(storeId, userId);
_eventAggregator.Publish(new StoreUserEvent.Added(storeId, userId, roleId.Id));
return true;
}
@@ -366,8 +367,17 @@ public record DuplicateRole(StoreRoleId RoleId) : AddOrUpdateStoreUserResult
{
public override string ToString() => $"The user already has the role {RoleId}.";
}
+ public record Expired : AddOrUpdateStoreUserResult
+ {
+ public override string ToString() => "The invitation has expired.";
+ }
+ public record NotFound : AddOrUpdateStoreUserResult
+ {
+ public override string ToString() => "The user does not have access to this store.";
+ }
}
- public async Task<AddOrUpdateStoreUserResult> AddOrUpdateStoreUser(string storeId, string userId, StoreRoleId? roleId = null)
+
+ public async Task<AddOrUpdateStoreUserResult> UpdateStoreUserRole(string storeId, string userId, StoreRoleId? roleId = null)
{
ArgumentNullException.ThrowIfNull(storeId);
AssertStoreRoleIfNeeded(storeId, roleId);
@@ -379,15 +389,11 @@ public async Task<AddOrUpdateStoreUserResult> AddOrUpdateStoreUser(string storeI
await using var ctx = _ContextFactory.CreateContext();
var userStore = await ctx.UserStore.Include(store => store.StoreRole)
.FirstOrDefaultAsync(u => u.ApplicationUserId == userId && u.StoreDataId == storeId);
- var added = false;
if (userStore is null)
- {
- userStore = new UserStore { StoreDataId = storeId, ApplicationUserId = userId };
- ctx.UserStore.Add(userStore);
- added = true;
- }
+ return new AddOrUpdateStoreUserResult.NotFound();
+
// ensure the last owner doesn't get downgraded
- else if (userStore.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings))
+ if (userStore.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings))
{
if (storeRole.Permissions.Contains(Policies.CanModifyStoreSettings) is false && !await EnsureRemainingOwner(ctx.UserStore, storeId, userId))
return new AddOrUpdateStoreUserResult.LastOwner();
@@ -400,10 +406,8 @@ public async Task<AddOrUpdateStoreUserResult> AddOrUpdateStoreUser(string storeI
try
{
await ctx.SaveChangesAsync();
- StoreUserEvent evt = added
- ? new StoreUserEvent.Added(storeId, userId, userStore.StoreRoleId)
- : new StoreUserEvent.Updated(storeId, userId, userStore.StoreRoleId);
- _eventAggregator.Publish(evt);
+ await DeleteStoreInvitation(storeId, userId);
+ _eventAggregator.Publish(new StoreUserEvent.Updated(storeId, userId, userStore.StoreRoleId));
return new AddOrUpdateStoreUserResult.Success();
}
catch (DbUpdateException)
@@ -412,6 +416,22 @@ public async Task<AddOrUpdateStoreUserResult> AddOrUpdateStoreUser(string storeI
}
}
+ public async Task<AddOrUpdateStoreUserResult> AddOrUpdateStoreUser(string storeId, string userId, StoreRoleId? roleId = null)
+ {
+ ArgumentNullException.ThrowIfNull(storeId);
+ AssertStoreRoleIfNeeded(storeId, roleId);
+ roleId ??= await GetDefaultRole();
+ if (await GetStoreRole(roleId) is null)
+ return new AddOrUpdateStoreUserResult.InvalidRole();
+
+ if (await GetStoreUser(storeId, userId) is not null)
+ return await UpdateStoreUserRole(storeId, userId, roleId);
+
+ return await AddStoreUser(storeId, userId, roleId)
+ ? (AddOrUpdateStoreUserResult)new AddOrUpdateStoreUserResult.Success()
+ : new AddOrUpdateStoreUserResult.DuplicateRole(roleId);
+ }
+
static void AssertStoreRoleIfNeeded(string storeId, StoreRoleId? roleId)
{
if (roleId?.StoreId != null && storeId != roleId.StoreId)
### BTCPayServer/Views/Shared/_LayoutWizard.cshtml
@@ -1,5 +1,7 @@
@{
Layout = "_LayoutSimple";
+ // Pages that want a narrower or wider column than the wizard default can override this.
+ var mainClass = ViewData["WizardMainClass"] as string ?? "col-md-10 col-lg-8 col-xl-7";
}
@section PageHeadContent {
@@ -16,7 +18,7 @@
</nav>
<div class="row justify-content-md-center mt-5 pt-sm-3 pt-md-0">
- <main class="col-md-10 col-lg-8 col-xl-7">
+ <main class="@mainClass">
<partial name="_StatusMessage" />
@RenderBody()
</main>
### BTCPayServer/Views/UIServer/Policies.cshtml
@@ -76,6 +76,14 @@
<span asp-validation-for="RequiresUserApproval" class="text-danger"></span>
</div>
</div>
+ <div class="d-flex my-3">
+ <input asp-for="AllowStoreOwnersToSkipInvitation" type="checkbox" class="btcpay-toggle me-3"/>
+ <div>
+ <label asp-for="AllowStoreOwnersToSkipInvitation" class="form-check-label"></label>
+ <span asp-validation-for="AllowStoreOwnersToSkipInvitation" class="text-danger"></span>
+ <div class="text-secondary" text-translate="true">Otherwise only server admins can do so, and store owners must send an invitation the user has to accept.</div>
+ </div>
+ </div>
<div class="d-flex my-3">
<input asp-for="EnableNonAdminCreateUserApi" type="checkbox" class="btcpay-toggle me-3"/>
<div>
### BTCPayServer/Views/UIStores/StoreInvitationSent.cshtml
@@ -0,0 +1,59 @@
+@model BTCPayServer.Models.StoreViewModels.StoreInvitationSentViewModel
+@{
+ Layout = "_LayoutWizard";
+ ViewData.SetTitle(Model.JustSent ? StringLocalizer["Invitation sent"] : StringLocalizer["Invitation link"]);
+}
+
+@section PageHeadContent {
+ <link href="~/main/qrcode.css" rel="stylesheet" asp-append-version="true" />
+}
+
+@section Navbar {
+ <a asp-action="StoreUsers" asp-route-storeId="@Model.StoreId" id="CancelWizard" class="cancel">
+ <vc:icon symbol="cross" />
+ </a>
+}
+
+<header class="text-center">
+ <h1 class="mb-2">@ViewData["Title"]</h1>
+ <p class="text-secondary">
+ @if (Model.JustSent)
+ {
+ @StringLocalizer["{0} has been invited as {1}.", Model.Email, Model.Role]
+ }
+ else
+ {
+ @StringLocalizer["{0} was invited as {1}.", Model.Email, Model.Role]
+ }
+ <span>@StringLocalizer["The invitation expires {0}.", Model.Expiry.ToBrowserDate(ViewsRazor.DateDisplayFormat.Relative)]</span>
+ </p>
+</header>
+
+<div class="col-sm-10 col-xxl-8 mx-auto text-center" id="StoreInvitationSent">
+ <div class="payment-box">
+ <div class="qr-container clipboard-button" data-clipboard="@Model.InvitationUrl">
+ <vc:qr-code data="@Model.InvitationUrl" />
+ </div>
+ <div class="input-group mt-3">
+ <div class="form-floating">
+ <vc:truncate-center text="@Model.InvitationUrl" padding="15" elastic="true" classes="form-control-plaintext" id="InvitationUrl" />
+ <label for="InvitationUrl" text-translate="true">Invitation URL</label>
+ </div>
+ </div>
+ </div>
+ <p class="text-secondary mt-3 mb-0">
+ @if (!Model.JustSent)
+ {
+ <span text-translate="true">Share this link with them. Viewing it here changes nothing.</span>
+ }
+ else if (Model.EmailSent)
+ {
+ <span text-translate="true">An invitation email has been sent. You may alternatively share this link with them.</span>
+ }
+ else
+ {
+ <span text-translate="true">No invitation email was sent, so you need to share this link with them.</span>
+ }
+ </p>
+ <a asp-action="StoreUsers" asp-route-storeId="@Model.StoreId" class="btn btn-primary w-100 mt-4" id="BackToStoreUsers" text-translate="true">Back to store users</a>
+</div>
### BTCPayServer/Views/UIStores/StoreUsers.cshtml
@@ -1,5 +1,6 @@
@using BTCPayServer.Services.Stores
@using BTCPayServer.Client
+@using System.Globalization
@model StoreUsersViewModel
@inject StoreRepository StoreRepository
@{
@@ -15,6 +16,7 @@
@@media (min-width: 576px) {
#Role { width: auto !important; }
}
+ .store-users__role-form select { min-width: 11rem; }
</style>
}
<div class="sticky-header">
@@ -25,40 +27,124 @@
</div>
<partial name="_StatusMessage" />
<div class="row">
- <div class="col-xxl-constrain col-xl-8">
+ <div class="col-xxl-constrain col-xl-10">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
}
<div class="settings-section">
- <form method="post" class="d-flex flex-wrap align-items-center gap-3 mb-4" permission="@Policies.CanModifyStoreSettings">
- <input asp-for="Email" type="text" class="form-control" placeholder="@StringLocalizer["user@example.com"]" style="flex: 1 1 14rem">
- <select asp-for="Role" class="form-select" asp-items="roles"></select>
- <button type="submit" role="button" class="btn btn-primary text-nowrap flex-grow-1 flex-sm-grow-0" id="AddUser" text-translate="true">Add User</button>
+ <form method="post" class="mb-5" permission="@Policies.CanModifyStoreSettings">
+ <div class="d-flex flex-wrap align-items-end gap-3">
+ <div class="flex-grow-1" style="flex-basis: 14rem">
+ <label asp-for="Email" class="form-label"></label>
+ <input asp-for="Email" type="text" class="form-control" placeholder="@StringLocalizer["name@company.com"]">
+ </div>
+ <div>
+ <label asp-for="Role" class="form-label"></label>
+ <select asp-for="Role" class="form-select" asp-items="roles"></select>
+ </div>
+ <button type="submit" role="button" class="btn btn-primary text-nowrap" id="AddUser"
+ name="command"
+ value="Add"
+ data-label-invite="@StringLocalizer["Send invite"]"
+ data-label-add="@StringLocalizer["Add user"]">@(!Model.CanSkipInvitation || Model.RequireInvitation ? StringLocalizer["Send invite"] : StringLocalizer["Add user"])</button>
+ </div>
+ @if (Model.CanSkipInvitation)
+ {
+ <div class="form-check mt-3">
+ <input asp-for="RequireInvitation" type="checkbox" class="form-check-input store-users__require-invitation" id="RequireInvitation">
+ <label asp-for="RequireInvitation" class="form-check-label" for="RequireInvitation" text-translate="true">Require the user to accept the invitation</label>
+ <span class="text-secondary ms-1 store-users__require-invitation-help" data-bs-toggle="tooltip" title="@StringLocalizer["When unchecked, an existing user is given access immediately, without being asked."]">
+ <vc:icon symbol="info" />
+ </span>
+ </div>
+ }
+ else
+ {
+ <input asp-for="RequireInvitation" type="hidden" value="true">
+ }
</form>
<div class="table-responsive-md settings-section__table">
- <table class="table table-hover mb-0">
+ <table class="table table-hover mb-0 store-users">
<thead>
<tr>
<th text-translate="true">Email</th>
- <th text-translate="true">Name</th>
<th text-translate="true">Role</th>
- <th class="actions-col" permission="@Policies.CanModifyStoreSettings"></th>
+ <th text-translate="true">Status</th>
+ <th class="actions-col" permission="@Policies.CanModifyStoreSettings" text-translate="true">Actions</th>
</tr>
</thead>
<tbody id="StoreUsersList">
@foreach (var user in Model.Users)
{
- <tr>
- <td>@user.Email</td>
- <td>@user.Name</td>
- <td>@user.Role</td>
- <td class="actions-col" permission="@Policies.CanModifyStoreSettings">
+ <tr class="store-users__row">
+ <td class="align-middle store-users__email">
+ @user.Email
+ @if (user.IsCurrentUser)
+ {
+ <span class="badge rounded-pill text-bg-info ms-2" text-translate="true">You</span>
+ }
+ </td>
+ <td class="align-middle store-users__role">
+ @if (user.IsPending)
+ {
+ <span class="store-users__role-value">@user.Role</span>
+ }
+ else if (user.IsLocked)
+ {
+ <span class="d-inline-flex align-items-center gap-2 text-secondary" title="@StringLocalizer["The last owner's role cannot be changed"]">
+ <vc:icon symbol="lock" />
+ <span>@user.Role</span>
+ </span>
+ }
+ else
+ {
+ <form method="post" asp-action="UpdateStoreUser" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id" class="store-users__role-form" permission="@Policies.CanModifyStoreSettings">
+ <select name="Role" class="form-select form-select-sm" asp-items="@(new SelectList(await StoreRepository.GetStoreRoles(storeId), nameof(StoreRepository.StoreRole.Id), nameof(StoreRepository.StoreRole.Role), user.Role))" onchange="this.form.submit()"></select>
+ <button type="submit" name="command" value="Update:@user.Id" class="btn btn-link btn-sm p-0 d-none" text-translate="true">Save</button>
+ </form>
+ <span not-permission="@Policies.CanModifyStoreSettings">@user.Role</span>
+ }
+ </td>
+ <td class="align-middle store-users__status">
+ @if (!user.IsPending)
+ {
+ <span text-translate="true">Active</span>
+ }
+ else if (user.IsExpired)
+ {
+ <span class="badge rounded-pill text-bg-danger" data-bs-toggle="tooltip" title="@StringLocalizer["Expired {0}", user.Expiry!.Value.ToString("g", CultureInfo.InvariantCulture)]" text-translate="true">Expired</span>
+ }
+ else
+ {
+ var age = DateTimeOffset.UtcNow - user.InvitedAt!.Value;
+ var showAge = age > BTCPayServer.Data.StoreInvitationRow.Lifetime / 2;
+ <span class="badge rounded-pill text-bg-warning" data-bs-toggle="tooltip" title="@StringLocalizer["Invited {0}", user.InvitedAt!.Value.ToString("g", CultureInfo.InvariantCulture)]">
+ <span text-translate="true">Pending</span>@(showAge ? $" · {age.TimeString()}" : "")
+ </span>
+ }
+ </td>
+ <td class="actions-col align-middle" permission="@Policies.CanModifyStoreSettings">
<div class="d-inline-flex align-items-center gap-3">
- <a asp-action="UpdateStoreUser" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#EditModal" data-user-email="@user.Email" data-user-role="@user.Role" text-translate="true">Change Role</a>
- <a asp-action="DeleteStoreUser" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@StringLocalizer["This action will prevent {0} from accessing this store and its settings.", Html.Encode(user.Email)]" data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
+ @if (user.IsPending)
+ {
+ <form method="post" asp-action="ResendStoreInvitation" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id">
+ <button type="submit" class="btn btn-link p-0 store-users__resend" text-translate="true">Resend</button>
+ </form>
+ <form method="post" asp-action="CancelStoreInvitation" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id">
+ <button type="submit" class="btn btn-link p-0 store-users__cancel">@(user.IsExpired ? StringLocalizer["Remove"] : StringLocalizer["Cancel"])</button>
+ </form>
+ }
+ else if (user.IsLocked)
+ {
+ <span class="text-secondary" text-translate="true">Can't remove</span>
+ }
+ else
+ {
+ <a asp-action="DeleteStoreUser" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id" class="store-users__remove" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@StringLocalizer["This action will prevent {0} from accessing this store and its settings.", Html.Encode(user.Email)]" data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
+ }
</div>
</td>
</tr>
@@ -72,49 +158,20 @@
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Remove store user"], StringLocalizer["This action will prevent the user from accessing this store and its settings. Are you sure?"], StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
-<div class="modal fade" id="EditModal" tabindex="-1" aria-labelledby="EditTitle" aria-hidden="true" permission="@Policies.CanModifyStoreSettings">
- <div class="modal-dialog modal-dialog-centered">
- <div class="modal-content">
- <div class="modal-header">
- <h4 class="modal-title" id="EditTitle"><span text-translate="true">Edit</span> <span id="EditUserEmail">store user</span></h4>
- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
- <vc:icon symbol="close" />
- </button>
- </div>
- <form id="EditForm" method="post" rel="noreferrer noopener">
- <div class="modal-body">
- <label asp-for="Role" for="EditUserRole" class="form-label" text-translate="true">New role</label>
- <select asp-for="Role" id="EditUserRole" class="form-select w-auto" asp-items="roles"></select>
- </div>
- <div class="modal-footer">
- <button type="button" class="btn btn-secondary only-for-js" data-bs-dismiss="modal" id="EditCancel" text-translate="true">Cancel</button>
- <button type="submit" class="btn btn-primary" id="EditContinue" text-translate="true">Change Role</button>
- </div>
- </form>
- </div>
- </div>
-</div>
-
-<script>
- (function () {
- const modal = document.getElementById('EditModal')
- modal.addEventListener('show.bs.modal', event => {
- const $target = event.relatedTarget
- const $form = document.getElementById('EditForm')
- const $role = document.getElementById('EditUserRole')
- const $email = document.getElementById('EditUserEmail')
- const { userEmail, userRole } = $target.dataset
- const action = $target.dataset.action || ($target.nodeName === 'A'
- ? $target.getAttribute('href')
- : $target.form.getAttribute('action'))
-
- if ($form && !$form.hasAttribute('action')) $form.setAttribute('action', action)
- if (userEmail) $email.textContent = userEmail
- if (userRole) $role.value = userRole
- });
- })()
-</script>
-
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
+ <script>
+ (function () {
+ const toggle = document.getElementById('RequireInvitation')
+ const button = document.getElementById('AddUser')
+ if (!toggle || !button) return
+ const relabel = () => {
+ button.textContent = toggle.checked
+ ? button.dataset.labelInvite
+ : button.dataset.labelAdd
+ }
+ toggle.addEventListener('change', relabel)
+ relabel()
+ })()
+ </script>
}
### BTCPayServer/Views/UIUserStores/AcceptStoreInvitation.cshtml
@@ -0,0 +1,61 @@
+@model BTCPayServer.Models.StoreViewModels.StoreInvitationViewModel
+@{
+ Layout = "_LayoutWizard";
+ ViewData["WizardMainClass"] = "col-md-10 col-lg-8 col-xl-4";
+ ViewData.SetTitle(StringLocalizer["Join {0}", Model.StoreName]);
+}
+
+@section Navbar {
+ <a asp-action="ListStores" id="CancelWizard" class="cancel">
+ <vc:icon symbol="cross" />
+ </a>
+}
+
+<header class="text-center">
+ <h1 class="mb-4">@ViewData["Title"]</h1>
+</header>
+
+<div class="store-invitation">
+ @if (!string.IsNullOrEmpty(Model.Error))
+ {
+ <div class="alert alert-danger" role="alert">@Model.Error</div>
+ }
+
+ @if (Model.IsForAnotherUser)
+ {
+ <p class="text-center store-invitation__wrong-user">
+ @if (string.IsNullOrEmpty(Model.InvitedEmail))
+ {
+ <span text-translate="true">This invitation belongs to a different account.</span>
+ }
+ else
+ {
+ @ViewLocalizer["This invitation was sent to <strong>{0}</strong>. Sign in with that account to accept it.", Html.Encode(Model.InvitedEmail)]
+ }
+ </p>
+ <div class="d-grid gap-3 mt-4">
+ <a asp-controller="UIAccount" asp-action="Logout" class="btn btn-primary" text-translate="true">Sign in as another user</a>
+ <a asp-action="ListStores" class="btn btn-secondary" text-translate="true">Back to stores</a>
+ </div>
+ }
+ else if (Model.IsExpired)
+ {
+ <p class="text-center text-secondary" text-translate="true">This invitation has expired. Ask the store owner to send you a new one.</p>
+ <div class="d-grid mt-4">
+ <a asp-action="ListStores" class="btn btn-secondary" text-translate="true">Back to stores</a>
+ </div>
+ }
+ else
+ {
+ <p class="text-center">
+ @ViewLocalizer["You have been invited to join <strong>{0}</strong> as {1}.", Html.Encode(Model.StoreName), Html.Encode(Model.Role)]
+ </p>
+ <p class="text-center text-secondary store-invitation__expiry">
+ @StringLocalizer["This invitation expires {0}.", Model.Expiry.ToBrowserDate(ViewsRazor.DateDisplayFormat.Relative)]
+ </p>
+ <form method="post" class="d-grid gap-3 mt-4">
+ <button type="submit" name="command" value="accept" class="btn btn-primary store-invitation__accept" text-translate="true">Accept invitation</button>
+ <button type="submit" name="command" value="decline" class="btn btn-secondary store-invitation__decline" text-translate="true">Decline</button>
+ </form>
+ }
+</div>
### BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-users.json
@@ -34,7 +34,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canviewstoresettings"
],
"Basic": []
}
@@ -44,8 +44,8 @@
"tags": [
"Stores (Users)"
],
- "summary": "Add a store user",
- "description": "Add a store user",
+ "summary": "Add or invite a store user",
+ "description": "Add a store user. By default, this creates a store invitation. To add the user directly, set `requireInvitation` to `false`; this requires server settings permission.",
"operationId": "Stores_AddStoreUser",
"parameters": [
{
@@ -57,7 +57,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/StoreUserData"
+ "$ref": "#/components/schemas/AddStoreUserDataRequest"
}
}
},
@@ -66,7 +66,14 @@
},
"responses": {
"200": {
- "description": "The user was added"
+ "description": "The user was added, or an invitation was created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AddStoreUserResult"
+ }
+ }
+ }
},
"400": {
"description": "A list of errors that occurred when adding the store user",
@@ -85,7 +92,7 @@
"description": "The store or user could not be found"
},
"409": {
- "description": "Error code: `duplicate-store-user-role`. Removing this user would result in the store having no owner.",
+ "description": "Error codes: `already-store-user`",
"content": {
"application/json": {
"schema": {
@@ -103,15 +110,276 @@
"Basic": []
}
]
+ },
+ "put": {
+ "tags": [
+ "Stores (Users)"
+ ],
+ "summary": "Update a store user role",
+ "description": "Update the role of an existing store user. The user id or email must be provided in the request body.",
+ "operationId": "Stores_UpdateStoreUserByRequest",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ }
+ ],
+ "requestBody": {
+ "x-name": "request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StoreUserDataRequest"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 1
+ },
+ "responses": {
+ "200": {
+ "description": "The user was updated"
+ },
+ "400": {
+ "description": "A list of errors that occurred when updating the store user",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ValidationProblemDetails"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "If you are authenticated but forbidden to update store users"
+ },
+ "404": {
+ "description": "The store or user could not be found"
+ },
+ "409": {
+ "description": "Error codes: `store-user-role-orphaned`, `duplicate-store-user-role`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmodifystoresettings"
+ ],
+ "Basic": []
+ }
+ ]
+ }
+ },
+ "/api/v1/stores/{storeId}/users/invitations": {
+ "get": {
+ "tags": [
+ "Stores (Users)"
+ ],
+ "summary": "Get pending store invitations",
+ "description": "View pending and expired invitations for the specified store.",
+ "operationId": "Stores_GetStoreInvitations",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The store invitations",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StoreInvitationDataList"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "If you are authenticated but forbidden to view the specified store's invitations"
+ },
+ "404": {
+ "description": "The store could not be found"
+ }
+ },
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canviewstoresettings"
+ ],
+ "Basic": []
+ }
+ ]
+ }
+ },
+ "/api/v1/invitations/{token}": {
+ "get": {
+ "tags": [
+ "Stores (Users)"
+ ],
+ "summary": "Get a store invitation",
+ "description": "Get invitation details from its token before accepting or declining it.",
+ "operationId": "Stores_GetStoreInvitation",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreInvitationToken"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The store invitation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StoreInvitationData"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "If you are authenticated but forbidden to modify your profile"
+ },
+ "404": {
+ "description": "Error code: `store-invitation-not-found`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.user.canmodifyprofile"
+ ],
+ "Basic": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Stores (Users)"
+ ],
+ "summary": "Accept a store invitation",
+ "description": "Accept the current user's pending invitation identified by its token.",
+ "operationId": "Stores_AcceptStoreInvitation",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreInvitationToken"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The invitation was accepted"
+ },
+ "400": {
+ "description": "Error code: `invalid-role`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "If you are authenticated but forbidden to modify your profile"
+ },
+ "404": {
+ "description": "Error code: `store-invitation-not-found`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Error code: `already-store-user`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "410": {
+ "description": "Error code: `store-invitation-expired`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.user.canmodifyprofile"
+ ],
+ "Basic": []
+ }
+ ]
+ },
+ "delete": {
+ "tags": [
+ "Stores (Users)"
+ ],
+ "summary": "Decline a store invitation",
+ "description": "Decline the current user's invitation identified by its token.",
+ "operationId": "Stores_DeclineStoreInvitation",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreInvitationToken"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The invitation was declined"
+ },
+ "403": {
+ "description": "If you are authenticated but forbidden to modify your profile"
+ },
+ "404": {
+ "description": "Error code: `store-invitation-not-found`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.user.canmodifyprofile"
+ ],
+ "Basic": []
+ }
+ ]
}
},
"/api/v1/stores/{storeId}/users/{idOrEmail}": {
"put": {
"tags": [
"Stores (Users)"
],
- "summary": "Updates a store user",
- "description": "Updates a store user",
+ "summary": "Update a store user role",
+ "description": "Update the role of an existing store user.",
"operationId": "Stores_UpdateStoreUser",
"parameters": [
{
@@ -126,7 +394,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/StoreUserData"
+ "$ref": "#/components/schemas/StoreUserDataRequest"
}
}
},
@@ -152,6 +420,16 @@
},
"404": {
"description": "The store or user could not be found"
+ },
+ "409": {
+ "description": "Error codes: `store-user-role-orphaned`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
}
},
"security": [
@@ -218,9 +496,113 @@
}
]
}
+ },
+ "/api/v1/stores/{storeId}/users/{idOrEmail}/invitation": {
+ "post": {
+ "tags": [
+ "Stores (Users)"
+ ],
+ "summary": "Resend a store invitation",
+ "description": "Generate and deliver a new token for an existing pending store invitation.",
+ "operationId": "Stores_ResendStoreInvitation",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/UserIdOrEmail"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The replacement invitation token and link",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StoreInvitationResult"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "If you are authenticated but forbidden to modify store users"
+ },
+ "404": {
+ "description": "Error codes: `user-not-found`, `store-invitation-not-found`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmodifystoresettings"
+ ],
+ "Basic": []
+ }
+ ]
+ },
+ "delete": {
+ "tags": [
+ "Stores (Users)"
+ ],
+ "summary": "Cancel a store invitation",
+ "description": "Cancel the specified user's pending store invitation.",
+ "operationId": "Stores_CancelStoreInvitation",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/UserIdOrEmail"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The invitation was cancelled"
+ },
+ "403": {
+ "description": "If you are authenticated but forbidden to modify store users"
+ },
+ "404": {
+ "description": "Error codes: `user-not-found`, `store-invitation-not-found`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmodifystoresettings"
+ ],
+ "Basic": []
+ }
+ ]
+ }
}
},
"components": {
+ "parameters": {
+ "StoreInvitationToken": {
+ "name": "token",
+ "in": "path",
+ "required": true,
+ "description": "The raw store invitation token",
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
"schemas": {
"StoreUserDataList": {
"type": "array",
@@ -229,33 +611,144 @@
}
},
"StoreUserData": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The id of the user",
+ "nullable": false
+ },
+ "email": {
+ "type": "string",
+ "description": "The email of the user",
+ "nullable": false
+ },
+ "roleId": {
+ "type": "string",
+ "description": "The store role id of the user",
+ "nullable": false
+ }
+ }
+ },
+ "StoreUserDataRequest": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The id or email of the user",
+ "nullable": false
+ },
+ "storeRole": {
+ "type": "string",
+ "description": "The role of the user. Default roles are `Owner`, `Manager`, `Employee` and `Guest`",
+ "nullable": false
+ }
+ }
+ },
+ "AddStoreUserDataRequest": {
"allOf": [
+ {
+ "$ref": "#/components/schemas/StoreUserDataRequest"
+ },
{
"type": "object",
"properties": {
- "userId": {
- "type": "string",
- "description": "The id of the user (Deprecated, use `id` instead)",
- "nullable": false,
- "deprecated": true
- },
- "role": {
- "type": "string",
- "description": "The role of the user. Default roles are `Owner`, `Manager`, `Employee` and `Guest` (Deprecated, use `storeRole` instead)",
- "nullable": false,
- "deprecated": true
- },
- "storeRole": {
- "type": "string",
- "description": "The role of the user. Default roles are `Owner`, `Manager`, `Employee` and `Guest`",
- "nullable": false
+ "requireInvitation": {
+ "type": "boolean",
+ "description": "Whether to create a store invitation instead of adding the user directly. Defaults to `true`. Setting this to `false` requires server settings permission.",
+ "nullable": true
}
}
- },
- {
- "$ref": "#/components/schemas/ApplicationUserData"
}
]
+ },
+ "AddStoreUserResult": {
+ "type": "object",
+ "properties": {
+ "storeInvitation": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/StoreInvitationResult"
+ }
+ ],
+ "nullable": true,
+ "description": "Invitation details when an invitation was created."
+ }
+ }
+ },
+ "StoreInvitationResult": {
+ "type": "object",
+ "properties": {
+ "token": {
+ "type": "string",
+ "description": "The invitation token",
+ "nullable": false
+ },
+ "link": {
+ "type": "string",
+ "description": "The invitation link",
+ "nullable": false
+ }
+ }
+ },
+ "StoreInvitationDataList": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/StoreInvitationData"
+ }
+ },
+ "StoreInvitationData": {
+ "type": "object",
+ "properties": {
+ "storeId": {
+ "type": "string",
+ "description": "The id of the invited store",
+ "nullable": false
+ },
+ "storeName": {
+ "type": "string",
+ "description": "The name of the invited store",
+ "nullable": false
+ },
+ "userId": {
+ "type": "string",
+ "description": "The id of the invited user",
+ "nullable": false
+ },
+ "userEmail": {
+ "type": "string",
+ "description": "The email of the invited user",
+ "nullable": false
+ },
+ "roleId": {
+ "type": "string",
+ "description": "The store role assigned by the invitation",
+ "nullable": false
+ },
+ "invitedByUserId": {
+ "type": "string",
+ "description": "The id of the user who created the invitation",
+ "nullable": true
+ },
+ "created": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The invitation creation time"
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The invitation expiration time"
+ },
+ "isExpired": {
+ "type": "boolean",
+ "description": "Whether the invitation has expired"
+ },
+ "isForCurrentUser": {
+ "type": "boolean",
+ "description": "Whether the invitation belongs to the authenticated user"
+ }
+ }
}
}
},Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.