diff --git a/.env.example b/.env.example deleted file mode 100644 index 4365875..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -POSTGRES_USER=platform -POSTGRES_DB=platform -POSTGRES_PASSWORD=CHANGE_ME_FOR_LOCAL_DEVELOPMENT diff --git a/backend/Platform.Api/Endpoints/AccountEndpoints.cs b/backend/Platform.Api/Endpoints/AccountEndpoints.cs new file mode 100644 index 0000000..8ef6d19 --- /dev/null +++ b/backend/Platform.Api/Endpoints/AccountEndpoints.cs @@ -0,0 +1,77 @@ +using Platform.Access.Contracts; +using Platform.Access.Domain.Enums; +using Platform.Access.Domain.Models; +using Platform.Api.Security; +using Platform.Identity.Application; +using Platform.Identity.Contracts; +using Platform.Identity.Domain.Enums; +using Platform.Identity.Domain.Models; + +namespace Platform.Api.Endpoints; + +public sealed record CreateAccountRequest(string? Email, string? Password) +{ + public override string ToString() => "CreateAccountRequest { *** }"; +} + +public static class AccountEndpoints +{ + public static IEndpointRouteBuilder MapAccountEndpoints(this IEndpointRouteBuilder app) + { + app.MapPost("/api/admin/accounts", CreateAccountAsync); + return app; + } + + private static async Task CreateAccountAsync( + CreateAccountRequest? body, + HttpContext context, + IRequestSubjectResolver subjectResolver, + IAccessDecision accessDecision, + IAccountRegistration registration, + CancellationToken cancellationToken) + { + var correlationId = Guid.NewGuid().ToString("N"); + + var decision = await accessDecision.DecideAsync( + new AccessRequest( + subjectResolver.Resolve(context), + CreateAccountPolicy.ActionId, + null, + correlationId), + cancellationToken); + + if (decision.Outcome != AccessOutcome.Allow) + { + return decision.DenialReason == AccessDenialReason.Unauthenticated + ? Results.Json(new { error = "unauthenticated", correlationId }, + statusCode: StatusCodes.Status401Unauthorized) + : Results.Json(new { error = "forbidden", correlationId }, + statusCode: StatusCodes.Status403Forbidden); + } + + if (body is null) + { + return Results.BadRequest(new + { + error = "invalid_input", + errors = new[] { "Telo zahteva je obavezno." }, + correlationId + }); + } + + var result = await registration.RegisterAsync( + new RegisterAccountCommand(body.Email ?? string.Empty, body.Password ?? string.Empty), + cancellationToken); + + return result.Outcome switch + { + RegistrationOutcome.Created => Results.Created( + $"/api/admin/accounts/{result.AccountId}", + new { id = result.AccountId, correlationId }), + RegistrationOutcome.IdentifierTaken => Results.Conflict( + new { error = "identifier_taken", correlationId }), + _ => Results.BadRequest( + new { error = "invalid_input", errors = result.Errors, correlationId }) + }; + } +} \ No newline at end of file diff --git a/backend/Platform.Api/Platform.Api.csproj b/backend/Platform.Api/Platform.Api.csproj index af34409..26028e0 100644 --- a/backend/Platform.Api/Platform.Api.csproj +++ b/backend/Platform.Api/Platform.Api.csproj @@ -1,11 +1,22 @@ + net10.0 enable enable + 3fd00482-a54c-426f-9d2a-0f2204817946 + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/backend/Platform.Api/Program.cs b/backend/Platform.Api/Program.cs index 513139b..b5ea3df 100644 --- a/backend/Platform.Api/Program.cs +++ b/backend/Platform.Api/Program.cs @@ -1,52 +1,88 @@ + using Platform.Access.Composition; using Platform.Access.Contracts; using Platform.Access.Domain.Enums; using Platform.Access.Domain.Models; + +using Platform.Identity.Composition; +using Platform.Api.Endpoints; +using Platform.Api.Security; + using Platform.Api.Domain.Identity; using Platform.Api.Middlewares; using Platform.Api.Services.Identity; using Platform.Audit.Composition; -// Oba modula imaju interfejs IAccessDecision, pa se koriste aliasi da ime ne bude dvosmisleno. +// Oba modula imaju IAccessDecision, zato koristimo alias. using IdentityAccessDecision = Platform.Api.Services.Identity.IAccessDecision; using PlatformAccessDecision = Platform.Access.Contracts.IAccessDecision; var builder = WebApplication.CreateBuilder(args); +// Access modul builder.Services.AddAccessControl(); + +// IDN-01: registracija naloga +builder.Services.AddIdentityModule(); + +if (builder.Environment.IsDevelopment()) +{ + builder.Services.AddSingleton(); +} +else +{ + builder.Services.AddSingleton(); +} + +// Servisi sa main grane builder.Services.AddAudit(); builder.Services.AddControllers(); builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(TimeProvider.System); -builder.Services.AddSingleton(); // TODO: zameniti trajnim repozitorijumom (PostgreSQL) -builder.Services.AddSingleton(); // zameniti IDN-02 implementacijom -builder.Services.AddSingleton(); // zameniti IDN-02 implementacijom + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddScoped(); builder.Services.AddScoped(); if (builder.Environment.IsDevelopment()) { builder.Services.AddScoped(); - builder.Services.AddSingleton(); // samo za lokalni razvoj + builder.Services.AddSingleton(); } else { builder.Services.AddScoped(); - builder.Services.AddSingleton(); // zameniti kad IDN-02 bude gotov + builder.Services.AddSingleton(); } var app = builder.Build(); -// Probni nalozi samo za lokalni razvoj (izmišljeni domen example.com). +// IDN-01: politike i endpoint za kreiranje naloga +app.Services.RegisterIdentityAccess(); +app.MapAccountEndpoints(); + +// Probni nalozi samo za lokalni razvoj if (app.Environment.IsDevelopment()) { var repo = app.Services.GetRequiredService(); - repo.Save(new UserAccount(Guid.Parse("11111111-1111-1111-1111-111111111111"), "admin@example.com")); - repo.Save(new UserAccount(Guid.Parse("22222222-2222-2222-2222-222222222222"), "student@example.com")); - repo.Save(new UserAccount(Guid.Parse("33333333-3333-3333-3333-333333333333"), "other@example.com")); + + repo.Save(new UserAccount( + Guid.Parse("11111111-1111-1111-1111-111111111111"), + "admin@example.com")); + + repo.Save(new UserAccount( + Guid.Parse("22222222-2222-2222-2222-222222222222"), + "student@example.com")); + + repo.Save(new UserAccount( + Guid.Parse("33333333-3333-3333-3333-333333333333"), + "other@example.com")); } app.MapGet("/api/health", () => Results.Ok(new @@ -85,4 +121,4 @@ app.Run(); -public partial class Program { } \ No newline at end of file +public partial class Program { } diff --git a/backend/Platform.Api/Security/DevelopmentSubjectResolver.cs b/backend/Platform.Api/Security/DevelopmentSubjectResolver.cs new file mode 100644 index 0000000..d2fae65 --- /dev/null +++ b/backend/Platform.Api/Security/DevelopmentSubjectResolver.cs @@ -0,0 +1,13 @@ +using Platform.Access.Domain.Enums; +using Platform.Access.Domain.Models; + +namespace Platform.Api.Security; + +public sealed class DevelopmentSubjectResolver(IConfiguration configuration) : IRequestSubjectResolver +{ + public SubjectRef? Resolve(HttpContext context) + { + var id = configuration["Identity:DevSubjectId"]; + return string.IsNullOrWhiteSpace(id) ? null : new SubjectRef(id, AccountState.Active); + } +} \ No newline at end of file diff --git a/backend/Platform.Api/Security/IRequestSubjectResolver.cs b/backend/Platform.Api/Security/IRequestSubjectResolver.cs new file mode 100644 index 0000000..2a68af9 --- /dev/null +++ b/backend/Platform.Api/Security/IRequestSubjectResolver.cs @@ -0,0 +1,8 @@ +using Platform.Access.Domain.Models; + +namespace Platform.Api.Security; + +public interface IRequestSubjectResolver +{ + SubjectRef? Resolve(HttpContext context); +} \ No newline at end of file diff --git a/backend/Platform.Api/Security/UnauthenticatedSubjectResolver.cs b/backend/Platform.Api/Security/UnauthenticatedSubjectResolver.cs new file mode 100644 index 0000000..250fa93 --- /dev/null +++ b/backend/Platform.Api/Security/UnauthenticatedSubjectResolver.cs @@ -0,0 +1,8 @@ +using Platform.Access.Domain.Models; + +namespace Platform.Api.Security; + +public sealed class UnauthenticatedSubjectResolver : IRequestSubjectResolver +{ + public SubjectRef? Resolve(HttpContext context) => null; +} \ No newline at end of file diff --git a/backend/Platform.Identity.Tests/AccountRegistrationTests.cs b/backend/Platform.Identity.Tests/AccountRegistrationTests.cs new file mode 100644 index 0000000..5ac08f8 --- /dev/null +++ b/backend/Platform.Identity.Tests/AccountRegistrationTests.cs @@ -0,0 +1,184 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Platform.Identity.Composition; +using Platform.Identity.Contracts; +using Platform.Identity.Domain.Enums; +using Platform.Identity.Domain.Models; +using Platform.Identity.Infrastructure.Persistence; +using Testcontainers.PostgreSql; + +namespace Platform.Identity.Tests; + +[TestFixture] +public sealed class AccountRegistrationTests +{ + private const string StrongPassword = "Str0ng-Password!2026"; + + private PostgreSqlContainer _db = null!; + private ServiceProvider _provider = null!; + + [OneTimeSetUp] + public async Task StartDatabase() + { + _db = new PostgreSqlBuilder("postgres:17-alpine").Build(); + await _db.StartAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Identity"] = _db.GetConnectionString() + }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(config); + services.AddIdentityModule(); + _provider = services.BuildServiceProvider(); + + using var scope = _provider.CreateScope(); + await scope.ServiceProvider.GetRequiredService() + .Database.MigrateAsync(); + } + + [OneTimeTearDown] + public async Task StopDatabase() + { + await _provider.DisposeAsync(); + await _db.DisposeAsync(); + } + + [Test] + public async Task Valid_registration_creates_active_account_with_hashed_password() + { + var email = $"Mixed.{Guid.NewGuid():N}@Example.com"; + + var result = await RegisterAsync(email, StrongPassword); + + Assert.That(result.Outcome, Is.EqualTo(RegistrationOutcome.Created)); + Assert.That(result.AccountId, Is.Not.Null); + + var user = await FindByIdAsync(result.AccountId!.Value); + Assert.That(user.Email, Is.EqualTo(email.ToLowerInvariant())); + Assert.That(user.Status, Is.EqualTo(AccountStatus.Active)); + Assert.That(user.PasswordHash, Is.Not.Null.And.Not.EqualTo(StrongPassword)); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("not-an-email")] + [TestCase("a@")] + [TestCase("@example.com")] + [TestCase("two@@example.com")] + public async Task Invalid_email_is_rejected(string email) + { + var result = await RegisterAsync(email, StrongPassword); + + Assert.That(result.Outcome, Is.EqualTo(RegistrationOutcome.InvalidInput)); + Assert.That(result.AccountId, Is.Null); + } + + [TestCase("")] + [TestCase("short1!A")] + [TestCase("alllowercase-password-123")] + public async Task Weak_password_is_rejected_and_no_account_is_created(string password) + { + var email = NewEmail(); + + var result = await RegisterAsync(email, password); + + Assert.That(result.Outcome, Is.EqualTo(RegistrationOutcome.InvalidInput)); + Assert.That(await CountByEmailAsync(email), Is.EqualTo(0)); + } + + [Test] + public async Task Duplicate_email_is_rejected() + { + var email = NewEmail(); + + var first = await RegisterAsync(email, StrongPassword); + var second = await RegisterAsync(email, StrongPassword); + + Assert.That(first.Outcome, Is.EqualTo(RegistrationOutcome.Created)); + Assert.That(second.Outcome, Is.EqualTo(RegistrationOutcome.IdentifierTaken)); + Assert.That(await CountByEmailAsync(email), Is.EqualTo(1)); + } + + [Test] + public async Task Duplicate_email_differing_only_in_case_or_whitespace_is_rejected() + { + var email = NewEmail(); + + await RegisterAsync(email, StrongPassword); + var second = await RegisterAsync($" {email.ToUpperInvariant()} ", StrongPassword); + + Assert.That(second.Outcome, Is.EqualTo(RegistrationOutcome.IdentifierTaken)); + Assert.That(await CountByEmailAsync(email), Is.EqualTo(1)); + } + + [Test] + public async Task Identifier_of_archived_account_cannot_be_reused() + { + var email = NewEmail(); + var first = await RegisterAsync(email, StrongPassword); + + using (var scope = _provider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.SingleAsync(u => u.Id == first.AccountId); + user.Status = AccountStatus.Archived; + await db.SaveChangesAsync(); + } + + var second = await RegisterAsync(email, StrongPassword); + + Assert.That(second.Outcome, Is.EqualTo(RegistrationOutcome.IdentifierTaken)); + Assert.That(await CountByEmailAsync(email), Is.EqualTo(1)); + } + + [Test] + public async Task Concurrent_registrations_of_same_email_create_exactly_one_account() + { + var email = NewEmail(); + + var results = await Task.WhenAll( + Enumerable.Range(0, 5).Select(_ => Task.Run(() => RegisterAsync(email, StrongPassword)))); + + Assert.That(results.Count(r => r.Outcome == RegistrationOutcome.Created), Is.EqualTo(1)); + Assert.That(results.Count(r => r.Outcome == RegistrationOutcome.IdentifierTaken), Is.EqualTo(4)); + Assert.That(await CountByEmailAsync(email), Is.EqualTo(1)); + } + + [Test] + public void Command_ToString_does_not_leak_password() + { + var command = new RegisterAccountCommand("someone@example.com", StrongPassword); + + Assert.That(command.ToString(), Does.Not.Contain(StrongPassword)); + } + + private static string NewEmail() => $"user-{Guid.NewGuid():N}@example.com"; + + private async Task RegisterAsync(string email, string password) + { + using var scope = _provider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + return await service.RegisterAsync(new RegisterAccountCommand(email, password)); + } + + private async Task FindByIdAsync(Guid id) + { + using var scope = _provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Users.SingleAsync(u => u.Id == id); + } + + private async Task CountByEmailAsync(string email) + { + using var scope = _provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var normalized = email.Trim().ToUpperInvariant(); + return await db.Users.CountAsync(u => u.NormalizedEmail == normalized); + } +} \ No newline at end of file diff --git a/backend/Platform.Identity.Tests/Platform.Identity.Tests.csproj b/backend/Platform.Identity.Tests/Platform.Identity.Tests.csproj new file mode 100644 index 0000000..a4354a5 --- /dev/null +++ b/backend/Platform.Identity.Tests/Platform.Identity.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/Platform.Identity/Application/AccountRegistrationService.cs b/backend/Platform.Identity/Application/AccountRegistrationService.cs new file mode 100644 index 0000000..f8bb0c3 --- /dev/null +++ b/backend/Platform.Identity/Application/AccountRegistrationService.cs @@ -0,0 +1,79 @@ +using System.Net.Mail; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Platform.Identity.Contracts; +using Platform.Identity.Domain.Models; + +namespace Platform.Identity.Application; + +public sealed class AccountRegistrationService(UserManager userManager) + : IAccountRegistration +{ + private const int MaxEmailLength = 254; + private const int MaxPasswordLength = 128; + + public async ValueTask RegisterAsync( + RegisterAccountCommand command, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + cancellationToken.ThrowIfCancellationRequested(); + + var email = (command.Email ?? string.Empty).Trim().ToLowerInvariant(); + if (!IsValidEmail(email)) + { + return RegisterAccountResult.Invalid("Email adresa nije validna."); + } + + if (string.IsNullOrEmpty(command.Password)) + { + return RegisterAccountResult.Invalid("Lozinka je obavezna."); + } + + if (command.Password.Length > MaxPasswordLength) + { + return RegisterAccountResult.Invalid( + $"Lozinka može imati najviše {MaxPasswordLength} karaktera."); + } + + var user = new AppUser + { + Id = Guid.NewGuid(), + UserName = email, + Email = email + }; + + try + { + var result = await userManager.CreateAsync(user, command.Password); + + if (result.Succeeded) + { + return RegisterAccountResult.Created(user.Id); + } + + if (result.Errors.Any(e => + e.Code is nameof(IdentityErrorDescriber.DuplicateEmail) + or nameof(IdentityErrorDescriber.DuplicateUserName))) + { + return RegisterAccountResult.Taken(); + } + + return RegisterAccountResult.Invalid( + result.Errors.Select(e => e.Description).ToArray()); + } + catch (DbUpdateException ex) + when (ex.InnerException is PostgresException + { SqlState: PostgresErrorCodes.UniqueViolation }) + { + // Trka: dva istovremena zahteva za isti email, unique indeks u bazi je poslednja linija odbrane. + return RegisterAccountResult.Taken(); + } + } + + private static bool IsValidEmail(string email) => + email.Length is > 0 and <= MaxEmailLength + && MailAddress.TryCreate(email, out var parsed) + && parsed.Address == email; +} \ No newline at end of file diff --git a/backend/Platform.Identity/Application/CreateAccountPolicy.cs b/backend/Platform.Identity/Application/CreateAccountPolicy.cs new file mode 100644 index 0000000..67ff7b9 --- /dev/null +++ b/backend/Platform.Identity/Application/CreateAccountPolicy.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Configuration; +using Platform.Access.Contracts; +using Platform.Access.Domain.Models; + +namespace Platform.Identity.Application; + +public sealed class CreateAccountPolicy(IConfiguration configuration) : IAccessPolicy +{ + public const string ActionId = "identity.account.create"; + + public string Id => "identity.account.create.admin-only"; + public string Version => "v1"; + + public ValueTask EvaluateAsync( + AccessRequest request, + CancellationToken cancellationToken = default) + { + var admins = configuration.GetSection("Identity:AdminAccountIds").Get() ?? []; + var allowed = request.Subject is not null + && admins.Contains(request.Subject.Id, StringComparer.Ordinal); + + return ValueTask.FromResult(allowed); + } +} \ No newline at end of file diff --git a/backend/Platform.Identity/Composition/IdentityAccessRegistration.cs b/backend/Platform.Identity/Composition/IdentityAccessRegistration.cs new file mode 100644 index 0000000..615c0a3 --- /dev/null +++ b/backend/Platform.Identity/Composition/IdentityAccessRegistration.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Platform.Access.Infrastructure.Catalog; +using Platform.Access.Infrastructure.Policies; +using Platform.Identity.Application; + +namespace Platform.Identity.Composition; + +public static class IdentityAccessRegistration +{ + public static IServiceProvider RegisterIdentityAccess(this IServiceProvider services) + { + var configuration = services.GetRequiredService(); + + services.GetRequiredService() + .Register(CreateAccountPolicy.ActionId); + services.GetRequiredService() + .Register(CreateAccountPolicy.ActionId, new CreateAccountPolicy(configuration)); + + return services; + } +} \ No newline at end of file diff --git a/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs b/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs new file mode 100644 index 0000000..d0fc347 --- /dev/null +++ b/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Platform.Identity.Domain.Models; +using Platform.Identity.Infrastructure.Persistence; +using Platform.Identity.Application; +using Platform.Identity.Contracts; + +namespace Platform.Identity.Composition; + +public static class IdentityServiceCollectionExtensions +{ + public static IServiceCollection AddIdentityModule(this IServiceCollection services) + { + services.AddDbContext((sp, options) => + options.UseNpgsql( + sp.GetRequiredService().GetConnectionString("Identity"), + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "identity"))); + + services.AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; + options.Password.RequiredLength = 12; + }) + .AddEntityFrameworkStores(); + + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/backend/Platform.Identity/Contracts/IAccountRegistration.cs b/backend/Platform.Identity/Contracts/IAccountRegistration.cs new file mode 100644 index 0000000..3a3f252 --- /dev/null +++ b/backend/Platform.Identity/Contracts/IAccountRegistration.cs @@ -0,0 +1,10 @@ +using Platform.Identity.Domain.Models; + +namespace Platform.Identity.Contracts; + +public interface IAccountRegistration +{ + ValueTask RegisterAsync( + RegisterAccountCommand command, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/Platform.Identity/Contracts/IIdentityContext.cs b/backend/Platform.Identity/Contracts/IIdentityContext.cs new file mode 100644 index 0000000..6502354 --- /dev/null +++ b/backend/Platform.Identity/Contracts/IIdentityContext.cs @@ -0,0 +1,12 @@ +using Platform.Identity.Domain.Enums; + +namespace Platform.Identity.Contracts; + +public sealed record AccountSnapshot(Guid Id, AccountStatus Status); + +public interface IIdentityContext +{ + ValueTask FindByIdAsync( + Guid accountId, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/Platform.Identity/Domain/Enums/AccountStatus.cs b/backend/Platform.Identity/Domain/Enums/AccountStatus.cs new file mode 100644 index 0000000..0c60662 --- /dev/null +++ b/backend/Platform.Identity/Domain/Enums/AccountStatus.cs @@ -0,0 +1,8 @@ +namespace Platform.Identity.Domain.Enums; + +public enum AccountStatus +{ + Active, + Blocked, + Archived +} \ No newline at end of file diff --git a/backend/Platform.Identity/Domain/Enums/RegistrationOutcome.cs b/backend/Platform.Identity/Domain/Enums/RegistrationOutcome.cs new file mode 100644 index 0000000..9b26134 --- /dev/null +++ b/backend/Platform.Identity/Domain/Enums/RegistrationOutcome.cs @@ -0,0 +1,8 @@ +namespace Platform.Identity.Domain.Enums; + +public enum RegistrationOutcome +{ + Created, + IdentifierTaken, + InvalidInput +} \ No newline at end of file diff --git a/backend/Platform.Identity/Domain/Models/AppUser.cs b/backend/Platform.Identity/Domain/Models/AppUser.cs new file mode 100644 index 0000000..5c09f74 --- /dev/null +++ b/backend/Platform.Identity/Domain/Models/AppUser.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Identity; +using Platform.Identity.Domain.Enums; + +namespace Platform.Identity.Domain.Models; + +public sealed class AppUser : IdentityUser +{ + public AccountStatus Status { get; set; } = AccountStatus.Active; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} \ No newline at end of file diff --git a/backend/Platform.Identity/Domain/Models/RegisterAccountCommand.cs b/backend/Platform.Identity/Domain/Models/RegisterAccountCommand.cs new file mode 100644 index 0000000..fd12b27 --- /dev/null +++ b/backend/Platform.Identity/Domain/Models/RegisterAccountCommand.cs @@ -0,0 +1,6 @@ +namespace Platform.Identity.Domain.Models; + +public sealed record RegisterAccountCommand(string Email, string Password) +{ + public override string ToString() => "RegisterAccountCommand { *** }"; +} \ No newline at end of file diff --git a/backend/Platform.Identity/Domain/Models/RegisterAccountResult.cs b/backend/Platform.Identity/Domain/Models/RegisterAccountResult.cs new file mode 100644 index 0000000..aee0d0c --- /dev/null +++ b/backend/Platform.Identity/Domain/Models/RegisterAccountResult.cs @@ -0,0 +1,18 @@ +using Platform.Identity.Domain.Enums; + +namespace Platform.Identity.Domain.Models; + +public sealed record RegisterAccountResult( + RegistrationOutcome Outcome, + Guid? AccountId, + IReadOnlyList Errors) +{ + public static RegisterAccountResult Created(Guid id) => + new(RegistrationOutcome.Created, id, []); + + public static RegisterAccountResult Taken() => + new(RegistrationOutcome.IdentifierTaken, null, []); + + public static RegisterAccountResult Invalid(params string[] errors) => + new(RegistrationOutcome.InvalidInput, null, errors); +} \ No newline at end of file diff --git a/backend/Platform.Identity/Infrastructure/Persistence/AccountDbContext.cs b/backend/Platform.Identity/Infrastructure/Persistence/AccountDbContext.cs new file mode 100644 index 0000000..f301515 --- /dev/null +++ b/backend/Platform.Identity/Infrastructure/Persistence/AccountDbContext.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Platform.Identity.Domain.Models; + +namespace Platform.Identity.Infrastructure.Persistence; + +public sealed class AccountDbContext(DbContextOptions options) + : IdentityUserContext(options) +{ + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.HasDefaultSchema("identity"); + + // IDN-01: jedinstven identifikator (email) na nivou baze, ne samo u kodu. + builder.Entity() + .HasIndex(u => u.NormalizedEmail) + .IsUnique(); + } +} \ No newline at end of file diff --git a/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.Designer.cs b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.Designer.cs new file mode 100644 index 0000000..5a3fc97 --- /dev/null +++ b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.Designer.cs @@ -0,0 +1,194 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Platform.Identity.Infrastructure.Persistence; + +#nullable disable + +namespace Platform.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AccountDbContext))] + [Migration("20261102181215_InitialAccounts")] + partial class InitialAccounts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("identity") + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "identity"); + }); + + modelBuilder.Entity("Platform.Identity.Domain.Models.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.cs b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.cs new file mode 100644 index 0000000..82d707c --- /dev/null +++ b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.cs @@ -0,0 +1,160 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Platform.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialAccounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + schema: "identity", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + schema: "identity", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + schema: "identity", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + schema: "identity", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "identity", + table: "AspNetUsers", + column: "NormalizedEmail", + unique: true); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "identity", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetUserClaims", + schema: "identity"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins", + schema: "identity"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens", + schema: "identity"); + + migrationBuilder.DropTable( + name: "AspNetUsers", + schema: "identity"); + } + } +} diff --git a/backend/Platform.Identity/Infrastructure/Persistence/Migrations/AccountDbContextModelSnapshot.cs b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/AccountDbContextModelSnapshot.cs new file mode 100644 index 0000000..50ac398 --- /dev/null +++ b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/AccountDbContextModelSnapshot.cs @@ -0,0 +1,191 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Platform.Identity.Infrastructure.Persistence; + +#nullable disable + +namespace Platform.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AccountDbContext))] + partial class AccountDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("identity") + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "identity"); + }); + + modelBuilder.Entity("Platform.Identity.Domain.Models.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Platform.Identity/Platform.Identity.csproj b/backend/Platform.Identity/Platform.Identity.csproj new file mode 100644 index 0000000..615e5f8 --- /dev/null +++ b/backend/Platform.Identity/Platform.Identity.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 634821c..4d639f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import CreateAccountForm from './components/CreateAccountForm' export default function App() { const [health, setHealth] = useState('Checking API…') @@ -12,5 +13,6 @@ export default function App() {

