From 6bb4741a05c198ac48e8b496b201624ce99cedbe Mon Sep 17 00:00:00 2001 From: Mathieu Guindon Date: Tue, 10 Jun 2025 20:38:40 -0400 Subject: [PATCH 01/10] add admin endpoints, edit summary/description --- .../Api/Admin/AdminController.cs | 5 +- .../Api/Features/FeatureEditViewModel.cs | 8 +- .../Api/Features/FeatureViewModel.cs | 2 +- .../Api/Features/FeaturesController.cs | 44 +++++---- .../Model/Entity/FeatureEntity.cs | 2 +- rubberduckvba.Server/Model/Feature.cs | 6 +- rubberduckvba.Server/Program.cs | 13 +-- .../Services/RubberduckDbService.cs | 2 +- .../Services/rubberduckdb/FeatureServices.cs | 2 +- rubberduckvba.client/src/app/app.module.ts | 5 +- .../edit-feature/edit-feature.component.html | 85 +++++++++++++++++ .../edit-feature/edit-feature.component.ts | 91 +++++++++++++++++++ .../feature-box/feature-box.component.html | 9 ++ .../feature-box/feature-box.component.ts | 53 ++++++++--- .../feature-info/feature-info.component.html | 4 + .../feature-info/feature-info.component.ts | 26 ++++-- .../src/app/model/feature.model.ts | 16 ++++ .../routes/features/features.component.html | 2 +- .../app/routes/features/features.component.ts | 13 +-- .../src/app/services/api-client.service.ts | 20 +++- .../src/app/services/data.service.ts | 10 +- .../src/environments/environment.prod.ts | 2 +- .../src/environments/environment.ts | 2 +- 23 files changed, 333 insertions(+), 89 deletions(-) create mode 100644 rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html create mode 100644 rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.ts diff --git a/rubberduckvba.Server/Api/Admin/AdminController.cs b/rubberduckvba.Server/Api/Admin/AdminController.cs index c478ca3..382b1ee 100644 --- a/rubberduckvba.Server/Api/Admin/AdminController.cs +++ b/rubberduckvba.Server/Api/Admin/AdminController.cs @@ -7,6 +7,7 @@ namespace rubberduckvba.Server.Api.Admin; [ApiController] +[EnableCors(CorsPolicies.AllowAll)] public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire, CacheService cache) : ControllerBase { /// @@ -14,7 +15,6 @@ public class AdminController(ConfigurationOptions options, HangfireLauncherServi /// /// The unique identifier of the enqueued job. [Authorize("github")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [HttpPost("admin/update/xmldoc")] public IActionResult UpdateXmldocContent() { @@ -27,7 +27,6 @@ public IActionResult UpdateXmldocContent() /// /// The unique identifier of the enqueued job. [Authorize("github")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [HttpPost("admin/update/tags")] public IActionResult UpdateTagMetadata() { @@ -36,7 +35,6 @@ public IActionResult UpdateTagMetadata() } [Authorize("github")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [HttpPost("admin/cache/clear")] public IActionResult ClearCache() { @@ -46,7 +44,6 @@ public IActionResult ClearCache() #if DEBUG [AllowAnonymous] - [EnableCors(CorsPolicies.AllowAll)] [HttpGet("admin/config/current")] public IActionResult Config() { diff --git a/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs b/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs index 62868d6..fa3d9f6 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, + ParentId = 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.ParentId; 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..062734c 100644 --- a/rubberduckvba.Server/Api/Features/FeatureViewModel.cs +++ b/rubberduckvba.Server/Api/Features/FeatureViewModel.cs @@ -11,7 +11,7 @@ public FeatureViewModel(Feature model, bool summaryOnly = false) DateInserted = model.DateTimeInserted; DateUpdated = model.DateTimeUpdated; - FeatureId = model.FeatureId; + FeatureId = model.ParentId; FeatureName = model.FeatureName; Name = model.Name; diff --git a/rubberduckvba.Server/Api/Features/FeaturesController.cs b/rubberduckvba.Server/Api/Features/FeaturesController.cs index 6868419..df32afc 100644 --- a/rubberduckvba.Server/Api/Features/FeaturesController.cs +++ b/rubberduckvba.Server/Api/Features/FeaturesController.cs @@ -9,8 +9,14 @@ 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; @@ -18,8 +24,9 @@ public class FeaturesController : RubberduckApiController 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, FeatureServices features, IMarkdownFormattingService markdownService, IRepository assetsRepository, IRepository tagsRepository, ILogger logger) : base(logger) { @@ -28,6 +35,7 @@ public FeaturesController(CacheService cache, IRubberduckDbService db, FeatureSe this.features = features; this.assetsRepository = assetsRepository; this.tagsRepository = tagsRepository; + this.markdownService = markdownService; } private static RepositoryOptionViewModel[] RepositoryOptions { get; } = @@ -38,8 +46,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 +74,6 @@ public IActionResult Index() } [HttpGet("features/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Info([FromRoute] string name) { return GuardInternalAction(() => @@ -85,8 +89,6 @@ public IActionResult Info([FromRoute] string name) } [HttpGet("inspections/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Inspection([FromRoute] string name) { return GuardInternalAction(() => @@ -107,8 +109,6 @@ public IActionResult Inspection([FromRoute] string name) } [HttpGet("annotations/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Annotation([FromRoute] string name) { return GuardInternalAction(() => @@ -129,8 +129,6 @@ public IActionResult Annotation([FromRoute] string name) } [HttpGet("quickfixes/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult QuickFix([FromRoute] string name) { return GuardInternalAction(() => @@ -151,7 +149,6 @@ public IActionResult QuickFix([FromRoute] string name) } [HttpGet("features/create")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Create([FromQuery] RepositoryId repository = RepositoryId.Rubberduck, [FromQuery] int? parentId = default) { @@ -163,7 +160,6 @@ public async Task> Create([FromQuery] Reposit } [HttpPost("create")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Create([FromBody] FeatureEditViewModel model) { @@ -179,14 +175,14 @@ public async Task> Create([FromBody] FeatureE } var feature = model.ToFeature(); - var result = await db.SaveFeature(feature); + var result = await db.SaveFeature(feature); var features = await GetFeatureOptions(model.RepositoryId); + return Ok(new FeatureEditViewModel(result, features, RepositoryOptions)); } [HttpPost("features/update")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Update([FromBody] FeatureEditViewModel model) { @@ -201,12 +197,25 @@ public async Task> Update([FromBody] FeatureE return BadRequest("Model is invalid for this endpoint."); } - var result = await db.SaveFeature(model.ToFeature()); + var feature = model.ToFeature(); + + var result = await db.SaveFeature(feature); var features = await GetFeatureOptions(model.RepositoryId); 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() { InspectionsFeatureViewModel result; @@ -274,5 +283,4 @@ private FeatureViewModel GetFeature(string name) return result; } - -} +} \ No newline at end of file diff --git a/rubberduckvba.Server/Model/Entity/FeatureEntity.cs b/rubberduckvba.Server/Model/Entity/FeatureEntity.cs index 38b2803..a623791 100644 --- a/rubberduckvba.Server/Model/Entity/FeatureEntity.cs +++ b/rubberduckvba.Server/Model/Entity/FeatureEntity.cs @@ -4,7 +4,7 @@ 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!; diff --git a/rubberduckvba.Server/Model/Feature.cs b/rubberduckvba.Server/Model/Feature.cs index b7d0b30..9926c0c 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; + ParentId = entity.ParentId; FeatureName = entity.FeatureName; RepositoryId = (Services.RepositoryId)entity.RepositoryId; Title = entity.Title; @@ -45,7 +45,7 @@ public Feature(FeatureEntity entity) : this() public DateTime? DateTimeUpdated { get; init; } public string Name { get; init; } = string.Empty; - public int? FeatureId { get; init; } + public int? ParentId { get; init; } public string FeatureName { get; init; } = string.Empty; public Services.RepositoryId RepositoryId { get; init; } = Services.RepositoryId.Rubberduck; public string Title { get; init; } = string.Empty; @@ -69,7 +69,7 @@ public Feature(FeatureEntity entity) : this() IsNew = IsNew, Name = Name, ShortDescription = ShortDescription, - FeatureId = FeatureId, + ParentId = ParentId, FeatureName = FeatureName, RepositoryId = (int)Services.RepositoryId.Rubberduck, Title = Title, diff --git a/rubberduckvba.Server/Program.cs b/rubberduckvba.Server/Program.cs index 05feb62..216e39c 100644 --- a/rubberduckvba.Server/Program.cs +++ b/rubberduckvba.Server/Program.cs @@ -35,7 +35,6 @@ public class HangfireAuthenticationFilter : IDashboardAuthorizationFilter public static class CorsPolicies { public const string AllowAll = "AllowAll"; - public const string AllowAuthenticated = "AllowAuthenticated"; } public class Program @@ -57,17 +56,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(); }); }); diff --git a/rubberduckvba.Server/Services/RubberduckDbService.cs b/rubberduckvba.Server/Services/RubberduckDbService.cs index 95a88ad..2a17a67 100644 --- a/rubberduckvba.Server/Services/RubberduckDbService.cs +++ b/rubberduckvba.Server/Services/RubberduckDbService.cs @@ -160,7 +160,7 @@ public async Task ResolveFeature(RepositoryId repositoryId, string { var features = _featureServices.Get(topLevelOnly: false).ToList(); var feature = features.Single(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase)); - var children = features.Where(e => e.FeatureId == feature.Id); + var children = features.Where(e => e.ParentId == feature.Id); return new FeatureGraph(feature.ToEntity()) { Features = children.ToArray() diff --git a/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs b/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs index 2a0033f..3f9149f 100644 --- a/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs +++ b/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs @@ -15,7 +15,7 @@ public class FeatureServices( public IEnumerable Get(bool topLevelOnly = true) { return featureRepository.GetAll() - .Where(e => !topLevelOnly || e.FeatureId is null) + .Where(e => !topLevelOnly || e.ParentId is null) .Select(e => new Feature(e)); } diff --git a/rubberduckvba.client/src/app/app.module.ts b/rubberduckvba.client/src/app/app.module.ts index 86f5ce9..6cf1f25 100644 --- a/rubberduckvba.client/src/app/app.module.ts +++ b/rubberduckvba.client/src/app/app.module.ts @@ -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'; @@ -78,7 +80,8 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { InspectionComponent, AnnotationComponent, QuickFixComponent, - AboutComponent + AboutComponent, + EditFeatureComponent ], bootstrap: [AppComponent], imports: [ 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..dd788c2 --- /dev/null +++ b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html @@ -0,0 +1,85 @@ + + + + + + + + + +
+ + + +
+
+ + +
+ + + +
+
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..621c69d --- /dev/null +++ b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.ts @@ -0,0 +1,91 @@ +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, OnChanges { + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); + private readonly _feature: BehaviorSubject = new BehaviorSubject(null!); + + private _action: AdminAction = AdminAction.Create; + + @ViewChild('editModal', { read: TemplateRef }) editModal: 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; + } + + @Input() + public set action(value: AdminAction) { + this._action = value; + } + + public get action(): AdminAction { + return this._action; + } + + @Output() + public onApplyChanges = new EventEmitter(); + + + constructor(private fa: FaIconLibrary, private api: ApiClientService) { + fa.addIconPacks(fas); + } + + ngOnChanges(changes: SimpleChanges): void { + } + + ngOnInit(): void { + } + + public doAction(): void { + const localModal = this.action == 'delete' ? this.deleteModal : this.editModal; + const size = this.action == 'delete' ? 'modal-m' : 'modal-xl'; + this.modal.open(localModal, { modalDialogClass: size }); + } + + public onConfirmChanges(): void { + this.modal.dismissAll(); + this.api.saveFeature(this.feature).subscribe(saved => { + this._feature.next(new EditSubFeatureViewModelClass(saved)); + this.onApplyChanges.emit(saved) + }); + } + + public onPreviewDescription(): void { + this.api.formatMarkdown(this.feature.description).subscribe((formatted: MarkdownContent) => { + this.feature.descriptionPreview = formatted.content; + }); + } + + public onDeleteFeature(): void { + this.modal.dismissAll(); + this.api.deleteFeature(this.feature).subscribe(() => { + this._feature.next(new EditSubFeatureViewModelClass(null!)); + }); + } +} 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..908b67c 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 @@ -29,5 +29,14 @@

