diff --git a/src/components/people/PeopleList.tsx b/src/components/people/PeopleList.tsx index f031000..a7e9e57 100644 --- a/src/components/people/PeopleList.tsx +++ b/src/components/people/PeopleList.tsx @@ -8,6 +8,15 @@ import { useRouter } from "next/router"; import PeopleFilterContext from "@/contexts/PeopleFilterContext"; import PageLayout from "../layouts/PageLayout"; import Header from "../shared/Header"; +import { Users } from "lucide-react"; + +const COLS_KEY = "people_grid_cols"; + +function getStoredCols(): number { + if (typeof window === "undefined") return 6; + const v = parseInt(localStorage.getItem(COLS_KEY) || "", 10); + return isNaN(v) ? 6 : Math.min(20, Math.max(3, v)); +} export default function PeopleList() { const router = useRouter(); @@ -15,12 +24,22 @@ export default function PeopleList() { const [count, setCount] = useState(0); const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const [cols, setCols] = useState(6); const [filters, setFilters] = useState({ ...router.query, page: 1, }); - + // Hydrate cols from localStorage after mount + useEffect(() => { + setCols(getStoredCols()); + }, []); + + const handleColsChange = (val: number) => { + setCols(val); + localStorage.setItem(COLS_KEY, String(val)); + }; + const fetchData = async () => { setLoading(true); setErrorMessage(null); @@ -38,29 +57,36 @@ export default function PeopleList() { }; const handleRemove = (person: IPerson) => { - setPeople((prev) => prev.filter((p) => { - return p.id !== person.id - })); - } + setPeople((prev) => prev.filter((p) => p.id !== person.id)); + }; useEffect(() => { if (!router.isReady) return; fetchData(); }, [filters]); - const renderContent = () => { if (loading) return ; - if (errorMessage) return
{errorMessage}
; + if (errorMessage) return
{errorMessage}
; + if (!people.length) return ( +
+ +

No people found

+
+ ); return ( -
+
{people.map((person) => ( - + ))}
); }; + return ( ({ ...prev, ...newConfig })), }} > - +
} /> {renderContent()} + + {/* Grid size slider — fixed bottom-left */} +
+ + handleColsChange(Number(e.target.value))} + className="w-28 h-1.5 appearance-none rounded-full bg-muted cursor-pointer accent-foreground" + title={`${cols} per row`} + /> + {cols} +
); diff --git a/src/components/people/PersonBirthdayCell.tsx b/src/components/people/PersonBirthdayCell.tsx index b6c848f..96d6bfc 100644 --- a/src/components/people/PersonBirthdayCell.tsx +++ b/src/components/people/PersonBirthdayCell.tsx @@ -1,102 +1,141 @@ import { IPerson } from '@/types/person' -import React, { useEffect, useMemo, useState } from 'react' -import { DatePicker } from '../ui/datepicker' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { updatePerson } from '@/handlers/api/people.handler'; -import { Input } from '../ui/input'; import { useToast } from '../ui/use-toast'; import { format } from 'date-fns'; +import { CalendarDays, X } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { Calendar } from '../ui/calendar'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; // @ts-ignore import chrono from 'chrono-node' - - interface IProps { person: IPerson } const normalizeDateOnly = (value?: Date | string | null): Date | null => { - if (!value) { - return null; - } - + if (!value) return null; if (typeof value === 'string') { - const dateOnlyMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/); - if (dateOnlyMatch) { - const [, year, month, day] = dateOnlyMatch; - return new Date(Number(year), Number(month) - 1, Number(day)); - } + const m = value.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); } - - const parsedDate = new Date(value); - if (Number.isNaN(parsedDate.getTime())) { - return null; - } - - return new Date( - parsedDate.getUTCFullYear(), - parsedDate.getUTCMonth(), - parsedDate.getUTCDate() - ); + const d = new Date(value); + if (Number.isNaN(d.getTime())) return null; + return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); } -export default function PersonBirthdayCell( - { person }: IProps -) { +export default function PersonBirthdayCell({ person }: IProps) { const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); const { toast } = useToast(); - const [errorMessage, setErrorMessage] = useState(null); const normalizedBirthDate = useMemo(() => normalizeDateOnly(person.birthDate), [person.birthDate]); const [selectedDate, setSelectedDate] = useState(normalizedBirthDate); - const [textDate, setTextDate] = useState(normalizedBirthDate ? format(normalizedBirthDate, 'PPP') : ''); + const [textInput, setTextInput] = useState(''); useEffect(() => { - setSelectedDate(normalizedBirthDate); - setTextDate(normalizedBirthDate ? format(normalizedBirthDate, 'PPP') : ''); - }, [normalizedBirthDate]); - - const handleEdit = (date?: Date | null) => { - const formatedDate = date ? format(date, 'yyyy-MM-dd') : null; - setSelectedDate(date || null); - setTextDate(date ? format(date, 'PPP') : ''); + setSelectedDate(normalizeDateOnly(person.birthDate)); + }, [person.birthDate]); + + const handleSave = (date: Date | null) => { + const formatted = date ? format(date, 'yyyy-MM-dd') : null; setLoading(true); - return updatePerson(person.id, { - birthDate: formatedDate, - }) + updatePerson(person.id, { birthDate: formatted }) .then(() => { - toast({ - title: "Success", - description: "Person updated successfully", - }) + setSelectedDate(date); + setOpen(false); + setTextInput(''); + toast({ title: "Birthday saved" }); }) - .catch(() => {}) - .finally(() => { - setLoading(false); - }); - } + .catch(() => { + toast({ title: "Error", description: "Failed to save birthday", variant: "destructive" }); + }) + .finally(() => setLoading(false)); + }; + const handleCalendarSelect = (date?: Date) => { + handleSave(date ?? null); + }; + const handleTextCommit = () => { + if (!textInput.trim()) { + handleSave(null); + return; + } + const parsed = chrono.parseDate(textInput); + if (parsed) { + handleSave(parsed); + } else { + toast({ title: "Couldn't parse date", description: 'Try "Jan 1 1990" or "1990-01-01"', variant: "destructive" }); + } + }; + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation(); + handleSave(null); + }; return ( -
- { - if (e.key === 'Enter') { - const parsedDate = chrono.parseDate(textDate || ""); - if (parsedDate) { - handleEdit(parsedDate); - } else { - handleEdit(null); - } - } - }} - disabled={loading} - onChange={(e) => setTextDate(e.target.value)} - /> - -
- ) + { setOpen(v); if (v) setTextInput(''); }}> + + + + +
+

