Initial release v1.0.0
This commit is contained in:
91
frontend/features/settings/hooks/useAPIKeys.ts
Normal file
91
frontend/features/settings/hooks/useAPIKeys.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ApiKey } from "../types";
|
||||
|
||||
export function useAPIKeys() {
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [loadingApiKeys, setLoadingApiKeys] = useState(false);
|
||||
const [showCreateApiKeyDialog, setShowCreateApiKeyDialog] = useState(false);
|
||||
const [newApiKeyName, setNewApiKeyName] = useState("");
|
||||
const [generatedApiKey, setGeneratedApiKey] = useState<string | null>(null);
|
||||
const [creatingApiKey, setCreatingApiKey] = useState(false);
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
try {
|
||||
setLoadingApiKeys(true);
|
||||
const response = await api.listApiKeys();
|
||||
// Map API response to match ApiKey type
|
||||
const mappedKeys = response.apiKeys.map(key => ({
|
||||
...key,
|
||||
lastUsedAt: key.lastUsed,
|
||||
keyPreview: key.id.substring(0, 8) + "..." // Generate preview from ID
|
||||
}));
|
||||
setApiKeys(mappedKeys);
|
||||
} catch (error) {
|
||||
console.error("Failed to load API keys:", error);
|
||||
// Caller handles error display if needed
|
||||
} finally {
|
||||
setLoadingApiKeys(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new API key
|
||||
* Returns { success: true } or { success: false, error: string }
|
||||
*/
|
||||
const createApiKey = async (name: string): Promise<{ success: boolean; error?: string }> => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
return { success: false, error: "Device name required" };
|
||||
}
|
||||
|
||||
try {
|
||||
setCreatingApiKey(true);
|
||||
const response = await api.createApiKey(trimmedName);
|
||||
setGeneratedApiKey(response.apiKey);
|
||||
setShowCreateApiKeyDialog(false);
|
||||
setNewApiKeyName("");
|
||||
await loadApiKeys();
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error("Failed to create API key:", error);
|
||||
return { success: false, error: error.message || "Failed to create" };
|
||||
} finally {
|
||||
setCreatingApiKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Revoke an API key
|
||||
* Returns { success: true } or { success: false, error: string }
|
||||
*/
|
||||
const revokeApiKey = async (id: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await api.revokeApiKey(id);
|
||||
await loadApiKeys();
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error("Failed to revoke API key:", error);
|
||||
return { success: false, error: error.message || "Failed to revoke" };
|
||||
}
|
||||
};
|
||||
|
||||
const clearGeneratedKey = () => {
|
||||
setGeneratedApiKey(null);
|
||||
};
|
||||
|
||||
return {
|
||||
apiKeys,
|
||||
loadingApiKeys,
|
||||
showCreateApiKeyDialog,
|
||||
newApiKeyName,
|
||||
generatedApiKey,
|
||||
creatingApiKey,
|
||||
setShowCreateApiKeyDialog,
|
||||
setNewApiKeyName,
|
||||
loadApiKeys,
|
||||
createApiKey,
|
||||
revokeApiKey,
|
||||
clearGeneratedKey,
|
||||
};
|
||||
}
|
||||
65
frontend/features/settings/hooks/useSettingsData.ts
Normal file
65
frontend/features/settings/hooks/useSettingsData.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { UserSettings } from "../types";
|
||||
|
||||
const defaultSettings: UserSettings = {
|
||||
playbackQuality: "original",
|
||||
wifiOnly: false,
|
||||
offlineEnabled: false,
|
||||
maxCacheSizeMb: 5120,
|
||||
};
|
||||
|
||||
export function useSettingsData() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [settings, setSettings] = useState<UserSettings>(defaultSettings);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getSettings();
|
||||
setSettings(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
// No toast - error visible in UI if settings fail to load
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async (newSettings: UserSettings) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await api.updateSettings(newSettings);
|
||||
setSettings(newSettings);
|
||||
// No toast - caller shows inline status
|
||||
} catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
throw error; // Re-throw so caller can show inline error
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSettings = (updates: Partial<UserSettings>) => {
|
||||
setSettings((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
isLoading,
|
||||
isSaving,
|
||||
setSettings,
|
||||
updateSettings,
|
||||
saveSettings,
|
||||
loadSettings,
|
||||
};
|
||||
}
|
||||
210
frontend/features/settings/hooks/useSystemSettings.ts
Normal file
210
frontend/features/settings/hooks/useSystemSettings.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SystemSettings } from "../types";
|
||||
|
||||
const defaultSystemSettings: SystemSettings = {
|
||||
lidarrEnabled: true,
|
||||
lidarrUrl: "http://localhost:8686",
|
||||
lidarrApiKey: "",
|
||||
openaiEnabled: false,
|
||||
openaiApiKey: "",
|
||||
openaiModel: "gpt-4",
|
||||
fanartEnabled: false,
|
||||
fanartApiKey: "",
|
||||
audiobookshelfEnabled: false,
|
||||
audiobookshelfUrl: "http://localhost:13378",
|
||||
audiobookshelfApiKey: "",
|
||||
soulseekUsername: "",
|
||||
soulseekPassword: "",
|
||||
spotifyClientId: "",
|
||||
spotifyClientSecret: "",
|
||||
musicPath: "/music",
|
||||
downloadPath: "/downloads",
|
||||
transcodeCacheMaxGb: 10,
|
||||
maxCacheSizeMb: 10240,
|
||||
autoSync: true,
|
||||
autoEnrichMetadata: true,
|
||||
// Download preferences
|
||||
downloadSource: "soulseek",
|
||||
soulseekFallback: "none",
|
||||
};
|
||||
|
||||
export function useSystemSettings() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings>(
|
||||
defaultSystemSettings
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [changedServices, setChangedServices] = useState<string[]>([]);
|
||||
const [originalSettings, setOriginalSettings] = useState<SystemSettings>(
|
||||
defaultSystemSettings
|
||||
);
|
||||
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && isAdmin) {
|
||||
loadSystemSettings();
|
||||
}
|
||||
}, [isAuthenticated, isAdmin]);
|
||||
|
||||
const loadSystemSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [sysData, userData] = await Promise.all([
|
||||
api.getSystemSettings(),
|
||||
api.getSettings(),
|
||||
]);
|
||||
|
||||
// Sanitize null values to empty strings for controlled inputs
|
||||
const sanitizeSettings = (settings: any): SystemSettings => {
|
||||
const sanitized: any = {};
|
||||
for (const key in settings) {
|
||||
const value = settings[key];
|
||||
// Convert null to empty string for string fields
|
||||
if (value === null && typeof defaultSystemSettings[key as keyof SystemSettings] === 'string') {
|
||||
sanitized[key] = '';
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const combinedSettings = {
|
||||
...sanitizeSettings(sysData),
|
||||
maxCacheSizeMb: userData.maxCacheSizeMb,
|
||||
};
|
||||
|
||||
setSystemSettings(combinedSettings);
|
||||
setOriginalSettings(combinedSettings);
|
||||
} catch (error) {
|
||||
console.error("Failed to load system settings:", error);
|
||||
// No toast - error will be visible in the UI if settings fail to load
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSystemSettings = async (settingsToSave: SystemSettings, showToast = false) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Save system settings
|
||||
await api.updateSystemSettings(settingsToSave);
|
||||
|
||||
// Also save user cache setting
|
||||
await api.updateSettings({
|
||||
maxCacheSizeMb: settingsToSave.maxCacheSizeMb,
|
||||
});
|
||||
|
||||
// Determine which services changed
|
||||
const changed: string[] = [];
|
||||
if (
|
||||
originalSettings.lidarrEnabled !==
|
||||
settingsToSave.lidarrEnabled ||
|
||||
originalSettings.lidarrUrl !== settingsToSave.lidarrUrl ||
|
||||
originalSettings.lidarrApiKey !== settingsToSave.lidarrApiKey
|
||||
) {
|
||||
changed.push("Lidarr");
|
||||
}
|
||||
if (
|
||||
originalSettings.soulseekUsername !== settingsToSave.soulseekUsername ||
|
||||
originalSettings.soulseekPassword !== settingsToSave.soulseekPassword
|
||||
) {
|
||||
changed.push("Soulseek");
|
||||
}
|
||||
if (
|
||||
originalSettings.audiobookshelfEnabled !==
|
||||
settingsToSave.audiobookshelfEnabled ||
|
||||
originalSettings.audiobookshelfUrl !==
|
||||
settingsToSave.audiobookshelfUrl ||
|
||||
originalSettings.audiobookshelfApiKey !==
|
||||
settingsToSave.audiobookshelfApiKey
|
||||
) {
|
||||
changed.push("Audiobookshelf");
|
||||
}
|
||||
|
||||
setChangedServices(changed);
|
||||
setOriginalSettings(settingsToSave);
|
||||
|
||||
return changed; // Return changed services
|
||||
} catch (error) {
|
||||
console.error("Failed to save system settings:", error);
|
||||
throw error; // Caller handles the error display
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSystemSettings = (updates: Partial<SystemSettings>) => {
|
||||
setSystemSettings((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Test a service connection
|
||||
* Returns { success: true, version?: string } or { success: false, error: string }
|
||||
* Caller handles displaying the result inline
|
||||
*/
|
||||
const testService = async (service: string): Promise<{ success: boolean; version?: string; error?: string }> => {
|
||||
try {
|
||||
let result;
|
||||
switch (service) {
|
||||
case "lidarr":
|
||||
result = await api.testLidarr(
|
||||
systemSettings.lidarrUrl,
|
||||
systemSettings.lidarrApiKey
|
||||
);
|
||||
break;
|
||||
case "openai":
|
||||
result = await api.testOpenai(
|
||||
systemSettings.openaiApiKey,
|
||||
systemSettings.openaiModel
|
||||
);
|
||||
break;
|
||||
case "fanart":
|
||||
result = await api.testFanart(systemSettings.fanartApiKey);
|
||||
break;
|
||||
case "audiobookshelf":
|
||||
result = await api.testAudiobookshelf(
|
||||
systemSettings.audiobookshelfUrl,
|
||||
systemSettings.audiobookshelfApiKey
|
||||
);
|
||||
break;
|
||||
case "soulseek":
|
||||
result = await api.testSoulseek(
|
||||
systemSettings.soulseekUsername,
|
||||
systemSettings.soulseekPassword
|
||||
);
|
||||
break;
|
||||
case "spotify":
|
||||
result = await api.testSpotify(
|
||||
systemSettings.spotifyClientId,
|
||||
systemSettings.spotifyClientSecret
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown service: ${service}`);
|
||||
}
|
||||
|
||||
return { success: true, version: result?.version };
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to test ${service}:`, error);
|
||||
return { success: false, error: error.message || `Failed to connect` };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
systemSettings,
|
||||
isLoading,
|
||||
isSaving,
|
||||
changedServices,
|
||||
setSystemSettings,
|
||||
updateSystemSettings,
|
||||
saveSystemSettings,
|
||||
testService,
|
||||
loadSystemSettings,
|
||||
};
|
||||
}
|
||||
137
frontend/features/settings/hooks/useTwoFactor.ts
Normal file
137
frontend/features/settings/hooks/useTwoFactor.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export function useTwoFactor() {
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||
const [loadingTwoFactor, setLoadingTwoFactor] = useState(false);
|
||||
const [settingUpTwoFactor, setSettingUpTwoFactor] = useState(false);
|
||||
const [twoFactorSecret, setTwoFactorSecret] = useState("");
|
||||
const [twoFactorQR, setTwoFactorQR] = useState("");
|
||||
const [twoFactorToken, setTwoFactorToken] = useState("");
|
||||
const [disablingTwoFactor, setDisablingTwoFactor] = useState(false);
|
||||
const [disableTwoFactorPassword, setDisableTwoFactorPassword] = useState("");
|
||||
const [disableTwoFactorToken, setDisableTwoFactorToken] = useState("");
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
|
||||
const [showRecoveryCodes, setShowRecoveryCodes] = useState(false);
|
||||
|
||||
// Retry tracking to prevent infinite loops on failure
|
||||
const retryCountRef = useRef(0);
|
||||
const maxRetries = 3;
|
||||
const hasFailedRef = useRef(false);
|
||||
|
||||
const load2FAStatus = useCallback(async () => {
|
||||
// Don't retry if we've already failed too many times
|
||||
if (hasFailedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingTwoFactor(true);
|
||||
const status = await api.get("/auth/2fa/status");
|
||||
setTwoFactorEnabled(status.enabled);
|
||||
// Reset retry count on success
|
||||
retryCountRef.current = 0;
|
||||
} catch (error) {
|
||||
console.error("Failed to load 2FA status:", error);
|
||||
retryCountRef.current++;
|
||||
|
||||
// Stop retrying after max attempts
|
||||
if (retryCountRef.current >= maxRetries) {
|
||||
hasFailedRef.current = true;
|
||||
console.warn(`2FA status load failed after ${maxRetries} attempts, giving up`);
|
||||
}
|
||||
} finally {
|
||||
setLoadingTwoFactor(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setup2FA = async () => {
|
||||
try {
|
||||
setLoadingTwoFactor(true);
|
||||
const response = await api.post("/auth/2fa/setup", {});
|
||||
setTwoFactorSecret(response.secret);
|
||||
setTwoFactorQR(response.qrCode);
|
||||
setSettingUpTwoFactor(true);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to setup 2FA:", error);
|
||||
throw error; // Let caller handle display
|
||||
} finally {
|
||||
setLoadingTwoFactor(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enable2FA = async (token: string) => {
|
||||
try {
|
||||
setLoadingTwoFactor(true);
|
||||
const response = await api.post("/auth/2fa/enable", {
|
||||
secret: twoFactorSecret,
|
||||
token,
|
||||
});
|
||||
|
||||
setRecoveryCodes(response.recoveryCodes);
|
||||
setShowRecoveryCodes(true);
|
||||
setTwoFactorEnabled(true);
|
||||
setSettingUpTwoFactor(false);
|
||||
setTwoFactorToken("");
|
||||
} catch (error: any) {
|
||||
console.error("Failed to enable 2FA:", error);
|
||||
throw error; // Let caller handle display
|
||||
} finally {
|
||||
setLoadingTwoFactor(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable2FA = async (password: string, token: string) => {
|
||||
try {
|
||||
setDisablingTwoFactor(true);
|
||||
await api.post("/auth/2fa/disable", {
|
||||
password,
|
||||
token,
|
||||
});
|
||||
|
||||
setTwoFactorEnabled(false);
|
||||
setDisableTwoFactorPassword("");
|
||||
setDisableTwoFactorToken("");
|
||||
} catch (error: any) {
|
||||
console.error("Failed to disable 2FA:", error);
|
||||
throw error; // Let caller handle display
|
||||
} finally {
|
||||
setDisablingTwoFactor(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel2FASetup = () => {
|
||||
setSettingUpTwoFactor(false);
|
||||
setTwoFactorToken("");
|
||||
setTwoFactorSecret("");
|
||||
setTwoFactorQR("");
|
||||
};
|
||||
|
||||
const closeRecoveryCodes = () => {
|
||||
setShowRecoveryCodes(false);
|
||||
setRecoveryCodes([]);
|
||||
};
|
||||
|
||||
return {
|
||||
twoFactorEnabled,
|
||||
loadingTwoFactor,
|
||||
settingUpTwoFactor,
|
||||
twoFactorSecret,
|
||||
twoFactorQR,
|
||||
twoFactorToken,
|
||||
disablingTwoFactor,
|
||||
disableTwoFactorPassword,
|
||||
disableTwoFactorToken,
|
||||
recoveryCodes,
|
||||
showRecoveryCodes,
|
||||
setTwoFactorToken,
|
||||
setDisableTwoFactorPassword,
|
||||
setDisableTwoFactorToken,
|
||||
load2FAStatus,
|
||||
setup2FA,
|
||||
enable2FA,
|
||||
disable2FA,
|
||||
cancel2FASetup,
|
||||
closeRecoveryCodes,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user