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.

This commit is contained in:
Leonid Pershin
2026-07-25 07:48:17 +03:00
parent 1bdc3323ab
commit c53848477f
33 changed files with 1289 additions and 17 deletions
@@ -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<RegistrationStatusDto>();
group.MapPost("/register", Register).Produces<AuthResponseDto>();
group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
@@ -46,6 +48,15 @@ public static class AuthEndpoints
return app;
}
private static async Task<IResult> RegistrationStatus(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(new RegistrationStatusDto(settings.RegistrationEnabled));
}
private static async Task<IResult> Register(
RegisterCommand command,
ISender sender,
@@ -218,3 +229,6 @@ public sealed record AuthResponseDto(
DateTimeOffset ExpiresAt,
CurrentUserDto User
);
/// <summary>Публичный ответ: включена ли открытая регистрация (для страниц входа/регистрации).</summary>
public sealed record RegistrationStatusDto(bool Enabled);
@@ -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<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(ISender sender, CancellationToken cancellationToken)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings);
}
private static async Task<IResult> 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);
+1
View File
@@ -117,6 +117,7 @@ app.MapShowEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -26,4 +26,9 @@ public static class AuthErrors
"Auth.UserNameTaken",
"Это имя пользователя уже занято."
);
public static readonly Error RegistrationDisabled = Error.Forbidden(
"Auth.RegistrationDisabled",
"Регистрация на сайте отключена администратором."
);
}
@@ -7,7 +7,8 @@ namespace TeleWave.Application.Auth.Register;
public sealed class RegisterCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService
IRefreshTokenService refreshTokenService,
ISiteSettings siteSettings
) : ICommandHandler<RegisterCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
@@ -15,6 +16,10 @@ public sealed class RegisterCommandHandler(
CancellationToken cancellationToken
)
{
// Открытая регистрация должна быть явно включена админом; иначе учётки заводит только он.
if (!await siteSettings.IsRegistrationEnabledAsync(cancellationToken))
return Result.Failure<AuthResult>(AuthErrors.RegistrationDisabled);
var createResult = await identityService.CreateUserAsync(
command.UserName,
command.Password,
@@ -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<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,10 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
public interface ISiteSettings
{
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Settings;
/// <summary>Чтение типизированных значений из key-value настроек с дефолтом при отсутствии ключа.</summary>
public static class AppSettingsReader
{
public static async Task<bool> 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;
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Settings.GetSiteSettings;
public sealed record GetSiteSettingsQuery : IQuery<SiteSettingsDto>;
@@ -0,0 +1,17 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Settings.GetSiteSettings;
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
{
public async Task<SiteSettingsDto> Handle(
GetSiteSettingsQuery query,
CancellationToken cancellationToken
)
{
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
return new SiteSettingsDto(registrationEnabled);
}
}
@@ -0,0 +1,8 @@
namespace TeleWave.Application.Settings;
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
public static class SettingKeys
{
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
public const string RegistrationEnabled = "registration.enabled";
}
@@ -0,0 +1,4 @@
namespace TeleWave.Application.Settings;
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
public sealed record SiteSettingsDto(bool RegistrationEnabled);
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed record UpdateSiteSettingsCommand(bool RegistrationEnabled) : ICommand<Result>;
@@ -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<UpdateSiteSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateSiteSettingsCommand command,
CancellationToken cancellationToken
)
{
// Сохранение выполняет UnitOfWorkBehavior команды.
await siteSettings.SetRegistrationEnabledAsync(command.RegistrationEnabled, cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,19 @@
namespace TeleWave.Domain.Settings;
/// <summary>
/// Простое key-value хранилище настроек сайта (единичные глобальные флаги/значения, которые админ
/// меняет в рантайме). Ключ — стабильный строковый идентификатор; значение — строка (парсится
/// потребителем). Отсутствие ключа трактуется как значение по умолчанию.
/// </summary>
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;
}
@@ -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<IRefreshTokenService, RefreshTokenService>();
services.AddScoped<IRoleService, RoleService>();
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<ISiteSettings, SiteSettings>();
services.AddScoped<DbInitializer>();
AddMedia(services, configuration);
@@ -0,0 +1,812 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromShowId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");
b.ToTable("BumperAssets");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<string>("BumperAccentColor")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperBackgroundColor")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperBackgroundColor2")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperBackgroundExtension")
.HasColumnType("text");
b.Property<int>("BumperDurationSeconds")
.HasColumnType("integer");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<int>("BumperMode")
.HasColumnType("integer");
b.Property<string>("BumperMusicExtension")
.HasColumnType("text");
b.Property<string>("BumperNextLabel")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperNowLabel")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumperOnlyBetweenDifferentShows")
.HasColumnType("boolean");
b.Property<int>("BumperRevision")
.HasColumnType("integer");
b.Property<string>("BumperTextColor")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<int>("NextJingleIndex")
.HasColumnType("integer");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelJingle");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Key");
b.ToTable("AppSettings");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AppSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppSettings",
columns: table => new
{
Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Value = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppSettings", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppSettings");
}
}
}
@@ -561,6 +561,22 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Key");
b.ToTable("AppSettings");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
@@ -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<AppDbContext> options)
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -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<AppSetting>
{
public void Configure(EntityTypeBuilder<AppSetting> builder)
{
builder.HasKey(x => x.Key);
builder.Property(x => x.Key).HasMaxLength(128);
builder.Property(x => x.Value).IsRequired().HasMaxLength(1024);
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Settings;
using TeleWave.Domain.Settings;
namespace TeleWave.Infrastructure.Settings;
/// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама —
/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary>
public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
{
public Task<bool> 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);
}
}
@@ -13,9 +13,15 @@ public class RegisterCommandHandlerTests
private readonly IJwtTokenService _jwtTokenService = Substitute.For<IJwtTokenService>();
private readonly IRefreshTokenService _refreshTokenService =
Substitute.For<IRefreshTokenService>();
private readonly ISiteSettings _siteSettings = Substitute.For<ISiteSettings>();
public RegisterCommandHandlerTests()
{
_siteSettings.IsRegistrationEnabledAsync(Arg.Any<CancellationToken>()).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<CancellationToken>()).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<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WithTakenUserName_ReturnsFailure()
{
@@ -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 (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
<Card>
<CardHeader>
<CardTitle>{t('admin.settings.registration')}</CardTitle>
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={registrationEnabled}
disabled={isLoading}
onChange={(e) => setRegistrationEnabled(e.target.checked)}
/>
{t('admin.settings.registrationLabel')}
</label>
<div>
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,10 @@
import { apiRequest } from '@/shared/api/client'
import type { SiteSettings } from '@/shared/api/types'
export function getSiteSettings() {
return apiRequest<SiteSettings>('/admin/settings')
}
export function updateSiteSettings(body: SiteSettings) {
return apiRequest<void>('/admin/settings', { method: 'PUT', body })
}
+6 -1
View File
@@ -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<RegistrationStatus>('/auth/registration')
}
export function login(userName: string, password: string) {
return apiRequest<AuthResponse>('/auth/login', { method: 'POST', body: { userName, password } })
}
+21
View File
@@ -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,
+7
View File
@@ -57,6 +57,13 @@ function AdminLayout() {
>
{t('admin.maintenance.title')}
</Link>
<Link
to="/admin/settings"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
>
{t('admin.settings.title')}
</Link>
</nav>
<Outlet />
</div>
+4
View File
@@ -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 })
+15 -6
View File
@@ -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 (
<div className="mx-auto max-w-sm py-8">
<Card>
@@ -20,12 +27,14 @@ function LoginPage() {
</CardHeader>
<CardContent>
<LoginForm onSuccess={() => void navigate({ to: '/dashboard' })} />
<p className="mt-4 text-center text-sm text-muted-foreground">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-primary hover:underline">
{t('nav.register')}
</Link>
</p>
{registration?.enabled && (
<p className="mt-4 text-center text-sm text-muted-foreground">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-primary hover:underline">
{t('nav.register')}
</Link>
</p>
)}
</CardContent>
</Card>
</div>
+30 -8
View File
@@ -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 (
<div className="mx-auto max-w-sm py-8">
<Card>
<CardHeader>
<CardTitle>{t('auth.registerTitle')}</CardTitle>
<CardDescription>{t('auth.registerSubtitle')}</CardDescription>
<CardDescription>
{disabled ? t('auth.registrationClosed') : t('auth.registerSubtitle')}
</CardDescription>
</CardHeader>
<CardContent>
<RegisterForm onSuccess={() => void navigate({ to: '/dashboard' })} />
<p className="mt-4 text-center text-sm text-muted-foreground">
{t('auth.haveAccount')}{' '}
<Link to="/login" className="text-primary hover:underline">
{t('nav.login')}
</Link>
</p>
{disabled ? (
<p className="text-center text-sm text-muted-foreground">
{t('auth.registrationClosedHint')}{' '}
<Link to="/login" className="text-primary hover:underline">
{t('nav.login')}
</Link>
</p>
) : (
<>
<RegisterForm onSuccess={() => void navigate({ to: '/dashboard' })} />
<p className="mt-4 text-center text-sm text-muted-foreground">
{t('auth.haveAccount')}{' '}
<Link to="/login" className="text-primary hover:underline">
{t('nav.login')}
</Link>
</p>
</>
)}
</CardContent>
</Card>
</div>
+4
View File
@@ -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
+18
View File
@@ -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',
},
},
},
},