Skip to content

More admin/reviewer features #78

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using rubberduckvba.Server.Model;
using rubberduckvba.Server.Model.Entity;
using rubberduckvba.Server.Services;

namespace rubberduckvba.Server.Api.Features;
Expand Down Expand Up @@ -36,7 +37,8 @@ public Feature ToFeature()
ShortDescription = ShortDescription,
Description = Description,
IsHidden = IsHidden,
IsNew = IsNew
IsNew = IsNew,
Links = Links
};
}

Expand All @@ -56,6 +58,8 @@ public FeatureEditViewModel(Feature model, FeatureOptionViewModel[] features, Re

Features = features;
Repositories = repositories;

Links = model.Links;
}

public int? Id { get; init; }
Expand All @@ -72,4 +76,6 @@ public FeatureEditViewModel(Feature model, FeatureOptionViewModel[] features, Re

public FeatureOptionViewModel[] Features { get; init; } = [];
public RepositoryOptionViewModel[] Repositories { get; init; } = [];

public BlogLink[] Links { get; init; } = [];
}
10 changes: 6 additions & 4 deletions rubberduckvba.Server/Api/Features/FeaturesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,13 @@ public async Task Delete([FromBody] Feature model)
public MarkdownContentViewModel FormatMarkdown([FromBody] MarkdownContentViewModel model)
{
var markdown = model.Content;
var formatted = markdownService.FormatMarkdownDocument(markdown, withSyntaxHighlighting: true);
return new MarkdownContentViewModel
if (!cache.TryGetHtml(markdown, out var html))
{
Content = formatted
};
html = markdownService.FormatMarkdownDocument(markdown, withSyntaxHighlighting: true);
cache.CacheHtml(markdown, html);
}

return new MarkdownContentViewModel { Content = html! };
}

private InspectionsFeatureViewModel GetInspections()
Expand Down
11 changes: 11 additions & 0 deletions rubberduckvba.Server/Services/CacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using rubberduckvba.Server.Api.Tags;
using rubberduckvba.Server.Data;
using rubberduckvba.Server.Model;
using System.Security.Cryptography;
using System.Text;

namespace rubberduckvba.Server.Services;

Expand Down Expand Up @@ -41,6 +43,9 @@ private void GetCurrentJobState()
XmldocJobState = state.TryGetValue(XmldocJobName, out var xmldocsJobState) ? xmldocsJobState : null;
}

public bool TryGetHtml(string markdown, out string? cached) => TryReadFromCache($"md:{Encoding.UTF8.GetString(SHA256.HashData(Encoding.UTF8.GetBytes(markdown)))}", out cached);
public void CacheHtml(string markdown, string html) => Write($"md:{Encoding.UTF8.GetString(SHA256.HashData(Encoding.UTF8.GetBytes(markdown)))}", html);

public bool TryGetLatestTags(out LatestTagsViewModel? cached) => TryReadFromTagsCache("tags/latest", out cached);
public bool TryGetAvailableDownloads(out AvailableDownload[]? cached) => TryReadFromTagsCache("downloads", out cached);
public bool TryGetFeatures(out FeatureViewModel[]? cached) => TryReadXmldocCache("features", out cached);
Expand Down Expand Up @@ -177,4 +182,10 @@ private bool TryReadXmldocCache<T>(string key, out T? cached)

return result;
}

private bool TryReadFromCache(string key, out string? cached)
{
var result = _cache.TryGetValue(key, out cached);
return result;
}
}
63 changes: 32 additions & 31 deletions rubberduckvba.Server/Services/GitHubClientService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,51 +42,52 @@ private class ReleaseComparer : IEqualityComparer<Release>
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 org = orgs.SingleOrDefault(e => e.Id == RDConstants.Org.OrganisationId);
var isOrgMember = org is Organization rdOrg;

var (name, role) = await DetermineUserRole(client);
var claims = new List<Claim>
{
new(ClaimTypes.Name, user.Login),
new(ClaimTypes.Name, name),
new(ClaimTypes.Role, role),
new(ClaimTypes.Authentication, token),
new("access_token", token)
};

if (isOrgMember && !user.Suspended)
return new ClaimsPrincipal(new ClaimsIdentity(claims, "github"));
}

