feat: enhance API key validation with detailed permission checks and user feedback
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{["asset.read", "asset.upload", "album.read", "album.create", "album.update", "tag.create"].map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-[10px] h-5 font-mono font-normal">{p}</Badge>
|
||||
))}
|
||||
</div>
|
||||
{importValidationResult && (
|
||||
<div className={`rounded-md p-2.5 text-xs ${importValidationResult.valid ? "bg-green-500/10 border border-green-500/20" : "bg-destructive/10 border border-destructive/20"}`}>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
{importValidationResult.valid ? (
|
||||
<><CheckCircle className="h-3.5 w-3.5 text-green-500" /><span className="font-medium text-green-600 dark:text-green-400">All permissions verified</span></>
|
||||
) : (
|
||||
<><XCircle className="h-3.5 w-3.5 text-destructive" /><span className="font-medium text-destructive">{importValidationResult.error}</span></>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{IMPORT_REQUIRED_PERMISSIONS.map((p) => {
|
||||
const granted = importValidationResult.permissions.includes(p);
|
||||
const missing = importValidationResult.missing.includes(p);
|
||||
return (
|
||||
<Badge
|
||||
key={p}
|
||||
variant="outline"
|
||||
className={`text-[10px] h-5 font-mono font-normal ${granted ? "border-green-500/40 text-green-600 dark:text-green-400" : missing ? "border-destructive/40 text-destructive" : ""}`}
|
||||
>
|
||||
{granted ? "✓" : "✗"} {p}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!importValidationResult && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{IMPORT_REQUIRED_PERMISSIONS.map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-[10px] h-5 font-mono font-normal">{p}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleGenerateImportKey}
|
||||
disabled={generatingImportKey}
|
||||
disabled={generatingImportKey || importApiKeySaved}
|
||||
>
|
||||
<Wand2 className="h-3 w-3 mr-1" />
|
||||
{generatingImportKey ? "Generating..." : "Generate automatically"}
|
||||
|
||||
Reference in New Issue
Block a user