Merge branch 'claude/adding-hosts-phone-docs-e472b0'
ci / build and test (push) Successful in 1m16s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 45s

This commit is contained in:
2026-08-03 14:41:18 +02:00
43 changed files with 6976 additions and 147 deletions
@@ -17,6 +17,38 @@ public interface ICurrentUserContext
Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken);
}
/// <summary>
/// Turns pending team invitations addressed to a verified email into memberships.
/// </summary>
/// <remarks>
/// <para>
/// Declared here, beside its only caller, and implemented in <c>Features/Teams</c>. The direction is
/// deliberate: sign-in is what an invitation waits for, so the sign-in path names the shape it needs
/// and the teams feature supplies it — rather than <see cref="ICurrentUserContext"/>, which every
/// endpoint in the server depends on, growing a reference into one feature's folder.
/// </para>
/// </remarks>
public interface ITeamInvitationClaim
{
/// <summary>
/// Claims every live invitation addressed to <paramref name="email"/> for this account.
/// </summary>
/// <param name="user">The account signing in.</param>
/// <param name="email">The address the token asserted, or null if it asserted none.</param>
/// <param name="emailVerified">
/// Whether the provider marked that address verified. False refuses the claim outright and is the
/// whole of what stops an invitation being taken by anybody able to assert somebody else's
/// address.
/// </param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>How many invitations became memberships.</returns>
Task<int> ClaimAsync(
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken);
}
/// <summary>
/// Request-scoped caller identity with just-in-time provisioning.
/// </summary>
@@ -29,9 +61,23 @@ internal sealed class CurrentUserContext(
IHttpContextAccessor accessor,
DodoDbContext database,
IOptions<Setup.OidcOptions> oidcOptions,
ITeamInvitationClaim invitations,
TimeProvider clock)
: ICurrentUserContext
{
/// <summary>
/// How stale <see cref="UserAccount.LastSeenAtUtc"/> may get before a request refreshes it.
/// </summary>
/// <remarks>
/// An hour, and coarse on purpose in both directions. Writing it on every request would put an
/// UPDATE on the hot path of every authenticated call and — because <c>user_account</c> carries
/// the xmin concurrency token — would start losing races between a user's own overlapping
/// requests. Writing it never is what made the old "last active" column impossible to offer
/// honestly. An hour answers the question a colleague actually asks, which is "this week or not",
/// and it is also the window on which a pending invitation is swept for.
/// </remarks>
private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1);
private UserAccount? cached;
/// <inheritdoc />
@@ -55,14 +101,90 @@ internal sealed class CurrentUserContext(
var options = oidcOptions.Value;
var email = principal.FindFirstValue(options.EmailClaim);
var displayName = principal.FindFirstValue(options.NameClaim);
var emailVerified = IsVerified(principal, options.EmailVerifiedClaim);
cached = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false)
?? await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
var existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
cached = await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
.ConfigureAwait(false);
// A first sign-in is exactly what an invitation is waiting for, so it is claimed at once
// rather than on the next hourly sweep — which would leave somebody staring at a team
// list that does not yet contain the team they were told they had been added to.
await invitations
.ClaimAsync(cached, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
return cached;
}
cached = existing;
await RefreshLastSeenAsync(existing, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
return cached;
}
/// <summary>
/// Records that this account is active, and sweeps for invitations it can now claim.
/// </summary>
/// <remarks>
/// <para>
/// The two are one operation because they want the same rate. Both are housekeeping nobody is
/// waiting on, and doing them together costs one extra round trip per account per hour rather
/// than two.
/// </para>
/// <para>
/// The sweep is what makes claiming recoverable rather than one-shot. A claim that failed at
/// provisioning — or an invitation issued in the window between an account being created and this
/// person next signing in — is picked up here instead of being stranded for ever.
/// </para>
/// <para>
/// <c>ExecuteUpdateAsync</c> rather than the change tracker, and the predicate rather than a
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
/// requests from one user would each read the row, each set the timestamp, and the second would
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
/// </para>
/// </remarks>
private async Task RefreshLastSeenAsync(
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
if (user.LastSeenAtUtc is { } seen && now - seen < LastSeenWindow)
{
return;
}
await database.Users
.Where(u => u.Id == user.Id
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
.ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
cancellationToken)
.ConfigureAwait(false);
await invitations
.ClaimAsync(user, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
}
/// <remarks>
/// A JWT boolean arrives as the string "true", so this parses rather than compares against a
/// constant. Anything else — absent, "false", or a value this does not understand — is false,
/// because the failure that matters is treating an unverified address as verified.
/// </remarks>
private static bool IsVerified(ClaimsPrincipal principal, string claimType) =>
bool.TryParse(principal.FindFirstValue(claimType), out var verified) && verified;
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
database.Users.SingleOrDefaultAsync(
u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
@@ -84,6 +84,188 @@ internal sealed class ListTeamsEndpoint(ICurrentUserContext currentUser, TeamSer
}
}
/// <summary>Renames a team, or changes its description.</summary>
/// <remarks>
/// Admin rather than owner-only. A rename is visible to everybody and reversible by anybody who can
/// perform it, which is the test that separates it from archiving and from handing the team over.
/// </remarks>
internal sealed class UpdateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams)
: Endpoint<UpdateTeamRequest, Results<Ok<TeamSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
// PUT, not PATCH: the body carries both fields every time, so clearing a description is
// sending null rather than a distinct verb, and a repeat is the same team.
Put("/api/v1/teams/{teamId:guid}");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("UpdateTeam")
.WithSummary("Renames a team, or changes its description.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<TeamSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
UpdateTeamRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can rename it.");
}
try
{
return TypedResults.Ok(
await teams.UpdateAsync(user, access, req, ct).ConfigureAwait(false));
}
catch (TeamInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
}
}
}
/// <summary>Archives a team.</summary>
/// <remarks>
/// Owner-only, and refused while the team owns vaults. See <c>TeamService.ArchiveAsync</c> for why
/// the refusal is the end of that road rather than a step on it.
/// </remarks>
internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamService teams)
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Delete("/api/v1/teams/{teamId:guid}");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("ArchiveTeam")
.WithSummary("Archives a team. Refused while it still owns vaults.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
if (!access.IsOwner)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only the owner of this team can archive it.");
}
try
{
await teams.ArchiveAsync(user, access.Team!, ct).ConfigureAwait(false);
return TypedResults.NoContent();
}
catch (TeamNotEmptyException exception)
{
return Problems.Coded(
StatusCodes.Status409Conflict, ProblemCodes.TeamNotEmpty, exception.Message);
}
}
}
/// <summary>Hands a team's ownership to another member.</summary>
internal sealed class TransferTeamOwnershipEndpoint(ICurrentUserContext currentUser, TeamService teams)
: Endpoint<TransferTeamOwnershipRequest, Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
// POST to a singular sub-resource rather than PUT on the member's role, because it is not a
// change to one membership: two rows move together and neither is meaningful alone.
Post("/api/v1/teams/{teamId:guid}/owner");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("TransferTeamOwnership")
.WithSummary("Hands ownership to another member, demoting the outgoing owner to admin.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
TransferTeamOwnershipRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.IsOwner)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only the owner of this team can hand it over.");
}
try
{
await teams.TransferOwnershipAsync(user, teamId, req, ct).ConfigureAwait(false);
// 204. The caller knows both ids — it supplied one and is the other — and a client that
// wants the new roles reads the members list, which is where roles live.
return TypedResults.NoContent();
}
catch (LastTeamOwnerException exception)
{
return Problems.Coded(
StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message);
}
catch (TeamInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
}
}
}
/// <summary>Lists a team's members.</summary>
internal sealed class ListTeamMembersEndpoint(ICurrentUserContext currentUser, TeamService teams)
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamMemberSummary>>, NotFound>>
@@ -306,6 +488,173 @@ internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser,
}
}
/// <summary>Lists a team's invitations.</summary>
/// <remarks>
/// Readable by every member, as the members list is: whoever is about to be handed a vault key needs
/// to see who else is on their way in. Accepted and withdrawn invitations are included so the screen
/// can say an invitation was taken up rather than letting it vanish and read as never sent.
/// </remarks>
internal sealed class ListTeamInvitationsEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamInvitationSummary>>, NotFound>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/teams/{teamId:guid}/invitations");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("ListTeamInvitations")
.WithSummary("Lists a team's invitations.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<IReadOnlyList<TeamInvitationSummary>>, NotFound>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(await invitations.ListAsync(teamId, ct).ConfigureAwait(false));
}
}
/// <summary>Invites an address to a team.</summary>
/// <remarks>
/// Authenticated rather than Enrolled, and pointedly so. Every other write that ends in somebody
/// reading a vault needs a key of the caller's own; this one does not, because an invitation grants
/// membership and membership is not readability. Requiring enrollment here would also be requiring it
/// of the wrong person — the invitee is the one with no key, and they have no account yet either.
/// </remarks>
internal sealed class CreateTeamInvitationEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: Endpoint<CreateTeamInvitationRequest, Results<Ok<TeamInvitationSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Post("/api/v1/teams/{teamId:guid}/invitations");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("CreateTeamInvitation")
.WithSummary("Invites an email address to a team.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<TeamInvitationSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
CreateTeamInvitationRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can invite people to it.");
}
try
{
var invitation = await invitations
.CreateAsync(user, teamId, req, ct)
.ConfigureAwait(false);
return TypedResults.Ok(invitation);
}
catch (TeamInvitationInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.InvalidTeamInvitation,
exception.Message);
}
}
}
/// <summary>Withdraws an invitation that has not been taken up.</summary>
/// <remarks>
/// 404 for an invitation that is not there, is not this team's, or has already been claimed — the
/// same answer for all three, and for the reason revoking a device grant gives: a caller driving
/// towards "that invitation will not let anybody in" can treat 404 as having arrived. A claimed one
/// is a membership now, and removing a member is a different operation with different consequences.
/// </remarks>
internal sealed class RevokeTeamInvitationEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Delete("/api/v1/teams/{teamId:guid}/invitations/{invitationId:guid}");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("RevokeTeamInvitation")
.WithSummary("Withdraws an invitation that has not been taken up.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var invitationId = Route<Guid>("invitationId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can withdraw an invitation.");
}
var revoked = await invitations
.RevokeAsync(user, teamId, invitationId, ct)
.ConfigureAwait(false);
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
}
}
/// <summary>Creates a vault owned by a team.</summary>
internal sealed class CreateTeamVaultEndpoint(
ICurrentUserContext currentUser,
@@ -20,10 +20,28 @@ internal sealed class TeamSlugTakenException(string message) : Exception(message
/// <summary>The change would leave a team with no owner.</summary>
/// <remarks>
/// Refused rather than allowed: a team with no owner has nobody who can appoint one, so the only
/// route back would be an operator editing the database by hand.
/// route back would be an operator editing the database by hand. The deliberate way through it is a
/// transfer, which moves ownership and the outgoing owner's demotion together.
/// </remarks>
internal sealed class LastTeamOwnerException(string message) : Exception(message);
/// <summary>The team still owns vaults, so it cannot be archived.</summary>
/// <remarks>
/// Its own type because the remedy is neither fixing the request nor picking another value: archiving
/// would hide vaults from every member including the ones holding keys to them, and nothing in this
/// product deletes a vault, so there is no sequence of calls that turns this refusal into a success
/// today. Saying that plainly is better than a flag that hides somebody's data.
/// </remarks>
internal sealed class TeamNotEmptyException(string message) : Exception(message);
/// <summary>An invitation was rejected.</summary>
/// <remarks>
/// Separate from <see cref="TeamInvalidException"/> because its commonest cause has a different
/// remedy: an address that already has an account here should be added through the directory, which
/// is the path that shows the caller the public key they are about to trust.
/// </remarks>
internal sealed class TeamInvitationInvalidException(string message) : Exception(message);
/// <summary>
/// A vault key grant was rejected.
/// </summary>
@@ -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);
}
+76
View File
@@ -65,4 +65,80 @@ internal static partial class TeamLog
+ "Blocks future reads only; see ADR 0001.")]
internal static partial void GrantRevoked(
ILogger logger, Guid vaultId, Guid recipientId, Guid actorId);
[LoggerMessage(
EventId = 2108,
Level = LogLevel.Information,
Message = "Renamed team {TeamId}, by {ActorId}.")]
internal static partial void TeamUpdated(ILogger logger, Guid teamId, Guid actorId);
/// <remarks>
/// Warning, and it names the member count, for the reason removal does: an archive takes a team
/// out of every member's list at once and only an operator can put it back.
/// </remarks>
[LoggerMessage(
EventId = 2109,
Level = LogLevel.Warning,
Message = "Archived team {TeamId} and its {MemberCount} membership(s), by {ActorId}. "
+ "Recoverable only by an operator clearing deleted_at_utc.")]
internal static partial void TeamArchived(
ILogger logger, Guid teamId, Guid actorId, int memberCount);
/// <remarks>
/// Warning rather than information: it is the only operation that takes administrative control of
/// a team away from the account that had it, and the account it is taken from is not the one
/// asking afterwards.
/// </remarks>
[LoggerMessage(
EventId = 2110,
Level = LogLevel.Warning,
Message = "Transferred ownership of team {TeamId} from {FormerOwnerId} to {NewOwnerId}. "
+ "The former owner is now an admin.")]
internal static partial void OwnershipTransferred(
ILogger logger, Guid teamId, Guid formerOwnerId, Guid newOwnerId);
/// <remarks>
/// The invitation id, never the address. TeamLog's rule is ids and outcomes only, and an email is
/// exactly the kind of personal detail a log aggregator would then keep for its whole retention.
/// </remarks>
[LoggerMessage(
EventId = 2111,
Level = LogLevel.Information,
Message = "Issued invitation {InvitationId} to team {TeamId} as {Role}, by {ActorId}.")]
internal static partial void InvitationIssued(
ILogger logger, Guid invitationId, Guid teamId, Domain.TeamRole role, Guid actorId);
[LoggerMessage(
EventId = 2112,
Level = LogLevel.Information,
Message = "Revoked invitation {InvitationId} to team {TeamId}, by {ActorId}.")]
internal static partial void InvitationRevoked(
ILogger logger, Guid invitationId, Guid teamId, Guid actorId);
[LoggerMessage(
EventId = 2113,
Level = LogLevel.Information,
Message = "User {UserId} claimed invitation {InvitationId} and joined team {TeamId} as {Role}.")]
internal static partial void InvitationClaimed(
ILogger logger, Guid userId, Guid invitationId, Guid teamId, Domain.TeamRole role);
/// <remarks>
/// <para>
/// Warning, and the one log line an operator will need when invitations appear not to work at all.
/// A provider that does not assert <c>email_verified</c> leaves every invitation pending for ever
/// with nothing else to show for it, and this is the only place that difference is visible.
/// </para>
/// <para>
/// It names the count and the account, never the address — the address is the thing being refused
/// as untrustworthy, and writing it to a log would be keeping a claim the server just rejected.
/// </para>
/// </remarks>
[LoggerMessage(
EventId = 2114,
Level = LogLevel.Warning,
Message = "Left {InvitationCount} invitation(s) unclaimed for user {UserId}: the access token "
+ "does not assert that their email address is verified. Check the identity provider "
+ "sends the email_verified claim.")]
internal static partial void InvitationNotClaimedUnverified(
ILogger logger, int invitationCount, Guid userId);
}
+242 -9
View File
@@ -24,6 +24,17 @@ internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
/// </remarks>
public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner;
/// <summary>
/// Whether the caller owns this team.
/// </summary>
/// <remarks>
/// Distinct from <see cref="CanAdminister"/>, and the distinction is load-bearing: an admin may
/// manage members and vaults, but archiving a team and handing it to somebody else are the two
/// things that decide whether the team continues to exist and who controls it. Gating those on
/// <see cref="CanAdminister"/> would let anybody the owner promoted take the team from them.
/// </remarks>
public bool IsOwner => Role is TeamRole.Owner;
/// <summary>Denied access.</summary>
public static TeamAccess Denied => new(null, TeamRole.Unspecified);
}
@@ -156,6 +167,200 @@ internal sealed class TeamService(
return team;
}
/// <summary>Renames a team, or changes its description.</summary>
/// <remarks>
/// The slug is not touched and cannot be. It is unique only among live teams, so a rename could
/// take a slug an archived team still holds, and that archived team could then never be restored
/// — a rename that quietly forecloses somebody else's recovery is worse than one the product
/// simply does not offer. There is also nowhere to record that this happened: <c>team</c> has no
/// updated-at column, so nothing can show "edited" and the log line is the only trace.
/// </remarks>
/// <param name="actor">Who is renaming it.</param>
/// <param name="access">
/// The caller's resolved access. The <em>role</em> is taken from here rather than assumed, because
/// an admin may rename a team and telling them the response says <see cref="TeamRole.Owner"/> would
/// hand a client a summary claiming rights it does not have — and this is the one write on a team
/// that both an admin and an owner can perform.
/// </param>
/// <param name="request">The new name and description.</param>
/// <param name="cancellationToken">Cancellation.</param>
internal async Task<TeamSummary> UpdateAsync(
UserAccount actor,
TeamAccess access,
UpdateTeamRequest request,
CancellationToken cancellationToken)
{
var team = access.Team
?? throw new TeamInvalidException("That team is not there.");
ArgumentNullException.ThrowIfNull(request);
team.Name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
team.Description = OptionalText(request.Description, MaxDescriptionLength);
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.TeamUpdated(logger, team.Id, actor.Id);
var memberCount = await CountMembersAsync(team.Id, cancellationToken).ConfigureAwait(false);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
return new TeamSummary(
team.Id, team.Name, team.Slug, team.Description,
ToContract(access.Role), memberCount, vaultCount, team.CreatedAtUtc);
}
/// <summary>
/// Archives a team, provided it owns no vaults.
/// </summary>
/// <remarks>
/// <para>
/// <b>The vault check is the whole of this operation's safety and it refuses rather than
/// cascades.</b> Archiving a team hides it from every member's list at once, and a team vault
/// resolves through membership — so archiving one that still owned vaults would take those vaults
/// away from people who hold keys to them, silently, including the caller. Nothing in this product
/// deletes a vault, so there is no sequence of calls that turns this refusal into a success today.
/// That is stated plainly rather than worked around, for the reason the SFTP layer refuses a
/// recursive delete: a refusal is visible and a quiet removal is not.
/// </para>
/// <para>
/// Memberships are archived with the team, in one transaction, because a live membership pointing
/// at an archived team is a row every membership query has to remember to exclude twice. The slug
/// is freed by the same write — the unique index is filtered on <c>deleted_at_utc IS NULL</c> — so
/// a team can be recreated under the archived one's slug, and restoring the archived one would
/// then collide. Only an operator can restore it, and this is the thing they have to look at
/// first.
/// </para>
/// </remarks>
internal async Task ArchiveAsync(
UserAccount actor,
Team team,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(team);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0)
{
throw new TeamNotEmptyException(
string.Create(
CultureInfo.InvariantCulture,
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. There is no way to delete a vault in this product yet, so a team with vaults cannot be archived."));
}
var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy();
var archived = await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
var memberships = await database.TeamMemberships
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var membership in memberships)
{
membership.Status = MembershipStatus.Revoked;
membership.DeletedAtUtc = now;
}
// Pending invitations go too. An invitation that outlived its team would become a
// membership of something nobody can see, on a sign-in weeks later.
var invitations = await database.TeamInvitations
.Where(i => i.TeamId == team.Id
&& i.AcceptedAtUtc == null
&& i.RevokedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var invitation in invitations)
{
invitation.RevokedAtUtc = now;
}
team.DeletedAtUtc = now;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return memberships.Count;
}).ConfigureAwait(false);
TeamLog.TeamArchived(logger, team.Id, actor.Id, archived);
}
/// <summary>
/// Hands ownership to another active member, demoting the outgoing owner to admin.
/// </summary>
/// <remarks>
/// <para>
/// One transaction, because ownership is sole and the two writes are not separable: promoting
/// first leaves the team owned twice, demoting first leaves it owned by nobody, and a failure
/// between them leaves whichever of those the ordering chose. That is why this is not two calls
/// to <see cref="ChangeRoleAsync"/>, which refuses <see cref="TeamRole.Owner"/> outright.
/// </para>
/// <para>
/// The recipient must already be an active member. Adding somebody and handing them the team in
/// one step would let an id supplied once take it, and the reason
/// <see cref="AddMemberAsync"/> refuses the owner role is the same one.
/// </para>
/// <para>
/// The outgoing owner is demoted rather than removed. Removing them would revoke their vault key
/// grants and flag every team vault for rekey — a far larger act than the one asked for, and
/// somebody handing over a team is usually staying in it.
/// </para>
/// </remarks>
internal async Task TransferOwnershipAsync(
UserAccount actor,
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (request.UserId == actor.Id)
{
throw new TeamInvalidException("You already own this team.");
}
var outgoing = await RequireMembershipAsync(teamId, actor.Id, cancellationToken)
.ConfigureAwait(false);
// Belt and braces: the endpoint already refused anybody who is not the owner. Checking again
// here keeps the invariant with the code that enforces it rather than one layer away.
if (outgoing.Role != TeamRole.Owner)
{
throw new LastTeamOwnerException("Only this team's owner can hand it over.");
}
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
.ConfigureAwait(false);
var strategy = database.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
incoming.Role = TeamRole.Owner;
outgoing.Role = TeamRole.Admin;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
TeamLog.OwnershipTransferred(logger, teamId, actor.Id, request.UserId);
}
/// <summary>Lists the teams the caller is an active member of.</summary>
internal async Task<IReadOnlyList<TeamSummary>> ListAsync(
UserAccount user,
@@ -260,7 +465,8 @@ internal sealed class TeamService(
ToContract(m.Role),
ToContract(m.Status),
enrolledIds.Contains(m.UserId),
m.JoinedAtUtc)),
m.JoinedAtUtc,
m.User?.LastSeenAtUtc)),
];
}
@@ -268,8 +474,9 @@ internal sealed class TeamService(
/// <remarks>
/// <para>
/// The role may not be <see cref="TeamMemberRole.Owner"/>. Ownership is sole, so granting it to
/// somebody else is a transfer rather than an addition — a different operation with a different
/// confirmation, and not one M3 offers.
/// somebody else is a transfer rather than an addition — a different operation, with its own
/// endpoint, which demotes the outgoing owner in the same transaction. Adding somebody straight
/// to owner would hand a team to an id typed once.
/// </para>
/// <para>
/// Re-adding a removed member reactivates the original row rather than inserting a second one,
@@ -365,7 +572,8 @@ internal sealed class TeamService(
ToContract(membership.Role),
ToContract(membership.Status),
isEnrolled,
membership.JoinedAtUtc);
membership.JoinedAtUtc,
user.LastSeenAtUtc);
}
/// <summary>Changes a member's role.</summary>
@@ -388,12 +596,15 @@ internal sealed class TeamService(
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
.ConfigureAwait(false);
// Demoting the owner is what would leave the team ownerless, and there is no transfer to
// do it through yet. Refused with the code a client can act on rather than a bare 400.
// Demoting the owner here would leave the team ownerless, because this operation cannot
// appoint a replacement in the same breath. Transferring can, and does both at once — so
// the refusal names it rather than saying the thing is impossible.
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"This team's owner cannot be demoted, because nothing can appoint a replacement yet.");
"This team's owner cannot be demoted on its own. Transfer ownership to another "
+ "member instead: that hands the team over and makes the outgoing owner an admin, "
+ "in one step, so the team is never left with nobody who can manage it.");
}
membership.Role = role;
@@ -445,8 +656,9 @@ internal sealed class TeamService(
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"This team's owner cannot be removed. Ownership transfer is not implemented, so "
+ "removing them would leave the team with nobody who can manage it.");
"This team's owner cannot be removed while they own it, because that would leave the "
+ "team with nobody who can manage it. Transfer ownership to another member first — "
+ "the outgoing owner becomes an admin and can then be removed like anybody else.");
}
var now = clock.GetUtcNow();
@@ -580,6 +792,27 @@ internal sealed class TeamService(
?? throw new TeamInvalidException("That account is not an active member of this team.");
}
/// <summary>Counts a team's active members.</summary>
private Task<int> CountMembersAsync(Guid teamId, CancellationToken cancellationToken) =>
database.TeamMemberships.CountAsync(
m => m.TeamId == teamId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken);
/// <summary>Counts the vaults a team owns.</summary>
/// <remarks>
/// Filtered on <c>OwnerKind</c> as well as on the id, matching <see cref="ListAsync"/>. A vault
/// carrying a team id it does not belong to would otherwise be counted here and not there, and
/// this count is what decides whether a team may be archived.
/// </remarks>
private Task<int> CountVaultsAsync(Guid teamId, CancellationToken cancellationToken) =>
database.Vaults.CountAsync(
v => v.TeamId == teamId
&& v.OwnerKind == VaultOwnerKind.Team
&& v.DeletedAtUtc == null,
cancellationToken);
/// <remarks>
/// A retry is the same id with the same name and slug, from the account that owns it. Anything
/// else under an id that is already taken is refused: silently returning somebody else's team
+7
View File
@@ -36,6 +36,13 @@ builder.Services.AddScoped<DeviceService>();
builder.Services.AddScoped<DirectoryService>();
builder.Services.AddScoped<KeyLogService>();
builder.Services.AddScoped<TeamService>();
builder.Services.AddScoped<TeamInvitationService>();
// Registered twice on purpose, resolving to the same scoped instance: the endpoints take the
// concrete service, and CurrentUserContext takes only the claim it needs, so the sign-in path does
// not gain a reference to the whole of a feature it calls one method on.
builder.Services.AddScoped<ITeamInvitationClaim>(
provider => provider.GetRequiredService<TeamInvitationService>());
builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
+21
View File
@@ -63,6 +63,27 @@ public sealed class OidcOptions
/// <summary>Claim type holding the user's display name.</summary>
public string NameClaim { get; set; } = "name";
/// <summary>
/// Claim type asserting that the provider has verified the user's email.
/// </summary>
/// <remarks>
/// <para>
/// Read for exactly one purpose: deciding whether a pending team invitation addressed to that
/// email may be claimed. Nothing else in this server trusts the email claim for anything, and
/// <see cref="AllowEmailLinking"/> records why — a token from any configured provider carrying a
/// victim's address must not confer access to anything of theirs. An invitation is access, so it
/// needs the same bar.
/// </para>
/// <para>
/// <b>Absence is a refusal, not a default.</b> A provider that does not send this claim leaves
/// every invitation pending for ever, which is visible on the teams screen and diagnosable in the
/// log. There is deliberately no option to trust an unverified address instead: a flag that exists
/// is a flag somebody turns on for the afternoon their provider is misconfigured, and this is the
/// one it must not be possible to turn on.
/// </para>
/// </remarks>
public string EmailVerifiedClaim { get; set; } = "email_verified";
}
/// <summary>Schema management.</summary>
@@ -45,10 +45,16 @@ internal static class EndpointRegistration
typeof(SyncPushEndpoint),
typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint),
typeof(UpdateTeamEndpoint),
typeof(ArchiveTeamEndpoint),
typeof(TransferTeamOwnershipEndpoint),
typeof(ListTeamMembersEndpoint),
typeof(AddTeamMemberEndpoint),
typeof(ChangeTeamMemberRoleEndpoint),
typeof(RemoveTeamMemberEndpoint),
typeof(ListTeamInvitationsEndpoint),
typeof(CreateTeamInvitationEndpoint),
typeof(RevokeTeamInvitationEndpoint),
typeof(CreateTeamVaultEndpoint),
typeof(ListVaultGrantsEndpoint),
typeof(IssueVaultGrantEndpoint),
@@ -8,10 +8,13 @@
<!--
Design v2 — MORE: the hub for everything the bottom bar has no room for.
Four slots and nine destinations is the arithmetic the design solves by putting five of them one tap
Four slots and ten destinations is the arithmetic the design solves by putting six of them one tap
deeper. This screen is that tap. It takes the shell as its data context rather than the vault, because
every row on it is a navigation command and nothing here reads an item.
Teams is the tenth and the design never drew it — see the row itself. The count is the design's plus
one rather than a rearrangement of it: nothing moved out of the bottom bar to make room.
The rows are the design's list rows rather than cards: a card is one thing you act on, and a destination
is not a thing — it is a place. Each carries a sentence saying what is behind it, because a hub whose
entries are one word each is a menu you have to open to read.
@@ -89,6 +92,29 @@
</Grid>
</Button>
<!--
Teams, which the v2 design has no row for — it is a shipped screen the design had no slot for
rather than a drawn one with nothing behind it. It is on the phone because an invitation is
claimed by signing in, and somebody being invited is at least as likely to be holding a phone.
◎ rather than a glyph of its own. The desktop rail already draws teams with it, and two heads
giving one destination two marks is how a user learns the wrong one.
-->
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Team}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="◎" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Teams" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Who shares a keychain with you, and who holds its key." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
<Grid ColumnDefinitions="Auto,*,Auto">
@@ -14,10 +14,11 @@
state machine. What differs is only what each one draws.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
The desktop's eight rail destinations become four in a bottom bar, and five more live one tap deeper
behind MORE: snippets, SFTP, S3, logs and preferences. That is the v2 design's own arrangement, and it
replaces the first design's four, which had nothing behind them at all. Five characters was a desktop
constraint and the phone uses words.
The desktop's eight rail destinations become four in a bottom bar, and the rest live one tap deeper
behind MORE: snippets, SFTP, S3, logs, preferences — and teams, which v2 did not draw and which is
argued for on the screen itself. That is the v2 design's own arrangement, and it replaces the first
design's four, which had nothing behind them at all. Five characters was a desktop constraint and the
phone uses words.
The order is the design's rather than the rail's. Terminal sits second, beside Hosts, because those two
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
@@ -58,7 +59,7 @@
Hidden behind MORE, and that is the design's arrangement rather than a saving. v2 gives every screen
one header carrying that screen's own name and its own actions — a back arrow, an add, a refresh —
so the five hub screens draw their own and this one stands down rather than stacking a second row of
so the hub's screens draw their own and this one stands down rather than stacking a second row of
chrome above theirs. It stays on the two destinations that are the product's top level, where the
vault's name and the sync light are the most useful thing a header could say.
@@ -135,7 +136,7 @@
============ under MORE ============
The hub itself takes the shell as its data context, because every row on it is a navigation
command; the five destinations behind it each take the view model they are about, so each one is
command; the destinations behind it each take the view model they are about, so each one is
wrapped. SnippetsScreen and LogsScreen are nullable on the shell — they are rebuilt on every
unlock and nulled on lock — and it is the collapsed wrapper that keeps a template from binding
against nothing.
@@ -150,6 +151,15 @@
<views:LogsScreen DataContext="{Binding LogsScreen}" />
</Panel>
<!--
The sixth destination behind MORE, and the one v2 never drew — see the comment on the screen
itself. Wrapped like its neighbours even though Teams is not nullable: the reason for the wrapper
is the data context, not the null. IsTeamShowing is the shell's and Teams is not the shell.
-->
<Panel IsVisible="{Binding IsTeamShowing}">
<views:TeamsScreen DataContext="{Binding Teams}" />
</Panel>
<!--
One screen for both file destinations. SFTP and S3 differ in which picker they offer and in
nothing else below it — the panes, the queue and the transfers are the same IRemoteFileStore
@@ -282,8 +292,9 @@
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
<!--
IsMoreSurface rather than IsMoreShowing: this tab stands for six screens, and a bar that went
dark the moment you opened one of them would only ever light three of its four entries.
IsMoreSurface rather than IsMoreShowing: this tab stands for the hub and everything behind it,
and a bar that went dark the moment you opened one of them would only ever light three of its
four entries.
-->
<views:NavButton Grid.Column="3" Label="More" Glyph="≣" IsCurrent="{Binding IsMoreSurface}"
Command="{Binding ShowScreenCommand}"
@@ -272,7 +272,7 @@ internal sealed partial class PhoneShell : UserControl
/// </summary>
/// <remarks>
/// <para>
/// v2 is the first arrangement here with a second level: five destinations sit behind MORE, each with
/// v2 is the first arrangement here with a second level: six destinations sit behind MORE, each with
/// its own back arrow. Android's back is the same gesture as that arrow and users reach for it first,
/// and left unhandled it does not go up — it finishes the activity. Ending the application from a log
/// screen is not a plausible reading of "back".
@@ -347,7 +347,7 @@ internal sealed partial class PhoneShell : UserControl
switch (current.Screen)
{
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences:
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Team:
current.ShowScreenCommand.Execute(ShellScreen.More);
e.Handled = true;
break;
@@ -0,0 +1,367 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.TeamsScreen"
x:DataType="vm:TeamsViewModel"
Background="{StaticResource Canvas}">
<!--
TEAMS, under MORE — and the one screen behind that hub the v2 phone design never drew.
It is the reverse of every other entry in docs/design-import-gaps.md: a shipped screen the design had
no slot for, rather than a drawn screen with nothing behind it. It is on the phone because an
invitation is claimed by *signing in*, and the person being invited is at least as likely to be
holding a phone as sitting at a desktop — a team the server has just put somebody into, visible only
on a head they may never have installed, is a membership they cannot see.
That argument is also why the invited list is drawn here and not treated as an administrator's detail:
the people on it are the ones who cannot yet see the team, and the row says out loud that no mail was
sent.
So there is no mock-up to depart from. What this departs from instead is the desktop screen over the
same view model, and every difference below is a phone difference rather than a second opinion.
**The desktop's two columns are one.** A 268-pixel team list beside a members-and-vaults table does
not exist at 360dp, so the three lists stack in one scrolling column with the teams at the top. That
is the same thing HOSTS does with the desktop's sidebar and its connect column, and for the same
reason.
**Nothing scrolls inside anything.** The desktop caps its members and vaults lists at 240 and 200
pixels so the two can sit above each other in one pane. Here every list is sized to its content and
the screen's own ScrollViewer does all of the scrolling: a list that scrolls inside a page is a region
a thumb has to find the edges of, and three of them on one screen is three ways to get stuck.
◆ **SHARE KEY is drawn and nothing that takes something away is.** That is a decision rather than a
subset. Wrapping a vault key is the one act on this screen a server cannot perform at all — it needs a
machine that already holds the key, and this phone is one — so a teams screen that could only be read
would leave the product's central claim undemonstrated on the head most people carry. REMOVE MEMBER,
WITHDRAW KEY and REVOKE INVITATION are the other half of that, and each of them acts on the first
press: the view model's armed-confirmation state covers archiving a team and handing one over, and
those three are not armed by it. The desktop guards them with a tooltip instead, which is a control a
touch screen has no way to show. An irreversible revocation under a thumb with its explanation missing
is the wrong trade, so all three stay on the desktop — where the sentence beside them is visible.
Archiving and hand-over are not drawn either, for a plainer reason: they decide whether a team goes on
existing and who controls it, which is not a thing to do while walking.
**ADD MEMBER is not drawn either**, and it is the operation this screen least needs. It is an address
typed into a box, a directory lookup, a role picker, and a paragraph beside it saying what adding
somebody did *not* do — and since invitations arrived the ordinary way into a team is one the server
claims at sign-in, which is what put this screen on the phone at all. Creating a team is here, because
a team is where those invitations are sent from and it is two short fields.
**The key-holder list under a vault is not drawn.** It is a fourth list, it belongs to the selected
vault rather than to the team, and the view model publishes no flag saying whether it has anything in
it — so a heading for it would sit over nothing whenever nobody holds a key, which is exactly the
empty state this head insists comes from the view model rather than from markup. What the phone can
answer about a vault is on the vault's own row: whether *this* machine can open it.
**↻ and `+` both, because this screen has more reason to re-read than any other.** Nothing here is
cached — it is all read from the server on arrival and again at the end of every command — so the one
thing a member cannot otherwise see is a change somebody else just made: a vault key wrapped to them
from a colleague's desktop, or a team they have this moment been invited into. On the desktop the
re-read is leaving the rail and coming back, which is one click. Here it is a trip out to MORE and
back, so the button earns its place. It binds to a real command rather than to ShowScreen(Team),
which would set Screen to the value it already holds, raise nothing and reload nothing.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto" Height="56" Margin="8,0">
<Button Grid.Column="0" Classes="icon" Content="←"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Teams" Margin="4,0" />
<Button Grid.Column="2" Classes="icon" Content="↻" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="3" Classes="icon accent" Content="+" Command="{Binding NewTeamCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
<!-- ============ a new team ============ -->
<!--
Above the list rather than in place of it, which is the opposite of what the host and snippet
editors do — and the difference is what the form is about. Those two edit a row that is on screen,
so a card stacked over the list hides the thing being changed. This one is about a team that does
not exist yet, and the teams that do are exactly the useful thing to be able to see while naming it:
the slug has to be unique on this server, and the near misses are right underneath.
-->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding IsCreatingTeam}">
<StackPanel Spacing="10">
<TextBlock Classes="label" Text="NEW TEAM" />
<TextBox Classes="field" Text="{Binding NewTeamName}" PlaceholderText="name" />
<TextBox Classes="field" Text="{Binding NewTeamSlug}" PlaceholderText="slug-for-urls" />
<TextBlock Classes="body"
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server. It is fixed once the team exists — a team can be renamed and its slug cannot." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="CREATE"
Command="{Binding CreateTeamCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelNewTeamCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
Status, and it is the empty state as well: the view model writes "you are not in a team yet" into
the same property it writes an offline notice and every command's outcome into. A literal here would
be a second voice saying the same thing slightly differently.
-->
<TextBlock Grid.Row="2" Classes="detail" Margin="18,2,18,6" TextWrapping="Wrap"
Text="{Binding Status}"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- ============ the column ============ -->
<ScrollViewer Grid.Row="3">
<StackPanel Margin="0,0,0,18">
<TextBlock Classes="section" Text="TEAMS" Margin="18,4,18,4" />
<!--
Rows as cards, filled when chosen, which is what HOSTS settled on in v2 and what the radius
ladder calls a card: one item, one rule, one thing you act on. The fill is on the item rather
than on a Border inside it so the rounding the theme draws for selection is the row's own.
-->
<ListBox ItemsSource="{Binding Teams}" SelectedItem="{Binding SelectedTeam}"
IsVisible="{Binding HasTeams}" Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Detail}" />
</StackPanel>
<!-- The caller's own role in this team, which is what says why some of it is read-only. -->
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- ============ the chosen team ============ -->
<StackPanel IsVisible="{Binding HasSelection}">
<TextBlock Classes="section" Text="MEMBERS" Margin="18,18,18,4" />
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<!--
◆ The one fact on this row that decides whether the button at the foot of the screen
can do anything: an account with no published identity key has nothing for a vault
key to be wrapped to. One sentence, from the view model, painted twice rather than
written twice — the warning colour is the whole of the difference, and a converter
for it would hide that the two are the same string.
The published case is quiet rather than green. Green on this head means a shell is
open right now, and a published key is a durable fact about an account — borrowing
the status colour for it would be the second meaning that makes the first
unreadable. Only the missing key is coloured, because only it needs answering.
-->
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding KeyState}"
IsVisible="{Binding Member.IsEnrolled}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding KeyState}"
IsVisible="{Binding !Member.IsEnrolled}" />
<!--
A date to the day, or that they have never been here at all. The view model writes
both, and neither is a guess: the server records the account's last authenticated
request at most once an hour, which is what makes a day the honest unit.
-->
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextFaint}"
Text="{Binding LastActive}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="body" Margin="18,10,18,0"
Text="Being in a team is what lets the server hand somebody this team's vaults. It is not what lets them read one: a vault key can only be wrapped by a machine that already holds it, which is what sharing below does." />
<!-- ============ ◆ who has been asked and has not arrived ============ -->
<!--
The section this screen exists for, and the one the desktop had nothing to draw until
invitations were built. Read-only here: withdrawing one is a control that acts on the first
press, which is the line drawn at the top of this file.
So these are cards rather than the flat rows above them, and the shape is the difference: a row
that fills when you touch it is one of several you are choosing between, and there is nothing
to choose here. An ItemsControl rather than a ListBox for the same reason — a list with a
selection nothing reads would be a control offering something it cannot do.
Gated on the view model's own count rather than left to stand over an empty list, because a
team with nobody outstanding is the ordinary case and a permanent empty heading would make it
look like a section that had failed to load.
The waiting row carries the whole mechanism in its own sentence — no mail was sent, and they
join when they first sign in here. That is the sentence somebody has to read, because every
other product's version of this word means an email is on its way.
-->
<StackPanel IsVisible="{Binding HasInvitations}">
<TextBlock Classes="section" Text="INVITED" Margin="18,18,18,4" />
<ItemsControl ItemsSource="{Binding Invitations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TeamInvitationRowViewModel">
<Border Classes="card" Margin="12,3">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding State}"
IsVisible="{Binding !IsPending}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding IsPending}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock Classes="section" Text="VAULTS" Margin="18,18,18,4" />
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
<StackPanel Spacing="3" MinHeight="54" Margin="14,11" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<!--
Whether *this* phone can open it, which is a property of its keyring rather than
anything the server could answer. Painted the same two ways as the member's key
state above, because it is the same question asked from the other end.
-->
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding State}"
IsVisible="{Binding IsReadable}" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding !IsReadable}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="body" Margin="18,10,18,0"
Text="A vault listed here that this phone has no key to stays listed and stays shut. That is the ordinary case rather than a fault: somebody has been added to the team and nobody has wrapped the key to them yet." />
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ============ ◆ giving somebody the key ============ -->
<!--
Raised over the column when both halves of the act have been chosen, as HOSTS raises its connect bar
and SNIPPETS its insert bar, and for the reason written there: there is no second column to put it
in, so it names what it will do rather than relying on a selection being visible beside the button.
Two wrappers rather than one condition. Sharing needs a member *and* a vault, and a binding cannot
say `SelectedMember is not null && SelectedVault is not null` without a converter that does not
exist — the log screen makes the same trade for the same reason. It also gets the halves in the
right order: choosing who comes first, and until a vault is chosen there is nothing to offer them.
-->
<Panel Grid.Row="4" IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}">
<Border IsVisible="{Binding SelectedVault, Converter={x:Static ObjectConverters.IsNotNull}}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="14,12">
<StackPanel Spacing="9">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="THE KEY TO" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedVault.Name}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="FOR" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedMember.Name}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
<Button Classes="primary" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
IsEnabled="{Binding !IsBusy}" />
<!--
The sentence the desktop hangs off a tooltip, which a phone cannot show — so it is body text
under the button, where it is read before the tap rather than after it. It is not decoration:
the key-log check proves this server has been consistent with itself and nothing more.
-->
<TextBlock Classes="body"
Text="Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged. That proves the server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
</StackPanel>
</Border>
</Panel>
</Grid>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Teams, under MORE — who is in one, and which of its vaults this phone can open.</summary>
internal sealed partial class TeamsScreen : UserControl
{
public TeamsScreen() => AvaloniaXamlLoader.Load(this);
}
+118 -11
View File
@@ -75,6 +75,27 @@ public interface ITeamApi
/// <summary>Creates a team, with the caller as its owner.</summary>
Task<TeamSummary> CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken);
/// <summary>Renames a team, or changes its description.</summary>
Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Archives a team. Refused while it still owns vaults.
/// </summary>
/// <returns>
/// Whether there was a team to archive. False means there was not, which a caller driving towards
/// "that team is gone" should treat as having arrived.
/// </returns>
Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken);
/// <summary>Hands ownership to another member, demoting the outgoing owner to admin.</summary>
Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken);
/// <summary>Lists a team's members.</summary>
Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
@@ -102,6 +123,30 @@ public interface ITeamApi
/// </returns>
Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken);
/// <summary>Invites an email address to a team.</summary>
Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Withdraws an invitation that has not been taken up.
/// </summary>
/// <returns>
/// Whether there was a live invitation to withdraw. False covers one that was never there and one
/// already claimed — a claimed invitation is a membership now, and removing a member is a different
/// operation with different consequences.
/// </returns>
Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken);
/// <summary>Creates a vault owned by a team, with the creator's key grant.</summary>
Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
@@ -341,6 +386,35 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
DodoSshJsonContext.Default.TeamSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Put,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
JsonContent.Create(request, DodoSshJsonContext.Default.UpdateTeamRequest),
DodoSshJsonContext.Default.TeamSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
cancellationToken);
/// <inheritdoc />
public Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken) =>
SendNoContentAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/owner"),
JsonContent.Create(request, DodoSshJsonContext.Default.TransferTeamOwnershipRequest),
cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
@@ -386,6 +460,39 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
null,
DodoSshJsonContext.Default.IReadOnlyListTeamInvitationSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamInvitationRequest),
DodoSshJsonContext.Default.TeamInvitationSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(
CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations/{invitationId}"),
cancellationToken);
/// <inheritdoc />
public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
@@ -507,22 +614,13 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a delete whose success carries no body.
/// </summary>
/// <returns>True for a 2xx, false for a 404; anything else throws.</returns>
/// <remarks>
/// Its own path rather than <see cref="SendAsync{T}"/> with some empty response type, because the two
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
/// worth an exception; here it is the answer.
/// </remarks>
/// <summary>
/// Sends a request whose success carries no body.
/// </summary>
/// <remarks>
/// Its own path for the reason <see cref="DeleteAsync"/> gives, minus the 404: a grant that will
/// not be recorded is a failure with a problem document behind it, so there is nothing here to
/// translate into a return value.
/// not be recorded, or an ownership transfer that will not happen, is a failure with a problem
/// document behind it, so there is nothing here to translate into a return value.
/// </remarks>
private async Task SendNoContentAsync(
HttpMethod method,
@@ -547,6 +645,15 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
}
}
/// <summary>
/// Sends a delete whose success carries no body.
/// </summary>
/// <returns>True for a 2xx, false for a 404; anything else throws.</returns>
/// <remarks>
/// Its own path rather than <see cref="SendAsync{T}"/> with some empty response type, because the two
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
/// worth an exception; here it is the answer.
/// </remarks>
private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
+177 -9
View File
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:contracts="using:DodoSSH.Contracts"
x:Class="DodoSSH.Client.App.Views.TeamsScreen"
x:DataType="vm:TeamsViewModel">
@@ -13,9 +14,12 @@
and the vaults table are side by side, an addition says out loud that it granted nothing readable yet,
and SHARE KEY is its own button rather than a checkbox on the member row.
What the design asked for and is still not here: pending invitations (there is no outbound mail path and
no invitation token), two-factor state and last-active (the server records neither), and avatars (no
picture is stored anywhere). None of them is drawn with invented data.
What the design asked for and is still not here: two-factor state (no such concept exists anywhere in
this product) and avatars (no picture is stored anywhere). Invitations and last-active are here, and
both are narrower than the design drew. Nothing is sent — there is no outbound mail path and no token,
so an invitation is a standing instruction that the next account signing in with that address joins the
team, and there is consequently nothing to resend. Last-active is recorded at most once per account per
hour, so it is drawn coarsely rather than to the minute. None of it is drawn with invented data.
-->
<Grid ColumnDefinitions="268,*">
@@ -82,13 +86,71 @@
<Grid Grid.Column="1" RowDefinitions="44,*,Auto">
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<TextBlock Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="11"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<!--
The team's own operations. RENAME is an admin's; the other two are the owner's alone, and
that is the line the server draws as well — an admin the owner promoted must not be able
to archive the team or take it from them.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
IsVisible="{Binding ShowsTeamActions}">
<Button Classes="ghost" Content="RENAME" Command="{Binding RenameTeamCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
<Button Classes="ghost" Content="HAND OVER" Command="{Binding TransferOwnershipCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding OwnsSelected}"
ToolTip.Tip="Hands this team to the selected member. They become the owner and you become an admin; only the new owner can hand it on again." />
<Button Classes="danger" Content="ARCHIVE" Command="{Binding ArchiveTeamCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding OwnsSelected}"
ToolTip.Tip="Takes the team out of every member's list. Refused while it still owns any vault, and only somebody with database access can bring it back." />
</StackPanel>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
<StackPanel Margin="14,14" Spacing="18">
<!-- The rename form, in place, exactly as the create form on the left is. -->
<Border Padding="12" CornerRadius="4" BorderThickness="1"
BorderBrush="{StaticResource Border}" IsVisible="{Binding IsEditingTeam}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="RENAME TEAM" />
<TextBox PlaceholderText="Name" Text="{Binding EditTeamName}" />
<TextBox PlaceholderText="What this team is for (optional)"
Text="{Binding EditTeamDescription}" />
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="The slug does not change. It is what URLs and the server's own records use, and it is unique only among live teams — so a rename that moved it could take one an archived team is still holding." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveTeamCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelRenameTeamCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!--
The armed confirmation, drawn where the buttons that armed it were. The vault screen's
idiom, and for the same reason: there is no modal anywhere in this window.
-->
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="4" Padding="12"
IsVisible="{Binding IsConfirming}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding PendingAction.Question}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="{Binding PendingAction.Consequence}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="CONFIRM" Command="{Binding ConfirmActionCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelActionCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Members -->
<StackPanel Spacing="8">
@@ -98,14 +160,17 @@
Background="Transparent" BorderThickness="0" MaxHeight="240">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
<Grid ColumnDefinitions="*,150,Auto" Margin="0,3">
<Grid ColumnDefinitions="*,168,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding Email}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="hint" FontSize="10" VerticalAlignment="Center"
Text="{Binding KeyState}" TextWrapping="Wrap" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="hint" FontSize="10" Text="{Binding KeyState}"
TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5" Text="{Binding LastActive}" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
@@ -114,21 +179,96 @@
</ListBox.ItemTemplate>
</ListBox>
<!--
Buttons and a command rather than a selector bound to the role, which is the choice the
key editor and the category rail already make and for the reason they record: a selector
moves its own highlight before anything can refuse, so it can end up showing a role
nobody was given. OWNER is absent because it is not a role that can be assigned —
handing the team over is its own act, with its own confirmation.
-->
<StackPanel Spacing="6" IsVisible="{Binding CanAdministerSelected}">
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="VIEWER" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May pull this team's vaults and may not push. It does not withdraw a vault key they already hold." />
<Button Classes="flat choice" Content="MEMBER" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May read and change this team's vaults." />
<Button Classes="flat choice" Content="ADMIN" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May also manage members, create vaults and share vault keys." />
</StackPanel>
</StackPanel>
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}"
Margin="0,0,6,0" />
<Button Grid.Column="1" Classes="accent" Content="ADD MEMBER"
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}" />
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Adds the account with this address, or invites the address if there is no account here yet. Nothing is sent either way — tell them yourself." />
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Removes the selected member and withdraws every vault key they hold from this team. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
</Grid>
<StackPanel Spacing="4" IsVisible="{Binding CanAdministerSelected}">
<TextBlock Classes="label" Text="THEY ARRIVE AS" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="VIEWER" Classes.active="{Binding AddsAsViewer}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" />
<Button Classes="flat choice" Content="MEMBER" Classes.active="{Binding AddsAsMember}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" />
<Button Classes="flat choice" Content="ADMIN" Classes.active="{Binding AddsAsAdmin}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}" />
</StackPanel>
</StackPanel>
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
IsVisible="{Binding CanAdministerSelected}"
Text="Adding somebody lets the server serve them this team's vaults. It does not let them read one: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
</StackPanel>
<!--
Invitations, drawn only when there are any. An empty INVITED heading on every team would be
a permanent reminder of a feature most teams never use.
-->
<StackPanel Spacing="8" IsVisible="{Binding HasInvitations}">
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<TextBlock Classes="label" Text="INVITED" />
<ListBox ItemsSource="{Binding Invitations}" SelectedItem="{Binding SelectedInvitation}"
Background="Transparent" BorderThickness="0" MaxHeight="160">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamInvitationRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Email}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding State}"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Classes="danger" Content="WITHDRAW INVITATION" HorizontalAlignment="Left"
Command="{Binding RevokeInvitationCommand}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanAdministerSelected}"
ToolTip.Tip="Signing in with that address will no longer put them in this team. An invitation already taken up is a membership — remove the member instead." />
</StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<!-- Vaults -->
@@ -167,6 +307,34 @@
ToolTip.Tip="Withdraws the selected member's key to the selected vault. Blocks future reads only." />
</StackPanel>
<!--
Who can open the selected vault — the design's "shared with" avatars, as names and a
state. Under the vault rather than beside the member, because a grant is per vault: a
count on a member row would imply per-item sharing, which is M5 and does not exist.
Withdrawn and stale grants stay listed and say which they are, because a list that
quietly dropped them would show a departed colleague as merely absent rather than as
somebody whose key was taken away. The dot is Live and means exactly what it says: this
person can open this vault right now.
-->
<TextBlock Classes="label" Text="KEY HOLDERS" />
<ListBox ItemsSource="{Binding Grants}" Background="Transparent" BorderThickness="0"
MaxHeight="150">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamGrantRowViewModel">
<Grid ColumnDefinitions="10,*" Margin="0,3">
<Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center"
IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" />
<StackPanel Grid.Column="1" Spacing="2" Margin="6,0,0,0">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding State}" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
</StackPanel>
@@ -73,7 +73,7 @@ internal enum ShellScreen
/// <summary>Everything in the vault that is not a host.</summary>
Vault = 2,
/// <summary>Shared vaults and the people in them. Nothing implements it yet.</summary>
/// <summary>Shared vaults and the people in them. Both heads draw it.</summary>
Team = 3,
/// <summary>Preferences.</summary>
@@ -777,14 +777,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// The hub and everything behind it, because a bottom bar that went dark the moment you opened one of
/// its destinations would be a bar that only ever lights three of its four entries. This is the one
/// place where "which tab" and "which screen" are deliberately not the same question — the other three
/// tabs are each exactly one screen, and this one is six.
/// tabs are each exactly one screen, and this one is seven.
///
/// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
/// the rail and never asks this.
/// the rail and never asks this. <see cref="ShellScreen.Team"/> is in it for the same reason and no
/// other: the desktop has a rail entry for teams and the phone reaches them through MORE, so a screen
/// missing here is one whose arrival darkens the tab that led to it and brings the shell's own header
/// back over a screen that already has one.
/// </remarks>
internal bool IsMoreSurface =>
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences;
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences
or ShellScreen.Team;
/// <summary>
/// Whether the terminal's WebView may be on screen at this instant.
@@ -50,18 +50,119 @@ internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsS
/// What the account can be given, in one phrase.
/// </summary>
/// <remarks>
/// Not a two-factor column, not a last-active column. The server records neither: there is no
/// second-factor concept anywhere in it, and <c>LastSeenAtUtc</c> is written at provisioning and at
/// enrollment and nowhere else, so a column headed "last active" would be reporting something else.
/// What is true and worth a column is whether a vault key can be wrapped to them at all.
/// Not a two-factor column: there is no second-factor concept anywhere in the server. What is
/// true and worth a column is whether a vault key can be wrapped to them at all.
/// </remarks>
internal string KeyState => Member.IsEnrolled
? "key published"
: "no key yet — cannot be given a vault";
/// <summary>
/// The day they were last here, or that they never have been.
/// </summary>
/// <remarks>
/// A date to the day, not a time and not a "3 hours ago". Two reasons, and they point the same
/// way: the server writes this at most once an hour, so anything finer would be reading a
/// precision into it that is not there — and a relative phrase would have to be recomputed against
/// a clock, which this row does not have and which the pinned-host list already decided against by
/// rendering its own dates the same way.
/// </remarks>
internal string LastActive => Member.LastActiveAt is { } seen
? "last here " + seen.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
: "never signed in";
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
/// <summary>Whether this member's role can be changed at all.</summary>
/// <remarks>
/// The owner's cannot, and not for want of an endpoint: ownership is sole, so demoting them is
/// only meaningful as half of a transfer. That is its own command.
/// </remarks>
internal bool CanChangeRole => Member.Role != TeamMemberRole.Owner;
}
/// <summary>One vault key grant, as a row under the vault it opens.</summary>
/// <remarks>
/// This is the "shared with" list the design drew as a row of avatars. It is drawn as names and a
/// state instead, and it is a list rather than a count for a reason worth keeping: a grant is per
/// vault, so a number on an item row would imply per-item sharing, which does not exist.
/// </remarks>
internal sealed record TeamGrantRowViewModel(VaultGrantSummary Grant, uint VaultGeneration)
{
internal Guid UserId => Grant.RecipientUserId;
internal string Name => Grant.DisplayName ?? Grant.Email ?? Grant.RecipientUserId.ToString();
/// <summary>
/// What this grant is worth, in one phrase.
/// </summary>
/// <remarks>
/// Staleness is decided by comparing generations rather than by reading
/// <see cref="VaultGrantState"/> alone, which is what <c>VaultGrantsResponse.KeyGeneration</c>
/// exists for: a grant can be Active and still open nothing, because it was wrapped to a key the
/// vault has since moved past.
/// </remarks>
internal string State => Grant.State switch
{
VaultGrantState.Revoked => "withdrawn — blocks future reads only",
VaultGrantState.AwaitingRewrap => "needs wrapping again — their key changed",
_ when Grant.KeyGeneration < VaultGeneration => "stale — wrapped to an older key, opens nothing",
_ => "holds a key",
};
/// <summary>Whether this row still represents somebody who can read the vault.</summary>
internal bool IsLive =>
Grant.State == VaultGrantState.Active && Grant.KeyGeneration >= VaultGeneration;
}
/// <summary>One invitation, as a row under the members it will join.</summary>
internal sealed record TeamInvitationRowViewModel(TeamInvitationSummary Invitation)
{
internal Guid InvitationId => Invitation.InvitationId;
internal string Email => Invitation.Email;
internal string Role => Invitation.Role.ToString().ToUpperInvariant();
/// <summary>
/// What has become of it, said as a sentence rather than a status word.
/// </summary>
/// <remarks>
/// The pending case has to carry the whole mechanism, because there is nothing else on this screen
/// that could: nothing was sent, so somebody reading "invited" would reasonably wait for an email
/// that is never coming.
/// </remarks>
internal string State => Invitation.State switch
{
TeamInvitationState.Accepted => "joined",
TeamInvitationState.Revoked => "withdrawn",
TeamInvitationState.Expired => "expired — invite them again if they still need it",
_ => "waiting — they join when they first sign in here. Nothing was sent; tell them yourself.",
};
/// <summary>Whether this invitation can still be withdrawn.</summary>
internal bool IsPending => Invitation.State == TeamInvitationState.Pending;
}
/// <summary>
/// A destructive team operation, armed and waiting to be confirmed.
/// </summary>
/// <remarks>
/// The armed-state idiom the vault screen uses, and for the same reason: this window has no modal, so
/// a confirmation is drawn in place of the buttons that armed it. The target id is carried here rather
/// than read from the selection at confirm time — otherwise selecting a different row between arming
/// and confirming would apply the answer to something else.
/// </remarks>
/// <param name="TeamId">The team the action is aimed at.</param>
/// <param name="MemberId">The member it is aimed at, for a transfer.</param>
/// <param name="Question">What is being asked.</param>
/// <param name="Consequence">What will actually happen, stated honestly.</param>
internal sealed record TeamActionRequest(
Guid TeamId,
Guid MemberId,
string Question,
string Consequence);
/// <summary>One vault of the selected team, with what this account can do to it.</summary>
internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
{
@@ -108,6 +209,17 @@ internal sealed partial class TeamsViewModel(
/// <summary>Vaults the selected team owns, as far as this account can see them.</summary>
internal ObservableCollection<TeamVaultRowViewModel> Vaults { get; } = [];
/// <summary>Who holds a key to the selected vault.</summary>
/// <remarks>
/// Read from the server rather than from the session, and it is the one list on this screen that
/// has to be: the keyring can only answer whether <em>this</em> machine can open a vault, and this
/// question is about everybody else.
/// </remarks>
internal ObservableCollection<TeamGrantRowViewModel> Grants { get; } = [];
/// <summary>Invitations to addresses that are not accounts here yet.</summary>
internal ObservableCollection<TeamInvitationRowViewModel> Invitations { get; } = [];
[ObservableProperty]
private TeamRowViewModel? selectedTeam;
@@ -117,6 +229,9 @@ internal sealed partial class TeamsViewModel(
[ObservableProperty]
private TeamVaultRowViewModel? selectedVault;
[ObservableProperty]
private TeamInvitationRowViewModel? selectedInvitation;
[ObservableProperty]
private string status = string.Empty;
@@ -134,26 +249,92 @@ internal sealed partial class TeamsViewModel(
[ObservableProperty]
private string newTeamSlug = string.Empty;
// ---- Renaming a team ----
[ObservableProperty]
private bool isEditingTeam;
[ObservableProperty]
private string editTeamName = string.Empty;
[ObservableProperty]
private string editTeamDescription = string.Empty;
// ---- Adding a member ----
[ObservableProperty]
private string inviteEmail = string.Empty;
/// <summary>
/// The role a newly added or invited account gets.
/// </summary>
/// <remarks>
/// Member by default, which is the role somebody adding a colleague almost always means. Viewer
/// would be safer and would be the wrong default: an interface whose default is wrong teaches
/// people to change it without reading it.
/// </remarks>
[ObservableProperty]
private TeamMemberRole newMemberRole = TeamMemberRole.Member;
// ---- Confirming something that cannot be undone ----
[ObservableProperty]
private TeamActionRequest? pendingAction;
/// <summary>Whether there is a server to talk to at all.</summary>
internal bool IsOnline => connection() is not null;
/// <summary>Whether the selected team can be administered by this account.</summary>
internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true;
/// <summary>Whether this account owns the selected team.</summary>
/// <remarks>
/// A narrower gate than <see cref="CanAdministerSelected"/>, and the server draws the same line:
/// archiving a team and handing it over decide whether it goes on existing and who controls it,
/// so an admin the owner promoted must not be able to do either.
/// </remarks>
internal bool OwnsSelected => SelectedTeam?.Team.Role == TeamMemberRole.Owner;
/// <summary>Whether there is anything to show below the team list.</summary>
internal bool HasSelection => SelectedTeam is not null;
internal bool HasTeams => Teams.Count > 0;
/// <summary>Whether a destructive action is armed and waiting for an answer.</summary>
internal bool IsConfirming => PendingAction is not null;
/// <summary>Whether the ordinary team buttons should be showing.</summary>
/// <remarks>
/// The inverse of <see cref="IsConfirming"/>, so the confirmation replaces the buttons that armed
/// it rather than appearing beneath them still pressable.
/// </remarks>
internal bool ShowsTeamActions => !IsConfirming;
/// <summary>Whether the selected team has any invitation worth drawing a list for.</summary>
internal bool HasInvitations => Invitations.Count > 0;
internal bool AddsAsViewer => NewMemberRole == TeamMemberRole.Viewer;
internal bool AddsAsMember => NewMemberRole == TeamMemberRole.Member;
internal bool AddsAsAdmin => NewMemberRole == TeamMemberRole.Admin;
/// <summary>Reads the teams this account belongs to, and the selected one's detail.</summary>
internal Task LoadAsync(CancellationToken cancellationToken) =>
RunAsync(() => ReloadAsync(cancellationToken));
/// <summary>Reads it all again.</summary>
/// <remarks>
/// The same work as <see cref="LoadAsync"/>, exposed as a command because markup cannot invoke a
/// method. The phone needs it and the desktop does not: this screen is loaded on arrival, and on
/// the desktop leaving the rail and coming back is one click, where on the phone it is a trip out
/// to MORE and back. Nothing on this screen is cached, so a re-read is the only way to see a change
/// somebody else made.
/// </remarks>
[RelayCommand]
private Task RefreshAsync(CancellationToken cancellationToken) =>
RunAsync(() => ReloadAsync(cancellationToken));
/// <summary>
/// The reload itself, without the busy gate.
/// </summary>
@@ -289,16 +470,14 @@ internal sealed partial class TeamsViewModel(
if (found.Count == 0)
{
Status = $"No account here has the address '{email}'. They have to sign in to this "
+ "server once before they can be added — that is what publishes the key a vault "
+ "would be shared with.";
await InviteAsync(server, team, email, cancellationToken).ConfigureAwait(true);
return;
}
var member = await server.Teams
.AddTeamMemberAsync(
team.TeamId,
new AddTeamMemberRequest(found[0].UserId, TeamMemberRole.Member),
new AddTeamMemberRequest(found[0].UserId, NewMemberRole),
cancellationToken)
.ConfigureAwait(true);
@@ -313,6 +492,282 @@ internal sealed partial class TeamsViewModel(
}).ConfigureAwait(true);
}
/// <summary>
/// Invites an address the directory does not know.
/// </summary>
/// <remarks>
/// <para>
/// Reached by falling through from <see cref="AddMemberAsync"/> rather than from a second button,
/// because the person typing an address does not know or care which of the two applies — that is a
/// fact about the server's account table, not about what they are trying to do. Which one happened
/// is reported afterwards, because the difference decides what they have to do next.
/// </para>
/// <para>
/// The message has to carry the whole mechanism. Nothing is sent — this server has no outbound
/// mail — so somebody who reads "invited" and waits has been misled by an interface that knew
/// better.
/// </para>
/// </remarks>
private async Task InviteAsync(
IVaultServer server,
TeamRowViewModel team,
string email,
CancellationToken cancellationToken)
{
var invitation = await server.Teams
.CreateTeamInvitationAsync(
team.TeamId,
new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, NewMemberRole),
cancellationToken)
.ConfigureAwait(true);
InviteEmail = string.Empty;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"No account here has the address '{email}' yet, so it has been invited instead. "
+ $"They join this team as {invitation.Role.ToString().ToLowerInvariant()} the first time "
+ "they sign in. Nothing was sent — this server cannot send mail, so tell them yourself — "
+ "and their identity provider has to confirm the address is theirs.";
}
/// <summary>Withdraws an invitation that has not been taken up.</summary>
[RelayCommand]
private async Task RevokeInvitationAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server
|| SelectedTeam is not { } team
|| SelectedInvitation is not { } invitation)
{
return;
}
await RunAsync(async () =>
{
var revoked = await server.Teams
.RevokeTeamInvitationAsync(team.TeamId, invitation.InvitationId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = revoked
? $"Withdrew the invitation to {invitation.Email}. Signing in will no longer put them "
+ "in this team."
: $"The invitation to {invitation.Email} was already taken up or withdrawn. If they "
+ "are a member now, remove them instead.";
}).ConfigureAwait(true);
}
/// <summary>Picks the role a newly added or invited account will get.</summary>
[RelayCommand]
private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role;
/// <summary>Changes the selected member's role.</summary>
/// <remarks>
/// Owner is not offered, and the command refuses it rather than relying on the view not to send
/// it: the server refuses it too, and a button that produced a server error would be reporting a
/// rule the interface should have known.
/// </remarks>
[RelayCommand]
private async Task ChangeRoleAsync(TeamMemberRole role, CancellationToken cancellationToken)
{
if (connection() is not { } server
|| SelectedTeam is not { } team
|| SelectedMember is not { } member)
{
return;
}
if (role is TeamMemberRole.Owner or TeamMemberRole.Unspecified)
{
Status = "Ownership is handed over rather than assigned. Use HAND OVER below.";
return;
}
if (member.Member.Role == role)
{
return;
}
await RunAsync(async () =>
{
var changed = await server.Teams
.ChangeTeamMemberRoleAsync(
team.TeamId,
member.UserId,
new ChangeTeamMemberRoleRequest(role),
cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedMember = Members.FirstOrDefault(row => row.UserId == member.UserId);
// What a role does and does not reach. A viewer still holds whatever key they were
// wrapped, so demoting somebody is not a way of taking a vault back from them.
Status = $"{member.Name} is now {changed.Role.ToString().ToLowerInvariant()}. This changes "
+ "what the server will serve them; it does not withdraw a vault key they already "
+ "hold — use WITHDRAW KEY for that.";
}).ConfigureAwait(true);
}
/// <summary>Opens the rename form for the selected team.</summary>
[RelayCommand]
private void RenameTeam()
{
if (SelectedTeam is not { } team)
{
return;
}
EditTeamName = team.Name;
EditTeamDescription = team.Team.Description ?? string.Empty;
IsEditingTeam = true;
Status = string.Empty;
}
/// <summary>Abandons the rename form.</summary>
[RelayCommand]
private void CancelRenameTeam()
{
IsEditingTeam = false;
Status = string.Empty;
}
/// <summary>Saves the renamed team.</summary>
[RelayCommand]
private async Task SaveTeamAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server || SelectedTeam is not { } team)
{
return;
}
var name = EditTeamName.Trim();
if (name.Length == 0)
{
Status = "A team needs a name.";
return;
}
var description = EditTeamDescription.Trim();
await RunAsync(async () =>
{
await server.Teams
.UpdateTeamAsync(
team.TeamId,
new UpdateTeamRequest(name, description.Length == 0 ? null : description),
cancellationToken)
.ConfigureAwait(true);
IsEditingTeam = false;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// The slug is named because it did not change and somebody expecting it to would
// otherwise find out from a URL much later.
Status = $"Renamed to '{name}'. Its slug is still '{team.Slug}' — that is what URLs and "
+ "the server's own records use, and it does not change.";
}).ConfigureAwait(true);
}
/// <summary>Arms the archive confirmation for the selected team.</summary>
[RelayCommand]
private void ArchiveTeam()
{
if (SelectedTeam is not { } team)
{
return;
}
PendingAction = new TeamActionRequest(
team.TeamId,
Guid.Empty,
$"Archive '{team.Name}'?",
"Everybody loses sight of it at once, and only somebody with database access can bring it "
+ "back. It is refused outright if the team still owns any vault.");
}
/// <summary>Arms the hand-over confirmation for the selected member.</summary>
[RelayCommand]
private void TransferOwnership()
{
if (SelectedTeam is not { } team || SelectedMember is not { } member)
{
return;
}
if (member.IsSelf)
{
Status = "You already own this team.";
return;
}
PendingAction = new TeamActionRequest(
team.TeamId,
member.UserId,
$"Hand '{team.Name}' to {member.Name}?",
"They become the owner and you become an admin. You will not be able to take it back "
+ "yourself — only the new owner can hand it on.");
}
/// <summary>Cancels an armed action.</summary>
[RelayCommand]
private void CancelAction() => PendingAction = null;
/// <summary>
/// Carries out whichever action was armed.
/// </summary>
/// <remarks>
/// Disarmed before the work rather than after it, so the card goes the moment it is answered and a
/// second press during a slow round trip has nothing left to agree to.
/// </remarks>
[RelayCommand]
private async Task ConfirmActionAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server || PendingAction is not { } request)
{
return;
}
PendingAction = null;
await RunAsync(async () =>
{
if (request.MemberId == Guid.Empty)
{
var archived = await server.Teams
.ArchiveTeamAsync(request.TeamId, cancellationToken)
.ConfigureAwait(true);
SelectedTeam = null;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = archived
? "Archived. It is gone from everybody's list; the rows are still in the database "
+ "and only an operator can bring them back."
: "There was no such team to archive.";
return;
}
await server.Teams
.TransferTeamOwnershipAsync(
request.TeamId,
new TransferTeamOwnershipRequest(request.MemberId),
cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = "Handed over. You are an admin of this team now, and only its new owner can hand "
+ "it on again.";
}).ConfigureAwait(true);
}
/// <summary>Removes a member, revoking every vault key grant they hold from this team.</summary>
[RelayCommand]
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
@@ -432,17 +887,70 @@ internal sealed partial class TeamsViewModel(
{
RaiseState();
// An armed confirmation names the team it was armed for, so a selection change has to disarm
// it — otherwise the card stays on screen above a different team and reads as being about it.
PendingAction = null;
IsEditingTeam = false;
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
// from a list box, which has no cancellation token and no way to await. Failures land in Status
// through RunAsync exactly as a command's would.
_ = LoadSelectedAsync(CancellationToken.None);
}
/// <summary>Reads the selected team's members and vaults.</summary>
partial void OnPendingActionChanged(TeamActionRequest? value)
{
OnPropertyChanged(nameof(IsConfirming));
OnPropertyChanged(nameof(ShowsTeamActions));
}
partial void OnNewMemberRoleChanged(TeamMemberRole value)
{
OnPropertyChanged(nameof(AddsAsViewer));
OnPropertyChanged(nameof(AddsAsMember));
OnPropertyChanged(nameof(AddsAsAdmin));
}
/// <remarks>
/// The grants list belongs to a vault rather than to a team, so it is reloaded on selection here
/// rather than in <see cref="LoadSelectedAsync"/> — which would leave it showing the previous
/// vault's key-holders after a click.
/// </remarks>
partial void OnSelectedVaultChanged(TeamVaultRowViewModel? value) =>
_ = LoadGrantsAsync(CancellationToken.None);
/// <summary>Reads who holds a key to the selected vault.</summary>
private async Task LoadGrantsAsync(CancellationToken cancellationToken)
{
Grants.Clear();
if (connection() is not { } server || SelectedVault is not { } vault)
{
return;
}
await RunAsync(async () =>
{
var response = await server.Grants
.ListVaultGrantsAsync(vault.VaultId, cancellationToken)
.ConfigureAwait(true);
Grants.Clear();
foreach (var grant in response.Grants)
{
Grants.Add(new TeamGrantRowViewModel(grant, response.KeyGeneration));
}
}).ConfigureAwait(true);
}
/// <summary>Reads the selected team's members, invitations and vaults.</summary>
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
{
Members.Clear();
Invitations.Clear();
Vaults.Clear();
Grants.Clear();
if (connection() is not { } server || SelectedTeam is not { } team)
{
@@ -461,6 +969,19 @@ internal sealed partial class TeamsViewModel(
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
}
var invitations = await server.Teams
.ListTeamInvitationsAsync(team.TeamId, cancellationToken)
.ConfigureAwait(true);
foreach (var invitation in invitations)
{
Invitations.Add(new TeamInvitationRowViewModel(invitation));
}
SelectedInvitation = Invitations.FirstOrDefault(row => row.IsPending);
OnPropertyChanged(nameof(HasInvitations));
if (open is null)
{
return;
@@ -485,6 +1006,8 @@ internal sealed partial class TeamsViewModel(
OnPropertyChanged(nameof(HasTeams));
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanAdministerSelected));
OnPropertyChanged(nameof(OwnsSelected));
OnPropertyChanged(nameof(HasInvitations));
OnPropertyChanged(nameof(IsOnline));
}
@@ -43,10 +43,15 @@ namespace DodoSSH.Contracts;
[JsonSerializable(typeof(TeamSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamSummary>))]
[JsonSerializable(typeof(CreateTeamRequest))]
[JsonSerializable(typeof(UpdateTeamRequest))]
[JsonSerializable(typeof(TransferTeamOwnershipRequest))]
[JsonSerializable(typeof(TeamMemberSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamMemberSummary>))]
[JsonSerializable(typeof(AddTeamMemberRequest))]
[JsonSerializable(typeof(ChangeTeamMemberRoleRequest))]
[JsonSerializable(typeof(CreateTeamInvitationRequest))]
[JsonSerializable(typeof(TeamInvitationSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamInvitationSummary>))]
[JsonSerializable(typeof(CreateTeamVaultRequest))]
[JsonSerializable(typeof(IssueVaultGrantRequest))]
[JsonSerializable(typeof(VaultGrantsResponse))]
+25 -1
View File
@@ -102,10 +102,34 @@ public static class ProblemCodes
/// </summary>
/// <remarks>
/// Refused rather than allowed, because a team with no owner has nobody who can appoint one —
/// and the only route back would be an operator editing the database by hand.
/// and the only route back would be an operator editing the database by hand. The way past it is
/// <c>POST /api/v1/teams/{teamId}/owner</c>, which moves ownership and the outgoing owner's
/// demotion in one transaction; a client that gets this code can offer that.
/// </remarks>
public const string LastTeamOwner = "last-team-owner";
/// <summary>
/// A team cannot be archived while it still owns vaults.
/// </summary>
/// <remarks>
/// Its own code because the remedy is neither "fix what you typed" nor "pick another value": it is
/// to deal with the vaults first. Archiving anyway would hide vaults from every member including
/// the ones holding keys to them, and this product has no way to delete a vault, so the refusal is
/// the honest end of that road rather than a step on it.
/// </remarks>
public const string TeamNotEmpty = "team-not-empty";
/// <summary>
/// An invitation was rejected: a malformed address, an unknown or ownership role, an expiry the
/// server will not issue, or an address that already has an account here.
/// </summary>
/// <remarks>
/// Separate from <see cref="InvalidTeam"/> because the most common cause has its own remedy that a
/// client can act on — an address that already has an account should be added through the
/// directory instead, which is the path that shows the caller the key they are about to trust.
/// </remarks>
public const string InvalidTeamInvitation = "invalid-team-invitation";
/// <summary>
/// A vault key grant was rejected: a fingerprint or wrap of the wrong size, a generation that is
/// not the vault's current one, or a recipient who cannot reach the vault in the first place.
+81 -3
View File
@@ -9,6 +9,7 @@ const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidTeam = "invalid-team" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidTeamInvitation = "invalid-team-invitation" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidVaultGrant = "invalid-vault-grant" -> string!
const DodoSSH.Contracts.ProblemCodes.LastTeamOwner = "last-team-owner" -> string!
const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string!
@@ -16,6 +17,7 @@ const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large"
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string!
const DodoSSH.Contracts.ProblemCodes.TeamNotEmpty = "team-not-empty" -> string!
const DodoSSH.Contracts.ProblemCodes.TeamSlugTaken = "team-slug-taken" -> string!
const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string!
const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string!
@@ -35,6 +37,17 @@ DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Deconstruct(out DodoSSH.Contracts.
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? other) -> bool
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest
DodoSSH.Contracts.CreateTeamInvitationRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamInvitationRequest!
DodoSSH.Contracts.CreateTeamInvitationRequest.CreateTeamInvitationRequest(System.Guid InvitationId, string! Email, DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Deconstruct(out System.Guid InvitationId, out string! Email, out DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Email.get -> string!
DodoSSH.Contracts.CreateTeamInvitationRequest.Email.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Equals(DodoSSH.Contracts.CreateTeamInvitationRequest? other) -> bool
DodoSSH.Contracts.CreateTeamInvitationRequest.InvitationId.get -> System.Guid
DodoSSH.Contracts.CreateTeamInvitationRequest.InvitationId.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.CreateTeamInvitationRequest.Role.init -> void
DodoSSH.Contracts.CreateTeamRequest
DodoSSH.Contracts.CreateTeamRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamRequest!
DodoSSH.Contracts.CreateTeamRequest.CreateTeamRequest(System.Guid TeamId, string! Name, string! Slug, string? Description) -> void
@@ -433,12 +446,12 @@ DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.ActivityLogEntry = 12 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.ConnectionLogEntry = 11 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Credential = 2 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Host = 1 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.HostCredential = 7 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.HostGroup = 4 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.HostTag = 6 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.KnownHostKey = 10 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.PortForward = 9 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Snippet = 8 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.SshKey = 3 -> DodoSSH.Contracts.SyncEntityType
@@ -554,6 +567,33 @@ DodoSSH.Contracts.SyncPushResult.Status.init -> void
DodoSSH.Contracts.SyncPushResult.SyncPushResult(System.Guid OperationId, DodoSSH.Contracts.SyncOperationStatus Status, int? Version, long? ChangeSequence, DodoSSH.Contracts.SyncChange? ServerEntity, string? Detail) -> void
DodoSSH.Contracts.SyncPushResult.Version.get -> int?
DodoSSH.Contracts.SyncPushResult.Version.init -> void
DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Accepted = 2 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Expired = 4 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Pending = 1 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Revoked = 3 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Unspecified = 0 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationSummary
DodoSSH.Contracts.TeamInvitationSummary.<Clone>$() -> DodoSSH.Contracts.TeamInvitationSummary!
DodoSSH.Contracts.TeamInvitationSummary.AcceptedAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.TeamInvitationSummary.AcceptedAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.CreatedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.TeamInvitationSummary.CreatedAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Deconstruct(out System.Guid InvitationId, out string! Email, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamInvitationState State, out System.Guid InvitedByUserId, out System.DateTimeOffset CreatedAt, out System.DateTimeOffset ExpiresAt, out System.DateTimeOffset? AcceptedAt) -> void
DodoSSH.Contracts.TeamInvitationSummary.Email.get -> string!
DodoSSH.Contracts.TeamInvitationSummary.Email.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Equals(DodoSSH.Contracts.TeamInvitationSummary? other) -> bool
DodoSSH.Contracts.TeamInvitationSummary.ExpiresAt.get -> System.DateTimeOffset
DodoSSH.Contracts.TeamInvitationSummary.ExpiresAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.InvitationId.get -> System.Guid
DodoSSH.Contracts.TeamInvitationSummary.InvitationId.init -> void
DodoSSH.Contracts.TeamInvitationSummary.InvitedByUserId.get -> System.Guid
DodoSSH.Contracts.TeamInvitationSummary.InvitedByUserId.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamInvitationSummary.Role.init -> void
DodoSSH.Contracts.TeamInvitationSummary.State.get -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationSummary.State.init -> void
DodoSSH.Contracts.TeamInvitationSummary.TeamInvitationSummary(System.Guid InvitationId, string! Email, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamInvitationState State, System.Guid InvitedByUserId, System.DateTimeOffset CreatedAt, System.DateTimeOffset ExpiresAt, System.DateTimeOffset? AcceptedAt) -> void
DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Admin = 30 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Member = 20 -> DodoSSH.Contracts.TeamMemberRole
@@ -567,7 +607,7 @@ DodoSSH.Contracts.TeamMemberStatus.Revoked = 3 -> DodoSSH.Contracts.TeamMemberSt
DodoSSH.Contracts.TeamMemberStatus.Unspecified = 0 -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberSummary
DodoSSH.Contracts.TeamMemberSummary.<Clone>$() -> DodoSSH.Contracts.TeamMemberSummary!
DodoSSH.Contracts.TeamMemberSummary.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamMemberStatus Status, out bool IsEnrolled, out System.DateTimeOffset? JoinedAt) -> void
DodoSSH.Contracts.TeamMemberSummary.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamMemberStatus Status, out bool IsEnrolled, out System.DateTimeOffset? JoinedAt, out System.DateTimeOffset? LastActiveAt) -> void
DodoSSH.Contracts.TeamMemberSummary.DisplayName.get -> string?
DodoSSH.Contracts.TeamMemberSummary.DisplayName.init -> void
DodoSSH.Contracts.TeamMemberSummary.Email.get -> string?
@@ -577,11 +617,13 @@ DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.get -> bool
DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.init -> void
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.init -> void
DodoSSH.Contracts.TeamMemberSummary.LastActiveAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.TeamMemberSummary.LastActiveAt.init -> void
DodoSSH.Contracts.TeamMemberSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberSummary.Role.init -> void
DodoSSH.Contracts.TeamMemberSummary.Status.get -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberSummary.Status.init -> void
DodoSSH.Contracts.TeamMemberSummary.TeamMemberSummary(System.Guid UserId, string? Email, string? DisplayName, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamMemberStatus Status, bool IsEnrolled, System.DateTimeOffset? JoinedAt) -> void
DodoSSH.Contracts.TeamMemberSummary.TeamMemberSummary(System.Guid UserId, string? Email, string? DisplayName, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamMemberStatus Status, bool IsEnrolled, System.DateTimeOffset? JoinedAt, System.DateTimeOffset? LastActiveAt = null) -> void
DodoSSH.Contracts.TeamMemberSummary.UserId.get -> System.Guid
DodoSSH.Contracts.TeamMemberSummary.UserId.init -> void
DodoSSH.Contracts.TeamSummary
@@ -605,6 +647,22 @@ DodoSSH.Contracts.TeamSummary.TeamId.init -> void
DodoSSH.Contracts.TeamSummary.TeamSummary(System.Guid TeamId, string! Name, string! Slug, string? Description, DodoSSH.Contracts.TeamMemberRole Role, int MemberCount, int VaultCount, System.DateTimeOffset CreatedAt) -> void
DodoSSH.Contracts.TeamSummary.VaultCount.get -> int
DodoSSH.Contracts.TeamSummary.VaultCount.init -> void
DodoSSH.Contracts.TransferTeamOwnershipRequest
DodoSSH.Contracts.TransferTeamOwnershipRequest.<Clone>$() -> DodoSSH.Contracts.TransferTeamOwnershipRequest!
DodoSSH.Contracts.TransferTeamOwnershipRequest.Deconstruct(out System.Guid UserId) -> void
DodoSSH.Contracts.TransferTeamOwnershipRequest.Equals(DodoSSH.Contracts.TransferTeamOwnershipRequest? other) -> bool
DodoSSH.Contracts.TransferTeamOwnershipRequest.TransferTeamOwnershipRequest(System.Guid UserId) -> void
DodoSSH.Contracts.TransferTeamOwnershipRequest.UserId.get -> System.Guid
DodoSSH.Contracts.TransferTeamOwnershipRequest.UserId.init -> void
DodoSSH.Contracts.UpdateTeamRequest
DodoSSH.Contracts.UpdateTeamRequest.<Clone>$() -> DodoSSH.Contracts.UpdateTeamRequest!
DodoSSH.Contracts.UpdateTeamRequest.Deconstruct(out string! Name, out string? Description) -> void
DodoSSH.Contracts.UpdateTeamRequest.Description.get -> string?
DodoSSH.Contracts.UpdateTeamRequest.Description.init -> void
DodoSSH.Contracts.UpdateTeamRequest.Equals(DodoSSH.Contracts.UpdateTeamRequest? other) -> bool
DodoSSH.Contracts.UpdateTeamRequest.Name.get -> string!
DodoSSH.Contracts.UpdateTeamRequest.Name.init -> void
DodoSSH.Contracts.UpdateTeamRequest.UpdateTeamRequest(string! Name, string? Description) -> void
DodoSSH.Contracts.VaultGrantsResponse
DodoSSH.Contracts.VaultGrantsResponse.<Clone>$() -> DodoSSH.Contracts.VaultGrantsResponse!
DodoSSH.Contracts.VaultGrantsResponse.Deconstruct(out System.Guid VaultId, out uint KeyGeneration, out bool RekeyRequired, out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void
@@ -671,6 +729,9 @@ override DodoSSH.Contracts.AddTeamMemberRequest.ToString() -> string!
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.GetHashCode() -> int
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamInvitationRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamInvitationRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamInvitationRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamRequest.ToString() -> string!
@@ -761,12 +822,21 @@ override DodoSSH.Contracts.SyncPushResponse.ToString() -> string!
override DodoSSH.Contracts.SyncPushResult.Equals(object? obj) -> bool
override DodoSSH.Contracts.SyncPushResult.GetHashCode() -> int
override DodoSSH.Contracts.SyncPushResult.ToString() -> string!
override DodoSSH.Contracts.TeamInvitationSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamInvitationSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamInvitationSummary.ToString() -> string!
override DodoSSH.Contracts.TeamMemberSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamMemberSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamMemberSummary.ToString() -> string!
override DodoSSH.Contracts.TeamSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamSummary.ToString() -> string!
override DodoSSH.Contracts.TransferTeamOwnershipRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.TransferTeamOwnershipRequest.GetHashCode() -> int
override DodoSSH.Contracts.TransferTeamOwnershipRequest.ToString() -> string!
override DodoSSH.Contracts.UpdateTeamRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.UpdateTeamRequest.GetHashCode() -> int
override DodoSSH.Contracts.UpdateTeamRequest.ToString() -> string!
override DodoSSH.Contracts.VaultGrantsResponse.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultGrantsResponse.GetHashCode() -> int
override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
@@ -780,6 +850,8 @@ static DodoSSH.Contracts.AddTeamMemberRequest.operator !=(DodoSSH.Contracts.AddT
static DodoSSH.Contracts.AddTeamMemberRequest.operator ==(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator !=(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator ==(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamInvitationRequest.operator !=(DodoSSH.Contracts.CreateTeamInvitationRequest? left, DodoSSH.Contracts.CreateTeamInvitationRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamInvitationRequest.operator ==(DodoSSH.Contracts.CreateTeamInvitationRequest? left, DodoSSH.Contracts.CreateTeamInvitationRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator !=(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator ==(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamVaultRequest.operator !=(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
@@ -843,10 +915,16 @@ static DodoSSH.Contracts.SyncPushResponse.operator !=(DodoSSH.Contracts.SyncPush
static DodoSSH.Contracts.SyncPushResponse.operator ==(DodoSSH.Contracts.SyncPushResponse? left, DodoSSH.Contracts.SyncPushResponse? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator !=(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator ==(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.TeamInvitationSummary.operator !=(DodoSSH.Contracts.TeamInvitationSummary? left, DodoSSH.Contracts.TeamInvitationSummary? right) -> bool
static DodoSSH.Contracts.TeamInvitationSummary.operator ==(DodoSSH.Contracts.TeamInvitationSummary? left, DodoSSH.Contracts.TeamInvitationSummary? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator !=(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator ==(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamSummary.operator !=(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
static DodoSSH.Contracts.TeamSummary.operator ==(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
static DodoSSH.Contracts.TransferTeamOwnershipRequest.operator !=(DodoSSH.Contracts.TransferTeamOwnershipRequest? left, DodoSSH.Contracts.TransferTeamOwnershipRequest? right) -> bool
static DodoSSH.Contracts.TransferTeamOwnershipRequest.operator ==(DodoSSH.Contracts.TransferTeamOwnershipRequest? left, DodoSSH.Contracts.TransferTeamOwnershipRequest? right) -> bool
static DodoSSH.Contracts.UpdateTeamRequest.operator !=(DodoSSH.Contracts.UpdateTeamRequest? left, DodoSSH.Contracts.UpdateTeamRequest? right) -> bool
static DodoSSH.Contracts.UpdateTeamRequest.operator ==(DodoSSH.Contracts.UpdateTeamRequest? left, DodoSSH.Contracts.UpdateTeamRequest? right) -> bool
static DodoSSH.Contracts.VaultGrantsResponse.operator !=(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantsResponse.operator ==(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantSummary.operator !=(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
+157 -8
View File
@@ -49,10 +49,18 @@ public enum TeamMemberStatus
/// Invited but not yet accepted.
/// </summary>
/// <remarks>
/// Nothing writes this today. An invitation needs a token with a lifetime and an outbound mail
/// path, and this server has neither — so a member is added by looking their account up in the
/// directory, which requires that they have signed in here at least once. Retained because the
/// column exists and a client must not fail on a value a later server may send.
/// <para>
/// Still nothing writes this, and invitations shipping is the reason rather than an exception to
/// it. A membership names an account: <c>team_membership.user_id</c> is not nullable and carries a
/// foreign key, so somebody who has never signed in has nothing for that row to point at. An
/// invitation is therefore its own record against an <em>address</em>
/// (<see cref="TeamInvitationSummary"/>), and it becomes a membership at
/// <see cref="Active"/> the moment an account with that address first signs in.
/// </para>
/// <para>
/// Retained because the column exists and a client must not fail on a value a later server may
/// send — a server that grew a second invitation model would use it.
/// </para>
/// </remarks>
Invited = 1,
@@ -124,11 +132,51 @@ public sealed record CreateTeamRequest(
string Slug,
string? Description);
/// <summary>Renames a team, or changes its description.</summary>
/// <remarks>
/// <para>
/// The slug is not here and cannot be changed. It is what a URL, an operator's query and any bookmark
/// name, and it is unique only among <em>live</em> teams — so a rename could take a slug an archived
/// team is still holding on to, and the archived one could then never be brought back. Renaming the
/// display name is the operation people actually want; renaming the identifier is a migration.
/// </para>
/// <para>
/// A whole replacement rather than a patch: both fields are always sent, so clearing a description is
/// sending null rather than a distinct verb. There is nowhere to record <em>when</em> a team was last
/// renamed — <c>team</c> has no updated-at column — so no client can show "edited", and this contract
/// does not pretend one can.
/// </para>
/// </remarks>
/// <param name="Name">Display name. Required.</param>
/// <param name="Description">Optional description. Null clears it.</param>
public sealed record UpdateTeamRequest(string Name, string? Description);
/// <summary>Hands a team's ownership to another member.</summary>
/// <remarks>
/// <para>
/// Its own operation rather than a role change, because it is two writes that must not be separable:
/// the recipient becomes owner and the outgoing owner becomes an admin, in one transaction. Ownership
/// is sole, so doing it as two role changes would leave the team either briefly ownerless or briefly
/// owned twice, and <see cref="ChangeTeamMemberRoleRequest"/> refuses
/// <see cref="TeamMemberRole.Owner"/> outright for exactly that reason.
/// </para>
/// <para>
/// The outgoing owner is demoted to <see cref="TeamMemberRole.Admin"/> 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 being asked for — and somebody handing over a team is usually staying in it.
/// </para>
/// </remarks>
/// <param name="UserId">
/// The member to hand it to. Must already be an active member: adding somebody and making them owner
/// in one step would let an address typed once take the team.
/// </param>
public sealed record TransferTeamOwnershipRequest(Guid UserId);
/// <summary>One member of a team.</summary>
/// <remarks>
/// Carries no last-active time and no avatar. <c>UserAccount.LastSeenAtUtc</c> is written at
/// provisioning and at enrollment and at no other point, so a column labelled "last active" would
/// be reporting something else entirely; and no picture is stored anywhere.
/// Carries no avatar, because no picture is stored anywhere. It does now carry a last-active time —
/// see <see cref="LastActiveAt"/>, which names precisely what it measures, because the useful version
/// of that column and the misleading one differ only in what the server bothered to write down.
/// </remarks>
/// <param name="UserId">The member.</param>
/// <param name="Email">Email, for display.</param>
@@ -141,6 +189,15 @@ public sealed record CreateTeamRequest(
/// rather than offering a share that would fail.
/// </param>
/// <param name="JoinedAt">When the membership became active.</param>
/// <param name="LastActiveAt">
/// When this account last made an authenticated request, or null if it never has.
/// <para>
/// It is deliberately coarse. The server records it at most once per account per hour, so a value an
/// hour old means "recently" rather than "at that instant" — which is the granularity the question is
/// actually asked at, and a far smaller thing to know about a colleague than a per-request timeline
/// would be. Displaying it to the minute would be reading precision into it that is not there.
/// </para>
/// </param>
public sealed record TeamMemberSummary(
Guid UserId,
string? Email,
@@ -148,7 +205,8 @@ public sealed record TeamMemberSummary(
TeamMemberRole Role,
TeamMemberStatus Status,
bool IsEnrolled,
DateTimeOffset? JoinedAt);
DateTimeOffset? JoinedAt,
DateTimeOffset? LastActiveAt = null);
/// <summary>Adds a member to a team.</summary>
/// <remarks>
@@ -165,6 +223,97 @@ public sealed record AddTeamMemberRequest(Guid UserId, TeamMemberRole Role);
/// <param name="Role">The new role.</param>
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);
/// <summary>What has become of an invitation.</summary>
/// <remarks>
/// Derived from the invitation's own timestamps rather than stored, so — unlike every other enum in
/// this file — it has no <c>DodoSSH.Domain</c> twin and no numbering to keep in step. That is the
/// point of computing it: <see cref="Expired"/> is a fact about the clock, and a stored state would
/// have to be swept by something that remembered to run.
/// </remarks>
public enum TeamInvitationState
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Waiting. It becomes a membership when an account with this address signs in.</summary>
Pending = 1,
/// <summary>Taken up. The address signed in and is now a member.</summary>
Accepted = 2,
/// <summary>Withdrawn before it was taken up.</summary>
Revoked = 3,
/// <summary>Its lifetime ran out. It will not become a membership.</summary>
Expired = 4,
}
/// <summary>Invites an address that has no account here yet.</summary>
/// <remarks>
/// <para>
/// <b>By email, where <see cref="AddTeamMemberRequest"/> is by user id.</b> Adding a member resolves an
/// address through the directory first, so the caller sees the public key they are about to wrap a
/// vault to. An invitation cannot do that — there may be no account and therefore no key — so it grants
/// nothing readable and is never a step towards sharing.
/// </para>
/// <para>
/// <b>An address that already has an account is accepted rather than refused</b>, and only an address
/// already belonging to a member of this team is turned away. Refusing on the strength of an account
/// existing would make this endpoint an oracle for which addresses have accounts here, answerable by
/// anybody willing to create a team first — and it would be answering a question the caller did not
/// ask. Whether the account exists changes only how soon the invitation is taken up: an existing one
/// picks it up on its next request.
/// </para>
/// <para>
/// <b>There is no token and nothing is sent.</b> This server has no outbound mail path, so the
/// invitation is not a link: it is a standing instruction that the next account to sign in with this
/// address joins the team. Telling them to sign in is the caller's job, over a channel this server
/// does not carry. That also means the address has to be one the identity provider will assert and
/// mark verified — an unverified email is refused at claim time, because an invitation that anybody
/// could take by naming somebody else's address is a way in.
/// </para>
/// </remarks>
/// <param name="InvitationId">
/// Client-generated UUIDv7, for the reason a team id is client-generated: a create whose response was
/// lost can be re-sent verbatim rather than leaving two invitations to the same address.
/// </param>
/// <param name="Email">The address to invite. Matched case-insensitively.</param>
/// <param name="Role">
/// Role to grant on arrival. May not be <see cref="TeamMemberRole.Owner"/> — ownership is sole and is
/// handed over deliberately, never conferred by an address signing in.
/// </param>
public sealed record CreateTeamInvitationRequest(
Guid InvitationId,
string Email,
TeamMemberRole Role);
/// <summary>One invitation, as the teams interface sees it.</summary>
/// <remarks>
/// The address is in plaintext here, as it is on <see cref="TeamMemberSummary"/>. It is readable by
/// the team's members, who are the people it concerns; the server stores it in plaintext either way
/// and docs/crypto.md §10 already records that membership metadata is not encrypted.
/// </remarks>
/// <param name="InvitationId">The invitation.</param>
/// <param name="Email">The address invited.</param>
/// <param name="Role">The role it will grant.</param>
/// <param name="State">What has become of it.</param>
/// <param name="InvitedByUserId">Who issued it.</param>
/// <param name="CreatedAt">When it was issued.</param>
/// <param name="ExpiresAt">
/// When it stops being claimable. An invitation that never expired would be a standing offer on an
/// address somebody may hand on or lose.
/// </param>
/// <param name="AcceptedAt">When an account with this address signed in and took it up, if one has.</param>
public sealed record TeamInvitationSummary(
Guid InvitationId,
string Email,
TeamMemberRole Role,
TeamInvitationState State,
Guid InvitedByUserId,
DateTimeOffset CreatedAt,
DateTimeOffset ExpiresAt,
DateTimeOffset? AcceptedAt);
/// <summary>
/// Creates a vault owned by a team, with its key already wrapped to the creator.
/// </summary>
+65
View File
@@ -33,6 +33,9 @@ public sealed class Team
/// <summary>Members.</summary>
public ICollection<TeamMembership> Memberships { get; } = [];
/// <summary>Invitations to addresses that have no account here yet.</summary>
public ICollection<TeamInvitation> Invitations { get; } = [];
}
/// <summary>
@@ -77,3 +80,65 @@ public sealed class TeamMembership
/// <summary>Soft-delete marker.</summary>
public DateTimeOffset? DeletedAtUtc { get; set; }
}
/// <summary>
/// A standing offer of membership to an email address that has no account here yet.
/// </summary>
/// <remarks>
/// <para>
/// <b>Its own table rather than a <see cref="TeamMembership"/> with
/// <see cref="MembershipStatus.Invited"/>.</b> A membership names an account —
/// <c>team_membership.user_id</c> is not nullable and carries a foreign key to
/// <see cref="UserAccount"/> — so an invitee who has never signed in has nothing for that row to
/// point at. Widening that column would make the unique index on (team, user) meaningless, because
/// PostgreSQL counts every NULL as distinct, and would silently change what every
/// <c>m.UserId == user.Id</c> query in the server means.
/// </para>
/// <para>
/// <b>There is no token.</b> Nothing is sent, because this server has no outbound mail path; the row
/// is an instruction to the sign-in path rather than a secret somebody presents. That is why it is
/// keyed on the address and why the address has to be one the identity provider marks verified before
/// the claim is honoured — an unclaimable invitation is an inconvenience, but one claimable by
/// anybody who can assert an address is a way into the team.
/// </para>
/// <para>
/// Revoked and accepted rows are retained rather than deleted, as <see cref="VaultKeyGrant"/> is and
/// for the same reason: the uniqueness that matters is among <em>pending</em> invitations, and the
/// history of who invited whom stays resolvable.
/// </para>
/// </remarks>
public sealed class TeamInvitation
{
/// <summary>Primary key.</summary>
public Guid Id { get; set; }
/// <summary>The team.</summary>
public Guid TeamId { get; set; }
/// <summary>The team.</summary>
public Team? Team { get; set; }
/// <summary>The address invited. Case-insensitive.</summary>
public string Email { get; set; } = string.Empty;
/// <summary>Role the membership will carry when it is claimed.</summary>
public TeamRole Role { get; set; }
/// <summary>Who issued it.</summary>
public Guid InvitedByUserId { get; set; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>When it stops being claimable.</summary>
public DateTimeOffset ExpiresAtUtc { get; set; }
/// <summary>When an account with this address signed in and took it up.</summary>
public DateTimeOffset? AcceptedAtUtc { get; set; }
/// <summary>Which account took it up.</summary>
public Guid? AcceptedByUserId { get; set; }
/// <summary>Revocation timestamp.</summary>
public DateTimeOffset? RevokedAtUtc { get; set; }
}
@@ -62,6 +62,47 @@ public sealed class TeamMembershipConfiguration : IEntityTypeConfiguration<TeamM
}
}
/// <summary>Maps <see cref="TeamInvitation"/>.</summary>
public sealed class TeamInvitationConfiguration : IEntityTypeConfiguration<TeamInvitation>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<TeamInvitation> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.ToTable("team_invitation");
builder.HasKey(i => i.Id);
builder.Property(i => i.Id).ValueGeneratedNever();
builder.UseXminConcurrencyToken();
// citext, matching user_account.email: the claim at sign-in compares an address the identity
// provider chose the casing of against one a person typed, and lower() on both sides of that
// is a rule somebody eventually forgets on one side.
builder.Property(i => i.Email).HasColumnType("citext").HasMaxLength(320).IsRequired();
builder.Property(i => i.Role).HasConversion<int>();
builder.HasOne(i => i.Team)
.WithMany(t => t.Invitations)
.HasForeignKey(i => i.TeamId)
.OnDelete(DeleteBehavior.Cascade);
// One live invitation per address per team. Filtered on the two tombstones rather than on a
// deletion marker, as vault_key_grant is: an accepted or withdrawn invitation is kept, and
// re-inviting an address whose invitation lapsed has to be possible.
//
// Expiry is deliberately not in this predicate. A partial index predicate must be IMMUTABLE,
// so now() cannot appear in one; an expired invitation therefore still holds the slot, and
// the service treats replacing one as a revoke-and-reissue rather than a second insert.
builder.HasIndex(i => new { i.TeamId, i.Email })
.IsUnique()
.HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
// The claim at sign-in knows the address and nothing else — it is looking for every team that
// invited this person, across all of them — so the address is the hot direction here.
builder.HasIndex(i => i.Email);
}
}
/// <summary>Maps <see cref="Vault"/>.</summary>
public sealed class VaultConfiguration : IEntityTypeConfiguration<Vault>
{
@@ -45,6 +45,9 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
/// <summary>Team memberships.</summary>
public DbSet<TeamMembership> TeamMemberships => Set<TeamMembership>();
/// <summary>Invitations to addresses with no account here yet.</summary>
public DbSet<TeamInvitation> TeamInvitations => Set<TeamInvitation>();
/// <summary>Vaults.</summary>
public DbSet<Vault> Vaults => Set<Vault>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTeamInvitation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "team_invitation",
schema: "dodo",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
team_id = table.Column<Guid>(type: "uuid", nullable: false),
email = table.Column<string>(type: "citext", maxLength: 320, nullable: false),
role = table.Column<int>(type: "integer", nullable: false),
invited_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
expires_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
accepted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
accepted_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
revoked_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_team_invitation", x => x.id);
table.ForeignKey(
name: "fk_team_invitation_team_team_id",
column: x => x.team_id,
principalSchema: "dodo",
principalTable: "team",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_team_invitation_email",
schema: "dodo",
table: "team_invitation",
column: "email");
migrationBuilder.CreateIndex(
name: "ix_team_invitation_team_id_email",
schema: "dodo",
table: "team_invitation",
columns: new[] { "team_id", "email" },
unique: true,
filter: "accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "team_invitation",
schema: "dodo");
}
}
}
@@ -316,6 +316,70 @@ namespace DodoSSH.Infrastructure.Migrations
b.ToTable("team", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.TeamInvitation", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset?>("AcceptedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("accepted_at_utc");
b.Property<Guid?>("AcceptedByUserId")
.HasColumnType("uuid")
.HasColumnName("accepted_by_user_id");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("citext")
.HasColumnName("email");
b.Property<DateTimeOffset>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at_utc");
b.Property<Guid>("InvitedByUserId")
.HasColumnType("uuid")
.HasColumnName("invited_by_user_id");
b.Property<DateTimeOffset?>("RevokedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at_utc");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("TeamId")
.HasColumnType("uuid")
.HasColumnName("team_id");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id")
.HasName("pk_team_invitation");
b.HasIndex("Email")
.HasDatabaseName("ix_team_invitation_email");
b.HasIndex("TeamId", "Email")
.IsUnique()
.HasDatabaseName("ix_team_invitation_team_id_email")
.HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
b.ToTable("team_invitation", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
{
b.Property<Guid>("Id")
@@ -1580,6 +1644,18 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("Vault");
});
modelBuilder.Entity("DodoSSH.Domain.TeamInvitation", b =>
{
b.HasOne("DodoSSH.Domain.Team", "Team")
.WithMany("Invitations")
.HasForeignKey("TeamId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_team_invitation_team_team_id");
b.Navigation("Team");
});
modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
{
b.HasOne("DodoSSH.Domain.Team", "Team")
@@ -1782,6 +1858,8 @@ namespace DodoSSH.Infrastructure.Migrations
modelBuilder.Entity("DodoSSH.Domain.Team", b =>
{
b.Navigation("Invitations");
b.Navigation("Memberships");
});