From 2fa00debb41504ec82ab0ddeca0f3d0f83f6560d Mon Sep 17 00:00:00 2001 From: preet-chandak Date: Fri, 12 Dec 2025 16:48:25 +0530 Subject: [PATCH 1/2] feat: created the api key tab --- api/src/services/api-key.service.ts | 3 +- .../create-organization.component.html | 25 ++- .../create-organization.component.ts | 40 ++++- .../delete-dialog.component.html | 2 +- .../common/sidenav/sidenav.component.html | 2 +- .../app/components/home/home.component.html | 2 +- .../settings/api-keys/api-keys.component.css | 0 .../settings/api-keys/api-keys.component.html | 144 ++++++++++++++++++ .../settings/api-keys/api-keys.component.ts | 128 ++++++++++++++++ .../settings/api-keys/store/api-keys.store.ts | 114 ++++++++++++++ .../home/settings/settings.component.html | 7 + .../home/settings/settings.component.ts | 3 +- frontend/src/app/services/api-keys.service.ts | 24 +++ frontend/src/dtos/api-key.dto.ts | 7 + 14 files changed, 489 insertions(+), 12 deletions(-) create mode 100644 frontend/src/app/components/home/settings/api-keys/api-keys.component.css create mode 100644 frontend/src/app/components/home/settings/api-keys/api-keys.component.html create mode 100644 frontend/src/app/components/home/settings/api-keys/api-keys.component.ts create mode 100644 frontend/src/app/components/home/settings/api-keys/store/api-keys.store.ts create mode 100644 frontend/src/app/services/api-keys.service.ts create mode 100644 frontend/src/dtos/api-key.dto.ts diff --git a/api/src/services/api-key.service.ts b/api/src/services/api-key.service.ts index 88c8da3..dc60708 100644 --- a/api/src/services/api-key.service.ts +++ b/api/src/services/api-key.service.ts @@ -74,7 +74,8 @@ export class ApiKeyService { if (!apiKey) { this.logger.warn('Api Key not found'); - throw new NotFoundException('Api key not found'); + this.logger.info('END: fetchApiKey service'); + return null; } this.logger.info('END: fetchApiKey service'); diff --git a/frontend/src/app/components/create-organization-container/create-organization/create-organization.component.html b/frontend/src/app/components/create-organization-container/create-organization/create-organization.component.html index 2fb2a61..3883b34 100644 --- a/frontend/src/app/components/create-organization-container/create-organization/create-organization.component.html +++ b/frontend/src/app/components/create-organization-container/create-organization/create-organization.component.html @@ -8,12 +8,27 @@ Currency - - @for(currency of currencyList; track currency) { - {{ currency.text }} + + + + + @for (currency of filteredCurrencyList; track currency) { + + {{ currency.text }} ({{ currency.code }}) + } - - Currency is required + + + + Currency is required + External Id diff --git a/frontend/src/app/components/create-organization-container/create-organization/create-organization.component.ts b/frontend/src/app/components/create-organization-container/create-organization/create-organization.component.ts index 1366257..985937d 100644 --- a/frontend/src/app/components/create-organization-container/create-organization/create-organization.component.ts +++ b/frontend/src/app/components/create-organization-container/create-organization/create-organization.component.ts @@ -11,6 +11,7 @@ import { RxFormBuilder } from '@rxweb/reactive-form-validators'; import { Router } from '@angular/router'; import { CreateOrganizationDto } from '../../../../dtos/organization.dto'; import { instanceToPlain } from 'class-transformer'; +import { MatAutocomplete, MatAutocompleteModule } from "@angular/material/autocomplete"; @Component({ selector: 'app-create-organization', @@ -20,8 +21,10 @@ import { instanceToPlain } from 'class-transformer'; MatInputModule, MatIconModule, MatSelectModule, - CommonModule - ], + CommonModule, + MatAutocomplete, + MatAutocompleteModule, +], templateUrl: './create-organization.component.html', styleUrls: ['./create-organization.component.css'], }) @@ -31,6 +34,8 @@ export class CreateOrganizationComponent implements OnInit { createOrganizationStore = inject(CreateOrganizationStore); isNextClicked = this.createOrganizationStore.onNext; + filteredCurrencyList = this.currencyList; + constructor(private formBuilder: RxFormBuilder, private router: Router) { this.createOrganizationForm = formBuilder.formGroup(new CreateOrganizationDto()) @@ -63,4 +68,35 @@ export class CreateOrganizationComponent implements OnInit { } }) } + + filterCurrency(value: string) { + const search = value.toLowerCase(); + + this.filteredCurrencyList = this.currencyList.filter( + (currency: any) => + currency.text.toLowerCase().includes(search) || + currency.code.toLowerCase().includes(search) + ); + } + + onCurrencySelected(code: string) { + this.createOrganizationForm.get('currency')?.setValue(code); + } + + validateCurrency() { + const value = this.createOrganizationForm.get('currency')?.value; + + if (!value) return; + + const exists = this.currencyList.some( + (c: any) => + c.code === value|| + c.text === value + ); + + if (!exists) { + this.createOrganizationForm.get('currency')?.setValue(''); + } + } + } diff --git a/frontend/src/app/components/home/common/delete-dialog/delete-dialog.component.html b/frontend/src/app/components/home/common/delete-dialog/delete-dialog.component.html index b85b773..edcad1d 100644 --- a/frontend/src/app/components/home/common/delete-dialog/delete-dialog.component.html +++ b/frontend/src/app/components/home/common/delete-dialog/delete-dialog.component.html @@ -17,7 +17,7 @@ data.onDelete() " > - Delete + {{ data.buttonText || 'Delete' }} diff --git a/frontend/src/app/components/home/common/sidenav/sidenav.component.html b/frontend/src/app/components/home/common/sidenav/sidenav.component.html index 2649fda..88d8e7a 100644 --- a/frontend/src/app/components/home/common/sidenav/sidenav.component.html +++ b/frontend/src/app/components/home/common/sidenav/sidenav.component.html @@ -1,6 +1,6 @@
-
+
diff --git a/frontend/src/app/components/home/home.component.html b/frontend/src/app/components/home/home.component.html index ed6aa06..44a652c 100644 --- a/frontend/src/app/components/home/home.component.html +++ b/frontend/src/app/components/home/home.component.html @@ -3,7 +3,7 @@
-
+
diff --git a/frontend/src/app/components/home/settings/api-keys/api-keys.component.css b/frontend/src/app/components/home/settings/api-keys/api-keys.component.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/components/home/settings/api-keys/api-keys.component.html b/frontend/src/app/components/home/settings/api-keys/api-keys.component.html new file mode 100644 index 0000000..17ee3b3 --- /dev/null +++ b/frontend/src/app/components/home/settings/api-keys/api-keys.component.html @@ -0,0 +1,144 @@ + +
+
API Keys
+
+ + + + +
+ + +
+ + @if (apiKeysStore.isLoading()) { + + +
+
API Key
+ + +
+ +
+
Created At
+ + +
+ + } @else if (!apiKeysStore.apiKey()) { + + +
+
+ vpn_key +
+
+
No API Key Generated
+
+ Generate an API key to start integrating with our services +
+
+
+ + } @else { + + +
+
API Key
+
+
+ {{ apiKeysStore.apiKey()?.key }} +
+ +
+
+ + + @if (showSecret() && apiKeysStore.apiKey()?.secret) { +
+
Secret Key
+
+
+ {{ apiKeysStore.apiKey()?.secret }} +
+ +
+
+ warning + Save this secret key now. You won't be able to see it again! +
+
+ + +
+ +
+ } + + +
+
Generated at
+
+ {{ (apiKeysStore.apiKey()?.created_at ?? '') | custom_date : "" : true }} +
+
+ + } + +
+ + +
+ + + @if (!apiKeysStore.apiKey() && !apiKeysStore.isLoading()) { + + + } @else if (apiKeysStore.apiKey()) { + + + } +
+ +
+
+
+ + +
+
+ info +
+
API Key Usage
+
    +
  • Use this API key to authenticate your requests
  • +
  • Keep your API key and secret secure
  • +
  • Regenerating will invalidate the previous key
  • +
