diff --git a/apps/36-blocks-widget/src/app/app.component.ts b/apps/36-blocks-widget/src/app/app.component.ts index 9a2aefc7..d60469b8 100644 --- a/apps/36-blocks-widget/src/app/app.component.ts +++ b/apps/36-blocks-widget/src/app/app.component.ts @@ -2,12 +2,14 @@ import { ChangeDetectionStrategy, Component, effect, inject, OnDestroy, OnInit } import { CommonModule } from '@angular/common'; import { environment } from '../environments/environment'; import { BaseComponent } from '@proxy/ui/base-component'; -import { WidgetTheme, PublicScriptType, WidgetConfig, PROXY_DOM_ID } from '@proxy/constant'; +import { WidgetTheme, PublicScriptType, WidgetConfig, PROXY_DOM_ID, WidgetAuthType } from '@proxy/constant'; import { WidgetThemeService } from './otp/service/widget-theme.service'; const REFERENCE_ID = '4512365o17835822266a4f4e12e0cd1'; const THEME: WidgetTheme = WidgetTheme.System; const TYPE: PublicScriptType = PublicScriptType.Authorization; +/** Classic login vs OAuth 2.0 — set to `WidgetAuthType.Auth2` to test authorize. */ +const AUTH_TYPE: WidgetAuthType = WidgetAuthType.Auth2; const OPEN_EDIT_PROFILE = true; const AUTH_TOKEN = ''; @@ -54,7 +56,7 @@ export class AppComponent extends BaseComponent implements OnInit, OnDestroy { if (!environment.production) { const widgetConfig: WidgetConfig = { referenceId: REFERENCE_ID, // Always pass referenceId - + // authType: AUTH_TYPE, target: '_self', // '_blank' | '_self' success: (data: unknown) => { console.log('Success response:', data); diff --git a/apps/36-blocks-widget/src/app/init-verification.ts b/apps/36-blocks-widget/src/app/init-verification.ts index c5a8cf49..b95d3bec 100644 --- a/apps/36-blocks-widget/src/app/init-verification.ts +++ b/apps/36-blocks-widget/src/app/init-verification.ts @@ -3,7 +3,7 @@ import { ProxyAuthWidgetComponent } from './otp/widget/widget.component'; import { omit } from 'lodash-es'; import { PROXY_DOM_ID, PublicScriptType, WidgetEvent } from '@proxy/constant'; -export const RESERVED_KEYS = ['referenceId', 'target', 'style', 'success', 'failure']; +export const RESERVED_KEYS = ['referenceId', 'target', 'style', 'success', 'failure', 'authType']; declare global { interface Window { @@ -78,6 +78,7 @@ if (!window.initVerification) { WithProperties; widgetElement.referenceId = config?.referenceId; widgetElement.type = config?.type; + widgetElement.authType = config?.authType || 'authorization'; widgetElement.authToken = config?.authToken; widgetElement.showCompanyDetails = config?.showCompanyDetails; widgetElement.userToken = config?.userToken; diff --git a/apps/36-blocks-widget/src/app/otp/service/otp.service.ts b/apps/36-blocks-widget/src/app/otp/service/otp.service.ts index f669c097..fa85abb2 100644 --- a/apps/36-blocks-widget/src/app/otp/service/otp.service.ts +++ b/apps/36-blocks-widget/src/app/otp/service/otp.service.ts @@ -58,6 +58,37 @@ export class OtpService { return this.http.post>(url, payload ?? {}, this.options); } + /** OAuth 2.0 authorize validation — passes through all query params from the host page URL. */ + public getOauthAuthorize(params: Record): Observable> { + return this.http.get>( + otpVerificationUrls.oauthAuthorize(this.baseUrl), + params, + this.options + ); + } + + /** + * OAuth 2.0 consent decision. Sends `proxy_auth_token` as a header (same as other c-user APIs). + * Response includes the destination URL for the single browser navigation. + */ + public postOauthAuthorizeDecision( + payload: Record, + proxyAuthToken: string + ): Observable> { + const options = { + ...this.options, + headers: { + ...this.options.headers, + proxy_auth_token: proxyAuthToken, + }, + }; + return this.http.post>( + otpVerificationUrls.oauthAuthorizeDecision(this.baseUrl), + payload, + options + ); + } + public sendOtp(request: ISendOtpReq): Observable { this.setOtpRequestHeaders(request); const body = { identifier: request.identifier ?? request.mobile }; diff --git a/apps/36-blocks-widget/src/app/otp/service/urls/otp-urls.ts b/apps/36-blocks-widget/src/app/otp/service/urls/otp-urls.ts index 376b7626..e19f2797 100644 --- a/apps/36-blocks-widget/src/app/otp/service/urls/otp-urls.ts +++ b/apps/36-blocks-widget/src/app/otp/service/urls/otp-urls.ts @@ -2,6 +2,8 @@ import { createUrl } from '@proxy/service'; export const otpVerificationUrls = { getWidgetData: (baseUrl) => createUrl(baseUrl, ':referenceId/widget'), + oauthAuthorize: (baseUrl) => createUrl(baseUrl, 'oauth/authorize'), + oauthAuthorizeDecision: (baseUrl) => createUrl(baseUrl, 'oauth/authorize/decision'), sendOtp: (baseUrl) => createUrl(baseUrl, 'otp/send'), retryOtp: (baseUrl) => createUrl(baseUrl, 'otp/retry'), verifyOtpV2: (baseUrl) => createUrl(baseUrl, 'otp/verify'), diff --git a/apps/36-blocks-widget/src/app/otp/widget/widget.component.html b/apps/36-blocks-widget/src/app/otp/widget/widget.component.html index bd57af5d..f3111046 100644 --- a/apps/36-blocks-widget/src/app/otp/widget/widget.component.html +++ b/apps/36-blocks-widget/src/app/otp/widget/widget.component.html @@ -1,4 +1,4 @@ -@if (viewMode() === PublicScriptType.Authorization) { +@if (viewMode() === PublicScriptType.Authorization && !showOauthConsent() && !showOauthError()) {
} @@ -38,14 +38,109 @@ } -@if (showRegistration() || showLogin() || showForgotPassword()) { +@if (showRegistration() || showLogin() || showForgotPassword() || showOauthConsent() || showOauthError()) {
+ @if (showOauthError()) { + + } + + @if (showOauthConsent()) { + + } + @if (showForgotPassword()) { } - @if (!showForgotPassword()) { + @if (!showForgotPassword() && !showOauthConsent() && !showOauthError()) { + +
+ +
+
- @if (editModeMainTabIndex !== editModeManageMembersTabIndex) { + @if (!isEditModeFullWidthTab) {
@@ -1266,6 +1271,11 @@

+ +

+
diff --git a/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.ts b/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.ts index cd0061c4..27fbae2e 100644 --- a/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.ts +++ b/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.ts @@ -69,6 +69,7 @@ import { CreateTaxDialogComponent } from './create-tax-dialog/create-tax-dialog. import { ConfirmDialogComponent } from '@proxy/ui/confirm-dialog'; import { ServiceListComponent, ServiceListItem } from '@proxy/ui/service-list'; import { ManagementComponent } from '../../users/management/management.component'; +import { OauthConfigComponent } from '../oauth-config/oauth-config.component'; import { UiSettingsService } from '../../layout/ui-settings.service'; import { SideNavService } from '../../layout/side-nav.service'; import { WidgetThemeService } from 'apps/36-blocks-widget/src/app/otp/service/widget-theme.service'; @@ -143,6 +144,7 @@ export interface PeriodicElement { MatButtonToggleModule, FeaturePreviewComponent, ManagementComponent, + OauthConfigComponent, ], templateUrl: './create-feature.component.html', styleUrls: ['./create-feature.component.scss'], @@ -226,10 +228,20 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, public errorInUploadLogo$: Observable = this.componentStore.errorInUploadLogo$; public allowedOrigins$: Observable = this.componentStore.allowedOrigins$; public isEditMode = false; - /** Selected index of the edit-mode tab group; used to hide the preview panel on Manage Members. */ + /** Selected index of the edit-mode tab group; used to hide the preview panel on full-width tabs. */ public editModeMainTabIndex = 0; - /** Manage Members tab index: Service, Branding, Setting, Design & code, Webhook, Manage Members. */ + /** Manage Members tab index: Service, Branding, Setting, Design & code, Webhook, Manage Members, OAuth Config. */ public readonly editModeManageMembersTabIndex = 5; + /** OAuth Config tab index (full-width, no preview panel). */ + public readonly editModeOauthConfigTabIndex = 6; + + /** Tabs that use the full content width (hide authorization preview). */ + public get isEditModeFullWidthTab(): boolean { + return ( + this.editModeMainTabIndex === this.editModeManageMembersTabIndex || + this.editModeMainTabIndex === this.editModeOauthConfigTabIndex + ); + } public previewInputPosition: 'top' | 'bottom' = 'top'; public selectedServiceIndex = 0; public selectedSubscriptionServiceIndex = -2; diff --git a/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.component.html b/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.component.html new file mode 100644 index 00000000..2c4d73e7 --- /dev/null +++ b/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.component.html @@ -0,0 +1,428 @@ +
+ @if (!featureId) { +
+
+ vpn_key +
+
+

Feature required

+

Open a saved block to manage OAuth configs and scopes.

+
+
+ } @else { +
+ + +
+ + @if (selectedSectionIndex === 0) { +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name +

+ {{ element.name }} +

+
Client ID +
+ {{ element.client_id }} + +
+
Type + {{ element.client_type }} + @if (element.pkce_required) { +

PKCE required

+ } +
Scopes + + @for (scope of element.allowed_scopes; track scope) { + {{ scope }} + } + + Status + + {{ element.status === 'active' ? 'Active' : 'Inactive' }} + + +
+ + + + @if (element.client_type === 'confidential') { + + } + @if (element.status === 'active') { + + } @else { + + } + + + +
+
+ +
+
+ + } + + + @if (selectedSectionIndex === 1) { +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
Key +

{{ element.key }}

+
Label +

{{ element.label || '—' }}

+
+
+ + + + + + +
+
+ +
+
+ + } +
+
+ } +
+ + + +

{{ isEditOauthMode ? 'Edit OAuth' : 'Add OAuth' }}

+ +
+ + Name + + @if (dialogOauthForm.get('name')?.hasError('required')) { + Name is required + } + + + + Client type + + @for (type of clientTypes; track type.value) { + {{ type.label }} + } + + + +
+
+
+ Require PKCE + info +
+ Always enforced for public clients; optional for confidential +
+ +
+ + + Allowed scopes + + + + @for (scope of availableScopeOptions; track scope.key) { + {{ scope.key }} + } + + @if (dialogOauthForm.get('allowed_scopes')?.hasError('required')) { + Select at least one scope + } + + + + Redirect URIs + + @for (uri of redirectUriChips; track uri) { + + {{ uri }} + + + } + + + +
+
+ + + + +
+ + + +

{{ isEditScopeMode ? 'Edit Scope' : 'Add Scope' }}

+ +
+ + Key + + @if (dialogScopeForm.get('key')?.hasError('required')) { + Key is required + } + @if (dialogScopeForm.get('key')?.hasError('pattern')) { + Use lowercase letters, numbers, underscores; optional colon segments + } + + + Label + + @if (dialogScopeForm.get('label')?.hasError('required')) { + Label is required + } + +
+
+ + + + +
+ + + +

Client secret

+ +

+ Copy this secret now — it will not be shown again. Regenerating invalidates the previous secret. +

+ @if (revealedClientId) { +

Client ID

+
+ {{ revealedClientId }} + +
+ } +

Client secret

+
+ {{ revealedSecret }} + +
+
+ + + +
diff --git a/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.component.scss b/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.component.scss new file mode 100644 index 00000000..85b190ed --- /dev/null +++ b/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.component.scss @@ -0,0 +1,207 @@ +:host { + display: block; + height: 100%; +} +td[data-label='Actions'] { + padding-right: 16px !important; +} + +/* Desktop-only column constraints — break stacked mobile cells if applied there. */ +@media screen and (min-width: 769px) { + .col-name { + min-width: 120px; + max-width: 200px; + + &__text { + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; + max-width: 100%; + } + } + + .col-scopes { + min-width: 160px; + max-width: 320px; + + &__chips { + max-width: 100%; + overflow: hidden; + } + } +} + +@media screen and (max-width: 768px) { + .col-name, + .col-scopes { + min-width: 0 !important; + max-width: none !important; + width: 100% !important; + } + + .col-name__text { + flex: 1 1 auto; + min-width: 0; + max-width: none; + text-align: right; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; + } + + .col-scopes__chips { + flex: 1 1 auto; + min-width: 0; + max-width: none; + overflow: visible; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + } + + /* Keep ⋮ menu visible on stacked cards (narrow desktop / trackpad still reports hover). */ + tr.hover-action .actions { + opacity: 1 !important; + } +} + +.status-badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + line-height: 1.4; + text-transform: capitalize; + + &--active { + color: #15803d; + background: color-mix(in srgb, #22c55e 18%, transparent); + border: 1px solid color-mix(in srgb, #22c55e 35%, transparent); + } + + &--inactive { + color: #b91c1c; + background: color-mix(in srgb, #ef4444 18%, transparent); + border: 1px solid color-mix(in srgb, #ef4444 35%, transparent); + } +} + +/* Create-scope action at top of Allowed scopes dropdown */ +::ng-deep .oauth-create-scope-option.mat-mdc-option { + min-height: 44px; + opacity: 1 !important; + cursor: default; + + .mdc-list-item__primary-text { + width: 100%; + } + + &.mdc-list-item--disabled { + pointer-events: auto; + } +} + +/* Overlay panel — fixed width so menu doesn’t shrink when “Regenerate secret” is hidden. */ +::ng-deep .oauth-actions-menu.mat-mdc-menu-panel { + min-width: 220px !important; + max-width: 260px; + border-radius: 10px !important; + overflow: hidden; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 8%), + 0 10px 24px -4px rgb(0 0 0 / 12%) !important; + + .mat-mdc-menu-content { + padding: 6px !important; + } + + .mat-mdc-menu-item { + min-height: 40px; + border-radius: 8px; + margin: 0; + font-size: 13px; + letter-spacing: 0.01em; + + .mat-icon { + margin-right: 12px; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--mat-sys-on-surface-variant, #64748b); + } + + &:hover .mat-icon { + color: var(--mat-sys-on-surface, inherit); + } + } + + .mat-divider { + margin: 6px 8px !important; + } + + .oauth-actions-menu__danger { + color: #b91c1c; + + .mat-icon { + color: #b91c1c; + } + + &:hover { + background: color-mix(in srgb, #ef4444 12%, transparent); + } + } +} + +/* Grow outline with wrapped chips; keep empty-state label aligned with other fields. */ +.oauth-redirect-uris { + width: 100%; + + ::ng-deep { + .mat-mdc-text-field-wrapper.mdc-text-field--outlined { + height: auto !important; + + .mat-mdc-floating-label { + top: 22px; + } + + .mdc-notched-outline--upgraded .mdc-floating-label--float-above { + --mat-mdc-form-field-label-transform: translateY(-27.75px) + scale(var(--mat-mdc-form-field-floating-label-scale, 0.75)); + } + } + + .mat-mdc-form-field-infix { + width: 100%; + min-width: 0; + height: auto !important; + min-height: var(--custom-mat-form-field-height, 48px); + --mat-form-field-container-vertical-padding: 10px; + padding-top: 12px !important; + padding-bottom: 14px !important; + } + + .mat-mdc-chip-set { + width: 100%; + } + + .mat-mdc-chip.mat-mdc-chip-row { + max-width: calc(100% - 8px); + } + + .mdc-evolution-chip__text-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 240px; + } + + .mat-mdc-chip-input { + max-width: 100%; + margin: 0; + outline: none; + box-shadow: none; + } + } +} diff --git a/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.component.ts b/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.component.ts new file mode 100644 index 00000000..6a107e81 --- /dev/null +++ b/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.component.ts @@ -0,0 +1,663 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + TemplateRef, + ViewChild, + inject, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelect, MatSelectModule } from '@angular/material/select'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatMenuModule } from '@angular/material/menu'; +import { PageEvent } from '@angular/material/paginator'; +import { COMMA, ENTER } from '@angular/cdk/keycodes'; +import { MatChipInput, MatChipInputEvent } from '@angular/material/chips'; +import { ServiceListComponent } from '@proxy/ui/service-list'; +import { NoRecordFoundComponent } from '@proxy/ui/no-record-found'; +import { MatPaginatorGotoComponent } from '@proxy/ui/mat-paginator-goto'; +import { ConfirmDialogComponent } from '@proxy/ui/confirm-dialog'; +import { SearchComponent } from '@proxy/ui/search'; +import { CopyButtonComponent } from '@proxy/ui/copy-button'; +import { BUILTIN_OAUTH_SCOPES, IBlockScope, IOAuthConfig, OAuthClientType } from '@proxy/models/features-model'; +import { Subject, filter, takeUntil } from 'rxjs'; +import { OauthConfigComponentStore } from './oauth-config.store'; +import { PAGE_SIZE_OPTIONS } from '@proxy/constant'; + +const SCOPE_KEY_PATTERN = /^[a-z0-9_]+(:[a-z0-9_]+)*$/; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'proxy-oauth-config', + imports: [ + CommonModule, + ReactiveFormsModule, + MatTableModule, + ServiceListComponent, + NoRecordFoundComponent, + MatPaginatorGotoComponent, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatDialogModule, + MatTooltipModule, + MatChipsModule, + MatSlideToggleModule, + MatMenuModule, + MatDividerModule, + SearchComponent, + CopyButtonComponent, + ], + templateUrl: './oauth-config.component.html', + styleUrls: ['./oauth-config.component.scss'], + providers: [OauthConfigComponentStore], +}) +export class OauthConfigComponent implements OnInit, OnChanges, OnDestroy { + private store = inject(OauthConfigComponentStore); + private dialog = inject(MatDialog); + private cdr = inject(ChangeDetectorRef); + private destroy$ = new Subject(); + + /** Numeric feature configuration id (`features/{id}/...`). */ + @Input() featureId: number | null = null; + + public selectedSectionIndex = 0; + public readonly oauthSections = [ + { name: 'OAuth', icon: 'vpn_key' }, + { name: 'Scope', icon: 'tune' }, + ]; + public readonly clientTypes: { value: OAuthClientType; label: string }[] = [ + { value: 'confidential', label: 'Confidential' }, + { value: 'public', label: 'Public' }, + ]; + public readonly chipListSeparatorKeysCodes = [ENTER, COMMA]; + public readonly builtinScopes = BUILTIN_OAUTH_SCOPES; + /** Local chip list for template (OnPush + dialog-safe); synced to form `redirect_uris`. */ + public redirectUriChips: string[] = []; + + public pageSizeOptions: number[] = PAGE_SIZE_OPTIONS; + + public oauthSearchTerm = ''; + public oauthPageSize = 25; + public oauthPageIndex = 0; + public oauthTotalCount = 0; + public oauthDisplayedColumns: string[] = ['name', 'clientId', 'clientType', 'scopes', 'status', 'actions']; + public oauthDataSource = new MatTableDataSource([]); + + public scopeSearchTerm = ''; + public scopePageSize = 25; + public scopePageIndex = 0; + public scopeTotalCount = 0; + public scopeDisplayedColumns: string[] = ['key', 'label', 'actions']; + public scopeDataSource = new MatTableDataSource([]); + + public dialogOauthForm: FormGroup; + public dialogScopeForm: FormGroup; + public isEditOauthMode = false; + public isEditScopeMode = false; + public editingOauth: IOAuthConfig | null = null; + public editingScope: IBlockScope | null = null; + public revealedSecret: string | null = null; + public revealedClientId: string | null = null; + + private blockScopeCatalog: IBlockScope[] = []; + private oauthDialogRef: MatDialogRef; + private scopeDialogRef: MatDialogRef; + private secretDialogRef: MatDialogRef; + /** When true, newly created scopes are selected in the open OAuth dialog. */ + private selectCreatedScopeInOauthForm = false; + + @ViewChild('addOauthDialogTemplate', { static: false }) addOauthDialogTemplate: TemplateRef; + @ViewChild('addScopeDialogTemplate', { static: false }) addScopeDialogTemplate: TemplateRef; + @ViewChild('secretRevealDialogTemplate', { static: false }) secretRevealDialogTemplate: TemplateRef; + + constructor() { + this.dialogOauthForm = new FormGroup({ + name: new FormControl('', [Validators.required, Validators.maxLength(255)]), + client_type: new FormControl('confidential', [Validators.required]), + pkce_required: new FormControl(true), + allowed_scopes: new FormControl([], [Validators.required, Validators.minLength(1)]), + redirect_uris: new FormControl([]), + }); + this.dialogScopeForm = new FormGroup({ + key: new FormControl('', [Validators.required, Validators.pattern(SCOPE_KEY_PATTERN)]), + label: new FormControl('', [Validators.required, Validators.maxLength(255)]), + }); + + this.dialogOauthForm + .get('client_type') + ?.valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe((clientType: OAuthClientType) => { + this.syncPkceForClientType(clientType); + }); + } + + ngOnInit(): void { + this.store.oauthConfigs$.pipe(takeUntil(this.destroy$)).subscribe((page) => { + this.oauthDataSource.data = page?.data ?? []; + this.oauthTotalCount = page?.totalEntityCount ?? 0; + this.cdr.markForCheck(); + }); + + this.store.blockScopes$.pipe(takeUntil(this.destroy$)).subscribe((page) => { + this.scopeDataSource.data = page?.data ?? []; + this.scopeTotalCount = page?.totalEntityCount ?? 0; + this.cdr.markForCheck(); + }); + + this.store.blockScopeCatalog$.pipe(takeUntil(this.destroy$)).subscribe((catalog) => { + this.blockScopeCatalog = catalog ?? []; + this.cdr.markForCheck(); + }); + + this.store.createOauthConfig$ + .pipe( + filter((c): c is IOAuthConfig => !!c), + takeUntil(this.destroy$) + ) + .subscribe((created) => { + this.oauthPageIndex = 0; + this.reloadAll(); + if (created.client_secret) { + this.openSecretReveal(created.client_secret, created.client_id); + } + }); + + this.store.updateOauthConfig$ + .pipe( + filter((c) => !!c), + takeUntil(this.destroy$) + ) + .subscribe(() => this.loadOauthConfigs()); + + this.store.deleteOauthConfig$ + .pipe( + filter((c) => !!c), + takeUntil(this.destroy$) + ) + .subscribe(() => this.loadOauthConfigs()); + + this.store.disableOauthConfig$ + .pipe( + filter((c) => !!c), + takeUntil(this.destroy$) + ) + .subscribe(() => this.loadOauthConfigs()); + + this.store.regenerateSecret$ + .pipe( + filter((c): c is IOAuthConfig => !!c), + takeUntil(this.destroy$) + ) + .subscribe((updated) => { + this.loadOauthConfigs(); + if (updated.client_secret) { + this.openSecretReveal(updated.client_secret, updated.client_id); + } + }); + + this.store.createBlockScope$ + .pipe( + filter((c): c is IBlockScope => !!c), + takeUntil(this.destroy$) + ) + .subscribe((created) => { + this.scopePageIndex = 0; + this.loadBlockScopes(); + this.loadBlockScopeCatalog(); + if (this.selectCreatedScopeInOauthForm && created.key) { + const control = this.dialogOauthForm.get('allowed_scopes'); + const current: string[] = [...(control?.value || [])]; + if (!current.includes(created.key)) { + control?.setValue([...current, created.key]); + control?.markAsDirty(); + } + this.selectCreatedScopeInOauthForm = false; + this.cdr.markForCheck(); + } + }); + + this.store.updateBlockScope$ + .pipe( + filter((c) => !!c), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this.loadBlockScopes(); + this.loadBlockScopeCatalog(); + }); + + this.store.deleteBlockScope$ + .pipe( + filter((c) => !!c), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this.loadBlockScopes(); + this.loadBlockScopeCatalog(); + }); + + if (this.featureId) { + this.reloadAll(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['featureId'] && this.featureId && !changes['featureId'].firstChange) { + this.reloadAll(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** Built-in + block catalog keys for the allowed_scopes picker. */ + public get availableScopeOptions(): { key: string; label: string }[] { + const custom = this.blockScopeCatalog.map((s) => ({ key: s.key, label: s.label })); + return [...this.builtinScopes, ...custom]; + } + + public searchOauth(event: string): void { + this.oauthSearchTerm = event || ''; + this.oauthPageIndex = 0; + this.loadOauthConfigs(); + } + + public onOauthPageChange(event: PageEvent): void { + this.oauthPageSize = event.pageSize; + this.oauthPageIndex = event.pageIndex; + this.loadOauthConfigs(); + } + + public addOauth(): void { + if (!this.featureId) { + return; + } + this.isEditOauthMode = false; + this.editingOauth = null; + this.redirectUriChips = []; + this.dialogOauthForm.reset({ + name: '', + client_type: 'confidential', + pkce_required: true, + allowed_scopes: [], + redirect_uris: [], + }); + this.dialogOauthForm.get('client_type')?.enable({ emitEvent: false }); + this.syncPkceForClientType('confidential'); + this.oauthDialogRef = this.dialog.open(this.addOauthDialogTemplate, { + panelClass: ['mat-dialog'], + autoFocus: true, + restoreFocus: false, + }); + this.oauthDialogRef.afterClosed().subscribe((payload) => { + this.selectCreatedScopeInOauthForm = false; + if (payload && this.featureId) { + this.store.createOauthConfig({ featureId: this.featureId, body: payload }); + } + }); + } + + public editOauth(config: IOAuthConfig): void { + if (!this.featureId) { + return; + } + this.isEditOauthMode = true; + this.editingOauth = config; + this.redirectUriChips = [...(config.redirect_uris || [])]; + this.dialogOauthForm.patchValue( + { + name: config.name, + client_type: config.client_type, + pkce_required: config.client_type === 'public' ? true : config.pkce_required, + allowed_scopes: [...(config.allowed_scopes || [])], + redirect_uris: [...this.redirectUriChips], + }, + { emitEvent: false } + ); + this.dialogOauthForm.get('client_type')?.disable({ emitEvent: false }); + this.syncPkceForClientType(config.client_type); + this.oauthDialogRef = this.dialog.open(this.addOauthDialogTemplate, { + panelClass: ['mat-dialog'], + autoFocus: true, + restoreFocus: false, + }); + this.oauthDialogRef.afterClosed().subscribe((payload) => { + this.selectCreatedScopeInOauthForm = false; + this.dialogOauthForm.get('client_type')?.enable({ emitEvent: false }); + this.dialogOauthForm.get('pkce_required')?.enable({ emitEvent: false }); + if (payload && this.editingOauth && this.featureId) { + this.store.updateOauthConfig({ + featureId: this.featureId, + oauthConfigId: this.editingOauth.id, + body: { + name: payload.name, + pkce_required: payload.pkce_required, + allowed_scopes: payload.allowed_scopes, + redirect_uris: payload.redirect_uris, + }, + }); + } + this.isEditOauthMode = false; + this.editingOauth = null; + }); + } + + public deleteOauth(config: IOAuthConfig): void { + const confirmDialogRef: MatDialogRef = this.dialog.open(ConfirmDialogComponent, { + panelClass: ['mat-dialog'], + }); + confirmDialogRef.componentRef.setInput( + 'confirmationMessage', + `Are you sure you want to delete the OAuth config "${config.name}"?` + ); + confirmDialogRef.componentRef.setInput('confirmButtonText', 'Delete'); + confirmDialogRef.componentRef.setInput('confirmButtonColor', 'warn'); + + confirmDialogRef.afterClosed().subscribe((action) => { + if (action === 'yes' && this.featureId) { + this.store.deleteOauthConfig({ featureId: this.featureId, oauthConfigId: config.id }); + } + }); + } + + public disableOauth(config: IOAuthConfig): void { + const confirmDialogRef: MatDialogRef = this.dialog.open(ConfirmDialogComponent, { + panelClass: ['mat-dialog'], + }); + confirmDialogRef.componentRef.setInput( + 'confirmationMessage', + `Disable "${config.name}"? This revokes all active grants and refresh tokens immediately.` + ); + confirmDialogRef.componentRef.setInput('confirmButtonText', 'Disable'); + confirmDialogRef.componentRef.setInput('confirmButtonColor', 'warn'); + + confirmDialogRef.afterClosed().subscribe((action) => { + if (action === 'yes' && this.featureId) { + this.store.disableOauthConfig({ featureId: this.featureId, oauthConfigId: config.id }); + } + }); + } + + public enableOauth(config: IOAuthConfig): void { + if (!this.featureId || config.status === 'active') { + return; + } + this.store.updateOauthConfig({ + featureId: this.featureId, + oauthConfigId: config.id, + body: { status: 'active' }, + }); + } + + public regenerateSecret(config: IOAuthConfig): void { + if (config.client_type !== 'confidential') { + return; + } + const confirmDialogRef: MatDialogRef = this.dialog.open(ConfirmDialogComponent, { + panelClass: ['mat-dialog'], + }); + confirmDialogRef.componentRef.setInput( + 'confirmationMessage', + `Regenerate secret for "${config.name}"? The current secret stops working immediately.` + ); + confirmDialogRef.componentRef.setInput('confirmButtonText', 'Regenerate'); + confirmDialogRef.componentRef.setInput('confirmButtonColor', 'warn'); + + confirmDialogRef.afterClosed().subscribe((action) => { + if (action === 'yes' && this.featureId) { + this.store.regenerateOauthSecret({ featureId: this.featureId, oauthConfigId: config.id }); + } + }); + } + + public submitOauthDialog(): void { + if (!this.dialogOauthForm.valid) { + return; + } + const raw = this.dialogOauthForm.getRawValue(); + this.oauthDialogRef.close({ + name: raw.name, + client_type: raw.client_type, + pkce_required: !!raw.pkce_required, + allowed_scopes: raw.allowed_scopes || [], + redirect_uris: [...this.redirectUriChips], + }); + } + + public closeOauthDialog(): void { + this.oauthDialogRef.close(false); + } + + public addRedirectUri(event: MatChipInputEvent): void { + const parts = (event.value || '') + .split(/[,\n]+/) + .map((part) => part.trim()) + .filter(Boolean); + const chipInput: MatChipInput | undefined = event.chipInput; + if (!parts.length) { + chipInput?.clear(); + return; + } + let changed = false; + for (const uri of parts) { + if (!this.redirectUriChips.includes(uri)) { + this.redirectUriChips = [...this.redirectUriChips, uri]; + changed = true; + } + } + if (changed) { + this.syncRedirectUrisControl(); + } + chipInput?.clear(); + this.cdr.detectChanges(); + } + + public removeRedirectUri(uri: string): void { + this.redirectUriChips = this.redirectUriChips.filter((u) => u !== uri); + this.syncRedirectUrisControl(); + this.cdr.detectChanges(); + } + + private syncRedirectUrisControl(): void { + this.dialogOauthForm.get('redirect_uris')?.setValue([...this.redirectUriChips]); + this.dialogOauthForm.get('redirect_uris')?.updateValueAndValidity(); + } + + public searchScope(event: string): void { + this.scopeSearchTerm = event || ''; + this.scopePageIndex = 0; + this.loadBlockScopes(); + } + + public onScopePageChange(event: PageEvent): void { + this.scopePageSize = event.pageSize; + this.scopePageIndex = event.pageIndex; + this.loadBlockScopes(); + } + + public addScope(): void { + this.openScopeDialog(false); + } + + /** Opens create-scope dialog from the OAuth Allowed scopes field. */ + public addScopeFromOauthForm(event?: Event, select?: MatSelect): void { + event?.preventDefault(); + event?.stopPropagation(); + select?.close(); + this.openScopeDialog(true); + } + + private openScopeDialog(fromOauthForm: boolean): void { + if (!this.featureId) { + return; + } + this.selectCreatedScopeInOauthForm = fromOauthForm; + this.isEditScopeMode = false; + this.editingScope = null; + this.dialogScopeForm.reset({ key: '', label: '' }); + this.dialogScopeForm.get('key')?.enable(); + this.scopeDialogRef = this.dialog.open(this.addScopeDialogTemplate, { + panelClass: ['mat-dialog'], + autoFocus: true, + restoreFocus: false, + }); + this.scopeDialogRef.afterClosed().subscribe((payload) => { + if (!payload) { + this.selectCreatedScopeInOauthForm = false; + return; + } + if (this.featureId) { + this.store.createBlockScope({ featureId: this.featureId, body: payload }); + } + }); + } + + public editScope(scope: IBlockScope): void { + if (!this.featureId) { + return; + } + this.selectCreatedScopeInOauthForm = false; + this.isEditScopeMode = true; + this.editingScope = scope; + this.dialogScopeForm.patchValue({ key: scope.key, label: scope.label }); + this.dialogScopeForm.get('key')?.disable(); + this.scopeDialogRef = this.dialog.open(this.addScopeDialogTemplate, { + panelClass: ['mat-dialog'], + autoFocus: true, + restoreFocus: false, + }); + this.scopeDialogRef.afterClosed().subscribe((payload) => { + this.dialogScopeForm.get('key')?.enable(); + if (payload && this.editingScope && this.featureId) { + this.store.updateBlockScope({ + featureId: this.featureId, + blockScopeId: this.editingScope.id, + body: { label: payload.label }, + }); + } + this.isEditScopeMode = false; + this.editingScope = null; + }); + } + + public deleteScope(scope: IBlockScope): void { + const confirmDialogRef: MatDialogRef = this.dialog.open(ConfirmDialogComponent, { + panelClass: ['mat-dialog'], + }); + confirmDialogRef.componentRef.setInput( + 'confirmationMessage', + `Are you sure you want to delete the scope "${scope.key}"? OAuth configs that still list this key will keep a dangling reference.` + ); + confirmDialogRef.componentRef.setInput('confirmButtonText', 'Delete'); + confirmDialogRef.componentRef.setInput('confirmButtonColor', 'warn'); + + confirmDialogRef.afterClosed().subscribe((action) => { + if (action === 'yes' && this.featureId) { + this.store.deleteBlockScope({ featureId: this.featureId, blockScopeId: scope.id }); + } + }); + } + + public submitScopeDialog(): void { + if (!this.dialogScopeForm.valid) { + return; + } + const raw = this.dialogScopeForm.getRawValue(); + this.scopeDialogRef.close({ + key: raw.key, + label: raw.label, + }); + } + + public closeScopeDialog(): void { + this.scopeDialogRef.close(false); + } + + public closeSecretReveal(): void { + this.revealedSecret = null; + this.revealedClientId = null; + this.secretDialogRef?.close(); + this.secretDialogRef = null; + } + + private openSecretReveal(secret: string, clientId?: string): void { + this.revealedSecret = secret; + this.revealedClientId = clientId || null; + this.secretDialogRef = this.dialog.open(this.secretRevealDialogTemplate, { + panelClass: ['mat-dialog'], + disableClose: true, + }); + this.cdr.markForCheck(); + } + + private syncPkceForClientType(clientType: OAuthClientType): void { + const pkceControl = this.dialogOauthForm.get('pkce_required'); + if (!pkceControl) { + return; + } + if (clientType === 'public') { + pkceControl.setValue(true, { emitEvent: false }); + pkceControl.disable({ emitEvent: false }); + } else { + pkceControl.enable({ emitEvent: false }); + } + this.cdr.markForCheck(); + } + + private reloadAll(): void { + this.loadOauthConfigs(); + this.loadBlockScopes(); + this.loadBlockScopeCatalog(); + } + + private loadOauthConfigs(): void { + if (!this.featureId) { + return; + } + this.store.getOauthConfigs({ + featureId: this.featureId, + itemsPerPage: this.oauthPageSize, + pageNo: this.oauthPageIndex + 1, + search: this.oauthSearchTerm, + }); + } + + private loadBlockScopes(): void { + if (!this.featureId) { + return; + } + this.store.getBlockScopes({ + featureId: this.featureId, + itemsPerPage: this.scopePageSize, + pageNo: this.scopePageIndex + 1, + search: this.scopeSearchTerm, + }); + } + + private loadBlockScopeCatalog(): void { + if (!this.featureId) { + return; + } + this.store.getBlockScopeCatalog(this.featureId); + } +} diff --git a/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.store.ts b/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.store.ts new file mode 100644 index 00000000..e6beccb3 --- /dev/null +++ b/apps/36-blocks/src/app/panel/features/oauth-config/oauth-config.store.ts @@ -0,0 +1,458 @@ +import { Injectable } from '@angular/core'; +import { ComponentStore } from '@ngrx/component-store'; +import { tapResponse } from '@ngrx/operators'; +import { FeaturesService } from '@proxy/services/proxy/features'; +import { + IBlockScope, + IBlockScopeCreatePayload, + IBlockScopeUpdatePayload, + IOAuthConfig, + IOAuthConfigCreatePayload, + IOAuthConfigUpdatePayload, +} from '@proxy/models/features-model'; +import { BaseResponse, IPaginatedResponse, errorResolver } from '@proxy/models/root-models'; +import { PrimeNgToastService } from '@proxy/ui/prime-ng-toast'; +import { EMPTY, Observable, catchError, switchMap } from 'rxjs'; + +export interface IOauthListParams { + featureId: string | number; + itemsPerPage: number; + pageNo: number; + search?: string; +} + +export interface IOauthConfigState { + oauthConfigs: IPaginatedResponse | null; + blockScopes: IPaginatedResponse | null; + /** Full-ish catalog for allowed_scopes picker (not tied to Scope table page). */ + blockScopeCatalog: IBlockScope[]; + createOauthConfig: IOAuthConfig | null; + updateOauthConfig: IOAuthConfig | null; + deleteOauthConfig: any; + regenerateSecret: IOAuthConfig | null; + disableOauthConfig: IOAuthConfig | null; + createBlockScope: IBlockScope | null; + updateBlockScope: IBlockScope | null; + deleteBlockScope: any; + isLoading: boolean; +} + +@Injectable() +export class OauthConfigComponentStore extends ComponentStore { + constructor( + private service: FeaturesService, + private toast: PrimeNgToastService + ) { + super({ + oauthConfigs: null, + blockScopes: null, + blockScopeCatalog: [], + createOauthConfig: null, + updateOauthConfig: null, + deleteOauthConfig: null, + regenerateSecret: null, + disableOauthConfig: null, + createBlockScope: null, + updateBlockScope: null, + deleteBlockScope: null, + isLoading: false, + }); + } + + readonly loading$ = this.select((state) => ({ dataLoading: state.isLoading })); + readonly oauthConfigs$ = this.select((state) => state.oauthConfigs); + readonly blockScopes$ = this.select((state) => state.blockScopes); + readonly blockScopeCatalog$ = this.select((state) => state.blockScopeCatalog); + readonly createOauthConfig$ = this.select((state) => state.createOauthConfig); + readonly updateOauthConfig$ = this.select((state) => state.updateOauthConfig); + readonly deleteOauthConfig$ = this.select((state) => state.deleteOauthConfig); + readonly regenerateSecret$ = this.select((state) => state.regenerateSecret); + readonly disableOauthConfig$ = this.select((state) => state.disableOauthConfig); + readonly createBlockScope$ = this.select((state) => state.createBlockScope); + readonly updateBlockScope$ = this.select((state) => state.updateBlockScope); + readonly deleteBlockScope$ = this.select((state) => state.deleteBlockScope); + + readonly getOauthConfigs = this.effect((data: Observable) => { + return data.pipe( + switchMap(({ featureId, itemsPerPage, pageNo, search }) => { + this.patchState({ isLoading: true }); + const params: any = { itemsPerPage, pageNo }; + if (search?.trim()) { + params.search = search.trim(); + } + return this.service.getOauthConfigs(featureId, params).pipe( + tapResponse( + (res: BaseResponse, void>) => { + if (res?.hasError) { + this.showError(res?.errors); + } + this.patchState({ + isLoading: false, + oauthConfigs: this.normalizePaginated(res?.data), + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ + isLoading: false, + oauthConfigs: this.emptyPage(), + }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + }); + + readonly createOauthConfig = this.effect( + (data: Observable<{ featureId: string | number; body: IOAuthConfigCreatePayload }>) => { + return data.pipe( + switchMap(({ featureId, body }) => { + this.patchState({ isLoading: true, createOauthConfig: null }); + return this.service.createOauthConfig(featureId, body).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + } else { + this.toast.success('OAuth config created successfully'); + } + this.patchState({ + isLoading: false, + createOauthConfig: res?.hasError ? null : res?.data, + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ isLoading: false, createOauthConfig: null }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + } + ); + + readonly updateOauthConfig = this.effect( + ( + data: Observable<{ + featureId: string | number; + oauthConfigId: string | number; + body: IOAuthConfigUpdatePayload; + }> + ) => { + return data.pipe( + switchMap(({ featureId, oauthConfigId, body }) => { + this.patchState({ isLoading: true, updateOauthConfig: null }); + return this.service.updateOauthConfig(featureId, oauthConfigId, body).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + } else if (body?.status === 'active') { + this.toast.success('OAuth config enabled successfully'); + } else { + this.toast.success('OAuth config updated successfully'); + } + this.patchState({ + isLoading: false, + updateOauthConfig: res?.hasError ? null : res?.data, + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ isLoading: false, updateOauthConfig: null }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + } + ); + + readonly deleteOauthConfig = this.effect( + (data: Observable<{ featureId: string | number; oauthConfigId: string | number }>) => { + return data.pipe( + switchMap(({ featureId, oauthConfigId }) => { + this.patchState({ isLoading: true, deleteOauthConfig: null }); + return this.service.deleteOauthConfig(featureId, oauthConfigId).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + } else { + this.toast.success('OAuth config deleted successfully'); + } + this.patchState({ + isLoading: false, + deleteOauthConfig: res?.hasError ? null : (res?.data ?? true), + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ isLoading: false, deleteOauthConfig: null }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + } + ); + + readonly regenerateOauthSecret = this.effect( + (data: Observable<{ featureId: string | number; oauthConfigId: string | number }>) => { + return data.pipe( + switchMap(({ featureId, oauthConfigId }) => { + this.patchState({ isLoading: true, regenerateSecret: null }); + return this.service.regenerateOauthSecret(featureId, oauthConfigId).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + } else { + this.toast.success('Client secret regenerated'); + } + this.patchState({ + isLoading: false, + regenerateSecret: res?.hasError ? null : res?.data, + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ isLoading: false, regenerateSecret: null }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + } + ); + + readonly disableOauthConfig = this.effect( + (data: Observable<{ featureId: string | number; oauthConfigId: string | number }>) => { + return data.pipe( + switchMap(({ featureId, oauthConfigId }) => { + this.patchState({ isLoading: true, disableOauthConfig: null }); + return this.service.disableOauthConfig(featureId, oauthConfigId).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + } else { + this.toast.success('OAuth config disabled'); + } + this.patchState({ + isLoading: false, + disableOauthConfig: res?.hasError ? null : res?.data, + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ isLoading: false, disableOauthConfig: null }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + } + ); + + readonly getBlockScopes = this.effect((data: Observable) => { + return data.pipe( + switchMap(({ featureId, itemsPerPage, pageNo, search }) => { + this.patchState({ isLoading: true }); + const params: any = { itemsPerPage, pageNo }; + if (search?.trim()) { + params.search = search.trim(); + } + return this.service.getBlockScopes(featureId, params).pipe( + tapResponse( + (res: BaseResponse, void>) => { + if (res?.hasError) { + this.showError(res?.errors); + } + this.patchState({ + isLoading: false, + blockScopes: this.normalizePaginated(res?.data), + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ + isLoading: false, + blockScopes: this.emptyPage(), + }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + }); + + /** Loads scopes for the OAuth allowed_scopes picker (large page, not table pagination). */ + readonly getBlockScopeCatalog = this.effect((data: Observable) => { + return data.pipe( + switchMap((featureId) => { + return this.service.getBlockScopes(featureId, { itemsPerPage: 100, pageNo: 1 }).pipe( + tapResponse( + (res: BaseResponse, void>) => { + if (res?.hasError) { + this.showError(res?.errors); + } + const page = this.normalizePaginated(res?.data); + this.patchState({ blockScopeCatalog: page.data || [] }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ blockScopeCatalog: [] }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + }); + + readonly createBlockScope = this.effect( + (data: Observable<{ featureId: string | number; body: IBlockScopeCreatePayload }>) => { + return data.pipe( + switchMap(({ featureId, body }) => { + this.patchState({ isLoading: true, createBlockScope: null }); + return this.service.createBlockScope(featureId, body).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + } else { + this.toast.success('Scope created successfully'); + } + this.patchState({ + isLoading: false, + createBlockScope: res?.hasError ? null : res?.data, + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ isLoading: false, createBlockScope: null }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + } + ); + + readonly updateBlockScope = this.effect( + ( + data: Observable<{ + featureId: string | number; + blockScopeId: string | number; + body: IBlockScopeUpdatePayload; + }> + ) => { + return data.pipe( + switchMap(({ featureId, blockScopeId, body }) => { + this.patchState({ isLoading: true, updateBlockScope: null }); + return this.service.updateBlockScope(featureId, blockScopeId, body).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + } else { + this.toast.success('Scope updated successfully'); + } + this.patchState({ + isLoading: false, + updateBlockScope: res?.hasError ? null : res?.data, + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ isLoading: false, updateBlockScope: null }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + } + ); + + readonly deleteBlockScope = this.effect( + (data: Observable<{ featureId: string | number; blockScopeId: string | number }>) => { + return data.pipe( + switchMap(({ featureId, blockScopeId }) => { + this.patchState({ isLoading: true, deleteBlockScope: null }); + return this.service.deleteBlockScope(featureId, blockScopeId).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + } else { + this.toast.success('Scope deleted successfully'); + } + this.patchState({ + isLoading: false, + deleteBlockScope: res?.hasError ? null : (res?.data ?? true), + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ isLoading: false, deleteBlockScope: null }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + } + ); + + private showError(error): void { + const errorMessage = errorResolver(error); + errorMessage.forEach((msg) => { + this.toast.error(msg); + }); + } + + private normalizePaginated(data: any): IPaginatedResponse { + if (Array.isArray(data)) { + return { + data, + itemsPerPage: data.length || 25, + pageNumber: 1, + pageNo: 1, + totalEntityCount: data.length, + totalPageCount: 1, + }; + } + const rows = Array.isArray(data?.data) ? data.data : []; + return { + data: rows, + itemsPerPage: data?.itemsPerPage ?? 25, + pageNumber: data?.pageNumber ?? data?.pageNo ?? 1, + pageNo: data?.pageNo ?? data?.pageNumber ?? 1, + totalEntityCount: data?.totalEntityCount ?? rows.length, + totalPageCount: data?.totalPageCount ?? 1, + }; + } + + private emptyPage(): IPaginatedResponse { + return { + data: [], + itemsPerPage: 25, + pageNumber: 1, + pageNo: 1, + totalEntityCount: 0, + totalPageCount: 0, + }; + } +} diff --git a/apps/shared/scss/component/_mat-form-field.scss b/apps/shared/scss/component/_mat-form-field.scss index 6b0bc855..6cd4d4ea 100644 --- a/apps/shared/scss/component/_mat-form-field.scss +++ b/apps/shared/scss/component/_mat-form-field.scss @@ -5,15 +5,21 @@ mat-form-field { --mat-form-field-container-height: var(--custom-mat-form-field-height); .mdc-text-field--outlined { height: var(--mat-form-field-container-height); + /* Chip grids wrap to multiple rows — grow with content instead of clipping. */ + &:has(.mat-mdc-chip-set) { + height: auto; + } .mat-mdc-form-field-infix { - &:has(.mat-mdc-chip-set) { - padding-top: 8px !important; - } --mat-form-field-container-vertical-padding: 10px; min-height: var(--mat-form-field-container-height); height: var(--mat-form-field-container-height); padding-top: 12px !important; padding-bottom: 14px !important; + + &:has(.mat-mdc-chip-set) { + padding-top: 8px !important; + height: auto; + } } } diff --git a/libs/constant/src/verification/verification.ts b/libs/constant/src/verification/verification.ts index 8ff6973c..36074d90 100644 --- a/libs/constant/src/verification/verification.ts +++ b/libs/constant/src/verification/verification.ts @@ -4,6 +4,13 @@ export enum PublicScriptType { UserManagement = 'user-management', OrganizationDetails = 'organization-details', Subscription = 'subscription', + OAuth = 'oauth2', +} + +/** Flow mode for the authorization widget: classic login vs OAuth 2.0 consent. */ +export enum WidgetAuthType { + Authorization = 'authorization', + Auth2 = 'auth2', } export enum WidgetTheme { @@ -16,6 +23,8 @@ export type WidgetConfig = { referenceId?: string; authToken?: string; type?: PublicScriptType; + /** Defaults to `authorization`. Pass `auth2` for the OAuth 2.0 consent flow. */ + authType?: WidgetAuthType | 'authorization' | 'auth2'; exclude_role_ids?: number[]; showCompanyDetails?: boolean; isRolePermission?: boolean; diff --git a/libs/models/features-model/src/index.ts b/libs/models/features-model/src/index.ts index e3b8f4ac..b3877f03 100644 --- a/libs/models/features-model/src/index.ts +++ b/libs/models/features-model/src/index.ts @@ -19,6 +19,67 @@ export interface IAllowedOrigin { created_at?: string; } +export type OAuthClientType = 'public' | 'confidential'; +export type OAuthConfigStatus = 'active' | 'disabled'; + +export interface IOAuthConfig { + id: number; + client_id: string; + feature_configuration_id: number; + name: string; + client_type: OAuthClientType; + pkce_required: boolean; + allowed_scopes: string[]; + redirect_uris: string[]; + status: OAuthConfigStatus; + has_secret: boolean; + /** Only present on create / regenerateSecret responses. */ + client_secret?: string; + created_by?: number; + updated_by?: number; + created_at?: string; + updated_at?: string; +} + +export interface IOAuthConfigCreatePayload { + name: string; + client_type: OAuthClientType; + pkce_required?: boolean; + allowed_scopes: string[]; + redirect_uris: string[]; +} + +export interface IOAuthConfigUpdatePayload { + name?: string; + pkce_required?: boolean; + allowed_scopes?: string[]; + redirect_uris?: string[]; + status?: OAuthConfigStatus; +} + +export interface IBlockScope { + id: number; + feature_configuration_id: number; + key: string; + label: string; + created_at?: string; +} + +export interface IBlockScopeCreatePayload { + key: string; + label: string; +} + +export interface IBlockScopeUpdatePayload { + label: string; +} + +/** Built-in scopes always allowed on OAuth configs (not returned by blockScopes). */ +export const BUILTIN_OAUTH_SCOPES: { key: string; label: string }[] = [ + { key: 'profile', label: 'Profile' }, + { key: 'email', label: 'Email' }, +]; + export interface IFeatureDetails extends IFeature { projects?: string[]; callback_url: string; diff --git a/libs/services/proxy/features/src/lib/services-proxy-features.module.ts b/libs/services/proxy/features/src/lib/services-proxy-features.module.ts index 0fdd085e..70304e13 100644 --- a/libs/services/proxy/features/src/lib/services-proxy-features.module.ts +++ b/libs/services/proxy/features/src/lib/services-proxy-features.module.ts @@ -1,8 +1,20 @@ import { Inject, Injectable } from '@angular/core'; import { HttpWrapperService } from '@proxy/services/httpWrapper'; -import { BaseResponse, IPaginatedResponse, ProxyBaseUrls } from '@proxy/models/root-models'; +import { BaseResponse, IPaginatedResponse, IReqParams, ProxyBaseUrls } from '@proxy/models/root-models'; import { Observable } from 'rxjs'; -import { IFeature, IFeatureDetails, IFeatureReq, IFeatureType, IMethod } from '@proxy/models/features-model'; +import { + IBlockScope, + IBlockScopeCreatePayload, + IBlockScopeUpdatePayload, + IFeature, + IFeatureDetails, + IFeatureReq, + IFeatureType, + IMethod, + IOAuthConfig, + IOAuthConfigCreatePayload, + IOAuthConfigUpdatePayload, +} from '@proxy/models/features-model'; import { FeaturesUrls } from '@proxy/urls/features-url'; @Injectable({ providedIn: 'root', @@ -128,4 +140,112 @@ export class FeaturesService { public getAllowedOrigins(id: string | number): Observable> { return this.http.get>(FeaturesUrls.getAllowedOrigins(this.baseURL, id)); } + + // ── OAuth configs ── + public getOauthConfigs( + featureId: string | number, + params?: IReqParams + ): Observable, void>> { + return this.http.get, void>>( + FeaturesUrls.oauthConfigs(this.baseURL, featureId), + params + ); + } + + public getOauthConfig( + featureId: string | number, + oauthConfigId: string | number + ): Observable> { + return this.http.get>( + FeaturesUrls.oauthConfig(this.baseURL, featureId, oauthConfigId) + ); + } + + public createOauthConfig( + featureId: string | number, + body: IOAuthConfigCreatePayload + ): Observable> { + return this.http.post>( + FeaturesUrls.oauthConfigs(this.baseURL, featureId), + body + ); + } + + public updateOauthConfig( + featureId: string | number, + oauthConfigId: string | number, + body: IOAuthConfigUpdatePayload + ): Observable> { + return this.http.put>( + FeaturesUrls.oauthConfig(this.baseURL, featureId, oauthConfigId), + body + ); + } + + public deleteOauthConfig( + featureId: string | number, + oauthConfigId: string | number + ): Observable> { + return this.http.delete>( + FeaturesUrls.oauthConfig(this.baseURL, featureId, oauthConfigId) + ); + } + + public regenerateOauthSecret( + featureId: string | number, + oauthConfigId: string | number + ): Observable> { + return this.http.post>( + FeaturesUrls.regenerateOauthSecret(this.baseURL, featureId, oauthConfigId), + {} + ); + } + + public disableOauthConfig( + featureId: string | number, + oauthConfigId: string | number + ): Observable> { + return this.http.post>( + FeaturesUrls.disableOauthConfig(this.baseURL, featureId, oauthConfigId), + {} + ); + } + + // ── Block scopes ── + public getBlockScopes( + featureId: string | number, + params?: IReqParams + ): Observable, void>> { + return this.http.get, void>>( + FeaturesUrls.blockScopes(this.baseURL, featureId), + params + ); + } + + public createBlockScope( + featureId: string | number, + body: IBlockScopeCreatePayload + ): Observable> { + return this.http.post>(FeaturesUrls.blockScopes(this.baseURL, featureId), body); + } + + public updateBlockScope( + featureId: string | number, + blockScopeId: string | number, + body: IBlockScopeUpdatePayload + ): Observable> { + return this.http.put>( + FeaturesUrls.blockScope(this.baseURL, featureId, blockScopeId), + body + ); + } + + public deleteBlockScope( + featureId: string | number, + blockScopeId: string | number + ): Observable> { + return this.http.delete>( + FeaturesUrls.blockScope(this.baseURL, featureId, blockScopeId) + ); + } } diff --git a/libs/urls/features-url/src/index.ts b/libs/urls/features-url/src/index.ts index 9bba7350..58195bb1 100644 --- a/libs/urls/features-url/src/index.ts +++ b/libs/urls/features-url/src/index.ts @@ -22,4 +22,14 @@ export const FeaturesUrls = { getWebhookEvents: (baseUrl) => createUrl(baseUrl, `getWebhookTriggerEvents`), uploadLogo: (baseUrl, id) => createUrl(baseUrl, `features/${id}/upload-logo`), getAllowedOrigins: (baseUrl, id) => createUrl(baseUrl, `features/${id}/allowedOrigins`), + oauthConfigs: (baseUrl, featureId) => createUrl(baseUrl, `features/${featureId}/oauthConfigs`), + oauthConfig: (baseUrl, featureId, oauthConfigId) => + createUrl(baseUrl, `features/${featureId}/oauthConfigs/${oauthConfigId}`), + regenerateOauthSecret: (baseUrl, featureId, oauthConfigId) => + createUrl(baseUrl, `features/${featureId}/oauthConfigs/${oauthConfigId}/regenerateSecret`), + disableOauthConfig: (baseUrl, featureId, oauthConfigId) => + createUrl(baseUrl, `features/${featureId}/oauthConfigs/${oauthConfigId}/disable`), + blockScopes: (baseUrl, featureId) => createUrl(baseUrl, `features/${featureId}/blockScopes`), + blockScope: (baseUrl, featureId, blockScopeId) => + createUrl(baseUrl, `features/${featureId}/blockScopes/${blockScopeId}`), };