Security Lab

An interactive laboratory platform for the Fundamentals of Information Security course. Develop features through the assigned project components.

Development status

{health}

KT1: initial implementation, component architecture, and data flow diagram.

+

Kreiranje naloga (administrator)

} diff --git a/frontend/src/api/accounts.ts b/frontend/src/api/accounts.ts new file mode 100644 index 0000000..c2ebda6 --- /dev/null +++ b/frontend/src/api/accounts.ts @@ -0,0 +1,44 @@ +export type CreateAccountOutcome = + | { kind: 'created'; id: string; correlationId?: string } + | { kind: 'taken'; correlationId?: string } + | { kind: 'invalid'; errors: string[]; correlationId?: string } + | { kind: 'unauthenticated'; correlationId?: string } + | { kind: 'forbidden'; correlationId?: string } + | { kind: 'error' } + +interface ApiBody { + id?: string + errors?: string[] + correlationId?: string +} + +export async function createAccount(email: string, password: string): Promise { + let res: Response + try { + res = await fetch('/api/admin/accounts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + } catch { + return { kind: 'error' } + } + + const body = (await res.json().catch(() => null)) as ApiBody | null + const correlationId = body?.correlationId + + switch (res.status) { + case 201: + return body?.id ? { kind: 'created', id: body.id, correlationId } : { kind: 'error' } + case 400: + return { kind: 'invalid', errors: body?.errors ?? ['Zahtev nije validan.'], correlationId } + case 401: + return { kind: 'unauthenticated', correlationId } + case 403: + return { kind: 'forbidden', correlationId } + case 409: + return { kind: 'taken', correlationId } + default: + return { kind: 'error' } + } +} \ No newline at end of file diff --git a/frontend/src/components/CreateAccountForm.tsx b/frontend/src/components/CreateAccountForm.tsx new file mode 100644 index 0000000..f74c744 --- /dev/null +++ b/frontend/src/components/CreateAccountForm.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import type { FormEvent } from 'react' +import { createAccount } from '../api/accounts' +import type { CreateAccountOutcome } from '../api/accounts' + +const MIN_PASSWORD_LENGTH = 12 +const MAX_PASSWORD_LENGTH = 128 +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function messageFor(outcome: CreateAccountOutcome): { ok: boolean; lines: string[] } { + switch (outcome.kind) { + case 'created': + return { ok: true, lines: ['Nalog je uspešno kreiran.'] } + case 'taken': + return { ok: false, lines: ['Nalog sa ovom email adresom već postoji.'] } + case 'invalid': + return { ok: false, lines: outcome.errors } + case 'unauthenticated': + return { ok: false, lines: ['Niste prijavljeni.'] } + case 'forbidden': + return { ok: false, lines: ['Nemate dozvolu za kreiranje naloga.'] } + default: + return { ok: false, lines: ['Došlo je do greške. Pokušajte ponovo.'] } + } +} + +export default function CreateAccountForm() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [submitting, setSubmitting] = useState(false) + const [result, setResult] = useState<{ ok: boolean; lines: string[]; correlationId?: string } | null>(null) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + + // Samo korisnička pomoć: server je jedini koji stvarno validira. + const trimmed = email.trim() + const problems: string[] = [] + if (!EMAIL_PATTERN.test(trimmed)) problems.push('Unesite ispravnu email adresu.') + if (password.length < MIN_PASSWORD_LENGTH) { + problems.push(`Lozinka mora imati najmanje ${MIN_PASSWORD_LENGTH} karaktera.`) + } + if (problems.length > 0) { + setResult({ ok: false, lines: problems }) + return + } + + setSubmitting(true) + setResult(null) + const outcome = await createAccount(trimmed, password) + setPassword('') // lozinka se ne zadržava u stanju posle slanja + if (outcome.kind === 'created') setEmail('') + setResult({ + ...messageFor(outcome), + correlationId: 'correlationId' in outcome ? outcome.correlationId : undefined, + }) + setSubmitting(false) + } + + return ( +
+ + setEmail(e.target.value)} + required + /> + + + setPassword(e.target.value)} + aria-describedby="account-password-hint" + required + /> +

+ Najmanje {MIN_PASSWORD_LENGTH} karaktera, sa velikim i malim slovom, brojem i specijalnim znakom. +

+ + + + {result && ( +
+ {result.lines.map(line =>

{line}

)} + {result.correlationId &&

ID zahteva: {result.correlationId}

} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css index 1f28b1f..8038629 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -5,3 +5,14 @@ main { max-width: 900px; margin: 8vh auto; padding: 0 24px; } h1 { font-size: clamp(2rem, 5vw, 3rem); line-height: 1.15; } .eyebrow { font-weight: 700; letter-spacing: .12em; color: #315d8a; } section { background: white; border: 1px solid #e1e6ed; border-radius: 12px; padding: 22px; margin-top: 32px; } +form { display: grid; gap: 8px; max-width: 420px; } +label { font-weight: 600; margin-top: 8px; } +input { padding: 10px 12px; border: 1px solid #c5cfdb; border-radius: 8px; font: inherit; } +input:focus-visible, button:focus-visible { outline: 2px solid #315d8a; outline-offset: 2px; } +button { margin-top: 12px; padding: 10px 14px; border: 0; border-radius: 8px; background: #315d8a; color: white; font: inherit; font-weight: 600; cursor: pointer; } +button:disabled { opacity: .6; cursor: not-allowed; } +.hint { margin: 0; font-size: .875rem; color: #5b6b7f; } +.result { margin-top: 12px; padding: 10px 12px; border-radius: 8px; } +.result p { margin: 0 0 4px; } +.result.ok { background: #e8f5ec; border: 1px solid #9ad0a8; } +.result.error { background: #fdecec; border: 1px solid #e3a3a3; }