{{feature.title}}

+ 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..0b5d70d 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,17 +1,20 @@ import { Component, Input, OnChanges, OnInit, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; -import { FeatureViewModel, QuickFixViewModel, SubFeatureViewModel } from '../../model/feature.model'; +import { FeatureViewModel, 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'; @Component({ selector: 'feature-box', templateUrl: './feature-box.component.html' }) -export class FeatureBoxComponent implements OnInit { +export class FeatureBoxComponent implements OnInit, OnChanges { private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); @ViewChild('content', { read: TemplateRef }) content: TemplateRef | undefined; public modal = inject(NgbModal); @@ -22,6 +25,20 @@ export class FeatureBoxComponent implements OnInit { @Input() public hasOwnDetailsPage: boolean = false; + 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) { @@ -29,6 +46,11 @@ export class FeatureBoxComponent implements OnInit { } } + public editAction: AdminAction = AdminAction.EditSummary; + public editDetailsAction: AdminAction = AdminAction.Edit; + public createAction: AdminAction = AdminAction.Create; + public deleteAction: AdminAction = AdminAction.Delete; + public get feature(): FeatureViewModel | undefined { return this._info.value as FeatureViewModel; } @@ -37,27 +59,28 @@ export class FeatureBoxComponent implements OnInit { return this._info.value as SubFeatureViewModel; } - private readonly _quickfixes: BehaviorSubject = new BehaviorSubject(null!); - - @Input() - public set quickFixes(value: QuickFixViewModel[]) { - if (value != null) { - this._quickfixes.next(value); - } + constructor(private fa: FaIconLibrary, private auth: AuthService) { + fa.addIconPacks(fas); } - - public get quickFixes(): QuickFixViewModel[] { - return this._quickfixes.value; + ngOnChanges(changes: SimpleChanges): void { + console.log(changes); } - constructor(private fa: FaIconLibrary) { - fa.addIconPacks(fas); + ngOnInit(): void { + this.auth.getUser().subscribe(vm => { + this._user.next(vm); + }); } - ngOnInit(): void { + public applyChanges(model: any): void { + this._info.next(model); } public showDetails(): void { this.modal.open(this.content); } + + public get user(): UserViewModel { + return this._user.getValue(); + } } 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..2453a08 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,6 +4,9 @@

HomeFeatures{{feature?.featureName}}

{{feature?.title}}

+
+ +

@@ -34,6 +37,7 @@

{{feature?.title}}

+
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..19de4d5 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 { AnnotationViewModel, AnnotationsFeatureViewModel, BlogLink, FeatureViewModel, InspectionViewModel, InspectionsFeatureViewModel, QuickFixViewModel, QuickFixesFeatureViewModel, SubFeatureViewModel, UserViewModel, XmlDocItemViewModel, XmlDocOrFeatureViewModel, XmlDocViewModel } from '../../model/feature.model'; import { BehaviorSubject } from 'rxjs'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { ApiClientService } from '../../services/api-client.service'; +import { AuthService } from '../../services/auth.service'; +import { AdminAction } from '../edit-feature/edit-feature.component'; @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!); + + public editAction: AdminAction = AdminAction.Edit; + public createAction: AdminAction = AdminAction.Create; + public deleteAction: AdminAction = AdminAction.Delete; public filterState = { // searchbox @@ -68,11 +75,20 @@ export class FeatureInfoComponent implements OnInit, OnChanges { return feature?.links ?? []; } - constructor(private api: ApiClientService, private fa: FaIconLibrary) { + public get user(): UserViewModel { + return this._user.getValue(); + } + + constructor(private auth: AuthService, private api: ApiClientService, private fa: FaIconLibrary) { fa.addIconPacks(fas); } ngOnInit(): void { + this.auth.getUser().subscribe(result => { + if (result) { + this._user.next(result as UserViewModel); + } + }); this.api.getFeature('quickfixes').subscribe(result => { if (result) { this._quickfixes.next((result as QuickFixesFeatureViewModel).quickFixes.slice()); @@ -80,10 +96,6 @@ export class FeatureInfoComponent implements OnInit, OnChanges { }); } - - ngOnChanges(changes: SimpleChanges): void { - } - public onFilter(): void { this.filterByNameOrDescription(this.filterState.filterText); } diff --git a/rubberduckvba.client/src/app/model/feature.model.ts b/rubberduckvba.client/src/app/model/feature.model.ts index 97a0534..f20df66 100644 --- a/rubberduckvba.client/src/app/model/feature.model.ts +++ b/rubberduckvba.client/src/app/model/feature.model.ts @@ -10,6 +10,10 @@ export interface ViewModel { isDetailsCollapsed: boolean; } +export interface MarkdownContent { + content: string; +} + export interface SubFeatureViewModel extends ViewModel { featureId?: number; featureName?: string; @@ -272,6 +276,18 @@ export class SubFeatureViewModelClass extends ViewModelBase implements SubFeatur } } +export class EditSubFeatureViewModelClass extends SubFeatureViewModelClass { + constructor(model: SubFeatureViewModel) { + super(model); + this.isDetailsCollapsed = false; + this.descriptionPreview = model.description; + this.shortDescription = (model as FeatureViewModel)?.shortDescription; + } + + public descriptionPreview: string; + public shortDescription?: string; +} + export class InspectionViewModelClass extends SubFeatureViewModelClass implements InspectionViewModel { inspectionType: string; defaultSeverity: string; diff --git a/rubberduckvba.client/src/app/routes/features/features.component.html b/rubberduckvba.client/src/app/routes/features/features.component.html index 2366bed..f55801a 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..5e1acd7 100644 --- a/rubberduckvba.client/src/app/routes/features/features.component.ts +++ b/rubberduckvba.client/src/app/routes/features/features.component.ts @@ -1,4 +1,4 @@ -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'; @@ -9,7 +9,7 @@ import { FeatureViewModel, QuickFixViewModel } from '../../model/feature.model'; selector: 'app-features', templateUrl: './features.component.html', }) -export class FeaturesComponent implements OnInit, OnChanges { +export class FeaturesComponent implements OnInit { private readonly _features: BehaviorSubject = new BehaviorSubject(null!); public set features(value: FeatureViewModel[]) { @@ -19,19 +19,10 @@ 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; - } - constructor(private api: ApiClientService, private fa: FaIconLibrary) { fa.addIconPacks(fas); } - ngOnChanges(changes: SimpleChanges): void { - console.log(changes); - } - ngOnInit(): void { this.api.getFeatureSummaries().subscribe(result => { if (result) { diff --git a/rubberduckvba.client/src/app/services/api-client.service.ts b/rubberduckvba.client/src/app/services/api-client.service.ts index 5be402b..37d4899 100644 --- a/rubberduckvba.client/src/app/services/api-client.service.ts +++ b/rubberduckvba.client/src/app/services/api-client.service.ts @@ -1,6 +1,6 @@ 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, QuickFixViewModel, QuickFixViewModelClass, QuickFixesFeatureViewModel, QuickFixesFeatureViewModelClass, SubFeatureViewModel, SubFeatureViewModelClass, UserViewModel, XmlDocOrFeatureViewModel } from "../model/feature.model"; import { DownloadInfo } from "../model/downloads.model"; import { DataService } from "./data.service"; import { environment } from "../../environments/environment.prod"; @@ -82,4 +82,22 @@ export class ApiClientService { return model; })); } + + 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 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/data.service.ts b/rubberduckvba.client/src/app/services/data.service.ts index c66a376..748b7ea 100644 --- a/rubberduckvba.client/src/app/services/data.service.ts +++ b/rubberduckvba.client/src/app/services/data.service.ts @@ -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; } - 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; } 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/' }; /* From 5d62997a0e943c4099e43c7b1d6ebca01ebe59ef Mon Sep 17 00:00:00 2001 From: Mathieu Guindon Date: Tue, 10 Jun 2025 22:31:40 -0400 Subject: [PATCH 02/10] add create mechanics --- .../edit-feature/edit-feature.component.html | 84 ++++++++++++++++++- .../edit-feature/edit-feature.component.ts | 54 ++++++++++-- .../src/app/model/feature.model.ts | 5 +- 3 files changed, 134 insertions(+), 9 deletions(-) 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 index dd788c2..12ca603 100644 --- a/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html +++ b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html @@ -35,7 +35,7 @@
Edit {{action == 'edit' ? 'description' : 'summary'}}
-
@@ -64,6 +64,88 @@

{{feature.title}}

+ +
+ + + +
+
+
{{feature.description.length}} - {{feature.shortDescription?.length ?? 0}} -
+ {{feature.shortDescription.length}} +
x

+
+
+ +
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..c6d22c8 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,12 +32,14 @@ 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); } ngOnInit(): void { this.getUserInfo(); + this.api.getPendingAudits().subscribe(e => this.pendingAudits = e.edits.length + e.other.length); } getUserInfo(): void { @@ -46,6 +50,34 @@ export class AuthMenuComponent implements OnInit { }); } + + 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 disabled'; + } + else { + return 'text-danger'; + } + } + + public confirm(): void { this.modal.open(this.confirmbox); } @@ -62,6 +94,14 @@ export class AuthMenuComponent implements OnInit { this.modal.open(this.confirmclearcachebox); } + public reviewPendingAudits(): void { + window.location.href = '/audits'; + } + + public disableReviewLink(): boolean { + return this.pendingAudits == 0; + } + public signin(): void { this.auth.signin(); } diff --git a/rubberduckvba.client/src/app/model/feature.model.ts b/rubberduckvba.client/src/app/model/feature.model.ts index fad751f..6272025 100644 --- a/rubberduckvba.client/src/app/model/feature.model.ts +++ b/rubberduckvba.client/src/app/model/feature.model.ts @@ -14,6 +14,48 @@ 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, +} + +export interface FeatureEditViewModel extends AuditRecordViewModel { + featureId: string, + featureName: string, + fieldName: string, + valueBefore: string | null, + valueAfter: string, +} + +export enum FeatureOperation { + Create = 'Create', + Delete = 'Delete', +} + +export interface FeatureOperationViewModel extends AuditRecordViewModel { + featureName: string; + featureAction: FeatureOperation; + parentId: number | 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; diff --git a/rubberduckvba.client/src/app/services/api-client.service.ts b/rubberduckvba.client/src/app/services/api-client.service.ts index 431441d..4823dfa 100644 --- a/rubberduckvba.client/src/app/services/api-client.service.ts +++ b/rubberduckvba.client/src/app/services/api-client.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@angular/core"; import { LatestTags, Tag } from "../model/tags.model"; -import { AnnotationViewModel, AnnotationViewModelClass, AnnotationsFeatureViewModel, AnnotationsFeatureViewModelClass, FeatureViewModel, FeatureViewModelClass, InspectionViewModel, InspectionViewModelClass, InspectionsFeatureViewModel, InspectionsFeatureViewModelClass, MarkdownContent, 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, UserViewModel, XmlDocOrFeatureViewModel } from "../model/feature.model"; import { DownloadInfo } from "../model/downloads.model"; import { DataService } from "./data.service"; import { environment } from "../../environments/environment.prod"; @@ -98,6 +98,11 @@ export class ApiClientService { return this.data.postAsync(url, model).pipe(map(() => model)); } + public getPendingAudits(): Observable { + const url = `${environment.apiBaseUrl}admin/audits`; + return this.data.getAsync(url); + } + public formatMarkdown(raw: string): Observable { const url = `${environment.apiBaseUrl}markdown/format`; const content: MarkdownContent = { From f2a30b0b23f006d90764be267f701dd1079b52c1 Mon Sep 17 00:00:00 2001 From: Mathieu Guindon Date: Sun, 15 Jun 2025 01:56:41 -0400 Subject: [PATCH 06/10] add audits admin routing --- .../Pipeline/Sections/Context/SyncContext.cs | 2 +- rubberduckvba.client/src/app/app.module.ts | 3 +++ .../auth-menu/auth-menu.component.ts | 2 +- .../edit-feature/edit-feature.component.html | 2 +- .../app/routes/audits/audits.component.html | 9 ++++++++ .../src/app/routes/audits/audits.component.ts | 21 +++++++++++++++++++ 6 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 rubberduckvba.client/src/app/routes/audits/audits.component.html create mode 100644 rubberduckvba.client/src/app/routes/audits/audits.component.ts 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.client/src/app/app.module.ts b/rubberduckvba.client/src/app/app.module.ts index 6cf1f25..2780fc5 100644 --- a/rubberduckvba.client/src/app/app.module.ts +++ b/rubberduckvba.client/src/app/app.module.ts @@ -40,6 +40,7 @@ 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'; /** * https://stackoverflow.com/a/39560520 @@ -61,6 +62,7 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { HomeComponent, AuthComponent, AuthMenuComponent, + AuditsAdminComponent, IndenterComponent, FeaturesComponent, FeatureComponent, @@ -93,6 +95,7 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { { path: 'inspections/details/:name', redirectTo: 'inspections/:name' }, // actual routes: { path: 'auth/github', component: AuthComponent }, + { path: 'audits', component: AuditsAdminComponent }, { path: 'features', component: FeaturesComponent }, { path: 'features/:name', component: FeatureComponent }, { path: 'inspections/:name', component: InspectionComponent }, 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 c6d22c8..906c006 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 @@ -70,7 +70,7 @@ export class AuthMenuComponent implements OnInit { public auditsCountIconClass(): string { if (this.pendingAudits == 0) { - return 'text-success disabled'; + return 'text-success'; } else { return 'text-danger'; 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 index f1299fa..de2e5ba 100644 --- a/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html +++ b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html @@ -41,7 +41,7 @@
Edit {{action == 'edit' ? 'description' : 'summary'}}
{{feature.description.length}} {{feature.shortDescription.length}} -
x +

- - -
-
-
+
- +
@@ -33,13 +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 19de4d5..ddc8de8 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,10 +1,8 @@ -import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; -import { AnnotationViewModel, AnnotationsFeatureViewModel, BlogLink, FeatureViewModel, InspectionViewModel, InspectionsFeatureViewModel, QuickFixViewModel, QuickFixesFeatureViewModel, SubFeatureViewModel, UserViewModel, 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 { ApiClientService } from '../../services/api-client.service'; -import { AuthService } from '../../services/auth.service'; import { AdminAction } from '../edit-feature/edit-feature.component'; @Component({ @@ -15,6 +13,7 @@ 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; @@ -43,16 +42,64 @@ export class FeatureInfoComponent implements OnInit { this.filterByNameOrDescription(this.filterState.filterText) } } + public get feature(): XmlDocOrFeatureViewModel | undefined { + return this._info.value; + } + + @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) ?? []; } @@ -64,7 +111,6 @@ export class FeatureInfoComponent implements OnInit { 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 ?? []; @@ -75,25 +121,11 @@ export class FeatureInfoComponent implements OnInit { return feature?.links ?? []; } - public get user(): UserViewModel { - return this._user.getValue(); - } - - constructor(private auth: AuthService, private api: ApiClientService, private fa: FaIconLibrary) { + constructor(private fa: FaIconLibrary) { fa.addIconPacks(fas); } ngOnInit(): void { - this.auth.getUser().subscribe(result => { - if (result) { - this._user.next(result as UserViewModel); - } - }); - this.api.getFeature('quickfixes').subscribe(result => { - if (result) { - this._quickfixes.next((result as QuickFixesFeatureViewModel).quickFixes.slice()); - } - }); } public onFilter(): void { diff --git a/rubberduckvba.client/src/app/model/feature.model.ts b/rubberduckvba.client/src/app/model/feature.model.ts index 6272025..91ef153 100644 --- a/rubberduckvba.client/src/app/model/feature.model.ts +++ b/rubberduckvba.client/src/app/model/feature.model.ts @@ -34,14 +34,15 @@ export interface FeatureEditViewModel extends AuditRecordViewModel { } export enum FeatureOperation { - Create = 'Create', - Delete = 'Delete', + Create = 1, + Delete = 2, } export interface FeatureOperationViewModel extends AuditRecordViewModel { featureName: string; featureAction: FeatureOperation; - parentId: number | null, + parentId: number | null; + name: string | null; title: string | null; shortDescription: string | null; description: string | null; @@ -80,6 +81,8 @@ export interface FeatureViewModel extends SubFeatureViewModel { features: FeatureViewModel[]; links: BlogLink[]; + + isCreatePending: boolean; } export interface BlogLink { @@ -289,6 +292,8 @@ export class FeatureViewModelClass extends ViewModelBase { features: FeatureViewModel[]; links: BlogLink[]; + isCreatePending: boolean; + constructor(model: FeatureViewModel) { super(model); this.title = model.title; @@ -299,6 +304,7 @@ export class FeatureViewModelClass extends ViewModelBase { this.links = model.links?.map(e => new BlogLinkViewModelClass(e)) ?? []; this.isCollapsed = !model.hasImage; + this.isCreatePending = model.isCreatePending; } } diff --git a/rubberduckvba.client/src/app/routes/audits/audits.component.ts b/rubberduckvba.client/src/app/routes/audits/audits.component.ts index ec125f2..7d68edf 100644 --- a/rubberduckvba.client/src/app/routes/audits/audits.component.ts +++ b/rubberduckvba.client/src/app/routes/audits/audits.component.ts @@ -15,7 +15,7 @@ export class AuditsAdminComponent implements OnInit { public pendingAudits: PendingAuditsViewModel = { edits: [], other: [] }; ngOnInit(): void { - this.api.getPendingAudits().subscribe(e => this.pendingAudits = e); + this.api.getAllPendingAudits().subscribe(e => this.pendingAudits = e); } } 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 f55801a..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 5e1acd7..c9eddd7 100644 --- a/rubberduckvba.client/src/app/routes/features/features.component.ts +++ b/rubberduckvba.client/src/app/routes/features/features.component.ts @@ -3,7 +3,8 @@ 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', @@ -11,7 +12,11 @@ import { FeatureViewModel, QuickFixViewModel } from '../../model/feature.model'; }) 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,7 +24,15 @@ export class FeaturesComponent implements OnInit { return this._features.getValue(); } - constructor(private api: ApiClientService, private fa: FaIconLibrary) { + public get user() { + return this._user.getValue(); + } + + public get audits() { + return this._audits.getValue(); + } + + constructor(private api: ApiClientService, private auth: AuthService, private fa: FaIconLibrary) { fa.addIconPacks(fas); } @@ -29,5 +42,17 @@ export class FeaturesComponent implements OnInit { 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/services/api-client.service.ts b/rubberduckvba.client/src/app/services/api-client.service.ts index 4823dfa..7328bb3 100644 --- a/rubberduckvba.client/src/app/services/api-client.service.ts +++ b/rubberduckvba.client/src/app/services/api-client.service.ts @@ -98,10 +98,23 @@ export class ApiClientService { return this.data.postAsync(url, model).pipe(map(() => model)); } - public getPendingAudits(): Observable { - const url = `${environment.apiBaseUrl}admin/audits`; + public getPendingAudits(featureId: number): Observable { + const url = `${environment.apiBaseUrl}admin/audits/${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`; From 76a48d3ca610d77c850941d83c23afa8221c3a47 Mon Sep 17 00:00:00 2001 From: Mathieu Guindon Date: Sun, 22 Jun 2025 02:15:43 -0400 Subject: [PATCH 08/10] added review diffs --- .../Api/Auth/AuthController.cs | 12 ++- .../GitHubAuthenticationHandler.cs | 90 ++++++++++++++----- .../Services/RubberduckDbService.cs | 8 +- rubberduckvba.client/package-lock.json | 17 +++- rubberduckvba.client/package.json | 2 + rubberduckvba.client/src/app/app.module.ts | 4 +- .../app/routes/audits/audits.component.css | 0 .../app/routes/audits/audits.component.html | 65 +++++++++++++- .../src/app/routes/audits/audits.component.ts | 55 +++++++++++- .../src/app/services/auth.service.ts | 3 +- .../src/app/services/data.service.ts | 8 +- rubberduckvba.client/src/styles.css | 10 +++ 12 files changed, 232 insertions(+), 42 deletions(-) create mode 100644 rubberduckvba.client/src/app/routes/audits/audits.component.css diff --git a/rubberduckvba.Server/Api/Auth/AuthController.cs b/rubberduckvba.Server/Api/Auth/AuthController.cs index 31f50b6..a266975 100644 --- a/rubberduckvba.Server/Api/Auth/AuthController.cs +++ b/rubberduckvba.Server/Api/Auth/AuthController.cs @@ -115,15 +115,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 +172,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/GitHubAuthenticationHandler.cs b/rubberduckvba.Server/GitHubAuthenticationHandler.cs index 18b635f..e532e90 100644 --- a/rubberduckvba.Server/GitHubAuthenticationHandler.cs +++ b/rubberduckvba.Server/GitHubAuthenticationHandler.cs @@ -1,5 +1,4 @@ - -using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using rubberduckvba.Server.Services; @@ -10,6 +9,10 @@ namespace rubberduckvba.Server; public class GitHubAuthenticationHandler : AuthenticationHandler { + public static readonly string AuthCookie = "x-access-token"; + + private static readonly object _lock = new object(); + private readonly IGitHubClientService _github; private readonly IMemoryCache _cache; @@ -26,45 +29,86 @@ public GitHubAuthenticationHandler(IGitHubClientService github, IMemoryCache cac _cache = cache; } - protected async override Task HandleAuthenticateAsync() + 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 (_cache.TryGetValue(token, out var cached) && cached is AuthenticationTicket cachedTicket) + if (TryAuthenticateFromCache(token, out var cachedResult)) { - 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."); - return AuthenticateResult.Success(cachedTicket); + return Task.FromResult(cachedResult)!; } - var principal = await _github.ValidateTokenAsync(token); - if (principal is ClaimsPrincipal) + lock (_lock) { - Context.User = principal; - Thread.CurrentPrincipal = principal; - - var ticket = new AuthenticationTicket(principal, "github"); - _cache.Set(token, ticket, _options); + if (TryAuthenticateGitHubToken(token, out var result) + && result is AuthenticateResult + && result.Ticket is AuthenticationTicket ticket) + { + CacheAuthenticatedTicket(token, ticket); + return Task.FromResult(result!); + } - return AuthenticateResult.Success(ticket); + if (TryAuthenticateFromCache(token, out cachedResult)) + { + 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; + var principal = _github.ValidateTokenAsync(token).GetAwaiter().GetResult(); + if (principal is ClaimsPrincipal) + { + Context.User = principal; + Thread.CurrentPrincipal = principal; + + var ticket = new AuthenticationTicket(principal, "github"); + result = AuthenticateResult.Success(ticket); + return true; } + return false; } } diff --git a/rubberduckvba.Server/Services/RubberduckDbService.cs b/rubberduckvba.Server/Services/RubberduckDbService.cs index 89292ee..41e50e4 100644 --- a/rubberduckvba.Server/Services/RubberduckDbService.cs +++ b/rubberduckvba.Server/Services/RubberduckDbService.cs @@ -177,8 +177,12 @@ private async Task SubmitFeatureEdit(Feature feature, IIdentity identity) foreach (var name in editableFields) { - valueBefore = current?.GetType().GetProperty(name)?.GetValue(current)?.ToString(); - valueAfter = feature.GetType().GetProperty(name)?.GetValue(feature)?.ToString() ?? string.Empty; + 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) { 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.module.ts b/rubberduckvba.client/src/app/app.module.ts index 2780fc5..0a761d5 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'; @@ -88,6 +88,7 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { bootstrap: [AppComponent], imports: [ CommonModule, + NgbModule, BrowserModule, FormsModule, RouterModule.forRoot([ @@ -111,6 +112,7 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { providers: [ DataService, ApiClientService, + NgbAccordionDirective, provideHttpClient(withInterceptorsFromDi()), { provide: UrlSerializer, 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 index fe8c123..bb50cf2 100644 --- a/rubberduckvba.client/src/app/routes/audits/audits.component.html +++ b/rubberduckvba.client/src/app/routes/audits/audits.component.html @@ -5,5 +5,68 @@

Audits

-

TODO

+

Edits

+
+

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

+
+

There are no pending edits.

+
+
+
+
+

{{edit.featureName}}

+

{{edit.author}} wants to edit the '{{edit.fieldName.toLowerCase()}}' field.

+

Submitted {{edit.dateInserted}} , last modified {{edit.dateModified}}

+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+

Operations

+
+

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

+
+

There are no pending operations.

+
+
+
+
+

{{op.featureName}}

+
+

{{op.author}} wants to DELETE the '{{op.featureName}}' feature.

+

Submitted {{op.dateInserted}} , last modified {{op.dateModified}}

+
+
+

{{op.author}} wants to add a new '{{op.featureName}}' feature.

+

Submitted {{op.dateInserted}} , last modified {{op.dateModified}}

+
+
+
+
+

{{op.title}}

+

+

+ + +
+
+
+
+
diff --git a/rubberduckvba.client/src/app/routes/audits/audits.component.ts b/rubberduckvba.client/src/app/routes/audits/audits.component.ts index 7d68edf..3bd99da 100644 --- a/rubberduckvba.client/src/app/routes/audits/audits.component.ts +++ b/rubberduckvba.client/src/app/routes/audits/audits.component.ts @@ -1,10 +1,13 @@ import { Component, OnInit } from "@angular/core"; import { ApiClientService } from "../../services/api-client.service"; -import { PendingAuditsViewModel } from "../../model/feature.model"; +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' + templateUrl: './audits.component.html', + styleUrls: ['./audits.component.css'] }) export class AuditsAdminComponent implements OnInit { @@ -17,5 +20,51 @@ export class AuditsAdminComponent implements OnInit { ngOnInit(): void { this.api.getAllPendingAudits().subscribe(e => this.pendingAudits = e); } - + + public get deleteOp() { return FeatureOperation.Delete; } + public get createOp() { return FeatureOperation.Create; } + + public getDiffHtml(before: string, after: string): string { + const diff = diffWords(before, after, { ignoreCase: false }); + return diff.map((part: Change) => { + const htmlEncodedValue = encode(part.value); + if (part.added) { + console.log(`added: ${part.value}`); + return `${htmlEncodedValue}`; + } else if (part.removed) { + console.log(`removed: ${part.value}`); + return `${htmlEncodedValue}`; + } else { + return part.value; + } + }).join(''); + } + + public onApproveEdit(edit:FeatureEditViewModel): void { + this.api.approvePendingAudit(edit.id).subscribe(() => { + console.log(`Feature edit audit record id ${edit.id} was approved.`); + window.location.reload(); + }); + } + + public onRejectEdit(edit:FeatureEditViewModel): void { + this.api.rejectPendingAudit(edit.id).subscribe(() => { + console.log(`Feature edit audit record id ${edit.id} was rejected.`); + window.location.reload(); + }); + } + + public onApproveOperation(op: FeatureOperationViewModel): void { + this.api.approvePendingAudit(op.id).subscribe(() => { + console.log(`Feature operation audit record id ${op.id} was approved.`); + window.location.reload(); + }); + } + + public onRejectOperation(op: FeatureOperationViewModel): void { + this.api.rejectPendingAudit(op.id).subscribe(() => { + console.log(`Feature operation audit record id ${op.id} was rejected.`); + window.location.reload(); + }); + } } diff --git a/rubberduckvba.client/src/app/services/auth.service.ts b/rubberduckvba.client/src/app/services/auth.service.ts index fc0c3da..f51e8d9 100644 --- a/rubberduckvba.client/src/app/services/auth.service.ts +++ b/rubberduckvba.client/src/app/services/auth.service.ts @@ -37,7 +37,7 @@ 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 { @@ -66,7 +66,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 748b7ea..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"; @@ -15,7 +15,7 @@ export class DataService { .append('accept', 'application/json'); const token = sessionStorage.getItem('github:access_token'); if (token) { - headers = headers.append('X-ACCESS-TOKEN', token); + headers = headers.append('x-access-token', token); } return this.http.get(url, { headers }) @@ -37,11 +37,11 @@ export class DataService { const token = sessionStorage.getItem('github:access_token'); if (token) { - headers = headers.append('X-ACCESS-TOKEN', token); + headers = headers.append('x-access-token', token); } return (content - ? this.http.post(url, content, { headers } ) + ? this.http.post(url, content, { headers }) : this.http.post(url, null, { headers })) .pipe( map(result => result), 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; +} From 4e4f45604f8eb4c5f8fadd3e1a6d94095e026d9f Mon Sep 17 00:00:00 2001 From: Mathieu Guindon Date: Mon, 23 Jun 2025 01:04:11 -0400 Subject: [PATCH 09/10] added auditbox components, tweak auth, add roles --- .../Api/Admin/AdminController.cs | 14 +-- .../Api/Auth/AuthController.cs | 8 +- .../Api/Features/FeaturesController.cs | 8 +- rubberduckvba.Server/GitHubSettings.cs | 12 ++ .../Services/GitHubClientService.cs | 47 ++++++-- .../rubberduckvba.client.esproj | 2 + rubberduckvba.client/src/app/app.component.ts | 2 +- rubberduckvba.client/src/app/app.module.ts | 6 + .../audits/audit-box/audit-box.component.html | 67 +++++++++++ .../audits/audit-box/audit-box.component.ts | 104 ++++++++++++++++++ .../feature-add.review.component.html | 68 ++++++++++++ .../feature-add.review.component.ts | 48 ++++++++ ...eature-edit-markdown.review.component.html | 32 ++++++ .../feature-edit-markdown.review.component.ts | 78 +++++++++++++ .../auth-menu/auth-menu.component.html | 2 +- .../auth-menu/auth-menu.component.ts | 4 +- .../src/app/model/feature.model.ts | 2 + .../app/routes/audits/audits.component.html | 46 +------- .../src/app/routes/audits/audits.component.ts | 44 -------- .../src/app/services/auth.service.ts | 24 +++- 20 files changed, 501 insertions(+), 117 deletions(-) create mode 100644 rubberduckvba.client/src/app/components/audits/audit-box/audit-box.component.html create mode 100644 rubberduckvba.client/src/app/components/audits/audit-box/audit-box.component.ts create mode 100644 rubberduckvba.client/src/app/components/audits/feature-add.review/feature-add.review.component.html create mode 100644 rubberduckvba.client/src/app/components/audits/feature-add.review/feature-add.review.component.ts create mode 100644 rubberduckvba.client/src/app/components/audits/feature-markdown.review/feature-edit-markdown.review.component.html create mode 100644 rubberduckvba.client/src/app/components/audits/feature-markdown.review/feature-edit-markdown.review.component.ts diff --git a/rubberduckvba.Server/Api/Admin/AdminController.cs b/rubberduckvba.Server/Api/Admin/AdminController.cs index 603611e..8e50989 100644 --- a/rubberduckvba.Server/Api/Admin/AdminController.cs +++ b/rubberduckvba.Server/Api/Admin/AdminController.cs @@ -16,7 +16,7 @@ public class AdminController(ConfigurationOptions options, HangfireLauncherServi /// Enqueues a job that updates xmldoc content from the latest release/pre-release tags. /// /// The unique identifier of the enqueued job. - [Authorize("github")] + [Authorize("github", Roles = RDConstants.AdminRole)] [HttpPost("admin/update/xmldoc")] public IActionResult UpdateXmldocContent() { @@ -28,7 +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")] + [Authorize("github", Roles = RDConstants.AdminRole)] [HttpPost("admin/update/tags")] public IActionResult UpdateTagMetadata() { @@ -36,7 +36,7 @@ public IActionResult UpdateTagMetadata() return Ok(jobId); } - [Authorize("github")] + [Authorize("github", Roles = RDConstants.AdminRole)] [HttpPost("admin/cache/clear")] public IActionResult ClearCache() { @@ -44,7 +44,7 @@ public IActionResult ClearCache() return Ok(); } - [Authorize("github")] + [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole}")] [HttpGet("admin/audits/pending")] public async Task GetPendingAudits() { @@ -54,7 +54,7 @@ public async Task GetPendingAudits() return Ok(new { edits = edits.ToArray(), other = ops.ToArray() }); } - [Authorize("github")] + [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole}")] [HttpGet("admin/audits/{featureId}")] public async Task GetPendingAudits([FromRoute] int featureId) { @@ -64,7 +64,7 @@ public async Task GetPendingAudits([FromRoute] int featureId) return Ok(new { edits = edits.ToArray(), other = ops.ToArray() }); } - [Authorize("github")] + [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole}")] [HttpPost("admin/audits/approve/{id}")] public async Task ApprovePendingAudit([FromRoute] int id) { @@ -100,7 +100,7 @@ public async Task ApprovePendingAudit([FromRoute] int id) return Ok("Operation was approved successfully."); } - [Authorize("github")] + [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole}")] [HttpPost("admin/audits/reject/{id}")] public async Task RejectPendingAudit([FromRoute] int id) { diff --git a/rubberduckvba.Server/Api/Auth/AuthController.cs b/rubberduckvba.Server/Api/Auth/AuthController.cs index a266975..45ff368 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.AdminRole, + IsReviewer = role == RDConstants.AdminRole || role == RDConstants.ReviewerRole, + IsWriter = role == RDConstants.WriterRole || role == RDConstants.AdminRole || role == RDConstants.ReviewerRole, }; return Ok(model); diff --git a/rubberduckvba.Server/Api/Features/FeaturesController.cs b/rubberduckvba.Server/Api/Features/FeaturesController.cs index 9d4d887..1a01652 100644 --- a/rubberduckvba.Server/Api/Features/FeaturesController.cs +++ b/rubberduckvba.Server/Api/Features/FeaturesController.cs @@ -152,7 +152,7 @@ public IActionResult QuickFix([FromRoute] string name) } [HttpGet("features/create")] - [Authorize("github")] + [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole},{RDConstants.WriterRole}")] public async Task> Create([FromQuery] RepositoryId repository = RepositoryId.Rubberduck, [FromQuery] int? parentId = default) { var features = await GetFeatureOptions(repository); @@ -163,7 +163,7 @@ public async Task> Create([FromQuery] Reposit } [HttpPost("features/create")] - [Authorize("github")] + [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole},{RDConstants.WriterRole}")] public async Task> Create([FromBody] FeatureEditViewModel model) { if (model.Id.HasValue || string.IsNullOrWhiteSpace(model.Name) || model.Name.Trim().Length < 3) @@ -190,7 +190,7 @@ public async Task> Create([FromBody] FeatureE } [HttpPost("features/update")] - [Authorize("github")] + [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole},{RDConstants.WriterRole}")] public async Task> Update([FromBody] FeatureEditViewModel model) { if (model.Id.GetValueOrDefault() == default) @@ -217,7 +217,7 @@ public async Task> Update([FromBody] FeatureE } [HttpPost("features/delete")] - [Authorize("github")] + [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole},{RDConstants.WriterRole}")] public async Task Delete([FromBody] Feature model) { if (model.Id == default) diff --git a/rubberduckvba.Server/GitHubSettings.cs b/rubberduckvba.Server/GitHubSettings.cs index 4ca4143..4a8ba05 100644 --- a/rubberduckvba.Server/GitHubSettings.cs +++ b/rubberduckvba.Server/GitHubSettings.cs @@ -13,6 +13,18 @@ public record class ConnectionSettings public string HangfireDb { get; set; } = default!; } +public static class RDConstants +{ + public const int OrganisationId = 12832254; + public const string WebAdminTeam = "WebAdmin"; + public const string ContributorsTeam = "Contributors"; + + public const string ReaderRole = "rd-reader"; + public const string WriterRole = "rd-writer"; + public const string ReviewerRole = "rd-reviewer"; + public const string AdminRole = "rd-admin"; +} + public record class GitHubSettings { public string ClientId { get; set; } = default!; diff --git a/rubberduckvba.Server/Services/GitHubClientService.cs b/rubberduckvba.Server/Services/GitHubClientService.cs index 76a1b7d..f894721 100644 --- a/rubberduckvba.Server/Services/GitHubClientService.cs +++ b/rubberduckvba.Server/Services/GitHubClientService.cs @@ -42,21 +42,46 @@ 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.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.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.AdminRole)); + } + else + { + var contributorsTeam = teams.SingleOrDefault(e => e.Name == RDConstants.ContributorsTeam); + if (contributorsTeam is not null) + { + // members of the contributors team can review/approve/reject suggested changes + claims.Add(new Claim(ClaimTypes.Role, RDConstants.ReviewerRole)); + } + else + { + // authenticated members of the org can submit edits + claims.Add(new Claim(ClaimTypes.Role, RDConstants.WriterRole)); + } + } + } + + var identity = new ClaimsIdentity(claims, "github"); return new ClaimsPrincipal(identity); } diff --git a/rubberduckvba.client/rubberduckvba.client.esproj b/rubberduckvba.client/rubberduckvba.client.esproj index 0bf23c4..9a08b85 100644 --- a/rubberduckvba.client/rubberduckvba.client.esproj +++ b/rubberduckvba.client/rubberduckvba.client.esproj @@ -8,6 +8,8 @@ $(MSBuildProjectDirectory)\dist\rubberduckvba.client\ + + \ No newline at end of file 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 0a761d5..53afd96 100644 --- a/rubberduckvba.client/src/app/app.module.ts +++ b/rubberduckvba.client/src/app/app.module.ts @@ -41,6 +41,9 @@ 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'; /** * https://stackoverflow.com/a/39560520 @@ -63,6 +66,9 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { AuthComponent, AuthMenuComponent, AuditsAdminComponent, + AuditBoxComponent, + AuditFeatureAdditionComponent, + AuditFeatureEditMarkdownComponent, IndenterComponent, FeaturesComponent, FeatureComponent, 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..3e01da9 --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/audit-box/audit-box.component.html @@ -0,0 +1,67 @@ +
+
+

{{auditEdit!.fieldName}}

+

Create

{{auditOp!.name}}
+

Delete

{{auditOp!.name}}
+
Submitted {{dateSubmitted}} by {{author}}
+
, last modified {{dateModified}}
+
+
+
+ +
+
+

(todo: delete op)

+ +
+
+ +
+
+ +
+ + + + 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..f07281c --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/audit-box/audit-box.component.ts @@ -0,0 +1,104 @@ +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 } from "../../../model/feature.model"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { ApiClientService } from "../../../services/api-client.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; + + constructor(private fa: FaIconLibrary, private api: ApiClientService) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + } + + @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 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 onConfirmApprove(): void { + + this.api.approvePendingAudit(this.audit.id).subscribe(() => { + window.location.reload(); + }); + } + + public onConfirmReject(): void { + 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..38cf95f --- /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-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..a26ac07 --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/feature-markdown.review/feature-edit-markdown.review.component.html @@ -0,0 +1,32 @@ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+

+
+
+
+
+
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..784f6bb --- /dev/null +++ b/rubberduckvba.client/src/app/components/audits/feature-markdown.review/feature-edit-markdown.review.component.ts @@ -0,0 +1,78 @@ +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 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/auth-menu/auth-menu.component.html b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html index 2299b07..14be9d8 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 @@ -33,7 +33,7 @@
-
+
- -
+
+
@@ -44,29 +27,8 @@

Operations

There are no pending operations.

-
-
-
-

{{op.featureName}}

-
-

{{op.author}} wants to DELETE the '{{op.featureName}}' feature.

-

Submitted {{op.dateInserted}} , last modified {{op.dateModified}}

-
-
-

{{op.author}} wants to add a new '{{op.featureName}}' feature.

-

Submitted {{op.dateInserted}} , last modified {{op.dateModified}}

-
-
-
-
-

{{op.title}}

-

-

- - -
-
-
+
+
diff --git a/rubberduckvba.client/src/app/routes/audits/audits.component.ts b/rubberduckvba.client/src/app/routes/audits/audits.component.ts index 3bd99da..9abc4ae 100644 --- a/rubberduckvba.client/src/app/routes/audits/audits.component.ts +++ b/rubberduckvba.client/src/app/routes/audits/audits.component.ts @@ -23,48 +23,4 @@ export class AuditsAdminComponent implements OnInit { public get deleteOp() { return FeatureOperation.Delete; } public get createOp() { return FeatureOperation.Create; } - - public getDiffHtml(before: string, after: string): string { - const diff = diffWords(before, after, { ignoreCase: false }); - return diff.map((part: Change) => { - const htmlEncodedValue = encode(part.value); - if (part.added) { - console.log(`added: ${part.value}`); - return `${htmlEncodedValue}`; - } else if (part.removed) { - console.log(`removed: ${part.value}`); - return `${htmlEncodedValue}`; - } else { - return part.value; - } - }).join(''); - } - - public onApproveEdit(edit:FeatureEditViewModel): void { - this.api.approvePendingAudit(edit.id).subscribe(() => { - console.log(`Feature edit audit record id ${edit.id} was approved.`); - window.location.reload(); - }); - } - - public onRejectEdit(edit:FeatureEditViewModel): void { - this.api.rejectPendingAudit(edit.id).subscribe(() => { - console.log(`Feature edit audit record id ${edit.id} was rejected.`); - window.location.reload(); - }); - } - - public onApproveOperation(op: FeatureOperationViewModel): void { - this.api.approvePendingAudit(op.id).subscribe(() => { - console.log(`Feature operation audit record id ${op.id} was approved.`); - window.location.reload(); - }); - } - - public onRejectOperation(op: FeatureOperationViewModel): void { - this.api.rejectPendingAudit(op.id).subscribe(() => { - console.log(`Feature operation audit record id ${op.id} was rejected.`); - window.location.reload(); - }); - } } diff --git a/rubberduckvba.client/src/app/services/auth.service.ts b/rubberduckvba.client/src/app/services/auth.service.ts index f51e8d9..9cd3f78 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 } 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); + request.subscribe(user => { this._user.next(user); }); + return request; } public signin(): void { @@ -41,6 +56,7 @@ export class AuthService { } public signout(): void { + this._user.next(this._anonymousUser); sessionStorage.clear(); } From 0185557c155603296f8db45e2b6800db1efc7f1e Mon Sep 17 00:00:00 2001 From: Mathieu Guindon Date: Sun, 29 Jun 2025 22:13:23 -0400 Subject: [PATCH 10/10] way too much stuff for a single commit --- .../Api/Admin/AdminController.cs | 85 ++++++-- .../Api/Auth/AuthController.cs | 6 +- .../Api/Auth/ClaimsPrincipalExtensions.cs | 51 +++++ .../Api/Features/FeatureViewModel.cs | 1 - .../Api/Features/FeaturesController.cs | 8 +- .../GitHubAuthenticationHandler.cs | 50 +++-- rubberduckvba.Server/GitHubSettings.cs | 34 ++- .../Model/Entity/FeatureEntity.cs | 43 +++- .../Services/GitHubClientService.cs | 16 +- .../Services/RubberduckDbService.cs | 202 ++++++++++++++++-- .../Services/rubberduckdb/FeatureServices.cs | 10 +- .../rubberduckvba.client.esproj | 2 - rubberduckvba.client/src/app/app.module.ts | 11 + .../audits/audit-box/audit-box.component.html | 115 +++++----- .../audits/audit-box/audit-box.component.ts | 48 ++++- .../feature-add.review.component.html | 2 +- .../feature-delete.review.component.html | 10 + .../feature-delete.review.component.ts | 28 +++ ...eature-edit-markdown.review.component.html | 41 ++-- .../feature-edit-markdown.review.component.ts | 9 + .../feature-state.review.component.html | 11 + .../feature-state.review.component.ts | 1 + .../auth-menu/auth-menu.component.html | 33 +-- .../auth-menu/auth-menu.component.ts | 4 - .../feature-box/feature-box.component.html | 11 +- .../feature-box/feature-box.component.ts | 37 +++- .../feature-info/feature-info.component.html | 2 +- .../feature-info/feature-info.component.ts | 20 +- .../src/app/model/feature.model.ts | 61 ++++++ .../audit-item/audit-item.component.html | 13 ++ .../audits/audit-item/audit-item.component.ts | 69 ++++++ .../app/routes/audits/audits.component.html | 1 + .../app/routes/features/features.component.ts | 5 +- .../profile/user-profile.component.html | 108 ++++++++++ .../routes/profile/user-profile.component.ts | 132 ++++++++++++ .../src/app/services/api-client.service.ts | 15 +- .../src/app/services/auth.service.ts | 4 +- 37 files changed, 1109 insertions(+), 190 deletions(-) create mode 100644 rubberduckvba.client/src/app/components/audits/feature-delete.review/feature-delete.review.component.html create mode 100644 rubberduckvba.client/src/app/components/audits/feature-delete.review/feature-delete.review.component.ts create mode 100644 rubberduckvba.client/src/app/components/audits/feature-state.review/feature-state.review.component.html create mode 100644 rubberduckvba.client/src/app/components/audits/feature-state.review/feature-state.review.component.ts create mode 100644 rubberduckvba.client/src/app/routes/audits/audit-item/audit-item.component.html create mode 100644 rubberduckvba.client/src/app/routes/audits/audit-item/audit-item.component.ts create mode 100644 rubberduckvba.client/src/app/routes/profile/user-profile.component.html create mode 100644 rubberduckvba.client/src/app/routes/profile/user-profile.component.ts diff --git a/rubberduckvba.Server/Api/Admin/AdminController.cs b/rubberduckvba.Server/Api/Admin/AdminController.cs index 8e50989..0fbd7af 100644 --- a/rubberduckvba.Server/Api/Admin/AdminController.cs +++ b/rubberduckvba.Server/Api/Admin/AdminController.cs @@ -16,7 +16,7 @@ public class AdminController(ConfigurationOptions options, HangfireLauncherServi /// Enqueues a job that updates xmldoc content from the latest release/pre-release tags. /// /// The unique identifier of the enqueued job. - [Authorize("github", Roles = RDConstants.AdminRole)] + [Authorize("github", Roles = RDConstants.Roles.AdminRole)] [HttpPost("admin/update/xmldoc")] public IActionResult UpdateXmldocContent() { @@ -28,7 +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", Roles = RDConstants.AdminRole)] + [Authorize("github", Roles = RDConstants.Roles.AdminRole)] [HttpPost("admin/update/tags")] public IActionResult UpdateTagMetadata() { @@ -36,7 +36,7 @@ public IActionResult UpdateTagMetadata() return Ok(jobId); } - [Authorize("github", Roles = RDConstants.AdminRole)] + [Authorize("github", Roles = RDConstants.Roles.AdminRole)] [HttpPost("admin/cache/clear")] public IActionResult ClearCache() { @@ -44,27 +44,80 @@ public IActionResult ClearCache() return Ok(); } - [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole}")] + [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(); - var ops = await audits.GetPendingItems(); + 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.AdminRole},{RDConstants.ReviewerRole}")] - [HttpGet("admin/audits/{featureId}")] + [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(featureId); - var ops = await audits.GetPendingItems(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.AdminRole},{RDConstants.ReviewerRole}")] + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] [HttpPost("admin/audits/approve/{id}")] public async Task ApprovePendingAudit([FromRoute] int id) { @@ -74,13 +127,13 @@ public async Task ApprovePendingAudit([FromRoute] int id) return Unauthorized("User identity is not available."); } - var edits = await audits.GetPendingItems(); + 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(); + var ops = await audits.GetPendingItems(User.Identity); audit = ops.SingleOrDefault(e => e.Id == id); } @@ -100,7 +153,7 @@ public async Task ApprovePendingAudit([FromRoute] int id) return Ok("Operation was approved successfully."); } - [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole}")] + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] [HttpPost("admin/audits/reject/{id}")] public async Task RejectPendingAudit([FromRoute] int id) { @@ -110,13 +163,13 @@ public async Task RejectPendingAudit([FromRoute] int id) return Unauthorized("User identity is not available."); } - var edits = await audits.GetPendingItems(); + 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(); + var ops = await audits.GetPendingItems(User.Identity); audit = ops.SingleOrDefault(e => e.Id == id); } diff --git a/rubberduckvba.Server/Api/Auth/AuthController.cs b/rubberduckvba.Server/Api/Auth/AuthController.cs index 45ff368..7c72f03 100644 --- a/rubberduckvba.Server/Api/Auth/AuthController.cs +++ b/rubberduckvba.Server/Api/Auth/AuthController.cs @@ -61,9 +61,9 @@ public IActionResult Index() { Name = name, IsAuthenticated = isAuthenticated, - IsAdmin = role == RDConstants.AdminRole, - IsReviewer = role == RDConstants.AdminRole || role == RDConstants.ReviewerRole, - IsWriter = role == RDConstants.WriterRole || role == RDConstants.AdminRole || role == RDConstants.ReviewerRole, + 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); 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/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 1a01652..42352a0 100644 --- a/rubberduckvba.Server/Api/Features/FeaturesController.cs +++ b/rubberduckvba.Server/Api/Features/FeaturesController.cs @@ -152,7 +152,7 @@ public IActionResult QuickFix([FromRoute] string name) } [HttpGet("features/create")] - [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole},{RDConstants.WriterRole}")] + [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); @@ -163,7 +163,7 @@ public async Task> Create([FromQuery] Reposit } [HttpPost("features/create")] - [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole},{RDConstants.WriterRole}")] + [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) @@ -190,7 +190,7 @@ public async Task> Create([FromBody] FeatureE } [HttpPost("features/update")] - [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole},{RDConstants.WriterRole}")] + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] public async Task> Update([FromBody] FeatureEditViewModel model) { if (model.Id.GetValueOrDefault() == default) @@ -217,7 +217,7 @@ public async Task> Update([FromBody] FeatureE } [HttpPost("features/delete")] - [Authorize("github", Roles = $"{RDConstants.AdminRole},{RDConstants.ReviewerRole},{RDConstants.WriterRole}")] + [Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")] public async Task Delete([FromBody] Feature model) { if (model.Id == default) diff --git a/rubberduckvba.Server/GitHubAuthenticationHandler.cs b/rubberduckvba.Server/GitHubAuthenticationHandler.cs index e532e90..5a8cfe3 100644 --- a/rubberduckvba.Server/GitHubAuthenticationHandler.cs +++ b/rubberduckvba.Server/GitHubAuthenticationHandler.cs @@ -2,6 +2,7 @@ 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; @@ -11,8 +12,6 @@ public class GitHubAuthenticationHandler : AuthenticationHandler> _authApiTask = new(); + protected override Task HandleAuthenticateAsync() { try @@ -46,20 +47,17 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(cachedResult)!; } - lock (_lock) + if (TryAuthenticateGitHubToken(token, out var result) + && result is AuthenticateResult + && result.Ticket is AuthenticationTicket ticket) { - if (TryAuthenticateGitHubToken(token, out var result) - && result is AuthenticateResult - && result.Ticket is AuthenticationTicket ticket) - { - CacheAuthenticatedTicket(token, ticket); - return Task.FromResult(result!); - } - - if (TryAuthenticateFromCache(token, out cachedResult)) - { - return Task.FromResult(cachedResult!); - } + CacheAuthenticatedTicket(token, ticket); + return Task.FromResult(result!); + } + + if (TryAuthenticateFromCache(token, out cachedResult)) + { + return Task.FromResult(cachedResult!); } return Task.FromResult(AuthenticateResult.Fail("Missing or invalid access token")); @@ -99,16 +97,30 @@ private bool TryAuthenticateFromCache(string token, out AuthenticateResult? resu private bool TryAuthenticateGitHubToken(string token, out AuthenticateResult? result) { result = null; - var principal = _github.ValidateTokenAsync(token).GetAwaiter().GetResult(); + 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"); - result = AuthenticateResult.Success(ticket); - return true; + return AuthenticateResult.Success(ticket); } - return false; + return null; } } diff --git a/rubberduckvba.Server/GitHubSettings.cs b/rubberduckvba.Server/GitHubSettings.cs index 4a8ba05..5e9e077 100644 --- a/rubberduckvba.Server/GitHubSettings.cs +++ b/rubberduckvba.Server/GitHubSettings.cs @@ -15,14 +15,32 @@ public record class ConnectionSettings public static class RDConstants { - public const int OrganisationId = 12832254; - public const string WebAdminTeam = "WebAdmin"; - public const string ContributorsTeam = "Contributors"; - - public const string ReaderRole = "rd-reader"; - public const string WriterRole = "rd-writer"; - public const string ReviewerRole = "rd-reviewer"; - public const string AdminRole = "rd-admin"; + 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 diff --git a/rubberduckvba.Server/Model/Entity/FeatureEntity.cs b/rubberduckvba.Server/Model/Entity/FeatureEntity.cs index 0064225..9f9dcf4 100644 --- a/rubberduckvba.Server/Model/Entity/FeatureEntity.cs +++ b/rubberduckvba.Server/Model/Entity/FeatureEntity.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Runtime.Serialization; +using System.Text.Json; namespace rubberduckvba.Server.Model.Entity; @@ -36,6 +37,39 @@ public record class AuditEntity 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; } @@ -44,6 +78,12 @@ public record class FeatureEditEntity : AuditEntity 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, @@ -56,6 +96,7 @@ public record class FeatureOpEntity : AuditEntity 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!; diff --git a/rubberduckvba.Server/Services/GitHubClientService.cs b/rubberduckvba.Server/Services/GitHubClientService.cs index f894721..c7c1d27 100644 --- a/rubberduckvba.Server/Services/GitHubClientService.cs +++ b/rubberduckvba.Server/Services/GitHubClientService.cs @@ -45,7 +45,7 @@ private class ReleaseComparer : IEqualityComparer var user = await client.User.Current(); var orgs = await client.Organization.GetAllForCurrent(); - var org = orgs.SingleOrDefault(e => e.Id == RDConstants.OrganisationId); + var org = orgs.SingleOrDefault(e => e.Id == RDConstants.Org.OrganisationId); var isOrgMember = org is Organization rdOrg; var claims = new List @@ -59,27 +59,31 @@ private class ReleaseComparer : IEqualityComparer { var teams = await client.Organization.Team.GetAllForCurrent(); - var adminTeam = teams.SingleOrDefault(e => e.Name == RDConstants.WebAdminTeam); + 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.AdminRole)); + claims.Add(new Claim(ClaimTypes.Role, RDConstants.Roles.AdminRole)); } else { - var contributorsTeam = teams.SingleOrDefault(e => e.Name == RDConstants.ContributorsTeam); + 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.ReviewerRole)); + 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.WriterRole)); + 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 41e50e4..b7bbd9b 100644 --- a/rubberduckvba.Server/Services/RubberduckDbService.cs +++ b/rubberduckvba.Server/Services/RubberduckDbService.cs @@ -1,10 +1,12 @@ 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; @@ -67,7 +69,9 @@ public interface IAuditService Task UpdateFeature(Feature feature, IIdentity identity); - Task> GetPendingItems(int? featureId = default) where T : AuditEntity; + 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; @@ -96,10 +100,11 @@ public async Task Approve(T entity, IIdentity identity) where T : AuditEntity { var procName = entity switch { - FeatureOpEntity => "audits.ApproveFeatureOp", - FeatureEditEntity => "audits.ApproveFeatureEdit", + 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); } @@ -107,8 +112,8 @@ public async Task Reject(T entity, IIdentity identity) where T : AuditEntity { var procName = entity switch { - FeatureOpEntity => "audits.RejectFeatureOp", - FeatureEditEntity => "audits.RejectFeatureEdit", + 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); @@ -116,10 +121,24 @@ public async Task Reject(T entity, IIdentity identity) where T : AuditEntity private async Task ApproveOrReject(string procedure, int id, IIdentity identity) { - var login = identity?.Name ?? throw new ArgumentNullException(nameof(identity), "Identity name cannot be null."); + 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 }); + 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) @@ -168,7 +187,9 @@ private async Task SubmitFeatureEdit(Feature feature, IIdentity identity) using var db = await GetDbConnection(); - var current = await db.QuerySingleOrDefaultAsync("SELECT * FROM dbo.Features WHERE Id = @featureId", new { featureId = feature.Id }); + 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; @@ -207,13 +228,143 @@ private async Task SubmitFeatureEdit(Feature feature, IIdentity identity) }); } - public async Task> GetPendingItems(int? featureId = default) where T : AuditEntity + 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(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."), }; @@ -224,12 +375,35 @@ public async Task> GetPendingItems(int? featureId = default) w { 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}"; - sql += " ORDER BY src.[DateInserted] DESC"; - return await db.QueryAsync(sql); + 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 []; } } diff --git a/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs b/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs index 43abb74..6232cdb 100644 --- a/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs +++ b/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs @@ -37,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 = []; @@ -74,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, diff --git a/rubberduckvba.client/rubberduckvba.client.esproj b/rubberduckvba.client/rubberduckvba.client.esproj index 9a08b85..0bf23c4 100644 --- a/rubberduckvba.client/rubberduckvba.client.esproj +++ b/rubberduckvba.client/rubberduckvba.client.esproj @@ -8,8 +8,6 @@ $(MSBuildProjectDirectory)\dist\rubberduckvba.client\ - - \ No newline at end of file diff --git a/rubberduckvba.client/src/app/app.module.ts b/rubberduckvba.client/src/app/app.module.ts index 53afd96..8b7c8f9 100644 --- a/rubberduckvba.client/src/app/app.module.ts +++ b/rubberduckvba.client/src/app/app.module.ts @@ -44,6 +44,10 @@ 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 @@ -64,10 +68,13 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { AppComponent, HomeComponent, AuthComponent, + UserProfileComponent, AuthMenuComponent, AuditsAdminComponent, + AuditItemComponent, AuditBoxComponent, AuditFeatureAdditionComponent, + AuditFeatureDeleteComponent, AuditFeatureEditMarkdownComponent, IndenterComponent, FeaturesComponent, @@ -102,7 +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 }, @@ -118,6 +128,7 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { providers: [ DataService, ApiClientService, + AuthService, NgbAccordionDirective, provideHttpClient(withInterceptorsFromDi()), { 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 index 3e01da9..969e7bf 100644 --- 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 @@ -1,67 +1,78 @@
-

{{auditEdit!.fieldName}}

-

Create

{{auditOp!.name}}
-

Delete

{{auditOp!.name}}
-
Submitted {{dateSubmitted}} by {{author}}
-
, last modified {{dateModified}}
-
-
-
- -
-
-

(todo: delete op)

- +
+
+

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 +
+
+
+ +
-
- +
+
+
+
+ +
+
+ +
+
+ +
- -