diff --git a/src/app/core/components/breadcrumb/breadcrumb.component.ts b/src/app/core/components/breadcrumb/breadcrumb.component.ts
index 06af64b2d..21ace7a94 100644
--- a/src/app/core/components/breadcrumb/breadcrumb.component.ts
+++ b/src/app/core/components/breadcrumb/breadcrumb.component.ts
@@ -1,5 +1,6 @@
-import { Component, computed, inject, signal } from '@angular/core';
-import { Router } from '@angular/router';
+import { Component, computed, DestroyRef, inject, signal } from '@angular/core';
+import { NavigationEnd, Router } from '@angular/router';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'osf-breadcrumb',
@@ -9,8 +10,19 @@ import { Router } from '@angular/router';
})
export class BreadcrumbComponent {
#router = inject(Router);
+ #destroyRef = inject(DestroyRef);
protected readonly url = signal(this.#router.url);
protected readonly parsedUrl = computed(() => {
return this.url().split('/').filter(Boolean);
});
+
+ constructor() {
+ this.#router.events
+ .pipe(takeUntilDestroyed(this.#destroyRef))
+ .subscribe((event) => {
+ if (event instanceof NavigationEnd) {
+ this.url.set(this.#router.url);
+ }
+ });
+ }
}
diff --git a/src/app/core/helpers/link-validator.helper.ts b/src/app/core/helpers/link-validator.helper.ts
new file mode 100644
index 000000000..a7c406224
--- /dev/null
+++ b/src/app/core/helpers/link-validator.helper.ts
@@ -0,0 +1,16 @@
+import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
+
+export function linkValidator(): ValidatorFn {
+ return (control: AbstractControl): ValidationErrors | null => {
+ const value = control.value;
+ if (!value) {
+ return null;
+ }
+
+ const urlPattern = /^(https?):\/\/.+/i;
+
+ const isValid = urlPattern.test(value);
+
+ return isValid ? null : { link: true };
+ };
+}
diff --git a/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.html b/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.html
new file mode 100644
index 000000000..5aeaa6349
--- /dev/null
+++ b/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.html
@@ -0,0 +1,58 @@
+
diff --git a/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.scss b/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.scss
new file mode 100644
index 000000000..e69de29bb
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/create-developer-app/create-developer-app.component.spec.ts
new file mode 100644
index 000000000..06cae3a5e
--- /dev/null
+++ b/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CreateDeveloperAppComponent } from './create-developer-app.component';
+
+describe('CreateDeveloperAppComponent', () => {
+ let component: CreateDeveloperAppComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [CreateDeveloperAppComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CreateDeveloperAppComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.ts b/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.ts
new file mode 100644
index 000000000..063a2199d
--- /dev/null
+++ b/src/app/features/settings/developer-apps/create-developer-app/create-developer-app.component.ts
@@ -0,0 +1,71 @@
+import { ChangeDetectionStrategy, Component, inject } 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/developer-app.entities';
+import { linkValidator } from '@core/helpers/link-validator.helper';
+
+@Component({
+ selector: 'osf-create-developer-app',
+ imports: [Button, InputText, ReactiveFormsModule],
+ templateUrl: './create-developer-app.component.html',
+ styleUrl: './create-developer-app.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CreateDeveloperAppComponent {
+ readonly dialogRef = inject(DynamicDialogRef);
+ protected readonly DeveloperAppFormFormControls =
+ DeveloperAppFormFormControls;
+
+ readonly createAppForm: 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()],
+ },
+ ),
+ });
+
+ submitForm(): void {
+ if (!this.createAppForm.valid) {
+ this.createAppForm.markAllAsTouched();
+ this.createAppForm
+ .get([DeveloperAppFormFormControls.AppName])
+ ?.markAsDirty();
+ this.createAppForm
+ .get(DeveloperAppFormFormControls.ProjectHomePageUrl)
+ ?.markAsDirty();
+ this.createAppForm
+ .get(DeveloperAppFormFormControls.AppDescription)
+ ?.markAsDirty();
+ this.createAppForm
+ .get(DeveloperAppFormFormControls.AuthorizationCallbackUrl)
+ ?.markAsDirty();
+ return;
+ }
+
+ //TODO integrate API
+ this.dialogRef.close();
+ }
+}
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
new file mode 100644
index 000000000..f229fbbfc
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.html
@@ -0,0 +1,175 @@
+
+
+
+
+ {{ developerApp().appName + developerAppId() }}
+
+
+
+
+
+
+ Client ID
+
+
+ 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!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 000000000..6a9a327fb
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.scss
@@ -0,0 +1,86 @@
+@use "assets/styles/variables" as var;
+@use "assets/styles/mixins" as mix;
+
+.content-container {
+ padding: 1.7rem;
+ color: var.$dark-blue-1;
+ background-color: var.$white;
+
+ &.mobile {
+ padding: 1rem;
+ }
+
+ .navigation-bar-container {
+ margin-bottom: 1.7rem;
+ }
+
+ .tittle-container {
+ @include mix.flex-center-between;
+ margin-bottom: 3.4rem;
+
+ &.mobile {
+ @include mix.flex-column;
+ align-items: inherit;
+ gap: 1.7rem;
+ }
+ }
+
+ .cards-container {
+ @include mix.flex-column;
+ gap: 1.7rem;
+
+ .card-body {
+ h2 {
+ margin-bottom: 1.7rem;
+ }
+
+ p {
+ margin-bottom: 0.85rem;
+ }
+
+ .client-secret-container {
+ @include mix.flex-align-center;
+ gap: 0.85rem;
+ margin-bottom: 1.7rem;
+
+ &.mobile {
+ @include mix.flex-column;
+ align-items: start;
+ }
+ }
+
+ .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: 0px 0px 4px 0px #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/developer-apps/developer-app-details/developer-app-details.component.spec.ts b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.spec.ts
new file mode 100644
index 000000000..bebf34b45
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DeveloperAppDetailsComponent } from './developer-app-details.component';
+
+describe('DeveloperApplicationDetailsComponent', () => {
+ let component: DeveloperAppDetailsComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [DeveloperAppDetailsComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DeveloperAppDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
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
new file mode 100644
index 000000000..54884e8b3
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.ts
@@ -0,0 +1,189 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ 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 { 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 { ConfirmationService } from 'primeng/api';
+import { defaultConfirmationConfig } from '@shared/helpers/default-confirmation-config.helper';
+import { timer } from 'rxjs';
+
+@Component({
+ selector: 'osf-developer-application-details',
+ imports: [
+ Button,
+ Card,
+ RouterLink,
+ InputText,
+ IconField,
+ InputIcon,
+ CdkCopyToClipboard,
+ FormsModule,
+ ReactiveFormsModule,
+ ],
+ templateUrl: './developer-app-details.component.html',
+ styleUrl: './developer-app-details.component.scss',
+ 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);
+
+ clientId = signal('clientid');
+ clientIdCopiedNotificationVisible = signal(false);
+
+ 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()],
+ },
+ ),
+ });
+
+ ngOnInit(): void {
+ this.developerAppId.set(this.activatedRoute.snapshot.params['id']);
+ }
+
+ deleteApp(): void {
+ 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}?`,
+ acceptButtonProps: {
+ ...defaultConfirmationConfig.acceptButtonProps,
+ severity: 'danger',
+ label: 'Delete',
+ },
+ accept: () => {
+ //TODO integrate API
+ },
+ });
+ }
+
+ resetClientSecret(): void {
+ this.confirmationService.confirm({
+ ...defaultConfirmationConfig,
+ message:
+ 'Resetting the client secret will render your application unusable until it is updated with the new client secret,' +
+ ' and all users must reauthorize access. Previously issued access tokens will no longer work.' +
+ '
Are you sure you want to reset the client secret? This cannot be reversed.',
+ header: `Reset Client Secret?`,
+ acceptButtonProps: {
+ ...defaultConfirmationConfig.acceptButtonProps,
+ severity: 'danger',
+ label: 'Reset',
+ },
+ accept: () => {
+ //TODO integrate API
+ },
+ });
+ }
+
+ clientIdCopiedToClipboard(): void {
+ this.clientIdCopiedNotificationVisible.set(true);
+
+ timer(2500)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(() => {
+ this.clientIdCopiedNotificationVisible.set(false);
+ });
+ }
+
+ clientSecretCopiedToClipboard(): void {
+ this.clientSecretCopiedNotificationVisible.set(true);
+
+ timer(2500)
+ .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.entities.ts b/src/app/features/settings/developer-apps/developer-app.entities.ts
new file mode 100644
index 000000000..200a7ff5e
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-app.entities.ts
@@ -0,0 +1,24 @@
+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',
+ ProjectHomePageUrl = 'projHomePageUrl',
+ AppDescription = 'appDescription',
+ AuthorizationCallbackUrl = 'authorizationCallbackUrl',
+}
+
+export type DeveloperAppForm = FormGroup<{
+ [DeveloperAppFormFormControls.AppName]: FormControl;
+ [DeveloperAppFormFormControls.ProjectHomePageUrl]: FormControl;
+ [DeveloperAppFormFormControls.AppDescription]: FormControl;
+ [DeveloperAppFormFormControls.AuthorizationCallbackUrl]: FormControl;
+}>;
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
new file mode 100644
index 000000000..17a12df66
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-apps-container.component.html
@@ -0,0 +1,11 @@
+
+
+
diff --git a/src/app/features/settings/developer-apps/developer-apps-container.component.scss b/src/app/features/settings/developer-apps/developer-apps-container.component.scss
new file mode 100644
index 000000000..3a0294d96
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-apps-container.component.scss
@@ -0,0 +1,12 @@
+@use "assets/styles/mixins" as mix;
+@use "assets/styles/variables" as var;
+
+:host {
+ @include mix.flex-column;
+ flex: 1;
+
+ section {
+ @include mix.flex-column;
+ flex: 1;
+ }
+}
diff --git a/src/app/features/settings/developer-apps/developer-apps.component.spec.ts b/src/app/features/settings/developer-apps/developer-apps-container.component.spec.ts
similarity index 52%
rename from src/app/features/settings/developer-apps/developer-apps.component.spec.ts
rename to src/app/features/settings/developer-apps/developer-apps-container.component.spec.ts
index 877f51a84..e481631a6 100644
--- a/src/app/features/settings/developer-apps/developer-apps.component.spec.ts
+++ b/src/app/features/settings/developer-apps/developer-apps-container.component.spec.ts
@@ -1,17 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { DeveloperAppsComponent } from './developer-apps.component';
+import { DeveloperAppsContainerComponent } from './developer-apps-container.component';
describe('DeveloperAppsComponent', () => {
- let component: DeveloperAppsComponent;
- let fixture: ComponentFixture;
+ let component: DeveloperAppsContainerComponent;
+ let fixture: ComponentFixture;
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [DeveloperAppsComponent],
+ imports: [DeveloperAppsContainerComponent],
}).compileComponents();
- fixture = TestBed.createComponent(DeveloperAppsComponent);
+ fixture = TestBed.createComponent(DeveloperAppsContainerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
diff --git a/src/app/features/settings/developer-apps/developer-apps-container.component.ts b/src/app/features/settings/developer-apps/developer-apps-container.component.ts
new file mode 100644
index 000000000..fa654d3a8
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-apps-container.component.ts
@@ -0,0 +1,41 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { RouterOutlet } from '@angular/router';
+import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component';
+import { DialogService } from 'primeng/dynamicdialog';
+import { CreateDeveloperAppComponent } from '@osf/features/settings/developer-apps/create-developer-app/create-developer-app.component';
+import { IS_MEDIUM, IS_XSMALL } from '@shared/utils/breakpoints.tokens';
+import { toSignal } from '@angular/core/rxjs-interop';
+
+@Component({
+ selector: 'osf-developer-apps',
+ imports: [RouterOutlet, SubHeaderComponent],
+ templateUrl: './developer-apps-container.component.html',
+ styleUrl: './developer-apps-container.component.scss',
+ providers: [DialogService],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DeveloperAppsContainerComponent {
+ private readonly dialogService = inject(DialogService);
+ isXSmall$ = inject(IS_XSMALL);
+ isMedium$ = inject(IS_MEDIUM);
+ isXSmall = toSignal(this.isXSmall$);
+ isMedium = toSignal(this.isMedium$);
+
+ createDeveloperApp(): void {
+ let dialogWidth = '850px';
+ if (this.isXSmall()) {
+ dialogWidth = '345px';
+ } else if (this.isMedium()) {
+ dialogWidth = '500px';
+ }
+
+ this.dialogService.open(CreateDeveloperAppComponent, {
+ width: dialogWidth,
+ focusOnShow: false,
+ header: 'Create Developer App',
+ closeOnEscape: true,
+ modal: true,
+ closable: true,
+ });
+ }
+}
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
new file mode 100644
index 000000000..8c19106e4
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.html
@@ -0,0 +1,26 @@
+
+
+ Third-party web applications can connect to the OSF on behalf of users via
+ the OAuth 2.0 web application flow.
+
+
+
+ @for (developerApp of developerApplications(); track $index) {
+
+
+
+ }
+
+
diff --git a/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.scss b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.scss
new file mode 100644
index 000000000..13e3d1ff1
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.scss
@@ -0,0 +1,49 @@
+@use "assets/styles/variables" as var;
+@use "assets/styles/mixins" as mix;
+
+:host {
+ @include mix.flex-column;
+ flex: 1;
+
+ .content-container {
+ flex: 1;
+ padding: 1.7rem;
+ color: var.$dark-blue-1;
+ background-color: var.$white;
+
+ &.mobile {
+ padding: 1rem;
+ }
+
+ p {
+ margin-bottom: 1.7rem;
+ }
+
+ .applications-container {
+ @include mix.flex-column;
+ gap: 0.85rem;
+
+ p-card {
+ .card-body {
+ &.mobile {
+ @include mix.flex-column;
+ gap: 0.85rem;
+
+ a {
+ align-self: flex-start;
+ }
+
+ .button-container {
+ align-self: flex-end;
+ width: 50%;
+ }
+ }
+
+ &:not(.mobile) {
+ @include mix.flex-center-between;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.spec.ts b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.spec.ts
new file mode 100644
index 000000000..11b7fe2fb
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DeveloperAppsListComponent } from './developer-apps-list.component';
+
+describe('DeveloperApplicationsListComponent', () => {
+ let component: DeveloperAppsListComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [DeveloperAppsListComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DeveloperAppsListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
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
new file mode 100644
index 000000000..cdf7ac554
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-apps-list/developer-apps-list.component.ts
@@ -0,0 +1,61 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ inject,
+ 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';
+
+@Component({
+ selector: 'osf-developer-applications-list',
+ imports: [Button, Card, RouterLink],
+ 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$);
+
+ 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',
+ },
+ ]);
+
+ deleteApp(developerApp: DeveloperApp): void {
+ 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}?`,
+ acceptButtonProps: {
+ ...defaultConfirmationConfig.acceptButtonProps,
+ severity: 'danger',
+ label: 'Delete',
+ },
+ accept: () => {
+ //TODO integrate API
+ },
+ });
+ }
+}
diff --git a/src/app/features/settings/developer-apps/developer-apps.component.html b/src/app/features/settings/developer-apps/developer-apps.component.html
deleted file mode 100644
index 7ab621ed7..000000000
--- a/src/app/features/settings/developer-apps/developer-apps.component.html
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
- Third-party web applications can connect to the OSF on behalf of users via
- the OAuth 2.0 web application flow.
-
-
-
- @for (developerApp of developerApplications; track $index) {
-
-
- {{ developerApp }}
- Delete
-
-
- }
-
-
diff --git a/src/app/features/settings/developer-apps/developer-apps.component.scss b/src/app/features/settings/developer-apps/developer-apps.component.scss
deleted file mode 100644
index a4448def7..000000000
--- a/src/app/features/settings/developer-apps/developer-apps.component.scss
+++ /dev/null
@@ -1,45 +0,0 @@
-@use "assets/styles/mixins" as mix;
-@use "assets/styles/variables" as var;
-
-:host {
- @include mix.flex-column;
- flex: 1;
-
- .header {
- @include mix.flex-center-between;
- width: 100%;
- padding: 7.14rem 1.71rem 3.43rem 1.71rem;
- background: var.$gradient-1;
-
- h1 {
- margin-left: 0.85rem;
- }
-
- p-button {
- margin-left: auto;
- }
-
- i {
- color: var.$dark-blue-1;
- font-size: 2.6rem;
- }
- }
-
- .content {
- margin: 1.7rem;
- color: var.$dark-blue-1;
-
- p {
- margin-bottom: 1.7rem;
- }
-
- .applications-container {
- @include mix.flex-column;
- gap: 0.85rem;
-
- .card-body {
- @include mix.flex-center-between;
- }
- }
- }
-}
diff --git a/src/app/features/settings/developer-apps/developer-apps.component.ts b/src/app/features/settings/developer-apps/developer-apps.component.ts
deleted file mode 100644
index a9d59f78c..000000000
--- a/src/app/features/settings/developer-apps/developer-apps.component.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
-import { Button } from 'primeng/button';
-import { Card } from 'primeng/card';
-
-@Component({
- selector: 'osf-developer-apps',
- imports: [Button, Card],
- templateUrl: './developer-apps.component.html',
- styleUrl: './developer-apps.component.scss',
- changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class DeveloperAppsComponent {
- developerApplications: string[] = [
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- 'Developer app name example',
- ];
-
- onDeleteDeveloperApp(developerApp: string): void {
- console.log('delete', developerApp);
- //TODO implement api integration
- }
-}
diff --git a/src/app/features/settings/developer-apps/developer-apps.route.ts b/src/app/features/settings/developer-apps/developer-apps.route.ts
new file mode 100644
index 000000000..0e3c87d52
--- /dev/null
+++ b/src/app/features/settings/developer-apps/developer-apps.route.ts
@@ -0,0 +1,23 @@
+import { Route } from '@angular/router';
+import { DeveloperAppsContainerComponent } from '@osf/features/settings/developer-apps/developer-apps-container.component';
+
+export const developerAppsRoute: Route = {
+ path: 'developer-apps',
+ component: DeveloperAppsContainerComponent,
+ children: [
+ {
+ path: '',
+ loadComponent: () =>
+ import('./developer-apps-list/developer-apps-list.component').then(
+ (c) => c.DeveloperAppsListComponent,
+ ),
+ },
+ {
+ path: ':id/details',
+ loadComponent: () =>
+ import('./developer-app-details/developer-app-details.component').then(
+ (c) => c.DeveloperAppDetailsComponent,
+ ),
+ },
+ ],
+};
diff --git a/src/app/features/settings/settings.routes.ts b/src/app/features/settings/settings.routes.ts
index 713b9a278..affc73cdc 100644
--- a/src/app/features/settings/settings.routes.ts
+++ b/src/app/features/settings/settings.routes.ts
@@ -1,5 +1,6 @@
import { Routes } from '@angular/router';
import { SettingsContainerComponent } from '@osf/features/settings/settings-container.component';
+import { developerAppsRoute } from '@osf/features/settings/developer-apps/developer-apps.route';
export const settingsRoutes: Routes = [
{
@@ -20,13 +21,7 @@ export const settingsRoutes: Routes = [
(c) => c.AccountSettingsComponent,
),
},
- {
- path: 'developer-apps',
- loadComponent: () =>
- import('./developer-apps/developer-apps.component').then(
- (mod) => mod.DeveloperAppsComponent,
- ),
- },
+ developerAppsRoute,
{
path: 'addons',
children: [
diff --git a/src/assets/styles/overrides/button.scss b/src/assets/styles/overrides/button.scss
index dd31b7762..29f265659 100644
--- a/src/assets/styles/overrides/button.scss
+++ b/src/assets/styles/overrides/button.scss
@@ -148,6 +148,13 @@
}
}
+.btn-half-width {
+ width: 50%;
+ .p-button {
+ width: 100%;
+ }
+}
+
.form-btn {
.p-button {
@include mix.flex-center;
diff --git a/src/assets/styles/overrides/card.scss b/src/assets/styles/overrides/card.scss
index c2b7bbd90..0066c7adf 100644
--- a/src/assets/styles/overrides/card.scss
+++ b/src/assets/styles/overrides/card.scss
@@ -17,3 +17,7 @@
}
}
}
+
+.mobile .p-card .p-card-body {
+ padding: 0.85rem;
+}
diff --git a/src/assets/styles/overrides/iconfield.scss b/src/assets/styles/overrides/iconfield.scss
new file mode 100644
index 000000000..7781e8f4b
--- /dev/null
+++ b/src/assets/styles/overrides/iconfield.scss
@@ -0,0 +1,14 @@
+@use "assets/styles/variables" as var;
+
+.p-iconfield {
+ display: inline-block;
+
+ .p-inputicon {
+ cursor: pointer;
+ color: var.$dark-blue-1;
+
+ i {
+ font-size: 1.2rem;
+ }
+ }
+}
diff --git a/src/assets/styles/styles.scss b/src/assets/styles/styles.scss
index 88b20489e..10e18939d 100644
--- a/src/assets/styles/styles.scss
+++ b/src/assets/styles/styles.scss
@@ -17,7 +17,7 @@
@use "./overrides/radio";
@use "./overrides/dropdown";
@use "./overrides/confirmation-dialog";
-@use "./overrides/tabs";
+@use "./overrides/iconfield";
@layer base, primeng, reset;