Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/lib/settings/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,93 @@ describe('Settings System Integration', () => {
});
});

// ── Electronic Signature Integration ──────────────────────────────────────

describe('Electronic Signature Integration', () => {
it('defaults to disabled electronic signature', () => {
const defaults = createDefaultSettings();
expect(defaults.electronicSignatureEnabled).toBe(false);
expect(defaults.signatureName).toBe('');
expect(defaults.requireSignatureOnCertificates).toBe(false);
});

it('validates a full electronic signature configuration', () => {
const settings = {
...createDefaultSettings(),
electronicSignatureEnabled: true,
signatureName: 'Jane Doe',
requireSignatureOnCertificates: true,
};
const result = SettingsService.validateSettings(settings);
expect(result.valid).toBe(true);
expect(result.data?.electronicSignatureEnabled).toBe(true);
expect(result.data?.signatureName).toBe('Jane Doe');
expect(result.data?.requireSignatureOnCertificates).toBe(true);
});

it('rejects a signatureName that exceeds 100 characters', () => {
const settings = {
...createDefaultSettings(),
electronicSignatureEnabled: true,
signatureName: 'a'.repeat(101),
};
const result = SettingsService.validateSettings(settings);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('signatureName'))).toBe(true);
});

it('preserves electronic signature settings through export/import cycle', () => {
const original = {
...createDefaultSettings(),
electronicSignatureEnabled: true,
signatureName: 'Alice Smith',
requireSignatureOnCertificates: true,
};
const storeState = SettingsService.createStoreState(original);
const exported = SettingsService.exportSettings(storeState);
const importResult = SettingsService.importSettings(exported);

expect(importResult.valid).toBe(true);
expect(importResult.data?.electronicSignatureEnabled).toBe(true);
expect(importResult.data?.signatureName).toBe('Alice Smith');
expect(importResult.data?.requireSignatureOnCertificates).toBe(true);
});

it('resets electronic signature to defaults on resetToDefaults', () => {
const reset = SettingsService.resetToDefaults();
expect(reset.electronicSignatureEnabled).toBe(false);
expect(reset.signatureName).toBe('');
expect(reset.requireSignatureOnCertificates).toBe(false);
});

it('canEditSetting returns true for all electronic signature fields', () => {
expect(SettingsService.canEditSetting('electronicSignatureEnabled')).toBe(true);
expect(SettingsService.canEditSetting('signatureName')).toBe(true);
expect(SettingsService.canEditSetting('requireSignatureOnCertificates')).toBe(true);
});

it('merges electronic signature settings via last-write-wins', () => {
const localState: SettingsStorePersistedShape = {
settings: { ...createDefaultSettings(), electronicSignatureEnabled: false },
updatedAt: Date.now() - 2000,
lastSyncedAt: null,
};
const remoteState: SettingsStorePersistedShape = {
settings: {
...createDefaultSettings(),
electronicSignatureEnabled: true,
signatureName: 'Bob',
},
updatedAt: Date.now() - 1000,
lastSyncedAt: null,
};

const merged = SettingsService.mergeSettings(localState, remoteState);
expect(merged.settings.electronicSignatureEnabled).toBe(true);
expect(merged.settings.signatureName).toBe('Bob');
});
});

// ── LocalStorage Integration ───────────────────────────────────────────────

describe('LocalStorage Integration', () => {
Expand Down
41 changes: 41 additions & 0 deletions src/lib/settings/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,31 @@ describe('SettingsService', () => {
const result = SettingsService.validateSettingValue('language', 'a'.repeat(25));
expect(result.valid).toBe(false);
});

it('validates correct electronicSignatureEnabled value', () => {
const result = SettingsService.validateSettingValue('electronicSignatureEnabled', false);
expect(result.valid).toBe(true);
});

it('rejects non-boolean electronicSignatureEnabled', () => {
const result = SettingsService.validateSettingValue('electronicSignatureEnabled', 'yes');
expect(result.valid).toBe(false);
});

it('validates correct signatureName value', () => {
const result = SettingsService.validateSettingValue('signatureName', 'Jane Doe');
expect(result.valid).toBe(true);
});

it('rejects signatureName that is too long', () => {
const result = SettingsService.validateSettingValue('signatureName', 'a'.repeat(101));
expect(result.valid).toBe(false);
});

it('validates correct requireSignatureOnCertificates value', () => {
const result = SettingsService.validateSettingValue('requireSignatureOnCertificates', true);
expect(result.valid).toBe(true);
});
});

