Restore admin login unlock and password reset, plus desktop episode Sub actions.
This commit is contained in:
parent
5ec899c9cb
commit
91e7502d3b
3 changed files with 149 additions and 29 deletions
|
|
@ -1140,13 +1140,39 @@ export default function DownloadsPage() {
|
|||
<td>
|
||||
{j.movieTitle}
|
||||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||
{(() => {
|
||||
{j.episodes && j.episodes.length > 0 ? (
|
||||
<ul className="downloads-episode-list">
|
||||
{j.episodes.map((ep) => (
|
||||
<li key={ep.episodeNumber}>
|
||||
<span>
|
||||
E{String(ep.episodeNumber).padStart(2, "0")}
|
||||
{ep.episodeTitle ? ` · ${ep.episodeTitle}` : ""}
|
||||
{!ep.importedPath
|
||||
? " · niet geïmporteerd"
|
||||
: ep.subtitlePath
|
||||
? " · sub OK"
|
||||
: " · sub mist"}
|
||||
</span>
|
||||
{ep.importedPath && !ep.subtitlePath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openSubtitlePicker(j, ep.episodeNumber)}
|
||||
>
|
||||
Sub
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
(() => {
|
||||
const sub = subtitleStatus(j);
|
||||
if (!sub || (j.episodes && j.episodes.length > 0)) return null;
|
||||
if (!sub) return null;
|
||||
if (sub.kind === "ok") return <div className="ok-text">{sub.text}</div>;
|
||||
if (sub.kind === "pending") return <div className="muted">{sub.text}</div>;
|
||||
return <div className="danger-text">{sub.text}</div>;
|
||||
})()}
|
||||
})()
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{j.sourceTitle}</td>
|
||||
<td>{j.nodeName}</td>
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ export default function ViewersPage() {
|
|||
const [iptvLocalErr, setIptvLocalErr] = useState<Record<string, string | null>>({});
|
||||
const [addonInstallUrls, setAddonInstallUrls] = useState<Record<string, string | null>>({});
|
||||
const [claimCodes, setClaimCodes] = useState<Record<string, string>>({});
|
||||
const [newPasswords, setNewPasswords] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetch("/api/v1/admin/viewers", { credentials: "include" })
|
||||
|
|
@ -584,6 +585,7 @@ export default function ViewersPage() {
|
|||
|
||||
async function unlockLogin(v: Viewer) {
|
||||
setActionId(`unlock-${v.id}`);
|
||||
setMsg(null);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/unlock-login`, {
|
||||
|
|
@ -602,6 +604,35 @@ export default function ViewersPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function changeViewerPassword(v: Viewer) {
|
||||
const next = (newPasswords[v.id] ?? "").trim();
|
||||
if (next.length < 8) {
|
||||
setErr("Nieuw wachtwoord minstens 8 tekens");
|
||||
return;
|
||||
}
|
||||
setActionId(`pw-${v.id}`);
|
||||
setMsg(null);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: next }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error?.message ?? "Wachtwoord wijzigen mislukt");
|
||||
return;
|
||||
}
|
||||
setNewPasswords((prev) => ({ ...prev, [v.id]: "" }));
|
||||
setMsg(`Wachtwoord bijgewerkt voor ${v.email}`);
|
||||
load();
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteViewer(v: Viewer) {
|
||||
const label = v.name || v.email;
|
||||
if (
|
||||
|
|
@ -917,8 +948,13 @@ export default function ViewersPage() {
|
|||
<p className="muted detail-copy">
|
||||
Bepaalt of deze gebruiker kan inloggen en de app-inhoud mag gebruiken.
|
||||
</p>
|
||||
{(selected.loginLocked || (selected.loginFailedCount ?? 0) > 0) && (
|
||||
<div className="login-lock-box">
|
||||
<div
|
||||
className={
|
||||
selected.loginLocked || (selected.loginFailedCount ?? 0) > 0
|
||||
? "login-lock-box"
|
||||
: "login-lock-box idle"
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{selected.loginLocked
|
||||
? `Login vergrendeld tot ${
|
||||
|
|
@ -926,19 +962,58 @@ export default function ViewersPage() {
|
|||
? new Date(selected.loginLockedUntil).toLocaleString("nl-NL")
|
||||
: "…"
|
||||
} (3 foute pogingen).`
|
||||
: `${selected.loginFailedCount} foute wachtwoordpoging${
|
||||
: (selected.loginFailedCount ?? 0) > 0
|
||||
? `${selected.loginFailedCount} foute wachtwoordpoging${
|
||||
(selected.loginFailedCount ?? 0) === 1 ? "" : "en"
|
||||
} (lock na 3).`}
|
||||
} (lock na 3).`
|
||||
: "Geen login-blokkade actief. Ontgrendelen wist foute-pogingen-teller."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionId === `unlock-${selected.id}`}
|
||||
onClick={() => void unlockLogin(selected)}
|
||||
>
|
||||
Login ontgrendelen
|
||||
{actionId === `unlock-${selected.id}`
|
||||
? "Ontgrendelen…"
|
||||
: "Login ontgrendelen"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-group password-change">
|
||||
<label className="field">
|
||||
Nieuw wachtwoord
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Minstens 8 tekens"
|
||||
value={newPasswords[selected.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setNewPasswords((prev) => ({
|
||||
...prev,
|
||||
[selected.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void changeViewerPassword(selected);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={
|
||||
actionId === `pw-${selected.id}` ||
|
||||
!(newPasswords[selected.id] ?? "").trim()
|
||||
}
|
||||
onClick={() => void changeViewerPassword(selected)}
|
||||
>
|
||||
{actionId === `pw-${selected.id}`
|
||||
? "Opslaan…"
|
||||
: "Wachtwoord wijzigen"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="toggle-rows">
|
||||
<div className="toggle-row">
|
||||
<div>
|
||||
|
|
@ -1593,6 +1668,11 @@ export default function ViewersPage() {
|
|||
border-radius: 10px;
|
||||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.login-lock-box.idle {
|
||||
border-color: var(--border-dim);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.login-lock-box p {
|
||||
margin: 0;
|
||||
|
|
@ -1600,6 +1680,16 @@ export default function ViewersPage() {
|
|||
min-width: 180px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.password-change {
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.password-change .btn-primary {
|
||||
justify-self: start;
|
||||
}
|
||||
.toggle-rows {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ export class ViewerService {
|
|||
}
|
||||
patch.passwordHash = await hashPassword(data.password);
|
||||
}
|
||||
return prisma.viewerUser.update({
|
||||
const updated = await prisma.viewerUser.update({
|
||||
where: { id },
|
||||
data: patch,
|
||||
select: {
|
||||
|
|
@ -266,6 +266,10 @@ export class ViewerService {
|
|||
createdAt: true,
|
||||
},
|
||||
});
|
||||
if (patch.passwordHash) {
|
||||
await this.clearLoginLock(updated.email.toLowerCase());
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async revokeDevice(deviceId: string) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue