Skip to content

Commit 6e25b85

Browse files
authored
feat(passwords): first-class passwordPrompt API with full customization (#2551)
1 parent 68207b5 commit 6e25b85

12 files changed

Lines changed: 1191 additions & 200 deletions

File tree

apps/docs/core/superdoc/configuration.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,8 @@ new SuperDoc({
274274
<ParamField path="modules.surfaces.floating.autoFocus" type="boolean" default="true">
275275
Move focus into the first focusable child on open
276276
</ParamField>
277-
<ParamField path="modules.surfaces.passwordPrompt" type="boolean | Object" default="true">
278-
Built-in password dialog for encrypted DOCX files. Enabled by default when omitted; set to `false` to disable, or pass an object to customize titles and button text. See [Surfaces — Built-in password prompt](/core/superdoc/surfaces#built-in-password-prompt).
277+
<ParamField path="modules.surfaces.passwordPrompt" type="false | true | Object" default="true">
278+
Password prompt for encrypted DOCX files. Enabled by default. Set to `false` to disable, `true` for defaults, or pass an object to customize text, provide a custom component/render function, or add a per-document resolver. See [Surfaces — Password prompt](/core/superdoc/surfaces#password-prompt).
279279
</ParamField>
280280
</Expandable>
281281
</ParamField>

apps/docs/core/superdoc/surfaces.mdx

Lines changed: 179 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ Open a `dialog` or `floating` surface above the document.
4646
<ParamField path="title" type="string">
4747
Optional title rendered in the surface chrome
4848
</ParamField>
49+
<ParamField path="ariaLabel" type="string">
50+
Accessible name for the surface when no visible `title` is provided. Used as `aria-label` on the surface element (dialog or floating). Ignored when `title` or `ariaLabelledBy` is set.
51+
</ParamField>
4952
<ParamField path="closeOnEscape" type="boolean" default="true">
5053
Whether `Escape` closes the surface
5154
</ParamField>
@@ -343,97 +346,241 @@ superdoc.openSurface({
343346
There is no built-in surface registry yet. If you use `kind`, you must provide `modules.surfaces.resolver`.
344347
</Warning>
345348
346-
## Built-in password prompt
347-
348-
SuperDoc includes a built-in password dialog for encrypted DOCX files. It is enabled by default. On wrong password, the dialog stays open and shows an error. On success, the document loads normally.
349+
## Password prompt
349350
350-
You can configure it via `modules.surfaces.passwordPrompt`:
351+
SuperDoc includes a built-in password dialog for encrypted DOCX files. Enabled by default. On wrong password, the dialog stays open and shows an error. On success, the document loads normally.
351352
352353
```javascript
353354
new SuperDoc({
354355
selector: '#editor',
355356
document: encryptedFile,
356357
modules: {
357358
surfaces: {
358-
passwordPrompt: true,
359+
passwordPrompt: true, // default — can omit
359360
},
360361
},
361362
});
362363
```
363364
364-
### Custom labels
365+
Set to `false` to disable:
365366
366-
Pass an object to override the default titles and button text:
367+
```javascript
368+
modules: {
369+
surfaces: {
370+
passwordPrompt: false,
371+
},
372+
}
373+
```
374+
375+
### Text customization
376+
377+
Override any of the built-in copy:
367378
368379
```javascript
369380
modules: {
370381
surfaces: {
371382
passwordPrompt: {
372383
title: 'Unlock document',
373384
invalidTitle: 'Wrong password — try again',
385+
description: 'This file requires a password.',
374386
submitLabel: 'Unlock',
375387
cancelLabel: 'Dismiss',
388+
busyLabel: 'Opening\u2026',
389+
invalidMessage: 'That password is wrong. Try again.',
390+
timeoutMessage: 'Took too long. Try again.',
391+
genericErrorMessage: 'Could not open this file.',
376392
},
377393
},
378394
}
379395
```
380396
381-
<ParamField path="modules.surfaces.passwordPrompt" type="boolean | Object" default="true">
382-
Built-in password prompt for encrypted DOCX files. Enabled by default when omitted; set to `false` to disable.
397+
<ParamField path="modules.surfaces.passwordPrompt" type="false | true | Object" default="true">
398+
Password prompt configuration. Enabled by default when omitted; set to `false` to disable.
383399
384-
<Expandable title="properties">
400+
<Expandable title="text fields">
385401
<ParamField path="title" type="string" default="'Password Required'">
386402
Dialog heading on first attempt
387403
</ParamField>
388404
<ParamField path="invalidTitle" type="string" default="'Incorrect Password'">
389405
Dialog heading after a wrong password
390406
</ParamField>
407+
<ParamField path="description" type="string" default="'This document is password protected. Enter the password to open it.'">
408+
Explanatory text below the heading
409+
</ParamField>
410+
<ParamField path="placeholder" type="string" default="'Enter password'">
411+
Input placeholder text
412+
</ParamField>
413+
<ParamField path="inputAriaLabel" type="string" default="'Document password'">
414+
Accessible label for the password input
415+
</ParamField>
391416
<ParamField path="submitLabel" type="string" default="'Open'">
392417
Submit button text
393418
</ParamField>
394419
<ParamField path="cancelLabel" type="string" default="'Cancel'">
395420
Cancel button text
396421
</ParamField>
422+
<ParamField path="busyLabel" type="string" default="'Decrypting\u2026'">
423+
Submit button text while decrypting
424+
</ParamField>
425+
<ParamField path="invalidMessage" type="string" default="'Incorrect password. Please try again.'">
426+
Error message for wrong password
427+
</ParamField>
428+
<ParamField path="timeoutMessage" type="string" default="'Timed out while decrypting. Please try again.'">
429+
Error message for decryption timeout
430+
</ParamField>
431+
<ParamField path="genericErrorMessage" type="string" default="'Unable to decrypt this document.'">
432+
Error message for other decryption failures
433+
</ParamField>
434+
</Expandable>
435+
436+
<Expandable title="custom UI fields">
437+
<ParamField path="component" type="Vue Component">
438+
Custom Vue component rendered inside the dialog shell. Receives a `passwordPrompt` prop (see handle reference below). Mutually exclusive with `render`.
439+
</ParamField>
440+
<ParamField path="props" type="Record<string, unknown>">
441+
Extra props passed to the custom Vue component. Component-only; ignored for `render`.
442+
</ParamField>
443+
<ParamField path="render" type="(ctx: PasswordPromptRenderContext) => { destroy?: () => void } | void">
444+
Framework-agnostic renderer for React or manual DOM mounting. Mutually exclusive with `component`.
445+
</ParamField>
446+
<ParamField path="resolver" type="(ctx: PasswordPromptContext) => PasswordPromptResolution | null | undefined">
447+
Conditional resolver for per-document customization. Can coexist with `component`/`render` — the resolver runs first, and `null`/`undefined`/`{ type: 'default' }` falls through to the direct component/render or built-in.
448+
</ParamField>
397449
</Expandable>
398450
</ParamField>
399451
400-
### Custom password UI via resolver
452+
### Custom Vue component
401453
402-
To replace the built-in dialog with your own component, add a resolver that handles the `'password-prompt'` kind. Keep `passwordPrompt` enabled so the password flow stays active:
454+
Replace the built-in dialog content with your own Vue component. Your component receives a `passwordPrompt` prop with everything it needs:
403455
404456
```javascript
405457
import MyPasswordDialog from './MyPasswordDialog.vue';
406458

407-
new SuperDoc({
408-
selector: '#editor',
409-
document: encryptedFile,
410-
modules: {
411-
surfaces: {
412-
passwordPrompt: true,
413-
resolver: (request) => {
414-
if (request.kind === 'password-prompt') {
415-
return {
416-
type: 'custom',
417-
component: MyPasswordDialog,
418-
props: {
419-
attemptPassword: request.payload.attemptPassword,
420-
errorCode: request.payload.errorCode,
421-
},
422-
};
459+
modules: {
460+
surfaces: {
461+
passwordPrompt: {
462+
component: MyPasswordDialog,
463+
},
464+
},
465+
}
466+
```
467+
468+
Inside your component:
469+
470+
```vue
471+
<script setup>
472+
const props = defineProps({
473+
passwordPrompt: { type: Object, required: true },
474+
resolve: { type: Function, required: true },
475+
close: { type: Function, required: true },
476+
});
477+
478+
async function handleSubmit(password) {
479+
const result = await props.passwordPrompt.attemptPassword(password);
480+
if (result.success) {
481+
props.resolve(); // no args required
482+
}
483+
// result.errorCode tells you what went wrong
484+
}
485+
</script>
486+
```
487+
488+
### Custom render function (React, vanilla JS)
489+
490+
Use `render` for framework-agnostic mounting. The render function receives a `PasswordPromptRenderContext`:
491+
492+
```javascript
493+
modules: {
494+
surfaces: {
495+
passwordPrompt: {
496+
render: (ctx) => {
497+
// ctx.container — empty DOM element to render into
498+
// ctx.passwordPrompt — the handle (documentId, errorCode, texts, attemptPassword)
499+
// ctx.resolve — call on success
500+
// ctx.close — call to dismiss
501+
502+
const root = ReactDOM.createRoot(ctx.container);
503+
root.render(<MyPasswordPrompt passwordPrompt={ctx.passwordPrompt} resolve={ctx.resolve} close={ctx.close} />);
504+
return { destroy: () => root.unmount() };
505+
},
506+
},
507+
},
508+
}
509+
```
510+
511+
### Conditional resolver
512+
513+
Use `resolver` when you need per-document decisions. The resolver receives a read-only `PasswordPromptContext` (`documentId`, `errorCode`, `texts`) and returns a resolution:
514+
515+
```javascript
516+
modules: {
517+
surfaces: {
518+
passwordPrompt: {
519+
resolver: (ctx) => {
520+
if (ctx.documentId === 'public-doc') {
521+
return { type: 'none' }; // suppress prompt for this doc
423522
}
523+
return null; // fall through to built-in
424524
},
425525
},
426526
},
427-
});
527+
}
428528
```
429529
430-
Your custom component receives `resolve` and `close` as reserved props, plus whatever you pass in `props`. Call `request.payload.attemptPassword(password)` to retry — it returns `{ success: true }` or `{ success: false, errorCode }`. On success, call `resolve()` to close the dialog.
530+
**Resolution types:**
531+
532+
| Type | Behavior |
533+
|------|----------|
534+
| `null` / `undefined` | Fall through to `component`/`render` or built-in |
535+
| `{ type: 'default' }` | Same as `null` — fall through |
536+
| `{ type: 'none' }` | Suppress the password prompt for this document |
537+
| `{ type: 'custom', component, props? }` | Mount a Vue component in the dialog shell |
538+
| `{ type: 'external', render }` | Mount framework-agnostic UI in the dialog shell |
539+
540+
The resolver can coexist with `component`/`render`. If the resolver returns `null` or `{ type: 'default' }`, the direct `component`/`render` is used. If neither is configured, the built-in prompt renders.
541+
542+
**Precedence:** resolver → `component`/`render` → built-in.
543+
544+
### The `passwordPrompt` handle
545+
546+
Every custom UI (component or render) receives a `passwordPrompt` handle with this shape:
547+
548+
| Field | Type | Description |
549+
|-------|------|-------------|
550+
| `documentId` | `string` | The document ID requiring a password |
551+
| `errorCode` | `string` | `'DOCX_PASSWORD_REQUIRED'` or `'DOCX_PASSWORD_INVALID'` |
552+
| `texts` | `ResolvedPasswordPromptTexts` | All 11 text strings resolved with defaults |
553+
| `attemptPassword` | `(password: string) => Promise<{ success, errorCode? }>` | Submit a password attempt |
554+
555+
### How actions work
556+
557+
**Submit / unlock button:**
558+
1. Call `await passwordPrompt.attemptPassword(password)`
559+
2. If `{ success: true }` — call `resolve()` to close the dialog
560+
3. If `{ success: false, errorCode }` — keep the dialog open, show your error state
561+
562+
`resolve()` with no arguments is sufficient. The password flow does not consume the resolved data.
563+
564+
**Cancel / dismiss button:**
565+
- Call `close('user-cancelled')`
566+
567+
**Other actions** (forgot password, help links): app-owned. Keep the dialog open or close with a custom reason.
568+
569+
<Warning>
570+
Do not set `doc.password` or increment `editorMountNonce` directly. Use `passwordPrompt.attemptPassword(password)` — it handles the document mutation and remount internally.
571+
</Warning>
572+
573+
### `{ type: 'none' }` semantics
574+
575+
`{ type: 'none' }` means **suppress SuperDoc's password prompt**. The resolver context does not expose `attemptPassword`, so `none` cannot be used to build a self-hosted modal that retries passwords through SuperDoc.
576+
577+
Use this when your app handles passwords entirely outside SuperDoc — for example, pre-prompting before instantiation or server-side decryption. For global suppression, use `passwordPrompt: false` instead.
431578
432579
### Relationship with the exception event
433580
434-
If `passwordPrompt` is disabled, encrypted files emit a `DOCX_PASSWORD_REQUIRED` or `DOCX_PASSWORD_INVALID` code on the `exception` event. Your app can handle password entry entirely through that event if you prefer.
581+
If `passwordPrompt` is disabled, encrypted files emit `DOCX_PASSWORD_REQUIRED` or `DOCX_PASSWORD_INVALID` on the `exception` event. Your app can handle password entry through that event instead.
435582
436-
When `passwordPrompt` is enabled, the built-in dialog handles retry automatically. The `exception` event still fires (with `documentId` in the payload) so your app can log or track failures.
583+
When `passwordPrompt` is enabled, recoverable encryption errors are intercepted by the password prompt flow. If the prompt renders successfully (built-in, custom, or external), the `exception` event is suppressed. If the prompt cannot render (resolver returned `{ type: 'none' }`, invalid config, or resolver threw), the original `exception` event is re-emitted so your app can handle it.
437584
438585
### Multi-document handling
439586

packages/superdoc/src/SuperDoc.test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,9 @@ describe('SuperDoc.vue', () => {
510510
code: 'DOCX_PASSWORD_REQUIRED',
511511
});
512512

513+
// The built-in password prompt lazy-imports the component before opening
514+
await vi.dynamicImportSettled();
515+
513516
expect(surfaceManager.open).toHaveBeenCalledTimes(1);
514517
expect(
515518
superdocStub.emit.mock.calls.some(

packages/superdoc/src/SuperDoc.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ const surfaceManager = inject('surfaceManager', null);
6565
const passwordPrompt = usePasswordPrompt({
6666
getSurfaceManager: () => surfaceManager,
6767
getPasswordPromptConfig: () => proxy.$superdoc?.config?.modules?.surfaces?.passwordPrompt,
68+
onUnhandled: (doc, errorCode, originalException) => {
69+
// The password prompt initially claimed this error but could not show a dialog
70+
// (resolver returned { type: 'none' }, config was invalid, or resolver threw).
71+
// Re-emit the original exception event so the app can handle it.
72+
proxy.$superdoc?.emit('exception', {
73+
error: originalException?.error ?? new Error(`Password prompt unhandled: ${errorCode}`),
74+
editor: originalException?.editor ?? null,
75+
code: errorCode,
76+
documentId: doc?.id,
77+
});
78+
},
6879
});
6980

7081
/*
@@ -603,7 +614,7 @@ const onEditorContentError = ({ error, editor }) => {
603614
};
604615

605616
const onEditorException = (doc, { error, editor, code }) => {
606-
const handled = passwordPrompt.handleEncryptionError(doc, code);
617+
const handled = passwordPrompt.handleEncryptionError(doc, code, { error, editor });
607618
if (handled) return true;
608619
proxy.$superdoc.emit('exception', { error, editor, code, documentId: doc?.id });
609620
return false;

0 commit comments

Comments
 (0)