v3: added password change/reset feature

This commit is contained in:
khannurien
2026-04-06 16:30:00 +00:00
parent 3b6980a8fc
commit 20b9bfe7b4
26 changed files with 1268 additions and 236 deletions

View File

@@ -0,0 +1,186 @@
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "./ErrorCard.tsx";
import { Modal } from "./Modal.tsx";
interface ChangePasswordModalProps {
onClose: () => void;
}
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
const { authFetch } = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const mismatch = confirmPassword.length > 0 &&
newPassword !== confirmPassword;
const tooShort = newPassword.length > 0 &&
newPassword.length < VALIDATION.PASSWORD_MIN;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (mismatch || tooShort || !currentPassword || !newPassword) return;
setSubmitting(true);
setError(null);
try {
const res = await authFetch(
`${API_URL}/api/users/me/change-password`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
},
);
const body = await res.json();
if (!body.success) {
setError(body.error?.message ?? t`Unknown error`);
return;
}
setDone(true);
} catch {
setError(t`Failed to change password`);
} finally {
setSubmitting(false);
}
};
return (
<Modal title={t`Change password`} onClose={onClose}>
{done
? (
<div className="modal-new-playlist-form">
<p style={{ color: "var(--color-success, green)" }}>
<Trans>Password changed successfully.</Trans>
</p>
<div className="form-actions">
<div className="form-actions-right">
<button type="button" className="btn-primary" onClick={onClose}>
<Trans>Close</Trans>
</button>
</div>
</div>
</div>
)
: (
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
<label>
<span
style={{
display: "block",
marginBottom: "0.25rem",
fontSize: "0.85rem",
opacity: 0.7,
}}
>
<Trans>Current password</Trans>
</span>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
autoFocus
/>
</label>
<label>
<span
style={{
display: "block",
marginBottom: "0.25rem",
fontSize: "0.85rem",
opacity: 0.7,
}}
>
<Trans>New password</Trans>
</span>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
required
/>
{tooShort && (
<span
style={{
fontSize: "0.8rem",
color: "var(--color-error, red)",
marginTop: "0.2rem",
display: "block",
}}
>
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
</span>
)}
</label>
<label>
<span
style={{
display: "block",
marginBottom: "0.25rem",
fontSize: "0.85rem",
opacity: 0.7,
}}
>
<Trans>Confirm new password</Trans>
</span>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
{mismatch && (
<span
style={{
fontSize: "0.8rem",
color: "var(--color-error, red)",
marginTop: "0.2rem",
display: "block",
}}
>
<Trans>Passwords do not match</Trans>
</span>
)}
</label>
{error && (
<ErrorCard title={t`Could not change password`} message={error} />
)}
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="form-cancel"
onClick={onClose}
>
<Trans>Cancel</Trans>
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting || mismatch || tooShort ||
!currentPassword || !newPassword || !confirmPassword}
>
{submitting
? <Trans>Saving</Trans>
: <Trans>Change password</Trans>}
</button>
</div>
</div>
</form>
)}
</Modal>
);
}

View File

@@ -18,7 +18,8 @@ import {
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { dumpUrl } from "../utils/urls.ts";
import { dumpUrl, normalizeUrl } from "../utils/urls.ts";
import { MAX_FILE_SIZE } from "../config/upload.ts";
import RichContentCard from "./RichContentCard.tsx";
import { MediaPlayer } from "./MediaPlayer.tsx";
import type { RichContent } from "../model.ts";
@@ -29,14 +30,6 @@ import { Modal } from "./Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
function normalizeUrl(input: string): string {
const s = input.trim();
if (!s || /^https?:\/\//i.test(s)) return s;
if (s.startsWith("//")) return `https:${s}`;
return `https://${s}`;
}
import { MAX_FILE_SIZE } from "../config/upload.ts";
type Mode = "url" | "file";
type Phase = "create" | "playlist";

View File

@@ -2,7 +2,7 @@ import { useContext, useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { formatBytes } from "../utils/format.ts";
import { MediaPlayer } from "./MediaPlayer.tsx";
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts";
import {
BAR_GAP,
@@ -76,26 +76,7 @@ function AudioFilePreview(
onClick={handlePlayBtn}
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying
? (
<svg
viewBox="0 0 24 24"
fill="currentColor"
style={{ padding: "1px" }}
>
<rect x="5" y="3" width="4" height="18" rx="1" />
<rect x="15" y="3" width="4" height="18" rx="1" />
</svg>
)
: (
<svg
viewBox="0 0 24 24"
fill="currentColor"
style={{ marginLeft: "2px" }}
>
<polygon points="6,3 20,12 6,21" />
</svg>
)}
{isPlaying ? <IconPause /> : <IconPlay />}
</button>
{peaks
? (

View File

@@ -15,13 +15,13 @@ function fmt(s: number): string {
return `${m}:${sec.toString().padStart(2, "0")}`;
}
const IconPlay = () => (
export const IconPlay = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
<polygon points="6,3 20,12 6,21" />
</svg>
);
const IconPause = () => (
export const IconPause = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
<rect x="5" y="3" width="4" height="18" rx="1" />
<rect x="15" y="3" width="4" height="18" rx="1" />