From c53848477f27f06cd791c167c737670f68663b36 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 07:48:17 +0300 Subject: [PATCH] Add settings endpoints and registration status check: implement new API endpoints for settings management, including registration status retrieval, and update the registration command handler to enforce registration rules based on site settings. --- .../TeleWave.Api/Endpoints/AuthEndpoints.cs | 14 + .../Endpoints/SettingsEndpoints.cs | 44 + backend/src/TeleWave.Api/Program.cs | 1 + .../TeleWave.Application/Auth/AuthErrors.cs | 5 + .../Auth/Register/RegisterCommandHandler.cs | 7 +- .../Common/Interfaces/IAppDbContext.cs | 2 + .../Common/Interfaces/ISiteSettings.cs | 10 + .../Settings/AppSettingsReader.cs | 23 + .../GetSiteSettings/GetSiteSettingsQuery.cs | 5 + .../GetSiteSettingsQueryHandler.cs | 17 + .../Settings/SettingKeys.cs | 8 + .../Settings/SiteSettingsDto.cs | 4 + .../UpdateSiteSettingsCommand.cs | 6 + .../UpdateSiteSettingsCommandHandler.cs | 19 + .../TeleWave.Domain/Settings/AppSetting.cs | 19 + .../DependencyInjection.cs | 2 + .../20260725041827_AppSettings.Designer.cs | 812 ++++++++++++++++++ .../Migrations/20260725041827_AppSettings.cs | 33 + .../Migrations/AppDbContextModelSnapshot.cs | 16 + .../Persistence/AppDbContext.cs | 2 + .../Configurations/AppSettingConfiguration.cs | 15 + .../Settings/SiteSettings.cs | 27 + .../Auth/RegisterCommandHandlerTests.cs | 23 +- .../features/admin/settings/SettingsPanel.tsx | 62 ++ frontend/src/features/admin/settings/api.ts | 10 + frontend/src/features/auth/api.ts | 7 +- frontend/src/routeTree.gen.ts | 21 + frontend/src/routes/admin.tsx | 7 + frontend/src/routes/admin/settings.tsx | 4 + frontend/src/routes/login.tsx | 21 +- frontend/src/routes/register.tsx | 38 +- frontend/src/shared/api/types.ts | 4 + frontend/src/shared/lib/i18n.ts | 18 + 33 files changed, 1289 insertions(+), 17 deletions(-) create mode 100644 backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/ISiteSettings.cs create mode 100644 backend/src/TeleWave.Application/Settings/AppSettingsReader.cs create mode 100644 backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQuery.cs create mode 100644 backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Settings/SettingKeys.cs create mode 100644 backend/src/TeleWave.Application/Settings/SiteSettingsDto.cs create mode 100644 backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommand.cs create mode 100644 backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommandHandler.cs create mode 100644 backend/src/TeleWave.Domain/Settings/AppSetting.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725041827_AppSettings.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725041827_AppSettings.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/AppSettingConfiguration.cs create mode 100644 backend/src/TeleWave.Infrastructure/Settings/SiteSettings.cs create mode 100644 frontend/src/features/admin/settings/SettingsPanel.tsx create mode 100644 frontend/src/features/admin/settings/api.ts create mode 100644 frontend/src/routes/admin/settings.tsx diff --git a/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs index 82058bb..42ae55e 100644 --- a/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs @@ -9,6 +9,7 @@ using TeleWave.Application.Auth.Logout; using TeleWave.Application.Auth.Me; using TeleWave.Application.Auth.Refresh; using TeleWave.Application.Auth.Register; +using TeleWave.Application.Settings.GetSiteSettings; namespace TeleWave.Api.Endpoints; @@ -22,6 +23,7 @@ public static class AuthEndpoints .WithTags("Auth") .RequireRateLimiting(RateLimiting.AuthPolicy); + group.MapGet("/registration", RegistrationStatus).Produces(); group.MapPost("/register", Register).Produces(); group.MapPost("/login", Login).Produces(); group.MapPost("/refresh", Refresh).Produces(); @@ -46,6 +48,15 @@ public static class AuthEndpoints return app; } + private static async Task RegistrationStatus( + ISender sender, + CancellationToken cancellationToken + ) + { + var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken); + return Results.Ok(new RegistrationStatusDto(settings.RegistrationEnabled)); + } + private static async Task Register( RegisterCommand command, ISender sender, @@ -218,3 +229,6 @@ public sealed record AuthResponseDto( DateTimeOffset ExpiresAt, CurrentUserDto User ); + +/// Публичный ответ: включена ли открытая регистрация (для страниц входа/регистрации). +public sealed record RegistrationStatusDto(bool Enabled); diff --git a/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs new file mode 100644 index 0000000..aa59721 --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs @@ -0,0 +1,44 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Settings; +using TeleWave.Application.Settings.GetSiteSettings; +using TeleWave.Application.Settings.UpdateSiteSettings; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class SettingsEndpoints +{ + public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/settings") + .WithTags("Admin.Settings") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", GetSettings).Produces(); + admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task GetSettings(ISender sender, CancellationToken cancellationToken) + { + var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken); + return Results.Ok(settings); + } + + private static async Task UpdateSettings( + UpdateSiteSettingsBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateSiteSettingsCommand(body.RegistrationEnabled), + cancellationToken + ); + return result.ToHttpResult(); + } +} + +public sealed record UpdateSiteSettingsBody(bool RegistrationEnabled); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 1f821d9..feb83ca 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -117,6 +117,7 @@ app.MapShowEndpoints(); app.MapChannelEndpoints(); app.MapStreamingEndpoints(); app.MapMaintenanceEndpoints(); +app.MapSettingsEndpoints(); // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. app.UseDefaultFiles(); diff --git a/backend/src/TeleWave.Application/Auth/AuthErrors.cs b/backend/src/TeleWave.Application/Auth/AuthErrors.cs index 6086405..37795c7 100644 --- a/backend/src/TeleWave.Application/Auth/AuthErrors.cs +++ b/backend/src/TeleWave.Application/Auth/AuthErrors.cs @@ -26,4 +26,9 @@ public static class AuthErrors "Auth.UserNameTaken", "Это имя пользователя уже занято." ); + + public static readonly Error RegistrationDisabled = Error.Forbidden( + "Auth.RegistrationDisabled", + "Регистрация на сайте отключена администратором." + ); } diff --git a/backend/src/TeleWave.Application/Auth/Register/RegisterCommandHandler.cs b/backend/src/TeleWave.Application/Auth/Register/RegisterCommandHandler.cs index b465062..b3562ec 100644 --- a/backend/src/TeleWave.Application/Auth/Register/RegisterCommandHandler.cs +++ b/backend/src/TeleWave.Application/Auth/Register/RegisterCommandHandler.cs @@ -7,7 +7,8 @@ namespace TeleWave.Application.Auth.Register; public sealed class RegisterCommandHandler( IIdentityService identityService, IJwtTokenService jwtTokenService, - IRefreshTokenService refreshTokenService + IRefreshTokenService refreshTokenService, + ISiteSettings siteSettings ) : ICommandHandler> { public async Task> Handle( @@ -15,6 +16,10 @@ public sealed class RegisterCommandHandler( CancellationToken cancellationToken ) { + // Открытая регистрация должна быть явно включена админом; иначе учётки заводит только он. + if (!await siteSettings.IsRegistrationEnabledAsync(cancellationToken)) + return Result.Failure(AuthErrors.RegistrationDisabled); + var createResult = await identityService.CreateUserAsync( command.UserName, command.Password, diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs index 9988314..3b83d59 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs @@ -3,6 +3,7 @@ using TeleWave.Domain.Auth; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Library; using TeleWave.Domain.Media; +using TeleWave.Domain.Settings; namespace TeleWave.Application.Common.Interfaces; @@ -14,6 +15,7 @@ public interface IAppDbContext DbSet Channels { get; } DbSet ScheduleEntries { get; } DbSet BumperAssets { get; } + DbSet AppSettings { get; } Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/ISiteSettings.cs b/backend/src/TeleWave.Application/Common/Interfaces/ISiteSettings.cs new file mode 100644 index 0000000..e5e1c81 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/ISiteSettings.cs @@ -0,0 +1,10 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров. +public interface ISiteSettings +{ + Task IsRegistrationEnabledAsync(CancellationToken cancellationToken); + + /// Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды). + Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Settings/AppSettingsReader.cs b/backend/src/TeleWave.Application/Settings/AppSettingsReader.cs new file mode 100644 index 0000000..d0b3b8d --- /dev/null +++ b/backend/src/TeleWave.Application/Settings/AppSettingsReader.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Settings; + +/// Чтение типизированных значений из key-value настроек с дефолтом при отсутствии ключа. +public static class AppSettingsReader +{ + public static async Task GetBoolSettingAsync( + this IAppDbContext db, + string key, + bool defaultValue, + CancellationToken cancellationToken + ) + { + var value = await db.AppSettings.AsNoTracking() + .Where(s => s.Key == key) + .Select(s => s.Value) + .FirstOrDefaultAsync(cancellationToken); + + return value is not null && bool.TryParse(value, out var parsed) ? parsed : defaultValue; + } +} diff --git a/backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQuery.cs b/backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQuery.cs new file mode 100644 index 0000000..0696f8f --- /dev/null +++ b/backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQuery.cs @@ -0,0 +1,5 @@ +using LiteCqrs; + +namespace TeleWave.Application.Settings.GetSiteSettings; + +public sealed record GetSiteSettingsQuery : IQuery; diff --git a/backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQueryHandler.cs b/backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQueryHandler.cs new file mode 100644 index 0000000..3f6affb --- /dev/null +++ b/backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQueryHandler.cs @@ -0,0 +1,17 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Settings.GetSiteSettings; + +public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings) + : IQueryHandler +{ + public async Task Handle( + GetSiteSettingsQuery query, + CancellationToken cancellationToken + ) + { + var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken); + return new SiteSettingsDto(registrationEnabled); + } +} diff --git a/backend/src/TeleWave.Application/Settings/SettingKeys.cs b/backend/src/TeleWave.Application/Settings/SettingKeys.cs new file mode 100644 index 0000000..03441f2 --- /dev/null +++ b/backend/src/TeleWave.Application/Settings/SettingKeys.cs @@ -0,0 +1,8 @@ +namespace TeleWave.Application.Settings; + +/// Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting). +public static class SettingKeys +{ + /// Разрешена ли открытая регистрация пользователей (по умолчанию — нет). + public const string RegistrationEnabled = "registration.enabled"; +} diff --git a/backend/src/TeleWave.Application/Settings/SiteSettingsDto.cs b/backend/src/TeleWave.Application/Settings/SiteSettingsDto.cs new file mode 100644 index 0000000..d68a41d --- /dev/null +++ b/backend/src/TeleWave.Application/Settings/SiteSettingsDto.cs @@ -0,0 +1,4 @@ +namespace TeleWave.Application.Settings; + +/// Глобальные настройки сайта, управляемые администратором. +public sealed record SiteSettingsDto(bool RegistrationEnabled); diff --git a/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommand.cs b/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommand.cs new file mode 100644 index 0000000..6de1be8 --- /dev/null +++ b/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Settings.UpdateSiteSettings; + +public sealed record UpdateSiteSettingsCommand(bool RegistrationEnabled) : ICommand; diff --git a/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommandHandler.cs new file mode 100644 index 0000000..97033eb --- /dev/null +++ b/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommandHandler.cs @@ -0,0 +1,19 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Settings.UpdateSiteSettings; + +public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings) + : ICommandHandler +{ + public async Task Handle( + UpdateSiteSettingsCommand command, + CancellationToken cancellationToken + ) + { + // Сохранение выполняет UnitOfWorkBehavior команды. + await siteSettings.SetRegistrationEnabledAsync(command.RegistrationEnabled, cancellationToken); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Domain/Settings/AppSetting.cs b/backend/src/TeleWave.Domain/Settings/AppSetting.cs new file mode 100644 index 0000000..934d015 --- /dev/null +++ b/backend/src/TeleWave.Domain/Settings/AppSetting.cs @@ -0,0 +1,19 @@ +namespace TeleWave.Domain.Settings; + +/// +/// Простое key-value хранилище настроек сайта (единичные глобальные флаги/значения, которые админ +/// меняет в рантайме). Ключ — стабильный строковый идентификатор; значение — строка (парсится +/// потребителем). Отсутствие ключа трактуется как значение по умолчанию. +/// +public class AppSetting +{ + public string Key { get; private set; } = string.Empty; + public string Value { get; private set; } = string.Empty; + + private AppSetting() { } + + public static AppSetting Create(string key, string value) => + new() { Key = key, Value = value }; + + public void SetValue(string value) => Value = value; +} diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 7154e8c..260ab7a 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -14,6 +14,7 @@ using TeleWave.Infrastructure.Broadcast; using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Media; using TeleWave.Infrastructure.Persistence; +using TeleWave.Infrastructure.Settings; using TeleWave.Infrastructure.Streaming; namespace TeleWave.Infrastructure; @@ -90,6 +91,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); AddMedia(services, configuration); diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725041827_AppSettings.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725041827_AppSettings.Designer.cs new file mode 100644 index 0000000..2066e29 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725041827_AppSettings.Designer.cs @@ -0,0 +1,812 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260725041827_AppSettings")] + partial class AppSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperAccentColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperBackgroundColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperBackgroundColor2") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperBackgroundExtension") + .HasColumnType("text"); + + b.Property("BumperDurationSeconds") + .HasColumnType("integer"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperMode") + .HasColumnType("integer"); + + b.Property("BumperMusicExtension") + .HasColumnType("text"); + + b.Property("BumperNextLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperNowLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperOnlyBetweenDifferentShows") + .HasColumnType("boolean"); + + b.Property("BumperRevision") + .HasColumnType("integer"); + + b.Property("BumperTextColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextJingleIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelJingle"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.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("IsBlocked") + .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("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Jingles") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("Jingles"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725041827_AppSettings.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725041827_AppSettings.cs new file mode 100644 index 0000000..6cfe365 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725041827_AppSettings.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class AppSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppSettings", + columns: table => new + { + Key = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Value = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppSettings", x => x.Key); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppSettings"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 118b926..0027e62 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -561,6 +561,22 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("MediaAssets"); }); + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => { b.Property("Id") diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index 46126b8..70bc33f 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -6,6 +6,7 @@ using TeleWave.Domain.Auth; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Library; using TeleWave.Domain.Media; +using TeleWave.Domain.Settings; using TeleWave.Infrastructure.Identity; namespace TeleWave.Infrastructure.Persistence; @@ -24,6 +25,7 @@ public class AppDbContext(DbContextOptions options) public DbSet Channels => Set(); public DbSet ScheduleEntries => Set(); public DbSet BumperAssets => Set(); + public DbSet AppSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/AppSettingConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/AppSettingConfiguration.cs new file mode 100644 index 0000000..78ccd22 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/AppSettingConfiguration.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeleWave.Domain.Settings; + +namespace TeleWave.Infrastructure.Persistence.Configurations; + +public class AppSettingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Key); + builder.Property(x => x.Key).HasMaxLength(128); + builder.Property(x => x.Value).IsRequired().HasMaxLength(1024); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Settings/SiteSettings.cs b/backend/src/TeleWave.Infrastructure/Settings/SiteSettings.cs new file mode 100644 index 0000000..34dec09 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Settings/SiteSettings.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Settings; +using TeleWave.Domain.Settings; + +namespace TeleWave.Infrastructure.Settings; + +/// Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама — +/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст). +public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings +{ + public Task IsRegistrationEnabledAsync(CancellationToken cancellationToken) => + dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken); + + public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken) + { + var value = enabled ? "true" : "false"; + var existing = await dbContext.AppSettings.FirstOrDefaultAsync( + s => s.Key == SettingKeys.RegistrationEnabled, + cancellationToken + ); + if (existing is null) + dbContext.AppSettings.Add(AppSetting.Create(SettingKeys.RegistrationEnabled, value)); + else + existing.SetValue(value); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Auth/RegisterCommandHandlerTests.cs b/backend/tests/TeleWave.Application.Tests/Auth/RegisterCommandHandlerTests.cs index 342a174..2e6df74 100644 --- a/backend/tests/TeleWave.Application.Tests/Auth/RegisterCommandHandlerTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Auth/RegisterCommandHandlerTests.cs @@ -13,9 +13,15 @@ public class RegisterCommandHandlerTests private readonly IJwtTokenService _jwtTokenService = Substitute.For(); private readonly IRefreshTokenService _refreshTokenService = Substitute.For(); + private readonly ISiteSettings _siteSettings = Substitute.For(); + + public RegisterCommandHandlerTests() + { + _siteSettings.IsRegistrationEnabledAsync(Arg.Any()).Returns(true); + } private RegisterCommandHandler CreateHandler() => - new(_identityService, _jwtTokenService, _refreshTokenService); + new(_identityService, _jwtTokenService, _refreshTokenService, _siteSettings); [Fact] public async Task Handle_WithNewUserName_CreatesUserAndReturnsAuthResult() @@ -41,6 +47,21 @@ public class RegisterCommandHandlerTests Assert.Equal("bob", result.Value.User.UserName); } + [Fact] + public async Task Handle_WhenRegistrationDisabled_ReturnsForbidden() + { + _siteSettings.IsRegistrationEnabledAsync(Arg.Any()).Returns(false); + + var result = await CreateHandler() + .Handle(new RegisterCommand("bob", "password123"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(AuthErrors.RegistrationDisabled, result.Error); + await _identityService + .DidNotReceive() + .CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + [Fact] public async Task Handle_WithTakenUserName_ReturnsFailure() { diff --git a/frontend/src/features/admin/settings/SettingsPanel.tsx b/frontend/src/features/admin/settings/SettingsPanel.tsx new file mode 100644 index 0000000..1d0b4ab --- /dev/null +++ b/frontend/src/features/admin/settings/SettingsPanel.tsx @@ -0,0 +1,62 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { HttpError } from '@/shared/api/client' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { toast } from '@/shared/ui/toast-store' +import { getSiteSettings, updateSiteSettings } from './api' + +export function SettingsPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [registrationEnabled, setRegistrationEnabled] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'settings'], + queryFn: getSiteSettings, + }) + + useEffect(() => { + if (data) setRegistrationEnabled(data.registrationEnabled) + }, [data]) + + const save = useMutation({ + mutationFn: () => updateSiteSettings({ registrationEnabled }), + onSuccess: () => { + toast.success(t('settings.saved')) + void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }) + }, + onError: (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')), + }) + + return ( +
+

{t('admin.settings.title')}

+ + + + {t('admin.settings.registration')} + {t('admin.settings.registrationHint')} + + + +
+ +
+
+
+
+ ) +} diff --git a/frontend/src/features/admin/settings/api.ts b/frontend/src/features/admin/settings/api.ts new file mode 100644 index 0000000..8344b02 --- /dev/null +++ b/frontend/src/features/admin/settings/api.ts @@ -0,0 +1,10 @@ +import { apiRequest } from '@/shared/api/client' +import type { SiteSettings } from '@/shared/api/types' + +export function getSiteSettings() { + return apiRequest('/admin/settings') +} + +export function updateSiteSettings(body: SiteSettings) { + return apiRequest('/admin/settings', { method: 'PUT', body }) +} diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts index db0fe46..eb847b9 100644 --- a/frontend/src/features/auth/api.ts +++ b/frontend/src/features/auth/api.ts @@ -1,7 +1,12 @@ import { apiRequest, setAccessToken } from '@/shared/api/client' -import type { AuthResponse, CurrentUser } from '@/shared/api/types' +import type { AuthResponse, CurrentUser, RegistrationStatus } from '@/shared/api/types' import { useAuthStore } from './store' +/** Публично: включена ли открытая регистрация (для страниц входа/регистрации). */ +export function fetchRegistrationStatus() { + return apiRequest('/auth/registration') +} + export function login(userName: string, password: string) { return apiRequest('/auth/login', { method: 'POST', body: { userName, password } }) } diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 4195c6b..9bff091 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as AdminChannelsRouteImport } from './routes/admin/channels' import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance' import { Route as AdminMediaRouteImport } from './routes/admin/media' import { Route as AdminRolesRouteImport } from './routes/admin/roles' +import { Route as AdminSettingsRouteImport } from './routes/admin/settings' import { Route as AdminShowsRouteImport } from './routes/admin/shows' import { Route as AdminUsersRouteImport } from './routes/admin/users' import { Route as AdminChannelsIndexRouteImport } from './routes/admin/channels.index' @@ -82,6 +83,11 @@ const AdminRolesRoute = AdminRolesRouteImport.update({ path: '/roles', getParentRoute: () => AdminRoute, } as any) +const AdminSettingsRoute = AdminSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AdminRoute, +} as any) const AdminShowsRoute = AdminShowsRouteImport.update({ id: '/shows', path: '/shows', @@ -124,6 +130,7 @@ export interface FileRoutesByFullPath { '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute + '/admin/settings': typeof AdminSettingsRoute '/admin/shows': typeof AdminShowsRouteWithChildren '/admin/users': typeof AdminUsersRoute '/admin/': typeof AdminIndexRoute @@ -141,6 +148,7 @@ export interface FileRoutesByTo { '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute + '/admin/settings': typeof AdminSettingsRoute '/admin/users': typeof AdminUsersRoute '/admin': typeof AdminIndexRoute '/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute @@ -160,6 +168,7 @@ export interface FileRoutesById { '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute + '/admin/settings': typeof AdminSettingsRoute '/admin/shows': typeof AdminShowsRouteWithChildren '/admin/users': typeof AdminUsersRoute '/admin/': typeof AdminIndexRoute @@ -181,6 +190,7 @@ export interface FileRouteTypes { | '/admin/maintenance' | '/admin/media' | '/admin/roles' + | '/admin/settings' | '/admin/shows' | '/admin/users' | '/admin/' @@ -198,6 +208,7 @@ export interface FileRouteTypes { | '/admin/maintenance' | '/admin/media' | '/admin/roles' + | '/admin/settings' | '/admin/users' | '/admin' | '/admin/channels/$channelId' @@ -216,6 +227,7 @@ export interface FileRouteTypes { | '/admin/maintenance' | '/admin/media' | '/admin/roles' + | '/admin/settings' | '/admin/shows' | '/admin/users' | '/admin/' @@ -313,6 +325,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminRolesRouteImport parentRoute: typeof AdminRoute } + '/admin/settings': { + id: '/admin/settings' + path: '/settings' + fullPath: '/admin/settings' + preLoaderRoute: typeof AdminSettingsRouteImport + parentRoute: typeof AdminRoute + } '/admin/shows': { id: '/admin/shows' path: '/shows' @@ -391,6 +410,7 @@ interface AdminRouteChildren { AdminMaintenanceRoute: typeof AdminMaintenanceRoute AdminMediaRoute: typeof AdminMediaRoute AdminRolesRoute: typeof AdminRolesRoute + AdminSettingsRoute: typeof AdminSettingsRoute AdminShowsRoute: typeof AdminShowsRouteWithChildren AdminUsersRoute: typeof AdminUsersRoute AdminIndexRoute: typeof AdminIndexRoute @@ -401,6 +421,7 @@ const AdminRouteChildren: AdminRouteChildren = { AdminMaintenanceRoute: AdminMaintenanceRoute, AdminMediaRoute: AdminMediaRoute, AdminRolesRoute: AdminRolesRoute, + AdminSettingsRoute: AdminSettingsRoute, AdminShowsRoute: AdminShowsRouteWithChildren, AdminUsersRoute: AdminUsersRoute, AdminIndexRoute: AdminIndexRoute, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 142da5d..7ef35fe 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -57,6 +57,13 @@ function AdminLayout() { > {t('admin.maintenance.title')} + + {t('admin.settings.title')} + diff --git a/frontend/src/routes/admin/settings.tsx b/frontend/src/routes/admin/settings.tsx new file mode 100644 index 0000000..4aa1b77 --- /dev/null +++ b/frontend/src/routes/admin/settings.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SettingsPanel } from '@/features/admin/settings/SettingsPanel' + +export const Route = createFileRoute('/admin/settings')({ component: SettingsPanel }) diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index 78e3cb5..982aa1c 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -1,6 +1,8 @@ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { LoginForm } from '@/features/auth/LoginForm' +import { fetchRegistrationStatus } from '@/features/auth/api' import { useRequireGuest } from '@/features/auth/guards' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -11,6 +13,11 @@ function LoginPage() { const { t } = useTranslation() const navigate = useNavigate() + const { data: registration } = useQuery({ + queryKey: ['auth', 'registration'], + queryFn: fetchRegistrationStatus, + }) + return (
@@ -20,12 +27,14 @@ function LoginPage() { void navigate({ to: '/dashboard' })} /> -

- {t('auth.noAccount')}{' '} - - {t('nav.register')} - -

+ {registration?.enabled && ( +

+ {t('auth.noAccount')}{' '} + + {t('nav.register')} + +

+ )}
diff --git a/frontend/src/routes/register.tsx b/frontend/src/routes/register.tsx index 88219c8..1633f75 100644 --- a/frontend/src/routes/register.tsx +++ b/frontend/src/routes/register.tsx @@ -1,6 +1,8 @@ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { RegisterForm } from '@/features/auth/RegisterForm' +import { fetchRegistrationStatus } from '@/features/auth/api' import { useRequireGuest } from '@/features/auth/guards' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -11,21 +13,41 @@ function RegisterPage() { const { t } = useTranslation() const navigate = useNavigate() + const { data: registration, isLoading } = useQuery({ + queryKey: ['auth', 'registration'], + queryFn: fetchRegistrationStatus, + }) + + const disabled = !isLoading && registration?.enabled === false + return (
{t('auth.registerTitle')} - {t('auth.registerSubtitle')} + + {disabled ? t('auth.registrationClosed') : t('auth.registerSubtitle')} + - void navigate({ to: '/dashboard' })} /> -

- {t('auth.haveAccount')}{' '} - - {t('nav.login')} - -

+ {disabled ? ( +

+ {t('auth.registrationClosedHint')}{' '} + + {t('nav.login')} + +

+ ) : ( + <> + void navigate({ to: '/dashboard' })} /> +

+ {t('auth.haveAccount')}{' '} + + {t('nav.login')} + +

+ + )}
diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 85f96da..e024a1a 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -16,6 +16,10 @@ export type AuthResponse = { user: CurrentUser } +export type RegistrationStatus = { enabled: boolean } + +export type SiteSettings = { registrationEnabled: boolean } + export type RoleDto = { id: string name: string diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 3694788..564f629 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -47,6 +47,8 @@ const resources = { submitRegister: 'Зарегистрироваться', noAccount: 'Нет аккаунта?', haveAccount: 'Уже есть аккаунт?', + registrationClosed: 'Регистрация закрыта', + registrationClosedHint: 'Открытая регистрация отключена. Учётную запись может завести администратор.', invalidCredentials: 'Неверное имя пользователя или пароль', userNameTaken: 'Это имя пользователя уже занято', blocked: 'Аккаунт заблокирован администратором', @@ -229,6 +231,13 @@ const resources = { confirmDeleteShows: 'Удалить ВСЕ шоу безвозвратно?', doneCount: 'Удалено: {{count}}', }, + settings: { + title: 'Настройки', + registration: 'Регистрация', + registrationHint: + 'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.', + registrationLabel: 'Разрешить регистрацию на сайте', + }, }, }, }, @@ -277,6 +286,8 @@ const resources = { submitRegister: 'Sign up', noAccount: "Don't have an account?", haveAccount: 'Already have an account?', + registrationClosed: 'Registration is closed', + registrationClosedHint: 'Public registration is disabled. An administrator can create an account for you.', invalidCredentials: 'Invalid username or password', userNameTaken: 'This username is already taken', blocked: 'Account blocked by an administrator', @@ -459,6 +470,13 @@ const resources = { confirmDeleteShows: 'Permanently delete ALL shows?', doneCount: 'Deleted: {{count}}', }, + settings: { + title: 'Settings', + registration: 'Registration', + registrationHint: + 'When off, new users cannot sign up themselves — only an administrator can create accounts.', + registrationLabel: 'Allow public registration', + }, }, }, },