diff --git a/backend/src/CodeSpace.Api/Controllers/CredentialsController.cs b/backend/src/CodeSpace.Api/Controllers/CredentialsController.cs index 8e72e1711..562bade06 100644 --- a/backend/src/CodeSpace.Api/Controllers/CredentialsController.cs +++ b/backend/src/CodeSpace.Api/Controllers/CredentialsController.cs @@ -115,6 +115,17 @@ public async Task ReplaceToken([FromRoute] Guid credentialId, [Fr return Ok(result); } + /// + /// Remove a disconnected credential nothing leans on any more: soft-deleted, so it leaves the list. Refused with 400 for one still connected and for one with + /// repositories, group hooks or act-as identities left on it. A single route value, so the one-field command is built here (Rule 17). + /// + [HttpDelete("{credentialId:guid}")] + public async Task RemoveDisconnected([FromRoute] Guid credentialId, CancellationToken cancellationToken) + { + var result = await _mediator.Send(new RemoveDisconnectedCredentialCommand { CredentialId = credentialId }, cancellationToken).ConfigureAwait(false); + return Ok(result); + } + /// /// Move every repository and group hook still bound through a dead credential onto another credential in one action. /// Route's fromCredentialId is authoritative; the body carries toCredentialId. diff --git a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RemoveDisconnectedCredentialCommandHandler.cs b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RemoveDisconnectedCredentialCommandHandler.cs new file mode 100644 index 000000000..274f95d2b --- /dev/null +++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RemoveDisconnectedCredentialCommandHandler.cs @@ -0,0 +1,15 @@ +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Messages.Commands.Credentials; +using MediatR; + +namespace CodeSpace.Core.Handlers.CommandHandlers.Credentials; + +public sealed class RemoveDisconnectedCredentialCommandHandler : IRequestHandler +{ + private readonly ICredentialService _credentials; + + public RemoveDisconnectedCredentialCommandHandler(ICredentialService credentials) { _credentials = credentials; } + + public async Task Handle(RemoveDisconnectedCredentialCommand request, CancellationToken cancellationToken) => + new() { CredentialId = await _credentials.RemoveDisconnectedAsync(request.CredentialId, cancellationToken).ConfigureAwait(false) }; +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRemoval.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRemoval.cs new file mode 100644 index 000000000..4e0ba778d --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRemoval.cs @@ -0,0 +1,42 @@ +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; + +namespace CodeSpace.Core.Services.Credentials; + +/// +/// When a credential may be taken out of the team's list: it is disconnected, and nothing leans on it. Pure, so the rule and the +/// words a refusal says are pinned without a database — and so the Providers dialog, which folds away the dead rows with nothing on +/// them, and the removal that accepts them read one rule: a credential still in service is a Disconnect's to end, and what leans on +/// a dead one () is moved or unlinked before the row goes. +/// +internal static class CredentialRemoval +{ + /// Why the credential may not be removed, in words a person can act on; null when it may. + public static string? Refusal(Credential credential, CredentialDependents dependents) + { + if (credential.Status != CredentialStatus.Revoked) + return $"Credential '{credential.DisplayName}' is still connected. Disconnect it first — only a disconnected credential with nothing on it is removed."; + + var leaning = Leaning(dependents); + + return leaning == null ? null : $"Credential '{credential.DisplayName}' still has {leaning} leaning on it. Move or unlink them first."; + } + + /// "2 repositories, 1 group hook and 1 act-as identity" — only what is there; null when nothing is. + private static string? Leaning(CredentialDependents dependents) + { + var parts = new List(); + + AddCount(parts, dependents.Repositories.Count, "repository", "repositories"); + AddCount(parts, dependents.ConnectionWebhooks.Count, "group hook", "group hooks"); + AddCount(parts, dependents.Identities.Count, "act-as identity", "act-as identities"); + + return parts.Count == 0 ? null : parts.Count == 1 ? parts[0] : $"{string.Join(", ", parts.Take(parts.Count - 1))} and {parts[^1]}"; + } + + private static void AddCount(List parts, int count, string one, string many) + { + if (count > 0) parts.Add($"{count} {(count == 1 ? one : many)}"); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.Dependents.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.Dependents.cs new file mode 100644 index 000000000..ff27429f5 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.Dependents.cs @@ -0,0 +1,27 @@ +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Credentials; + +/// +/// What else leans on each listed credential beside its repositories, counted for the whole list in one read apiece — the rest of +/// the answer gives for one credential, with the same rules (a hook is in +/// service by , an identity is live until it is soft-deleted), so the list's +/// DependentCount and a removal's refusal never disagree on whether a dead row is history. +/// +public sealed partial class CredentialService +{ + private async Task> CountInServiceConnectionWebhooksAsync(List credentialIds, CancellationToken cancellationToken) => + await _db.ConnectionWebhook.AsNoTracking() + .Where(w => credentialIds.Contains(w.CredentialId) && WebhookRegistrationLifecycle.InService.Contains(w.RegistrationStatus)) + .GroupBy(w => w.CredentialId) + .Select(g => new { CredentialId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.CredentialId, g => g.Count, cancellationToken).ConfigureAwait(false); + + private async Task> CountLiveIdentitiesAsync(List credentialIds, CancellationToken cancellationToken) => + await _db.UserProviderIdentity.AsNoTracking() + .Where(i => credentialIds.Contains(i.CredentialId) && i.DeletedDate == null) + .GroupBy(i => i.CredentialId) + .Select(g => new { CredentialId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.CredentialId, g => g.Count, cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.Remove.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.Remove.cs new file mode 100644 index 000000000..d05268f02 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.Remove.cs @@ -0,0 +1,64 @@ +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CodeSpace.Core.Services.Credentials; + +/// +/// Taking a disconnected credential with nothing on it out of the team's list. The row ends as a departed member's retired credential +/// does — soft-deleted, Revoked, its token already blanked by the Disconnect — so no list shows it and nothing can revive it. A write +/// that reads, judges and then writes would lose to a reconnect landing in between, so the judgement is made again by the write itself. +/// +public sealed partial class CredentialService +{ + public async Task RemoveDisconnectedAsync(Guid credentialId, CancellationToken cancellationToken) + { + var credential = await LoadRemovableAsync(credentialId, cancellationToken).ConfigureAwait(false); + + await EnsureMayBeRemovedAsync(credential, cancellationToken).ConfigureAwait(false); + await SoftDeleteWhileStillDisconnectedAsync(credential, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Removed disconnected credential {CredentialId} in team {TeamId}: nothing leaned on it", credential.Id, credential.TeamId); + + return credential.Id; + } + + /// The team's credential by id. One already removed is no longer anyone's to remove: not found, as it reads everywhere else. + private async Task LoadRemovableAsync(Guid credentialId, CancellationToken cancellationToken) + { + var credential = await LoadTeamCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false); + + if (credential.DeletedDate != null) + throw new KeyNotFoundException($"credential {credentialId} not found"); + + return credential; + } + + /// What leans on it is read the way a move would carry it (), so a removal and a relink agree on what a credential's dependents are. + private async Task EnsureMayBeRemovedAsync(Credential credential, CancellationToken cancellationToken) + { + var dependents = await _relink.GetDependentsAsync(credential.Id, cancellationToken).ConfigureAwait(false); + + if (CredentialRemoval.Refusal(credential, dependents) is { } refusal) + throw new InvalidOperationException(refusal); + } + + /// + /// The one write, conditional on the row still being disconnected and still there: a reconnect that landed after the checks sets it Active, and this + /// then matches nothing — refused, never an overwrite of the token the person was just told was stored. ExecuteUpdate skips the audit hook, so the + /// modifier is stamped here. + /// + private async Task SoftDeleteWhileStillDisconnectedAsync(Credential credential, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var by = _currentUser.Id!.Value; + + var stillDisconnected = _db.Credential.Where(c => c.Id == credential.Id && c.Status == CredentialStatus.Revoked && c.DeletedDate == null); + + var removed = await stillDisconnected.ExecuteUpdateAsync(s => s.SetProperty(c => c.DeletedDate, now).SetProperty(c => c.LastModifiedDate, now).SetProperty(c => c.LastModifiedBy, by), cancellationToken).ConfigureAwait(false); + + if (removed == 0) + throw new InvalidOperationException($"Credential '{credential.DisplayName}' was reconnected or changed while it was being removed, so nothing was removed. Look at it again."); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs index a9504aca3..9e85c6b16 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs @@ -35,11 +35,12 @@ public sealed partial class CredentialService : ICredentialService, IScopedDepen private readonly ICredentialRevocationService _revocation; private readonly ICredentialHealthService _health; private readonly ICredentialReauthorizer _reauthorizer; + private readonly ICredentialRelinkService _relink; private readonly ITeamMemberService _members; private readonly TeamMembershipResolver _membership; private readonly ILogger _logger; - public CredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, IOAuthGrantRevoker grants, IProviderRegistry registry, IProviderModuleCatalog modules, IScopeChecker scopeChecker, ICredentialSuccessionService succession, ICredentialRevocationService revocation, ICredentialHealthService health, ICredentialReauthorizer reauthorizer, ITeamMemberService members, TeamMembershipResolver membership, ILogger logger) + public CredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, IOAuthGrantRevoker grants, IProviderRegistry registry, IProviderModuleCatalog modules, IScopeChecker scopeChecker, ICredentialSuccessionService succession, ICredentialRevocationService revocation, ICredentialHealthService health, ICredentialReauthorizer reauthorizer, ICredentialRelinkService relink, ITeamMemberService members, TeamMembershipResolver membership, ILogger logger) { _db = db; _currentTeam = currentTeam; @@ -54,6 +55,7 @@ public CredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurre _revocation = revocation; _health = health; _reauthorizer = reauthorizer; + _relink = relink; _members = members; _membership = membership; _logger = logger; @@ -105,13 +107,18 @@ private async Task> WithHealthAsync(List s.Id).ToList(); var left = await DepartedCredentials.Of(_db).AsNoTracking().Where(c => c.TeamId == teamId && ids.Contains(c.Id)).Select(c => c.Id).ToListAsync(cancellationToken).ConfigureAwait(false); var bound = await CountBoundRepositoriesAsync(teamId, ids, cancellationToken).ConfigureAwait(false); + var hooks = await CountInServiceConnectionWebhooksAsync(ids, cancellationToken).ConfigureAwait(false); + var identities = await CountLiveIdentitiesAsync(ids, cancellationToken).ConfigureAwait(false); var now = DateTimeOffset.UtcNow; return summaries.Select(s => s with { Health = CredentialHealthDerivation.Derive(s.Status, s.AuthType, s.ExpiresDate, left.Contains(s.Id), now), DaysToExpiry = CredentialHealthDerivation.DaysToExpiry(s.AuthType, s.ExpiresDate, now), - BoundRepositoryCount = bound.GetValueOrDefault(s.Id) + BoundRepositoryCount = bound.GetValueOrDefault(s.Id), + BoundConnectionWebhookCount = hooks.GetValueOrDefault(s.Id), + ActAsIdentityCount = identities.GetValueOrDefault(s.Id), + DependentCount = bound.GetValueOrDefault(s.Id) + hooks.GetValueOrDefault(s.Id) + identities.GetValueOrDefault(s.Id) }).ToList(); } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialService.cs b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialService.cs index 84c5ee1b3..938ff6fd3 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialService.cs @@ -40,5 +40,13 @@ public interface ICredentialService /// Task ReplaceTokenAsync(Guid credentialId, string token, CancellationToken cancellationToken); + /// + /// Take a disconnected credential with nothing leaning on it out of the team's list — soft-deleted, as a departed member's retired credential is. + /// Refused, writing nothing, for one still in service and for one with repositories, group hooks or act-as identities left on it + /// (CredentialRemoval), and for one a reconnect revived while this ran. A credential already removed, or of another team, is not found. + /// Returns the credential's id. Tracked changes only — the caller's unit of work commits them. + /// + Task RemoveDisconnectedAsync(Guid credentialId, CancellationToken cancellationToken); + Task ListAccessibleRepositoriesAsync(Guid credentialId, string? search, int page, int perPage, CancellationToken cancellationToken); } diff --git a/backend/src/CodeSpace.Messages/Commands/Credentials/RemoveDisconnectedCredentialCommand.cs b/backend/src/CodeSpace.Messages/Commands/Credentials/RemoveDisconnectedCredentialCommand.cs new file mode 100644 index 000000000..6755e5c77 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Commands/Credentials/RemoveDisconnectedCredentialCommand.cs @@ -0,0 +1,30 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Commands.Credentials; + +/// +/// Takes a disconnected credential out of the team's list when nothing leans on it any more: a Disconnect leaves the row behind +/// (Revoked, its token blanked), and a person who disconnects and reconnects a few times is left with a pile of them — dead rows +/// that carry no repository, no group hook and no act-as identity, and that the Providers dialog could only list. The row is +/// soft-deleted, exactly the shape a departed member's retired credential has, so it is no longer listed and cannot be revived. +/// +/// Refused, and nothing written, for anything that is not exactly that: a credential that is still connected (a Disconnect, which +/// says where its repositories go, is the way to end one), a disconnected credential that still has repositories bound through it, group +/// hooks registered through it or an act-as identity backed by it (they are moved to another credential first, by +/// RelinkCredentialBindingsCommand), and one that was reconnected while the removal was running. A credential that is already +/// removed, or belongs to another team, reads as not found. +/// +/// , the bar of every other write on one credential: its own person removes their own +/// Personal credential on membership alone; anyone else — a teammate's or a team-service credential — needs credentials.manage. +/// +public sealed record RemoveDisconnectedCredentialCommand : ICommand, IRequireCredentialManagement +{ + /// Set by the controller from the route segment (URL is authoritative). + public Guid CredentialId { get; init; } +} + +public sealed record RemoveDisconnectedCredentialResult +{ + public required Guid CredentialId { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialSummary.cs b/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialSummary.cs index 61eaa3ad0..0ba914f3b 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialSummary.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialSummary.cs @@ -45,4 +45,17 @@ public sealed record CredentialSummary /// How many live repositories are bound through the credential — what a person reconnecting, replacing or disconnecting it needs to know is at stake. Zero from a server that predates it. public int BoundRepositoryCount { get; init; } + + /// + /// How many things lean on the credential: the live repositories bound through it, the group hooks registered through it that are still in service, and the act-as identities it backs — the three a Disconnect leaves behind or a + /// removal would strand (GET /credentials/{id}/dependents lists them). A disconnected credential with none is history, which a client may + /// fold away and offer to remove (RemoveDisconnectedCredentialCommand). Null from a server that predates it, which is also the sign that it has no such removal. + /// + public int? DependentCount { get; init; } + + /// How many of are group hooks registered through the credential that are still in service — what a dead credential with no repository left on it is still held by, and what relink-bindings moves with the repositories. Zero from a server that predates it. + public int BoundConnectionWebhookCount { get; init; } + + /// How many of are live act-as identities the credential backs. relink-bindings does not move them: they end with the credential's own disconnect or are unlinked. Zero from a server that predates it. + public int ActAsIdentityCount { get; init; } } diff --git a/backend/tests/CodeSpace.E2ETests/Credentials/CredentialRemovalHttpE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Credentials/CredentialRemovalHttpE2ETests.cs new file mode 100644 index 000000000..ec7ffa1ad --- /dev/null +++ b/backend/tests/CodeSpace.E2ETests/Credentials/CredentialRemovalHttpE2ETests.cs @@ -0,0 +1,233 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.E2ETests.Infrastructure; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Credentials; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace CodeSpace.E2ETests.Credentials; + +/// +/// Clearing away the dead rows a Disconnect leaves behind, through the whole HTTP surface: POST /credentials/{id}/revoke makes the row, the credential +/// list says nothing leans on it (dependentCount), and DELETE /credentials/{id} removes it — or refuses, with a message the Providers dialog +/// shows as it is. +/// +/// Tier: 🟢 High-fidelity (Rule 12) — the real ASP.NET pipeline (routing, JWT auth, the X-Team-Id scope, the exception filter, the mediator and its +/// authorization behaviors) and real Postgres. A dead credential is made by the production Disconnect over HTTP, never by writing a Revoked row. One +/// documented seam, shared with : the provider is the Git test provider, so nothing real is contacted. +/// +[Trait("Category", "E2E")] +[Trait("Surface", "Http")] +public sealed class CredentialRemovalHttpE2ETests : IClassFixture +{ + private readonly CredentialBindingsHttpE2ETests.GitProviderApiFactory _factory; + private readonly TenantHarness _tenants; + + public CredentialRemovalHttpE2ETests(CredentialBindingsHttpE2ETests.GitProviderApiFactory factory) + { + _factory = factory; + _tenants = new TenantHarness(factory); + } + + [Fact] + public async Task A_member_removes_their_own_disconnected_credential_and_it_leaves_the_list() + { + var world = await SeedWorldAsync(); + var token = await SeedCredentialAsync(world, world.MemberId, "pat-dead"); + var keeper = await SeedCredentialAsync(world, world.MemberId, "pat-keeper"); + + (await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{token}/revoke", world.MemberId, world.TeamId)).IsSuccessStatusCode.ShouldBeTrue("fixture check: disconnected through the door"); + + var listed = await ListAsync(world, world.MemberId); + + listed[token].GetProperty("status").GetString().ShouldBe("Revoked"); + listed[token].GetProperty("dependentCount").GetInt32().ShouldBe(0, "the list says it is history"); + + var response = await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{token}", world.MemberId, world.TeamId); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response)); + (await ReadJsonAsync(response)).GetProperty("credentialId").GetGuid().ShouldBe(token); + + (await ListAsync(world, world.MemberId)).Keys.ToList().ShouldBe(new List { keeper }, "gone from the list; the other credential is untouched"); + (await LoadCredentialAsync(token)).DeletedDate.ShouldNotBeNull(); + } + + [Fact] + public async Task The_list_says_what_leans_on_each_credential_and_keeps_every_field_it_always_had() + { + var world = await SeedWorldAsync(); + var dead = await SeedCredentialAsync(world, world.MemberId, "pat-dead"); + var quiet = await SeedCredentialAsync(world, world.MemberId, "pat-quiet"); + var live = await SeedCredentialAsync(world, world.MemberId, "pat-live"); + + await SeedRepositoryAsync(world, dead); + await SeedRepositoryAsync(world, live); + + foreach (var id in new[] { dead, quiet }) (await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{id}/revoke", world.MemberId, world.TeamId)).IsSuccessStatusCode.ShouldBeTrue(); + + var listed = await ListAsync(world, world.MemberId); + + listed[quiet].GetProperty("dependentCount").GetInt32().ShouldBe(0); + listed[live].GetProperty("dependentCount").GetInt32().ShouldBe(1); + listed[dead].GetProperty("boundRepositoryCount").GetInt32().ShouldBe(listed[dead].GetProperty("dependentCount").GetInt32(), "with no hooks or identities, the dependents are the bound repositories"); + + foreach (var field in new[] { "boundConnectionWebhookCount", "actAsIdentityCount" }) + listed[dead].GetProperty(field).GetInt32().ShouldBe(0, $"'{field}' is sent for every row, and nothing but the repository leans on this one"); + + foreach (var field in new[] { "id", "teamId", "providerInstanceId", "ownerUserId", "ownership", "authType", "displayName", "status", "lastValidatedDate", "createdDate", "health", "boundRepositoryCount", "callerMayRepair" }) + listed[live].TryGetProperty(field, out _).ShouldBeTrue($"an older client reads '{field}', which must still be there"); + } + + [Fact] + public async Task A_credential_that_is_still_connected_is_a_400_that_says_to_disconnect_it_first() + { + var world = await SeedWorldAsync(); + var live = await SeedCredentialAsync(world, world.MemberId, "pat-live"); + + var response = await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{live}", world.MemberId, world.TeamId); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, await TenantHarness.DescribeAsync(response)); + (await ReadJsonAsync(response, requireSuccess: false)).GetProperty("message").GetString().ShouldContain("Disconnect it first"); + (await LoadCredentialAsync(live)).DeletedDate.ShouldBeNull(); + } + + [Fact] + public async Task A_disconnected_credential_with_a_repository_left_on_it_is_a_400_that_says_which_and_what_to_do() + { + var world = await SeedWorldAsync(); + var dead = await SeedCredentialAsync(world, world.MemberId, "pat-dead"); + var repoId = await SeedRepositoryAsync(world, dead); + + (await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{dead}/revoke", world.MemberId, world.TeamId)).IsSuccessStatusCode.ShouldBeTrue(); + + var response = await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{dead}", world.MemberId, world.TeamId); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, await TenantHarness.DescribeAsync(response)); + + var message = (await ReadJsonAsync(response, requireSuccess: false)).GetProperty("message").GetString(); + + message.ShouldContain("1 repository"); + message.ShouldContain("Move or unlink them first"); + (await LoadCredentialAsync(dead)).DeletedDate.ShouldBeNull(); + (await _tenants.DbAsync(db => db.Repository.AsNoTracking().SingleAsync(r => r.Id == repoId))).CredentialId.ShouldBe(dead, "the repository was not touched"); + } + + [Fact] + public async Task Only_whoever_manages_a_credential_removes_it_and_a_viewer_removes_only_their_own() + { + var world = await SeedWorldAsync(); + var viewer = await _tenants.AccountAsync(); + await _tenants.AddMemberAsync(world.TeamId, viewer, TeamRole.Viewer); + + var admins = await DisconnectedAsync(world, world.AdminId, "pat-admins"); + var members = await DisconnectedAsync(world, world.MemberId, "pat-members"); + var viewers = await DisconnectedAsync(world, viewer, "pat-viewers"); + + (await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{admins}", world.MemberId, world.TeamId)).StatusCode.ShouldBe(HttpStatusCode.Forbidden, "a Member does not remove an Admin's"); + (await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{members}", viewer, world.TeamId)).StatusCode.ShouldBe(HttpStatusCode.Forbidden, "a Viewer does not remove a Member's"); + (await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{viewers}", viewer, world.TeamId)).StatusCode.ShouldBe(HttpStatusCode.OK, "a Viewer removes their own: it takes membership alone, like disconnecting it did"); + (await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{members}", world.AdminId, world.TeamId)).StatusCode.ShouldBe(HttpStatusCode.OK, "an Admin removes a teammate's"); + + (await LoadCredentialAsync(admins)).DeletedDate.ShouldBeNull(); + } + + [Fact] + public async Task Another_teams_credential_is_refused_whichever_team_is_named_and_removing_twice_is_refused_the_second_time() + { + var world = await SeedWorldAsync(); + var outsider = await _tenants.AccountAsync(); + var outsiderTeam = await _tenants.TeamAsync((outsider, TeamRole.Owner)); + var dead = await DisconnectedAsync(world, world.MemberId, "pat-dead"); + + (await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{dead}", outsider, world.TeamId)).StatusCode.ShouldBe(HttpStatusCode.Forbidden, "not a member of the credential's team"); + (await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{dead}", outsider, outsiderTeam)).StatusCode.ShouldBe(HttpStatusCode.Forbidden, "named through their own team: the credential is not theirs"); + (await LoadCredentialAsync(dead)).DeletedDate.ShouldBeNull(); + + (await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{dead}", world.MemberId, world.TeamId)).StatusCode.ShouldBe(HttpStatusCode.OK); + (await _tenants.SendAsync(HttpMethod.Delete, $"/api/credentials/{dead}", world.MemberId, world.TeamId)).IsSuccessStatusCode.ShouldBeFalse("it is gone"); + } + + // ─── Plumbing ──────────────────────────────────────────────────────────────── + + private sealed record World(Guid TeamId, Guid OwnerId, Guid AdminId, Guid MemberId, Guid InstanceId); + + private async Task SeedWorldAsync() + { + var owner = await _tenants.AccountAsync(); + var admin = await _tenants.AccountAsync(); + var member = await _tenants.AccountAsync(); + var teamId = await _tenants.TeamAsync((owner, TeamRole.Owner), (admin, TeamRole.Admin), (member, TeamRole.Member)); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var suffix = Guid.NewGuid().ToString("N")[..10]; + var instance = new ProviderInstance { Id = Guid.NewGuid(), TeamId = teamId, Provider = ProviderKind.GitLab, DisplayName = "GitLab", BaseUrl = $"https://gitlab.removal.test/{suffix}", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }; + + db.ProviderInstance.Add(instance); + await db.SaveChangesAsync(); + + return new World(teamId, owner, admin, member, instance.Id); + } + + /// A live personal token of , written as a connect writes one (a person and an instance, sealed), then disconnected through the door. + private async Task DisconnectedAsync(World world, Guid ownerId, string token) + { + var id = await SeedCredentialAsync(world, ownerId, token); + + (await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{id}/revoke", ownerId, world.TeamId)).IsSuccessStatusCode.ShouldBeTrue("fixture check: disconnected through the door"); + + return id; + } + + private async Task SeedCredentialAsync(World world, Guid ownerId, string token) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var sealedToken = scope.ServiceProvider.GetRequiredService().Encrypt(scope.ServiceProvider.GetRequiredService().Serialize(new PatPayload { Token = token })); + + var credential = new Credential { Id = Guid.NewGuid(), TeamId = world.TeamId, ProviderInstanceId = world.InstanceId, OwnerUserId = ownerId, Ownership = CredentialOwnership.Personal, AuthType = AuthType.Pat, DisplayName = token, EncryptedPayload = sealedToken, Status = CredentialStatus.Active, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }; + + db.Credential.Add(credential); + await db.SaveChangesAsync(); + + return credential.Id; + } + + private async Task SeedRepositoryAsync(World world, Guid credentialId) + { + var suffix = Guid.NewGuid().ToString("N")[..10]; + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var repository = new Repository { Id = Guid.NewGuid(), TeamId = world.TeamId, ProviderInstanceId = world.InstanceId, CredentialId = credentialId, ExternalId = $"acme/ext-{suffix}", NamespacePath = "acme", Name = $"app-{suffix}", FullPath = $"acme/app-{suffix}", DefaultBranch = "main", Visibility = RepositoryVisibility.Private, WebUrl = "https://gitlab.removal.test", CloneUrlHttps = $"https://gitlab.removal.test/acme/app-{suffix}.git", Status = RepositoryStatus.Active, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }; + + db.Repository.Add(repository); + await db.SaveChangesAsync(); + + return repository.Id; + } + + /// The credential list as the Providers dialog reads it, by credential id. + private async Task> ListAsync(World world, Guid userId) + { + var body = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, "/api/credentials", userId, world.TeamId)); + + return body.EnumerateArray().ToDictionary(c => c.GetProperty("id").GetGuid()); + } + + private Task LoadCredentialAsync(Guid id) => _tenants.DbAsync(db => db.Credential.AsNoTracking().SingleAsync(c => c.Id == id)); + + private static async Task ReadJsonAsync(HttpResponseMessage response, bool requireSuccess = true) + { + if (requireSuccess) response.IsSuccessStatusCode.ShouldBeTrue(await TenantHarness.DescribeAsync(response)); + + return (await response.Content.ReadFromJsonAsync())!; + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Credentials/RemoveDisconnectedCredentialFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Credentials/RemoveDisconnectedCredentialFlowTests.cs new file mode 100644 index 000000000..22ef4f792 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Credentials/RemoveDisconnectedCredentialFlowTests.cs @@ -0,0 +1,420 @@ +using Autofac; +using CodeSpace.Core.Authorization; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.OAuth; +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Credentials; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Queries.Credentials; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Credentials; + +/// +/// Clearing away the dead rows a Disconnect leaves behind, through the real mediator pipeline: a person who disconnects and +/// reconnects a few times is left with a pile of Revoked credentials, each with its token blanked and nothing bound through it, +/// that the Providers dialog could only list. Removing one soft-deletes it — the shape a departed member's retired credential has — +/// and is refused, with nothing written, for anything else: a credential still in service, one something still leans on, one +/// a reconnect revived while the removal was running. +/// +/// Real Postgres; the production pipeline and services. A dead credential is made the way a person makes one — connected +/// through the door, then disconnected through the door — and what leans on it is seeded as a connect or a bind would write it. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class RemoveDisconnectedCredentialFlowTests +{ + private readonly BindingMoveKit _kit; + + public RemoveDisconnectedCredentialFlowTests(PostgresFixture fixture) { _kit = new BindingMoveKit(fixture); } + + public enum Door { MemberToken, AdminOAuth } + + [Theory] + [InlineData(Door.MemberToken)] + [InlineData(Door.AdminOAuth)] + public async Task A_disconnected_credential_with_nothing_on_it_is_removed_and_leaves_the_list(Door door) + { + var world = await _kit.SeedWorldAsync(); + var person = door == Door.MemberToken ? world.MemberId : world.AdminId; + var dead = door == Door.MemberToken ? await DisconnectedTokenAsync(world, person) : await DisconnectedOAuthAsync(world, person); + + (await ListAsync(world, person)).Single(c => c.Id == dead).DependentCount.ShouldBe(0, "fixture check: a Disconnect left nothing leaning on it, so the dialog folds this row away"); + + var result = await RemoveAsync(world, person, dead); + + result.CredentialId.ShouldBe(dead); + + var row = await _kit.LoadCredentialAsync(dead); + + row.DeletedDate.ShouldNotBeNull("soft-deleted, as a departed member's retired credential is"); + row.Status.ShouldBe(CredentialStatus.Revoked); + row.EncryptedPayload.ShouldBe(string.Empty, "there is no token to keep"); + row.LastModifiedBy.ShouldBe(person, "the row says who removed it"); + row.LastModifiedDate.ShouldBeGreaterThanOrEqualTo(row.DeletedDate!.Value.AddSeconds(-1)); + (await ListAsync(world, person)).ShouldNotContain(c => c.Id == dead); + } + + [Fact] + public async Task Removing_one_credential_leaves_the_others_as_they_were() + { + var world = await _kit.SeedWorldAsync(); + var dead = await DisconnectedTokenAsync(world, world.MemberId); + var alsoDead = await DisconnectedTokenAsync(world, world.MemberId); + var live = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-live"); + + await RemoveAsync(world, world.MemberId, dead); + + (await _kit.LoadCredentialAsync(alsoDead)).DeletedDate.ShouldBeNull(); + (await _kit.LoadCredentialAsync(live)).DeletedDate.ShouldBeNull(); + (await ListAsync(world, world.MemberId)).Select(c => c.Id).ShouldBe(new[] { alsoDead, live }, ignoreOrder: true); + } + + [Theory] + [InlineData(CredentialStatus.Active)] + [InlineData(CredentialStatus.Error)] + public async Task A_credential_still_in_service_is_refused_and_not_touched(CredentialStatus status) + { + var world = await _kit.SeedWorldAsync(); + var inService = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-in-service", status); + + var refusal = await Should.ThrowAsync(() => RemoveAsync(world, world.MemberId, inService)); + + refusal.Message.ShouldContain("still connected"); + + var row = await _kit.LoadCredentialAsync(inService); + + row.DeletedDate.ShouldBeNull(); + row.Status.ShouldBe(status); + row.EncryptedPayload.ShouldNotBeEmpty("a live token is never blanked by a removal"); + } + + public enum Lean { Repository, ConnectionWebhook, Identity } + + [Theory] + [InlineData(Lean.Repository, "1 repository")] + [InlineData(Lean.ConnectionWebhook, "1 group hook")] + [InlineData(Lean.Identity, "1 act-as identity")] + public async Task A_disconnected_credential_something_still_leans_on_is_refused_names_it_and_is_not_touched(Lean lean, string said) + { + var world = await _kit.SeedWorldAsync(); + var dead = await DisconnectedTokenAsync(world, world.MemberId); + + await LeanOnAsync(world, dead, lean); + + var refusal = await Should.ThrowAsync(() => RemoveAsync(world, world.MemberId, dead)); + + refusal.Message.ShouldContain(said); + refusal.Message.ShouldContain("Move or unlink them first"); + (await _kit.LoadCredentialAsync(dead)).DeletedDate.ShouldBeNull($"{lean}: nothing was removed"); + (await ListAsync(world, world.MemberId)).Single(c => c.Id == dead).DependentCount.ShouldBe(1, $"{lean}: the list would not have folded it away either"); + } + + [Fact] + public async Task What_is_already_gone_does_not_hold_a_removal_back() + { + var world = await _kit.SeedWorldAsync(); + var dead = await DisconnectedTokenAsync(world, world.MemberId); + var removedRepository = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var cancelledHook = await _kit.SeedConnectionWebhookAsync(world, dead, RepositoryWebhookRegistrationStatus.Cancelled); + var endedIdentity = await SeedIdentityAsync(world, world.MemberId, dead); + + await SoftDeleteRepositoryAsync(removedRepository); + await SoftDeleteIdentityAsync(endedIdentity); + + (await ListAsync(world, world.MemberId)).Single(c => c.Id == dead).DependentCount.ShouldBe(0, $"fixture check: a removed repository, the cancelled hook {cancelledHook} and an ended identity are not dependents"); + + await RemoveAsync(world, world.MemberId, dead); + + (await _kit.LoadCredentialAsync(dead)).DeletedDate.ShouldNotBeNull(); + } + + public enum Who { Member, Viewer, Admin, Owner } + + public enum Whose { OwnPersonal, MembersPersonal, AdminsPersonal, TeamToken } + + [Theory] + [InlineData(Who.Member, Whose.OwnPersonal, true)] + [InlineData(Who.Viewer, Whose.OwnPersonal, true)] + [InlineData(Who.Admin, Whose.OwnPersonal, true)] + [InlineData(Who.Admin, Whose.MembersPersonal, true)] + [InlineData(Who.Owner, Whose.MembersPersonal, true)] + [InlineData(Who.Admin, Whose.TeamToken, true)] + [InlineData(Who.Owner, Whose.TeamToken, true)] + [InlineData(Who.Member, Whose.AdminsPersonal, false)] + [InlineData(Who.Member, Whose.TeamToken, false)] + [InlineData(Who.Viewer, Whose.MembersPersonal, false)] + [InlineData(Who.Viewer, Whose.TeamToken, false)] + public async Task Whoever_may_manage_a_credential_may_remove_it_and_nobody_else(Who who, Whose whose, bool allowed) + { + var world = await _kit.SeedWorldAsync(); + var asker = IdOf(world, who); + var dead = whose switch + { + Whose.OwnPersonal => await DisconnectedTokenAsync(world, asker), + Whose.MembersPersonal => await DisconnectedTokenAsync(world, world.MemberId), + Whose.AdminsPersonal => await DisconnectedTokenAsync(world, world.AdminId), + _ => await DisconnectedTeamTokenAsync(world) + }; + + var outcome = await Record.ExceptionAsync(() => RemoveAsync(world, asker, dead)); + + if (allowed) + { + outcome.ShouldBeNull($"{who} removing {whose}"); + (await _kit.LoadCredentialAsync(dead)).DeletedDate.ShouldNotBeNull(); + return; + } + + outcome.ShouldBeOfType($"{who} removing {whose}"); + (await _kit.LoadCredentialAsync(dead)).DeletedDate.ShouldBeNull(); + } + + [Fact] + public async Task A_credential_of_another_team_is_not_the_callers_to_remove() + { + var world = await _kit.SeedWorldAsync(); + var elsewhere = await _kit.SeedWorldAsync(); + var foreign = await DisconnectedTokenAsync(elsewhere, elsewhere.MemberId); + + var outcome = await Record.ExceptionAsync(() => RemoveAsync(world, world.OwnerId, foreign)); + + outcome.ShouldBeOfType("an Owner of this team, naming a credential of another"); + (await _kit.LoadCredentialAsync(foreign)).DeletedDate.ShouldBeNull(); + } + + [Fact] + public async Task Removing_again_is_refused_because_the_credential_is_gone() + { + var world = await _kit.SeedWorldAsync(); + var dead = await DisconnectedTokenAsync(world, world.MemberId); + + await RemoveAsync(world, world.MemberId, dead); + + var outcome = await Record.ExceptionAsync(() => RemoveAsync(world, world.MemberId, dead)); + + outcome.ShouldBeOfType(); + } + + [Fact] + public async Task A_reconnect_that_lands_between_the_checks_and_the_write_keeps_the_credential_and_is_never_overwritten() + { + var world = await _kit.SeedWorldAsync(); + var dead = await DisconnectedTokenAsync(world, world.MemberId); + var reconnect = new ReconnectOnce(_kit, world, dead); + + var refusal = await Should.ThrowAsync(() => RemoveAsync(world, world.MemberId, dead, reconnect.Install)); + + reconnect.Landed.ShouldBeTrue("fixture check: the reconnect landed after the removal had read the row and its dependents"); + refusal.Message.ShouldContain("reconnected"); + + var row = await _kit.LoadCredentialAsync(dead); + + row.DeletedDate.ShouldBeNull("the person had just reconnected it; removing it under them would lose the authorization they were told succeeded"); + row.Status.ShouldBe(CredentialStatus.Active); + row.EncryptedPayload.ShouldNotBeEmpty(); + } + + [Fact] + public async Task The_lists_dependent_count_is_what_the_dependents_read_finds() + { + var world = await _kit.SeedWorldAsync(); + var busy = await DisconnectedTokenAsync(world, world.MemberId); + var quiet = await DisconnectedTokenAsync(world, world.MemberId); + var live = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-live"); + + await _kit.SeedRepositoryAsync(world, busy, RepositoryStatus.Error); + await _kit.SeedRepositoryAsync(world, busy, RepositoryStatus.Error); + await _kit.SeedConnectionWebhookAsync(world, busy, RepositoryWebhookRegistrationStatus.DeadLettered); + await _kit.SeedConnectionWebhookAsync(world, busy, RepositoryWebhookRegistrationStatus.Cancelled); + await SeedIdentityAsync(world, world.MemberId, busy); + await SoftDeleteIdentityAsync(await SeedIdentityAsync(world, world.AdminId, busy)); + await SoftDeleteRepositoryAsync(await _kit.SeedRepositoryAsync(world, busy, RepositoryStatus.Error)); + await _kit.SeedRepositoryAsync(world, live); + + var listed = (await ListAsync(world, world.MemberId)).ToDictionary(c => c.Id); + + foreach (var id in new[] { busy, quiet, live }) + { + var dependents = await DependentsAsync(world, world.MemberId, id); + + listed[id].DependentCount.ShouldBe(dependents.Repositories.Count + dependents.ConnectionWebhooks.Count + dependents.Identities.Count, $"{id}: one rule for what leans on a credential, whichever read asks"); + listed[id].BoundRepositoryCount.ShouldBe(dependents.Repositories.Count, $"{id}: the per-kind counts are the dependents read's own lists"); + listed[id].BoundConnectionWebhookCount.ShouldBe(dependents.ConnectionWebhooks.Count, $"{id}: a dead row held only by a group hook says so, so the dialog can name what keeps it listed and move it"); + listed[id].ActAsIdentityCount.ShouldBe(dependents.Identities.Count, $"{id}: an act-as identity is named too"); + } + + listed[busy].DependentCount.ShouldBe(4, "two live repositories, one in-service hook and one live identity — not the removed repository, the cancelled hook or the ended identity"); + (listed[busy].BoundRepositoryCount, listed[busy].BoundConnectionWebhookCount, listed[busy].ActAsIdentityCount).ShouldBe((2, 1, 1), "by kind: two repositories, the dead-lettered hook and the live identity"); + listed[quiet].DependentCount.ShouldBe(0); + listed[live].DependentCount.ShouldBe(1, "a live credential's repository leans on it too: the count is not only for dead rows"); + } + + // ── Doors ───────────────────────────────────────────────────────────────────── + + private async Task RemoveAsync(BindingMoveKit.World world, Guid userId, Guid credentialId, Action? configure = null) + { + using var scope = _kit.Scope(world, userId, configure); + + return await scope.Resolve().Send(new RemoveDisconnectedCredentialCommand { CredentialId = credentialId }); + } + + private async Task> ListAsync(BindingMoveKit.World world, Guid userId) + { + using var scope = _kit.Scope(world, userId); + + return await scope.Resolve().Send(new ListCredentialsQuery()); + } + + private async Task DependentsAsync(BindingMoveKit.World world, Guid userId, Guid credentialId) + { + using var scope = _kit.Scope(world, userId); + + return await scope.Resolve().Send(new GetCredentialDependentsQuery { CredentialId = credentialId }); + } + + /// "Use a token", then "Disconnect" — the person's own credential, dead the way the Disconnect leaves it. + private async Task DisconnectedTokenAsync(BindingMoveKit.World world, Guid userId) + { + var token = await _kit.LinkTokenAsync(world, userId, $"glpat-{Guid.NewGuid():N}"); + + await _kit.DisconnectAsync(world, userId, token); + + return token; + } + + /// "Connect with OAuth", then "Disconnect" — through the provider's callback, as a person connects. + private async Task DisconnectedOAuthAsync(BindingMoveKit.World world, Guid userId) + { + var oauth = await _kit.ConnectOAuthAsync(world, userId, $"at-{Guid.NewGuid():N}"); + + await _kit.DisconnectAsync(world, userId, oauth); + + return oauth; + } + + /// "Add team token" (owner-less, team-owned), disconnected by an Admin. + private async Task DisconnectedTeamTokenAsync(BindingMoveKit.World world) + { + var token = await _kit.AddTokenAsync(world, world.AdminId, $"glpat-{Guid.NewGuid():N}", ownership: CredentialOwnership.TeamService); + + await _kit.DisconnectAsync(world, world.AdminId, token); + + return token; + } + + private static Guid IdOf(BindingMoveKit.World world, Who who) => who switch + { + Who.Member => world.MemberId, + Who.Viewer => world.ViewerId, + Who.Admin => world.AdminId, + _ => world.OwnerId + }; + + // ── Seeds ───────────────────────────────────────────────────────────────────── + + private async Task LeanOnAsync(BindingMoveKit.World world, Guid credentialId, Lean lean) + { + switch (lean) + { + case Lean.Repository: await _kit.SeedRepositoryAsync(world, credentialId, RepositoryStatus.Error); break; + case Lean.ConnectionWebhook: await _kit.SeedConnectionWebhookAsync(world, credentialId, RepositoryWebhookRegistrationStatus.Registered); break; + default: await SeedIdentityAsync(world, world.MemberId, credentialId); break; + } + } + + private async Task SeedIdentityAsync(BindingMoveKit.World world, Guid userId, Guid credentialId) + { + var identity = new UserProviderIdentity { Id = Guid.NewGuid(), UserId = userId, ProviderInstanceId = world.InstanceId, CredentialId = credentialId, ProviderUserId = $"gl-{Guid.NewGuid():N}"[..10], ProviderUsername = $"gl-user-{Guid.NewGuid():N}"[..14] }; + + using var scope = _kit.Fixture.BeginScope(); + var db = scope.Resolve(); + + db.UserProviderIdentity.Add(identity); + await db.SaveChangesAsync(); + + return identity.Id; + } + + private async Task SoftDeleteRepositoryAsync(Guid id) + { + using var scope = _kit.Fixture.BeginScope(); + + await scope.Resolve().Repository.Where(r => r.Id == id).ExecuteUpdateAsync(s => s.SetProperty(r => r.DeletedDate, DateTimeOffset.UtcNow)); + } + + private async Task SoftDeleteIdentityAsync(Guid id) + { + using var scope = _kit.Fixture.BeginScope(); + + await scope.Resolve().UserProviderIdentity.Where(i => i.Id == id).ExecuteUpdateAsync(s => s.SetProperty(i => i.DeletedDate, DateTimeOffset.UtcNow)); + } + + /// + /// A person reconnecting the credential in place — the production re-authorization write, on its own connection — at the + /// moment the removal has finished reading the credential and what leans on it and has not yet written: the window a check-then-write + /// would lose. Installed as a decorator of the dependents read, which is the last thing the removal asks before it writes. + /// + private sealed class ReconnectOnce + { + private readonly BindingMoveKit _kit; + private readonly BindingMoveKit.World _world; + private readonly Guid _credentialId; + + public ReconnectOnce(BindingMoveKit kit, BindingMoveKit.World world, Guid credentialId) + { + _kit = kit; + _world = world; + _credentialId = credentialId; + } + + public bool Landed { get; private set; } + + public void Install(ContainerBuilder builder) + { + builder.RegisterInstance(this).AsSelf().SingleInstance(); + builder.RegisterDecorator(); + } + + public async Task LandAsync() + { + using var scope = _kit.Fixture.BeginScope(); + var sealedToken = scope.Resolve().Encrypt(scope.Resolve().Serialize(new PatPayload { Token = "glpat-reconnected" })); + + Landed = await scope.Resolve().TryReplaceAsync(new CredentialReplacement { CredentialId = _credentialId, ExpectedEncryptedPayload = string.Empty, EncryptedPayload = sealedToken, AuthType = AuthType.Pat, ModifiedBy = _world.MemberId }, CancellationToken.None); + } + } + + private sealed class ReconnectingDependentsRead : ICredentialRelinkService + { + private readonly ICredentialRelinkService _inner; + private readonly ReconnectOnce _reconnect; + + public ReconnectingDependentsRead(ICredentialRelinkService inner, ReconnectOnce reconnect) + { + _inner = inner; + _reconnect = reconnect; + } + + public async Task GetDependentsAsync(Guid credentialId, CancellationToken cancellationToken) + { + var dependents = await _inner.GetDependentsAsync(credentialId, cancellationToken); + + await _reconnect.LandAsync(); + + return dependents; + } + + public Task> GetSuggestionsAsync(Guid credentialId, CancellationToken cancellationToken) => _inner.GetSuggestionsAsync(credentialId, cancellationToken); + + public Task> GetCandidatesAsync(Guid providerInstanceId, Guid? leavingCredentialId, CancellationToken cancellationToken) => _inner.GetCandidatesAsync(providerInstanceId, leavingCredentialId, cancellationToken); + + public Task RelinkAllAsync(Guid fromCredentialId, Guid toCredentialId, CancellationToken cancellationToken) => _inner.RelinkAllAsync(fromCredentialId, toCredentialId, cancellationToken); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Credentials/CredentialRemovalTests.cs b/backend/tests/CodeSpace.UnitTests/Credentials/CredentialRemovalTests.cs new file mode 100644 index 000000000..a563a2b58 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Credentials/CredentialRemovalTests.cs @@ -0,0 +1,74 @@ +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using Shouldly; + +namespace CodeSpace.UnitTests.Credentials; + +/// +/// When a disconnected credential may be taken out of the team's list: it is disconnected, and nothing leans on it. Pure, so every +/// shape the Providers dialog could send a removal for is pinned without a database — and the words the refusal gives the person. +/// +[Trait("Category", "Unit")] +public class CredentialRemovalTests +{ + [Fact] + public void A_disconnected_credential_with_nothing_on_it_may_be_removed() + { + CredentialRemoval.Refusal(Credential(CredentialStatus.Revoked), Leaning()).ShouldBeNull(); + } + + [Theory] + [InlineData(CredentialStatus.Active)] + [InlineData(CredentialStatus.Error)] + [InlineData(CredentialStatus.Expired)] + public void A_credential_that_is_still_in_service_is_never_removed(CredentialStatus status) + { + var refusal = CredentialRemoval.Refusal(Credential(status), Leaning()); + + refusal.ShouldNotBeNull(); + refusal.ShouldContain("still connected", Case.Sensitive); + refusal.ShouldContain("Disconnect it first", Case.Sensitive); + } + + [Theory] + [InlineData(1, 0, 0, "1 repository")] + [InlineData(3, 0, 0, "3 repositories")] + [InlineData(0, 1, 0, "1 group hook")] + [InlineData(0, 2, 0, "2 group hooks")] + [InlineData(0, 0, 1, "1 act-as identity")] + [InlineData(0, 0, 2, "2 act-as identities")] + [InlineData(2, 1, 1, "2 repositories, 1 group hook and 1 act-as identity")] + [InlineData(1, 0, 4, "1 repository and 4 act-as identities")] + public void A_disconnected_credential_something_leans_on_is_refused_and_says_what(int repositories, int hooks, int identities, string expected) + { + var refusal = CredentialRemoval.Refusal(Credential(CredentialStatus.Revoked), Leaning(repositories, hooks, identities)); + + refusal.ShouldNotBeNull(); + refusal.ShouldContain(expected, Case.Sensitive); + refusal.ShouldContain("Move or unlink them first", Case.Sensitive); + } + + [Fact] + public void The_refusal_names_the_credential() + { + CredentialRemoval.Refusal(Credential(CredentialStatus.Active, "Maya's GitLab"), Leaning()).ShouldContain("'Maya's GitLab'", Case.Sensitive); + CredentialRemoval.Refusal(Credential(CredentialStatus.Revoked, "Maya's GitLab"), Leaning(1, 0, 0)).ShouldContain("'Maya's GitLab'", Case.Sensitive); + } + + private static Credential Credential(CredentialStatus status, string name = "Maya's GitLab") => new() { Id = Guid.NewGuid(), TeamId = Guid.NewGuid(), ProviderInstanceId = Guid.NewGuid(), DisplayName = name, Status = status }; + + private static CredentialDependents Leaning(int repositories = 0, int hooks = 0, int identities = 0) => new() + { + CredentialId = Guid.NewGuid(), + DisplayName = "irrelevant", + Status = CredentialStatus.Revoked, + Retired = false, + ProviderInstanceId = Guid.NewGuid(), + Repositories = Enumerable.Range(0, repositories).Select(i => new DependentRepository { Id = Guid.NewGuid(), FullPath = $"acme/r{i}", Status = RepositoryStatus.Error, Projects = new List() }).ToList(), + ConnectionWebhooks = Enumerable.Range(0, hooks).Select(i => new DependentConnectionWebhook { Id = Guid.NewGuid(), OwnerPath = $"acme/g{i}", RegistrationStatus = RepositoryWebhookRegistrationStatus.Registered }).ToList(), + Identities = Enumerable.Range(0, identities).Select(i => new DependentIdentity { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), ProviderUsername = $"u{i}" }).ToList(), + Activations = new List() + }; +} diff --git a/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx b/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx index 9feaf668c..9bc722099 100644 --- a/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx +++ b/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx @@ -6,11 +6,14 @@ import type { CredentialSummary, ProviderInstanceSummary, ProviderKind } from "@ import { useAlert, useConfirm } from "@/components/dialog"; import { IdentityLinkModal } from "@/components/identities/IdentityLinkModal"; import { ReplaceTokenModal } from "@/components/identities/ReplaceTokenModal"; +import { CredentialRow } from "@/components/providers/CredentialRow"; +import { AuthorizedSince, CredentialDependents, CredentialHealthChip, NoticeLine, type Notice } from "@/components/providers/credentialParts"; +import { EarlierConnections } from "@/components/providers/EarlierConnections"; import { useAddGroupAccessToken, useAddProviderInstance, useCredentialCapabilities, useCredentials, useDeleteProviderInstance, useProviderDefaults, useProviderInstances, useRevokeCredential, useUpdateProviderInstance } from "@/hooks/use-credentials"; import { useMe } from "@/hooks/use-me"; import { TeamPermissions, useTeamPermissions } from "@/hooks/use-team-management"; import { describeOAuthFlowError, OAuthFlowError, useOAuthFlow } from "@/hooks/use-oauth-flow"; -import { credentialHealthWord, isLiveCredential, isStaticToken } from "@/lib/credentialHealth"; +import { credentialHealthWord, isHistory, isLiveCredential } from "@/lib/credentialHealth"; import { providerEndpointMoves, providerSecretMustBeReentered, showsMaskedCredentials } from "@/lib/credentialEndpoint"; import { providerSupportsTeamServiceCredential } from "@/lib/teamCredentials"; @@ -80,50 +83,12 @@ function capabilityLabel(key: string): string { ?? key.replace(/^I/, "").replace(/Capability$/, "").replace(/([a-z])([A-Z])/g, "$1 $2"); } -/** - * What a credential's health says to the person — every state, so no credential reads as healthy by saying nothing: working, - * a token about to expire (and how long it has), one the provider refused (how to come back), one disconnected, one lent by - * someone who left the team. The words come from `credentialHealthWord`, which folds `status` in for a server that sends no `health`. - */ -function CredentialHealthChip({ credential }: { credential: CredentialSummary }) { - const word = credentialHealthWord(credential); - - if (word === "rejected") { - return ( - - {isStaticToken(credential) ? "needs a new token" : "needs reconnect"} - - ); - } - - if (word === "disconnected") return disconnected; - if (word === "owner_left") return owner left; - - if (word === "expiring") { - const days = credential.daysToExpiry ?? 0; - - return ( - - {days === 0 ? "expires today" : `expires in ${days} ${days === 1 ? "day" : "days"}`} - - ); - } - - return active; -} - -/** How many repositories still run through a credential — what reconnecting, replacing or disconnecting it puts at stake. Nothing when none do. */ -function BoundRepositories({ credential }: { credential: CredentialSummary }) { - const count = credential.boundRepositoryCount ?? 0; - - return count > 0 ? {count} {count === 1 ? "repository" : "repositories"} bound : null; -} - /** * Every credential is listed, whatever shape it is in — a refused one and a disconnected one are exactly the ones a person came * to repair, and hiding them left a dead credential invisible with its repositories still bound to it. (A retired credential is * not in the API's list at all; what leans on it is reached through its repositories.) Ones that need a person's attention come - * first, so the list opens on what is broken. + * first, so the list opens on what is broken. The one exception is a disconnected credential nothing leans on any more: history, + * folded into a single line (`EarlierConnections`) so the list is not a pile of dead rows. */ function needsAttentionFirst(credentials: CredentialSummary[]): CredentialSummary[] { return [...credentials].sort((a, b) => Number(isLiveCredential(a)) - Number(isLiveCredential(b)) || b.createdDate.localeCompare(a.createdDate)); @@ -140,7 +105,10 @@ export function ConnectRemoteModal({ onClose, editInstanceId, suggestedBaseUrl } const [teamTokenInstance, setTeamTokenInstance] = useState(null); const [providerTab, setProviderTab] = useState("personal"); const [connectingId, setConnectingId] = useState(null); - const [errorByInstance, setErrorByInstance] = useState>({}); + // What the last action said, keyed by what it was for: a credential's id for a reconnect, a replaced token or a refused move, a provider's id for a plain + // connect that failed and for a move that emptied the row it was pressed on. + const [notices, setNotices] = useState>({}); + const setNotice = (key: string, notice: Notice | undefined) => setNotices(prev => ({ ...prev, [key]: notice })); const me = useMe(); const credentials = useCredentials(); @@ -206,19 +174,24 @@ export function ConnectRemoteModal({ onClose, editInstanceId, suggestedBaseUrl } // repositories bound through it keep working. Without it this is a plain connect, which creates a new credential. const connect = async (instance: ProviderInstanceSummary, reauthorizing?: CredentialSummary) => { setConnectingId(instance.id); - setErrorByInstance(prev => ({ ...prev, [instance.id]: "" })); + setNotice(instance.id, undefined); + + if (reauthorizing) setNotice(reauthorizing.id, undefined); try { const displayName = reauthorizing?.displayName ?? `${me.data?.name ?? "user"}'s ${instance.displayName}`; await runOAuth({ providerInstanceId: instance.id, displayName, replaceCredentialId: reauthorizing?.id }); - // Hook invalidates ['credentials']; row re-renders with "Connected" automatically. + // Hook invalidates ['credentials']; row re-renders with "Connected" automatically. A re-authorization changes nothing a person can see by itself + // (the same row, the same id), so it says it landed: this is what "Reconnect does nothing" was. + if (reauthorizing) setNotice(reauthorizing.id, { tone: "ok", text: `Reconnected — ${PROVIDER_THEME[instance.provider].label} authorized ${reauthorizing.displayName} just now` }); } catch (err) { const message = err instanceof OAuthFlowError ? describeOAuthFlowError(err) : err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Authorization failed."; - setErrorByInstance(prev => ({ ...prev, [instance.id]: message })); + // A refused re-authorization is said on the credential it was for, where the person pressed it; a failed new connect has no row, so it is said on the provider. + setNotice(reauthorizing?.id ?? instance.id, { tone: "err", text: message }); } finally { setConnectingId(null); } @@ -257,6 +230,8 @@ export function ConnectRemoteModal({ onClose, editInstanceId, suggestedBaseUrl } destructive: true, }); if (!ok) return; + // What the row said about an earlier action (a "Reconnected" confirmation) is not true of it once it is disconnected. + setNotice(credential.id, undefined); revoke.mutate(credential.id); }; @@ -276,9 +251,16 @@ export function ConnectRemoteModal({ onClose, editInstanceId, suggestedBaseUrl } const ok = await confirm({ title: "Revoke this team credential?", message: usageMsg, confirmLabel: "Revoke", destructive: true }); if (!ok) return; + setNotice(credential.id, undefined); revoke.mutate(credential.id); }; + // Opening Replace token for a credential starts a new action on it: the confirmation of the last one no longer describes what is about to happen. + const replaceToken = (credential: CredentialSummary, instance: ProviderInstanceSummary) => { + setNotice(credential.id, undefined); + setReplacing({ credential, instance }); + }; + return createPortal( <> {/* Backdrop is non-interactive — clicking outside the modal must not close it @@ -297,10 +279,11 @@ export function ConnectRemoteModal({ onClose, editInstanceId, suggestedBaseUrl } onTabChange={setProviderTab} connectingId={connectingId} revokingId={revoke.isPending ? revoke.variables ?? null : null} - errors={errorByInstance} + notices={notices} + onNotice={setNotice} onConnect={connect} onReconnect={connect} - onReplaceToken={(credential, instance) => setReplacing({ credential, instance })} + onReplaceToken={replaceToken} onConnectPat={connectPat} onDisconnect={disconnect} onAddProvider={() => setStep("addProvider")} @@ -351,6 +334,7 @@ export function ConnectRemoteModal({ onClose, editInstanceId, suggestedBaseUrl } credential={replacing.credential} providerLabel={`${PROVIDER_THEME[replacing.instance.provider].label} · ${replacing.instance.displayName}`} onClose={() => setReplacing(null)} + onReplaced={() => setNotice(replacing.credential.id, { tone: "ok", text: `Token replaced — ${PROVIDER_THEME[replacing.instance.provider].label} authorized ${replacing.credential.displayName} just now` })} /> )} , @@ -370,7 +354,8 @@ interface ProvidersStepProps { onTabChange: (tab: ProviderTab) => void; connectingId: string | null; revokingId: string | null; - errors: Record; + notices: Record; + onNotice: (key: string, notice: Notice | undefined) => void; onConnect: (instance: ProviderInstanceSummary) => void; onReconnect: (instance: ProviderInstanceSummary, credential: CredentialSummary) => void; onReplaceToken: (credential: CredentialSummary, instance: ProviderInstanceSummary) => void; @@ -383,7 +368,7 @@ interface ProvidersStepProps { onClose: () => void; } -function ProvidersStep({ providers, loading, error, myCredsByInstance, teamServiceCreds, tab, onTabChange, connectingId, revokingId, errors, onConnect, onReconnect, onReplaceToken, onConnectPat, onDisconnect, onAddProvider, onEditProvider, onAddTeamToken, onRevokeTeamCred, onClose }: ProvidersStepProps) { +function ProvidersStep({ providers, loading, error, myCredsByInstance, teamServiceCreds, tab, onTabChange, connectingId, revokingId, notices, onNotice, onConnect, onReconnect, onReplaceToken, onConnectPat, onDisconnect, onAddProvider, onEditProvider, onAddTeamToken, onRevokeTeamCred, onClose }: ProvidersStepProps) { // Absent rather than present-and-refusing. The form this opens asks for an OAuth client secret // before it can be submitted, so a member who cannot add a provider would type one into a request // that was never going to land. Asked as a permission — the server's matrix stays the only copy of @@ -395,6 +380,8 @@ function ProvidersStep({ providers, loading, error, myCredsByInstance, teamServi // caller's own credentials, "Use a token", which links a personal token, and Reconnect, which re-authorizes one of those // same credentials in place: all stay theirs on membership alone (the server's init passes a credential's own person). const mayManageCredentials = can(TeamPermissions.CredentialsManage); + // Moving every repository off a dead credential in one action is the relink-bindings door, which holds both bars (the command's and the service's). + const mayMoveBindings = can(TeamPermissions.ReposManage) && mayManageCredentials; return ( <>
@@ -450,7 +437,9 @@ function ProvidersStep({ providers, loading, error, myCredsByInstance, teamServi myCreds={myCredsByInstance.get(instance.id) ?? []} isConnecting={connectingId === instance.id} revokingId={revokingId} - errorMsg={errors[instance.id]} + notices={notices} + mayMove={mayMoveBindings} + onNotice={onNotice} onConnect={mayManageCredentials ? onConnect : null} onReconnect={onReconnect} onReplaceToken={onReplaceToken} @@ -473,6 +462,9 @@ function ProvidersStep({ providers, loading, error, myCredsByInstance, teamServi theme={PROVIDER_THEME[instance.provider]} creds={teamServiceCreds.filter(c => c.providerInstanceId === instance.id)} revokingId={revokingId} + notices={notices} + onNotice={onNotice} + mayRemove={mayManageCredentials} onAdd={mayManageCredentials ? onAddTeamToken : null} onRevoke={mayManageCredentials ? onRevokeTeamCred : null} onReplaceToken={mayManageCredentials ? onReplaceToken : null} @@ -504,7 +496,10 @@ interface ProviderRowProps { myCreds: CredentialSummary[]; isConnecting: boolean; revokingId: string | null; - errorMsg: string | undefined; + notices: Record; + /** Whether the caller may move a dead credential's repositories in one action — see CredentialRow. */ + mayMove: boolean; + onNotice: (key: string, notice: Notice | undefined) => void; /** Null when the caller may not start an OAuth connect — the button is absent rather than present-and-refused. */ onConnect: ((instance: ProviderInstanceSummary) => void) | null; /** Reconnect re-authorizes one of the caller's own credentials in place, so it needs no more than membership — the row lists only theirs. */ @@ -516,7 +511,7 @@ interface ProviderRowProps { onEdit: ((instance: ProviderInstanceSummary) => void) | null; } -function ProviderRow({ instance, theme, myCreds, isConnecting, revokingId, errorMsg, onConnect, onReconnect, onReplaceToken, onConnectPat, onDisconnect, onEdit }: ProviderRowProps) { +function ProviderRow({ instance, theme, myCreds, isConnecting, revokingId, notices, mayMove, onNotice, onConnect, onReconnect, onReplaceToken, onConnectPat, onDisconnect, onEdit }: ProviderRowProps) { // "Connected" is about a credential that works; a disconnected one is listed (so it can be reconnected) but is not a connection. const connected = myCreds.some(isLiveCredential); // A refused credential is brought back in place (Reconnect, Replace token), so it counts as the connection of its kind — a second @@ -524,6 +519,9 @@ function ProviderRow({ instance, theme, myCreds, isConnecting, revokingId, error const hasOAuth = myCreds.some(c => c.authType === "OAuth" && isInService(c)); const hasPat = myCreds.some(c => c.authType === "Pat" && isInService(c)); const listed = myCreds.length > 0; + // Disconnected and leaned on by nothing: history, folded into one line. The rest are rows, the ones that need a person first. + const history = myCreds.filter(isHistory); + const rows = myCreds.filter(c => !isHistory(c)); // Connect affordances for the methods NOT yet linked. OAuth needs a configured app; a personal // token is always available; a provider with no OAuth app instead offers "Configure OAuth". @@ -560,37 +558,28 @@ function ProviderRow({ instance, theme, myCreds, isConnecting, revokingId, error {onEdit && }
- {/* What you HAVE — one uniform row per credential, labelled by method, each independently repairable and revocable. */} + {/* What you HAVE — one uniform row per credential, labelled by method, each independently repairable and revocable. The dead ones nothing leans on are one folded line. */} {listed && (
- {myCreds.map(c => ( -
-
- - {c.authType === "OAuth" ? <> OAuth : <> Token} - - connected {formatRelative(c.createdDate)} - - - {/* Sign in again to the SAME credential (an OAuth sign-in), or paste a new token into it (a token): either way - it keeps its id, so the repositories bound through it keep working. */} - {c.authType === "OAuth" && instance.oauthEnabled && ( - - )} - {c.authType !== "OAuth" && ( - - )} - {credentialHealthWord(c) !== "disconnected" && ( - - )} -
+ {rows.map(c => ( + onNotice(c.id, notice)} + onMoved={(text) => onNotice(instance.id, { tone: "ok", text })} + isConnecting={isConnecting} + isRevoking={revokingId === c.id} + mayMove={mayMove} + onReconnect={() => onReconnect(instance, c)} + onReplaceToken={() => onReplaceToken(c, instance)} + onDisconnect={() => onDisconnect(c)} + > -
+ ))} + {history.length > 0 && onReconnect(instance, c) : null} isConnecting={isConnecting} />}
)} @@ -618,7 +607,7 @@ function ProviderRow({ instance, theme, myCreds, isConnecting, revokingId, error )} - {errorMsg &&
{errorMsg}
} + {notices[instance.id] &&
} ); } @@ -630,11 +619,15 @@ function ProviderRow({ instance, theme, myCreds, isConnecting, revokingId, error * can mint a group token in place ("+ Add team token"); others show why they can't yet (GitHub * needs an App installation — not built). Each existing token carries its own Revoke. */ -function TeamProviderGroup({ instance, theme, creds, revokingId, onAdd, onRevoke, onReplaceToken }: { +function TeamProviderGroup({ instance, theme, creds, revokingId, notices, onNotice, mayRemove, onAdd, onRevoke, onReplaceToken }: { instance: ProviderInstanceSummary; theme: { initials: string; label: string }; creds: CredentialSummary[]; revokingId: string | null; + notices: Record; + onNotice: (key: string, notice: Notice | undefined) => void; + /** False when the caller may not remove a team token's dead row — the button is absent rather than present-and-refused. */ + mayRemove: boolean; /** Null when the caller may not add team tokens — the button is absent rather than present-and-refused. */ onAdd: ((instance: ProviderInstanceSummary) => void) | null; /** Null when the caller may not revoke team tokens — the button is absent rather than present-and-refused. */ @@ -643,6 +636,8 @@ function TeamProviderGroup({ instance, theme, creds, revokingId, onAdd, onRevoke onReplaceToken: ((credential: CredentialSummary, instance: ProviderInstanceSummary) => void) | null; }) { const supportsTeamToken = providerSupportsTeamServiceCredential(instance.provider); + const history = creds.filter(isHistory); + const rows = creds.filter(c => !isHistory(c)); return (
@@ -654,22 +649,27 @@ function TeamProviderGroup({ instance, theme, creds, revokingId, onAdd, onRevoke {/* Tokens + add/coming-soon, indented to line up under the provider name. A provider can hold many team tokens (different groups, rotation) — they stack here, each revocable. */}
- {creds.map(c => ( -
- - {c.displayName} - - - {onReplaceToken && ( - - )} - {onRevoke && credentialHealthWord(c) !== "disconnected" && ( - - )} + {rows.map(c => ( +
+
+ + {c.displayName} + + + + {onReplaceToken && ( + + )} + {onRevoke && credentialHealthWord(c) !== "disconnected" && ( + + )} +
+ {notices[c.id] && }
))} + {history.length > 0 && } {supportsTeamToken ? ( onAdd && @@ -1383,16 +1383,6 @@ function buildRemoveProviderMessage(repoCount: number, credCount: number): strin return `${repoCount} ${r} bound to this provider will be unbound (remote webhooks deleted best-effort).${credPart} This can't be undone.`; } -function formatRelative(iso: string) { - const then = new Date(iso).getTime(); - const seconds = Math.max(0, Math.round((Date.now() - then) / 1000)); - - if (seconds < 60) return "just now"; - if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)} hr ago`; - return `${Math.floor(seconds / 86400)} d ago`; -} - /** * One-line hint shown in the connect footer when the caller has NOT linked this provider yet — * it says what connecting will do. Two states: a provider with no OAuth app prompts setup, an diff --git a/frontend/src/api/oauth.ts b/frontend/src/api/oauth.ts index 513b85961..6aa1a4d39 100644 --- a/frontend/src/api/oauth.ts +++ b/frontend/src/api/oauth.ts @@ -1,5 +1,5 @@ import { ApiError, fetchJson } from "./request"; -import type { CompleteOAuthResponse, CredentialCapabilitiesResponse, CredentialSummary, CredentialUsage, DeleteProviderInstanceResponse, InitOAuthResponse, ProviderDefaults, ProviderInstanceSummary, ProviderInstanceUsage, ProviderKind, RelinkSuggestion, ReplaceCredentialTokenResponse, RevokeCredentialResponse } from "./types"; +import type { CompleteOAuthResponse, CredentialCapabilitiesResponse, CredentialSummary, CredentialUsage, DeleteProviderInstanceResponse, InitOAuthResponse, ProviderDefaults, ProviderInstanceSummary, ProviderInstanceUsage, ProviderKind, RelinkCredentialBindingsResponse, RelinkSuggestion, RemoveCredentialResponse, ReplaceCredentialTokenResponse, RevokeCredentialResponse } from "./types"; /** * Thin typed wrappers around the new OAuth + credential endpoints. They handle: @@ -109,6 +109,18 @@ export const oauthApi = { method: "POST", }), + // Take a disconnected credential nothing leans on out of the list (soft-delete). The server refuses a live one and one with repositories, hooks or identities left on it — 400 with the reason. + removeCredential: (credentialId: string) => fetchJson(`/api/credentials/${encodeURIComponent(credentialId)}`, { + method: "DELETE", + }), + + // Move every repository and group hook still bound through a dead credential onto another, in one action. + relinkCredentialBindings: (fromCredentialId: string, toCredentialId: string) => + fetchJson(`/api/credentials/${encodeURIComponent(fromCredentialId)}/relink-bindings`, { + method: "POST", + body: JSON.stringify({ toCredentialId }), + }), + // Put a new token into an existing credential, in place — it keeps its id, so what is bound through it keeps working. The // token must belong to the same provider account (409 credential_account_mismatch otherwise). replaceCredentialToken: (credentialId: string, token: string) => diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4a3c03a26..81b4f24df 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -60,6 +60,19 @@ export interface CredentialSummary { * from a server that predates it: read the role then (`ownsIt || can(CredentialsManage)`), which is what every client did before. */ callerMayRepair?: boolean | null; + /** + * How many things lean on it: live repositories bound through it, group hooks registered through it still in service, and act-as identities it backs. A + * disconnected credential with none is history, which the Providers dialog folds away and offers to remove. Absent from a server that predates it — + * which also has no removal, so the dialog then lists every credential as it always did. + */ + dependentCount?: number | null; + /** + * Of `dependentCount`, the group hooks registered through it that are still in service: what a dead credential with no repository left is still held by, and what + * moving its bindings carries with the repositories. Absent from a server that predates it — read as none. + */ + boundConnectionWebhookCount?: number | null; + /** Of `dependentCount`, the act-as identities it backs. Moving a dead credential's bindings does not carry them. Absent from a server that predates it — read as none. */ + actAsIdentityCount?: number | null; } export interface InitOAuthResponse { @@ -185,6 +198,18 @@ export interface RelinkSuggestion { strandedActAs?: string[]; } +/** What `POST /api/credentials/{id}/relink-bindings` moved: repositories re-pointed (the ones its dead credential had turned Error come back) and group hooks re-pointed. */ +export interface RelinkCredentialBindingsResponse { + fromCredentialId: string; + toCredentialId: string; + movedRepositoryCount: number; + movedConnectionWebhookCount: number; +} + +export interface RemoveCredentialResponse { + credentialId: string; +} + export interface ReplaceCredentialTokenResponse { credentialId: string; /** The provider account the new token belongs to, when the provider could say — what the person can check it against. */ diff --git a/frontend/src/components/connect-remote-modal.test.tsx b/frontend/src/components/connect-remote-modal.test.tsx index 85b9f6947..7f8102d86 100644 --- a/frontend/src/components/connect-remote-modal.test.tsx +++ b/frontend/src/components/connect-remote-modal.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { MeResponse, MeTeam, ProviderInstanceSummary } from "@/api/types"; @@ -183,13 +183,19 @@ describe("connect remote modal re-authorizing a credential in place", () => { afterEach(() => { localStorage.clear(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + /** A working sign-in keeps no Reconnect button of its own: re-authorizing it is in its row's overflow menu. */ + async function reauthorizeFromTheMenu() { + await waitFor(() => expect(screen.getByRole("button", { name: /More actions for/ })).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: /More actions for/ })); + fireEvent.click(await screen.findByRole("button", { name: "Re-authorize" })); + } + it("starts the OAuth flow naming the credential, under the credential's own name", async () => { // A blocked popup ends the flow right after the init request, which is the request under test. vi.spyOn(window, "open").mockReturnValue(null); const calls = renderAs(admin, [credential("oauth-1", "OAuth")], { "/api/credentials/oauth/init": { authorizeUrl: "https://gitlab.test/oauth/authorize", state: "s" } }); - await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy()); - fireEvent.click(screen.getByRole("button", { name: "Reconnect" })); + await reauthorizeFromTheMenu(); await waitFor(() => expect(calls.some((c) => c.url.endsWith("/api/credentials/oauth/init"))).toBe(true)); const init = calls.find((c) => c.url.endsWith("/api/credentials/oauth/init"))!; @@ -213,14 +219,15 @@ describe("connect remote modal re-authorizing a credential in place", () => { it.each([ ["Member", member], ["Viewer", { ...admin, role: "Viewer", permissions: [] } as MeTeam], - ] as const)("offers a %s Reconnect on their own sign-in and Replace token on their own token: the credential's own person passes on membership", async (_role, asker) => { + ] as const)("offers a %s Re-authorize on their own sign-in and Replace token on their own token: the credential's own person passes on membership", async (_role, asker) => { vi.spyOn(window, "open").mockReturnValue(null); const calls = renderAs(asker, [credential("oauth-1", "OAuth"), credential("pat-1", "Pat")], { "/api/credentials/oauth/init": { authorizeUrl: "https://gitlab.test/oauth/authorize", state: "s" } }); await waitFor(() => expect(screen.getAllByRole("button", { name: "Disconnect" })).toHaveLength(2)); expect(screen.getAllByRole("button", { name: "Replace token" })).toHaveLength(1); - fireEvent.click(screen.getByRole("button", { name: "Reconnect" })); + expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull(); // a working sign-in is re-authorized from its menu + await reauthorizeFromTheMenu(); await waitFor(() => expect(calls.some((c) => c.url.endsWith("/api/credentials/oauth/init"))).toBe(true)); expect(calls.find((c) => c.url.endsWith("/api/credentials/oauth/init"))!.body).toMatchObject({ providerInstanceId: "pi1", replaceCredentialId: "oauth-1" }); @@ -268,7 +275,7 @@ describe("connect remote modal re-authorizing a credential in place", () => { await waitFor(() => expect(screen.getAllByText("disconnected")).toHaveLength(2)); - expect(screen.getAllByTestId("bound-repositories").map((e) => e.textContent)).toEqual(["3 repositories bound", "1 repository bound"]); + expect(screen.getAllByTestId("credential-dependents").map((e) => e.textContent)).toEqual(["3 repositories still use it", "1 repository still uses it"]); expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Replace token" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "Disconnect" })).toBeNull(); @@ -280,8 +287,8 @@ describe("connect remote modal re-authorizing a credential in place", () => { await waitFor(() => expect(screen.getByText("needs reconnect")).toBeTruthy()); - const rows = screen.getAllByTestId("bound-repositories").map((e) => e.textContent); - expect(rows).toEqual(["5 repositories bound", "2 repositories bound"]); // the refused one leads although it is older + const rows = screen.getAllByTestId("credential-dependents").map((e) => e.textContent); + expect(rows).toEqual(["5 repositories still use it", "2 repositories bound"]); // the refused one leads although it is older }); it("marks a token whose expiry has passed as needing a new one although nothing has marked the row yet", async () => { @@ -354,6 +361,673 @@ describe("connect remote modal re-authorizing a credential in place", () => { }); }); +/** + * A Providers dialog that says what it did and does not pile up history. Every Disconnect leaves a dead row, so a person who reconnected a few times saw four + * rows for one account and a Reconnect that, on the working one, re-authorized in place and changed nothing they could see. Now: dead rows with nothing leaning + * on them fold into one quiet line (and can be removed), a dead row that still has repositories says so and leads with moving them to the person's working + * connection, every row says when the provider last confirmed it, a working sign-in is re-authorized from its menu, and each Reconnect / Replace token that lands + * is confirmed in words. + */ +describe("connect remote modal — dead rows folded, repositories moved, actions confirmed", () => { + const DAY = 86_400_000; + const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); + + const admin: MeTeam = { + id: "t1", slug: "acme", name: "Acme", kind: "Workspace", role: "Admin", permissions: ["repos.manage", "credentials.manage", "members.manage"], + memberCount: 3, repositoryCount: 0, projectCount: 1, workflowCount: 0, + }; + const member: MeTeam = { ...admin, role: "Member", permissions: ["workflows.write"] }; + + const instance: ProviderInstanceSummary = { id: "pi1", teamId: "t1", provider: "GitLab", displayName: "GitLab_TED", baseUrl: "https://gitlab.sjfood.us", createdDate: "2026-01-01T00:00:00Z", oauthEnabled: true }; + + type Row = Record & { id: string }; + const credential = (id: string, authType: string, extra: Record = {}): Row => ({ id, teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u1", ownerUserName: "Maya", ownership: "Personal", authType, displayName: `Maya's ${id}`, status: "Active", createdDate: ago(30 * DAY), ...extra }); + const dead = (id: string, extra: Record = {}) => credential(id, "OAuth", { status: "Revoked", dependentCount: 0, boundRepositoryCount: 0, ...extra }); + const teamToken = (id: string, extra: Record = {}) => credential(id, "GroupAccessToken", { ownerUserId: null, ownerUserName: null, ownership: "TeamService", displayName: "Acme group token", ...extra }); + + type Call = { method: string; url: string; path: string; body: unknown }; + type Reply = { status?: number; body?: unknown }; + type Respond = (call: Call, state: { credentials: Row[] }) => Reply | undefined; + + /** The dialog over a fake API whose credential list is `state.credentials`, which a responder may change as the server would. `respond` answers first. */ + function renderDialog(team: MeTeam, held: Row[], respond: Respond = () => undefined, providerOver: Partial = {}) { + localStorage.setItem("codespace.jwt", "test-jwt"); + localStorage.setItem("codespace.activeTeamId", team.id); + + const state = { credentials: held }; + const calls: Call[] = []; + + const defaults = (call: Call): Reply => { + if (call.path === "/api/users/me") return { body: { id: "u1", email: "u@test.local", name: "Maya", teams: [team], permissions: [], passwordMustChange: false } }; + if (call.path === "/api/provider-instances") return { body: [{ ...instance, ...providerOver }] }; + if (call.path === "/api/credentials") return { body: state.credentials }; + if (call.path.endsWith("/capabilities")) return { body: { credentialId: "any", grantedScopes: [], capabilities: [] } }; + + return {}; + }; + + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + const call: Call = { method: init?.method ?? "GET", url, path: new URL(url, "http://localhost").pathname, body: init?.body ? JSON.parse(String(init.body)) : undefined }; + + calls.push(call); + + const reply = respond(call, state) ?? defaults(call); + + return new Response(reply.body === undefined ? "" : JSON.stringify(reply.body), { status: reply.status ?? (reply.body === undefined ? 404 : 200), headers: { "Content-Type": "application/json" } }); + })); + + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + + render( + + {}} /> + , + ); + + return { calls, state, client }; + } + + afterEach(() => { localStorage.clear(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + + /** Pop the provider's window and have it answer, as /oauth-callback does through window.opener. */ + function answerPopup(message: Record) { + vi.spyOn(window, "open").mockReturnValue({ closed: false, close: vi.fn() } as unknown as Window); + + return () => window.dispatchEvent(new MessageEvent("message", { origin: window.location.origin, data: { type: "codespace:oauth-result", ...message } })); + } + + const init: Respond = (call) => call.path === "/api/credentials/oauth/init" ? { body: { authorizeUrl: "https://gitlab.sjfood.us/oauth/authorize", state: "s" } } : undefined; + + const earlier = (count: number) => new RegExp(`${count} earlier connections? — disconnected`); + + describe("folding the history", () => { + it("folds the disconnected credentials nothing leans on into one quiet line, and lists the one that works", async () => { + renderDialog(admin, [credential("oauth-live", "OAuth", { lastValidatedDate: ago(60_000) }), dead("old-1", { createdDate: ago(2 * DAY) }), dead("old-2", { createdDate: ago(10 * DAY) }), dead("old-3", { createdDate: ago(21 * DAY) })]); + + const line = await screen.findByRole("button", { name: earlier(3) }); + + expect(screen.getAllByRole("button", { name: "Disconnect" })).toHaveLength(1); // the working one is the only row + expect(screen.queryByText("disconnected")).toBeNull(); // no dead row wears its chip + expect(screen.queryByRole("button", { name: "Remove" })).toBeNull(); // and nothing is removable until the line is opened + expect(line.getAttribute("aria-expanded")).toBe("false"); + + fireEvent.click(line); + + expect(line.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getAllByRole("button", { name: "Remove" })).toHaveLength(3); + expect(screen.getByText("connected 21 d ago")).toBeTruthy(); // each says when it was connected, so which is which + }); + + it("says 'earlier connection' for one", async () => { + renderDialog(admin, [dead("old-1")]); + + expect(await screen.findByRole("button", { name: earlier(1) })).toBeTruthy(); + }); + + it("keeps a disconnected credential something still leans on as a row, and folds only the rest", async () => { + renderDialog(admin, [dead("busy", { dependentCount: 2, boundRepositoryCount: 2 }), dead("quiet")]); + + await screen.findByRole("button", { name: earlier(1) }); + + expect(screen.getAllByText("disconnected")).toHaveLength(1); // the busy one, still a row, with its chip + expect(screen.getByTestId("credential-dependents").textContent).toBe("2 repositories still use it"); + }); + + it("keeps a disconnected credential only a group hook leans on a row, and says what holds it: the count covers more than repositories", async () => { + renderDialog(admin, [dead("hooked", { dependentCount: 1, boundRepositoryCount: 0, boundConnectionWebhookCount: 1 })]); + + await waitFor(() => expect(screen.getByText("disconnected")).toBeTruthy()); + + expect(screen.getByTestId("credential-dependents").textContent).toBe("1 group hook still uses it"); + expect(screen.queryByRole("button", { name: /earlier connection/ })).toBeNull(); + expect(screen.queryByRole("button", { name: "Remove" })).toBeNull(); + }); + + it("names an act-as identity that holds a disconnected credential too", async () => { + renderDialog(admin, [dead("identified", { dependentCount: 1, boundRepositoryCount: 0, actAsIdentityCount: 1 })]); + + await waitFor(() => expect(screen.getByText("disconnected")).toBeTruthy()); + + expect(screen.getByTestId("credential-dependents").textContent).toBe("1 act-as identity still uses it"); + expect(screen.queryByRole("button", { name: /earlier connection/ })).toBeNull(); + }); + + it("lists every credential as it always did when the server sends no count — and offers no removal it could not do", async () => { + renderDialog(admin, [credential("oauth-live", "OAuth"), dead("old-1", { dependentCount: undefined }), dead("old-2", { dependentCount: undefined })]); + + await waitFor(() => expect(screen.getAllByText("disconnected")).toHaveLength(2)); + + expect(screen.queryByRole("button", { name: /earlier connection/ })).toBeNull(); + expect(screen.queryByRole("button", { name: "Remove" })).toBeNull(); + }); + + it("never folds a credential the provider refused or one that works", async () => { + renderDialog(admin, [credential("oauth-refused", "OAuth", { status: "Error", dependentCount: 0 }), credential("pat-ok", "Pat", { dependentCount: 0 })]); + + await waitFor(() => expect(screen.getByText("needs reconnect")).toBeTruthy()); + + expect(screen.queryByRole("button", { name: /earlier connection/ })).toBeNull(); + }); + }); + + describe("removing a row", () => { + const removalOf = (path: string, state: { credentials: Row[] }) => { + const id = path.split("/").pop()!; + + state.credentials = state.credentials.filter((c) => c.id !== id); + + return { body: { credentialId: id } }; + }; + + it("removes the row through the API and the line counts one fewer", async () => { + const { calls } = renderDialog(admin, [dead("old-1"), dead("old-2"), dead("old-3")], (call, state) => call.method === "DELETE" ? removalOf(call.path, state) : undefined); + + fireEvent.click(await screen.findByRole("button", { name: earlier(3) })); + fireEvent.click(screen.getAllByRole("button", { name: "Remove" })[0]); + + await waitFor(() => expect(screen.getByRole("button", { name: earlier(2) })).toBeTruthy()); + + const deletes = calls.filter((c) => c.method === "DELETE"); + + expect(deletes).toHaveLength(1); + expect(deletes[0].path).toMatch(/^\/api\/credentials\/old-[123]$/); + expect(screen.getAllByRole("button", { name: "Remove" })).toHaveLength(2); // the line stays open as rows leave it + }); + + it("shows the server's refusal beside the row and keeps the row", async () => { + const refusal = "Credential 'Maya's old-1' still has 1 repository leaning on it. Move or unlink them first."; + + renderDialog(admin, [dead("old-1")], (call) => call.method === "DELETE" ? { status: 400, body: { code: "invalid_request", message: refusal } } : undefined); + + fireEvent.click(await screen.findByRole("button", { name: earlier(1) })); + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + + expect((await screen.findByRole("alert")).textContent).toContain(refusal); + expect(screen.getByRole("button", { name: earlier(1) })).toBeTruthy(); + }); + + it("reads the list again when the server refuses, so a row that changed under the person stops reading as history", async () => { + const refusal = "Credential 'Maya's old-1' was reconnected or changed while it was being removed, so nothing was removed. Look at it again."; + const listReads = (calls: Call[]) => calls.filter((c) => c.method === "GET" && c.path === "/api/credentials").length; + + const { calls } = renderDialog(admin, [dead("old-1")], (call, state) => { + if (call.method !== "DELETE") return undefined; + + state.credentials = state.credentials.map((c) => ({ ...c, status: "Active", lastValidatedDate: new Date().toISOString() })); // the reconnect that won the race + + return { status: 400, body: { code: "invalid_request", message: refusal } }; + }); + + fireEvent.click(await screen.findByRole("button", { name: earlier(1) })); + expect(listReads(calls)).toBe(1); + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + + expect((await screen.findByRole("alert")).textContent).toContain(refusal); // the refusal is still said, on the row it was for + await waitFor(() => expect(screen.getByRole("button", { name: "Disconnect" })).toBeTruthy()); // which is now a working row, not a line in the fold + expect(screen.queryByRole("button", { name: /earlier connection/ })).toBeNull(); + expect(listReads(calls)).toBeGreaterThan(1); + }); + + it("lets a member remove their own history but gives them no Remove on a team token", async () => { + renderDialog(member, [dead("old-1"), teamToken("team-old", { status: "Revoked", dependentCount: 0, boundRepositoryCount: 0 })]); + + fireEvent.click(await screen.findByRole("button", { name: earlier(1) })); + expect(screen.getAllByRole("button", { name: "Remove" })).toHaveLength(1); + + fireEvent.click(screen.getByRole("tab", { name: "Team" })); + fireEvent.click(await screen.findByRole("button", { name: earlier(1) })); + + expect(screen.queryByRole("button", { name: "Remove" })).toBeNull(); + }); + + it("gives an admin Remove on a disconnected team token nothing leans on, which is not also listed as a token", async () => { + const held = [teamToken("team-live", { displayName: "Acme live token" }), teamToken("team-old", { displayName: "Acme old token", status: "Revoked", dependentCount: 0, boundRepositoryCount: 0 })]; + const { calls } = renderDialog(admin, held, (call, state) => call.method === "DELETE" ? removalOf(call.path, state) : undefined); + + fireEvent.click(await screen.findByRole("tab", { name: "Team" })); + const line = await screen.findByRole("button", { name: earlier(1) }); + + expect(screen.getByText("Acme live token")).toBeTruthy(); + expect(screen.queryByText("Acme old token")).toBeNull(); // folded, not a row beside the live token + expect(document.querySelectorAll(".cn-tg-cred")).toHaveLength(1); + + fireEvent.click(line); + expect(screen.queryByText("Acme old token")).toBeNull(); // and still not one once the line is open + expect(document.querySelectorAll(".cn-tg-cred")).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + + await waitFor(() => expect(calls.some((c) => c.method === "DELETE" && c.path === "/api/credentials/team-old")).toBe(true)); + }); + }); + + describe("moving what a dead credential still holds", () => { + const mine = (over: Record = {}) => ({ credentialId: "oauth-live", displayName: "Maya's GitLab", authType: "OAuth", ownership: "Personal", kind: "YourConnection", createdDate: ago(DAY), connectedBeforeDisconnect: true, ...over }); + const suggestingTo = (...suggestions: unknown[]): Respond => (call) => call.path.endsWith("/relink-suggestions") ? { body: suggestions } : undefined; + + const holder = (extra: Record = {}) => dead("old-1", { dependentCount: 3, boundRepositoryCount: 3, ...extra }); + const live = () => credential("oauth-live", "OAuth", { displayName: "Maya's GitLab", lastValidatedDate: ago(60_000) }); + + /** + * The suggestions for a credential have been read and applied to its row. A negative assertion ("no Move") means something only after this: Reconnect, which + * the row shows from the credential list alone, appears before the suggestions are back. + */ + async function suggestionsRead(client: QueryClient, credentialId: string) { + await waitFor(() => expect(client.getQueryState(["relink-suggestions", credentialId])?.status).toBe("success")); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); // the row renders what the cache now holds + } + + const rowOf = (connected: string) => screen.getByText(connected).closest(".cn-cred") as HTMLElement; + + it("leads with moving the repositories to the person's own working connection, and keeps Reconnect as the other way", async () => { + renderDialog(admin, [holder(), live()], suggestingTo(mine())); + + const move = await screen.findByRole("button", { name: "Move them to Maya's GitLab" }); + const reconnect = screen.getByRole("button", { name: "Reconnect" }); + + expect(screen.getByTestId("credential-dependents").textContent).toBe("3 repositories still use it"); + expect(move.className).toContain("btn-primary"); + expect(reconnect.className).not.toContain("btn-primary"); + expect(move.compareDocumentPosition(reconnect) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); // primary first + }); + + it("moves them in one request, says what moved, and the emptied row folds away", async () => { + const { calls } = renderDialog(admin, [holder(), live()], (call, state) => { + if (call.path.endsWith("/relink-suggestions")) return { body: [mine()] }; + if (call.method !== "POST" || !call.path.endsWith("/relink-bindings")) return undefined; + + state.credentials = state.credentials.map((c) => c.id === "old-1" ? { ...c, dependentCount: 0, boundRepositoryCount: 0 } : c); + + return { body: { fromCredentialId: "old-1", toCredentialId: "oauth-live", movedRepositoryCount: 3, movedConnectionWebhookCount: 0 } }; + }); + + fireEvent.click(await screen.findByRole("button", { name: "Move them to Maya's GitLab" })); + + expect((await screen.findByText(/Moved 3 repositories to Maya's GitLab/)).closest("[role=status]")).toBeTruthy(); // said where it stays: the row it came from has folded + await waitFor(() => expect(screen.getByRole("button", { name: earlier(1) })).toBeTruthy()); + + const post = calls.find((c) => c.method === "POST" && c.path.endsWith("/relink-bindings"))!; + + expect(post.path).toBe("/api/credentials/old-1/relink-bindings"); + expect(post.body).toEqual({ toCredentialId: "oauth-live" }); + }); + + it("also counts the group hooks it moved", async () => { + renderDialog(admin, [holder(), live()], (call) => { + if (call.path.endsWith("/relink-suggestions")) return { body: [mine()] }; + if (call.method === "POST" && call.path.endsWith("/relink-bindings")) return { body: { fromCredentialId: "old-1", toCredentialId: "oauth-live", movedRepositoryCount: 1, movedConnectionWebhookCount: 2 } }; + + return undefined; + }); + + fireEvent.click(await screen.findByRole("button", { name: "Move them to Maya's GitLab" })); + + expect(await screen.findByText(/Moved 1 repository and 2 group hooks to Maya's GitLab/)).toBeTruthy(); + }); + + it("shows the server's refusal and leaves the row as it was", async () => { + const refusal = "Credential 'Maya's GitLab' cannot read acme/app. Nothing was moved."; + + renderDialog(admin, [holder(), live()], (call) => { + if (call.path.endsWith("/relink-suggestions")) return { body: [mine()] }; + if (call.method === "POST" && call.path.endsWith("/relink-bindings")) return { status: 400, body: { code: "invalid_request", message: refusal } }; + + return undefined; + }); + + fireEvent.click(await screen.findByRole("button", { name: "Move them to Maya's GitLab" })); + + expect((await screen.findByRole("alert")).textContent).toContain(refusal); + expect(screen.getByRole("button", { name: "Move them to Maya's GitLab" })).toBeTruthy(); + expect(screen.getByTestId("credential-dependents").textContent).toBe("3 repositories still use it"); + }); + + it("offers it for a credential the provider refused, which relink-bindings also takes bindings from", async () => { + renderDialog(admin, [credential("oauth-refused", "OAuth", { status: "Error", dependentCount: 2, boundRepositoryCount: 2 }), live()], suggestingTo(mine())); + + expect(await screen.findByRole("button", { name: "Move them to Maya's GitLab" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy(); + expect(screen.getByTestId("credential-dependents").textContent).toBe("2 repositories still use it"); + }); + + it.each([ + ["has no other working connection", [] as unknown[]], + ["has only a team token to offer", [mine({ credentialId: "team-1", displayName: "Acme group token", kind: "TeamServiceToken", ownership: "TeamService" })]], + ])("offers no move when the person %s, and Reconnect stays the way back", async (_why, suggestions) => { + const { client } = renderDialog(admin, [holder()], suggestingTo(...suggestions)); + + await suggestionsRead(client, "old-1"); + await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy()); + + expect(screen.queryByRole("button", { name: /Move them to/ })).toBeNull(); + }); + + it("reads each dead credential's own suggestions: two rows with different candidates offer different moves", async () => { + const own = (id: string) => (call: Call) => call.path === `/api/credentials/${id}/relink-suggestions`; + const first = holder({ createdDate: ago(5 * DAY) }); + const second = dead("old-2", { dependentCount: 1, boundRepositoryCount: 1, createdDate: ago(9 * DAY) }); + + const { calls, client } = renderDialog(admin, [first, second, live()], (call) => { + if (own("old-1")(call)) return { body: [mine({ credentialId: "oauth-live", displayName: "Maya's GitLab" })] }; + if (own("old-2")(call)) return { body: [] }; // nothing to move this one to + + return undefined; + }); + + await suggestionsRead(client, "old-1"); + await suggestionsRead(client, "old-2"); + + expect(within(rowOf("connected 5 d ago")).getByRole("button", { name: "Move them to Maya's GitLab" })).toBeTruthy(); + expect(within(rowOf("connected 9 d ago")).queryByRole("button", { name: /Move them to/ })).toBeNull(); + expect(calls.filter((c) => c.path.endsWith("/relink-suggestions")).map((c) => c.path).sort()).toEqual(["/api/credentials/old-1/relink-suggestions", "/api/credentials/old-2/relink-suggestions"]); + }); + + it("reads the suggestions of the rows that remain again after a move, which changes what they can be offered", async () => { + const first = holder({ createdDate: ago(5 * DAY) }); + const second = dead("old-2", { dependentCount: 1, boundRepositoryCount: 1, createdDate: ago(9 * DAY) }); + const readsOf = (calls: Call[], id: string) => calls.filter((c) => c.method === "GET" && c.path === `/api/credentials/${id}/relink-suggestions`).length; + + const { calls, client } = renderDialog(admin, [first, second, live()], (call) => { + if (call.path.endsWith("/relink-suggestions")) return { body: [mine()] }; + if (call.method === "POST" && call.path.endsWith("/relink-bindings")) return { body: { fromCredentialId: "old-1", toCredentialId: "oauth-live", movedRepositoryCount: 3, movedConnectionWebhookCount: 0 } }; + + return undefined; + }); + + await suggestionsRead(client, "old-2"); + expect(readsOf(calls, "old-2")).toBe(1); + + fireEvent.click(within(rowOf("connected 5 d ago")).getByRole("button", { name: "Move them to Maya's GitLab" })); + + await waitFor(() => expect(readsOf(calls, "old-2")).toBe(2)); + }); + + it("offers to move the group hooks of a dead credential that holds no repository, and says what moved", async () => { + const hooked = dead("hooked", { dependentCount: 2, boundRepositoryCount: 0, boundConnectionWebhookCount: 2 }); + + const { calls } = renderDialog(admin, [hooked, live()], (call) => { + if (call.path.endsWith("/relink-suggestions")) return { body: [mine()] }; + if (call.method === "POST" && call.path.endsWith("/relink-bindings")) return { body: { fromCredentialId: "hooked", toCredentialId: "oauth-live", movedRepositoryCount: 0, movedConnectionWebhookCount: 2 } }; + + return undefined; + }); + + expect((await screen.findByTestId("credential-dependents")).textContent).toBe("2 group hooks still use it"); + fireEvent.click(await screen.findByRole("button", { name: "Move them to Maya's GitLab" })); + + expect(await screen.findByText(/Moved 2 group hooks to Maya's GitLab/)).toBeTruthy(); + expect(calls.find((c) => c.method === "POST" && c.path.endsWith("/relink-bindings"))!.path).toBe("/api/credentials/hooked/relink-bindings"); + }); + + it("offers no move for a dead credential held only by an act-as identity, which a move leaves where it is", async () => { + const { calls } = renderDialog(admin, [dead("identified", { dependentCount: 1, boundRepositoryCount: 0, actAsIdentityCount: 1 }), live()], suggestingTo(mine())); + + await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy()); + + expect(screen.queryByRole("button", { name: /Move them to/ })).toBeNull(); + expect(calls.some((c) => c.path.endsWith("/relink-suggestions"))).toBe(false); + }); + + it("does not ask for suggestions, nor offer a move, to someone who may not relink: it needs repos.manage and credentials.manage", async () => { + const { calls } = renderDialog(member, [holder(), live()], suggestingTo(mine())); + + await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy()); + + expect(screen.queryByRole("button", { name: /Move them to/ })).toBeNull(); + expect(calls.some((c) => c.path.endsWith("/relink-suggestions"))).toBe(false); + }); + + it("does not offer a move off a credential still in service, however it reads — the server accepts only a dead source", async () => { + const { calls } = renderDialog(admin, [credential("pat-lapsed", "Pat", { status: "Active", health: "rejected", boundRepositoryCount: 2, dependentCount: 2 }), live()], suggestingTo(mine())); + + await waitFor(() => expect(screen.getByRole("button", { name: "Replace token" })).toBeTruthy()); + + expect(screen.queryByRole("button", { name: /Move them to/ })).toBeNull(); + expect(calls.some((c) => c.path.endsWith("/relink-suggestions"))).toBe(false); + }); + }); + + describe("authorized time", () => { + it("says when the provider last confirmed the authorization, and keeps when it was connected in the tooltip", async () => { + renderDialog(admin, [credential("oauth-1", "OAuth", { createdDate: ago(21 * DAY), lastValidatedDate: ago(5 * 60_000) })]); + + const since = await screen.findByText("authorized 5 min ago"); + + expect(since.getAttribute("title")).toMatch(/^Connected /); + expect(screen.queryByText(/^connected \d+ d ago$/)).toBeNull(); + }); + + it("says when it was connected for a row nothing has confirmed, and for a server that sends no confirmation time", async () => { + renderDialog(admin, [credential("pat-1", "Pat", { createdDate: ago(3 * DAY) })]); + + expect(await screen.findByText("connected 3 d ago")).toBeTruthy(); + expect(screen.queryByText(/^authorized/)).toBeNull(); + }); + }); + + describe("re-authorizing in place", () => { + it("keeps Reconnect as a visible action for a credential the provider refused, and has no menu for it", async () => { + renderDialog(admin, [credential("oauth-refused", "OAuth", { status: "Error" })]); + + await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy()); + + expect(screen.queryByRole("button", { name: /More actions for/ })).toBeNull(); + }); + + it("keeps Reconnect visible on a disconnected credential that still holds repositories", async () => { + renderDialog(admin, [dead("old-1", { dependentCount: 2, boundRepositoryCount: 2 })]); + + await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy()); + }); + + it("keeps Reconnect on a folded sign-in for a member, whose only way back it is: a plain Connect with OAuth takes credentials.manage", async () => { + vi.spyOn(window, "open").mockReturnValue(null); + const { calls } = renderDialog(member, [dead("old-1")], init); + + fireEvent.click(await screen.findByRole("button", { name: earlier(1) })); + + expect(screen.queryByRole("button", { name: /Connect with OAuth/ })).toBeNull(); + fireEvent.click(await screen.findByRole("button", { name: "Reconnect" })); + + await waitFor(() => expect(calls.some((c) => c.path === "/api/credentials/oauth/init")).toBe(true)); + expect(calls.find((c) => c.path === "/api/credentials/oauth/init")!.body).toMatchObject({ providerInstanceId: "pi1", replaceCredentialId: "old-1" }); + }); + + it("offers Reconnect on a folded sign-in to an admin as well, beside the plain connect that would mint another row", async () => { + renderDialog(admin, [dead("old-1")]); + + fireEvent.click(await screen.findByRole("button", { name: earlier(1) })); + + expect(await screen.findByRole("button", { name: "Reconnect" })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Connect with OAuth/ })).toBeTruthy(); + }); + + it("offers no Reconnect on a folded token", async () => { + const oldToken = credential("pat-old", "Pat", { status: "Revoked", dependentCount: 0, boundRepositoryCount: 0 }); + + renderDialog(admin, [oldToken]); + fireEvent.click(await screen.findByRole("button", { name: earlier(1) })); + expect(screen.getByRole("button", { name: "Remove" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull(); + }); + + it("offers no Reconnect on a folded sign-in of a provider with no OAuth app", async () => { + renderDialog(admin, [dead("old-1")], () => undefined, { oauthEnabled: false }); + + fireEvent.click(await screen.findByRole("button", { name: earlier(1) })); + + expect(screen.getByRole("button", { name: "Remove" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull(); + }); + + it("confirms a Reconnect from the fold: the row comes back as a working connection and leaves the line", async () => { + const { calls, state } = renderDialog(admin, [dead("old-1"), dead("old-2", { createdDate: ago(40 * DAY) })], init); + const answer = answerPopup({ status: "success", credentialId: "old-1" }); + + fireEvent.click(await screen.findByRole("button", { name: earlier(2) })); + fireEvent.click((await screen.findAllByRole("button", { name: "Reconnect" }))[0]); + await waitFor(() => expect(calls.some((c) => c.path === "/api/credentials/oauth/init")).toBe(true)); + + state.credentials = state.credentials.map((c) => c.id === "old-1" ? { ...c, status: "Active", lastValidatedDate: new Date().toISOString() } : c); + answer(); + + expect((await screen.findByRole("status")).textContent).toContain("Reconnected — GitLab authorized Maya's old-1 just now"); + await waitFor(() => expect(screen.getByRole("button", { name: earlier(1) })).toBeTruthy()); + expect(screen.getAllByRole("button", { name: "Disconnect" })).toHaveLength(1); + }); + + it("shows a refused Reconnect's own sentence on the folded row it was for", async () => { + const sentence = "That sign-in is a different GitLab account than the one this credential was connected as."; + const { calls } = renderDialog(member, [dead("old-1")], init); + const answer = answerPopup({ status: "error", error: "credential_account_mismatch", description: sentence }); + + fireEvent.click(await screen.findByRole("button", { name: earlier(1) })); + fireEvent.click(await screen.findByRole("button", { name: "Reconnect" })); + await waitFor(() => expect(calls.some((c) => c.path === "/api/credentials/oauth/init")).toBe(true)); + answer(); + + expect((await screen.findByRole("alert")).textContent).toBe(sentence); + expect(screen.getByRole("button", { name: earlier(1) }).getAttribute("aria-expanded")).toBe("true"); // still folded, still there to try again + }); + + it("puts a working sign-in's Re-authorize in its menu, beside a Disconnect that stays where it was", async () => { + renderDialog(admin, [credential("oauth-live", "OAuth")]); + + await waitFor(() => expect(screen.getByRole("button", { name: "Disconnect" })).toBeTruthy()); + + expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Re-authorize" })).toBeNull(); // not until the menu is opened + + fireEvent.click(screen.getByRole("button", { name: /More actions for/ })); + + expect(await screen.findByRole("button", { name: "Re-authorize" })).toBeTruthy(); + }); + }); + + describe("saying what a reconnect or a replaced token did", () => { + async function reauthorize(calls: Call[]) { + fireEvent.click(await screen.findByRole("button", { name: /More actions for/ })); + fireEvent.click(await screen.findByRole("button", { name: "Re-authorize" })); + await waitFor(() => expect(calls.some((c) => c.path === "/api/credentials/oauth/init")).toBe(true)); + } + + it("confirms a Reconnect that landed, and the row's authorized time follows", async () => { + const { calls, state } = renderDialog(admin, [credential("oauth-1", "OAuth", { lastValidatedDate: ago(2 * DAY) })], init); + const answer = answerPopup({ status: "success", credentialId: "oauth-1" }); + + await screen.findByText("authorized 2 d ago"); + await reauthorize(calls); + + state.credentials = state.credentials.map((c) => ({ ...c, lastValidatedDate: new Date().toISOString() })); + answer(); + + const confirmation = await screen.findByRole("status"); + + expect(confirmation.textContent).toContain("Reconnected — GitLab authorized Maya's oauth-1 just now"); + expect(within(confirmation.closest(".cn-cred") as HTMLElement).getByText("authorized just now")).toBeTruthy(); // said on the credential it was for + }); + + it("shows a refused Reconnect's own sentence on the credential it was for", async () => { + const sentence = "That sign-in is a different GitLab account than the one this credential was connected as."; + const { calls } = renderDialog(admin, [credential("oauth-1", "OAuth"), credential("oauth-2", "OAuth", { createdDate: ago(40 * DAY) })], init); + const answer = answerPopup({ status: "error", error: "credential_account_mismatch", description: sentence }); + + fireEvent.click((await screen.findAllByRole("button", { name: /More actions for/ }))[0]); + fireEvent.click(await screen.findByRole("button", { name: "Re-authorize" })); + await waitFor(() => expect(calls.some((c) => c.path === "/api/credentials/oauth/init")).toBe(true)); + answer(); + + const refusal = await screen.findByRole("alert"); + + expect(refusal.textContent).toBe(sentence); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("confirms a replaced token the same way", async () => { + const { state } = renderDialog(member, [credential("pat-1", "Pat", { lastValidatedDate: ago(2 * DAY) })], (call) => call.method === "PUT" ? { body: { credentialId: "pat-1", providerUsername: "maya" } } : undefined); + + await screen.findByText("authorized 2 d ago"); + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + const dialog = await screen.findByRole("dialog", { name: "Replace token" }); + + fireEvent.change(within(dialog).getByPlaceholderText(/glpat/i), { target: { value: "glpat-fresh" } }); + state.credentials = state.credentials.map((c) => ({ ...c, lastValidatedDate: new Date().toISOString() })); + fireEvent.click(within(dialog).getByRole("button", { name: "Replace token" })); + + const confirmation = await screen.findByRole("status"); + + expect(confirmation.textContent).toContain("Token replaced — GitLab authorized Maya's pat-1 just now"); + await waitFor(() => expect(screen.getByText("authorized just now")).toBeTruthy()); + }); + + it("drops the confirmation when the credential is disconnected afterwards: the row no longer reads as freshly authorized", async () => { + const held = credential("oauth-1", "OAuth", { lastValidatedDate: ago(2 * DAY), boundRepositoryCount: 2, dependentCount: 2 }); + + const { calls, state } = renderDialog(admin, [held], (call, st) => { + if (call.method === "POST" && call.path === "/api/credentials/oauth-1/revoke") { + st.credentials = st.credentials.map((c) => ({ ...c, status: "Revoked" })); + + return { body: { credentialId: "oauth-1" } }; + } + + return init(call, st); + }); + const answer = answerPopup({ status: "success", credentialId: "oauth-1" }); + + await screen.findByText("authorized 2 d ago"); + await reauthorize(calls); + state.credentials = state.credentials.map((c) => ({ ...c, lastValidatedDate: new Date().toISOString() })); + answer(); + await screen.findByRole("status"); + + fireEvent.click(screen.getByRole("button", { name: "Disconnect" })); + fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Disconnect" })); + + await waitFor(() => expect(screen.getByText("disconnected")).toBeTruthy()); + + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("drops the confirmation of a replaced token when its replacement is opened again", async () => { + const { state } = renderDialog(member, [credential("pat-1", "Pat", { lastValidatedDate: ago(2 * DAY) })], (call) => call.method === "PUT" ? { body: { credentialId: "pat-1", providerUsername: "maya" } } : undefined); + + const replace = async () => { + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + const dialog = await screen.findByRole("dialog", { name: "Replace token" }); + + fireEvent.change(within(dialog).getByPlaceholderText(/glpat/i), { target: { value: "glpat-fresh" } }); + state.credentials = state.credentials.map((c) => ({ ...c, lastValidatedDate: new Date().toISOString() })); + fireEvent.click(within(dialog).getByRole("button", { name: "Replace token" })); + }; + + await screen.findByText("authorized 2 d ago"); + await replace(); + await screen.findByRole("status"); + + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + await screen.findByRole("dialog", { name: "Replace token" }); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("confirms a Reconnect of a dead row that still holds repositories, which comes back to life in place", async () => { + const { calls, state } = renderDialog(admin, [dead("old-1", { dependentCount: 2, boundRepositoryCount: 2 })], init); + const answer = answerPopup({ status: "success", credentialId: "old-1" }); + + fireEvent.click(await screen.findByRole("button", { name: "Reconnect" })); + await waitFor(() => expect(calls.some((c) => c.path === "/api/credentials/oauth/init")).toBe(true)); + + state.credentials = state.credentials.map((c) => ({ ...c, status: "Active", lastValidatedDate: new Date().toISOString() })); + answer(); + + expect((await screen.findByRole("status")).textContent).toContain("Reconnected — GitLab authorized Maya's old-1 just now"); + await waitFor(() => expect(screen.getByText("2 repositories bound")).toBeTruthy()); + }); + }); +}); + /** * Editing a provider's base URL. The list shows the URL with any embedded credentials masked, so the form must not send * the masked value back as an edit; and a token or OAuth secret is only ever sent to the host it was entered for, so diff --git a/frontend/src/components/providers/CredentialRow.tsx b/frontend/src/components/providers/CredentialRow.tsx new file mode 100644 index 000000000..b04e6e827 --- /dev/null +++ b/frontend/src/components/providers/CredentialRow.tsx @@ -0,0 +1,123 @@ +import type { ReactNode } from "react"; + +import { Ic } from "@/_imported/ai-code-space/icons"; +import type { CredentialSummary, ProviderInstanceSummary, RelinkCredentialBindingsResponse, RelinkSuggestion } from "@/api/types"; +import { RowMenu } from "@/components/settings/RowMenu"; +import { useRelinkCredentialBindings, useRelinkSuggestions } from "@/hooks/use-credentials"; +import { canMoveBindingsOff, credentialHealthWord } from "@/lib/credentialHealth"; + +import { AuthorizedSince, CredentialDependents, CredentialHealthChip, NoticeLine, type Notice } from "./credentialParts"; + +interface CredentialRowProps { + credential: CredentialSummary; + instance: ProviderInstanceSummary; + /** What the last action on this credential said — a confirmation or the server's refusal. */ + notice: Notice | undefined; + onNotice: (notice: Notice | undefined) => void; + /** Said where the row will not be: moving what it held empties it, and an emptied dead row folds away with its confirmation. */ + onMoved: (text: string) => void; + /** A connect for this provider is running, so its buttons wait. */ + isConnecting: boolean; + isRevoking: boolean; + /** Moving every repository at once takes `repos.manage` and `credentials.manage`. False hides the offer — and spares the read that finds where to move them. */ + mayMove: boolean; + onReconnect: () => void; + onReplaceToken: () => void; + onDisconnect: () => void; + /** What else hangs under the row (the capability warnings). */ + children?: ReactNode; +} + +/** + * One of the caller's credentials on a provider, and what can be done about it. A credential that does not work leads with the way back: moving what + * it still holds to the person's own working connection when there is one, else bringing the credential itself back in place. A working sign-in keeps + * re-authorizing in a menu — it is rarely the thing to press — and every action that lands says so in the row, because a re-authorization changes + * nothing a person can otherwise see. + */ +export function CredentialRow({ credential: c, instance, notice, onNotice, onMoved, isConnecting, isRevoking, mayMove, onReconnect, onReplaceToken, onDisconnect, children }: CredentialRowProps) { + const word = credentialHealthWord(c); + const needsAttention = word !== "active"; + const target = useMoveTarget(c, mayMove); + const relink = useRelinkCredentialBindings(); + + const reconnectable = c.authType === "OAuth" && instance.oauthEnabled; + const reconnectInMenu = reconnectable && !needsAttention; + + const move = async (to: RelinkSuggestion) => { + onNotice(undefined); + + try { + onMoved(movedSentence(await relink.mutateAsync({ fromCredentialId: c.id, toCredentialId: to.credentialId }), to.displayName)); + } catch (err) { + onNotice({ tone: "err", text: err instanceof Error ? err.message : "Couldn't move them." }); + } + }; + + const secondary = target ? "btn btn-ghost cn-cred-x" : needsAttention ? "btn cn-cred-x" : "btn btn-ghost cn-cred-x"; + + return ( +
+
+ + {c.authType === "OAuth" ? <> OAuth : <> Token} + + + + + {target && ( + + )} + {/* Sign in again to the SAME credential (an OAuth sign-in), or paste a new token into it (a token): either way + it keeps its id, so the repositories bound through it keep working. */} + {reconnectable && !reconnectInMenu && ( + + )} + {c.authType !== "OAuth" && ( + + )} + {word !== "disconnected" && ( + + )} + {reconnectInMenu && ( + + {(close) => ( + + )} + + )} +
+ {notice && } + {children} +
+ ); +} + +/** + * Where "Move them" would send what the credential still holds: the person's own working connection, the first the server suggests of that kind. The read is + * made only for a credential that has something to move and a caller who may move it. A team token is never the lead: it names a different identity, and + * choosing it is what the repository's own Re-link dialog is for. + */ +function useMoveTarget(credential: CredentialSummary, mayMove: boolean): RelinkSuggestion | null { + const eligible = mayMove && canMoveBindingsOff(credential); + const suggestions = useRelinkSuggestions(credential.id, eligible); + + return eligible ? suggestions.data?.find(s => s.kind === "YourConnection") ?? null : null; +} + +function movedSentence(moved: RelinkCredentialBindingsResponse, to: string): string { + const parts = [plural(moved.movedRepositoryCount, "repository", "repositories"), plural(moved.movedConnectionWebhookCount, "group hook", "group hooks")].filter((p): p is string => p != null); + + return parts.length === 0 ? "Nothing was left on it to move." : `Moved ${parts.join(" and ")} to ${to}.`; +} + +function plural(count: number, one: string, many: string): string | null { + return count > 0 ? `${count} ${count === 1 ? one : many}` : null; +} diff --git a/frontend/src/components/providers/EarlierConnections.tsx b/frontend/src/components/providers/EarlierConnections.tsx new file mode 100644 index 000000000..1ddda65a1 --- /dev/null +++ b/frontend/src/components/providers/EarlierConnections.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; + +import { Ic } from "@/_imported/ai-code-space/icons"; +import type { CredentialSummary } from "@/api/types"; +import { useRemoveCredential } from "@/hooks/use-credentials"; + +import { AuthorizedSince, NoticeLine, type Notice } from "./credentialParts"; + +interface EarlierConnectionsProps { + /** Disconnected credentials nothing leans on (`isHistory`): history, not connections. */ + credentials: CredentialSummary[]; + /** False when the caller may not remove these — the button is absent rather than present-and-refused. */ + mayRemove: boolean; + /** What the last action on each row said, by credential id — a refused Remove or Reconnect, said beside the row it was for. */ + notices: Record; + onNotice: (credentialId: string, notice: Notice | undefined) => void; + /** Sign in again to a folded sign-in, in place. Null when there is nothing to sign in with: the provider has no OAuth app. */ + onReconnect: ((credential: CredentialSummary) => void) | null; + /** A connect for this provider is running, so its buttons wait. */ + isConnecting: boolean; +} + +/** + * The dead rows a Disconnect leaves behind, folded into one quiet line so the list opens on what works. Each Disconnect and reconnect adds one, and + * nothing depends on any of them, so listing them only made a person look for the one that counts. Opening the line lists them, each with Remove — which the + * server accepts only while nothing leans on the row (and says what does when something does, shown beside it as it was said) — and, for a sign-in, Reconnect: + * for a caller who cannot start a plain connect (it takes credentials.manage) re-authorizing their own dead sign-in in place is the only way back to one. + */ +export function EarlierConnections({ credentials, mayRemove, notices, onNotice, onReconnect, isConnecting }: EarlierConnectionsProps) { + const [open, setOpen] = useState(false); + const [removingId, setRemovingId] = useState(null); + const remove = useRemoveCredential(); + + const removeOne = async (credential: CredentialSummary) => { + setRemovingId(credential.id); + onNotice(credential.id, undefined); + + try { + await remove.mutateAsync(credential.id); + } catch (err) { + onNotice(credential.id, { tone: "err", text: err instanceof Error ? err.message : "Couldn't remove it." }); + } finally { + setRemovingId(null); + } + }; + + return ( +
+ + + {open && ( +
+ {credentials.map(c => ( +
+
+ + {c.authType === "OAuth" ? <> OAuth : <> Token} + + + {onReconnect && c.authType === "OAuth" && ( + + )} + {mayRemove && ( + + )} +
+ {notices[c.id] && } +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/providers/credentialParts.tsx b/frontend/src/components/providers/credentialParts.tsx new file mode 100644 index 000000000..0e58365e1 --- /dev/null +++ b/frontend/src/components/providers/credentialParts.tsx @@ -0,0 +1,83 @@ +import { Ic } from "@/_imported/ai-code-space/icons"; +import type { CredentialSummary } from "@/api/types"; +import { credentialHealthWord, dependentsLabel, isStaticToken } from "@/lib/credentialHealth"; +import { formatRelative } from "@/lib/formatRelative"; + +/** + * The small pieces every credential row in the Providers dialog is made of — what its health says, how many repositories lean on it, when it was last + * authorized, and the line a finished action leaves behind — shared by the Personal tab's rows, the Team tab's and the folded history. + */ + +/** What a finished action says in the row it was for: a confirmation (`ok`) or the server's own refusal (`err`). */ +export interface Notice { + tone: "ok" | "err"; + text: string; +} + +/** + * What a credential's health says to the person — every state, so no credential reads as healthy by saying nothing: working, + * a token about to expire (and how long it has), one the provider refused (how to come back), one disconnected, one lent by + * someone who left the team. The words come from `credentialHealthWord`, which folds `status` in for a server that sends no `health`. + */ +export function CredentialHealthChip({ credential }: { credential: CredentialSummary }) { + const word = credentialHealthWord(credential); + + if (word === "rejected") { + return ( + + {isStaticToken(credential) ? "needs a new token" : "needs reconnect"} + + ); + } + + if (word === "disconnected") return disconnected; + if (word === "owner_left") return owner left; + + if (word === "expiring") { + const days = credential.daysToExpiry ?? 0; + + return ( + + {days === 0 ? "expires today" : `expires in ${days} ${days === 1 ? "day" : "days"}`} + + ); + } + + return active; +} + +/** What still leans on a credential — the repositories a reconnect, replace or disconnect puts at stake, or what a dead one is still held by (repositories, group hooks, act-as identities). Nothing when none do. */ +export function CredentialDependents({ credential }: { credential: CredentialSummary }) { + const label = dependentsLabel(credential); + + return label ? {label} : null; +} + +/** + * When the provider last confirmed this authorization: a reconnect or a replaced token stamps it, and so does the periodic health check, so it + * reads "authorized" however recently the person signed in. A row nothing has confirmed says when it was connected instead. The tooltip keeps both dates. + */ +export function AuthorizedSince({ credential }: { credential: CredentialSummary }) { + const connected = `Connected ${formatDate(credential.createdDate)}`; + const confirmed = credential.lastValidatedDate; + + if (!confirmed) return {`connected ${formatRelative(credential.createdDate)}`}; + + return {`authorized ${formatRelative(confirmed)}`}; +} + +/** The line an action leaves in its row: green and polite for what landed, the row's error red and assertive for what was refused. */ +export function NoticeLine({ notice }: { notice: Notice }) { + const ok = notice.tone === "ok"; + + return ( +
+ {ok && } + {notice.text} +
+ ); +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} diff --git a/frontend/src/components/settings/ProvidersSettings.test.tsx b/frontend/src/components/settings/ProvidersSettings.test.tsx index 5e3bfd1b1..7887a4b83 100644 --- a/frontend/src/components/settings/ProvidersSettings.test.tsx +++ b/frontend/src/components/settings/ProvidersSettings.test.tsx @@ -49,6 +49,8 @@ describe("providers settings summary", () => { { what: "a token whose expiry passed, though its row is still Active", held: [credential("lapsed", { health: "rejected" })], expected: "needs reconnect" }, { what: "a refused one beside a working one", held: [credential("dead", { status: "Error" }), credential("ok")], expected: "connected" }, { what: "only someone else's credential", held: [credential("theirs", { ownerUserId: "u2" })], expected: "not connected" }, + { what: "only disconnected rows nothing leans on, which the dialog folds into one line", held: [credential("old-1", { status: "Revoked", dependentCount: 0 }), credential("old-2", { status: "Revoked", dependentCount: 0 })], expected: "not connected" }, + { what: "a working credential beside folded history, from a server that sends the new fields", held: [credential("ok", { lastValidatedDate: "2026-10-19T09:00:00Z", dependentCount: 1, boundRepositoryCount: 1 }), credential("old-1", { status: "Revoked", dependentCount: 0 })], expected: "connected" }, { what: "nothing", held: [], expected: "not connected" }, ])("reads $what as '$expected'", async ({ held, expected }) => { renderWith(held); diff --git a/frontend/src/components/settings/RowMenu.tsx b/frontend/src/components/settings/RowMenu.tsx index a40496c36..edad04a96 100644 --- a/frontend/src/components/settings/RowMenu.tsx +++ b/frontend/src/components/settings/RowMenu.tsx @@ -13,7 +13,7 @@ import { createPortal } from "react-dom"; * above the trigger when there is no room below, so the last row of a list opens upward instead of * off-screen.

*/ -export function RowMenu({ label, children }: { label: string; children: (close: () => void) => ReactNode }) { +export function RowMenu({ label, triggerClassName = "btn btn-icon", children }: { label: string; /** The ⋯ button's classes: a row that already carries buttons of its own wants it quieter than the list default. */ triggerClassName?: string; children: (close: () => void) => ReactNode }) { const trigger = useRef(null); const [at, setAt] = useState<{ top: number; right: number; flip: boolean } | null>(null); @@ -47,7 +47,7 @@ export function RowMenu({ label, children }: { label: string; children: (close: return ( <> - + {at != null && createPortal( <> diff --git a/frontend/src/hooks/use-credentials.ts b/frontend/src/hooks/use-credentials.ts index b8a320f1d..5ffc7c6b3 100644 --- a/frontend/src/hooks/use-credentials.ts +++ b/frontend/src/hooks/use-credentials.ts @@ -282,6 +282,36 @@ export function useReplaceCredentialToken() { }); } +/** + * Remove a disconnected credential nothing leans on: it leaves the list, so refresh it. A refusal refreshes it too — what the server refused over (the row was + * reconnected, something now leans on it) is exactly what the list on screen no longer says. + */ +export function useRemoveCredential() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (credentialId: string) => oauthApi.removeCredential(credentialId), + onSettled: () => queryClient.invalidateQueries({ queryKey: ["credentials"] }), + }); +} + +/** + * Move every repository and group hook bound through a dead credential onto another credential. What it moved shows in the credential list (counts), the repository + * rows (their connection issue goes), a run page's remedy card and the suggestions that offered it, so each is read again. + */ +export function useRelinkCredentialBindings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ fromCredentialId, toCredentialId }: { fromCredentialId: string; toCredentialId: string }) => oauthApi.relinkCredentialBindings(fromCredentialId, toCredentialId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["credentials"] }); + queryClient.invalidateQueries({ queryKey: ["repositories"] }); + queryClient.invalidateQueries({ queryKey: ["relink-suggestions"] }); + queryClient.invalidateQueries({ queryKey: ["run-remedy"] }); + queryClient.invalidateQueries({ queryKey: ["repository"] }); + }, + }); +} + /** * The credentials the caller could move a credential's bindings onto, best first. Asked only when a fix is being picked * (`enabled`), never on page load: it is a read per credential. diff --git a/frontend/src/hooks/use-oauth-flow.test.ts b/frontend/src/hooks/use-oauth-flow.test.ts index 83b790df6..b88d24444 100644 --- a/frontend/src/hooks/use-oauth-flow.test.ts +++ b/frontend/src/hooks/use-oauth-flow.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "@/api/oauth"; -import { initAtFirstAcceptedReturnUrl, oauthReturnUrlsFor } from "./use-oauth-flow"; +import { describeOAuthFlowError, initAtFirstAcceptedReturnUrl, OAuthFlowError, oauthReturnUrlsFor } from "./use-oauth-flow"; /** * Where a connect brings its popup back to. A connect whose callback cannot find the browser's binding cookie — started @@ -102,3 +102,24 @@ describe("initAtFirstAcceptedReturnUrl", () => { }); } }); + +/** + * What a refused connect says. The callback hands the opener a short code (`error`) and, when it has one, the words for a person + * (`description`): a reconnect refused because it signed in as a different account is `credential_account_mismatch` plus a sentence + * saying so and what to do, and the code alone tells a person nothing. + */ +describe("describeOAuthFlowError", () => { + it("says the callback's own sentence when it gave one, not only its code", () => { + const refused = new OAuthFlowError("provider_error", "That sign-in is a different GitLab account than the one this credential was connected as.", "credential_account_mismatch"); + + expect(describeOAuthFlowError(refused)).toBe("That sign-in is a different GitLab account than the one this credential was connected as."); + }); + + it("falls back to naming the code when the callback gave nothing more", () => { + expect(describeOAuthFlowError(new OAuthFlowError("provider_error", "access_denied", "access_denied"))).toBe("Provider rejected: access_denied"); + }); + + it("says the message of a refusal that carried no code at all", () => { + expect(describeOAuthFlowError(new OAuthFlowError("provider_error", "Authorization failed"))).toBe("Authorization failed"); + }); +}); diff --git a/frontend/src/hooks/use-oauth-flow.ts b/frontend/src/hooks/use-oauth-flow.ts index 68ed4eb3a..1203ea51f 100644 --- a/frontend/src/hooks/use-oauth-flow.ts +++ b/frontend/src/hooks/use-oauth-flow.ts @@ -59,7 +59,8 @@ export function describeOAuthFlowError(err: OAuthFlowError): string { case "popup_blocked": return "Your browser blocked the OAuth popup. Allow popups for this site and try again."; case "cancelled": return "You closed the authorization window. No connection was made."; case "init_failed": return `Couldn't start the OAuth flow: ${err.message}`; - case "provider_error": return err.providerError ? `Provider rejected: ${err.providerError}` : err.message; + // The callback's own sentence when it gave one; its code alone ("credential_account_mismatch") tells a person nothing. `message` IS the code when there was no sentence. + case "provider_error": return err.providerError && err.message === err.providerError ? `Provider rejected: ${err.providerError}` : err.message; case "timeout": return "The authorization took too long. Try again."; default: return err.message; } diff --git a/frontend/src/lib/credentialHealth.test.ts b/frontend/src/lib/credentialHealth.test.ts index f3632a0cb..5086ded5c 100644 --- a/frontend/src/lib/credentialHealth.test.ts +++ b/frontend/src/lib/credentialHealth.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { CredentialSummary, RepositorySummary } from "@/api/types"; -import { credentialHealthWord, isLiveCredential, isStaticToken, repositoryConnectionIssue } from "./credentialHealth"; +import { canMoveBindingsOff, credentialHealthWord, dependentsLabel, isHistory, isLiveCredential, isStaticToken, repositoryConnectionIssue } from "./credentialHealth"; const credential = (over: Partial = {}): CredentialSummary => ({ id: "c1", teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u1", ownership: "Personal", authType: "OAuth", displayName: "GitLab · nacho", status: "Active", createdDate: "2026-01-01T00:00:00Z", ...over, @@ -41,6 +41,69 @@ describe("credentialHealthWord", () => { }); }); +describe("isHistory — a disconnected credential nothing leans on", () => { + it("is history only when the server says nothing leans on a disconnected credential", () => { + expect(isHistory(credential({ status: "Revoked", dependentCount: 0 }))).toBe(true); + expect(isHistory(credential({ status: "Active", health: "revoked", dependentCount: 0 }))).toBe(true); + }); + + it.each([ + ["something leans on it", { status: "Revoked", dependentCount: 1 }], + ["an older server says nothing about what leans on it", { status: "Revoked" }], + ["a server that sends no count", { status: "Revoked", dependentCount: null }], + ["it is still connected", { status: "Active", dependentCount: 0 }], + ["it was refused, not disconnected", { status: "Error", dependentCount: 0 }], + ["its owner left", { status: "Active", health: "owner_left", dependentCount: 0 }], + ] as const)("is not history when %s", (_why, over) => { + expect(isHistory(credential(over as Partial))).toBe(false); + }); +}); + +describe("canMoveBindingsOff — the credentials relink-bindings accepts as a source", () => { + it.each([ + ["a disconnected credential with repositories", { status: "Revoked", boundRepositoryCount: 3 }, true], + ["a refused credential with repositories", { status: "Error", boundRepositoryCount: 1 }, true], + ["an expired credential with repositories", { status: "Expired", boundRepositoryCount: 2 }, true], + ["a disconnected credential held only by a group hook: relink-bindings moves hooks with no repository to carry them", { status: "Revoked", boundRepositoryCount: 0, boundConnectionWebhookCount: 1 }, true], + ["a refused credential held only by a group hook", { status: "Error", boundConnectionWebhookCount: 2 }, true], + ["a disconnected credential held only by an act-as identity, which relink-bindings does not move", { status: "Revoked", boundRepositoryCount: 0, actAsIdentityCount: 1 }, false], + ["a disconnected credential with none", { status: "Revoked", boundRepositoryCount: 0 }, false], + ["a server that sends no count", { status: "Revoked" }, false], + ["a credential in service held by a group hook", { status: "Active", boundConnectionWebhookCount: 2 }, false], + ["a credential in service, however it reads", { status: "Active", health: "rejected", boundRepositoryCount: 4 }, false], + ["a working credential", { status: "Active", boundRepositoryCount: 4 }, false], + ["an expiring token", { status: "Active", health: "expiring", boundRepositoryCount: 4 }, false], + ] as const)("says %s: %s", (_what, over, expected) => { + expect(canMoveBindingsOff(credential(over as Partial))).toBe(expected); + }); +}); + +describe("dependentsLabel", () => { + it.each([ + [{ status: "Active", boundRepositoryCount: 1 }, "1 repository bound"], + [{ status: "Active", boundRepositoryCount: 3 }, "3 repositories bound"], + [{ status: "Active", health: "expiring", boundRepositoryCount: 2 }, "2 repositories bound"], + [{ status: "Active", boundRepositoryCount: 2, boundConnectionWebhookCount: 3 }, "2 repositories bound"], // a working credential says what a reconnect puts at stake: its repositories + [{ status: "Revoked", boundRepositoryCount: 1 }, "1 repository still uses it"], + [{ status: "Revoked", boundRepositoryCount: 3 }, "3 repositories still use it"], + [{ status: "Error", boundRepositoryCount: 5 }, "5 repositories still use it"], + [{ status: "Active", health: "rejected", boundRepositoryCount: 2 }, "2 repositories still use it"], + [{ status: "Revoked", boundRepositoryCount: 0, boundConnectionWebhookCount: 1 }, "1 group hook still uses it"], + [{ status: "Revoked", boundRepositoryCount: 0, boundConnectionWebhookCount: 4 }, "4 group hooks still use it"], + [{ status: "Revoked", boundRepositoryCount: 0, actAsIdentityCount: 1 }, "1 act-as identity still uses it"], + [{ status: "Revoked", boundRepositoryCount: 2, boundConnectionWebhookCount: 1 }, "2 repositories and 1 group hook still use it"], + [{ status: "Error", boundRepositoryCount: 1, boundConnectionWebhookCount: 2, actAsIdentityCount: 3 }, "1 repository, 2 group hooks and 3 act-as identities still use it"], + ] as const)("reads %j as '%s'", (over, label) => { + expect(dependentsLabel(credential(over as Partial))).toBe(label); + }); + + it("says nothing when nothing leans on it, and reads an older server's missing counts as none", () => { + expect(dependentsLabel(credential({ status: "Revoked", boundRepositoryCount: 0 }))).toBeNull(); + expect(dependentsLabel(credential({ status: "Revoked" }))).toBeNull(); + expect(dependentsLabel(credential({ status: "Active", boundRepositoryCount: 0, boundConnectionWebhookCount: 2 }))).toBeNull(); + }); +}); + describe("repositoryConnectionIssue — joined by credentialId, not only status Error", () => { it("is nothing for a repository whose credential works", () => { expect(repositoryConnectionIssue(repository(), byId(credential()), true)).toBeNull(); diff --git a/frontend/src/lib/credentialHealth.ts b/frontend/src/lib/credentialHealth.ts index 3781ef331..997936323 100644 --- a/frontend/src/lib/credentialHealth.ts +++ b/frontend/src/lib/credentialHealth.ts @@ -27,6 +27,52 @@ export function isLiveCredential(credential: CredentialSummary): boolean { return word === "active" || word === "expiring"; } +/** + * A disconnected credential nothing leans on: no repository, group hook or act-as identity is left on it, so it is only history, and the + * dialog folds it away and offers to remove it. Only a server that sends `dependentCount` can say so — one that predates it says nothing, + * which is also the one that has no removal, so its rows are listed as they always were. + */ +export function isHistory(credential: CredentialSummary): boolean { + return credentialHealthWord(credential) === "disconnected" && credential.dependentCount === 0; +} + +/** + * The credentials whose bindings `relink-bindings` moves in one action: a disconnected or refused credential that still holds repositories or group hooks + * (the two it carries — a hook-only credential moves too). Not one still in service, whatever it reads — the server refuses a source that is `Active` (a Disconnect that + * names where they go is how those are moved), so a token whose expiry passed but whose row is not yet marked is not offered a move that could only answer 400. + * Not a credential held only by act-as identities: the move leaves those where they are. + */ +export function canMoveBindingsOff(credential: CredentialSummary): boolean { + const word = credentialHealthWord(credential); + + return (word === "disconnected" || word === "rejected") && credential.status !== "Active" && ((credential.boundRepositoryCount ?? 0) > 0 || (credential.boundConnectionWebhookCount ?? 0) > 0); +} + +/** + * What leans on a credential, in a few words. A working one says what a reconnect or a disconnect puts at stake — "3 repositories bound". A dead one says why it is + * still listed — "3 repositories still use it", or with the group hooks and act-as identities that hold it as well ("2 repositories and 1 group hook still use it") — so + * a row held only by a hook still gives its reason. Null when nothing is on it (an older server's missing counts too). + */ +export function dependentsLabel(credential: CredentialSummary): string | null { + const repositories = credential.boundRepositoryCount ?? 0; + const word = credentialHealthWord(credential); + + if (word !== "disconnected" && word !== "rejected") return repositories > 0 ? `${repositories} ${repositories === 1 ? "repository" : "repositories"} bound` : null; + + const kinds = [ + { count: repositories, one: "repository", many: "repositories" }, + { count: credential.boundConnectionWebhookCount ?? 0, one: "group hook", many: "group hooks" }, + { count: credential.actAsIdentityCount ?? 0, one: "act-as identity", many: "act-as identities" }, + ].filter(k => k.count > 0); + + if (kinds.length === 0) return null; + + const parts = kinds.map(k => `${k.count} ${k.count === 1 ? k.one : k.many}`); + const subject = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`; + + return `${subject} ${kinds.length === 1 && kinds[0].count === 1 ? "still uses" : "still use"} it`; +} + /** A pasted token (personal, project or group) rather than a sign-in — decides Replace token versus Reconnect. */ export function isStaticToken(credential: Pick): boolean { return credential.authType === "Pat" || credential.authType === "ProjectAccessToken" || credential.authType === "GroupAccessToken"; diff --git a/frontend/src/lib/formatRelative.test.ts b/frontend/src/lib/formatRelative.test.ts new file mode 100644 index 000000000..0c9651bca --- /dev/null +++ b/frontend/src/lib/formatRelative.test.ts @@ -0,0 +1,28 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { formatRelative } from "./formatRelative"; + +describe("formatRelative", () => { + const now = new Date("2026-10-19T12:00:00Z"); + const before = (seconds: number) => new Date(now.getTime() - seconds * 1000).toISOString(); + + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(now); }); + afterEach(() => { vi.useRealTimers(); }); + + it.each([ + [0, "just now"], + [59, "just now"], + [60, "1 min ago"], + [3599, "59 min ago"], + [3600, "1 hr ago"], + [86399, "23 hr ago"], + [86400, "1 d ago"], + [21 * 86400, "21 d ago"], + ])("reads %i seconds ago as '%s'", (seconds, expected) => { + expect(formatRelative(before(seconds))).toBe(expected); + }); + + it("reads a time slightly in the future (clock skew with the server) as just now, never as negative", () => { + expect(formatRelative(new Date(now.getTime() + 5000).toISOString())).toBe("just now"); + }); +}); diff --git a/frontend/src/lib/formatRelative.ts b/frontend/src/lib/formatRelative.ts new file mode 100644 index 000000000..984801cc6 --- /dev/null +++ b/frontend/src/lib/formatRelative.ts @@ -0,0 +1,10 @@ +/** "just now", "5 min ago", "3 hr ago", "21 d ago" — how long ago an ISO timestamp was, in the coarsest unit that is not zero. */ +export function formatRelative(iso: string): string { + const then = new Date(iso).getTime(); + const seconds = Math.max(0, Math.round((Date.now() - then) / 1000)); + + if (seconds < 60) return "just now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)} hr ago`; + return `${Math.floor(seconds / 86400)} d ago`; +} diff --git a/frontend/src/styles/ai-code-space.css b/frontend/src/styles/ai-code-space.css index c97a9dadb..6b5a1b6d6 100644 --- a/frontend/src/styles/ai-code-space.css +++ b/frontend/src/styles/ai-code-space.css @@ -1689,6 +1689,19 @@ html, body, * { scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, .16) trans .cn-cred-since { font-size: 11.5px; color: var(--muted); white-space: nowrap; } .cn-cred-x { flex-shrink: 0; height: 28px; padding: 0 10px; font-size: 12px; } .cn-cred-main .cn-cred-x:first-of-type { margin-left: auto; } +/* The ⋯ that holds a working sign-in's Re-authorize: the action buttons' height, a glyph's width. */ +.cn-cred-menu { padding: 0 8px; } +/* What a finished action says in its row — the warm "good" green the room uses for a repaired connection. Refusals use .cn-row-err. */ +.cn-note-ok { display: flex; align-items: center; gap: 6px; padding: 5px 9px; border: 1px solid #C2E0CF; border-radius: 6px; background: #E9F4EE; color: #1F6B3F; font-size: 12px; line-height: 1.4; } +.cn-note-ok svg { flex-shrink: 0; } +/* Dead rows nothing leans on, folded into one quiet line (EarlierConnections) — muted, small, never louder than the credentials that work. */ +.cn-earlier { display: flex; flex-direction: column; gap: 6px; } +.cn-earlier-toggle { display: inline-flex; align-items: center; gap: 5px; align-self: flex-start; padding: 2px 4px; border: 0; background: transparent; font-size: 11.5px; color: var(--muted); cursor: pointer; } +.cn-earlier-toggle:hover { color: var(--ink-2); } +.cn-earlier-toggle svg { transition: transform .12s; } +.cn-earlier-toggle[aria-expanded="true"] svg { transform: rotate(90deg); } +.cn-earlier-list { display: flex; flex-direction: column; gap: 4px; } +.cn-earlier-row { display: flex; flex-direction: column; gap: 5px; padding: 4px 10px; border: 1px dashed var(--line); border-radius: 8px; } /* Capability warnings — a wrapping line listing what this token CAN'T do. A leading amber label ("Missing access") explains the chips; the row wraps freely so two or six warnings both fit without overflowing the credential card. */ @@ -1723,9 +1736,9 @@ html, body, * { scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, .16) trans .cn-tg-items { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; margin-left: 46px; } .cn-tg-note { font-size: 12px; color: var(--muted); line-height: 1.45; } .cn-tg-add { align-self: flex-start; } -.cn-tg-cred { display: flex; align-items: center; gap: 8px; padding: 6px 8px 6px 10px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; } +.cn-tg-cred { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; padding: 6px 8px 6px 10px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; } .cn-tg-cred svg { color: var(--muted); flex-shrink: 0; } -.cn-tg-cred-name { flex: 1; min-width: 0; font-size: 12.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cn-tg-cred-name { flex: 1 1 150px; min-width: 0; font-size: 12.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cn-tg-cred .btn { height: 26px; padding: 0 10px; font-size: 12px; } .cn-pv-list { display: flex; flex-direction: column; gap: 10px; } .cn-pv-card { display: flex; align-items: center; gap: 14px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); cursor: pointer; transition: border-color .12s, background .12s; }