Add OpenID Connect + API Key authentication
Backend: - Cookie + OIDC + API Key authentication schemes - ApiKeyAuthenticationHandler with SHA-256 validation and 24h cache - AuthController with login/logout/profile endpoints - API Key domain model (EventFlow aggregate, events, commands) - ApiKeyReadModel and repository for key validation - Database migration 002_ApiKeys.sql - CORS configuration for frontend Frontend: - authService.ts for login/logout/profile API calls - authStore.ts (Zustand) for user context state - ProtectedRoute component for route guards - Header updated with user display and logout - GraphQL client with credentials: include 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c4a27f0bac
commit
926085eeab
28 changed files with 849 additions and 24 deletions
114
backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs
Normal file
114
backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Books.Api.Authentication;
|
||||
|
||||
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
public string HeaderName { get; set; } = ApiKeyDefaults.HeaderName;
|
||||
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(24);
|
||||
}
|
||||
|
||||
public class ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IMemoryCache cache,
|
||||
IServiceProvider serviceProvider)
|
||||
: AuthenticationHandler<ApiKeyAuthenticationOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(Options.HeaderName, out var apiKeyHeader))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
var apiKeyValue = apiKeyHeader.ToString();
|
||||
if (string.IsNullOrWhiteSpace(apiKeyValue))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
// Expected format: {keyId}.{secret}
|
||||
var parts = apiKeyValue.Split('.', 2);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid API key format. Expected: {keyId}.{secret}");
|
||||
}
|
||||
|
||||
var keyId = parts[0];
|
||||
var secret = parts[1];
|
||||
|
||||
// Check cache first
|
||||
var cacheKey = $"apikey:{keyId}";
|
||||
if (cache.TryGetValue(cacheKey, out CachedApiKeyInfo? cachedInfo) && cachedInfo != null)
|
||||
{
|
||||
var providedHash = HashSecret(secret);
|
||||
if (cachedInfo.KeyHash == providedHash)
|
||||
{
|
||||
return AuthenticateResult.Success(
|
||||
new AuthenticationTicket(cachedInfo.Principal, Scheme.Name));
|
||||
}
|
||||
|
||||
return AuthenticateResult.Fail("Invalid API key");
|
||||
}
|
||||
|
||||
// Cache miss - lookup in database
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var repository = scope.ServiceProvider.GetService<IApiKeyRepository>();
|
||||
|
||||
if (repository == null)
|
||||
{
|
||||
Logger.LogWarning("IApiKeyRepository not registered - API key authentication unavailable");
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
var apiKey = await repository.GetByIdForValidationAsync(keyId);
|
||||
if (apiKey == null)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid API key");
|
||||
}
|
||||
|
||||
// Verify hash
|
||||
var secretHash = HashSecret(secret);
|
||||
if (apiKey.KeyHash != secretHash)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid API key");
|
||||
}
|
||||
|
||||
// Build claims
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, apiKey.ApiKeyId),
|
||||
new(ClaimTypes.Name, apiKey.Name),
|
||||
new("company_id", apiKey.CompanyId),
|
||||
new("preferred_username", $"apikey:{apiKey.Name}"),
|
||||
new(ClaimTypes.Role, "api_client"),
|
||||
new(ClaimTypes.AuthenticationMethod, "api_key")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
// Cache the result
|
||||
var cacheInfo = new CachedApiKeyInfo(secretHash, principal);
|
||||
cache.Set(cacheKey, cacheInfo, Options.CacheDuration);
|
||||
|
||||
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
|
||||
}
|
||||
|
||||
private static string HashSecret(string secret)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(secret));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record CachedApiKeyInfo(string KeyHash, ClaimsPrincipal Principal);
|
||||
}
|
||||
7
backend/Books.Api/Authentication/ApiKeyDefaults.cs
Normal file
7
backend/Books.Api/Authentication/ApiKeyDefaults.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace Books.Api.Authentication;
|
||||
|
||||
public static class ApiKeyDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "ApiKey";
|
||||
public const string HeaderName = "x-api-key";
|
||||
}
|
||||
34
backend/Books.Api/Authentication/UserExtensions.cs
Normal file
34
backend/Books.Api/Authentication/UserExtensions.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System.Security.Claims;
|
||||
|
||||
namespace Books.Api.Authentication;
|
||||
|
||||
public static class UserExtensions
|
||||
{
|
||||
public static UserContext? GetUserContext(this ClaimsPrincipal? principal)
|
||||
{
|
||||
if (principal?.Identity?.IsAuthenticated != true)
|
||||
return null;
|
||||
|
||||
return new UserContext(
|
||||
Id: principal.FindFirst(ClaimTypes.NameIdentifier)?.Value,
|
||||
Email: principal.FindFirst(ClaimTypes.Email)?.Value
|
||||
?? principal.FindFirst("preferred_username")?.Value,
|
||||
Name: principal.FindFirst(ClaimTypes.GivenName)?.Value
|
||||
?? principal.FindFirst(ClaimTypes.Name)?.Value,
|
||||
CompanyId: principal.FindFirst("company_id")?.Value,
|
||||
IsApiKey: principal.FindFirst(ClaimTypes.AuthenticationMethod)?.Value == "api_key"
|
||||
);
|
||||
}
|
||||
|
||||
public static string? GetClaimValue(this ClaimsPrincipal principal, string claimType)
|
||||
{
|
||||
return principal.FindFirst(claimType)?.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public record UserContext(
|
||||
string? Id,
|
||||
string? Email,
|
||||
string? Name,
|
||||
string? CompanyId,
|
||||
bool IsApiKey);
|
||||
|
|
@ -37,6 +37,9 @@
|
|||
|
||||
<!-- DI Decoration -->
|
||||
<PackageReference Include="Scrutor" Version="5.0.2" />
|
||||
|
||||
<!-- Authentication -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
30
backend/Books.Api/Commands/ApiKeys/ApiKeyCommandHandlers.cs
Normal file
30
backend/Books.Api/Commands/ApiKeys/ApiKeyCommandHandlers.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
using Books.Api.Domain.ApiKeys;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.ApiKeys;
|
||||
|
||||
public class CreateApiKeyCommandHandler
|
||||
: CommandHandler<ApiKeyAggregate, ApiKeyId, CreateApiKeyCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
ApiKeyAggregate aggregate,
|
||||
CreateApiKeyCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
aggregate.Create(command.Name, command.KeyHash, command.CompanyId, command.CreatedBy);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public class RevokeApiKeyCommandHandler
|
||||
: CommandHandler<ApiKeyAggregate, ApiKeyId, RevokeApiKeyCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
ApiKeyAggregate aggregate,
|
||||
RevokeApiKeyCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
aggregate.Revoke(command.RevokedBy);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
24
backend/Books.Api/Commands/ApiKeys/ApiKeyCommands.cs
Normal file
24
backend/Books.Api/Commands/ApiKeys/ApiKeyCommands.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using Books.Api.Domain.ApiKeys;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.ApiKeys;
|
||||
|
||||
public class CreateApiKeyCommand(
|
||||
ApiKeyId aggregateId,
|
||||
string name,
|
||||
string keyHash,
|
||||
string companyId,
|
||||
string createdBy) : Command<ApiKeyAggregate, ApiKeyId>(aggregateId)
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public string KeyHash { get; } = keyHash;
|
||||
public string CompanyId { get; } = companyId;
|
||||
public string CreatedBy { get; } = createdBy;
|
||||
}
|
||||
|
||||
public class RevokeApiKeyCommand(
|
||||
ApiKeyId aggregateId,
|
||||
string revokedBy) : Command<ApiKeyAggregate, ApiKeyId>(aggregateId)
|
||||
{
|
||||
public string RevokedBy { get; } = revokedBy;
|
||||
}
|
||||
41
backend/Books.Api/Controllers/AuthController.cs
Normal file
41
backend/Books.Api/Controllers/AuthController.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using Books.Api.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Books.Api.Controllers;
|
||||
|
||||
[Route("api")]
|
||||
[ApiController]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
[HttpGet("login")]
|
||||
[Authorize]
|
||||
public IActionResult Login([FromQuery] string? returnUrl)
|
||||
{
|
||||
// The [Authorize] attribute triggers the OIDC challenge if not authenticated.
|
||||
// If we reach here, the user is authenticated - redirect back to the app.
|
||||
return Redirect(returnUrl ?? "/");
|
||||
}
|
||||
|
||||
[HttpGet("logout")]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return Ok(new { message = "Logged out successfully" });
|
||||
}
|
||||
|
||||
[HttpGet("profile")]
|
||||
[Authorize]
|
||||
public IActionResult Profile()
|
||||
{
|
||||
var userContext = User.GetUserContext();
|
||||
if (userContext == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(userContext);
|
||||
}
|
||||
}
|
||||
21
backend/Books.Api/Database/Migrations/002_ApiKeys.sql
Normal file
21
backend/Books.Api/Database/Migrations/002_ApiKeys.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- API Keys table for programmatic access authentication
|
||||
CREATE TABLE IF NOT EXISTS apikey_read_models (
|
||||
aggregate_id VARCHAR(255) PRIMARY KEY,
|
||||
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
|
||||
|
||||
name VARCHAR(255) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL,
|
||||
company_id VARCHAR(255) NOT NULL,
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
revoked_time TIMESTAMPTZ,
|
||||
revoked_by VARCHAR(255)
|
||||
);
|
||||
|
||||
-- Index for looking up API keys by company
|
||||
CREATE INDEX IF NOT EXISTS idx_apikey_company ON apikey_read_models(company_id);
|
||||
|
||||
-- Partial index for efficient lookups of active API keys
|
||||
CREATE INDEX IF NOT EXISTS idx_apikey_active ON apikey_read_models(aggregate_id) WHERE is_active = true;
|
||||
49
backend/Books.Api/Domain/ApiKeys/ApiKeyAggregate.cs
Normal file
49
backend/Books.Api/Domain/ApiKeys/ApiKeyAggregate.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using Books.Api.Domain.ApiKeys.Events;
|
||||
using EventFlow.Aggregates;
|
||||
|
||||
namespace Books.Api.Domain.ApiKeys;
|
||||
|
||||
public class ApiKeyAggregate : AggregateRoot<ApiKeyAggregate, ApiKeyId>,
|
||||
IEmit<ApiKeyCreatedEvent>,
|
||||
IEmit<ApiKeyRevokedEvent>
|
||||
{
|
||||
public new string Name { get; private set; } = string.Empty;
|
||||
public string KeyHash { get; private set; } = string.Empty;
|
||||
public string CompanyId { get; private set; } = string.Empty;
|
||||
public string CreatedBy { get; private set; } = string.Empty;
|
||||
public bool IsActive { get; private set; } = true;
|
||||
public string? RevokedBy { get; private set; }
|
||||
|
||||
public ApiKeyAggregate(ApiKeyId id) : base(id) { }
|
||||
|
||||
public void Create(string name, string keyHash, string companyId, string createdBy)
|
||||
{
|
||||
if (!IsNew)
|
||||
throw new DomainException("APIKEY_EXISTS", "API key already exists", "API nøgle eksisterer allerede");
|
||||
|
||||
Emit(new ApiKeyCreatedEvent(name, keyHash, companyId, createdBy));
|
||||
}
|
||||
|
||||
public void Revoke(string revokedBy)
|
||||
{
|
||||
if (!IsActive)
|
||||
throw new DomainException("APIKEY_REVOKED", "API key is already revoked", "API nøgle er allerede tilbagekaldt");
|
||||
|
||||
Emit(new ApiKeyRevokedEvent(revokedBy));
|
||||
}
|
||||
|
||||
public void Apply(ApiKeyCreatedEvent e)
|
||||
{
|
||||
Name = e.Name;
|
||||
KeyHash = e.KeyHash;
|
||||
CompanyId = e.CompanyId;
|
||||
CreatedBy = e.CreatedBy;
|
||||
IsActive = true;
|
||||
}
|
||||
|
||||
public void Apply(ApiKeyRevokedEvent e)
|
||||
{
|
||||
IsActive = false;
|
||||
RevokedBy = e.RevokedBy;
|
||||
}
|
||||
}
|
||||
8
backend/Books.Api/Domain/ApiKeys/ApiKeyId.cs
Normal file
8
backend/Books.Api/Domain/ApiKeys/ApiKeyId.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
using EventFlow.Core;
|
||||
|
||||
namespace Books.Api.Domain.ApiKeys;
|
||||
|
||||
public class ApiKeyId : Identity<ApiKeyId>
|
||||
{
|
||||
public ApiKeyId(string value) : base(value) { }
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
using EventFlow.Aggregates;
|
||||
|
||||
namespace Books.Api.Domain.ApiKeys.Events;
|
||||
|
||||
public class ApiKeyCreatedEvent(
|
||||
string name,
|
||||
string keyHash,
|
||||
string companyId,
|
||||
string createdBy) : AggregateEvent<ApiKeyAggregate, ApiKeyId>
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public string KeyHash { get; } = keyHash;
|
||||
public string CompanyId { get; } = companyId;
|
||||
public string CreatedBy { get; } = createdBy;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
using EventFlow.Aggregates;
|
||||
|
||||
namespace Books.Api.Domain.ApiKeys.Events;
|
||||
|
||||
public class ApiKeyRevokedEvent(string revokedBy) : AggregateEvent<ApiKeyAggregate, ApiKeyId>
|
||||
{
|
||||
public string RevokedBy { get; } = revokedBy;
|
||||
}
|
||||
|
|
@ -14,16 +14,19 @@ public static class ReadModelRegistrationExtensions
|
|||
{
|
||||
return options
|
||||
.UsePostgreSqlReadModel<CompanyReadModel, CompanyReadModelLocator>()
|
||||
.RegisterServices( sr => sr.AddSingleton<IReadModelSqlGenerator>(new ReadModelSqlGenerator()));
|
||||
.UsePostgreSqlReadModel<ApiKeyReadModel, ApiKeyReadModelLocator>()
|
||||
.RegisterServices(sr => sr.AddSingleton<IReadModelSqlGenerator>(new ReadModelSqlGenerator()));
|
||||
}
|
||||
|
||||
public static IServiceCollection AddRepositories(this IServiceCollection services)
|
||||
{
|
||||
// Register locators
|
||||
services.AddTransient<CompanyReadModelLocator>();
|
||||
services.AddTransient<ApiKeyReadModelLocator>();
|
||||
|
||||
// Register repositories
|
||||
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
||||
services.AddScoped<IApiKeyRepository, ApiKeyRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
|
|
|||
74
backend/Books.Api/EventFlow/ReadModels/ApiKeyReadModel.cs
Normal file
74
backend/Books.Api/EventFlow/ReadModels/ApiKeyReadModel.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Books.Api.Domain.ApiKeys;
|
||||
using Books.Api.Domain.ApiKeys.Events;
|
||||
using EventFlow.Aggregates;
|
||||
using EventFlow.PostgreSql.ReadStores.Attributes;
|
||||
using EventFlow.ReadStores;
|
||||
|
||||
namespace Books.Api.EventFlow.ReadModels;
|
||||
|
||||
[Table("apikey_read_models")]
|
||||
public class ApiKeyReadModel : IReadModel,
|
||||
IAmReadModelFor<ApiKeyAggregate, ApiKeyId, ApiKeyCreatedEvent>,
|
||||
IAmReadModelFor<ApiKeyAggregate, ApiKeyId, ApiKeyRevokedEvent>
|
||||
{
|
||||
[PostgreSqlReadModelIdentityColumn]
|
||||
public string AggregateId { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreateTime { get; set; }
|
||||
public DateTimeOffset UpdatedTime { get; set; }
|
||||
|
||||
[PostgreSqlReadModelVersionColumn]
|
||||
public int LastAggregateSequenceNumber { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string KeyHash { get; set; } = string.Empty;
|
||||
public string CompanyId { get; set; } = string.Empty;
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTimeOffset? RevokedTime { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
|
||||
public Task ApplyAsync(
|
||||
IReadModelContext context,
|
||||
IDomainEvent<ApiKeyAggregate, ApiKeyId, ApiKeyCreatedEvent> domainEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var e = domainEvent.AggregateEvent;
|
||||
AggregateId = domainEvent.AggregateIdentity.Value;
|
||||
CreateTime = domainEvent.Timestamp;
|
||||
UpdatedTime = domainEvent.Timestamp;
|
||||
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
|
||||
Name = e.Name;
|
||||
KeyHash = e.KeyHash;
|
||||
CompanyId = e.CompanyId;
|
||||
CreatedBy = e.CreatedBy;
|
||||
IsActive = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ApplyAsync(
|
||||
IReadModelContext context,
|
||||
IDomainEvent<ApiKeyAggregate, ApiKeyId, ApiKeyRevokedEvent> domainEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var e = domainEvent.AggregateEvent;
|
||||
UpdatedTime = domainEvent.Timestamp;
|
||||
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
|
||||
IsActive = false;
|
||||
RevokedTime = domainEvent.Timestamp;
|
||||
RevokedBy = e.RevokedBy;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public class ApiKeyReadModelLocator : IReadModelLocator
|
||||
{
|
||||
public IEnumerable<string> GetReadModelIds(IDomainEvent domainEvent)
|
||||
{
|
||||
if (domainEvent.GetAggregateEvent() is IAggregateEvent<ApiKeyAggregate, ApiKeyId>)
|
||||
{
|
||||
yield return domainEvent.GetIdentity().Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
55
backend/Books.Api/EventFlow/Repositories/ApiKeyRepository.cs
Normal file
55
backend/Books.Api/EventFlow/Repositories/ApiKeyRepository.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Books.Api.EventFlow.Repositories;
|
||||
|
||||
public class ApiKeyRepository(NpgsqlDataSource dataSource) : IApiKeyRepository
|
||||
{
|
||||
public async Task<ApiKeyValidationDto?> GetByIdForValidationAsync(
|
||||
string apiKeyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
aggregate_id AS ApiKeyId,
|
||||
name AS Name,
|
||||
key_hash AS KeyHash,
|
||||
company_id AS CompanyId,
|
||||
is_active AS IsActive
|
||||
FROM apikey_read_models
|
||||
WHERE aggregate_id = @ApiKeyId
|
||||
AND is_active = true
|
||||
""";
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<ApiKeyValidationDto>(
|
||||
sql,
|
||||
new { ApiKeyId = apiKeyId });
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApiKeyDto>> GetByCompanyIdAsync(
|
||||
string companyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
aggregate_id AS Id,
|
||||
name AS Name,
|
||||
company_id AS CompanyId,
|
||||
created_by AS CreatedBy,
|
||||
create_time AS CreatedAt,
|
||||
is_active AS IsActive,
|
||||
revoked_time AS RevokedAt,
|
||||
revoked_by AS RevokedBy
|
||||
FROM apikey_read_models
|
||||
WHERE company_id = @CompanyId
|
||||
ORDER BY create_time DESC
|
||||
""";
|
||||
|
||||
var result = await connection.QueryAsync<ApiKeyDto>(sql, new { CompanyId = companyId });
|
||||
return result.ToList();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
namespace Books.Api.EventFlow.Repositories;
|
||||
|
||||
public interface IApiKeyRepository
|
||||
{
|
||||
Task<ApiKeyValidationDto?> GetByIdForValidationAsync(string apiKeyId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ApiKeyDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public record ApiKeyValidationDto(
|
||||
string ApiKeyId,
|
||||
string Name,
|
||||
string KeyHash,
|
||||
string CompanyId,
|
||||
bool IsActive);
|
||||
|
||||
public record ApiKeyDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string CompanyId,
|
||||
string CreatedBy,
|
||||
DateTime CreatedAt,
|
||||
bool IsActive,
|
||||
DateTime? RevokedAt,
|
||||
string? RevokedBy);
|
||||
|
|
@ -23,6 +23,16 @@ if (app.Environment.IsDevelopment())
|
|||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// CORS must come before auth
|
||||
app.UseCors();
|
||||
|
||||
// Authentication & Authorization
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Map controllers (for AuthController)
|
||||
app.MapControllers();
|
||||
|
||||
// GraphQL endpoint
|
||||
app.UseGraphQL<BooksSchema>("/graphql");
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7141;http://localhost:5142",
|
||||
"applicationUrl": "https://localhost:5001;http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Books.Api.Authentication;
|
||||
using Books.Api.EventFlow.Extensions;
|
||||
using Books.Api.EventFlow.Infrastructure;
|
||||
using Books.Api.GraphQL;
|
||||
|
|
@ -14,6 +15,9 @@ using EventFlow.Subscribers;
|
|||
using GraphQL;
|
||||
using Hangfire;
|
||||
using Hangfire.PostgreSql;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Npgsql;
|
||||
|
||||
namespace Books.Api;
|
||||
|
|
@ -73,5 +77,96 @@ public static class Startup
|
|||
.AddDataLoader()
|
||||
.AddGraphTypes(typeof(BooksSchema).Assembly)
|
||||
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
|
||||
|
||||
// Memory cache for API key caching
|
||||
services.AddMemoryCache();
|
||||
|
||||
// Controllers (for AuthController)
|
||||
services.AddControllers();
|
||||
|
||||
// CORS
|
||||
SetupCors(services, config);
|
||||
|
||||
// Authentication & Authorization
|
||||
SetupAuthentication(services, config);
|
||||
}
|
||||
|
||||
private static void SetupCors(IServiceCollection services, IConfiguration config)
|
||||
{
|
||||
var allowedOrigins = config.GetSection("Cors:AllowedOrigins").Get<string[]>()
|
||||
?? ["http://localhost:3000"];
|
||||
|
||||
services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(builder =>
|
||||
{
|
||||
builder.WithOrigins(allowedOrigins)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void SetupAuthentication(IServiceCollection services, IConfiguration config)
|
||||
{
|
||||
services.AddAuthorization();
|
||||
|
||||
var keycloakClientSecret = config["Keycloak:ClientSecret"];
|
||||
|
||||
// If Keycloak not configured, use cookie-only auth (for development)
|
||||
if (string.IsNullOrEmpty(keycloakClientSecret))
|
||||
{
|
||||
Console.WriteLine("[WARNING] Keycloak:ClientSecret not configured - OIDC authentication disabled");
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.Cookie.Name = ".Books.Auth";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.SlidingExpiration = true;
|
||||
})
|
||||
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
|
||||
ApiKeyDefaults.AuthenticationScheme, _ => { });
|
||||
return;
|
||||
}
|
||||
|
||||
// Full authentication with OIDC
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.Cookie.Name = ".Books.Auth";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.Cookie.SameSite = SameSiteMode.None;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.SlidingExpiration = true;
|
||||
})
|
||||
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
|
||||
ApiKeyDefaults.AuthenticationScheme, _ => { })
|
||||
.AddOpenIdConnect(options =>
|
||||
{
|
||||
options.GetClaimsFromUserInfoEndpoint = true;
|
||||
options.MetadataAddress = config["Keycloak:MetadataAddress"]
|
||||
?? "https://id.tatic.io/auth/realms/master/.well-known/openid-configuration";
|
||||
options.ClientId = config["Keycloak:ClientId"] ?? "books";
|
||||
options.SaveTokens = true;
|
||||
options.ClientSecret = keycloakClientSecret;
|
||||
options.ResponseType = "code";
|
||||
options.Scope.Add("openid");
|
||||
options.Scope.Add("email");
|
||||
options.Scope.Add("profile");
|
||||
options.CallbackPath = "/callback";
|
||||
options.UsePkce = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,15 @@
|
|||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Default": "Host=localhost;Database=books;Username=postgres;Password=postgres;Include Error Detail=true"
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:3000"
|
||||
]
|
||||
},
|
||||
"Keycloak": {
|
||||
"MetadataAddress": "https://id.tatic.io/auth/realms/master/.well-known/openid-configuration",
|
||||
"ClientId": "books",
|
||||
"ClientSecret": "3fd1679b-4505-4e2b-9d19-0be19d4ae297"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
# Development environment defaults
|
||||
VITE_GRAPHQL_ENDPOINT=http://localhost:5000/graphql
|
||||
VITE_GRAPHQL_ENDPOINT=https://localhost:5001/graphql
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@ import { BrowserRouter } from 'react-router-dom';
|
|||
import { App as AntApp } from 'antd';
|
||||
import AppRoutes from './routes';
|
||||
import AppLayout from './components/layout/AppLayout';
|
||||
import ProtectedRoute from './components/auth/ProtectedRoute';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AntApp>
|
||||
<BrowserRouter>
|
||||
<ProtectedRoute>
|
||||
<AppLayout>
|
||||
<AppRoutes />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
</BrowserRouter>
|
||||
</AntApp>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,22 +4,11 @@ import { QueryClient } from '@tanstack/react-query';
|
|||
// GraphQL endpoint - configure based on environment
|
||||
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
|
||||
|
||||
// Create GraphQL client
|
||||
// Create GraphQL client with cookie-based authentication
|
||||
export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, {
|
||||
headers: {
|
||||
// Add auth headers here when authentication is implemented
|
||||
// 'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
credentials: 'include', // Send cookies with requests
|
||||
});
|
||||
|
||||
// Configure headers dynamically (for auth tokens, etc.)
|
||||
export const setAuthHeader = (token: string) => {
|
||||
graphqlClient.setHeader('Authorization', `Bearer ${token}`);
|
||||
};
|
||||
|
||||
export const removeAuthHeader = () => {
|
||||
graphqlClient.setHeader('Authorization', '');
|
||||
};
|
||||
|
||||
// Create TanStack Query client with default options
|
||||
export const queryClient = new QueryClient({
|
||||
|
|
|
|||
64
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
64
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useEffect, type ReactNode } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that ensures the user is authenticated
|
||||
* Shows loading spinner while checking auth status
|
||||
* Redirects to login if not authenticated
|
||||
*/
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading, login, refreshUser } = useAuthStore();
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
refreshUser();
|
||||
}, [refreshUser]);
|
||||
|
||||
// Show loading spinner while checking auth
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#666' }}>Logger ind...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
login();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#666' }}>Omdirigerer til login...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default ProtectedRoute;
|
||||
|
|
@ -9,11 +9,14 @@ import {
|
|||
import type { MenuProps } from 'antd';
|
||||
import CompanySwitcher from './CompanySwitcher';
|
||||
import FiscalYearSelector from './FiscalYearSelector';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
const { Header: AntHeader } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
|
|
@ -39,8 +42,7 @@ export default function Header() {
|
|||
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'logout':
|
||||
// Handle logout
|
||||
console.log('Logout clicked');
|
||||
logout();
|
||||
break;
|
||||
case 'settings':
|
||||
// Navigate to settings
|
||||
|
|
@ -103,7 +105,7 @@ export default function Header() {
|
|||
style={{ backgroundColor: '#1677ff' }}
|
||||
/>
|
||||
<Text style={{ maxWidth: 120 }} ellipsis>
|
||||
Bruger
|
||||
{user?.name || user?.email || 'Bruger'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
|
|
|
|||
82
frontend/src/services/authService.ts
Normal file
82
frontend/src/services/authService.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// API URL - extract base from GraphQL endpoint
|
||||
const API_URL = (import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql').replace('/graphql', '');
|
||||
|
||||
export interface UserContext {
|
||||
id: string | null;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
companyId: string | null;
|
||||
isApiKey: boolean;
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
private userContext: UserContext | null = null;
|
||||
|
||||
/**
|
||||
* Fetch user profile from the backend
|
||||
* Returns null if not authenticated
|
||||
*/
|
||||
async refreshUserContext(): Promise<UserContext | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/profile`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.userContext = await response.json();
|
||||
return this.userContext;
|
||||
}
|
||||
|
||||
// 401/403 means not authenticated
|
||||
this.userContext = null;
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user profile:', error);
|
||||
this.userContext = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to login page
|
||||
* The backend will handle OIDC authentication and redirect back
|
||||
*/
|
||||
login(returnUrl?: string): void {
|
||||
const url = returnUrl || window.location.href;
|
||||
window.location.href = `${API_URL}/api/login?returnUrl=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear session
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await fetch(`${API_URL}/api/logout`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
|
||||
this.userContext = null;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user context
|
||||
*/
|
||||
get user(): UserContext | null {
|
||||
return this.userContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
get isAuthenticated(): boolean {
|
||||
return this.userContext !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const authService = new AuthService();
|
||||
61
frontend/src/stores/authStore.ts
Normal file
61
frontend/src/stores/authStore.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { create } from 'zustand';
|
||||
import { authService, type UserContext } from '@/services/authService';
|
||||
|
||||
interface AuthState {
|
||||
// User context from backend
|
||||
user: UserContext | null;
|
||||
// Loading state for initial auth check
|
||||
isLoading: boolean;
|
||||
// Whether user is authenticated
|
||||
isAuthenticated: boolean;
|
||||
// Error message if auth failed
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
login: (returnUrl?: string) => void;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
|
||||
login: (returnUrl?: string) => {
|
||||
authService.login(returnUrl);
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await authService.logout();
|
||||
set({ user: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
refreshUser: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const user = await authService.refreshUserContext();
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: user !== null,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Authentication failed',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
||||
// Selector hooks for convenience
|
||||
export const useUser = () => useAuthStore((state) => state.user);
|
||||
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
|
||||
export const useAuthLoading = () => useAuthStore((state) => state.isLoading);
|
||||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/simple-booking/accountquickpicker.tsx","./src/components/simple-booking/banktransactioncard.tsx","./src/components/simple-booking/quickbookmodal.tsx","./src/components/simple-booking/splitbookmodal.tsx","./src/components/simple-booking/index.ts","./src/components/tables/datatable.tsx","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/useperiod.ts","./src/lib/accounting.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/bankafstemning.tsx","./src/pages/dashboard.tsx","./src/pages/hurtigbogforing.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/settings.tsx","./src/stores/companystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/simplebookingstore.ts","./src/stores/uistore.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/periods.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/auth/protectedroute.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/simple-booking/accountquickpicker.tsx","./src/components/simple-booking/banktransactioncard.tsx","./src/components/simple-booking/quickbookmodal.tsx","./src/components/simple-booking/splitbookmodal.tsx","./src/components/simple-booking/index.ts","./src/components/tables/datatable.tsx","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/useperiod.ts","./src/lib/accounting.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/bankafstemning.tsx","./src/pages/dashboard.tsx","./src/pages/hurtigbogforing.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/settings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/simplebookingstore.ts","./src/stores/uistore.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/periods.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue