Skip to content

Commit

Permalink
Got user icon and name loading (#63)
Browse files Browse the repository at this point in the history
  • Loading branch information
greggbjensen authored Aug 17, 2024
1 parent c143621 commit 6eec8ce
Show file tree
Hide file tree
Showing 14 changed files with 166 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
using System.Security.Claims;
using System.Text;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Octokit;
using PrDeploy.Api.Business.Auth;
using PrDeploy.Api.Business.Auth.Interfaces;
using PrDeploy.Api.Business.Clients.Interfaces;
using PrDeploy.Api.Business.Mapping;
using PrDeploy.Api.Business.Options;
using PrDeploy.Api.Models.Auth;
using RestSharp;
Expand All @@ -18,6 +20,7 @@ namespace PrDeploy.Api.Business.Clients
public class GitHubAuthClient : IGitHubAuthClient
{
private readonly ICipherService _cipherService;
private readonly IServiceProvider _serviceProvider;
private readonly IValidator<AccessTokenRequest> _validator;
private readonly IRestClient _client;
private readonly GitHubAuthOptions _gitHubOptions;
Expand All @@ -26,10 +29,12 @@ public class GitHubAuthClient : IGitHubAuthClient
public GitHubAuthClient(IRestClientInstance<GitHubAuthOptions> restClientInstance,
IOptions<JwtOptions> jwtOptions,
ICipherService cipherService,
IServiceProvider serviceProvider,
IValidator<AccessTokenRequest> validator)
{
_jwtOptions = jwtOptions.Value;
_cipherService = cipherService;
_serviceProvider = serviceProvider;
_validator = validator;
_client = restClientInstance.RestClient;
_gitHubOptions = restClientInstance.Options;
Expand Down Expand Up @@ -58,7 +63,16 @@ public async Task<AccessTokenResponse> GetAccessTokenAsync(AccessTokenRequest ac
return response!;
}

public string CreateJwt(string gitHubToken)
public async Task<UserInfo> GetUserInfoAsync()
{
// We have to resolve this here to make sure there is a token.
var gitHubClient = _serviceProvider.GetRequiredService<IGitHubClient>();
var user = await gitHubClient.User.Current();
var userInfo = Map.UserInfo(user);
return userInfo ?? new UserInfo();
}

private string CreateJwt(string gitHubToken)
{
var encryptedToken = _cipherService.Encrypt(gitHubToken);
var key = Encoding.ASCII.GetBytes(_jwtOptions.Key);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ namespace PrDeploy.Api.Business.Clients.Interfaces;
public interface IGitHubAuthClient
{
Task<AccessTokenResponse> GetAccessTokenAsync(AccessTokenRequest accessTokenRequest);
Task<UserInfo> GetUserInfoAsync();
}
19 changes: 17 additions & 2 deletions prdeploy-api/src/PrDeploy.Api.Business/Mapping/Map.Objects.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
using PrDeploy.Api.Models.OwnerRepo;
using Octokit;
using PrDeploy.Api.Models.Auth;
using PrDeploy.Api.Models.PullRequests;
using PrDeploy.Api.Models.Settings;
using PullRequest = PrDeploy.Api.Models.PullRequests.PullRequest;
using Repository = PrDeploy.Api.Models.OwnerRepo.Repository;

namespace PrDeploy.Api.Business.Mapping;
public static partial class Map
{
public static UserInfo? UserInfo(User? source) =>
source != null
? new UserInfo
{
Id = source.Id,
AvatarUrl = source.AvatarUrl,
Login = source.Login,
Name = !string.IsNullOrEmpty(source.Name) ? source.Name : source.Login,
Admin = source.SiteAdmin,
Type = (GitHubAccountType)source.Type.GetValueOrDefault()
}
: null;

public static PullRequest? PullRequest(Octokit.PullRequest? source) =>
source != null
? new PullRequest
Expand Down
10 changes: 10 additions & 0 deletions prdeploy-api/src/PrDeploy.Api.Models/Auth/GitHubAccountType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace PrDeploy.Api.Models.Auth
{
public enum GitHubAccountType
{
User = 0,
Organization = 1,
Bot = 2,
Mannequin = 3
}
}
17 changes: 17 additions & 0 deletions prdeploy-api/src/PrDeploy.Api.Models/Auth/UserInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace PrDeploy.Api.Models.Auth
{
public class UserInfo
{
public long Id { get; set; }

public string AvatarUrl { get; set; } = string.Empty;

public string Login { get; set; }

public string Name { get; set; }

public bool Admin { get; set; }

public GitHubAccountType? Type { get; set; }
}
}
31 changes: 29 additions & 2 deletions prdeploy-api/src/PrDeploy.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System.Net;
using System.Text.Json;
using HotChocolate.AspNetCore;
using PrDeploy.Api;
using PrDeploy.Api.Business;
using PrDeploy.Api.Options;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.IdentityModel.Tokens;
using Octokit;
using PrDeploy.Api.Auth;
using PrDeploy.Api.Builder;
using PrDeploy.Api.Business.Clients.Interfaces;
Expand All @@ -14,8 +16,9 @@
using PrDeploy.Api.Models.Auth;
using Serilog;
using Serilog.Formatting.Json;
using IServiceCollectionExtensions = PrDeploy.Api.IServiceCollectionExtensions;
using Path = System.IO.Path;
using GreenDonut;
using System.Text.Json.Serialization;

try
{
Expand Down Expand Up @@ -85,6 +88,12 @@
app.UseEndpoints(endpoints =>
{
// Simple GitHub Access Token proxy.
var apiJsonSerializerOptions = new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() },
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

endpoints.MapPost("/api/oauth/access_token", async (HttpRequest request, IGitHubAuthClient authClient) =>
{
var form = await request.ReadFormAsync();
Expand Down Expand Up @@ -118,6 +127,24 @@
})
.AllowAnonymous();

// User info endpoint since GitHub does not provide one for OAuth.
endpoints.MapGet("/api/oauth/userinfo", async (HttpRequest request, IGitHubAuthClient authClient) =>
{
IResult result;
try
{
var userInfo = await authClient.GetUserInfoAsync();
result = Results.Json(userInfo, apiJsonSerializerOptions, statusCode: StatusCodes.Status200OK);
}
catch (HttpRequestException e)
{
Log.Logger.Error(e, $"Error getting user info ({e.StatusCode}).");
result = Results.StatusCode((int)e.StatusCode!);
}

return result;
});

endpoints.MapGraphQL().WithOptions(new GraphQLServerOptions {
Tool = { Enable = false } // Use Apollo Playground instead of Banana Cake Pop.
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<div class="user-panel">
<div class="user-info">
<div class="image-container">
<i class="dx-icon dx-icon-user"></i>
@if (user) {
<img [src]="user.avatarUrl" [title]="user.name" />
} @else {
<i class="dx-icon dx-icon-user"></i>
}
</div>
</div>
@if (menuMode === 'context') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
margin: 0 auto;
font-size: 18px;
}

img {
width: 32px;
height: 32px;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Component, Input } from '@angular/core';
import { Component, DestroyRef, Input } from '@angular/core';
import { DxListModule } from 'devextreme-angular/ui/list';
import { DxContextMenuModule } from 'devextreme-angular/ui/context-menu';
import { AuthService } from '../../services';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { User } from '../../models';

@Component({
selector: 'app-user-panel',
Expand All @@ -16,5 +19,12 @@ export class UserPanelComponent {
@Input()
menuMode!: string;

constructor() {}
user: User = null;

constructor(
private _authService: AuthService,
private _destroyRef: DestroyRef
) {
this._authService.user$.pipe(takeUntilDestroyed(this._destroyRef)).subscribe(user => (this.user = user));
}
}
6 changes: 6 additions & 0 deletions prdeploy-app/src/app/shared/models/github-account-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export enum GitHubAccountType {
User = 'User',
Organization = 'Organization',
Bot = 'Bot',
Mannequin = 'Mannequin'
}
2 changes: 2 additions & 0 deletions prdeploy-app/src/app/shared/models/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './repository';
export * from './tab';
export * from './user';
export * from './github-account-type';
10 changes: 10 additions & 0 deletions prdeploy-app/src/app/shared/models/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { GitHubAccountType } from './github-account-type';

export interface User {
id: number;
avatarUrl: string;
login: string;
name: string;
admin: boolean;
type: GitHubAccountType;
}
2 changes: 2 additions & 0 deletions prdeploy-app/src/app/shared/services/auth/auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ export const authConfig = (options: OAuthOptions, document: Document) => {
issuer: 'https://github.com',
loginUrl: 'https://github.com/login/oauth/authorize',
tokenEndpoint: '/api/oauth/access_token',
userinfoEndpoint: '/api/oauth/userinfo',
clientId: options.clientId, // The "Auth Code + PKCE" client
responseType: 'code',
redirectUri: document.location.origin + '/login/callback',
scope: 'repo read:org',
oidc: false,
sessionChecksEnabled: true,
showDebugInformation: true, // Also requires enabling "Verbose" level in devtools
clearHashAfterLogin: false, // https://github.com/manfredsteyer/angular-oauth2-oidc/issues/457#issuecomment-431807040,
Expand Down
48 changes: 35 additions & 13 deletions prdeploy-app/src/app/shared/services/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,38 @@ import { Router } from '@angular/router';
import { AuthConfig, OAuthErrorEvent, OAuthService } from 'angular-oauth2-oidc';
import { BehaviorSubject } from 'rxjs';
import { filter } from 'rxjs/operators';
import { User } from '../../models';

@Injectable({ providedIn: 'root' })
export class AuthService {
private static readonly DefaultUrl = '/deployments';
private static readonly LoginUrl = '/login';

private userSubject$ = new BehaviorSubject<User>(null);
user$ = this.userSubject$.asObservable();

private isAuthenticatedSubject$ = new BehaviorSubject<boolean>(false);
public isAuthenticated$ = this.isAuthenticatedSubject$.asObservable();
isAuthenticated$ = this.isAuthenticatedSubject$.asObservable();

private isDoneLoadingSubject$ = new BehaviorSubject<boolean>(false);
public isDoneLoading$ = this.isDoneLoadingSubject$.asObservable();

private navigateToLoginPage() {
this._router.navigateByUrl(AuthService.LoginUrl);
}
isDoneLoading$ = this.isDoneLoadingSubject$.asObservable();

constructor(
private _oauthService: OAuthService,
private _router: Router
) {}

public login(targetUrl?: string) {
get user(): User {
return this.userSubject$.value;
}

login(targetUrl?: string) {
// Note: before version 9.1.0 of the library you needed to
// call encodeURIComponent on the argument to the method.
this._oauthService.initLoginFlow(targetUrl || this._router.url);
}

public async runInitialLoginSequence(): Promise<void> {
async runInitialLoginSequence(): Promise<void> {
await this._oauthService.tryLogin();
this.updateIsAuthenticated();
if (!this.isAuthenticatedSubject$.value) {
Expand All @@ -52,18 +56,18 @@ export class AuthService {
this.isDoneLoadingSubject$.next(true);
}

public logout(forbidden = false) {
logout(forbidden = false) {
this._oauthService.logOut();
this._router.navigate([AuthService.LoginUrl], {
queryParams: !forbidden ? null : { forbidden }
});
}

public hasValidToken() {
hasValidToken() {
return this._oauthService.hasValidAccessToken();
}

public initialize(authConfig: AuthConfig): void {
initialize(authConfig: AuthConfig): void {
this._oauthService.configure(authConfig);

// Useful for debugging:
Expand Down Expand Up @@ -102,17 +106,35 @@ export class AuthService {
this.updateIsAuthenticated();

this._oauthService.events.pipe(filter(e => ['token_received'].includes(e.type))).subscribe(async () => {
// const user = (await this._oauthService.loadUserProfile()) as User;
// this.userSubject$.next(user);
await this.tryLoadUser();
this.updateIsAuthenticated();
});

this._oauthService.events
.pipe(filter(e => ['session_terminated', 'session_error'].includes(e.type)))
.subscribe(() => this.navigateToLoginPage());

this.isDoneLoading$.subscribe(async done => {
if (!done) {
return;
}
await this.tryLoadUser();
});
}

private async tryLoadUser() {
const isAuthenticated = this._oauthService.hasValidAccessToken();
if (isAuthenticated && !this.user) {
const userInfo: any = await this._oauthService.loadUserProfile();
this.userSubject$.next(userInfo.info as User);
}
}

private updateIsAuthenticated(): void {
this.isAuthenticatedSubject$.next(this._oauthService.hasValidAccessToken());
}

private navigateToLoginPage() {
this._router.navigateByUrl(AuthService.LoginUrl);
}
}

0 comments on commit 6eec8ce

Please sign in to comment.