// ── exportSettings ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -380,6 +405,7 @@ describe('SettingsService', () => {
expect(capabilities).toHaveProperty('canEditEmail');
expect(capabilities).toHaveProperty('canEditPrefetching');
expect(capabilities).toHaveProperty('canEditReducedMotion');
expect(capabilities).toHaveProperty('canEditElectronicSignature');
expect(capabilities).toHaveProperty('canExportSettings');
expect(capabilities).toHaveProperty('canImportSettings');
expect(capabilities).toHaveProperty('canSyncSettings');
Expand Down Expand Up @@ -431,6 +457,21 @@ describe('SettingsService', () => {
const result = SettingsService.canEditSetting('version');
expect(result).toBeDefined(); // Should map to a capability
});

it('allows editing electronicSignatureEnabled', () => {
const result = SettingsService.canEditSetting('electronicSignatureEnabled');
expect(result).toBe(true);
});

it('allows editing signatureName', () => {
const result = SettingsService.canEditSetting('signatureName');
expect(result).toBe(true);
});

it('allows editing requireSignatureOnCertificates', () => {
const result = SettingsService.canEditSetting('requireSignatureOnCertificates');
expect(result).toBe(true);
});
});

// ── migrateSettings ──────────────────────────────────────────────────────
Expand Down
4 changes: 2 additions & 2 deletions src/lib/settings/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const SETTINGS_SCHEMA_VERSION = 1 as const;
export const SETTINGS_SCHEMA_VERSION = 2 as const;

/** Zustand persist key for local persistence */
export const SETTINGS_STORAGE_KEY = 'teachlink-app-settings-v1';
export const SETTINGS_STORAGE_KEY = 'teachlink-app-settings-v2';

