feat(web): Last.fm credentials (api key + username) + Settings tab
This commit is contained in:
@@ -101,3 +101,32 @@ describe("config DELETE", () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config API — Last.fm", () => {
|
||||
it("stores the API key encrypted and username plain", async () => {
|
||||
await PUT(putReq({ lastfmApiKey: "abc123", lastfmUsername: "jonathan" }));
|
||||
|
||||
const key = await prisma.config.findUnique({ where: { key: "lastfm.api_key" } });
|
||||
const username = await prisma.config.findUnique({ where: { key: "lastfm.username" } });
|
||||
expect(key!.secret).toBe(true);
|
||||
expect(key!.value).not.toContain("abc123"); // encrypted envelope
|
||||
expect(username!.secret).toBe(false);
|
||||
expect(username!.value).toBe("jonathan");
|
||||
});
|
||||
|
||||
it("GET masks the API key and reports the username", async () => {
|
||||
await PUT(putReq({ lastfmApiKey: "abc123", lastfmUsername: "jonathan" }));
|
||||
const body = await (await GET()).json();
|
||||
expect(body.lastfmUsername).toBe("jonathan");
|
||||
expect(body.lastfmApiKeySet).toBe(true);
|
||||
expect(JSON.stringify(body)).not.toContain("abc123");
|
||||
});
|
||||
|
||||
it("DELETE clears the API key", async () => {
|
||||
await PUT(putReq({ lastfmApiKey: "abc123" }));
|
||||
const res = await DELETE(delReq("lastfmApiKey"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await prisma.config.findUnique({ where: { key: "lastfm.api_key" } })).toBeNull();
|
||||
expect((await (await GET()).json()).lastfmApiKeySet).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ const FIELDS: Record<string, { key: string; secret: boolean }> = {
|
||||
qobuzToken: { key: "qobuz.auth_token", secret: true },
|
||||
slskdUrl: { key: "slskd.url", secret: false },
|
||||
slskdApiKey: { key: "slskd.api_key", secret: true },
|
||||
lastfmApiKey: { key: "lastfm.api_key", secret: true },
|
||||
lastfmUsername: { key: "lastfm.username", secret: false },
|
||||
};
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
@@ -40,9 +42,11 @@ export async function GET() {
|
||||
qobuzEmail: byKey.get("qobuz.email")?.value ?? "",
|
||||
qobuzUserId: byKey.get("qobuz.user_id")?.value ?? "",
|
||||
slskdUrl: byKey.get("slskd.url")?.value ?? "",
|
||||
lastfmUsername: byKey.get("lastfm.username")?.value ?? "",
|
||||
qobuzPasswordSet: byKey.has("qobuz.password"),
|
||||
qobuzTokenSet: byKey.has("qobuz.auth_token"),
|
||||
slskdApiKeySet: byKey.has("slskd.api_key"),
|
||||
lastfmApiKeySet: byKey.has("lastfm.api_key"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@ type Loaded = {
|
||||
qobuzEmail: string;
|
||||
qobuzUserId: string;
|
||||
slskdUrl: string;
|
||||
lastfmUsername: string;
|
||||
qobuzPasswordSet: boolean;
|
||||
qobuzTokenSet: boolean;
|
||||
slskdApiKeySet: boolean;
|
||||
lastfmApiKeySet: boolean;
|
||||
};
|
||||
|
||||
type Tab = "Qobuz" | "Soulseek" | "Library" | "Monitor" | "Discovery";
|
||||
const TABS: Tab[] = ["Qobuz", "Soulseek", "Library", "Monitor", "Discovery"];
|
||||
type Tab = "Qobuz" | "Soulseek" | "Last.fm" | "Library" | "Monitor" | "Discovery";
|
||||
const TABS: Tab[] = ["Qobuz", "Soulseek", "Last.fm", "Library", "Monitor", "Discovery"];
|
||||
|
||||
const DISCOVERY_FIELDS: [string, string][] = [
|
||||
["discover.intervalHours", "Interval (hours)"],
|
||||
@@ -38,9 +40,12 @@ export function SettingsForm() {
|
||||
const [qobuzToken, setQobuzToken] = useState("");
|
||||
const [slskdUrl, setSlskdUrl] = useState("");
|
||||
const [slskdApiKey, setSlskdApiKey] = useState("");
|
||||
const [lastfmUsername, setLastfmUsername] = useState("");
|
||||
const [lastfmApiKey, setLastfmApiKey] = useState("");
|
||||
const [pwSet, setPwSet] = useState(false);
|
||||
const [tokenSet, setTokenSet] = useState(false);
|
||||
const [keySet, setKeySet] = useState(false);
|
||||
const [lastfmKeySet, setLastfmKeySet] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<string | null>(null);
|
||||
const [scanRequested, setScanRequested] = useState(false);
|
||||
@@ -80,9 +85,11 @@ export function SettingsForm() {
|
||||
setQobuzEmail(c.qobuzEmail);
|
||||
setQobuzUserId(c.qobuzUserId);
|
||||
setSlskdUrl(c.slskdUrl);
|
||||
setLastfmUsername(c.lastfmUsername);
|
||||
setPwSet(c.qobuzPasswordSet);
|
||||
setTokenSet(c.qobuzTokenSet);
|
||||
setKeySet(c.slskdApiKeySet);
|
||||
setLastfmKeySet(c.lastfmApiKeySet);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -103,16 +110,27 @@ export function SettingsForm() {
|
||||
await fetch("/api/config", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ qobuzEmail, qobuzPassword, qobuzUserId, qobuzToken, slskdUrl, slskdApiKey }),
|
||||
body: JSON.stringify({
|
||||
qobuzEmail,
|
||||
qobuzPassword,
|
||||
qobuzUserId,
|
||||
qobuzToken,
|
||||
slskdUrl,
|
||||
slskdApiKey,
|
||||
lastfmApiKey,
|
||||
lastfmUsername,
|
||||
}),
|
||||
});
|
||||
setQobuzPassword("");
|
||||
setQobuzToken("");
|
||||
setSlskdApiKey("");
|
||||
setLastfmApiKey("");
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 1500);
|
||||
setPwSet(pwSet || qobuzPassword !== "");
|
||||
setTokenSet(tokenSet || qobuzToken !== "");
|
||||
setKeySet(keySet || slskdApiKey !== "");
|
||||
setLastfmKeySet(lastfmKeySet || lastfmApiKey !== "");
|
||||
}
|
||||
|
||||
function setCfg(key: string, value: string) {
|
||||
@@ -147,16 +165,17 @@ export function SettingsForm() {
|
||||
|
||||
const secretHint = (set: boolean) => (set ? <span className="set"> · set</span> : null);
|
||||
|
||||
async function clearField(field: "qobuzPassword" | "qobuzToken" | "slskdApiKey") {
|
||||
async function clearField(field: "qobuzPassword" | "qobuzToken" | "slskdApiKey" | "lastfmApiKey") {
|
||||
await fetch(`/api/config?field=${field}`, { method: "DELETE" });
|
||||
if (field === "qobuzPassword") { setPwSet(false); setQobuzPassword(""); }
|
||||
if (field === "qobuzToken") { setTokenSet(false); setQobuzToken(""); }
|
||||
if (field === "slskdApiKey") { setKeySet(false); setSlskdApiKey(""); }
|
||||
if (field === "lastfmApiKey") { setLastfmKeySet(false); setLastfmApiKey(""); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHead title="Settings" eyebrow="Credentials · library · monitor · discovery" />
|
||||
<PageHead title="Settings" eyebrow="Credentials · library · monitor · discovery · last.fm" />
|
||||
|
||||
<div className="tabs">
|
||||
{TABS.map((t) => (
|
||||
@@ -241,6 +260,34 @@ export function SettingsForm() {
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{tab === "Last.fm" ? (
|
||||
<form className="settings-section" onSubmit={saveCredentials}>
|
||||
<div className="subhead">Last.fm</div>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>Last.fm username</span>
|
||||
<input aria-label="lastfm username" value={lastfmUsername} onChange={(e) => setLastfmUsername(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>
|
||||
Last.fm API key{secretHint(lastfmKeySet)}
|
||||
{lastfmKeySet ? (
|
||||
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
|
||||
aria-label="clear lastfm api key" onClick={() => clearField("lastfmApiKey")}>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
<input aria-label="lastfm api key" type="password" placeholder={lastfmKeySet ? "•••• stored" : ""} value={lastfmApiKey} onChange={(e) => setLastfmApiKey(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="save-row">
|
||||
<button type="submit" className="btn accent">Save</button>
|
||||
{saved ? <span className="saved-note">Saved</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{tab === "Library" ? (
|
||||
<section className="settings-section">
|
||||
<div className="info-panel">
|
||||
|
||||
Reference in New Issue
Block a user