Public Access
Let a team change hands, and be joined by somebody with no account yet
M3 built teams and stopped short of the two operations that decide who controls one. Both were written down as refusals rather than omissions: ADR 0009 listed ownership transfer under "deliberately not built", and design-import-gaps said an invitation needed "a token with a lifetime and an outbound mail path". One of those reasons had expired and the other never applied — an invitation does not need a token if it is not a thing anybody presents. Handing a team over is one write. The member you name becomes owner and you become an admin, in a single transaction, because ownership is sole: promoting first leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody left with the authority to finish a transfer that stopped in the middle. That is also why it is not two calls to the role endpoint, which refuses Owner outright. The outgoing owner is demoted rather than removed — removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one asked for, and somebody handing over a team is usually staying in it. It unblocks the thing that was impossible before: an owner can now leave, by handing the team on first. An invitation is a standing instruction rather than a message. This server has no outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row says the next account signing in with that address joins this team at this role, and telling them to sign in is the caller's job over a channel this server does not carry. A link nobody can deliver would be worse than none. It lives in its own table rather than becoming a membership with MembershipStatus.Invited, and that member stays unwritten for the reason it always was: team_membership.user_id is not nullable and carries a foreign key, so somebody who has never signed in has nothing for that row to point at. Widening it would make the unique index on (team, user) meaningless, because PostgreSQL counts every NULL as distinct. Verification is the security boundary, and nothing in this server read it before. A claim requires the access token to assert email_verified. An invitation decides what the server will serve, so one claimable by anybody able to obtain a token carrying somebody else's address is a way into a team — which is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it would have been reintroduced by the back door. There is deliberately no setting that relaxes it: a flag that exists is one somebody turns on for the afternoon their provider is misconfigured. Absence is refused rather than trusted, and logged, because a provider that never sends the claim otherwise leaves every invitation pending with nothing anywhere saying why. Claiming happens at just-in-time provisioning and again on an hourly sweep. The sweep is what makes it recoverable rather than one-shot — an invitation issued between an account being created and that person next signing in would otherwise be stranded for ever — and it shares its rate with the last-seen write because both are housekeeping nobody is waiting on. Archiving is refused while a team owns a vault, and that refusal is the end of the road rather than a step on it. A team vault is readable because of membership, so archiving one that still owned vaults would take them away from everybody holding a key, including the caller, quietly and all at once. Nothing in this product deletes a vault, so no order of operations gets past it today — which is stated with a count of what is in the way, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. It is owner-only, as handing over is; renaming is not, because a rename is visible to everybody and reversible by anybody who can do it. The slug is not renameable at all: it is unique only among live teams, so a rename could take one an archived team is still holding, and that team could then never be restored. LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is refreshed on ordinary authenticated requests, at most once per account per hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency token, so a read-then-write on the hot path would start losing races between one user's own overlapping requests. An hour is the granularity the question is actually asked at, and the interface draws it to the day rather than the minute so it does not read as a precision that is not there. The remarks in Contracts and in the view model that argued at length for the column's absence are rewritten rather than extended; both had become false. Two endpoints already existed and nothing called them. ChangeTeamMemberRole and ListVaultGrants have been reachable since M3. The role picker refuses Owner itself rather than letting the server do it, since the interface already knew the rule; the key-holder list sits under the vault rather than beside the member, because a grant is per vault and a count on a member row would imply per-item sharing, which is M5. It lists withdrawn and stale grants and says which they are — a list that dropped them would show a departed colleague as merely absent rather than as somebody whose key was taken away — and staleness is decided by comparing generations, since a grant can be Active and still open nothing. ADD MEMBER stopped being a dead end. An address the directory did not know used to end at a sentence telling the user their colleague had to sign in first. It invites them instead, from the same button, because which of the two applies is a fact about the server's account table rather than about what the user is doing; which one happened is reported afterwards, because that decides what they do next. An address that merely has an account is invited rather than refused: refusing would have made the endpoint an oracle for which addresses have accounts here, answerable by anybody willing to create a team first. The phone has a TEAMS screen, behind MORE, and it is the reverse of every other row in design-import-gaps: a shipped screen the design had no slot for. It is there because an invitation is claimed by signing in, so somebody told they are now in a team is at least as likely to be holding a phone — and a membership visible only on a head they never installed is one they cannot see. It draws SHARE KEY and nothing that takes something away: wrapping a key is the one act on that screen a server cannot perform at all, and the desktop guards its revocations with a tooltip, which is a control a touch screen cannot show. Two defects were found by an adversarial pass and both were green against the whole suite at the time. The owner-only check on archiving and handing over had been weakened to the admin check while their messages and comments still said owner — and since nothing behind the archive endpoint re-checks it, an admin the owner had promoted could have archived the team out from under them. And the rename endpoint built its response with a hardcoded Owner role, so an admin who renamed a team was handed a summary claiming they owned it, and a client trusting that instead of re-listing would have offered them the two owner-only buttons the server then refuses. The new table gets its constraints tested rather than merely migrated: live uniqueness per (team, address), the citext proof that an address typed by a person matches one cased by a provider, and reissue after both revocation and acceptance. The teams screen gets its first entries in the layout suite, at the minimum window with every list populated and with each of the two states that cover half of it — it had none, and it just grew four sections and a second line in the member row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Invitations to addresses that have no account here yet, and the sign-in path that claims them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>An invitation is a standing instruction, not a message and not a token.</b> This server has no
|
||||
/// outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row
|
||||
/// says "the next account to sign in with this address joins this team as this role", and telling
|
||||
/// them to sign in is the caller's job over a channel this server does not carry. That is a smaller
|
||||
/// feature than the design drew, and it is the whole of what can be built honestly without a mail
|
||||
/// path — a link nobody can deliver would be worse than none.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Verification is the security boundary, and it is the only one.</b> Membership is authorization
|
||||
/// (ADR 0009), so an invitation decides what the server will serve. Claiming one on an address the
|
||||
/// identity provider has not marked verified would let anybody who can get a token asserting somebody
|
||||
/// else's address walk into their team — which is precisely the attack
|
||||
/// <c>OidcOptions.AllowEmailLinking</c> exists to refuse. So an unverified address claims nothing,
|
||||
/// there is no setting that relaxes it, and the refusal is logged rather than silent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What an invitation still cannot do is make anything readable. It creates a membership, and a
|
||||
/// membership is not a key — somebody has to wrap the vault key to them afterwards, from a machine
|
||||
/// that holds it. The split ADR 0009 describes is not weakened by this; the invitation simply moves
|
||||
/// the first half of it earlier.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class TeamInvitationService(
|
||||
DodoDbContext database,
|
||||
TimeProvider clock,
|
||||
ILogger<TeamInvitationService> logger)
|
||||
: ITeamInvitationClaim
|
||||
{
|
||||
/// <summary>Longest acceptable address. Matches the column, and RFC 5321's own limit.</summary>
|
||||
private const int MaxEmailLength = 320;
|
||||
|
||||
/// <summary>
|
||||
/// How long an invitation stays claimable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fourteen days, and finite for a reason rather than as a default. An invitation that never
|
||||
/// expired would be a standing offer against an address, and addresses are reassigned — a
|
||||
/// company address handed to the next person to hold the job would let them into a team the
|
||||
/// person who left was invited to. Fourteen days is long enough to survive a holiday and short
|
||||
/// enough that a forgotten invitation lapses rather than waiting.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan Lifetime = TimeSpan.FromDays(14);
|
||||
|
||||
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
|
||||
/// <remarks>
|
||||
/// Every member may read this, as with the members list and for the same reason: whoever is about
|
||||
/// to be handed a vault key needs to see who else is on their way into the team. Accepted and
|
||||
/// revoked rows are returned too, so the screen can show that an invitation was taken up rather
|
||||
/// than having it silently vanish and read as never sent.
|
||||
/// </remarks>
|
||||
internal async Task<IReadOnlyList<TeamInvitationSummary>> ListAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var invitations = await database.TeamInvitations
|
||||
.Where(i => i.TeamId == teamId)
|
||||
.OrderByDescending(i => i.CreatedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
return [.. invitations.Select(invitation => Describe(invitation, now))];
|
||||
}
|
||||
|
||||
/// <summary>Invites an address to a team.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>An address that already has an account here is accepted rather than refused.</b> The obvious
|
||||
/// alternative — refusing and pointing at the directory — would turn this endpoint into an oracle
|
||||
/// for which addresses have accounts, answerable by anybody willing to create a team first. It
|
||||
/// would also be answering a question the caller did not ask: they want that person in the team,
|
||||
/// and whether the account exists yet only changes how soon it happens. An existing account picks
|
||||
/// the invitation up on its next request, within the hour.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Idempotent on the client-chosen id, as team and vault creation are: the same id, team and
|
||||
/// address returns the existing invitation rather than a second one. A different address under an
|
||||
/// id already in use is refused rather than reinterpreted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<TeamInvitationSummary> CreateAsync(
|
||||
UserAccount actor,
|
||||
Guid teamId,
|
||||
CreateTeamInvitationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var email = RequireEmail(request.Email);
|
||||
var role = RequireInvitableRole(request.Role);
|
||||
|
||||
if (request.InvitationId == Guid.Empty)
|
||||
{
|
||||
throw new TeamInvitationInvalidException(
|
||||
"An invitation id is required. Generate a UUIDv7 on the client.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var existing = await database.TeamInvitations
|
||||
.SingleOrDefaultAsync(i => i.Id == request.InvitationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return ResolveExisting(existing, teamId, email, now);
|
||||
}
|
||||
|
||||
await RefuseIfAlreadyAMemberAsync(teamId, email, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var invitation = new TeamInvitation
|
||||
{
|
||||
Id = request.InvitationId,
|
||||
TeamId = teamId,
|
||||
Email = email,
|
||||
Role = role,
|
||||
InvitedByUserId = actor.Id,
|
||||
CreatedAtUtc = now,
|
||||
ExpiresAtUtc = now + Lifetime,
|
||||
};
|
||||
|
||||
database.TeamInvitations.Add(invitation);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// The partial unique index on (team, email) among live invitations. Reported as its own
|
||||
// sentence because it is the one failure the caller could not see coming from their own
|
||||
// input — somebody else may have invited the same person a minute earlier.
|
||||
throw new TeamInvitationInvalidException(
|
||||
"There is already an invitation to that address for this team. Withdraw it first if "
|
||||
+ "you want to invite them at a different role.");
|
||||
}
|
||||
|
||||
TeamLog.InvitationIssued(logger, invitation.Id, teamId, role, actor.Id);
|
||||
|
||||
return Describe(invitation, now);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A retry is the same id against the same team and address. Anything else under an id already in
|
||||
/// use is refused rather than reinterpreted: returning a differently-addressed invitation would
|
||||
/// tell a client its invite went to somebody it did not.
|
||||
/// </remarks>
|
||||
private static TeamInvitationSummary ResolveExisting(
|
||||
TeamInvitation existing,
|
||||
Guid teamId,
|
||||
string email,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var isRetry = existing.TeamId == teamId
|
||||
&& string.Equals(existing.Email, email, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return isRetry
|
||||
? Describe(existing, now)
|
||||
: throw new TeamInvitationInvalidException(
|
||||
"That invitation id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
private static TeamRole RequireInvitableRole(TeamMemberRole role)
|
||||
{
|
||||
var domain = ToDomain(role);
|
||||
|
||||
return domain is TeamRole.Unspecified or TeamRole.Owner
|
||||
? throw new TeamInvitationInvalidException(
|
||||
"Invite somebody as a viewer, member or admin. Ownership is sole and is handed over "
|
||||
+ "deliberately, never conferred by an address signing in.")
|
||||
: domain;
|
||||
}
|
||||
|
||||
/// <summary>Withdraws an invitation that has not been taken up.</summary>
|
||||
/// <returns>Whether there was a live invitation to withdraw.</returns>
|
||||
/// <remarks>
|
||||
/// An invitation that has already been claimed is <em>not</em> withdrawable, and answering false
|
||||
/// rather than unpicking it is the honest outcome: it is a membership now, and removing a member
|
||||
/// is a different operation with different consequences — it revokes their vault key grants and
|
||||
/// flags every team vault for rekey.
|
||||
/// </remarks>
|
||||
internal async Task<bool> RevokeAsync(
|
||||
UserAccount actor,
|
||||
Guid teamId,
|
||||
Guid invitationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var invitation = await database.TeamInvitations
|
||||
.SingleOrDefaultAsync(
|
||||
i => i.Id == invitationId
|
||||
&& i.TeamId == teamId
|
||||
&& i.AcceptedAtUtc == null
|
||||
&& i.RevokedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (invitation is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
invitation.RevokedAtUtc = clock.GetUtcNow();
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.InvitationRevoked(logger, invitation.Id, teamId, actor.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ClaimAsync(
|
||||
UserAccount user,
|
||||
string? email,
|
||||
bool emailVerified,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var pending = await database.TeamInvitations
|
||||
.Where(i => i.Email == email
|
||||
&& i.AcceptedAtUtc == null
|
||||
&& i.RevokedAtUtc == null
|
||||
&& i.ExpiresAtUtc > now)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!emailVerified)
|
||||
{
|
||||
// Logged rather than silent, and this is the only signal an operator gets that their
|
||||
// provider is not sending the claim. Without it, invitations would simply never work and
|
||||
// there would be nothing anywhere saying why.
|
||||
TeamLog.InvitationNotClaimedUnverified(logger, pending.Count, user.Id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await ApplyAsync(user, pending, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Turns each claimable invitation into an active membership.</summary>
|
||||
private async Task<int> ApplyAsync(
|
||||
UserAccount user,
|
||||
List<TeamInvitation> pending,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var teamIds = pending.Select(i => i.TeamId).ToArray();
|
||||
|
||||
// Archived teams are excluded here as well as at archive time. An invitation issued moments
|
||||
// before an archive can still be in flight, and joining a team nobody can see is worse than
|
||||
// an invitation that quietly lapses.
|
||||
var liveTeamIds = await database.Teams
|
||||
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var live = liveTeamIds.ToHashSet();
|
||||
|
||||
var memberships = await database.TeamMemberships
|
||||
.Where(m => teamIds.Contains(m.TeamId) && m.UserId == user.Id && m.DeletedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var claimed = 0;
|
||||
|
||||
foreach (var invitation in pending.Where(i => live.Contains(i.TeamId)))
|
||||
{
|
||||
invitation.AcceptedAtUtc = now;
|
||||
invitation.AcceptedByUserId = user.Id;
|
||||
|
||||
if (Join(user, invitation, memberships.Find(m => m.TeamId == invitation.TeamId), now))
|
||||
{
|
||||
claimed++;
|
||||
|
||||
TeamLog.InvitationClaimed(
|
||||
logger, user.Id, invitation.Id, invitation.TeamId, invitation.Role);
|
||||
}
|
||||
}
|
||||
|
||||
return await SaveClaimAsync(claimed, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Adds or reactivates the membership an invitation asks for.</summary>
|
||||
/// <returns>Whether the membership changed. False means they were already an active member.</returns>
|
||||
private bool Join(
|
||||
UserAccount user,
|
||||
TeamInvitation invitation,
|
||||
TeamMembership? membership,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (membership is null)
|
||||
{
|
||||
database.TeamMemberships.Add(new TeamMembership
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TeamId = invitation.TeamId,
|
||||
UserId = user.Id,
|
||||
Role = invitation.Role,
|
||||
Status = MembershipStatus.Active,
|
||||
InvitedByUserId = invitation.InvitedByUserId,
|
||||
JoinedAtUtc = now,
|
||||
CreatedAtUtc = now,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (membership.Status == MembershipStatus.Active)
|
||||
{
|
||||
// Already in the team — the invitation is satisfied rather than applied. It must not
|
||||
// change a role somebody set deliberately in the meantime, which is what re-applying an
|
||||
// invitation issued weeks ago would silently do.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Removed earlier and invited again. The row is reactivated rather than duplicated, exactly
|
||||
// as TeamService.AddMemberAsync does, so historic audit entries stay resolvable to one
|
||||
// membership. Their revoked key grants are not restored — those were wrapped to a generation
|
||||
// the vault has since been flagged to leave behind.
|
||||
membership.Role = invitation.Role;
|
||||
membership.Status = MembershipStatus.Active;
|
||||
membership.JoinedAtUtc = now;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Its own SaveChanges, never folded into the caller's. <c>CurrentUserContext.ProvisionAsync</c>
|
||||
/// catches a unique violation and re-reads the account by (issuer, subject); a claim sharing that
|
||||
/// call would put violations from this table inside a filter written for exactly one race, and
|
||||
/// its rethrow would stop being correct.
|
||||
/// </remarks>
|
||||
private async Task<int> SaveClaimAsync(int claimed, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// Two of this account's requests claiming at once. One wins; the other finds nothing
|
||||
// left to do on the next sweep. Swallowed rather than surfaced because this runs inside
|
||||
// the authorization middleware, where a throw is a 500 on a request that was otherwise
|
||||
// fine — and because the outcome the caller wanted has happened either way.
|
||||
foreach (var entry in database.ChangeTracker.Entries<TeamMembership>().ToList())
|
||||
{
|
||||
entry.State = EntityState.Detached;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return claimed;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Refused only for an account that is <em>already in this team</em> — a fact about a team the
|
||||
/// caller can see, so naming it leaks nothing. Whether an address has an account at all is
|
||||
/// deliberately not answered here; see <see cref="CreateAsync"/>.
|
||||
/// </remarks>
|
||||
private async Task RefuseIfAlreadyAMemberAsync(
|
||||
Guid teamId,
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isMember = await database.TeamMemberships
|
||||
.Where(m => m.TeamId == teamId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.Join(
|
||||
database.Users.Where(u => u.Email == email && u.DeletedAtUtc == null),
|
||||
m => m.UserId,
|
||||
u => u.Id,
|
||||
(m, u) => m.Id)
|
||||
.AnyAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (isMember)
|
||||
{
|
||||
throw new TeamInvitationInvalidException(
|
||||
"That address already belongs to a member of this team. Change their role instead.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Derives what has become of an invitation from its timestamps.</summary>
|
||||
/// <remarks>
|
||||
/// Computed rather than stored, which is why <see cref="TeamInvitationState"/> has no domain twin.
|
||||
/// Expiry is a fact about the clock: a stored state would need a sweeper to keep it true, and an
|
||||
/// invitation that read Pending because nothing had run yet would be a lie the interface repeats.
|
||||
/// </remarks>
|
||||
private static TeamInvitationSummary Describe(TeamInvitation invitation, DateTimeOffset now)
|
||||
{
|
||||
var state = invitation switch
|
||||
{
|
||||
{ AcceptedAtUtc: not null } => TeamInvitationState.Accepted,
|
||||
{ RevokedAtUtc: not null } => TeamInvitationState.Revoked,
|
||||
_ when invitation.ExpiresAtUtc <= now => TeamInvitationState.Expired,
|
||||
_ => TeamInvitationState.Pending,
|
||||
};
|
||||
|
||||
return new TeamInvitationSummary(
|
||||
invitation.Id,
|
||||
invitation.Email,
|
||||
ToContract(invitation.Role),
|
||||
state,
|
||||
invitation.InvitedByUserId,
|
||||
invitation.CreatedAtUtc,
|
||||
invitation.ExpiresAtUtc,
|
||||
invitation.AcceptedAtUtc);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Deliberately shallow. This checks the shape the column and the claim path need — one at-sign
|
||||
/// with something either side, no spaces, and inside the length the column holds — and nothing
|
||||
/// more. A stricter address grammar here would reject addresses that a real identity provider
|
||||
/// will happily assert, and the only thing that ultimately decides whether an address is that
|
||||
/// person's is the provider marking it verified.
|
||||
/// </remarks>
|
||||
private static string RequireEmail(string? value)
|
||||
{
|
||||
var email = (value ?? string.Empty).Trim();
|
||||
|
||||
var at = email.IndexOf('@', StringComparison.Ordinal);
|
||||
|
||||
var acceptable = email.Length is > 2 and <= MaxEmailLength
|
||||
&& at > 0
|
||||
&& at == email.LastIndexOf('@')
|
||||
&& at < email.Length - 1
|
||||
&& !email.Any(char.IsWhiteSpace);
|
||||
|
||||
return acceptable
|
||||
? email
|
||||
: throw new TeamInvitationInvalidException(
|
||||
"That does not look like an email address. Invite the address they sign in with.");
|
||||
}
|
||||
|
||||
private static TeamRole ToDomain(TeamMemberRole role) => role switch
|
||||
{
|
||||
TeamMemberRole.Viewer => TeamRole.Viewer,
|
||||
TeamMemberRole.Member => TeamRole.Member,
|
||||
TeamMemberRole.Admin => TeamRole.Admin,
|
||||
TeamMemberRole.Owner => TeamRole.Owner,
|
||||
_ => TeamRole.Unspecified,
|
||||
};
|
||||
|
||||
private static TeamMemberRole ToContract(TeamRole role) => role switch
|
||||
{
|
||||
TeamRole.Viewer => TeamMemberRole.Viewer,
|
||||
TeamRole.Member => TeamMemberRole.Member,
|
||||
TeamRole.Admin => TeamMemberRole.Admin,
|
||||
TeamRole.Owner => TeamMemberRole.Owner,
|
||||
_ => TeamMemberRole.Unspecified,
|
||||
};
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception) =>
|
||||
string.Equals(
|
||||
(exception.InnerException as PostgresException)?.SqlState,
|
||||
PostgresErrorCodes.UniqueViolation,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
Reference in New Issue
Block a user