private static async Task<(string name, string role)> DetermineUserRole(GitHubClient client)
{
var role = RDConstants.Roles.ReaderRole;

var user = await client.User.Current();
if (!user.Suspended)
{
var teams = await client.Organization.Team.GetAllForCurrent();
// only authenticated GitHub users in good standing can submit edits
role = RDConstants.Roles.WriterRole;

var adminTeam = teams.SingleOrDefault(e => e.Name == RDConstants.Org.WebAdminTeam);
if (adminTeam is not null)
var orgs = await client.Organization.GetAllForCurrent();
if (orgs.SingleOrDefault(e => e.Id == RDConstants.Org.OrganisationId) is not null)
{
// authenticated members of the org who are in the admin team can manage the site and approve their own changes
claims.Add(new Claim(ClaimTypes.Role, RDConstants.Roles.AdminRole));
}
else
{
var contributorsTeam = teams.SingleOrDefault(e => e.Name == RDConstants.Org.ContributorsTeam);
if (contributorsTeam is not null)
{
// members of the contributors team can review/approve/reject suggested changes
claims.Add(new Claim(ClaimTypes.Role, RDConstants.Roles.ReviewerRole));
}
else
var teams = await client.Organization.Team.GetAllForCurrent();

// members of the Rubberduck organization are welcome to review/approve/reject suggested changes
// NOTE: opportunity for eventual distinction between members and contributors
role = RDConstants.Roles.ReviewerRole;

//// members of the contributors team can review/approve/reject suggested changes
//if (teams.SingleOrDefault(e => e.Name == RDConstants.Org.ContributorsTeam) is not null)
//{
// role = RDConstants.Roles.ReviewerRole;
//}

// authenticated org members in the WebAdmin team can manage the site and approve their own changes
if (teams.SingleOrDefault(e => e.Name == RDConstants.Org.WebAdminTeam) is not null)
{
// authenticated members of the org can submit edits
claims.Add(new Claim(ClaimTypes.Role, RDConstants.Roles.WriterRole));
role = RDConstants.Roles.AdminRole;
}
}
}
else
{
claims.Add(new Claim(ClaimTypes.Role, RDConstants.Roles.ReaderRole));
}

var identity = new ClaimsIdentity(claims, "github");
return new ClaimsPrincipal(identity);
return (user.Login, role);
}

public async Task<IEnumerable<TagGraph>> GetAllTagsAsync()
Expand Down
4 changes: 3 additions & 1 deletion rubberduckvba.client/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { AuditFeatureDeleteComponent } from './components/audits/feature-delete.
import { UserProfileComponent } from './routes/profile/user-profile.component';
import { AuditItemComponent } from './routes/audits/audit-item/audit-item.component';
import { AuthService } from './services/auth.service';
import { EditBlogLinkComponent } from './components/edit-feature/edit-bloglink/edit-bloglink.component';

