From 890aca44f422bd3e9351e5785e60d0eb75f9add1 Mon Sep 17 00:00:00 2001 From: Nati Date: Sat, 29 Aug 2026 06:52:14 +0000 Subject: [PATCH] feat(signature): add signature functionality for officers and seafarers - Implemented MySignaturePad component for officers to draw and upload their signatures. - Added signature API endpoints for managing officer signatures in the backoffice. - Created SignaturePad component for seafarers to manage their specimen signatures. - Updated ProfilePage to include signature tabs for both officers and seafarers. - Added translations for signature-related terms in English and Amharic. - Introduced RankTier options for seafarer registration. - Added tests for canvas-point utility to ensure accurate pointer mapping on signature pad. --- .../app/features/profile/api/signature-api.ts | 42 +++ .../profile/components/MySignaturePad.tsx | 26 ++ .../features/profile/pages/ProfilePage.tsx | 27 +- apps/backoffice/src/app/i18n/locales/am.ts | 22 ++ apps/backoffice/src/app/i18n/locales/en.ts | 23 ++ .../app/features/profile/api/signature-api.ts | 46 +++ .../profile/components/SignaturePad.tsx | 28 ++ .../features/profile/pages/ProfilePage.tsx | 13 + .../components/steps.tsx | 13 + .../pages/SeafarerRegistrationPage.tsx | 2 +- apps/portal/src/app/i18n/locales/am.ts | 22 ++ apps/portal/src/app/i18n/locales/en.ts | 23 ++ .../seafarer-registration.constants.ts | 17 +- .../seafarer-registration.types.ts | 8 + libs/ui/src/index.ts | 2 + libs/ui/src/lib/components/SignaturePad.tsx | 316 ++++++++++++++++++ libs/ui/src/lib/input/canvas-point.spec.ts | 30 ++ libs/ui/src/lib/input/canvas-point.ts | 20 ++ 18 files changed, 677 insertions(+), 3 deletions(-) create mode 100644 apps/backoffice/src/app/features/profile/api/signature-api.ts create mode 100644 apps/backoffice/src/app/features/profile/components/MySignaturePad.tsx create mode 100644 apps/portal/src/app/features/profile/api/signature-api.ts create mode 100644 apps/portal/src/app/features/profile/components/SignaturePad.tsx create mode 100644 libs/ui/src/lib/components/SignaturePad.tsx create mode 100644 libs/ui/src/lib/input/canvas-point.spec.ts create mode 100644 libs/ui/src/lib/input/canvas-point.ts diff --git a/apps/backoffice/src/app/features/profile/api/signature-api.ts b/apps/backoffice/src/app/features/profile/api/signature-api.ts new file mode 100644 index 000000000..35ee040cc --- /dev/null +++ b/apps/backoffice/src/app/features/profile/api/signature-api.ts @@ -0,0 +1,42 @@ +import { baseApi } from '@ema-platform/api'; + +/** + * The officer's own signing signature — drawn onto certificates they approve + * (`{{signatureImage}}`), as distinct from the seafarer's specimen signature + * that the portal manages. + * + * Scoped to the caller: the API resolves the employee record from the token, + * so no employee id is passed and nobody can upload on another's behalf. + */ +const signatureApi = baseApi + .enhanceEndpoints({ addTagTypes: ['MyEmployeeSignature'] as const }) + .injectEndpoints({ + endpoints: (builder) => ({ + getMyEmployeeSignature: builder.query<{ url: string | null }, void>({ + query: () => ({ url: '/employee-signatures/me' }), + providesTags: ['MyEmployeeSignature'], + }), + + uploadMyEmployeeSignature: builder.mutation<{ id: string }, File>({ + query: (file) => { + const body = new FormData(); + body.append('file', file); + // No Content-Type header: fetch sets it with the multipart boundary. + return { url: '/employee-signatures/me', method: 'POST', body }; + }, + invalidatesTags: (_r, error) => (error ? [] : ['MyEmployeeSignature']), + }), + + deleteMyEmployeeSignature: builder.mutation<{ removed: boolean }, void>({ + query: () => ({ url: '/employee-signatures/me', method: 'DELETE' }), + invalidatesTags: (_r, error) => (error ? [] : ['MyEmployeeSignature']), + }), + }), + overrideExisting: false, + }); + +export const { + useGetMyEmployeeSignatureQuery, + useUploadMyEmployeeSignatureMutation, + useDeleteMyEmployeeSignatureMutation, +} = signatureApi; diff --git a/apps/backoffice/src/app/features/profile/components/MySignaturePad.tsx b/apps/backoffice/src/app/features/profile/components/MySignaturePad.tsx new file mode 100644 index 000000000..4473d1739 --- /dev/null +++ b/apps/backoffice/src/app/features/profile/components/MySignaturePad.tsx @@ -0,0 +1,26 @@ +import { SignaturePad } from '@ema-platform/ui'; +import { + useDeleteMyEmployeeSignatureMutation, + useGetMyEmployeeSignatureQuery, + useUploadMyEmployeeSignatureMutation, +} from '../api/signature-api'; + +/** The signature drawn onto certificates this officer approves. */ +export function MySignaturePad() { + const { data, isLoading } = useGetMyEmployeeSignatureQuery(); + const [upload, { isLoading: isUploading }] = + useUploadMyEmployeeSignatureMutation(); + const [remove, { isLoading: isDeleting }] = + useDeleteMyEmployeeSignatureMutation(); + + return ( + upload(file).unwrap()} + onDelete={() => remove().unwrap()} + /> + ); +} diff --git a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx index 1ff6dbef9..d129802ba 100644 --- a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx @@ -32,6 +32,7 @@ import { IconMail, IconMoon, IconSettings, + IconSignature, IconShieldLock, IconSun, IconUser, @@ -43,12 +44,18 @@ import { z } from 'zod'; import { useTranslation } from 'react-i18next'; import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui'; import { useApiMutation } from '@ema-platform/api'; -import { ActiveSessions, setUser } from '@ema-platform/auth'; +import { + ActiveSessions, + LICENSE_PERMISSIONS, + setUser, + usePermissions, +} from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth'; import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { setLayoutMode } from '../../../store/preferences.slice'; import type { LayoutMode } from '../../../store/preferences.slice'; +import { MySignaturePad } from '../components/MySignaturePad'; import classes from './ProfilePage.module.css'; function getInitials(name: string, fallback: string) { @@ -77,6 +84,10 @@ export function ProfilePage() { const { colorScheme, setColorScheme } = useMantineColorScheme(); const layoutMode = useAppSelector((state) => state.preferences.layoutMode); const { handleError } = useErrorHandler(); + // Only officers who approve applications ever sign a certificate, so nobody + // else is asked for a signature they would never use. + const { can } = usePermissions(); + const canSign = can([LICENSE_PERMISSIONS.APPROVE_APPLICATION]); const [updateTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); @@ -318,6 +329,11 @@ export function ProfilePage() { }> {t('profile.tabs.profile')} + {canSign && ( + }> + {t('profile.tabs.signature')} + + )} }> {t('profile.tabs.security')} @@ -405,6 +421,15 @@ export function ProfilePage() { + {/* ---- Signature (drawn onto certificates this officer approves) ---- */} + {canSign && ( + + + + + + )} + {/* ---- Security ---- */} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 7c6a309d6..8eea67b42 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -465,9 +465,31 @@ export const am: Translations = { unverified: "ያልተረጋገጠ", tabs: { profile: "መገለጫ", + signature: "ፊርማ", security: "ደህንነት", preferences: "ምርጫዎች", }, + signature: { + title: "የመፈረሚያ ፊርማ", + description: "እርስዎ በሚያጸድቋቸው ሰነዶች ላይ ይታተማል። አንድ ጊዜ ይሳሉ ወይም ምስል ይጫኑ።", + reissueNotice: + "ፊርማዎን መቀየር አስቀድሞ የተሰጠን ሰነድ አይለውጥም — ከአሁን በኋላ በሚፈርሙዋቸው ላይ ብቻ ይሠራል።", + current: "የተመዘገበ ፊርማ", + currentAlt: "የተቀመጠ ፊርማዎ", + none: "እስካሁን የተመዘገበ ፊርማ የለም። የሚያጸድቋቸው ሰነዶች ያለ ፊርማ ይሰጣሉ።", + modeDraw: "ይሳሉ", + modeUpload: "ይጫኑ", + save: "ፊርማ አስቀምጥ", + clear: "አጽዳ", + choose: "ምስል ይምረጡ", + fileHint: "PNG ወይም JPEG፣ እስከ 2 ሜባ።", + remove: "አስወግድ", + saved: "ፊርማ ተቀምጧል።", + removed: "ፊርማ ተወግዷል።", + badType: "PNG እና JPEG ምስሎች ብቻ ይፈቀዳሉ።", + tooLarge: "ምስሉ ከ2 ሜባ ይበልጣል።", + drawFailed: "ሥዕሉን ማንበብ አልተቻለም። እባክዎ እንደገና ይሞክሩ።", + }, personalHint: "በኦፊሴላዊ ኢማ ሰነዶች ላይ እንደሚታየው ስምዎ።", languageTitle: "ቋንቋ", languageHint: "በአስተዳደር ፓነል ውስጥ የሚጠቀሙትን ቋንቋ ይምረጡ።", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index add94cb20..13eaddd45 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -464,9 +464,32 @@ export const en = { unverified: 'Unverified', tabs: { profile: 'Profile', + signature: 'Signature', security: 'Security', preferences: 'Preferences', }, + signature: { + title: 'Signing signature', + description: + 'Drawn onto the certificates you approve. Draw it once or upload an image.', + reissueNotice: + 'Changing your signature does not alter a certificate already issued — it applies to whatever you sign from now on.', + current: 'Signature on file', + currentAlt: 'Your stored signature', + none: 'No signature on file yet. Certificates you approve will be issued without one.', + modeDraw: 'Draw', + modeUpload: 'Upload', + save: 'Save signature', + clear: 'Clear', + choose: 'Choose image', + fileHint: 'PNG or JPEG, up to 2 MB.', + remove: 'Remove', + saved: 'Signature saved.', + removed: 'Signature removed.', + badType: 'Only PNG and JPEG images are accepted.', + tooLarge: 'That image is larger than 2 MB.', + drawFailed: 'Could not read the drawing. Please try again.', + }, personalHint: 'Your name as it appears on official EMA documents.', languageTitle: 'Language', languageHint: 'Choose the language used across the admin panel.', diff --git a/apps/portal/src/app/features/profile/api/signature-api.ts b/apps/portal/src/app/features/profile/api/signature-api.ts new file mode 100644 index 000000000..bcdb72d5b --- /dev/null +++ b/apps/portal/src/app/features/profile/api/signature-api.ts @@ -0,0 +1,46 @@ +import { baseApi } from '@ema-platform/api'; + +/** + * The caller's specimen signature. + * + * Upload is multipart rather than the presign+PUT flow used for documents: + * the API validates type and size on the way through, which it cannot do when + * bytes go straight to storage. `signatureUrl` on the profile is an + * object-storage key, so the stored signature is displayed through a + * short-lived link from `GET me/signature` rather than read off the profile. + */ +const signatureApi = baseApi + .enhanceEndpoints({ addTagTypes: ['CurrentProfile', 'MySignature'] as const }) + .injectEndpoints({ + endpoints: (builder) => ({ + getMySignature: builder.query<{ url: string | null }, void>({ + query: () => ({ url: '/profiles/me/signature' }), + providesTags: ['MySignature'], + }), + + uploadMySignature: builder.mutation<{ signatureUrl: string }, File>({ + query: (file) => { + const body = new FormData(); + body.append('file', file); + // No Content-Type header: fetch sets it with the multipart boundary, + // and naming it here would omit the boundary and fail to parse. + return { url: '/profiles/me/signature', method: 'POST', body }; + }, + invalidatesTags: (_r, error) => + error ? [] : ['MySignature', 'CurrentProfile'], + }), + + deleteMySignature: builder.mutation<{ signatureUrl: null }, void>({ + query: () => ({ url: '/profiles/me/signature', method: 'DELETE' }), + invalidatesTags: (_r, error) => + error ? [] : ['MySignature', 'CurrentProfile'], + }), + }), + overrideExisting: false, + }); + +export const { + useGetMySignatureQuery, + useUploadMySignatureMutation, + useDeleteMySignatureMutation, +} = signatureApi; diff --git a/apps/portal/src/app/features/profile/components/SignaturePad.tsx b/apps/portal/src/app/features/profile/components/SignaturePad.tsx new file mode 100644 index 000000000..c558ad51a --- /dev/null +++ b/apps/portal/src/app/features/profile/components/SignaturePad.tsx @@ -0,0 +1,28 @@ +import { SignaturePad } from '@ema-platform/ui'; +import { + useDeleteMySignatureMutation, + useGetMySignatureQuery, + useUploadMySignatureMutation, +} from '../api/signature-api'; + +/** + * The seafarer's own specimen signature, printed on documents issued to them + * (`{{seafarerSignature}}`). Distinct from an officer's signing signature, + * which the backoffice manages against a different endpoint. + */ +export function MySignaturePad() { + const { data, isLoading } = useGetMySignatureQuery(); + const [upload, { isLoading: isUploading }] = useUploadMySignatureMutation(); + const [remove, { isLoading: isDeleting }] = useDeleteMySignatureMutation(); + + return ( + upload(file).unwrap()} + onDelete={() => remove().unwrap()} + /> + ); +} diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index b926ca3d4..3041cbfa6 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -38,6 +38,7 @@ import { IconMapPin, IconMoon, IconSettings, + IconSignature, IconShieldLock, IconSun, IconUser, @@ -67,6 +68,7 @@ import { import { useSaveMyAddressMutation } from '../api/address-api'; import { toAddressPayload } from '../types/address'; import { OperationsFormContent } from '../components/OperationsFormContent'; +import { MySignaturePad } from '../components/SignaturePad'; import { SEAFARER_PROFILE_REQUIREMENT } from '../components/RequireSeafarerProfile'; import classes from './ProfilePage.module.css'; @@ -76,6 +78,7 @@ const VALID_TABS = [ 'profile', 'address', 'operations', + 'signature', 'security', 'preferences', ]; @@ -633,6 +636,9 @@ export function ProfilePage() { > {t('profile.tabs.operations')} + }> + {t('profile.tabs.signature')} + }> {t('profile.tabs.security')} @@ -808,6 +814,13 @@ export function ProfilePage() { + {/* ---- Signature (printed on issued documents) ---- */} + + + + + + {/* ---- Security ---- */} diff --git a/apps/portal/src/app/features/seafarer-registration/components/steps.tsx b/apps/portal/src/app/features/seafarer-registration/components/steps.tsx index 1ca06da9a..934244cd0 100644 --- a/apps/portal/src/app/features/seafarer-registration/components/steps.tsx +++ b/apps/portal/src/app/features/seafarer-registration/components/steps.tsx @@ -6,6 +6,7 @@ import { GENDER_OPTIONS, HAIR_COLOR_OPTIONS, MARITAL_STATUS_OPTIONS, + RANK_TIER_OPTIONS, isEthiopianNationality, useGetActiveDepartmentsQuery, useLocalized, @@ -130,6 +131,18 @@ export function ApplicantDetailsStep(p: StepProps) { options={departmentOptions} description="The STCW department you serve in. Determines which certificates, examinations and services apply to you." /> + diff --git a/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx index abfd257cc..864e2e159 100644 --- a/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx +++ b/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx @@ -59,7 +59,7 @@ const STEPS = [ */ const REQUIRED_BY_STEP: AnswerKey[][] = [ ['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'], - ['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'], + ['placeOfBirth', 'department', 'tier', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'], ['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'], [], ['declarationAccepted'], diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 4d0e24091..c2ca040b2 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -334,9 +334,31 @@ export const am: Translations = { profile: 'መገለጫ', address: 'አድራሻ', operations: 'የስራ ዘርፍ', + signature: 'ፊርማ', security: 'ደህንነት', preferences: 'ምርጫዎች', }, + signature: { + title: 'የፊርማ ናሙና', + description: 'አንድ ጊዜ ይሳሉ ወይም ይጫኑ፤ በሚሰጡዎት ሰነዶች ላይ ይታተማል።', + reissueNotice: + 'ፊርማዎን መቀየር አስቀድሞ የተሰጠን ሰነድ አይለውጥም — ከአሁን በኋላ በሚሰጡ ሰነዶች ላይ ብቻ ይሠራል።', + current: 'የተመዘገበ ፊርማ', + currentAlt: 'የተቀመጠ ፊርማዎ', + none: 'እስካሁን የተመዘገበ ፊርማ የለም።', + modeDraw: 'ይሳሉ', + modeUpload: 'ይጫኑ', + save: 'ፊርማ አስቀምጥ', + clear: 'አጽዳ', + choose: 'ምስል ይምረጡ', + fileHint: 'PNG ወይም JPEG፣ እስከ 2 ሜባ።', + remove: 'አስወግድ', + saved: 'ፊርማ ተቀምጧል።', + removed: 'ፊርማ ተወግዷል።', + badType: 'PNG እና JPEG ምስሎች ብቻ ይፈቀዳሉ።', + tooLarge: 'ምስሉ ከ2 ሜባ ይበልጣል።', + drawFailed: 'ሥዕሉን ማንበብ አልተቻለም። እባክዎ እንደገና ይሞክሩ።', + }, maritimeSection: { title: 'የባህር ሙያ መገለጫ', subtitle: 'የባህር ሙያ ዝርዝሮችዎ', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 4e5b0af40..8a1dd9d80 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -334,9 +334,32 @@ export const en = { profile: 'Profile', address: 'Address', operations: 'Operations', + signature: 'Signature', security: 'Security', preferences: 'Preferences', }, + signature: { + title: 'Specimen signature', + description: + 'Drawn or uploaded once and printed on the documents issued to you.', + reissueNotice: + 'Changing your signature does not alter a document already issued — it applies to whatever is issued from now on.', + current: 'Signature on file', + currentAlt: 'Your stored signature', + none: 'No signature on file yet.', + modeDraw: 'Draw', + modeUpload: 'Upload', + save: 'Save signature', + clear: 'Clear', + choose: 'Choose image', + fileHint: 'PNG or JPEG, up to 2 MB.', + remove: 'Remove', + saved: 'Signature saved.', + removed: 'Signature removed.', + badType: 'Only PNG and JPEG images are accepted.', + tooLarge: 'That image is larger than 2 MB.', + drawFailed: 'Could not read the drawing. Please try again.', + }, maritimeSection: { title: 'Maritime Profile', subtitle: 'Your professional maritime details', diff --git a/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts b/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts index 46e1e618a..f2fbf7c1d 100644 --- a/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts +++ b/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts @@ -25,6 +25,19 @@ export const DEPARTMENT_OPTIONS = [ { value: 'CATERING', label: 'Catering' }, ]; +/** + * The STCW limitation a Certificate of Competency is issued under. + * + * One choice, worded generically, because the threshold it means differs by + * department — gross tonnage on deck, propulsion power in the engine room. The + * certificate states the department-specific wording; the applicant only picks + * which side of the line they serve. + */ +export const RANK_TIER_OPTIONS = [ + { value: 'ABOVE', label: 'Above' }, + { value: 'BELOW', label: 'Below' }, +]; + export const HAIR_COLOR_OPTIONS = [ { value: 'BLACK', label: 'Black' }, { value: 'BROWN', label: 'Brown' }, @@ -158,6 +171,7 @@ export const SEAFARER_REGISTRATION_FIELD_LABELS: Record Promise; + onDelete: () => Promise; +} + +export function SignaturePad({ + currentUrl, + isLoading, + isUploading, + isDeleting, + onUpload, + onDelete, +}: SignaturePadProps) { + const { t } = useTranslation(); + const { handleError } = useErrorHandler(); + const canvasRef = useRef(null); + const drawing = useRef(false); + // Whether anything has actually been drawn — a blank canvas still encodes to + // a valid PNG, so without this "Save" would happily store an empty image. + const [hasInk, setHasInk] = useState(false); + const [mode, setMode] = useState<'draw' | 'upload'>('draw'); + + const busy = isUploading || isDeleting; + + const context = useCallback(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + if (!ctx) return null; + ctx.lineWidth = 2.5; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = '#111'; + return ctx; + }, []); + + // The stored signature is flattened onto white before upload, so a canvas + // left transparent would print as a black box on some renderers. + const clear = useCallback(() => { + const ctx = context(); + if (!ctx) return; + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, PAD_WIDTH, PAD_HEIGHT); + setHasInk(false); + }, [context]); + + useEffect(() => { + if (mode === 'draw') clear(); + }, [mode, clear]); + + const pointAt = (event: React.PointerEvent) => + toCanvasPoint( + event.clientX, + event.clientY, + event.currentTarget.getBoundingClientRect(), + { width: PAD_WIDTH, height: PAD_HEIGHT }, + ); + + const onPointerDown = (event: React.PointerEvent) => { + const ctx = context(); + if (!ctx) return; + // Keeps strokes tracking the pointer when it leaves the canvas mid-signature + // rather than ending the line at the edge. + event.currentTarget.setPointerCapture(event.pointerId); + drawing.current = true; + const { x, y } = pointAt(event); + ctx.beginPath(); + ctx.moveTo(x, y); + // A tap with no movement should still leave a mark (a dot on an "i"). + ctx.lineTo(x, y); + ctx.stroke(); + setHasInk(true); + }; + + const onPointerMove = (event: React.PointerEvent) => { + if (!drawing.current) return; + const ctx = context(); + if (!ctx) return; + const { x, y } = pointAt(event); + ctx.lineTo(x, y); + ctx.stroke(); + }; + + const onPointerUp = () => { + drawing.current = false; + }; + + const save = async (file: File) => { + try { + await onUpload(file); + notify.success(t('profile.signature.saved')); + if (mode === 'draw') clear(); + } catch (error) { + handleError(error); + } + }; + + const saveDrawing = () => { + const canvas = canvasRef.current; + if (!canvas || !hasInk) return; + canvas.toBlob((blob) => { + if (!blob) { + notify.error(t('profile.signature.drawFailed')); + return; + } + void save(new File([blob], 'signature.png', { type: 'image/png' })); + }, 'image/png'); + }; + + // Validated here as well as server-side so the reason is immediate and the + // user is not made to wait on an upload that is going to be rejected. + const onFile = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + // Lets the same file be picked again after a rejection. + event.target.value = ''; + if (!file) return; + if (!ACCEPTED.includes(file.type)) { + notify.error(t('profile.signature.badType')); + return; + } + if (file.size > MAX_BYTES) { + notify.error(t('profile.signature.tooLarge')); + return; + } + void save(file); + }; + + const handleDelete = async () => { + try { + await onDelete(); + notify.success(t('profile.signature.removed')); + } catch (error) { + handleError(error); + } + }; + + return ( + +
+ {t('profile.signature.title')} + + {t('profile.signature.description')} + +
+ + } color="blue" variant="light"> + {t('profile.signature.reissueNotice')} + + + {isLoading ? ( + + + + ) : currentUrl ? ( + + + + {t('profile.signature.current')} + + {t('profile.signature.currentAlt')} + + + + + + ) : ( + + {t('profile.signature.none')} + + )} + + setMode(value as 'draw' | 'upload')} + data={[ + { value: 'draw', label: t('profile.signature.modeDraw') }, + { value: 'upload', label: t('profile.signature.modeUpload') }, + ]} + /> + + {mode === 'draw' ? ( + + + + + + + + ) : ( + + + + {t('profile.signature.fileHint')} + + + )} +
+ ); +} diff --git a/libs/ui/src/lib/input/canvas-point.spec.ts b/libs/ui/src/lib/input/canvas-point.spec.ts new file mode 100644 index 000000000..2d2041b4d --- /dev/null +++ b/libs/ui/src/lib/input/canvas-point.spec.ts @@ -0,0 +1,30 @@ +import { toCanvasPoint } from './canvas-point'; + +/** + * Guards the scaling between a canvas's on-screen size and its backing store. + * Getting this wrong offsets strokes from the cursor — worse the further from + * the origin — which stays invisible until someone actually tries to sign. + */ +describe('toCanvasPoint', () => { + const size = { width: 800, height: 260 }; + // Half scale: 400px wide on screen, 800 in the backing store. + const rect = { left: 100, top: 50, width: 400, height: 130 }; + + it('maps the top-left corner to the origin', () => { + expect(toCanvasPoint(100, 50, rect, size)).toEqual({ x: 0, y: 0 }); + }); + + it('maps the bottom-right corner to the full backing-store size', () => { + expect(toCanvasPoint(500, 180, rect, size)).toEqual({ x: 800, y: 260 }); + }); + + it('scales a midpoint rather than using raw client pixels', () => { + // Raw offset would be (200, 65) — half of the correct answer. + expect(toCanvasPoint(300, 115, rect, size)).toEqual({ x: 400, y: 130 }); + }); + + it('is unscaled when the element is already the backing-store size', () => { + const exact = { left: 0, top: 0, width: 800, height: 260 }; + expect(toCanvasPoint(123, 45, exact, size)).toEqual({ x: 123, y: 45 }); + }); +}); diff --git a/libs/ui/src/lib/input/canvas-point.ts b/libs/ui/src/lib/input/canvas-point.ts new file mode 100644 index 000000000..0be06fa97 --- /dev/null +++ b/libs/ui/src/lib/input/canvas-point.ts @@ -0,0 +1,20 @@ +/** + * Pointer position in canvas coordinates. + * + * A canvas is displayed at whatever width the layout gives it, but drawn into a + * fixed backing store, so a click at the right-hand edge of a 400px-wide + * element has to land at x=width, not x=400. Skipping this scaling is the + * classic canvas bug: strokes appear offset from the cursor, worsening the + * further from the origin you draw. + */ +export function toCanvasPoint( + clientX: number, + clientY: number, + rect: { left: number; top: number; width: number; height: number }, + size: { width: number; height: number }, +) { + return { + x: ((clientX - rect.left) / rect.width) * size.width, + y: ((clientY - rect.top) / rect.height) * size.height, + }; +}