From 8e2a374b089324909253f2e771709ae19f02b45f Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Mon, 31 Mar 2025 16:33:29 +0300 Subject: [PATCH 01/11] feat(pat-api-integration): initial scopes fetching and store --- src/app/app.config.ts | 7 +++- src/app/app.module.ts | 3 +- src/app/core/store/settings/index.ts | 1 + src/app/core/store/settings/tokens/index.ts | 3 ++ .../store/settings/tokens/tokens.actions.ts | 3 ++ .../store/settings/tokens/tokens.models.ts | 21 ++++++++++++ .../store/settings/tokens/tokens.state.ts | 32 +++++++++++++++++++ .../token-add-edit-form.component.html | 12 +++---- .../token-add-edit-form.component.ts | 8 +++-- .../settings/tokens/tokens.component.ts | 12 ++++--- .../settings/tokens/tokens.service.ts | 16 ++++++++++ .../styles/overrides/confirmation-dialog.scss | 1 + 12 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 src/app/core/store/settings/index.ts create mode 100644 src/app/core/store/settings/tokens/index.ts create mode 100644 src/app/core/store/settings/tokens/tokens.actions.ts create mode 100644 src/app/core/store/settings/tokens/tokens.models.ts create mode 100644 src/app/core/store/settings/tokens/tokens.state.ts create mode 100644 src/app/features/settings/tokens/tokens.service.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 10c3872ae..91eb65537 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -8,12 +8,17 @@ import Aura from '@primeng/themes/aura'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideHttpClient } from '@angular/common/http'; import { ConfirmationService } from 'primeng/api'; +import { AuthState } from '@core/store/auth'; +import { TokensState } from '@core/store/settings'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), - provideStore([], withNgxsReduxDevtoolsPlugin({ disabled: false })), + provideStore( + [AuthState, TokensState], + withNgxsReduxDevtoolsPlugin({ disabled: false }), + ), providePrimeNG({ theme: { preset: Aura, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 87ec29d5d..552e071a3 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,8 +1,9 @@ import { NgModule } from '@angular/core'; import { NgxsModule } from '@ngxs/store'; import { AuthState } from '@core/store/auth'; +import { TokensState } from '@core/store/settings'; @NgModule({ - imports: [NgxsModule.forRoot([AuthState])], + imports: [NgxsModule.forRoot([AuthState, TokensState])], }) export class AppModule {} diff --git a/src/app/core/store/settings/index.ts b/src/app/core/store/settings/index.ts new file mode 100644 index 000000000..e56053c16 --- /dev/null +++ b/src/app/core/store/settings/index.ts @@ -0,0 +1 @@ +export * from './tokens'; diff --git a/src/app/core/store/settings/tokens/index.ts b/src/app/core/store/settings/tokens/index.ts new file mode 100644 index 000000000..de4f7bfbc --- /dev/null +++ b/src/app/core/store/settings/tokens/index.ts @@ -0,0 +1,3 @@ +export * from './tokens.actions'; +export * from './tokens.models'; +export * from './tokens.state'; diff --git a/src/app/core/store/settings/tokens/tokens.actions.ts b/src/app/core/store/settings/tokens/tokens.actions.ts new file mode 100644 index 000000000..cb41315f7 --- /dev/null +++ b/src/app/core/store/settings/tokens/tokens.actions.ts @@ -0,0 +1,3 @@ +export class GetScopes { + static readonly type = '[Tokens] Get Scopes'; +} diff --git a/src/app/core/store/settings/tokens/tokens.models.ts b/src/app/core/store/settings/tokens/tokens.models.ts new file mode 100644 index 000000000..0fb1f7794 --- /dev/null +++ b/src/app/core/store/settings/tokens/tokens.models.ts @@ -0,0 +1,21 @@ +export interface Scope { + id: string; + type: string; + attributes: { + description: string; + }; + links: { + self: string; + }; +} + +export interface PersonalAccessToken { + id: string; + tokenName: string; + scopes: string[]; +} + +export interface TokensStateModel { + scopes: Scope[]; + tokens: PersonalAccessToken[]; +} diff --git a/src/app/core/store/settings/tokens/tokens.state.ts b/src/app/core/store/settings/tokens/tokens.state.ts new file mode 100644 index 000000000..c1fe0ef03 --- /dev/null +++ b/src/app/core/store/settings/tokens/tokens.state.ts @@ -0,0 +1,32 @@ +import { inject, Injectable } from '@angular/core'; +import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { Scope, TokensStateModel } from './tokens.models'; +import { TokensService } from '@osf/features/settings/tokens/tokens.service'; +import { GetScopes } from './tokens.actions'; +import { tap } from 'rxjs'; + +@State({ + name: 'tokens', + defaults: { + scopes: [], + tokens: [], + }, +}) +@Injectable() +export class TokensState { + tokensService = inject(TokensService); + + @Selector() + static getScopes(state: TokensStateModel): Scope[] { + return state.scopes; + } + + @Action(GetScopes) + getScopes(ctx: StateContext) { + return this.tokensService.getScopes().pipe( + tap((scopes) => { + ctx.patchState({ scopes }); + }), + ); + } +} diff --git a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.html b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.html index 6d2dcb023..9104fa4d0 100644 --- a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.html +++ b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.html @@ -23,18 +23,18 @@

Scopes

- @for (scope of availableScopes; track scope.name) { + @for (scope of tokenScopes(); track scope.id) {
-
} diff --git a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts index 30846a8d1..32dd1f4df 100644 --- a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts +++ b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts @@ -16,7 +16,6 @@ import { Validators, } from '@angular/forms'; import { - AVAILABLE_SCOPES, PersonalAccessToken, TokenForm, TokenFormControls, @@ -24,6 +23,8 @@ import { import { CommonModule } from '@angular/common'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { toSignal } from '@angular/core/rxjs-interop'; +import { Store } from '@ngxs/store'; +import { TokensState } from '@core/store/settings'; @Component({ selector: 'osf-token-add-edit-form', @@ -34,12 +35,15 @@ import { toSignal } from '@angular/core/rxjs-interop'; }) export class TokenAddEditFormComponent implements OnInit { #isXSmall$ = inject(IS_XSMALL); + #store = inject(Store); isEditMode = input(false); initialValues = input(null); protected readonly dialogRef = inject(DynamicDialogRef); protected readonly TokenFormControls = TokenFormControls; - protected readonly availableScopes = AVAILABLE_SCOPES; protected readonly isXSmall = toSignal(this.#isXSmall$); + protected readonly tokenScopes = this.#store.selectSignal( + TokensState.getScopes, + ); readonly tokenForm: TokenForm = new FormGroup({ [TokenFormControls.TokenName]: new FormControl('', { diff --git a/src/app/features/settings/tokens/tokens.component.ts b/src/app/features/settings/tokens/tokens.component.ts index 679d94012..631f181a4 100644 --- a/src/app/features/settings/tokens/tokens.component.ts +++ b/src/app/features/settings/tokens/tokens.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject, - signal, + OnInit, } from '@angular/core'; import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; import { DialogService } from 'primeng/dynamicdialog'; @@ -10,6 +10,8 @@ import { IS_MEDIUM, IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { toSignal } from '@angular/core/rxjs-interop'; import { TokenAddEditFormComponent } from '@osf/features/settings/tokens/token-add-edit-form/token-add-edit-form.component'; import { RouterOutlet } from '@angular/router'; +import { Store } from '@ngxs/store'; +import { GetScopes } from '@core/store/settings'; @Component({ selector: 'osf-tokens', @@ -19,11 +21,11 @@ import { RouterOutlet } from '@angular/router'; providers: [DialogService], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TokensComponent { +export class TokensComponent implements OnInit { #dialogService = inject(DialogService); #isXSmall$ = inject(IS_XSMALL); #isMedium$ = inject(IS_MEDIUM); - tokenValue = signal(''); + #store = inject(Store); protected readonly isXSmall = toSignal(this.#isXSmall$); protected readonly isMedium = toSignal(this.#isMedium$); @@ -45,7 +47,7 @@ export class TokensComponent { }); } - onTokenCreated(): void { - this.tokenValue.set('asdkDh3bhHfEqhndsdk#fo90jNbvt8dd5%1jFkc42kIop'); + ngOnInit() { + this.#store.dispatch(GetScopes); } } diff --git a/src/app/features/settings/tokens/tokens.service.ts b/src/app/features/settings/tokens/tokens.service.ts new file mode 100644 index 000000000..0fa08b2f2 --- /dev/null +++ b/src/app/features/settings/tokens/tokens.service.ts @@ -0,0 +1,16 @@ +import { inject, Injectable } from '@angular/core'; +import { JsonApiService } from '@core/services/json-api/json-api.service'; +import { Observable } from 'rxjs'; +import { Scope } from '@core/store/settings'; + +@Injectable({ + providedIn: 'root', +}) +export class TokensService { + jsonApiService = inject(JsonApiService); + baseUrl = 'https://api.staging4.osf.io/v2/'; + + getScopes(): Observable { + return this.jsonApiService.getArray(this.baseUrl + 'scopes'); + } +} diff --git a/src/assets/styles/overrides/confirmation-dialog.scss b/src/assets/styles/overrides/confirmation-dialog.scss index e59ae5a8b..bf3cfec06 100644 --- a/src/assets/styles/overrides/confirmation-dialog.scss +++ b/src/assets/styles/overrides/confirmation-dialog.scss @@ -4,6 +4,7 @@ min-height: 212px; width: 450px; margin: 1rem; + z-index: 2000; .p-dialog-header { padding: 24px; From e60f7197ff95abb5d94b3a27818175bda0833519 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Tue, 1 Apr 2025 12:09:41 +0300 Subject: [PATCH 02/11] feat(pat-api-integration): added methods for token crud operations --- .../components/topnav/topnav.component.scss | 2 +- .../core/services/json-api/json-api.entity.ts | 13 ++- .../services/json-api/json-api.service.ts | 68 +++++++++++++--- .../store/settings/tokens/tokens.actions.ts | 18 +++++ .../store/settings/tokens/tokens.models.ts | 20 +---- .../store/settings/tokens/tokens.state.ts | 53 +++++++++++- .../tokens/entities/scope.interface.ts | 10 +++ .../tokens/entities/token-form.entities.ts | 11 +++ .../settings/tokens/entities/tokens.models.ts | 80 +++++++++++++++++++ .../settings/tokens/mappers/token.mapper.ts | 32 ++++++++ .../token-add-edit-form.component.ts | 24 ++++-- .../token-details.component.html | 2 +- .../token-details/token-details.component.ts | 12 ++- .../tokens-list/tokens-list.component.html | 2 +- .../tokens-list/tokens-list.component.ts | 32 ++++++-- .../settings/tokens/tokens.enities.ts | 38 --------- .../settings/tokens/tokens.service.ts | 34 +++++++- .../styles/overrides/confirmation-dialog.scss | 1 - 18 files changed, 358 insertions(+), 94 deletions(-) create mode 100644 src/app/features/settings/tokens/entities/scope.interface.ts create mode 100644 src/app/features/settings/tokens/entities/token-form.entities.ts create mode 100644 src/app/features/settings/tokens/entities/tokens.models.ts create mode 100644 src/app/features/settings/tokens/mappers/token.mapper.ts delete mode 100644 src/app/features/settings/tokens/tokens.enities.ts diff --git a/src/app/core/components/topnav/topnav.component.scss b/src/app/core/components/topnav/topnav.component.scss index d2a0a06f1..42c8b45da 100644 --- a/src/app/core/components/topnav/topnav.component.scss +++ b/src/app/core/components/topnav/topnav.component.scss @@ -2,7 +2,7 @@ @use "assets/styles/mixins" as mix; :host { - z-index: 2000; + z-index: 1300; .nav-container { @include mix.flex-center-between; diff --git a/src/app/core/services/json-api/json-api.entity.ts b/src/app/core/services/json-api/json-api.entity.ts index ec4610998..31e660911 100644 --- a/src/app/core/services/json-api/json-api.entity.ts +++ b/src/app/core/services/json-api/json-api.entity.ts @@ -1,8 +1,13 @@ export interface JsonApiResponse { - data: ApiData | ApiData[]; + data: T; } -export interface ApiData { - id: string | number; - attributes: T; +export interface JsonApiArrayResponse { + data: T[]; +} + +export interface ApiData { + id: string; + attributes: Attributes; + embeds: Embeds; } diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts index 37769e667..100745321 100644 --- a/src/app/core/services/json-api/json-api.service.ts +++ b/src/app/core/services/json-api/json-api.service.ts @@ -1,8 +1,8 @@ import { inject, Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { map, Observable } from 'rxjs'; import { - ApiData, + JsonApiArrayResponse, JsonApiResponse, } from '@core/services/json-api/json-api.entity'; @@ -15,16 +15,64 @@ export class JsonApiService { get(url: string): Observable { return this.http .get>(url) - .pipe(map((response) => (response.data as ApiData).attributes)); + .pipe(map((response) => response.data)); } - getArray(url: string): Observable { + getArray(url: string, params?: Record): Observable { + let httpParams = new HttpParams(); + + if (params) { + for (const key in params) { + const value = params[key]; + + if (Array.isArray(value)) { + value.forEach((item) => { + httpParams = httpParams.append(`${key}[]`, item); // Handles arrays + }); + } else { + httpParams = httpParams.set(key, value as string); + } + } + } + + const headers = new HttpHeaders({ + Authorization: `Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt`, + }); + return this.http - .get>(url) - .pipe( - map((response) => - (response.data as ApiData[]).map((item) => item.attributes), - ), - ); + .get< + JsonApiArrayResponse + >(url, { params: httpParams, headers: headers }) + .pipe(map((response) => response.data)); + } + + post(url: string, body: unknown): Observable { + const headers = new HttpHeaders({ + 'Content-Type': 'application/json', + Authorization: `Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt`, + }); + + return this.http + .post>(url, body, { headers }) + .pipe(map((response) => response.data)); + } + + patch(url: string, body: unknown): Observable { + const headers = new HttpHeaders({ + 'Content-Type': 'application/json', + Authorization: `Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt`, + }); + + return this.http + .patch>(url, body, { headers }) + .pipe(map((response) => response.data)); + } + + delete(url: string): Observable { + const headers = new HttpHeaders({ + Authorization: `Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt`, + }); + + return this.http.delete(url, { headers }); } } diff --git a/src/app/core/store/settings/tokens/tokens.actions.ts b/src/app/core/store/settings/tokens/tokens.actions.ts index cb41315f7..940ec130f 100644 --- a/src/app/core/store/settings/tokens/tokens.actions.ts +++ b/src/app/core/store/settings/tokens/tokens.actions.ts @@ -1,3 +1,21 @@ export class GetScopes { static readonly type = '[Tokens] Get Scopes'; } + +export class GetTokens { + static readonly type = '[Tokens] Get Tokens'; +} + +export class UpdateToken { + static readonly type = '[Tokens] Update Token'; + constructor( + public tokenId: string, + public name: string, + public scopes: string[], + ) {} +} + +export class DeleteToken { + static readonly type = '[Tokens] Delete Token'; + constructor(public tokenId: string) {} +} diff --git a/src/app/core/store/settings/tokens/tokens.models.ts b/src/app/core/store/settings/tokens/tokens.models.ts index 0fb1f7794..c403f2a48 100644 --- a/src/app/core/store/settings/tokens/tokens.models.ts +++ b/src/app/core/store/settings/tokens/tokens.models.ts @@ -1,21 +1,7 @@ -export interface Scope { - id: string; - type: string; - attributes: { - description: string; - }; - links: { - self: string; - }; -} - -export interface PersonalAccessToken { - id: string; - tokenName: string; - scopes: string[]; -} +import { Scope } from '@osf/features/settings/tokens/entities/scope.interface'; +import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; export interface TokensStateModel { scopes: Scope[]; - tokens: PersonalAccessToken[]; + tokens: Token[]; } diff --git a/src/app/core/store/settings/tokens/tokens.state.ts b/src/app/core/store/settings/tokens/tokens.state.ts index c1fe0ef03..bb07ea5ab 100644 --- a/src/app/core/store/settings/tokens/tokens.state.ts +++ b/src/app/core/store/settings/tokens/tokens.state.ts @@ -1,9 +1,16 @@ import { inject, Injectable } from '@angular/core'; import { State, Action, StateContext, Selector } from '@ngxs/store'; -import { Scope, TokensStateModel } from './tokens.models'; +import { TokensStateModel } from './tokens.models'; import { TokensService } from '@osf/features/settings/tokens/tokens.service'; -import { GetScopes } from './tokens.actions'; +import { + GetScopes, + GetTokens, + UpdateToken, + DeleteToken, +} from './tokens.actions'; import { tap } from 'rxjs'; +import { Scope } from '@osf/features/settings/tokens/entities/scope.interface'; +import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; @State({ name: 'tokens', @@ -21,6 +28,11 @@ export class TokensState { return state.scopes; } + @Selector() + static getTokens(state: TokensStateModel): Token[] { + return state.tokens; + } + @Action(GetScopes) getScopes(ctx: StateContext) { return this.tokensService.getScopes().pipe( @@ -29,4 +41,41 @@ export class TokensState { }), ); } + + @Action(GetTokens) + getTokens(ctx: StateContext) { + return this.tokensService.getTokens().pipe( + tap((tokens) => { + ctx.patchState({ tokens }); + }), + ); + } + + @Action(UpdateToken) + updateToken(ctx: StateContext, action: UpdateToken) { + return this.tokensService + .updateToken(action.tokenId, action.name, action.scopes) + .pipe( + tap((updatedToken) => { + const state = ctx.getState(); + const updatedTokens = state.tokens.map((token) => + token.id === action.tokenId ? updatedToken : token, + ); + ctx.patchState({ tokens: updatedTokens }); + }), + ); + } + + @Action(DeleteToken) + deleteToken(ctx: StateContext, action: DeleteToken) { + return this.tokensService.deleteToken(action.tokenId).pipe( + tap(() => { + const state = ctx.getState(); + const updatedTokens = state.tokens.filter( + (token) => token.id !== action.tokenId, + ); + ctx.patchState({ tokens: updatedTokens }); + }), + ); + } } diff --git a/src/app/features/settings/tokens/entities/scope.interface.ts b/src/app/features/settings/tokens/entities/scope.interface.ts new file mode 100644 index 000000000..11abe44af --- /dev/null +++ b/src/app/features/settings/tokens/entities/scope.interface.ts @@ -0,0 +1,10 @@ +export interface Scope { + id: string; + type: string; + attributes: { + description: string; + }; + links: { + self: string; + }; +} diff --git a/src/app/features/settings/tokens/entities/token-form.entities.ts b/src/app/features/settings/tokens/entities/token-form.entities.ts new file mode 100644 index 000000000..9e9b60942 --- /dev/null +++ b/src/app/features/settings/tokens/entities/token-form.entities.ts @@ -0,0 +1,11 @@ +import { FormControl, FormGroup } from '@angular/forms'; + +export enum TokenFormControls { + TokenName = 'tokenName', + Scopes = 'scopes', +} + +export type TokenForm = FormGroup<{ + [TokenFormControls.TokenName]: FormControl; + [TokenFormControls.Scopes]: FormControl; +}>; diff --git a/src/app/features/settings/tokens/entities/tokens.models.ts b/src/app/features/settings/tokens/entities/tokens.models.ts new file mode 100644 index 000000000..ba291230e --- /dev/null +++ b/src/app/features/settings/tokens/entities/tokens.models.ts @@ -0,0 +1,80 @@ +import { Scope } from '@osf/features/settings/tokens/entities/scope.interface'; + +// API Request Model +export interface TokenCreateRequest { + data: { + attributes: { + name: string; + scopes: string; + }; + type: 'tokens'; + }; +} + +// API Response Model +export interface TokenCreateResponse { + data: { + id: string; + type: 'tokens'; + attributes: { + name: string; + token_id: string; + }; + relationships: { + scopes: { + links: { + related: { + href: string; + meta: Record; + }; + }; + }; + owner: { + links: { + related: { + href: string; + meta: Record; + }; + }; + data: { + id: string; + type: string; + }; + }; + }; + embeds: { + scopes: { + data: Scope[]; + meta: { + total: number; + per_page: number; + }; + links: { + self: string; + first: string | null; + last: string | null; + prev: string | null; + next: string | null; + }; + }; + }; + links: { + html: string; + self: string; + }; + }; + meta: { + version: string; + }; +} + +// Domain Models +export interface Token { + id: string; + name: string; + tokenId: string; + scopes: string[]; + ownerId: string; + htmlUrl: string; + apiUrl: string; +} diff --git a/src/app/features/settings/tokens/mappers/token.mapper.ts b/src/app/features/settings/tokens/mappers/token.mapper.ts new file mode 100644 index 000000000..67d2552b8 --- /dev/null +++ b/src/app/features/settings/tokens/mappers/token.mapper.ts @@ -0,0 +1,32 @@ +import { + Token, + TokenCreateRequest, + TokenCreateResponse, +} from '@osf/features/settings/tokens/entities/tokens.models'; + +export class TokenMapper { + static toRequest(name: string, scopes: string[]): TokenCreateRequest { + return { + data: { + attributes: { + name, + scopes: scopes.join(' '), + }, + type: 'tokens', + }, + }; + } + + static fromResponse(response: TokenCreateResponse): Token { + const { data } = response; + return { + id: data.id, + name: data.attributes.name, + tokenId: data.attributes.token_id, + scopes: data.embeds.scopes.data.map((scope) => scope.id), + ownerId: data.relationships.owner.data.id, + htmlUrl: data.links.html, + apiUrl: data.links.self, + }; + } +} diff --git a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts index 32dd1f4df..3fb7003e8 100644 --- a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts +++ b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts @@ -16,15 +16,16 @@ import { Validators, } from '@angular/forms'; import { - PersonalAccessToken, TokenForm, TokenFormControls, -} from '@osf/features/settings/tokens/tokens.enities'; +} from '@osf/features/settings/tokens/entities/token-form.entities'; import { CommonModule } from '@angular/common'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { toSignal } from '@angular/core/rxjs-interop'; import { Store } from '@ngxs/store'; import { TokensState } from '@core/store/settings'; +import { TokensService } from '@osf/features/settings/tokens/tokens.service'; +import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; @Component({ selector: 'osf-token-add-edit-form', @@ -36,8 +37,9 @@ import { TokensState } from '@core/store/settings'; export class TokenAddEditFormComponent implements OnInit { #isXSmall$ = inject(IS_XSMALL); #store = inject(Store); + #tokensService = inject(TokensService); isEditMode = input(false); - initialValues = input(null); + initialValues = input(null); protected readonly dialogRef = inject(DynamicDialogRef); protected readonly TokenFormControls = TokenFormControls; protected readonly isXSmall = toSignal(this.#isXSmall$); @@ -59,7 +61,7 @@ export class TokenAddEditFormComponent implements OnInit { ngOnInit(): void { if (this.initialValues()) { this.tokenForm.patchValue({ - [TokenFormControls.TokenName]: this.initialValues()?.tokenName, + [TokenFormControls.TokenName]: this.initialValues()?.name, [TokenFormControls.Scopes]: this.initialValues()?.scopes, }); } @@ -73,7 +75,17 @@ export class TokenAddEditFormComponent implements OnInit { return; } - //TODO integrate API - this.dialogRef.close(); + const { tokenName, scopes } = this.tokenForm.value; + if (!tokenName || !scopes) return; + + this.#tokensService.createToken(tokenName, scopes).subscribe({ + next: (token) => { + this.dialogRef.close(token); + }, + error: (error) => { + console.error('Failed to create token:', error); + // TODO: Show error message to user + }, + }); } } diff --git a/src/app/features/settings/tokens/token-details/token-details.component.html b/src/app/features/settings/tokens/token-details/token-details.component.html index 6a89fd706..06cc66be1 100644 --- a/src/app/features/settings/tokens/token-details/token-details.component.html +++ b/src/app/features/settings/tokens/token-details/token-details.component.html @@ -10,7 +10,7 @@
-

{{ token().tokenName }}

+

{{ token().name }}

({ + readonly token = signal({ id: '1', - tokenName: 'Token name example', + name: 'Token name example', + tokenId: 'token1', scopes: ['osf.full_read', 'osf.full_write'], + ownerId: 'user1', + htmlUrl: 'https://osf.io/settings/tokens/1', + apiUrl: 'https://api.osf.io/v2/tokens/1', }); protected readonly isXSmall = toSignal(this.#isXSmall$); @@ -39,7 +43,7 @@ export class TokenDetailsComponent { ...defaultConfirmationConfig, message: 'Are you sure you want to delete this token? This action cannot be reversed.', - header: `Delete Token ${this.token().tokenName}?`, + header: `Delete Token ${this.token().name}?`, acceptButtonProps: { ...defaultConfirmationConfig.acceptButtonProps, severity: 'danger', diff --git a/src/app/features/settings/tokens/tokens-list/tokens-list.component.html b/src/app/features/settings/tokens/tokens-list/tokens-list.component.html index 533af6aa5..ac2d05d48 100644 --- a/src/app/features/settings/tokens/tokens-list/tokens-list.component.html +++ b/src/app/features/settings/tokens/tokens-list/tokens-list.component.html @@ -12,7 +12,7 @@ [class.mobile]="isXSmall()" > -

{{ token.tokenName }}

+

{{ token.name }}

([ + tokens = signal([ { id: '1', - tokenName: 'Token name example 1', + name: 'Token name example 1', + tokenId: 'token1', scopes: ['osf.full_read', 'osf.full_write'], + ownerId: 'user1', + htmlUrl: 'https://osf.io/settings/tokens/1', + apiUrl: 'https://api.osf.io/v2/tokens/1', }, { id: '2', - tokenName: 'Token name example 2', + name: 'Token name example 2', + tokenId: 'token2', scopes: ['osf.full_read', 'osf.full_write'], + ownerId: 'user1', + htmlUrl: 'https://osf.io/settings/tokens/2', + apiUrl: 'https://api.osf.io/v2/tokens/2', }, { id: '3', - tokenName: 'Token name example 3', + name: 'Token name example 3', + tokenId: 'token3', scopes: ['osf.full_read', 'osf.full_write'], + ownerId: 'user1', + htmlUrl: 'https://osf.io/settings/tokens/3', + apiUrl: 'https://api.osf.io/v2/tokens/3', }, { id: '4', - tokenName: 'Token name example 4', + name: 'Token name example 4', + tokenId: 'token4', scopes: ['osf.full_read', 'osf.full_write'], + ownerId: 'user1', + htmlUrl: 'https://osf.io/settings/tokens/4', + apiUrl: 'https://api.osf.io/v2/tokens/4', }, ]); - deleteApp(token: PersonalAccessToken) { + deleteApp(token: Token) { this.#confirmationService.confirm({ ...defaultConfirmationConfig, message: 'Are you sure you want to delete this token? This action cannot be reversed.', - header: `Delete Token ${token.tokenName}?`, + header: `Delete Token ${token.name}?`, acceptButtonProps: { ...defaultConfirmationConfig.acceptButtonProps, severity: 'danger', diff --git a/src/app/features/settings/tokens/tokens.enities.ts b/src/app/features/settings/tokens/tokens.enities.ts deleted file mode 100644 index ffa3e674d..000000000 --- a/src/app/features/settings/tokens/tokens.enities.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { FormControl, FormGroup } from '@angular/forms'; - -export interface PersonalAccessToken { - id: string; - tokenName: string; - scopes: string[]; -} - -export enum TokenFormControls { - TokenName = 'tokenName', - Scopes = 'scopes', -} - -export type TokenForm = FormGroup<{ - [TokenFormControls.TokenName]: FormControl; - [TokenFormControls.Scopes]: FormControl; -}>; - -export const AVAILABLE_SCOPES = [ - { - name: 'osf.full_read', - description: - 'View all information associated with this account, including for private projects', - }, - { - name: 'osf.full_write', - description: - 'View and edit all information associated with this account, including for private projects', - }, - { - name: 'osf.users.profile_read', - description: 'Read your profile data', - }, - { - name: 'osf.users.email_read', - description: 'Read your primary email address', - }, -]; diff --git a/src/app/features/settings/tokens/tokens.service.ts b/src/app/features/settings/tokens/tokens.service.ts index 0fa08b2f2..ace03b07c 100644 --- a/src/app/features/settings/tokens/tokens.service.ts +++ b/src/app/features/settings/tokens/tokens.service.ts @@ -1,7 +1,10 @@ import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@core/services/json-api/json-api.service'; import { Observable } from 'rxjs'; -import { Scope } from '@core/store/settings'; +import { Token, TokenCreateResponse } from './entities/tokens.models'; +import { map } from 'rxjs/operators'; +import { TokenMapper } from '@osf/features/settings/tokens/mappers/token.mapper'; +import { Scope } from '@osf/features/settings/tokens/entities/scope.interface'; @Injectable({ providedIn: 'root', @@ -13,4 +16,33 @@ export class TokensService { getScopes(): Observable { return this.jsonApiService.getArray(this.baseUrl + 'scopes'); } + + getTokens(): Observable { + return this.jsonApiService.getArray(this.baseUrl + 'tokens'); + } + + createToken(name: string, scopes: string[]): Observable { + const request = TokenMapper.toRequest(name, scopes); + + return this.jsonApiService + .post(this.baseUrl + 'tokens', request) + .pipe(map((response) => TokenMapper.fromResponse(response))); + } + + updateToken( + tokenId: string, + name: string, + scopes: string[], + ): Observable { + const request = TokenMapper.toRequest(name, scopes); + console.log(request); + + return this.jsonApiService + .patch(this.baseUrl + `tokens/${tokenId}`, request) + .pipe(map((response) => TokenMapper.fromResponse(response))); + } + + deleteToken(tokenId: string): Observable { + return this.jsonApiService.delete(this.baseUrl + `tokens/${tokenId}`); + } } diff --git a/src/assets/styles/overrides/confirmation-dialog.scss b/src/assets/styles/overrides/confirmation-dialog.scss index bf3cfec06..e59ae5a8b 100644 --- a/src/assets/styles/overrides/confirmation-dialog.scss +++ b/src/assets/styles/overrides/confirmation-dialog.scss @@ -4,7 +4,6 @@ min-height: 212px; width: 450px; margin: 1rem; - z-index: 2000; .p-dialog-header { padding: 24px; From 7996464fe020e7a222f20c7b44fccb2b1a2f82d8 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Tue, 1 Apr 2025 15:46:16 +0300 Subject: [PATCH 03/11] feat(addons-api-integration): started integrating addons api --- src/app/app.config.ts | 3 +- .../store/settings/addons/addons.actions.ts | 3 + .../store/settings/addons/addons.models.ts | 5 ++ .../store/settings/addons/addons.state.ts | 33 +++++++++ src/app/core/store/settings/addons/index.ts | 3 + .../addon-card-list.component.html | 2 +- .../addon-card-list.component.ts | 4 +- .../addon-card/addon-card.component.html | 6 +- .../addons/addon-card/addon-card.component.ts | 4 +- .../settings/addons/addons.component.html | 13 ++-- .../settings/addons/addons.component.ts | 70 ++++++------------- .../settings/addons/addons.service.ts | 16 +++++ .../tokens/{mappers => }/token.mapper.ts | 0 .../settings/tokens/tokens.service.ts | 2 +- src/app/shared/entities/addons.entities.ts | 11 +++ .../entities/select-option.interface.ts | 6 ++ src/assets/styles/overrides/select.scss | 22 ++++++ src/assets/styles/styles.scss | 2 +- 18 files changed, 143 insertions(+), 62 deletions(-) create mode 100644 src/app/core/store/settings/addons/addons.actions.ts create mode 100644 src/app/core/store/settings/addons/addons.models.ts create mode 100644 src/app/core/store/settings/addons/addons.state.ts create mode 100644 src/app/core/store/settings/addons/index.ts create mode 100644 src/app/features/settings/addons/addons.service.ts rename src/app/features/settings/tokens/{mappers => }/token.mapper.ts (100%) create mode 100644 src/app/shared/entities/addons.entities.ts create mode 100644 src/app/shared/entities/select-option.interface.ts create mode 100644 src/assets/styles/overrides/select.scss diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 91eb65537..46f5ae47a 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -10,13 +10,14 @@ import { provideHttpClient } from '@angular/common/http'; import { ConfirmationService } from 'primeng/api'; import { AuthState } from '@core/store/auth'; import { TokensState } from '@core/store/settings'; +import { AddonsState } from '@core/store/settings/addons'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideStore( - [AuthState, TokensState], + [AuthState, TokensState, AddonsState], withNgxsReduxDevtoolsPlugin({ disabled: false }), ), providePrimeNG({ diff --git a/src/app/core/store/settings/addons/addons.actions.ts b/src/app/core/store/settings/addons/addons.actions.ts new file mode 100644 index 000000000..d4448cf0c --- /dev/null +++ b/src/app/core/store/settings/addons/addons.actions.ts @@ -0,0 +1,3 @@ +export class GetAddons { + static readonly type = '[Addons] Get Addons'; +} diff --git a/src/app/core/store/settings/addons/addons.models.ts b/src/app/core/store/settings/addons/addons.models.ts new file mode 100644 index 000000000..20fa74b78 --- /dev/null +++ b/src/app/core/store/settings/addons/addons.models.ts @@ -0,0 +1,5 @@ +import { Addon } from '@shared/entities/addons.entities'; + +export interface AddonsStateModel { + addons: Addon[]; +} diff --git a/src/app/core/store/settings/addons/addons.state.ts b/src/app/core/store/settings/addons/addons.state.ts new file mode 100644 index 000000000..9b781bc96 --- /dev/null +++ b/src/app/core/store/settings/addons/addons.state.ts @@ -0,0 +1,33 @@ +import { inject, Injectable } from '@angular/core'; +import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { AddonsService } from '@osf/features/settings/addons/addons.service'; +import { GetAddons } from './addons.actions'; +import { tap } from 'rxjs'; +import { AddonsStateModel } from './addons.models'; +import { Addon } from '@shared/entities/addons.entities'; + +@State({ + name: 'addons', + defaults: { + addons: [], + }, +}) +@Injectable() +export class AddonsState { + addonsService = inject(AddonsService); + + @Selector() + static getAddons(state: AddonsStateModel): Addon[] { + return state.addons; + } + + @Action(GetAddons) + getAddons(ctx: StateContext) { + return this.addonsService.getAddons().pipe( + tap((addons) => { + console.log(addons); + ctx.patchState({ addons }); + }), + ); + } +} diff --git a/src/app/core/store/settings/addons/index.ts b/src/app/core/store/settings/addons/index.ts new file mode 100644 index 000000000..3401dee59 --- /dev/null +++ b/src/app/core/store/settings/addons/index.ts @@ -0,0 +1,3 @@ +export * from './addons.actions'; +export * from './addons.models'; +export * from './addons.state'; diff --git a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html index feaa6a09d..428370d7b 100644 --- a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html +++ b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html @@ -1,6 +1,6 @@
@if (cards().length) { - @for (card of cards(); track card.title) { + @for (card of cards(); track card.attributes.name) {
diff --git a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts index fe43facbb..228cfcc55 100644 --- a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts +++ b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts @@ -1,6 +1,6 @@ import { Component, input } from '@angular/core'; import { AddonCardComponent } from '@osf/features/settings/addons/addon-card/addon-card.component'; -import { AddonCard } from '@shared/entities/addon-card.interface'; +import { Addon } from '@shared/entities/addons.entities'; @Component({ selector: 'osf-addon-card-list', @@ -9,6 +9,6 @@ import { AddonCard } from '@shared/entities/addon-card.interface'; styleUrl: './addon-card-list.component.scss', }) export class AddonCardListComponent { - cards = input([]); + cards = input([]); cardButtonLabel = input(''); } diff --git a/src/app/features/settings/addons/addon-card/addon-card.component.html b/src/app/features/settings/addons/addon-card/addon-card.component.html index a5779d782..4b540b92a 100644 --- a/src/app/features/settings/addons/addon-card/addon-card.component.html +++ b/src/app/features/settings/addons/addon-card/addon-card.component.html @@ -5,12 +5,14 @@
Addon card image
-

{{ card()?.title }}

+

{{ card()?.attributes?.name }}

(); + card = input(); cardButtonLabel = input(''); isMobile = toSignal(inject(IS_XSMALL)); diff --git a/src/app/features/settings/addons/addons.component.html b/src/app/features/settings/addons/addons.component.html index 9665e5762..1458a5489 100644 --- a/src/app/features/settings/addons/addons.component.html +++ b/src/app/features/settings/addons/addons.component.html @@ -10,13 +10,13 @@ @if (isMobile()) { - + > }

@@ -25,11 +25,14 @@

- + >
([]); - protected selectedTab = this.defaultTabValue; - protected readonly tabOptions: TabOption[] = [ + protected readonly cards = this.#store.selectSignal(AddonsState.getAddons); + protected readonly tabOptions: SelectOption[] = [ { label: 'All Add-ons', value: 0 }, { label: 'Connected Add-ons', value: 1 }, ]; - protected readonly filteredCards = computed((): AddonCard[] => { - const searchValue = this.searchValue(); + protected readonly categoryOptions: SelectOption[] = [ + { label: 'Additional Storage', value: 'storage' }, + { label: 'Citation Manager', value: 'citations' }, + ]; + protected selectedTab = this.defaultTabValue; + protected selectedCategory = signal('storage'); + protected readonly filteredCards = computed(() => { + const searchValue = this.searchValue().toLowerCase(); + const selectedCategory = this.selectedCategory(); - return untracked(() => - this.cards().filter((card) => - card.title.toLowerCase().includes(searchValue.toLowerCase()), - ), + return this.cards().filter( + (card) => + card.attributes.name.toLowerCase().includes(searchValue) && + (!selectedCategory || + card.attributes.categories.includes(selectedCategory)), ); }); ngOnInit(): void { - this.cards.set([ - { - title: 'Bitbucket', - img: 'bitbucket', - }, - { - title: 'Github', - img: 'github', - }, - { - title: 'Dropbox', - img: 'dropbox', - }, - { - title: 'Figshare', - img: 'figshare', - }, - { - title: 'OneDrive', - img: 'onedrive', - }, - { - title: 'S3', - img: 's3', - }, - { - title: 'OwnCloud', - img: 'owncloud', - }, - ]); + this.#store.dispatch(GetAddons); } } diff --git a/src/app/features/settings/addons/addons.service.ts b/src/app/features/settings/addons/addons.service.ts new file mode 100644 index 000000000..2bb2086fb --- /dev/null +++ b/src/app/features/settings/addons/addons.service.ts @@ -0,0 +1,16 @@ +import { inject, Injectable } from '@angular/core'; +import { JsonApiService } from '@core/services/json-api/json-api.service'; +import { Observable } from 'rxjs'; +import { Addon } from '@shared/entities/addons.entities'; + +@Injectable({ + providedIn: 'root', +}) +export class AddonsService { + jsonApiService = inject(JsonApiService); + baseUrl = 'https://api.staging4.osf.io/v2/'; + + getAddons(): Observable { + return this.jsonApiService.getArray(this.baseUrl + 'addons'); + } +} diff --git a/src/app/features/settings/tokens/mappers/token.mapper.ts b/src/app/features/settings/tokens/token.mapper.ts similarity index 100% rename from src/app/features/settings/tokens/mappers/token.mapper.ts rename to src/app/features/settings/tokens/token.mapper.ts diff --git a/src/app/features/settings/tokens/tokens.service.ts b/src/app/features/settings/tokens/tokens.service.ts index ace03b07c..81638d475 100644 --- a/src/app/features/settings/tokens/tokens.service.ts +++ b/src/app/features/settings/tokens/tokens.service.ts @@ -3,7 +3,7 @@ import { JsonApiService } from '@core/services/json-api/json-api.service'; import { Observable } from 'rxjs'; import { Token, TokenCreateResponse } from './entities/tokens.models'; import { map } from 'rxjs/operators'; -import { TokenMapper } from '@osf/features/settings/tokens/mappers/token.mapper'; +import { TokenMapper } from '@osf/features/settings/tokens/token.mapper'; import { Scope } from '@osf/features/settings/tokens/entities/scope.interface'; @Injectable({ diff --git a/src/app/shared/entities/addons.entities.ts b/src/app/shared/entities/addons.entities.ts new file mode 100644 index 000000000..f0ee07954 --- /dev/null +++ b/src/app/shared/entities/addons.entities.ts @@ -0,0 +1,11 @@ +export interface Addon { + id: string; + type: string; + attributes: { + name: string; + categories: string[]; + url?: string; + description?: string; + }; + links: Record; +} diff --git a/src/app/shared/entities/select-option.interface.ts b/src/app/shared/entities/select-option.interface.ts new file mode 100644 index 000000000..b584846f3 --- /dev/null +++ b/src/app/shared/entities/select-option.interface.ts @@ -0,0 +1,6 @@ +import { Primitive } from '@core/helpers/types.helper'; + +export interface SelectOption { + label: string; + value: Primitive; +} diff --git a/src/assets/styles/overrides/select.scss b/src/assets/styles/overrides/select.scss new file mode 100644 index 000000000..fd278d420 --- /dev/null +++ b/src/assets/styles/overrides/select.scss @@ -0,0 +1,22 @@ +@use "assets/styles/variables" as var; +@use "assets/styles/mixins" as mix; + +.p-select { + height: 3.125rem; + border: 1px solid var(--grey-2); + border-radius: 0.57rem; + outline: none; + font-size: 16px; + color: var.$dark-blue-1; + + .p-select-label { + @include mix.flex-align-center; + } + + .p-select-option { + color: var.$dark-blue-1; + &.p-select-option-selected.p-focus { + background: var.$bg-blue-2; + } + } +} diff --git a/src/assets/styles/styles.scss b/src/assets/styles/styles.scss index 8a09902d7..6d30e384b 100644 --- a/src/assets/styles/styles.scss +++ b/src/assets/styles/styles.scss @@ -19,7 +19,7 @@ @use "./overrides/confirmation-dialog"; @use "./overrides/input-group-addon"; @use "./overrides/iconfield"; - +@use "./overrides/select"; @layer base, primeng, reset; From c66bcb4c54d2c3df3bed40fa63ccb9019b031ca0 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Thu, 3 Apr 2025 11:39:07 +0300 Subject: [PATCH 04/11] feat(addons-api-integration): added addons images to assets --- src/app/app.component.ts | 16 +- src/app/app.config.ts | 9 +- src/app/app.module.ts | 3 +- src/app/core/helpers/ngxs-states.constant.ts | 6 + .../services/json-api/json-api.service.ts | 6 +- .../underscore-entites/user/user-us.entity.ts | 12 +- .../services/mappers/users/users.mapper.ts | 7 +- src/app/core/services/user/user.entity.ts | 3 + src/app/core/services/user/user.service.ts | 5 +- .../store/settings/addons/addons.actions.ts | 8 +- .../store/settings/addons/addons.models.ts | 3 +- .../store/settings/addons/addons.state.ts | 32 +++- src/app/core/store/user/index.ts | 3 + src/app/core/store/user/user.actions.ts | 11 ++ src/app/core/store/user/user.models.ts | 5 + src/app/core/store/user/user.state.ts | 39 +++++ .../addon-card-list.component.html | 2 +- .../addon-card/addon-card.component.html | 6 +- .../addon-card/addon-card.component.scss | 4 +- .../features/settings/addons/addon.mapper.ts | 15 ++ .../settings/addons/addons.component.html | 3 +- .../settings/addons/addons.component.ts | 64 +++++--- .../settings/addons/addons.service.ts | 49 +++++- .../settings/tokens/tokens.service.ts | 1 - src/app/shared/entities/addons.entities.ts | 68 +++++++- src/assets/images/addons/bitbucket.svg | 5 + src/assets/images/addons/box.svg | 1 + src/assets/images/addons/dataverse.svg | 30 ++++ src/assets/images/addons/dropbox.svg | 19 +++ src/assets/images/addons/figshare.svg | 1 + src/assets/images/addons/github.svg | 1 + src/assets/images/addons/gitlab.svg | 1 + src/assets/images/addons/googledrive.svg | 8 + src/assets/images/addons/mendeley.svg | 3 + src/assets/images/addons/onedrive.svg | 1 + src/assets/images/addons/owncloud.svg | 145 ++++++++++++++++++ src/assets/images/addons/s3.svg | 18 +++ src/assets/images/addons/zotero.svg | 11 ++ src/assets/images/temp/bitbucket.png | Bin 2538 -> 0 bytes src/assets/images/temp/dropbox.png | Bin 3276 -> 0 bytes src/assets/images/temp/figshare.png | Bin 10408 -> 0 bytes src/assets/images/temp/github.png | Bin 2578 -> 0 bytes src/assets/images/temp/onedrive.png | Bin 2281 -> 0 bytes src/assets/images/temp/owncloud.png | Bin 4950 -> 0 bytes src/assets/images/temp/s3.png | Bin 14504 -> 0 bytes src/assets/styles/overrides/select.scss | 4 +- 46 files changed, 561 insertions(+), 67 deletions(-) create mode 100644 src/app/core/helpers/ngxs-states.constant.ts create mode 100644 src/app/core/store/user/index.ts create mode 100644 src/app/core/store/user/user.actions.ts create mode 100644 src/app/core/store/user/user.models.ts create mode 100644 src/app/core/store/user/user.state.ts create mode 100644 src/app/features/settings/addons/addon.mapper.ts create mode 100644 src/assets/images/addons/bitbucket.svg create mode 100644 src/assets/images/addons/box.svg create mode 100644 src/assets/images/addons/dataverse.svg create mode 100644 src/assets/images/addons/dropbox.svg create mode 100644 src/assets/images/addons/figshare.svg create mode 100644 src/assets/images/addons/github.svg create mode 100644 src/assets/images/addons/gitlab.svg create mode 100644 src/assets/images/addons/googledrive.svg create mode 100644 src/assets/images/addons/mendeley.svg create mode 100644 src/assets/images/addons/onedrive.svg create mode 100644 src/assets/images/addons/owncloud.svg create mode 100644 src/assets/images/addons/s3.svg create mode 100644 src/assets/images/addons/zotero.svg delete mode 100644 src/assets/images/temp/bitbucket.png delete mode 100644 src/assets/images/temp/dropbox.png delete mode 100644 src/assets/images/temp/figshare.png delete mode 100644 src/assets/images/temp/github.png delete mode 100644 src/assets/images/temp/onedrive.png delete mode 100644 src/assets/images/temp/owncloud.png delete mode 100644 src/assets/images/temp/s3.png diff --git a/src/app/app.component.ts b/src/app/app.component.ts index a3e0702c5..30594c352 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,5 +1,12 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + inject, + OnInit, +} from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { Store } from '@ngxs/store'; +import { GetCurrentUser } from '@core/store/user'; @Component({ selector: 'osf-root', @@ -9,6 +16,11 @@ import { RouterOutlet } from '@angular/router'; standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppComponent { +export class AppComponent implements OnInit { + #store = inject(Store); title = 'osf'; + + ngOnInit(): void { + this.#store.dispatch(GetCurrentUser); + } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 46f5ae47a..db11c3aed 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -8,18 +8,13 @@ import Aura from '@primeng/themes/aura'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideHttpClient } from '@angular/common/http'; import { ConfirmationService } from 'primeng/api'; -import { AuthState } from '@core/store/auth'; -import { TokensState } from '@core/store/settings'; -import { AddonsState } from '@core/store/settings/addons'; +import { STATES } from '@core/helpers/ngxs-states.constant'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), - provideStore( - [AuthState, TokensState, AddonsState], - withNgxsReduxDevtoolsPlugin({ disabled: false }), - ), + provideStore(STATES, withNgxsReduxDevtoolsPlugin({ disabled: false })), providePrimeNG({ theme: { preset: Aura, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 552e071a3..35f368e43 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -2,8 +2,9 @@ import { NgModule } from '@angular/core'; import { NgxsModule } from '@ngxs/store'; import { AuthState } from '@core/store/auth'; import { TokensState } from '@core/store/settings'; +import { AddonsState } from '@core/store/settings/addons'; @NgModule({ - imports: [NgxsModule.forRoot([AuthState, TokensState])], + imports: [NgxsModule.forRoot([AuthState, TokensState, AddonsState])], }) export class AppModule {} diff --git a/src/app/core/helpers/ngxs-states.constant.ts b/src/app/core/helpers/ngxs-states.constant.ts new file mode 100644 index 000000000..1450e8ca1 --- /dev/null +++ b/src/app/core/helpers/ngxs-states.constant.ts @@ -0,0 +1,6 @@ +import { AuthState } from '@core/store/auth'; +import { TokensState } from '@core/store/settings'; +import { AddonsState } from '@core/store/settings/addons'; +import { UserState } from '@core/store/user'; + +export const STATES = [AuthState, TokensState, AddonsState, UserState]; diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts index 100745321..6df28a0ff 100644 --- a/src/app/core/services/json-api/json-api.service.ts +++ b/src/app/core/services/json-api/json-api.service.ts @@ -13,8 +13,12 @@ export class JsonApiService { http: HttpClient = inject(HttpClient); get(url: string): Observable { + const headers = new HttpHeaders({ + Authorization: `Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt`, + }); + return this.http - .get>(url) + .get>(url, { headers }) .pipe(map((response) => response.data)); } diff --git a/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts b/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts index 735b8238c..73b511b9b 100644 --- a/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts +++ b/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts @@ -1,4 +1,12 @@ export interface UserUS { - full_name: string; - given_name: string; + id: string; + type: string; + attributes: { + full_name: string; + given_name: string; + family_name: string; + email: string; + }; + relationships: Record; + links: Record; } diff --git a/src/app/core/services/mappers/users/users.mapper.ts b/src/app/core/services/mappers/users/users.mapper.ts index b86c43c2d..4d5ecc1e6 100644 --- a/src/app/core/services/mappers/users/users.mapper.ts +++ b/src/app/core/services/mappers/users/users.mapper.ts @@ -3,7 +3,10 @@ import { UserUS } from '@core/services/json-api/underscore-entites/user/user-us. export function mapUserUStoUser(user: UserUS): User { return { - fullName: user.full_name, - givenName: user.given_name, + id: user.id, + fullName: user.attributes.full_name, + givenName: user.attributes.given_name, + familyName: user.attributes.family_name, + email: user.attributes.email, }; } diff --git a/src/app/core/services/user/user.entity.ts b/src/app/core/services/user/user.entity.ts index 6ff2e9419..abebee534 100644 --- a/src/app/core/services/user/user.entity.ts +++ b/src/app/core/services/user/user.entity.ts @@ -1,4 +1,7 @@ export interface User { + id: string; fullName: string; givenName: string; + familyName: string; + email: string; } diff --git a/src/app/core/services/user/user.service.ts b/src/app/core/services/user/user.service.ts index a1b5c2614..5758d9079 100644 --- a/src/app/core/services/user/user.service.ts +++ b/src/app/core/services/user/user.service.ts @@ -9,11 +9,12 @@ import { mapUserUStoUser } from '@core/services/mappers/users/users.mapper'; providedIn: 'root', }) export class UserService { + baseUrl = 'https://api.staging4.osf.io/v2/'; jsonApiService = inject(JsonApiService); - getMe(): Observable { + getCurrentUser(): Observable { return this.jsonApiService - .get('https://api.test.osf.io/v2/users/me') + .get(this.baseUrl + 'users/me') .pipe(map((user) => mapUserUStoUser(user))); } } diff --git a/src/app/core/store/settings/addons/addons.actions.ts b/src/app/core/store/settings/addons/addons.actions.ts index d4448cf0c..16cdc8f66 100644 --- a/src/app/core/store/settings/addons/addons.actions.ts +++ b/src/app/core/store/settings/addons/addons.actions.ts @@ -1,3 +1,7 @@ -export class GetAddons { - static readonly type = '[Addons] Get Addons'; +export class GetStorageAddons { + static readonly type = '[Addons] Get Storage Addons'; +} + +export class GetCitationAddons { + static readonly type = '[Addons] Get Citation Addons'; } diff --git a/src/app/core/store/settings/addons/addons.models.ts b/src/app/core/store/settings/addons/addons.models.ts index 20fa74b78..caf96b4d6 100644 --- a/src/app/core/store/settings/addons/addons.models.ts +++ b/src/app/core/store/settings/addons/addons.models.ts @@ -1,5 +1,6 @@ import { Addon } from '@shared/entities/addons.entities'; export interface AddonsStateModel { - addons: Addon[]; + storageAddons: Addon[]; + citationAddons: Addon[]; } diff --git a/src/app/core/store/settings/addons/addons.state.ts b/src/app/core/store/settings/addons/addons.state.ts index 9b781bc96..6b28f71b1 100644 --- a/src/app/core/store/settings/addons/addons.state.ts +++ b/src/app/core/store/settings/addons/addons.state.ts @@ -1,7 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { State, Action, StateContext, Selector } from '@ngxs/store'; import { AddonsService } from '@osf/features/settings/addons/addons.service'; -import { GetAddons } from './addons.actions'; +import { GetStorageAddons } from './addons.actions'; import { tap } from 'rxjs'; import { AddonsStateModel } from './addons.models'; import { Addon } from '@shared/entities/addons.entities'; @@ -9,7 +9,8 @@ import { Addon } from '@shared/entities/addons.entities'; @State({ name: 'addons', defaults: { - addons: [], + storageAddons: [], + citationAddons: [], }, }) @Injectable() @@ -17,16 +18,31 @@ export class AddonsState { addonsService = inject(AddonsService); @Selector() - static getAddons(state: AddonsStateModel): Addon[] { - return state.addons; + static getStorageAddons(state: AddonsStateModel): Addon[] { + return state.storageAddons; } - @Action(GetAddons) - getAddons(ctx: StateContext) { - return this.addonsService.getAddons().pipe( + @Selector() + static getCitationAddons(state: AddonsStateModel): Addon[] { + return state.citationAddons; + } + + @Action(GetStorageAddons) + getStorageAddons(ctx: StateContext) { + return this.addonsService.getAddons('storage').pipe( + tap((addons) => { + console.log(addons); + ctx.patchState({ storageAddons: addons }); + }), + ); + } + + @Action(GetStorageAddons) + getCitationAddons(ctx: StateContext) { + return this.addonsService.getAddons('citation').pipe( tap((addons) => { console.log(addons); - ctx.patchState({ addons }); + ctx.patchState({ citationAddons: addons }); }), ); } diff --git a/src/app/core/store/user/index.ts b/src/app/core/store/user/index.ts new file mode 100644 index 000000000..e8c90712a --- /dev/null +++ b/src/app/core/store/user/index.ts @@ -0,0 +1,3 @@ +export * from './user.models'; +export * from './user.actions'; +export * from './user.state'; diff --git a/src/app/core/store/user/user.actions.ts b/src/app/core/store/user/user.actions.ts new file mode 100644 index 000000000..0e255ffd8 --- /dev/null +++ b/src/app/core/store/user/user.actions.ts @@ -0,0 +1,11 @@ +import { User } from '@core/services/user/user.entity'; + +export class GetCurrentUser { + static readonly type = '[User] Get Current User'; +} + +export class SetCurrentUser { + static readonly type = '[User] Set Current User'; + + constructor(public user: User) {} +} diff --git a/src/app/core/store/user/user.models.ts b/src/app/core/store/user/user.models.ts new file mode 100644 index 000000000..64e37b79c --- /dev/null +++ b/src/app/core/store/user/user.models.ts @@ -0,0 +1,5 @@ +import { User } from '@core/services/user/user.entity'; + +export interface UserStateModel { + currentUser: User | null; +} diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts new file mode 100644 index 000000000..9079fca83 --- /dev/null +++ b/src/app/core/store/user/user.state.ts @@ -0,0 +1,39 @@ +import { Injectable, inject } from '@angular/core'; +import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { UserStateModel } from './user.models'; +import { GetCurrentUser, SetCurrentUser } from './user.actions'; +import { UserService } from '@core/services/user/user.service'; +import { tap } from 'rxjs'; +import { User } from '@core/services/user/user.entity'; + +@State({ + name: 'user', + defaults: { + currentUser: null, + }, +}) +@Injectable() +export class UserState { + private userService = inject(UserService); + + @Selector([UserState]) + static getCurrentUser(state: UserStateModel): User | null { + return state.currentUser; + } + + @Action(GetCurrentUser) + getCurrentUser(ctx: StateContext) { + return this.userService.getCurrentUser().pipe( + tap((user) => { + ctx.dispatch(new SetCurrentUser(user)); + }), + ); + } + + @Action(SetCurrentUser) + setCurrentUser(ctx: StateContext, action: SetCurrentUser) { + ctx.patchState({ + currentUser: action.user, + }); + } +} diff --git a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html index 428370d7b..19504d2b8 100644 --- a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html +++ b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html @@ -1,6 +1,6 @@
@if (cards().length) { - @for (card of cards(); track card.attributes.name) { + @for (card of cards(); track card.id) {
diff --git a/src/app/features/settings/addons/addon-card/addon-card.component.html b/src/app/features/settings/addons/addon-card/addon-card.component.html index 4b540b92a..88c4c936d 100644 --- a/src/app/features/settings/addons/addon-card/addon-card.component.html +++ b/src/app/features/settings/addons/addon-card/addon-card.component.html @@ -5,14 +5,12 @@
Addon card image
-

{{ card()?.attributes?.name }}

+

{{ card()?.displayName }}

diff --git a/src/app/features/settings/addons/addons.component.ts b/src/app/features/settings/addons/addons.component.ts index 1919a00f8..bdcc2cfa8 100644 --- a/src/app/features/settings/addons/addons.component.ts +++ b/src/app/features/settings/addons/addons.component.ts @@ -3,8 +3,8 @@ import { Component, computed, signal, - OnInit, inject, + effect, } from '@angular/core'; import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; @@ -16,7 +16,11 @@ import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { SelectModule } from 'primeng/select'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngxs/store'; -import { AddonsState, GetAddons } from '@core/store/settings/addons'; +import { + AddonsState, + GetStorageAddons, + GetCitationAddons, +} from '@core/store/settings/addons'; import { SelectOption } from '@shared/entities/select-option.interface'; @@ -40,35 +44,57 @@ import { SelectOption } from '@shared/entities/select-option.interface'; styleUrl: './addons.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AddonsComponent implements OnInit { +export class AddonsComponent { #store = inject(Store); protected readonly defaultTabValue = 0; protected readonly isMobile = toSignal(inject(IS_XSMALL)); protected readonly searchValue = signal(''); - protected readonly cards = this.#store.selectSignal(AddonsState.getAddons); + protected readonly selectedCategory = signal( + 'external-storage-services', + ); + + protected readonly storageAddons = this.#store.selectSignal( + AddonsState.getStorageAddons, + ); + protected readonly citationAddons = this.#store.selectSignal( + AddonsState.getCitationAddons, + ); + + protected readonly currentAddons = computed(() => { + return this.selectedCategory() === 'external-storage-services' + ? this.storageAddons() + : this.citationAddons(); + }); + + protected readonly filteredCards = computed(() => { + const searchValue = this.searchValue().toLowerCase(); + return this.currentAddons().filter((card) => + card.externalServiceName.includes(searchValue), + ); + }); protected readonly tabOptions: SelectOption[] = [ { label: 'All Add-ons', value: 0 }, { label: 'Connected Add-ons', value: 1 }, ]; protected readonly categoryOptions: SelectOption[] = [ - { label: 'Additional Storage', value: 'storage' }, - { label: 'Citation Manager', value: 'citations' }, + { label: 'Additional Storage', value: 'external-storage-services' }, + { label: 'Citation Manager', value: 'external-citation-services' }, ]; protected selectedTab = this.defaultTabValue; - protected selectedCategory = signal('storage'); - protected readonly filteredCards = computed(() => { - const searchValue = this.searchValue().toLowerCase(); - const selectedCategory = this.selectedCategory(); - return this.cards().filter( - (card) => - card.attributes.name.toLowerCase().includes(searchValue) && - (!selectedCategory || - card.attributes.categories.includes(selectedCategory)), - ); - }); + protected onCategoryChange(value: string): void { + this.selectedCategory.set(value); + } + + constructor() { + effect(() => { + const category = this.selectedCategory(); - ngOnInit(): void { - this.#store.dispatch(GetAddons); + this.#store.dispatch( + category === 'external-storage-services' + ? GetStorageAddons + : GetCitationAddons, + ); + }); } } diff --git a/src/app/features/settings/addons/addons.service.ts b/src/app/features/settings/addons/addons.service.ts index 2bb2086fb..2a3c568fb 100644 --- a/src/app/features/settings/addons/addons.service.ts +++ b/src/app/features/settings/addons/addons.service.ts @@ -1,16 +1,55 @@ import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@core/services/json-api/json-api.service'; -import { Observable } from 'rxjs'; -import { Addon } from '@shared/entities/addons.entities'; +import { map, Observable } from 'rxjs'; +import { + Addon, + AddonResponse, + UserReference, +} from '@shared/entities/addons.entities'; +import { AddonMapper } from '@osf/features/settings/addons/addon.mapper'; +import { Store } from '@ngxs/store'; +import { UserState } from '@core/store/user'; @Injectable({ providedIn: 'root', }) export class AddonsService { + #store = inject(Store); jsonApiService = inject(JsonApiService); - baseUrl = 'https://api.staging4.osf.io/v2/'; + baseUrl = 'https://addons.staging4.osf.io/v1/'; + currentUser = this.#store.selectSignal(UserState.getCurrentUser); - getAddons(): Observable { - return this.jsonApiService.getArray(this.baseUrl + 'addons'); + // baseUrl = 'https://api.staging4.osf.io/v2/'; + + getUserReference(): Observable { + const userUri = `https://staging4.osf.io/${this.currentUser()!.id}`; + const params = { 'filter[user_uri]': userUri }; + + return this.jsonApiService.getArray( + this.baseUrl + 'user-references', + params, + ); } + + getAddons(addonType: string): Observable { + return this.jsonApiService + .getArray(this.baseUrl + `external-${addonType}-services`) + .pipe( + map((response) => { + console.log(response); + return response.map((item) => AddonMapper.fromResponse(item)); + }), + ); + } + + // getCitationAddons(): Observable { + // return this.jsonApiService + // .getArray(this.baseUrl + 'external-citation-services') + // .pipe( + // map((response) => { + // console.log(response); + // return response.map((item) => AddonMapper.fromResponse(item)); + // }), + // ); + // } } diff --git a/src/app/features/settings/tokens/tokens.service.ts b/src/app/features/settings/tokens/tokens.service.ts index 81638d475..c8ff5862f 100644 --- a/src/app/features/settings/tokens/tokens.service.ts +++ b/src/app/features/settings/tokens/tokens.service.ts @@ -35,7 +35,6 @@ export class TokensService { scopes: string[], ): Observable { const request = TokenMapper.toRequest(name, scopes); - console.log(request); return this.jsonApiService .patch(this.baseUrl + `tokens/${tokenId}`, request) diff --git a/src/app/shared/entities/addons.entities.ts b/src/app/shared/entities/addons.entities.ts index f0ee07954..260baf029 100644 --- a/src/app/shared/entities/addons.entities.ts +++ b/src/app/shared/entities/addons.entities.ts @@ -1,11 +1,69 @@ +export interface AddonResponse { + type: string; + id: string; + attributes: { + auth_uri: string; + display_name: string; + supported_features: string[]; + external_service_name: string; + credentials_format: string; + [key: string]: unknown; + }; + relationships: { + addon_imp: { + links: { + related: string; + }; + data: { + type: string; + id: string; + }; + }; + }; + links: { + self: string; + }; +} + export interface Addon { + type: string; id: string; + authUri: string; + displayName: string; + externalServiceName: string; + supportedFeatures: string[]; + credentialsFormat: string; +} + +export interface UserReference { type: string; + id: string; attributes: { - name: string; - categories: string[]; - url?: string; - description?: string; + user_uri: string; + }; + relationships: { + authorized_storage_accounts: { + links: { + related: string; + }; + }; + authorized_citation_accounts: { + links: { + related: string; + }; + }; + authorized_computing_accounts: { + links: { + related: string; + }; + }; + configured_resources: { + links: { + related: string; + }; + }; + }; + links: { + self: string; }; - links: Record; } diff --git a/src/assets/images/addons/bitbucket.svg b/src/assets/images/addons/bitbucket.svg new file mode 100644 index 000000000..483dd5fc9 --- /dev/null +++ b/src/assets/images/addons/bitbucket.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/assets/images/addons/box.svg b/src/assets/images/addons/box.svg new file mode 100644 index 000000000..5eec50e55 --- /dev/null +++ b/src/assets/images/addons/box.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/addons/dataverse.svg b/src/assets/images/addons/dataverse.svg new file mode 100644 index 000000000..959ae3ecc --- /dev/null +++ b/src/assets/images/addons/dataverse.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/src/assets/images/addons/dropbox.svg b/src/assets/images/addons/dropbox.svg new file mode 100644 index 000000000..164b6e96f --- /dev/null +++ b/src/assets/images/addons/dropbox.svg @@ -0,0 +1,19 @@ + + + + + + + diff --git a/src/assets/images/addons/figshare.svg b/src/assets/images/addons/figshare.svg new file mode 100644 index 000000000..ef34ad5fc --- /dev/null +++ b/src/assets/images/addons/figshare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/addons/github.svg b/src/assets/images/addons/github.svg new file mode 100644 index 000000000..e693cd34c --- /dev/null +++ b/src/assets/images/addons/github.svg @@ -0,0 +1 @@ + diff --git a/src/assets/images/addons/gitlab.svg b/src/assets/images/addons/gitlab.svg new file mode 100644 index 000000000..e85bb8363 --- /dev/null +++ b/src/assets/images/addons/gitlab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/addons/googledrive.svg b/src/assets/images/addons/googledrive.svg new file mode 100644 index 000000000..42d6c7b88 --- /dev/null +++ b/src/assets/images/addons/googledrive.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/images/addons/mendeley.svg b/src/assets/images/addons/mendeley.svg new file mode 100644 index 000000000..28799f5b9 --- /dev/null +++ b/src/assets/images/addons/mendeley.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/addons/onedrive.svg b/src/assets/images/addons/onedrive.svg new file mode 100644 index 000000000..f7d7a6a60 --- /dev/null +++ b/src/assets/images/addons/onedrive.svg @@ -0,0 +1 @@ +OfficeCore10_32x_24x_20x_16x_01-22-2019 \ No newline at end of file diff --git a/src/assets/images/addons/owncloud.svg b/src/assets/images/addons/owncloud.svg new file mode 100644 index 000000000..99cd1686e --- /dev/null +++ b/src/assets/images/addons/owncloud.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/addons/s3.svg b/src/assets/images/addons/s3.svg new file mode 100644 index 000000000..1f55ea3a2 --- /dev/null +++ b/src/assets/images/addons/s3.svg @@ -0,0 +1,18 @@ + + + + Icon-Architecture/16/Arch_Amazon-S3-Standard_16 + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/addons/zotero.svg b/src/assets/images/addons/zotero.svg new file mode 100644 index 000000000..4bb8d7b4f --- /dev/null +++ b/src/assets/images/addons/zotero.svg @@ -0,0 +1,11 @@ + + Zotero + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/temp/bitbucket.png b/src/assets/images/temp/bitbucket.png deleted file mode 100644 index d41abaccff3e4979110cb87962312bac6ac7ca65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2538 zcma);_ct31AH_q7iWRlDqP9{yW@7}kXVMmp^+;c%5)~s$_ zRe$TnT-y}O5Gd7SQ2mk=ES(uwRTrlh(Uj|+z#9MRh z0?a|?w}Sxy7OsCx2gobnzj)FGJD3>(Du;!?UmOg+P-`dvP?O4X;=y=Ph_*0+I)%|~ zzjVWJnSseT7w)MvCQPiNOkz}QA{Rg=5fXC^A3BHwDadpb0JLMWxNh2{4Ka&5GiS)v z;dgSg>N4QE^R>-gE`Q(@qZdbB*t3Jt@%dVS?6abOr~UJ;@)OPKqpP{1Po7GiD0LH9 zQ~8bAm63IX7|aR+WVM_(zyiS8p(BOU%jbP_M{g7$E_*CNeHe)edBRJSN1wd@fLZnP zIFO2GQoBejZZ5nevFa!J_xHCp17AgQM#>$7+j{YP4 zLm6){xJa8}TeXX6LQ$OB=0tJ!Ls5xO@3!Il$|MK@q>wGJ`+^8mh{#>`9XIs_@f_F9 z92wjCRmZ~-m3_ng=>Cewhl=>ntUvoT8`Ytgi)Y1N%gKzZ70?6oIXdjg3*q8i+I@tZ zuNF_nBDyP#^rfZo<80m8Bb#C^jL9O<6k}xg`XPxzNUqJ7QDOffVEsr?k6j2q_-q)j z^|_wNz!t2YZQ&3HJQpTjE%QS&`vG^{(Q;ODnq6@4Kf28O0bxMCa9BG4ZFNqFJEWX58aFnU4uBeM{*FXTT_&wZJl0OnU zS9e|3CeS_cHqzH(6cuP`3B4ue4dWGSr*zM>qxlt*nVx}?a@=$&)OY1~5Ahoo zKg27D5kH(dN7610pOz}$8${X3e+x3AY^d1^lcvu|Gm=DD)+NGFo~eHQdLM?bXQEl_ zeLc9UMc=jkZ{HQw;kMG&#c##GEc@>Uoz4Pbyh*q<53ee&m%AWp zMVrd7tc3vC!*q>9=(%O`q3wM2Kx~0h_Z3v!cvj7W@U_mls;#|o`+hyGV3CU0Dbcwz z>{zE+0qIbA`R{5C|5(WZkwD5m@~9W1T(UMZyQS8Kkq2HwnZMBXj~o$$pUPD-HM4va zrW1@h?`trcJ!w+st5>?-xsyKi97i}l6OO19IB!%0DRNHgZ&Jk*U6Ik9tWA$AHx>6_ z-kb5Tvej(c+n&>RQ+Wwy-J4MnD|1?8Jsn;9EH`dn>}~~f&L)cXq2g9k zTpbbRjb53hDcCCCPw~bk`mJ}hP>-)Mh zaZFP=#n929QCt!unK?qyf#!Tj*Z52L?7$`dzE{dskm77@bTc1>4px%j#*}!^>t?Alq&3IJE$quL*%9ED!=JmAQ(qEbeseLCX}8!q%9s% zUzy3Qqct%j-+;(@hj$!Bn|$GYY*-0brBs#nh=B8&>9E(fw^HY+IWc^C3i~;`e7Uhw z=AawpG9hG*F8U3gpLy{Z+PYsbc-sd%3d{0n^EW=a32cOP;w{gQnMOT z>Ow*js+=PBl4Wm;u*qpns%q0tfI{LUNsjNU{sa};{@&3{Qo4LtcVZI)WSC~ziu*a- z%Zr|2@ZNN*B*qO;y?a(I57648C)TFJ_nO@}LZE?p_<#i)gL?j%d3%4Yr3j*4o4q-B z4UP*{R$kmS(Ii#4NDNu~9;G124j@(m`MX0pol7Ahn>PK18tD(>B)w^0BZmXGF>e3}`@WpAw z)tp1>_Hg(bep1>(y0hQjig5e6xXmKO9Yu>k^QdPk0wF=y190t*Utl{5-Uh1NQ&T&; z@CHh72oB|FG;7I1fU3m|Xi27X9VhPP#SgYht0a7@v1p&~Z&r9oI8;+A6+JHJz|su9 z1Z=%;<8#)7Kn9D)1f%BqSr+7X>)RS4LZtl77Ol1J)m`t$zWJHeYCB1dRtcfx^W;7g w$x0QR(0n!7np_gbPEBDlwdekS%G{xG_yl6#;psEkE~Xj4!qmp3(g+#*KUNjT3jhEB diff --git a/src/assets/images/temp/dropbox.png b/src/assets/images/temp/dropbox.png deleted file mode 100644 index ee29bbf65ae29313fd45a027f942c6847f4e0fb0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3276 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 diff --git a/src/assets/images/temp/github.png b/src/assets/images/temp/github.png deleted file mode 100644 index a4b97bbb92573464a28c8a0426bdd9cb6c3ab0d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2578 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 diff --git a/src/assets/images/temp/owncloud.png b/src/assets/images/temp/owncloud.png deleted file mode 100644 index e05e37c50d0655890d8c0a5c691c899e442b1159..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4950 zcmcIo)k6~w)J3`(-7P(E2uOo4Vu*B0ZFC5X(!uB$B_Jp%NT)QYLppvmqeHq=xM{bz6;m9&(wuxb-Y?qPUXSXBEODoTckCkNSG#$3~EH&j8i&*K;D6~{T0(YQV6_@y7fv|Zx_2w%+|Hpo@br-}2 zL!<>Y|H=#d)y}3#SLD0$(C^=j=pNuZ!rQ*DlUy3z^tQ)2Rp(-P;*Rtj6CU;C(Q5>P zLjI-l4-a^%<>A=wVjN3j+yo2K?)B#)k8G+IqUXv#8LN*Llj0B(%~XYYPWH_oZ;6|ZD6*r_*<_h-j2-fuY(f-bwHI zDD1ZI(OBqeSkvTXhw$EiX;(t}A}q@0q}|9MpKd3k5)pi1P=&`=up?3PZw9=NUU?zH z_?Xg2l0)%_gVPipY()yWIyG9NiW;`gPpAK;^I-C|t#71`y%X0(MKW8XjQRCNXljaH zw!?;&nu{D9J%%4i_f0pB%_iyt%({+htcLP@Jx62g1x$mL;)S^PM0svS!8U|6AtB z=k9%EnWt0VWs-6-v&+1w@hB(B8?A(-$N=oJ|br{=NBHk_PAV#&$d85VTz~@`NbvC z?_xl=d_|z!XNadlqI^rd_Zq2gI~{e(2SvXV@b}%{tY?bg5PA%&LH$qn#>NA+%d;BrW~XQ^iH2?40fwMnX4qkdrM>{=-kf4HtYJ;r1Oqhf^QgyHHJs^ z1LtMoGjn2XpnR0Wti24H{4G)XnM3Vov78q&-=Kc0_kuXWHbWkNt+wcco2<#CS^JWx zOQz1%O-RgoTnPSc#~Na!pr9xNR(MYP{nZvS^+FU3qO7)TRseI@b@(zIx?uk-_3dPm zf#MOfJ)3s52RNanH&nEKx>iQ8Q&{v!n7b16p>)p7661H#w6w47h*^+kM@g?Y&uEpy zfl~G9OnD)$n4XKoa$q~>(unCZ1-FI_BvIab)khm;dGyNYWP1muXoy|_OSE5(EsM#D$*D9KTbL4my zR17_bw-;x%N1wDSew*)de}<0TpNmxIgltMLqBB|xPQ&wjWKl5vb{fr7~iw$YK&ocft#m7D6b~n%m~cnDGj{C$KENOH&L*n zhJ3BJO()7QRa{R}ok0$Bx)8_2`EqmzVD9Ta#Ob4@R3Phnxd^2taSZw<=JoVpZWKa3 z+j2B|zx)wJ|3eH+`#J~!d~RKcuBocx{{BYvOAH+vS{0ZntO78;*69M1<%`A8+qA(k zn$fez*C8fDm~AP|H9{fk41uYf^_~?;F1bq?qWlUkW&4*;=wFMmL8a{89ehYQi*!0k zR^nn-`L&498u)H=4GcHH_o`46C?P4%lTN*O!*iaDEt2lAouk)rdu?ud9Ck0SVnE=S z3KWQ2_5PZn??qAC`q2pM?bSH3ZaZGMVWR_%Xy^D=KDNqhC9+anGke{>>pN<)vysY? z4SIR|x;kU?7pt%`!lEyz5}uU4*Ln|{^?STg0r?L>i4+K%h&eCh=^?q z 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�(-B56Wih^ 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 Date: Thu, 3 Apr 2025 13:07:56 +0300 Subject: [PATCH 05/11] feat(addons-api-integration): separated store selectors in their own files --- .../store/settings/addons/addons.selectors.ts | 16 ++++++++++++++++ .../core/store/settings/addons/addons.state.ts | 13 +------------ src/app/core/store/settings/addons/index.ts | 3 ++- src/app/core/store/settings/tokens/index.ts | 3 ++- .../store/settings/tokens/tokens.selectors.ts | 17 +++++++++++++++++ .../core/store/settings/tokens/tokens.state.ts | 14 +------------- .../settings/addons/addons.component.ts | 6 +++--- .../token-add-edit-form.component.ts | 4 ++-- 8 files changed, 44 insertions(+), 32 deletions(-) create mode 100644 src/app/core/store/settings/addons/addons.selectors.ts create mode 100644 src/app/core/store/settings/tokens/tokens.selectors.ts diff --git a/src/app/core/store/settings/addons/addons.selectors.ts b/src/app/core/store/settings/addons/addons.selectors.ts new file mode 100644 index 000000000..369b2160d --- /dev/null +++ b/src/app/core/store/settings/addons/addons.selectors.ts @@ -0,0 +1,16 @@ +import { Selector } from '@ngxs/store'; +import { AddonsStateModel } from './addons.models'; +import { Addon } from '@shared/entities/addons.entities'; +import { AddonsState } from '@core/store/settings/addons/addons.state'; + +export class AddonsSelectors { + @Selector([AddonsState]) + static getStorageAddons(state: AddonsStateModel): Addon[] { + return state.storageAddons; + } + + @Selector([AddonsState]) + static getCitationAddons(state: AddonsStateModel): Addon[] { + return state.citationAddons; + } +} diff --git a/src/app/core/store/settings/addons/addons.state.ts b/src/app/core/store/settings/addons/addons.state.ts index 6b28f71b1..ab839a250 100644 --- a/src/app/core/store/settings/addons/addons.state.ts +++ b/src/app/core/store/settings/addons/addons.state.ts @@ -1,10 +1,9 @@ import { inject, Injectable } from '@angular/core'; -import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { State, Action, StateContext } from '@ngxs/store'; import { AddonsService } from '@osf/features/settings/addons/addons.service'; import { GetStorageAddons } from './addons.actions'; import { tap } from 'rxjs'; import { AddonsStateModel } from './addons.models'; -import { Addon } from '@shared/entities/addons.entities'; @State({ name: 'addons', @@ -17,16 +16,6 @@ import { Addon } from '@shared/entities/addons.entities'; export class AddonsState { addonsService = inject(AddonsService); - @Selector() - static getStorageAddons(state: AddonsStateModel): Addon[] { - return state.storageAddons; - } - - @Selector() - static getCitationAddons(state: AddonsStateModel): Addon[] { - return state.citationAddons; - } - @Action(GetStorageAddons) getStorageAddons(ctx: StateContext) { return this.addonsService.getAddons('storage').pipe( diff --git a/src/app/core/store/settings/addons/index.ts b/src/app/core/store/settings/addons/index.ts index 3401dee59..056a13928 100644 --- a/src/app/core/store/settings/addons/index.ts +++ b/src/app/core/store/settings/addons/index.ts @@ -1,3 +1,4 @@ +export * from './addons.state'; export * from './addons.actions'; export * from './addons.models'; -export * from './addons.state'; +export * from './addons.selectors'; diff --git a/src/app/core/store/settings/tokens/index.ts b/src/app/core/store/settings/tokens/index.ts index de4f7bfbc..cecb04f7b 100644 --- a/src/app/core/store/settings/tokens/index.ts +++ b/src/app/core/store/settings/tokens/index.ts @@ -1,3 +1,4 @@ +export * from './tokens.state'; export * from './tokens.actions'; export * from './tokens.models'; -export * from './tokens.state'; +export * from './tokens.selectors'; diff --git a/src/app/core/store/settings/tokens/tokens.selectors.ts b/src/app/core/store/settings/tokens/tokens.selectors.ts new file mode 100644 index 000000000..5915f0283 --- /dev/null +++ b/src/app/core/store/settings/tokens/tokens.selectors.ts @@ -0,0 +1,17 @@ +import { Selector } from '@ngxs/store'; +import { TokensStateModel } from './tokens.models'; +import { Scope } from '@osf/features/settings/tokens/entities/scope.interface'; +import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; +import { TokensState } from '@core/store/settings'; + +export class TokensSelectors { + @Selector([TokensState]) + static getScopes(state: TokensStateModel): Scope[] { + return state.scopes; + } + + @Selector([TokensState]) + static getTokens(state: TokensStateModel): Token[] { + return state.tokens; + } +} diff --git a/src/app/core/store/settings/tokens/tokens.state.ts b/src/app/core/store/settings/tokens/tokens.state.ts index bb07ea5ab..cd72d3a74 100644 --- a/src/app/core/store/settings/tokens/tokens.state.ts +++ b/src/app/core/store/settings/tokens/tokens.state.ts @@ -1,5 +1,5 @@ import { inject, Injectable } from '@angular/core'; -import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { State, Action, StateContext } from '@ngxs/store'; import { TokensStateModel } from './tokens.models'; import { TokensService } from '@osf/features/settings/tokens/tokens.service'; import { @@ -9,8 +9,6 @@ import { DeleteToken, } from './tokens.actions'; import { tap } from 'rxjs'; -import { Scope } from '@osf/features/settings/tokens/entities/scope.interface'; -import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; @State({ name: 'tokens', @@ -23,16 +21,6 @@ import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; export class TokensState { tokensService = inject(TokensService); - @Selector() - static getScopes(state: TokensStateModel): Scope[] { - return state.scopes; - } - - @Selector() - static getTokens(state: TokensStateModel): Token[] { - return state.tokens; - } - @Action(GetScopes) getScopes(ctx: StateContext) { return this.tokensService.getScopes().pipe( diff --git a/src/app/features/settings/addons/addons.component.ts b/src/app/features/settings/addons/addons.component.ts index bdcc2cfa8..6d3cdfe85 100644 --- a/src/app/features/settings/addons/addons.component.ts +++ b/src/app/features/settings/addons/addons.component.ts @@ -17,9 +17,9 @@ import { SelectModule } from 'primeng/select'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngxs/store'; import { - AddonsState, GetStorageAddons, GetCitationAddons, + AddonsSelectors, } from '@core/store/settings/addons'; import { SelectOption } from '@shared/entities/select-option.interface'; @@ -54,10 +54,10 @@ export class AddonsComponent { ); protected readonly storageAddons = this.#store.selectSignal( - AddonsState.getStorageAddons, + AddonsSelectors.getStorageAddons, ); protected readonly citationAddons = this.#store.selectSignal( - AddonsState.getCitationAddons, + AddonsSelectors.getCitationAddons, ); protected readonly currentAddons = computed(() => { diff --git a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts index 3fb7003e8..23dbd8612 100644 --- a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts +++ b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts @@ -23,7 +23,7 @@ import { CommonModule } from '@angular/common'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { toSignal } from '@angular/core/rxjs-interop'; import { Store } from '@ngxs/store'; -import { TokensState } from '@core/store/settings'; +import { TokensSelectors } from '@core/store/settings'; import { TokensService } from '@osf/features/settings/tokens/tokens.service'; import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; @@ -44,7 +44,7 @@ export class TokenAddEditFormComponent implements OnInit { protected readonly TokenFormControls = TokenFormControls; protected readonly isXSmall = toSignal(this.#isXSmall$); protected readonly tokenScopes = this.#store.selectSignal( - TokensState.getScopes, + TokensSelectors.getScopes, ); readonly tokenForm: TokenForm = new FormGroup({ From 8cfe37c1e860aaa2a7537d7a2501321821ca3ed4 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Tue, 8 Apr 2025 11:38:24 +0300 Subject: [PATCH 06/11] feat(pat-addons-api-integration): json-api service refactor --- src/app/core/helpers/ngxs-states.constant.ts | 9 +++- .../services/json-api/json-api.service.ts | 54 ++++++++----------- src/app/core/services/user/user.entity.ts | 2 +- src/app/features/home/dashboard.service.ts | 2 +- .../features/home/mappers/dashboard.mapper.ts | 1 + 5 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/app/core/helpers/ngxs-states.constant.ts b/src/app/core/helpers/ngxs-states.constant.ts index 1450e8ca1..e90300f0d 100644 --- a/src/app/core/helpers/ngxs-states.constant.ts +++ b/src/app/core/helpers/ngxs-states.constant.ts @@ -2,5 +2,12 @@ import { AuthState } from '@core/store/auth'; import { TokensState } from '@core/store/settings'; import { AddonsState } from '@core/store/settings/addons'; import { UserState } from '@core/store/user'; +import { HomeState } from '@core/store/home'; -export const STATES = [AuthState, TokensState, AddonsState, UserState]; +export const STATES = [ + AuthState, + TokensState, + AddonsState, + UserState, + HomeState, +]; diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts index 6df28a0ff..88162d968 100644 --- a/src/app/core/services/json-api/json-api.service.ts +++ b/src/app/core/services/json-api/json-api.service.ts @@ -11,18 +11,28 @@ import { }) export class JsonApiService { http: HttpClient = inject(HttpClient); + #headers = new HttpHeaders({ + Authorization: 'ENTER_VALID_TOKEN', + }); - get(url: string): Observable { - const headers = new HttpHeaders({ - Authorization: `Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt`, - }); - + get(url: string, params?: Record): Observable { return this.http - .get>(url, { headers }) + .get< + JsonApiResponse + >(url, { params: this.buildHttpParams(params), headers: this.#headers }) .pipe(map((response) => response.data)); } getArray(url: string, params?: Record): Observable { + return this.http + .get>(url, { + params: this.buildHttpParams(params), + headers: this.#headers, + }) + .pipe(map((response) => response.data)); + } + + private buildHttpParams(params?: Record): HttpParams { let httpParams = new HttpParams(); if (params) { @@ -31,7 +41,7 @@ export class JsonApiService { if (Array.isArray(value)) { value.forEach((item) => { - httpParams = httpParams.append(`${key}[]`, item); // Handles arrays + httpParams = httpParams.append(`${key}[]`, item); }); } else { httpParams = httpParams.set(key, value as string); @@ -39,44 +49,22 @@ export class JsonApiService { } } - const headers = new HttpHeaders({ - Authorization: `Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt`, - }); - - return this.http - .get< - JsonApiArrayResponse - >(url, { params: httpParams, headers: headers }) - .pipe(map((response) => response.data)); + return httpParams; } post(url: string, body: unknown): Observable { - const headers = new HttpHeaders({ - 'Content-Type': 'application/json', - Authorization: `Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt`, - }); - return this.http - .post>(url, body, { headers }) + .post>(url, body, { headers: this.#headers }) .pipe(map((response) => response.data)); } patch(url: string, body: unknown): Observable { - const headers = new HttpHeaders({ - 'Content-Type': 'application/json', - Authorization: `Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt`, - }); - return this.http - .patch>(url, body, { headers }) + .patch>(url, body, { headers: this.#headers }) .pipe(map((response) => response.data)); } delete(url: string): Observable { - const headers = new HttpHeaders({ - Authorization: `Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt`, - }); - - return this.http.delete(url, { headers }); + return this.http.delete(url, { headers: this.#headers }); } } diff --git a/src/app/core/services/user/user.entity.ts b/src/app/core/services/user/user.entity.ts index abebee534..cb2410561 100644 --- a/src/app/core/services/user/user.entity.ts +++ b/src/app/core/services/user/user.entity.ts @@ -3,5 +3,5 @@ export interface User { fullName: string; givenName: string; familyName: string; - email: string; + email?: string; } diff --git a/src/app/features/home/dashboard.service.ts b/src/app/features/home/dashboard.service.ts index 94e21caa2..3662e239a 100644 --- a/src/app/features/home/dashboard.service.ts +++ b/src/app/features/home/dashboard.service.ts @@ -13,7 +13,7 @@ export class DashboardService { jsonApiService = inject(JsonApiService); getProjects(): Observable { - const userId = 'k9p2t'; + const userId = 'ENTER_VALID_USER_ID'; const params = { embed: ['bibliographic_contributors', 'parent', 'root'], page: 1, diff --git a/src/app/features/home/mappers/dashboard.mapper.ts b/src/app/features/home/mappers/dashboard.mapper.ts index 0dc43944f..011b2422e 100644 --- a/src/app/features/home/mappers/dashboard.mapper.ts +++ b/src/app/features/home/mappers/dashboard.mapper.ts @@ -13,6 +13,7 @@ export function mapProjectUStoProject(dataItem: ProjectItem): Project { permission: item.attributes.permission, unregisteredContributor: item.attributes.unregistered_contributor, users: { + id: item.embeds.users.data.id, familyName: item.embeds.users.data.attributes.family_name, fullName: item.embeds.users.data.attributes.full_name, givenName: item.embeds.users.data.attributes.given_name, From c3bf8fa8dac1c439d6a70a572419f2f5a02a2886 Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Fri, 11 Apr 2025 13:02:55 +0300 Subject: [PATCH 07/11] fix(api-service): fixes for json-api service --- .../core/services/json-api/json-api.entity.ts | 9 ++---- .../services/json-api/json-api.service.ts | 29 ++++++------------ src/app/features/home/dashboard.service.ts | 28 ++++++++--------- .../models/raw-models/ProjectItem.entity.ts | 30 +++++++++++++------ 4 files changed, 46 insertions(+), 50 deletions(-) diff --git a/src/app/core/services/json-api/json-api.entity.ts b/src/app/core/services/json-api/json-api.entity.ts index 31e660911..ce7b6d27a 100644 --- a/src/app/core/services/json-api/json-api.entity.ts +++ b/src/app/core/services/json-api/json-api.entity.ts @@ -1,9 +1,6 @@ -export interface JsonApiResponse { - data: T; -} - -export interface JsonApiArrayResponse { - data: T[]; +export interface JsonApiResponse { + data: Data; + included: Included; } export interface ApiData { diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts index ea639e05b..b147f7718 100644 --- a/src/app/core/services/json-api/json-api.service.ts +++ b/src/app/core/services/json-api/json-api.service.ts @@ -1,11 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; -import { map, Observable } from 'rxjs'; -import { - JsonApiArrayResponse, - JsonApiResponse, -} from '@core/services/json-api/json-api.entity'; - +import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root', }) @@ -13,22 +8,16 @@ export class JsonApiService { http: HttpClient = inject(HttpClient); get(url: string, params?: Record): Observable { - return this.http - .get>(url, { params: this.buildHttpParams(params) }) - .pipe(map((response) => response.data)); - } - - getArray(url: string, params?: Record): Observable { + const token = 'ENTER_VALID_PAT'; const headers = new HttpHeaders({ - Authorization: 'ENTER_VALID_PAT', + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.api+json', }); - return this.http - .get>(url, { - params: this.buildHttpParams(params), - headers: headers, - }) - .pipe(map((response) => response.data)); + return this.http.get(url, { + params: this.buildHttpParams(params), + headers: headers, + }); } private buildHttpParams(params?: Record): HttpParams { @@ -40,7 +29,7 @@ export class JsonApiService { if (Array.isArray(value)) { value.forEach((item) => { - httpParams = httpParams.append(`${key}[]`, item); // Handles arrays + httpParams = httpParams.append(`${key}`, item); // Handles arrays }); } else { httpParams = httpParams.set(key, value as string); diff --git a/src/app/features/home/dashboard.service.ts b/src/app/features/home/dashboard.service.ts index 3662e239a..7ab9a3148 100644 --- a/src/app/features/home/dashboard.service.ts +++ b/src/app/features/home/dashboard.service.ts @@ -5,6 +5,7 @@ import { Project } from '@osf/features/home/models/project.entity'; import { mapProjectUStoProject } from '@osf/features/home/mappers/dashboard.mapper'; import { ProjectItem } from '@osf/features/home/models/raw-models/ProjectItem.entity'; import { environment } from '../../../environments/environment'; +import { JsonApiResponse } from '@core/services/json-api/json-api.entity'; @Injectable({ providedIn: 'root', @@ -21,11 +22,10 @@ export class DashboardService { }; return this.jsonApiService - .getArray( - `${environment.apiUrl}/sparse/users/${userId}/nodes/`, - params, - ) - .pipe(map((projects) => projects.map(mapProjectUStoProject))); + .get< + JsonApiResponse + >(`${environment.apiUrl}/sparse/users/${userId}/nodes/`, params) + .pipe(map((response) => response.data.map(mapProjectUStoProject))); } getNoteworthy(): Observable { @@ -36,11 +36,10 @@ export class DashboardService { }; return this.jsonApiService - .getArray( - `${environment.apiUrl}/nodes/${projectId}/linked_nodes`, - params, - ) - .pipe(map((projects) => projects.map(mapProjectUStoProject))); + .get< + JsonApiResponse + >(`${environment.apiUrl}/nodes/${projectId}/linked_nodes`, params) + .pipe(map((response) => response.data.map(mapProjectUStoProject))); } getMostPopular(): Observable { @@ -51,10 +50,9 @@ export class DashboardService { }; return this.jsonApiService - .getArray( - `${environment.apiUrl}/nodes/${projectId}/linked_nodes`, - params, - ) - .pipe(map((projects) => projects.map(mapProjectUStoProject))); + .get< + JsonApiResponse + >(`${environment.apiUrl}/nodes/${projectId}/linked_nodes`, params) + .pipe(map((response) => response.data.map(mapProjectUStoProject))); } } diff --git a/src/app/features/home/models/raw-models/ProjectItem.entity.ts b/src/app/features/home/models/raw-models/ProjectItem.entity.ts index af1f3a2c8..ceab31895 100644 --- a/src/app/features/home/models/raw-models/ProjectItem.entity.ts +++ b/src/app/features/home/models/raw-models/ProjectItem.entity.ts @@ -1,10 +1,22 @@ -import {ProjectUS} from '@osf/features/home/models/raw-models/projectUS.entity'; -import {ApiData, JsonApiArrayResponse, JsonApiResponse} from '@core/services/json-api/json-api.entity'; -import {BibliographicContributorUS} from '@osf/features/home/models/raw-models/bibliographicContributorUS.entity'; -import {UserUS} from '@osf/features/home/models/raw-models/userUS.entity'; +import { ProjectUS } from '@osf/features/home/models/raw-models/projectUS.entity'; +import { + ApiData, + JsonApiResponse, +} from '@core/services/json-api/json-api.entity'; +import { BibliographicContributorUS } from '@osf/features/home/models/raw-models/bibliographicContributorUS.entity'; +import { UserUS } from '@osf/features/home/models/raw-models/userUS.entity'; -export type ProjectItem = ApiData> - }>> -}> +export type ProjectItem = ApiData< + ProjectUS, + { + bibliographic_contributors: JsonApiResponse< + ApiData< + BibliographicContributorUS, + { + users: JsonApiResponse, null>; + } + >[], + null + >; + } +>; From b807cd0740f219d1e48c7a37df1946a676ed4c1c Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Fri, 11 Apr 2025 13:06:26 +0300 Subject: [PATCH 08/11] fix(api-service): params fix --- src/app/features/home/dashboard.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/home/dashboard.service.ts b/src/app/features/home/dashboard.service.ts index 7ab9a3148..d995d3f71 100644 --- a/src/app/features/home/dashboard.service.ts +++ b/src/app/features/home/dashboard.service.ts @@ -16,7 +16,7 @@ export class DashboardService { getProjects(): Observable { const userId = 'ENTER_VALID_USER_ID'; const params = { - embed: ['bibliographic_contributors', 'parent', 'root'], + 'embed[]': ['bibliographic_contributors', 'parent', 'root'], page: 1, sort: '-last_logged', }; From 62bbe6deeb6e138d5860adfdba8598fd100fa6c1 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Fri, 11 Apr 2025 14:02:34 +0300 Subject: [PATCH 09/11] feat(pat-addons-api-integration): integrated pat api --- bun.lock | 28 ++-- .../core/services/json-api/json-api.entity.ts | 23 +++ .../services/json-api/json-api.service.ts | 19 ++- src/app/core/services/user/user.service.ts | 2 +- .../store/settings/addons/addons.actions.ts | 8 + .../store/settings/addons/addons.models.ts | 7 +- .../store/settings/addons/addons.selectors.ts | 12 +- .../store/settings/addons/addons.state.ts | 35 ++++- .../store/settings/tokens/tokens.actions.ts | 13 ++ .../store/settings/tokens/tokens.selectors.ts | 7 + .../store/settings/tokens/tokens.state.ts | 44 +++++- src/app/features/home/dashboard.service.ts | 8 +- .../my-projects/my-projects.component.html | 20 ++- .../addon-card-list.component.ts | 7 +- .../addon-card/addon-card.component.html | 10 +- .../addon-card/addon-card.component.scss | 2 +- .../addons/addon-card/addon-card.component.ts | 14 +- .../features/settings/addons/addon.mapper.ts | 43 +++++- .../settings/addons/addons.service.ts | 41 ++--- .../connect-addon.component.html | 2 +- .../connect-addon/connect-addon.component.ts | 141 ++++++++++++------ .../addons}/entities/addon-card.interface.ts | 0 .../addons}/entities/addon-terms.interface.ts | 0 .../addons/entities/addons.entities.ts | 131 ++++++++++++++++ .../addons/utils/addon-terms.const.ts | 75 ++++++++++ .../developer-app-details.component.scss | 29 ---- .../settings/tokens/entities/tokens.models.ts | 78 ++++------ .../token-add-edit-form.component.html | 2 +- .../token-add-edit-form.component.ts | 69 +++++++-- .../token-created-dialog.component.html | 43 ++++++ .../token-created-dialog.component.scss | 12 ++ .../token-created-dialog.component.spec.ts | 22 +++ .../token-created-dialog.component.ts | 47 ++++++ .../token-details.component.html | 29 ++-- .../token-details/token-details.component.ts | 53 +++++-- .../features/settings/tokens/token.mapper.ts | 30 ++-- .../tokens-list/tokens-list.component.ts | 54 ++----- .../settings/tokens/tokens.component.html | 2 +- .../settings/tokens/tokens.component.ts | 11 +- .../settings/tokens/tokens.service.ts | 31 +++- src/app/shared/entities/addons.entities.ts | 69 --------- .../styles/overrides/confirmation-dialog.scss | 4 + src/assets/styles/overrides/paginator.scss | 64 ++++---- src/assets/styles/overrides/select.scss | 4 + src/assets/styles/styles.scss | 29 ++++ 45 files changed, 978 insertions(+), 396 deletions(-) rename src/app/{shared => features/settings/addons}/entities/addon-card.interface.ts (100%) rename src/app/{shared => features/settings/addons}/entities/addon-terms.interface.ts (100%) create mode 100644 src/app/features/settings/addons/entities/addons.entities.ts create mode 100644 src/app/features/settings/addons/utils/addon-terms.const.ts create mode 100644 src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.html create mode 100644 src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.scss create mode 100644 src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.spec.ts create mode 100644 src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.ts delete mode 100644 src/app/shared/entities/addons.entities.ts diff --git a/bun.lock b/bun.lock index d5d1ef93a..10d562168 100644 --- a/bun.lock +++ b/bun.lock @@ -856,7 +856,7 @@ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "call-bound": ["call-bound@1.0.3", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "get-intrinsic": "^1.2.6" } }, "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA=="], + "call-bound": ["call-bound@1.0.3", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "getData-intrinsic": "^1.2.6" } }, "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -1094,7 +1094,7 @@ "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], + "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "getData-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], "exponential-backoff": ["exponential-backoff@3.1.2", "", {}, "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA=="], @@ -1164,15 +1164,15 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "getData-caller-file": ["getData-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + "getData-east-asian-width": ["getData-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "getData-intrinsic": ["getData-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "getData-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "getData-proto": ["getData-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], + "getData-stream": ["getData-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], "git-raw-commits": ["git-raw-commits@4.0.0", "", { "dependencies": { "dargs": "^8.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.mjs" } }, "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ=="], @@ -1792,9 +1792,9 @@ "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "getData-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "getData-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -2028,7 +2028,7 @@ "yaml": ["yaml@2.7.0", "", { "bin": "bin.mjs" }, "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA=="], - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "getData-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -2188,7 +2188,7 @@ "cacache/tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], - "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "getData-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -2256,7 +2256,7 @@ "karma/glob": ["glob@7.2.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q=="], - "karma/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], + "karma/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "getData-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], "karma-coverage/istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="], @@ -2376,7 +2376,7 @@ "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], - "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "getData-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], @@ -2472,7 +2472,7 @@ "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], - "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.0.0", "", { "dependencies": { "get-east-asian-width": "^1.0.0" } }, "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA=="], + "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.0.0", "", { "dependencies": { "getData-east-asian-width": "^1.0.0" } }, "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA=="], "log-update/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], diff --git a/src/app/core/services/json-api/json-api.entity.ts b/src/app/core/services/json-api/json-api.entity.ts index 31e660911..08a4ae3b6 100644 --- a/src/app/core/services/json-api/json-api.entity.ts +++ b/src/app/core/services/json-api/json-api.entity.ts @@ -1,9 +1,32 @@ export interface JsonApiResponse { data: T; + included?: T[]; } export interface JsonApiArrayResponse { data: T[]; + included?: T[]; +} + +export interface IncludedData { + type: string; + id: string; + attributes: Record; + relationships?: Record< + string, + { + links: { + related: string; + }; + data?: { + type: string; + id: string; + }; + } + >; + links?: { + self: string; + }; } export interface ApiData { diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts index 88162d968..657cf42e8 100644 --- a/src/app/core/services/json-api/json-api.service.ts +++ b/src/app/core/services/json-api/json-api.service.ts @@ -12,10 +12,10 @@ import { export class JsonApiService { http: HttpClient = inject(HttpClient); #headers = new HttpHeaders({ - Authorization: 'ENTER_VALID_TOKEN', + Authorization: 'ENTER_VALID_PAT', }); - get(url: string, params?: Record): Observable { + getData(url: string, params?: Record): Observable { return this.http .get< JsonApiResponse @@ -23,7 +23,10 @@ export class JsonApiService { .pipe(map((response) => response.data)); } - getArray(url: string, params?: Record): Observable { + getDataArray( + url: string, + params?: Record, + ): Observable { return this.http .get>(url, { params: this.buildHttpParams(params), @@ -32,6 +35,16 @@ export class JsonApiService { .pipe(map((response) => response.data)); } + getFullArrayResponse( + url: string, + params?: Record, + ): Observable> { + return this.http.get>(url, { + params: this.buildHttpParams(params), + headers: this.#headers, + }); + } + private buildHttpParams(params?: Record): HttpParams { let httpParams = new HttpParams(); diff --git a/src/app/core/services/user/user.service.ts b/src/app/core/services/user/user.service.ts index 5758d9079..e509b3ba1 100644 --- a/src/app/core/services/user/user.service.ts +++ b/src/app/core/services/user/user.service.ts @@ -14,7 +14,7 @@ export class UserService { getCurrentUser(): Observable { return this.jsonApiService - .get(this.baseUrl + 'users/me') + .getData(this.baseUrl + 'users/me') .pipe(map((user) => mapUserUStoUser(user))); } } diff --git a/src/app/core/store/settings/addons/addons.actions.ts b/src/app/core/store/settings/addons/addons.actions.ts index 16cdc8f66..c87e13a50 100644 --- a/src/app/core/store/settings/addons/addons.actions.ts +++ b/src/app/core/store/settings/addons/addons.actions.ts @@ -5,3 +5,11 @@ export class GetStorageAddons { export class GetCitationAddons { static readonly type = '[Addons] Get Citation Addons'; } + +export class GetAuthorizedStorageAddons { + static readonly type = '[Addons] Get Authorized Storage Addons'; +} + +export class GetAuthorizedCitationAddons { + static readonly type = '[Addons] Get Authorized Citation Addons'; +} diff --git a/src/app/core/store/settings/addons/addons.models.ts b/src/app/core/store/settings/addons/addons.models.ts index caf96b4d6..250f5d827 100644 --- a/src/app/core/store/settings/addons/addons.models.ts +++ b/src/app/core/store/settings/addons/addons.models.ts @@ -1,6 +1,11 @@ -import { Addon } from '@shared/entities/addons.entities'; +import { + Addon, + AuthorizedAddon, +} from '@osf/features/settings/addons/entities/addons.entities'; export interface AddonsStateModel { storageAddons: Addon[]; citationAddons: Addon[]; + authorizedStorageAddons: AuthorizedAddon[]; + authorizedCitationAddons: AuthorizedAddon[]; } diff --git a/src/app/core/store/settings/addons/addons.selectors.ts b/src/app/core/store/settings/addons/addons.selectors.ts index 369b2160d..65acaa795 100644 --- a/src/app/core/store/settings/addons/addons.selectors.ts +++ b/src/app/core/store/settings/addons/addons.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; import { AddonsStateModel } from './addons.models'; -import { Addon } from '@shared/entities/addons.entities'; +import { Addon } from '@osf/features/settings/addons/entities/addons.entities'; import { AddonsState } from '@core/store/settings/addons/addons.state'; export class AddonsSelectors { @@ -13,4 +13,14 @@ export class AddonsSelectors { static getCitationAddons(state: AddonsStateModel): Addon[] { return state.citationAddons; } + + @Selector([AddonsState]) + static getAuthorizedStorageAddons(state: AddonsStateModel) { + return state.authorizedStorageAddons; + } + + @Selector([AddonsState]) + static getAuthorizedCitationAddons(state: AddonsStateModel) { + return state.authorizedCitationAddons; + } } diff --git a/src/app/core/store/settings/addons/addons.state.ts b/src/app/core/store/settings/addons/addons.state.ts index ab839a250..3a0d81ec4 100644 --- a/src/app/core/store/settings/addons/addons.state.ts +++ b/src/app/core/store/settings/addons/addons.state.ts @@ -1,7 +1,12 @@ import { inject, Injectable } from '@angular/core'; import { State, Action, StateContext } from '@ngxs/store'; import { AddonsService } from '@osf/features/settings/addons/addons.service'; -import { GetStorageAddons } from './addons.actions'; +import { + GetCitationAddons, + GetStorageAddons, + GetAuthorizedStorageAddons, + GetAuthorizedCitationAddons, +} from './addons.actions'; import { tap } from 'rxjs'; import { AddonsStateModel } from './addons.models'; @@ -10,6 +15,8 @@ import { AddonsStateModel } from './addons.models'; defaults: { storageAddons: [], citationAddons: [], + authorizedStorageAddons: [], + authorizedCitationAddons: [], }, }) @Injectable() @@ -20,19 +27,39 @@ export class AddonsState { getStorageAddons(ctx: StateContext) { return this.addonsService.getAddons('storage').pipe( tap((addons) => { - console.log(addons); + console.log('storage', addons); ctx.patchState({ storageAddons: addons }); }), ); } - @Action(GetStorageAddons) + @Action(GetCitationAddons) getCitationAddons(ctx: StateContext) { return this.addonsService.getAddons('citation').pipe( tap((addons) => { - console.log(addons); + console.log('citation', addons); ctx.patchState({ citationAddons: addons }); }), ); } + + @Action(GetAuthorizedStorageAddons) + getAuthorizedStorageAddons(ctx: StateContext) { + return this.addonsService.getAuthorizedAddons('storage').pipe( + tap((addons) => { + console.log('authorized storage', addons); + ctx.patchState({ authorizedStorageAddons: addons }); + }), + ); + } + + @Action(GetAuthorizedCitationAddons) + getAuthorizedCitationAddons(ctx: StateContext) { + return this.addonsService.getAuthorizedAddons('citation').pipe( + tap((addons) => { + console.log('authorized citation', addons); + ctx.patchState({ authorizedCitationAddons: addons }); + }), + ); + } } diff --git a/src/app/core/store/settings/tokens/tokens.actions.ts b/src/app/core/store/settings/tokens/tokens.actions.ts index 940ec130f..54a657610 100644 --- a/src/app/core/store/settings/tokens/tokens.actions.ts +++ b/src/app/core/store/settings/tokens/tokens.actions.ts @@ -6,6 +6,11 @@ export class GetTokens { static readonly type = '[Tokens] Get Tokens'; } +export class GetTokenById { + static readonly type = '[Tokens] Get Token By Id'; + constructor(public tokenId: string) {} +} + export class UpdateToken { static readonly type = '[Tokens] Update Token'; constructor( @@ -19,3 +24,11 @@ export class DeleteToken { static readonly type = '[Tokens] Delete Token'; constructor(public tokenId: string) {} } + +export class CreateToken { + static readonly type = '[Tokens] Create Token'; + constructor( + public name: string, + public scopes: string[], + ) {} +} diff --git a/src/app/core/store/settings/tokens/tokens.selectors.ts b/src/app/core/store/settings/tokens/tokens.selectors.ts index 5915f0283..d0cac0eb4 100644 --- a/src/app/core/store/settings/tokens/tokens.selectors.ts +++ b/src/app/core/store/settings/tokens/tokens.selectors.ts @@ -14,4 +14,11 @@ export class TokensSelectors { static getTokens(state: TokensStateModel): Token[] { return state.tokens; } + + @Selector([TokensState]) + static getTokenById( + state: TokensStateModel, + ): (id: string) => Token | undefined { + return (id: string) => state.tokens.find((token) => token.id === id); + } } diff --git a/src/app/core/store/settings/tokens/tokens.state.ts b/src/app/core/store/settings/tokens/tokens.state.ts index cd72d3a74..4c873a320 100644 --- a/src/app/core/store/settings/tokens/tokens.state.ts +++ b/src/app/core/store/settings/tokens/tokens.state.ts @@ -1,14 +1,17 @@ import { inject, Injectable } from '@angular/core'; -import { State, Action, StateContext } from '@ngxs/store'; +import { State, Action, StateContext, Store } from '@ngxs/store'; import { TokensStateModel } from './tokens.models'; import { TokensService } from '@osf/features/settings/tokens/tokens.service'; import { GetScopes, GetTokens, + GetTokenById, UpdateToken, DeleteToken, + CreateToken, } from './tokens.actions'; -import { tap } from 'rxjs'; +import { tap, of } from 'rxjs'; +import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; @State({ name: 'tokens', @@ -20,6 +23,7 @@ import { tap } from 'rxjs'; @Injectable() export class TokensState { tokensService = inject(TokensService); + store = inject(Store); @Action(GetScopes) getScopes(ctx: StateContext) { @@ -39,6 +43,25 @@ export class TokensState { ); } + @Action(GetTokenById) + getTokenById(ctx: StateContext, action: GetTokenById) { + const state = ctx.getState(); + const tokenFromState = state.tokens.find( + (token: Token) => token.id === action.tokenId, + ); + + if (tokenFromState) { + return of(tokenFromState); + } + + return this.tokensService.getTokenById(action.tokenId).pipe( + tap((token) => { + const updatedTokens = [...state.tokens, token]; + ctx.patchState({ tokens: updatedTokens }); + }), + ); + } + @Action(UpdateToken) updateToken(ctx: StateContext, action: UpdateToken) { return this.tokensService @@ -46,7 +69,7 @@ export class TokensState { .pipe( tap((updatedToken) => { const state = ctx.getState(); - const updatedTokens = state.tokens.map((token) => + const updatedTokens = state.tokens.map((token: Token) => token.id === action.tokenId ? updatedToken : token, ); ctx.patchState({ tokens: updatedTokens }); @@ -60,10 +83,23 @@ export class TokensState { tap(() => { const state = ctx.getState(); const updatedTokens = state.tokens.filter( - (token) => token.id !== action.tokenId, + (token: Token) => token.id !== action.tokenId, ); ctx.patchState({ tokens: updatedTokens }); }), ); } + + @Action(CreateToken) + createToken(ctx: StateContext, action: CreateToken) { + return this.tokensService.createToken(action.name, action.scopes).pipe( + tap((newToken) => { + const state = ctx.getState(); + const updatedTokens = [newToken, ...state.tokens]; + ctx.patchState({ tokens: updatedTokens }); + + return newToken; + }), + ); + } } diff --git a/src/app/features/home/dashboard.service.ts b/src/app/features/home/dashboard.service.ts index 3662e239a..2e8904aa1 100644 --- a/src/app/features/home/dashboard.service.ts +++ b/src/app/features/home/dashboard.service.ts @@ -13,7 +13,7 @@ export class DashboardService { jsonApiService = inject(JsonApiService); getProjects(): Observable { - const userId = 'ENTER_VALID_USER_ID'; + const userId = '8bxwv'; const params = { embed: ['bibliographic_contributors', 'parent', 'root'], page: 1, @@ -21,7 +21,7 @@ export class DashboardService { }; return this.jsonApiService - .getArray( + .getDataArray( `${environment.apiUrl}/sparse/users/${userId}/nodes/`, params, ) @@ -36,7 +36,7 @@ export class DashboardService { }; return this.jsonApiService - .getArray( + .getDataArray( `${environment.apiUrl}/nodes/${projectId}/linked_nodes`, params, ) @@ -51,7 +51,7 @@ export class DashboardService { }; return this.jsonApiService - .getArray( + .getDataArray( `${environment.apiUrl}/nodes/${projectId}/linked_nodes`, params, ) diff --git a/src/app/features/my-projects/my-projects.component.html b/src/app/features/my-projects/my-projects.component.html index d300ffe97..22406e3a3 100644 --- a/src/app/features/my-projects/my-projects.component.html +++ b/src/app/features/my-projects/my-projects.component.html @@ -48,9 +48,11 @@ [rows]="5" [rowsPerPageOptions]="[2, 5, 10]" [paginator]="true" + paginatorDropdownAppendTo="body" [resizableColumns]="true" [autoLayout]="true" [scrollable]="true" + [sortMode]="'single'" > @@ -59,9 +61,9 @@ Contributors - + Modified - + @@ -98,12 +100,14 @@ @@ -112,9 +116,9 @@ Contributors - + Modified - + @@ -154,9 +158,11 @@ [rows]="5" [rowsPerPageOptions]="[2, 5, 10]" [paginator]="true" + paginatorDropdownAppendTo="body" [resizableColumns]="true" [autoLayout]="true" [scrollable]="true" + [sortMode]="'single'" > @@ -165,9 +171,9 @@ Contributors - + Modified - + diff --git a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts index 228cfcc55..751b0afe7 100644 --- a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts +++ b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts @@ -1,6 +1,9 @@ import { Component, input } from '@angular/core'; import { AddonCardComponent } from '@osf/features/settings/addons/addon-card/addon-card.component'; -import { Addon } from '@shared/entities/addons.entities'; +import { + Addon, + AuthorizedAddon, +} from '@osf/features/settings/addons/entities/addons.entities'; @Component({ selector: 'osf-addon-card-list', @@ -9,6 +12,6 @@ import { Addon } from '@shared/entities/addons.entities'; styleUrl: './addon-card-list.component.scss', }) export class AddonCardListComponent { - cards = input([]); + cards = input([]); cardButtonLabel = input(''); } diff --git a/src/app/features/settings/addons/addon-card/addon-card.component.html b/src/app/features/settings/addons/addon-card/addon-card.component.html index 88c4c936d..e5bfc063e 100644 --- a/src/app/features/settings/addons/addon-card/addon-card.component.html +++ b/src/app/features/settings/addons/addon-card/addon-card.component.html @@ -3,10 +3,12 @@ [class.mobile]="isMobile()" >
- Addon card image + @if (card()!.externalServiceName) { + Addon card image + }
diff --git a/src/app/features/settings/addons/addon-card/addon-card.component.scss b/src/app/features/settings/addons/addon-card/addon-card.component.scss index 6ff0f27d6..6282c15cc 100644 --- a/src/app/features/settings/addons/addon-card/addon-card.component.scss +++ b/src/app/features/settings/addons/addon-card/addon-card.component.scss @@ -5,7 +5,7 @@ img { height: 7rem; - max-width: 100%; + max-width: 60%; } .addon-card { diff --git a/src/app/features/settings/addons/addon-card/addon-card.component.ts b/src/app/features/settings/addons/addon-card/addon-card.component.ts index 91f7fa529..2d58f03f7 100644 --- a/src/app/features/settings/addons/addon-card/addon-card.component.ts +++ b/src/app/features/settings/addons/addon-card/addon-card.component.ts @@ -3,7 +3,10 @@ import { Button } from 'primeng/button'; import { Router } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; -import { Addon } from '@shared/entities/addons.entities'; +import { + Addon, + AuthorizedAddon, +} from '@osf/features/settings/addons/entities/addons.entities'; @Component({ selector: 'osf-addon-card', @@ -12,13 +15,18 @@ import { Addon } from '@shared/entities/addons.entities'; styleUrl: './addon-card.component.scss', }) export class AddonCardComponent { - card = input(); + card = input(); cardButtonLabel = input(''); isMobile = toSignal(inject(IS_XSMALL)); constructor(private router: Router) {} onConnect(): void { - this.router.navigate(['/settings/addons/connect-addon']); + const addon = this.card(); + if (addon) { + this.router.navigate(['/settings/addons/connect-addon'], { + state: { addon }, + }); + } } } diff --git a/src/app/features/settings/addons/addon.mapper.ts b/src/app/features/settings/addons/addon.mapper.ts index 2088be6a9..f9edb8e77 100644 --- a/src/app/features/settings/addons/addon.mapper.ts +++ b/src/app/features/settings/addons/addon.mapper.ts @@ -1,4 +1,10 @@ -import { Addon, AddonResponse } from '@shared/entities/addons.entities'; +import { + Addon, + AddonResponse, + AuthorizedAddon, + AuthorizedAddonResponse, +} from '@osf/features/settings/addons/entities/addons.entities'; +import { IncludedData } from '@core/services/json-api/json-api.entity'; export class AddonMapper { static fromResponse(response: AddonResponse): Addon { @@ -12,4 +18,39 @@ export class AddonMapper { credentialsFormat: response.attributes.credentials_format, }; } + + static fromAuthorizedAddonResponse( + response: AuthorizedAddonResponse, + included?: IncludedData[], + ): AuthorizedAddon { + const externalStorageServiceId = + response.relationships.external_storage_service.data.id; + + // Look for both storage and citation services + const matchingService = included?.find( + (item) => + (item.type === 'external-storage-services' || + item.type === 'external-citation-services') && + item.id === externalStorageServiceId, + ); + + const externalServiceName = + (matchingService?.attributes?.['external_service_name'] as string) || ''; + + return { + type: response.type, + id: response.id, + displayName: response.attributes.display_name, + apiBaseUrl: response.attributes.api_base_url, + authUrl: response.attributes.auth_url, + authorizedCapabilities: response.attributes.authorized_capabilities, + authorizedOperationNames: response.attributes.authorized_operation_names, + defaultRootFolder: response.attributes.default_root_folder, + credentialsAvailable: response.attributes.credentials_available, + accountOwnerId: response.relationships.account_owner.data.id, + externalStorageServiceId: + response.relationships.external_storage_service.data.id, + externalServiceName, + }; + } } diff --git a/src/app/features/settings/addons/addons.service.ts b/src/app/features/settings/addons/addons.service.ts index 2a3c568fb..6c6d90ae5 100644 --- a/src/app/features/settings/addons/addons.service.ts +++ b/src/app/features/settings/addons/addons.service.ts @@ -4,8 +4,10 @@ import { map, Observable } from 'rxjs'; import { Addon, AddonResponse, + AuthorizedAddon, + AuthorizedAddonResponse, UserReference, -} from '@shared/entities/addons.entities'; +} from '@osf/features/settings/addons/entities/addons.entities'; import { AddonMapper } from '@osf/features/settings/addons/addon.mapper'; import { Store } from '@ngxs/store'; import { UserState } from '@core/store/user'; @@ -19,37 +21,40 @@ export class AddonsService { baseUrl = 'https://addons.staging4.osf.io/v1/'; currentUser = this.#store.selectSignal(UserState.getCurrentUser); - // baseUrl = 'https://api.staging4.osf.io/v2/'; + getAddons(addonType: string): Observable { + return this.jsonApiService + .getDataArray( + this.baseUrl + `external-${addonType}-services`, + ) + .pipe( + map((response) => { + return response.map((item) => AddonMapper.fromResponse(item)); + }), + ); + } getUserReference(): Observable { const userUri = `https://staging4.osf.io/${this.currentUser()!.id}`; const params = { 'filter[user_uri]': userUri }; - return this.jsonApiService.getArray( + return this.jsonApiService.getDataArray( this.baseUrl + 'user-references', params, ); } - getAddons(addonType: string): Observable { + getAuthorizedAddons(addonType: string): Observable { return this.jsonApiService - .getArray(this.baseUrl + `external-${addonType}-services`) + .getFullArrayResponse( + this.baseUrl + + `user-references/3873149c-9fb7-4444-bbb9-138d9f358a85/authorized_${addonType}_accounts?include=external-${addonType}-service`, + ) .pipe( map((response) => { - console.log(response); - return response.map((item) => AddonMapper.fromResponse(item)); + return response.data.map((item) => + AddonMapper.fromAuthorizedAddonResponse(item, response.included), + ); }), ); } - - // getCitationAddons(): Observable { - // return this.jsonApiService - // .getArray(this.baseUrl + 'external-citation-services') - // .pipe( - // map((response) => { - // console.log(response); - // return response.map((item) => AddonMapper.fromResponse(item)); - // }), - // ); - // } } diff --git a/src/app/features/settings/addons/connect-addon/connect-addon.component.html b/src/app/features/settings/addons/connect-addon/connect-addon.component.html index fb9be27d4..d8b59d415 100644 --- a/src/app/features/settings/addons/connect-addon/connect-addon.component.html +++ b/src/app/features/settings/addons/connect-addon/connect-addon.component.html @@ -7,7 +7,7 @@

Figshare Terms

- + Function diff --git a/src/app/features/settings/addons/connect-addon/connect-addon.component.ts b/src/app/features/settings/addons/connect-addon/connect-addon.component.ts index 85fc54c05..46a3480d0 100644 --- a/src/app/features/settings/addons/connect-addon/connect-addon.component.ts +++ b/src/app/features/settings/addons/connect-addon/connect-addon.component.ts @@ -3,15 +3,20 @@ import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.com import { StepPanel, StepPanels, Stepper } from 'primeng/stepper'; import { Button } from 'primeng/button'; import { TableModule } from 'primeng/table'; -import { RouterLink } from '@angular/router'; +import { RouterLink, Router } from '@angular/router'; import { NgClass } from '@angular/common'; import { Card } from 'primeng/card'; import { RadioButton } from 'primeng/radiobutton'; import { FormsModule } from '@angular/forms'; import { Checkbox } from 'primeng/checkbox'; import { GoogleDriveFolder } from '@shared/entities/google-drive-folder.interface'; -import { AddonTerm } from '@shared/entities/addon-terms.interface'; +import { AddonTerm } from '@osf/features/settings/addons/entities/addon-terms.interface'; import { Divider } from 'primeng/divider'; +import { ADDON_TERMS_MESSAGES } from '../utils/addon-terms.const'; +import { + Addon, + AuthorizedAddon, +} from '@osf/features/settings/addons/entities/addons.entities'; @Component({ selector: 'osf-connect-addon', @@ -45,48 +50,96 @@ export class ConnectAddonComponent { { name: 'folder name example', selected: false }, { name: 'folder name example', selected: false }, ]; - protected readonly terms: AddonTerm[] = [ - { - function: 'Add / Update Files', - status: 'You cannot add or update files for figshare within OSF.', - type: 'warning', - }, - { - function: 'Delete files', - status: 'You cannot delete files for figshare within OSF.', - type: 'warning', - }, - { - function: 'Forking', - status: - 'Only the user who first authorized the figshare add-on within source project can transfer its authorization to a forked project or component.', - type: 'info', - }, - { - function: 'Logs', - status: - 'OSF tracks changes you make to your figshare content within OSF, but not changes made directly within figshare.', - type: 'info', - }, - { - function: 'Permissions', - status: - 'The OSF does not change permissions for linked figshare files. Privacy changes made to an OSF project or component will not affect those set in figshare.', - type: 'info', - }, - { - function: 'Registering', - status: - 'figshare content will be registered, but version history will not be copied to the registration.', - type: 'info', - }, - { - function: 'View/Download File Versions', - status: - 'figshare files can be viewed/downloaded in OSF, but version history is not supported.', - type: 'warning', - }, - ]; + protected readonly terms = signal([]); + + constructor(private router: Router) { + const addon = this.router.getCurrentNavigation()?.extras.state?.[ + 'addon' + ] as Addon | AuthorizedAddon; + if (!addon) return; + + const supportedFeatures = + 'supportedFeatures' in addon ? addon.supportedFeatures : []; + const provider = + addon.displayName || addon.externalServiceName || 'provider'; + + const terms: AddonTerm[] = [ + { + function: ADDON_TERMS_MESSAGES.labels['add-update-files'], + status: this.getTermMessage( + 'add-update-files', + supportedFeatures, + provider, + ), + type: this.getTermType('add-update-files', supportedFeatures), + }, + { + function: ADDON_TERMS_MESSAGES.labels['delete-files'], + status: this.getTermMessage( + 'delete-files', + supportedFeatures, + provider, + ), + type: this.getTermType('delete-files', supportedFeatures), + }, + { + function: ADDON_TERMS_MESSAGES.labels['forking'], + status: this.getTermMessage('forking', supportedFeatures, provider), + type: this.getTermType('forking', supportedFeatures), + }, + { + function: ADDON_TERMS_MESSAGES.labels['logs'], + status: this.getTermMessage('logs', supportedFeatures, provider), + type: this.getTermType('logs', supportedFeatures), + }, + { + function: ADDON_TERMS_MESSAGES.labels['permissions'], + status: this.getTermMessage('permissions', supportedFeatures, provider), + type: this.getTermType('permissions', supportedFeatures), + }, + { + function: ADDON_TERMS_MESSAGES.labels['registering'], + status: this.getTermMessage('registering', supportedFeatures, provider), + type: this.getTermType('registering', supportedFeatures), + }, + { + function: ADDON_TERMS_MESSAGES.labels['file-versions'], + status: this.getTermMessage( + 'file-versions', + supportedFeatures, + provider, + ), + type: this.getTermType('file-versions', supportedFeatures), + }, + ]; + + this.terms.set(terms); + } + + private getTermMessage( + term: string, + supportedFeatures: string[], + provider: string, + ): string { + const feature = term.toUpperCase().replace(/-/g, '_'); + const hasFeature = supportedFeatures.includes(feature); + + const messageKey = `${term}-${hasFeature ? 'true' : 'false'}`; + const message = + ADDON_TERMS_MESSAGES.storage[ + messageKey as keyof typeof ADDON_TERMS_MESSAGES.storage + ]; + + return message ? message.replace(/{provider}/g, provider) : ''; + } + + private getTermType( + term: string, + supportedFeatures: string[], + ): 'warning' | 'info' { + const feature = term.toUpperCase().replace(/-/g, '_'); + return supportedFeatures.includes(feature) ? 'info' : 'warning'; + } toggleFolderSelection(folder: GoogleDriveFolder): void { folder.selected = !folder.selected; diff --git a/src/app/shared/entities/addon-card.interface.ts b/src/app/features/settings/addons/entities/addon-card.interface.ts similarity index 100% rename from src/app/shared/entities/addon-card.interface.ts rename to src/app/features/settings/addons/entities/addon-card.interface.ts diff --git a/src/app/shared/entities/addon-terms.interface.ts b/src/app/features/settings/addons/entities/addon-terms.interface.ts similarity index 100% rename from src/app/shared/entities/addon-terms.interface.ts rename to src/app/features/settings/addons/entities/addon-terms.interface.ts diff --git a/src/app/features/settings/addons/entities/addons.entities.ts b/src/app/features/settings/addons/entities/addons.entities.ts new file mode 100644 index 000000000..9f3f550d1 --- /dev/null +++ b/src/app/features/settings/addons/entities/addons.entities.ts @@ -0,0 +1,131 @@ +export interface AddonResponse { + type: string; + id: string; + attributes: { + auth_uri: string; + display_name: string; + supported_features: string[]; + external_service_name: string; + credentials_format: string; + [key: string]: unknown; + }; + relationships: { + addon_imp: { + links: { + related: string; + }; + data: { + type: string; + id: string; + }; + }; + }; + links: { + self: string; + }; +} + +export interface AuthorizedAddonResponse { + type: string; + id: string; + attributes: { + display_name: string; + api_base_url: string; + auth_url: string | null; + authorized_capabilities: string[]; + authorized_operation_names: string[]; + default_root_folder: string; + credentials_available: boolean; + }; + relationships: { + account_owner: { + links: { + related: string; + }; + data: { + type: string; + id: string; + }; + }; + authorized_operations: { + links: { + related: string; + }; + }; + configured_storage_addons: { + links: { + related: string; + }; + }; + external_storage_service: { + links: { + related: string; + }; + data: { + type: string; + id: string; + }; + }; + }; + links: { + self: string; + }; +} + +export interface Addon { + type: string; + id: string; + authUri: string; + displayName: string; + externalServiceName: string; + supportedFeatures: string[]; + credentialsFormat: string; +} + +export interface AuthorizedAddon { + type: string; + id: string; + displayName: string; + apiBaseUrl: string; + authUrl: string | null; + authorizedCapabilities: string[]; + authorizedOperationNames: string[]; + defaultRootFolder: string; + credentialsAvailable: boolean; + accountOwnerId: string; + externalStorageServiceId: string; + externalServiceName: string; +} + +export interface UserReference { + type: string; + id: string; + attributes: { + user_uri: string; + }; + relationships: { + authorized_storage_accounts: { + links: { + related: string; + }; + }; + authorized_citation_accounts: { + links: { + related: string; + }; + }; + authorized_computing_accounts: { + links: { + related: string; + }; + }; + configured_resources: { + links: { + related: string; + }; + }; + }; + links: { + self: string; + }; +} diff --git a/src/app/features/settings/addons/utils/addon-terms.const.ts b/src/app/features/settings/addons/utils/addon-terms.const.ts new file mode 100644 index 000000000..6fb5bb0f9 --- /dev/null +++ b/src/app/features/settings/addons/utils/addon-terms.const.ts @@ -0,0 +1,75 @@ +export interface AddonTermsMessages { + labels: { + 'add-update-files': string; + 'delete-files': string; + forking: string; + logs: string; + permissions: string; + registering: string; + 'file-versions': string; + }; + storage: { + 'add-update-files-true': string; + 'add-update-files-false': string; + 'add-update-files-partial': string; + 'delete-files-true': string; + 'delete-files-false': string; + 'delete-files-partial': string; + 'forking-true': string; + 'logs-true': string; + 'logs-false': string; + 'permissions-true': string; + 'registering-true': string; + 'file-versions-true': string; + 'file-versions-false': string; + }; + citation: { + 'forking-partial': string; + 'permissions-partial': string; + 'registering-false': string; + }; +} + +export const ADDON_TERMS_MESSAGES: AddonTermsMessages = { + labels: { + 'add-update-files': 'Add / update files', + 'delete-files': 'Delete files', + forking: 'Forking', + logs: 'Logs', + permissions: 'Permissions', + registering: 'Registering', + 'file-versions': 'View / download file versions', + }, + storage: { + 'add-update-files-true': + 'Adding/updating files within OSF will be reflected in {provider}.', + 'add-update-files-false': + 'You cannot add or update files for {provider} within OSF.', + 'add-update-files-partial': 'Files can be added but not updated.', + 'delete-files-true': 'Files deleted in OSF will be deleted in {provider}.', + 'delete-files-false': 'You cannot delete files for {provider} within OSF.', + 'delete-files-partial': + '{provider} has limitations on which files can be deleted within OSF.', + 'forking-true': + 'Only the user who first authorized the {provider} add-on within source project can transfer its authorization to a forked project or component.', + 'logs-true': + 'OSF tracks changes you make to your {provider} content within OSF, but not changes made directly within {provider}.', + 'logs-false': + 'OSF does not keep track of changes made using {provider} directly.', + 'permissions-true': + 'The OSF does not change permissions for linked {provider} files. Privacy changes made to an OSF project or component will not affect those set in {provider}.', + 'registering-true': + '{provider} content will be registered, but version history will not be copied to the registration.', + 'file-versions-true': + '{provider} files and their versions can be viewed/downloaded in OSF.', + 'file-versions-false': + '{provider} files can be viewed/downloaded in OSF, but version history is not supported.', + }, + citation: { + 'forking-partial': + 'Forking a project or component does not copy {provider} authorization unless the user forking the project is the same user who authorized the {provider} add-on in the source project being forked.', + 'permissions-partial': + 'Making an OSF project public or private is independent of making a {provider} folder public or private. The OSF does not alter the permissions of a linked {provider} folder.', + 'registering-false': '{provider} content will not be registered.', + }, +}; diff --git a/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.scss b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.scss index a6dd08ec9..55b2d3a52 100644 --- a/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.scss +++ b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.scss @@ -52,35 +52,6 @@ .card-actions { @include mix.flex-center-right; } - - .copy-notification { - position: absolute; - top: -45px; - left: 50%; - transform: translateX(-50%); - padding: 10px; - opacity: 0; - transition: opacity 0.3s ease; - white-space: nowrap; - border-radius: 8px; - box-shadow: 0 0 4px 0 #00000029; - background: var.$white; - - &.visible { - opacity: 1; - } - - &:after { - content: ""; - position: absolute; - bottom: -5px; - left: 50%; - transform: translateX(-50%); - border-width: 5px 5px 0; - border-style: solid; - border-color: var.$grey-1 transparent transparent; - } - } } } } diff --git a/src/app/features/settings/tokens/entities/tokens.models.ts b/src/app/features/settings/tokens/entities/tokens.models.ts index ba291230e..bb278dae6 100644 --- a/src/app/features/settings/tokens/entities/tokens.models.ts +++ b/src/app/features/settings/tokens/entities/tokens.models.ts @@ -1,5 +1,3 @@ -import { Scope } from '@osf/features/settings/tokens/entities/scope.interface'; - // API Request Model export interface TokenCreateRequest { data: { @@ -13,58 +11,32 @@ export interface TokenCreateRequest { // API Response Model export interface TokenCreateResponse { - data: { - id: string; - type: 'tokens'; - attributes: { - name: string; - token_id: string; - }; - relationships: { - scopes: { - links: { - related: { - href: string; - meta: Record; - }; - }; - }; - owner: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data: { - id: string; - type: string; - }; - }; - }; - embeds: { - scopes: { - data: Scope[]; - meta: { - total: number; - per_page: number; - }; - links: { - self: string; - first: string | null; - last: string | null; - prev: string | null; - next: string | null; - }; - }; - }; - links: { - html: string; - self: string; - }; + id: string; + type: 'tokens'; + attributes: { + name: string; + token_id: string; + scopes: string; + owner: string; + }; + links: { + html: string; + self: string; + }; +} + +// API Response Model for GET request +export interface TokenGetResponse { + id: string; + type: 'tokens'; + attributes: { + name: string; + scopes: string; + owner: string; }; - meta: { - version: string; + links: { + html: string; + self: string; }; } diff --git a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.html b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.html index abe6c46eb..1249f93f3 100644 --- a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.html +++ b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.html @@ -1,6 +1,6 @@
diff --git a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts index 23dbd8612..208bad697 100644 --- a/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts +++ b/src/app/features/settings/tokens/token-add-edit-form/token-add-edit-form.component.ts @@ -5,7 +5,7 @@ import { input, OnInit, } from '@angular/core'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { Button } from 'primeng/button'; import { InputText } from 'primeng/inputtext'; import { Checkbox } from 'primeng/checkbox'; @@ -23,9 +23,16 @@ import { CommonModule } from '@angular/common'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { toSignal } from '@angular/core/rxjs-interop'; import { Store } from '@ngxs/store'; -import { TokensSelectors } from '@core/store/settings'; -import { TokensService } from '@osf/features/settings/tokens/tokens.service'; +import { + TokensSelectors, + CreateToken, + UpdateToken, + GetTokens, +} from '@core/store/settings'; import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; +import { map } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TokenCreatedDialogComponent } from '@osf/features/settings/tokens/token-created-dialog/token-created-dialog.component'; @Component({ selector: 'osf-token-add-edit-form', @@ -35,14 +42,18 @@ import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class TokenAddEditFormComponent implements OnInit { - #isXSmall$ = inject(IS_XSMALL); #store = inject(Store); - #tokensService = inject(TokensService); + #route = inject(ActivatedRoute); + #router = inject(Router); + #dialogService = inject(DialogService); isEditMode = input(false); initialValues = input(null); + protected readonly tokenId = toSignal( + this.#route.params.pipe(map((params) => params['id'])), + ); protected readonly dialogRef = inject(DynamicDialogRef); protected readonly TokenFormControls = TokenFormControls; - protected readonly isXSmall = toSignal(this.#isXSmall$); + protected readonly isMobile = toSignal(inject(IS_XSMALL)); protected readonly tokenScopes = this.#store.selectSignal( TokensSelectors.getScopes, ); @@ -59,6 +70,7 @@ export class TokenAddEditFormComponent implements OnInit { }); ngOnInit(): void { + this.#store.dispatch(GetTokens); if (this.initialValues()) { this.tokenForm.patchValue({ [TokenFormControls.TokenName]: this.initialValues()?.name, @@ -67,7 +79,7 @@ export class TokenAddEditFormComponent implements OnInit { } } - submitForm(): void { + handleSubmitForm(): void { if (!this.tokenForm.valid) { this.tokenForm.markAllAsTouched(); this.tokenForm.get(TokenFormControls.TokenName)?.markAsDirty(); @@ -78,13 +90,42 @@ export class TokenAddEditFormComponent implements OnInit { const { tokenName, scopes } = this.tokenForm.value; if (!tokenName || !scopes) return; - this.#tokensService.createToken(tokenName, scopes).subscribe({ - next: (token) => { - this.dialogRef.close(token); - }, - error: (error) => { - console.error('Failed to create token:', error); - // TODO: Show error message to user + if (!this.isEditMode()) { + this.#store.dispatch(new CreateToken(tokenName, scopes)).subscribe({ + complete: () => { + const tokens = this.#store.selectSnapshot(TokensSelectors.getTokens); + const newToken = tokens[0]; + this.dialogRef.close(); + this.#showTokenCreatedDialog(newToken.name, newToken.tokenId); + }, + }); + } else { + this.#store + .dispatch(new UpdateToken(this.tokenId(), tokenName, scopes)) + .subscribe({ + complete: () => { + this.#router.navigate(['settings/tokens']); + }, + }); + } + } + + #showTokenCreatedDialog(tokenName: string, tokenValue: string) { + let dialogWidth = '500px'; + + if (this.isMobile()) { + dialogWidth = '345px'; + } + + this.#dialogService.open(TokenCreatedDialogComponent, { + width: dialogWidth, + header: 'Token Successfully Created', + closeOnEscape: true, + modal: true, + closable: true, + data: { + tokenName, + tokenValue, }, }); } diff --git a/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.html b/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.html new file mode 100644 index 000000000..f00b0ea38 --- /dev/null +++ b/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.html @@ -0,0 +1,43 @@ +
+
+

+ Token {{ tokenName() }} successfully created. +

+

+ This token will never expire. This token should never be shared with + others. If it is accidentally revealed publicly, it should be deactivated + immediately. +

+
+ +
+
+
+ + Copied! + + + + + + + +
+
+

This is the only time your token will be displayed.

+
+ +
+ +
+
diff --git a/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.scss b/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.scss new file mode 100644 index 000000000..6f2b243ef --- /dev/null +++ b/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.scss @@ -0,0 +1,12 @@ +@use "assets/styles/variables" as var; + +:host { + h3 { + color: var.$red-1; + text-transform: none; + } + + input { + text-overflow: ellipsis; + } +} diff --git a/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.spec.ts b/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.spec.ts new file mode 100644 index 000000000..f436a1348 --- /dev/null +++ b/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TokenCreatedDialogComponent } from './token-created-dialog.component'; + +describe('TokenCreatedDialogComponent', () => { + let component: TokenCreatedDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TokenCreatedDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TokenCreatedDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.ts b/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.ts new file mode 100644 index 000000000..7f9e28de7 --- /dev/null +++ b/src/app/features/settings/tokens/token-created-dialog/token-created-dialog.component.ts @@ -0,0 +1,47 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, + signal, + viewChild, + afterNextRender, + ElementRef, +} from '@angular/core'; +import { Button } from 'primeng/button'; +import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { InputText } from 'primeng/inputtext'; +import { IconField } from 'primeng/iconfield'; +import { InputIcon } from 'primeng/inputicon'; +import { ClipboardModule } from '@angular/cdk/clipboard'; + +@Component({ + selector: 'osf-token-created-dialog', + imports: [Button, InputText, IconField, InputIcon, ClipboardModule], + templateUrl: './token-created-dialog.component.html', + styleUrl: './token-created-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TokenCreatedDialogComponent { + protected readonly dialogRef = inject(DynamicDialogRef); + protected readonly config = inject(DynamicDialogConfig); + + readonly tokenInput = viewChild>('tokenInput'); + readonly tokenName = input(this.config.data?.tokenName ?? ''); + readonly tokenId = input(this.config.data?.tokenValue ?? ''); + protected readonly tokenCopiedNotificationVisible = signal(false); + + constructor() { + afterNextRender(() => { + const input = this.tokenInput(); + if (input) { + input.nativeElement.setSelectionRange(0, 0); + } + }); + } + + protected tokenCopiedToClipboard(): void { + this.tokenCopiedNotificationVisible.set(true); + setTimeout(() => this.tokenCopiedNotificationVisible.set(false), 2000); + } +} diff --git a/src/app/features/settings/tokens/token-details/token-details.component.html b/src/app/features/settings/tokens/token-details/token-details.component.html index 06cc66be1..26f80cae7 100644 --- a/src/app/features/settings/tokens/token-details/token-details.component.html +++ b/src/app/features/settings/tokens/token-details/token-details.component.html @@ -8,19 +8,20 @@ < Back to list of personal tokens + @if (token()) { +
+

{{ token()?.name }}

+ +
-
-

{{ token().name }}

- -
- - -

Edit Token

- -
+ +

Edit Token

+ +
+ }
diff --git a/src/app/features/settings/tokens/token-details/token-details.component.ts b/src/app/features/settings/tokens/token-details/token-details.component.ts index 7af6c691d..db2fb9234 100644 --- a/src/app/features/settings/tokens/token-details/token-details.component.ts +++ b/src/app/features/settings/tokens/token-details/token-details.component.ts @@ -2,19 +2,25 @@ import { ChangeDetectionStrategy, Component, inject, - signal, + computed, } from '@angular/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { FormsModule } from '@angular/forms'; -import { RouterLink } from '@angular/router'; +import { RouterLink, ActivatedRoute, Router } from '@angular/router'; import { ConfirmationService } from 'primeng/api'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { toSignal } from '@angular/core/rxjs-interop'; -import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; import { defaultConfirmationConfig } from '@shared/helpers/default-confirmation-config.helper'; import { TokenAddEditFormComponent } from '@osf/features/settings/tokens/token-add-edit-form/token-add-edit-form.component'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { map, switchMap, of } from 'rxjs'; +import { Store } from '@ngxs/store'; +import { TokensSelectors } from '@core/store/settings/tokens/tokens.selectors'; +import { + DeleteToken, + GetTokenById, +} from '@core/store/settings/tokens/tokens.actions'; @Component({ selector: 'osf-token-details', @@ -27,15 +33,32 @@ import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; export class TokenDetailsComponent { #confirmationService = inject(ConfirmationService); #isXSmall$ = inject(IS_XSMALL); - readonly token = signal({ - id: '1', - name: 'Token name example', - tokenId: 'token1', - scopes: ['osf.full_read', 'osf.full_write'], - ownerId: 'user1', - htmlUrl: 'https://osf.io/settings/tokens/1', - apiUrl: 'https://api.osf.io/v2/tokens/1', + #route = inject(ActivatedRoute); + #router = inject(Router); + #store = inject(Store); + + readonly tokenId = toSignal( + this.#route.params.pipe( + map((params) => params['id']), + switchMap((tokenId) => { + const token = this.#store.selectSnapshot(TokensSelectors.getTokenById)( + tokenId, + ); + if (!token) { + this.#store.dispatch(new GetTokenById(tokenId)); + } + return of(tokenId); + }), + ), + ); + + readonly token = computed(() => { + const id = this.tokenId(); + if (!id) return null; + const token = this.#store.selectSignal(TokensSelectors.getTokenById)(); + return token(id) ?? null; }); + protected readonly isXSmall = toSignal(this.#isXSmall$); deleteToken(): void { @@ -43,14 +66,18 @@ export class TokenDetailsComponent { ...defaultConfirmationConfig, message: 'Are you sure you want to delete this token? This action cannot be reversed.', - header: `Delete Token ${this.token().name}?`, + header: `Delete Token ${this.token()?.name}?`, acceptButtonProps: { ...defaultConfirmationConfig.acceptButtonProps, severity: 'danger', label: 'Delete', }, accept: () => { - //TODO integrate API + this.#store.dispatch(new DeleteToken(this.tokenId())).subscribe({ + next: () => { + this.#router.navigate(['settings/tokens']); + }, + }); }, }); } diff --git a/src/app/features/settings/tokens/token.mapper.ts b/src/app/features/settings/tokens/token.mapper.ts index 67d2552b8..3bd6de110 100644 --- a/src/app/features/settings/tokens/token.mapper.ts +++ b/src/app/features/settings/tokens/token.mapper.ts @@ -2,6 +2,7 @@ import { Token, TokenCreateRequest, TokenCreateResponse, + TokenGetResponse, } from '@osf/features/settings/tokens/entities/tokens.models'; export class TokenMapper { @@ -17,16 +18,27 @@ export class TokenMapper { }; } - static fromResponse(response: TokenCreateResponse): Token { - const { data } = response; + static fromCreateResponse(response: TokenCreateResponse): Token { return { - id: data.id, - name: data.attributes.name, - tokenId: data.attributes.token_id, - scopes: data.embeds.scopes.data.map((scope) => scope.id), - ownerId: data.relationships.owner.data.id, - htmlUrl: data.links.html, - apiUrl: data.links.self, + id: response.id, + name: response.attributes.name, + tokenId: response.attributes.token_id, + scopes: response.attributes.scopes.split(' '), + ownerId: response.attributes.owner, + htmlUrl: response.links.html, + apiUrl: response.links.self, + }; + } + + static fromGetResponse(response: TokenGetResponse): Token { + return { + id: response.id, + name: response.attributes.name, + tokenId: response.id, + scopes: response.attributes.scopes.split(' '), + ownerId: response.attributes.owner, + htmlUrl: response.links.html, + apiUrl: response.links.self, }; } } diff --git a/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts b/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts index 09a01ea41..e66ce686b 100644 --- a/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts +++ b/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject, - signal, + OnInit, } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; @@ -12,6 +12,8 @@ import { Card } from 'primeng/card'; import { RouterLink } from '@angular/router'; import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; import { defaultConfirmationConfig } from '@shared/helpers/default-confirmation-config.helper'; +import { Store } from '@ngxs/store'; +import { DeleteToken, GetTokens, TokensSelectors } from '@core/store/settings'; @Component({ selector: 'osf-tokens-list', @@ -20,49 +22,13 @@ import { defaultConfirmationConfig } from '@shared/helpers/default-confirmation- styleUrl: './tokens-list.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TokensListComponent { +export class TokensListComponent implements OnInit { + #store = inject(Store); #confirmationService = inject(ConfirmationService); #isXSmall$ = inject(IS_XSMALL); protected readonly isXSmall = toSignal(this.#isXSmall$); - tokens = signal([ - { - id: '1', - name: 'Token name example 1', - tokenId: 'token1', - scopes: ['osf.full_read', 'osf.full_write'], - ownerId: 'user1', - htmlUrl: 'https://osf.io/settings/tokens/1', - apiUrl: 'https://api.osf.io/v2/tokens/1', - }, - { - id: '2', - name: 'Token name example 2', - tokenId: 'token2', - scopes: ['osf.full_read', 'osf.full_write'], - ownerId: 'user1', - htmlUrl: 'https://osf.io/settings/tokens/2', - apiUrl: 'https://api.osf.io/v2/tokens/2', - }, - { - id: '3', - name: 'Token name example 3', - tokenId: 'token3', - scopes: ['osf.full_read', 'osf.full_write'], - ownerId: 'user1', - htmlUrl: 'https://osf.io/settings/tokens/3', - apiUrl: 'https://api.osf.io/v2/tokens/3', - }, - { - id: '4', - name: 'Token name example 4', - tokenId: 'token4', - scopes: ['osf.full_read', 'osf.full_write'], - ownerId: 'user1', - htmlUrl: 'https://osf.io/settings/tokens/4', - apiUrl: 'https://api.osf.io/v2/tokens/4', - }, - ]); + tokens = this.#store.selectSignal(TokensSelectors.getTokens); deleteApp(token: Token) { this.#confirmationService.confirm({ @@ -76,8 +42,14 @@ export class TokensListComponent { label: 'Delete', }, accept: () => { - //TODO integrate API + this.#store.dispatch(new DeleteToken(token.id)); }, }); } + + ngOnInit(): void { + if (!this.tokens().length) { + this.#store.dispatch(GetTokens); + } + } } diff --git a/src/app/features/settings/tokens/tokens.component.html b/src/app/features/settings/tokens/tokens.component.html index 0b428d9e1..289bf9a99 100644 --- a/src/app/features/settings/tokens/tokens.component.html +++ b/src/app/features/settings/tokens/tokens.component.html @@ -1,5 +1,5 @@ this.#router.url === '/settings/tokens'), + ), + { initialValue: this.#router.url === '/settings/tokens' }, + ); createToken(): void { let dialogWidth = '850px'; diff --git a/src/app/features/settings/tokens/tokens.service.ts b/src/app/features/settings/tokens/tokens.service.ts index c8ff5862f..e9ac1e2e1 100644 --- a/src/app/features/settings/tokens/tokens.service.ts +++ b/src/app/features/settings/tokens/tokens.service.ts @@ -1,7 +1,11 @@ import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@core/services/json-api/json-api.service'; import { Observable } from 'rxjs'; -import { Token, TokenCreateResponse } from './entities/tokens.models'; +import { + Token, + TokenCreateResponse, + TokenGetResponse, +} from './entities/tokens.models'; import { map } from 'rxjs/operators'; import { TokenMapper } from '@osf/features/settings/tokens/token.mapper'; import { Scope } from '@osf/features/settings/tokens/entities/scope.interface'; @@ -14,19 +18,34 @@ export class TokensService { baseUrl = 'https://api.staging4.osf.io/v2/'; getScopes(): Observable { - return this.jsonApiService.getArray(this.baseUrl + 'scopes'); + return this.jsonApiService.getDataArray(this.baseUrl + 'scopes'); } getTokens(): Observable { - return this.jsonApiService.getArray(this.baseUrl + 'tokens'); + return this.jsonApiService + .getDataArray(this.baseUrl + 'tokens') + .pipe( + map((responses) => { + console.log(responses); + return responses.map((response) => + TokenMapper.fromGetResponse(response), + ); + }), + ); + } + + getTokenById(tokenId: string): Observable { + return this.jsonApiService + .getData(this.baseUrl + `tokens/${tokenId}`) + .pipe(map((response) => TokenMapper.fromGetResponse(response))); } createToken(name: string, scopes: string[]): Observable { const request = TokenMapper.toRequest(name, scopes); return this.jsonApiService - .post(this.baseUrl + 'tokens', request) - .pipe(map((response) => TokenMapper.fromResponse(response))); + .post(this.baseUrl + 'tokens/', request) + .pipe(map((response) => TokenMapper.fromCreateResponse(response))); } updateToken( @@ -38,7 +57,7 @@ export class TokensService { return this.jsonApiService .patch(this.baseUrl + `tokens/${tokenId}`, request) - .pipe(map((response) => TokenMapper.fromResponse(response))); + .pipe(map((response) => TokenMapper.fromCreateResponse(response))); } deleteToken(tokenId: string): Observable { diff --git a/src/app/shared/entities/addons.entities.ts b/src/app/shared/entities/addons.entities.ts deleted file mode 100644 index 260baf029..000000000 --- a/src/app/shared/entities/addons.entities.ts +++ /dev/null @@ -1,69 +0,0 @@ -export interface AddonResponse { - type: string; - id: string; - attributes: { - auth_uri: string; - display_name: string; - supported_features: string[]; - external_service_name: string; - credentials_format: string; - [key: string]: unknown; - }; - relationships: { - addon_imp: { - links: { - related: string; - }; - data: { - type: string; - id: string; - }; - }; - }; - links: { - self: string; - }; -} - -export interface Addon { - type: string; - id: string; - authUri: string; - displayName: string; - externalServiceName: string; - supportedFeatures: string[]; - credentialsFormat: string; -} - -export interface UserReference { - type: string; - id: string; - attributes: { - user_uri: string; - }; - relationships: { - authorized_storage_accounts: { - links: { - related: string; - }; - }; - authorized_citation_accounts: { - links: { - related: string; - }; - }; - authorized_computing_accounts: { - links: { - related: string; - }; - }; - configured_resources: { - links: { - related: string; - }; - }; - }; - links: { - self: string; - }; -} diff --git a/src/assets/styles/overrides/confirmation-dialog.scss b/src/assets/styles/overrides/confirmation-dialog.scss index 9b7157887..50efc3b59 100644 --- a/src/assets/styles/overrides/confirmation-dialog.scss +++ b/src/assets/styles/overrides/confirmation-dialog.scss @@ -1,5 +1,9 @@ @use "../variables" as var; +.p-dialog-mask { + z-index: 2103 !important; +} + .p-dialog { min-height: 212px; width: 450px; diff --git a/src/assets/styles/overrides/paginator.scss b/src/assets/styles/overrides/paginator.scss index 851a57673..728bfd5d1 100644 --- a/src/assets/styles/overrides/paginator.scss +++ b/src/assets/styles/overrides/paginator.scss @@ -4,6 +4,38 @@ background: white; padding: 0.8rem 0 0 0; row-gap: 0.8rem; + + .p-select-overlay { + background: white; + border: 1px solid var.$grey-2; + color: var.$dark-blue-1; + } + + .p-select-option { + &.p-select-option-selected { + background-color: white; + color: var.$dark-blue-1; + + &:hover { + background: var.$bg-blue-3; + color: var.$dark-blue-1; + } + } + + &:not(.p-select-option-selected):not(.p-disabled).p-focus { + background: var.$bg-blue-3; + color: var.$dark-blue-1; + } + } + + p-select { + background: white; + border: 1px solid var.$grey-2; + + span { + color: var.$dark-blue-1; + } + } } .p-paginator-page { @@ -47,35 +79,3 @@ .p-paginator-prev:not(.p-disabled):hover { background: white; } - -.p-select-overlay { - background: white; - border: 1px solid var.$grey-2; - color: var.$dark-blue-1; -} - -.p-select-option { - &.p-select-option-selected { - background-color: white; - color: var.$dark-blue-1; - - &:hover { - background: var.$bg-blue-3; - color: var.$dark-blue-1; - } - } - - &:not(.p-select-option-selected):not(.p-disabled).p-focus { - background: var.$bg-blue-3; - color: var.$dark-blue-1; - } -} - -p-select { - background: white; - border: 1px solid var.$grey-2; - - span { - color: var.$dark-blue-1; - } -} diff --git a/src/assets/styles/overrides/select.scss b/src/assets/styles/overrides/select.scss index eead30a9b..9852fb310 100644 --- a/src/assets/styles/overrides/select.scss +++ b/src/assets/styles/overrides/select.scss @@ -13,6 +13,10 @@ .p-select-label { @include mix.flex-align-center; width: 100%; + + &::placeholder { + color: var.$grey-1; + } } .p-select-option { diff --git a/src/assets/styles/styles.scss b/src/assets/styles/styles.scss index d8c2c31e9..6d985814c 100644 --- a/src/assets/styles/styles.scss +++ b/src/assets/styles/styles.scss @@ -105,4 +105,33 @@ display: block; } } + + .copy-notification { + position: absolute; + top: -45px; + left: 50%; + transform: translateX(-50%); + padding: 10px; + opacity: 0; + transition: opacity 0.3s ease; + white-space: nowrap; + border-radius: 8px; + box-shadow: 0 0 4px 0 #00000029; + background: var.$white; + + &.visible { + opacity: 1; + } + + &:after { + content: ""; + position: absolute; + bottom: -5px; + left: 50%; + transform: translateX(-50%); + border-width: 5px 5px 0; + border-style: solid; + border-color: var.$grey-1 transparent transparent; + } + } } From c287d9d3da2b13a366719d8b125d656ed43bd156 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Thu, 17 Apr 2025 16:16:08 +0300 Subject: [PATCH 10/11] feat(pat-addons-api-integration): added connecting addons functionality --- .../services/json-api/json-api.service.ts | 1 + .../store/settings/addons/addons.actions.ts | 38 ++ .../store/settings/addons/addons.models.ts | 4 + .../store/settings/addons/addons.selectors.ts | 10 + .../store/settings/addons/addons.state.ts | 105 ++++- src/app/core/store/user/user.selectors.ts | 11 + src/app/core/store/user/user.state.ts | 8 +- src/app/features/home/dashboard.service.ts | 2 +- .../addon-card-list.component.html | 6 +- .../addon-card-list.component.ts | 3 +- .../addon-card/addon-card.component.html | 65 ++- .../addon-card/addon-card.component.scss | 14 +- .../addons/addon-card/addon-card.component.ts | 63 ++- .../features/settings/addons/addon.mapper.ts | 38 +- .../settings/addons/addons.component.html | 15 +- .../settings/addons/addons.component.ts | 87 +++- .../settings/addons/addons.service.ts | 69 ++- .../connect-addon.component.html | 183 +++++++- .../connect-addon/connect-addon.component.ts | 403 +++++++++++++----- .../addons/entities/addon-card.interface.ts | 4 - .../addons/entities/addon-form.entities.ts | 21 + .../addons/entities/addon-terms.interface.ts | 2 +- .../addons/entities/addons.entities.ts | 99 ++++- .../entities/credentials-format.enum.ts | 8 + .../addons/utils/addon-terms.const.ts | 143 ++++--- .../tokens-list/tokens-list.component.html | 2 +- .../tokens-list/tokens-list.component.ts | 2 +- .../settings/tokens/tokens.service.ts | 1 - src/assets/styles/overrides/table.scss | 10 +- 29 files changed, 1132 insertions(+), 285 deletions(-) create mode 100644 src/app/core/store/user/user.selectors.ts delete mode 100644 src/app/features/settings/addons/entities/addon-card.interface.ts create mode 100644 src/app/features/settings/addons/entities/addon-form.entities.ts create mode 100644 src/app/features/settings/addons/entities/credentials-format.enum.ts diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts index a38a32a58..e4ec8f422 100644 --- a/src/app/core/services/json-api/json-api.service.ts +++ b/src/app/core/services/json-api/json-api.service.ts @@ -13,6 +13,7 @@ export class JsonApiService { readonly #headers = new HttpHeaders({ Authorization: this.#token, Accept: 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', }); get(url: string, params?: Record): Observable { diff --git a/src/app/core/store/settings/addons/addons.actions.ts b/src/app/core/store/settings/addons/addons.actions.ts index c87e13a50..6cb8d4a41 100644 --- a/src/app/core/store/settings/addons/addons.actions.ts +++ b/src/app/core/store/settings/addons/addons.actions.ts @@ -1,3 +1,5 @@ +import { AddonRequest } from '@osf/features/settings/addons/entities/addons.entities'; + export class GetStorageAddons { static readonly type = '[Addons] Get Storage Addons'; } @@ -8,8 +10,44 @@ export class GetCitationAddons { export class GetAuthorizedStorageAddons { static readonly type = '[Addons] Get Authorized Storage Addons'; + + constructor(public referenceId: string) {} } export class GetAuthorizedCitationAddons { static readonly type = '[Addons] Get Authorized Citation Addons'; + + constructor(public referenceId: string) {} +} + +export class CreateAuthorizedAddon { + static readonly type = '[Addons] Create Storage Addon'; + + constructor( + public payload: AddonRequest, + public addonType: string, + ) {} +} + +export class UpdateAuthorizedAddon { + static readonly type = '[Addons] Update Storage Addon'; + + constructor( + public payload: AddonRequest, + public addonType: string, + public addonId: string, + ) {} +} + +export class GetAddonsUserReference { + static readonly type = '[Addons] Get Addons User Reference'; +} + +export class DeleteAuthorizedAddon { + static readonly type = '[Addons] Delete Authorized Addon'; + + constructor( + public payload: string, + public addonType: string, + ) {} } diff --git a/src/app/core/store/settings/addons/addons.models.ts b/src/app/core/store/settings/addons/addons.models.ts index 250f5d827..e2095fdb3 100644 --- a/src/app/core/store/settings/addons/addons.models.ts +++ b/src/app/core/store/settings/addons/addons.models.ts @@ -1,6 +1,8 @@ import { Addon, AuthorizedAddon, + AddonResponse, + UserReference, } from '@osf/features/settings/addons/entities/addons.entities'; export interface AddonsStateModel { @@ -8,4 +10,6 @@ export interface AddonsStateModel { citationAddons: Addon[]; authorizedStorageAddons: AuthorizedAddon[]; authorizedCitationAddons: AuthorizedAddon[]; + addonsUserReference: UserReference[]; + createdUpdatedAuthorizedAddon: AddonResponse | null; } diff --git a/src/app/core/store/settings/addons/addons.selectors.ts b/src/app/core/store/settings/addons/addons.selectors.ts index 65acaa795..d3cac15df 100644 --- a/src/app/core/store/settings/addons/addons.selectors.ts +++ b/src/app/core/store/settings/addons/addons.selectors.ts @@ -23,4 +23,14 @@ export class AddonsSelectors { static getAuthorizedCitationAddons(state: AddonsStateModel) { return state.authorizedCitationAddons; } + + @Selector([AddonsState]) + static getAddonUserReference(state: AddonsStateModel) { + return state.addonsUserReference; + } + + @Selector([AddonsState]) + static getCreatedOrUpdatedStorageAddon(state: AddonsStateModel) { + return state.createdUpdatedAuthorizedAddon; + } } diff --git a/src/app/core/store/settings/addons/addons.state.ts b/src/app/core/store/settings/addons/addons.state.ts index 3a0d81ec4..3eaba4860 100644 --- a/src/app/core/store/settings/addons/addons.state.ts +++ b/src/app/core/store/settings/addons/addons.state.ts @@ -6,9 +6,14 @@ import { GetStorageAddons, GetAuthorizedStorageAddons, GetAuthorizedCitationAddons, + GetAddonsUserReference, + DeleteAuthorizedAddon, + CreateAuthorizedAddon, + UpdateAuthorizedAddon, } from './addons.actions'; -import { tap } from 'rxjs'; +import { Observable, switchMap, tap } from 'rxjs'; import { AddonsStateModel } from './addons.models'; +import { AddonResponse } from '@osf/features/settings/addons/entities/addons.entities'; @State({ name: 'addons', @@ -17,6 +22,8 @@ import { AddonsStateModel } from './addons.models'; citationAddons: [], authorizedStorageAddons: [], authorizedCitationAddons: [], + addonsUserReference: [], + createdUpdatedAuthorizedAddon: null, }, }) @Injectable() @@ -27,7 +34,6 @@ export class AddonsState { getStorageAddons(ctx: StateContext) { return this.addonsService.getAddons('storage').pipe( tap((addons) => { - console.log('storage', addons); ctx.patchState({ storageAddons: addons }); }), ); @@ -37,29 +43,98 @@ export class AddonsState { getCitationAddons(ctx: StateContext) { return this.addonsService.getAddons('citation').pipe( tap((addons) => { - console.log('citation', addons); ctx.patchState({ citationAddons: addons }); }), ); } @Action(GetAuthorizedStorageAddons) - getAuthorizedStorageAddons(ctx: StateContext) { - return this.addonsService.getAuthorizedAddons('storage').pipe( - tap((addons) => { - console.log('authorized storage', addons); - ctx.patchState({ authorizedStorageAddons: addons }); - }), - ); + getAuthorizedStorageAddons( + ctx: StateContext, + action: GetAuthorizedStorageAddons, + ) { + return this.addonsService + .getAuthorizedAddons('storage', action.referenceId) + .pipe( + tap((addons) => { + ctx.patchState({ authorizedStorageAddons: addons }); + }), + ); } @Action(GetAuthorizedCitationAddons) - getAuthorizedCitationAddons(ctx: StateContext) { - return this.addonsService.getAuthorizedAddons('citation').pipe( - tap((addons) => { - console.log('authorized citation', addons); - ctx.patchState({ authorizedCitationAddons: addons }); + getAuthorizedCitationAddons( + ctx: StateContext, + action: GetAuthorizedCitationAddons, + ) { + return this.addonsService + .getAuthorizedAddons('citation', action.referenceId) + .pipe( + tap((addons) => { + ctx.patchState({ authorizedCitationAddons: addons }); + }), + ); + } + + @Action(CreateAuthorizedAddon) + createAuthorizedAddon( + ctx: StateContext, + action: CreateAuthorizedAddon, + ): Observable { + return this.addonsService + .createAuthorizedAddon(action.payload, action.addonType) + .pipe( + tap((addon) => { + ctx.patchState({ createdUpdatedAuthorizedAddon: addon }); + const referenceId = ctx.getState().addonsUserReference[0].id; + return action.addonType === 'storage' + ? ctx.dispatch(new GetAuthorizedStorageAddons(referenceId)) + : ctx.dispatch(new GetAuthorizedCitationAddons(referenceId)); + }), + ); + } + + @Action(UpdateAuthorizedAddon) + updateAuthorizedAddon( + ctx: StateContext, + action: UpdateAuthorizedAddon, + ): Observable { + return this.addonsService + .updateAuthorizedAddon(action.payload, action.addonType, action.addonId) + .pipe( + tap((addon) => { + ctx.patchState({ createdUpdatedAuthorizedAddon: addon }); + const referenceId = ctx.getState().addonsUserReference[0].id; + return action.addonType === 'storage' + ? ctx.dispatch(new GetAuthorizedStorageAddons(referenceId)) + : ctx.dispatch(new GetAuthorizedCitationAddons(referenceId)); + }), + ); + } + + @Action(GetAddonsUserReference) + getAddonsUserReference(ctx: StateContext) { + return this.addonsService.getAddonsUserReference().pipe( + tap((userReference) => { + ctx.patchState({ addonsUserReference: userReference }); }), ); } + + @Action(DeleteAuthorizedAddon) + deleteAuthorizedAddon( + ctx: StateContext, + action: DeleteAuthorizedAddon, + ) { + return this.addonsService + .deleteAuthorizedAddon(action.payload, action.addonType) + .pipe( + switchMap(() => { + const referenceId = ctx.getState().addonsUserReference[0].id; + return action.addonType === 'storage' + ? ctx.dispatch(new GetAuthorizedStorageAddons(referenceId)) + : ctx.dispatch(new GetAuthorizedCitationAddons(referenceId)); + }), + ); + } } diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts new file mode 100644 index 000000000..783a8df22 --- /dev/null +++ b/src/app/core/store/user/user.selectors.ts @@ -0,0 +1,11 @@ +import { Selector } from '@ngxs/store'; +import { UserStateModel } from '@core/store/user/user.models'; +import { User } from '@core/services/user/user.entity'; +import { UserState } from '@core/store/user/user.state'; + +export class UserSelectors { + @Selector([UserState]) + static getCurrentUser(state: UserStateModel): User | null { + return state.currentUser; + } +} diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index 9079fca83..96092266c 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -1,10 +1,9 @@ import { Injectable, inject } from '@angular/core'; -import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { State, Action, StateContext } from '@ngxs/store'; import { UserStateModel } from './user.models'; import { GetCurrentUser, SetCurrentUser } from './user.actions'; import { UserService } from '@core/services/user/user.service'; import { tap } from 'rxjs'; -import { User } from '@core/services/user/user.entity'; @State({ name: 'user', @@ -16,11 +15,6 @@ import { User } from '@core/services/user/user.entity'; export class UserState { private userService = inject(UserService); - @Selector([UserState]) - static getCurrentUser(state: UserStateModel): User | null { - return state.currentUser; - } - @Action(GetCurrentUser) getCurrentUser(ctx: StateContext) { return this.userService.getCurrentUser().pipe( diff --git a/src/app/features/home/dashboard.service.ts b/src/app/features/home/dashboard.service.ts index d995d3f71..ce715e0a0 100644 --- a/src/app/features/home/dashboard.service.ts +++ b/src/app/features/home/dashboard.service.ts @@ -14,7 +14,7 @@ export class DashboardService { jsonApiService = inject(JsonApiService); getProjects(): Observable { - const userId = 'ENTER_VALID_USER_ID'; + const userId = '8bxwv'; const params = { 'embed[]': ['bibliographic_contributors', 'parent', 'root'], page: 1, diff --git a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html index 19504d2b8..76c0f3ad7 100644 --- a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html +++ b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.html @@ -2,7 +2,11 @@ @if (cards().length) { @for (card of cards(); track card.id) {
- +
} } diff --git a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts index 751b0afe7..802504dfd 100644 --- a/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts +++ b/src/app/features/settings/addons/addon-card-list/addon-card-list.component.ts @@ -12,6 +12,7 @@ import { styleUrl: './addon-card-list.component.scss', }) export class AddonCardListComponent { - cards = input([]); + cards = input<(Addon | AuthorizedAddon)[]>([]); cardButtonLabel = input(''); + showDangerButton = input(false); } diff --git a/src/app/features/settings/addons/addon-card/addon-card.component.html b/src/app/features/settings/addons/addon-card/addon-card.component.html index e5bfc063e..db06d1c1b 100644 --- a/src/app/features/settings/addons/addon-card/addon-card.component.html +++ b/src/app/features/settings/addons/addon-card/addon-card.component.html @@ -1,5 +1,5 @@
@@ -13,10 +13,67 @@

{{ card()?.displayName }}

+ +
+ @if (showDangerButton()) { + + } + + +
+
+
+ + + +
+ Disable Account + +
+
+

+ Are you sure you want to disable this account? All projects connected to + this account will be affected. +

+
+
-
+ diff --git a/src/app/features/settings/addons/addon-card/addon-card.component.scss b/src/app/features/settings/addons/addon-card/addon-card.component.scss index 6282c15cc..288440f8d 100644 --- a/src/app/features/settings/addons/addon-card/addon-card.component.scss +++ b/src/app/features/settings/addons/addon-card/addon-card.component.scss @@ -9,7 +9,6 @@ } .addon-card { - height: 18.6rem; text-align: center; border: 1px solid var.$grey-2; border-radius: 1rem; @@ -20,11 +19,22 @@ img { max-height: 4rem; } + + .btn-container { + flex-direction: column-reverse; + gap: 1rem; + } } .addon-card.mobile { - height: 12.5rem; gap: 0.5rem; padding: 1rem; } + + .p-dialog-header-icon { + background-color: transparent; + color: var.$grey-1; + border: none; + height: 1.1rem; + } } diff --git a/src/app/features/settings/addons/addon-card/addon-card.component.ts b/src/app/features/settings/addons/addon-card/addon-card.component.ts index 2d58f03f7..da3d868cb 100644 --- a/src/app/features/settings/addons/addon-card/addon-card.component.ts +++ b/src/app/features/settings/addons/addon-card/addon-card.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, input } from '@angular/core'; +import { Component, computed, inject, input, signal } from '@angular/core'; import { Button } from 'primeng/button'; import { Router } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -7,26 +7,71 @@ import { Addon, AuthorizedAddon, } from '@osf/features/settings/addons/entities/addons.entities'; +import { NgClass } from '@angular/common'; +import { Store } from '@ngxs/store'; +import { DeleteAuthorizedAddon } from '@core/store/settings/addons'; +import { DialogModule } from 'primeng/dialog'; @Component({ selector: 'osf-addon-card', - imports: [Button], + imports: [Button, NgClass, DialogModule], templateUrl: './addon-card.component.html', styleUrl: './addon-card.component.scss', + standalone: true, }) export class AddonCardComponent { - card = input(); - cardButtonLabel = input(''); - isMobile = toSignal(inject(IS_XSMALL)); - - constructor(private router: Router) {} + #router = inject(Router); + #store = inject(Store); + readonly card = input(null); + readonly cardButtonLabel = input(''); + readonly showDangerButton = input(false); + protected isDialogVisible = signal(false); + protected readonly isMobile = toSignal(inject(IS_XSMALL)); + protected readonly isDisabling = signal(false); + protected readonly addonTypeString = computed(() => { + const addon = this.card(); + if (addon) { + return addon.type === 'authorized-storage-accounts' + ? 'storage' + : 'citation'; + } + return ''; + }); - onConnect(): void { + onConnectAddon(): void { const addon = this.card(); if (addon) { - this.router.navigate(['/settings/addons/connect-addon'], { + this.#router.navigate(['/settings/addons/connect-addon'], { state: { addon }, }); } } + + showDisableDialog(): void { + this.isDialogVisible.set(true); + } + + hideDialog(): void { + if (!this.isDisabling()) { + this.isDialogVisible.set(false); + } + } + + onDisableAddon(): void { + const addonId = this.card()?.id; + if (addonId) { + this.isDisabling.set(true); + this.#store + .dispatch(new DeleteAuthorizedAddon(addonId, this.addonTypeString())) + .subscribe({ + complete: () => { + this.isDisabling.set(false); + this.isDialogVisible.set(false); + }, + error: () => { + this.isDisabling.set(false); + }, + }); + } + } } diff --git a/src/app/features/settings/addons/addon.mapper.ts b/src/app/features/settings/addons/addon.mapper.ts index f0b3ef5df..8c6af83d7 100644 --- a/src/app/features/settings/addons/addon.mapper.ts +++ b/src/app/features/settings/addons/addon.mapper.ts @@ -1,40 +1,52 @@ import { Addon, - AddonResponse, + AddonGetResponse, AuthorizedAddon, - AuthorizedAddonResponse, + AuthorizedAddonGetResponse, IncludedAddonData, } from '@osf/features/settings/addons/entities/addons.entities'; export class AddonMapper { - static fromResponse(response: AddonResponse): Addon { + static fromResponse(response: AddonGetResponse): Addon { return { type: response.type, id: response.id, - authUri: response.attributes.auth_uri, + authUrl: response.attributes.auth_uri, displayName: response.attributes.display_name, externalServiceName: response.attributes.external_service_name, supportedFeatures: response.attributes.supported_features, credentialsFormat: response.attributes.credentials_format, + providerName: response.attributes.display_name, }; } static fromAuthorizedAddonResponse( - response: AuthorizedAddonResponse, + response: AuthorizedAddonGetResponse, included?: IncludedAddonData[], ): AuthorizedAddon { - const externalStorageServiceId = - response.relationships.external_storage_service.data.id; + // Handle both storage and citation service relationships + const externalServiceData = + response.relationships?.external_storage_service?.data || + response.relationships?.external_citation_service?.data; + const externalServiceId = externalServiceData?.id; + + // Find the matching service in the included data const matchingService = included?.find( (item) => (item.type === 'external-storage-services' || item.type === 'external-citation-services') && - item.id === externalStorageServiceId, - ); + item.id === externalServiceId, + )?.attributes; + // Extract the relevant properties from the matching service const externalServiceName = - (matchingService?.attributes?.['external_service_name'] as string) || ''; + (matchingService?.['external_service_name'] as string) || ''; + const displayName = (matchingService?.['display_name'] as string) || ''; + const credentialsFormat = + (matchingService?.['credentials_format'] as string) || ''; + const supportedFeatures = + (matchingService?.['supported_features'] as string[]) || []; return { type: response.type, @@ -47,9 +59,11 @@ export class AddonMapper { defaultRootFolder: response.attributes.default_root_folder, credentialsAvailable: response.attributes.credentials_available, accountOwnerId: response.relationships.account_owner.data.id, - externalStorageServiceId: - response.relationships.external_storage_service.data.id, + externalStorageServiceId: externalServiceId || '', externalServiceName, + supportedFeatures, + credentialsFormat, + providerName: displayName, }; } } diff --git a/src/app/features/settings/addons/addons.component.html b/src/app/features/settings/addons/addons.component.html index 15ac98122..d4520e92b 100644 --- a/src/app/features/settings/addons/addons.component.html +++ b/src/app/features/settings/addons/addons.component.html @@ -1,6 +1,6 @@
- + @if (!isMobile()) { All Add-ons @@ -15,7 +15,8 @@ [options]="tabOptions" optionLabel="label" optionValue="value" - [(ngModel)]="selectedTab" + [ngModel]="selectedTab()" + (ngModelChange)="selectedTab.set($event)" > } @@ -45,7 +46,10 @@
- + diff --git a/src/app/features/settings/addons/addons.component.ts b/src/app/features/settings/addons/addons.component.ts index 6d3cdfe85..9d2ac3914 100644 --- a/src/app/features/settings/addons/addons.component.ts +++ b/src/app/features/settings/addons/addons.component.ts @@ -20,9 +20,12 @@ import { GetStorageAddons, GetCitationAddons, AddonsSelectors, + GetAuthorizedStorageAddons, + GetAuthorizedCitationAddons, + GetAddonsUserReference, } from '@core/store/settings/addons'; - import { SelectOption } from '@shared/entities/select-option.interface'; +import { UserSelectors } from '@core/store/user/user.selectors'; @Component({ selector: 'osf-addons', @@ -52,26 +55,60 @@ export class AddonsComponent { protected readonly selectedCategory = signal( 'external-storage-services', ); - + protected readonly selectedTab = signal(this.defaultTabValue); + protected readonly currentUser = this.#store.selectSignal( + UserSelectors.getCurrentUser, + ); + protected readonly addonsUserReference = this.#store.selectSignal( + AddonsSelectors.getAddonUserReference, + ); protected readonly storageAddons = this.#store.selectSignal( AddonsSelectors.getStorageAddons, ); protected readonly citationAddons = this.#store.selectSignal( AddonsSelectors.getCitationAddons, ); + protected readonly authorizedStorageAddons = this.#store.selectSignal( + AddonsSelectors.getAuthorizedStorageAddons, + ); + protected readonly authorizedCitationAddons = this.#store.selectSignal( + AddonsSelectors.getAuthorizedCitationAddons, + ); + protected readonly allAuthorizedAddons = computed(() => { + const authorizedAddons = [ + ...this.authorizedStorageAddons(), + ...this.authorizedCitationAddons(), + ]; - protected readonly currentAddons = computed(() => { - return this.selectedCategory() === 'external-storage-services' - ? this.storageAddons() - : this.citationAddons(); + const searchValue = this.searchValue().toLowerCase(); + return authorizedAddons.filter((card) => + card.displayName.includes(searchValue), + ); }); - protected readonly filteredCards = computed(() => { + protected readonly userReferenceId = computed(() => { + return this.addonsUserReference()[0]?.id; + }); + + protected readonly currentAction = computed(() => + this.selectedCategory() === 'external-storage-services' + ? GetStorageAddons + : GetCitationAddons, + ); + + protected readonly currentAddonsState = computed(() => + this.selectedCategory() === 'external-storage-services' + ? this.storageAddons() + : this.citationAddons(), + ); + + protected readonly filteredAddonCards = computed(() => { const searchValue = this.searchValue().toLowerCase(); - return this.currentAddons().filter((card) => + return this.currentAddonsState().filter((card) => card.externalServiceName.includes(searchValue), ); }); + protected readonly tabOptions: SelectOption[] = [ { label: 'All Add-ons', value: 0 }, { label: 'Connected Add-ons', value: 1 }, @@ -80,7 +117,6 @@ export class AddonsComponent { { label: 'Additional Storage', value: 'external-storage-services' }, { label: 'Citation Manager', value: 'external-citation-services' }, ]; - protected selectedTab = this.defaultTabValue; protected onCategoryChange(value: string): void { this.selectedCategory.set(value); @@ -88,13 +124,34 @@ export class AddonsComponent { constructor() { effect(() => { - const category = this.selectedCategory(); + // Only proceed if we have the current user + if (this.currentUser()) { + this.#store.dispatch(GetAddonsUserReference); + } + }); - this.#store.dispatch( - category === 'external-storage-services' - ? GetStorageAddons - : GetCitationAddons, - ); + effect(() => { + const isStorageCategory = + this.selectedCategory() === 'external-storage-services'; + + // Only proceed if we have both current user and user reference + if (this.currentUser() && this.userReferenceId()) { + this.#loadAddonsIfNeeded(isStorageCategory, this.userReferenceId()); + } }); } + + #loadAddonsIfNeeded( + isStorageCategory: boolean, + userReferenceId: string, + ): void { + const action = this.currentAction(); + const addons = this.currentAddonsState(); + + if (!addons?.length) { + this.#store.dispatch(action); + this.#store.dispatch(new GetAuthorizedStorageAddons(userReferenceId)); + this.#store.dispatch(new GetAuthorizedCitationAddons(userReferenceId)); + } + } } diff --git a/src/app/features/settings/addons/addons.service.ts b/src/app/features/settings/addons/addons.service.ts index 05560f4f6..7fb7452b0 100644 --- a/src/app/features/settings/addons/addons.service.ts +++ b/src/app/features/settings/addons/addons.service.ts @@ -3,31 +3,33 @@ import { JsonApiService } from '@core/services/json-api/json-api.service'; import { map, Observable } from 'rxjs'; import { Addon, - AddonResponse, + AddonGetResponse, AuthorizedAddon, - AuthorizedAddonResponse, + AuthorizedAddonGetResponse, IncludedAddonData, + AddonRequest, UserReference, + AddonResponse, } from '@osf/features/settings/addons/entities/addons.entities'; import { AddonMapper } from '@osf/features/settings/addons/addon.mapper'; import { Store } from '@ngxs/store'; -import { UserState } from '@core/store/user'; import { JsonApiResponse } from '@core/services/json-api/json-api.entity'; +import { UserSelectors } from '@core/store/user/user.selectors'; @Injectable({ providedIn: 'root', }) export class AddonsService { #store = inject(Store); - jsonApiService = inject(JsonApiService); - baseUrl = 'https://addons.staging4.osf.io/v1/'; - currentUser = this.#store.selectSignal(UserState.getCurrentUser); + #baseUrl = 'https://addons.staging4.osf.io/v1/'; + #jsonApiService = inject(JsonApiService); + #currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser); getAddons(addonType: string): Observable { - return this.jsonApiService + return this.#jsonApiService .get< - JsonApiResponse - >(this.baseUrl + `external-${addonType}-services`) + JsonApiResponse + >(this.#baseUrl + `external-${addonType}-services`) .pipe( map((response) => { return response.data.map((item) => AddonMapper.fromResponse(item)); @@ -35,22 +37,28 @@ export class AddonsService { ); } - getUserReference(): Observable { - const userUri = `https://staging4.osf.io/${this.currentUser()!.id}`; + getAddonsUserReference(): Observable { + const currentUser = this.#currentUser(); + if (!currentUser) throw new Error('Current user not found'); + + const userUri = `https://staging4.osf.io/${currentUser.id}`; const params = { 'filter[user_uri]': userUri }; - return this.jsonApiService + return this.#jsonApiService .get< JsonApiResponse - >(this.baseUrl + 'user-references', params) + >(this.#baseUrl + 'user-references', params) .pipe(map((response) => response.data)); } - getAuthorizedAddons(addonType: string): Observable { - return this.jsonApiService + getAuthorizedAddons( + addonType: string, + referenceId: string, + ): Observable { + return this.#jsonApiService .get< - JsonApiResponse - >(this.baseUrl + `user-references/3873149c-9fb7-4444-bbb9-138d9f358a85/authorized_${addonType}_accounts?include=external-${addonType}-service`) + JsonApiResponse + >(this.#baseUrl + `user-references/${referenceId}/authorized_${addonType}_accounts?include=external-${addonType}-service`) .pipe( map((response) => { return response.data.map((item) => @@ -59,4 +67,31 @@ export class AddonsService { }), ); } + + createAuthorizedAddon( + addonRequestPayload: AddonRequest, + addonType: string, + ): Observable { + return this.#jsonApiService.post( + this.#baseUrl + `authorized-${addonType}-accounts`, + addonRequestPayload, + ); + } + + updateAuthorizedAddon( + addonRequestPayload: AddonRequest, + addonType: string, + addonId: string, + ): Observable { + return this.#jsonApiService.patch( + this.#baseUrl + `authorized-${addonType}-accounts/${addonId}`, + addonRequestPayload, + ); + } + + deleteAuthorizedAddon(id: string, addonType: string): Observable { + return this.#jsonApiService.delete( + this.#baseUrl + `authorized-${addonType}-accounts/${id}`, + ); + } } diff --git a/src/app/features/settings/addons/connect-addon/connect-addon.component.html b/src/app/features/settings/addons/connect-addon/connect-addon.component.html index d8b59d415..f5c26bb43 100644 --- a/src/app/features/settings/addons/connect-addon/connect-addon.component.html +++ b/src/app/features/settings/addons/connect-addon/connect-addon.component.html @@ -1,11 +1,11 @@
- +
-

Figshare Terms

+

{{ addon()?.providerName }} Terms

@@ -19,6 +19,7 @@

Figshare Terms

[ngClass]="{ 'background-warning': term.type === 'warning', 'background-success': term.type === 'info', + 'background-danger': term.type === 'danger', }" > {{ term.function }} @@ -60,31 +61,183 @@

Figshare Terms

-
-

Setup new account

+ +

+ {{ isAuthorized() ? "Reconnect Account" : "Setup new account" }} +

-

Account Name

-

+ @if ( + addon()?.credentialsFormat === + credentialsFormat.ACCESS_SECRET_KEYS + ) { +

Access Key

+ +

Secret Key

+ + } + + @if ( + addon()?.credentialsFormat === + credentialsFormat.DATAVERSE_API_TOKEN + ) { +

Host Url

+

+ Please include the protocol (http:// or https://) in the URL. +

+ +

Personal Access Token

+ + } + + @if ( + addon()?.credentialsFormat === + credentialsFormat.USERNAME_PASSWORD + ) { +

Host Url

+

+ Please include the protocol (http:// or https://) in the URL. +

+ +

Username

+ +

Password

+

These credentials will be encrypted

+
+ +
+ } + + @if ( + addon()?.credentialsFormat === credentialsFormat.REPO_TOKEN + ) { +

Access Key

+ +

Secret Key

+ + } + +

Account Name

+

This will distinguish your account from other using the same addon.

- Google Drive +
+ @if (!isAuthorized()) { + + + + } @else { + + + + } +
+ + + + + + +
+
+

Setup new account

-
-
+ +

+ Complete the OAuth process in the new window before returning to + this page. If you do not see a new window, please click the Start + Oauth link below. +

+ Start OAuth +
diff --git a/src/app/features/settings/addons/connect-addon/connect-addon.component.ts b/src/app/features/settings/addons/connect-addon/connect-addon.component.ts index 46a3480d0..ed29663ed 100644 --- a/src/app/features/settings/addons/connect-addon/connect-addon.component.ts +++ b/src/app/features/settings/addons/connect-addon/connect-addon.component.ts @@ -1,4 +1,11 @@ -import { Component, signal } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + signal, + viewChild, +} from '@angular/core'; import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; import { StepPanel, StepPanels, Stepper } from 'primeng/stepper'; import { Button } from 'primeng/button'; @@ -7,16 +14,35 @@ import { RouterLink, Router } from '@angular/router'; import { NgClass } from '@angular/common'; import { Card } from 'primeng/card'; import { RadioButton } from 'primeng/radiobutton'; -import { FormsModule } from '@angular/forms'; +import { + FormsModule, + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; import { Checkbox } from 'primeng/checkbox'; -import { GoogleDriveFolder } from '@shared/entities/google-drive-folder.interface'; -import { AddonTerm } from '@osf/features/settings/addons/entities/addon-terms.interface'; import { Divider } from 'primeng/divider'; -import { ADDON_TERMS_MESSAGES } from '../utils/addon-terms.const'; +import { ADDON_TERMS as addonTerms } from '../utils/addon-terms.const'; import { Addon, AuthorizedAddon, + AddonRequest, } from '@osf/features/settings/addons/entities/addons.entities'; +import { CredentialsFormat } from '@osf/features/settings/addons/entities/credentials-format.enum'; +import { InputText } from 'primeng/inputtext'; +import { Password } from 'primeng/password'; +import { Store } from '@ngxs/store'; +import { + AddonsSelectors, + CreateAuthorizedAddon, + UpdateAuthorizedAddon, +} from '@core/store/settings/addons'; +import { + AddonForm, + AddonFormControls, +} from '@osf/features/settings/addons/entities/addon-form.entities'; +import { AddonTerm } from '@osf/features/settings/addons/entities/addon-terms.interface'; @Component({ selector: 'osf-connect-addon', @@ -31,120 +57,295 @@ import { NgClass, Card, FormsModule, + ReactiveFormsModule, RadioButton, Checkbox, Divider, + InputText, + Password, ], templateUrl: './connect-addon.component.html', styleUrl: './connect-addon.component.scss', standalone: true, }) export class ConnectAddonComponent { - protected radioConfig = ''; - protected readonly selectedFolders = signal([]); - protected readonly folders: GoogleDriveFolder[] = [ - { name: 'folder name example', selected: false }, - { name: 'folder name example', selected: false }, - { name: 'folder name example', selected: false }, - { name: 'folder name example', selected: false }, - { name: 'folder name example', selected: false }, - { name: 'folder name example', selected: false }, - ]; + protected readonly stepper = viewChild(Stepper); + // protected readonly selectedFolders = signal([]); + // protected readonly folders: GoogleDriveFolder[] = [ + // { name: 'folder name example', selected: false }, + // { name: 'folder name example', selected: false }, + // { name: 'folder name example', selected: false }, + // { name: 'folder name example', selected: false }, + // { name: 'folder name example', selected: false }, + // { name: 'folder name example', selected: false }, + // ]; + #router = inject(Router); + #store = inject(Store); + #fb = inject(FormBuilder); + protected readonly credentialsFormat = CredentialsFormat; protected readonly terms = signal([]); + protected readonly addon = signal(null); + protected readonly addonAuthUrl = signal('/settings/addons'); + protected readonly formControls = AddonFormControls; + protected readonly userReference = this.#store.selectSignal( + AddonsSelectors.getAddonUserReference, + ); + protected createdAddon = this.#store.selectSignal( + AddonsSelectors.getCreatedOrUpdatedStorageAddon, + ); + protected readonly isConnecting = signal(false); + protected isAuthorized = computed(() => { + //check if the addon is already authorized + const addon = this.addon(); + if (addon) { + return ( + addon.type === 'authorized-storage-accounts' || + addon.type === 'authorized-citation-accounts' + ); + } + return false; + }); + protected readonly addonTypeString = computed(() => { + //get the addon type string based on the addon type property + const addon = this.addon(); + if (addon) { + return addon.type === 'external-storage-services' || + addon.type === 'authorized-storage-accounts' + ? 'storage' + : 'citation'; + } + return ''; + }); + protected addonForm: FormGroup; - constructor(private router: Router) { - const addon = this.router.getCurrentNavigation()?.extras.state?.[ - 'addon' - ] as Addon | AuthorizedAddon; - if (!addon) return; - - const supportedFeatures = - 'supportedFeatures' in addon ? addon.supportedFeatures : []; - const provider = - addon.displayName || addon.externalServiceName || 'provider'; - - const terms: AddonTerm[] = [ - { - function: ADDON_TERMS_MESSAGES.labels['add-update-files'], - status: this.getTermMessage( - 'add-update-files', - supportedFeatures, - provider, - ), - type: this.getTermType('add-update-files', supportedFeatures), - }, - { - function: ADDON_TERMS_MESSAGES.labels['delete-files'], - status: this.getTermMessage( - 'delete-files', - supportedFeatures, - provider, - ), - type: this.getTermType('delete-files', supportedFeatures), - }, - { - function: ADDON_TERMS_MESSAGES.labels['forking'], - status: this.getTermMessage('forking', supportedFeatures, provider), - type: this.getTermType('forking', supportedFeatures), - }, - { - function: ADDON_TERMS_MESSAGES.labels['logs'], - status: this.getTermMessage('logs', supportedFeatures, provider), - type: this.getTermType('logs', supportedFeatures), - }, - { - function: ADDON_TERMS_MESSAGES.labels['permissions'], - status: this.getTermMessage('permissions', supportedFeatures, provider), - type: this.getTermType('permissions', supportedFeatures), - }, - { - function: ADDON_TERMS_MESSAGES.labels['registering'], - status: this.getTermMessage('registering', supportedFeatures, provider), - type: this.getTermType('registering', supportedFeatures), - }, - { - function: ADDON_TERMS_MESSAGES.labels['file-versions'], - status: this.getTermMessage( - 'file-versions', - supportedFeatures, - provider, + constructor() { + const terms = this.#getTerms(); + this.terms.set(terms); + this.addonForm = this.#initializeForm(); + + effect(() => { + if (this.isAuthorized()) { + this.stepper()?.value.set(2); //if the addon is already authorized, we skip terms table page + } + }); + } + + // toggleFolderSelection(folder: GoogleDriveFolder): void { + // folder.selected = !folder.selected; + // this.selectedFolders.set( + // this.folders.filter((f) => f.selected).map((f) => f.name), + // ); + // } + + handleConnectStorageAddon() { + if (!this.addon() || !this.addonForm.valid) return; + + this.isConnecting.set(true); + const request = this.#generateRequestPayload(); + + this.#store + .dispatch( + !this.isAuthorized() + ? new CreateAuthorizedAddon(request, this.addonTypeString()) + : new UpdateAuthorizedAddon( + request, + this.addonTypeString(), + this.addon()!.id, + ), + ) + .subscribe({ + complete: () => { + const createdAddon = this.createdAddon(); + if (createdAddon) { + this.addonAuthUrl.set(createdAddon.attributes.auth_url); + window.open(createdAddon.attributes.auth_url, '_blank'); + this.stepper()?.value.set(3); + } + this.isConnecting.set(false); + }, + error: () => { + this.isConnecting.set(false); + }, + }); + } + + #initializeForm(): FormGroup { + const addon = this.addon(); + + if (addon) { + const formControls: Partial = { + [AddonFormControls.AccountName]: this.#fb.control( + addon.displayName || '', + Validators.required, ), - type: this.getTermType('file-versions', supportedFeatures), - }, - ]; + }; - this.terms.set(terms); + switch (addon.credentialsFormat) { + case CredentialsFormat.ACCESS_SECRET_KEYS: + formControls[AddonFormControls.AccessKey] = this.#fb.control( + '', + Validators.required, + ); + formControls[AddonFormControls.SecretKey] = this.#fb.control( + '', + Validators.required, + ); + break; + case CredentialsFormat.DATAVERSE_API_TOKEN: + formControls[AddonFormControls.HostUrl] = this.#fb.control( + '', + Validators.required, + ); + formControls[AddonFormControls.PersonalAccessToken] = + this.#fb.control('', Validators.required); + break; + case CredentialsFormat.USERNAME_PASSWORD: + formControls[AddonFormControls.HostUrl] = this.#fb.control( + '', + Validators.required, + ); + formControls[AddonFormControls.Username] = this.#fb.control( + '', + Validators.required, + ); + formControls[AddonFormControls.Password] = this.#fb.control( + '', + Validators.required, + ); + break; + case CredentialsFormat.REPO_TOKEN: + formControls[AddonFormControls.AccessKey] = this.#fb.control( + '', + Validators.required, + ); + formControls[AddonFormControls.SecretKey] = this.#fb.control( + '', + Validators.required, + ); + break; + } + return this.#fb.group(formControls as AddonForm); + } + + return new FormGroup({} as AddonForm); } - private getTermMessage( - term: string, - supportedFeatures: string[], - provider: string, - ): string { - const feature = term.toUpperCase().replace(/-/g, '_'); - const hasFeature = supportedFeatures.includes(feature); - - const messageKey = `${term}-${hasFeature ? 'true' : 'false'}`; - const message = - ADDON_TERMS_MESSAGES.storage[ - messageKey as keyof typeof ADDON_TERMS_MESSAGES.storage - ]; - - return message ? message.replace(/{provider}/g, provider) : ''; + #generateRequestPayload(): AddonRequest { + const formValue = this.addonForm.value; + const addon = this.addon()!; + const credentials: Record = {}; + const initiateOAuth = + addon.credentialsFormat === CredentialsFormat.OAUTH2 || + addon.credentialsFormat === CredentialsFormat.OAUTH; + + switch (addon.credentialsFormat) { + case CredentialsFormat.ACCESS_SECRET_KEYS: + credentials['access_key'] = formValue[AddonFormControls.AccessKey]; + credentials['secret_key'] = formValue[AddonFormControls.SecretKey]; + break; + case CredentialsFormat.DATAVERSE_API_TOKEN: + credentials['personal_access_token'] = + formValue[AddonFormControls.PersonalAccessToken]; + break; + case CredentialsFormat.USERNAME_PASSWORD: + credentials['username'] = formValue[AddonFormControls.Username]; + credentials['password'] = formValue[AddonFormControls.Password]; + break; + case CredentialsFormat.REPO_TOKEN: + credentials['access_key'] = formValue[AddonFormControls.AccessKey]; + credentials['secret_key'] = formValue[AddonFormControls.SecretKey]; + break; + } + + const requestPayload: AddonRequest = { + data: { + id: addon.id || '', + attributes: { + api_base_url: formValue[AddonFormControls.HostUrl] || '', + display_name: formValue[AddonFormControls.AccountName] || '', + authorized_capabilities: ['ACCESS', 'UPDATE'], + credentials, + initiate_oauth: initiateOAuth, + auth_url: null, + credentials_available: false, + }, + relationships: { + account_owner: { + data: { + type: 'user-references', + id: this.userReference()[0].id || '', + }, + }, + ...this.#getServiceRelationship(addon), + }, + type: `authorized-${this.addonTypeString()}-accounts`, + }, + }; + + return requestPayload; } - private getTermType( - term: string, - supportedFeatures: string[], - ): 'warning' | 'info' { - const feature = term.toUpperCase().replace(/-/g, '_'); - return supportedFeatures.includes(feature) ? 'info' : 'warning'; + #getServiceRelationship(addon: Addon | AuthorizedAddon) { + return { + [`external_${this.addonTypeString()}_service`]: { + data: { + type: `external-${this.addonTypeString()}-services`, + id: this.isAuthorized() + ? (addon as AuthorizedAddon).externalStorageServiceId + : (addon as Addon).id, //check if addon is already authorized and set relationship ID accordingly + }, + }, + }; } - toggleFolderSelection(folder: GoogleDriveFolder): void { - folder.selected = !folder.selected; - this.selectedFolders.set( - this.folders.filter((f) => f.selected).map((f) => f.name), - ); + #getTerms(): AddonTerm[] { + const addon = this.#router.getCurrentNavigation()?.extras.state?.[ + 'addon' + ] as Addon | AuthorizedAddon; + if (!addon) { + this.#router.navigate(['/settings/addons']); + } + + this.addon.set(addon); + const supportedFeatures = addon.supportedFeatures; + const provider = addon.providerName; + const isCitationService = addon.type === 'external-citation-services'; + + const relevantTerms = isCitationService + ? addonTerms.filter((term) => term.citation) + : addonTerms; + + return relevantTerms.map((term) => { + const feature = term.supportedFeature; + const hasFeature = supportedFeatures.includes(feature); + const hasPartialFeature = supportedFeatures.includes( + `${feature}_PARTIAL`, + ); + + let message: string; + let type: 'warning' | 'info' | 'danger'; + + if (isCitationService && term.citation) { + if (hasPartialFeature && term.citation.partial) { + message = term.citation.partial; + type = 'warning'; + } else if (!hasFeature && term.citation.false) { + message = term.citation.false; + type = 'danger'; + } else { + message = term.storage[hasFeature ? 'true' : 'false']; + type = hasFeature ? 'info' : 'danger'; + } + } else { + message = term.storage[hasFeature ? 'true' : 'false']; + type = hasFeature ? 'info' : hasPartialFeature ? 'warning' : 'danger'; + } + + return { + function: term.label, + status: message.replace(/{provider}/g, provider), + type, + }; + }); } } diff --git a/src/app/features/settings/addons/entities/addon-card.interface.ts b/src/app/features/settings/addons/entities/addon-card.interface.ts deleted file mode 100644 index 0887fb260..000000000 --- a/src/app/features/settings/addons/entities/addon-card.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface AddonCard { - title: string; - img: string; -} diff --git a/src/app/features/settings/addons/entities/addon-form.entities.ts b/src/app/features/settings/addons/entities/addon-form.entities.ts new file mode 100644 index 000000000..a0fc82b06 --- /dev/null +++ b/src/app/features/settings/addons/entities/addon-form.entities.ts @@ -0,0 +1,21 @@ +import { FormControl } from '@angular/forms'; + +export enum AddonFormControls { + AccessKey = 'accessKey', + SecretKey = 'secretKey', + HostUrl = 'hostUrl', + Username = 'username', + Password = 'password', + PersonalAccessToken = 'personalAccessToken', + AccountName = 'accountName', +} + +export interface AddonForm { + [AddonFormControls.AccessKey]?: FormControl; + [AddonFormControls.SecretKey]?: FormControl; + [AddonFormControls.HostUrl]?: FormControl; + [AddonFormControls.Username]?: FormControl; + [AddonFormControls.Password]?: FormControl; + [AddonFormControls.PersonalAccessToken]?: FormControl; + [AddonFormControls.AccountName]: FormControl; +} diff --git a/src/app/features/settings/addons/entities/addon-terms.interface.ts b/src/app/features/settings/addons/entities/addon-terms.interface.ts index 1787afb95..59996778c 100644 --- a/src/app/features/settings/addons/entities/addon-terms.interface.ts +++ b/src/app/features/settings/addons/entities/addon-terms.interface.ts @@ -1,5 +1,5 @@ export interface AddonTerm { function: string; status: string; - type: 'warning' | 'info'; + type: 'info' | 'danger' | 'warning'; } diff --git a/src/app/features/settings/addons/entities/addons.entities.ts b/src/app/features/settings/addons/entities/addons.entities.ts index ce3dd5170..3c6435d75 100644 --- a/src/app/features/settings/addons/entities/addons.entities.ts +++ b/src/app/features/settings/addons/entities/addons.entities.ts @@ -1,4 +1,4 @@ -export interface AddonResponse { +export interface AddonGetResponse { type: string; id: string; attributes: { @@ -25,7 +25,7 @@ export interface AddonResponse { }; } -export interface AuthorizedAddonResponse { +export interface AuthorizedAddonGetResponse { type: string; id: string; attributes: { @@ -57,7 +57,16 @@ export interface AuthorizedAddonResponse { related: string; }; }; - external_storage_service: { + external_storage_service?: { + links: { + related: string; + }; + data: { + type: string; + id: string; + }; + }; + external_citation_service?: { links: { related: string; }; @@ -75,11 +84,12 @@ export interface AuthorizedAddonResponse { export interface Addon { type: string; id: string; - authUri: string; + authUrl: string; displayName: string; externalServiceName: string; supportedFeatures: string[]; credentialsFormat: string; + providerName: string; } export interface AuthorizedAddon { @@ -95,6 +105,9 @@ export interface AuthorizedAddon { accountOwnerId: string; externalStorageServiceId: string; externalServiceName: string; + supportedFeatures: string[]; + providerName: string; + credentialsFormat: string; } export interface IncludedAddonData { @@ -150,3 +163,81 @@ export interface UserReference { self: string; }; } + +export interface AddonRequest { + data: { + id?: string; + attributes: { + display_name: string; + authorized_capabilities: string[]; + api_base_url: string; + credentials: Record; + initiate_oauth: boolean; + auth_url: string | null; + credentials_available: boolean; + }; + relationships: { + account_owner: { + data: { + type: 'user-references'; + id: string; + }; + }; + external_storage_service?: { + data: { + type: string; + id: string; + }; + }; + external_citation_service?: { + data: { + type: string; + id: string; + }; + }; + }; + type: string; + }; +} + +export interface AddonResponse { + type: string; + id: string; + attributes: { + display_name: string; + api_base_url: string; + auth_url: string; + authorized_capabilities: string[]; + authorized_operation_names: string[]; + default_root_folder: string; + credentials_available: boolean; + }; + relationships: { + account_owner: { + links: { + related: string; + }; + data: { + type: 'user-references'; + id: string; + }; + }; + authorized_operations: { + links: { + related: string; + }; + }; + external_storage_service: { + links: { + related: string; + }; + data: { + type: string; + id: string; + }; + }; + }; + links: { + self: string; + }; +} diff --git a/src/app/features/settings/addons/entities/credentials-format.enum.ts b/src/app/features/settings/addons/entities/credentials-format.enum.ts new file mode 100644 index 000000000..0b681b099 --- /dev/null +++ b/src/app/features/settings/addons/entities/credentials-format.enum.ts @@ -0,0 +1,8 @@ +export enum CredentialsFormat { + OAUTH = 'OAUTH1A', + OAUTH2 = 'OAUTH2', + USERNAME_PASSWORD = 'USERNAME_PASSWORD', + ACCESS_SECRET_KEYS = 'ACCESS_KEY_SECRET_KEY', + REPO_TOKEN = 'PERSONAL_ACCESS_TOKEN', + DATAVERSE_API_TOKEN = 'DATAVERSE_API_TOKEN', +} diff --git a/src/app/features/settings/addons/utils/addon-terms.const.ts b/src/app/features/settings/addons/utils/addon-terms.const.ts index 6fb5bb0f9..df1b9139e 100644 --- a/src/app/features/settings/addons/utils/addon-terms.const.ts +++ b/src/app/features/settings/addons/utils/addon-terms.const.ts @@ -1,75 +1,84 @@ -export interface AddonTermsMessages { - labels: { - 'add-update-files': string; - 'delete-files': string; - forking: string; - logs: string; - permissions: string; - registering: string; - 'file-versions': string; - }; +export interface Term { + label: string; + supportedFeature: string; storage: { - 'add-update-files-true': string; - 'add-update-files-false': string; - 'add-update-files-partial': string; - 'delete-files-true': string; - 'delete-files-false': string; - 'delete-files-partial': string; - 'forking-true': string; - 'logs-true': string; - 'logs-false': string; - 'permissions-true': string; - 'registering-true': string; - 'file-versions-true': string; - 'file-versions-false': string; + true: string; + false: string; }; - citation: { - 'forking-partial': string; - 'permissions-partial': string; - 'registering-false': string; + citation?: { + partial?: string; + false?: string; }; } -export const ADDON_TERMS_MESSAGES: AddonTermsMessages = { - labels: { - 'add-update-files': 'Add / update files', - 'delete-files': 'Delete files', - forking: 'Forking', - logs: 'Logs', - permissions: 'Permissions', - registering: 'Registering', - 'file-versions': 'View / download file versions', +export const ADDON_TERMS: Term[] = [ + { + label: 'Add / update files', + supportedFeature: 'ADD_UPDATE_FILES', + storage: { + true: 'Adding/updating files within OSF will be reflected in {provider}.', + false: 'You cannot add or update files for {provider} within OSF.', + }, }, - storage: { - 'add-update-files-true': - 'Adding/updating files within OSF will be reflected in {provider}.', - 'add-update-files-false': - 'You cannot add or update files for {provider} within OSF.', - 'add-update-files-partial': 'Files can be added but not updated.', - 'delete-files-true': 'Files deleted in OSF will be deleted in {provider}.', - 'delete-files-false': 'You cannot delete files for {provider} within OSF.', - 'delete-files-partial': - '{provider} has limitations on which files can be deleted within OSF.', - 'forking-true': - 'Only the user who first authorized the {provider} add-on within source project can transfer its authorization to a forked project or component.', - 'logs-true': - 'OSF tracks changes you make to your {provider} content within OSF, but not changes made directly within {provider}.', - 'logs-false': - 'OSF does not keep track of changes made using {provider} directly.', - 'permissions-true': - 'The OSF does not change permissions for linked {provider} files. Privacy changes made to an OSF project or component will not affect those set in {provider}.', - 'registering-true': - '{provider} content will be registered, but version history will not be copied to the registration.', - 'file-versions-true': - '{provider} files and their versions can be viewed/downloaded in OSF.', - 'file-versions-false': - '{provider} files can be viewed/downloaded in OSF, but version history is not supported.', + { + label: 'Delete files', + supportedFeature: 'DELETE_FILES', + storage: { + true: 'Files deleted in OSF will be deleted in {provider}.', + false: 'You cannot delete files for {provider} within OSF.', + }, + }, + { + label: 'Forking', + supportedFeature: 'FORKING', + storage: { + true: 'Only the user who first authorized the {provider} add-on within source project can transfer its authorization to a forked project or component.', + false: 'You cannot fork {provider} content.', + }, + citation: { + partial: + 'Forking a project or component does not copy {provider} authorization unless the user forking the project is the same user who authorized the {provider} add-on in the source project being forked.', + }, + }, + { + label: 'Logs', + supportedFeature: 'LOGS', + storage: { + true: 'OSF tracks changes you make to your {provider} content within OSF, but not changes made directly within {provider}.', + false: + 'OSF does not keep track of changes made using {provider} directly.', + }, + }, + { + label: 'Permissions', + supportedFeature: 'PERMISSIONS', + storage: { + true: 'The OSF does not change permissions for linked {provider} files. Privacy changes made to an OSF project or component will not affect those set in {provider}.', + false: 'You cannot change permissions for {provider} content within OSF.', + }, + citation: { + partial: + 'Making an OSF project public or private is independent of making a {provider} folder public or private. The OSF does not alter the permissions of a linked {provider} folder.', + }, + }, + { + label: 'Registering', + supportedFeature: 'REGISTERING', + storage: { + true: '{provider} content will be registered, but version history will not be copied to the registration.', + false: '{provider} content will not be registered.', + }, + citation: { + false: '{provider} content will not be registered.', + }, }, - citation: { - 'forking-partial': - 'Forking a project or component does not copy {provider} authorization unless the user forking the project is the same user who authorized the {provider} add-on in the source project being forked.', - 'permissions-partial': - 'Making an OSF project public or private is independent of making a {provider} folder public or private. The OSF does not alter the permissions of a linked {provider} folder.', - 'registering-false': '{provider} content will not be registered.', + { + label: 'View / download file versions', + supportedFeature: 'FILE_VERSIONS', + storage: { + true: '{provider} files and their versions can be viewed/downloaded in OSF.', + false: + '{provider} files can be viewed/downloaded in OSF, but version history is not supported.', + }, }, -}; +]; diff --git a/src/app/features/settings/tokens/tokens-list/tokens-list.component.html b/src/app/features/settings/tokens/tokens-list/tokens-list.component.html index ac2d05d48..477a9530b 100644 --- a/src/app/features/settings/tokens/tokens-list/tokens-list.component.html +++ b/src/app/features/settings/tokens/tokens-list/tokens-list.component.html @@ -19,7 +19,7 @@

{{ token.name }}

class="btn-full-width" label="Delete" severity="danger" - (onClick)="deleteApp(token)" + (onClick)="deleteToken(token)" />
diff --git a/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts b/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts index e66ce686b..e3402bf18 100644 --- a/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts +++ b/src/app/features/settings/tokens/tokens-list/tokens-list.component.ts @@ -30,7 +30,7 @@ export class TokensListComponent implements OnInit { tokens = this.#store.selectSignal(TokensSelectors.getTokens); - deleteApp(token: Token) { + deleteToken(token: Token) { this.#confirmationService.confirm({ ...defaultConfirmationConfig, message: diff --git a/src/app/features/settings/tokens/tokens.service.ts b/src/app/features/settings/tokens/tokens.service.ts index 1f33f5e77..8017bbe47 100644 --- a/src/app/features/settings/tokens/tokens.service.ts +++ b/src/app/features/settings/tokens/tokens.service.ts @@ -29,7 +29,6 @@ export class TokensService { .get>(this.baseUrl + 'tokens') .pipe( map((responses) => { - console.log(responses); return responses.data.map((response) => TokenMapper.fromGetResponse(response), ); diff --git a/src/assets/styles/overrides/table.scss b/src/assets/styles/overrides/table.scss index 5fefc9e21..44c2e847b 100644 --- a/src/assets/styles/overrides/table.scss +++ b/src/assets/styles/overrides/table.scss @@ -50,12 +50,20 @@ .addon-table { tr { &.background-warning td { - background-color: var.$red-2; + background-color: var.$yellow-2; } &.background-success td { background-color: var.$green-2; } + + &.background-danger td { + background-color: var.$red-2; + } + + td { + white-space: wrap; + } } } } From 12cbcd97a3ea5816122e18ee63230c9208c508ac Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Thu, 17 Apr 2025 16:27:25 +0300 Subject: [PATCH 11/11] feat(pat-addons-api-integration): removed unused parameter --- src/app/features/settings/addons/addons.component.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/app/features/settings/addons/addons.component.ts b/src/app/features/settings/addons/addons.component.ts index 9d2ac3914..8168a83aa 100644 --- a/src/app/features/settings/addons/addons.component.ts +++ b/src/app/features/settings/addons/addons.component.ts @@ -131,20 +131,14 @@ export class AddonsComponent { }); effect(() => { - const isStorageCategory = - this.selectedCategory() === 'external-storage-services'; - // Only proceed if we have both current user and user reference if (this.currentUser() && this.userReferenceId()) { - this.#loadAddonsIfNeeded(isStorageCategory, this.userReferenceId()); + this.#loadAddonsIfNeeded(this.userReferenceId()); } }); } - #loadAddonsIfNeeded( - isStorageCategory: boolean, - userReferenceId: string, - ): void { + #loadAddonsIfNeeded(userReferenceId: string): void { const action = this.currentAction(); const addons = this.currentAddonsState();