Skip to content

Commit a1d4a8e

Browse files
committed
Add API Key Management and Backend Enhancements
Introduced API key management for programmatic plugin uploads, including generation, validation, and revocation. Added `GenerateApiKeyHandler`, `GenerateApiKeyRequest`, `GenerateApiKeyResponse`, and `GenerateApiKeyValidator` for backend handling. Updated `MediatorExtensions` and `ApiKeys.cs` to support API key operations. Enhanced `IApiKeyService` and `ApiKeyService` with permissions and plugin restrictions. Updated `StatsApiService` to validate API keys during plugin uploads. Added `ICurrentUserService` for user authentication and role management. Improved the UI with a new "API Keys" page (`ApiKeys.razor`) and modals for creating and revoking keys. Updated `app.css` for better button styles and modal appearance. Refactored `ApiKeyService` to simplify API key creation logic. Fixed `ApiClient` to support cancellation tokens. Updated `ApplicationContext` to normalize `DateTime` values to UTC and improve exception handling. Registered new services (`IApiKeyClientService`, `ICurrentUserService`) in `ServiceCollectionExtensions` and `Program.cs`. Added `CreateApiKeyDialog.razor` for API key creation with expiration, permissions, and plugin restrictions. #45
1 parent 007c987 commit a1d4a8e

31 files changed

Lines changed: 1217 additions & 40 deletions

File tree

