diff --git a/rubberduckvba.Server/Api/Admin/AdminController.cs b/rubberduckvba.Server/Api/Admin/AdminController.cs index c478ca3..0fbd7af 100644 --- a/rubberduckvba.Server/Api/Admin/AdminController.cs +++ b/rubberduckvba.Server/Api/Admin/AdminController.cs @@ -2,19 +2,21 @@ using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using rubberduckvba.Server.Model.Entity; using rubberduckvba.Server.Services; +using System.Security.Principal; namespace rubberduckvba.Server.Api.Admin; [ApiController] -public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire, CacheService cache) : ControllerBase +[EnableCors(CorsPolicies.AllowAll)] +public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire, CacheService cache, IAuditService audits) : ControllerBase { /// /// Enqueues a job that updates xmldoc content from the latest release/pre-release tags. /// /// The unique identifier of the enqueued job. - [Authorize("github")] - [EnableCors(CorsPolicies.AllowAuthenticated)] + [Authorize("github", Roles = RDConstants.Roles.AdminRole)] [HttpPost("admin/update/xmldoc")] public IActionResult UpdateXmldocContent() { @@ -26,8 +28,7 @@ public IActionResult UpdateXmldocContent() /// Enqueues a job that gets the latest release/pre-release tags and their respective assets, and updates the installer download stats. /// /// The unique identifier of the enqueued job. - [Authorize("github")] - [EnableCors(CorsPolicies.AllowAuthenticated)] + [Authorize("github", Roles = RDConstants.Roles.AdminRole)] [HttpPost("admin/update/tags")] public IActionResult UpdateTagMetadata() { @@ -35,8 +36,7 @@ public IActionResult UpdateTagMetadata() return Ok(jobId); } - [Authorize("github")] - [EnableCors(CorsPolicies.AllowAuthenticated)] + [Authorize("github", Roles = RDConstants.Roles.AdminRole)] [HttpPost("admin/cache/clear")] public IActionResult ClearCache() { @@ -44,9 +44,153 @@ public IActionResult ClearCache() return Ok(); } + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole},{RDConstants.Roles.WriterRole}")] + [HttpGet("admin/audits/pending")] + public async Task GetPendingAudits() + { + var edits = await audits.GetPendingItems(User.Identity); + var ops = await audits.GetPendingItems(User.Identity); + + return Ok(new { edits = edits.ToArray(), other = ops.ToArray() }); + } + + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole},{RDConstants.Roles.WriterRole}")] + [HttpGet("profile/activity")] + public async Task GetUserActivity() + { + if (User.Identity is not IIdentity identity) + { + // this is arguably a bug in the authentication middleware, but we can handle it gracefully here + return Unauthorized("User identity is not available."); + } + + var activity = await audits.GetAllActivity(identity); + return Ok(activity); + } + + private static readonly AuditActivityType[] EditActivityTypes = [ + AuditActivityType.SubmitEdit, + AuditActivityType.ApproveEdit, + AuditActivityType.RejectEdit + ]; + + private static readonly AuditActivityType[] OpActivityTypes = [ + AuditActivityType.SubmitCreate, + AuditActivityType.ApproveCreate, + AuditActivityType.RejectCreate, + AuditActivityType.SubmitDelete, + AuditActivityType.ApproveDelete, + AuditActivityType.RejectDelete + ]; + + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] + [HttpGet("admin/audits/{id}")] + public async Task GetAudit([FromRoute] int id, [FromQuery] string type) + { + if (!Enum.TryParse(type, ignoreCase: true, out var validType)) + { + return BadRequest("Invalid activity type."); + } + + var edit = (FeatureEditViewEntity?)null; + var op = (FeatureOpEntity?)null; + + if (EditActivityTypes.Contains(validType)) + { + edit = await audits.GetItem(id); + } + else if (OpActivityTypes.Contains(validType)) + { + op = await audits.GetItem(id); + } + + return Ok(new { edits = new[] { edit }, other = op is null ? [] : new[] { op } }); + } + + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] + [HttpGet("admin/audits/feature/{featureId}")] + public async Task GetPendingAudits([FromRoute] int featureId) + { + var edits = await audits.GetPendingItems(User.Identity, featureId); + var ops = await audits.GetPendingItems(User.Identity, featureId); + + return Ok(new { edits = edits.ToArray(), other = ops.ToArray() }); + } + + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] + [HttpPost("admin/audits/approve/{id}")] + public async Task ApprovePendingAudit([FromRoute] int id) + { + if (User.Identity is not IIdentity identity) + { + // this is arguably a bug in the authentication middleware, but we can handle it gracefully here + return Unauthorized("User identity is not available."); + } + + var edits = await audits.GetPendingItems(User.Identity); + AuditEntity? audit; + + audit = edits.SingleOrDefault(e => e.Id == id); + if (audit is null) + { + var ops = await audits.GetPendingItems(User.Identity); + audit = ops.SingleOrDefault(e => e.Id == id); + } + + if (audit is null) + { + // TODO log this + return BadRequest("Invalid ID"); + } + + if (!audit.IsPending) + { + // TODO log this + return BadRequest($"This operation has already been audited"); + } + + await audits.Approve(audit, identity); + return Ok("Operation was approved successfully."); + } + + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] + [HttpPost("admin/audits/reject/{id}")] + public async Task RejectPendingAudit([FromRoute] int id) + { + if (User.Identity is not IIdentity identity) + { + // this is arguably a bug in the authentication middleware, but we can handle it gracefully here + return Unauthorized("User identity is not available."); + } + + var edits = await audits.GetPendingItems(User.Identity); + AuditEntity? audit; + + audit = edits.SingleOrDefault(e => e.Id == id); + if (audit is null) + { + var ops = await audits.GetPendingItems(User.Identity); + audit = ops.SingleOrDefault(e => e.Id == id); + } + + if (audit is null) + { + // TODO log this + return BadRequest("Invalid ID"); + } + + if (!audit.IsPending) + { + // TODO log this + return BadRequest($"This operation has already been audited"); + } + + await audits.Reject(audit, identity); + return Ok("Operation was rejected successfully."); + } + #if DEBUG [AllowAnonymous] - [EnableCors(CorsPolicies.AllowAll)] [HttpGet("admin/config/current")] public IActionResult Config() { diff --git a/rubberduckvba.Server/Api/Auth/AuthController.cs b/rubberduckvba.Server/Api/Auth/AuthController.cs index 31f50b6..7c72f03 100644 --- a/rubberduckvba.Server/Api/Auth/AuthController.cs +++ b/rubberduckvba.Server/Api/Auth/AuthController.cs @@ -10,11 +10,13 @@ namespace rubberduckvba.Server.Api.Auth; public record class UserViewModel { - public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", IsAuthenticated = false, IsAdmin = false }; + public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", IsAuthenticated = false, IsAdmin = false, IsReviewer = false, IsWriter = false }; public string Name { get; init; } = default!; public bool IsAuthenticated { get; init; } public bool IsAdmin { get; init; } + public bool IsReviewer { get; init; } + public bool IsWriter { get; init; } } public record class SignInViewModel @@ -59,7 +61,9 @@ public IActionResult Index() { Name = name, IsAuthenticated = isAuthenticated, - IsAdmin = role == configuration.Value.OwnerOrg + IsAdmin = role == RDConstants.Roles.AdminRole, + IsReviewer = role == RDConstants.Roles.AdminRole || role == RDConstants.Roles.ReviewerRole, + IsWriter = role == RDConstants.Roles.WriterRole || role == RDConstants.Roles.AdminRole || role == RDConstants.Roles.ReviewerRole, }; return Ok(model); @@ -115,15 +119,16 @@ public IActionResult OnGitHubCallback(SignInViewModel vm) { return GuardInternalAction(() => { - Logger.LogInformation("OAuth token was received. State: {state}", vm.State); + Logger.LogInformation("OAuth code was received. State: {state}", vm.State); var clientId = configuration.Value.ClientId; var clientSecret = configuration.Value.ClientSecret; var agent = configuration.Value.UserAgent; var github = new GitHubClient(new ProductHeaderValue(agent)); - var request = new OauthTokenRequest(clientId, clientSecret, vm.Code); + var token = github.Oauth.CreateAccessToken(request).GetAwaiter().GetResult(); + if (token is null) { Logger.LogWarning("OAuth access token was not created."); @@ -171,6 +176,13 @@ public IActionResult OnGitHubCallback(SignInViewModel vm) Thread.CurrentPrincipal = HttpContext.User; Logger.LogInformation("GitHub user with login {login} has signed in with role authorizations '{role}'.", githubUser.Login, configuration.Value.OwnerOrg); + Response.Cookies.Append(GitHubAuthenticationHandler.AuthCookie, token, new CookieOptions + { + IsEssential = true, + HttpOnly = true, + Secure = true, + Expires = DateTimeOffset.UtcNow.AddHours(1) + }); return token; } else diff --git a/rubberduckvba.Server/Api/Auth/ClaimsPrincipalExtensions.cs b/rubberduckvba.Server/Api/Auth/ClaimsPrincipalExtensions.cs index 076c126..e6bb0cf 100644 --- a/rubberduckvba.Server/Api/Auth/ClaimsPrincipalExtensions.cs +++ b/rubberduckvba.Server/Api/Auth/ClaimsPrincipalExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Security.Principal; using System.Text; namespace rubberduckvba.Server.Api.Auth; @@ -18,4 +19,54 @@ public static string AsJWT(this ClaimsPrincipal principal, string secret, string return new JwtSecurityTokenHandler().WriteToken(token); } + + /// + /// true if the user is authenticated and has the rd-admin role. + /// + public static bool IsAdmin(this ClaimsPrincipal principal) + { + return principal.IsInRole(RDConstants.Roles.AdminRole); + } + + /// + /// true if the user is authenticated and has the rd-reviewer or rd-admin role. + /// + public static bool IsReviewer(this ClaimsPrincipal principal) + { + return principal.IsInRole(RDConstants.Roles.ReviewerRole); + } } + +public static class ClaimsIdentityExtensions +{ + /// + /// true if the user is authenticated and has the rd-admin role. + /// + public static bool IsAdmin(this IIdentity identity) + { + return identity is ClaimsIdentity claimsIdentity && claimsIdentity.IsAdmin(); + } + + /// + /// true if the user is authenticated and has the rd-reviewer or rd-admin role. + /// + public static bool IsReviewer(this IIdentity identity) + { + return identity is ClaimsIdentity claimsIdentity && claimsIdentity.IsReviewer(); + } + + /// + /// true if the user is authenticated and has the rd-admin role. + /// + public static bool IsAdmin(this ClaimsIdentity identity) + { + return identity.IsAuthenticated && identity.HasClaim(ClaimTypes.Role, RDConstants.Roles.AdminRole); + } + /// + /// true if the user is authenticated and has the rd-reviewer or rd-admin role. + /// + public static bool IsReviewer(this ClaimsIdentity identity) + { + return identity != null && identity.IsAuthenticated && (identity.HasClaim(ClaimTypes.Role, RDConstants.Roles.AdminRole) || identity.HasClaim(ClaimTypes.Role, RDConstants.Roles.ReviewerRole)); + } +} \ No newline at end of file diff --git a/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs b/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs index 62868d6..136e769 100644 --- a/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs +++ b/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs @@ -17,7 +17,7 @@ public static FeatureEditViewModel Default(RepositoryId repository, FeatureOptio Repositories = repositories, RepositoryId = repository, - ParentId = parent?.Id, + FeatureId = parent?.Id, Name = parent is null ? "NewFeature" : $"New{parent.Name}Feature", Title = "Feature Title", ShortDescription = "A short description; markdown is supported.", @@ -29,7 +29,7 @@ public Feature ToFeature() return new Feature { Id = Id ?? default, - FeatureId = ParentId, + FeatureId = FeatureId, RepositoryId = RepositoryId, Name = Name, Title = Title, @@ -43,7 +43,7 @@ public Feature ToFeature() public FeatureEditViewModel(Feature model, FeatureOptionViewModel[] features, RepositoryOptionViewModel[] repositories) { Id = model.Id; - ParentId = model.FeatureId; + FeatureId = model.FeatureId; RepositoryId = model.RepositoryId; Name = model.Name; @@ -59,7 +59,7 @@ public FeatureEditViewModel(Feature model, FeatureOptionViewModel[] features, Re } public int? Id { get; init; } - public int? ParentId { get; init; } + public int? FeatureId { get; init; } public RepositoryId RepositoryId { get; init; } public string Name { get; init; } diff --git a/rubberduckvba.Server/Api/Features/FeatureViewModel.cs b/rubberduckvba.Server/Api/Features/FeatureViewModel.cs index a47439d..1b0b07a 100644 --- a/rubberduckvba.Server/Api/Features/FeatureViewModel.cs +++ b/rubberduckvba.Server/Api/Features/FeatureViewModel.cs @@ -242,7 +242,6 @@ public class InspectionsFeatureViewModel : FeatureViewModel public InspectionsFeatureViewModel(FeatureGraph model, IEnumerable quickFixes, IDictionary tagsByAssetId, bool summaryOnly = false) : base(model, summaryOnly) { - Inspections = model.Inspections.OrderBy(e => e.Name).Select(e => new InspectionViewModel(e, quickFixes, tagsByAssetId)).ToArray(); } diff --git a/rubberduckvba.Server/Api/Features/FeaturesController.cs b/rubberduckvba.Server/Api/Features/FeaturesController.cs index 6868419..42352a0 100644 --- a/rubberduckvba.Server/Api/Features/FeaturesController.cs +++ b/rubberduckvba.Server/Api/Features/FeaturesController.cs @@ -6,28 +6,39 @@ using rubberduckvba.Server.Model.Entity; using rubberduckvba.Server.Services; using rubberduckvba.Server.Services.rubberduckdb; +using System.Security.Principal; namespace rubberduckvba.Server.Api.Features; +public class MarkdownContentViewModel +{ + public string Content { get; init; } = string.Empty; +} + [AllowAnonymous] +[EnableCors(CorsPolicies.AllowAll)] public class FeaturesController : RubberduckApiController { private readonly CacheService cache; private readonly IRubberduckDbService db; + private readonly IAuditService admin; private readonly FeatureServices features; private readonly IRepository assetsRepository; private readonly IRepository tagsRepository; + private readonly IMarkdownFormattingService markdownService; - public FeaturesController(CacheService cache, IRubberduckDbService db, FeatureServices features, + public FeaturesController(CacheService cache, IRubberduckDbService db, IAuditService admin, FeatureServices features, IMarkdownFormattingService markdownService, IRepository assetsRepository, IRepository tagsRepository, ILogger logger) : base(logger) { this.cache = cache; this.db = db; + this.admin = admin; this.features = features; this.assetsRepository = assetsRepository; this.tagsRepository = tagsRepository; + this.markdownService = markdownService; } private static RepositoryOptionViewModel[] RepositoryOptions { get; } = @@ -38,8 +49,6 @@ await db.GetTopLevelFeatures(repositoryId) .ContinueWith(t => t.Result.Select(e => new FeatureOptionViewModel { Id = e.Id, Name = e.Name, Title = e.Title }).ToArray()); [HttpGet("features")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Index() { return GuardInternalAction(() => @@ -68,8 +77,6 @@ public IActionResult Index() } [HttpGet("features/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Info([FromRoute] string name) { return GuardInternalAction(() => @@ -85,8 +92,6 @@ public IActionResult Info([FromRoute] string name) } [HttpGet("inspections/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Inspection([FromRoute] string name) { return GuardInternalAction(() => @@ -107,8 +112,6 @@ public IActionResult Inspection([FromRoute] string name) } [HttpGet("annotations/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Annotation([FromRoute] string name) { return GuardInternalAction(() => @@ -129,8 +132,6 @@ public IActionResult Annotation([FromRoute] string name) } [HttpGet("quickfixes/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult QuickFix([FromRoute] string name) { return GuardInternalAction(() => @@ -151,8 +152,7 @@ public IActionResult QuickFix([FromRoute] string name) } [HttpGet("features/create")] - [EnableCors(CorsPolicies.AllowAuthenticated)] - [Authorize("github")] + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] public async Task> Create([FromQuery] RepositoryId repository = RepositoryId.Rubberduck, [FromQuery] int? parentId = default) { var features = await GetFeatureOptions(repository); @@ -162,9 +162,8 @@ public async Task> Create([FromQuery] Reposit return Ok(model); } - [HttpPost("create")] - [EnableCors(CorsPolicies.AllowAuthenticated)] - [Authorize("github")] + [HttpPost("features/create")] + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] public async Task> Create([FromBody] FeatureEditViewModel model) { if (model.Id.HasValue || string.IsNullOrWhiteSpace(model.Name) || model.Name.Trim().Length < 3) @@ -172,22 +171,26 @@ public async Task> Create([FromBody] FeatureE return BadRequest("Model is invalid for this endpoint."); } - var existing = await db.ResolveFeature(model.RepositoryId, model.Name); - if (existing != null) + var existingId = await db.GetFeatureId(model.RepositoryId, model.Name); + if (existingId != null) { return BadRequest($"Model [Name] must be unique; feature '{model.Name}' already exists."); } var feature = model.ToFeature(); - var result = await db.SaveFeature(feature); - - var features = await GetFeatureOptions(model.RepositoryId); - return Ok(new FeatureEditViewModel(result, features, RepositoryOptions)); + if (User.Identity is IIdentity identity) + { + await admin.CreateFeature(feature, identity); + return Ok(feature); + } + else + { + return Unauthorized("User identity is not available."); + } } [HttpPost("features/update")] - [EnableCors(CorsPolicies.AllowAuthenticated)] - [Authorize("github")] + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] public async Task> Update([FromBody] FeatureEditViewModel model) { if (model.Id.GetValueOrDefault() == default) @@ -195,16 +198,57 @@ public async Task> Update([FromBody] FeatureE return BadRequest("Model is invalid for this endpoint."); } - var existing = await db.ResolveFeature(model.RepositoryId, model.Name); - if (existing is null) + var existingId = await db.GetFeatureId(model.RepositoryId, model.Name); + if (existingId is null) { return BadRequest("Model is invalid for this endpoint."); } - var result = await db.SaveFeature(model.ToFeature()); - var features = await GetFeatureOptions(model.RepositoryId); + var feature = model.ToFeature(); + if (User.Identity is IIdentity identity) + { + await admin.UpdateFeature(feature, identity); + return Ok(feature); + } + else + { + return Unauthorized("User identity is not available."); + } + } + + [HttpPost("features/delete")] + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] + public async Task Delete([FromBody] Feature model) + { + if (model.Id == default) + { + throw new ArgumentException("Model is invalid for this endpoint."); + } + var existing = await db.ResolveFeature(RepositoryId.Rubberduck, model.Name); + if (existing is null) + { + throw new ArgumentException("Model is invalid for this endpoint."); + } + + if (User.Identity is IIdentity identity) + { + await admin.DeleteFeature(existing, identity); + } + else + { + throw new UnauthorizedAccessException("User identity is not available."); + } + } - return new FeatureEditViewModel(result, features, RepositoryOptions); + [HttpPost("markdown/format")] + public MarkdownContentViewModel FormatMarkdown([FromBody] MarkdownContentViewModel model) + { + var markdown = model.Content; + var formatted = markdownService.FormatMarkdownDocument(markdown, withSyntaxHighlighting: true); + return new MarkdownContentViewModel + { + Content = formatted + }; } private InspectionsFeatureViewModel GetInspections() @@ -274,5 +318,4 @@ private FeatureViewModel GetFeature(string name) return result; } - -} +} \ No newline at end of file diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs index ee71d83..ce978e2 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs @@ -203,7 +203,7 @@ public static void ThrowIfNotNull(params T?[] values) { if (values.Any(e => e != null)) { - throw new ContextAlreadyInitializedException(); + //throw new ContextAlreadyInitializedException(); } } } \ No newline at end of file diff --git a/rubberduckvba.Server/Data/FeaturesRepository.cs b/rubberduckvba.Server/Data/FeaturesRepository.cs index ba987ea..1bc2e29 100644 --- a/rubberduckvba.Server/Data/FeaturesRepository.cs +++ b/rubberduckvba.Server/Data/FeaturesRepository.cs @@ -17,7 +17,7 @@ public FeaturesRepository(IOptions settings) a.[DateTimeInserted], a.[DateTimeUpdated], a.[RepositoryId], - a.[ParentId] AS [FeatureId], + a.[ParentId], f.[Name] AS [FeatureName], a.[Name], a.[Title], diff --git a/rubberduckvba.Server/Data/Repository.cs b/rubberduckvba.Server/Data/Repository.cs index d2bdbc8..0c60312 100644 --- a/rubberduckvba.Server/Data/Repository.cs +++ b/rubberduckvba.Server/Data/Repository.cs @@ -9,6 +9,7 @@ namespace rubberduckvba.Server.Data; public interface IRepository where TEntity : Entity { + bool TryGetId(string name, out int id); int GetId(string name); TEntity GetById(int id); IEnumerable GetAll(); @@ -17,6 +18,7 @@ public interface IRepository where TEntity : Entity IEnumerable Insert(IEnumerable entities); void Update(TEntity entity); void Update(IEnumerable entities); + void Delete(int id); } public abstract class QueryableRepository where T : class @@ -71,8 +73,34 @@ ParentFKColumnName is null || !parentId.HasValue ? GetAll() : Query(db => db.Query($"{SelectSql} WHERE a.[{ParentFKColumnName}]=@parentId", new { parentId })); + public virtual bool TryGetId(string name, out int id) + { + id = default; + + var result = Get(db => db.QuerySingleOrDefault($"SELECT [Id] FROM [dbo].[{TableName}] WHERE [Name]=@name", new { name })); + if (result.HasValue) + { + id = result.Value; + return true; + } + + return false; + } + public virtual int GetId(string name) => Get(db => db.QuerySingle($"SELECT [Id] FROM [dbo].[{TableName}] WHERE [Name]=@name", new { name })); public virtual TEntity GetById(int id) => Get(db => db.QuerySingle(SelectSql + " WHERE a.[Id]=@id", new { id })); + + public virtual void Delete(int id) + { + using var db = new SqlConnection(ConnectionString); + db.Open(); + + using var transaction = db.BeginTransaction(); + db.Execute($"DELETE FROM [dbo].[{TableName}] WHERE [Id]=@id", new { id }, transaction); + + transaction.Commit(); + } + public virtual TEntity Insert(TEntity entity) => Insert([entity]).Single(); public virtual IEnumerable Insert(IEnumerable entities) { diff --git a/rubberduckvba.Server/GitHubAuthenticationHandler.cs b/rubberduckvba.Server/GitHubAuthenticationHandler.cs index 51ee239..5a8cfe3 100644 --- a/rubberduckvba.Server/GitHubAuthenticationHandler.cs +++ b/rubberduckvba.Server/GitHubAuthenticationHandler.cs @@ -1,7 +1,8 @@ - -using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using rubberduckvba.Server.Services; +using System.Collections.Concurrent; using System.Security.Claims; using System.Text.Encodings.Web; @@ -9,39 +10,117 @@ namespace rubberduckvba.Server; public class GitHubAuthenticationHandler : AuthenticationHandler { + public static readonly string AuthCookie = "x-access-token"; + private readonly IGitHubClientService _github; + private readonly IMemoryCache _cache; + + private static readonly MemoryCacheEntryOptions _options = new MemoryCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(60), + }; - public GitHubAuthenticationHandler(IGitHubClientService github, + public GitHubAuthenticationHandler(IGitHubClientService github, IMemoryCache cache, IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { _github = github; + _cache = cache; } - protected async override Task HandleAuthenticateAsync() + private static readonly ConcurrentDictionary> _authApiTask = new(); + + protected override Task HandleAuthenticateAsync() { try { - var token = Context.Request.Headers["X-ACCESS-TOKEN"].SingleOrDefault(); + var token = Context.Request.Cookies[AuthCookie] + ?? Context.Request.Headers[AuthCookie]; + if (string.IsNullOrWhiteSpace(token)) { - return AuthenticateResult.Fail("Access token was not provided"); + return Task.FromResult(AuthenticateResult.Fail("Access token was not provided")); + } + + if (TryAuthenticateFromCache(token, out var cachedResult)) + { + return Task.FromResult(cachedResult)!; + } + + if (TryAuthenticateGitHubToken(token, out var result) + && result is AuthenticateResult + && result.Ticket is AuthenticationTicket ticket) + { + CacheAuthenticatedTicket(token, ticket); + return Task.FromResult(result!); } - var principal = await _github.ValidateTokenAsync(token); - if (principal is ClaimsPrincipal) + if (TryAuthenticateFromCache(token, out cachedResult)) { - Context.User = principal; - Thread.CurrentPrincipal = principal; - return AuthenticateResult.Success(new AuthenticationTicket(principal, "github")); + return Task.FromResult(cachedResult!); } - return AuthenticateResult.Fail("An invalid access token was provided"); + return Task.FromResult(AuthenticateResult.Fail("Missing or invalid access token")); } catch (InvalidOperationException e) { Logger.LogError(e, e.Message); - return AuthenticateResult.NoResult(); + return Task.FromResult(AuthenticateResult.NoResult()); + } + } + + private void CacheAuthenticatedTicket(string token, AuthenticationTicket ticket) + { + if (!string.IsNullOrWhiteSpace(token) && ticket.Principal.Identity?.IsAuthenticated == true) + { + _cache.Set(token, ticket, _options); + } + } + + private bool TryAuthenticateFromCache(string token, out AuthenticateResult? result) + { + result = null; + if (_cache.TryGetValue(token, out var cached) && cached is AuthenticationTicket cachedTicket) + { + var cachedPrincipal = cachedTicket.Principal; + + Context.User = cachedPrincipal; + Thread.CurrentPrincipal = cachedPrincipal; + + Logger.LogInformation($"Successfully retrieved authentication ticket from cached token for {cachedPrincipal.Identity!.Name}; token will not be revalidated."); + result = AuthenticateResult.Success(cachedTicket); + return true; + } + return false; + } + + private bool TryAuthenticateGitHubToken(string token, out AuthenticateResult? result) + { + result = null; + if (_authApiTask.TryGetValue(token, out var task) && task is not null) + { + result = task.GetAwaiter().GetResult(); + return result is not null; + } + + _authApiTask[token] = AuthenticateGitHubAsync(token); + result = _authApiTask[token].GetAwaiter().GetResult(); + + _authApiTask[token] = null!; + return result is not null; + } + + private async Task AuthenticateGitHubAsync(string token) + { + var principal = await _github.ValidateTokenAsync(token); + if (principal is ClaimsPrincipal) + { + Context.User = principal; + Thread.CurrentPrincipal = principal; + + var ticket = new AuthenticationTicket(principal, "github"); + return AuthenticateResult.Success(ticket); } + return null; } } diff --git a/rubberduckvba.Server/GitHubSettings.cs b/rubberduckvba.Server/GitHubSettings.cs index 4ca4143..5e9e077 100644 --- a/rubberduckvba.Server/GitHubSettings.cs +++ b/rubberduckvba.Server/GitHubSettings.cs @@ -13,6 +13,36 @@ public record class ConnectionSettings public string HangfireDb { get; set; } = default!; } +public static class RDConstants +{ + public static class Org + { + public const int OrganisationId = 12832254; + public const string WebAdminTeam = "WebAdmin"; + public const string ContributorsTeam = "Contributors"; + } + + public static class Roles + { + /// + /// Anonymous users have this role. + /// + public const string ReaderRole = "rd-reader"; + /// + /// Authenticated (via GitHub OAuth2) users have this role. + /// + public const string WriterRole = "rd-writer"; + /// + /// Authenticated (via GitHub OAuth2) members of the Rubberduck organization have this role. + /// + public const string ReviewerRole = "rd-reviewer"; + /// + /// Authenticated (via GitHub OAuth2) members of the WebApin team within the Rubberduck organization have this role. + /// + public const string AdminRole = "rd-admin"; + } +} + public record class GitHubSettings { public string ClientId { get; set; } = default!; diff --git a/rubberduckvba.Server/Model/Entity/FeatureEntity.cs b/rubberduckvba.Server/Model/Entity/FeatureEntity.cs index 38b2803..9f9dcf4 100644 --- a/rubberduckvba.Server/Model/Entity/FeatureEntity.cs +++ b/rubberduckvba.Server/Model/Entity/FeatureEntity.cs @@ -1,10 +1,11 @@ -using System.Text.Json; +using System.Runtime.Serialization; +using System.Text.Json; namespace rubberduckvba.Server.Model.Entity; public record class FeatureEntity : Entity { - public int? FeatureId { get; init; } + public int? ParentId { get; init; } public string FeatureName { get; init; } = default!; public int RepositoryId { get; init; } public string Title { get; init; } = default!; @@ -19,3 +20,89 @@ public record class FeatureEntity : Entity } public record class BlogLink(string Name, string Url, string Author, string Published) { } + +public record class AuditEntity +{ + public int Id { get; init; } + public DateTime DateInserted { get; init; } + public DateTime? DateModified { get; init; } + public string Author { get; init; } = string.Empty; + + public string? ApprovedBy { get; init; } + public DateTime? ApprovedAt { get; init; } + public string? RejectedBy { get; init; } + public DateTime? RejectedAt { get; init; } + + public bool IsPending => !ApprovedAt.HasValue && !RejectedAt.HasValue; + public bool IsApproved => ApprovedAt.HasValue && !RejectedAt.HasValue; +} + +public enum AuditActivityType +{ + [EnumMember(Value = nameof(SubmitEdit))] + SubmitEdit, + [EnumMember(Value = nameof(ApproveEdit))] + ApproveEdit, + [EnumMember(Value = nameof(RejectEdit))] + RejectEdit, + [EnumMember(Value = nameof(SubmitCreate))] + SubmitCreate, + [EnumMember(Value = nameof(ApproveCreate))] + ApproveCreate, + [EnumMember(Value = nameof(RejectCreate))] + RejectCreate, + [EnumMember(Value = nameof(SubmitDelete))] + SubmitDelete, + [EnumMember(Value = nameof(ApproveDelete))] + ApproveDelete, + [EnumMember(Value = nameof(RejectDelete))] + RejectDelete, +} + +public record class AuditActivityEntity +{ + public int Id { get; init; } + public string Author { get; init; } = string.Empty; + public DateTime ActivityTimestamp { get; init; } + public string Activity { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public string? ReviewedBy { get; init; } +} + +public record class FeatureEditEntity : AuditEntity +{ + public int FeatureId { get; init; } + public string FieldName { get; init; } = string.Empty; + public string? ValueBefore { get; init; } + public string ValueAfter { get; init; } = string.Empty; +} + +public record class FeatureEditViewEntity : FeatureEditEntity +{ + public string FeatureName { get; init; } = string.Empty; + //public bool IsStale { get; init; } +} + +public enum FeatureOperation +{ + Create = 1, + Delete = 2, +} + +public record class FeatureOpEntity : AuditEntity +{ + public FeatureOperation FeatureAction { get; init; } + + public int? ParentId { get; init; } + public string FeatureName { get; init; } = default!; + public string Name { get; init; } = default!; + public string Title { get; init; } = default!; + public string ShortDescription { get; init; } = default!; + public string Description { get; init; } = default!; + public bool IsHidden { get; init; } + public bool IsNew { get; init; } + public bool HasImage { get; init; } + + public string Links { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/rubberduckvba.Server/Model/Feature.cs b/rubberduckvba.Server/Model/Feature.cs index b7d0b30..282ff7a 100644 --- a/rubberduckvba.Server/Model/Feature.cs +++ b/rubberduckvba.Server/Model/Feature.cs @@ -27,7 +27,7 @@ public Feature(FeatureEntity entity) : this() DateTimeInserted = entity.DateTimeInserted; DateTimeUpdated = entity.DateTimeUpdated; Name = entity.Name; - FeatureId = entity.FeatureId; + FeatureId = entity.ParentId; FeatureName = entity.FeatureName; RepositoryId = (Services.RepositoryId)entity.RepositoryId; Title = entity.Title; @@ -69,7 +69,7 @@ public Feature(FeatureEntity entity) : this() IsNew = IsNew, Name = Name, ShortDescription = ShortDescription, - FeatureId = FeatureId, + ParentId = FeatureId, FeatureName = FeatureName, RepositoryId = (int)Services.RepositoryId.Rubberduck, Title = Title, diff --git a/rubberduckvba.Server/Program.cs b/rubberduckvba.Server/Program.cs index 05feb62..6cb79bd 100644 --- a/rubberduckvba.Server/Program.cs +++ b/rubberduckvba.Server/Program.cs @@ -3,6 +3,7 @@ using Hangfire.Dashboard; using Hangfire.SqlServer; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using NLog.Config; using NLog.Extensions.Logging; @@ -35,7 +36,6 @@ public class HangfireAuthenticationFilter : IDashboardAuthorizationFilter public static class CorsPolicies { public const string AllowAll = "AllowAll"; - public const string AllowAuthenticated = "AllowAuthenticated"; } public class Program @@ -57,17 +57,9 @@ public static void Main(string[] args) { policy .SetIsOriginAllowed(origin => true) - .AllowAnyHeader() - .WithMethods("OPTIONS", "GET", "POST") - .Build(); - }); - builder.AddPolicy(CorsPolicies.AllowAuthenticated, policy => - { - policy - .SetIsOriginAllowed(origin => true) - .WithHeaders("X-ACCESS-TOKEN") .WithMethods("OPTIONS", "GET", "POST") - .AllowCredentials() + .AllowAnyHeader() + .AllowAnyOrigin() .Build(); }); }); @@ -235,6 +227,8 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSession(ConfigureSession); + services.AddSingleton(provider => new MemoryCache(new MemoryCacheOptions(), provider.GetRequiredService())); + services.AddSingleton(); } private static void ConfigureLogging(IServiceCollection services) diff --git a/rubberduckvba.Server/Services/GitHubClientService.cs b/rubberduckvba.Server/Services/GitHubClientService.cs index 76a1b7d..c7c1d27 100644 --- a/rubberduckvba.Server/Services/GitHubClientService.cs +++ b/rubberduckvba.Server/Services/GitHubClientService.cs @@ -42,21 +42,50 @@ private class ReleaseComparer : IEqualityComparer var credentials = new Credentials(token); var client = new GitHubClient(new ProductHeaderValue(config.UserAgent), new InMemoryCredentialStore(credentials)); + var user = await client.User.Current(); var orgs = await client.Organization.GetAllForCurrent(); - var isOrgMember = orgs.Any(e => e.Id == config.RubberduckOrgId); - if (!isOrgMember) + + var org = orgs.SingleOrDefault(e => e.Id == RDConstants.Org.OrganisationId); + var isOrgMember = org is Organization rdOrg; + + var claims = new List { - return null; - } + new(ClaimTypes.Name, user.Login), + new(ClaimTypes.Authentication, token), + new("access_token", token) + }; - var user = await client.User.Current(); - var identity = new ClaimsIdentity(new[] + if (isOrgMember && !user.Suspended) { - new Claim(ClaimTypes.Name, user.Login), - new Claim(ClaimTypes.Role, config.OwnerOrg), - new Claim(ClaimTypes.Authentication, token), - new Claim("access_token", token) - }, "github"); + var teams = await client.Organization.Team.GetAllForCurrent(); + + var adminTeam = teams.SingleOrDefault(e => e.Name == RDConstants.Org.WebAdminTeam); + if (adminTeam is not null) + { + // authenticated members of the org who are in the admin team can manage the site and approve their own changes + claims.Add(new Claim(ClaimTypes.Role, RDConstants.Roles.AdminRole)); + } + else + { + var contributorsTeam = teams.SingleOrDefault(e => e.Name == RDConstants.Org.ContributorsTeam); + if (contributorsTeam is not null) + { + // members of the contributors team can review/approve/reject suggested changes + claims.Add(new Claim(ClaimTypes.Role, RDConstants.Roles.ReviewerRole)); + } + else + { + // authenticated members of the org can submit edits + claims.Add(new Claim(ClaimTypes.Role, RDConstants.Roles.WriterRole)); + } + } + } + else + { + claims.Add(new Claim(ClaimTypes.Role, RDConstants.Roles.ReaderRole)); + } + + var identity = new ClaimsIdentity(claims, "github"); return new ClaimsPrincipal(identity); } diff --git a/rubberduckvba.Server/Services/RubberduckDbService.cs b/rubberduckvba.Server/Services/RubberduckDbService.cs index 95a88ad..b7bbd9b 100644 --- a/rubberduckvba.Server/Services/RubberduckDbService.cs +++ b/rubberduckvba.Server/Services/RubberduckDbService.cs @@ -1,9 +1,15 @@ using Microsoft.Data.SqlClient; using Microsoft.Extensions.Options; +using rubberduckvba.Server.Api.Auth; using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.Context; using rubberduckvba.Server.Data; using rubberduckvba.Server.Model; +using rubberduckvba.Server.Model.Entity; using rubberduckvba.Server.Services.rubberduckdb; +using System.Security.Claims; +using System.Security.Principal; +using System.Text.Json; +using static Dapper.SqlMapper; namespace rubberduckvba.Server.Services; @@ -56,6 +62,351 @@ public enum RepositoryId Rubberduck3 = 2 } +public interface IAuditService +{ + Task CreateFeature(Feature feature, IIdentity identity); + Task DeleteFeature(Feature feature, IIdentity identity); + Task UpdateFeature(Feature feature, IIdentity identity); + + + Task GetItem(int id) where T : AuditEntity; + Task> GetPendingItems(IIdentity? identity, int? featureId = default) where T : AuditEntity; + Task> GetAllActivity(IIdentity identity); + + Task Approve(T entity, IIdentity identity) where T : AuditEntity; + Task Reject(T entity, IIdentity identity) where T : AuditEntity; +} + +public class AuditService : IAuditService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + + public AuditService(IOptions settings, ILogger logger) + { + _connectionString = settings.Value.RubberduckDb ?? throw new InvalidOperationException("ConnectionString 'RubberduckDb' could not be retrieved."); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + private async Task GetDbConnection() + { + var db = new SqlConnection(_connectionString); + await db.OpenAsync(); + + return db; + } + + public async Task Approve(T entity, IIdentity identity) where T : AuditEntity + { + var procName = entity switch + { + FeatureOpEntity => "[audits].[ApproveFeatureOp]", + FeatureEditEntity => "[audits].[ApproveFeatureEdit]", + _ => throw new NotSupportedException($"The entity type {typeof(T).Name} is not supported for approval."), + }; + + await ApproveOrReject(procName, entity.Id, identity); + } + + public async Task Reject(T entity, IIdentity identity) where T : AuditEntity + { + var procName = entity switch + { + FeatureOpEntity => "[audits].[RejectFeatureOp]", + FeatureEditEntity => "[audits].[RejectFeatureEdit]", + _ => throw new NotSupportedException($"The entity type {typeof(T).Name} is not supported for approval."), + }; + await ApproveOrReject(procName, entity.Id, identity); + } + + private async Task ApproveOrReject(string procedure, int id, IIdentity identity) + { + var authorized = false; + if (identity is ClaimsIdentity user) + { + if (user.IsReviewer()) + { + authorized = true; + + using var db = await GetDbConnection(); + db.Execute($"EXEC {procedure} @id, @login", new { id, login = user.Name }); + } + } + + if (!authorized) + { + // we should never be here; auth middleware should already have prevented unauthorized access to these endpoints. + _logger.LogWarning("Unauthorized attempt to use login '{UserName}' to execute '{procedure}'.", identity.Name, procedure); + throw new UnauthorizedAccessException("The provided user identity is not authorized to perform this action."); + } + } + + public async Task CreateFeature(Feature feature, IIdentity identity) + { + await SubmitFeatureOp(feature, identity, FeatureOperation.Create); + } + + public async Task DeleteFeature(Feature feature, IIdentity identity) + { + await SubmitFeatureOp(feature, identity, FeatureOperation.Delete); + } + + public async Task UpdateFeature(Feature feature, IIdentity identity) + { + await SubmitFeatureEdit(feature, identity); + } + + private async Task SubmitFeatureOp(Feature feature, IIdentity identity, FeatureOperation operation) + { + var login = identity?.Name ?? throw new ArgumentNullException(nameof(identity), "Identity name cannot be null."); + const string sql = $@"INSERT INTO audits.FeatureOps (DateInserted,Author,FeatureName,FeatureAction,ParentId,Title,ShortDescription,Description,IsNew,IsHidden,HasImage,Links) + VALUES (GETDATE(),@login,@name,@action,@parentId,@title,@summary,@description,@isNew,@isHidden,@hasImage,@links);"; + + using var db = await GetDbConnection(); + await db.ExecuteAsync(sql, new + { + login, + name = feature.Name, + action = Convert.ToInt32(operation), + parentId = feature.FeatureId, + title = feature.Title, + summary = feature.ShortDescription, + description = feature.Description, + isNew = feature.IsNew, + isHidden = feature.IsHidden, + hasImage = feature.HasImage, + links = JsonSerializer.Serialize(feature.Links) + }); + } + + private async Task SubmitFeatureEdit(Feature feature, IIdentity identity) + { + var login = identity?.Name ?? throw new ArgumentNullException(nameof(identity), "Identity name cannot be null."); + const string sql = $@"INSERT INTO audits.FeatureEdits (DateInserted,Author,FeatureId,FieldName,ValueBefore,ValueAfter) + VALUES (GETDATE(),@login,@featureId,@fieldName,@valueBefore,@valueAfter);"; + + using var db = await GetDbConnection(); + + var current = await db.QuerySingleOrDefaultAsync("SELECT * FROM dbo.Features WHERE Id = @featureId", new { featureId = feature.Id }) + ?? throw new ArgumentOutOfRangeException(nameof(feature), "Invalid feature ID"); + + var editableFields = await db.QueryAsync("SELECT FieldName FROM audits.v_FeatureColumns"); + + string? fieldName = null; + string? valueBefore = null; + string? valueAfter = null; + + foreach (var name in editableFields) + { + var currentProperty = current.GetType().GetProperty(name); + var property = feature.GetType().GetProperty(name)!; + var asJson = property.PropertyType.IsClass && property.PropertyType != typeof(string); + + valueBefore = asJson ? JsonSerializer.Serialize(currentProperty?.GetValue(current)) : currentProperty?.GetValue(current)?.ToString() ?? string.Empty; + valueAfter = asJson ? JsonSerializer.Serialize(property?.GetValue(feature)) : property?.GetValue(feature)?.ToString() ?? string.Empty; + + if (valueBefore != valueAfter) + { + fieldName = name; + break; + } + } + + if (fieldName is null) + { + _logger.LogInformation("No change detected for field in feature '{FeatureName}'. No audit entry created.", feature.Name); + return; + } + + await db.ExecuteAsync(sql, new + { + login, + featureId = feature.Id, + fieldName, + valueBefore, + valueAfter + }); + } + + public async Task> GetAllActivity(IIdentity identity) + { + const string sql = @$" +SELECT + src.[Id] + ,src.[Author] + ,[ActivityTimestamp] = src.[DateInserted] + ,[Activity] = 'SubmitEdit' + ,[Description] = src.[FieldName] + ' of ' + ISNULL(f.[Name], '(deleted)') + ,[ReviewedBy] = ISNULL(src.[ApprovedBy],src.[RejectedBy]) + ,[Status] = CASE + WHEN src.[ApprovedAt] IS NOT NULL THEN 'Approved' + WHEN src.[RejectedAt] IS NOT NULL THEN 'Rejected' + ELSE 'Pending' + END +FROM [audits].[FeatureEdits] src +LEFT JOIN [dbo].[Features] f ON src.[FeatureId] = f.[Id] +WHERE src.[Author] = @login +UNION ALL +SELECT + src.[Id] + ,src.[Author] + ,[ActivityTimestamp] = src.[ApprovedAt] + ,[Activity] = 'ApproveEdit' + ,[Description] = src.[FieldName] + ' of ' + ISNULL(f.[Name], '(deleted)') + ,[ReviewedBy] = ISNULL(src.[ApprovedBy],src.[RejectedBy]) + ,[Status] = CASE + WHEN src.[ApprovedAt] IS NOT NULL THEN 'Approved' + WHEN src.[RejectedAt] IS NOT NULL THEN 'Rejected' + ELSE 'Pending' + END +FROM [audits].[FeatureEdits] src +LEFT JOIN [dbo].[Features] f ON src.[FeatureId] = f.[Id] +WHERE src.[ApprovedBy] = @login +UNION ALL +SELECT + src.[Id] + ,src.[Author] + ,[ActivityTimestamp] = src.[RejectedAt] + ,[Activity] = 'RejectEdit' + ,[Description] = src.[FieldName] + ' of ' + ISNULL(f.[Name], '(deleted)') + ,[ReviewedBy] = ISNULL(src.[ApprovedBy],src.[RejectedBy]) + ,[Status] = CASE + WHEN src.[ApprovedAt] IS NOT NULL THEN 'Approved' + WHEN src.[RejectedAt] IS NOT NULL THEN 'Rejected' + ELSE 'Pending' + END +FROM [audits].[FeatureEdits] src +LEFT JOIN [dbo].[Features] f ON src.[FeatureId] = f.[Id] +WHERE src.[RejectedBy] = @login + +UNION ALL + +SELECT + src.[Id] + ,src.[Author] + ,[ActivityTimestamp] = src.[DateInserted] + ,[Activity] = CASE WHEN src.[FeatureAction] = 1 THEN 'SubmitCreate' ELSE 'SubmitDelete' END + ,[Description] = src.[FeatureName] + CASE WHEN src.[ParentId] IS NULL THEN ' (top-level)' ELSE ' (' + ISNULL(parent.[Name], 'deleted') + ')' END + ,[ReviewedBy] = ISNULL(src.[ApprovedBy],src.[RejectedBy]) + ,[Status] = CASE + WHEN src.[ApprovedAt] IS NOT NULL THEN 'Approved' + WHEN src.[RejectedAt] IS NOT NULL THEN 'Rejected' + ELSE 'Pending' + END +FROM [audits].[FeatureOps] src +LEFT JOIN [dbo].[Features] parent ON src.[ParentId] = parent.[Id] +WHERE src.[Author] = @login +UNION ALL +SELECT + src.[Id] + ,src.[Author] + ,[ActivityTimestamp] = src.[DateInserted] + ,[Activity] = CASE WHEN src.[FeatureAction] = 1 THEN 'ApproveCreate' ELSE 'ApproveDelete' END + ,[Description] = src.[FeatureName] + CASE WHEN src.[ParentId] IS NULL THEN ' (top-level)' ELSE ' (' + ISNULL(parent.[Name], 'deleted') + ')' END + ,[ReviewedBy] = ISNULL(src.[ApprovedBy],src.[RejectedBy]) + ,[Status] = CASE + WHEN src.[ApprovedAt] IS NOT NULL THEN 'Approved' + WHEN src.[RejectedAt] IS NOT NULL THEN 'Rejected' + ELSE 'Pending' + END +FROM [audits].[FeatureOps] src +LEFT JOIN [dbo].[Features] parent ON src.[ParentId] = parent.[Id] +WHERE src.[ApprovedBy] = @login +UNION ALL +SELECT + src.[Id] + ,src.[Author] + ,[ActivityTimestamp] = src.[DateInserted] + ,[Activity] = CASE WHEN src.[FeatureAction] = 1 THEN 'RejectCreate' ELSE 'RejectDelete' END + ,[Description] = src.[FeatureName] + CASE WHEN src.[ParentId] IS NULL THEN ' (top-level)' ELSE ' (' + ISNULL(parent.[Name], 'deleted') + ')' END + ,[ReviewedBy] = ISNULL(src.[ApprovedBy],src.[RejectedBy]) + ,[Status] = CASE + WHEN src.[ApprovedAt] IS NOT NULL THEN 'Approved' + WHEN src.[RejectedAt] IS NOT NULL THEN 'Rejected' + ELSE 'Pending' + END +FROM [audits].[FeatureOps] src +LEFT JOIN [dbo].[Features] parent ON src.[ParentId] = parent.[Id] +WHERE src.[RejectedBy] = @login + +ORDER BY ActivityTimestamp DESC; +"; + using var db = await GetDbConnection(); + return await db.QueryAsync(sql, new { login = identity.Name }); + } + + public async Task GetItem(int id) where T : AuditEntity + { + using var db = await GetDbConnection(); + var (tableName, columns) = typeof(T).Name switch + { + nameof(FeatureOpEntity) => ("[audits].[FeatureOps] src", string.Join(',', typeof(FeatureOpEntity).GetProperties().Where(p => p.CanWrite).Select(p => $"src.[{p.Name}]"))), + nameof(FeatureEditEntity) => ("[audits].[FeatureEdits] src", string.Join(',', typeof(FeatureEditEntity).GetProperties().Where(p => p.CanWrite).Select(p => $"src.[{p.Name}]"))), + nameof(FeatureEditViewEntity) => ("[audits].[v_FeatureEdits] src", string.Join(',', typeof(FeatureEditViewEntity).GetProperties().Where(p => p.CanWrite).Select(p => $"src.[{p.Name}]"))), + _ => throw new NotSupportedException($"The entity type {typeof(T).Name} is not supported for pending items retrieval."), + }; + + var sql = typeof(T).Name switch + { + nameof(FeatureOpEntity) => $"SELECT {columns} FROM {tableName} INNER JOIN dbo.Features f ON src.[FeatureName] = f.[Name] WHERE src.[Id] = @id", + nameof(FeatureEditEntity) => $"SELECT {columns} FROM {tableName} INNER JOIN dbo.Features f ON src.[FeatureId] = f.[Id] WHERE src.[Id] = @id", + nameof(FeatureEditViewEntity) => $"SELECT {columns} FROM {tableName} WHERE src.[Id] = @id", + _ => throw new NotSupportedException($"The entity type {typeof(T).Name} is not supported for pending items retrieval."), + }; + + return await db.QuerySingleOrDefaultAsync(sql, new { id }); + } + + public async Task> GetPendingItems(IIdentity? identity, int? featureId = default) where T : AuditEntity + { + using var db = await GetDbConnection(); + var (tableName, columns) = typeof(T).Name switch + { + nameof(FeatureOpEntity) => ("[audits].[FeatureOps] src", string.Join(',', typeof(FeatureOpEntity).GetProperties().Where(p => p.CanWrite).Select(p => $"src.[{p.Name}]"))), + nameof(FeatureEditEntity) => ("[audits].[FeatureEdits] src", string.Join(',', typeof(FeatureEditEntity).GetProperties().Where(p => p.CanWrite).Select(p => $"src.[{p.Name}]"))), + nameof(FeatureEditViewEntity) => ("[audits].[v_FeatureEdits] src", string.Join(',', typeof(FeatureEditViewEntity).GetProperties().Where(p => p.CanWrite).Select(p => $"src.[{p.Name}]"))), + _ => throw new NotSupportedException($"The entity type {typeof(T).Name} is not supported for pending items retrieval."), + }; + + const string pendingFilter = "src.[ApprovedBy] IS NULL AND src.[RejectedBy] IS NULL"; + + var sql = featureId.HasValue + ? typeof(T).Name switch + { + nameof(FeatureOpEntity) => $"SELECT {columns} FROM {tableName} INNER JOIN dbo.Features f ON src.[FeatureName] = f.[Name] WHERE {pendingFilter} AND f.[Id] = {featureId}", + nameof(FeatureEditEntity) => $"SELECT {columns} FROM {tableName} WHERE {pendingFilter} AND src.[FeatureId] = {featureId}", + nameof(FeatureEditViewEntity) => $"SELECT {columns} FROM {tableName} WHERE {pendingFilter} AND src.[FeatureId] = {featureId}", + _ => throw new NotSupportedException($"The entity type {typeof(T).Name} is not supported for pending items retrieval."), + } + : $"SELECT {columns} FROM {tableName} WHERE {pendingFilter}"; + + var rlsFilter = "src.[Author] = @login"; // default to 'mine' only + + if (identity is ClaimsIdentity user) + { + // unless a user has admin rights, they cannot review their own edits. + + if (user.IsReviewer()) + { + rlsFilter = "1=1"; + } + + sql += $" AND {rlsFilter} ORDER BY src.[DateInserted] DESC"; + + if (rlsFilter.Contains("@login")) + { + return await db.QueryAsync(sql, new { login = user.Name }); + } + else + { + return await db.QueryAsync(sql); + } + } + + return []; + } +} + public interface IRubberduckDbService { Task> GetJobStateAsync(); @@ -68,92 +419,29 @@ public interface IRubberduckDbService Task> GetTopLevelFeatures(RepositoryId? repositoryId = default); Task ResolveFeature(RepositoryId repositoryId, string name); Task GetFeatureId(RepositoryId repositoryId, string name); - Task SaveFeature(Feature feature); } public class RubberduckDbService : IRubberduckDbService { - private readonly string _connectionString; private readonly TagServices _tagServices; private readonly FeatureServices _featureServices; private readonly HangfireJobStateRepository _hangfireJobState; - public RubberduckDbService(IOptions settings, ILogger logger, - TagServices tagServices, FeatureServices featureServices, HangfireJobStateRepository hangfireJobState) + public RubberduckDbService(TagServices tagServices, FeatureServices featureServices, HangfireJobStateRepository hangfireJobState) { - _connectionString = settings.Value.RubberduckDb ?? throw new InvalidOperationException("ConnectionString 'RubberduckDb' could not be retrieved."); - Logger = logger; - _tagServices = tagServices; _featureServices = featureServices; _hangfireJobState = hangfireJobState; } - private ILogger Logger { get; } - - private async Task GetDbConnection() - { - var db = new SqlConnection(_connectionString); - await db.OpenAsync(); - - return db; - } - public async Task> GetAllTagsAsync() { return _tagServices.GetAllTags(); - // const string sql = @" - //SELECT - // [Id], - // [DateTimeInserted], - // [DateTimeUpdated], - // [RepositoryId], - // [ReleaseId], - // [Name], - // [DateCreated], - // [InstallerDownloadUrl], - // [InstallerDownloads], - // [IsPreRelease] - //FROM [Tags] - //"; - // using var db = await GetDbConnection(); - - // var sw = Stopwatch.StartNew(); - // var result = (await db.QueryAsync(sql)).ToArray(); - // sw.Stop(); - - // Logger.LogInformation(nameof(GetAllTagsAsync) + " | SELECT operation completed ({results}) | ⏱️ {elapsed}", result.Length, sw.Elapsed); - // return result; } public async Task> GetTopLevelFeatures(RepositoryId? repositoryId = default) { return _featureServices.Get(topLevelOnly: true); - // const string sql = @" - //SELECT - // [Id], - // [DateTimeInserted], - // [DateTimeUpdated], - // [RepositoryId], - // [Name], - // [Title], - // [ShortDescription], - // [IsNew], - // [HasImage] - //FROM [Features] - //WHERE [RepositoryId]=ISNULL(@repositoryId,[RepositoryId]) - //AND [ParentId] IS NULL - //AND [IsHidden]=0; - //"; - // using var db = await GetDbConnection(); - // var parameters = new { repositoryId = (int)(repositoryId ?? RepositoryId.Rubberduck) }; - - // var sw = Stopwatch.StartNew(); - // var result = (await db.QueryAsync(sql, parameters)).ToArray(); - // sw.Stop(); - - // Logger.LogInformation(nameof(GetTopLevelFeatures) + " | SELECT operation completed ({results}) | ⏱️ {elapsed}", result.Length, sw.Elapsed); - // return result; } public async Task ResolveFeature(RepositoryId repositoryId, string name) @@ -165,156 +453,6 @@ public async Task ResolveFeature(RepositoryId repositoryId, string { Features = children.ToArray() }; - // const string featureSql = @" - //WITH feature AS ( - // SELECT [Id] - // FROM [Features] - // WHERE [RepositoryId]=@repositoryId AND LOWER([Name])=LOWER(@name) - //) - //SELECT - // src.[Id], - // src.[ParentId], - // src.[DateTimeInserted], - // src.[DateTimeUpdated], - // src.[Name], - // src.[Title], - // src.[ShortDescription], - // src.[Description], - // src.[IsNew], - // src.[HasImage] - //FROM [Features] src - //INNER JOIN feature ON src.[Id]=feature.[Id] OR src.[ParentId]=feature.[Id]; - //"; - // const string itemSql = @" - //WITH feature AS ( - // SELECT [Id] - // FROM [Features] - // WHERE [RepositoryId]=@repositoryId AND LOWER([Name])=LOWER(@name) - //) - //SELECT - // src.[Id], - // src.[DateTimeInserted], - // src.[DateTimeUpdated], - // src.[FeatureId], - // f.[Name] AS [FeatureName], - // f.[Title] AS [FeatureTitle], - // src.[Name], - // src.[Title], - // src.[Description] AS [Summary], - // src.[IsNew], - // src.[IsDiscontinued], - // src.[IsHidden], - // src.[TagAssetId], - // t.[Id] AS [TagId], - // src.[SourceUrl], - // src.[Serialized], - // t.[Name] AS [TagName] - //FROM [FeatureXmlDoc] src - //INNER JOIN feature ON src.[FeatureId]=feature.[Id] - //INNER JOIN [Features] f ON feature.[Id]=f.[Id] - //INNER JOIN [TagAssets] a ON src.[TagAssetId]=a.[Id] - //INNER JOIN [Tags] t ON a.[TagId]=t.[Id] - //ORDER BY src.[IsNew] DESC, src.[IsDiscontinued] DESC, src.[Name]; - //"; - - // using var db = await GetDbConnection(); - // var sw = Stopwatch.StartNew(); - - // var parameters = new { repositoryId, name }; - // var features = await db.QueryAsync(featureSql, parameters); - // var materialized = features.ToArray(); - - // var items = await db.QueryAsync(itemSql, parameters); - - // var graph = materialized.Where(e => e.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)) - // .Select(e => new FeatureGraph(e.ToEntity()) with - // { - // Features = materialized.Where(f => f.ParentId == e.Id).ToImmutableArray(), - // Items = items.ToImmutableArray() - // }).Single(); - // sw.Stop(); - - // Logger.LogInformation(nameof(ResolveFeature) + " | All SELECT operations completed | ⏱️ {elapsed}", sw.Elapsed); - // return graph; - } - - public async Task SaveFeature(Feature feature) - { - if (feature.Id == default) - { - _featureServices.Insert(new FeatureGraph(feature.ToEntity())); - } - else - { - _featureServices.Update(new FeatureGraph(feature.ToEntity())); - } - // TODO return with id - return feature; - // const string insertSql = @" - //INSERT INTO [Features] ([DateTimeInserted],[RepositoryId],[ParentId],[Name],[Title],[ShortDescription],[Description],[IsHidden],[IsNew],[HasImage]) - //VALUES (@ts,@repositoryId,@parentId,@name,@title,@shortDescription,@description,@isHidden,@isNew,@hasImage) - //RETURNING [Id]; - //"; - // const string updateSql = @" - //UPDATE [Features] SET - // [DateTimeUpdated]=@ts, - // [RepositoryId]=@repositoryId, - // [ParentId]=@parentId, - // [Name]=@name, - // [Title]=@title, - // [ShortDescription]=@shortDescription, - // [Description]=@description, - // [IsHidden]=@isHidden, - // [IsNew]=@isNew, - // [HasImage]=@hasImage - //WHERE [Id]=@id; - //"; - // Feature result; - - // using var db = await GetDbConnection(); - // using var transaction = await db.BeginTransactionAsync(); - - // if (feature.Id == default) - // { - // var parameters = new - // { - // ts = TimeProvider.System.GetUtcNow().ToTimestampString(), - // repositoryId = feature.RepositoryId, - // parentId = feature.ParentId, - // name = feature.Name, - // shortDescription = feature.ShortDescription, - // description = feature.Description, - // isHidden = feature.IsHidden, - // isNew = feature.IsNew, - // hasImage = feature.HasImage, - // }; - // var id = await db.ExecuteAsync(insertSql, parameters, transaction); - // result = feature with { Id = id }; - // } - // else - // { - // var parameters = new - // { - // ts = TimeProvider.System.GetUtcNow().ToTimestampString(), - // repositoryId = feature.RepositoryId, - // parentId = feature.ParentId, - // name = feature.Name, - // shortDescription = feature.ShortDescription, - // description = feature.Description, - // isHidden = feature.IsHidden, - // isNew = feature.IsNew, - // hasImage = feature.HasImage, - // id = feature.Id - // }; - // await db.ExecuteAsync(updateSql, parameters, transaction); - // result = feature; - // } - - // var trx = Stopwatch.StartNew(); - // await transaction.CommitAsync(); - // trx.Stop(); - // Logger.LogInformation(nameof(SaveFeature) + " | Transaction committed | ⏱️ {elapsed}", trx.Elapsed); - // return result; } public async Task CreateAsync(IEnumerable tags, RepositoryId repositoryId) diff --git a/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs b/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs index 2a0033f..6232cdb 100644 --- a/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs +++ b/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs @@ -11,12 +11,14 @@ public class FeatureServices( IRepository quickfixRepository, IRepository annotationRepository) { - public int GetId(string name) => featureRepository.GetId(name); + public int? GetId(string name) => featureRepository.TryGetId(name, out var id) ? id : null; public IEnumerable Get(bool topLevelOnly = true) { - return featureRepository.GetAll() - .Where(e => !topLevelOnly || e.FeatureId is null) - .Select(e => new Feature(e)); + var features = featureRepository.GetAll(); + return features + .Where(e => e.ParentId == null || !topLevelOnly) + .Select(e => new Feature(e)) + .ToList(); } public Inspection GetInspection(string name) @@ -35,15 +37,15 @@ public QuickFix GetQuickFix(string name) return new QuickFix(quickfixRepository.GetById(id)); } - public FeatureGraph Get(string name) + public FeatureGraph Get(string name, bool formatMarkdown = false) { var id = featureRepository.GetId(name); var feature = featureRepository.GetById(id); var children = featureRepository.GetAll(parentId: id).Select(e => new Feature(e with { - Description = markdown.FormatMarkdownDocument(e.Description, withSyntaxHighlighting: true), - ShortDescription = markdown.FormatMarkdownDocument(e.ShortDescription), + Description = formatMarkdown ? markdown.FormatMarkdownDocument(e.Description, withSyntaxHighlighting: true) : e.Description, + ShortDescription = formatMarkdown ? markdown.FormatMarkdownDocument(e.ShortDescription) : e.ShortDescription, })).ToList(); IEnumerable inspections = []; @@ -72,8 +74,8 @@ public FeatureGraph Get(string name) return new FeatureGraph( feature with { - Description = markdown.FormatMarkdownDocument(feature.Description, withSyntaxHighlighting: true), - ShortDescription = markdown.FormatMarkdownDocument(feature.ShortDescription), + Description = formatMarkdown ? markdown.FormatMarkdownDocument(feature.Description, withSyntaxHighlighting: true) : feature.Description, + ShortDescription = formatMarkdown ? markdown.FormatMarkdownDocument(feature.ShortDescription) : feature.ShortDescription, }) { Features = children, @@ -94,4 +96,6 @@ feature with public void Insert(IEnumerable inspections) => inspectionRepository.Insert(inspections.Select(inspection => inspection.ToEntity())); public void Insert(IEnumerable quickFixes) => quickfixRepository.Insert(quickFixes.Select(quickfix => quickfix.ToEntity())); public void Insert(IEnumerable annotations) => annotationRepository.Insert(annotations.Select(annotation => annotation.ToEntity())); + + public void DeleteFeature(int id) => featureRepository.Delete(id); } diff --git a/rubberduckvba.client/package-lock.json b/rubberduckvba.client/package-lock.json index 3cd73fa..ffaa95e 100644 --- a/rubberduckvba.client/package-lock.json +++ b/rubberduckvba.client/package-lock.json @@ -26,6 +26,8 @@ "@popperjs/core": "^2.11.6", "angular-device-information": "^4.0.0", "bootstrap": "^5.2.3", + "diff": "^8.0.2", + "html-entities": "^2.6.0", "jest-editor-support": "*", "run-script-os": "*", "rxjs": "~7.8.0", @@ -6743,6 +6745,14 @@ "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", "dev": true }, + "node_modules/diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "30.0.0-alpha.6", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-30.0.0-alpha.6.tgz", @@ -8484,10 +8494,9 @@ } }, "node_modules/html-entities": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", - "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", - "dev": true, + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", "funding": [ { "type": "github", diff --git a/rubberduckvba.client/package.json b/rubberduckvba.client/package.json index 5ea642a..36ba6f7 100644 --- a/rubberduckvba.client/package.json +++ b/rubberduckvba.client/package.json @@ -31,6 +31,8 @@ "@popperjs/core": "^2.11.6", "angular-device-information": "^4.0.0", "bootstrap": "^5.2.3", + "diff": "^8.0.2", + "html-entities": "^2.6.0", "jest-editor-support": "*", "run-script-os": "*", "rxjs": "~7.8.0", diff --git a/rubberduckvba.client/src/app/app.component.ts b/rubberduckvba.client/src/app/app.component.ts index 58dc64c..fc7f937 100644 --- a/rubberduckvba.client/src/app/app.component.ts +++ b/rubberduckvba.client/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; @Component({ selector: 'app-root', diff --git a/rubberduckvba.client/src/app/app.module.ts b/rubberduckvba.client/src/app/app.module.ts index 86f5ce9..8b7c8f9 100644 --- a/rubberduckvba.client/src/app/app.module.ts +++ b/rubberduckvba.client/src/app/app.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbAccordionDirective, NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { BrowserModule } from '@angular/platform-browser'; import { RouterModule, UrlSerializer } from '@angular/router'; @@ -26,6 +26,8 @@ import { AnnotationItemBoxComponent } from './components/feature-item-box/annota import { BlogLinkBoxComponent } from './components/blog-link-box/blog-link-box.component'; import { QuickFixItemBoxComponent } from './components/feature-item-box/quickfix-item-box/quickfix-item-box.component'; +import { EditFeatureComponent } from './components/edit-feature/edit-feature.component'; + import { HomeComponent } from './routes/home/home.component'; import { AboutComponent } from './routes/about/about.component'; import { FeaturesComponent } from './routes/features/features.component'; @@ -38,6 +40,14 @@ import { IndenterComponent } from './routes/indenter/indenter.component'; import { DefaultUrlSerializer, UrlTree } from '@angular/router'; import { AuthComponent } from './routes/auth/auth.component'; import { AuthMenuComponent } from './components/auth-menu/auth-menu.component'; +import { AuditsAdminComponent } from './routes/audits/audits.component'; +import { AuditFeatureAdditionComponent } from './components/audits/feature-add.review/feature-add.review.component'; +import { AuditBoxComponent } from './components/audits/audit-box/audit-box.component'; +import { AuditFeatureEditMarkdownComponent } from './components/audits/feature-markdown.review/feature-edit-markdown.review.component'; +import { AuditFeatureDeleteComponent } from './components/audits/feature-delete.review/feature-delete.review.component'; +import { UserProfileComponent } from './routes/profile/user-profile.component'; +import { AuditItemComponent } from './routes/audits/audit-item/audit-item.component'; +import { AuthService } from './services/auth.service'; /** * https://stackoverflow.com/a/39560520 @@ -58,7 +68,14 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { AppComponent, HomeComponent, AuthComponent, + UserProfileComponent, AuthMenuComponent, + AuditsAdminComponent, + AuditItemComponent, + AuditBoxComponent, + AuditFeatureAdditionComponent, + AuditFeatureDeleteComponent, + AuditFeatureEditMarkdownComponent, IndenterComponent, FeaturesComponent, FeatureComponent, @@ -78,11 +95,13 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { InspectionComponent, AnnotationComponent, QuickFixComponent, - AboutComponent + AboutComponent, + EditFeatureComponent ], bootstrap: [AppComponent], imports: [ CommonModule, + NgbModule, BrowserModule, FormsModule, RouterModule.forRoot([ @@ -90,6 +109,10 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { { path: 'inspections/details/:name', redirectTo: 'inspections/:name' }, // actual routes: { path: 'auth/github', component: AuthComponent }, + { path: 'profile', component: UserProfileComponent }, + { path: 'audits', component: AuditsAdminComponent }, + { path: 'audits/edits/:id', component: AuditItemComponent}, + { path: 'audits/ops/:id', component: AuditItemComponent }, { path: 'features', component: FeaturesComponent }, { path: 'features/:name', component: FeatureComponent }, { path: 'inspections/:name', component: InspectionComponent }, @@ -105,6 +128,8 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { providers: [ DataService, ApiClientService, + AuthService, + NgbAccordionDirective, provideHttpClient(withInterceptorsFromDi()), { provide: UrlSerializer, diff --git a/rubberduckvba.client/src/app/components/audits/audit-box/audit-box.component.html b/rubberduckvba.client/src/app/components/audits/audit-box/audit-box.component.html new file mode 100644 index 0000000..969e7bf --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/audit-box/audit-box.component.html @@ -0,0 +1,78 @@ +
+
+
+
+

Edit the {{auditEdit!.fieldName.toLowerCase()}} of {{auditEdit!.featureName}}

+

Create {{auditOp!.featureName}}

+

Delete {{auditOp!.featureName}}

+
Submitted {{dateSubmitted}} by {{author}}
+
, last modified {{dateModified}}
+
+ This operation is stale +
+
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+ + + + + + + + + + + + diff --git a/rubberduckvba.client/src/app/components/audits/audit-box/audit-box.component.ts b/rubberduckvba.client/src/app/components/audits/audit-box/audit-box.component.ts new file mode 100644 index 0000000..b718ae1 --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/audit-box/audit-box.component.ts @@ -0,0 +1,144 @@ +import { Component, Input, OnInit, TemplateRef, ViewChild, inject } from "@angular/core"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { BehaviorSubject } from "rxjs"; +import { AuditRecordViewModel, FeatureEditViewModel, FeatureOperationViewModel, UserViewModel } from "../../../model/feature.model"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { ApiClientService } from "../../../services/api-client.service"; +import { AuthService } from "../../../services/auth.service"; + +@Component({ + selector: 'audit-box', + templateUrl: './audit-box.component.html' +}) +export class AuditBoxComponent implements OnInit { + private readonly _audit: BehaviorSubject = new BehaviorSubject(null!); + private _isEdit: boolean = false; + + @ViewChild('confirmApproveModal', { read: TemplateRef }) confirmApproveModal: TemplateRef | undefined; + @ViewChild('confirmRejectModal', { read: TemplateRef }) confirmRejectModal: TemplateRef | undefined; + + constructor(private fa: FaIconLibrary, private api: ApiClientService, private auth: AuthService, private modal: NgbModal) { + fa.addIconPacks(fas); + } + + private _user: UserViewModel = null!; + + ngOnInit(): void { + this.auth.getUser().subscribe(user => { + this._user = user; + }); + } + + @Input() + public set auditOp(value: FeatureOperationViewModel | FeatureEditViewModel) { + this._audit.next(value); + } + + @Input() + public set auditEdit(value: FeatureEditViewModel) { + this._audit.next(value); + this._isEdit = true; + } + + public isCollapsed: boolean = true; + public isCollapsible: boolean = true; + + @Input() + public set collapsible(value: boolean) { + this.isCollapsible = value; + this.isCollapsed = this.isCollapsible; + } + + public get canReview(): boolean { + return this._user && (!this.collapsible || !this.isCollapsed) + && this._user.isAdmin || (this._user.isReviewer && this.audit.author != this._user.name); + } + + public get auditOp(): FeatureOperationViewModel | undefined { + if (this._isEdit) { + return undefined; + } + return this._audit.value; + } + + public get auditEdit(): FeatureEditViewModel | undefined { + if (!this._isEdit) { + return undefined; + } + return this._audit.value; + } + + public get audit(): AuditRecordViewModel { + if (this._isEdit) { + return this.auditEdit!; + } + return this.auditOp!; + } + + public get dateSubmitted(): string { + return (this._isEdit + ? this.auditEdit!.dateInserted + : this.auditOp!.dateInserted) ?? ''; + } + + public get dateModified(): string { + return (this._isEdit + ? this.auditEdit!.dateModified + : this.auditOp!.dateModified) ?? ''; + } + + public get author(): string { + return (this._isEdit + ? this.auditEdit!.author + : this.auditOp!.author) ?? ''; + } + + public get isEdit(): boolean { + return this._isEdit; + } + + public get isCreateOp(): boolean { + if (this._isEdit) { + return false; + } + return this.auditOp!.featureAction == 1; + } + + public get isDeleteOp(): boolean { + if (this._isEdit) { + return false; + } + return this.auditOp!.featureAction == 2; + } + + public confirmApprove(): void { + this.modal.open(this.confirmApproveModal); + } + + public confirmReject(): void { + this.modal.open(this.confirmRejectModal); + } + + public onCancelModal(): void { + this.modal.dismissAll(); + } + + public onConfirmApprove(): void { + console.log(`approving audit operation id ${this.audit.id}`); + console.log(this.audit); + this.modal.dismissAll(); + this.api.approvePendingAudit(this.audit.id).subscribe(() => { + window.location.reload(); + }); + } + + public onConfirmReject(): void { + console.log(`rejecting audit operation id ${this.audit.id}`); + console.log(this.audit); + this.modal.dismissAll(); + this.api.rejectPendingAudit(this.audit.id).subscribe(() => { + window.location.reload(); + }); + } +} diff --git a/rubberduckvba.client/src/app/components/audits/feature-add.review/feature-add.review.component.html b/rubberduckvba.client/src/app/components/audits/feature-add.review/feature-add.review.component.html new file mode 100644 index 0000000..f8dd468 --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/feature-add.review/feature-add.review.component.html @@ -0,0 +1,68 @@ +
+
+
+ +
+ +
+ The name of the feature. Must be unique. +
+ + + +
+ +
+ +
+ The display name of the feature +
+ + + +
+ +
+ +
+ Whether the feature should have a 'new feature' marker on the site +
+
+ +
+ +
+ A short description of the feature. +
+ + + +
+ +
+ +
+ A markdown document describing the feature in details. +
+ + + +
+
+
+
+
+

+
+
+

+
+

+
+
+
+
+
diff --git a/rubberduckvba.client/src/app/components/audits/feature-add.review/feature-add.review.component.ts b/rubberduckvba.client/src/app/components/audits/feature-add.review/feature-add.review.component.ts new file mode 100644 index 0000000..1ed21d5 --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/feature-add.review/feature-add.review.component.ts @@ -0,0 +1,48 @@ +import { Component, Input, OnInit } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; +import { FeatureOperationViewModel } from "../../../model/feature.model"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { ApiClientService } from "../../../services/api-client.service"; + +@Component({ + selector: 'review-feature-add', + templateUrl: './feature-add.review.component.html' +}) +export class AuditFeatureAdditionComponent implements OnInit { + + private readonly _audit: BehaviorSubject = new BehaviorSubject(null!); + private readonly _summary: BehaviorSubject = new BehaviorSubject(null!); + private readonly _description: BehaviorSubject = new BehaviorSubject(null!); + + constructor(private fa: FaIconLibrary, private api: ApiClientService) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + this.api.formatMarkdown(this.audit.shortDescription ?? '').subscribe(html => { + this._summary.next(html.content); + }); + this.api.formatMarkdown(this.audit.description ?? '').subscribe(html => { + this._description.next(html.content); + }); + } + + @Input() + public set audit(value: FeatureOperationViewModel | undefined) { + if (value) { + this._audit.next(value); + } + } + + public get audit(): FeatureOperationViewModel { + return this._audit.getValue(); + } + + public get htmlSummary(): string { + return this._summary.getValue(); + } + public get htmlDescription(): string { + return this._description.getValue(); + } +} diff --git a/rubberduckvba.client/src/app/components/audits/feature-delete.review/feature-delete.review.component.html b/rubberduckvba.client/src/app/components/audits/feature-delete.review/feature-delete.review.component.html new file mode 100644 index 0000000..bfa2abf --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/feature-delete.review/feature-delete.review.component.html @@ -0,0 +1,10 @@ +
+
+

