diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index c3f29dce7..9637ec02f 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -5,6 +5,7 @@ import { UserState } from '@core/store/user'; import { MyProjectsState } from '@osf/features/my-projects/store'; import { SearchState } from '@osf/features/search/store'; import { InstitutionsState } from '@osf/features/institutions/store'; +import { DeveloperAppsState } from '@osf/features/settings/developer-apps/store'; export const STATES = [ AuthState, @@ -14,4 +15,5 @@ export const STATES = [ SearchState, MyProjectsState, InstitutionsState, + DeveloperAppsState, ]; diff --git a/src/app/core/helpers/link-validator.helper.ts b/src/app/core/helpers/link-validator.helper.ts index a7c406224..03052cf5e 100644 --- a/src/app/core/helpers/link-validator.helper.ts +++ b/src/app/core/helpers/link-validator.helper.ts @@ -7,7 +7,7 @@ export function linkValidator(): ValidatorFn { return null; } - const urlPattern = /^(https?):\/\/.+/i; + const urlPattern = /^(https):\/\/.+/i; const isValid = urlPattern.test(value); diff --git a/src/app/features/auth/sign-up/sign-up.component.html b/src/app/features/auth/sign-up/sign-up.component.html index 9c0883e56..82f04cba7 100644 --- a/src/app/features/auth/sign-up/sign-up.component.html +++ b/src/app/features/auth/sign-up/sign-up.component.html @@ -5,9 +5,6 @@

Create A Free Account

- - - Orchid icon
@@ -48,16 +48,25 @@
- - + @if (isEditMode()) { + + } @else { + + + }
diff --git a/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.scss b/src/app/features/settings/developer-apps/developer-app-add-edit-form/developer-app-add-edit-form.component.scss similarity index 100% rename from src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.scss rename to src/app/features/settings/developer-apps/developer-app-add-edit-form/developer-app-add-edit-form.component.scss diff --git a/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.spec.ts b/src/app/features/settings/developer-apps/developer-app-add-edit-form/developer-app-add-edit-form.component.spec.ts similarity index 52% rename from src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.spec.ts rename to src/app/features/settings/developer-apps/developer-app-add-edit-form/developer-app-add-edit-form.component.spec.ts index 06cae3a5e..04bd621ff 100644 --- a/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.spec.ts +++ b/src/app/features/settings/developer-apps/developer-app-add-edit-form/developer-app-add-edit-form.component.spec.ts @@ -1,17 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CreateDeveloperAppComponent } from './create-developer-app.component'; +import { DeveloperAppAddEditFormComponent } from './developer-app-add-edit-form.component'; describe('CreateDeveloperAppComponent', () => { - let component: CreateDeveloperAppComponent; - let fixture: ComponentFixture; + let component: DeveloperAppAddEditFormComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [CreateDeveloperAppComponent], + imports: [DeveloperAppAddEditFormComponent], }).compileComponents(); - fixture = TestBed.createComponent(CreateDeveloperAppComponent); + fixture = TestBed.createComponent(DeveloperAppAddEditFormComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/app/features/settings/developer-apps/developer-app-add-edit-form/developer-app-add-edit-form.component.ts b/src/app/features/settings/developer-apps/developer-app-add-edit-form/developer-app-add-edit-form.component.ts new file mode 100644 index 000000000..85fb5054b --- /dev/null +++ b/src/app/features/settings/developer-apps/developer-app-add-edit-form/developer-app-add-edit-form.component.ts @@ -0,0 +1,124 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, + OnInit, +} from '@angular/core'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Button } from 'primeng/button'; +import { InputText } from 'primeng/inputtext'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { + DeveloperAppForm, + DeveloperAppFormFormControls, +} from '@osf/features/settings/developer-apps/entities/developer-app-form.entities'; +import { linkValidator } from '@core/helpers/link-validator.helper'; +import { Store } from '@ngxs/store'; +import { Router } from '@angular/router'; +import { + DeveloperApp, + DeveloperAppCreateUpdate, +} from '@osf/features/settings/developer-apps/entities/developer-apps.models'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; +import { NgClass } from '@angular/common'; +import { + CreateDeveloperApp, + UpdateDeveloperApp, +} from '@osf/features/settings/developer-apps/store'; + +@Component({ + selector: 'osf-developer-app-add-edit-form', + imports: [Button, InputText, ReactiveFormsModule, NgClass], + templateUrl: './developer-app-add-edit-form.component.html', + styleUrl: './developer-app-add-edit-form.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeveloperAppAddEditFormComponent implements OnInit { + #store = inject(Store); + #router = inject(Router); + readonly isEditMode = input(false); + readonly initialValues = input(null); + + protected readonly isMobile = toSignal(inject(IS_XSMALL)); + protected readonly dialogRef = inject(DynamicDialogRef); + protected readonly DeveloperAppFormFormControls = + DeveloperAppFormFormControls; + protected readonly appForm: DeveloperAppForm = new FormGroup({ + [DeveloperAppFormFormControls.AppName]: new FormControl('', { + nonNullable: true, + validators: [Validators.required], + }), + [DeveloperAppFormFormControls.ProjectHomePageUrl]: new FormControl('', { + nonNullable: true, + validators: [Validators.required, linkValidator()], + }), + [DeveloperAppFormFormControls.AppDescription]: new FormControl('', { + nonNullable: false, + }), + [DeveloperAppFormFormControls.AuthorizationCallbackUrl]: new FormControl( + '', + { + nonNullable: true, + validators: [Validators.required, linkValidator()], + }, + ), + }); + + ngOnInit(): void { + if (this.initialValues()) { + this.appForm.patchValue({ + [DeveloperAppFormFormControls.AppName]: this.initialValues()?.name, + [DeveloperAppFormFormControls.AppDescription]: + this.initialValues()?.description, + [DeveloperAppFormFormControls.ProjectHomePageUrl]: + this.initialValues()?.projHomePageUrl, + [DeveloperAppFormFormControls.AuthorizationCallbackUrl]: + this.initialValues()?.authorizationCallbackUrl, + }); + } + } + + handleSubmitForm(): void { + if (!this.appForm.valid) { + this.appForm.markAllAsTouched(); + Object.values(this.appForm.controls).forEach((control) => + control.markAsDirty(), + ); + return; + } + + if (!this.isEditMode()) { + this.#store + .dispatch( + new CreateDeveloperApp({ + ...this.appForm.value, + } as DeveloperAppCreateUpdate), + ) + .subscribe({ + complete: () => { + this.dialogRef.close(); + }, + }); + } else { + this.#store + .dispatch( + new UpdateDeveloperApp(this.initialValues()!.clientId, { + ...this.appForm.value, + id: this.initialValues()!.id, + } as DeveloperAppCreateUpdate), + ) + .subscribe({ + complete: () => { + this.#router.navigate(['settings/applications']); + }, + }); + } + } +} diff --git a/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.html b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.html index 3ce019244..2e3a12833 100644 --- a/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.html +++ b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.html @@ -1,177 +1,117 @@
-
-

{{ developerApp().appName + developerAppId() }}

- -
+ @if (developerApp()) { +
+

{{ developerApp()?.name }}

+ +
-
- -
-

Client ID

+
+ +
+

Client ID

-

- The client ID is the developer app's unique identifier and is safe to - share publicly. -

+

+ The client ID is the developer app's unique identifier and is safe + to share publicly. +

-
- - Copied! - - - - - - - -
-
-
- - -
-

Client Secret

- -

- The client secret is available only to you. Keep it private and do not - share it. -

- -
-
+
Copied! - +
- -
+ -
- -
-
-
- - -
-

Edit app

- -
-
- - -
+ +
+

Client Secret

-
- - -
+

+ The client secret is available only to you. Keep it private and do + not share it. +

-
- - -
+
+
+ + Copied! + + + + + + + +
-
- - -
+
- -
-
-
+
+ + + +
+

Edit App

+ +
+
+
+ }
diff --git a/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.ts b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.ts index a724fa4ee..e5c385235 100644 --- a/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.ts +++ b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.ts @@ -4,35 +4,30 @@ import { computed, DestroyRef, inject, - OnInit, signal, } from '@angular/core'; -import { - DeveloperApp, - DeveloperAppFormFormControls, - DeveloperAppForm, -} from '@osf/features/settings/developer-apps/developer-app.entities'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { InputText } from 'primeng/inputtext'; import { IconField } from 'primeng/iconfield'; import { InputIcon } from 'primeng/inputicon'; import { CdkCopyToClipboard } from '@angular/cdk/clipboard'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { - FormControl, - FormGroup, - FormsModule, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; -import { linkValidator } from '@core/helpers/link-validator.helper'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ConfirmationService } from 'primeng/api'; import { defaultConfirmationConfig } from '@shared/helpers/default-confirmation-config.helper'; -import { timer } from 'rxjs'; -import { NgClass } from '@angular/common'; +import { map, of, switchMap, timer } from 'rxjs'; +import { Store } from '@ngxs/store'; +import { + DeleteDeveloperApp, + DeveloperAppsSelectors, + GetDeveloperAppDetails, + ResetClientSecret, +} from '@osf/features/settings/developer-apps/store'; +import { DeveloperAppAddEditFormComponent } from '@osf/features/settings/developer-apps/developer-app-add-edit-form/developer-app-add-edit-form.component'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; @Component({ selector: 'osf-developer-application-details', @@ -46,93 +41,82 @@ import { NgClass } from '@angular/common'; CdkCopyToClipboard, FormsModule, ReactiveFormsModule, - NgClass, + DeveloperAppAddEditFormComponent, ], templateUrl: './developer-app-details.component.html', styleUrl: './developer-app-details.component.scss', + providers: [DialogService, DynamicDialogRef], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DeveloperAppDetailsComponent implements OnInit { - private readonly destroyRef = inject(DestroyRef); - private readonly activatedRoute = inject(ActivatedRoute); - private readonly confirmationService = inject(ConfirmationService); - private readonly isXSmall$ = inject(IS_XSMALL); - - isXSmall = toSignal(this.isXSmall$); - developerAppId = signal(null); - developerApp = signal({ - id: '1', - appName: 'Example name', - projHomePageUrl: 'https://example.com', - appDescription: 'Example description', - authorizationCallbackUrl: 'https://example.com/callback', - }); - isClientSecretVisible = signal(false); - clientSecret = signal( - 'clientsecretclientsecretclientsecretclientsecret', - ); - hiddenClientSecret = computed(() => - '*'.repeat(this.clientSecret().length), - ); - clientSecretCopiedNotificationVisible = signal(false); +export class DeveloperAppDetailsComponent { + #destroyRef = inject(DestroyRef); + #confirmationService = inject(ConfirmationService); + #activatedRoute = inject(ActivatedRoute); + #router = inject(Router); + #store = inject(Store); - clientId = signal('clientid'); - clientIdCopiedNotificationVisible = signal(false); + protected readonly isXSmall = toSignal(inject(IS_XSMALL)); - readonly DeveloperAppFormFormControls = DeveloperAppFormFormControls; - readonly editAppForm: DeveloperAppForm = new FormGroup({ - [DeveloperAppFormFormControls.AppName]: new FormControl( - this.developerApp().appName, - { - nonNullable: true, - validators: [Validators.required], - }, - ), - [DeveloperAppFormFormControls.ProjectHomePageUrl]: new FormControl( - this.developerApp().projHomePageUrl, - { - nonNullable: true, - validators: [Validators.required, linkValidator()], - }, - ), - [DeveloperAppFormFormControls.AppDescription]: new FormControl( - this.developerApp().appDescription, - { - nonNullable: false, - }, - ), - [DeveloperAppFormFormControls.AuthorizationCallbackUrl]: new FormControl( - this.developerApp().authorizationCallbackUrl, - { - nonNullable: true, - validators: [Validators.required, linkValidator()], - }, + readonly clientId = toSignal( + this.#activatedRoute.params.pipe( + map((params) => params['id']), + switchMap((clientId) => { + const app = this.#store.selectSnapshot( + DeveloperAppsSelectors.getDeveloperAppDetails, + )(clientId); + if (!app) { + this.#store.dispatch(new GetDeveloperAppDetails(clientId)); + } + return of(clientId); + }), ), + ); + + readonly developerApp = computed(() => { + const id = this.clientId(); + if (!id) return null; + const app = this.#store.selectSignal( + DeveloperAppsSelectors.getDeveloperAppDetails, + )(); + return app(id) ?? null; }); - ngOnInit(): void { - this.developerAppId.set(this.activatedRoute.snapshot.params['id']); - } + protected readonly isClientSecretVisible = signal(false); + protected readonly clientSecret = computed( + () => this.developerApp()?.clientSecret ?? '', + ); + protected readonly hiddenClientSecret = computed(() => + '*'.repeat(this.clientSecret().length), + ); + protected readonly clientSecretCopiedNotificationVisible = + signal(false); + protected readonly clientIdCopiedNotificationVisible = signal(false); deleteApp(): void { - this.confirmationService.confirm({ + this.#confirmationService.confirm({ ...defaultConfirmationConfig, message: "Are you sure you want to delete this developer app? All users' access tokens will be revoked. This cannot be reversed.", - header: `Delete App ${this.developerApp().appName}?`, + header: `Delete App ${this.developerApp()?.name}?`, acceptButtonProps: { ...defaultConfirmationConfig.acceptButtonProps, severity: 'danger', label: 'Delete', }, accept: () => { - //TODO integrate API + this.#store + .dispatch(new DeleteDeveloperApp(this.clientId())) + .subscribe({ + complete: () => { + this.#router.navigate(['settings/developer-apps']); + }, + }); }, }); } resetClientSecret(): void { - this.confirmationService.confirm({ + this.#confirmationService.confirm({ ...defaultConfirmationConfig, message: 'Resetting the client secret will render your application unusable until it is updated with the new client secret,' + @@ -145,7 +129,7 @@ export class DeveloperAppDetailsComponent implements OnInit { label: 'Reset', }, accept: () => { - //TODO integrate API + this.#store.dispatch(new ResetClientSecret(this.clientId())); }, }); } @@ -154,7 +138,7 @@ export class DeveloperAppDetailsComponent implements OnInit { this.clientIdCopiedNotificationVisible.set(true); timer(2500) - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe(takeUntilDestroyed(this.#destroyRef)) .subscribe(() => { this.clientIdCopiedNotificationVisible.set(false); }); @@ -164,28 +148,9 @@ export class DeveloperAppDetailsComponent implements OnInit { this.clientSecretCopiedNotificationVisible.set(true); timer(2500) - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe(takeUntilDestroyed(this.#destroyRef)) .subscribe(() => { this.clientSecretCopiedNotificationVisible.set(false); }); } - - submitForm(): void { - if (!this.editAppForm.valid) { - this.editAppForm.markAllAsTouched(); - this.editAppForm.get(DeveloperAppFormFormControls.AppName)?.markAsDirty(); - this.editAppForm - .get(DeveloperAppFormFormControls.ProjectHomePageUrl) - ?.markAsDirty(); - this.editAppForm - .get(DeveloperAppFormFormControls.AppDescription) - ?.markAsDirty(); - this.editAppForm - .get(DeveloperAppFormFormControls.AuthorizationCallbackUrl) - ?.markAsDirty(); - return; - } - - //TODO integrate API - } } diff --git a/src/app/features/settings/developer-apps/developer-app.mapper.ts b/src/app/features/settings/developer-apps/developer-app.mapper.ts new file mode 100644 index 000000000..f1f546730 --- /dev/null +++ b/src/app/features/settings/developer-apps/developer-app.mapper.ts @@ -0,0 +1,66 @@ +import { + DeveloperApp, + DeveloperAppCreateRequest, + DeveloperAppGetResponse, + DeveloperAppCreateUpdate, + DeveloperAppUpdateRequest, +} from '@osf/features/settings/developer-apps/entities/developer-apps.models'; + +export class DeveloperAppMapper { + static toCreateRequest( + developerCreate: DeveloperAppCreateUpdate, + ): DeveloperAppCreateRequest { + return { + data: { + attributes: { + name: developerCreate.name, + description: developerCreate.description, + home_url: developerCreate.projHomePageUrl, + callback_url: developerCreate.authorizationCallbackUrl, + }, + type: 'applications', + }, + }; + } + + static toUpdateRequest( + developerUpdate: DeveloperAppCreateUpdate, + ): DeveloperAppUpdateRequest { + return { + data: { + id: developerUpdate.id!, + attributes: { + name: developerUpdate.name, + description: developerUpdate.description, + home_url: developerUpdate.projHomePageUrl, + callback_url: developerUpdate.authorizationCallbackUrl, + }, + type: 'applications', + }, + }; + } + + static toResetSecretRequest(clientId: string) { + return { + data: { + id: clientId, + type: 'applications', + attributes: { + client_secret: null, + }, + }, + }; + } + + static fromGetResponse(response: DeveloperAppGetResponse): DeveloperApp { + return { + id: response.id, + name: response.attributes.name, + projHomePageUrl: response.attributes.home_url, + description: response.attributes.description, + authorizationCallbackUrl: response.attributes.callback_url, + clientId: response.attributes.client_id, + clientSecret: response.attributes.client_secret, + }; + } +} diff --git a/src/app/features/settings/developer-apps/developer-apps-container.component.html b/src/app/features/settings/developer-apps/developer-apps-container.component.html index 17a12df66..c5292d04b 100644 --- a/src/app/features/settings/developer-apps/developer-apps-container.component.html +++ b/src/app/features/settings/developer-apps/developer-apps-container.component.html @@ -1,5 +1,5 @@ this.#router.url === '/settings/developer-apps'), + ), + { initialValue: this.#router.url === '/settings/developer-apps' }, + ); createDeveloperApp(): void { let dialogWidth = '850px'; - if (this.isXSmall()) { + if (this.#isXSmall()) { dialogWidth = '345px'; - } else if (this.isMedium()) { + } else if (this.#isMedium()) { dialogWidth = '500px'; } - this.dialogService.open(CreateDeveloperAppComponent, { + this.#dialogService.open(DeveloperAppAddEditFormComponent, { width: dialogWidth, focusOnShow: false, header: 'Create Developer App', diff --git a/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.html b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.html index 8c19106e4..7aa2d0381 100644 --- a/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.html +++ b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.html @@ -8,8 +8,8 @@ @for (developerApp of developerApplications(); track $index) {
- -

{{ developerApp.appName }}

+
+

{{ developerApp.name }}

{{ developerApp.appName }}
} + + @if (isLoading()) { + @for (_ of [1, 2, 3]; track $index) { + +
+ + +
+
+ } + }
diff --git a/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.ts b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.ts index cdf7ac554..21b8bbc12 100644 --- a/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.ts +++ b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.ts @@ -2,60 +2,71 @@ import { ChangeDetectionStrategy, Component, inject, + OnInit, signal, } from '@angular/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { RouterLink } from '@angular/router'; -import { DeveloperApp } from '@osf/features/settings/developer-apps/developer-app.entities'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { toSignal } from '@angular/core/rxjs-interop'; import { defaultConfirmationConfig } from '@shared/helpers/default-confirmation-config.helper'; import { ConfirmationService } from 'primeng/api'; +import { Store } from '@ngxs/store'; +import { + DeleteDeveloperApp, + DeveloperAppsSelectors, + GetDeveloperApps, +} from '@osf/features/settings/developer-apps/store'; +import { DeveloperApp } from '@osf/features/settings/developer-apps/entities/developer-apps.models'; +import { Skeleton } from 'primeng/skeleton'; @Component({ selector: 'osf-developer-applications-list', - imports: [Button, Card, RouterLink], + imports: [Button, Card, RouterLink, Skeleton], templateUrl: './developer-apps-list.component.html', styleUrl: './developer-apps-list.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DeveloperAppsListComponent { - private readonly confirmationService = inject(ConfirmationService); - #isXSmall$ = inject(IS_XSMALL); - isXSmall = toSignal(this.#isXSmall$); +export class DeveloperAppsListComponent implements OnInit { + #store = inject(Store); + #confirmationService = inject(ConfirmationService); - developerApplications = signal([ - { - id: '1', - appName: 'Developer app name example', - projHomePageUrl: 'https://example.com', - appDescription: 'Example description', - authorizationCallbackUrl: 'https://example.com/callback', - }, - { - id: '2', - appName: 'Developer app name example', - projHomePageUrl: 'https://example.com', - appDescription: 'Example description', - authorizationCallbackUrl: 'https://example.com/callback', - }, - ]); + protected readonly isLoading = signal(false); + protected readonly isXSmall = toSignal(inject(IS_XSMALL)); + readonly developerApplications = this.#store.selectSignal( + DeveloperAppsSelectors.getDeveloperApps, + ); deleteApp(developerApp: DeveloperApp): void { - this.confirmationService.confirm({ + this.#confirmationService.confirm({ ...defaultConfirmationConfig, message: "Are you sure you want to delete this developer app? All users' access tokens will be revoked. This cannot be reversed.", - header: `Delete App ${developerApp.appName}?`, + header: `Delete App ${developerApp.name}?`, acceptButtonProps: { ...defaultConfirmationConfig.acceptButtonProps, severity: 'danger', label: 'Delete', }, accept: () => { - //TODO integrate API + this.#store.dispatch(new DeleteDeveloperApp(developerApp.clientId)); }, }); } + + ngOnInit(): void { + if (!this.developerApplications().length) { + this.isLoading.set(true); + + this.#store.dispatch(GetDeveloperApps).subscribe({ + complete: () => { + this.isLoading.set(false); + }, + error: () => { + this.isLoading.set(false); + }, + }); + } + } } diff --git a/src/app/features/settings/developer-apps/developer-apps.service.ts b/src/app/features/settings/developer-apps/developer-apps.service.ts new file mode 100644 index 000000000..8097b61f9 --- /dev/null +++ b/src/app/features/settings/developer-apps/developer-apps.service.ts @@ -0,0 +1,73 @@ +import { inject, Injectable } from '@angular/core'; +import { JsonApiService } from '@core/services/json-api/json-api.service'; +import { map, Observable } from 'rxjs'; +import { JsonApiResponse } from '@core/services/json-api/json-api.entity'; +import { + DeveloperApp, + DeveloperAppGetResponse, + DeveloperAppCreateUpdate, +} from '@osf/features/settings/developer-apps/entities/developer-apps.models'; +import { DeveloperAppMapper } from '@osf/features/settings/developer-apps/developer-app.mapper'; + +@Injectable({ + providedIn: 'root', +}) +export class DeveloperApplicationsService { + jsonApiService = inject(JsonApiService); + baseUrl = 'https://api.staging4.osf.io/v2/applications/'; + + getApplications(): Observable { + return this.jsonApiService + .get>(this.baseUrl) + .pipe( + map((responses) => { + return responses.data.map((response) => + DeveloperAppMapper.fromGetResponse(response), + ); + }), + ); + } + + getApplicationDetails(clientId: string): Observable { + return this.jsonApiService + .get< + JsonApiResponse + >(this.baseUrl + clientId + '/') + .pipe( + map((response) => DeveloperAppMapper.fromGetResponse(response.data)), + ); + } + + createApplication( + developerAppCreate: DeveloperAppCreateUpdate, + ): Observable { + const request = DeveloperAppMapper.toCreateRequest(developerAppCreate); + + return this.jsonApiService + .post(this.baseUrl, request) + .pipe(map((response) => DeveloperAppMapper.fromGetResponse(response))); + } + + updateApp( + clientId: string, + developerAppUpdate: DeveloperAppCreateUpdate, + ): Observable { + const request = DeveloperAppMapper.toUpdateRequest(developerAppUpdate); + + return this.jsonApiService + .patch(this.baseUrl + clientId + '/', request) + .pipe(map((response) => DeveloperAppMapper.fromGetResponse(response))); + } + + resetClientSecret(clientId: string) { + const request = DeveloperAppMapper.toResetSecretRequest(clientId); + + return this.jsonApiService + .patch(this.baseUrl + clientId + '/', request) + .pipe(map((response) => DeveloperAppMapper.fromGetResponse(response))); + } + + deleteApplication(clientId: string): Observable { + return this.jsonApiService.delete(this.baseUrl + clientId + '/'); + } +} diff --git a/src/app/features/settings/developer-apps/developer-app.entities.ts b/src/app/features/settings/developer-apps/entities/developer-app-form.entities.ts similarity index 72% rename from src/app/features/settings/developer-apps/developer-app.entities.ts rename to src/app/features/settings/developer-apps/entities/developer-app-form.entities.ts index 200a7ff5e..967725a85 100644 --- a/src/app/features/settings/developer-apps/developer-app.entities.ts +++ b/src/app/features/settings/developer-apps/entities/developer-app-form.entities.ts @@ -1,18 +1,10 @@ import { FormControl, FormGroup } from '@angular/forms'; import { StringOrNull } from '@core/helpers/types.helper'; -export interface DeveloperApp { - id: string; - appName: string; - projHomePageUrl: string; - appDescription: StringOrNull; - authorizationCallbackUrl: string; -} - export enum DeveloperAppFormFormControls { - AppName = 'appName', + AppName = 'name', + AppDescription = 'description', ProjectHomePageUrl = 'projHomePageUrl', - AppDescription = 'appDescription', AuthorizationCallbackUrl = 'authorizationCallbackUrl', } diff --git a/src/app/features/settings/developer-apps/entities/developer-apps.models.ts b/src/app/features/settings/developer-apps/entities/developer-apps.models.ts new file mode 100644 index 000000000..bc05de7a5 --- /dev/null +++ b/src/app/features/settings/developer-apps/entities/developer-apps.models.ts @@ -0,0 +1,59 @@ +import { StringOrNull } from '@core/helpers/types.helper'; + +//Domain models +export interface DeveloperApp { + id: string; + name: string; + description: StringOrNull; + projHomePageUrl: string; + authorizationCallbackUrl: string; + clientId: string; + clientSecret: string; +} + +export interface DeveloperAppCreateUpdate { + id?: string; + name: string; + description: StringOrNull; + projHomePageUrl: string; + authorizationCallbackUrl: string; +} + +// API Request/Response Models +export interface DeveloperAppCreateRequest { + data: { + type: 'applications'; + attributes: { + name: string; + description: StringOrNull; + home_url: string; + callback_url: string; + }; + }; +} + +export interface DeveloperAppUpdateRequest { + data: { + id: string; + type: 'applications'; + attributes: { + name: string; + description: StringOrNull; + home_url: string; + callback_url: string; + }; + }; +} + +export interface DeveloperAppGetResponse { + id: string; + type: 'applications'; + attributes: { + name: string; + description: string; + home_url: string; + callback_url: string; + client_id: string; + client_secret: string; + }; +} diff --git a/src/app/features/settings/developer-apps/store/developer-apps.actions.ts b/src/app/features/settings/developer-apps/store/developer-apps.actions.ts new file mode 100644 index 000000000..516ec75ae --- /dev/null +++ b/src/app/features/settings/developer-apps/store/developer-apps.actions.ts @@ -0,0 +1,38 @@ +import { DeveloperAppCreateUpdate } from '@osf/features/settings/developer-apps/entities/developer-apps.models'; + +export class GetDeveloperApps { + static readonly type = '[Developer Apps] Get All'; +} + +export class GetDeveloperAppDetails { + static readonly type = '[Developer Apps] Get Details'; + + constructor(public clientId: string) {} +} + +export class CreateDeveloperApp { + static readonly type = '[Developer Apps] Create'; + + constructor(public developerAppCreate: DeveloperAppCreateUpdate) {} +} + +export class ResetClientSecret { + static readonly type = '[Developer Apps] Reset Client Secret'; + + constructor(public clientId: string) {} +} + +export class UpdateDeveloperApp { + static readonly type = '[Developer Apps] Update'; + + constructor( + public clientId: string, + public developerAppUpdate: DeveloperAppCreateUpdate, + ) {} +} + +export class DeleteDeveloperApp { + static readonly type = '[Developer Apps] Delete'; + + constructor(public clientId: string) {} +} diff --git a/src/app/features/settings/developer-apps/store/developer-apps.selectors.ts b/src/app/features/settings/developer-apps/store/developer-apps.selectors.ts new file mode 100644 index 000000000..d06b5fe17 --- /dev/null +++ b/src/app/features/settings/developer-apps/store/developer-apps.selectors.ts @@ -0,0 +1,19 @@ +import { Selector } from '@ngxs/store'; +import { DeveloperAppsStateModel } from '@osf/features/settings/developer-apps/store/developer-apps.state-model'; +import { DeveloperAppsState } from '@osf/features/settings/developer-apps/store/developer-apps.state'; +import { DeveloperApp } from '@osf/features/settings/developer-apps/entities/developer-apps.models'; + +export class DeveloperAppsSelectors { + @Selector([DeveloperAppsState]) + static getDeveloperApps(state: DeveloperAppsStateModel): DeveloperApp[] { + return state.developerApps; + } + + @Selector([DeveloperAppsState]) + static getDeveloperAppDetails( + state: DeveloperAppsStateModel, + ): (clientId: string) => DeveloperApp | undefined { + return (clientId: string) => + state.developerApps.find((app) => app.clientId === clientId); + } +} diff --git a/src/app/features/settings/developer-apps/store/developer-apps.state-model.ts b/src/app/features/settings/developer-apps/store/developer-apps.state-model.ts new file mode 100644 index 000000000..c53e950ce --- /dev/null +++ b/src/app/features/settings/developer-apps/store/developer-apps.state-model.ts @@ -0,0 +1,5 @@ +import { DeveloperApp } from '@osf/features/settings/developer-apps/entities/developer-apps.models'; + +export interface DeveloperAppsStateModel { + developerApps: DeveloperApp[]; +} diff --git a/src/app/features/settings/developer-apps/store/developer-apps.state.ts b/src/app/features/settings/developer-apps/store/developer-apps.state.ts new file mode 100644 index 000000000..7c226ab43 --- /dev/null +++ b/src/app/features/settings/developer-apps/store/developer-apps.state.ts @@ -0,0 +1,143 @@ +import { inject, Injectable } from '@angular/core'; +import { State, Action, StateContext } from '@ngxs/store'; +import { tap, of } from 'rxjs'; +import { DeveloperAppsStateModel } from '@osf/features/settings/developer-apps/store/developer-apps.state-model'; +import { + CreateDeveloperApp, + DeleteDeveloperApp, + GetDeveloperAppDetails, + GetDeveloperApps, + ResetClientSecret, + UpdateDeveloperApp, +} from './developer-apps.actions'; +import { DeveloperApplicationsService } from '@osf/features/settings/developer-apps/developer-apps.service'; +import { DeveloperApp } from '@osf/features/settings/developer-apps/entities/developer-apps.models'; +import { + insertItem, + patch, + removeItem, + updateItem, +} from '@ngxs/store/operators'; + +@State({ + name: 'developerApps', + defaults: { + developerApps: [], + }, +}) +@Injectable() +export class DeveloperAppsState { + #developerAppsService = inject(DeveloperApplicationsService); + + @Action(GetDeveloperApps) + getDeveloperApps(ctx: StateContext) { + return this.#developerAppsService.getApplications().pipe( + tap((developerApps) => { + ctx.setState(patch({ developerApps })); + }), + ); + } + + @Action(GetDeveloperAppDetails) + getDeveloperAppDetails( + ctx: StateContext, + action: GetDeveloperAppDetails, + ) { + const state = ctx.getState(); + const developerAppFromState = state.developerApps.find( + (app: DeveloperApp) => app.clientId === action.clientId, + ); + + if (developerAppFromState) { + return of(developerAppFromState); + } + + return this.#developerAppsService + .getApplicationDetails(action.clientId) + .pipe( + tap((fetchedApp) => { + ctx.setState( + patch({ + developerApps: insertItem(fetchedApp), + }), + ); + }), + ); + } + + @Action(CreateDeveloperApp) + createDeveloperApp( + ctx: StateContext, + action: CreateDeveloperApp, + ) { + return this.#developerAppsService + .createApplication(action.developerAppCreate) + .pipe( + tap((newApp) => { + ctx.setState( + patch({ + developerApps: insertItem(newApp, 0), + }), + ); + }), + ); + } + + @Action(UpdateDeveloperApp) + updateDeveloperApp( + ctx: StateContext, + action: UpdateDeveloperApp, + ) { + return this.#developerAppsService + .updateApp(action.clientId, action.developerAppUpdate) + .pipe( + tap((updatedApp) => { + ctx.setState( + patch({ + developerApps: updateItem( + (app) => app.clientId === action.clientId, + updatedApp, + ), + }), + ); + }), + ); + } + + @Action(ResetClientSecret) + resetClientSecret( + ctx: StateContext, + action: ResetClientSecret, + ) { + return this.#developerAppsService.resetClientSecret(action.clientId).pipe( + tap((updatedApp) => { + ctx.setState( + patch({ + developerApps: updateItem( + (app) => app.clientId === action.clientId, + updatedApp, + ), + }), + ); + }), + ); + } + + @Action(DeleteDeveloperApp) + deleteDeveloperApp( + ctx: StateContext, + action: DeleteDeveloperApp, + ) { + return this.#developerAppsService.deleteApplication(action.clientId).pipe( + tap(() => { + ctx.setState( + patch({ + developerApps: removeItem( + (app) => app.clientId === action.clientId, + ), + }), + ); + }), + ); + } +} diff --git a/src/app/features/settings/developer-apps/store/index.ts b/src/app/features/settings/developer-apps/store/index.ts new file mode 100644 index 000000000..291fcbfa9 --- /dev/null +++ b/src/app/features/settings/developer-apps/store/index.ts @@ -0,0 +1,4 @@ +export * from './developer-apps.state'; +export * from './developer-apps.actions'; +export * from './developer-apps.state-model'; +export * from './developer-apps.selectors'; 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 26f80cae7..aef69f789 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 @@ -4,8 +4,7 @@ > @if (token()) { 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 477a9530b..5c43e7057 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 @@ -25,5 +25,19 @@

{{ token.name }}

} + + @if (isLoading()) { + @for (_ of [1, 2, 3]; track $index) { + +
+ + +
+
+ } + } 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 edcf6acfc..dead9740f 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 @@ -3,6 +3,7 @@ import { Component, inject, OnInit, + signal, } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; @@ -18,10 +19,11 @@ import { GetTokens, TokensSelectors, } from '@osf/features/settings/tokens/store'; +import { Skeleton } from 'primeng/skeleton'; @Component({ selector: 'osf-tokens-list', - imports: [Button, Card, RouterLink], + imports: [Button, Card, RouterLink, Skeleton], templateUrl: './tokens-list.component.html', styleUrl: './tokens-list.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -30,6 +32,7 @@ export class TokensListComponent implements OnInit { #store = inject(Store); #confirmationService = inject(ConfirmationService); #isXSmall$ = inject(IS_XSMALL); + protected readonly isLoading = signal(false); protected readonly isXSmall = toSignal(this.#isXSmall$); tokens = this.#store.selectSignal(TokensSelectors.getTokens); @@ -53,7 +56,15 @@ export class TokensListComponent implements OnInit { ngOnInit(): void { if (!this.tokens().length) { - this.#store.dispatch(GetTokens); + this.isLoading.set(true); + this.#store.dispatch(GetTokens).subscribe({ + complete: () => { + this.isLoading.set(false); + }, + error: () => { + this.isLoading.set(false); + }, + }); } } }