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.
This commit is contained in:
Nati
2026-08-29 06:52:14 +00:00
parent 9841092ddc
commit 890aca44f4
18 changed files with 677 additions and 3 deletions

View File

@@ -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;

View File

@@ -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 (
<SignaturePad
currentUrl={data?.url ?? null}
isLoading={isLoading}
isUploading={isUploading}
isDeleting={isDeleting}
onUpload={(file) => upload(file).unwrap()}
onDelete={() => remove().unwrap()}
/>
);
}

View File

@@ -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<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
@@ -318,6 +329,11 @@ export function ProfilePage() {
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
{t('profile.tabs.profile')}
</Tabs.Tab>
{canSign && (
<Tabs.Tab value="signature" leftSection={<IconSignature size={18} />}>
{t('profile.tabs.signature')}
</Tabs.Tab>
)}
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
</Tabs.Tab>
@@ -405,6 +421,15 @@ export function ProfilePage() {
</Paper>
</Tabs.Panel>
{/* ---- Signature (drawn onto certificates this officer approves) ---- */}
{canSign && (
<Tabs.Panel value="signature" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<MySignaturePad />
</Paper>
</Tabs.Panel>
)}
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Stack gap="lg">

View File

@@ -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: "በአስተዳደር ፓነል ውስጥ የሚጠቀሙትን ቋንቋ ይምረጡ።",

View File

@@ -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.',

View File

@@ -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;

View File

@@ -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 (
<SignaturePad
currentUrl={data?.url ?? null}
isLoading={isLoading}
isUploading={isUploading}
isDeleting={isDeleting}
onUpload={(file) => upload(file).unwrap()}
onDelete={() => remove().unwrap()}
/>
);
}

View File

@@ -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')}
</Tabs.Tab>
<Tabs.Tab value="signature" leftSection={<IconSignature size={18} />}>
{t('profile.tabs.signature')}
</Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
</Tabs.Tab>
@@ -808,6 +814,13 @@ export function ProfilePage() {
</Paper>
</Tabs.Panel>
{/* ---- Signature (printed on issued documents) ---- */}
<Tabs.Panel value="signature" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<MySignaturePad />
</Paper>
</Tabs.Panel>
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Stack gap="lg">

View File

@@ -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."
/>
<SelectField
{...p}
name="tier"
label="Certificate Limitation"
required
options={RANK_TIER_OPTIONS}
description={
p.form.department === 'ENGINE'
? 'Above covers ships of 3000 kW propulsion power or more; Below covers 7503000 kW. Every Certificate of Competency you apply for is issued under this limit.'
: 'Above covers ships of 3000 gross tonnage or more; Below covers 5003000 GT. Every Certificate of Competency you apply for is issued under this limit.'
}
/>
</Grid>
<Divider />

View File

@@ -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'],

View File

@@ -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: 'የባህር ሙያ ዝርዝሮችዎ',

View File

@@ -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',

View File

@@ -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<keyof SeafarerRegistrati
passportNumber: 'Passport Number',
passportExpiry: 'Passport Expiry Date',
department: 'Department',
tier: 'Certificate Limitation',
locationId: 'Location',
permanentAddress: 'Permanent Address',
currentAddress: 'Current Address',
@@ -192,7 +206,7 @@ export const SEAFARER_REGISTRATION_SECTIONS: {
{
key: 'identity',
title: 'Identity',
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department'],
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department', 'tier'],
},
{
key: 'address',
@@ -220,6 +234,7 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
gender: GENDER_OPTIONS,
maritalStatus: MARITAL_STATUS_OPTIONS,
department: DEPARTMENT_OPTIONS,
tier: RANK_TIER_OPTIONS,
hairColor: HAIR_COLOR_OPTIONS,
eyeColor: EYE_COLOR_OPTIONS,
bloodType: BLOOD_TYPE_OPTIONS,

View File

@@ -1,5 +1,8 @@
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
/** Above/Below — the STCW limitation a Certificate of Competency is issued under. */
export type RankTier = 'ABOVE' | 'BELOW';
export type SeafarerRegistrationStatus =
| 'DRAFT'
| 'SUBMITTED'
@@ -30,6 +33,11 @@ export interface SeafarerRegistrationAnswers {
passportNumber: string | null;
passportExpiry: string | null;
department: SeafarerDepartment | null;
/**
* The STCW ship-size limitation this seafarer's CoCs are issued under.
* Read with `department` to pick the CoC ladder; CoP carries no tier.
*/
tier: RankTier | null;
locationId: string | null;
permanentAddress: string | null;
currentAddress: string | null;

View File

@@ -24,6 +24,8 @@ export * from "./lib/layout/SkipLink";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/PhoneInput";
export * from "./lib/input/canvas-point";
export * from "./lib/components/SignaturePad";
export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/data/WaitingFor";

View File

@@ -0,0 +1,316 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Alert,
Box,
Button,
Group,
Image,
Loader,
Paper,
SegmentedControl,
Stack,
Text,
Title,
} from '@mantine/core';
import {
IconInfoCircle,
IconPencil,
IconTrash,
IconUpload,
IconWriting,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify } from '../feedback/notify';
import { useErrorHandler } from '../feedback/use-error-handler';
import { toCanvasPoint } from '../input/canvas-point';
/** Mirrors the API's own limits (`ProfileService.saveSignature`). */
const ACCEPTED = ['image/png', 'image/jpeg'];
const MAX_BYTES = 2 * 1024 * 1024;
/**
* The drawing surface's backing-store size.
*
* Fixed rather than matched to the rendered element: this is what gets printed
* on a Seaman Book, so the stored image must not vary with the width of the
* browser window it happened to be drawn in. The canvas is displayed at
* whatever width the layout gives it and scaled to these dimensions.
*/
const PAD_WIDTH = 800;
const PAD_HEIGHT = 260;
/**
* Captures a specimen signature, drawn or uploaded.
*
* Drawing is a plain canvas with pointer events — one element and ~40 lines,
* where a signature-pad dependency would be a package to keep patched. Pointer
* events (not mouse + touch separately) cover mouse, finger and stylus in one
* set of handlers.
*/
export interface SignaturePadProps {
/** Short-lived link to the signature on file, or null when there is none. */
currentUrl: string | null;
isLoading: boolean;
isUploading: boolean;
isDeleting: boolean;
onUpload: (file: File) => Promise<unknown>;
onDelete: () => Promise<unknown>;
}
export function SignaturePad({
currentUrl,
isLoading,
isUploading,
isDeleting,
onUpload,
onDelete,
}: SignaturePadProps) {
const { t } = useTranslation();
const { handleError } = useErrorHandler();
const canvasRef = useRef<HTMLCanvasElement | null>(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<HTMLCanvasElement>) =>
toCanvasPoint(
event.clientX,
event.clientY,
event.currentTarget.getBoundingClientRect(),
{ width: PAD_WIDTH, height: PAD_HEIGHT },
);
const onPointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLInputElement>) => {
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 (
<Stack gap="lg">
<div>
<Title order={5}>{t('profile.signature.title')}</Title>
<Text size="sm" c="dimmed">
{t('profile.signature.description')}
</Text>
</div>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t('profile.signature.reissueNotice')}
</Alert>
{isLoading ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : currentUrl ? (
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Text size="sm" fw={500}>
{t('profile.signature.current')}
</Text>
<Image
src={currentUrl}
alt={t('profile.signature.currentAlt')}
fit="contain"
h={120}
bg="white"
/>
<Group>
<Button
variant="light"
color="red"
size="xs"
leftSection={<IconTrash size={16} />}
onClick={handleDelete}
loading={isDeleting}
>
{t('profile.signature.remove')}
</Button>
</Group>
</Stack>
</Paper>
) : (
<Text size="sm" c="dimmed">
{t('profile.signature.none')}
</Text>
)}
<SegmentedControl
value={mode}
onChange={(value) => setMode(value as 'draw' | 'upload')}
data={[
{ value: 'draw', label: t('profile.signature.modeDraw') },
{ value: 'upload', label: t('profile.signature.modeUpload') },
]}
/>
{mode === 'draw' ? (
<Stack gap="sm">
<Box
component="canvas"
ref={canvasRef}
width={PAD_WIDTH}
height={PAD_HEIGHT}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
style={{
width: '100%',
height: 'auto',
aspectRatio: `${PAD_WIDTH} / ${PAD_HEIGHT}`,
border: '1px dashed var(--mantine-color-gray-4)',
borderRadius: 'var(--mantine-radius-md)',
background: '#fff',
// Stops the browser panning/zooming the page mid-stroke on touch.
touchAction: 'none',
cursor: 'crosshair',
}}
/>
<Group>
<Button
leftSection={<IconWriting size={16} />}
onClick={saveDrawing}
loading={isUploading}
disabled={!hasInk || busy}
>
{t('profile.signature.save')}
</Button>
<Button
variant="default"
leftSection={<IconPencil size={16} />}
onClick={clear}
disabled={!hasInk || busy}
>
{t('profile.signature.clear')}
</Button>
</Group>
</Stack>
) : (
<Stack gap="sm">
<Button
component="label"
variant="light"
leftSection={<IconUpload size={16} />}
loading={isUploading}
disabled={busy}
style={{ alignSelf: 'flex-start' }}
>
{t('profile.signature.choose')}
<input
type="file"
hidden
accept={ACCEPTED.join(',')}
onChange={onFile}
/>
</Button>
<Text size="xs" c="dimmed">
{t('profile.signature.fileHint')}
</Text>
</Stack>
)}
</Stack>
);
}

View File

@@ -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 });
});
});

View File

@@ -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,
};
}