Public Access
Merge branch 'main' into claude/angry-cray-f3d496
# Conflicts: # README.md
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The launcher mark, and it is the same mark ServerScreen and LockedScreen draw: a square
|
||||
outline in the accent with >_ inside it. Redrawn as a vector rather than exported as a
|
||||
bitmap so there is one geometry to change and no set of five PNG densities to forget one
|
||||
of.
|
||||
The launcher mark, and it is the same mark PhoneShell's header and the desktop titlebar draw:
|
||||
>_ in the canvas colour on a solid accent tile. Filled rather than outlined since v2.
|
||||
|
||||
#5B8CFF is AccentColor from DodoSSH.Client.Shell's Theme/Palette.axaml, written out
|
||||
because an Android resource cannot reference a XAML dictionary. The same duplication
|
||||
colors.xml already carries for the window background, and the same rule applies: if the
|
||||
palette moves, this moves with it.
|
||||
The tile is not in this file. It is the background layer — @color/dodo_accent, see ic_launcher.xml
|
||||
— and that is the whole trick of the filled design on Android: the rounding a launcher applies is
|
||||
its mask, so letting the mask make the tile gets a squircle on one device and a circle on another
|
||||
without either being drawn here. A rounded rectangle painted into this layer would be a second
|
||||
rounded shape inside the first, visibly clipped at the corners on any device whose mask is not the
|
||||
one it was drawn for.
|
||||
|
||||
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer
|
||||
18 on each edge is what the launcher eats for masking and parallax. That 72 is a width,
|
||||
though, and the mark is a square: its corners are what a circular mask reaches first. At
|
||||
48 across the corners land 33.9 out against a radius of 36 and read as clipped even
|
||||
though they technically clear it. 42 puts them at 29.7, which is margin one can see.
|
||||
#0E1220 is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
|
||||
Android resource cannot reference a XAML dictionary. It equals Canvas today and is named
|
||||
separately in the palette for a reason worth keeping in mind here: this is ink on the accent, not
|
||||
the window behind it, and it follows AccentInk if the two ever part.
|
||||
|
||||
The stroke widths are the one place this deliberately departs from the screen. In the app
|
||||
the box is a 1px border on 44px; scaled honestly that would be 1.0 here, and a launcher
|
||||
drawing this at 48dp would render it at half a pixel and show nothing. 2.2 and 2.8 are
|
||||
what keep it reading as the same hairline mark at the size it is actually looked at.
|
||||
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer 18 on each
|
||||
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
|
||||
half the width of that 72 and centred in it. That half is taken from the headers rather than
|
||||
invented: the phone draws >_ at font size 10 on a 26px tile and the titlebar at 9 on 20px, both a
|
||||
little under half the tile across, and a launcher icon is looked at from further away than either.
|
||||
|
||||
The stroke width is the one place this deliberately departs from the screen. In the app the glyph
|
||||
is a bold mono face whose stems come out near a fifth of its height; 5.4 here is nearer a quarter,
|
||||
which is what keeps it reading as the same mark at the size it is actually looked at.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
@@ -27,28 +31,21 @@
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- The box: 42 across, centred, square-cornered as the Border in the app is. -->
|
||||
<path
|
||||
android:pathData="M33,33 L75,33 L75,75 L33,75 Z"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#5B8CFF"
|
||||
android:strokeWidth="2.2" />
|
||||
|
||||
<!-- The chevron of >_ -->
|
||||
<path
|
||||
android:pathData="M44.8,48.75 L51.9,54 L44.8,59.25"
|
||||
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#5B8CFF"
|
||||
android:strokeWidth="2.8"
|
||||
android:strokeColor="#0E1220"
|
||||
android:strokeWidth="5.4"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
|
||||
<!-- The underscore, on the baseline the chevron bottoms out at. -->
|
||||
<path
|
||||
android:pathData="M54,59.7 L63.2,59.7"
|
||||
android:pathData="M54,64.68 L71.94,64.68"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#5B8CFF"
|
||||
android:strokeWidth="2.8"
|
||||
android:strokeColor="#0E1220"
|
||||
android:strokeWidth="5.4"
|
||||
android:strokeLineCap="round" />
|
||||
|
||||
</vector>
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
the wallpaper's colours. The system tints this by its alpha and discards the colour, so
|
||||
the geometry is the foreground's and white is only a way of saying "opaque here".
|
||||
|
||||
Note what that means for the filled design: the accent tile is the background layer, and a
|
||||
themed icon drops the background entirely. So the shape that survives here is the glyph, not
|
||||
the tile — which is the right way round anyway. Filling this layer to the edges to stand in
|
||||
for the tile would tint to a featureless square with nothing of the mark left in it.
|
||||
|
||||
Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome
|
||||
layer to use falls back to the full-colour icon, so the one app on the home screen still
|
||||
drawn in green is this one.
|
||||
drawn in blue is this one.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
@@ -15,24 +20,18 @@
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:pathData="M33,33 L75,33 L75,75 L33,75 Z"
|
||||
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2.2" />
|
||||
|
||||
<path
|
||||
android:pathData="M44.8,48.75 L51.9,54 L44.8,59.25"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2.8"
|
||||
android:strokeWidth="5.4"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
|
||||
<path
|
||||
android:pathData="M54,59.7 L63.2,59.7"
|
||||
android:pathData="M54,64.68 L71.94,64.68"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2.8"
|
||||
android:strokeWidth="5.4"
|
||||
android:strokeLineCap="round" />
|
||||
|
||||
</vector>
|
||||
|
||||
@@ -5,12 +5,17 @@
|
||||
adaptive icons landed in 26, so there is no device this ships to that would need the
|
||||
bitmaps. Density buckets exist to pick a PNG; a vector has nothing to pick between.
|
||||
|
||||
The background is the same @color/dodo_window the window, status bar and navigation bar
|
||||
use, so the mark sits on the app's own near-black rather than on a second dark that is
|
||||
almost but not quite it.
|
||||
The background is the accent, and that is the tile itself rather than a backdrop for one:
|
||||
the v2 mark is >_ knocked out of a solid accent square, so the square is this layer and the
|
||||
launcher's mask is what rounds it. See ic_launcher_foreground.xml for why the rounding is
|
||||
left to the mask instead of drawn.
|
||||
|
||||
It was @color/dodo_window until the mark went from outlined to filled, which is worth
|
||||
knowing if a home screen still shows the dark version: a launcher caches icons, and the
|
||||
cache outlives the install that changed them.
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/dodo_window" />
|
||||
<background android:drawable="@color/dodo_accent" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -10,4 +10,11 @@
|
||||
hidden.
|
||||
-->
|
||||
<color name="dodo_window">#0E1220</color>
|
||||
|
||||
<!--
|
||||
AccentColor from the same palette, here because the launcher icon's background layer is a colour
|
||||
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
|
||||
this moves with it.
|
||||
-->
|
||||
<color name="dodo_accent">#5B8CFF</color>
|
||||
</resources>
|
||||
|
||||
@@ -418,6 +418,10 @@
|
||||
<!--
|
||||
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
|
||||
is an invitation to type a secret nothing will use.
|
||||
|
||||
The tick below it is the phone's whole answer to storing one, and on this head it is the only one:
|
||||
the keychain lists credentials here but has no editor to create one in, so before this a password
|
||||
typed on a phone could only ever be typed again. The host editor's picker could then bind it.
|
||||
-->
|
||||
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
Text="{Binding ConnectPassword}" PlaceholderText="password">
|
||||
@@ -426,6 +430,12 @@
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<CheckBox IsChecked="{Binding RemembersConnectPassword}" MinHeight="44"
|
||||
IsVisible="{Binding SelectedHostAsksForAPassword}">
|
||||
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="Remember this password for this host" />
|
||||
</CheckBox>
|
||||
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !SelectedHostAsksForAPassword}"
|
||||
Text="{Binding SelectedHostAuthenticationNote}" />
|
||||
|
||||
|
||||
@@ -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,13 +14,24 @@
|
||||
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.
|
||||
|
||||
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
|
||||
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
|
||||
bottom bar itself. All three are bound on IsShowingPages, which is the same question asked once — the
|
||||
surface is either a page or a terminal, and these are the chrome a page has.
|
||||
|
||||
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
|
||||
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
|
||||
What takes their place is one 35-pixel bar drawn by the surface itself, carrying back on the left and
|
||||
the sessions and a + across from it. See TerminalScreen.axaml.
|
||||
-->
|
||||
|
||||
<!--
|
||||
@@ -48,46 +59,55 @@
|
||||
|
||||
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
|
||||
chrome above theirs. It stays on the three destinations that are the product's top level, where the
|
||||
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.
|
||||
|
||||
Wrapped rather than given a second condition, because Avalonia's bindings have no "and": the wrapper
|
||||
collapses it over a terminal, where the surface draws its own bar and the vault's name is not what
|
||||
the user is looking at. That is one of three rows this Grid stands down while a shell is showing —
|
||||
see the strip and the bottom bar below.
|
||||
-->
|
||||
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56" IsVisible="{Binding !IsMoreSurface}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Panel Grid.Row="0" IsVisible="{Binding IsShowingPages}">
|
||||
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56"
|
||||
IsVisible="{Binding !IsMoreSurface}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!--
|
||||
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
|
||||
fact about the vault, and the design gives it the accent as a solid tile — which is also what
|
||||
the launcher icon draws, so the two agree.
|
||||
-->
|
||||
<Border Grid.Column="0" Width="26" Height="26" CornerRadius="8"
|
||||
Background="{StaticResource Accent}" VerticalAlignment="Center">
|
||||
<TextBlock Text=">_" Foreground="{StaticResource AccentInk}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<!--
|
||||
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
|
||||
fact about the vault, and the design gives it the accent as a solid tile — which is also what
|
||||
the launcher icon draws, so the two agree.
|
||||
-->
|
||||
<Border Grid.Column="0" Width="26" Height="26" CornerRadius="8"
|
||||
Background="{StaticResource Accent}" VerticalAlignment="Center">
|
||||
<TextBlock Text=">_" Foreground="{StaticResource AccentInk}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="10,0,8,0" FontSize="16"
|
||||
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="10,0,8,0" FontSize="16"
|
||||
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
The sync light, and it is green only when it has earned it — see SyncLabel. The design draws a
|
||||
permanently green "Synced" here, which is the one claim on that mock-up this application will
|
||||
not make.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="9" Text="{Binding SyncLabel}" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
The sync light, and it is green only when it has earned it — see SyncLabel. The design draws
|
||||
a permanently green "Synced" here, which is the one claim on that mock-up this application
|
||||
will not make.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="9" Text="{Binding SyncLabel}" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||
ToolTip.Tip="Lock the keychain">
|
||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5" Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||
ToolTip.Tip="Lock the keychain">
|
||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- The screens. Only one draws; which one is the shell's business. -->
|
||||
<Panel Grid.Row="1">
|
||||
@@ -116,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.
|
||||
@@ -131,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
|
||||
@@ -180,41 +209,57 @@
|
||||
v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a
|
||||
rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row
|
||||
saying what the row already looks like.
|
||||
|
||||
On every screen except the one it names. The terminal draws these same sessions in its own bar, and
|
||||
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
|
||||
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
|
||||
whether there are any tabs and this one is about which surface is up.
|
||||
-->
|
||||
<Border Grid.Row="2" IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="9"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green only while there is a shell behind the tab. It used to be lit unconditionally,
|
||||
which was true when a tab could not exist without a session; one can now — connecting
|
||||
opens the tab first — and a dot that was green before anything had answered would be
|
||||
the one thing on this strip claiming something untrue.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<Panel Grid.Row="2" IsVisible="{Binding IsShowingPages}">
|
||||
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="9"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green only while there is a shell behind the tab. It used to be lit unconditionally,
|
||||
which was true when a tab could not exist without a session; one can now —
|
||||
connecting opens the tab first — and a dot that was green before anything had
|
||||
answered would be the one thing on this strip claiming something untrue.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ navigation ============ -->
|
||||
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
<!--
|
||||
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's four
|
||||
destinations are replaced by a back arrow and a + that leads to three of them, both in the terminal's
|
||||
own bar. See TerminalScreen.axaml.
|
||||
|
||||
This one is bound directly rather than wrapped — its visibility is a single question and it has no
|
||||
second condition of its own to keep separate.
|
||||
-->
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsShowingPages}"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Height="64">
|
||||
<Grid ColumnDefinitions="*,*,*,*">
|
||||
|
||||
@@ -225,8 +270,13 @@
|
||||
<!--
|
||||
The terminal is a surface rather than a page — see ShellSurface — so this one does not go
|
||||
through ShowScreen. Its own command is on the shell.
|
||||
|
||||
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
|
||||
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
|
||||
would be a rule about a state this control cannot be in. What marks the terminal as current is
|
||||
the surface filling the screen.
|
||||
-->
|
||||
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗" IsCurrent="{Binding IsTerminalSurface}"
|
||||
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗"
|
||||
Command="{Binding ShowTerminalCommand}" />
|
||||
|
||||
<!--
|
||||
@@ -242,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".
|
||||
@@ -298,6 +298,13 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// moving between screens at all. Closing an editor is not the same refusal as leaving a host-key
|
||||
/// decision alone — an editor is abandonable by design, and the CANCEL button beside it says so.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now
|
||||
/// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way
|
||||
/// off it other than the scrim and CANCEL. It is checked before the terminal is dismissed for the
|
||||
/// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a
|
||||
/// menu would take two, neither of them the one being looked at.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnBackRequested(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -313,6 +320,17 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// The connect menu, which is raised from the terminal's own bar and is the topmost thing the phone
|
||||
// draws while it is up. Ahead of the editors below because it is nearer, and ahead of leaving the
|
||||
// terminal because a gesture that dismissed the surface underneath a menu would close two things at
|
||||
// once — and the one the user was looking at would not be either of them.
|
||||
if (current.IsConnectSheetOpen)
|
||||
{
|
||||
current.CloseConnectSheetCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryCloseAnOpenEditor(current))
|
||||
{
|
||||
e.Handled = true;
|
||||
@@ -329,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);
|
||||
}
|
||||
@@ -16,113 +16,142 @@
|
||||
|
||||
The tab strip is horizontal-scrolling rather than wrapping. Wrapping would reflow the terminal every
|
||||
time a tab opened, which is the one thing a terminal must not do while output is arriving.
|
||||
|
||||
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
|
||||
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
|
||||
four-entry bottom bar are all collapsed by PhoneShell while this surface is up, and what replaces them
|
||||
is the row below: back, the sessions, and the way to open another one.
|
||||
|
||||
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
|
||||
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
|
||||
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
|
||||
reach to Hosts, Keychain and MORE; back and the + between them lead to all of it, and the system back
|
||||
gesture does the same thing the arrow does.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<Panel>
|
||||
|
||||
<!-- ============ tabs ============ -->
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ============ the bar ============ -->
|
||||
<!--
|
||||
v2 draws these as pills rather than as a segmented strip, so the row is transparent and each session
|
||||
carries its own outline. The close cross moved inside the pill with the name, which is what makes it
|
||||
read as one object you can dismiss rather than as two adjacent targets.
|
||||
-->
|
||||
<Border Grid.Row="0" Height="52">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<!--
|
||||
44 tall, where the session pills on the shell strip are 34. The difference is the close
|
||||
cross: a pill you only select can be chip-sized, and a pill containing the control that ends
|
||||
a shell cannot. This head's rule is 44 and this is the one control on the phone that is both
|
||||
destructive and has no confirmation and no undo — see CloseTabAsync, which ends the session
|
||||
the moment it is pressed.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="11" Height="44">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="row" MinHeight="42" Padding="13,0" CornerRadius="11"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||
Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!--
|
||||
The close cross is inside the tab, which the plan calls out: a strip-level close would
|
||||
act on whichever tab happened to be selected, and on a phone that is a mis-tap away from
|
||||
killing the wrong shell.
|
||||
Everything the phone draws above a shell. It is a bar rather than a strip because it now carries the
|
||||
two controls the collapsed chrome took with it, one at each end, with the sessions between them.
|
||||
|
||||
The hairline down its left edge is not decoration. The two targets are flush inside one
|
||||
pill, so without a visible seam there is nothing telling a thumb where "switch to this
|
||||
shell" stops and "end it" starts.
|
||||
-->
|
||||
<Button Classes="row" MinHeight="42" Width="44" Padding="0" CornerRadius="0,11,11,0"
|
||||
HorizontalContentAlignment="Center"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Text="×" Foreground="{StaticResource TextFaint}" FontSize="14" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
Both are outside the ScrollViewer deliberately. They are the way out of this surface and the way to
|
||||
another host, and a tenth tab must not be able to push either of them off the right-hand edge.
|
||||
|
||||
<!-- ============ the connection line ============ -->
|
||||
<!--
|
||||
The text-size buttons live here rather than in the accessory row below, and the row is the reason:
|
||||
that one scrolls, so a key can be off-screen, and these two must not be — a terminal that is too
|
||||
small to read is exactly the state in which hunting for the control that fixes it is worst.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head has
|
||||
no keyboard to press it with, which is why the two heads differ here and nowhere else in this screen.
|
||||
35 tall, a third off the 52 it opened at. Every height inside it came down with it — the icon squares
|
||||
to 34, the pills to 30 — because a bar that shrank around controls that did not would only have moved
|
||||
the clipping somewhere harder to see.
|
||||
-->
|
||||
<!--
|
||||
The design's line here also carries a round-trip time and a forwarded port. Neither is drawn: the SSH
|
||||
library offers no RTT measurement, and nothing in this application forwards anything. What is left is
|
||||
the one fact that is real and is the one that matters — the account and endpoint actually dialled.
|
||||
-->
|
||||
<Border Grid.Row="1" Padding="16,5" Height="36"
|
||||
IsVisible="{Binding SelectedTab, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="detail" FontSize="10.5" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{StaticResource TextDim}"
|
||||
VerticalAlignment="Center" Text="{Binding SelectedTab.Address}" />
|
||||
<Border Grid.Row="0" Height="35" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<!--
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="row" MinHeight="34" MinWidth="38" Padding="0"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
|
||||
system back gesture already does exactly that — see PhoneShell.axaml.cs — and an arrow that
|
||||
landed somewhere else would be the second of two answers to one question.
|
||||
|
||||
<Button Grid.Column="2" Classes="row" MinHeight="34" MinWidth="38" Padding="0" Margin="4,0,0,0"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
Height overridden and width left alone. Button.icon is a 44 square, which is taller than this bar;
|
||||
the 44 that matters is the horizontal one, since nothing in a row of this shape is hard to hit
|
||||
above or below.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="←" Height="34" Margin="4,0,0,0"
|
||||
Command="{Binding ShowScreenCommand}" CommandParameter="{Binding Screen}" />
|
||||
|
||||
<!--
|
||||
v2 draws these as pills rather than as a segmented strip, so the row is transparent and each
|
||||
session carries its own outline. The close cross moved inside the pill with the name, which is
|
||||
what makes it read as one object you can dismiss rather than as two adjacent targets.
|
||||
-->
|
||||
<ScrollViewer Grid.Column="1" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="6,0" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<!--
|
||||
30 tall, in a bar of 35. It was 44, and the argument for 44 was that a pill you only
|
||||
select can be chip-sized while one containing the control that ends a shell cannot — the
|
||||
close cross is the one control on this head that is both destructive and has neither
|
||||
confirmation nor undo. That argument is now carried by width rather than by height: the
|
||||
cross keeps its full 44-pixel column, and what it lost is 14 pixels of vertical slack in a
|
||||
row where nothing sits above or below it to be hit by mistake.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="9" Height="30">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="9"
|
||||
VerticalContentAlignment="Center"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||
Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!--
|
||||
The close cross is inside the tab, which the plan calls out: a strip-level close would
|
||||
act on whichever tab happened to be selected, and on a phone that is a mis-tap away from
|
||||
killing the wrong shell.
|
||||
|
||||
The hairline down its left edge is not decoration. The two targets are flush inside one
|
||||
pill, so without a visible seam there is nothing telling a thumb where "switch to this
|
||||
shell" stops and "end it" starts.
|
||||
|
||||
Both alignments are stated, and the vertical one is not decoration either: Button.row
|
||||
sets HorizontalContentAlignment and says nothing about the other axis, so the cross
|
||||
was sitting against the top of its own column rather than in the middle of the pill.
|
||||
It reads as a misprint, which for the control that ends a session is the wrong thing
|
||||
to look like.
|
||||
-->
|
||||
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,9,9,0"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Text="×" Foreground="{StaticResource TextFaint}" FontSize="14"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
Another connection, and the three kinds this application can make. A menu rather than a straight
|
||||
jump to Hosts because SFTP and S3 used to be two taps through the bottom bar's MORE, and the bar
|
||||
is not on screen here — so the control that replaces it has to lead to all three or it has quietly
|
||||
removed two of them.
|
||||
|
||||
The desktop's own + refuses a flyout on this reasoning, in TerminalTabs.axaml: a popup dropping
|
||||
into the renderer's rectangle may or may not composite above a native child window, and that is
|
||||
not a claim to make without a screenshot. It is answered here rather than dodged — this is a sheet
|
||||
at the bottom of the screen and opening it collapses the renderer outright, exactly as the
|
||||
palette does on the desktop. Nothing is drawn over the WebView.
|
||||
|
||||
Plain Button.icon, the same as the arrow across from it, rather than the accent variant. The two
|
||||
are a matched pair at either end of one bar — one leaves this surface, one adds to it — and an
|
||||
accented + would rank itself above the way out. The accent fill belongs to the floating + on
|
||||
HOSTS, which is the only action on its screen; this one is not.
|
||||
-->
|
||||
<Button Grid.Column="2" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
|
||||
Command="{Binding OpenConnectSheetCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ the renderer ============ -->
|
||||
<Panel Grid.Row="2">
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<!--
|
||||
The empty state, and it says what the surface is for rather than that it is empty. A phone opens
|
||||
@@ -131,7 +160,7 @@
|
||||
<StackPanel IsVisible="{Binding !HasTabs}" VerticalAlignment="Center" Margin="24" Spacing="10">
|
||||
<TextBlock Classes="title" FontSize="13" Text="NO SHELL OPEN" />
|
||||
<TextBlock Classes="body"
|
||||
Text="Choose a host and press CONNECT. A shell opened here keeps running while the app is in the background, and keeps running after the keychain is locked — a notification says so for as long as one is alive." />
|
||||
Text="Press + above, or choose a host and press CONNECT. A shell opened here keeps running while the app is in the background, and keeps running after the keychain is locked — a notification says so for as long as one is alive." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -140,10 +169,17 @@
|
||||
hold the application still any more, so there is a stretch in which a tab is selected and there is
|
||||
nothing yet to render in it. A phone needs it more than a desktop does: mobile links are slower, and
|
||||
the alternative is a black rectangle.
|
||||
|
||||
It carries the address, which is where that fact went when the connection line was folded into the
|
||||
bar above. This is the moment it is worth reading — what is being dialled, before anything has
|
||||
answered — and once a shell is open its own prompt says the same thing more accurately than a header
|
||||
derived from the keychain ever did.
|
||||
-->
|
||||
<StackPanel IsVisible="{Binding IsConnectingShowing}" VerticalAlignment="Center" Margin="24"
|
||||
Spacing="10">
|
||||
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
||||
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
@@ -153,13 +189,14 @@
|
||||
|
||||
<!--
|
||||
Collapsed rather than merely covered when there is no pane to show. On Windows this control is a
|
||||
native child window that composites above everything Avalonia draws, which is why the desktop head
|
||||
native child view that composites above everything Avalonia draws, which is why the desktop head
|
||||
hides it explicitly; whether Android's WebView does the same is recorded as unverified in
|
||||
docs/android-port.md. Hiding it either way costs nothing and is correct under both answers.
|
||||
|
||||
IsTerminalShowing rather than HasTabs, which are no longer the same question: a tab that is still
|
||||
connecting has no pane, and showing the renderer for it would show the previous session's output
|
||||
under the name of a machine nothing has connected to yet.
|
||||
under the name of a machine nothing has connected to yet. It is also what the connect sheet turns
|
||||
off — see MainWindowViewModel.IsTerminalShowing.
|
||||
|
||||
v2 insets this behind a 14-pixel radius. Not done, and not an oversight: this is a native child view
|
||||
composited above everything Avalonia draws, so a rounded Border behind it clips nothing — the
|
||||
@@ -178,13 +215,144 @@
|
||||
then release, because holding a modifier while typing is not possible one-thumbed.
|
||||
-->
|
||||
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="50">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
|
||||
VerticalAlignment="Center" />
|
||||
</ScrollViewer>
|
||||
<!--
|
||||
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
|
||||
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
|
||||
|
||||
The five pixels above it are a gap and not a border. The renderer is a native child view, so nothing
|
||||
Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its own —
|
||||
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
|
||||
that has gone wrong rather than as two that are different things.
|
||||
-->
|
||||
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
|
||||
VerticalAlignment="Center" />
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
|
||||
beside them.
|
||||
|
||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
|
||||
in which hunting for the control that fixes it is worst. That argument is answered rather than
|
||||
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
|
||||
on a surface this change exists to give back.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
|
||||
screen.
|
||||
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ the connect sheet ============ -->
|
||||
<!--
|
||||
Follows the add sheet on HostsScreen, which follows HostKeySheet: a scrim, a bottom-aligned panel with
|
||||
the top two corners rounded, and a grab handle that is decoration. Dismissible, like the add sheet and
|
||||
unlike the host-key one — "which kind of connection" has no wrong answer and no answer at all is one
|
||||
of them.
|
||||
|
||||
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
|
||||
but this surface raises it. The scrim reaching only the screen area is not a compromise here the way
|
||||
it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the
|
||||
display.
|
||||
|
||||
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
|
||||
each of the three destinations is a picker, and the shell they open lands back on this surface as a
|
||||
new tab in the bar above.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsConnectSheetOpen}">
|
||||
|
||||
<!--
|
||||
Declared before the sheet so the sheet draws over it: a Panel stacks its children in declaration
|
||||
order. See the .scrim style for why a tap on it must not light anything up.
|
||||
-->
|
||||
<Button Classes="scrim" Command="{Binding CloseConnectSheetCommand}" />
|
||||
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
CornerRadius="22,22,0,0" Padding="20,18,20,16">
|
||||
<StackPanel Spacing="0">
|
||||
|
||||
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,16" />
|
||||
|
||||
<TextBlock Classes="title" Text="CONNECT" FontSize="13" />
|
||||
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="Another shell, on this host or any other in the keychain." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
These two go through ShowFiles rather than ShowScreen, as the MORE hub's own rows do: one screen
|
||||
over one view model, and which kind of remote it offers is the thing being chosen. It can refuse
|
||||
— there is a single transfer session behind both — and refusing lands on the screen the open one
|
||||
belongs to with a sentence saying why, which is a better place to read it than a sheet that has
|
||||
just closed.
|
||||
-->
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect via SFTP" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="Browse a host's files." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect via S3" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="Objects in an S3-compatible bucket from the keychain." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="secondary" Content="CANCEL" Margin="0,12,0,0"
|
||||
Command="{Binding CloseConnectSheetCommand}" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -126,10 +126,13 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
|
||||
// 44 wide as well as tall. The design draws them flexed across the width, which at 360dp
|
||||
// with ten keys is 32 pixels each — under every thumb-target guideline there is.
|
||||
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
||||
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
||||
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
||||
// costs nothing a width does: the keys are a single row with the terminal above and the
|
||||
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
||||
MinWidth = 44,
|
||||
Height = 38,
|
||||
Height = 30,
|
||||
Padding = new Thickness(10, 0),
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Background = Palette("Panel"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Regenerates dodossh.ico from the same geometry the Android launcher icon draws.
|
||||
#
|
||||
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
|
||||
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
|
||||
# to forget one of. Windows will not take a vector: <ApplicationIcon> wants an .ico and nothing
|
||||
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
|
||||
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
|
||||
#
|
||||
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
|
||||
#
|
||||
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
|
||||
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
|
||||
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
|
||||
# here would draw the glyph at half the size it is meant to be — correct arithmetic, and a stamp
|
||||
# lost in a field of accent. Matching what the phone displays means matching the 72.
|
||||
#
|
||||
# The one thing this draws that the phone's vector does not is the tile. On Android the tile is
|
||||
# the background layer and the launcher's mask rounds it; Windows has no mask, so the rounding
|
||||
# happens here.
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5B8CFF') # dodo_accent / AccentColor
|
||||
$ink = [System.Drawing.ColorTranslator]::FromHtml('#0E1220') # AccentInk
|
||||
|
||||
# Every size Windows asks for: 16 in a titlebar and a tree, 32 on the desktop, 48 in a large-icon
|
||||
# view, 256 for the preview pane. Shipping fewer means Windows downsamples one of these to fill
|
||||
# the gap, and its downsampler is not kind to a hairline.
|
||||
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||
|
||||
function New-MarkPng([int]$size)
|
||||
{
|
||||
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||
|
||||
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
|
||||
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
|
||||
$radius = [double]$size * 0.22
|
||||
$d = $radius * 2.0
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$path.AddArc(0.0, 0.0, $d, $d, 180, 90)
|
||||
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
|
||||
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
|
||||
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
|
||||
$path.CloseFigure()
|
||||
$brush = New-Object System.Drawing.SolidBrush($accent)
|
||||
$g.FillPath($brush, $path)
|
||||
|
||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge.
|
||||
$scale = [double]$size / 72.0
|
||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
|
||||
|
||||
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
|
||||
# difference between a mark and a smudge. The phone's file already bumps this width for the
|
||||
# same reason at 48dp; the floor is that argument carried down to the sizes Windows asks for.
|
||||
$pen = New-Object System.Drawing.Pen($ink, [float][Math]::Max(5.4 * $scale, 1.0))
|
||||
$pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
|
||||
$pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
|
||||
$pen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
|
||||
|
||||
# The chevron of >_ and the underscore on the baseline it bottoms out at.
|
||||
$g.DrawLines($pen, @((P 36.06 43.33), (P 49.91 53.57), (P 36.06 63.8)))
|
||||
$g.DrawLine($pen, (P 54 64.68), (P 71.94 64.68))
|
||||
|
||||
$stream = New-Object System.IO.MemoryStream
|
||||
$bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
|
||||
$pen.Dispose(); $brush.Dispose(); $path.Dispose(); $g.Dispose(); $bitmap.Dispose()
|
||||
return $stream.ToArray()
|
||||
}
|
||||
|
||||
# ICO is a six-byte header, a sixteen-byte directory entry per image, then the images. The entries
|
||||
# carry PNG payloads rather than the older BMP-with-AND-mask form, which every Windows since Vista
|
||||
# reads and which is what keeps a 256px entry from costing 256KB.
|
||||
#
|
||||
# The cast on each frame is load-bearing. A byte[] returned through PowerShell's output collector
|
||||
# comes back as Object[] of boxed bytes, which BinaryWriter has no overload for — it binds to one
|
||||
# of the scalar Write()s instead and puts a single byte on the stream. The first run of this
|
||||
# script produced a 159-byte .ico that way, header and directory intact and nine one-byte images.
|
||||
$frames = New-Object 'System.Collections.Generic.List[byte[]]'
|
||||
foreach ($size in $sizes)
|
||||
{
|
||||
[byte[]]$png = New-MarkPng $size
|
||||
$frames.Add($png)
|
||||
}
|
||||
|
||||
$out = New-Object System.IO.MemoryStream
|
||||
$w = New-Object System.IO.BinaryWriter($out)
|
||||
$w.Write([uint16]0) # reserved
|
||||
$w.Write([uint16]1) # type: icon
|
||||
$w.Write([uint16]$sizes.Count)
|
||||
|
||||
$offset = 6 + (16 * $sizes.Count)
|
||||
for ($i = 0; $i -lt $sizes.Count; $i++)
|
||||
{
|
||||
$size = $sizes[$i]
|
||||
$w.Write([byte]($(if ($size -ge 256) { 0 } else { $size }))) # 0 means 256
|
||||
$w.Write([byte]($(if ($size -ge 256) { 0 } else { $size })))
|
||||
$w.Write([byte]0) # palette entries: none, this is truecolour
|
||||
$w.Write([byte]0) # reserved
|
||||
$w.Write([uint16]1) # colour planes
|
||||
$w.Write([uint16]32) # bits per pixel
|
||||
$w.Write([uint32]$frames[$i].Length)
|
||||
$w.Write([uint32]$offset)
|
||||
$offset += $frames[$i].Length
|
||||
}
|
||||
|
||||
foreach ($frame in $frames) { $w.Write($frame) }
|
||||
$w.Flush()
|
||||
|
||||
$target = Join-Path $PSScriptRoot 'dodossh.ico'
|
||||
[System.IO.File]::WriteAllBytes($target, $out.ToArray())
|
||||
$w.Dispose(); $out.Dispose()
|
||||
|
||||
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
@@ -5,6 +5,14 @@
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
|
||||
<!--
|
||||
The icon on the executable itself — what Explorer, the Start menu and a pinned taskbar button
|
||||
draw, all of which read it from the PE resource and never start the process. Window.Icon in
|
||||
MainWindow.axaml is a separate thing that only exists once the application is running; both are
|
||||
needed, and both point at this file.
|
||||
-->
|
||||
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
|
||||
|
||||
<!--
|
||||
False here, unlike every server project. The root Directory.Build.props sets it true because
|
||||
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
||||
@@ -15,6 +23,14 @@
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Named rather than globbed as Assets/**, because the folder also holds the script that draws the
|
||||
icon and a build has no reason to carry a copy of it around inside the binary.
|
||||
-->
|
||||
<AvaloniaResource Include="Assets/dodossh.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
|
||||
@@ -36,14 +36,23 @@
|
||||
a key wants nothing typed here — and a sentence in its place when it does not, because "nothing
|
||||
needs typing" and "something needs typing and the box has not appeared yet" look identical and only
|
||||
one of them is fine.
|
||||
|
||||
REMEMBER travels with the box and hides with it. It is the two-step chore the box's tooltip used to
|
||||
describe — add a password under Keychain, then bind the host to it — done from the one screen that
|
||||
already has the password, and it takes effect only once the remote has accepted it.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="12,8" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password"
|
||||
PasswordChar="•" Width="200" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
||||
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Keychain and bind this host to it in the host's own editor." />
|
||||
ToolTip.Tip="Typed each time unless REMEMBER is ticked, in which case it is saved to your keychain and bound to this host once the connection succeeds." />
|
||||
<CheckBox IsChecked="{Binding Vault.RemembersConnectPassword}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
||||
ToolTip.Tip="Saves this password to your keychain, bound to this host, so it is not asked for again. It syncs to your other machines, and only happens if the connection works.">
|
||||
<TextBlock Text="REMEMBER" Classes="hint" FontSize="11" />
|
||||
</CheckBox>
|
||||
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
x:Class="DodoSSH.Client.App.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="DodoSSH"
|
||||
Icon="/Assets/dodossh.ico"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="1016"
|
||||
|
||||
@@ -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>
|
||||
@@ -418,25 +418,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two defaults, because the two audiences never overlap. A release build is installed by somebody
|
||||
/// signing in to the hosted deployment, and typing its address is the only thing standing between
|
||||
/// them and a working application. A debug build is run from a clone, next to
|
||||
/// <c>dotnet run --project src/DodoSSH.Api</c>, and shipping the hosted address there would point
|
||||
/// every development launch at production — which is worse than an inconvenience, since sign-in is
|
||||
/// the step that enrolls a device.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Note the schemes. <c>http</c> locally is not an oversight: the API's first launch profile — the
|
||||
/// one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing an
|
||||
/// HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
|
||||
/// One default for every build. The hosted deployment is what all but a handful of launches are
|
||||
/// aiming at, and typing its address is the only thing standing between an installed application and
|
||||
/// a working one. Running against a clone means replacing this with
|
||||
/// <c>http://localhost:5233</c> by hand — note the scheme, because the API's first launch profile —
|
||||
/// the one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing
|
||||
/// an HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
|
||||
/// sends people looking for a certificate problem. See <see cref="ExplainSignInFailure" />.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This was once split on <c>DEBUG</c> so a development launch could not enroll a device against
|
||||
/// production by accident. That protection is gone: a debug build now offers the hosted address like
|
||||
/// any other, and the first sign-in accepted unread lands there.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
#if DEBUG
|
||||
internal const string DefaultServerUrl = "http://localhost:5233";
|
||||
#else
|
||||
internal const string DefaultServerUrl = "https://ssh.dodotech.cloud";
|
||||
#endif
|
||||
|
||||
[ObservableProperty]
|
||||
private string serverUrl = DefaultServerUrl;
|
||||
@@ -781,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.
|
||||
@@ -799,8 +799,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
|
||||
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
|
||||
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
|
||||
/// locked vault (the unlock card), the page area (every screen uses the full width), and the
|
||||
/// quick-connect palette.
|
||||
/// locked vault (the unlock card), the page area (every screen uses the full width), the quick-connect
|
||||
/// palette, and the phone's connect sheet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The sheet is here rather than in <see cref="IsTerminalSurface"/>, and the palette is not.</b> The
|
||||
/// palette replaces the whole surface, so collapsing everything the terminal half draws is right. The
|
||||
/// sheet is raised from the terminal's own top bar and that bar has to stay on screen behind it —
|
||||
/// dropping the surface would take the bar, the tabs and the phone's whole chrome with it and leave the
|
||||
/// sheet floating over the page underneath. So only the renderer's rectangle is given up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
|
||||
@@ -829,7 +836,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// safe — that detaches it and destroys the whole WebView2 process tree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsTerminalShowing => IsTerminalSurface && SelectedTab is { HasSession: true };
|
||||
internal bool IsTerminalShowing =>
|
||||
IsTerminalSurface && !IsConnectSheetOpen && SelectedTab is { HasSession: true };
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal half of the window is the half being shown, pane or no pane.
|
||||
@@ -893,6 +901,52 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void ShowTerminal() => Surface = ShellSurface.Terminal;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone's connect menu is open over the terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Drawn by the Android head alone, and shell state rather than something that view could hold on its
|
||||
/// own for the reason <see cref="IsSearching"/> is: it has to collapse the renderer while it is up. See
|
||||
/// <see cref="IsTerminalShowing"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It exists because the phone gives a terminal the whole screen. The bottom bar and the vault header
|
||||
/// are gone while a shell is showing, so the three things that bar was the way to — a host, a host's
|
||||
/// files, a bucket — need a way back that is not "leave the terminal first and remember what you were
|
||||
/// doing". The menu is that, and every entry on it is one of the two navigation commands above.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isConnectSheetOpen;
|
||||
|
||||
/// <summary>Raises the connect menu over the terminal.</summary>
|
||||
/// <remarks>
|
||||
/// Gated on the terminal surface rather than merely trusting its only button to be off screen otherwise.
|
||||
/// The flag collapses the renderer, so one set while a page was showing would be a sheet nobody can see
|
||||
/// holding a terminal hidden that nothing would put back.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void OpenConnectSheet()
|
||||
{
|
||||
if (!IsTerminalSurface)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsConnectSheetOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>Lowers the connect menu, leaving the terminal where it was.</summary>
|
||||
/// <remarks>
|
||||
/// The scrim, the CANCEL row and the system back gesture all come here. Choosing an entry does not, and
|
||||
/// does not need to: every entry navigates, and leaving the terminal surface lowers the sheet on its own
|
||||
/// — see <see cref="OnSurfaceChanged"/>, which is what makes "the sheet is only ever up over a terminal"
|
||||
/// true of routes nobody wrote it for.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void CloseConnectSheet() => IsConnectSheetOpen = false;
|
||||
|
||||
// ---- Open terminals ----
|
||||
|
||||
/// <summary>
|
||||
@@ -2537,7 +2591,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnScreenChanged" />
|
||||
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
|
||||
/// <remarks>
|
||||
/// <b>The one place the connect sheet is lowered by something other than a tap.</b> Every way out of a
|
||||
/// terminal ends here — a rail or bottom-bar destination, the files screen, the palette connecting to a
|
||||
/// host, closing the last tab, a lock — and each of them would otherwise leave the flag set on a shell
|
||||
/// showing a page. That is not merely untidy: the flag collapses the renderer, so the next return to the
|
||||
/// terminal would draw the sheet again over a rectangle held blank by it.
|
||||
/// </remarks>
|
||||
partial void OnSurfaceChanged(ShellSurface value)
|
||||
{
|
||||
if (value is not ShellSurface.Terminal)
|
||||
{
|
||||
IsConnectSheetOpen = false;
|
||||
}
|
||||
|
||||
RaiseSurfaceState();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
|
||||
@@ -2600,6 +2669,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();
|
||||
|
||||
/// <inheritdoc cref="OnIsSearchingChanged" />
|
||||
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
|
||||
|
||||
/// <remarks>
|
||||
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
|
||||
/// <see cref="IsAskingForThePassphrase"/>.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -333,7 +333,13 @@ internal sealed partial class HostRowViewModel(
|
||||
/// <summary>What a host can authenticate with.</summary>
|
||||
internal enum AuthenticationKind
|
||||
{
|
||||
/// <summary>Typed at the moment of connecting, and never stored.</summary>
|
||||
/// <summary>Typed at the moment of connecting.</summary>
|
||||
/// <remarks>
|
||||
/// Nothing is stored under this kind. Ticking the connect bar's REMEMBER does not change that — it
|
||||
/// creates a credential and moves the host to <see cref="Credential"/>, so a stored password is always
|
||||
/// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See
|
||||
/// <see cref="VaultViewModel.RemembersConnectPassword"/>.
|
||||
/// </remarks>
|
||||
Typed,
|
||||
|
||||
/// <summary>An SSH key in this vault.</summary>
|
||||
@@ -1984,14 +1990,36 @@ internal sealed partial class VaultViewModel(
|
||||
// ---- Connecting ----
|
||||
|
||||
/// <remarks>
|
||||
/// Typed per connection, never persisted, and now only reached by a host bound to nothing. It stays because
|
||||
/// not every password is worth storing — a one-off on a machine somebody will never open again, or one
|
||||
/// they would rather this vault did not hold — and because a credential has to be created before it can be
|
||||
/// bound, which means the first connection to a new host happens through this box.
|
||||
/// Typed per connection, not persisted unless <see cref="RemembersConnectPassword"/> says otherwise, and
|
||||
/// only reached by a host bound to nothing. It stays because not every password is worth storing — a
|
||||
/// one-off on a machine somebody will never open again, or one they would rather this vault did not hold
|
||||
/// — and because a credential has to be created before it can be bound, which means the first connection
|
||||
/// to a new host happens through this box.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string connectPassword = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a password typed here should be kept, so this host stops asking for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What it produces is an ordinary keychain credential bound to the host, and not a fourth place a
|
||||
/// password can live. The two-step chore it replaces — add a password under Keychain, then open the host
|
||||
/// and bind it — is what the box's tooltip used to instruct people to do by hand, and doing it by hand
|
||||
/// means typing the secret into a second screen while the first one already has it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Off by default, and it stays a decision.</b> The reason a typed password exists at all is that not
|
||||
/// every password belongs in a synchronised vault; remembering silently would move each of them there and
|
||||
/// tell nobody. It also only takes effect once the handshake has succeeded — see
|
||||
/// <see cref="RememberTypedPasswordAsync"/> — because a password that has just been refused is precisely
|
||||
/// the one not worth keeping.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool remembersConnectPassword;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected host will want something typed into the password box.
|
||||
/// </summary>
|
||||
@@ -5231,6 +5259,95 @@ internal sealed partial class VaultViewModel(
|
||||
sessionId,
|
||||
row.Label,
|
||||
Dialled(row, authentication)));
|
||||
|
||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||
// for must not wait on a vault write to appear.
|
||||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns the password that just worked into a keychain credential bound to this host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Only after a handshake the remote accepted.</b> Storing a password the moment it is typed would
|
||||
/// bind whatever was in the box — including the typo that is about to be refused — and the host would
|
||||
/// then stop asking, leaving a machine that cannot be connected to until somebody works out that the
|
||||
/// keychain is where the wrong password now lives.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A credential rather than a field on the host, which is why nothing else here had to change.</b>
|
||||
/// It syncs, merges, appears in the keychain, can be renamed, deleted and — the reason the item type
|
||||
/// exists — bound to the other nineteen machines that share the account. See <see cref="HostSecret"/>
|
||||
/// on why the binding is an id and not a copy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The credential carries no username of its own, so it keeps taking the host's — which is what the
|
||||
/// connection that just succeeded did. Copying the resolved username into it would pin whatever the
|
||||
/// group happened to say at this moment, and quietly stop following the group afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every failure is reported and swallowed. The caller's <c>catch</c> blocks describe a connection that
|
||||
/// did not happen, and this one did: a vault write that fails here must not tell the user their terminal
|
||||
/// was abandoned, and a cancellation must not report it as cancelled.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RememberTypedPasswordAsync(
|
||||
HostRowViewModel row,
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// The password as dialled, not as the box currently reads: the two can differ by now, because a
|
||||
// handshake takes time and the box stays typeable throughout it.
|
||||
if (!RemembersConnectPassword
|
||||
|| row.Resolved.Binding.Kind is not ResolvedBindingKind.TypedPassword
|
||||
|| authentication.Credential is not SshPasswordCredential { Password.Length: > 0 } typed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
|
||||
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
|
||||
return;
|
||||
}
|
||||
|
||||
var credential = new CredentialSecret { Label = row.Label, Password = typed.Password };
|
||||
|
||||
try
|
||||
{
|
||||
var credentialId = await session.Credentials
|
||||
.CreateAsync(row.VaultId, credential, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// Into the same vault as the host, deliberately: a credential in the personal vault bound to a
|
||||
// team's host is a binding every other member can see and none of them can resolve.
|
||||
await session.Hosts
|
||||
.UpdateAsync(
|
||||
row.VaultId,
|
||||
row.EntityId,
|
||||
row.Host with { CredentialId = credentialId, AsksForPassword = null },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleared together. The box is about to disappear — the host answers "credential" now — and a tick
|
||||
// left behind would apply to the next host somebody selects.
|
||||
RemembersConnectPassword = false;
|
||||
ConnectPassword = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
|
||||
+ "will not be asked for again.";
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>The address as actually dialled.</summary>
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
+1887
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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user