diff --git a/src/Exceptionless.Core/Jobs/CleanupDataJob.cs b/src/Exceptionless.Core/Jobs/CleanupDataJob.cs index 3482dbaf9a..8282a7c6a1 100644 --- a/src/Exceptionless.Core/Jobs/CleanupDataJob.cs +++ b/src/Exceptionless.Core/Jobs/CleanupDataJob.cs @@ -23,6 +23,7 @@ namespace Exceptionless.Core.Jobs; public class CleanupDataJob : JobWithLockBase, IHealthCheck { private static readonly TimeSpan OAuthTokenCleanupSafetyWindow = TimeSpan.FromDays(1); + private static readonly TimeSpan OAuthApplicationCleanupSafetyWindow = TimeSpan.FromDays(1); private static readonly TimeSpan SyntheticOrganizationCleanupSafetyWindow = TimeSpan.FromDays(1); private static readonly TimeSpan SyntheticUserCleanupSafetyWindow = TimeSpan.FromDays(1); private const string SyntheticOrganizationNamePrefix = "E2E Playwright Org"; @@ -38,6 +39,7 @@ public class CleanupDataJob : JobWithLockBase, IHealthCheck private readonly IEventRepository _eventRepository; private readonly ITokenRepository _tokenRepository; private readonly IOAuthTokenRepository _oauthTokenRepository; + private readonly IOAuthApplicationRepository _oauthApplicationRepository; private readonly IWebHookRepository _webHookRepository; private readonly BillingManager _billingManager; private readonly UsageService _usageService; @@ -58,6 +60,7 @@ public CleanupDataJob( IEventRepository eventRepository, ITokenRepository tokenRepository, IOAuthTokenRepository oauthTokenRepository, + IOAuthApplicationRepository oauthApplicationRepository, IWebHookRepository webHookRepository, ILockProvider lockProvider, ICacheClient cacheClient, @@ -80,6 +83,7 @@ ILoggerFactory loggerFactory _eventRepository = eventRepository; _tokenRepository = tokenRepository; _oauthTokenRepository = oauthTokenRepository; + _oauthApplicationRepository = oauthApplicationRepository; _webHookRepository = webHookRepository; _billingManager = billingManager; _billingPlans = billingPlans; @@ -103,6 +107,7 @@ protected override async Task RunInternalAsync(JobContext context) bool canCleanupSourceMaps = await FlushSourceMapUsagesAsync(context.CancellationToken); await MarkTokensSuspended(context); + await CleanupOAuthApplicationsAsync(context); await CleanupOAuthTokensAsync(context); await CleanupSyntheticOrganizationsAsync(context); await CleanupSyntheticUsersAsync(context); @@ -140,6 +145,65 @@ private async Task CleanupOAuthTokensAsync(JobContext context) _logger.LogInformation("Removed {OAuthTokenCount} expired disabled OAuth token(s)", removed); } + private async Task CleanupOAuthApplicationsAsync(JobContext context) + { + var utcCutoff = _timeProvider.GetUtcNow().UtcDateTime.Subtract(OAuthApplicationCleanupSafetyWindow); + var applications = await _oauthApplicationRepository.FindAsync( + query => AbandonedApplications(query).SortAscending(application => application.Id), + options => options.SearchAfterPaging().PageLimit(500)); + + long removed = 0; + while (applications.Documents.Count > 0 && !context.CancellationToken.IsCancellationRequested) + { + // Legacy applications may have tokens before their organization associations are backfilled. + string[] clientIds = applications.Documents.Select(application => application.ClientId).Distinct(StringComparer.Ordinal).ToArray(); + var tokens = await _oauthTokenRepository.FindAsync( + query => query.FieldEquals(token => token.ClientId, clientIds).Include(token => token.ClientId, token => token.OrganizationIds).SortAscending(token => token.Id), + options => options.SearchAfterPaging().PageLimit(500)); + var authorizedClientIds = new HashSet(StringComparer.Ordinal); + do + { + context.CancellationToken.ThrowIfCancellationRequested(); + authorizedClientIds.UnionWith(tokens.Documents.Select(token => token.ClientId)); + foreach (var clientTokens in tokens.Documents.GroupBy(token => token.ClientId, StringComparer.Ordinal)) + { + // Retain authorization history even after the legacy tokens themselves are cleaned up. + string[] organizationIds = clientTokens.SelectMany(token => token.OrganizationIds).Distinct(StringComparer.Ordinal).ToArray(); + await _oauthApplicationRepository.AddOrganizationIdsAsync(clientTokens.Key, organizationIds, options => options.ImmediateConsistency().Notifications(false)); + } + } while (!context.CancellationToken.IsCancellationRequested && await tokens.NextPageAsync()); + + context.CancellationToken.ThrowIfCancellationRequested(); + + string[] abandonedIds = applications.Documents + .Where(application => !authorizedClientIds.Contains(application.ClientId)) + .Select(application => application.Id) + .ToArray(); + if (abandonedIds.Length > 0) + { + // Recheck eligibility when deleting. Delete-by-query skips concurrent consent or administrator updates. + removed += await _oauthApplicationRepository.RemoveAllAsync( + query => AbandonedApplications(query).Id(abandonedIds), + options => options.Cache(false).ImmediateConsistency()); + } + + await RenewLockAsync(context); + if (!await applications.NextPageAsync()) + break; + } + + _logger.LogInformation("Removed {OAuthApplicationCount} abandoned OAuth application(s)", removed); + + IRepositoryQuery AbandonedApplications(IRepositoryQuery query) => query + .FieldEquals(application => application.CreatedByUserId, OAuthApplication.SystemUserId) + .FieldOr(group => group + .FieldEquals(application => application.UpdatedByUserId, OAuthApplication.SystemUserId) + .FieldEmpty(application => application.UpdatedByUserId)) + .FieldEquals(application => application.IsDisabled, false) + .FieldEmpty(application => application.OrganizationIds) + .DateRange(null, utcCutoff, (OAuthApplication application) => application.UpdatedUtc); + } + private async Task CleanupSyntheticOrganizationsAsync(JobContext context) { var utcCutoff = _timeProvider.GetUtcNow().UtcDateTime.Subtract(SyntheticOrganizationCleanupSafetyWindow); diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IOAuthApplicationRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IOAuthApplicationRepository.cs index 76a12a34d0..62f4195dc0 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IOAuthApplicationRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IOAuthApplicationRepository.cs @@ -9,4 +9,5 @@ public interface IOAuthApplicationRepository : ISearchableRepository AddOrganizationIdsAsync(string clientId, IReadOnlyCollection organizationIds, CommandOptionsDescriptor? options = null); Task GetByClientIdAsync(string clientId, CommandOptionsDescriptor? options = null); Task> GetByCriteriaAsync(string? criteria, IReadOnlyCollection? organizationIds, CommandOptionsDescriptor? options = null); + Task> GetByCriteriaAsync(string? criteria, IReadOnlyCollection? organizationIds, bool? authorized, string? sort, CommandOptionsDescriptor? options = null); } diff --git a/src/Exceptionless.Core/Repositories/OAuthApplicationRepository.cs b/src/Exceptionless.Core/Repositories/OAuthApplicationRepository.cs index 9816dad13c..d604e6509d 100644 --- a/src/Exceptionless.Core/Repositories/OAuthApplicationRepository.cs +++ b/src/Exceptionless.Core/Repositories/OAuthApplicationRepository.cs @@ -52,6 +52,9 @@ public Task AddOrganizationIdsAsync(string clientId, IReadOnlyCollection> GetByCriteriaAsync(string? criteria, IReadOnlyCollection? organizationIds, CommandOptionsDescriptor? options = null) + => GetByCriteriaAsync(criteria, organizationIds, null, null, options); + + public Task> GetByCriteriaAsync(string? criteria, IReadOnlyCollection? organizationIds, bool? authorized, string? sort, CommandOptionsDescriptor? options = null) { var query = new RepositoryQuery(); @@ -66,7 +69,16 @@ public Task> GetByCriteriaAsync(string? criteria, if (organizationIds is { Count: > 0 }) query.FieldEquals(application => application.OrganizationIds, organizationIds); - query.SortAscending(application => application.Name); + if (authorized is true) + query.FieldHasValue(application => application.OrganizationIds); + else if (authorized is false) + query.FieldEmpty(application => application.OrganizationIds); + + if (!String.IsNullOrWhiteSpace(sort)) + query.SortExpression($"{sort} id"); + else + query.SortAscending(application => application.Name).SortAscending(application => application.Id); + return FindAsync(q => query, options); } } diff --git a/src/Exceptionless.Web/Api/Endpoints/OAuthApplicationEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/OAuthApplicationEndpoints.cs index 4cc12981b8..b63d3793c5 100644 --- a/src/Exceptionless.Web/Api/Endpoints/OAuthApplicationEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/OAuthApplicationEndpoints.cs @@ -18,8 +18,8 @@ public static IEndpointRouteBuilder MapOAuthApplicationEndpoints(this IEndpointR .AddEndpointFilter() .ExcludeFromDescription(); - endpoints.MapGet("api/v2/admin/oauth-applications", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? criteria = null, string? organization = null, int page = 1, int limit = 100) - => (await mediator.InvokeAsync>>(new GetOAuthApplications(criteria, organization, page, limit, httpContext))).ToHttpResult(resultMapper)) + endpoints.MapGet("api/v2/admin/oauth-applications", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? criteria = null, string? organization = null, int page = 1, int limit = 100, bool? authorized = null, string? sort = null) + => (await mediator.InvokeAsync>>(new GetOAuthApplications(criteria, organization, page, limit, httpContext, authorized, sort))).ToHttpResult(resultMapper)) .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) .AddEndpointFilter() .Produces>() diff --git a/src/Exceptionless.Web/Api/Handlers/OAuthApplicationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OAuthApplicationHandler.cs index fcf1207db2..5846cb3630 100644 --- a/src/Exceptionless.Web/Api/Handlers/OAuthApplicationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OAuthApplicationHandler.cs @@ -28,7 +28,7 @@ public async Task>> Handle(GetOAuthAppl if (!String.IsNullOrWhiteSpace(message.Organization) && organizationIds.Count == 0) return new PagedResult([], false, page, 0); - var results = await repository.GetByCriteriaAsync(message.Criteria, organizationIds, o => o.PageNumber(page).PageLimit(limit)); + var results = await repository.GetByCriteriaAsync(message.Criteria, organizationIds, message.Authorized, message.Sort, o => o.PageNumber(page).PageLimit(limit)); var applications = await MapApplicationsAsync(results.Documents); return new PagedResult(applications, results.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page, results.Total); } @@ -158,7 +158,9 @@ private async Task> MapApplicationsAsy var organizations = organizationIds.Length > 0 ? await organizationRepository.GetByIdsAsync(organizationIds, options => options.Cache()) : []; - var organizationNames = organizations.ToDictionary(organization => organization.Id, organization => organization.Name, StringComparer.Ordinal); + var organizationNames = organizations + .Where(organization => !organization.IsDeleted) + .ToDictionary(organization => organization.Id, organization => organization.Name, StringComparer.Ordinal); return applications.Select(application => ViewOAuthApplication.FromApplication(application, organizationNames)).ToArray(); } diff --git a/src/Exceptionless.Web/Api/Messages/OAuthApplicationMessages.cs b/src/Exceptionless.Web/Api/Messages/OAuthApplicationMessages.cs index 4d359e8994..da1f745a4c 100644 --- a/src/Exceptionless.Web/Api/Messages/OAuthApplicationMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/OAuthApplicationMessages.cs @@ -2,7 +2,7 @@ namespace Exceptionless.Web.Api.Messages; -public record GetOAuthApplications(string? Criteria, string? Organization, int Page, int Limit, HttpContext Context); +public record GetOAuthApplications(string? Criteria, string? Organization, int Page, int Limit, HttpContext Context, bool? Authorized = null, string? Sort = null); public record GetOAuthApplication(string Id); public record CreateOAuthApplicationMessage(NewOAuthApplication Model, HttpContext Context); public record UpdateOAuthApplicationMessage(string Id, UpdateOAuthApplication Model, HttpContext Context); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-applications.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-applications.e2e.ts new file mode 100644 index 0000000000..15255b9c09 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-applications.e2e.ts @@ -0,0 +1,92 @@ +import type { OAuthApplication } from '../../src/lib/features/admin/models'; + +import { expect, test } from '../fixtures/e2e-test'; + +test('OAuth applications default to authorized and expose configuration and organization links', async ({ e2eScenario, page }) => { + await page.setViewportSize({ height: 1100, width: 1440 }); + const authorizedApplication: OAuthApplication = { + client_id: 'https://client.example/oauth/client-metadata', + created_by_user_id: '000000000000000000000001', + created_utc: '2026-09-01T12:00:00Z', + id: '000000000000000000000101', + is_disabled: false, + name: 'Recently authorized application', + notes: 'OAuth configuration for browser verification.', + organizations: [ + { id: e2eScenario.organizationId, is_available: true, name: e2eScenario.organizationName }, + { id: '000000000000000000000103', is_available: true, name: 'Second authorized organization' }, + { id: '000000000000000000000104', is_available: false, name: '000000000000000000000104' } + ], + redirect_uris: ['https://client.example/oauth/callback', 'http://localhost:54321/callback'], + scopes: ['mcp:read', 'events:read'], + updated_utc: '2026-09-12T12:00:00Z' + }; + const unauthorizedApplication: OAuthApplication = { + ...authorizedApplication, + client_id: 'dcr_pending-application', + id: '000000000000000000000102', + name: 'Pending application', + organizations: [], + updated_utc: '2026-09-11T12:00:00Z' + }; + + await page.route('**/api/v2/admin/oauth-applications?*', async (route) => { + const params = new URL(route.request().url()).searchParams; + const applications = + params.get('authorized') === 'true' + ? [authorizedApplication] + : params.get('authorized') === 'false' + ? [unauthorizedApplication] + : [authorizedApplication, unauthorizedApplication]; + await route.fulfill({ json: applications }); + }); + + const initialRequest = page.waitForRequest((request) => request.url().includes('/api/v2/admin/oauth-applications?')); + await page.goto('/next/system/oauth-applications'); + const params = new URL((await initialRequest).url()).searchParams; + expect(params.get('authorized')).toBe('true'); + expect(params.get('sort')).toBe('-updated_utc'); + await expect(page.getByRole('button', { name: 'Filter by authorization' })).toHaveText('Authorized'); + await expect(page.getByRole('link', { exact: true, name: authorizedApplication.name })).toBeVisible(); + await expect(page.getByRole('link', { exact: true, name: unauthorizedApplication.name })).toHaveCount(0); + await expect(page.getByRole('columnheader', { name: 'Client ID' })).toHaveCount(0); + await expect(page.getByText(authorizedApplication.client_id, { exact: true })).toHaveCount(0); + await expect(page.getByText('000000000000000000000104', { exact: true })).toBeVisible(); + await expect(page.getByRole('link', { exact: true, name: '000000000000000000000104' })).toHaveCount(0); + + await page.getByRole('button', { name: `Show details for ${authorizedApplication.name}` }).click(); + await expect(page.getByText(authorizedApplication.client_id, { exact: true })).toBeVisible(); + for (const uri of authorizedApplication.redirect_uris) { + await expect(page.getByText(uri, { exact: true })).toBeVisible(); + } + await expect(page.getByRole('button', { name: /copy/i })).toHaveCount(0); + await expect(page.getByRole('link', { exact: true, name: 'Edit application' })).toHaveAttribute( + 'href', + `/next/system/oauth-applications/${authorizedApplication.id}` + ); + await expect(page.getByRole('link', { exact: true, name: 'Second authorized organization' })).toHaveAttribute( + 'href', + '/next/organization/000000000000000000000103/manage' + ); + + await page.screenshot({ fullPage: true, path: test.info().outputPath('oauth-application-details.png') }); + + await page.getByRole('button', { name: `Hide details for ${authorizedApplication.name}` }).click(); + await expect(page.getByText(authorizedApplication.client_id, { exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Filter by authorization' }).click(); + await page.getByRole('option', { exact: true, name: 'Not authorized' }).click(); + await expect(page.getByRole('link', { exact: true, name: unauthorizedApplication.name })).toBeVisible(); + await expect(page.getByRole('link', { exact: true, name: authorizedApplication.name })).toHaveCount(0); + await expect(page).toHaveURL(/authorization=unauthorized/); + + await page.getByRole('button', { name: 'Filter by authorization' }).click(); + await page.getByRole('option', { exact: true, name: 'All applications' }).click(); + await expect(page.getByRole('link', { exact: true, name: authorizedApplication.name })).toBeVisible(); + await expect(page.getByRole('link', { exact: true, name: unauthorizedApplication.name })).toBeVisible(); + await expect(page).toHaveURL(/authorization=all/); + await page.reload(); + await expect(page.getByRole('button', { name: 'Filter by authorization' })).toHaveText('All applications'); + + await page.getByRole('link', { exact: true, name: e2eScenario.organizationName }).click(); + await expect(page).toHaveURL(new RegExp(`/next/organization/${e2eScenario.organizationId}/manage`)); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index 5ef6d164e9..7937b61e56 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -22,10 +22,12 @@ import type { } from './models'; export type GetOAuthApplicationsParams = { + authorized?: boolean; criteria?: string; limit?: number; organization?: string; page?: number; + sort?: string; }; export type GetOAuthApplicationsRequest = { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-client-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-client-cell.svelte deleted file mode 100644 index db695d7fd3..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-client-cell.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - -
- {clientId} - -
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-details.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-details.svelte new file mode 100644 index 0000000000..3ba0ded74c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-details.svelte @@ -0,0 +1,64 @@ + + +
+
+
+
Client ID
+
{application.client_id}
+
+
+
Status
+
{application.is_disabled ? 'Disabled' : 'Enabled'}
+
+
+
Redirect URLs
+
+
    + {#each application.redirect_uris as uri (uri)} +
  • {uri}
  • + {/each} +
+
+
+
+
Allowed scopes
+
+ {#each application.scopes as scope (scope)} + {scope} + {/each} +
+
+
+
Created
+
+
+
+
Updated
+
+
+ {#if application.notes} +
+
Notes
+
{application.notes}
+
+ {/if} +
+
+ +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-expand-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-expand-cell.svelte new file mode 100644 index 0000000000..6bb6136d20 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-expand-cell.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-organizations-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-organizations-cell.svelte index fe5803f4d1..b93192b2f1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-organizations-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-organizations-cell.svelte @@ -1,40 +1,29 @@ {#if application.organizations.length > 0} - - - {#snippet child({ props })} - - {/snippet} - - -
- {#each application.organizations as organization (organization.id)} -
{organization.name}
- {/each} -
-
-
+
+ {#each application.organizations as organization (organization.id)} + + {organization.name} + + {/each} +
{:else} Not authorized {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-summary-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-summary-cell.svelte index cd13c468b0..e4b95b0a5d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-summary-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-summary-cell.svelte @@ -1,7 +1,9 @@
-
{application.name}
+ {application.name}
Updated
{#if application.is_disabled} Disabled diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-applications-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-applications-data-table.svelte index f2f2814e63..1a5e929aed 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-applications-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-applications-data-table.svelte @@ -6,6 +6,8 @@ import DelayedRender from '$comp/delayed-render.svelte'; import { type StockFeatures, type Table } from '@tanstack/svelte-table'; + import OAuthApplicationDetails from './oauth-application-details.svelte'; + interface Props { isLoading: boolean; limit: number; @@ -28,6 +30,9 @@ + {#snippet rowDetails(application: OAuthApplication)} + + {/snippet} {#if isLoading} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/options.svelte.ts index 10b77361f1..08bd63adad 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/options.svelte.ts @@ -7,7 +7,7 @@ import { getSharedTableOptions } from '$features/shared/table.svelte'; import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; import OAuthApplicationActionsCell from './oauth-application-actions-cell.svelte'; -import OAuthApplicationClientCell from './oauth-application-client-cell.svelte'; +import OAuthApplicationExpandCell from './oauth-application-expand-cell.svelte'; import OAuthApplicationOrganizationsCell from './oauth-application-organizations-cell.svelte'; import OAuthApplicationScopesCell from './oauth-application-scopes-cell.svelte'; import OAuthApplicationSummaryCell from './oauth-application-summary-cell.svelte'; @@ -15,28 +15,29 @@ import OAuthApplicationSummaryCell from './oauth-application-summary-cell.svelte export function getColumns(): ColumnDef[] { return [ { - accessorKey: 'name', cell: (info) => - renderComponent(OAuthApplicationSummaryCell, { - application: info.row.original + renderComponent(OAuthApplicationExpandCell, { + row: info.row }), enableHiding: false, enableSorting: false, - header: 'Application', + header: '', + id: 'expand', meta: { - class: 'w-[28%] max-w-none whitespace-normal' + class: 'w-12 min-w-12 max-w-12' } }, { - accessorKey: 'client_id', + accessorKey: 'name', cell: (info) => - renderComponent(OAuthApplicationClientCell, { - clientId: info.row.original.client_id + renderComponent(OAuthApplicationSummaryCell, { + application: info.row.original }), + enableHiding: false, enableSorting: false, - header: 'Client ID', + header: 'Application', meta: { - class: 'w-[30%] max-w-none' + class: 'w-[45%] max-w-none whitespace-normal' } }, { @@ -84,8 +85,12 @@ export function getTableOptions( queryResponse: CreateQueryResult, ProblemDetails> ) { return getSharedTableOptions({ - columnPersistenceKey: 'oauth-applications-compact', + columnPersistenceKey: 'oauth-applications-details', columns: getColumns(), + configureOptions: (options) => ({ + ...options, + getRowCanExpand: () => true + }), paginationStrategy: 'offset', get queryData() { return queryResponse.data?.data ?? []; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts index b362c30b17..0814459c25 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts @@ -170,6 +170,7 @@ export type OAuthApplication = { export type OAuthApplicationOrganization = { id: string; + is_available?: boolean; name: string; }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index 1f41c1d098..581a825399 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -19,12 +19,13 @@ children?: Snippet; onAutoFillColumnResized?: (columnId: string) => void; rowClick?: (row: TData, event?: MouseEvent) => void; + rowDetails?: Snippet<[TData]>; rowHref?: (row: TData) => string; table: SvelteTable; wrappedColumnIds?: readonly string[]; } - let { autoFillColumnId, children, onAutoFillColumnResized, rowClick, rowHref, table, wrappedColumnIds = [] }: Props = $props(); + let { autoFillColumnId, children, onAutoFillColumnResized, rowClick, rowDetails, rowHref, table, wrappedColumnIds = [] }: Props = $props(); const selectColumnClass = 'w-8 min-w-8 max-w-8'; const selectColumnWidth = 32; @@ -373,6 +374,13 @@ {/if} + {#if rowDetails && row.getIsExpanded()} + + + {@render rowDetails(row.original)} + + + {/if} {/each} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/exie/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/exie/+page.svelte index 861fdf04b5..7c91b77432 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/exie/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/exie/+page.svelte @@ -82,12 +82,9 @@
-
+
Monthly Exie usage, provider cost, and plan-limit health across all organizations - +
{#if usageQuery.isError} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/oauth-applications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/oauth-applications/+page.svelte index b1e0123952..ab17f8b6d6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/oauth-applications/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/oauth-applications/+page.svelte @@ -1,11 +1,9 @@