feat: import API key, people card redesign, birthday popover, autocomplete portal

- Add import_api_key setting with auto-generate endpoint and settings UI
- Import job runner uses import API key when available, falls back to session
- Show ApiKeyGate empty state in both workflow and import pages when key missing
- Replace workflow API key warning popover with full gate overlay
- Redesign person cards: square avatar, hover action toolbar, cleaner layout
- Birthday field now uses popover with calendar + natural language input
- Fix autocomplete dropdown clipping inside overflow-hidden cards via portal
- People grid column slider persisted to localStorage (min 3, max 20)
This commit is contained in:
Varun committed 2026-04-08 09:53:09 +05:30
1 parent 6a5b2b442a
commit e47e99ccfc
10 files changed
+626 -266

No files matched your search

+52 -10
View File
@@ -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<string | null>(null);
const [cols, setCols] = useState<number>(6);
const [filters, setFilters] = useState<IPersonListFilters>({
...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 <Loader />;
if (errorMessage) return <div>{errorMessage}</div>;
if (errorMessage) return <div className="p-4 text-sm text-destructive">{errorMessage}</div>;
if (!people.length) return (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-muted-foreground">
<Users className="h-10 w-10 opacity-30" />
<p className="text-sm">No people found</p>
</div>
);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-1 p-2">
<div
className="grid gap-2 p-3"
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
>
{people.map((person) => (
<PersonItem person={person} key={person.id} onRemove={handleRemove}/>
<PersonItem person={person} key={person.id} onRemove={handleRemove} />
))}
</div>
);
};
return (
<PeopleFilterContext.Provider
value={{
@@ -69,12 +95,28 @@ export default function PeopleList() {
setFilters((prev) => ({ ...prev, ...newConfig })),
}}
>
<PageLayout className="!p-0 !mb-0">
<PageLayout className="!p-0 !mb-0 relative">
<Header
leftComponent="Manage People"
rightComponent={<PeopleFilters />}
/>
{renderContent()}
{/* Grid size slider — fixed bottom-left */}
<div className="fixed bottom-4 left-[210px] lg:left-[250px] z-20 flex items-center gap-3 bg-background/90 backdrop-blur-sm border rounded-full px-4 py-2 shadow-md">
<Users className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
type="range"
min={3}
max={20}
step={1}
value={cols}
onChange={(e) => 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`}
/>
<span className="text-xs font-mono text-muted-foreground w-4 text-center">{cols}</span>
</div>
</PageLayout>
</PeopleFilterContext.Provider>
);
+110 -71
View File
@@ -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<string | null>(null);
const normalizedBirthDate = useMemo(() => normalizeDateOnly(person.birthDate), [person.birthDate]);
const [selectedDate, setSelectedDate] = useState<Date | null>(normalizedBirthDate);
const [textDate, setTextDate] = useState<string>(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 (
<div className='flex gap-1'>
<Input
className=''
value={textDate}
placeholder='Enter a date'
onKeyDown={(e) => {
if (e.key === 'Enter') {
const parsedDate = chrono.parseDate(textDate || "");
if (parsedDate) {
handleEdit(parsedDate);
} else {
handleEdit(null);
}
}
}}
disabled={loading}
onChange={(e) => setTextDate(e.target.value)}
/>
<DatePicker date={selectedDate} onSelect={handleEdit} iconOnly/>
</div>
)
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (v) setTextInput(''); }}>
<PopoverTrigger asChild>
<button
className="flex items-center gap-1.5 w-full text-left text-xs text-muted-foreground hover:text-foreground transition-colors group/bday"
title={selectedDate ? `Birthday: ${format(selectedDate, 'PPP')}` : "Add birthday"}
>
<CalendarDays size={11} className="shrink-0 opacity-60" />
{selectedDate ? (
<span className="flex-1 truncate">{format(selectedDate, 'PPP')}</span>
) : (
<span className="italic opacity-50">Add birthday</span>
)}
{selectedDate && (
<span
role="button"
title="Clear birthday"
onClick={handleClear}
className="ml-auto opacity-0 group-hover/bday:opacity-60 hover:!opacity-100 transition-opacity p-0.5 rounded"
>
<X size={10} />
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" side="bottom">
<div className="p-3 border-b">
<p className="text-xs font-medium text-muted-foreground mb-2">Type a date or pick from calendar</p>
<div className="flex gap-1.5">
<Input
className="h-7 text-xs"
placeholder='e.g. "Jan 1 1990"'
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleTextCommit();
if (e.key === 'Escape') setOpen(false);
}}
autoFocus
disabled={loading}
/>
<Button
size="sm"
className="h-7 text-xs px-2"
onClick={handleTextCommit}
disabled={loading}
>
Set
</Button>
</div>
</div>
<Calendar
mode="single"
selected={selectedDate ?? undefined}
onSelect={handleCalendarSelect}
defaultMonth={selectedDate ?? new Date(1990, 0, 1)}
disabled={loading}
/>
</PopoverContent>
</Popover>
);
}
+125 -110
View File
@@ -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 (
<div
className={clsx(
"flex flex-col rounded-lg pb-2 border border-2 border-transparent items-center gap-2",
"group relative flex flex-col rounded-xl overflow-hidden border bg-card transition-all duration-200",
"hover:shadow-lg hover:border-border/80",
{
"opacity-50": formData.isHidden,
"border border-blue-500": formData.name,
"opacity-40": formData.isHidden,
"border-blue-500/60 shadow-blue-500/10 shadow-md": formData.name,
"border-border/40": !formData.name,
}
)}
>
<div className="relative w-full h-auto group">
<Avatar
className="w-full min-h-full h-auto rounded-lg"
src={person.thumbnailPath}
alt={person.name}
/>
<div className="absolute bottom-2 w-full flex justify-center items-center">
<Badge variant={"secondary"} className="text-xs !font-medium font-mono">{person.assetCount} Assets</Badge>
{/* Image area */}
<div className="relative aspect-square w-full overflow-hidden bg-muted">
{person.thumbnailPath ? (
<img
src={person.thumbnailPath}
alt={formData.name || "Unknown"}
className="w-full h-full object-cover object-top transition-transform duration-300 group-hover:scale-[1.03]"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
<span className="text-4xl text-muted-foreground/30 font-light select-none">?</span>
</div>
)}
{/* Gradient overlay — always visible at bottom */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
{/* Asset count — bottom of image */}
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
<span className="text-[11px] font-medium text-white/90 tabular-nums tracking-wide">
{formData.isHidden && <span className="mr-1 opacity-70">Hidden ·</span>}
{person.assetCount} assets
</span>
</div>
<div className="absolute top-2 left-2 group-hover:flex hidden items-center gap-2">
<Link
className="bg-green-300 block rounded-lg px-2 py-1 text-sm dark:text-gray-900"
href={`${exImmichUrl}/people/${person.id}`}
target="_blank"
>
<ArrowUpRight size={16} />
</Link>
<Link
className="bg-gray-300 block rounded-lg px-2 py-1 text-sm dark:text-gray-900"
href={`/people/${person.id}`}
>
<Info size={16} />
</Link>
</div>
<div className="absolute top-2 right-2 flex flex-col gap-2">
<PersonMergeDropdown person={person} onRemove={onRemove} />
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={() => {
handleHide(!formData.isHidden);
}}>
{formData.isHidden ? "Show" : "Hide"}
</Button>
<ShareAssetsTrigger filters={{ personIds: [person.id] }} buttonProps={{ variant: "outline", className: "!py-0.5 !px-2 text-xs h-7" }} />
{/* Action row — slides in from top on hover */}
<div className="absolute top-0 inset-x-0 flex items-center justify-between p-1.5 translate-y-[-100%] group-hover:translate-y-0 transition-transform duration-200 bg-gradient-to-b from-black/60 to-transparent">
{/* Left: immich + info links */}
<div className="flex items-center gap-1">
<Link
href={`${exImmichUrl}/people/${person.id}`}
target="_blank"
title="Open in Immich"
className="p-1 rounded-md bg-white/10 hover:bg-white/25 text-white transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight size={13} />
</Link>
<Link
href={`/people/${person.id}`}
title="View details"
className="p-1 rounded-md bg-white/10 hover:bg-white/25 text-white transition-colors"
onClick={(e) => e.stopPropagation()}
>
<Info size={13} />
</Link>
</div>
{/* Right: hide + share + merge */}
<div className="flex items-center gap-1">
<button
title={formData.isHidden ? "Show person" : "Hide person"}
disabled={loading}
onClick={() => handleHide(!formData.isHidden)}
className="p-1 rounded-md bg-white/10 hover:bg-white/25 text-white transition-colors disabled:opacity-50"
>
{formData.isHidden ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
<ShareAssetsTrigger
filters={{ personIds: [person.id] }}
buttonProps={{
variant: "ghost",
className: "!p-1 !h-auto !w-auto rounded-md bg-white/10 hover:bg-white/25 text-white hover:text-white [&>svg]:h-[13px] [&>svg]:w-[13px]",
}}
/>
<PersonMergeDropdown
person={person}
onRemove={onRemove}
triggerClassName="!p-1 !h-auto !w-auto rounded-md !bg-white/10 hover:!bg-white/25 !text-white !border-transparent text-[10px] leading-none"
/>
</div>
</div>
</div>
{!editMode ? (
<h2
className="text-lg text-center font-semibold hover:bg-gray-300 dark:hover:bg-gray-800 w-full px-2 py-1 rounded-lg"
onClick={() => {
setEditMode((prev) => !prev);
}}
>
{formData.name ? (
formData.name
) : (
<span className="text-gray-400">Unknown</span>
)}
</h2>
) : (
<Autocomplete
loadOptions={(query: string) => 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();
}}
/>
)}
<div className="flex flex-col gap-2">
{/* Name + birthday section */}
<div className="px-2 pt-2 pb-2 flex flex-col gap-1.5">
{!editMode ? (
<button
className="text-left w-full text-sm font-semibold leading-snug hover:text-muted-foreground transition-colors truncate"
onClick={() => setEditMode(true)}
title={formData.name || "Click to set name"}
>
{formData.name || (
<span className="text-muted-foreground/50 font-normal italic text-xs">Unknown</span>
)}
</button>
) : (
<Autocomplete
loadOptions={(query: string) =>
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()}
/>
)}
<PersonBirthdayCell person={person} />
</div>
@@ -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);
}}
/>
</div>
+83
View File
@@ -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<string | null>(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 (
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-md w-full text-center space-y-6">
<div className="mx-auto h-14 w-14 rounded-2xl bg-muted flex items-center justify-center">
<KeyRound className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-2">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
</div>
<div className="rounded-lg border bg-muted/40 p-4 text-left space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Required permissions</p>
<div className="flex flex-wrap gap-1.5">
{permissions.map((p) => (
<Badge key={p.name} variant="outline" className="text-[11px] font-mono font-normal" title={p.description}>
{p.name}
</Badge>
))}
</div>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<div className="flex flex-col gap-2">
<Button onClick={handleGenerate} disabled={generating} className="w-full">
<Wand2 className="h-4 w-4 mr-2" />
{generating ? "Generating…" : "Generate API key automatically"}
</Button>
<Link href="/settings">
<Button variant="outline" className="w-full">
<Settings className="h-4 w-4 mr-2" />
Configure manually in Settings
</Button>
</Link>
</div>
</div>
</div>
);
}
+17 -3
View File
@@ -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<HTMLInputElement, AutocompleteProps>(
const [options, setOptions] = React.useState<AutocompleteOption[]>(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<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const debounceTimeout = React.useRef<NodeJS.Timeout>(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<HTMLInputElement, AutocompleteProps>(
}}
value={inputValue}
onFocus={() => {
updateDropdownRect()
setOpen(true)
setSelectedIndex(showCreateNew ? 0 : -1)
}}
@@ -194,8 +204,11 @@ const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(
props.onChange?.(e)
}}
/>
{open && (filteredOptions.length > 0 || isLoading || showCreateNew) && (
<div className={cn("absolute left-0 right-0 z-50 mt-1 max-h-[300px] overflow-auto rounded-md border bg-popover shadow-md", position === "top" ? "top-full" : "bottom-full")}>
{open && (filteredOptions.length > 0 || isLoading || showCreateNew) && dropdownRect && typeof document !== "undefined" && ReactDOM.createPortal(
<div
style={{ position: "absolute", top: dropdownRect.top, left: dropdownRect.left, width: dropdownRect.width }}
className="z-[9999] max-h-[300px] overflow-auto rounded-md border bg-popover shadow-md"
>
{isLoading ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
Loading...
@@ -241,7 +254,8 @@ const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(
))}
</>
)}
</div>
</div>,
document.body
)}
</div>
)
+18 -2
View File
@@ -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<string, string>;
// 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<string, string>;
if (importKeyRow?.value) {
headers = { "x-api-key": importKeyRow.value, "Content-Type": "application/json" };
} else {
headers = getUserHeaders(currentUser) as Record<string, string>;
}
runImportJob(job.id, headers).catch((err) =>
console.error("Unhandled error in runImportJob", err)
);
@@ -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 });
}
+32 -2
View File
@@ -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<string | null>(null);
const [hasImportApiKey, setHasImportApiKey] = useState<boolean | null>(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 && (
<ApiKeyGate
title="Import API Key Required"
description="Importing shared albums requires a dedicated Immich API key with upload permissions. Generate one automatically or configure it in Settings."
permissions={IMPORT_PERMISSIONS}
generateEndpoint="/api/settings/generate-import-api-key"
onGenerated={() => setHasImportApiKey(true)}
/>
)}
{/* ── Landing state ── */}
{!sharedData && (
{hasImportApiKey !== false && !sharedData && (
<div className="flex flex-1 justify-center px-4 py-8">
<div className="w-full max-w-2xl space-y-6">
{permissions?.canUpload === false && (
@@ -552,7 +582,7 @@ export default function ImportSharedPage() {
)}
{/* ── Album detail + gallery view ── */}
{sharedData && albumDetails && sharedLinkDetails && (
{hasImportApiKey !== false && sharedData && albumDetails && sharedLinkDetails && (
<>
<div className="flex flex-col gap-4 p-4 pb-36">
{/* Upload banner */}
+110 -1
View File
@@ -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() {
</div>
</section>
{/* Import API Key */}
<section>
<div className="flex items-center gap-2 mb-3">
<Download className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground">Import API Key</h2>
{importApiKeySaved ? (
<CheckCircle className="h-3.5 w-3.5 text-green-500 ml-auto" />
) : (
<XCircle className="h-3.5 w-3.5 text-yellow-500 ml-auto" />
)}
</div>
<div className="border rounded-lg p-4 space-y-3">
<p className="text-xs text-muted-foreground">
Immich API key used when importing from shared links. Needs asset upload, album, and tag permissions.
</p>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showImportApiKey ? "text" : "password"}
className="h-9 text-sm font-mono pr-10"
placeholder="Paste your Immich API key here"
value={importApiKey}
onChange={(e) => setImportApiKey(e.target.value)}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowImportApiKey((v) => !v)}
>
{showImportApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button size="sm" className="h-9" onClick={saveImportApiKey} disabled={importApiKeyLoading}>
<Save className="h-3.5 w-3.5 mr-1" />
{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>
<div className="flex items-center justify-between">
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={handleGenerateImportKey}
disabled={generatingImportKey}
>
<Wand2 className="h-3 w-3 mr-1" />
{generatingImportKey ? "Generating..." : "Generate automatically"}
</Button>
{exImmichUrl && (
<Link
href={`${exImmichUrl}/user-settings?isOpen=api-keys`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
Create in Immich
<ExternalLink className="h-3 w-3" />
</Link>
)}
</div>
</div>
</section>
{/* Managed API Keys */}
<section>
<div className="flex items-center gap-2 mb-3">
+28 -67
View File
@@ -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<string | null>(null);
const [hasApiKey, setHasApiKey] = useState<boolean | null>(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() {
</PopoverContent>
</Popover>
{/* API Key warning */}
{hasApiKey === false && (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 text-yellow-500 hover:text-yellow-600">
<TriangleAlert className="h-4 w-4 mr-1" />
<span className="text-xs">API Key Missing</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-3">
<div className="flex items-start gap-2">
<TriangleAlert className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Workflow API Key Not Configured</p>
<p className="text-xs text-muted-foreground mt-1">
Workflows need a dedicated Immich API key to execute actions like creating albums, favoriting assets, and managing tags.
</p>
</div>
</div>
<div className="border rounded p-2 bg-muted/50">
<p className="text-xs font-medium mb-1">Required Permissions:</p>
<ul className="text-[10px] text-muted-foreground space-y-0.5">
<li>- <strong>asset.read</strong> — Query and filter assets</li>
<li>- <strong>asset.update</strong> — Favorite, archive, update metadata</li>
<li>- <strong>album.create</strong> — Create new albums</li>
<li>- <strong>album.update</strong> — Add/remove assets from albums</li>
<li>- <strong>tag.create</strong> — Create and assign tags</li>
</ul>
</div>
<Button
size="sm"
className="w-full h-7 text-xs"
onClick={handleGenerateApiKey}
disabled={generatingKey}
>
<Wand2 className="h-3 w-3 mr-1" />
{generatingKey ? "Creating..." : "Generate automatically"}
</Button>
<Link href="/settings" className="block">
<Button size="sm" variant="outline" className="w-full h-7 text-xs">
Configure manually in Settings
</Button>
</Link>
</div>
</PopoverContent>
</Popover>
)}
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handleRun("debug")} disabled={running}>
@@ -561,8 +509,19 @@ function WorkflowEditorInner() {
{/* Left: Node palette */}
<NodePalette />
{/* API Key gate — shown instead of canvas when key is missing */}
{hasApiKey === false && (
<ApiKeyGate
title="Workflow API Key Required"
description="Workflows need a dedicated Immich API key to execute actions like creating albums, favoriting assets, and managing tags. Generate one automatically or configure it in Settings."
permissions={WORKFLOW_PERMISSIONS}
generateEndpoint="/api/settings/generate-workflow-api-key"
onGenerated={() => setHasApiKey(true)}
/>
)}
{/* Center: Canvas */}
<div className="flex-1" ref={reactFlowWrapper}>
<div className={`flex-1 ${hasApiKey === false ? "hidden" : ""}`} ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
@@ -585,9 +544,11 @@ function WorkflowEditorInner() {
</div>
{/* Right: Config panel */}
<div className="w-72 min-w-[288px] border-l bg-background overflow-y-auto h-full">
{renderConfigPanel()}
</div>
{hasApiKey !== false && (
<div className="w-72 min-w-[288px] border-l bg-background overflow-y-auto h-full">
{renderConfigPanel()}
</div>
)}
</div>
{/* Runs panel */}