Immich : demander confirmation avant de remplacer un portrait existant

L'association remplaçait et supprimait le portrait en place sans rien
demander. Le serveur refuse maintenant le remplacement d'un portrait
existant (409 avatar_exists), quelle que soit son origine, tant que la
requête ne porte pas replaceAvatar ; le panneau Immich transforme ce
refus en demande de confirmation avant de rejouer la requête. Sans
vignette Immich, l'association ne touche pas au portrait et aucune
confirmation n'est demandée.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mistral Vibe committed 2026-10-13 15:58:32 +02:00
1 parent a3386824d3
commit 0bda88920d
6 files changed
+121 -13

No files matched your search

+10
View File
@@ -30,6 +30,9 @@ const treeParamSchema = z.object({ id: z.string().min(1) })
const assignSchema = z.object({
personId: z.string().min(1),
immichPersonId: z.string().refine(isImmichPersonId, 'invalid_immich_person_id'),
// Remplacer un portrait déjà en place demande un accord explicite, quelle que soit son
// origine (posé à la main ou venu d'une association Immich précédente).
replaceAvatar: z.boolean().optional(),
})
const unassignSchema = z.object({
@@ -385,6 +388,13 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
return reply.send({ ok: true, avatarUpdated: false, immichPersonId })
}
// Un portrait déjà en place ne se remplace jamais en silence : sans accord explicite,
// l'association s'arrête avant d'écrire quoi que ce soit. Le front transforme ce refus
// en demande de confirmation puis rejoue la requête avec replaceAvatar.
if (person.avatarPath && !payload.data.replaceAvatar) {
return reply.code(409).send({ error: 'avatar_exists' })
}
// La vignette passe par le même traitement qu'un portrait envoyé : type lu dans les octets,
// métadonnées retirées, quota de l'arbre respecté.
let prepared: Awaited<ReturnType<typeof prepareUploadedFile>>
+56 -3
View File
@@ -357,7 +357,8 @@ describe('Immich integration: assign', () => {
const app = createApp()
const token = await createUserToken(app)
// Portrait manuel en place : il doit être supprimé une fois la base à jour.
// Portrait manuel en place : l'administrateur a confirmé son remplacement, il doit
// être supprimé une fois la base à jour.
const oldAvatarRelative = 'tree-1/person-1/avatar-old.jpg'
await mkdir(path.dirname(await absStoragePath(oldAvatarRelative)), { recursive: true })
await writeFile(await absStoragePath(oldAvatarRelative), 'old avatar')
@@ -369,7 +370,7 @@ describe('Immich integration: assign', () => {
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId },
payload: { personId: 'person-1', immichPersonId, replaceAvatar: true },
})
expect(response.statusCode).toBe(200)
@@ -399,10 +400,62 @@ describe('Immich integration: assign', () => {
await app.close()
})
it('asks before replacing an existing portrait, whatever its origin', async () => {
const app = createApp()
const token = await createUserToken(app)
mockImmichPerson()
// Portrait manuel en place : l'association ne le remplace pas sans accord explicite.
const manualAvatarRelative = 'tree-1/person-confirm/avatar-manuel.jpg'
await mkdir(path.dirname(await absStoragePath(manualAvatarRelative)), { recursive: true })
await writeFile(await absStoragePath(manualAvatarRelative), 'portrait manuel')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-confirm', avatarPath: manualAvatarRelative })
const manual = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-confirm', immichPersonId },
})
expect(manual.statusCode).toBe(409)
expect(manual.json()).toEqual({ error: 'avatar_exists' })
// Même garde pour un portrait venu d'une association Immich précédente.
prismaMock.person.findFirst.mockResolvedValue({
id: 'person-confirm',
avatarPath: `tree-1/person-confirm/immich-${randomUUID()}.jpg`,
immichPersonId: otherImmichId,
})
const fromImmich = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-confirm', immichPersonId },
})
expect(fromImmich.statusCode).toBe(409)
expect(fromImmich.json()).toEqual({ error: 'avatar_exists' })
// Base, journal d'audit et fichiers : rien n'a bougé.
expect(prismaMock.person.update).not.toHaveBeenCalled()
expect(prismaMock.auditLog.create).not.toHaveBeenCalled()
expect(prismaMock.$transaction).not.toHaveBeenCalled()
await expect(fsExists(await absStoragePath(manualAvatarRelative))).resolves.toBe(true)
const files = await fsListDir(path.join(storageRoot, 'tree-1', 'person-confirm'))
expect(files.filter((name) => name.startsWith('immich-'))).toEqual([])
await app.close()
})
it('assigns without touching the avatar when Immich has no thumbnail', async () => {
const app = createApp()
const token = await createUserToken(app)
// Portrait manuel conservé : sans vignette Immich, l'association ne le remplace pas,
// aucune confirmation n'est donc nécessaire.
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: 'tree-1/person-1/avatar-keep.jpg' })
mockImmichPerson({ thumbnailPath: '' })
@@ -601,7 +654,7 @@ describe('Immich integration: assign', () => {
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-tx', immichPersonId },
payload: { personId: 'person-tx', immichPersonId, replaceAvatar: true },
})
expect(response.statusCode).toBe(500)
+4 -2
View File
@@ -70,10 +70,12 @@ export async function fetchImmichThumbnail(treeId, immichPersonId, token) {
return URL.createObjectURL(await response.blob())
}
export async function assignImmichPerson(treeId, personId, immichPersonId, token) {
export async function assignImmichPerson(treeId, personId, immichPersonId, token, { replaceAvatar = false } = {}) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/assign`, withAuth({
method: 'POST',
body: JSON.stringify({ personId, immichPersonId }),
// replaceAvatar : accord explicite pour remplacer un portrait déjà en place — le serveur
// refuse sinon (409 avatar_exists), quelle que soit l'origine du portrait actuel.
body: JSON.stringify({ personId, immichPersonId, ...(replaceAvatar ? { replaceAvatar: true } : {}) }),
}, token))
}
@@ -77,5 +77,16 @@
.immich-empty { font: 500 13px var(--pz-sans); color: var(--pz-faint); text-align: center; padding: 12px 0; }
.immich-error { margin: 0; }
/* Demande de confirmation avant de remplacer un portrait déjà en place. */
.immich-confirm {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--pz-danger);
border-radius: var(--pz-r-m);
background: var(--pz-danger-bg);
color: var(--pz-danger);
font: 500 13px/1.5 var(--pz-sans);
}
.immich-spin { animation: immich-rotate 900ms linear infinite; }
@keyframes immich-rotate { to { transform: rotate(360deg); } }
+36 -8
View File
@@ -25,6 +25,7 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) =>
const [selected, setSelected] = useState(null)
const [thumbnailUrl, setThumbnailUrl] = useState(null)
const [actionLoading, setActionLoading] = useState(false)
const [confirmReplace, setConfirmReplace] = useState(false)
const [error, setError] = useState('')
const thumbnailRef = useRef(null)
@@ -82,6 +83,7 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) =>
const handleSelect = useCallback((immichPerson) => {
setSelected(immichPerson)
setError('')
setConfirmReplace(false)
if (immichPerson?.hasThumbnail && immichPerson?.id) {
loadThumbnail(immichPerson.id)
} else {
@@ -89,16 +91,22 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) =>
}
}, [loadThumbnail, releaseThumbnail])
const handleAssign = useCallback(async () => {
const handleAssign = useCallback(async (replaceAvatar = false) => {
if (!selected || actionLoading) return
setActionLoading(true)
setError('')
try {
await assignImmichPerson(treeId, person.id, selected.id, token)
await assignImmichPerson(treeId, person.id, selected.id, token, { replaceAvatar })
onImmichChange?.(person.id)
onClose?.()
} catch (err) {
setError(getImmichErrorMessage(err, t))
// Un portrait est déjà en place : le serveur refuse le remplacement en silence
// (409 avatar_exists) ; il revient à l'administrateur de le confirmer.
if (!replaceAvatar && err?.payload?.error === 'avatar_exists') {
setConfirmReplace(true)
} else {
setError(getImmichErrorMessage(err, t))
}
} finally {
setActionLoading(false)
}
@@ -210,12 +218,32 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) =>
{error && <div className="pz-error immich-error">{error}</div>}
{confirmReplace && (
<p className="immich-confirm">
{t('Un portrait est déjà en place. Associer cette personne Immich remplacera le portrait actuel et supprimera son fichier.')}
</p>
)}
<div className="pz-modal-actions">
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose}>{t('Annuler')}</button>
<button type="button" className="pz-btn pz-btn--primary" onClick={handleAssign} disabled={!selected || actionLoading}>
{actionLoading ? <Loader2 size={16} className="immich-spin" aria-hidden="true" /> : null}
{t('Associer et prendre le portrait')}
</button>
{confirmReplace ? (
<>
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => setConfirmReplace(false)}>
{t('Garder le portrait actuel')}
</button>
<button type="button" className="pz-btn pz-btn--danger" onClick={() => handleAssign(true)} disabled={actionLoading}>
{actionLoading ? <Loader2 size={16} className="immich-spin" aria-hidden="true" /> : null}
{t('Remplacer le portrait et associer')}
</button>
</>
) : (
<>
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose}>{t('Annuler')}</button>
<button type="button" className="pz-btn pz-btn--primary" onClick={() => handleAssign()} disabled={!selected || actionLoading}>
{actionLoading ? <Loader2 size={16} className="immich-spin" aria-hidden="true" /> : null}
{t('Associer et prendre le portrait')}
</button>
</>
)}
</div>
</div>
)}
+4
View File
@@ -285,4 +285,8 @@ export default {
'Sans nom': 'Unnamed',
'Aucune sélection': 'No selection',
'Associer et prendre le portrait': 'Link and use as portrait',
'Un portrait est déjà en place. Associer cette personne Immich remplacera le portrait actuel et supprimera son fichier.':
'A portrait is already in place. Linking this Immich person will replace the current portrait and delete its file.',
'Garder le portrait actuel': 'Keep the current portrait',
'Remplacer le portrait et associer': 'Replace the portrait and link',
}