@gntik-ai/blocks 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +34 -7
- package/dist/index.js +283 -109
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/builders/LogViewer/LogViewer.test.tsx +11 -0
- package/src/builders/LogViewer/LogViewer.tsx +28 -22
- package/src/forms/CredentialDisclosureDialog/CredentialDisclosureDialog.additional.test.tsx +467 -0
- package/src/forms/CredentialDisclosureDialog/CredentialDisclosureDialog.tsx +175 -106
- package/src/forms/CredentialDisclosureDialog/IssueOnClick.test.tsx +56 -0
- package/src/forms/CredentialDisclosureDialog/SecretCopy.tsx +83 -0
- package/src/forms/CredentialDisclosureDialog/block.meta.ts +1 -1
- package/src/forms/CredentialDisclosureDialog/examples/Inline.tsx +19 -0
- package/src/forms/CredentialDisclosureDialog/examples/IssueOnClick.tsx +87 -0
- package/src/forms/CredentialDisclosureDialog/examples/MultiSecret.tsx +29 -0
- package/src/forms/CredentialDisclosureDialog/focus.ts +18 -0
- package/src/forms/CredentialDisclosureDialog/types.ts +5 -0
- package/src/page-chrome/PageHeader/PageHeader.test.tsx +18 -0
- package/src/page-chrome/PageHeader/PageHeader.tsx +3 -1
- package/src/page-chrome/PageHeader/block.meta.ts +1 -1
- package/src/page-chrome/PageHeader/examples/Spanish.tsx +11 -0
- package/src/tables/DataTable/DataTable.labels.test.tsx +2 -0
- package/src/tables/DataTable/DataTable.locale.test.tsx +153 -0
- package/src/tables/DataTable/DataTable.server-pagination.test.tsx +1 -1
- package/src/tables/DataTable/DataTable.test.tsx +16 -0
- package/src/tables/DataTable/block.meta.ts +2 -0
- package/src/tables/DataTable/data-table-row.tsx +18 -2
- package/src/tables/DataTable/data-table-utils.ts +18 -4
- package/src/tables/DataTable/labels.ts +2 -0
- package/src/test/CredentialDisclosureDialog.compatibility.tsx +30 -0
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Button,
|
|
3
|
-
CopyButton,
|
|
4
3
|
Dialog,
|
|
5
4
|
DialogContent,
|
|
6
5
|
DialogHeader,
|
|
@@ -9,13 +8,15 @@ import {
|
|
|
9
8
|
DialogBody,
|
|
10
9
|
DialogFooter,
|
|
11
10
|
DialogClose,
|
|
12
|
-
LiveAnnouncer,
|
|
13
|
-
SecretField,
|
|
14
|
-
CodeBlock,
|
|
15
11
|
Alert,
|
|
16
12
|
useI18n,
|
|
17
13
|
} from '@gntik-ai/ui';
|
|
18
|
-
import { useRef, useState, type ReactNode } from 'react';
|
|
14
|
+
import { useEffectEvent, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
15
|
+
import { SecretCopy } from './SecretCopy';
|
|
16
|
+
import { usableFocusTarget } from './focus';
|
|
17
|
+
import type { CredentialSecret } from './types';
|
|
18
|
+
|
|
19
|
+
export type { CredentialSecret } from './types';
|
|
19
20
|
|
|
20
21
|
export interface CredentialDisclosure {
|
|
21
22
|
variant: 'stored' | 'fresh';
|
|
@@ -26,9 +27,21 @@ export interface CredentialDisclosure {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export interface CredentialDisclosureDialogProps {
|
|
30
|
+
/** Additional masked values; ids must be unique, non-empty and not "secret". */
|
|
31
|
+
additionalSecrets?: readonly CredentialSecret[];
|
|
32
|
+
/** Mask a fresh primary secret; read at the start of each session. */
|
|
33
|
+
maskFresh?: boolean;
|
|
34
|
+
/** Redacted content below the secrets; clear secret-bearing content on close. */
|
|
35
|
+
revealContent?: ReactNode;
|
|
36
|
+
/** Presentation fixed for each non-null opening. Defaults to a modal dialog. */
|
|
37
|
+
presentation?: 'dialog' | 'inline';
|
|
38
|
+
/** Called after copying with the secret id (primary: "secret"), never its value. */
|
|
39
|
+
onCopy?: (id: string) => void;
|
|
40
|
+
/** Label-only success announcement override, used only in multi-secret mode. */
|
|
41
|
+
copiedAnnouncementFor?: (label: string) => string;
|
|
29
42
|
/** Set only after a successful response. Leave null while pending or on failure. */
|
|
30
43
|
disclosure: CredentialDisclosure | null;
|
|
31
|
-
/** Clear disclosure
|
|
44
|
+
/** Clear disclosure, additionalSecrets and secret-bearing revealContent on dismissal. */
|
|
32
45
|
onClose: () => void;
|
|
33
46
|
/** Resolve the current row action at close time, including after list reloads. */
|
|
34
47
|
resolveReturnFocus?: () => HTMLElement | null;
|
|
@@ -51,9 +64,10 @@ export interface CredentialDisclosureDialogProps {
|
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
67
|
+
* Render only successful responses. Keep disclosure null while pending or on failure.
|
|
68
|
+
* On close, clear disclosure, additionalSecrets and any secret-bearing revealContent.
|
|
69
|
+
* No pending/error state or internal secret retention. Retained dismissed sessions stay closed;
|
|
70
|
+
* a changed credential/value or a null-to-value opening starts a new session.
|
|
57
71
|
*/
|
|
58
72
|
export function CredentialDisclosureDialog(props: CredentialDisclosureDialogProps) {
|
|
59
73
|
if (!props.disclosure) return null;
|
|
@@ -61,108 +75,182 @@ export function CredentialDisclosureDialog(props: CredentialDisclosureDialogProp
|
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
type OpeningProps = CredentialDisclosureDialogProps & { disclosure: CredentialDisclosure };
|
|
78
|
+
type FocusScope = {
|
|
79
|
+
onRootRef: (node: HTMLElement | null) => void;
|
|
80
|
+
onTargetRef: (node: HTMLElement | null) => void;
|
|
81
|
+
onPopupRef: (node: HTMLDivElement | null) => void;
|
|
82
|
+
containsFocus: () => boolean;
|
|
83
|
+
initialFocus: () => HTMLElement | false | null;
|
|
84
|
+
};
|
|
64
85
|
|
|
65
86
|
function DisclosureOpening(props: OpeningProps) {
|
|
66
|
-
// Keep the original opener across credential/value changes; the old popup is not an opener.
|
|
67
87
|
const [opener] = useState(() => (typeof document === 'undefined' ? null : document.activeElement));
|
|
88
|
+
const [presentation] = useState(props.presentation ?? 'dialog');
|
|
89
|
+
const root = useRef<HTMLElement>(null);
|
|
90
|
+
const target = useRef<HTMLElement>(null);
|
|
91
|
+
const active = useRef<Element | null>(opener);
|
|
92
|
+
const started = useRef(false);
|
|
93
|
+
const activeWasInside = useRef(false);
|
|
94
|
+
const returnFocus = () => {
|
|
95
|
+
const current = props.resolveReturnFocus?.();
|
|
96
|
+
if (usableFocusTarget(current)) return current;
|
|
97
|
+
const fallback = props.fallbackFocus?.();
|
|
98
|
+
if (usableFocusTarget(fallback)) return fallback;
|
|
99
|
+
return usableFocusTarget(opener) ? opener : false;
|
|
100
|
+
};
|
|
101
|
+
const restore = useEffectEvent(() => {
|
|
102
|
+
if (root.current?.contains(document.activeElement)) {
|
|
103
|
+
const element = returnFocus();
|
|
104
|
+
if (element) element.focus();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
useLayoutEffect(() => {
|
|
108
|
+
const track = () => {
|
|
109
|
+
if (document.activeElement !== document.body) {
|
|
110
|
+
active.current = document.activeElement;
|
|
111
|
+
activeWasInside.current = root.current?.contains(document.activeElement) ?? false;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
document.addEventListener('focusin', track);
|
|
115
|
+
return () => {
|
|
116
|
+
document.removeEventListener('focusin', track);
|
|
117
|
+
restore();
|
|
118
|
+
};
|
|
119
|
+
}, [presentation]);
|
|
120
|
+
useLayoutEffect(() => {
|
|
121
|
+
// A surviving focused control wins, including one outside the disclosure.
|
|
122
|
+
if (activeWasInside.current && active.current && !active.current.isConnected) target.current?.focus();
|
|
123
|
+
});
|
|
124
|
+
const seen = new Set(['secret', '']);
|
|
125
|
+
const additionalSecrets = (props.additionalSecrets ?? []).filter(({ id }) => {
|
|
126
|
+
if (seen.has(id)) return false;
|
|
127
|
+
seen.add(id);
|
|
128
|
+
return true;
|
|
129
|
+
});
|
|
68
130
|
const { variant, credentialId, secret } = props.disclosure;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
131
|
+
return (
|
|
132
|
+
<DisclosureSession
|
|
133
|
+
key={JSON.stringify([variant, credentialId, secret, ...additionalSecrets.map(({ id, value }) => [id, value])])}
|
|
134
|
+
{...props}
|
|
135
|
+
presentation={presentation}
|
|
136
|
+
additionalSecrets={additionalSecrets}
|
|
137
|
+
onRootRef={(node) => {
|
|
138
|
+
root.current = node;
|
|
139
|
+
}}
|
|
140
|
+
onTargetRef={(node) => {
|
|
141
|
+
target.current = node;
|
|
142
|
+
}}
|
|
143
|
+
onPopupRef={(node) => {
|
|
144
|
+
root.current = node;
|
|
145
|
+
target.current = node;
|
|
146
|
+
}}
|
|
147
|
+
containsFocus={() => root.current?.contains(document.activeElement) ?? false}
|
|
148
|
+
initialFocus={() => {
|
|
149
|
+
const shouldFocus = !started.current || (activeWasInside.current && !active.current?.isConnected);
|
|
150
|
+
started.current = true;
|
|
151
|
+
return shouldFocus ? root.current : false;
|
|
152
|
+
}}
|
|
153
|
+
returnFocus={returnFocus}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
72
156
|
}
|
|
73
157
|
|
|
74
|
-
function DisclosureSession({
|
|
75
|
-
disclosure,
|
|
76
|
-
onClose,
|
|
77
|
-
resolveReturnFocus,
|
|
78
|
-
fallbackFocus,
|
|
79
|
-
opener,
|
|
80
|
-
...labels
|
|
81
|
-
}: OpeningProps & { opener: Element | null }) {
|
|
158
|
+
function DisclosureSession(props: OpeningProps & FocusScope & { returnFocus: () => HTMLElement | false }) {
|
|
82
159
|
const { t } = useI18n();
|
|
160
|
+
const {
|
|
161
|
+
disclosure,
|
|
162
|
+
onRootRef,
|
|
163
|
+
onTargetRef,
|
|
164
|
+
onPopupRef,
|
|
165
|
+
initialFocus,
|
|
166
|
+
containsFocus,
|
|
167
|
+
presentation,
|
|
168
|
+
additionalSecrets = [],
|
|
169
|
+
} = props;
|
|
83
170
|
const [open, setOpen] = useState(true);
|
|
84
|
-
const
|
|
171
|
+
const [maskFresh] = useState(props.maskFresh ?? false);
|
|
85
172
|
const dismissed = useRef(false);
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
173
|
+
const headingId = useId();
|
|
174
|
+
const close = () => {
|
|
175
|
+
if (dismissed.current) return;
|
|
176
|
+
dismissed.current = true;
|
|
177
|
+
if (presentation === 'inline' && containsFocus()) {
|
|
178
|
+
const element = props.returnFocus();
|
|
179
|
+
if (element) element.focus();
|
|
180
|
+
}
|
|
181
|
+
setOpen(false);
|
|
182
|
+
props.onClose();
|
|
92
183
|
};
|
|
93
|
-
const closeLabel =
|
|
94
|
-
const
|
|
184
|
+
const closeLabel = props.closeLabel ?? t('credentialDisclosure.close');
|
|
185
|
+
const title = props.title ?? t('credentialDisclosure.title');
|
|
186
|
+
const description = props.description ?? t('credentialDisclosure.description');
|
|
187
|
+
const secretLabel = props.secretLabel ?? t('credentialDisclosure.secret');
|
|
188
|
+
const warning =
|
|
189
|
+
disclosure.variant === 'fresh' ? (
|
|
190
|
+
<Alert
|
|
191
|
+
role={presentation === 'inline' ? 'note' : 'alert'}
|
|
192
|
+
tone="warning"
|
|
193
|
+
title={props.warningTitle ?? t('credentialDisclosure.warningTitle')}
|
|
194
|
+
description={props.warningText ?? t('credentialDisclosure.warningText')}
|
|
195
|
+
/>
|
|
196
|
+
) : null;
|
|
197
|
+
const body = (
|
|
198
|
+
<>
|
|
199
|
+
<dl className="grid grid-cols-2 gap-2 text-sm">
|
|
200
|
+
<dt className="text-muted-foreground">{props.credentialIdLabel ?? t('credentialDisclosure.credentialId')}</dt>
|
|
201
|
+
<dd className="break-all font-mono">{disclosure.credentialId}</dd>
|
|
202
|
+
<dt className="text-muted-foreground">{props.expiryLabel ?? t('credentialDisclosure.expiry')}</dt>
|
|
203
|
+
<dd>{disclosure.expiresAt}</dd>
|
|
204
|
+
</dl>
|
|
205
|
+
<SecretCopy
|
|
206
|
+
secret={{ id: 'secret', label: secretLabel, value: disclosure.secret }}
|
|
207
|
+
options={props}
|
|
208
|
+
grouped={additionalSecrets.length > 0}
|
|
209
|
+
masked={disclosure.variant === 'stored' || maskFresh}
|
|
210
|
+
warning={warning}
|
|
211
|
+
/>
|
|
212
|
+
{additionalSecrets.map((secret) => (
|
|
213
|
+
<SecretCopy key={secret.id} secret={secret} options={props} grouped />
|
|
214
|
+
))}
|
|
215
|
+
{props.revealContent != null && <div data-kit-redact>{props.revealContent}</div>}
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
if (presentation === 'inline') {
|
|
219
|
+
if (!open) return null;
|
|
220
|
+
return (
|
|
221
|
+
<section ref={onRootRef} aria-labelledby={headingId} className="grid gap-4">
|
|
222
|
+
<h2 id={headingId} tabIndex={-1} ref={onTargetRef}>
|
|
223
|
+
{title}
|
|
224
|
+
</h2>
|
|
225
|
+
<p>{description}</p>
|
|
226
|
+
{body}
|
|
227
|
+
<Button variant="secondary" onClick={close}>
|
|
228
|
+
{closeLabel}
|
|
229
|
+
</Button>
|
|
230
|
+
</section>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
95
233
|
return (
|
|
96
234
|
<Dialog
|
|
97
235
|
open={open}
|
|
98
236
|
onOpenChange={(next) => {
|
|
99
|
-
if (!next
|
|
100
|
-
dismissed.current = true;
|
|
101
|
-
setOpen(false);
|
|
102
|
-
onClose();
|
|
103
|
-
}
|
|
237
|
+
if (!next) close();
|
|
104
238
|
}}
|
|
105
239
|
>
|
|
106
240
|
<DialogContent
|
|
107
|
-
ref={
|
|
108
|
-
initialFocus={
|
|
109
|
-
finalFocus={returnFocus}
|
|
241
|
+
ref={onPopupRef}
|
|
242
|
+
initialFocus={initialFocus}
|
|
243
|
+
finalFocus={() => (dismissed.current ? props.returnFocus() : false)}
|
|
110
244
|
showClose={false}
|
|
111
245
|
closeLabel={closeLabel}
|
|
112
246
|
>
|
|
113
247
|
{open && (
|
|
114
248
|
<>
|
|
115
249
|
<DialogHeader>
|
|
116
|
-
<DialogTitle>{
|
|
117
|
-
<DialogDescription>{
|
|
250
|
+
<DialogTitle>{title}</DialogTitle>
|
|
251
|
+
<DialogDescription>{description}</DialogDescription>
|
|
118
252
|
</DialogHeader>
|
|
119
|
-
<DialogBody className="grid gap-4">
|
|
120
|
-
<dl className="grid grid-cols-2 gap-2 text-sm">
|
|
121
|
-
<dt className="text-muted-foreground">
|
|
122
|
-
{labels.credentialIdLabel ?? t('credentialDisclosure.credentialId')}
|
|
123
|
-
</dt>
|
|
124
|
-
<dd className="break-all font-mono">{disclosure.credentialId}</dd>
|
|
125
|
-
<dt className="text-muted-foreground">{labels.expiryLabel ?? t('credentialDisclosure.expiry')}</dt>
|
|
126
|
-
<dd>{disclosure.expiresAt}</dd>
|
|
127
|
-
</dl>
|
|
128
|
-
<LiveAnnouncer>
|
|
129
|
-
{disclosure.variant === 'stored' ? (
|
|
130
|
-
<SecretField
|
|
131
|
-
value={disclosure.secret}
|
|
132
|
-
copyable={false}
|
|
133
|
-
aria-label={secretLabel}
|
|
134
|
-
showLabel={labels.showLabel ?? t('credentialDisclosure.show')}
|
|
135
|
-
hideLabel={labels.hideLabel ?? t('credentialDisclosure.hide')}
|
|
136
|
-
/>
|
|
137
|
-
) : (
|
|
138
|
-
<>
|
|
139
|
-
<Alert
|
|
140
|
-
role="alert"
|
|
141
|
-
tone="warning"
|
|
142
|
-
title={labels.warningTitle ?? t('credentialDisclosure.warningTitle')}
|
|
143
|
-
description={labels.warningText ?? t('credentialDisclosure.warningText')}
|
|
144
|
-
/>
|
|
145
|
-
<CodeBlock
|
|
146
|
-
code={disclosure.secret}
|
|
147
|
-
copyable={false}
|
|
148
|
-
label={secretLabel}
|
|
149
|
-
wrapToggle={false}
|
|
150
|
-
defaultWrap
|
|
151
|
-
/>
|
|
152
|
-
</>
|
|
153
|
-
)}
|
|
154
|
-
<CopyButton
|
|
155
|
-
value={() => disclosure.secret}
|
|
156
|
-
display="label"
|
|
157
|
-
label={labels.copyLabel ?? t('credentialDisclosure.copy')}
|
|
158
|
-
copiedLabel={labels.copiedLabel ?? t('credentialDisclosure.copied')}
|
|
159
|
-
announcement={labels.successAnnouncement ?? t('credentialDisclosure.success')}
|
|
160
|
-
failureLabel={labels.failureAnnouncement ?? t('credentialDisclosure.failure')}
|
|
161
|
-
failureAnnouncement={labels.failureAnnouncement ?? t('credentialDisclosure.failure')}
|
|
162
|
-
failurePoliteness="polite"
|
|
163
|
-
/>
|
|
164
|
-
</LiveAnnouncer>
|
|
165
|
-
</DialogBody>
|
|
253
|
+
<DialogBody className="grid gap-4">{body}</DialogBody>
|
|
166
254
|
<DialogFooter>
|
|
167
255
|
<DialogClose render={<Button variant="secondary" />}>{closeLabel}</DialogClose>
|
|
168
256
|
</DialogFooter>
|
|
@@ -172,22 +260,3 @@ function DisclosureSession({
|
|
|
172
260
|
</Dialog>
|
|
173
261
|
);
|
|
174
262
|
}
|
|
175
|
-
|
|
176
|
-
/** Programmatic focus permits negative tabIndex, but never hidden, inert or disabled targets. */
|
|
177
|
-
function usableFocusTarget(element: Element | null | undefined): element is HTMLElement {
|
|
178
|
-
if (!(element instanceof HTMLElement) || !element.isConnected || element === document.body) return false;
|
|
179
|
-
if (element.matches(':disabled, [disabled], [aria-disabled="true"], input[type="hidden"]')) return false;
|
|
180
|
-
for (let ancestor: HTMLElement | null = element; ancestor; ancestor = ancestor.parentElement) {
|
|
181
|
-
if (ancestor.hidden || ancestor.hasAttribute('inert') || ancestor.getAttribute('aria-disabled') === 'true')
|
|
182
|
-
return false;
|
|
183
|
-
const style = getComputedStyle(ancestor);
|
|
184
|
-
if (
|
|
185
|
-
style.display === 'none' ||
|
|
186
|
-
style.visibility === 'hidden' ||
|
|
187
|
-
style.visibility === 'collapse' ||
|
|
188
|
-
style.contentVisibility === 'hidden'
|
|
189
|
-
)
|
|
190
|
-
return false;
|
|
191
|
-
}
|
|
192
|
-
return element.tabIndex >= 0 || element.hasAttribute('tabindex') || element.isContentEditable;
|
|
193
|
-
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import IssueOnClick from './examples/IssueOnClick';
|
|
4
|
+
import { freshDisclosure } from './fixtures';
|
|
5
|
+
import type { CredentialDisclosure } from './CredentialDisclosureDialog';
|
|
6
|
+
|
|
7
|
+
it.each(['cancel', 'supersede'] as const)('ignores a deferred issuance after %s', async (action) => {
|
|
8
|
+
const user = userEvent.setup();
|
|
9
|
+
const results: ((value: CredentialDisclosure) => void)[] = [];
|
|
10
|
+
const signals: AbortSignal[] = [];
|
|
11
|
+
const request = vi.fn((signal: AbortSignal) => {
|
|
12
|
+
signals.push(signal);
|
|
13
|
+
return new Promise<CredentialDisclosure>((resolve) => results.push(resolve));
|
|
14
|
+
});
|
|
15
|
+
render(<IssueOnClick request={request} />);
|
|
16
|
+
await user.click(screen.getByRole('button', { name: 'Issue credential' }));
|
|
17
|
+
await user.click(screen.getByRole('button', { name: action === 'cancel' ? 'Cancel request' : 'Supersede request' }));
|
|
18
|
+
expect(signals[0]!.aborted).toBe(true);
|
|
19
|
+
await act(async () => results[0]!(freshDisclosure));
|
|
20
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
21
|
+
expect(document.body.textContent).not.toContain(freshDisclosure.secret);
|
|
22
|
+
if (action === 'supersede') {
|
|
23
|
+
await act(async () => results[1]!({ ...freshDisclosure, secret: 'synthetic_current_response' }));
|
|
24
|
+
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
|
25
|
+
await user.click(screen.getByRole('button', { name: 'Show secret' }));
|
|
26
|
+
expect(screen.getByRole('textbox')).toHaveValue('synthetic_current_response');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('cancels the pending request on unmount before its deferred response settles', async () => {
|
|
31
|
+
const user = userEvent.setup();
|
|
32
|
+
const onAbort = vi.fn();
|
|
33
|
+
let settle!: (value: CredentialDisclosure) => void;
|
|
34
|
+
const request = vi.fn((signal: AbortSignal) => {
|
|
35
|
+
signal.addEventListener('abort', onAbort);
|
|
36
|
+
return new Promise<CredentialDisclosure>((resolve) => {
|
|
37
|
+
settle = resolve;
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
const { unmount } = render(<IssueOnClick request={request} />);
|
|
41
|
+
await user.click(screen.getByRole('button', { name: 'Issue credential' }));
|
|
42
|
+
expect(onAbort).not.toHaveBeenCalled();
|
|
43
|
+
unmount();
|
|
44
|
+
expect(onAbort).toHaveBeenCalledOnce();
|
|
45
|
+
expect(request.mock.calls[0]![0].aborted).toBe(true);
|
|
46
|
+
await act(async () => settle(freshDisclosure));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('keeps disclosure null on failure and shows only a caller-owned error', async () => {
|
|
50
|
+
const user = userEvent.setup();
|
|
51
|
+
render(<IssueOnClick request={() => Promise.reject(new Error('409 DB_SHARED_MODE synthetic_not_for_display'))} />);
|
|
52
|
+
await user.click(screen.getByRole('button', { name: 'Issue credential' }));
|
|
53
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('No credential was disclosed.');
|
|
54
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
55
|
+
expect(document.body.textContent).not.toContain('DB_SHARED_MODE');
|
|
56
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { CodeBlock, CopyButton, LiveAnnouncer, SecretField, useI18n } from '@gntik-ai/ui';
|
|
2
|
+
import { useState, type ReactNode } from 'react';
|
|
3
|
+
import type { CredentialSecret } from './types';
|
|
4
|
+
|
|
5
|
+
interface SecretCopyOptions {
|
|
6
|
+
showLabel?: string;
|
|
7
|
+
hideLabel?: string;
|
|
8
|
+
copyLabel?: string;
|
|
9
|
+
copiedLabel?: string;
|
|
10
|
+
successAnnouncement?: string;
|
|
11
|
+
failureAnnouncement?: string;
|
|
12
|
+
onCopy?: (id: string) => void;
|
|
13
|
+
copiedAnnouncementFor?: (label: string) => string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Each secret owns feedback so simultaneous settlements cannot overwrite each other. */
|
|
17
|
+
export function SecretCopy({
|
|
18
|
+
secret,
|
|
19
|
+
options,
|
|
20
|
+
grouped = false,
|
|
21
|
+
masked = true,
|
|
22
|
+
warning,
|
|
23
|
+
}: {
|
|
24
|
+
secret: CredentialSecret;
|
|
25
|
+
options: SecretCopyOptions;
|
|
26
|
+
grouped?: boolean;
|
|
27
|
+
masked?: boolean;
|
|
28
|
+
warning?: ReactNode;
|
|
29
|
+
}) {
|
|
30
|
+
const { t } = useI18n();
|
|
31
|
+
const [pending, setPending] = useState(false);
|
|
32
|
+
const copied = options.copiedLabel ?? t('credentialDisclosure.copied');
|
|
33
|
+
const content = (
|
|
34
|
+
<LiveAnnouncer>
|
|
35
|
+
{warning}
|
|
36
|
+
<div data-kit-redact>
|
|
37
|
+
{masked ? (
|
|
38
|
+
<SecretField
|
|
39
|
+
value={secret.value}
|
|
40
|
+
copyable={false}
|
|
41
|
+
aria-label={secret.label}
|
|
42
|
+
showLabel={options.showLabel ?? t('credentialDisclosure.show')}
|
|
43
|
+
hideLabel={options.hideLabel ?? t('credentialDisclosure.hide')}
|
|
44
|
+
/>
|
|
45
|
+
) : (
|
|
46
|
+
<CodeBlock code={secret.value} copyable={false} label={secret.label} wrapToggle={false} defaultWrap />
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
<CopyButton
|
|
50
|
+
value={() => {
|
|
51
|
+
if (grouped) setPending(true);
|
|
52
|
+
return secret.value;
|
|
53
|
+
}}
|
|
54
|
+
loading={grouped && pending}
|
|
55
|
+
display="label"
|
|
56
|
+
label={options.copyLabel ?? t('credentialDisclosure.copy')}
|
|
57
|
+
copiedLabel={copied}
|
|
58
|
+
announcement={
|
|
59
|
+
grouped
|
|
60
|
+
? (options.copiedAnnouncementFor?.(secret.label) ?? `${secret.label}: ${copied}`)
|
|
61
|
+
: (options.successAnnouncement ?? t('credentialDisclosure.success'))
|
|
62
|
+
}
|
|
63
|
+
failureLabel={options.failureAnnouncement ?? t('credentialDisclosure.failure')}
|
|
64
|
+
failureAnnouncement={options.failureAnnouncement ?? t('credentialDisclosure.failure')}
|
|
65
|
+
failurePoliteness="polite"
|
|
66
|
+
onCopy={() => {
|
|
67
|
+
if (grouped) setPending(false);
|
|
68
|
+
options.onCopy?.(secret.id);
|
|
69
|
+
}}
|
|
70
|
+
onCopyError={() => {
|
|
71
|
+
if (grouped) setPending(false);
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
</LiveAnnouncer>
|
|
75
|
+
);
|
|
76
|
+
return grouped ? (
|
|
77
|
+
<div role="group" aria-label={secret.label}>
|
|
78
|
+
{content}
|
|
79
|
+
</div>
|
|
80
|
+
) : (
|
|
81
|
+
content
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -5,6 +5,6 @@ export const meta: BlockMeta = {
|
|
|
5
5
|
family: 'forms',
|
|
6
6
|
status: 'beta',
|
|
7
7
|
description:
|
|
8
|
-
'Controlled disclosure for an existing credential. Set disclosure ({variant: stored | fresh, credentialId, expiresAt: caller-formatted ReactNode, secret}) only after a successful response; null renders nothing while pending or on failure. Clear disclosure
|
|
8
|
+
'Controlled disclosure for an existing credential. Set disclosure ({variant: stored | fresh, credentialId, expiresAt: caller-formatted ReactNode, secret}) only after a successful response; null renders nothing while pending or on failure. Clear disclosure, additionalSecrets and secret-bearing revealContent in onClose to drop every secret; a fresh secret requires a new successful response before showing it again. No pending/error state or internal secret retention. Stored values start masked; each null-to-value opening or changed credentialId/secret resets masking and copy feedback. Fresh values use a non-copyable CodeBlock with a kit CopyButton and a replacement warning, or a masked SecretField when maskFresh is set at session start. Optional additionalSecrets ({id, label, value}) are always masked and use independent toggles and serialized copy actions; ids must be non-empty, unique and not secret, with the first duplicate winning. Multi-secret groups announce label plus copiedLabel, or copiedAnnouncementFor(label); onCopy(id) never receives a value. Every secret and the optional revealContent slot have data-kit-redact. The session key includes additional ids and values. presentation is dialog by default or inline, fixed until the next opening: inline is a named section, never traps focus or handles Escape, and its warning is a non-live note. Surviving focus stays; removing the focused slot or session control focuses the popup or inline heading. Focused inline unmounts return focus through the same resolvers. Dismissed retained props stay hidden; clear and re-set or change a session value to reopen. Optional English defaults through en/es i18n for title, description, credentialIdLabel, expiryLabel, secretLabel, showLabel, hideLabel, copyLabel, copiedLabel, successAnnouncement, failureAnnouncement, warningTitle, warningText and closeLabel. Copy results are announced politely, including manual-selection fallback. Popup initial focus and focus trapping use Base UI; at close, resolveReturnFocus() is validated for connection, visibility, enabled state and focusability, then fallbackFocus(), then the original opener. Synthetic fixtures only; the default Stored preview is masked and the Fresh preview starts closed.',
|
|
9
9
|
uses: ['Dialog', 'SecretField', 'CodeBlock', 'CopyButton', 'Alert', 'LiveAnnouncer', 'Button'],
|
|
10
10
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { CredentialDisclosureDialog } from '../CredentialDisclosureDialog';
|
|
3
|
+
import { freshDisclosure } from '../fixtures';
|
|
4
|
+
|
|
5
|
+
export default function Inline() {
|
|
6
|
+
const [open, setOpen] = useState(true);
|
|
7
|
+
if (!open) return null;
|
|
8
|
+
return (
|
|
9
|
+
<CredentialDisclosureDialog
|
|
10
|
+
disclosure={freshDisclosure}
|
|
11
|
+
presentation="inline"
|
|
12
|
+
maskFresh
|
|
13
|
+
onClose={() => setOpen(false)}
|
|
14
|
+
title="Connection credentials"
|
|
15
|
+
secretLabel="DSN"
|
|
16
|
+
additionalSecrets={[{ id: 'password', label: 'Password', value: 'synthetic_password_for_demo_only' }]}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Alert, Button } from '@gntik-ai/ui';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { CredentialDisclosureDialog, type CredentialDisclosure } from '../CredentialDisclosureDialog';
|
|
4
|
+
import { freshDisclosure } from '../fixtures';
|
|
5
|
+
|
|
6
|
+
const syntheticRequest = async () => ({ ...freshDisclosure });
|
|
7
|
+
|
|
8
|
+
/** Page recipe: the caller owns issuance, cancellation and errors. Fixtures are synthetic. */
|
|
9
|
+
export default function IssueOnClick({
|
|
10
|
+
request = syntheticRequest,
|
|
11
|
+
}: {
|
|
12
|
+
request?: (signal: AbortSignal) => Promise<CredentialDisclosure>;
|
|
13
|
+
}) {
|
|
14
|
+
const [disclosure, setDisclosure] = useState<CredentialDisclosure | null>(null);
|
|
15
|
+
const [pending, setPending] = useState(false);
|
|
16
|
+
const [failed, setFailed] = useState(false);
|
|
17
|
+
const current = useRef<AbortController | null>(null);
|
|
18
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
19
|
+
useEffect(
|
|
20
|
+
() => () => {
|
|
21
|
+
current.current?.abort();
|
|
22
|
+
},
|
|
23
|
+
[],
|
|
24
|
+
);
|
|
25
|
+
const issue = async () => {
|
|
26
|
+
current.current?.abort();
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
current.current = controller;
|
|
29
|
+
setDisclosure(null);
|
|
30
|
+
setFailed(false);
|
|
31
|
+
setPending(true);
|
|
32
|
+
try {
|
|
33
|
+
const response = await request(controller.signal);
|
|
34
|
+
if (!controller.signal.aborted) setDisclosure(response);
|
|
35
|
+
} catch {
|
|
36
|
+
if (!controller.signal.aborted) setFailed(true);
|
|
37
|
+
} finally {
|
|
38
|
+
if (!controller.signal.aborted) {
|
|
39
|
+
current.current = null;
|
|
40
|
+
setPending(false);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
return (
|
|
45
|
+
<>
|
|
46
|
+
<h1>Issue a credential</h1>
|
|
47
|
+
<div className="flex flex-wrap gap-2">
|
|
48
|
+
<Button
|
|
49
|
+
ref={trigger}
|
|
50
|
+
loading={pending}
|
|
51
|
+
onClick={() => {
|
|
52
|
+
void issue();
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
Issue credential
|
|
56
|
+
</Button>
|
|
57
|
+
{pending && (
|
|
58
|
+
<>
|
|
59
|
+
<Button
|
|
60
|
+
onClick={() => {
|
|
61
|
+
current.current?.abort();
|
|
62
|
+
current.current = null;
|
|
63
|
+
setPending(false);
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
Cancel request
|
|
67
|
+
</Button>
|
|
68
|
+
<Button
|
|
69
|
+
onClick={() => {
|
|
70
|
+
void issue();
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
Supersede request
|
|
74
|
+
</Button>
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
{failed && <Alert tone="destructive" title="Request failed" description="No credential was disclosed." />}
|
|
79
|
+
<CredentialDisclosureDialog
|
|
80
|
+
disclosure={disclosure}
|
|
81
|
+
maskFresh
|
|
82
|
+
onClose={() => setDisclosure(null)}
|
|
83
|
+
resolveReturnFocus={() => trigger.current}
|
|
84
|
+
/>
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { CodeBlock } from '@gntik-ai/ui';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { CredentialDisclosureDialog } from '../CredentialDisclosureDialog';
|
|
4
|
+
import { freshDisclosure } from '../fixtures';
|
|
5
|
+
|
|
6
|
+
/** All values and snippets are synthetic; closing unmounts every secret-bearing prop. */
|
|
7
|
+
export default function MultiSecret() {
|
|
8
|
+
const [open, setOpen] = useState(true);
|
|
9
|
+
if (!open) return null;
|
|
10
|
+
return (
|
|
11
|
+
<CredentialDisclosureDialog
|
|
12
|
+
disclosure={freshDisclosure}
|
|
13
|
+
maskFresh
|
|
14
|
+
onClose={() => setOpen(false)}
|
|
15
|
+
secretLabel="Access key"
|
|
16
|
+
additionalSecrets={[
|
|
17
|
+
{ id: 'dsn', label: 'DSN', value: 'synthetic_dsn_for_demo_only' },
|
|
18
|
+
{ id: 'password', label: 'Password', value: 'synthetic_password_for_demo_only' },
|
|
19
|
+
]}
|
|
20
|
+
revealContent={
|
|
21
|
+
<CodeBlock
|
|
22
|
+
copyable={false}
|
|
23
|
+
label="Integration examples"
|
|
24
|
+
code={`fetch('/example', { headers: { Authorization: '${freshDisclosure.secret}' } })\ncurl -H 'Authorization: ${freshDisclosure.secret}' /example`}
|
|
25
|
+
/>
|
|
26
|
+
}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Programmatic focus permits negative tabIndex, but never hidden, inert or disabled targets. */
|
|
2
|
+
export function usableFocusTarget(element: Element | null | undefined): element is HTMLElement {
|
|
3
|
+
if (!(element instanceof HTMLElement) || !element.isConnected || element === document.body) return false;
|
|
4
|
+
if (element.matches(':disabled, [disabled], [aria-disabled="true"], input[type="hidden"]')) return false;
|
|
5
|
+
for (let ancestor: HTMLElement | null = element; ancestor; ancestor = ancestor.parentElement) {
|
|
6
|
+
if (ancestor.hidden || ancestor.hasAttribute('inert') || ancestor.getAttribute('aria-disabled') === 'true')
|
|
7
|
+
return false;
|
|
8
|
+
const style = getComputedStyle(ancestor);
|
|
9
|
+
if (
|
|
10
|
+
style.display === 'none' ||
|
|
11
|
+
style.visibility === 'hidden' ||
|
|
12
|
+
style.visibility === 'collapse' ||
|
|
13
|
+
style.contentVisibility === 'hidden'
|
|
14
|
+
)
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return element.tabIndex >= 0 || element.hasAttribute('tabindex') || element.isContentEditable;
|
|
18
|
+
}
|