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
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:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-3",
|
||||
"version": "0.23.0-4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-3",
|
||||
"version": "0.23.0-4",
|
||||
"dependencies": {
|
||||
"maplibre-gl": "6.4.1",
|
||||
"react": "^19.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-3",
|
||||
"version": "0.23.0-4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user