src/FlowSynx.PluginRegistry.Application/Extensions/MediatorExtensions.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using FlowSynx.PluginRegistry.Application.Features.Plugins.Command.SetPluginVersionActiveStatus;
1+
using FlowSynx.PluginRegistry.Application.Features.ApiKeys.Command.GenerateKey;
2+
using FlowSynx.PluginRegistry.Application.Features.Plugins.Command.SetPluginVersionActiveStatus;
23
using FlowSynx.PluginRegistry.Application.Features.Plugins.Query.PluginDetails;
34
using FlowSynx.PluginRegistry.Application.Features.Plugins.Query.PluginIcon;
45
using FlowSynx.PluginRegistry.Application.Features.Plugins.Query.PluginLocation;
@@ -134,4 +135,12 @@ public static Task<Result<Unit>> IncreaseDownloadCountAsync(
134135
return mediator.Send(request, cancellationToken);
135136
}
136137
#endregion
138+
139+
#region ApiKeys
140+
public static Task<Result<GenerateApiKeyResponse>> GenerateApiKey(
141+
this IMediator mediator, GenerateApiKeyRequest request, CancellationToken cancellationToken)
142+
{
143+
return mediator.Send(request, cancellationToken);
144+
}
145+
#endregion
137146
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using FlowSynx.PluginRegistry.Application.Services;
2+
using FlowSynx.PluginRegistry.Application.Wrapper;
3+
using FlowSynx.PluginRegistry.Domain.ApiKey;
4+
using FlowSynx.PluginRegistry.Domain.Plugin;
5+
using FlowSynx.PluginRegistry.Domain.Profile;
6+
using MediatR;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace FlowSynx.PluginRegistry.Application.Features.ApiKeys.Command.GenerateKey;
10+
11+
internal class GenerateApiKeyHandler : IRequestHandler<GenerateApiKeyRequest, Result<GenerateApiKeyResponse>>
12+
{
13+
private readonly ILogger<GenerateApiKeyHandler> _logger;
14+
private readonly IPluginVersionService _pluginVersionService;
15+
private readonly IProfileService _profileService;
16+
private readonly ICurrentUserService _currentUserService;
17+
private readonly IApiKeyService _apiKeyService;
18+
19+
public GenerateApiKeyHandler(
20+
ILogger<GenerateApiKeyHandler> logger,
21+
IPluginVersionService pluginVersionService,
22+
IProfileService profileService,
23+
ICurrentUserService currentUserService,
24+
IApiKeyService apiKeyService)
25+
{
26+
ArgumentNullException.ThrowIfNull(logger);
27+
ArgumentNullException.ThrowIfNull(pluginVersionService);
28+
ArgumentNullException.ThrowIfNull(profileService);
29+
ArgumentNullException.ThrowIfNull(currentUserService);
30+
ArgumentNullException.ThrowIfNull(apiKeyService);
31+
_logger = logger;
32+
_pluginVersionService = pluginVersionService;
33+
_profileService = profileService;
34+
_currentUserService = currentUserService;
35+
_apiKeyService = apiKeyService;
36+
}
37+
38+
public async Task<Result<GenerateApiKeyResponse>> Handle(GenerateApiKeyRequest request, CancellationToken cancellationToken)
39+
{
40+
try
41+
{
42+
_currentUserService.ValidateAuthentication();
43+
44+
var profileId = await GetCurrentUserProfileId(cancellationToken);
45+
if (profileId == Guid.Empty)
46+
return await Result<GenerateApiKeyResponse>.FailAsync("User profile not found");
47+
48+
var result = await _apiKeyService.GenerateKey(
49+
request.Name,
50+
profileId,
51+
request.CanPushNewPlugins,
52+
request.CanPushPluginVersions,
53+
request.ExpiresAt,
54+
request.PluginIds,
55+
cancellationToken);
56+
57+
var response = new GenerateApiKeyResponse
58+
{
59+
Id = result.Id,
60+
Name = result.Name,
61+
RawKey = result.RawKey,
62+
Key = result.Key,
63+
CreatedAt = result.CreatedOn,
64+
ExpiresAt = result.ExpiresAt,
65+
IsRevoked = result.IsRevoked,
66+
CanPushNewPlugins = result.CanPushNewPlugins,
67+
CanPushPluginVersions = result.CanPushPluginVersions,
68+
AssignedPlugins = result.PluginAssignments?.Select(p => p.PluginId.ToString()).ToList() ?? new()
69+
};
70+
71+
return await Result<GenerateApiKeyResponse>.SuccessAsync(response, "API key generating successfully");
72+
}
73+
catch (Exception ex)
74+
{
75+
_logger.LogError(ex, "Error generating API key");
76+
return await Result<GenerateApiKeyResponse>.FailAsync($"Error generating API key: {ex.Message}");
77+
}
78+
}
79+
80+
private async Task<Guid> GetCurrentUserProfileId(CancellationToken cancellationToken)
81+
{
82+
var profile = await _profileService.GetByUserId(_currentUserService.UserId(), cancellationToken);
83+
return profile?.Id ?? Guid.Empty;
84+
}
85+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using FlowSynx.PluginRegistry.Application.Wrapper;
2+
using MediatR;
3+
4+
namespace FlowSynx.PluginRegistry.Application.Features.ApiKeys.Command.GenerateKey;
5+
6+
public class GenerateApiKeyRequest : IRequest<Result<GenerateApiKeyResponse>>
7+
{
8+
public string Name { get; set; } = string.Empty;
9+
public DateTime? ExpiresAt { get; set; }
10+
public bool CanPushNewPlugins { get; set; } = false;
11+
public bool CanPushPluginVersions { get; set; } = true;
12+
public List<Guid> PluginIds { get; set; } = new();
13+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace FlowSynx.PluginRegistry.Application.Features.ApiKeys.Command.GenerateKey;
2+
3+
public class GenerateApiKeyResponse
4+
{
5+
public Guid Id { get; set; }
6+
public string RawKey { get; set; } = string.Empty;
7+
public string Name { get; set; } = string.Empty;
8+
public string Key { get; set; } = string.Empty;
9+
public DateTime CreatedAt { get; set; }
10+
public DateTime? ExpiresAt { get; set; }
11+
public bool IsRevoked { get; set; }
12+
public bool CanPushNewPlugins { get; set; }
13+
public bool CanPushPluginVersions { get; set; }
14+
public bool IsExpired => ExpiresAt.HasValue && ExpiresAt.Value <= DateTime.UtcNow;
15+
public bool IsActive => !IsRevoked && !IsExpired;
16+
public List<string> AssignedPlugins { get; set; } = new();
17+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using FluentValidation;
2+
3+
namespace FlowSynx.PluginRegistry.Application.Features.ApiKeys.Command.GenerateKey;
4+
5+
public class GenerateApiKeyValidator : AbstractValidator<GenerateApiKeyRequest>
6+
{
7+
public GenerateApiKeyValidator()
8+
{
9+
RuleFor(x => x.Name)
10+
.NotNull()
11+
.NotEmpty()
12+
.WithMessage("API key name is required");
13+
}
14+
}

src/FlowSynx.PluginRegistry.Application/Features/Plugins/Query/PluginsListByProfile/PluginsListByProfileHandler.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public async Task<PaginatedResult<PluginsListByProfileResponse>> Handle(PluginsL
3333

3434
var response = plugins.Data.Select(p => new PluginsListByProfileResponse
3535
{
36+
Id = p.Id,
3637
Type = p.Type,
3738
Version = p.LatestVersion!.Version,
3839
Owners = p.Owners.Select(x => x.Profile!.UserName),

src/FlowSynx.PluginRegistry.Application/Features/Plugins/Query/PluginsListByProfile/PluginsListByProfileResponse.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
public class PluginsListByProfileResponse
44
{
5+
public required Guid Id { get; set; }
56
public required string Type { get; set; }
67
public required string Version { get; set; }
78
public string? Description { get; set; }
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace FlowSynx.PluginRegistry.Application.Services;
2+
3+
public interface ICurrentUserService
4+
{
5+
string UserId();
6+
string UserName();
7+
bool IsAuthenticated();
8+
List<string> Roles();
9+
void ValidateAuthentication();
10+
}

src/FlowSynx.PluginRegistry.Domain/ApiKey/IApiKeyService.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ public interface IApiKeyService
66

77
Task Add(ApiKeyEntity apiKeyEntity, CancellationToken cancellationToken);
88

9-
Task<(string rawKey, ApiKeyEntity savedKey)> GenerateKey(string name, Guid profileId, DateTime? expiresAt,
10-
List<Guid>? pluginIds, CancellationToken cancellationToken);
9+
Task<ApiKeyEntity> GenerateKey(
10+
string name,
11+
Guid profileId,
12+
bool? canPushNewPlugins,
13+
bool? canPushPluginVersions,
14+
DateTime? expiresAt,
15+
List<Guid>? pluginIds,
16+
CancellationToken cancellationToken);
1117

1218
Task<bool> ValidateApiKeyAsync(string rawKey, Guid pluginId, CancellationToken cancellationToken);
1319

src/FlowSynx.PluginRegistry.Infrastructure/Contexts/ApplicationContext.cs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public ApplicationContext(DbContextOptions<ApplicationContext> contextOptions,
4646
{
4747
HandleSoftDelete();
4848
ApplyAuditing();
49+
NormalizeDateTimes();
4950

5051
var userId = GetUserId();
5152
if (string.IsNullOrEmpty(userId))
@@ -55,7 +56,7 @@ public ApplicationContext(DbContextOptions<ApplicationContext> contextOptions,
5556
}
5657
catch (Exception ex)
5758
{
58-
throw new Exception(ex.Message);
59+
throw new Exception(ex.Message, ex);
5960
}
6061
}
6162

@@ -72,7 +73,7 @@ private void HandleSoftDelete()
7273
}
7374
catch (Exception ex)
7475
{
75-
throw new Exception(ex.Message);
76+
throw new Exception(ex.Message, ex);
7677
}
7778
}
7879

@@ -104,7 +105,35 @@ private void ApplyAuditing()
104105
}
105106
catch (Exception ex)
106107
{
107-
throw new Exception(ex.Message);
108+
throw new Exception(ex.Message, ex);
109+
}
110+
}
111+
112+
private void NormalizeDateTimes()
113+
{
114+
try
115+
{
116+
var entries = ChangeTracker.Entries()
117+
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
118+
119+
foreach (var entry in entries)
120+
{
121+
foreach (var property in entry.Properties)
122+
{
123+
if (property.CurrentValue is DateTime dateTime && dateTime.Kind == DateTimeKind.Unspecified)
124+
{
125+
var utcDateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
126+
// For nullable DateTime properties, explicitly cast to object to ensure proper boxing
127+
property.CurrentValue = property.Metadata.ClrType == typeof(DateTime?)
128+
? (DateTime?)utcDateTime
129+
: utcDateTime;
130+
}
131+
}
132+
}
133+
}
134+
catch (Exception ex)
135+
{
136+
throw new Exception(ex.Message, ex);
108137
}
109138
}
110139

@@ -120,7 +149,7 @@ protected override void OnModelCreating(ModelBuilder builder)
120149
}
121150
catch (Exception ex)
122151
{
123-
throw new Exception(ex.Message);
152+
throw new Exception(ex.Message, ex);
124153
}
125154
}
126155
}

0 commit comments

Comments
 (0)