From 4f3f0df40c61ee1b55aa2fb4f79116953f53fafa Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Mon, 4 Apr 2022 15:22:37 -0700 Subject: [PATCH 1/4] feat(apps): create account menu component An accounnt menu component which can be used to manage login state as well as display the logged in user and their account linking status. --- WORKSPACE | 14 ++ apps/prs/src/app/app.component.html | 5 +- apps/prs/src/app/app.component.scss | 5 + apps/prs/src/app/app.module.ts | 2 + apps/shared/account/BUILD.bazel | 33 +++++ apps/shared/account/account.component.html | 46 +++++++ apps/shared/account/account.component.scss | 124 ++++++++++++++++++ apps/shared/account/account.component.spec.ts | 27 ++++ apps/shared/account/account.component.ts | 63 +++++++++ apps/shared/account/account.module.ts | 16 +++ apps/shared/account/account.service.ts | 81 ++++++++++++ tools/defaults.bzl | 8 ++ 12 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 apps/shared/account/BUILD.bazel create mode 100644 apps/shared/account/account.component.html create mode 100644 apps/shared/account/account.component.scss create mode 100644 apps/shared/account/account.component.spec.ts create mode 100644 apps/shared/account/account.component.ts create mode 100644 apps/shared/account/account.module.ts create mode 100644 apps/shared/account/account.service.ts diff --git a/WORKSPACE b/WORKSPACE index fe18cdf14..f6f8ce7d8 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -16,6 +16,15 @@ http_archive( ], ) +http_archive( + name = "io_bazel_rules_sass", + sha256 = "bfb89ca97a4ad452ca5f623dfde23d2a5f3a848a97478d715881b69b4767d3bb", + strip_prefix = "rules_sass-1.49.4", + urls = [ + "https://github.com/bazelbuild/rules_sass/archive/1.49.4.zip", + ], +) + # Fetch rules_nodejs and install its dependencies so we can install our npm dependencies. http_archive( name = "build_bazel_rules_nodejs", @@ -78,6 +87,10 @@ load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies") rules_pkg_dependencies() +load("@io_bazel_rules_sass//:defs.bzl", "sass_repositories") + +sass_repositories() + register_toolchains( "//tools/git-toolchain:git_linux_toolchain", "//tools/git-toolchain:git_macos_x86_toolchain", @@ -85,6 +98,7 @@ register_toolchains( "//tools/git-toolchain:git_windows_toolchain", ) + http_file( name = "bazel_test_status_proto", sha256 = "61ce1dc62fdcfd6d68624a403e0f04c5fd5136d933b681467aad1ad2d00dbb03", diff --git a/apps/prs/src/app/app.component.html b/apps/prs/src/app/app.component.html index 7d7e24912..17616b696 100644 --- a/apps/prs/src/app/app.component.html +++ b/apps/prs/src/app/app.component.html @@ -1,4 +1,7 @@ - + + + + diff --git a/apps/prs/src/app/app.component.scss b/apps/prs/src/app/app.component.scss index 18f876b47..a38de8dab 100644 --- a/apps/prs/src/app/app.component.scss +++ b/apps/prs/src/app/app.component.scss @@ -2,6 +2,11 @@ position: sticky; top: 0; z-index: 1; + flex-direction: row; + + .spacer { + flex: 1; + } } .sidenav { diff --git a/apps/prs/src/app/app.module.ts b/apps/prs/src/app/app.module.ts index 86c3cd389..a93d2ba62 100644 --- a/apps/prs/src/app/app.module.ts +++ b/apps/prs/src/app/app.module.ts @@ -12,6 +12,7 @@ import {environment} from '../environments/environment'; import {LayoutModule} from '@angular/cdk/layout'; import {MatToolbarModule} from '@angular/material/toolbar'; import {MatSidenavModule} from '@angular/material/sidenav'; +import {AccountModule} from '../../../shared/account/account.module'; @NgModule({ declarations: [AppComponent], @@ -25,6 +26,7 @@ import {MatSidenavModule} from '@angular/material/sidenav'; LayoutModule, MatToolbarModule, MatSidenavModule, + AccountModule, ], providers: [], bootstrap: [AppComponent], diff --git a/apps/shared/account/BUILD.bazel b/apps/shared/account/BUILD.bazel new file mode 100644 index 000000000..297cfa8fa --- /dev/null +++ b/apps/shared/account/BUILD.bazel @@ -0,0 +1,33 @@ +load("//tools:defaults.bzl", "ng_module") +load("@io_bazel_rules_sass//:defs.bzl", "sass_binary") + +package(default_visibility = ["//visibility:private"]) + +ng_module( + name = "account", + srcs = glob( + [ + "*.ts", + ], + exclude = [ + "*.spec.ts", + ], + ), + assets = [ + "account.component.css", + "account.component.html", + ], + deps = [ + "@npm//@angular/cdk", + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//@angular/fire", + "@npm//@angular/material", + "@npm//rxjs", + ], +) + +sass_binary( + name = "account-style", + src = "account.component.scss", +) diff --git a/apps/shared/account/account.component.html b/apps/shared/account/account.component.html new file mode 100644 index 000000000..649d11677 --- /dev/null +++ b/apps/shared/account/account.component.html @@ -0,0 +1,46 @@ + + + + + \ No newline at end of file diff --git a/apps/shared/account/account.component.scss b/apps/shared/account/account.component.scss new file mode 100644 index 000000000..5b2a81bf3 --- /dev/null +++ b/apps/shared/account/account.component.scss @@ -0,0 +1,124 @@ +:host { + height: 100%; + + img { + line-height: 48px; + height: 48px; + font-size: 48px; + width: 48px; + padding: 8px; + border-radius: 50%; + cursor: pointer; + } +} + +.account-menu-container { + width: 300px; + animation-name: fade-in-out; + border-radius: 8px; + animation-duration: 125ms; + animation-timing-function: ease-in-out; + + .spacer { + flex: 1; + } + + .user-photo { + line-height: 48px; + height: 48px; + font-size: 48px; + width: 48px; + border-radius: 50%; + cursor: pointer; + position: absolute; + top: -8px; + right: -8px; + z-index: 1; + + &[src=''] { + visibility: hidden; + } + } + + +.bottom-row { + flex-direction: row; + display: flex; + padding: 8px 8px 8px 0; + border-top: 1px solid #d7d7d7; +} + +.title-row { + flex-direction: row; + display: flex; + + .title { + font-size: 22px; + line-height: 48px; + font-weight: 500; + margin: 0 0 0 8px; + } + + &:hover .expand-button { + display: initial; + } + .expand-button { + display: none; + margin: 4px 0 0 -8px; + } +} + +.provider-row { + height: 48px; + line-height: 48px; + flex-direction: row; + display: flex; + + &.disabled { + pointer-events: none; + opacity: 0.4; + } + + img.provider-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + font-size: 24px; + box-sizing: content-box; + border-radius: 50%; + margin: 12px 8px; + } + + span.provider-email { + line-height: inherit; + display: inline-block; + text-overflow: ellipsis; + width: 220px; + overflow: hidden; + } + + .link-provider-account { + &.mat-icon { + margin: 8px; + } + + &.mat-spinner { + width: 20px; + height: 20px; + margin: 10px; + visibility: visible; + } + } +} + +} + +@keyframes fade-in-out { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/apps/shared/account/account.component.spec.ts b/apps/shared/account/account.component.spec.ts new file mode 100644 index 000000000..fc0cddcf0 --- /dev/null +++ b/apps/shared/account/account.component.spec.ts @@ -0,0 +1,27 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {AccountComponent} from './account.component'; +import {AccountModule} from './account.module'; + +describe('AccountComponent', () => { + let component: AccountComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AccountModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create an account menu when opened', () => { + expect((component as any).accountMenuInstance).toBeFalsy(); + component.open(); + expect((component as any).accountMenuInstance).toBeTruthy(); + }); +}); diff --git a/apps/shared/account/account.component.ts b/apps/shared/account/account.component.ts new file mode 100644 index 000000000..05fb012d2 --- /dev/null +++ b/apps/shared/account/account.component.ts @@ -0,0 +1,63 @@ +import {Overlay} from '@angular/cdk/overlay'; +import {TemplatePortal} from '@angular/cdk/portal'; +import {Component, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; +import {AccountService} from './account.service'; + +@Component({ + selector: 'account', + templateUrl: './account.component.html', + styleUrls: ['./account.component.scss'], +}) +export class AccountComponent { + /** The overlay used to display the account menu. */ + private overlayRef = this.overlay.create(); + + /** The projected content provided to the template for insertion into the account menu. */ + @ViewChild('menuContent', {static: true}) private projected!: TemplateRef; + + /** The menu template portal. */ + portal!: TemplatePortal; + + constructor( + private vcr: ViewContainerRef, + private overlay: Overlay, + public account: AccountService, + ) {} + + ngOnInit() { + /** Create the template portal using the template from the component template. */ + this.portal = new TemplatePortal(this.projected, this.vcr); + // Close the menu whenever a click outside the menu occurs. + this.overlayRef.outsidePointerEvents().subscribe(() => this.close()); + // Close the menu if the login state changes while the menu is open. + this.account.loggedInStateChange.subscribe(() => this.close()); + // Set the positioning of the overlay to be attached to the container component. + this.overlayRef.updatePositionStrategy( + this.overlay + .position() + .flexibleConnectedTo(this.vcr.element.nativeElement) + .withPositions([ + { + offsetX: -16, + offsetY: 16, + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'top', + }, + ]), + ); + } + + /** Open the account menu. */ + open() { + this.portal.attach(this.overlayRef); + } + + /** Close the account menu. */ + close() { + if (this.portal.isAttached) { + this.portal.detach(); + } + } +} diff --git a/apps/shared/account/account.module.ts b/apps/shared/account/account.module.ts new file mode 100644 index 000000000..39d0098e3 --- /dev/null +++ b/apps/shared/account/account.module.ts @@ -0,0 +1,16 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {AccountComponent} from './account.component'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {CdkAccordionModule} from '@angular/cdk/accordion'; +import {AccountService} from './account.service'; + +@NgModule({ + declarations: [AccountComponent], + imports: [CommonModule, CdkAccordionModule, MatButtonModule, MatIconModule, OverlayModule], + providers: [AccountService], + exports: [AccountComponent], +}) +export class AccountModule {} diff --git a/apps/shared/account/account.service.ts b/apps/shared/account/account.service.ts new file mode 100644 index 000000000..a8be606e6 --- /dev/null +++ b/apps/shared/account/account.service.ts @@ -0,0 +1,81 @@ +import {Injectable} from '@angular/core'; +import { + Auth, + GithubAuthProvider, + GoogleAuthProvider, + linkWithPopup, + signInWithPopup, + unlink, + User, + UserInfo, +} from '@angular/fire/auth'; +import {Subject} from 'rxjs'; + +const DEFAULT_AVATAR_URL = 'https://lh3.googleusercontent.com/a/default-user=s64-c'; + +@Injectable() +export class AccountService { + loggedInStateChange = new Subject(); + + /** Whether a user is logged in currently. */ + isLoggedIn!: boolean; + + /** The Github account information for the current user, if a user is logged in and has linked their Github account. */ + githubInfo!: UserInfo | null; + + /** The Google account information for the current user, if a user is logged in. */ + googleInfo!: UserInfo | null; + + avatarUrl = DEFAULT_AVATAR_URL; + displayName: string | undefined; + + constructor(private auth: Auth) { + this.auth.onAuthStateChanged((user) => { + this.avatarUrl = user?.photoURL || DEFAULT_AVATAR_URL; + this.githubInfo = getInfoForProvider(user, 'github.com'); + this.googleInfo = getInfoForProvider(user, 'google.com'); + this.isLoggedIn = user !== null; + this.loggedInStateChange.next(); + }); + } + + /** Sign the user out of the application. */ + async signOut() { + await this.auth.signOut(); + } + + /** Sign the user into the application using a Google account.. */ + async signInWithGoogle() { + return !!(await signInWithPopup(this.auth, new GoogleAuthProvider())); + } + + /** If a user is currently logged in, link a Github account to the user's account. */ + async linkWithGithub() { + if (this.auth.currentUser === null) { + throw Error(); + } + return !!(await linkWithPopup(this.auth.currentUser, new GithubAuthProvider())); + } + + /** + * If a user is currently logged in and has a github account linked, unlink + * the Github account from the user's account. + */ + async unlinkFromGithub() { + if ( + this.auth.currentUser === null || + getInfoForProvider(this.auth.currentUser, 'github.com') === null + ) { + throw Error(); + } + return await unlink(this.auth.currentUser!, GithubAuthProvider.PROVIDER_ID); + } +} + +/** Get the provider specific UserInfo object for a given User. */ +function getInfoForProvider(user: User | null, provider: 'github.com' | 'google.com') { + if (user === null) { + return null; + } + return user.providerData.find((data) => data.providerId === provider) || null; +} diff --git a/tools/defaults.bzl b/tools/defaults.bzl index b5e04e856..f7998109c 100644 --- a/tools/defaults.bzl +++ b/tools/defaults.bzl @@ -3,6 +3,7 @@ load("@npm//@bazel/typescript:index.bzl", _ts_project = "ts_project") load("//bazel/esbuild:index.bzl", _esbuild = "esbuild", _esbuild_config = "esbuild_config") load("@build_bazel_rules_nodejs//:index.bzl", "generated_file_test", _pkg_npm = "pkg_npm") load("//tools/jasmine:jasmine.bzl", _jasmine_node_test = "jasmine_node_test") +load("@npm//@angular/bazel:index.bzl", _ng_module = "ng_module") esbuild_config = _esbuild_config jasmine_node_test = _jasmine_node_test @@ -101,3 +102,10 @@ def pkg_npm(build_package_json_from_template = False, deps = [], **kwargs): deps = deps, **kwargs ) + +def ng_module(name, **kwargs): + _ng_module( + name = name, + tsconfig = kwargs.pop("tsconfig", "//:tsconfig"), + **kwargs + ) From 2445e6ffc073abc446bd1417f1644364863732cc Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Wed, 6 Apr 2022 16:10:13 -0700 Subject: [PATCH 2/4] feat(apps): create login page Create the login page for the prs application. --- apps/prs/src/app/app-routing.module.ts | 4 +++- .../prs/src/app/login/login-routing.module.ts | 11 +++++++++ apps/prs/src/app/login/login.component.html | 11 +++++++++ apps/prs/src/app/login/login.component.scss | 6 +++++ .../prs/src/app/login/login.component.spec.ts | 24 +++++++++++++++++++ apps/prs/src/app/login/login.component.ts | 20 ++++++++++++++++ apps/prs/src/app/login/login.module.ts | 15 ++++++++++++ 7 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 apps/prs/src/app/login/login-routing.module.ts create mode 100644 apps/prs/src/app/login/login.component.html create mode 100644 apps/prs/src/app/login/login.component.scss create mode 100644 apps/prs/src/app/login/login.component.spec.ts create mode 100644 apps/prs/src/app/login/login.component.ts create mode 100644 apps/prs/src/app/login/login.module.ts diff --git a/apps/prs/src/app/app-routing.module.ts b/apps/prs/src/app/app-routing.module.ts index b504a39f6..2a039b319 100644 --- a/apps/prs/src/app/app-routing.module.ts +++ b/apps/prs/src/app/app-routing.module.ts @@ -1,7 +1,9 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; -const routes: Routes = []; +const routes: Routes = [ + {path: 'login', loadChildren: () => import('./login/login.module').then((m) => m.LoginModule)}, +]; @NgModule({ imports: [RouterModule.forRoot(routes)], diff --git a/apps/prs/src/app/login/login-routing.module.ts b/apps/prs/src/app/login/login-routing.module.ts new file mode 100644 index 000000000..d91714b19 --- /dev/null +++ b/apps/prs/src/app/login/login-routing.module.ts @@ -0,0 +1,11 @@ +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; +import {LoginComponent} from './login.component'; + +const routes: Routes = [{path: '', component: LoginComponent}]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class LoginRoutingModule {} diff --git a/apps/prs/src/app/login/login.component.html b/apps/prs/src/app/login/login.component.html new file mode 100644 index 000000000..16f61eb86 --- /dev/null +++ b/apps/prs/src/app/login/login.component.html @@ -0,0 +1,11 @@ + + + Angular PR Management + + + + + + + + \ No newline at end of file diff --git a/apps/prs/src/app/login/login.component.scss b/apps/prs/src/app/login/login.component.scss new file mode 100644 index 000000000..dc5fb2508 --- /dev/null +++ b/apps/prs/src/app/login/login.component.scss @@ -0,0 +1,6 @@ +:host { + .mat-card { + width: 500px; + margin: 150px auto 0; + } +} \ No newline at end of file diff --git a/apps/prs/src/app/login/login.component.spec.ts b/apps/prs/src/app/login/login.component.spec.ts new file mode 100644 index 000000000..ca2ac7947 --- /dev/null +++ b/apps/prs/src/app/login/login.component.spec.ts @@ -0,0 +1,24 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {LoginComponent} from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [LoginComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/prs/src/app/login/login.component.ts b/apps/prs/src/app/login/login.component.ts new file mode 100644 index 000000000..f4947d52e --- /dev/null +++ b/apps/prs/src/app/login/login.component.ts @@ -0,0 +1,20 @@ +import {Component} from '@angular/core'; +import {Router} from '@angular/router'; +import {AccountService} from '../../../../shared/account/account.service'; + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'], +}) +export class LoginComponent { + constructor(private account: AccountService, private router: Router) {} + + signIn() { + this.account.signInWithGoogle().then((signedIn) => { + if (signedIn) { + this.router.navigateByUrl(''); + } + }); + } +} diff --git a/apps/prs/src/app/login/login.module.ts b/apps/prs/src/app/login/login.module.ts new file mode 100644 index 000000000..8a73660a7 --- /dev/null +++ b/apps/prs/src/app/login/login.module.ts @@ -0,0 +1,15 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; + +import {LoginRoutingModule} from './login-routing.module'; +import {LoginComponent} from './login.component'; + +import {MatCardModule} from '@angular/material/card'; +import {MatButtonModule} from '@angular/material/button'; +import {AccountModule} from '../../../../shared/account/account.module'; + +@NgModule({ + declarations: [LoginComponent], + imports: [CommonModule, LoginRoutingModule, MatCardModule, MatButtonModule, AccountModule], +}) +export class LoginModule {} From 9eef9899ca1c9ff64609eefbdf8c67a02616fbb4 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Thu, 7 Apr 2022 13:30:04 -0700 Subject: [PATCH 3/4] fixup! feat(apps): create account menu component --- WORKSPACE | 1 - apps/shared/account/BUILD.bazel | 2 +- apps/shared/account/account.component.html | 10 +- apps/shared/account/account.component.scss | 151 ++++++++++----------- apps/shared/account/account.component.ts | 2 +- apps/shared/account/account.module.ts | 10 +- apps/shared/account/account.service.ts | 14 +- 7 files changed, 98 insertions(+), 92 deletions(-) diff --git a/WORKSPACE b/WORKSPACE index f6f8ce7d8..df81cc7c4 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -98,7 +98,6 @@ register_toolchains( "//tools/git-toolchain:git_windows_toolchain", ) - http_file( name = "bazel_test_status_proto", sha256 = "61ce1dc62fdcfd6d68624a403e0f04c5fd5136d933b681467aad1ad2d00dbb03", diff --git a/apps/shared/account/BUILD.bazel b/apps/shared/account/BUILD.bazel index 297cfa8fa..507bbaf79 100644 --- a/apps/shared/account/BUILD.bazel +++ b/apps/shared/account/BUILD.bazel @@ -14,7 +14,7 @@ ng_module( ], ), assets = [ - "account.component.css", + ":account.component.css", "account.component.html", ], deps = [ diff --git a/apps/shared/account/account.component.html b/apps/shared/account/account.component.html index 649d11677..318f17175 100644 --- a/apps/shared/account/account.component.html +++ b/apps/shared/account/account.component.html @@ -1,4 +1,4 @@ - +