From 363f683fe5535e32111d2ef0901c14c40003d5a1 Mon Sep 17 00:00:00 2001 From: Zhen Guan Date: Wed, 2 Mar 2022 17:25:14 +0800 Subject: [PATCH] Implement Texera User Avatars Featuer (#1373) This PR creates an Angular component to show the avatar of a Texera user. The library/component **ngx-avatar** does not support retrieving Google users' avatars by Google ID (https://github.com/HaithemMosbahi/ngx-avatar/issues/51), so I implemented the logic to retrieve Google users' avatars myself in the component. In order to send requests to Google People API, a public key is needed to provide. Creating a Google API Public Key is easy, please follow this instruction. https://developers.google.com/maps/documentation/maps-static/get-api-key Then fill the public key in the **environment.default.ts** `google: { clientID: "", publicKey: "", },` Demo: If the user is a normal Texera user, the avatar will be the default avatar with a random background color. ![normal user](https://user-images.githubusercontent.com/52941906/142727712-691254e9-d4ae-48f9-b70b-8a46bde91e54.gif) If the user is a Google user, the avatar will be its Google profile picture. ![google user](https://user-images.githubusercontent.com/52941906/142727715-3fc6e839-e33c-4405-9813-389cb573a152.gif) Co-authored-by: Yicong Huang <17627829+Yicong-Huang@users.noreply.github.com> --- .../edu/uci/ics/texera/web/auth/JwtAuth.scala | 1 + core/new-gui/src/app/app.module.ts | 2 + .../app/common/service/user/auth.service.ts | 5 +- .../common/service/user/stub-user.service.ts | 1 + core/new-gui/src/app/common/type/user.ts | 1 + .../user-icon/user-icon.component.html | 13 +++- .../user-icon/user-icon.component.scss | 12 ++++ .../user-avatar/user-avatar.component.html | 10 +++ .../user-avatar/user-avatar.component.scss | 16 +++++ .../user-avatar/user-avatar.component.spec.ts | 28 ++++++++ .../user-avatar/user-avatar.component.ts | 72 +++++++++++++++++++ .../app/dashboard/type/google-api-response.ts | 24 +++++++ .../src/environments/environment.default.ts | 1 + 13 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.html create mode 100644 core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.scss create mode 100644 core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.spec.ts create mode 100644 core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.ts create mode 100644 core/new-gui/src/app/dashboard/type/google-api-response.ts diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/JwtAuth.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/JwtAuth.scala index 0fb48b02868..fc92bc5d2c8 100644 --- a/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/JwtAuth.scala +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/JwtAuth.scala @@ -40,6 +40,7 @@ object JwtAuth { val claims = new JwtClaims claims.setSubject(user.getName) claims.setClaim("userId", user.getUid) + claims.setClaim("googleId", user.getGoogleId) claims.setExpirationTimeMinutesInTheFuture(dayToMin(expireInDays)) claims } diff --git a/core/new-gui/src/app/app.module.ts b/core/new-gui/src/app/app.module.ts index 3d459e4f7c8..81e753caadd 100644 --- a/core/new-gui/src/app/app.module.ts +++ b/core/new-gui/src/app/app.module.ts @@ -67,6 +67,7 @@ import { NgbdModalFileAddComponent } from "./dashboard/component/feature-contain import { UserFileSectionComponent } from "./dashboard/component/feature-container/user-file-section/user-file-section.component"; import { TopBarComponent } from "./dashboard/component/top-bar/top-bar.component"; import { UserIconComponent } from "./dashboard/component/top-bar/user-icon/user-icon.component"; +import { UserAvatarComponent } from "./dashboard/component/user-avatar/user-avatar.component"; import { NgbdModalUserLoginComponent } from "./dashboard/component/top-bar/user-icon/user-login/ngbdmodal-user-login.component"; import { CodeEditorDialogComponent } from "./workspace/component/code-editor-dialog/code-editor-dialog.component"; import { CodeareaCustomTemplateComponent } from "./workspace/component/codearea-custom-template/codearea-custom-template.component"; @@ -133,6 +134,7 @@ registerLocaleData(en); DashboardComponent, TopBarComponent, UserIconComponent, + UserAvatarComponent, FeatureBarComponent, FeatureContainerComponent, SavedWorkflowSectionComponent, diff --git a/core/new-gui/src/app/common/service/user/auth.service.ts b/core/new-gui/src/app/common/service/user/auth.service.ts index 62a5817ef51..4343c37a922 100644 --- a/core/new-gui/src/app/common/service/user/auth.service.ts +++ b/core/new-gui/src/app/common/service/user/auth.service.ts @@ -102,7 +102,10 @@ export class AuthService { if (token !== null && !this.jwtHelperService.isTokenExpired(token)) { this.registerAutoLogout(); this.registerAutoRefreshToken(); - return of({ name: this.jwtHelperService.decodeToken(token).sub }); + return of({ + name: this.jwtHelperService.decodeToken(token).sub, + googleId: this.jwtHelperService.decodeToken(token).googleId, + }); } else { // access token is expired, logout instantly return this.logout(); diff --git a/core/new-gui/src/app/common/service/user/stub-user.service.ts b/core/new-gui/src/app/common/service/user/stub-user.service.ts index 255383d1a53..d2af59fa62c 100644 --- a/core/new-gui/src/app/common/service/user/stub-user.service.ts +++ b/core/new-gui/src/app/common/service/user/stub-user.service.ts @@ -10,6 +10,7 @@ export const MOCK_USER_NAME = "testUser"; export const MOCK_USER = { name: MOCK_USER_NAME, uid: MOCK_USER_ID, + googleId: undefined, }; /** diff --git a/core/new-gui/src/app/common/type/user.ts b/core/new-gui/src/app/common/type/user.ts index fd4e336274a..abaf31e8aa7 100644 --- a/core/new-gui/src/app/common/type/user.ts +++ b/core/new-gui/src/app/common/type/user.ts @@ -7,4 +7,5 @@ export interface User extends Readonly<{ name: string; uid: number; + googleId?: string; }> {} diff --git a/core/new-gui/src/app/dashboard/component/top-bar/user-icon/user-icon.component.html b/core/new-gui/src/app/dashboard/component/top-bar/user-icon/user-icon.component.html index e67624457b1..d851c23f26c 100644 --- a/core/new-gui/src/app/dashboard/component/top-bar/user-icon/user-icon.component.html +++ b/core/new-gui/src/app/dashboard/component/top-bar/user-icon/user-icon.component.html @@ -1,5 +1,16 @@
- + + + + +
diff --git a/core/new-gui/src/app/dashboard/component/top-bar/user-icon/user-icon.component.scss b/core/new-gui/src/app/dashboard/component/top-bar/user-icon/user-icon.component.scss index 423db52c1d0..13c8836eeeb 100644 --- a/core/new-gui/src/app/dashboard/component/top-bar/user-icon/user-icon.component.scss +++ b/core/new-gui/src/app/dashboard/component/top-bar/user-icon/user-icon.component.scss @@ -5,3 +5,15 @@ .user-dropdown { width: 100%; } + +.user-avatar { + margin-right: 40px; +} + +.caret-off::before { + display: none; +} + +.caret-off::after { + display: none; +} diff --git a/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.html b/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.html new file mode 100644 index 00000000000..e2a0f598cbf --- /dev/null +++ b/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.html @@ -0,0 +1,10 @@ +
+ + +
diff --git a/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.scss b/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.scss new file mode 100644 index 00000000000..049dfb3e2d7 --- /dev/null +++ b/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.scss @@ -0,0 +1,16 @@ +.texera-user-avatar { + object-fit: cover; + border-radius: 50%; + width: 38px; + height: 38px; + vertical-align: top; + font-size: smaller; + font-family: roboto, arial; + color: white; + line-height: 38px; + text-align: center; +} + +.texera-user-avatar:hover { + box-shadow: #ccc 0px 0px 10px; +} diff --git a/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.spec.ts b/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.spec.ts new file mode 100644 index 00000000000..7d3b2e5a397 --- /dev/null +++ b/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.spec.ts @@ -0,0 +1,28 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { HttpClientModule } from "@angular/common/http"; +import { UserAvatarComponent } from "./user-avatar.component"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; + +describe("UserIconComponent", () => { + let component: UserAvatarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [UserAvatarComponent], + imports: [HttpClientModule, HttpClientTestingModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UserAvatarComponent); + component = fixture.componentInstance; + component.googleId = undefined; + component.userName = "fake Texera user"; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.ts b/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.ts new file mode 100644 index 00000000000..18e5c0025f0 --- /dev/null +++ b/core/new-gui/src/app/dashboard/component/user-avatar/user-avatar.component.ts @@ -0,0 +1,72 @@ +import { GooglePeopleApiResponse } from "../../type/google-api-response"; +import { Component, OnInit, Input } from "@angular/core"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { HttpClient } from "@angular/common/http"; +import { environment } from "../../../../environments/environment"; + +@UntilDestroy() +@Component({ + selector: "texera-user-avatar", + templateUrl: "./user-avatar.component.html", + styleUrls: ["./user-avatar.component.scss"], +}) + +/** + * UserAvatarComponent is used to show the avatar of a user + * The avatar of a Google user will be its Google profile picture + * The avatar of a normal user will be a default one with the initial + * + * Use Google People API to retrieve google user's profile picture + * Check https://developers.google.com/people/api/rest/v1/people/get for more details of the api usage + * + * @author Zhen Guan + */ +export class UserAvatarComponent implements OnInit { + public googleUserAvatarSrc: string = ""; + private publicKey = environment.google.publicKey; + + constructor(private http: HttpClient) {} + + @Input() googleId?: string; + @Input() userName?: string; + + ngOnInit(): void { + if (!this.googleId && !this.userName) { + throw new Error("google Id or user name should be provided"); + } else if (this.googleId) { + this.userName = ""; + // get the avatar of the google user + const googlePeopleAPIUrl = `https://people.googleapis.com/v1/people/${this.googleId}?personFields=names%2Cphotos&key=${this.publicKey}`; + this.http + .get(googlePeopleAPIUrl) + .pipe(untilDestroyed(this)) + .subscribe(res => { + this.googleUserAvatarSrc = res.photos[0].url; + }); + } else { + const r = Math.floor(Math.random() * 255); + const g = Math.floor(Math.random() * 255); + const b = Math.floor(Math.random() * 255); + const avatar = document.getElementById("texera-user-avatar"); + if (avatar) { + avatar.style.backgroundColor = "rgba(" + r + "," + g + "," + b + ",0.8)"; + } + } + } + + /** + * abbreviates the name under 5 chars + * @param userName + */ + public abbreviate(userName: string): string { + if (userName.length <= 5) { + return userName; + } else { + return this.getUserInitial(userName).slice(0, 5); + } + } + + public getUserInitial(userName: string): string { + return userName + "he"; + } +} diff --git a/core/new-gui/src/app/dashboard/type/google-api-response.ts b/core/new-gui/src/app/dashboard/type/google-api-response.ts new file mode 100644 index 00000000000..257ba716042 --- /dev/null +++ b/core/new-gui/src/app/dashboard/type/google-api-response.ts @@ -0,0 +1,24 @@ +export interface Source + extends Readonly<{ + type: string; + id: string; + }> {} + +export interface Metadata + extends Readonly<{ + primary: boolean; + source: Source; + }> {} + +export interface Photo + extends Readonly<{ + metadata: Metadata; + url: string; + }> {} + +export interface GooglePeopleApiResponse + extends Readonly<{ + resourceName: string; + etag: string; + photos: Photo[]; + }> {} diff --git a/core/new-gui/src/environments/environment.default.ts b/core/new-gui/src/environments/environment.default.ts index 8bf15911f8e..ba03325f472 100644 --- a/core/new-gui/src/environments/environment.default.ts +++ b/core/new-gui/src/environments/environment.default.ts @@ -77,6 +77,7 @@ export const defaultEnvironment = { */ google: { clientID: "", + publicKey: "", }, /**