/** Stable ID for anonymous sync across sessions on same browser */
export const ANONYMOUS_SETTINGS_USER_KEY = 'teachlink-anonymous-sync-user-id';
10 changes: 10 additions & 0 deletions src/lib/settings/export-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ export function parseExportedSettings(raw: unknown): AppSettings | { error: stri
: undefined,
reducedMotion:
typeof onlySettings.reducedMotion === 'boolean' ? onlySettings.reducedMotion : undefined,
electronicSignatureEnabled:
typeof onlySettings.electronicSignatureEnabled === 'boolean'
? onlySettings.electronicSignatureEnabled
: undefined,
signatureName:
typeof onlySettings.signatureName === 'string' ? onlySettings.signatureName : undefined,
requireSignatureOnCertificates:
typeof onlySettings.requireSignatureOnCertificates === 'boolean'
? onlySettings.requireSignatureOnCertificates
: undefined,
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/lib/settings/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export class SettingsService {
canEditEmail: boolean;
canEditPrefetching: boolean;
canEditReducedMotion: boolean;
canEditElectronicSignature: boolean;
canExportSettings: boolean;
canImportSettings: boolean;
canSyncSettings: boolean;
Expand All @@ -240,6 +241,7 @@ export class SettingsService {
canEditEmail: true,
canEditPrefetching: true,
canEditReducedMotion: true,
canEditElectronicSignature: true,
canExportSettings: true,
canImportSettings: true,
canSyncSettings: true,
Expand All @@ -260,6 +262,9 @@ export class SettingsService {
emailNotifications: 'canEditEmail',
prefetchingEnabled: 'canEditPrefetching',
reducedMotion: 'canEditReducedMotion',
electronicSignatureEnabled: 'canEditElectronicSignature',
signatureName: 'canEditElectronicSignature',
requireSignatureOnCertificates: 'canEditElectronicSignature',
};

return capabilities[permissionMap[key]] || false;
Expand Down
23 changes: 16 additions & 7 deletions src/lib/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ export type ThemePreference = z.infer<typeof themePreferenceSchema>;
* Validated schema for all user-configurable application settings.
*
* Fields:
* - `version` — Schema version; bumped when new fields are added (see `SETTINGS_SCHEMA_VERSION`).
* - `theme` — Colour scheme: `'light'`, `'dark'`, or `'system'` (follows OS preference).
* - `language` — BCP-47 locale tag (e.g. `'en'`, `'fr-CA'`), max 24 chars; defaults to `navigator.language`.
* - `notificationsEnabled` — Master toggle for in-app push/toast notifications.
* - `emailNotifications` — Whether transactional and digest emails should be sent.
* - `prefetchingEnabled` — Pre-fetches linked pages on hover for faster navigation; disable on slow connections.
* - `reducedMotion` — Suppresses non-essential animations for users who prefer reduced motion.
* - `version` — Schema version; bumped when new fields are added (see `SETTINGS_SCHEMA_VERSION`).
* - `theme` — Colour scheme: `'light'`, `'dark'`, or `'system'` (follows OS preference).
* - `language` — BCP-47 locale tag (e.g. `'en'`, `'fr-CA'`), max 24 chars; defaults to `navigator.language`.
* - `notificationsEnabled` — Master toggle for in-app push/toast notifications.
* - `emailNotifications` — Whether transactional and digest emails should be sent.
* - `prefetchingEnabled` — Pre-fetches linked pages on hover for faster navigation; disable on slow connections.
* - `reducedMotion` — Suppresses non-essential animations for users who prefer reduced motion.
* - `electronicSignatureEnabled` — Master toggle for electronic signature on authenticated actions.
* - `signatureName` — Full name used as the typed electronic signature (max 100 chars).
* - `requireSignatureOnCertificates` — Prompt the user to confirm their signature before a certificate is issued.
*/
export const appSettingsSchema = z.object({
version: z.literal(SETTINGS_SCHEMA_VERSION),
Expand All @@ -25,6 +28,9 @@ export const appSettingsSchema = z.object({
emailNotifications: z.boolean(),
prefetchingEnabled: z.boolean(),
reducedMotion: z.boolean(),
electronicSignatureEnabled: z.boolean(),
signatureName: z.string().max(100),
requireSignatureOnCertificates: z.boolean(),
});

/** Fully typed representation of all user settings. Inferred from `appSettingsSchema`. */
Expand Down Expand Up @@ -74,5 +80,8 @@ export function createDefaultSettings(): AppSettings {
emailNotifications: true,
prefetchingEnabled: true,
reducedMotion: false,
electronicSignatureEnabled: false,
signatureName: '',
requireSignatureOnCertificates: false,
};
}
81 changes: 81 additions & 0 deletions src/pages/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,87 @@ export default function SettingsPage() {
</p>
</section>

<section className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl p-6 shadow-sm space-y-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Security</h2>

<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">
Electronic Signature
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Enable a typed signature to authenticate important actions such as document
signing and certificate issuance.
</p>

<label className="flex items-start gap-3 cursor-pointer mb-4">
<input
type="checkbox"
className="mt-1 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={settings.electronicSignatureEnabled}
onChange={(e) =>
patchSettings({ electronicSignatureEnabled: e.target.checked })
}
/>
<span>
<span className="font-medium text-gray-900 dark:text-gray-50">
Enable electronic signature
</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
When enabled, your typed name acts as a legal acknowledgement for signed
actions.
</span>
</span>
</label>

<div
className={
settings.electronicSignatureEnabled ? undefined : 'opacity-50 pointer-events-none'
}
>
<label
htmlFor="signatureName"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Signature name
</label>
<input
id="signatureName"
type="text"
maxLength={100}
value={settings.signatureName}
onChange={(e) => patchSettings({ signatureName: e.target.value })}
disabled={!settings.electronicSignatureEnabled}
placeholder="Your full name"
className="block w-full max-w-xs rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-gray-900 dark:text-gray-100 text-sm shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Appears on signed documents and certificates.
</p>

<label className="flex items-start gap-3 cursor-pointer mt-4">
<input
type="checkbox"
className="mt-1 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={settings.requireSignatureOnCertificates}
disabled={!settings.electronicSignatureEnabled}
onChange={(e) =>
patchSettings({ requireSignatureOnCertificates: e.target.checked })
}
/>
<span>
<span className="font-medium text-gray-900 dark:text-gray-50">
Require signature for certificates
</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
You will be prompted to confirm your signature before a certificate is
issued.
</span>
</span>
</label>
</div>
</div>
</section>

<section className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl p-6 shadow-sm space-y-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
Export / import file
Expand Down
Loading