mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-02-19 07:17:04 -05:00
Compare commits
23 Commits
fix_pwa_in
...
add_authen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18dc0bb7e4 | ||
|
|
dd38b576f7 | ||
|
|
94215cee00 | ||
|
|
197bd0d444 | ||
|
|
d20773ab7b | ||
|
|
f4e92a68ee | ||
|
|
18dc2813eb | ||
|
|
63ef979d0d | ||
|
|
a72f01fe4c | ||
|
|
9699e0fc29 | ||
|
|
0be7e125c9 | ||
|
|
49f0ce9969 | ||
|
|
4d8e27b01e | ||
|
|
d822f7ef32 | ||
|
|
3d7ed0e702 | ||
|
|
f514523de1 | ||
|
|
7160838ab4 | ||
|
|
cf495b5aac | ||
|
|
6388677244 | ||
|
|
9d46c0ae12 | ||
|
|
dad8dd9eee | ||
|
|
5ea3b5273f | ||
|
|
8864207b8e |
@@ -15,6 +15,12 @@ ifndef name
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
|
||||
migrate-users:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-users name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
|
||||
|
||||
docker-build:
|
||||
ifndef tag
|
||||
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Custom WebApplicationFactory that uses an isolated SQLite database for each test fixture.
|
||||
/// The database file is created in a temp directory so both DI and static contexts share the same data.
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public CustomWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove the existing UsersContext registration
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
// Also remove the DbContext registration itself
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
|
||||
if (contextDescriptor != null) services.Remove(contextDescriptor);
|
||||
|
||||
var dbPath = Path.Combine(_tempDir, "users.db");
|
||||
|
||||
services.AddDbContext<UsersContext>(options =>
|
||||
{
|
||||
options.UseSqlite($"Data Source={dbPath}");
|
||||
});
|
||||
|
||||
// Ensure DB is created
|
||||
var sp = services.BuildServiceProvider();
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the authentication flow.
|
||||
/// Uses a single shared factory to avoid static state conflicts.
|
||||
/// Tests are ordered to build on each other: setup → login → protected endpoints.
|
||||
/// </summary>
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public AuthControllerTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task GetStatus_BeforeSetup_ReturnsNotCompleted()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccount_ReturnsCreated()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("userId").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Setup_CreateDuplicateAccount_ReturnsConflict()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "another",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Setup_Generate2FA_ReturnsSecretAndRecoveryCodes()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("secret").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("qrCodeUri").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("recoveryCodes").GetArrayLength().ShouldBeGreaterThan(0);
|
||||
|
||||
// Store the secret for the next test
|
||||
_totpSecret = body.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Setup_Verify2FA_WithValidCode_Succeeds()
|
||||
{
|
||||
// If we don't have the secret from the previous test, generate it again
|
||||
if (string.IsNullOrEmpty(_totpSecret))
|
||||
{
|
||||
var genResponse = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
var genBody = await genResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_totpSecret = genBody.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
var code = GenerateTotpCode(_totpSecret);
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/verify", new { code });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Setup_Complete_Succeeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task Login_ValidCredentials_RequiresTwoFactor()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("loginToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task Login_InvalidCredentials_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task Login_BruteForce_ReturnsRetryAfter()
|
||||
{
|
||||
// Make multiple failed attempts
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
}
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
body.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
body.TryGetProperty("retryAfterSeconds", out var retry).ShouldBeTrue();
|
||||
retry.GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task ProtectedEndpoint_WithoutAuth_DeniesAccess()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account");
|
||||
|
||||
// 401 (FallbackPolicy) or 403 (SetupGuardMiddleware) - both deny unauthenticated access
|
||||
new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task HealthEndpoint_WithoutAuth_Returns200()
|
||||
{
|
||||
var response = await _client.GetAsync("/health");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
private static string GenerateTotpCode(string base32Secret)
|
||||
{
|
||||
var key = Base32Decode(base32Secret);
|
||||
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
|
||||
var timestepBytes = BitConverter.GetBytes(timestep);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(timestepBytes);
|
||||
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA1(key);
|
||||
var hash = hmac.ComputeHash(timestepBytes);
|
||||
|
||||
var offset = hash[^1] & 0x0F;
|
||||
var binaryCode =
|
||||
((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return (binaryCode % 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
private static byte[] Base32Decode(string base32)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
base32 = base32.ToUpperInvariant().TrimEnd('=');
|
||||
|
||||
var bits = new List<byte>();
|
||||
foreach (var c in base32)
|
||||
{
|
||||
var val = alphabet.IndexOf(c);
|
||||
if (val < 0) continue;
|
||||
for (var i = 4; i >= 0; i--)
|
||||
bits.Add((byte)((val >> i) & 1));
|
||||
}
|
||||
|
||||
var bytes = new byte[bits.Count / 8];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
for (var j = 0; j < 8; j++)
|
||||
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
37
code/backend/Cleanuparr.Api.Tests/PriorityOrderer.cs
Normal file
37
code/backend/Cleanuparr.Api.Tests/PriorityOrderer.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
public sealed class PriorityOrderer : ITestCaseOrderer
|
||||
{
|
||||
public IEnumerable<TTestCase> OrderTestCases<TTestCase>(IEnumerable<TTestCase> testCases)
|
||||
where TTestCase : ITestCase
|
||||
{
|
||||
var sortedMethods = new SortedDictionary<int, List<TTestCase>>();
|
||||
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
var priority = testCase.TestMethod.Method
|
||||
.GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName)
|
||||
.FirstOrDefault()
|
||||
?.GetNamedArgument<int>("Priority") ?? 0;
|
||||
|
||||
if (!sortedMethods.TryGetValue(priority, out var list))
|
||||
{
|
||||
list = [];
|
||||
sortedMethods[priority] = list;
|
||||
}
|
||||
|
||||
list.Add(testCase);
|
||||
}
|
||||
|
||||
foreach (var list in sortedMethods.Values)
|
||||
{
|
||||
foreach (var testCase in list)
|
||||
{
|
||||
yield return testCase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
code/backend/Cleanuparr.Api.Tests/TestPriorityAttribute.cs
Normal file
12
code/backend/Cleanuparr.Api.Tests/TestPriorityAttribute.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class TestPriorityAttribute : Attribute
|
||||
{
|
||||
public int Priority { get; }
|
||||
|
||||
public TestPriorityAttribute(int priority)
|
||||
{
|
||||
Priority = priority;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.Auth;
|
||||
|
||||
public static class ApiKeyAuthenticationDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "ApiKey";
|
||||
public const string HeaderName = "X-Api-Key";
|
||||
public const string QueryParameterName = "apikey";
|
||||
}
|
||||
|
||||
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Try header first, then query string
|
||||
string? apiKey = null;
|
||||
|
||||
if (Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var headerValue))
|
||||
{
|
||||
apiKey = headerValue.ToString();
|
||||
}
|
||||
else if (Request.Query.TryGetValue(ApiKeyAuthenticationDefaults.QueryParameterName, out var queryValue))
|
||||
{
|
||||
apiKey = queryValue.ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.ApiKey == apiKey && u.SetupCompleted);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid API key");
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("auth_method", "apikey")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -65,9 +65,13 @@ public static class ApiDI
|
||||
// Add the global exception handling middleware first
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
|
||||
// Block non-auth requests until setup is complete
|
||||
app.UseMiddleware<SetupGuardMiddleware>();
|
||||
|
||||
app.UseCors("Any");
|
||||
app.UseRouting();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
@@ -108,11 +112,11 @@ public static class ApiDI
|
||||
|
||||
context.Response.ContentType = "text/html";
|
||||
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
|
||||
// Map SignalR hubs
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health");
|
||||
app.MapHub<AppHub>("/api/hubs/app");
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health").RequireAuthorization();
|
||||
app.MapHub<AppHub>("/api/hubs/app").RequireAuthorization();
|
||||
|
||||
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
|
||||
{
|
||||
@@ -144,7 +148,7 @@ public static class ApiDI
|
||||
};
|
||||
|
||||
return Results.Json(manifest, contentType: "application/manifest+json");
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
81
code/backend/Cleanuparr.Api/DependencyInjection/AuthDI.cs
Normal file
81
code/backend/Cleanuparr.Api/DependencyInjection/AuthDI.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Cleanuparr.Api.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class AuthDI
|
||||
{
|
||||
private const string SmartScheme = "Smart";
|
||||
|
||||
public static IServiceCollection AddAuthServices(this IServiceCollection services)
|
||||
{
|
||||
// Get the signing key from the JwtService
|
||||
var jwtService = new JwtService();
|
||||
var signingKey = jwtService.GetOrCreateSigningKey();
|
||||
|
||||
services
|
||||
.AddAuthentication(SmartScheme)
|
||||
.AddPolicyScheme(SmartScheme, "JWT or API Key", options =>
|
||||
{
|
||||
// Route to the correct auth handler based on the request
|
||||
options.ForwardDefaultSelector = context =>
|
||||
{
|
||||
if (context.Request.Headers.ContainsKey(ApiKeyAuthenticationDefaults.HeaderName) ||
|
||||
context.Request.Query.ContainsKey(ApiKeyAuthenticationDefaults.QueryParameterName))
|
||||
{
|
||||
return ApiKeyAuthenticationDefaults.AuthenticationScheme;
|
||||
}
|
||||
|
||||
return JwtBearerDefaults.AuthenticationScheme;
|
||||
};
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "Cleanuparr",
|
||||
ValidateAudience = true,
|
||||
ValidAudience = "Cleanuparr",
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(signingKey),
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
// Support SignalR token via query string
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/api/hubs"))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
||||
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
var defaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
options.DefaultPolicy = defaultPolicy;
|
||||
options.FallbackPolicy = defaultPolicy;
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -87,10 +87,13 @@ public static class MainDI
|
||||
{
|
||||
// Add the dynamic HTTP client system - this replaces all the previous static configurations
|
||||
services.AddDynamicHttpClients();
|
||||
|
||||
|
||||
// Add the dynamic HTTP client provider that uses the new system
|
||||
services.AddSingleton<IDynamicHttpClientProvider, DynamicHttpClientProvider>();
|
||||
|
||||
|
||||
// Add HTTP client for Plex authentication
|
||||
services.AddHttpClient("PlexAuth");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
@@ -26,6 +27,11 @@ public static class ServicesDI
|
||||
services
|
||||
.AddScoped<EventsContext>()
|
||||
.AddScoped<DataContext>()
|
||||
.AddScoped<UsersContext>()
|
||||
.AddSingleton<IJwtService, JwtService>()
|
||||
.AddSingleton<IPasswordService, PasswordService>()
|
||||
.AddSingleton<ITotpService, TotpService>()
|
||||
.AddScoped<IPlexAuthService, PlexAuthService>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record ChangePasswordRequest
|
||||
{
|
||||
[Required]
|
||||
public required string CurrentPassword { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
[MaxLength(128)]
|
||||
public required string NewPassword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record CreateAccountRequest
|
||||
{
|
||||
[Required]
|
||||
[MinLength(3)]
|
||||
[MaxLength(50)]
|
||||
public required string Username { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
[MaxLength(128)]
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record LoginRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Username { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record PlexPinRequest
|
||||
{
|
||||
[Required]
|
||||
public required int PinId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record RefreshTokenRequest
|
||||
{
|
||||
[Required]
|
||||
public required string RefreshToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record Regenerate2faRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
public required string TotpCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record TwoFactorRequest
|
||||
{
|
||||
[Required]
|
||||
public required string LoginToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Code { get; init; }
|
||||
|
||||
public bool IsRecoveryCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record VerifyTotpRequest
|
||||
{
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
public required string Code { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record AccountInfoResponse
|
||||
{
|
||||
public required string Username { get; init; }
|
||||
public required bool PlexLinked { get; init; }
|
||||
public string? PlexUsername { get; init; }
|
||||
public required bool TwoFactorEnabled { get; init; }
|
||||
public required string ApiKeyPreview { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record AuthStatusResponse
|
||||
{
|
||||
public required bool SetupCompleted { get; init; }
|
||||
public bool PlexLinked { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record LoginResponse
|
||||
{
|
||||
public required bool RequiresTwoFactor { get; init; }
|
||||
public string? LoginToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record PlexPinStatusResponse
|
||||
{
|
||||
public required int PinId { get; init; }
|
||||
public required string AuthUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlexVerifyResponse
|
||||
{
|
||||
public required bool Completed { get; init; }
|
||||
public TokenResponse? Tokens { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record TokenResponse
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required int ExpiresIn { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record TotpSetupResponse
|
||||
{
|
||||
public required string Secret { get; init; }
|
||||
public required string QrCodeUri { get; init; }
|
||||
public required List<string> RecoveryCodes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/account")]
|
||||
[Authorize]
|
||||
public sealed class AccountController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly ILogger<AccountController> _logger;
|
||||
|
||||
public AccountController(
|
||||
UsersContext usersContext,
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
ILogger<AccountController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAccountInfo()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
return Ok(new AccountInfoResponse
|
||||
{
|
||||
Username = user.Username,
|
||||
PlexLinked = user.PlexAccountId is not null,
|
||||
PlexUsername = user.PlexUsername,
|
||||
TwoFactorEnabled = user.TotpEnabled,
|
||||
ApiKeyPreview = user.ApiKey[..8] + "..."
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("password")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Current password is incorrect" });
|
||||
}
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("2fa/regenerate")]
|
||||
public async Task<IActionResult> Regenerate2fa([FromBody] Regenerate2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("api-key")]
|
||||
public async Task<IActionResult> GetApiKey()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
|
||||
[HttpPost("api-key/regenerate")]
|
||||
public async Task<IActionResult> RegenerateApiKey()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
|
||||
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("plex/link")]
|
||||
public async Task<IActionResult> StartPlexLink()
|
||||
{
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new { pinId = pin.PinId, authUrl = pin.AuthUrl });
|
||||
}
|
||||
|
||||
[HttpPost("plex/link/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLink([FromBody] PlexPinRequest request)
|
||||
{
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
{
|
||||
return Ok(new { completed = false });
|
||||
}
|
||||
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
|
||||
return Ok(new { completed = true, plexUsername = plexAccount.Username });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("plex/link")]
|
||||
public async Task<IActionResult> UnlinkPlex()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
user.PlexAccountId = null;
|
||||
user.PlexUsername = null;
|
||||
user.PlexEmail = null;
|
||||
user.PlexAuthToken = null;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Plex account unlinked" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<User?> GetCurrentUser(bool includeRecoveryCodes = false)
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null || !Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var query = _usersContext.Users.AsQueryable();
|
||||
|
||||
if (includeRecoveryCodes)
|
||||
{
|
||||
query = query.Include(u => u.RecoveryCodes);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
[AllowAnonymous]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
UsersContext usersContext,
|
||||
IJwtService jwtService,
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_jwtService = jwtService;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> GetStatus()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
return Ok(new AuthStatusResponse
|
||||
{
|
||||
SetupCompleted = user is { SetupCompleted: true },
|
||||
PlexLinked = user?.PlexAccountId is not null
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("setup/account")]
|
||||
public async Task<IActionResult> CreateAccount([FromBody] CreateAccountRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingUser = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return Conflict(new { error = "Account already exists" });
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = request.Username,
|
||||
PasswordHash = _passwordService.HashPassword(request.Password),
|
||||
TotpSecret = string.Empty,
|
||||
TotpEnabled = false,
|
||||
ApiKey = GenerateApiKey(),
|
||||
SetupCompleted = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_usersContext.Users.Add(user);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Admin account created for user {Username}", request.Username);
|
||||
|
||||
return Created("", new { userId = user.Id });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/2fa/generate")]
|
||||
public async Task<IActionResult> GenerateTotpSetup()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users
|
||||
.Include(u => u.RecoveryCodes)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted && user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already configured" });
|
||||
}
|
||||
|
||||
// Generate new TOTP secret
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
|
||||
// Generate recovery codes
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
// Store secret (will be finalized on verify)
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove old recovery codes and add new ones
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/2fa/verify")]
|
||||
public async Task<IActionResult> VerifyTotpSetup([FromBody] VerifyTotpRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA verified and enabled" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/complete")]
|
||||
public async Task<IActionResult> CompleteSetup()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
return BadRequest(new { error = "2FA must be configured before completing setup" });
|
||||
}
|
||||
|
||||
user.SetupCompleted = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Setup completed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Setup complete" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (user is null || !user.SetupCompleted)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid credentials" });
|
||||
}
|
||||
|
||||
// Check lockout
|
||||
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
|
||||
{
|
||||
var remaining = (int)(user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds;
|
||||
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash) ||
|
||||
!string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
|
||||
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
|
||||
}
|
||||
|
||||
// Reset failed attempts on successful password verification
|
||||
await ResetFailedAttempts(user.Id);
|
||||
|
||||
// Password valid - require 2FA
|
||||
var loginToken = _jwtService.GenerateLoginToken(user.Id);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
RequiresTwoFactor = true,
|
||||
LoginToken = loginToken
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("login/2fa")]
|
||||
public async Task<IActionResult> VerifyTwoFactor([FromBody] TwoFactorRequest request)
|
||||
{
|
||||
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
|
||||
if (userId is null)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid or expired login token" });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users
|
||||
.Include(u => u.RecoveryCodes)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId.Value);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid login token" });
|
||||
}
|
||||
|
||||
bool codeValid;
|
||||
|
||||
if (request.IsRecoveryCode)
|
||||
{
|
||||
codeValid = await TryUseRecoveryCode(user, request.Code);
|
||||
}
|
||||
else
|
||||
{
|
||||
codeValid = _totpService.ValidateCode(user.TotpSecret, request.Code);
|
||||
}
|
||||
|
||||
if (!codeValid)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
return Ok(await GenerateTokenResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var tokenHash = HashRefreshToken(request.RefreshToken);
|
||||
|
||||
var storedToken = await _usersContext.RefreshTokens
|
||||
.Include(r => r.User)
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
|
||||
|
||||
if (storedToken is null || storedToken.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid or expired refresh token" });
|
||||
}
|
||||
|
||||
// Revoke the old token (rotation)
|
||||
storedToken.RevokedAt = DateTime.UtcNow;
|
||||
|
||||
// Generate new tokens
|
||||
var response = await GenerateTokenResponse(storedToken.User);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout([FromBody] RefreshTokenRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var tokenHash = HashRefreshToken(request.RefreshToken);
|
||||
|
||||
var storedToken = await _usersContext.RefreshTokens
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
|
||||
|
||||
if (storedToken is not null)
|
||||
{
|
||||
storedToken.RevokedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new { message = "Logged out" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/plex/pin")]
|
||||
public async Task<IActionResult> RequestSetupPlexPin()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new PlexPinStatusResponse
|
||||
{
|
||||
PinId = pin.PinId,
|
||||
AuthUrl = pin.AuthUrl
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("setup/plex/verify")]
|
||||
public async Task<IActionResult> VerifySetupPlexLink([FromBody] PlexPinRequest request)
|
||||
{
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
{
|
||||
return Ok(new PlexVerifyResponse { Completed = false });
|
||||
}
|
||||
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked during setup for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
|
||||
return Ok(new PlexVerifyResponse { Completed = true });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("login/plex/pin")]
|
||||
public async Task<IActionResult> RequestPlexPin()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
return BadRequest(new { error = "Plex login is not available" });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new PlexPinStatusResponse
|
||||
{
|
||||
PinId = pin.PinId,
|
||||
AuthUrl = pin.AuthUrl
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("login/plex/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLogin([FromBody] PlexPinRequest request)
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
return BadRequest(new { error = "Plex login is not available" });
|
||||
}
|
||||
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
{
|
||||
return Ok(new PlexVerifyResponse { Completed = false });
|
||||
}
|
||||
|
||||
// Verify the Plex account matches the linked one
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
if (plexAccount.AccountId != user.PlexAccountId)
|
||||
{
|
||||
return Unauthorized(new { error = "Plex account does not match the linked account" });
|
||||
}
|
||||
|
||||
// Plex login bypasses 2FA
|
||||
_logger.LogInformation("User {Username} logged in via Plex", user.Username);
|
||||
|
||||
var tokenResponse = await GenerateTokenResponse(user);
|
||||
|
||||
return Ok(new PlexVerifyResponse
|
||||
{
|
||||
Completed = true,
|
||||
Tokens = tokenResponse
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<TokenResponse> GenerateTokenResponse(User user)
|
||||
{
|
||||
var accessToken = _jwtService.GenerateAccessToken(user);
|
||||
var refreshToken = _jwtService.GenerateRefreshToken();
|
||||
|
||||
_usersContext.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
TokenHash = HashRefreshToken(refreshToken),
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(7),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = 60 // seconds
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> TryUseRecoveryCode(User user, string code)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
foreach (var recoveryCode in user.RecoveryCodes.Where(r => !r.IsUsed))
|
||||
{
|
||||
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
|
||||
{
|
||||
recoveryCode.IsUsed = true;
|
||||
recoveryCode.UsedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> IncrementFailedAttempts(Guid userId)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts++;
|
||||
user.LockoutEnd = DateTime.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
|
||||
user.FailedLoginAttempts, user.Username, user.FailedLoginAttempts * 2);
|
||||
|
||||
return user.FailedLoginAttempts * 2;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResetFailedAttempts(Guid userId)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateApiKey()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string HashRefreshToken(string token)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(token);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,14 @@ public static class HostExtensions
|
||||
{
|
||||
await configContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
|
||||
// Apply users db migrations
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
if ((await usersContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await usersContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Middleware;
|
||||
|
||||
public class SetupGuardMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private volatile bool _setupCompleted;
|
||||
|
||||
public SetupGuardMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Fast path: setup already completed
|
||||
if (_setupCompleted)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Always allow these paths regardless of setup state
|
||||
if (IsAllowedPath(path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check database for setup completion
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (user is { SetupCompleted: true })
|
||||
{
|
||||
_setupCompleted = true;
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup not complete - block non-auth requests
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cached setup state. Call this if the user database is reset.
|
||||
/// </summary>
|
||||
public void ResetSetupState()
|
||||
{
|
||||
_setupCompleted = false;
|
||||
}
|
||||
|
||||
private static bool IsAllowedPath(string path)
|
||||
{
|
||||
return path.StartsWith("/api/auth/")
|
||||
|| path == "/api/auth"
|
||||
|| path.StartsWith("/health")
|
||||
|| !path.StartsWith("/api/");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Runtime.InteropServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api;
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
@@ -70,12 +71,19 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
// Add services to the container
|
||||
builder.Services
|
||||
.AddInfrastructure(builder.Configuration)
|
||||
.AddApiServices();
|
||||
.AddApiServices()
|
||||
.AddAuthServices();
|
||||
|
||||
// Persist Data Protection keys to the config directory
|
||||
builder.Services
|
||||
.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(ConfigurationPathProvider.GetConfigPath(), "DataProtection-Keys")))
|
||||
.SetApplicationName("Cleanuparr");
|
||||
|
||||
// Add CORS before SignalR
|
||||
builder.Services.AddCors(options =>
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Any", policy =>
|
||||
options.AddPolicy("Any", policy =>
|
||||
{
|
||||
policy
|
||||
// https://github.com/dotnet/aspnetcore/issues/4457#issuecomment-465669576
|
||||
@@ -146,14 +154,14 @@ app.Init();
|
||||
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
|
||||
SignalRLogSink.Instance.SetAppHubContext(appHub);
|
||||
|
||||
// Configure health check endpoints before the API configuration
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
// Configure health check endpoints as middleware (before auth pipeline) so they don't require authentication
|
||||
app.UseHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("liveness"),
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
app.UseHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("readiness"),
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface IJwtService
|
||||
{
|
||||
string GenerateAccessToken(User user);
|
||||
string GenerateLoginToken(Guid userId);
|
||||
string GenerateRefreshToken();
|
||||
ClaimsPrincipal? ValidateAccessToken(string token);
|
||||
Guid? ValidateLoginToken(string token);
|
||||
byte[] GetOrCreateSigningKey();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface IPasswordService
|
||||
{
|
||||
string HashPassword(string password);
|
||||
bool VerifyPassword(string password, string hash);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed record PlexPinResult
|
||||
{
|
||||
public required int PinId { get; init; }
|
||||
public required string PinCode { get; init; }
|
||||
public required string AuthUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlexPinCheckResult
|
||||
{
|
||||
public required bool Completed { get; init; }
|
||||
public string? AuthToken { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlexAccountInfo
|
||||
{
|
||||
public required string AccountId { get; init; }
|
||||
public required string Username { get; init; }
|
||||
public string? Email { get; init; }
|
||||
}
|
||||
|
||||
public interface IPlexAuthService
|
||||
{
|
||||
Task<PlexPinResult> RequestPin();
|
||||
Task<PlexPinCheckResult> CheckPin(int pinId);
|
||||
Task<PlexAccountInfo> GetAccount(string authToken);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface ITotpService
|
||||
{
|
||||
string GenerateSecret();
|
||||
string GetQrCodeUri(string secret, string username);
|
||||
bool ValidateCode(string secret, string code);
|
||||
List<string> GenerateRecoveryCodes(int count = 10);
|
||||
string HashRecoveryCode(string code);
|
||||
bool VerifyRecoveryCode(string code, string hash);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class JwtService : IJwtService
|
||||
{
|
||||
private const string Issuer = "Cleanuparr";
|
||||
private const string Audience = "Cleanuparr";
|
||||
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan LoginTokenLifetime = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly byte[] _signingKey;
|
||||
|
||||
public JwtService()
|
||||
{
|
||||
_signingKey = GetOrCreateSigningKey();
|
||||
}
|
||||
|
||||
public string GenerateAccessToken(User user)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("token_type", "access")
|
||||
};
|
||||
|
||||
return GenerateToken(claims, AccessTokenLifetime);
|
||||
}
|
||||
|
||||
public string GenerateLoginToken(Guid userId)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim("token_type", "login")
|
||||
};
|
||||
|
||||
return GenerateToken(claims, LoginTokenLifetime);
|
||||
}
|
||||
|
||||
public string GenerateRefreshToken()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? ValidateAccessToken(string token)
|
||||
{
|
||||
var principal = ValidateToken(token);
|
||||
if (principal is null) return null;
|
||||
|
||||
var tokenType = principal.FindFirst("token_type")?.Value;
|
||||
return tokenType == "access" ? principal : null;
|
||||
}
|
||||
|
||||
public Guid? ValidateLoginToken(string token)
|
||||
{
|
||||
var principal = ValidateToken(token);
|
||||
if (principal is null) return null;
|
||||
|
||||
var tokenType = principal.FindFirst("token_type")?.Value;
|
||||
if (tokenType != "login") return null;
|
||||
|
||||
var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
return Guid.TryParse(userIdClaim, out var userId) ? userId : null;
|
||||
}
|
||||
|
||||
public byte[] GetOrCreateSigningKey()
|
||||
{
|
||||
var keyPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "jwt-key.bin");
|
||||
|
||||
if (File.Exists(keyPath))
|
||||
{
|
||||
return File.ReadAllBytes(keyPath);
|
||||
}
|
||||
|
||||
var key = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(key);
|
||||
|
||||
var directory = Path.GetDirectoryName(keyPath);
|
||||
if (directory is not null && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllBytes(keyPath, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private string GenerateToken(Claim[] claims, TimeSpan lifetime)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(_signingKey);
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.Add(lifetime),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(_signingKey);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
try
|
||||
{
|
||||
return handler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = Audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
}, out _);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class PasswordService : IPasswordService
|
||||
{
|
||||
private const int WorkFactor = 12;
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
try
|
||||
{
|
||||
return BCrypt.Net.BCrypt.Verify(password, hash);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class PlexAuthService : IPlexAuthService
|
||||
{
|
||||
private const string PlexApiBaseUrl = "https://plex.tv/api/v2";
|
||||
private const string PlexProduct = "Cleanuparr";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<PlexAuthService> _logger;
|
||||
private readonly string _clientIdentifier;
|
||||
|
||||
public PlexAuthService(IHttpClientFactory httpClientFactory, ILogger<PlexAuthService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("PlexAuth");
|
||||
_logger = logger;
|
||||
_clientIdentifier = GetOrCreateClientIdentifier();
|
||||
}
|
||||
|
||||
public async Task<PlexPinResult> RequestPin()
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{PlexApiBaseUrl}/pins");
|
||||
AddPlexHeaders(request);
|
||||
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["strong"] = "true"
|
||||
});
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var pin = JsonSerializer.Deserialize<PlexPinResponse>(json);
|
||||
|
||||
if (pin is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Plex PIN response");
|
||||
}
|
||||
|
||||
var authUrl = $"https://app.plex.tv/auth#?clientID={Uri.EscapeDataString(_clientIdentifier)}&code={Uri.EscapeDataString(pin.Code)}&context%5Bdevice%5D%5Bproduct%5D={Uri.EscapeDataString(PlexProduct)}";
|
||||
|
||||
return new PlexPinResult
|
||||
{
|
||||
PinId = pin.Id,
|
||||
PinCode = pin.Code,
|
||||
AuthUrl = authUrl
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PlexPinCheckResult> CheckPin(int pinId)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{PlexApiBaseUrl}/pins/{pinId}");
|
||||
AddPlexHeaders(request);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new PlexPinCheckResult { Completed = false };
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var pin = JsonSerializer.Deserialize<PlexPinResponse>(json);
|
||||
|
||||
if (pin is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Plex PIN response");
|
||||
}
|
||||
|
||||
return new PlexPinCheckResult
|
||||
{
|
||||
Completed = !string.IsNullOrEmpty(pin.AuthToken),
|
||||
AuthToken = pin.AuthToken
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PlexAccountInfo> GetAccount(string authToken)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{PlexApiBaseUrl}/user");
|
||||
AddPlexHeaders(request);
|
||||
request.Headers.Add("X-Plex-Token", authToken);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var account = JsonSerializer.Deserialize<PlexAccountResponse>(json);
|
||||
|
||||
if (account is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Plex account response");
|
||||
}
|
||||
|
||||
return new PlexAccountInfo
|
||||
{
|
||||
AccountId = account.Id.ToString(),
|
||||
Username = account.Username,
|
||||
Email = account.Email
|
||||
};
|
||||
}
|
||||
|
||||
private void AddPlexHeaders(HttpRequestMessage request)
|
||||
{
|
||||
request.Headers.Add("Accept", "application/json");
|
||||
request.Headers.Add("X-Plex-Client-Identifier", _clientIdentifier);
|
||||
request.Headers.Add("X-Plex-Product", PlexProduct);
|
||||
}
|
||||
|
||||
private static string GetOrCreateClientIdentifier()
|
||||
{
|
||||
var path = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "plex-client-id.txt");
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return File.ReadAllText(path).Trim();
|
||||
}
|
||||
|
||||
var clientId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (directory is not null && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(path, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
// JSON deserialization models
|
||||
private sealed class PlexPinResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("authToken")]
|
||||
public string? AuthToken { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PlexAccountResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Security.Cryptography;
|
||||
using OtpNet;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class TotpService : ITotpService
|
||||
{
|
||||
private const string Issuer = "Cleanuparr";
|
||||
|
||||
public string GenerateSecret()
|
||||
{
|
||||
var key = KeyGeneration.GenerateRandomKey(20);
|
||||
return Base32Encoding.ToString(key);
|
||||
}
|
||||
|
||||
public string GetQrCodeUri(string secret, string username)
|
||||
{
|
||||
return $"otpauth://totp/{Uri.EscapeDataString(Issuer)}:{Uri.EscapeDataString(username)}?secret={secret}&issuer={Uri.EscapeDataString(Issuer)}&digits=6&period=30";
|
||||
}
|
||||
|
||||
public bool ValidateCode(string secret, string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code) || code.Length != 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var keyBytes = Base32Encoding.ToBytes(secret);
|
||||
var totp = new Totp(keyBytes);
|
||||
return totp.VerifyTotp(code, out _, new VerificationWindow(previous: 1, future: 1));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public List<string> GenerateRecoveryCodes(int count = 10)
|
||||
{
|
||||
var codes = new List<string>(count);
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
// Generate 8-character alphanumeric codes in format XXXX-XXXX
|
||||
var bytes = new byte[5];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
var code = Convert.ToHexString(bytes)[..8].ToUpperInvariant();
|
||||
codes.Add($"{code[..4]}-{code[4..]}");
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
public string HashRecoveryCode(string code)
|
||||
{
|
||||
// Normalize: remove dashes and uppercase
|
||||
var normalized = code.Replace("-", "").ToUpperInvariant();
|
||||
return BCrypt.Net.BCrypt.HashPassword(normalized, 10);
|
||||
}
|
||||
|
||||
public bool VerifyRecoveryCode(string code, string hash)
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalized = code.Replace("-", "").ToUpperInvariant();
|
||||
return BCrypt.Net.BCrypt.Verify(normalized, hash);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@ public static class LoggingConfigManager
|
||||
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.DataProtection", LogEventLevel.Error)
|
||||
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
||||
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
|
||||
|
||||
215
code/backend/Cleanuparr.Persistence/Migrations/Users/20260215094545_Initial.Designer.cs
generated
Normal file
215
code/backend/Cleanuparr.Persistence/Migrations/Users/20260215094545_Initial.Designer.cs
generated
Normal file
@@ -0,0 +1,215 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Users
|
||||
{
|
||||
[DbContext(typeof(UsersContext))]
|
||||
[Migration("20260215094545_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CodeHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("code_hash");
|
||||
|
||||
b.Property<bool>("IsUsed")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_used");
|
||||
|
||||
b.Property<DateTime?>("UsedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("used_at");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_recovery_codes");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_recovery_codes_user_id");
|
||||
|
||||
b.ToTable("recovery_codes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_refresh_tokens");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_refresh_tokens_token_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_refresh_tokens_user_id");
|
||||
|
||||
b.ToTable("refresh_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("FailedLoginAttempts")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_login_attempts");
|
||||
|
||||
b.Property<DateTime?>("LockoutEnd")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lockout_end");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("PlexAccountId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_account_id");
|
||||
|
||||
b.Property<string>("PlexAuthToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_auth_token");
|
||||
|
||||
b.Property<string>("PlexEmail")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_email");
|
||||
|
||||
b.Property<string>("PlexUsername")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_username");
|
||||
|
||||
b.Property<bool>("SetupCompleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("setup_completed");
|
||||
|
||||
b.Property<bool>("TotpEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("totp_enabled");
|
||||
|
||||
b.Property<string>("TotpSecret")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("totp_secret");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_users");
|
||||
|
||||
b.HasIndex("ApiKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_api_key");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_username");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
|
||||
.WithMany("RecoveryCodes")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_recovery_codes_users_user_id");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_refresh_tokens_users_user_id");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
|
||||
{
|
||||
b.Navigation("RecoveryCodes");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Users
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "users",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
username = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
password_hash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
totp_secret = table.Column<string>(type: "TEXT", nullable: false),
|
||||
totp_enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
plex_account_id = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
plex_username = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
plex_email = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
|
||||
plex_auth_token = table.Column<string>(type: "TEXT", nullable: true),
|
||||
api_key = table.Column<string>(type: "TEXT", nullable: false),
|
||||
setup_completed = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
failed_login_attempts = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
lockout_end = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
updated_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_users", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "recovery_codes",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
code_hash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
is_used = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
used_at = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_recovery_codes", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_recovery_codes_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "refresh_tokens",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
token_hash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
expires_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
revoked_at = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_refresh_tokens", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_refresh_tokens_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_recovery_codes_user_id",
|
||||
table: "recovery_codes",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_refresh_tokens_token_hash",
|
||||
table: "refresh_tokens",
|
||||
column: "token_hash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_refresh_tokens_user_id",
|
||||
table: "refresh_tokens",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_users_api_key",
|
||||
table: "users",
|
||||
column: "api_key",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_users_username",
|
||||
table: "users",
|
||||
column: "username",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "recovery_codes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "refresh_tokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Users
|
||||
{
|
||||
[DbContext(typeof(UsersContext))]
|
||||
partial class UsersContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CodeHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("code_hash");
|
||||
|
||||
b.Property<bool>("IsUsed")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_used");
|
||||
|
||||
b.Property<DateTime?>("UsedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("used_at");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_recovery_codes");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_recovery_codes_user_id");
|
||||
|
||||
b.ToTable("recovery_codes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_refresh_tokens");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_refresh_tokens_token_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_refresh_tokens_user_id");
|
||||
|
||||
b.ToTable("refresh_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("FailedLoginAttempts")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_login_attempts");
|
||||
|
||||
b.Property<DateTime?>("LockoutEnd")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lockout_end");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("PlexAccountId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_account_id");
|
||||
|
||||
b.Property<string>("PlexAuthToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_auth_token");
|
||||
|
||||
b.Property<string>("PlexEmail")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_email");
|
||||
|
||||
b.Property<string>("PlexUsername")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_username");
|
||||
|
||||
b.Property<bool>("SetupCompleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("setup_completed");
|
||||
|
||||
b.Property<bool>("TotpEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("totp_enabled");
|
||||
|
||||
b.Property<string>("TotpSecret")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("totp_secret");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_users");
|
||||
|
||||
b.HasIndex("ApiKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_api_key");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_username");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
|
||||
.WithMany("RecoveryCodes")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_recovery_codes_users_user_id");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_refresh_tokens_users_user_id");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
|
||||
{
|
||||
b.Navigation("RecoveryCodes");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Auth;
|
||||
|
||||
public class RecoveryCode
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
[SensitiveData]
|
||||
public required string CodeHash { get; set; }
|
||||
|
||||
public bool IsUsed { get; set; }
|
||||
|
||||
public DateTime? UsedAt { get; set; }
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Auth;
|
||||
|
||||
public class RefreshToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
[SensitiveData]
|
||||
public required string TokenHash { get; set; }
|
||||
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
53
code/backend/Cleanuparr.Persistence/Models/Auth/User.cs
Normal file
53
code/backend/Cleanuparr.Persistence/Models/Auth/User.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Auth;
|
||||
|
||||
public class User
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public required string Username { get; set; }
|
||||
|
||||
[Required]
|
||||
[SensitiveData]
|
||||
public required string PasswordHash { get; set; }
|
||||
|
||||
[Required]
|
||||
[SensitiveData]
|
||||
public required string TotpSecret { get; set; }
|
||||
|
||||
public bool TotpEnabled { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? PlexAccountId { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? PlexUsername { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? PlexEmail { get; set; }
|
||||
|
||||
[SensitiveData]
|
||||
public string? PlexAuthToken { get; set; }
|
||||
|
||||
[Required]
|
||||
[SensitiveData]
|
||||
public required string ApiKey { get; set; }
|
||||
|
||||
public bool SetupCompleted { get; set; }
|
||||
|
||||
public int FailedLoginAttempts { get; set; }
|
||||
|
||||
public DateTime? LockoutEnd { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
public List<RecoveryCode> RecoveryCodes { get; set; } = [];
|
||||
|
||||
public List<RefreshToken> RefreshTokens { get; set; } = [];
|
||||
}
|
||||
102
code/backend/Cleanuparr.Persistence/UsersContext.cs
Normal file
102
code/backend/Cleanuparr.Persistence/UsersContext.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Cleanuparr.Persistence.Converters;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Database context for user authentication data
|
||||
/// </summary>
|
||||
public class UsersContext : DbContext
|
||||
{
|
||||
public static SemaphoreSlim Lock { get; } = new(1, 1);
|
||||
|
||||
public DbSet<User> Users { get; set; }
|
||||
|
||||
public DbSet<RecoveryCode> RecoveryCodes { get; set; }
|
||||
|
||||
public DbSet<RefreshToken> RefreshTokens { get; set; }
|
||||
|
||||
public UsersContext()
|
||||
{
|
||||
}
|
||||
|
||||
public UsersContext(DbContextOptions<UsersContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public static UsersContext CreateStaticInstance()
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<UsersContext>();
|
||||
SetDbContextOptions(optionsBuilder);
|
||||
return new UsersContext(optionsBuilder.Options);
|
||||
}
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
SetDbContextOptions(optionsBuilder);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<User>(entity =>
|
||||
{
|
||||
entity.HasIndex(u => u.Username).IsUnique();
|
||||
entity.HasIndex(u => u.ApiKey).IsUnique();
|
||||
|
||||
entity.Property(u => u.CreatedAt)
|
||||
.HasConversion(new UtcDateTimeConverter());
|
||||
|
||||
entity.Property(u => u.UpdatedAt)
|
||||
.HasConversion(new UtcDateTimeConverter());
|
||||
|
||||
entity.Property(u => u.LockoutEnd)
|
||||
.HasConversion(new UtcDateTimeConverter());
|
||||
|
||||
entity.HasMany(u => u.RecoveryCodes)
|
||||
.WithOne(r => r.User)
|
||||
.HasForeignKey(r => r.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(u => u.RefreshTokens)
|
||||
.WithOne(r => r.User)
|
||||
.HasForeignKey(r => r.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RecoveryCode>(entity =>
|
||||
{
|
||||
entity.Property(r => r.UsedAt)
|
||||
.HasConversion(new UtcDateTimeConverter());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RefreshToken>(entity =>
|
||||
{
|
||||
entity.HasIndex(r => r.TokenHash).IsUnique();
|
||||
|
||||
entity.Property(r => r.ExpiresAt)
|
||||
.HasConversion(new UtcDateTimeConverter());
|
||||
|
||||
entity.Property(r => r.CreatedAt)
|
||||
.HasConversion(new UtcDateTimeConverter());
|
||||
|
||||
entity.Property(r => r.RevokedAt)
|
||||
.HasConversion(new UtcDateTimeConverter());
|
||||
});
|
||||
}
|
||||
|
||||
private static void SetDbContextOptions(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
if (optionsBuilder.IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "users.db");
|
||||
optionsBuilder
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cleanuparr.Infrastructure.T
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cleanuparr.Persistence.Tests", "Cleanuparr.Persistence.Tests\Cleanuparr.Persistence.Tests.csproj", "{7037FF30-4890-4435-B4A9-04A7A48188CE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cleanuparr.Api.Tests", "Cleanuparr.Api.Tests\Cleanuparr.Api.Tests.csproj", "{8D4A0C27-5D8F-44B6-967C-85E6CEE6BE7F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -48,5 +50,9 @@ Global
|
||||
{7037FF30-4890-4435-B4A9-04A7A48188CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7037FF30-4890-4435-B4A9-04A7A48188CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7037FF30-4890-4435-B4A9-04A7A48188CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8D4A0C27-5D8F-44B6-967C-85E6CEE6BE7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8D4A0C27-5D8F-44B6-967C-85E6CEE6BE7F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8D4A0C27-5D8F-44B6-967C-85E6CEE6BE7F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8D4A0C27-5D8F-44B6-967C-85E6CEE6BE7F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
565
code/frontend/package-lock.json
generated
565
code/frontend/package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"@ng-icons/tabler-icons": "^33.0.0",
|
||||
"@ngrx/signals": "^21.0.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"angularx-qrcode": "^21.0.4",
|
||||
"postcss": "^8.5.6",
|
||||
"rxjs": "~7.8.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
@@ -278,13 +279,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/architect": {
|
||||
"version": "0.2101.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2101.3.tgz",
|
||||
"integrity": "sha512-vKz8aPA62W+e9+pF6ct4CRDG/MjlIH7sWFGYkxPPRst2g46ZQsRkrzfMZAWv/wnt6OZ1OwyRuO3RW83EMhag8g==",
|
||||
"version": "0.2101.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2101.4.tgz",
|
||||
"integrity": "sha512-3yyebORk+ovtO+LfDjIGbGCZhCMDAsyn9vkCljARj3sSshS4blOQBar0g+V3kYAweLT5Gf+rTKbN5jneOkBAFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "21.1.3",
|
||||
"@angular-devkit/core": "21.1.4",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -297,9 +298,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/core": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.1.3.tgz",
|
||||
"integrity": "sha512-huEXd1tWQHwwN+0VGRT+vSVplV0KNrGFUGJzkIW6iJE1SQElxn6etMai+pSd5DJcePkx6+SuscVsxbfwf70hnA==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.1.4.tgz",
|
||||
"integrity": "sha512-ObPTI5gYCB1jGxTRhcqZ6oQVUBFVJ8GH4LksVuAiz0nFX7xxpzARWvlhq943EtnlovVlUd9I8fM3RQqjfGVVAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "8.17.1",
|
||||
@@ -324,13 +325,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.1.3.tgz",
|
||||
"integrity": "sha512-Ps7bRl5uOcM7WpNJHbSls/jz5/wAI0ldkTlKyiBFA7RtNeQIABAV+hvlw5DJuEb1Lo5hnK0hXj90AyZdOxzY+w==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.1.4.tgz",
|
||||
"integrity": "sha512-Nqq0ioCUxrbEX+L4KOarETcZZJNnJ1mAJ0ubO4VM91qnn8RBBM9SnQ91590TfC34Szk/wh+3+Uj6KUvTJNuegQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "21.1.3",
|
||||
"@angular-devkit/core": "21.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"magic-string": "0.30.21",
|
||||
"ora": "9.0.0",
|
||||
@@ -434,9 +435,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/animations": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.1.3.tgz",
|
||||
"integrity": "sha512-UADMncDd9lkmIT1NPVFcufyP5gJHMPzxNaQpojiGrxT1aT8Du30mao0KSrB4aTwcicv6/cdD5bZbIyg+FL6LkQ==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.1.4.tgz",
|
||||
"integrity": "sha512-8xQ0Ylw7qqVyw4ZJ/Tyw/z5Mtqtp8AMj+R+Z1sCWcyxBgDU4+qfxteVYdiipWC3tX77A0FTsXqwvNP9WVY2/WA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -446,18 +447,18 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "21.1.3"
|
||||
"@angular/core": "21.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/build": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.3.tgz",
|
||||
"integrity": "sha512-RXVRuamfrSPwsFCLJgsO2ucp+dwWDbGbhXrQnMrGXm0qdgYpI8bAsBMd8wOeUA6vn4fRmjaRFQZbL/rcIVrkzw==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.4.tgz",
|
||||
"integrity": "sha512-7CAAQPWFMMqod40ox5MOVB/CnoBXFDehyQhs0hls6lu7bOy/M0EDy0v6bERkyNGRz1mihWWBiCV8XzEinrlq1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@angular-devkit/architect": "0.2101.3",
|
||||
"@angular-devkit/architect": "0.2101.4",
|
||||
"@babel/core": "7.28.5",
|
||||
"@babel/helper-annotate-as-pure": "7.27.3",
|
||||
"@babel/helper-split-export-declaration": "7.24.7",
|
||||
@@ -500,7 +501,7 @@
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/platform-server": "^21.0.0",
|
||||
"@angular/service-worker": "^21.0.0",
|
||||
"@angular/ssr": "^21.1.3",
|
||||
"@angular/ssr": "^21.1.4",
|
||||
"karma": "^6.4.0",
|
||||
"less": "^4.2.0",
|
||||
"ng-packagr": "^21.0.0",
|
||||
@@ -550,9 +551,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cdk": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.3.tgz",
|
||||
"integrity": "sha512-jMiEKCcZMIAnyx2jxrJHmw5c7JXAiN56ErZ4X+OuQ5yFvYRocRVEs25I0OMxntcXNdPTJQvpGwGlhWhS0yDorg==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.4.tgz",
|
||||
"integrity": "sha512-PElA4Ww4TIa3+B/ND+fm8ZPDKONTIqc9a/s0qNxhcAD9IpDqjaBVi/fyg+ZWBtS+x0DQgJtKeCsSZ6sr2aFQaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^8.0.0",
|
||||
@@ -566,20 +567,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.3.tgz",
|
||||
"integrity": "sha512-UPtDcpKyrKZRPfym9gTovcibPzl2O/Woy7B8sm45sAnjDH+jDUCcCvuIak7GpH47shQkC2J4yvnHZbD4c6XxcQ==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.4.tgz",
|
||||
"integrity": "sha512-XsMHgxTvHGiXXrhYZz3zMZYhYU0gHdpoHKGiEKXwcx+S1KoYbIssyg6oF2Kq49ZaE0OYCTKjnvgDce6ZqdkJ/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/architect": "0.2101.3",
|
||||
"@angular-devkit/core": "21.1.3",
|
||||
"@angular-devkit/schematics": "21.1.3",
|
||||
"@angular-devkit/architect": "0.2101.4",
|
||||
"@angular-devkit/core": "21.1.4",
|
||||
"@angular-devkit/schematics": "21.1.4",
|
||||
"@inquirer/prompts": "7.10.1",
|
||||
"@listr2/prompt-adapter-inquirer": "3.0.5",
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"@schematics/angular": "21.1.3",
|
||||
"@schematics/angular": "21.1.4",
|
||||
"@yarnpkg/lockfile": "1.1.0",
|
||||
"algoliasearch": "5.46.2",
|
||||
"ini": "6.0.0",
|
||||
@@ -603,9 +604,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.1.3.tgz",
|
||||
"integrity": "sha512-Wdbln/UqZM5oVnpfIydRdhhL8A9x3bKZ9Zy1/mM0q+qFSftPvmFZIXhEpFqbDwNYbGUhGzx7t8iULC4sVVp/zA==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.1.4.tgz",
|
||||
"integrity": "sha512-1uOxPrHO9PFZBU/JavzYzjxAm+5x7vD2z6AeUndqdT4LjqOBIePswxFDRqM9dlfF8FIwnnfmNFipiC/yQjJSnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -615,14 +616,14 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "21.1.3",
|
||||
"@angular/core": "21.1.4",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.1.3.tgz",
|
||||
"integrity": "sha512-gDNLh7MEf7Qf88ktZzS4LJQXCA5U8aQTfK9ak+0mi2ruZ0x4XSjQCro4H6OPKrrbq94+6GcnlSX5+oVIajEY3w==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.1.4.tgz",
|
||||
"integrity": "sha512-H0qtASeqOTaS44ioF4DYE/yNqwzUmEJpMYrcNEUFEwA20/DkLzyoaEx4y1CjIxtXxuhtge95PNymDBOLWSjIdg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -633,9 +634,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler-cli": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.1.3.tgz",
|
||||
"integrity": "sha512-nKxoQ89W2B1WdonNQ9kgRnvLNS6DAxDrRHBslsKTlV+kbdv7h59M9PjT4ZZ2sp1M/M8LiofnUfa/s2jd/xYj5w==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.1.4.tgz",
|
||||
"integrity": "sha512-Uw8KmpVCo58/f5wf6pY8ZS5fodv65hn5jxms8Nv/K7/LVe3i1nNFrHyneBx5+a7qkz93nSV4rdwBVnMvjIyr+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -657,7 +658,7 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "21.1.3",
|
||||
"@angular/compiler": "21.1.4",
|
||||
"typescript": ">=5.9 <6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -667,9 +668,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-21.1.3.tgz",
|
||||
"integrity": "sha512-TbhQxRC7Lb/3WBdm1n8KRsktmVEuGBBp0WRF5mq0Ze4s1YewIM6cULrSw9ACtcL5jdcq7c74ms+uKQsaP/gdcQ==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-21.1.4.tgz",
|
||||
"integrity": "sha512-QBDO5SaVYTVQ0fIN9Qd7US9cUCgs2vM9x6K18PTUKmygIkHVHTFdzwm4MO5gpCOFzJseGbS+dNzqn+v0PaKf9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -679,7 +680,7 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "21.1.3",
|
||||
"@angular/compiler": "21.1.4",
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"zone.js": "~0.15.0 || ~0.16.0"
|
||||
},
|
||||
@@ -693,9 +694,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/forms": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.1.3.tgz",
|
||||
"integrity": "sha512-YW/YdjM9suZUeJam9agHFXIEE3qQIhGYXMjnnX7xGjOe+CuR2R0qsWn1AR0yrKrNmFspb0lKgM7kTTJyzt8gZg==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.1.4.tgz",
|
||||
"integrity": "sha512-duVT/eOncmFNBYRlK/F7WDg6GD1vL1mxUrDdnp7M9J8JvNrKH0PvdfzuOAmjbB8/bsvUNTLFXCV4+43Mc2Hqsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -705,16 +706,16 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "21.1.3",
|
||||
"@angular/core": "21.1.3",
|
||||
"@angular/platform-browser": "21.1.3",
|
||||
"@angular/common": "21.1.4",
|
||||
"@angular/core": "21.1.4",
|
||||
"@angular/platform-browser": "21.1.4",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.1.3.tgz",
|
||||
"integrity": "sha512-W+ZMXAioaP7CsACafBCHsIxiiKrRTPOlQ+hcC7XNBwy+bn5mjGONoCgLreQs76M8HNWLtr/OAUAr6h26OguOuA==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.1.4.tgz",
|
||||
"integrity": "sha512-S6Iw5CkORih5omh+MQY35w0bUBxdSFAPLDg386S6/9fEUjDClo61hvXNKxaNh9g7tnh1LD7zmTmKrqufnhnFDQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -724,9 +725,9 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "21.1.3",
|
||||
"@angular/common": "21.1.3",
|
||||
"@angular/core": "21.1.3"
|
||||
"@angular/animations": "21.1.4",
|
||||
"@angular/common": "21.1.4",
|
||||
"@angular/core": "21.1.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/animations": {
|
||||
@@ -735,9 +736,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.1.3.tgz",
|
||||
"integrity": "sha512-uAw4LAMHXAPCe4SywhlUEWjMYVbbLHwTxLyduSp1b+9aVwep0juy5O/Xttlxd/oigVe0NMnOyJG9y1Br/ubnrg==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.1.4.tgz",
|
||||
"integrity": "sha512-nPYuRJ8ub/X8GK55U2KqYy/ducVRn6HSoDmZz0yiXtI6haFsZlv9R1j5zi0EDIqrrN0HGARMs6jNDXZC1Ded3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -746,9 +747,9 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "21.1.3",
|
||||
"@angular/core": "21.1.3",
|
||||
"@angular/platform-browser": "21.1.3",
|
||||
"@angular/common": "21.1.4",
|
||||
"@angular/core": "21.1.4",
|
||||
"@angular/platform-browser": "21.1.4",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
@@ -2147,27 +2148,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
|
||||
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
@@ -4028,14 +4016,14 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@schematics/angular": {
|
||||
"version": "21.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.1.3.tgz",
|
||||
"integrity": "sha512-obJvWBhzRdsYL2msM4+8bQD21vFl3VxaVsuiq6iIfYsxhU5i2Iar2wM9NaRaIIqAYhZ8ehQQ/moB9BEbWvDCTw==",
|
||||
"version": "21.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.1.4.tgz",
|
||||
"integrity": "sha512-I1zdSNzdbrVCWpeE2NsZQmIoa9m0nlw4INgdGIkqUH6FgwvoGKC0RoOxKAmm6HHVJ48FE/sPI13dwAeK89ow5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "21.1.3",
|
||||
"@angular-devkit/schematics": "21.1.3",
|
||||
"@angular-devkit/core": "21.1.4",
|
||||
"@angular-devkit/schematics": "21.1.4",
|
||||
"jsonc-parser": "3.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4410,14 +4398,40 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tufjs/models/node_modules/balanced-match": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
|
||||
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jackspeak": "^4.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@tufjs/models/node_modules/brace-expansion": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
||||
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@tufjs/models/node_modules/minimatch": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
|
||||
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
|
||||
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.1"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -4842,6 +4856,20 @@
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/angularx-qrcode": {
|
||||
"version": "21.0.4",
|
||||
"resolved": "https://registry.npmjs.org/angularx-qrcode/-/angularx-qrcode-21.0.4.tgz",
|
||||
"integrity": "sha512-GZFa/X/3rHx/4peA4zNROkK6UaYqxJX0dgkEMk7dCcoYNwJM8/UkOkEUfcx+Btjr7iT4UEhf9twWhFjFp58wfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"qrcode": "1.5.4",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^21.0.0",
|
||||
"@angular/core": "^21.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||
@@ -4874,7 +4902,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -5118,10 +5145,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001769",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
|
||||
"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
|
||||
"version": "1.0.30001770",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
|
||||
"integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5312,7 +5348,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -5325,7 +5360,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
@@ -5474,6 +5508,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -5500,6 +5543,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
@@ -6389,7 +6438,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -6447,13 +6495,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.2.tgz",
|
||||
"integrity": "sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==",
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.3.tgz",
|
||||
"integrity": "sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.1.2",
|
||||
"minimatch": "^10.2.0",
|
||||
"minipass": "^7.1.2",
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
@@ -6484,14 +6532,40 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/glob/node_modules/balanced-match": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
|
||||
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jackspeak": "^4.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
||||
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
|
||||
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
|
||||
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.1"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -6731,14 +6805,40 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-walk/node_modules/balanced-match": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
|
||||
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jackspeak": "^4.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-walk/node_modules/brace-expansion": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
||||
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-walk/node_modules/minimatch": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
|
||||
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
|
||||
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.1"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -6938,6 +7038,22 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
|
||||
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -8291,6 +8407,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pacote": {
|
||||
"version": "21.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.4.tgz",
|
||||
@@ -8415,7 +8540,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -8517,6 +8641,15 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -8637,10 +8770,181 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -8706,6 +9010,15 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -8715,6 +9028,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -9007,6 +9326,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
@@ -9839,6 +10164,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -9853,7 +10184,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -9868,7 +10198,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -9878,14 +10207,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -9895,7 +10222,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -9910,7 +10236,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@ng-icons/tabler-icons": "^33.0.0",
|
||||
"@ngrx/signals": "^21.0.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"angularx-qrcode": "^21.0.4",
|
||||
"postcss": "^8.5.6",
|
||||
"rxjs": "~7.8.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
tablerChevronUp,
|
||||
tablerCopy,
|
||||
tablerFileExport,
|
||||
tablerUser,
|
||||
tablerLogout,
|
||||
} from '@ng-icons/tabler-icons';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
@@ -96,6 +98,8 @@ export const appConfig: ApplicationConfig = {
|
||||
tablerChevronUp,
|
||||
tablerCopy,
|
||||
tablerFileExport,
|
||||
tablerUser,
|
||||
tablerLogout,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { ShellComponent } from '@layout/shell/shell.component';
|
||||
import { AuthLayoutComponent } from '@layout/auth-layout/auth-layout.component';
|
||||
import { authGuard } from '@core/auth/auth.guard';
|
||||
import { authGuard, setupIncompleteGuard, loginGuard } from '@core/auth/auth.guard';
|
||||
import { pendingChangesGuard } from '@core/guards/pending-changes.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
@@ -104,6 +104,13 @@ export const routes: Routes = [
|
||||
).then((m) => m.NotificationsComponent),
|
||||
canDeactivate: [pendingChangesGuard],
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
loadComponent: () =>
|
||||
import(
|
||||
'@features/settings/account/account-settings.component'
|
||||
).then((m) => m.AccountSettingsComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -114,11 +121,20 @@ export const routes: Routes = [
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
canActivate: [loginGuard],
|
||||
loadComponent: () =>
|
||||
import('@features/auth/login/login.component').then(
|
||||
(m) => m.LoginComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'setup',
|
||||
canActivate: [setupIncompleteGuard],
|
||||
loadComponent: () =>
|
||||
import('@features/auth/setup/setup.component').then(
|
||||
(m) => m.SetupComponent,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: 'dashboard' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { ThemeService } from '@core/services/theme.service';
|
||||
import { AuthService } from '@core/auth/auth.service';
|
||||
import { ToastContainerComponent, ConfirmDialogComponent } from '@ui';
|
||||
|
||||
@Component({
|
||||
@@ -13,7 +14,12 @@ import { ToastContainerComponent, ConfirmDialogComponent } from '@ui';
|
||||
<app-confirm-dialog />
|
||||
`,
|
||||
})
|
||||
export class App {
|
||||
export class App implements OnInit {
|
||||
// Inject ThemeService eagerly so it binds theme to DOM on startup
|
||||
private themeService = inject(ThemeService);
|
||||
private auth = inject(AuthService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.auth.checkStatus().subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
74
code/frontend/src/app/core/api/account.api.ts
Normal file
74
code/frontend/src/app/core/api/account.api.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface AccountInfo {
|
||||
username: string;
|
||||
plexLinked: boolean;
|
||||
plexUsername: string | null;
|
||||
twoFactorEnabled: boolean;
|
||||
apiKeyPreview: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface Regenerate2faRequest {
|
||||
password: string;
|
||||
totpCode: string;
|
||||
}
|
||||
|
||||
export interface TotpSetupResponse {
|
||||
secret: string;
|
||||
qrCodeUri: string;
|
||||
recoveryCodes: string[];
|
||||
}
|
||||
|
||||
export interface PlexPinResponse {
|
||||
pinId: number;
|
||||
authUrl: string;
|
||||
}
|
||||
|
||||
export interface PlexPinStatus {
|
||||
completed: boolean;
|
||||
plexUsername?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AccountApi {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getInfo(): Observable<AccountInfo> {
|
||||
return this.http.get<AccountInfo>('/api/account');
|
||||
}
|
||||
|
||||
changePassword(request: ChangePasswordRequest): Observable<void> {
|
||||
return this.http.put<void>('/api/account/password', request);
|
||||
}
|
||||
|
||||
regenerate2fa(request: Regenerate2faRequest): Observable<TotpSetupResponse> {
|
||||
return this.http.post<TotpSetupResponse>('/api/account/2fa/regenerate', request);
|
||||
}
|
||||
|
||||
getApiKey(): Observable<{ apiKey: string }> {
|
||||
return this.http.get<{ apiKey: string }>('/api/account/api-key');
|
||||
}
|
||||
|
||||
regenerateApiKey(): Observable<{ apiKey: string }> {
|
||||
return this.http.post<{ apiKey: string }>('/api/account/api-key/regenerate', {});
|
||||
}
|
||||
|
||||
linkPlex(): Observable<PlexPinResponse> {
|
||||
return this.http.post<PlexPinResponse>('/api/account/plex/link', {});
|
||||
}
|
||||
|
||||
verifyPlexLink(pinId: number): Observable<PlexPinStatus> {
|
||||
return this.http.post<PlexPinStatus>('/api/account/plex/link/verify', { pinId });
|
||||
}
|
||||
|
||||
unlinkPlex(): Observable<void> {
|
||||
return this.http.delete<void>('/api/account/plex/link');
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,56 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { filter, map, take } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
/**
|
||||
* Waits for the initial auth status check to complete,
|
||||
* then evaluates the guard condition.
|
||||
*/
|
||||
function waitForAuth(guardFn: (auth: AuthService, router: Router) => boolean | import('@angular/router').UrlTree) {
|
||||
const fn: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (auth.isAuthenticated()) {
|
||||
return true;
|
||||
// If already loaded, evaluate immediately
|
||||
if (!auth.isLoading()) {
|
||||
return guardFn(auth, router);
|
||||
}
|
||||
|
||||
// Wait for loading to complete
|
||||
return toObservable(auth.isLoading).pipe(
|
||||
filter((loading) => !loading),
|
||||
take(1),
|
||||
map(() => guardFn(auth, router)),
|
||||
);
|
||||
};
|
||||
return fn;
|
||||
}
|
||||
|
||||
export const authGuard: CanActivateFn = waitForAuth((auth, router) => {
|
||||
if (!auth.isSetupComplete()) {
|
||||
return router.createUrlTree(['/auth/setup']);
|
||||
}
|
||||
if (!auth.isAuthenticated()) {
|
||||
return router.createUrlTree(['/auth/login']);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return router.createUrlTree(['/auth/login']);
|
||||
};
|
||||
export const setupIncompleteGuard: CanActivateFn = waitForAuth((auth, router) => {
|
||||
if (auth.isSetupComplete()) {
|
||||
return router.createUrlTree(['/auth/login']);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export const loginGuard: CanActivateFn = waitForAuth((auth, router) => {
|
||||
if (!auth.isSetupComplete()) {
|
||||
return router.createUrlTree(['/auth/setup']);
|
||||
}
|
||||
if (auth.isAuthenticated()) {
|
||||
return router.createUrlTree(['/dashboard']);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1,7 +1,62 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { HttpInterceptorFn, HttpErrorResponse, HttpContextToken, HttpContext } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { catchError, switchMap, throwError } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
const IS_RETRY = new HttpContextToken<boolean>(() => false);
|
||||
|
||||
// Placeholder: no-op interceptor. When auth is implemented, this will
|
||||
// attach JWT/session tokens to outgoing requests and handle 401 responses.
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return next(req);
|
||||
const auth = inject(AuthService);
|
||||
|
||||
// Skip auth header for auth endpoints
|
||||
if (req.url.includes('/api/auth/')) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
// Pre-flight: if token is expired, refresh before sending the request
|
||||
if (auth.getAccessToken() && auth.isTokenExpired(30)) {
|
||||
return auth.refreshToken().pipe(
|
||||
switchMap((result) => {
|
||||
if (result) {
|
||||
const freshReq = req.clone({
|
||||
setHeaders: { Authorization: `Bearer ${result.accessToken}` },
|
||||
context: new HttpContext().set(IS_RETRY, true),
|
||||
});
|
||||
return next(freshReq);
|
||||
}
|
||||
auth.logout();
|
||||
return throwError(() => new HttpErrorResponse({ status: 401 }));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Normal path: token is valid
|
||||
const token = auth.getAccessToken();
|
||||
if (token) {
|
||||
req = req.clone({
|
||||
setHeaders: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
// Fallback: 401 catch for edge cases (e.g., token expired between check and send)
|
||||
if (error.status === 401 && token && !req.context.get(IS_RETRY)) {
|
||||
return auth.refreshToken().pipe(
|
||||
switchMap((result) => {
|
||||
if (result) {
|
||||
const retryReq = req.clone({
|
||||
setHeaders: { Authorization: `Bearer ${result.accessToken}` },
|
||||
context: new HttpContext().set(IS_RETRY, true),
|
||||
});
|
||||
return next(retryReq);
|
||||
}
|
||||
auth.logout();
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,35 +1,292 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, tap, of, catchError, finalize, shareReplay } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
export interface AuthStatus {
|
||||
setupCompleted: boolean;
|
||||
plexLinked: boolean;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
export interface LoginResponse {
|
||||
requiresTwoFactor: boolean;
|
||||
loginToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
export interface TokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface TotpSetupResponse {
|
||||
secret: string;
|
||||
qrCodeUri: string;
|
||||
recoveryCodes: string[];
|
||||
}
|
||||
|
||||
export interface PlexPinResponse {
|
||||
pinId: number;
|
||||
authUrl: string;
|
||||
}
|
||||
|
||||
export interface PlexVerifyResponse {
|
||||
completed: boolean;
|
||||
tokens?: TokenResponse;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly _isAuthenticated = signal(true);
|
||||
private readonly _user = signal<User | null>(null);
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
private readonly _isAuthenticated = signal(false);
|
||||
private readonly _isSetupComplete = signal(false);
|
||||
private readonly _plexLinked = signal(false);
|
||||
private readonly _isLoading = signal(true);
|
||||
|
||||
readonly isAuthenticated = this._isAuthenticated.asReadonly();
|
||||
readonly user = this._user.asReadonly();
|
||||
readonly isSetupComplete = this._isSetupComplete.asReadonly();
|
||||
readonly plexLinked = this._plexLinked.asReadonly();
|
||||
readonly isLoading = this._isLoading.asReadonly();
|
||||
|
||||
login(_credentials: LoginCredentials): Observable<AuthResult> {
|
||||
// Placeholder: always succeeds. Implement real auth later.
|
||||
return of({ success: true });
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private refreshInFlight$: Observable<TokenResponse | null> | null = null;
|
||||
private visibilityHandler: (() => void) | null = null;
|
||||
|
||||
checkStatus(): Observable<AuthStatus> {
|
||||
return this.http.get<AuthStatus>('/api/auth/status').pipe(
|
||||
tap((status) => {
|
||||
this._isSetupComplete.set(status.setupCompleted);
|
||||
this._plexLinked.set(status.plexLinked);
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token && status.setupCompleted) {
|
||||
if (this.isTokenExpired(60)) {
|
||||
// Access token expired — try to refresh before marking as authenticated
|
||||
this.refreshToken().subscribe((result) => {
|
||||
if (result) {
|
||||
this._isAuthenticated.set(true);
|
||||
this.setupVisibilityListener();
|
||||
} else {
|
||||
this._isAuthenticated.set(false);
|
||||
this.router.navigate(['/auth/login']);
|
||||
}
|
||||
this._isLoading.set(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._isAuthenticated.set(true);
|
||||
this.scheduleRefresh();
|
||||
this.setupVisibilityListener();
|
||||
}
|
||||
|
||||
this._isLoading.set(false);
|
||||
}),
|
||||
catchError(() => {
|
||||
this._isLoading.set(false);
|
||||
return of({ setupCompleted: false, plexLinked: false });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Setup flow
|
||||
createAccount(username: string, password: string): Observable<{ userId: string }> {
|
||||
return this.http.post<{ userId: string }>('/api/auth/setup/account', { username, password });
|
||||
}
|
||||
|
||||
generateTotpSetup(): Observable<TotpSetupResponse> {
|
||||
return this.http.post<TotpSetupResponse>('/api/auth/setup/2fa/generate', {});
|
||||
}
|
||||
|
||||
verifyTotpSetup(code: string): Observable<{ message: string }> {
|
||||
return this.http.post<{ message: string }>('/api/auth/setup/2fa/verify', { code });
|
||||
}
|
||||
|
||||
completeSetup(): Observable<{ message: string }> {
|
||||
return this.http.post<{ message: string }>('/api/auth/setup/complete', {}).pipe(
|
||||
tap(() => this._isSetupComplete.set(true)),
|
||||
);
|
||||
}
|
||||
|
||||
// Login flow
|
||||
login(username: string, password: string): Observable<LoginResponse> {
|
||||
return this.http.post<LoginResponse>('/api/auth/login', { username, password });
|
||||
}
|
||||
|
||||
verify2fa(loginToken: string, code: string, isRecoveryCode = false): Observable<TokenResponse> {
|
||||
return this.http
|
||||
.post<TokenResponse>('/api/auth/login/2fa', { loginToken, code, isRecoveryCode })
|
||||
.pipe(tap((tokens) => this.handleTokens(tokens)));
|
||||
}
|
||||
|
||||
// Setup Plex linking
|
||||
requestSetupPlexPin(): Observable<PlexPinResponse> {
|
||||
return this.http.post<PlexPinResponse>('/api/auth/setup/plex/pin', {});
|
||||
}
|
||||
|
||||
verifySetupPlexPin(pinId: number): Observable<PlexVerifyResponse> {
|
||||
return this.http.post<PlexVerifyResponse>('/api/auth/setup/plex/verify', { pinId });
|
||||
}
|
||||
|
||||
// Plex login
|
||||
requestPlexPin(): Observable<PlexPinResponse> {
|
||||
return this.http.post<PlexPinResponse>('/api/auth/login/plex/pin', {});
|
||||
}
|
||||
|
||||
verifyPlexPin(pinId: number): Observable<PlexVerifyResponse> {
|
||||
return this.http.post<PlexVerifyResponse>('/api/auth/login/plex/verify', { pinId }).pipe(
|
||||
tap((result) => {
|
||||
if (result.completed && result.tokens) {
|
||||
this.handleTokens(result.tokens);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Token management
|
||||
refreshToken(): Observable<TokenResponse | null> {
|
||||
// Deduplicate: if a refresh is already in-flight, share the same observable
|
||||
if (this.refreshInFlight$) {
|
||||
return this.refreshInFlight$;
|
||||
}
|
||||
|
||||
const storedRefreshToken = localStorage.getItem('refresh_token');
|
||||
if (!storedRefreshToken) {
|
||||
this.clearAuth();
|
||||
return of(null);
|
||||
}
|
||||
|
||||
this.refreshInFlight$ = this.http
|
||||
.post<TokenResponse>('/api/auth/refresh', { refreshToken: storedRefreshToken })
|
||||
.pipe(
|
||||
tap((tokens) => this.handleTokens(tokens)),
|
||||
catchError(() => {
|
||||
this.clearAuth();
|
||||
return of(null);
|
||||
}),
|
||||
finalize(() => {
|
||||
this.refreshInFlight$ = null;
|
||||
}),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
return this.refreshInFlight$;
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
// Placeholder: no-op. Implement real logout later.
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (refreshToken) {
|
||||
this.http.post('/api/auth/logout', { refreshToken }).subscribe();
|
||||
}
|
||||
this.clearAuth();
|
||||
this.router.navigate(['/auth/login']);
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
/** Returns true if the access token is expired or will expire within the buffer period. */
|
||||
isTokenExpired(bufferSeconds = 30): boolean {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return true;
|
||||
|
||||
const exp = this.getTokenExpiry(token);
|
||||
if (exp === null) return true;
|
||||
|
||||
return Date.now() / 1000 >= exp - bufferSeconds;
|
||||
}
|
||||
|
||||
private handleTokens(tokens: TokenResponse): void {
|
||||
localStorage.setItem('access_token', tokens.accessToken);
|
||||
localStorage.setItem('refresh_token', tokens.refreshToken);
|
||||
this._isAuthenticated.set(true);
|
||||
this.scheduleRefresh();
|
||||
this.setupVisibilityListener();
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
|
||||
// Always derive from the JWT's actual exp claim — never trust ExpiresIn from response
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
|
||||
const exp = this.getTokenExpiry(token);
|
||||
if (exp === null) return;
|
||||
|
||||
const remainingSec = exp - Date.now() / 1000;
|
||||
if (remainingSec <= 30) {
|
||||
this.refreshToken().subscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh at 80% of remaining lifetime
|
||||
const refreshMs = remainingSec * 800;
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshToken().subscribe();
|
||||
}, refreshMs);
|
||||
}
|
||||
|
||||
private clearAuth(): void {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
this._isAuthenticated.set(false);
|
||||
this.refreshInFlight$ = null;
|
||||
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
this.teardownVisibilityListener();
|
||||
}
|
||||
|
||||
/** Extracts the exp claim (seconds since epoch) from a JWT. */
|
||||
private getTokenExpiry(token: string): number | null {
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
if (!payload) return null;
|
||||
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const parsed = JSON.parse(decoded);
|
||||
return typeof parsed.exp === 'number' ? parsed.exp : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private setupVisibilityListener(): void {
|
||||
if (this.visibilityHandler) return;
|
||||
|
||||
this.visibilityHandler = () => {
|
||||
if (document.visibilityState !== 'visible' || !this._isAuthenticated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isTokenExpired(60)) {
|
||||
// Token expired during sleep — refresh immediately
|
||||
this.refreshToken().subscribe((result) => {
|
||||
if (!result) {
|
||||
this.router.navigate(['/auth/login']);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Token still valid — reschedule timer (old setTimeout was frozen during sleep)
|
||||
this.scheduleRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', this.visibilityHandler);
|
||||
}
|
||||
|
||||
private teardownVisibilityListener(): void {
|
||||
if (this.visibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
||||
this.visibilityHandler = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
|
||||
export class ApiError extends Error {
|
||||
retryAfterSeconds?: number;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
@@ -17,7 +22,10 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
?? `Error ${error.status}`;
|
||||
}
|
||||
|
||||
return throwError(() => new Error(message));
|
||||
const apiError = new ApiError(message);
|
||||
apiError.retryAfterSeconds = error.error?.retryAfterSeconds;
|
||||
apiError.statusCode = error.status;
|
||||
return throwError(() => apiError);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Injectable, inject, signal, OnDestroy } from '@angular/core';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { SignalRHubConfig } from '@core/models/signalr.models';
|
||||
import { ApplicationPathService } from '@core/services/base-path.service';
|
||||
import { AuthService } from '@core/auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export abstract class HubService implements OnDestroy {
|
||||
private readonly pathService = inject(ApplicationPathService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private connection: signalR.HubConnection | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -24,7 +27,18 @@ export abstract class HubService implements OnDestroy {
|
||||
const hubUrl = this.pathService.buildHubUrl(this.config.hubUrl);
|
||||
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(hubUrl)
|
||||
.withUrl(hubUrl, {
|
||||
accessTokenFactory: async () => {
|
||||
if (this.authService.isTokenExpired(30)) {
|
||||
const result = await firstValueFrom(this.authService.refreshToken());
|
||||
if (result) {
|
||||
return result.accessToken;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return this.authService.getAccessToken() ?? '';
|
||||
},
|
||||
})
|
||||
.withAutomaticReconnect({
|
||||
nextRetryDelayInMilliseconds: (retryContext) => {
|
||||
return Math.min(
|
||||
|
||||
@@ -1,18 +1,130 @@
|
||||
<h1 class="login-title">Welcome to Cleanuparr</h1>
|
||||
<p class="login-subtitle">Sign in to continue</p>
|
||||
<h2 class="login-title">Sign In</h2>
|
||||
|
||||
<form class="login-form">
|
||||
<app-input
|
||||
label="Username"
|
||||
placeholder="Enter your username"
|
||||
type="text"
|
||||
/>
|
||||
<app-input
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
type="password"
|
||||
/>
|
||||
<app-button variant="primary" class="login-submit">
|
||||
Sign In
|
||||
</app-button>
|
||||
</form>
|
||||
@if (error()) {
|
||||
<div class="error-message">{{ error() }}</div>
|
||||
}
|
||||
|
||||
@if (retryCountdown() > 0) {
|
||||
<div class="retry-countdown">Try again in {{ retryCountdown() }}s</div>
|
||||
}
|
||||
|
||||
<!-- Credentials view -->
|
||||
@if (view() === 'credentials') {
|
||||
<div class="login-view">
|
||||
<form class="login-form" (ngSubmit)="submitLogin()">
|
||||
<app-input
|
||||
#usernameInput
|
||||
label="Username"
|
||||
placeholder="Enter your username"
|
||||
type="text"
|
||||
[value]="username()"
|
||||
(valueChange)="username.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
type="password"
|
||||
[value]="password()"
|
||||
(valueChange)="password.set($event)"
|
||||
/>
|
||||
<app-button
|
||||
variant="primary"
|
||||
class="login-submit"
|
||||
[disabled]="!username() || !password() || loading() || retryCountdown() > 0"
|
||||
type="submit"
|
||||
>
|
||||
@if (loading()) {
|
||||
<app-spinner size="sm" />
|
||||
} @else {
|
||||
Sign In
|
||||
}
|
||||
</app-button>
|
||||
</form>
|
||||
|
||||
@if (plexLinked()) {
|
||||
<div class="divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
<button
|
||||
class="plex-login-btn"
|
||||
[disabled]="plexLoading()"
|
||||
(click)="startPlexLogin()"
|
||||
>
|
||||
@if (plexLoading()) {
|
||||
<app-spinner size="sm" />
|
||||
Waiting for Plex...
|
||||
} @else {
|
||||
<svg class="plex-login-btn__icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.074 1L12 12 5.074 23h5.854L18.926 12 11.928 1z"/>
|
||||
</svg>
|
||||
Sign in with Plex
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 2FA view -->
|
||||
@if (view() === '2fa') {
|
||||
<div class="login-view">
|
||||
<form class="login-form" (ngSubmit)="submit2fa()">
|
||||
<app-input
|
||||
#totpInput
|
||||
label="Authentication Code"
|
||||
placeholder="Enter 6-digit code"
|
||||
type="text"
|
||||
[value]="totpCode()"
|
||||
(valueChange)="totpCode.set($event)"
|
||||
/>
|
||||
<app-button
|
||||
variant="primary"
|
||||
class="login-submit"
|
||||
[disabled]="totpCode().length !== 6 || loading()"
|
||||
type="submit"
|
||||
>
|
||||
@if (loading()) {
|
||||
<app-spinner size="sm" />
|
||||
} @else {
|
||||
Verify
|
||||
}
|
||||
</app-button>
|
||||
</form>
|
||||
|
||||
<div class="login-links">
|
||||
<button class="text-link" (click)="useRecoveryCode()">Use a recovery code</button>
|
||||
<button class="text-link" (click)="backToCredentials()">Back to sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Recovery code view -->
|
||||
@if (view() === 'recovery') {
|
||||
<div class="login-view">
|
||||
<form class="login-form" (ngSubmit)="submitRecoveryCode()">
|
||||
<app-input
|
||||
#recoveryInput
|
||||
label="Recovery Code"
|
||||
placeholder="XXXX-XXXX"
|
||||
type="text"
|
||||
[value]="recoveryCode()"
|
||||
(valueChange)="recoveryCode.set($event)"
|
||||
/>
|
||||
<app-button
|
||||
variant="primary"
|
||||
class="login-submit"
|
||||
[disabled]="!recoveryCode() || loading()"
|
||||
type="submit"
|
||||
>
|
||||
@if (loading()) {
|
||||
<app-spinner size="sm" />
|
||||
} @else {
|
||||
Verify Recovery Code
|
||||
}
|
||||
</app-button>
|
||||
</form>
|
||||
|
||||
<div class="login-links">
|
||||
<button class="text-link" (click)="backTo2fa()">Back to authenticator code</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,16 +1,51 @@
|
||||
.login-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2);
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
.login-view {
|
||||
animation: fadeSlideIn var(--duration-normal) var(--ease-default);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-view {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--color-error);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.retry-countdown {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
color: var(--color-warning);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-bottom: var(--space-4);
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
@@ -21,5 +56,87 @@
|
||||
|
||||
.login-submit {
|
||||
margin-top: var(--space-2);
|
||||
width: 100%;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-6) 0;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
}
|
||||
|
||||
.plex-login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 var(--space-4);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
background: #e5a00d;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #d4920c;
|
||||
box-shadow: 0 0 20px rgba(229, 160, 13, 0.3);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.text-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,214 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ButtonComponent, InputComponent } from '@ui';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, viewChild, effect, afterNextRender, OnInit, OnDestroy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ButtonComponent, InputComponent, SpinnerComponent } from '@ui';
|
||||
import { AuthService } from '@core/auth/auth.service';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
|
||||
type LoginView = 'credentials' | '2fa' | 'recovery';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [ButtonComponent, InputComponent],
|
||||
imports: [FormsModule, ButtonComponent, InputComponent, SpinnerComponent],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrl: './login.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LoginComponent {}
|
||||
export class LoginComponent implements OnInit, OnDestroy {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
view = signal<LoginView>('credentials');
|
||||
loading = signal(false);
|
||||
error = signal('');
|
||||
|
||||
// Credentials
|
||||
username = signal('');
|
||||
password = signal('');
|
||||
|
||||
// 2FA
|
||||
loginToken = signal('');
|
||||
totpCode = signal('');
|
||||
recoveryCode = signal('');
|
||||
|
||||
// Retry countdown
|
||||
retryCountdown = signal(0);
|
||||
private countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Plex
|
||||
plexLinked = this.auth.plexLinked;
|
||||
plexLoading = signal(false);
|
||||
plexPinId = signal(0);
|
||||
|
||||
// Auto-focus refs
|
||||
usernameInput = viewChild<InputComponent>('usernameInput');
|
||||
totpInput = viewChild<InputComponent>('totpInput');
|
||||
recoveryInput = viewChild<InputComponent>('recoveryInput');
|
||||
|
||||
constructor() {
|
||||
// Auto-focus username input on initial render
|
||||
afterNextRender(() => {
|
||||
this.usernameInput()?.focus();
|
||||
});
|
||||
|
||||
// Auto-focus on view change
|
||||
effect(() => {
|
||||
const currentView = this.view();
|
||||
if (currentView === '2fa') {
|
||||
setTimeout(() => this.totpInput()?.focus());
|
||||
} else if (currentView === 'recovery') {
|
||||
setTimeout(() => this.recoveryInput()?.focus());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.auth.checkStatus().subscribe();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearCountdown();
|
||||
if (this.plexPollTimer) {
|
||||
clearInterval(this.plexPollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
submitLogin(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.login(this.username(), this.password()).subscribe({
|
||||
next: (result) => {
|
||||
if (result.requiresTwoFactor && result.loginToken) {
|
||||
this.loginToken.set(result.loginToken);
|
||||
this.view.set('2fa');
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Invalid credentials');
|
||||
this.loading.set(false);
|
||||
|
||||
const retryAfter = (err as ApiError).retryAfterSeconds;
|
||||
if (retryAfter && retryAfter > 0) {
|
||||
this.startCountdown(retryAfter);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submit2fa(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.verify2fa(this.loginToken(), this.totpCode()).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Invalid code');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submitRecoveryCode(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.verify2fa(this.loginToken(), this.recoveryCode(), true).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Invalid recovery code');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useRecoveryCode(): void {
|
||||
this.view.set('recovery');
|
||||
this.error.set('');
|
||||
}
|
||||
|
||||
backTo2fa(): void {
|
||||
this.view.set('2fa');
|
||||
this.error.set('');
|
||||
}
|
||||
|
||||
backToCredentials(): void {
|
||||
this.view.set('credentials');
|
||||
this.error.set('');
|
||||
this.loginToken.set('');
|
||||
}
|
||||
|
||||
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
startPlexLogin(): void {
|
||||
this.plexLoading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.requestPlexPin().subscribe({
|
||||
next: (result) => {
|
||||
this.plexPinId.set(result.pinId);
|
||||
window.open(result.authUrl, '_blank');
|
||||
this.pollPlexPin();
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to start Plex login');
|
||||
this.plexLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pollPlexPin(): void {
|
||||
let attempts = 0;
|
||||
this.plexPollTimer = setInterval(() => {
|
||||
attempts++;
|
||||
if (attempts > 60) {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLoading.set(false);
|
||||
this.error.set('Plex authorization timed out');
|
||||
return;
|
||||
}
|
||||
|
||||
this.auth.verifyPlexPin(this.plexPinId()).subscribe({
|
||||
next: (result) => {
|
||||
if (result.completed) {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLoading.set(false);
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLoading.set(false);
|
||||
this.error.set(err.message || 'Plex authorization failed');
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private startCountdown(seconds: number): void {
|
||||
this.clearCountdown();
|
||||
this.retryCountdown.set(seconds);
|
||||
this.countdownTimer = setInterval(() => {
|
||||
const current = this.retryCountdown();
|
||||
if (current <= 1) {
|
||||
this.clearCountdown();
|
||||
} else {
|
||||
this.retryCountdown.set(current - 1);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private clearCountdown(): void {
|
||||
this.retryCountdown.set(0);
|
||||
if (this.countdownTimer) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
236
code/frontend/src/app/features/auth/setup/setup.component.html
Normal file
236
code/frontend/src/app/features/auth/setup/setup.component.html
Normal file
@@ -0,0 +1,236 @@
|
||||
<h2 class="setup-title">Initial Setup</h2>
|
||||
|
||||
<div class="steps-indicator">
|
||||
<div class="step-group" [class.active]="currentStep() === 1" [class.completed]="currentStep() > 1">
|
||||
<div class="step-marker">
|
||||
@if (currentStep() > 1) {
|
||||
<ng-icon name="tablerCheck" size="14" />
|
||||
} @else {
|
||||
1
|
||||
}
|
||||
</div>
|
||||
<span class="step-label">Account</span>
|
||||
</div>
|
||||
<div class="step-connector" [class.active]="currentStep() > 1"></div>
|
||||
<div class="step-group" [class.active]="currentStep() === 2" [class.completed]="currentStep() > 2">
|
||||
<div class="step-marker">
|
||||
@if (currentStep() > 2) {
|
||||
<ng-icon name="tablerCheck" size="14" />
|
||||
} @else {
|
||||
2
|
||||
}
|
||||
</div>
|
||||
<span class="step-label">2FA</span>
|
||||
</div>
|
||||
<div class="step-connector" [class.active]="currentStep() > 2"></div>
|
||||
<div class="step-group" [class.active]="currentStep() === 3" [class.completed]="currentStep() > 3">
|
||||
<div class="step-marker">3</div>
|
||||
<span class="step-label">Plex</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="error-message">{{ error() }}</div>
|
||||
}
|
||||
|
||||
<!-- Step 1: Create Account -->
|
||||
@if (currentStep() === 1) {
|
||||
<div class="step-content">
|
||||
<h2 class="step-title">Create Admin Account</h2>
|
||||
<p class="step-subtitle">Choose a username and strong password</p>
|
||||
|
||||
<form class="setup-form" (ngSubmit)="createAccount()">
|
||||
<app-input
|
||||
#usernameInput
|
||||
label="Username"
|
||||
placeholder="Enter a username"
|
||||
type="text"
|
||||
[value]="username()"
|
||||
(valueChange)="username.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="Password"
|
||||
placeholder="Minimum 8 characters"
|
||||
type="password"
|
||||
[value]="password()"
|
||||
(valueChange)="password.set($event)"
|
||||
/>
|
||||
|
||||
@if (password()) {
|
||||
<div class="password-strength">
|
||||
<div class="password-strength__bar">
|
||||
<div class="password-strength__fill password-strength__fill--{{ passwordStrength() }}"></div>
|
||||
</div>
|
||||
<span class="password-strength__label password-strength__label--{{ passwordStrength() }}">{{ passwordStrength() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-input
|
||||
label="Confirm Password"
|
||||
placeholder="Re-enter your password"
|
||||
type="password"
|
||||
[value]="confirmPassword()"
|
||||
(valueChange)="confirmPassword.set($event)"
|
||||
/>
|
||||
|
||||
@if (password() && !passwordValid) {
|
||||
<p class="field-error">Password must be at least 8 characters</p>
|
||||
}
|
||||
@if (confirmPassword() && !passwordsMatch) {
|
||||
<p class="field-error">Passwords do not match</p>
|
||||
}
|
||||
|
||||
<app-button
|
||||
variant="primary"
|
||||
class="submit-btn"
|
||||
[disabled]="!username() || !passwordValid || !passwordsMatch || loading()"
|
||||
type="submit"
|
||||
>
|
||||
@if (loading()) {
|
||||
<app-spinner size="sm" />
|
||||
} @else {
|
||||
Continue
|
||||
}
|
||||
</app-button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 2: Two-Factor Authentication -->
|
||||
@if (currentStep() === 2) {
|
||||
<div class="step-content">
|
||||
<h2 class="step-title">
|
||||
<ng-icon name="tablerShieldLock" size="20" />
|
||||
Two-Factor Authentication
|
||||
</h2>
|
||||
|
||||
@if (!totpVerified()) {
|
||||
<p class="step-subtitle">Scan the QR code with your authenticator app</p>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-center">
|
||||
<app-spinner />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="qr-section">
|
||||
<div class="qr-code-wrapper">
|
||||
<qrcode [qrdata]="qrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
|
||||
</div>
|
||||
|
||||
<details class="qr-manual-entry">
|
||||
<summary>Can't scan? Enter manually</summary>
|
||||
<div class="qr-manual-content">
|
||||
<p class="qr-manual-label">Secret key:</p>
|
||||
<code class="qr-secret">{{ totpSecret() }}</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<form class="setup-form" (ngSubmit)="verifyTotp()">
|
||||
<app-input
|
||||
#verificationInput
|
||||
label="Verification Code"
|
||||
placeholder="Enter 6-digit code"
|
||||
type="text"
|
||||
[value]="verificationCode()"
|
||||
(valueChange)="verificationCode.set($event)"
|
||||
/>
|
||||
<app-button
|
||||
variant="primary"
|
||||
class="submit-btn"
|
||||
[disabled]="verificationCode().length !== 6"
|
||||
type="submit"
|
||||
>
|
||||
Verify Code
|
||||
</app-button>
|
||||
</form>
|
||||
}
|
||||
} @else {
|
||||
<p class="step-subtitle success-text">
|
||||
<ng-icon name="tablerCheck" size="16" />
|
||||
2FA verified successfully!
|
||||
</p>
|
||||
|
||||
<div class="recovery-section">
|
||||
<h3 class="recovery-title">Save Your Recovery Codes</h3>
|
||||
<p class="recovery-desc">Store these codes in a safe place. Each code can only be used once.</p>
|
||||
|
||||
<div class="recovery-codes">
|
||||
@for (code of recoveryCodes(); track code) {
|
||||
<code class="recovery-code">{{ code }}</code>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="recovery-actions">
|
||||
<app-button variant="secondary" (click)="copyRecoveryCodes()">
|
||||
<ng-icon name="tablerCopy" size="16" />
|
||||
Copy Codes
|
||||
</app-button>
|
||||
<app-button variant="secondary" (click)="downloadRecoveryCodes()">
|
||||
Download
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" [checked]="codesSaved()" (change)="codesSaved.set(!codesSaved())" />
|
||||
I have saved my recovery codes
|
||||
</label>
|
||||
|
||||
<app-button
|
||||
variant="primary"
|
||||
class="submit-btn"
|
||||
[disabled]="!codesSaved()"
|
||||
(click)="goToStep3()"
|
||||
>
|
||||
Continue
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 3: Plex (Optional) -->
|
||||
@if (currentStep() === 3) {
|
||||
<div class="step-content">
|
||||
<h2 class="step-title">Link Plex Account</h2>
|
||||
<p class="step-subtitle">Optional: Link your Plex account for quick sign-in</p>
|
||||
|
||||
@if (!plexLinked()) {
|
||||
<button
|
||||
class="plex-btn"
|
||||
[disabled]="plexLinking()"
|
||||
(click)="startPlexLink()"
|
||||
>
|
||||
@if (plexLinking()) {
|
||||
<app-spinner size="sm" />
|
||||
Waiting for Plex authorization...
|
||||
} @else {
|
||||
<svg class="plex-btn__icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.074 1L12 12 5.074 23h5.854L18.926 12 11.928 1z"/>
|
||||
</svg>
|
||||
Link Plex Account
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<div class="plex-success">
|
||||
<ng-icon name="tablerCheck" size="16" />
|
||||
Plex account linked
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="step-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
class="submit-btn"
|
||||
[disabled]="loading()"
|
||||
(click)="completeSetup()"
|
||||
>
|
||||
@if (loading()) {
|
||||
<app-spinner size="sm" />
|
||||
} @else {
|
||||
Complete Setup
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
374
code/frontend/src/app/features/auth/setup/setup.component.scss
Normal file
374
code/frontend/src/app/features/auth/setup/setup.component.scss
Normal file
@@ -0,0 +1,374 @@
|
||||
// Animations
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
// Timeline indicator
|
||||
.steps-indicator {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.step-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.step-marker {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
color: var(--text-tertiary);
|
||||
transition: all var(--duration-normal) var(--ease-default);
|
||||
|
||||
.step-group.active & {
|
||||
background: rgba(126, 87, 194, 0.15);
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 0 8px rgba(126, 87, 194, 0.25);
|
||||
}
|
||||
|
||||
.step-group.completed & {
|
||||
background: var(--color-primary);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 0 12px rgba(126, 87, 194, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
transition: color var(--duration-normal) var(--ease-default);
|
||||
|
||||
.step-group.active & {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.step-group.completed & {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
width: 48px;
|
||||
height: 2px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
margin-top: 13px; // center vertically with 28px marker
|
||||
transition: background var(--duration-normal) var(--ease-default);
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// Step content
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: fadeSlideIn var(--duration-normal) var(--ease-default);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.step-content {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--color-success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--color-error);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.setup-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-error);
|
||||
margin-top: calc(-1 * var(--space-2));
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
margin-top: var(--space-2);
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.loading-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) 0;
|
||||
}
|
||||
|
||||
// Password strength indicator
|
||||
.password-strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: calc(-1 * var(--space-2));
|
||||
|
||||
&__bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
transition: width var(--duration-normal) var(--ease-default),
|
||||
background-color var(--duration-normal) var(--ease-default);
|
||||
|
||||
&--weak {
|
||||
width: 33%;
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
&--medium {
|
||||
width: 66%;
|
||||
background-color: var(--color-warning);
|
||||
}
|
||||
|
||||
&--strong {
|
||||
width: 100%;
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: capitalize;
|
||||
min-width: 52px;
|
||||
|
||||
&--weak {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&--medium {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
&--strong {
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// QR code section
|
||||
.qr-section {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.qr-code-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
qrcode {
|
||||
background: #ffffff;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-manual-entry {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr-manual-content {
|
||||
background: var(--surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-manual-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.qr-secret {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
// Recovery codes
|
||||
.recovery-section {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.recovery-title {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.recovery-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.recovery-codes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.recovery-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-secondary);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recovery-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
input[type='checkbox'] {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// Plex section
|
||||
.plex-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 var(--space-4);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
background: #e5a00d;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #d4920c;
|
||||
box-shadow: 0 0 20px rgba(229, 160, 13, 0.3);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.plex-btn__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.plex-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-success);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
233
code/frontend/src/app/features/auth/setup/setup.component.ts
Normal file
233
code/frontend/src/app/features/auth/setup/setup.component.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, effect, afterNextRender, OnDestroy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ButtonComponent, InputComponent, SpinnerComponent } from '@ui';
|
||||
import { AuthService } from '@core/auth/auth.service';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { tablerCheck, tablerCopy, tablerShieldLock } from '@ng-icons/tabler-icons';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-setup',
|
||||
standalone: true,
|
||||
imports: [FormsModule, ButtonComponent, InputComponent, SpinnerComponent, NgIconComponent, QRCodeComponent],
|
||||
templateUrl: './setup.component.html',
|
||||
styleUrl: './setup.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
viewProviders: [provideIcons({ tablerCheck, tablerCopy, tablerShieldLock })],
|
||||
})
|
||||
export class SetupComponent implements OnDestroy {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
currentStep = signal(1);
|
||||
loading = signal(false);
|
||||
error = signal('');
|
||||
|
||||
// Step 1 - Account
|
||||
username = signal('');
|
||||
password = signal('');
|
||||
confirmPassword = signal('');
|
||||
|
||||
// Step 2 - 2FA
|
||||
totpSecret = signal('');
|
||||
qrCodeUri = signal('');
|
||||
recoveryCodes = signal<string[]>([]);
|
||||
verificationCode = signal('');
|
||||
totpVerified = signal(false);
|
||||
codesSaved = signal(false);
|
||||
|
||||
// Step 3 - Plex
|
||||
plexLinking = signal(false);
|
||||
plexLinked = signal(false);
|
||||
plexUsername = signal('');
|
||||
plexPinId = signal(0);
|
||||
|
||||
// Auto-focus refs
|
||||
usernameInput = viewChild<InputComponent>('usernameInput');
|
||||
verificationInput = viewChild<InputComponent>('verificationInput');
|
||||
|
||||
// Password strength
|
||||
passwordStrength = computed(() => {
|
||||
const pw = this.password();
|
||||
if (!pw) return null;
|
||||
if (pw.length < 8) return 'weak';
|
||||
const hasUpper = /[A-Z]/.test(pw);
|
||||
const hasLower = /[a-z]/.test(pw);
|
||||
const hasNumber = /[0-9]/.test(pw);
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(pw);
|
||||
const score = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
|
||||
if (pw.length >= 12 && score >= 3) return 'strong';
|
||||
if (pw.length >= 8 && score >= 2) return 'medium';
|
||||
return 'weak';
|
||||
});
|
||||
|
||||
get passwordsMatch(): boolean {
|
||||
return this.password() === this.confirmPassword();
|
||||
}
|
||||
|
||||
get passwordValid(): boolean {
|
||||
return this.password().length >= 8;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Auto-focus username input on initial render
|
||||
afterNextRender(() => {
|
||||
this.usernameInput()?.focus();
|
||||
});
|
||||
|
||||
// Auto-focus verification input when entering step 2
|
||||
effect(() => {
|
||||
const step = this.currentStep();
|
||||
if (step === 2) {
|
||||
setTimeout(() => this.verificationInput()?.focus());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Step 1: Create account
|
||||
createAccount(): void {
|
||||
if (!this.passwordsMatch || !this.passwordValid) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.createAccount(this.username(), this.password()).subscribe({
|
||||
next: () => {
|
||||
this.currentStep.set(2);
|
||||
this.generateTotp();
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to create account');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Generate TOTP
|
||||
private generateTotp(): void {
|
||||
this.loading.set(true);
|
||||
this.auth.generateTotpSetup().subscribe({
|
||||
next: (result) => {
|
||||
this.totpSecret.set(result.secret);
|
||||
this.qrCodeUri.set(result.qrCodeUri);
|
||||
this.recoveryCodes.set(result.recoveryCodes);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to generate 2FA');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
verifyTotp(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.verifyTotpSetup(this.verificationCode()).subscribe({
|
||||
next: () => {
|
||||
this.totpVerified.set(true);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Invalid code');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goToStep3(): void {
|
||||
this.currentStep.set(3);
|
||||
this.error.set('');
|
||||
}
|
||||
|
||||
copyRecoveryCodes(): void {
|
||||
const text = this.recoveryCodes().join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
this.toast.success('Recovery codes copied to clipboard');
|
||||
}
|
||||
|
||||
downloadRecoveryCodes(): void {
|
||||
const text = this.recoveryCodes().join('\n');
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'cleanuparr-recovery-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Step 3: Plex linking
|
||||
startPlexLink(): void {
|
||||
this.plexLinking.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.requestSetupPlexPin().subscribe({
|
||||
next: (result) => {
|
||||
this.plexPinId.set(result.pinId);
|
||||
window.open(result.authUrl, '_blank');
|
||||
this.pollPlexPin();
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to start Plex link');
|
||||
this.plexLinking.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.plexPollTimer) {
|
||||
clearInterval(this.plexPollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
private pollPlexPin(): void {
|
||||
let attempts = 0;
|
||||
this.plexPollTimer = setInterval(() => {
|
||||
attempts++;
|
||||
if (attempts > 60) {
|
||||
// Timeout after ~2 minutes
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinking.set(false);
|
||||
this.error.set('Plex authorization timed out');
|
||||
return;
|
||||
}
|
||||
|
||||
this.auth.verifySetupPlexPin(this.plexPinId()).subscribe({
|
||||
next: (result) => {
|
||||
if (result.completed) {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinked.set(true);
|
||||
this.plexLinking.set(false);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinking.set(false);
|
||||
this.error.set(err.message || 'Plex linking failed');
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
completeSetup(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.completeSetup().subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/auth/login']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to complete setup');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<app-page-header
|
||||
title="Account Settings"
|
||||
subtitle="Manage your account, security, and integrations"
|
||||
/>
|
||||
|
||||
@if (loadError()) {
|
||||
<app-empty-state
|
||||
icon="tablerPlugConnectedX"
|
||||
heading="Could not connect to server"
|
||||
description="Failed to load account settings. Please check that the server is running and try again."
|
||||
>
|
||||
<app-button variant="primary" size="sm" (clicked)="retry()">
|
||||
Retry
|
||||
</app-button>
|
||||
</app-empty-state>
|
||||
} @else if (loader.showSpinner()) {
|
||||
<app-loading-state message="Loading account settings..." />
|
||||
} @else if (!loader.loading() && account()) {
|
||||
<div class="settings-form">
|
||||
<!-- Change Password -->
|
||||
<app-card header="Change Password">
|
||||
<div class="form-stack">
|
||||
<app-input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
[value]="currentPassword()"
|
||||
(valueChange)="currentPassword.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Enter new password (min 8 characters)"
|
||||
[value]="newPassword()"
|
||||
(valueChange)="newPassword.set($event)"
|
||||
/>
|
||||
|
||||
@if (newPassword()) {
|
||||
<div class="password-strength">
|
||||
<div class="password-strength__bar">
|
||||
<div class="password-strength__fill password-strength__fill--{{ newPasswordStrength() }}"></div>
|
||||
</div>
|
||||
<span class="password-strength__label password-strength__label--{{ newPasswordStrength() }}">{{ newPasswordStrength() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
[value]="confirmPassword()"
|
||||
(valueChange)="confirmPassword.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[glowing]="!!currentPassword() && !!newPassword() && !!confirmPassword()"
|
||||
[disabled]="!currentPassword() || !newPassword() || !confirmPassword() || changingPassword()"
|
||||
(clicked)="changePassword()"
|
||||
>
|
||||
@if (changingPassword()) {
|
||||
<app-spinner size="sm" /> Changing...
|
||||
} @else {
|
||||
Change Password
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- Two-Factor Authentication -->
|
||||
<app-card header="Two-Factor Authentication">
|
||||
<div class="form-stack">
|
||||
<div class="status-row">
|
||||
<span class="status-label">Status</span>
|
||||
<span class="status-value status-value--active">Active</span>
|
||||
</div>
|
||||
|
||||
@if (newRecoveryCodes().length > 0) {
|
||||
<div class="recovery-section">
|
||||
<p class="recovery-title">New Authenticator Setup</p>
|
||||
<p class="recovery-desc">Scan this QR code with your authenticator app to complete the setup.</p>
|
||||
<div class="qr-section">
|
||||
<div class="qr-code-wrapper">
|
||||
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
|
||||
</div>
|
||||
<details class="qr-manual-entry">
|
||||
<summary>Can't scan? Enter manually</summary>
|
||||
<div class="qr-manual-content">
|
||||
<p class="qr-manual-label">Secret key:</p>
|
||||
<code class="qr-secret">{{ newTotpSecret() }}</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-divider"></div>
|
||||
<p class="recovery-title">New Recovery Codes</p>
|
||||
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
|
||||
<div class="recovery-codes">
|
||||
@for (code of newRecoveryCodes(); track code) {
|
||||
<div class="recovery-code">{{ code }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="recovery-actions">
|
||||
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="dismissRecoveryCodes()">Dismiss</app-button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="form-divider"></div>
|
||||
<p class="section-hint">To regenerate your 2FA, enter your current password and a valid authenticator code.</p>
|
||||
<app-input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
[value]="twoFaPassword()"
|
||||
(valueChange)="twoFaPassword.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="Authenticator Code"
|
||||
type="text"
|
||||
placeholder="Enter 6-digit code"
|
||||
[value]="twoFaCode()"
|
||||
(valueChange)="twoFaCode.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || regenerating2fa()"
|
||||
(clicked)="confirmRegenerate2fa()"
|
||||
>
|
||||
@if (regenerating2fa()) {
|
||||
<app-spinner size="sm" /> Regenerating...
|
||||
} @else {
|
||||
Regenerate 2FA
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- API Key -->
|
||||
<app-card header="API Key">
|
||||
<div class="form-stack">
|
||||
<p class="section-hint">
|
||||
Use this API key to access the Cleanuparr API without authentication.
|
||||
Include it as the <code>X-Api-Key</code> header or <code>?apikey=</code> query parameter.
|
||||
</p>
|
||||
|
||||
<div class="api-key-row">
|
||||
<div class="api-key-display">
|
||||
@if (apiKeyRevealed()) {
|
||||
<code class="api-key-value">{{ apiKey() }}</code>
|
||||
} @else {
|
||||
<code class="api-key-value api-key-value--masked">{{ account()!.apiKeyPreview }}</code>
|
||||
}
|
||||
</div>
|
||||
<div class="api-key-actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="revealApiKey()">
|
||||
{{ apiKeyRevealed() ? 'Hide' : 'Reveal' }}
|
||||
</app-button>
|
||||
@if (apiKeyRevealed()) {
|
||||
<app-button variant="ghost" size="sm" (clicked)="copyApiKey()">Copy</app-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="regeneratingApiKey()"
|
||||
(clicked)="confirmRegenerateApiKey()"
|
||||
>
|
||||
@if (regeneratingApiKey()) {
|
||||
<app-spinner size="sm" /> Regenerating...
|
||||
} @else {
|
||||
Regenerate API Key
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- Plex Integration -->
|
||||
<app-card header="Plex Integration">
|
||||
<div class="form-stack">
|
||||
@if (account()!.plexLinked) {
|
||||
<div class="status-row">
|
||||
<span class="status-label">Linked Account</span>
|
||||
<span class="status-value">{{ account()!.plexUsername }}</span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="plexUnlinking()"
|
||||
(clicked)="confirmUnlinkPlex()"
|
||||
>
|
||||
@if (plexUnlinking()) {
|
||||
<app-spinner size="sm" /> Unlinking...
|
||||
} @else {
|
||||
Unlink Plex Account
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="section-hint">
|
||||
Link your Plex account to enable signing in with Plex as an alternative to username and password.
|
||||
</p>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="plexLinking()"
|
||||
(clicked)="startPlexLink()"
|
||||
>
|
||||
@if (plexLinking()) {
|
||||
<app-spinner size="sm" /> Waiting for Plex...
|
||||
} @else {
|
||||
Link Plex Account
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
@use 'settings-layout' as *;
|
||||
|
||||
:host { @include settings-page; }
|
||||
|
||||
.settings-form { @include settings-form; }
|
||||
.form-stack { @include form-stack; }
|
||||
.form-row { @include form-row; }
|
||||
.form-divider { @include form-divider; }
|
||||
.form-actions { @include form-actions; }
|
||||
|
||||
.section-hint {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
|
||||
code {
|
||||
background: var(--surface-secondary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
|
||||
&--active {
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
|
||||
// QR code (2FA regeneration)
|
||||
.qr-section {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.qr-code-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
qrcode {
|
||||
background: #ffffff;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-manual-entry {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr-manual-content {
|
||||
background: var(--surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-manual-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.qr-secret {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
// Recovery codes
|
||||
.recovery-section {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.recovery-title {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.recovery-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.recovery-codes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.recovery-code {
|
||||
font-family: monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-secondary);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recovery-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
// API key
|
||||
.api-key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.api-key-display {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-key-value {
|
||||
font-family: monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
|
||||
&--masked {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Password strength indicator
|
||||
.password-strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
&__bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
transition: width var(--duration-normal) var(--ease-default),
|
||||
background-color var(--duration-normal) var(--ease-default);
|
||||
|
||||
&--weak {
|
||||
width: 33%;
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
&--medium {
|
||||
width: 66%;
|
||||
background-color: var(--color-warning);
|
||||
}
|
||||
|
||||
&--strong {
|
||||
width: 100%;
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: capitalize;
|
||||
min-width: 52px;
|
||||
|
||||
&--weak {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&--medium {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
&--strong {
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
|
||||
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
||||
import {
|
||||
CardComponent, ButtonComponent, InputComponent, SpinnerComponent,
|
||||
EmptyStateComponent, LoadingStateComponent,
|
||||
} from '@ui';
|
||||
import { AccountApi, AccountInfo } from '@core/api/account.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { DeferredLoader } from '@shared/utils/loading.util';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
|
||||
SpinnerComponent, EmptyStateComponent, LoadingStateComponent, QRCodeComponent,
|
||||
],
|
||||
templateUrl: './account-settings.component.html',
|
||||
styleUrl: './account-settings.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AccountSettingsComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(AccountApi);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
|
||||
readonly loader = new DeferredLoader();
|
||||
readonly loadError = signal(false);
|
||||
readonly account = signal<AccountInfo | null>(null);
|
||||
|
||||
// Change password
|
||||
readonly currentPassword = signal('');
|
||||
readonly newPassword = signal('');
|
||||
readonly confirmPassword = signal('');
|
||||
readonly changingPassword = signal(false);
|
||||
|
||||
// Password strength
|
||||
readonly newPasswordStrength = computed(() => {
|
||||
const pw = this.newPassword();
|
||||
if (!pw) return null;
|
||||
if (pw.length < 8) return 'weak';
|
||||
const hasUpper = /[A-Z]/.test(pw);
|
||||
const hasLower = /[a-z]/.test(pw);
|
||||
const hasNumber = /[0-9]/.test(pw);
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(pw);
|
||||
const score = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
|
||||
if (pw.length >= 12 && score >= 3) return 'strong';
|
||||
if (pw.length >= 8 && score >= 2) return 'medium';
|
||||
return 'weak';
|
||||
});
|
||||
|
||||
// 2FA regeneration
|
||||
readonly twoFaPassword = signal('');
|
||||
readonly twoFaCode = signal('');
|
||||
readonly regenerating2fa = signal(false);
|
||||
readonly newRecoveryCodes = signal<string[]>([]);
|
||||
readonly newQrCodeUri = signal('');
|
||||
readonly newTotpSecret = signal('');
|
||||
|
||||
// API key
|
||||
readonly apiKey = signal('');
|
||||
readonly apiKeyRevealed = signal(false);
|
||||
readonly regeneratingApiKey = signal(false);
|
||||
|
||||
// Plex
|
||||
readonly plexLinking = signal(false);
|
||||
readonly plexUnlinking = signal(false);
|
||||
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAccount();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.plexPollTimer) {
|
||||
clearInterval(this.plexPollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
private loadAccount(): void {
|
||||
this.loader.start();
|
||||
this.api.getInfo().subscribe({
|
||||
next: (info) => {
|
||||
this.account.set(info);
|
||||
this.loader.stop();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to load account information');
|
||||
this.loader.stop();
|
||||
this.loadError.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.loadError.set(false);
|
||||
this.loadAccount();
|
||||
}
|
||||
|
||||
// Change password
|
||||
changePassword(): void {
|
||||
if (this.newPassword() !== this.confirmPassword()) {
|
||||
this.toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (this.newPassword().length < 8) {
|
||||
this.toast.error('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
this.changingPassword.set(true);
|
||||
this.api.changePassword({
|
||||
currentPassword: this.currentPassword(),
|
||||
newPassword: this.newPassword(),
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Password changed successfully');
|
||||
this.currentPassword.set('');
|
||||
this.newPassword.set('');
|
||||
this.confirmPassword.set('');
|
||||
this.changingPassword.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to change password');
|
||||
this.changingPassword.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2FA regeneration
|
||||
async confirmRegenerate2fa(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Regenerate 2FA',
|
||||
message: 'This will invalidate your current authenticator setup and all existing recovery codes. You will need to set up your authenticator app again.',
|
||||
confirmLabel: 'Regenerate',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.regenerating2fa.set(true);
|
||||
this.api.regenerate2fa({
|
||||
password: this.twoFaPassword(),
|
||||
totpCode: this.twoFaCode(),
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
this.newRecoveryCodes.set(result.recoveryCodes);
|
||||
this.newQrCodeUri.set(result.qrCodeUri);
|
||||
this.newTotpSecret.set(result.secret);
|
||||
this.toast.success('2FA regenerated. Scan the QR code and save your recovery codes!');
|
||||
this.twoFaPassword.set('');
|
||||
this.twoFaCode.set('');
|
||||
this.regenerating2fa.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to regenerate 2FA. Check your password and code.');
|
||||
this.regenerating2fa.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
copyRecoveryCodes(): void {
|
||||
const codes = this.newRecoveryCodes().join('\n');
|
||||
navigator.clipboard.writeText(codes);
|
||||
this.toast.success('Recovery codes copied to clipboard');
|
||||
}
|
||||
|
||||
dismissRecoveryCodes(): void {
|
||||
this.newRecoveryCodes.set([]);
|
||||
this.newQrCodeUri.set('');
|
||||
this.newTotpSecret.set('');
|
||||
}
|
||||
|
||||
// API key
|
||||
revealApiKey(): void {
|
||||
if (this.apiKeyRevealed()) {
|
||||
this.apiKeyRevealed.set(false);
|
||||
this.apiKey.set('');
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.getApiKey().subscribe({
|
||||
next: (result) => {
|
||||
this.apiKey.set(result.apiKey);
|
||||
this.apiKeyRevealed.set(true);
|
||||
},
|
||||
error: () => this.toast.error('Failed to load API key'),
|
||||
});
|
||||
}
|
||||
|
||||
copyApiKey(): void {
|
||||
navigator.clipboard.writeText(this.apiKey());
|
||||
this.toast.success('API key copied to clipboard');
|
||||
}
|
||||
|
||||
async confirmRegenerateApiKey(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Regenerate API Key',
|
||||
message: 'This will invalidate the current API key. Any integrations using this key will stop working.',
|
||||
confirmLabel: 'Regenerate',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.regeneratingApiKey.set(true);
|
||||
this.api.regenerateApiKey().subscribe({
|
||||
next: (result) => {
|
||||
this.apiKey.set(result.apiKey);
|
||||
this.apiKeyRevealed.set(true);
|
||||
this.toast.success('API key regenerated');
|
||||
this.regeneratingApiKey.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to regenerate API key');
|
||||
this.regeneratingApiKey.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Plex
|
||||
startPlexLink(): void {
|
||||
this.plexLinking.set(true);
|
||||
this.api.linkPlex().subscribe({
|
||||
next: (result) => {
|
||||
window.open(result.authUrl, '_blank');
|
||||
this.pollPlexLink(result.pinId);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to start Plex linking');
|
||||
this.plexLinking.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pollPlexLink(pinId: number): void {
|
||||
let attempts = 0;
|
||||
this.plexPollTimer = setInterval(() => {
|
||||
attempts++;
|
||||
if (attempts > 60) {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinking.set(false);
|
||||
this.toast.error('Plex linking timed out');
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.verifyPlexLink(pinId).subscribe({
|
||||
next: (result) => {
|
||||
if (result.completed) {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinking.set(false);
|
||||
this.toast.success('Plex account linked');
|
||||
this.loadAccount();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinking.set(false);
|
||||
this.toast.error('Plex linking failed');
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async confirmUnlinkPlex(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Unlink Plex',
|
||||
message: 'This will remove your linked Plex account. You will no longer be able to log in with Plex.',
|
||||
confirmLabel: 'Unlink',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.plexUnlinking.set(true);
|
||||
this.api.unlinkPlex().subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Plex account unlinked');
|
||||
this.plexUnlinking.set(false);
|
||||
this.loadAccount();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to unlink Plex account');
|
||||
this.plexUnlinking.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<div class="auth-layout">
|
||||
<div class="auth-layout__card">
|
||||
<div class="auth-layout__brand">
|
||||
<img src="icons/128.png" alt="Cleanuparr" class="auth-layout__logo" />
|
||||
<h1>Cleanuparr</h1>
|
||||
</div>
|
||||
<router-outlet />
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
}
|
||||
|
||||
&__brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
|
||||
@@ -61,4 +64,12 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
filter: drop-shadow(0 0 8px rgba(126, 87, 194, 0.4));
|
||||
margin-bottom: var(--space-3);
|
||||
animation: float-gentle 6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,12 @@
|
||||
</div>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
<button class="sidebar__logout" (click)="logout()">
|
||||
<ng-icon name="tablerLogout" class="sidebar__logout-icon" />
|
||||
@if (!collapsed()) {
|
||||
<span>Logout</span>
|
||||
}
|
||||
</button>
|
||||
<a
|
||||
class="sidebar__sponsor"
|
||||
href="https://cleanuparr.github.io/Cleanuparr/support"
|
||||
|
||||
@@ -189,6 +189,32 @@
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__logout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--sidebar-item-text);
|
||||
font-size: var(--font-size-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: var(--sidebar-item-hover);
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
&__logout-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__sponsor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -286,7 +312,8 @@
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar__sponsor {
|
||||
.sidebar__sponsor,
|
||||
.sidebar__logout {
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, ChangeDetectionStrategy, input, output, signal, inject, comp
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { AppHubService } from '@core/realtime/app-hub.service';
|
||||
import { AuthService } from '@core/auth/auth.service';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
@@ -26,6 +27,7 @@ interface ExternalLink {
|
||||
})
|
||||
export class NavSidebarComponent {
|
||||
private readonly hub = inject(AppHubService);
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
collapsed = input(false);
|
||||
mobileOpen = input(false);
|
||||
@@ -69,6 +71,7 @@ export class NavSidebarComponent {
|
||||
|
||||
otherSettingsItems: NavItem[] = [
|
||||
{ label: 'Notifications', icon: 'tablerBellRinging', route: '/settings/notifications' },
|
||||
{ label: 'Account', icon: 'tablerUser', route: '/settings/account' },
|
||||
];
|
||||
|
||||
suggestedApps: ExternalLink[] = [
|
||||
@@ -88,4 +91,8 @@ export class NavSidebarComponent {
|
||||
toggleMediaApps(): void {
|
||||
this.mediaAppsExpanded.set(!this.mediaAppsExpanded());
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.auth.logout();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user