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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user