/**
* https://stackoverflow.com/a/39560520
Expand Down Expand Up @@ -96,7 +97,8 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer {
AnnotationComponent,
QuickFixComponent,
AboutComponent,
EditFeatureComponent
EditFeatureComponent,
EditBlogLinkComponent
],
bootstrap: [AppComponent],
imports: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<div class="row my-2 p-1 bg-info-subtle">
<div class="col-10">
<div class="row">
<div class="col-6">
<input id="nameBox" title="Display title of the blog article" type="text" [(ngModel)]="blogLink.name" maxlength="255" [required]="true" class="p-1 w-100 border-0 fw-semibold" />
</div>
<div class="col-3">
<input id="authorBox" title="Author of the linked article" type="text" [(ngModel)]="blogLink.author" maxlength="255" [required]="true" class="p-1 w-100 border-0" />
</div>
<div class="col-3">
<input id="publishedBox" title="Publish date of the linked article" type="text" [(ngModel)]="blogLink.published" maxlength="10" [required]="true" class="p-1 w-100 border-0" />
</div>
</div>
<div class="row mt-1">
<div class="col-12">
<input id="urlBox" title="URL to the blog article" type="text" [(ngModel)]="blogLink.url" maxlength="1023" [required]="true" class="p-1 w-100 border-0" />
</div>
</div>
</div>
<div class="col-2 align-content-center text-end">
<button class="btn btn-danger m-1" (click)="onRemove()"><fa-icon [icon]="['fas', 'trash-can']"></fa-icon>&nbsp;Remove</button>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { BlogLink } from "../../../model/feature.model";

@Component({
selector: 'edit-blog-link',
templateUrl: './edit-bloglink.component.html'
})
export class EditBlogLinkComponent implements OnInit {

private _blogLink: BlogLink = null!;

constructor() {

}

ngOnInit(): void {
}

@Output()
public removeLink = new EventEmitter<BlogLink>();

@Input()
public set blogLink(value: BlogLink) {
this._blogLink = value;
}

public get blogLink(): BlogLink {
return this._blogLink;
}

public onRemove(): void {
this.removeLink.emit(this.blogLink);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
</span>
</button>

<button *ngIf="action == 'links'" type="button" class="btn btn-outline-secondary rounded-pill mx-1" (click)="doAction()" [disabled]="disabled">
<span>
<fa-icon [icon]="'link'"></fa-icon>&nbsp;Edit Links
</span>
</button>

<button *ngIf="action == 'create'" type="button" class="btn btn-outline-secondary rounded-pill m-1" (click)="doAction()" [disabled]="disabled">
<span>
<fa-icon [icon]="'plus-circle'"></fa-icon>&nbsp;Add Feature
Expand All @@ -30,13 +36,25 @@ <h4><img src="../../assets/vector-ducky-540.png" height="32">&nbsp;{{feature.tit
</div>
<div class="modal-body my-2 mx-4">
<div class="row">
<h6>Edit {{action == 'edit' ? 'description' : 'summary'}}</h6>
<h6>Edit {{action == 'edit' ? 'description' : (action == 'summary' ? 'summary' : 'links')}}</h6>
</div>
<div class="row">

<textarea *ngIf="action == 'edit'" [(ngModel)]="feature.description" type="text" maxlength="8000" rows="10" class="font-monospace text-nowrap p-1">
</textarea>

<textarea *ngIf="action == 'summary'" [(ngModel)]="feature.shortDescription" type="text" maxlength="1023" rows="5" class="font-monospace p-1">
</textarea>

<div *ngIf="action == 'links'">
<div *ngFor="let link of feature.links">
<edit-blog-link [blogLink]="link" (removeLink)="onRemoveLink(link)"></edit-blog-link>
</div>
<div class="text-end">
<button class="btn btn-outline-success" (click)="onAddLink()"><fa-icon [icon]="['fas', 'plus-circle']"></fa-icon>&nbsp;Add</button>
</div>
</div>

</div>
<div class="row">
<span *ngIf="action == 'edit'" class="text-small text-muted text-end">{{feature.description.length}}</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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 { BlogLink, BlogLinkViewModelClass, 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";
Expand All @@ -9,6 +9,7 @@ import { ApiClientService } from "../../services/api-client.service";
export enum AdminAction {
Edit = 'edit',
EditSummary = 'summary',
EditLinks = 'links',
Create = 'create',
Delete = 'delete',
}
Expand Down Expand Up @@ -98,14 +99,15 @@ export class EditFeatureComponent implements OnInit {
featureTitle: parentTitle,
isCollapsed: false,
isDetailsCollapsed: true,
links: []
});
}

this.modal.open(localModal, { modalDialogClass: size });
}

public onConfirmChanges(): void {
this.modal.dismissAll();
this.modal.dismissAll();
this.api.saveFeature(this.feature).subscribe(saved => {
window.location.reload();
});
Expand Down Expand Up @@ -138,4 +140,17 @@ export class EditFeatureComponent implements OnInit {
window.location.reload();
});
}

public onRemoveLink(link: BlogLink): void {
this.feature.links = this.feature.links!.filter(e => e.name.length > 0 && e.url.length > 0 && (e.name != link.name || e.url != link.url));
}

public onAddLink(): void {
this.feature.links.push(new BlogLinkViewModelClass({
name: 'Title',
author: 'Author',
published: new Date().toISOString().substring(0, 10),
url: 'https://rubberduckvba.blog/...'
}));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ <h4>{{feature.title}}</h4>
</div>

<div class="row my-3" *ngIf="!hasOwnDetailsPage">
<div class="col-12 text-center">
<div class="col-1"></div>
<div class="col-10 text-center">
<button class="btn btn-outline-dark btn-ducky rounded-pill w-auto" role="button" data-toggle="collapse" data-target="#featureBoxDetailsBody" aria-controls="featureBoxDetailsBody" (click)="feature.isDetailsCollapsed = !feature.isDetailsCollapsed">
<div *ngIf="feature.isDetailsCollapsed">
Show more ▾
Expand All @@ -23,10 +24,13 @@ <h4>{{feature.title}}</h4>
Show less ▴
</div>
</button>
<div id="featureBoxDetailsBody" class="collapse" [ngClass]="{'show': !feature.isDetailsCollapsed}">
<div [innerHtml]="feature.description"></div>
<div id="featureBoxDetailsBody" class="collapse mt-3 text-start p-2" [ngClass]="{'show': !feature.isDetailsCollapsed}">
<div class="card">
<div class="card-body" [innerHtml]="descriptionHtml"></div>
</div>
</div>
</div>
<div class="col-1"></div>
</div>
</div>
<div *ngIf="feature && user.isReviewer" class="card-footer">
Expand Down
Loading