Type a date or pick from calendar

+
+ setTextInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleTextCommit(); + if (e.key === 'Escape') setOpen(false); + }} + autoFocus + disabled={loading} + /> + +
+
+ +
+
+ ); } diff --git a/src/components/people/PersonItem.tsx b/src/components/people/PersonItem.tsx index d444b10..ac59872 100644 --- a/src/components/people/PersonItem.tsx +++ b/src/components/people/PersonItem.tsx @@ -1,23 +1,22 @@ import { IPerson } from "@/types/person"; import React, { useRef, useState } from "react"; -import { Avatar } from "../ui/avatar"; import { mergePerson, searchPeople, updatePerson } from "@/handlers/api/people.handler"; import { PersonMergeDropdown } from "./PersonMergeDropdown"; import PersonBirthdayCell from "./PersonBirthdayCell"; import clsx from "clsx"; import Link from "next/link"; -import { ArrowUpRight, Info } from "lucide-react"; +import { ArrowUpRight, Eye, EyeOff, Info } from "lucide-react"; import { useConfig } from "@/contexts/ConfigContext"; import { useToast } from "../ui/use-toast"; -import { Badge } from "../ui/badge"; -import { Button } from "@/components/ui/button"; import ShareAssetsTrigger from "../shared/ShareAssetsTrigger"; import { Autocomplete, AutocompleteOption } from "../ui/autocomplete"; import { AlertDialog, IAlertDialogActions } from "../ui/alert-dialog"; + interface IProps { person: IPerson; onRemove: (person: IPerson) => void; } + export default function PersonItem({ person, onRemove }: IProps) { const { exImmichUrl } = useConfig(); const { toast } = useToast(); @@ -30,26 +29,15 @@ export default function PersonItem({ person, onRemove }: IProps) { const handleEdit = () => { if (formData.name && formData.name !== person.name) { setLoading(true); - return updatePerson(person.id, { - name: formData.name, - }) + return updatePerson(person.id, { name: formData.name }) .then(() => { - setEditMode(!editMode); - toast({ - title: "Success", - description: "Person updated successfully", - }); + setEditMode(false); + toast({ title: "Success", description: "Person updated successfully" }); }) .catch(() => { - toast({ - title: "Error", - description: "Failed to update person", - variant: "destructive", - }); + toast({ title: "Error", description: "Failed to update person", variant: "destructive" }); }) - .finally(() => { - setLoading(false); - }); + .finally(() => setLoading(false)); } else { setEditMode(!editMode); } @@ -57,19 +45,10 @@ export default function PersonItem({ person, onRemove }: IProps) { const handleHide = (hidden: boolean) => { setLoading(true); - return updatePerson(person.id, { - isHidden: hidden, - }) - .then(() => { - setFormData((person) => ({ - ...person, - isHidden: hidden, - })); - }) - .catch(() => { }) - .finally(() => { - setLoading(false); - }); + return updatePerson(person.id, { isHidden: hidden }) + .then(() => setFormData((p) => ({ ...p, isHidden: hidden }))) + .catch(() => {}) + .finally(() => setLoading(false)); }; const handleMerge = async (option: AutocompleteOption) => { @@ -79,87 +58,125 @@ export default function PersonItem({ person, onRemove }: IProps) { return (
-
- -
- {person.assetCount} Assets + {/* Image area */} +
+ {person.thumbnailPath ? ( + {formData.name + ) : ( +
+ ? +
+ )} + + {/* Gradient overlay — always visible at bottom */} +
+ + {/* Asset count — bottom of image */} +
+ + {formData.isHidden && Hidden ·} + {person.assetCount} assets +
-
- - - - - - -
-
- - - + + {/* Action row — slides in from top on hover */} +
+ {/* Left: immich + info links */} +
+ e.stopPropagation()} + > + + + e.stopPropagation()} + > + + +
+ + {/* Right: hide + share + merge */} +
+ + svg]:h-[13px] [&>svg]:w-[13px]", + }} + /> + +
- {!editMode ? ( -

{ - setEditMode((prev) => !prev); - }} - > - {formData.name ? ( - formData.name - ) : ( - Unknown - )} -

- ) : ( - searchPeople(query).then((people) => people.map((person: any) => ({ - label: person.name, value: person.id, - imageUrl: person.thumbnailPath - })))} - type="text" - className="text-lg font-semibold text-center w-full px-2 py-1 rounded-lg" - defaultValue={formData.name} - placeholder="Enter name" - autoFocus - onChange={(e) => { - setFormData({ ...formData, name: e.target.value }); - }} - value={formData.name} - onOptionSelect={(value) => { - mergeDialogRef.current?.open(); - selectedPerson.current = value; - }} - createNewLabel="Create" - disabled={loading} - onCreateNew={(value) => { - handleEdit(); - }} - /> - )} -
+ + {/* Name + birthday section */} +
+ {!editMode ? ( + + ) : ( + + searchPeople(query).then((people) => + people.map((p: any) => ({ label: p.name, value: p.id, imageUrl: p.thumbnailPath })) + ) + } + type="text" + className="text-sm font-semibold w-full px-1.5 py-0.5 rounded-md" + defaultValue={formData.name} + placeholder="Enter name" + autoFocus + onChange={(e) => setFormData({ ...formData, name: e.target.value })} + value={formData.name} + onOptionSelect={(value) => { + mergeDialogRef.current?.open(); + selectedPerson.current = value; + }} + createNewLabel="Save" + disabled={loading} + onCreateNew={() => handleEdit()} + /> + )} +
@@ -168,9 +185,7 @@ export default function PersonItem({ person, onRemove }: IProps) { title="Merge Person" description="Are you sure you want to merge this person with the selected person?" onConfirm={() => { - if (selectedPerson.current) { - handleMerge(selectedPerson.current); - } + if (selectedPerson.current) handleMerge(selectedPerson.current); }} />
diff --git a/src/components/shared/ApiKeyGate.tsx b/src/components/shared/ApiKeyGate.tsx new file mode 100644 index 0000000..844384e --- /dev/null +++ b/src/components/shared/ApiKeyGate.tsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; +import { KeyRound, Wand2, Settings } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import Link from "next/link"; + +interface Permission { + name: string; + description: string; +} + +interface ApiKeyGateProps { + title: string; + description: string; + permissions: Permission[]; + generateEndpoint: string; + onGenerated: () => void; +} + +export default function ApiKeyGate({ title, description, permissions, generateEndpoint, onGenerated }: ApiKeyGateProps) { + const [generating, setGenerating] = useState(false); + const [error, setError] = useState(null); + + const handleGenerate = async () => { + setGenerating(true); + setError(null); + try { + const res = await fetch(generateEndpoint, { method: "POST" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.message ?? "Failed to generate API key"); + } + onGenerated(); + } catch (err: any) { + setError(err.message ?? "Failed to generate API key"); + } finally { + setGenerating(false); + } + }; + + return ( +
+
+
+ +
+ +
+

{title}

+

{description}

+
+ +
+

Required permissions

+
+ {permissions.map((p) => ( + + {p.name} + + ))} +
+
+ + {error && ( +

{error}

+ )} + +
+ + + + +
+
+
+ ); +} diff --git a/src/components/ui/autocomplete.tsx b/src/components/ui/autocomplete.tsx index d068bb1..0da4000 100644 --- a/src/components/ui/autocomplete.tsx +++ b/src/components/ui/autocomplete.tsx @@ -1,4 +1,5 @@ import * as React from "react" +import ReactDOM from "react-dom" import { Input } from "./input" import { cn } from "@/lib/utils" import { Avatar, AvatarImage } from "./avatar" @@ -43,10 +44,18 @@ const Autocomplete = React.forwardRef( const [options, setOptions] = React.useState(staticOptions || []) const [isLoading, setIsLoading] = React.useState(false) const [selectedIndex, setSelectedIndex] = React.useState(-1) + const [dropdownRect, setDropdownRect] = React.useState<{ top: number; left: number; width: number } | null>(null) const containerRef = React.useRef(null) const inputRef = React.useRef(null) const debounceTimeout = React.useRef(undefined) + const updateDropdownRect = React.useCallback(() => { + if (inputRef.current) { + const rect = inputRef.current.getBoundingClientRect() + setDropdownRect({ top: rect.bottom + window.scrollY + 4, left: rect.left + window.scrollX, width: rect.width }) + } + }, []) + // Find the label for the current value const currentLabel = React.useMemo(() => { if (!value) return "" @@ -181,6 +190,7 @@ const Autocomplete = React.forwardRef( }} value={inputValue} onFocus={() => { + updateDropdownRect() setOpen(true) setSelectedIndex(showCreateNew ? 0 : -1) }} @@ -194,8 +204,11 @@ const Autocomplete = React.forwardRef( props.onChange?.(e) }} /> - {open && (filteredOptions.length > 0 || isLoading || showCreateNew) && ( -
+ {open && (filteredOptions.length > 0 || isLoading || showCreateNew) && dropdownRect && typeof document !== "undefined" && ReactDOM.createPortal( +
{isLoading ? (
Loading... @@ -241,7 +254,8 @@ const Autocomplete = React.forwardRef( ))} )} -
+
, + document.body )}
) diff --git a/src/pages/api/import-jobs/index.ts b/src/pages/api/import-jobs/index.ts index b0c6590..eb78589 100644 --- a/src/pages/api/import-jobs/index.ts +++ b/src/pages/api/import-jobs/index.ts @@ -1,11 +1,15 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { desc, eq } from "drizzle-orm"; +import { desc, eq, and } from "drizzle-orm"; import { appDb } from "@/db"; import { importJobs, importJobItems } from "@/db/schema"; +import { settings } from "@/db/schema/settings.schema"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { getUserHeaders } from "@/helpers/user.helper"; import { runImportJob } from "@/workers/import/runner"; import { getProcessor } from "@/workers/import/registry"; +import { ENV } from "@/config/environment"; + +const IMPORT_API_KEY_SETTING = "import_api_key"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "GET") { @@ -66,7 +70,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) })) ); - const headers = getUserHeaders(currentUser) as Record; + // Prefer import API key over user session for uploads + const [importKeyRow] = await appDb + .select() + .from(settings) + .where(and(eq(settings.key, IMPORT_API_KEY_SETTING), eq(settings.ownerId, currentUser.id))); + + let headers: Record; + if (importKeyRow?.value) { + headers = { "x-api-key": importKeyRow.value, "Content-Type": "application/json" }; + } else { + headers = getUserHeaders(currentUser) as Record; + } + runImportJob(job.id, headers).catch((err) => console.error("Unhandled error in runImportJob", err) ); diff --git a/src/pages/api/settings/generate-import-api-key.ts b/src/pages/api/settings/generate-import-api-key.ts new file mode 100644 index 0000000..df97ea5 --- /dev/null +++ b/src/pages/api/settings/generate-import-api-key.ts @@ -0,0 +1,51 @@ +import { ENV } from "@/config/environment"; +import { appDb } from "@/db"; +import { settings } from "@/db/schema/settings.schema"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { getUserHeaders } from "@/helpers/user.helper"; +import { and, eq } from "drizzle-orm"; +import { NextApiRequest, NextApiResponse } from "next"; + +export const IMPORT_API_KEY_SETTING = "import_api_key"; +const IMPORT_KEY_NAME = "Power Tools Import Key"; +const IMPORT_PERMISSIONS = [ + "asset.read", + "asset.upload", + "album.read", + "album.create", + "album.update", + "tag.create", +]; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" }); + + const currentUser = await getCurrentUser(req); + if (!currentUser) return res.status(401).json({ message: "Unauthorized" }); + + const immichRes = await fetch(`${ENV.IMMICH_URL}/api/api-keys`, { + method: "POST", + headers: getUserHeaders(currentUser, { "Content-Type": "application/json" }), + body: JSON.stringify({ name: IMPORT_KEY_NAME, permissions: IMPORT_PERMISSIONS }), + }); + + if (!immichRes.ok) { + const err = await immichRes.json().catch(() => ({})); + return res.status(immichRes.status).json({ message: err.message ?? "Failed to create API key in Immich" }); + } + + const { secret } = await immichRes.json(); + + const [existing] = await appDb + .select() + .from(settings) + .where(and(eq(settings.key, IMPORT_API_KEY_SETTING), eq(settings.ownerId, currentUser.id))); + + if (existing) { + await appDb.update(settings).set({ value: secret }).where(eq(settings.id, existing.id)); + } else { + await appDb.insert(settings).values({ key: IMPORT_API_KEY_SETTING, value: secret, ownerId: currentUser.id }); + } + + return res.status(200).json({ success: true }); +} diff --git a/src/pages/assets/import-shared.tsx b/src/pages/assets/import-shared.tsx index 3fb6054..3a820b3 100644 --- a/src/pages/assets/import-shared.tsx +++ b/src/pages/assets/import-shared.tsx @@ -20,6 +20,16 @@ import { } from "lucide-react"; import PageLayout from "@/components/layouts/PageLayout"; import Header from "@/components/shared/Header"; +import ApiKeyGate from "@/components/shared/ApiKeyGate"; + +const IMPORT_PERMISSIONS = [ + { name: "asset.read", description: "Check for existing assets" }, + { name: "asset.upload", description: "Upload new assets" }, + { name: "album.read", description: "Read album data" }, + { name: "album.create", description: "Create new albums" }, + { name: "album.update", description: "Add assets to albums" }, + { name: "tag.create", description: "Tag imported assets" }, +]; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; @@ -152,6 +162,15 @@ export default function ImportSharedPage() { const [tagAssets, setTagAssets] = useState(true); const [detailJobId, setDetailJobId] = useState(null); + const [hasImportApiKey, setHasImportApiKey] = useState(null); + + useEffect(() => { + fetch("/api/settings/kv/import_api_key") + .then((r) => r.ok ? r.json() : null) + .then((data) => setHasImportApiKey(!!data?.value)) + .catch(() => setHasImportApiKey(false)); + }, []); + const { data: permissions } = useQuery({ queryKey: ["validate-permissions"], queryFn: validatePermissions, @@ -440,8 +459,19 @@ export default function ImportSharedPage() { } /> + {/* ── API key gate ── */} + {hasImportApiKey === false && ( + setHasImportApiKey(true)} + /> + )} + {/* ── Landing state ── */} - {!sharedData && ( + {hasImportApiKey !== false && !sharedData && (
{permissions?.canUpload === false && ( @@ -552,7 +582,7 @@ export default function ImportSharedPage() { )} {/* ── Album detail + gallery view ── */} - {sharedData && albumDetails && sharedLinkDetails && ( + {hasImportApiKey !== false && sharedData && albumDetails && sharedLinkDetails && ( <>
{/* Upload banner */} diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 6440e17..32f8541 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RefreshCw, Trash2, Save, Eye, EyeOff, ExternalLink, - Key, Workflow, Shield, CheckCircle, XCircle, Server, Globe, + Key, Workflow, Shield, CheckCircle, XCircle, Server, Globe, Download, Wand2, } from "lucide-react"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; @@ -60,6 +60,12 @@ export default function SettingsPage() { valid: boolean; error: string | null; permissions: string[]; missing: string[]; } | null>(null); + const [importApiKey, setImportApiKey] = useState(""); + const [importApiKeyLoading, setImportApiKeyLoading] = useState(false); + const [showImportApiKey, setShowImportApiKey] = useState(false); + const [importApiKeySaved, setImportApiKeySaved] = useState(false); + const [generatingImportKey, setGeneratingImportKey] = useState(false); + const fetchWorkflowApiKey = async () => { try { const data = await API.get("/api/settings/kv/workflow_api_key"); @@ -70,6 +76,40 @@ export default function SettingsPage() { } }; + const fetchImportApiKey = async () => { + try { + const data = await API.get("/api/settings/kv/import_api_key"); + setImportApiKey(data.value || ""); + setImportApiKeySaved(!!data.value); + } catch { + setImportApiKeySaved(false); + } + }; + + const saveImportApiKey = async () => { + setImportApiKeyLoading(true); + try { + 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"); + } catch { + toast.error("Failed to save import API key"); + } + setImportApiKeyLoading(false); + }; + + const handleGenerateImportKey = async () => { + setGeneratingImportKey(true); + try { + await API.post("/api/settings/generate-import-api-key", {}); + await fetchImportApiKey(); + toast.success("Import API key generated and saved"); + } catch (err: any) { + toast.error(err?.message ?? "Failed to generate import API key"); + } + setGeneratingImportKey(false); + }; + const saveWorkflowApiKey = async () => { if (!workflowApiKey.trim()) { // Clear the key @@ -123,6 +163,7 @@ export default function SettingsPage() { useEffect(() => { fetchKeys(); fetchWorkflowApiKey(); + fetchImportApiKey(); }, []); const handleDelete = async (purpose: string) => { @@ -265,6 +306,74 @@ export default function SettingsPage() {
+ {/* Import API Key */} +
+
+ +

Import API Key

+ {importApiKeySaved ? ( + + ) : ( + + )} +
+
+

+ Immich API key used when importing from shared links. Needs asset upload, album, and tag permissions. +

+
+
+ setImportApiKey(e.target.value)} + /> + +
+ +
+
+ {["asset.read", "asset.upload", "album.read", "album.create", "album.update", "tag.create"].map((p) => ( + {p} + ))} +
+
+ + {exImmichUrl && ( + + Create in Immich + + + )} +
+
+
+ {/* Managed API Keys */}
diff --git a/src/pages/workflows/[id].tsx b/src/pages/workflows/[id].tsx index ba09b1e..bdef677 100644 --- a/src/pages/workflows/[id].tsx +++ b/src/pages/workflows/[id].tsx @@ -30,12 +30,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Badge } from "@/components/ui/badge"; import { ASSET_THUMBNAIL_PATH } from "@/config/routes"; import { IWorkflowRun } from "@/types/workflow"; -import { ArrowLeft, Play, Save, Clock, Webhook, History, ChevronUp, ChevronDown, Bug, TriangleAlert, Wand2 } from "lucide-react"; +import { ArrowLeft, Play, Save, Clock, Webhook, History, ChevronUp, ChevronDown, Bug } from "lucide-react"; import Head from "next/head"; import { format } from "date-fns"; import Link from "next/link"; import Loader from "@/components/ui/loader"; import API from "@/lib/api"; +import ApiKeyGate from "@/components/shared/ApiKeyGate"; + +const WORKFLOW_PERMISSIONS = [ + { name: "asset.read", description: "Query and filter assets" }, + { name: "asset.update", description: "Favorite, archive, update metadata" }, + { name: "album.read", description: "Read album data" }, + { name: "album.create", description: "Create new albums" }, + { name: "album.update", description: "Add/remove assets from albums" }, + { name: "tag.create", description: "Create and assign tags" }, +]; function generateId() { return Math.random().toString(36).substring(2, 15); @@ -232,7 +242,6 @@ function WorkflowEditorInner() { const [showRuns, setShowRuns] = useState(false); const [expandedRunId, setExpandedRunId] = useState(null); const [hasApiKey, setHasApiKey] = useState(null); - const [generatingKey, setGeneratingKey] = useState(false); const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]); const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]); @@ -261,19 +270,6 @@ function WorkflowEditorInner() { .catch(() => setHasApiKey(false)); }, [id]); - const handleGenerateApiKey = async () => { - setGeneratingKey(true); - try { - await API.post("/api/settings/generate-workflow-api-key", {}); - setHasApiKey(true); - hotToast.success("Workflow API key created and saved"); - } catch (err: any) { - hotToast.error(err?.message ?? "Failed to create API key"); - } finally { - setGeneratingKey(false); - } - }; - const onConnect = useCallback((connection: Connection) => { setEdges((eds) => addEdge({ ...connection, animated: true }, eds)); }, [setEdges]); @@ -491,54 +487,6 @@ function WorkflowEditorInner() { - {/* API Key warning */} - {hasApiKey === false && ( - - - - - -
-
- -
-

Workflow API Key Not Configured

-

- Workflows need a dedicated Immich API key to execute actions like creating albums, favoriting assets, and managing tags. -

-
-
-
-

Required Permissions:

-
    -
  • - asset.read — Query and filter assets
  • -
  • - asset.update — Favorite, archive, update metadata
  • -
  • - album.create — Create new albums
  • -
  • - album.update — Add/remove assets from albums
  • -
  • - tag.create — Create and assign tags
  • -
-
- - - - -
-
-
- )}