fix(actas): move closure to reusable inspector signing
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 2m3s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m18s

This commit is contained in:
DH V2
2026-09-15 08:55:43 -03:00
parent a2ec846721
commit da7c1ddb55
26 changed files with 644 additions and 248 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-3';
export const APP_PHASE = 'F6.2 · Firma por Acta y correo de usuario';
export const APP_VERSION = '0.23.0-4';
export const APP_PHASE = 'F6.7 · Cierre y firma por Acta';
+32
View File
@@ -23,6 +23,15 @@ export interface UserSmtpSettings {
custom: UserSmtpCustomSettings | null;
}
export interface UserReusableSignature {
configured: boolean;
mimeType: 'image/png' | null;
sizeBytes: number | null;
imageSha256: string | null;
updatedAt: string | null;
}
export interface UserSmtpSettingsInput {
mode: UserSmtpMode;
host?: string;
@@ -65,3 +74,26 @@ export function testSelfSmtpSettings() {
method: 'POST',
});
}
export function getSelfSignature() {
return apiRequest<UserReusableSignature>('/users/self/signature');
}
export function saveSelfSignature(file: File) {
const body = new FormData();
body.append('file', file);
return apiRequest<UserReusableSignature>('/users/self/signature', {
method: 'PUT',
body,
});
}
export function deleteSelfSignature() {
return apiRequest<UserReusableSignature>('/users/self/signature', { method: 'DELETE' });
}
export function selfSignatureContentUrl(signature: UserReusableSignature) {
const version = signature.imageSha256 ? `?v=${encodeURIComponent(signature.imageSha256)}` : '';
return `/api/v3/users/self/signature/content${version}`;
}
+60 -2
View File
@@ -4,13 +4,18 @@ import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { useAuth } from '../auth/AuthContext';
import { initials } from '../lib/format';
import {
deleteSelfSignature,
getSelfProfile,
getSelfSignature,
getSelfSmtpSettings,
saveSelfSignature,
saveSelfSmtpSettings,
selfSignatureContentUrl,
testSelfSmtpSettings,
updateSelfProfile,
} from '../lib/myProfileApi';
import type {
UserReusableSignature,
UserSmtpMode,
UserSmtpSecurityMode,
UserSmtpSettings,
@@ -21,6 +26,8 @@ export function MyProfilePage() {
const { user } = useAuth();
const [profile, setProfile] = useState<AdministrativeUserProfile | null>(null);
const [smtp, setSmtp] = useState<UserSmtpSettings | null>(null);
const [signature, setSignature] = useState<UserReusableSignature | null>(null);
const [signatureFile, setSignatureFile] = useState<File | null>(null);
const [mode, setMode] = useState<UserSmtpMode>('SYSTEM');
const [host, setHost] = useState('');
const [port, setPort] = useState(587);
@@ -47,12 +54,14 @@ export function MyProfilePage() {
const load = async () => {
setLoading(true);
try {
const [loadedProfile, loadedSmtp] = await Promise.all([
const [loadedProfile, loadedSmtp, loadedSignature] = await Promise.all([
getSelfProfile(),
getSelfSmtpSettings(),
getSelfSignature(),
]);
setProfile(loadedProfile);
applySmtp(loadedSmtp);
setSignature(loadedSignature);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
@@ -82,6 +91,35 @@ export function MyProfilePage() {
}
};
const saveSignature = async () => {
if (!signatureFile) return;
setError(''); setSuccess(''); setSaving('signature');
try {
const updated = await saveSelfSignature(signatureFile);
setSignature(updated);
setSignatureFile(null);
setSuccess('Firma del inspector guardada. Se aplicará automáticamente al cerrar cada Acta.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving('');
}
};
const clearSignature = async () => {
setError(''); setSuccess(''); setSaving('signature-delete');
try {
const updated = await deleteSelfSignature();
setSignature(updated);
setSignatureFile(null);
setSuccess('Firma guardada eliminada.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving('');
}
};
const saveSmtp = async () => {
setError(''); setSuccess(''); setSaving('smtp');
try {
@@ -116,7 +154,9 @@ export function MyProfilePage() {
};
if (loading) return <LoadingBlock label="Cargando tu perfil…" />;
if (!profile || !smtp) return <Alert>{error || 'No se pudo cargar el perfil.'}</Alert>;
if (!profile || !smtp || !signature) return <Alert>{error || 'No se pudo cargar el perfil.'}</Alert>;
const isInspector = profile.roles.some((role) => role.code === 'inspector');
return <section>
<div className="page-heading user-heading">
@@ -184,6 +224,24 @@ export function MyProfilePage() {
</div>
</div>
</div>
{isInspector && <div className="panel form-panel" style={{ marginTop: 16 }}>
<div className="panel-heading">
<div><span className="eyebrow">FIRMA DEL INSPECTOR</span><h2>Firma guardada para Actas</h2></div>
</div>
<p className="muted-copy">Cargala una sola vez. Cuando cierres un Acta desde la APK, el contenido quedará en estado <strong>Para firmar</strong>; después firma el acompañante y, al finalizar, el sistema incorpora automáticamente esta firma del inspector al ejemplar inmutable.</p>
{signature.configured && <div className="signature-profile-preview">
<img src={selfSignatureContentUrl(signature)} alt="Firma guardada del inspector" />
<div><strong>Firma configurada</strong><small>PNG · {Math.max(1, Math.round((signature.sizeBytes ?? 0) / 1024))} KB</small></div>
</div>}
<div className="form-grid" style={{ marginTop: 14 }}>
<label className="field"><span>{signature.configured ? 'Reemplazar firma PNG' : 'Firma PNG *'}</span><input type="file" accept="image/png" onChange={(event) => setSignatureFile(event.target.files?.[0] ?? null)} /></label>
</div>
<p className="muted-copy">La imagen debe ser PNG y pesar como máximo 1 MB. Cada Acta conserva una copia propia de la firma y su SHA-256; cambiar la firma del perfil no altera Actas ya cerradas.</p>
<div className="form-actions wrap-actions">
<button className="button primary" type="button" onClick={saveSignature} disabled={!signatureFile || saving === 'signature'}>{saving === 'signature' ? 'Guardando…' : signature.configured ? 'Reemplazar firma' : 'Guardar firma'}</button>
{signature.configured && <button className="button secondary" type="button" onClick={clearSignature} disabled={saving === 'signature-delete'}>{saving === 'signature-delete' ? 'Eliminando…' : 'Eliminar firma guardada'}</button>}
</div>
</div>}
<div className="panel" style={{ marginTop: 16 }}>
<div className="panel-heading"><div><span className="eyebrow">SEGURIDAD</span><h2>Cuenta</h2></div></div>
<p>Tu email es obligatorio para operar en DH Inspección. La contraseña SMTP nunca se muestra ni se incluye en auditorías.</p>
+27
View File
@@ -1350,3 +1350,30 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
cursor: default;
opacity: .65;
}
/* F6.7 · firma reutilizable del inspector */
.signature-profile-preview {
display: flex;
align-items: center;
gap: 16px;
max-width: 620px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 14px;
background: #f7faf9;
}
.signature-profile-preview img {
width: min(240px, 46%);
height: 88px;
object-fit: contain;
border-radius: 10px;
background: #fff;
border: 1px solid #e3e7e5;
}
.signature-profile-preview strong,
.signature-profile-preview small { display: block; }
.signature-profile-preview small { margin-top: 4px; color: var(--muted); }
@media (max-width: 640px) {
.signature-profile-preview { align-items: flex-start; flex-direction: column; }
.signature-profile-preview img { width: 100%; }
}