+
+
+

+
+

+
+
diff --git a/rubberduckvba.client/src/app/components/audits/feature-delete.review/feature-delete.review.component.ts b/rubberduckvba.client/src/app/components/audits/feature-delete.review/feature-delete.review.component.ts new file mode 100644 index 0000000..ef04066 --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/feature-delete.review/feature-delete.review.component.ts @@ -0,0 +1,28 @@ +import { Component, Input, OnInit } from "@angular/core"; +import { FeatureOperationViewModel } from "../../../model/feature.model"; +import { BehaviorSubject } from "rxjs"; + +@Component({ + selector: 'review-feature-delete', + templateUrl: './feature-delete.review.component.html' +}) +export class AuditFeatureDeleteComponent implements OnInit { + + private readonly _audit: BehaviorSubject = new BehaviorSubject(null!); + + constructor() { } + + ngOnInit(): void { + } + + @Input() + public set audit(value: FeatureOperationViewModel | undefined) { + if (value) { + this._audit.next(value); + } + } + + public get audit(): FeatureOperationViewModel { + return this._audit.getValue(); + } +} diff --git a/rubberduckvba.client/src/app/components/audits/feature-markdown.review/feature-edit-markdown.review.component.html b/rubberduckvba.client/src/app/components/audits/feature-markdown.review/feature-edit-markdown.review.component.html new file mode 100644 index 0000000..110c65b --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/feature-markdown.review/feature-edit-markdown.review.component.html @@ -0,0 +1,47 @@ +
+
+
+
+
+

