Add password reset functionality for admin users: implement ResetPassword endpoint, update AdminUserEndpoints to include password reset logic, and enhance user interface for password management. Update translations for new features and ensure proper error handling in the reset process.

This commit is contained in:
Leonid Pershin
2026-07-25 19:49:08 +03:00
parent d44aa44d95
commit 1f6fa6f1ae
30 changed files with 1755 additions and 58 deletions
@@ -5,6 +5,7 @@ using TeleWave.Application.Admin.Users.CreateUser;
using TeleWave.Application.Admin.Users.DeleteUser;
using TeleWave.Application.Admin.Users.GetUser;
using TeleWave.Application.Admin.Users.ListUsers;
using TeleWave.Application.Admin.Users.ResetPassword;
using TeleWave.Application.Admin.Users.UnblockUser;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
@@ -29,6 +30,9 @@ public static class AdminUserEndpoints
admin
.MapPost("/{id:guid}/unblock", UnblockUser)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/password", ResetPassword)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
return app;
@@ -96,6 +100,20 @@ public static class AdminUserEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> ResetPassword(
Guid id,
ResetPasswordBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ResetUserPasswordCommand(id, body.NewPassword),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteUser(
Guid id,
ISender sender,
@@ -108,3 +126,5 @@ public static class AdminUserEndpoints
}
public sealed record CreateUserBody(string UserName, string Password, Guid RoleId);
public sealed record ResetPasswordBody(string NewPassword);
@@ -538,8 +538,12 @@ public static class ChannelEndpoints
new CreateProgrammingOverrideCommand(
id,
body.Mode,
body.Recurrence,
body.StartsAtUtc,
body.EndsAtUtc,
body.DayOfWeek,
body.StartMinute,
body.EndMinute,
body.Shows
),
cancellationToken
@@ -659,7 +663,11 @@ internal static class BumperFiles
public sealed record CreateOverrideBody(
OverrideMode Mode,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
OverrideRecurrence Recurrence,
DateTimeOffset? StartsAtUtc,
DateTimeOffset? EndsAtUtc,
int? DayOfWeek,
int? StartMinute,
int? EndMinute,
IReadOnlyList<OverrideShowInput> Shows
);
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ResetPassword;
/// <summary>Сброс пароля пользователя администратором (без текущего пароля).</summary>
public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand<Result>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ResetPassword;
public sealed class ResetUserPasswordCommandHandler(IIdentityService identityService)
: ICommandHandler<ResetUserPasswordCommand, Result>
{
public Task<Result> Handle(
ResetUserPasswordCommand command,
CancellationToken cancellationToken
) => identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken);
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Users.ResetPassword;
public sealed class ResetUserPasswordCommandValidator : AbstractValidator<ResetUserPasswordCommand>
{
public ResetUserPasswordCommandValidator()
{
// Длина — здесь; сложность (цифра/заглавная) проверяют валидаторы ASP.NET Identity при сбросе.
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
}
}
@@ -27,8 +27,12 @@ public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight);
public sealed record ProgrammingOverrideDto(
Guid Id,
OverrideMode Mode,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
OverrideRecurrence Recurrence,
DateTimeOffset? StartsAtUtc,
DateTimeOffset? EndsAtUtc,
int? DayOfWeek,
int? StartMinute,
int? EndMinute,
IReadOnlyList<OverrideShowDto> Shows
);
@@ -7,7 +7,11 @@ namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed record CreateProgrammingOverrideCommand(
Guid ChannelId,
OverrideMode Mode,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
OverrideRecurrence Recurrence,
DateTimeOffset? StartsAtUtc,
DateTimeOffset? EndsAtUtc,
int? DayOfWeek,
int? StartMinute,
int? EndMinute,
IReadOnlyList<OverrideShowInput> Shows
) : ICommand<Result<Guid>>;
@@ -2,6 +2,7 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.CreateOverride;
@@ -13,8 +14,24 @@ public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbCont
CancellationToken cancellationToken
)
{
if (command.EndsAtUtc <= command.StartsAtUtc)
var weekly = command.Recurrence == OverrideRecurrence.Weekly;
if (weekly)
{
if (
command.DayOfWeek is not (>= 0 and <= 6)
|| command.StartMinute is not { } sm
|| command.EndMinute is not { } em
|| em <= sm
|| sm < 0
|| em > 1440
)
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
}
else if (command.StartsAtUtc is not { } start || command.EndsAtUtc is not { } end || end <= start)
{
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
}
if (command.Shows.Count == 0)
return Result.Failure<Guid>(ChannelErrors.OverrideNeedsShow);
@@ -33,7 +50,14 @@ public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbCont
if (existingCount != showIds.Count)
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
var ovr = channel.AddOverride(command.Mode, command.StartsAtUtc, command.EndsAtUtc);
var ovr = weekly
? channel.AddWeeklyOverride(
command.Mode,
command.DayOfWeek!.Value,
command.StartMinute!.Value,
command.EndMinute!.Value
)
: channel.AddOverride(command.Mode, command.StartsAtUtc!.Value, command.EndsAtUtc!.Value);
foreach (var show in command.Shows)
ovr.AddShow(show.ShowId, show.Weight);
@@ -104,12 +104,18 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
.ToList();
var overrides = channel.Overrides
.OrderBy(o => o.StartsAtUtc)
.OrderBy(o => o.Recurrence)
.ThenBy(o => o.StartsAtUtc)
.ThenBy(o => o.DayOfWeek)
.Select(o => new ProgrammingOverrideDto(
o.Id,
o.Mode,
o.Recurrence,
o.StartsAtUtc,
o.EndsAtUtc,
o.DayOfWeek,
o.StartMinute,
o.EndMinute,
o.Shows
.Select(s => new OverrideShowDto(s.ShowId, ShowName(s.ShowId), s.Weight))
.ToList()
@@ -562,10 +562,14 @@ public sealed class ScheduleGenerator(
var overrides = channel.Overrides
.Select(o => new PlannerOverride(
o.Mode,
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList(),
o.Recurrence,
o.StartsAtUtc,
o.EndsAtUtc,
o.Mode,
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList()
o.DayOfWeek,
o.StartMinute,
o.EndMinute
))
.ToList();
@@ -36,6 +36,13 @@ public interface IIdentityService
CancellationToken cancellationToken
);
/// <summary>Сброс пароля администратором — без текущего пароля (для чужого аккаунта).</summary>
Task<Result> ResetPasswordAsync(
Guid userId,
string newPassword,
CancellationToken cancellationToken
);
Task<Result> ChangeUserNameAsync(
Guid userId,
string newUserName,
@@ -198,7 +198,20 @@ public class Channel
DateTimeOffset endsAtUtc
)
{
var ovr = ProgrammingOverride.Create(Id, mode, startsAtUtc, endsAtUtc);
var ovr = ProgrammingOverride.CreateOneTime(Id, mode, startsAtUtc, endsAtUtc);
_overrides.Add(ovr);
return ovr;
}
/// <summary>Еженедельный override: день недели (0=Вс..6=Сб) + окно минут суток (UTC).</summary>
public ProgrammingOverride AddWeeklyOverride(
OverrideMode mode,
int dayOfWeek,
int startMinute,
int endMinute
)
{
var ovr = ProgrammingOverride.CreateWeekly(Id, mode, dayOfWeek, startMinute, endMinute);
_overrides.Add(ovr);
return ovr;
}
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Как повторяется override программирования канала.</summary>
public enum OverrideRecurrence
{
/// <summary>Разовое окно [StartsAtUtc, EndsAtUtc).</summary>
OneTime,
/// <summary>Еженедельно в заданный день недели на окне часов суток (UTC).</summary>
Weekly,
}
@@ -1,9 +1,12 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Временный override программирования канала на окне [<see cref="StartsAtUtc"/>,
/// <see cref="EndsAtUtc"/>). Марафон = <see cref="OverrideMode.Exclusive"/> с одним шоу и большим
/// временным блоком. Пересекающийся с генерируемым временем override заменяет базовую ротацию.
/// Временный override программирования канала. Разовый (<see cref="OverrideRecurrence.OneTime"/>) —
/// на окне [<see cref="StartsAtUtc"/>, <see cref="EndsAtUtc"/>). Еженедельный
/// (<see cref="OverrideRecurrence.Weekly"/>) — каждую неделю в <see cref="DayOfWeek"/> на окне минут
/// суток [<see cref="StartMinute"/>, <see cref="EndMinute"/>) в UTC. Марафон = обычно
/// <see cref="OverrideMode.Exclusive"/> с одним шоу; пересекающийся с генерируемым временем override
/// заменяет базовую ротацию.
/// </summary>
public class ProgrammingOverride
{
@@ -12,14 +15,23 @@ public class ProgrammingOverride
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public OverrideMode Mode { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
public OverrideRecurrence Recurrence { get; private set; }
// ── OneTime ──
public DateTimeOffset? StartsAtUtc { get; private set; }
public DateTimeOffset? EndsAtUtc { get; private set; }
// ── Weekly ── (день недели 0=Вс..6=Сб как System.DayOfWeek/JS getDay; минуты суток 0..1440, UTC)
public int? DayOfWeek { get; private set; }
public int? StartMinute { get; private set; }
public int? EndMinute { get; private set; }
public IReadOnlyList<OverrideShow> Shows => _shows;
private ProgrammingOverride() { }
internal static ProgrammingOverride Create(
internal static ProgrammingOverride CreateOneTime(
Guid channelId,
OverrideMode mode,
DateTimeOffset startsAtUtc,
@@ -30,10 +42,29 @@ public class ProgrammingOverride
Id = Guid.NewGuid(),
ChannelId = channelId,
Mode = mode,
Recurrence = OverrideRecurrence.OneTime,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
internal static ProgrammingOverride CreateWeekly(
Guid channelId,
OverrideMode mode,
int dayOfWeek,
int startMinute,
int endMinute
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Mode = mode,
Recurrence = OverrideRecurrence.Weekly,
DayOfWeek = dayOfWeek,
StartMinute = startMinute,
EndMinute = endMinute,
};
public OverrideShow AddShow(Guid showId, int weight)
{
var entry = OverrideShow.Create(Id, showId, weight);
@@ -41,5 +72,18 @@ public class ProgrammingOverride
return entry;
}
public bool Covers(DateTimeOffset moment) => moment >= StartsAtUtc && moment < EndsAtUtc;
/// <summary>Действует ли override в этот момент (по типу повторения).</summary>
public bool Covers(DateTimeOffset moment)
{
if (Recurrence == OverrideRecurrence.Weekly)
{
var utc = moment.UtcDateTime;
var minuteOfDay = utc.Hour * 60 + utc.Minute;
return (int)utc.DayOfWeek == DayOfWeek
&& minuteOfDay >= StartMinute
&& minuteOfDay < EndMinute;
}
return moment >= StartsAtUtc && moment < EndsAtUtc;
}
}
@@ -205,7 +205,7 @@ public static class SchedulePlanner
IReadOnlyDictionary<Guid, PlannerShow> byShowId
)
{
var ovr = input.Overrides.FirstOrDefault(o => moment >= o.StartsAtUtc && moment < o.EndsAtUtc);
var ovr = input.Overrides.FirstOrDefault(o => o.Covers(moment));
if (ovr is not null)
{
var overridden = new List<(PlannerShow, int)>();
@@ -19,13 +19,36 @@ public sealed record PlannerHourWindow(int StartHour, int EndHour)
public bool Contains(int hour) => hour >= StartHour && hour < EndHour;
}
/// <summary>Override в терминах планировщика: окно + режим + шоу с весами.</summary>
/// <summary>
/// Override в терминах планировщика: режим + шоу с весами + правило действия (разовое окно либо
/// еженедельно по дню недели на окне минут суток UTC).
/// </summary>
public sealed record PlannerOverride(
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
OverrideMode Mode,
IReadOnlyList<PlannerOverrideShow> Shows
);
IReadOnlyList<PlannerOverrideShow> Shows,
OverrideRecurrence Recurrence = OverrideRecurrence.OneTime,
DateTimeOffset? StartsAtUtc = null,
DateTimeOffset? EndsAtUtc = null,
int? DayOfWeek = null,
int? StartMinute = null,
int? EndMinute = null
)
{
/// <summary>Действует ли override в этот момент.</summary>
public bool Covers(DateTimeOffset moment)
{
if (Recurrence == OverrideRecurrence.Weekly)
{
var utc = moment.UtcDateTime;
var minuteOfDay = utc.Hour * 60 + utc.Minute;
return (int)utc.DayOfWeek == DayOfWeek
&& minuteOfDay >= StartMinute
&& minuteOfDay < EndMinute;
}
return moment >= StartsAtUtc && moment < EndsAtUtc;
}
}
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
@@ -99,6 +99,29 @@ internal sealed class IdentityService(
);
}
public async Task<Result> ResetPasswordAsync(
Guid userId,
string newPassword,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(UserErrors.NotFound);
// Админ меняет чужой пароль — без текущего: сбрасываем через одноразовый токен сброса.
var token = await userManager.GeneratePasswordResetTokenAsync(user);
var result = await userManager.ResetPasswordAsync(user, token, newPassword);
return result.Succeeded
? Result.Success()
: Result.Failure(
Error.Validation(
"Auth.ResetPasswordFailed",
string.Join("; ", result.Errors.Select(e => e.Description))
)
);
}
public async Task<Result> ChangeUserNameAsync(
Guid userId,
string newUserName,
@@ -0,0 +1,96 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class WeeklyProgrammingOverrides : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "StartsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "EndsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone");
migrationBuilder.AddColumn<int>(
name: "DayOfWeek",
table: "ProgrammingOverride",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "EndMinute",
table: "ProgrammingOverride",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Recurrence",
table: "ProgrammingOverride",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "StartMinute",
table: "ProgrammingOverride",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DayOfWeek",
table: "ProgrammingOverride");
migrationBuilder.DropColumn(
name: "EndMinute",
table: "ProgrammingOverride");
migrationBuilder.DropColumn(
name: "Recurrence",
table: "ProgrammingOverride");
migrationBuilder.DropColumn(
name: "StartMinute",
table: "ProgrammingOverride");
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "StartsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "EndsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone",
oldNullable: true);
}
}
}
@@ -478,13 +478,25 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
b.Property<int?>("DayOfWeek")
.HasColumnType("integer");
b.Property<int?>("EndMinute")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("StartsAtUtc")
b.Property<int>("Recurrence")
.HasColumnType("integer");
b.Property<int?>("StartMinute")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
@@ -208,10 +208,10 @@ public class SchedulePlannerTests
Overrides:
[
new PlannerOverride(
Start,
Start.AddHours(1),
OverrideMode.Exclusive,
[new PlannerOverrideShow(b.ShowId, 1)]
[new PlannerOverrideShow(b.ShowId, 1)],
StartsAtUtc: Start,
EndsAtUtc: Start.AddHours(1)
),
],
StartTime: Start,
@@ -479,6 +479,63 @@ public class SchedulePlannerTests
Assert.All(bumpers, e => Assert.Equal(t1, e.BumperTemplateId));
}
[Fact]
public void WeeklyOverride_AppliesOnMatchingDayAndTime()
{
// Start = 2026-01-01 (четверг, DayOfWeek=4), полночь. Еженедельный override на четверг 00:0001:00.
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with
{
Overrides =
[
new PlannerOverride(
OverrideMode.Exclusive,
[new PlannerOverrideShow(b.ShowId, 1)],
OverrideRecurrence.Weekly,
DayOfWeek: (int)Start.UtcDateTime.DayOfWeek,
StartMinute: 0,
EndMinute: 60
),
],
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(b.ShowId, result.Entries[0].ShowId);
}
[Fact]
public void WeeklyOverride_IgnoredOnOtherDay()
{
// Override на другой день недели → базовая ротация (FixedRandom(0) берёт первое шоу — a).
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var otherDay = ((int)Start.UtcDateTime.DayOfWeek + 1) % 7;
var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with
{
Overrides =
[
new PlannerOverride(
OverrideMode.Exclusive,
[new PlannerOverrideShow(b.ShowId, 1)],
OverrideRecurrence.Weekly,
DayOfWeek: otherDay,
StartMinute: 0,
EndMinute: 1440
),
],
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(a.ShowId, result.Entries[0].ShowId);
}
[Fact]
public void PreferredHours_BoostsWeight_InsideWindow()
{
@@ -20,6 +20,7 @@ import type {
ChannelShowDto,
HourWindow,
OverrideMode,
OverrideRecurrence,
ScheduleEntryDto,
} from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
@@ -57,7 +58,8 @@ import {
uploadBumperTemplateAudio,
} from './api'
function formatTime(iso: string) {
function formatTime(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleString([], {
day: '2-digit',
month: '2-digit',
@@ -66,6 +68,14 @@ function formatTime(iso: string) {
})
}
/** Минуты суток → «HH:MM». */
function formatMinute(minute: number | null) {
if (minute == null) return '—'
const h = Math.floor(minute / 60)
const m = minute % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
export function ChannelDetail({ channelId }: { channelId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
@@ -212,8 +222,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<li key={o.id} className="flex items-center justify-between py-2 text-sm">
<span>
<Badge variant="muted">{t(`admin.channels.modes.${o.mode}`)}</Badge>{' '}
{formatTime(o.startsAtUtc)} {formatTime(o.endsAtUtc)} ·{' '}
{o.shows.map((s) => s.showName).join(', ')}
{o.recurrence === 'Weekly'
? `${t(`admin.channels.weekdays.${o.dayOfWeek}`)} ${formatMinute(o.startMinute)}${formatMinute(o.endMinute)}`
: `${formatTime(o.startsAtUtc)} ${formatTime(o.endsAtUtc)}`}{' '}
· {o.shows.map((s) => s.showName).join(', ')}
</span>
<RemoveButton
onClick={() => deleteOverride(channelId, o.id).then(invalidate).catch(onError)}
@@ -1472,32 +1484,72 @@ function OverrideForm({
}) {
const { t } = useTranslation()
const [mode, setMode] = useState<OverrideMode>('Exclusive')
const [recurrence, setRecurrence] = useState<OverrideRecurrence>('OneTime')
const [showId, setShowId] = useState('')
const [weight, setWeight] = useState(1)
const [start, setStart] = useState('')
const [end, setEnd] = useState('')
// Weekly: день недели (0=Вс..6=Сб) + окна времени суток «HH:MM».
const [dayOfWeek, setDayOfWeek] = useState(6)
const [startTime, setStartTime] = useState('')
const [endTime, setEndTime] = useState('')
const toMinutes = (hhmm: string) => {
const [h, m] = hhmm.split(':').map(Number)
return h * 60 + m
}
const weekly = recurrence === 'Weekly'
const create = useMutation({
mutationFn: () =>
createOverride(channelId, {
mode,
startsAtUtc: new Date(start).toISOString(),
endsAtUtc: new Date(end).toISOString(),
shows: [{ showId, weight }],
}),
createOverride(
channelId,
weekly
? {
mode,
recurrence,
dayOfWeek,
startMinute: toMinutes(startTime),
endMinute: toMinutes(endTime),
shows: [{ showId, weight }],
}
: {
mode,
recurrence,
startsAtUtc: new Date(start).toISOString(),
endsAtUtc: new Date(end).toISOString(),
shows: [{ showId, weight }],
},
),
onSuccess: () => {
setShowId('')
setStart('')
setEnd('')
setStartTime('')
setEndTime('')
onCreated()
},
onError,
})
const valid = showId && start && end && new Date(end) > new Date(start)
const valid = weekly
? showId && startTime && endTime && toMinutes(endTime) > toMinutes(startTime)
: showId && start && end && new Date(end) > new Date(start)
return (
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.overrideRecurrence')}</Label>
<Select value={recurrence} onValueChange={(v) => setRecurrence(v as OverrideRecurrence)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="OneTime">{t('admin.channels.recurrenceOneTime')}</SelectItem>
<SelectItem value="Weekly">{t('admin.channels.recurrenceWeekly')}</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={mode} onValueChange={(v) => setMode(v as OverrideMode)}>
<SelectTrigger className="w-36">
<SelectValue />
@@ -1522,14 +1574,44 @@ function OverrideForm({
{mode === 'Boost' && (
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
)}
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-52" />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-52" />
</div>
{weekly ? (
<>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.weekday')}</Label>
<Select value={String(dayOfWeek)} onValueChange={(v) => setDayOfWeek(Number(v))}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<SelectItem key={d} value={String(d)}>
{t(`admin.channels.weekdays.${d}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
</div>
</>
) : (
<>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-60" />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-60" />
</div>
</>
)}
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
{t('common.create')}
</Button>
+7 -2
View File
@@ -10,6 +10,7 @@ import type {
CreatedIdResponse,
HourWindow,
OverrideMode,
OverrideRecurrence,
ScheduleEntryDto,
} from '@/shared/api/types'
@@ -217,8 +218,12 @@ export function bumperPreviewPlaylistUrl(id: string, templateId: string, variant
export type OverrideBody = {
mode: OverrideMode
startsAtUtc: string
endsAtUtc: string
recurrence: OverrideRecurrence
startsAtUtc?: string | null
endsAtUtc?: string | null
dayOfWeek?: number | null
startMinute?: number | null
endMinute?: number | null
shows: { showId: string; weight: number }[]
}
@@ -6,6 +6,7 @@ import { HttpError } from '@/shared/api/client'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, listMedia } from './api'
@@ -46,12 +47,13 @@ export function MediaPanel() {
const fileInput = useRef<HTMLInputElement>(null)
const fileInputShow = useRef<HTMLInputElement>(null)
const [filter, setFilter] = useState<MediaFilter>('active')
const [page, setPage] = useState(1)
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
const enqueue = useUploadStore((s) => s.enqueue)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media', filter],
queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }),
queryKey: ['admin', 'media', filter, page],
queryFn: () => listMedia({ page, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }),
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
refetchInterval: (query) =>
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
@@ -70,7 +72,13 @@ export function MediaPanel() {
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
<Select value={filter} onValueChange={(v) => setFilter(v as MediaFilter)}>
<Select
value={filter}
onValueChange={(v) => {
setPage(1)
setFilter(v as MediaFilter)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
@@ -158,6 +166,12 @@ export function MediaPanel() {
</tbody>
</table>
</div>
<Pager
page={page}
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
onChange={setPage}
/>
</div>
)
}
@@ -1,24 +1,42 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import type { ShowKind } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { createShow, deleteShow, listShows } from './api'
const PAGE_SIZE = 20
export function ShowsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [originalName, setOriginalName] = useState('')
const [kind, setKind] = useState<ShowKind>('Series')
const [query, setQuery] = useState('')
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
// Список шоу обычно умещается в одну загрузку — фильтруем и листаем на клиенте (пикеры берут всё).
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
const all = data ?? []
if (!q) return all
return all.filter(
(s) =>
s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q),
)
}, [data, query])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
@@ -70,6 +88,16 @@ export function ShowsPanel() {
</Button>
</div>
<Input
className="max-w-xs"
placeholder={t('common.search')}
value={query}
onChange={(e) => {
setPage(1)
setQuery(e.target.value)
}}
/>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
@@ -89,7 +117,7 @@ export function ShowsPanel() {
</td>
</tr>
)}
{data?.map((show) => (
{pageItems.map((show) => (
<tr key={show.id} className="border-b border-border last:border-0">
<td className="px-4 py-2">
<Link
@@ -119,6 +147,8 @@ export function ShowsPanel() {
</tbody>
</table>
</div>
<Pager page={page} totalPages={totalPages} onChange={setPage} />
</div>
)
}
@@ -5,13 +5,21 @@ import { HttpError } from '@/shared/api/client'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import type { UserSummaryDto } from '@/shared/api/types'
import { changeUserRole } from '@/features/admin/roles/api'
import { listRoles } from '@/features/admin/roles/api'
import { blockUser, createUser, deleteUser, listUsers, unblockUser } from './api'
import { blockUser, createUser, deleteUser, listUsers, resetUserPassword, unblockUser } from './api'
const PAGE_SIZE = 20
@@ -24,6 +32,7 @@ export function UsersPanel() {
const [newUserName, setNewUserName] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newRoleId, setNewRoleId] = useState('')
const [resetTarget, setResetTarget] = useState<UserSummaryDto | null>(null)
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
const { data, isLoading } = useQuery({
@@ -214,6 +223,9 @@ export function UsersPanel() {
{t('admin.users.block')}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setResetTarget(user)}>
{t('admin.users.resetPassword')}
</Button>
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(user.id)}>
{t('common.delete')}
</Button>
@@ -238,6 +250,68 @@ export function UsersPanel() {
</Button>
</div>
)}
{resetTarget && (
<ResetPasswordDialog
user={resetTarget}
onClose={() => setResetTarget(null)}
onError={onError}
/>
)}
</div>
)
}
function ResetPasswordDialog({
user,
onClose,
onError,
}: {
user: UserSummaryDto
onClose: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [password, setPassword] = useState('')
const reset = useMutation({
mutationFn: () => resetUserPassword(user.id, password),
onSuccess: () => {
toast.success(t('admin.users.passwordReset'))
onClose()
},
onError,
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('admin.users.resetPasswordFor', { name: user.userName })}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label>{t('admin.users.newPassword')}</Label>
<Input
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('admin.users.passwordHint')}</p>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
size="sm"
disabled={password.length < 8 || reset.isPending}
onClick={() => reset.mutate()}
>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+7
View File
@@ -36,3 +36,10 @@ export function unblockUser(id: string) {
export function deleteUser(id: string) {
return apiRequest<void>(`/admin/users/${id}`, { method: 'DELETE' })
}
export function resetUserPassword(id: string, newPassword: string) {
return apiRequest<void>(`/admin/users/${id}/password`, {
method: 'POST',
body: { newPassword },
})
}
+9 -2
View File
@@ -206,11 +206,18 @@ export type ChannelAdDto = {
export type OverrideShowDto = { showId: string; showName: string; weight: number }
export type OverrideRecurrence = 'OneTime' | 'Weekly'
export type ProgrammingOverrideDto = {
id: string
mode: OverrideMode
startsAtUtc: string
endsAtUtc: string
recurrence: OverrideRecurrence
startsAtUtc: string | null
endsAtUtc: string | null
/** Weekly: день недели 0=Вс..6=Сб; окно минут суток (UTC). */
dayOfWeek: number | null
startMinute: number | null
endMinute: number | null
shows: OverrideShowDto[]
}
+38
View File
@@ -27,6 +27,8 @@ const resources = {
confirm: 'Подтвердить',
search: 'Поиск',
actions: 'Действия',
prevPage: 'Предыдущая страница',
nextPage: 'Следующая страница',
yes: 'Да',
no: 'Нет',
},
@@ -105,6 +107,10 @@ const resources = {
active: 'Активен',
block: 'Заблокировать',
unblock: 'Разблокировать',
resetPassword: 'Пароль',
resetPasswordFor: 'Сменить пароль: {{name}}',
newPassword: 'Новый пароль',
passwordReset: 'Пароль изменён',
filterAll: 'Все роли',
createTitle: 'Создать пользователя',
password: 'Пароль',
@@ -288,6 +294,19 @@ const resources = {
noAds: 'Пул рекламы пуст',
overrides: 'Марафоны / override',
modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' },
overrideRecurrence: 'Повтор',
recurrenceOneTime: 'Разово',
recurrenceWeekly: 'Еженедельно',
weekday: 'День недели',
weekdays: {
0: 'Вс',
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
},
from: 'С',
to: 'По',
noOverrides: 'Override не заданы',
@@ -368,6 +387,8 @@ const resources = {
confirm: 'Confirm',
search: 'Search',
actions: 'Actions',
prevPage: 'Previous page',
nextPage: 'Next page',
yes: 'Yes',
no: 'No',
},
@@ -446,6 +467,10 @@ const resources = {
active: 'Active',
block: 'Block',
unblock: 'Unblock',
resetPassword: 'Password',
resetPasswordFor: 'Reset password: {{name}}',
newPassword: 'New password',
passwordReset: 'Password changed',
filterAll: 'All roles',
createTitle: 'Create user',
password: 'Password',
@@ -629,6 +654,19 @@ const resources = {
noAds: 'Ad pool is empty',
overrides: 'Marathons / overrides',
modes: { Exclusive: 'Exclusive', Boost: 'Boost' },
overrideRecurrence: 'Repeat',
recurrenceOneTime: 'One-time',
recurrenceWeekly: 'Weekly',
weekday: 'Weekday',
weekdays: {
0: 'Sun',
1: 'Mon',
2: 'Tue',
3: 'Wed',
4: 'Thu',
5: 'Fri',
6: 'Sat',
},
from: 'From',
to: 'To',
noOverrides: 'No overrides set',
+42
View File
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next'
import { Button } from './button'
/** Простой пейджер «‹ N / M ›». Ничего не рисует, если страница одна. */
export function Pager({
page,
totalPages,
onChange,
}: {
page: number
totalPages: number
onChange: (page: number) => void
}) {
const { t } = useTranslation()
if (totalPages <= 1) return null
return (
<div className="flex items-center justify-center gap-2 text-sm">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
aria-label={t('common.prevPage')}
>
</Button>
<span className="tabular-nums text-muted-foreground">
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
aria-label={t('common.nextPage')}
>
</Button>
</div>
)
}