+
+
+
diff --git a/frontend/src/app/components/home/settings/api-keys/api-keys.component.ts b/frontend/src/app/components/home/settings/api-keys/api-keys.component.ts new file mode 100644 index 0000000..50ab605 --- /dev/null +++ b/frontend/src/app/components/home/settings/api-keys/api-keys.component.ts @@ -0,0 +1,128 @@ +import { Component, OnInit, effect, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { MatDialog } from '@angular/material/dialog'; +import { ApiKeysStore } from './store/api-keys.store'; +import { OrganizationStore } from '../../../../store/organization.store'; +import { CustomDatePipe } from '../../../../pipe/date.pipe'; +import { SnackbarService } from '../../../../services/snackbar.service'; +import { DeleteDialogComponent } from '../../common/delete-dialog/delete-dialog.component'; +import { Clipboard } from '@angular/cdk/clipboard'; +import { ApiKeyDto } from '../../../../../dtos/api-key.dto'; + +@Component({ + selector: 'app-api-keys', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatDividerModule, + MatCardModule, + MatIconModule, + MatTooltipModule, + NgxSkeletonLoaderModule, + CustomDatePipe, + ], + templateUrl: './api-keys.component.html', +}) +export class ApiKeysComponent implements OnInit { + apiKeysStore = inject(ApiKeysStore); + organizationStore = inject(OrganizationStore); + dialog = inject(MatDialog); + snack = inject(SnackbarService); + clipboard = inject(Clipboard); + + showSecret = signal(false); + + constructor() { + // Watch for newly generated API key to show secret + effect(() => { + const apiKey = this.apiKeysStore.apiKey(); + if (apiKey?.secret) { + this.showSecret.set(true); + } + }); + } + + ngOnInit(): void { + const organizationId = this.organizationStore.organizaiton()?.organizationId; + + if (organizationId) { + this.apiKeysStore.fetchApiKey({ organizationId }); + } + } + + onGenerateApiKey(): void { + const organizationId = this.organizationStore.organizaiton()?.organizationId; + + if (!organizationId) { + this.snack.openSnackBar('Organization not found', undefined); + return; + } + + this.showSecret.set(false); + this.apiKeysStore.generateApiKey({ organizationId }); + } + + onRegenerateApiKey(): void { + const org = this.organizationStore.organizaiton(); + + if (!org?.organizationId) { + this.snack.openSnackBar('Organization not found', undefined); + return; + } + + this.dialog.open(DeleteDialogComponent, { + width: '448px', + data: { + title: 'Regenerate API Key?', + description: 'Are you sure you want to regenerate your API key? This will invalidate your current key and any applications using it will stop working until you update them with the new key.', + buttonText: 'Regenerate', + onDelete: async () => { + try { + this.showSecret.set(false); + await this.apiKeysStore.generateApiKey({ organizationId: org.organizationId! }); + this.dialog.closeAll(); + } catch (err) { + this.snack.openSnackBar('Failed to regenerate API key', undefined); + } + } + } + }); + } + + copyToClipboard(text: string): void { + const success = this.clipboard.copy(text); + + if (success) { + this.snack.openSnackBar('Copied to clipboard', undefined); + } else { + this.snack.openSnackBar('Failed to copy', undefined); + } + } + + downloadTxt(apiKey: ApiKeyDto): void { + if (!apiKey) return; + + const textContent = + `API Key: ${apiKey.key || ''}\n` + + `Secret: ${apiKey.secret || ''}\n` + + `Created At: ${apiKey.created_at || ''}\n`; + + const blob = new Blob([textContent], { type: 'text/plain;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = 'api-key.txt'; + link.click(); + + URL.revokeObjectURL(url); + } + +} diff --git a/frontend/src/app/components/home/settings/api-keys/store/api-keys.store.ts b/frontend/src/app/components/home/settings/api-keys/store/api-keys.store.ts new file mode 100644 index 0000000..79f7a51 --- /dev/null +++ b/frontend/src/app/components/home/settings/api-keys/store/api-keys.store.ts @@ -0,0 +1,114 @@ +import { inject } from '@angular/core'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { catchError, concatMap, EMPTY, pipe, tap } from 'rxjs'; +import { tapResponse } from '@ngrx/operators'; +import { withDevtools } from '@angular-architects/ngrx-toolkit'; +import { ApiKeysService } from '../../../../../services/api-keys.service'; +import { ApiKeyDto } from '../../../../../../dtos/api-key.dto'; +import { SnackbarService } from '../../../../../services/snackbar.service'; + +type ApiKeysState = { + apiKey: ApiKeyDto | null; + isLoading: boolean; + error: string | null; +}; + +const initialState: ApiKeysState = { + apiKey: null, + isLoading: false, + error: null, +}; + +export const ApiKeysStore = signalStore( + { providedIn: 'root' }, + withDevtools('api-keys'), + withState(initialState), + + withMethods((store, apiKeysService = inject(ApiKeysService), snack = inject(SnackbarService)) => ({ + /** --------------------------------------------------------- + * FETCH API KEY + * --------------------------------------------------------- */ + fetchApiKey: rxMethod<{ organizationId: string }>( + pipe( + tap(() => patchState(store, { isLoading: true, error: null })), + + concatMap(({ organizationId }) => + apiKeysService.fetchApiKey(organizationId).pipe( + tapResponse({ + next: (response) => { + patchState(store, { + apiKey: response.data ?? null, + isLoading: false, + }); + }, + error: (error: any) => { + patchState(store, { + isLoading: false, + error: error?.message || 'Failed to fetch API key', + }); + + snack.openSnackBar(error?.message || 'Failed to fetch API key', undefined); + }, + }), + + catchError((error) => { + patchState(store, { + isLoading: false, + error: error?.message || 'Failed to fetch API key', + }); + return EMPTY; + }) + ) + ) + ) + ), + + /** --------------------------------------------------------- + * GENERATE API KEY + * --------------------------------------------------------- */ + generateApiKey: rxMethod<{ organizationId: string }>( + pipe( + tap(() => patchState(store, { isLoading: true, error: null })), + + concatMap(({ organizationId }) => + apiKeysService.generateApiKey(organizationId).pipe( + tapResponse({ + next: (response) => { + patchState(store, { + apiKey: response.data ?? null, + isLoading: false, + }); + + snack.openSnackBar('API key generated successfully', undefined); + }, + error: (error: any) => { + patchState(store, { + isLoading: false, + error: error?.message || 'Failed to generate API key', + }); + + snack.openSnackBar(error?.message || 'Failed to generate API key', undefined); + }, + }), + + catchError((error) => { + patchState(store, { + isLoading: false, + error: error?.message || 'Failed to generate API key', + }); + return EMPTY; + }) + ) + ) + ) + ), + + /** --------------------------------------------------------- + * RESET STATE + * --------------------------------------------------------- */ + resetState() { + patchState(store, initialState); + }, + })) +); diff --git a/frontend/src/app/components/home/settings/settings.component.html b/frontend/src/app/components/home/settings/settings.component.html index 428865d..60f8730 100644 --- a/frontend/src/app/components/home/settings/settings.component.html +++ b/frontend/src/app/components/home/settings/settings.component.html @@ -26,4 +26,11 @@
+ + + +
+ +
+
\ No newline at end of file diff --git a/frontend/src/app/components/home/settings/settings.component.ts b/frontend/src/app/components/home/settings/settings.component.ts index ee754b7..50aa62e 100644 --- a/frontend/src/app/components/home/settings/settings.component.ts +++ b/frontend/src/app/components/home/settings/settings.component.ts @@ -3,12 +3,13 @@ import { MatTabGroup, MatTab } from "@angular/material/tabs"; import { UserProfileComponent } from "./user-profile/user-profile.component"; import { OrganizationProfileComponent } from "./organization-profile/organization-profile.component"; import { TeamUsersComponent } from "./team/team.component"; +import { ApiKeysComponent } from "./api-keys/api-keys.component"; @Component({ selector: 'app-settings', templateUrl: './settings.component.html', styleUrls: ['./settings.component.css'], - imports: [MatTabGroup, MatTab, UserProfileComponent, OrganizationProfileComponent, TeamUsersComponent], + imports: [MatTabGroup, MatTab, UserProfileComponent, OrganizationProfileComponent, TeamUsersComponent, ApiKeysComponent], }) export class SettingsComponent { } diff --git a/frontend/src/app/services/api-keys.service.ts b/frontend/src/app/services/api-keys.service.ts new file mode 100644 index 0000000..f60f963 --- /dev/null +++ b/frontend/src/app/services/api-keys.service.ts @@ -0,0 +1,24 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { environment } from '../../environments/environment'; +import { ApiResponse } from '../../dtos/api-response.dto'; +import { ApiKeyDto } from '../../dtos/api-key.dto'; + +@Injectable({ + providedIn: 'root', +}) +export class ApiKeysService { + private endpoint = environment.base_url; + + constructor(private httpClient: HttpClient) {} + + fetchApiKey(organizationId: string) { + const url = `${this.endpoint}/organizations/${organizationId}/api-keys`; + return this.httpClient.get>(url); + } + + generateApiKey(organizationId: string) { + const url = `${this.endpoint}/organizations/${organizationId}/api-keys`; + return this.httpClient.post>(url, {}); + } +} diff --git a/frontend/src/dtos/api-key.dto.ts b/frontend/src/dtos/api-key.dto.ts new file mode 100644 index 0000000..f6ec798 --- /dev/null +++ b/frontend/src/dtos/api-key.dto.ts @@ -0,0 +1,7 @@ +export interface ApiKeyDto { + api_key_id?: string; + key: string; + secret: string; + created_at?: string; + updated_at?: string; +} From 41eb8426b292c9e4b1283bd83e18add4f9b79493 Mon Sep 17 00:00:00 2001 From: preet-chandak Date: Wed, 17 Dec 2025 18:17:01 +0530 Subject: [PATCH 2/2] fix: corrected the label of setting tab --- .../src/app/components/home/settings/settings.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/components/home/settings/settings.component.html b/frontend/src/app/components/home/settings/settings.component.html index 60f8730..97397f3 100644 --- a/frontend/src/app/components/home/settings/settings.component.html +++ b/frontend/src/app/components/home/settings/settings.component.html @@ -28,7 +28,7 @@ - +