+ + +

+

Show/hide the modified field's initial value.

+
+
+
+
+
+
+
+
+

Submitted value (unformatted):

+
+
+
+
+
+
+
+
+

Formatted:

+
+
+

+
+
+ +
+
+
diff --git a/rubberduckvba.client/src/app/components/audits/feature-markdown.review/feature-edit-markdown.review.component.ts b/rubberduckvba.client/src/app/components/audits/feature-markdown.review/feature-edit-markdown.review.component.ts new file mode 100644 index 0000000..461bc1c --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/feature-markdown.review/feature-edit-markdown.review.component.ts @@ -0,0 +1,87 @@ +import { Component, Input, OnInit } from "@angular/core"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { ApiClientService } from "../../../services/api-client.service"; +import { BehaviorSubject } from "rxjs"; +import { FeatureEditViewModel } from "../../../model/feature.model"; +import { Change, diffWords } from "diff"; + +@Component({ + selector: 'review-edit-markdown', + templateUrl: './feature-edit-markdown.review.component.html' +}) +export class AuditFeatureEditMarkdownComponent implements OnInit { + + private readonly _audit: BehaviorSubject = new BehaviorSubject(null!); + private readonly _htmlValue: BehaviorSubject = new BehaviorSubject(null!); + private readonly _diffSource: BehaviorSubject = new BehaviorSubject(null!); + + constructor(private fa: FaIconLibrary, private api: ApiClientService) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + this.renderPreview(); + } + + private renderPreview() { + const markdown = this.showDiff + ? this.getDiffHtml(this.audit.valueBefore ?? '', this.audit.valueAfter) + : this.audit.valueAfter; + + this._diffSource.next(markdown); + this.api.formatMarkdown(markdown).subscribe(md => { + this._htmlValue.next(md.content); + }); + } + + @Input() + public set audit(value: FeatureEditViewModel | undefined) { + if (value) { + this._audit.next(value); + } + } + + public get audit(): FeatureEditViewModel { + return this._audit.getValue(); + } + + public get htmlSource(): string { + return this._diffSource.getValue(); + } + + public get htmlPreview(): string { + return this._htmlValue.getValue(); + } + + private _showDiff: boolean = true; + public get showDiff(): boolean { + return this._showDiff; + } + public set showDiff(value: boolean) { + this._showDiff = value; + this.renderPreview(); + } + + private _showBefore: boolean = true; + public get showBefore(): boolean { + return this._showBefore; + } + + public set showBefore(value: boolean) { + this._showBefore = value; + } + + private getDiffHtml(before: string, after: string): string { + const diff = diffWords(before, after, { ignoreCase: false }); + return diff.map((part: Change) => { + if (part.added) { + return `${part.value}`; + } else if (part.removed) { + return `${part.value}`; + } else { + return part.value; + } + }).join(''); + } +} diff --git a/rubberduckvba.client/src/app/components/audits/feature-state.review/feature-state.review.component.html b/rubberduckvba.client/src/app/components/audits/feature-state.review/feature-state.review.component.html new file mode 100644 index 0000000..4d38912 --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/feature-state.review/feature-state.review.component.html @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/rubberduckvba.client/src/app/components/audits/feature-state.review/feature-state.review.component.ts b/rubberduckvba.client/src/app/components/audits/feature-state.review/feature-state.review.component.ts new file mode 100644 index 0000000..db79aa1 --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/feature-state.review/feature-state.review.component.ts @@ -0,0 +1 @@ +console.log("Hello World!") \ No newline at end of file diff --git a/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html index 1f6b45b..44e16dc 100644 --- a/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html +++ b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html @@ -9,6 +9,11 @@
+
+
+
+
+
diff --git a/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.ts b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.ts index 0411a94..794f3c5 100644 --- a/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.ts +++ b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.ts @@ -3,10 +3,12 @@ import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; import { BehaviorSubject } from "rxjs"; import { UserViewModel } from "../../model/feature.model"; import { AuthService } from "src/app/services/auth.service"; -import { fas } from "@fortawesome/free-solid-svg-icons"; +import { fa1, fa2, fa3, fa4, fa5, fa6, fa7, fa8, fa9, faCircle, faCircleCheck, faCircleExclamation, fas } from "@fortawesome/free-solid-svg-icons"; import { fab } from "@fortawesome/free-brands-svg-icons"; import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; import { ApiClientService } from "../../services/api-client.service"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { far } from "@fortawesome/free-regular-svg-icons"; @Component({ selector: 'auth-menu', @@ -30,6 +32,7 @@ export class AuthMenuComponent implements OnInit { public modal = inject(NgbModal); constructor(private auth: AuthService, private api: ApiClientService, private fa: FaIconLibrary) { + fa.addIconPacks(far); fa.addIconPacks(fas); fa.addIconPacks(fab); } @@ -42,10 +45,41 @@ export class AuthMenuComponent implements OnInit { this.auth.getUser().subscribe(result => { if (result) { this._user.next(result); + if (result.isReviewer) { + this.api.getAllPendingAudits().subscribe(e => { if (e) { this.pendingAudits = e.edits.length + e.other.length } }); + } } }); } + + public pendingAudits: number = 0; + public auditsCountIcon(): IconProp { + switch (this.pendingAudits) { + case 0: return faCircleCheck; + case 1: return fa1; + case 2: return fa2; + case 3: return fa3; + case 4: return fa4; + case 5: return fa5; + case 6: return fa6; + case 7: return fa7; + case 8: return fa8; + case 9: return fa9; + default: return faCircleExclamation; + } + } + + public auditsCountIconClass(): string { + if (this.pendingAudits == 0) { + return 'text-success'; + } + else { + return 'text-danger'; + } + } + + public confirm(): void { this.modal.open(this.confirmbox); } @@ -62,6 +96,10 @@ export class AuthMenuComponent implements OnInit { this.modal.open(this.confirmclearcachebox); } + public reviewPendingAudits(): void { + window.location.href = '/audits'; + } + public signin(): void { this.auth.signin(); } diff --git a/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html new file mode 100644 index 0000000..15a99a0 --- /dev/null +++ b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html @@ -0,0 +1,168 @@ + + + + + + + + + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
diff --git a/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.ts b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.ts new file mode 100644 index 0000000..aa4676d --- /dev/null +++ b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.ts @@ -0,0 +1,141 @@ +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, TemplateRef, ViewChild, inject, input } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; +import { EditSubFeatureViewModelClass, MarkdownContent, SubFeatureViewModel, SubFeatureViewModelClass, UserViewModel } from "../../model/feature.model"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { ApiClientService } from "../../services/api-client.service"; + +export enum AdminAction { + Edit = 'edit', + EditSummary = 'summary', + Create = 'create', + Delete = 'delete', +} + +@Component({ + selector: 'edit-feature', + templateUrl: './edit-feature.component.html' +}) +export class EditFeatureComponent implements OnInit { + private readonly _feature: BehaviorSubject = new BehaviorSubject(null!); + + private _action: AdminAction = AdminAction.Create; + + @ViewChild('editModal', { read: TemplateRef }) editModal: TemplateRef | undefined; + @ViewChild('createModal', { read: TemplateRef }) createModal: TemplateRef | undefined; + @ViewChild('deleteModal', { read: TemplateRef }) deleteModal: TemplateRef | undefined; + + public modal = inject(NgbModal); + + @Input() + public set feature(value: SubFeatureViewModel | undefined) { + if (value != null) { + this._feature.next(new EditSubFeatureViewModelClass(value)); + } + } + + public get feature(): EditSubFeatureViewModelClass { + return this._feature.value; + } + + private _disabled: boolean = false; + + @Input() + public set disabled(value: boolean) { + this._disabled = value; + } + + public get disabled(): boolean { + return this._disabled; + } + + @Input() + public set action(value: AdminAction) { + this._action = value; + } + + public get action(): AdminAction { + return this._action; + } + + @Output() + public onApplyChanges = new EventEmitter(); + + + public subfeature: EditSubFeatureViewModelClass = null!; + + constructor(private fa: FaIconLibrary, private api: ApiClientService) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + } + + public doAction(): void { + const localModal = this.action == 'delete' ? this.deleteModal + : this.action == 'create' ? this.createModal + : this.editModal; + const size = this.action == 'delete' ? 'modal-m' : 'modal-xl'; + + if (this.action == 'create') { + const parentId = this.feature.id; + const parentName = this.feature.name; + const parentTitle = this.feature.title; + + this.subfeature = new EditSubFeatureViewModelClass({ + dateInserted: '', + dateUpdated: '', + description: '', + id: undefined, + isHidden: false, + isNew: false, + name: 'NewFeature1', + title: 'New Feature', + shortDescription: '', + featureId: parentId, + featureName: parentName, + featureTitle: parentTitle, + isCollapsed: false, + isDetailsCollapsed: true, + }); + } + + this.modal.open(localModal, { modalDialogClass: size }); + } + + public onConfirmChanges(): void { + this.modal.dismissAll(); + this.api.saveFeature(this.feature).subscribe(saved => { + window.location.reload(); + }); + } + + public onConfirmCreate(): void { + this.modal.dismissAll(); + this.api.createFeature(this.subfeature).subscribe(saved => { + window.location.reload(); + }); + } + + public onPreviewDescription(): void { + const raw = this.action == 'create' + ? this.subfeature.description + : this.feature.description; + this.api.formatMarkdown(raw).subscribe((formatted: MarkdownContent) => { + if (this.action == 'create') { + this.subfeature.descriptionPreview = formatted.content; + } + else { + this.feature.descriptionPreview = formatted.content; + } + }); + } + + public onDeleteFeature(): void { + this.modal.dismissAll(); + this.api.deleteFeature(this.feature).subscribe(() => { + window.location.reload(); + }); + } +} diff --git a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html index a63f3e1..9bada5f 100644 --- a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html +++ b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html @@ -3,7 +3,7 @@ + + + + +
+ + + +
+
diff --git a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts index 7a6eb20..f7d64c2 100644 --- a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts +++ b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts @@ -1,9 +1,12 @@ import { Component, Input, OnChanges, OnInit, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; -import { FeatureViewModel, QuickFixViewModel, SubFeatureViewModel } from '../../model/feature.model'; +import { AuditRecordViewModel, FeatureEditViewModel, FeatureOperation, FeatureOperationViewModel, FeatureViewModel, PendingAuditsViewModel, QuickFixViewModel, SubFeatureViewModel, UserViewModel } from '../../model/feature.model'; import { BehaviorSubject } from 'rxjs'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AuthService } from '../../services/auth.service'; +import { AdminAction } from '../edit-feature/edit-feature.component'; +import { ApiClientService } from '../../services/api-client.service'; @Component({ selector: 'feature-box', @@ -12,52 +15,169 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; export class FeatureBoxComponent implements OnInit { private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _edits: BehaviorSubject = new BehaviorSubject([]); + private readonly _ops: BehaviorSubject = new BehaviorSubject([]); - @ViewChild('content', { read: TemplateRef }) content: TemplateRef | undefined; + private _audits?: PendingAuditsViewModel; + + @ViewChild('confirmDeleteFeature', { read: TemplateRef }) confirmDeleteFeatureModal: TemplateRef | undefined; public modal = inject(NgbModal); + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); + + @Input() + public set user(value: UserViewModel) { + this._user.next(value); + } + + public get user(): UserViewModel { + return this._user.getValue(); + } + @Input() public parentFeatureName: string = ''; @Input() public hasOwnDetailsPage: boolean = false; + @Input() + public set pendingAudits(value: PendingAuditsViewModel) { + this._audits = value; + this._edits.next(value.edits?.filter(e => this.feature && e.featureId == this.feature.id?.toString())); + this._ops.next(value.other?.filter(e => this.feature && e.featureName == this.feature.name)); + + if (this.pendingEdit) { + this.api.formatMarkdown(this.pendingEdit.valueAfter).subscribe(e => this._pendingSummaryHtml = e.content); + } + }; + + public get pendingAudits(): PendingAuditsViewModel { + return this._audits ?? { + edits: [], + other: [] + }; + } + + constructor(private fa: FaIconLibrary, private api: ApiClientService, private auth: AuthService) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + } + + public get isProtected(): boolean { + return this.feature?.name == 'Inspections' + || this.feature?.name == 'QuickFixes' + || this.feature?.name == 'Annotations' + || this.feature?.name == 'CodeInspections' + || this.feature?.name == 'CommentAnnotations'; + } + + public get hasXmlDocFeatures(): boolean { + return this.feature?.name == 'Inspections' + || this.feature?.name == 'QuickFixes' + || this.feature?.name == 'Annotations'; + } + @Input() public set feature(value: FeatureViewModel | undefined) { if (value != null) { this._info.next(value); + this.api.formatMarkdown(value.shortDescription).subscribe(e => this._summaryHtml = e.content); } } - public get feature(): FeatureViewModel | undefined { - return this._info.value as FeatureViewModel; + public editAction: AdminAction = AdminAction.EditSummary; + public editDetailsAction: AdminAction = AdminAction.Edit; + public createAction: AdminAction = AdminAction.Create; + public deleteAction: AdminAction = AdminAction.Delete; + + public showPendingEdit: boolean = true; + public get canToggleShowPendingEdit(): boolean { + return this.hasPendingEdits && (this.user.isAdmin || this.pendingEdit.author == this.user.name); } - public get subFeature(): SubFeatureViewModel | undefined { - return this._info.value as SubFeatureViewModel; + public get pendingEdits(): FeatureEditViewModel[] { + return this._edits.getValue(); } - private readonly _quickfixes: BehaviorSubject = new BehaviorSubject(null!); + public get pendingOperations(): FeatureOperationViewModel[] { + return this._ops.getValue(); + } - @Input() - public set quickFixes(value: QuickFixViewModel[]) { - if (value != null) { - this._quickfixes.next(value); + public get hasPendingEdits(): boolean { + return this._edits.getValue().length > 0; + } + + public get pendingEdit(): FeatureEditViewModel { + return this._edits.getValue()[0]; + } + + public get hasPendingDelete(): boolean { + return this._ops.getValue().some(e => e.featureAction == FeatureOperation.Delete); + } + + public get pendingDelete(): FeatureOperationViewModel { + return this._ops.getValue().find(e => e.featureAction == FeatureOperation.Delete)!; + } + + public onApprovePendingDelete(): void { + if (!this.hasPendingDelete) { + return; } + this.modal.open(this.confirmDeleteFeatureModal, { modalDialogClass: 'modal-l'}) } - public get quickFixes(): QuickFixViewModel[] { - return this._quickfixes.value; + public onConfirmApprovePendingDelete(): void { + if (!this.hasPendingDelete) { + return; + } + this.modal.dismissAll(); + this.api.approvePendingAudit(this.pendingDelete.id).subscribe(e => { + window.location.reload(); + }); } - constructor(private fa: FaIconLibrary) { - fa.addIconPacks(fas); + public onConfirmCreate(): void { + if (this.feature?.id && this.feature?.isCreatePending) { + this.api.approvePendingAudit(this.feature.id).subscribe(e => { + window.location.reload(); + }) + } } - ngOnInit(): void { + public onRejectPendingCreate(): void { + if (this.feature?.id && this.feature?.isCreatePending) { + this.api.rejectPendingAudit(this.feature.id).subscribe(e => { + window.location.reload(); + }) + } + } + + public onReviewPendingEdits(): void { + if (!this.hasPendingEdits) { + return; + } + } + + public get feature(): FeatureViewModel | undefined { + return this._info.value as FeatureViewModel; + } + + public get subFeature(): SubFeatureViewModel | undefined { + return this._info.value as SubFeatureViewModel; + } + + private _pendingSummaryHtml: string = ''; + private _summaryHtml: string = ''; + + public get summaryHtml(): string { + return this.showPendingEdit && this.canToggleShowPendingEdit + ? this._pendingSummaryHtml + : this._summaryHtml; } - public showDetails(): void { - this.modal.open(this.content); + public applyChanges(model: any): void { + this._info.next(model); } } diff --git a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html index 7e4620a..725175f 100644 --- a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html +++ b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html @@ -4,13 +4,16 @@

HomeFeatures{{feature?.featureName}}

{{feature?.title}}

-

+
+ +
+

-
+
- +
@@ -30,12 +33,19 @@

{{feature?.title}}

Rubberduck logo
-
+
+
- + +
+
+ +
+
+
diff --git a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts index 1c51009..4cf6c08 100644 --- a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts +++ b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts @@ -1,17 +1,24 @@ -import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; -import { AnnotationViewModel, AnnotationsFeatureViewModel, BlogLink, FeatureViewModel, InspectionViewModel, InspectionsFeatureViewModel, QuickFixViewModel, QuickFixesFeatureViewModel, SubFeatureViewModel, XmlDocItemViewModel, XmlDocOrFeatureViewModel, XmlDocViewModel } from '../../model/feature.model'; +import { Component, Input, OnInit } from '@angular/core'; +import { AnnotationViewModel, AnnotationsFeatureViewModel, BlogLink, FeatureOperationViewModel, FeatureViewModel, InspectionViewModel, InspectionsFeatureViewModel, PendingAuditsViewModel, QuickFixViewModel, QuickFixesFeatureViewModel, UserViewModel, XmlDocItemViewModel, XmlDocOrFeatureViewModel } from '../../model/feature.model'; import { BehaviorSubject } from 'rxjs'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; +import { AdminAction } from '../edit-feature/edit-feature.component'; import { ApiClientService } from '../../services/api-client.service'; @Component({ selector: 'feature-info', templateUrl: './feature-info.component.html', }) -export class FeatureInfoComponent implements OnInit, OnChanges { +export class FeatureInfoComponent implements OnInit { private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); + private readonly _audits: BehaviorSubject = new BehaviorSubject(null!); + + public editAction: AdminAction = AdminAction.Edit; + public createAction: AdminAction = AdminAction.Create; + public deleteAction: AdminAction = AdminAction.Delete; public filterState = { // searchbox @@ -33,19 +40,82 @@ export class FeatureInfoComponent implements OnInit, OnChanges { public set feature(value: XmlDocOrFeatureViewModel | undefined) { if (value != null) { this._info.next(value); + this._formattedDescriptionHtml = value.description; + this._formattedShortDescriptionHtml = value.shortDescription; this.filterByNameOrDescription(this.filterState.filterText) + + this.api.formatMarkdown(value.description).subscribe(e => this._formattedDescriptionHtml = e.content); + this.api.formatMarkdown(value.shortDescription).subscribe(e => this._formattedShortDescriptionHtml = e.content); } } + public get feature(): XmlDocOrFeatureViewModel | undefined { + return this._info.getValue(); + } + + private _formattedDescriptionHtml: string = ''; + private _formattedShortDescriptionHtml: string = ''; + + public get formattedDescriptionHtml(): string { + return this._formattedDescriptionHtml; + } + + public get formattedShortDescriptionHtml(): string { + return this._formattedShortDescriptionHtml; + } + @Input() + public set user(value: UserViewModel) { + this._user.next(value); + } + + public get user(): UserViewModel { + return this._user.getValue(); + } + + @Input() + public set audits(value: PendingAuditsViewModel) { + this._audits.next(value); + } + + public get audits(): PendingAuditsViewModel { + return this._audits.getValue(); + } + + public get pendingFeatures(): FeatureViewModel[] { + if (!this.audits?.other) { + return []; + } + + return this.audits.other.filter(e => e.featureAction == 1 && e.parentId == this.feature?.id) + .map((e) => { + return { + id: e.id, + dateInserted: e.dateInserted, + dateUpdated: '', + features: [], + name: e.name ?? '', + title: e.title ?? '', + description: e.description ?? '', + shortDescription: e.shortDescription ?? '', + featureId: e.parentId ?? undefined, + featureName: undefined, + featureTitle: undefined, + hasImage: e.hasImage ?? false, + isHidden: e.isHidden ?? false, + isNew: e.isNew ?? false, + links: e.links ?? [], + isCollapsed: false, + isDetailsCollapsed: true, + + isCreatePending: true + } + }); + } private _filteredItems: XmlDocItemViewModel[] = []; public get filteredItems(): XmlDocItemViewModel[] { return this._filteredItems; } - public get feature(): XmlDocOrFeatureViewModel | undefined { - return this._info.value; - } - public get inspectionItems(): InspectionViewModel[] { return (this.feature as InspectionsFeatureViewModel)?.inspections?.filter(e => !e.isHidden) ?? []; } @@ -57,7 +127,6 @@ export class FeatureInfoComponent implements OnInit, OnChanges { public get quickfixItems(): QuickFixViewModel[] { return (this.feature as QuickFixesFeatureViewModel)?.quickFixes?.filter(e => !e.isHidden) ?? []; } - private readonly _quickfixes: BehaviorSubject = new BehaviorSubject(null!); public get subfeatures(): FeatureViewModel[] { return (this.feature as FeatureViewModel)?.features ?? []; @@ -68,20 +137,11 @@ export class FeatureInfoComponent implements OnInit, OnChanges { return feature?.links ?? []; } - constructor(private api: ApiClientService, private fa: FaIconLibrary) { + constructor(private fa: FaIconLibrary, private api: ApiClientService) { fa.addIconPacks(fas); } ngOnInit(): void { - this.api.getFeature('quickfixes').subscribe(result => { - if (result) { - this._quickfixes.next((result as QuickFixesFeatureViewModel).quickFixes.slice()); - } - }); - } - - - ngOnChanges(changes: SimpleChanges): void { } public onFilter(): void { diff --git a/rubberduckvba.client/src/app/model/feature.model.ts b/rubberduckvba.client/src/app/model/feature.model.ts index 97a0534..6417664 100644 --- a/rubberduckvba.client/src/app/model/feature.model.ts +++ b/rubberduckvba.client/src/app/model/feature.model.ts @@ -1,5 +1,5 @@ export interface ViewModel { - id: number; + id: number | undefined; dateInserted: string; dateUpdated: string; name: string; @@ -10,6 +10,55 @@ export interface ViewModel { isDetailsCollapsed: boolean; } +export interface MarkdownContent { + content: string; +} + +export interface AuditRecordViewModel { + id: number, + dateInserted: string, + dateModified: string | null, + author: string, + approvedAt: string | null, + approvedBy: string | null, + rejectedAt: string | null, + rejectedBy: string | null, + isStale: boolean, + isPending: boolean, +} + +export interface FeatureEditViewModel extends AuditRecordViewModel { + featureId: string, + featureName: string, + fieldName: string, + valueBefore: string | null, + valueAfter: string, +} + +export enum FeatureOperation { + Create = 1, + Delete = 2, +} + +export interface FeatureOperationViewModel extends AuditRecordViewModel { + featureName: string; + featureAction: FeatureOperation; + parentId: number | null; + name: string | null; + title: string | null; + shortDescription: string | null; + description: string | null; + isNew: boolean | null; + isHidden: boolean | null; + hasImage: boolean | null; + links: BlogLink[] | null; +} + +export interface PendingAuditsViewModel { + edits: FeatureEditViewModel[]; + other: FeatureOperationViewModel[]; +} + export interface SubFeatureViewModel extends ViewModel { featureId?: number; featureName?: string; @@ -17,6 +66,7 @@ export interface SubFeatureViewModel extends ViewModel { title: string; description: string; + shortDescription: string; } export interface XmlDocViewModel extends SubFeatureViewModel { @@ -33,6 +83,8 @@ export interface FeatureViewModel extends SubFeatureViewModel { features: FeatureViewModel[]; links: BlogLink[]; + + isCreatePending: boolean; } export interface BlogLink { @@ -185,7 +237,7 @@ export interface AnnotationViewModel extends XmlDocViewModel { export type XmlDocItemViewModel = InspectionViewModel | QuickFixViewModel | AnnotationViewModel; export class ViewModelBase implements ViewModel { - id: number; + id: number | undefined; dateInserted: string; dateUpdated: string; name: string; @@ -242,6 +294,8 @@ export class FeatureViewModelClass extends ViewModelBase { features: FeatureViewModel[]; links: BlogLink[]; + isCreatePending: boolean; + constructor(model: FeatureViewModel) { super(model); this.title = model.title; @@ -252,6 +306,7 @@ export class FeatureViewModelClass extends ViewModelBase { this.links = model.links?.map(e => new BlogLinkViewModelClass(e)) ?? []; this.isCollapsed = !model.hasImage; + this.isCreatePending = model.isCreatePending; } } @@ -261,17 +316,29 @@ export class SubFeatureViewModelClass extends ViewModelBase implements SubFeatur featureTitle?: string | undefined; title: string; description: string; + shortDescription: string; constructor(model: SubFeatureViewModel) { super(model); this.title = model.title; this.description = model.description; + this.shortDescription = model.shortDescription; this.isDetailsCollapsed = true; this.featureId = model.featureId; this.featureName = model.featureName; } } +export class EditSubFeatureViewModelClass extends SubFeatureViewModelClass { + constructor(model: SubFeatureViewModel) { + super(model); + this.isDetailsCollapsed = false; + this.descriptionPreview = model.description; + } + + public descriptionPreview: string; +} + export class InspectionViewModelClass extends SubFeatureViewModelClass implements InspectionViewModel { inspectionType: string; defaultSeverity: string; @@ -429,4 +496,65 @@ export interface UserViewModel { name: string; isAuthenticated: boolean; isAdmin: boolean; + isReviewer: boolean; + isWriter: boolean; +} + +export enum UserActivityType { + SubmitEdit = 'SubmitEdit', + ApproveEdit = 'ApproveEdit', + RejectEdit = 'RejectEdit', + SubmitCreate = 'SubmitCreate', + ApproveCreate = 'ApproveCreate', + RejectCreate = 'RejectCreate', + SubmitDelete = 'SubmitDelete', + ApproveDelete = 'ApproveDelete', + RejectDelete = 'RejectDelete', +} + +export interface UserActivityItem { + id: number; + activityTimestamp: string; + author: string; + activity: UserActivityType; + description: string; + status: UserActivityStatus; + reviewedBy?: string; +} + +export enum UserActivityStatus { + pending = 'Pending', + approved = 'Approved', + rejected = 'Rejected' +} + +export class UserActivityItemClass implements UserActivityItem { + id: number; + activityTimestamp: string; + author: string; + activity: UserActivityType; + description: string; + status: UserActivityStatus; + reviewedBy?: string; + + constructor(item: UserActivityItem) { + this.id = item.id; + this.activityTimestamp = item.activityTimestamp; + this.author = item.author; + this.activity = item.activity; + this.description = item.description; + this.status = item.status; + this.reviewedBy = item.reviewedBy; + } + + public get linkUrl(): string { + switch (this.activity) { + case UserActivityType.SubmitEdit: + case UserActivityType.ApproveEdit: + case UserActivityType.RejectEdit: + return `audits/edits/${this.id}`; + default: + return `audits/ops/${this.id}`; + } + } } diff --git a/rubberduckvba.client/src/app/routes/audits/audit-item/audit-item.component.html b/rubberduckvba.client/src/app/routes/audits/audit-item/audit-item.component.html new file mode 100644 index 0000000..ccc234d --- /dev/null +++ b/rubberduckvba.client/src/app/routes/audits/audit-item/audit-item.component.html @@ -0,0 +1,13 @@ +
+

Review Submitted Operation

+

Any GitHub-authenticated visitor can submit edits to the site content, but only members of the rubberduck-vba organization can review these operations.

+
+
+
+ Rubberduck logo +
+
+
+ + +
diff --git a/rubberduckvba.client/src/app/routes/audits/audit-item/audit-item.component.ts b/rubberduckvba.client/src/app/routes/audits/audit-item/audit-item.component.ts new file mode 100644 index 0000000..b2219ea --- /dev/null +++ b/rubberduckvba.client/src/app/routes/audits/audit-item/audit-item.component.ts @@ -0,0 +1,69 @@ +import { Component, OnInit } from "@angular/core"; +import { ApiClientService } from "../../../services/api-client.service"; +import { ActivatedRoute } from "@angular/router"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { BehaviorSubject, switchMap } from "rxjs"; +import { AuditRecordViewModel, FeatureEditViewModel, FeatureOperationViewModel, UserActivityType } from "../../../model/feature.model"; + +@Component({ + selector: 'app-audit-item', + templateUrl: './audit-item.component.html', +}) +export class AuditItemComponent implements OnInit { + + private _item: BehaviorSubject = new BehaviorSubject(null!); + private _op: BehaviorSubject = new BehaviorSubject(null!); + private _edit: BehaviorSubject = new BehaviorSubject(null!); + + private _isEdit: boolean = false; + + constructor(private fa: FaIconLibrary, private api: ApiClientService, private route: ActivatedRoute) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + const route = this.route; + route.paramMap.pipe( + switchMap(params => { + const id = Number.parseInt(params.get('id')!); + if (route.routeConfig?.path?.includes('/edit')) { + return this.api.getAudit(id, UserActivityType.SubmitEdit); + } + //else if (route.routeConfig?.path?.includes('/op')) { + // return this.api.getAudit(id, UserActivityType.SubmitCreate); + //} + return this.api.getAudit(id, UserActivityType.SubmitCreate); + }) + ).subscribe(e => { + if (e.edits.length > 0) { + const edit = e.edits[0]; + this._item.next(edit); + this._edit.next(edit); + this._isEdit = true; + } + else if (e.other.length > 0) { + const op = e.other[0]; + this._item.next(op); + this._op.next(op); + this._isEdit = false; + } + }); + } + + public get isEdit(): boolean { + return this._isEdit; + } + + public get featureOp(): FeatureOperationViewModel { + return this._op.getValue(); + } + + public get featureEdit(): FeatureEditViewModel { + return this._edit.getValue(); + } + + public get item(): AuditRecordViewModel { + return this._item.getValue(); + } +} diff --git a/rubberduckvba.client/src/app/routes/audits/audits.component.css b/rubberduckvba.client/src/app/routes/audits/audits.component.css new file mode 100644 index 0000000..e69de29 diff --git a/rubberduckvba.client/src/app/routes/audits/audits.component.html b/rubberduckvba.client/src/app/routes/audits/audits.component.html new file mode 100644 index 0000000..cc979e1 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/audits/audits.component.html @@ -0,0 +1,35 @@ +
+

Audits

+

+ Use this page to review pending additions, deletions, and edits to features described on this site; approved/rejected records are not shown. +

+
+
+

Edits

+
+

+ Edits are changes to existing features, such as changing the description, or changing the title. +

+
+

There are no pending edits.

+
+
+ +
+
+
+
+
+

Operations

+
+

+ Operations are additions or deletions of features, such as adding a new feature, or deleting an existing feature. +

+
+

There are no pending operations.

+
+
+ +
+
+
diff --git a/rubberduckvba.client/src/app/routes/audits/audits.component.ts b/rubberduckvba.client/src/app/routes/audits/audits.component.ts new file mode 100644 index 0000000..9abc4ae --- /dev/null +++ b/rubberduckvba.client/src/app/routes/audits/audits.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit } from "@angular/core"; +import { ApiClientService } from "../../services/api-client.service"; +import { FeatureEditViewModel, FeatureOperation, FeatureOperationViewModel, PendingAuditsViewModel } from "../../model/feature.model"; +import { Change, diffWords } from "diff"; +import { encode } from "html-entities"; + +@Component({ + selector: 'app-audits', + templateUrl: './audits.component.html', + styleUrls: ['./audits.component.css'] +}) +export class AuditsAdminComponent implements OnInit { + + constructor(private api: ApiClientService) { + + } + + public pendingAudits: PendingAuditsViewModel = { edits: [], other: [] }; + + ngOnInit(): void { + this.api.getAllPendingAudits().subscribe(e => this.pendingAudits = e); + } + + public get deleteOp() { return FeatureOperation.Delete; } + public get createOp() { return FeatureOperation.Create; } +} diff --git a/rubberduckvba.client/src/app/routes/feature/feature.component.html b/rubberduckvba.client/src/app/routes/feature/feature.component.html index 6226879..9d08dc0 100644 --- a/rubberduckvba.client/src/app/routes/feature/feature.component.html +++ b/rubberduckvba.client/src/app/routes/feature/feature.component.html @@ -1,3 +1,3 @@
- +
diff --git a/rubberduckvba.client/src/app/routes/feature/feature.component.ts b/rubberduckvba.client/src/app/routes/feature/feature.component.ts index f21a64e..e4906cd 100644 --- a/rubberduckvba.client/src/app/routes/feature/feature.component.ts +++ b/rubberduckvba.client/src/app/routes/feature/feature.component.ts @@ -3,9 +3,10 @@ import { ApiClientService } from "../../services/api-client.service"; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { BehaviorSubject, switchMap } from 'rxjs'; -import { XmlDocOrFeatureViewModel } from '../../model/feature.model'; +import { PendingAuditsViewModel, UserViewModel, XmlDocOrFeatureViewModel } from '../../model/feature.model'; import { ActivatedRoute } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AuthService } from '../../services/auth.service'; @Component({ selector: 'app-feature', @@ -16,13 +17,21 @@ export class FeatureComponent implements OnInit { public modal = inject(NgbModal); private readonly _feature: BehaviorSubject = new BehaviorSubject(null!); - public set feature(value: XmlDocOrFeatureViewModel){ - this._feature.next(value); - } public get feature(): XmlDocOrFeatureViewModel { return this._feature.getValue(); } - constructor(private api: ApiClientService, private fa: FaIconLibrary, private route: ActivatedRoute) { + + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); + public get user(): UserViewModel { + return this._user.getValue(); + } + + private readonly _audits: BehaviorSubject = new BehaviorSubject(null!); + public get audits(): PendingAuditsViewModel { + return this._audits.getValue(); + } + + constructor(private auth: AuthService, private api: ApiClientService, private fa: FaIconLibrary, private route: ActivatedRoute) { fa.addIconPacks(fas); } @@ -32,8 +41,16 @@ export class FeatureComponent implements OnInit { const name = params.get('name')!; return this.api.getFeature(name); })).subscribe(e => { - this.feature = e; - console.log(this.feature); + this._feature.next(e); }); + + this.auth.getUser().subscribe(e => { + this._user.next(e); + if (e.isAdmin) { + this.api.getAllPendingAudits().subscribe(a => { + this._audits.next(a); + }); + } + }); } } diff --git a/rubberduckvba.client/src/app/routes/features/features.component.html b/rubberduckvba.client/src/app/routes/features/features.component.html index 2366bed..63ec77d 100644 --- a/rubberduckvba.client/src/app/routes/features/features.component.html +++ b/rubberduckvba.client/src/app/routes/features/features.component.html @@ -25,7 +25,7 @@

Your IDE is incomplete without...

diff --git a/rubberduckvba.client/src/app/routes/features/features.component.ts b/rubberduckvba.client/src/app/routes/features/features.component.ts index cf7c8e6..6814336 100644 --- a/rubberduckvba.client/src/app/routes/features/features.component.ts +++ b/rubberduckvba.client/src/app/routes/features/features.component.ts @@ -1,17 +1,22 @@ -import { Component, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ApiClientService } from "../../services/api-client.service"; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { BehaviorSubject } from 'rxjs'; -import { FeatureViewModel, QuickFixViewModel } from '../../model/feature.model'; +import { FeatureViewModel, PendingAuditsViewModel, QuickFixViewModel, UserViewModel } from '../../model/feature.model'; +import { AuthService } from '../../services/auth.service'; @Component({ selector: 'app-features', templateUrl: './features.component.html', }) -export class FeaturesComponent implements OnInit, OnChanges { +export class FeaturesComponent implements OnInit { + + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); + private readonly _audits: BehaviorSubject = new BehaviorSubject(null!); private readonly _features: BehaviorSubject = new BehaviorSubject(null!); + public set features(value: FeatureViewModel[]) { this._features.next(value); } @@ -19,17 +24,19 @@ export class FeaturesComponent implements OnInit, OnChanges { return this._features.getValue(); } - private readonly _quickFixes: BehaviorSubject = new BehaviorSubject(null!); - public get quickFixes(): QuickFixViewModel[] { - return this._quickFixes.value; + public get user() { + return this._user.getValue(); } - constructor(private api: ApiClientService, private fa: FaIconLibrary) { - fa.addIconPacks(fas); + public get audits() { + return this._audits.getValue() ?? { + edits: [], + other: [] + }; } - ngOnChanges(changes: SimpleChanges): void { - console.log(changes); + constructor(private api: ApiClientService, private auth: AuthService, private fa: FaIconLibrary) { + fa.addIconPacks(fas); } ngOnInit(): void { @@ -38,5 +45,17 @@ export class FeaturesComponent implements OnInit, OnChanges { this._features.next(result.filter(e => !e.isHidden)); } }); + this.auth.getUser().subscribe(result => { + if (result) { + this._user.next(result); + if (this.user.isAdmin) { + this.api.getAllPendingAudits().subscribe(audits => { + if (audits) { + this._audits.next(audits); + } + }) + } + } + }); } } diff --git a/rubberduckvba.client/src/app/routes/profile/user-profile.component.html b/rubberduckvba.client/src/app/routes/profile/user-profile.component.html new file mode 100644 index 0000000..fa1cf94 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/profile/user-profile.component.html @@ -0,0 +1,108 @@ +
+

 {{name}}

+  {{role}} +
+

+ Look, there is no reason for us to track any additional information about you. Get creative on your GitHub profile instead! +

+

+ We do need to track who did what and when, only so we can collaboratively improve the project's website and ensure only authorized users can make changes. + All changes are therefore logged, and this page will therefore display and link to each operation submitted or reviewed under your name. +

+
+
+
+ Rubberduck logo +
+
+
+

Recorded activities

+

+ Everything you did that ever left a trace somewhere on this site is listed here. +

+ +
+
+
+
+

Total Edits

+
+
+ {{totalEdits}} +

submitted

+
+
+
+
+
+
+

Approved Edits

+
+
+ {{totalApprovedEdits}} +

{{totalApprovedEdits/totalEdits | percent}} approved

+
+
+
+
+
+
+

Pending Edits

+
+
+ {{totalPendingEdits}} +

pending review

+
+
+
+
+
+
+

Reviews

+
+
+ {{totalApprovedReviews}} approved +  |  + {{totalRejectedReviews}} rejected +

{{totalApprovedReviews/totalReviews | percent}} reviews approved

+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
ActivityDescriptionAuthorTimestampStatus
Nothing to see here, yet.
 {{activity.activity}}{{activity.description}}{{activity.author}}{{activity.activityTimestamp}} +
+  Pending +  Approved +  Rejected +
+
+
+
+
+
diff --git a/rubberduckvba.client/src/app/routes/profile/user-profile.component.ts b/rubberduckvba.client/src/app/routes/profile/user-profile.component.ts new file mode 100644 index 0000000..e58fa60 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/profile/user-profile.component.ts @@ -0,0 +1,132 @@ +import { Component, OnInit } from "@angular/core"; +import { UserActivityItem, UserActivityItemClass, UserActivityType, UserViewModel } from "../../model/feature.model"; +import { AuthService } from "../../services/auth.service"; +import { BehaviorSubject } from "rxjs"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { IconDefinition, fas } from "@fortawesome/free-solid-svg-icons"; +import { ApiClientService } from "../../services/api-client.service"; + +@Component({ + selector: 'app-profile', + templateUrl: './user-profile.component.html', +}) +export class UserProfileComponent implements OnInit { + + private _user: BehaviorSubject = new BehaviorSubject(null!); + private _activity: BehaviorSubject = new BehaviorSubject([]); + + constructor(private fa: FaIconLibrary, private auth: AuthService, private api: ApiClientService) { + fa.addIconPacks(fas); + this.auth.getUser().subscribe(user => { + if (user) { + this._user.next(user); + this.api.getUserActivity().subscribe(activities => { + if (activities) { + this._activity.next(activities.map(e => new UserActivityItemClass(e))); + } + }); + } + }); + } + + ngOnInit(): void { + } + + public get name(): string { + return this._user.getValue()?.name; + } + + public get canReview(): boolean { + const user = this._user.getValue(); + return user?.isAdmin || user?.isReviewer; + } + + public get role(): string { + const user = this._user.getValue(); + if (user.isAdmin) { + return 'Administrator'; + } else if (user.isReviewer) { + return 'Reviewer'; + } else if (user.isWriter) { + return 'Writer'; + } else { + return 'Reader'; + } + } + + public get roleDescription(): string { + const user = this._user.getValue(); + if (user.isAdmin) { + return 'Can authorize operations from any user including themselves.'; + } else if (user.isReviewer) { + return 'Can authorize operations from any user excluding themselves.'; + } else if (user.isWriter) { + return 'Can submit operations for review/approval.'; + } else { + return 'Unauthenticated users only have a reader role.'; + } + } + + public get totalEdits(): number { + return this.activities.filter(e => e.activity == 'SubmitEdit' || e.activity == 'SubmitCreate').length; + } + public get totalApprovedEdits(): number { + return this.activities.filter(e => (e.activity == 'SubmitEdit' || e.activity == 'SubmitCreate') && e.status == 'Approved').length; + } + public get totalPendingEdits(): number { + return this.activities.filter(e => (e.activity == 'SubmitEdit' || e.activity == 'SubmitCreate') && e.status == 'Pending').length; + } + public get totalReviews(): number { + return this.activities.filter(e => e.reviewedBy == this.name).length; + } + public get totalApprovedReviews(): number { + return this.activities.filter(e => e.reviewedBy == this.name && e.status == 'Approved').length; + } + public get totalRejectedReviews(): number { + return this.activities.filter(e => e.reviewedBy == this.name && e.status == 'Rejected').length; + } + + public get activities(): UserActivityItemClass[] { + return this._activity.getValue(); + } + + public getActivityIcon(activity: UserActivityType) : IconDefinition { + switch (activity) { + case UserActivityType.SubmitEdit: + return this.fa.getIconDefinition('fas', 'square-pen')!; + case UserActivityType.SubmitCreate: + return this.fa.getIconDefinition('fas', 'square-plus')!; + case UserActivityType.SubmitDelete: + return this.fa.getIconDefinition('fas', 'square-minus')!; + case UserActivityType.ApproveEdit: + return this.fa.getIconDefinition('fas', 'user-pen')!; + case UserActivityType.ApproveCreate: + return this.fa.getIconDefinition('fas', 'user-plus')!; + case UserActivityType.ApproveDelete: + return this.fa.getIconDefinition('fas', 'user-minus')!; + case UserActivityType.RejectEdit: + case UserActivityType.RejectCreate: + case UserActivityType.RejectDelete: + return this.fa.getIconDefinition('fas', 'user-xmark')!; + default: + return null!; + } + } + + public getActivityClass(activity: UserActivityType): string { + switch (activity) { + case UserActivityType.SubmitCreate: + case UserActivityType.SubmitDelete: + case UserActivityType.SubmitEdit: + return 'text-primary'; + case UserActivityType.ApproveCreate: + case UserActivityType.ApproveDelete: + case UserActivityType.ApproveEdit: + return 'text-success'; + case UserActivityType.RejectCreate: + case UserActivityType.RejectDelete: + case UserActivityType.RejectEdit: + return 'text-danger'; + } + } +} diff --git a/rubberduckvba.client/src/app/services/api-client.service.ts b/rubberduckvba.client/src/app/services/api-client.service.ts index 5be402b..0fdd06f 100644 --- a/rubberduckvba.client/src/app/services/api-client.service.ts +++ b/rubberduckvba.client/src/app/services/api-client.service.ts @@ -1,11 +1,11 @@ import { Injectable } from "@angular/core"; import { LatestTags, Tag } from "../model/tags.model"; -import { AnnotationViewModel, AnnotationViewModelClass, AnnotationsFeatureViewModel, AnnotationsFeatureViewModelClass, FeatureViewModel, FeatureViewModelClass, InspectionViewModel, InspectionViewModelClass, InspectionsFeatureViewModel, InspectionsFeatureViewModelClass, QuickFixViewModel, QuickFixViewModelClass, QuickFixesFeatureViewModel, QuickFixesFeatureViewModelClass, SubFeatureViewModel, SubFeatureViewModelClass, UserViewModel, XmlDocOrFeatureViewModel } from "../model/feature.model"; +import { AnnotationViewModel, AnnotationViewModelClass, AnnotationsFeatureViewModel, AnnotationsFeatureViewModelClass, FeatureViewModel, FeatureViewModelClass, InspectionViewModel, InspectionViewModelClass, InspectionsFeatureViewModel, InspectionsFeatureViewModelClass, MarkdownContent, PendingAuditsViewModel, QuickFixViewModel, QuickFixViewModelClass, QuickFixesFeatureViewModel, QuickFixesFeatureViewModelClass, SubFeatureViewModel, SubFeatureViewModelClass, UserActivityItem, UserActivityType, XmlDocOrFeatureViewModel } from "../model/feature.model"; import { DownloadInfo } from "../model/downloads.model"; import { DataService } from "./data.service"; import { environment } from "../../environments/environment.prod"; import { Observable, map } from "rxjs"; -import { IndenterVersionViewModelClass, IndenterViewModel, IndenterViewModelClass } from "../model/indenter.model"; +import { IndenterViewModel, IndenterViewModelClass } from "../model/indenter.model"; @Injectable() export class ApiClientService { @@ -82,4 +82,54 @@ export class ApiClientService { return model; })); } + + public getUserActivity(): Observable { + const url = `${environment.apiBaseUrl}profile/activity`; + return this.data.getAsync(url); + } + + public createFeature(model: SubFeatureViewModel): Observable { + const url = `${environment.apiBaseUrl}features/create`; + return this.data.postAsync(url, model).pipe(map(result => new SubFeatureViewModelClass(result as SubFeatureViewModel))); + } + + public saveFeature(model: SubFeatureViewModel): Observable { + const url = `${environment.apiBaseUrl}features/update`; + return this.data.postAsync(url, model).pipe(map(result => new SubFeatureViewModelClass(result as SubFeatureViewModel))); + } + + public deleteFeature(model: SubFeatureViewModel): Observable { + const url = `${environment.apiBaseUrl}features/delete`; + return this.data.postAsync(url, model).pipe(map(() => model)); + } + + public getAudit(id: number, type: UserActivityType): Observable { + const url = `${environment.apiBaseUrl}admin/audits/${id}?type=${type}`; + return this.data.getAsync(url); + } + public getPendingAudits(featureId: number): Observable { + const url = `${environment.apiBaseUrl}admin/audits/feature/${featureId}`; + return this.data.getAsync(url); + } + public getAllPendingAudits(): Observable { + const url = `${environment.apiBaseUrl}admin/audits/pending`; + return this.data.getAsync(url); + } + + public approvePendingAudit(auditId: number): Observable { + const url = `${environment.apiBaseUrl}admin/audits/approve/${auditId}`; + return this.data.postAsync(url); + } + public rejectPendingAudit(auditId: number): Observable { + const url = `${environment.apiBaseUrl}admin/audits/reject/${auditId}`; + return this.data.postAsync(url); + } + + public formatMarkdown(raw: string): Observable { + const url = `${environment.apiBaseUrl}markdown/format`; + const content: MarkdownContent = { + content: raw + }; + return this.data.postAsync(url, content); + } } diff --git a/rubberduckvba.client/src/app/services/auth.service.ts b/rubberduckvba.client/src/app/services/auth.service.ts index fc0c3da..37c6d01 100644 --- a/rubberduckvba.client/src/app/services/auth.service.ts +++ b/rubberduckvba.client/src/app/services/auth.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@angular/core"; -import { Observable, map } from "rxjs"; +import { BehaviorSubject, Observable, map, shareReplay } from "rxjs"; import { environment } from "../../environments/environment"; import { UserViewModel } from "../model/feature.model"; import { AuthViewModel, DataService } from "./data.service"; @@ -7,8 +7,17 @@ import { AuthViewModel, DataService } from "./data.service"; @Injectable({ providedIn: 'root' }) export class AuthService { - private timeout: number = 10000; - constructor(private data: DataService) { } + private readonly _anonymousUser = { + isAdmin: false, + isWriter: false, + isReviewer: false, + isAuthenticated: false, + name: '(anonymous)', + }; + private _user: BehaviorSubject = new BehaviorSubject(this._anonymousUser); + + constructor(private data: DataService) { + } private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); @@ -27,8 +36,14 @@ export class AuthService { } public getUser(): Observable { + if (this._user.getValue().isAuthenticated) { + return this._user; + } + const url = `${environment.apiBaseUrl}auth`; - return this.data.getAsync(url); + const request = this.data.getAsync(url).pipe(shareReplay(2)); + request.subscribe(user => { this._user.next(user); }); + return request; } public signin(): void { @@ -37,10 +52,11 @@ export class AuthService { const url = `${environment.apiBaseUrl}auth/signin`; this.data.postAsync(url, vm) - .subscribe((result: string) => this.redirect(result)); + .subscribe((redirectUrl: string) => this.redirect(redirectUrl)); } public signout(): void { + this._user.next(this._anonymousUser); sessionStorage.clear(); } @@ -66,7 +82,6 @@ export class AuthService { } } else { - console.log('xsrf:state mismatched!'); this.redirect(); } } diff --git a/rubberduckvba.client/src/app/services/data.service.ts b/rubberduckvba.client/src/app/services/data.service.ts index c66a376..96ffb3e 100644 --- a/rubberduckvba.client/src/app/services/data.service.ts +++ b/rubberduckvba.client/src/app/services/data.service.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpHeaders } from "@angular/common/http"; +import { HttpClient, HttpContext, HttpHeaders } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { map, timeout, catchError, throwError, Observable } from "rxjs"; @@ -14,13 +14,11 @@ export class DataService { let headers = new HttpHeaders() .append('accept', 'application/json'); const token = sessionStorage.getItem('github:access_token'); - let withCreds = false; if (token) { - headers = headers.append('X-ACCESS-TOKEN', token); - withCreds = true; + headers = headers.append('x-access-token', token); } - return this.http.get(url, { headers, withCredentials: withCreds }) + return this.http.get(url, { headers }) .pipe( map(result => result), timeout(this.timeout), @@ -38,15 +36,13 @@ export class DataService { .append('Content-Type', 'application/json; charset=utf-8'); const token = sessionStorage.getItem('github:access_token'); - let withCreds = false; if (token) { - headers = headers.append('X-ACCESS-TOKEN', token); - withCreds = true; + headers = headers.append('x-access-token', token); } return (content - ? this.http.post(url, content, { headers, withCredentials: withCreds }) - : this.http.post(url, null, { headers, withCredentials: withCreds })) + ? this.http.post(url, content, { headers }) + : this.http.post(url, null, { headers })) .pipe( map(result => result), timeout(this.timeout), diff --git a/rubberduckvba.client/src/environments/environment.prod.ts b/rubberduckvba.client/src/environments/environment.prod.ts index d3de473..e95e3c7 100644 --- a/rubberduckvba.client/src/environments/environment.prod.ts +++ b/rubberduckvba.client/src/environments/environment.prod.ts @@ -1,4 +1,4 @@ export const environment = { production: true, - apiBaseUrl: 'https://api.rubberduckvba.com/' + apiBaseUrl: 'https://localhost:44314/' }; diff --git a/rubberduckvba.client/src/environments/environment.ts b/rubberduckvba.client/src/environments/environment.ts index fbede92..0fd806b 100644 --- a/rubberduckvba.client/src/environments/environment.ts +++ b/rubberduckvba.client/src/environments/environment.ts @@ -4,7 +4,7 @@ export const environment = { production: false, - apiBaseUrl: 'https://api.rubberduckvba.com/' + apiBaseUrl: 'https://localhost:44314/' }; /* diff --git a/rubberduckvba.client/src/styles.css b/rubberduckvba.client/src/styles.css index e0824eb..3123c31 100644 --- a/rubberduckvba.client/src/styles.css +++ b/rubberduckvba.client/src/styles.css @@ -193,3 +193,13 @@ span.icon-userform-module { span.icon-document-module { background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAApJJREFUeNqMU09IFGEU/83s7Kqr69riLonE9E8jNxM6CR3c9CTRJbooeBMqunRN0e1QZESR0aEiEAShWPDUwRBDocQOxeyhAt0y1PDP7upqubOzOzPbe+OMrJfowfveN9/3fr/v9973jXBlcBBsgiBcolCHf9s70zQTpmGgWCwitbQEgSfRkRF8WVy8OhqNPsvTZoHdNGHQHu+zKYqC0cnJaxaJYSw460wQoRghgu4GWW7QdR1qoYDfuRzyNNeJiJPj8Ti6Oztxub//OuVP0dqCQ3A7caMjqn2dhcfrQU1rB2r6YsgxmJQYRPBH0/BmYmK/jidjYwMU7vBc4oHBR/pfwlN3FDvD3XC7XLj1fAPLyymsrWUw/vQc2tvbLSU+nw9vFWXLIRN54JPL6o6hPHwevgtd1kZLYzVe32uG3++FKAiQRJEbbe2RMoHVsVsK/CR7e7jLAhvzH7Hx4yfef0hD004iEKiCRIo8EqVSWUxi6LrgKGCC6dDAODeyjReM1U/IFjXc7D2M0/V+9F70I5vPo9zttgCuPQLxAAH76lR9W2VtJcRMPdA8jMezjyCXyUilUrh74r7VF4uAol5CsD/JJrOobpmHWfMLocBZyK0y+mr7kEwmwXq5D0zipl4QgcDXzS45BOqmiu+vDsEb9KKavvnknm89SKfTVt2i3UCrhELhQAls01rkc8+ZpqbjfFUm+YNTD4FGeol7Xd8n4LG0BKkhEkFlMMh9eKHEYkO8yCQMNO2nXLRLgB11+xZ2qTwxMTODzZUVqLu7kv1TWUlO3c63SLU765zLGMaylIrlubnQ9tZWmSNLKAG4KLLzW5DoLfAtcC5jGMunVpCHdtbXM1Xh8BD+w0xVzTCG2/FXgAEAjik0I5QgmIIAAAAASUVORK5CYII='); } + + +.text-diff-added { + background-color: #d4fcbc; +} + +.text-diff-removed { + background-color: #fbb6c2; + text-decoration: line-through; +}