v3: added password change/reset feature
This commit is contained in:
186
src/components/ChangePasswordModal.tsx
Normal file
186
src/components/ChangePasswordModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
? (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user