feat: enhance API key validation with detailed permission checks and user feedback

This commit is contained in:
sai80082
2026-04-09 10:57:56 +05:30
parent b47fa4b2d8
commit 4861bbf0ce
2 changed files with 79 additions and 64 deletions
+8 -55
View File
@@ -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({
+71 -9
View File
@@ -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"}