diff --git a/src/pages/api/settings/validate-api-key.ts b/src/pages/api/settings/validate-api-key.ts index 8f7c2aa..ff49774 100644 --- a/src/pages/api/settings/validate-api-key.ts +++ b/src/pages/api/settings/validate-api-key.ts @@ -2,7 +2,7 @@ import { ENV } from "@/config/environment"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { NextApiRequest, NextApiResponse } from "next"; -const REQUIRED_PERMISSIONS = [ +const WORKFLOW_PERMISSIONS = [ "asset.read", "asset.update", "album.create", @@ -16,16 +16,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const currentUser = await getCurrentUser(req); if (!currentUser) return res.status(401).json({ message: "Unauthorized" }); - const { apiKey } = req.body; + const { apiKey, requiredPermissions } = req.body; if (!apiKey) return res.status(400).json({ message: "apiKey is required" }); + const REQUIRED_PERMISSIONS: string[] = Array.isArray(requiredPermissions) ? requiredPermissions : WORKFLOW_PERMISSIONS; + try { - // Validate by calling Immich's auth endpoint with the key - const authRes = await fetch(`${ENV.IMMICH_URL}/api/users/me`, { + const keyRes = await fetch(`${ENV.IMMICH_URL}/api/api-keys/me`, { headers: { "x-api-key": apiKey }, }); - if (!authRes.ok) { + if (!keyRes.ok) { return res.status(200).json({ valid: false, error: "Invalid API key — could not authenticate with Immich", @@ -34,56 +35,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } - // Get the API key details to check permissions - const keysRes = await fetch(`${ENV.IMMICH_URL}/api/api-keys`, { - headers: { "x-api-key": apiKey }, - }); - - if (!keysRes.ok) { - // Can authenticate but can't list keys — might be a full-access key - return res.status(200).json({ - valid: true, - error: null, - permissions: ["all (could not verify individual permissions)"], - missing: [], - }); - } - - const keys = await keysRes.json(); - - // Find the key being used — it's the one that matches our request - // Immich doesn't directly tell us which key is in use, so check all permissions across all keys - // The safest check: try to access endpoints that require each permission - const permissionChecks = await Promise.all([ - // asset.read — try searching assets - fetch(`${ENV.IMMICH_URL}/api/assets?take=1`, { - headers: { "x-api-key": apiKey }, - }).then((r) => ({ permission: "asset.read", granted: r.ok })), - - // asset.update — try a dry validation (we just check if the endpoint doesn't 403) - fetch(`${ENV.IMMICH_URL}/api/assets`, { - method: "PUT", - headers: { "x-api-key": apiKey, "Content-Type": "application/json" }, - body: JSON.stringify({ ids: [], isFavorite: true }), - }).then((r) => ({ permission: "asset.update", granted: r.status !== 403 })), - - // album.create — check via OPTIONS or a minimal request - fetch(`${ENV.IMMICH_URL}/api/albums`, { - headers: { "x-api-key": apiKey }, - }).then((r) => ({ permission: "album.create", granted: r.ok })), - - // album.update — same endpoint access implies read+write - fetch(`${ENV.IMMICH_URL}/api/albums`, { - headers: { "x-api-key": apiKey }, - }).then((r) => ({ permission: "album.update", granted: r.ok })), - - // tag.create - fetch(`${ENV.IMMICH_URL}/api/tags`, { - headers: { "x-api-key": apiKey }, - }).then((r) => ({ permission: "tag.create", granted: r.ok || r.status !== 403 })), - ]); - - const granted = permissionChecks.filter((p) => p.granted).map((p) => p.permission); + const keyData = await keyRes.json(); + const granted: string[] = keyData.permissions ?? []; const missing = REQUIRED_PERMISSIONS.filter((p) => !granted.includes(p)); return res.status(200).json({ diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 164f1bd..0616f2e 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -50,6 +50,9 @@ export default function SettingsPage() { const [showImportApiKey, setShowImportApiKey] = useState(false); const [importApiKeySaved, setImportApiKeySaved] = useState(false); const [generatingImportKey, setGeneratingImportKey] = useState(false); + const [importValidationResult, setImportValidationResult] = useState<{ + valid: boolean; error: string | null; permissions: string[]; missing: string[]; + } | null>(null); const fetchWorkflowApiKey = async () => { try { @@ -71,14 +74,45 @@ export default function SettingsPage() { } }; + const IMPORT_REQUIRED_PERMISSIONS = ["asset.read", "asset.upload", "album.read", "album.create", "album.update", "tag.create"]; + const saveImportApiKey = async () => { + if (!importApiKey.trim()) { + setImportApiKeyLoading(true); + try { + await API.put("/api/settings/kv/import_api_key", { value: "" }); + setImportApiKeySaved(false); + setImportValidationResult(null); + toast.success("Import API key cleared"); + } catch { + toast.error("Failed to clear import API key"); + } + setImportApiKeyLoading(false); + return; + } + setImportApiKeyLoading(true); + setImportValidationResult(null); + toast("Validating API key..."); + try { + const result = await API.post("/api/settings/validate-api-key", { + apiKey: importApiKey, + requiredPermissions: IMPORT_REQUIRED_PERMISSIONS, + }); + setImportValidationResult(result); + + if (!result.valid) { + toast.error(result.error || "API key validation failed"); + setImportApiKeyLoading(false); + return; + } + await API.put("/api/settings/kv/import_api_key", { value: importApiKey.trim() }); - setImportApiKeySaved(!!importApiKey.trim()); - toast.success(importApiKey.trim() ? "Import API key saved" : "Import API key cleared"); + setImportApiKeySaved(true); + toast.success("API key validated and saved"); } catch { - toast.error("Failed to save import API key"); + toast.error("Failed to validate API key"); } setImportApiKeyLoading(false); }; @@ -294,18 +328,46 @@ export default function SettingsPage() { {importApiKeyLoading ? "Saving..." : "Save"} -
- {["asset.read", "asset.upload", "album.read", "album.create", "album.update", "tag.create"].map((p) => ( - {p} - ))} -
+ {importValidationResult && ( +
+
+ {importValidationResult.valid ? ( + <>All permissions verified + ) : ( + <>{importValidationResult.error} + )} +
+
+ {IMPORT_REQUIRED_PERMISSIONS.map((p) => { + const granted = importValidationResult.permissions.includes(p); + const missing = importValidationResult.missing.includes(p); + return ( + + {granted ? "✓" : "✗"} {p} + + ); + })} +
+
+ )} + {!importValidationResult && ( +
+ {IMPORT_REQUIRED_PERMISSIONS.map((p) => ( + {p} + ))} +
+ )}