@visns-studio/visns-components 6.23.0 → 6.23.2
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/package.json +1 -1
- package/src/components/DataGrid.jsx +8 -1
- package/src/components/JsonView.jsx +299 -0
- package/src/components/styles/JsonView.module.scss +233 -0
- package/src/components/styles/Vault.module.scss +68 -0
- package/src/components/styles/global-datagrid.css +14 -1
- package/src/components/vault/VaultEntryForm.jsx +239 -8
- package/src/components/vault/VaultManager.jsx +14 -2
- package/src/index.js +8 -0
package/package.json
CHANGED
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
94
94
|
},
|
|
95
95
|
"name": "@visns-studio/visns-components",
|
|
96
|
-
"version": "6.23.
|
|
96
|
+
"version": "6.23.2",
|
|
97
97
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
98
98
|
"main": "src/index.js",
|
|
99
99
|
"files": [
|
|
@@ -1431,7 +1431,14 @@ const DataGrid = forwardRef(
|
|
|
1431
1431
|
// A rowColours tint is an inline background on the row
|
|
1432
1432
|
// element; the class lets the stylesheet clear the zebra
|
|
1433
1433
|
// band that would otherwise paint over it on odd rows.
|
|
1434
|
-
|
|
1434
|
+
// The literal twin exists for global-datagrid.css, which
|
|
1435
|
+
// cannot see the hashed module class: the 2026-pass row
|
|
1436
|
+
// surface there is `!important` (it has to outrank the
|
|
1437
|
+
// vendored theme) and would beat the inline tint, so that
|
|
1438
|
+
// rule stands down on rows carrying this class.
|
|
1439
|
+
findRowColour(rowProps?.data)
|
|
1440
|
+
? `${styles.tintedRow} vs-datagrid--row-tinted`
|
|
1441
|
+
: '',
|
|
1435
1442
|
]
|
|
1436
1443
|
.filter(Boolean)
|
|
1437
1444
|
.join(' '),
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import styles from './styles/JsonView.module.scss';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where a string stops being a value and starts being a document.
|
|
7
|
+
*
|
|
8
|
+
* Exported because the truncation is observable — a caller that wants to know
|
|
9
|
+
* whether a field will be cut before it renders should ask the same number the
|
|
10
|
+
* component uses rather than restate it.
|
|
11
|
+
*/
|
|
12
|
+
export const STRING_PREVIEW_LIMIT = 200;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse text that MIGHT be JSON, and answer only for the shapes worth drawing.
|
|
16
|
+
*
|
|
17
|
+
* `JSON.parse` is happy with `42`, `"a note"` and `true` — all three are valid
|
|
18
|
+
* JSON documents by the letter of the spec, and all three are also exactly what
|
|
19
|
+
* an ordinary hand-typed note looks like. Rendering "a note" as a one-row tree
|
|
20
|
+
* with a quoted string in it is worse than rendering the note, so a bare
|
|
21
|
+
* primitive is deliberately NOT JSON for this purpose: only an object or an
|
|
22
|
+
* array earns the tree.
|
|
23
|
+
*
|
|
24
|
+
* Returns the parsed value, or `null` for anything else — including the empty
|
|
25
|
+
* string, which parses as nothing at all.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} text Raw text, leading and trailing space included.
|
|
28
|
+
* @returns {object|Array|null}
|
|
29
|
+
*/
|
|
30
|
+
export const parseJsonDocument = (text) => {
|
|
31
|
+
const trimmed = String(text ?? '').trim();
|
|
32
|
+
|
|
33
|
+
// Cheap gate before the parse. A 32KB note that is plainly prose should
|
|
34
|
+
// not cost a parse on every keystroke of the field above it.
|
|
35
|
+
if (trimmed === '' || !/^[{[]/.test(trimmed)) return null;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(trimmed);
|
|
39
|
+
|
|
40
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : null;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// Not JSON, which is an answer rather than a failure.
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** An object or an array — the two things that get a collapse toggle. */
|
|
48
|
+
const isContainer = (value) => value !== null && typeof value === 'object';
|
|
49
|
+
|
|
50
|
+
const childEntries = (value) =>
|
|
51
|
+
Array.isArray(value)
|
|
52
|
+
? value.map((item, index) => [index, item])
|
|
53
|
+
: Object.entries(value);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What a collapsed node says about itself.
|
|
57
|
+
*
|
|
58
|
+
* The count is the whole point: `{…}` alone tells the reader nothing about
|
|
59
|
+
* whether opening it is worth the scroll, and `[…] 129 items` tells them
|
|
60
|
+
* everything.
|
|
61
|
+
*/
|
|
62
|
+
const summarise = (value) => {
|
|
63
|
+
const count = Array.isArray(value) ? value.length : Object.keys(value).length;
|
|
64
|
+
const noun = Array.isArray(value) ? 'item' : 'key';
|
|
65
|
+
|
|
66
|
+
return `${Array.isArray(value) ? '[…]' : '{…}'} ${count} ${noun}${
|
|
67
|
+
count === 1 ? '' : 's'
|
|
68
|
+
}`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** `{}` / `[]`, for a container with nothing in it to expand. */
|
|
72
|
+
const emptyLiteral = (value) => (Array.isArray(value) ? '[]' : '{}');
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A string value, which is the only primitive that can be too big to print.
|
|
76
|
+
*
|
|
77
|
+
* TWO reasons a string gets a toggle, and they are the same reason:
|
|
78
|
+
*
|
|
79
|
+
* It is long. 200 characters is about a line and a half here; past that the
|
|
80
|
+
* value stops being something you read in passing and starts pushing the rest
|
|
81
|
+
* of the document off the screen.
|
|
82
|
+
*
|
|
83
|
+
* It has newlines in it. This is the case that matters for the vault: a
|
|
84
|
+
* credentials backup keeps the actual credential in a `content` field with
|
|
85
|
+
* embedded line breaks, so squashing it onto one line destroys the only
|
|
86
|
+
* structure it has. A multi-line string is therefore treated as long even
|
|
87
|
+
* when it is short, and the expanded form is `pre-wrap`.
|
|
88
|
+
*
|
|
89
|
+
* The collapsed form is deliberately one line — first line, cut to the limit —
|
|
90
|
+
* because a preview that reflows is not a preview.
|
|
91
|
+
*/
|
|
92
|
+
const JsonString = ({ value }) => {
|
|
93
|
+
const [open, setOpen] = useState(false);
|
|
94
|
+
|
|
95
|
+
const multiline = value.includes('\n');
|
|
96
|
+
const long = value.length > STRING_PREVIEW_LIMIT;
|
|
97
|
+
|
|
98
|
+
if (!multiline && !long) {
|
|
99
|
+
return <span className={styles.string}>"{value}"</span>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (open) {
|
|
103
|
+
return (
|
|
104
|
+
<span className={styles.valueCell}>
|
|
105
|
+
<span className={`${styles.string} ${styles.stringOpen}`}>
|
|
106
|
+
"{value}"
|
|
107
|
+
</span>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
className={styles.more}
|
|
111
|
+
onClick={() => setOpen(false)}
|
|
112
|
+
>
|
|
113
|
+
show less
|
|
114
|
+
</button>
|
|
115
|
+
</span>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const firstLine = value.split('\n', 1)[0];
|
|
120
|
+
const preview =
|
|
121
|
+
firstLine.length > STRING_PREVIEW_LIMIT
|
|
122
|
+
? firstLine.slice(0, STRING_PREVIEW_LIMIT)
|
|
123
|
+
: firstLine;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<span className={styles.valueCell}>
|
|
127
|
+
<span className={styles.string}>"{preview}…"</span>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
className={styles.more}
|
|
131
|
+
onClick={() => setOpen(true)}
|
|
132
|
+
title={
|
|
133
|
+
multiline
|
|
134
|
+
? `${value.split('\n').length} lines, ${value.length} characters`
|
|
135
|
+
: `${value.length} characters`
|
|
136
|
+
}
|
|
137
|
+
>
|
|
138
|
+
show all
|
|
139
|
+
</button>
|
|
140
|
+
</span>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Everything that is not a container: tinted by type, so the type is readable. */
|
|
145
|
+
const JsonPrimitive = ({ value }) => {
|
|
146
|
+
if (value === null) return <span className={styles.null}>null</span>;
|
|
147
|
+
|
|
148
|
+
switch (typeof value) {
|
|
149
|
+
case 'string':
|
|
150
|
+
return <JsonString value={value} />;
|
|
151
|
+
case 'number':
|
|
152
|
+
// `String()` rather than the raw number so NaN and Infinity print
|
|
153
|
+
// as themselves: neither can come out of JSON.parse, but this
|
|
154
|
+
// component takes an already-parsed value from anywhere.
|
|
155
|
+
return <span className={styles.number}>{String(value)}</span>;
|
|
156
|
+
case 'boolean':
|
|
157
|
+
return <span className={styles.boolean}>{value ? 'true' : 'false'}</span>;
|
|
158
|
+
case 'undefined':
|
|
159
|
+
return <span className={styles.null}>undefined</span>;
|
|
160
|
+
default:
|
|
161
|
+
// A function or a symbol, which only a hand-built value can carry.
|
|
162
|
+
return <span className={styles.null}>{String(value)}</span>;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One row: a key, and either a value or a subtree behind a toggle.
|
|
168
|
+
*
|
|
169
|
+
* The toggle is a real `<button>` wrapping the caret, the key AND the summary,
|
|
170
|
+
* so the hit target is the whole line and there is one tab stop per node rather
|
|
171
|
+
* than one per glyph. There is no `role="tree"` here on purpose: that role
|
|
172
|
+
* promises arrow-key navigation, and a tree that does not answer arrow keys is
|
|
173
|
+
* worse for a screen reader than the plain nested list this actually is.
|
|
174
|
+
*/
|
|
175
|
+
const JsonNode = ({ label, isIndex, value, depth, collapsedDepth }) => {
|
|
176
|
+
const container = isContainer(value);
|
|
177
|
+
const entries = useMemo(
|
|
178
|
+
() => (container ? childEntries(value) : []),
|
|
179
|
+
[container, value]
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// The root is depth 0, so `collapsedDepth: 2` opens the root and its
|
|
183
|
+
// children and collapses everything from the grandchildren down. On the
|
|
184
|
+
// vault's backup payload that is exactly right: the document and its
|
|
185
|
+
// `credentials` array are open, and the 129 credentials inside it are 129
|
|
186
|
+
// one-line summaries rather than 129 open records.
|
|
187
|
+
const [open, setOpen] = useState(depth < collapsedDepth);
|
|
188
|
+
|
|
189
|
+
const key = label === null ? null : (
|
|
190
|
+
<span className={isIndex ? styles.index : styles.key}>
|
|
191
|
+
{String(label)}
|
|
192
|
+
<span className={styles.colon}>:</span>
|
|
193
|
+
</span>
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (!container || entries.length === 0) {
|
|
197
|
+
return (
|
|
198
|
+
<li className={styles.row}>
|
|
199
|
+
<div className={styles.line}>
|
|
200
|
+
<span className={styles.spacer} aria-hidden="true" />
|
|
201
|
+
{key}
|
|
202
|
+
{container ? (
|
|
203
|
+
<span className={styles.summary}>{emptyLiteral(value)}</span>
|
|
204
|
+
) : (
|
|
205
|
+
<JsonPrimitive value={value} />
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
</li>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<li className={styles.row}>
|
|
214
|
+
<div className={styles.line}>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
className={styles.toggle}
|
|
218
|
+
aria-expanded={open}
|
|
219
|
+
onClick={() => setOpen((v) => !v)}
|
|
220
|
+
>
|
|
221
|
+
<span className={styles.caret} aria-hidden="true">
|
|
222
|
+
{open ? '▾' : '▸'}
|
|
223
|
+
</span>
|
|
224
|
+
{key}
|
|
225
|
+
<span className={styles.summary}>
|
|
226
|
+
{open ? (Array.isArray(value) ? '[' : '{') : summarise(value)}
|
|
227
|
+
</span>
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{open && (
|
|
232
|
+
<>
|
|
233
|
+
<ul className={`${styles.list} ${styles.children}`}>
|
|
234
|
+
{entries.map(([childLabel, childValue]) => (
|
|
235
|
+
<JsonNode
|
|
236
|
+
key={String(childLabel)}
|
|
237
|
+
label={childLabel}
|
|
238
|
+
isIndex={Array.isArray(value)}
|
|
239
|
+
value={childValue}
|
|
240
|
+
depth={depth + 1}
|
|
241
|
+
collapsedDepth={collapsedDepth}
|
|
242
|
+
/>
|
|
243
|
+
))}
|
|
244
|
+
</ul>
|
|
245
|
+
{/* The closing bracket, so an open node reads as a pair
|
|
246
|
+
rather than as a bracket somebody forgot to shut. */}
|
|
247
|
+
<div className={styles.line}>
|
|
248
|
+
<span className={styles.spacer} aria-hidden="true" />
|
|
249
|
+
<span className={styles.summary}>
|
|
250
|
+
{Array.isArray(value) ? ']' : '}'}
|
|
251
|
+
</span>
|
|
252
|
+
</div>
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
</li>
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A parsed JSON value, as something a person can actually read.
|
|
261
|
+
*
|
|
262
|
+
* Written for the vault's notes, where `zoho:credentials-backup` leaves a
|
|
263
|
+
* 32KB machine-written blob — `{source, account_id, …, credentials: […]}` — in
|
|
264
|
+
* a field whose only previous rendering was a three-row textarea. Nothing in
|
|
265
|
+
* here knows that, and nothing should: it takes a value and draws it.
|
|
266
|
+
*
|
|
267
|
+
* READ-ONLY, and that is the design rather than an omission. Editing JSON as a
|
|
268
|
+
* tree means deciding what happens when a key is renamed to one that already
|
|
269
|
+
* exists, what a number that is being typed means halfway through, and what to
|
|
270
|
+
* do with a value the user has made syntactically impossible. The consumer
|
|
271
|
+
* keeps the raw text as the single source of truth and toggles between the two;
|
|
272
|
+
* see VaultEntryForm, where the notes VALUE is always the raw string and this
|
|
273
|
+
* view is presentation only.
|
|
274
|
+
*
|
|
275
|
+
* The value is passed ALREADY PARSED. A component that took text would have to
|
|
276
|
+
* decide what a parse failure looks like on screen, and the answer is always
|
|
277
|
+
* "show the text instead", which is a decision belonging to the screen and not
|
|
278
|
+
* to the viewer. `parseJsonDocument` above is the shared way to make that call.
|
|
279
|
+
*
|
|
280
|
+
* @param {*} value Any parsed JS value; objects and arrays get a tree.
|
|
281
|
+
* @param {number} collapsedDepth Nodes at this depth or deeper start collapsed.
|
|
282
|
+
* The root is depth 0. Default 2.
|
|
283
|
+
* @param {string} className Added to the root, for sizing by the caller.
|
|
284
|
+
*/
|
|
285
|
+
const JsonView = ({ value, collapsedDepth = 2, className, ...rest }) => (
|
|
286
|
+
<div className={[styles.root, className].filter(Boolean).join(' ')} {...rest}>
|
|
287
|
+
<ul className={styles.list}>
|
|
288
|
+
<JsonNode
|
|
289
|
+
label={null}
|
|
290
|
+
isIndex={false}
|
|
291
|
+
value={value}
|
|
292
|
+
depth={0}
|
|
293
|
+
collapsedDepth={collapsedDepth}
|
|
294
|
+
/>
|
|
295
|
+
</ul>
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
export default JsonView;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
@use 'surface' as *;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JsonView — a parsed JSON value drawn as a collapsible tree.
|
|
5
|
+
*
|
|
6
|
+
* The palette is the page-primitive one (`_surface.scss`): two levels, so a
|
|
7
|
+
* project that defines nothing gets something legible and a page that already
|
|
8
|
+
* has a palette sets `--vs-*` once on its own root. Nothing here carries a
|
|
9
|
+
* colour of its own except the four type tints, and even those are mixed
|
|
10
|
+
* against the app's body ink rather than declared outright — see below.
|
|
11
|
+
*
|
|
12
|
+
* Sizing is the CALLER's job. The root has no height, no scroll and no border:
|
|
13
|
+
* a tree inside a modal wants a capped scroll region, the same tree on a page
|
|
14
|
+
* wants to be as tall as it is, and a component that decides that itself is one
|
|
15
|
+
* the next screen has to fight.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
$jv-mono: 'SFMono-Regular', 'Menlo', 'Consolas', monospace;
|
|
19
|
+
|
|
20
|
+
.root {
|
|
21
|
+
@include surface-tokens;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The type tints, and why each is a MIX rather than a hex value.
|
|
25
|
+
*
|
|
26
|
+
* A JSON viewer needs four inks that are distinguishable from each other
|
|
27
|
+
* and readable on the page behind them — and this library does not know
|
|
28
|
+
* which page that is. A fixed `#1f8b4c` is a fine green on white and an
|
|
29
|
+
* unreadable one on a dark surface.
|
|
30
|
+
*
|
|
31
|
+
* Mixing the hue with the app's own body ink solves both directions at
|
|
32
|
+
* once: on a light theme the ink is near-black, so the hue darkens and
|
|
33
|
+
* gains contrast against white; on a dark theme the ink is near-white, so
|
|
34
|
+
* the SAME declaration lightens it against the dark ground. The hue stays
|
|
35
|
+
* recognisable either way because it is three quarters of the mix.
|
|
36
|
+
*/
|
|
37
|
+
--jv-key: var(--vs-json-key, color-mix(in srgb, var(--pr-heading) 80%, var(--pr-ink)));
|
|
38
|
+
--jv-string: var(--vs-json-string, color-mix(in srgb, #1f8b4c 74%, var(--pr-ink)));
|
|
39
|
+
--jv-number: var(--vs-json-number, color-mix(in srgb, #2f6fd0 74%, var(--pr-ink)));
|
|
40
|
+
--jv-boolean: var(
|
|
41
|
+
--vs-json-boolean,
|
|
42
|
+
color-mix(in srgb, #8a5cd0 74%, var(--pr-ink))
|
|
43
|
+
);
|
|
44
|
+
// The indent guide is a hairline that must read as structure and not as a
|
|
45
|
+
// border; the page's own line colour is too heavy repeated every level.
|
|
46
|
+
--jv-guide: var(--vs-json-guide, color-mix(in srgb, var(--pr-ink) 16%, transparent));
|
|
47
|
+
|
|
48
|
+
color: var(--pr-ink);
|
|
49
|
+
font-family: $jv-mono;
|
|
50
|
+
// Small and tight: the value of this view over a textarea is how much of
|
|
51
|
+
// the document fits on one screen.
|
|
52
|
+
font-size: 0.76rem;
|
|
53
|
+
line-height: 1.45;
|
|
54
|
+
text-align: left;
|
|
55
|
+
// A long unbroken token — a URL, a base64 blob — must wrap rather than
|
|
56
|
+
// push a horizontal scrollbar under the whole tree.
|
|
57
|
+
overflow-wrap: anywhere;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* The size is declared on the elements as well as the root: inherited
|
|
61
|
+
font-size loses to ANY rule that names the element directly, however
|
|
62
|
+
specific the ancestor — and this tree mounts inside host apps whose global
|
|
63
|
+
stylesheets do exactly that to li, span and button. Restating `inherit`
|
|
64
|
+
here puts a declaration on every element, outranking a host's bare
|
|
65
|
+
element rules (0-0-1 vs this 0-1-0). `:where()` keeps the inner selector
|
|
66
|
+
at zero so the file's own later, smaller sizes (`.more`) still win. */
|
|
67
|
+
.root :where(ul, li, span, button) {
|
|
68
|
+
font-family: inherit;
|
|
69
|
+
font-size: inherit;
|
|
70
|
+
line-height: inherit;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.list {
|
|
74
|
+
padding: 0;
|
|
75
|
+
margin: 0;
|
|
76
|
+
list-style: none;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Each level is indented by its own guide line, so the depth is readable
|
|
80
|
+
without counting spaces. */
|
|
81
|
+
.children {
|
|
82
|
+
padding-left: 0.7rem;
|
|
83
|
+
margin-left: 0.28rem;
|
|
84
|
+
border-left: 1px solid var(--jv-guide);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.row {
|
|
88
|
+
// The host CRM styles bare list items in places; nothing here inherits
|
|
89
|
+
// anything it needs. Same reasoning as Vault.module.scss's header.
|
|
90
|
+
padding: 0;
|
|
91
|
+
margin: 0;
|
|
92
|
+
list-style: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.line {
|
|
96
|
+
display: flex;
|
|
97
|
+
gap: 0.3rem;
|
|
98
|
+
align-items: flex-start;
|
|
99
|
+
min-width: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Sits under the caret of a node that has one, so keys line up whether or not
|
|
103
|
+
the row is expandable. */
|
|
104
|
+
.spacer {
|
|
105
|
+
flex: 0 0 0.85rem;
|
|
106
|
+
width: 0.85rem;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.toggle {
|
|
110
|
+
display: inline-flex;
|
|
111
|
+
gap: 0.3rem;
|
|
112
|
+
align-items: flex-start;
|
|
113
|
+
box-sizing: border-box;
|
|
114
|
+
width: auto;
|
|
115
|
+
min-width: 0;
|
|
116
|
+
height: auto;
|
|
117
|
+
min-height: 0;
|
|
118
|
+
padding: 0 0.15rem 0 0;
|
|
119
|
+
margin: 0;
|
|
120
|
+
color: inherit;
|
|
121
|
+
font: inherit;
|
|
122
|
+
text-align: left;
|
|
123
|
+
text-transform: none;
|
|
124
|
+
letter-spacing: normal;
|
|
125
|
+
background: none;
|
|
126
|
+
border: 0;
|
|
127
|
+
border-radius: var(--pr-radius, 6px);
|
|
128
|
+
box-shadow: none;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
appearance: none;
|
|
131
|
+
|
|
132
|
+
&:hover {
|
|
133
|
+
background: var(--pr-quiet);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
&:focus-visible {
|
|
137
|
+
outline: 2px solid var(--pr-accent);
|
|
138
|
+
outline-offset: 1px;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.caret {
|
|
143
|
+
flex: 0 0 0.85rem;
|
|
144
|
+
width: 0.85rem;
|
|
145
|
+
color: var(--pr-muted);
|
|
146
|
+
/* The two glyphs have different optical widths; a fixed box stops the key
|
|
147
|
+
beside them shifting by a pixel on every open and close. */
|
|
148
|
+
text-align: left;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.key {
|
|
152
|
+
color: var(--jv-key);
|
|
153
|
+
font-weight: 600;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* An array index is not a name, and painting it like one makes a long list look
|
|
157
|
+
like a hundred different keys. */
|
|
158
|
+
.index {
|
|
159
|
+
color: var(--pr-muted);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.colon {
|
|
163
|
+
color: var(--pr-muted);
|
|
164
|
+
font-weight: 400;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* `{…} 5 keys`, and the brackets of an open node. */
|
|
168
|
+
.summary {
|
|
169
|
+
color: var(--pr-muted);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.valueCell {
|
|
173
|
+
display: inline-flex;
|
|
174
|
+
flex-wrap: wrap;
|
|
175
|
+
gap: 0.35rem;
|
|
176
|
+
align-items: baseline;
|
|
177
|
+
min-width: 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.string {
|
|
181
|
+
color: var(--jv-string);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* The expanded form of a multi-line string. This is where the vault's
|
|
185
|
+
credentials actually live — a `content` field with embedded newlines — so the
|
|
186
|
+
line breaks are the content, not whitespace to be collapsed. */
|
|
187
|
+
.stringOpen {
|
|
188
|
+
display: block;
|
|
189
|
+
white-space: pre-wrap;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.number {
|
|
193
|
+
color: var(--jv-number);
|
|
194
|
+
font-variant-numeric: tabular-nums;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.boolean {
|
|
198
|
+
color: var(--jv-boolean);
|
|
199
|
+
font-weight: 600;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.null {
|
|
203
|
+
color: var(--pr-muted);
|
|
204
|
+
font-style: italic;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* "show all" / "show less" on a truncated value. A link rather than a chip: it
|
|
208
|
+
sits inside a line of text and must not become the loudest thing on it. */
|
|
209
|
+
.more {
|
|
210
|
+
box-sizing: border-box;
|
|
211
|
+
width: auto;
|
|
212
|
+
height: auto;
|
|
213
|
+
min-width: 0;
|
|
214
|
+
min-height: 0;
|
|
215
|
+
padding: 0;
|
|
216
|
+
margin: 0;
|
|
217
|
+
color: var(--pr-accent);
|
|
218
|
+
font: inherit;
|
|
219
|
+
font-size: 0.72rem;
|
|
220
|
+
text-decoration: underline;
|
|
221
|
+
text-transform: none;
|
|
222
|
+
letter-spacing: normal;
|
|
223
|
+
background: none;
|
|
224
|
+
border: 0;
|
|
225
|
+
box-shadow: none;
|
|
226
|
+
cursor: pointer;
|
|
227
|
+
appearance: none;
|
|
228
|
+
|
|
229
|
+
&:focus-visible {
|
|
230
|
+
outline: 2px solid var(--pr-accent);
|
|
231
|
+
outline-offset: 2px;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -1284,6 +1284,74 @@ input.checkControl {
|
|
|
1284
1284
|
resize: vertical;
|
|
1285
1285
|
}
|
|
1286
1286
|
|
|
1287
|
+
// ---------------------------------------------------------------------------
|
|
1288
|
+
// Notes: the Formatted / Raw toggle
|
|
1289
|
+
// ---------------------------------------------------------------------------
|
|
1290
|
+
// Notes holding a machine-written JSON backup are READ as a tree (JsonView) and
|
|
1291
|
+
// EDITED as text. Two chips rather than a switch, because a switch has to be
|
|
1292
|
+
// labelled with the state it is not in and there is no good way to word that
|
|
1293
|
+
// here; two chips name both and light the one you are looking at.
|
|
1294
|
+
|
|
1295
|
+
.viewToggle {
|
|
1296
|
+
display: inline-flex;
|
|
1297
|
+
gap: 0.25rem;
|
|
1298
|
+
align-self: flex-start;
|
|
1299
|
+
padding: 0.15rem;
|
|
1300
|
+
border: 1px solid var(--v-line);
|
|
1301
|
+
border-radius: var(--v-btn-br);
|
|
1302
|
+
background: var(--v-surface);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
.viewChip {
|
|
1306
|
+
@include vault-reset;
|
|
1307
|
+
@include vault-focus;
|
|
1308
|
+
|
|
1309
|
+
display: inline-flex;
|
|
1310
|
+
align-items: center;
|
|
1311
|
+
padding: 0.2rem 0.55rem;
|
|
1312
|
+
border: 1px solid transparent;
|
|
1313
|
+
border-radius: calc(var(--v-btn-br) - 1px);
|
|
1314
|
+
background: none;
|
|
1315
|
+
color: var(--v-muted);
|
|
1316
|
+
font-size: 0.72rem;
|
|
1317
|
+
font-weight: 600;
|
|
1318
|
+
letter-spacing: 0.02em;
|
|
1319
|
+
cursor: pointer;
|
|
1320
|
+
|
|
1321
|
+
&:hover:not(:disabled) {
|
|
1322
|
+
color: var(--v-primary);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Disabled here means "the JSON is broken, so there is nothing to lay
|
|
1326
|
+
// out"; the chip carries a title saying so. Dimmed rather than removed —
|
|
1327
|
+
// a control that disappears takes its own explanation with it.
|
|
1328
|
+
&:disabled {
|
|
1329
|
+
cursor: default;
|
|
1330
|
+
opacity: 0.45;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
.viewChipOn {
|
|
1335
|
+
border-color: var(--v-line);
|
|
1336
|
+
background: var(--v-background);
|
|
1337
|
+
color: var(--v-primary);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// The tree's container. JsonView declares no height of its own on purpose, so
|
|
1341
|
+
// the cap belongs here: 50vh keeps a 32KB backup scrolling inside the field
|
|
1342
|
+
// instead of pushing Save off the bottom of the dialog.
|
|
1343
|
+
.notesJson {
|
|
1344
|
+
@include vault-focus;
|
|
1345
|
+
|
|
1346
|
+
box-sizing: border-box;
|
|
1347
|
+
max-height: 50vh;
|
|
1348
|
+
padding: 0.5rem 0.6rem;
|
|
1349
|
+
overflow: auto;
|
|
1350
|
+
border: 1px solid var(--v-line);
|
|
1351
|
+
border-radius: var(--v-btn-br);
|
|
1352
|
+
background: var(--v-surface);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1287
1355
|
.help {
|
|
1288
1356
|
margin: 0;
|
|
1289
1357
|
color: var(--v-muted);
|
|
@@ -647,10 +647,23 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
|
|
|
647
647
|
.InovuaReactDataGrid__row {
|
|
648
648
|
min-height: 44px !important;
|
|
649
649
|
border: 0 !important;
|
|
650
|
-
background: var(--dgx-surface) !important;
|
|
651
650
|
transition: background 140ms cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
652
651
|
}
|
|
653
652
|
|
|
653
|
+
/* The surface is on its own rule so it can stand down for a `rowColours`
|
|
654
|
+
tint. That tint arrives as an INLINE background set by rowStyle in
|
|
655
|
+
DataGrid.jsx, and inline loses to any `!important` — so painting the
|
|
656
|
+
surface `!important` on every row silently killed the priority/urgency
|
|
657
|
+
colours projects configure (tasks grids and the like). Rows with a tint
|
|
658
|
+
are marked `vs-datagrid--row-tinted` (the literal twin of the module's
|
|
659
|
+
hashed `.tintedRow`); on those rows nothing is declared here and the
|
|
660
|
+
inline tint wins over the vendored theme's non-important zebra.
|
|
661
|
+
Hover and selected below stay `!important` on every row by design —
|
|
662
|
+
they have always outranked the tint. */
|
|
663
|
+
.InovuaReactDataGrid__row:not(.vs-datagrid--row-tinted) {
|
|
664
|
+
background: var(--dgx-surface) !important;
|
|
665
|
+
}
|
|
666
|
+
|
|
654
667
|
.InovuaReactDataGrid__row:hover,
|
|
655
668
|
.InovuaReactDataGrid__row--hover-target:hover {
|
|
656
669
|
background: var(--dgx-hover) !important;
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'lucide-react';
|
|
14
14
|
|
|
15
15
|
import CustomFetch from '../Fetch';
|
|
16
|
+
import JsonView, { parseJsonDocument } from '../JsonView';
|
|
16
17
|
import StandardModal from '../generic/StandardModal';
|
|
17
18
|
import styles from '../styles/Vault.module.scss';
|
|
18
19
|
import PasswordGenerator from './PasswordGenerator';
|
|
@@ -41,6 +42,26 @@ const EMPTY = {
|
|
|
41
42
|
* it. So the form tracks intent rather than value, and only puts a key in the
|
|
42
43
|
* body when the user actually expressed one.
|
|
43
44
|
*
|
|
45
|
+
* **Notes are the same rule for a different reason.** They are not secret, but
|
|
46
|
+
* they are encrypted and so are deliberately absent from the LIST row — and the
|
|
47
|
+
* list row is what opens this form. Seeding `notes` from it and then sending
|
|
48
|
+
* `notes: ""` back means every edit made from the list silently wipes whatever
|
|
49
|
+
* the entry held, which is how a 32KB machine-written backup can vanish behind
|
|
50
|
+
* a one-word title change. So an existing entry fetches its own detail payload
|
|
51
|
+
* when the form opens, and the `notes` key goes in the body only when there is
|
|
52
|
+
* an answer to send: always for a new entry, and for an existing one only once
|
|
53
|
+
* that fetch has succeeded. A fetch that failed omits the key entirely, which
|
|
54
|
+
* is the API's "leave it alone" — the rest of the form still saves.
|
|
55
|
+
*
|
|
56
|
+
* Once those notes are on screen they are often not prose at all:
|
|
57
|
+
* `zoho:credentials-backup` leaves a pretty-printed
|
|
58
|
+
* `{source, account_id, …, credentials: [{label, content}…]}` blob in the
|
|
59
|
+
* field, and reading 32KB of it through a three-row textarea is miserable. So
|
|
60
|
+
* notes that parse as a JSON object or array get a Formatted/Raw toggle and
|
|
61
|
+
* default to a collapsible tree (`JsonView`). It changes nothing about what is
|
|
62
|
+
* saved: the notes VALUE is the raw text either way, the tree is read-only, and
|
|
63
|
+
* every edit still happens in the textarea behind the Raw chip.
|
|
64
|
+
*
|
|
44
65
|
* The 2FA field takes either a bare base32 secret or the whole `otpauth://`
|
|
45
66
|
* link, and says what it understood — digits, period, algorithm — before the
|
|
46
67
|
* entry is saved, because the failure mode otherwise is a code that is simply
|
|
@@ -104,12 +125,24 @@ const VaultEntryForm = ({
|
|
|
104
125
|
const [dropping, setDropping] = useState(false);
|
|
105
126
|
const qrFileRef = useRef(null);
|
|
106
127
|
|
|
128
|
+
// 'ready' (nothing to load — a new entry) | 'loading' | 'loaded' | 'failed'.
|
|
129
|
+
// Only 'ready' and 'loaded' are safe to assert intent from; see submit().
|
|
130
|
+
const [notesState, setNotesState] = useState(() =>
|
|
131
|
+
entry?.id ? 'loading' : 'ready'
|
|
132
|
+
);
|
|
133
|
+
|
|
107
134
|
const [errors, setErrors] = useState({});
|
|
108
135
|
const [saving, setSaving] = useState(false);
|
|
109
136
|
|
|
110
137
|
const mountedRef = useRef(true);
|
|
111
138
|
const titleRef = useRef(null);
|
|
112
139
|
|
|
140
|
+
// The entry id whose notes have already been asked for. A ref rather than a
|
|
141
|
+
// dependency because the fetch must happen once per open, not once per
|
|
142
|
+
// render — `routes` is rebuilt whenever the consumer passes a fresh
|
|
143
|
+
// `endpoints` object, and a keystroke must never cost a round trip.
|
|
144
|
+
const notesRequestedFor = useRef(null);
|
|
145
|
+
|
|
113
146
|
useEffect(() => {
|
|
114
147
|
mountedRef.current = true;
|
|
115
148
|
|
|
@@ -122,6 +155,115 @@ const VaultEntryForm = ({
|
|
|
122
155
|
if (isOpen) setTimeout(() => titleRef.current?.focus(), 0);
|
|
123
156
|
}, [isOpen]);
|
|
124
157
|
|
|
158
|
+
/* -------------------------------------------------------------- notes */
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Fetch the entry's notes from the detail endpoint.
|
|
162
|
+
*
|
|
163
|
+
* The list row this form is opened from has no `notes` — the column is
|
|
164
|
+
* encrypted and loaded one entry at a time — so the only honest way to fill
|
|
165
|
+
* the field is to ask for the entry itself. The fetch lives here rather
|
|
166
|
+
* than in the list because this is the component that knows it is editing,
|
|
167
|
+
* and because the notes are of no use to a row that only draws a title.
|
|
168
|
+
*
|
|
169
|
+
* A failure is not toasted: it is not a failed action, it is a field that
|
|
170
|
+
* cannot be edited this time round. The submit path reads `notesState` and
|
|
171
|
+
* omits the key, so the stored notes survive the save either way.
|
|
172
|
+
*
|
|
173
|
+
* Note that the detail endpoint writes a `view` to the access log, so
|
|
174
|
+
* opening this form on an existing entry now leaves a row. That is correct
|
|
175
|
+
* rather than incidental — the notes are decrypted and put on screen, which
|
|
176
|
+
* is exactly what a `view` records.
|
|
177
|
+
*/
|
|
178
|
+
const loadNotes = useCallback(() => {
|
|
179
|
+
if (!entry?.id) return;
|
|
180
|
+
|
|
181
|
+
setNotesState('loading');
|
|
182
|
+
|
|
183
|
+
CustomFetch(
|
|
184
|
+
routes.show(entry.id),
|
|
185
|
+
'GET',
|
|
186
|
+
null,
|
|
187
|
+
(result) => {
|
|
188
|
+
if (!mountedRef.current) return;
|
|
189
|
+
|
|
190
|
+
setValues((prev) => ({ ...prev, notes: result?.notes ?? '' }));
|
|
191
|
+
setNotesState('loaded');
|
|
192
|
+
},
|
|
193
|
+
// An errorCallback, empty on purpose: given one, CustomFetch stops
|
|
194
|
+
// toasting, which is the whole point.
|
|
195
|
+
() => {
|
|
196
|
+
if (!mountedRef.current) return;
|
|
197
|
+
|
|
198
|
+
setNotesState('failed');
|
|
199
|
+
}
|
|
200
|
+
).catch(() => {
|
|
201
|
+
// Already handled above; swallowed so the rejection is not
|
|
202
|
+
// unhandled.
|
|
203
|
+
});
|
|
204
|
+
}, [entry?.id, routes]);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!isOpen || !entry?.id) return;
|
|
208
|
+
|
|
209
|
+
// Once per open. Re-running on every render would refetch while the
|
|
210
|
+
// user types, and worse, would overwrite what they had typed.
|
|
211
|
+
if (notesRequestedFor.current === entry.id) return;
|
|
212
|
+
|
|
213
|
+
notesRequestedFor.current = entry.id;
|
|
214
|
+
loadNotes();
|
|
215
|
+
}, [isOpen, entry?.id, loadNotes]);
|
|
216
|
+
|
|
217
|
+
// Disabled while there is nothing trustworthy to edit: blank-and-editable
|
|
218
|
+
// reads as "this entry has no notes", which is the misunderstanding that
|
|
219
|
+
// wiped them in the first place.
|
|
220
|
+
const notesLocked = notesState === 'loading' || notesState === 'failed';
|
|
221
|
+
|
|
222
|
+
/* --------------------------------------------------- notes as a document */
|
|
223
|
+
|
|
224
|
+
// 'formatted' (the tree) | 'raw' (the textarea, and the only place editing
|
|
225
|
+
// happens). Presentation only — see submit(): the notes VALUE is the raw
|
|
226
|
+
// string in `values.notes` whichever of the two is on screen.
|
|
227
|
+
const [notesView, setNotesView] = useState('formatted');
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The notes as a JSON document, or null if they are not one.
|
|
231
|
+
*
|
|
232
|
+
* `zoho:credentials-backup` writes up to 32KB of pretty-printed
|
|
233
|
+
* `{source, account_id, …, credentials: [{label, content}…]}` into this
|
|
234
|
+
* field, and a textarea three rows tall is a genuinely bad way to read it.
|
|
235
|
+
*
|
|
236
|
+
* Gated on `notesState` for the same reason submit() is: until the detail
|
|
237
|
+
* fetch has answered, `values.notes` is not the entry's notes — it is the
|
|
238
|
+
* empty string the form started with. Parsing that would be answering a
|
|
239
|
+
* question about a value we do not have yet. Because the gate reads the
|
|
240
|
+
* state, this re-runs by itself the moment the fetch lands, which is what
|
|
241
|
+
* makes the toggle appear on an entry opened from the list.
|
|
242
|
+
*/
|
|
243
|
+
const notesDocument = useMemo(
|
|
244
|
+
() =>
|
|
245
|
+
notesState === 'ready' || notesState === 'loaded'
|
|
246
|
+
? parseJsonDocument(values.notes)
|
|
247
|
+
: null,
|
|
248
|
+
[notesState, values.notes]
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// Shown while the notes ARE a document — and also while the user is in the
|
|
252
|
+
// raw editor with something that is still trying to be one. That second
|
|
253
|
+
// case is the whole reason this is not just `Boolean(notesDocument)`: break
|
|
254
|
+
// a brace while editing and the toggle would otherwise vanish out from
|
|
255
|
+
// under the cursor, taking the way back with it. Instead it stays, with
|
|
256
|
+
// Formatted disabled and saying why. It cannot appear on ordinary prose,
|
|
257
|
+
// because reaching the raw editor at all requires the chips, which require
|
|
258
|
+
// a document.
|
|
259
|
+
const notesToggle =
|
|
260
|
+
Boolean(notesDocument) ||
|
|
261
|
+
(notesView === 'raw' && /^\s*[{[]/.test(values.notes || ''));
|
|
262
|
+
|
|
263
|
+
// The tree is only ever drawn for a document that parsed THIS render, so
|
|
264
|
+
// broken JSON falls back to the textarea rather than to a stale tree.
|
|
265
|
+
const notesFormatted = Boolean(notesDocument) && notesView === 'formatted';
|
|
266
|
+
|
|
125
267
|
const set = (key, value) => {
|
|
126
268
|
setValues((prev) => ({ ...prev, [key]: value }));
|
|
127
269
|
setErrors((prev) => (prev[key] ? { ...prev, [key]: undefined } : prev));
|
|
@@ -266,7 +408,6 @@ const VaultEntryForm = ({
|
|
|
266
408
|
title: values.title.trim(),
|
|
267
409
|
url: (values.url || '').trim(),
|
|
268
410
|
username: (values.username || '').trim(),
|
|
269
|
-
notes: values.notes || '',
|
|
270
411
|
tags,
|
|
271
412
|
visibility: values.visibility,
|
|
272
413
|
// Always sent, including as null — that is how an entry gets
|
|
@@ -275,6 +416,15 @@ const VaultEntryForm = ({
|
|
|
275
416
|
};
|
|
276
417
|
|
|
277
418
|
// Key presence is the contract — see the note at the top of the file.
|
|
419
|
+
|
|
420
|
+
// Notes: sent whenever what is in the box is genuinely what the entry
|
|
421
|
+
// holds. For a new entry that is trivially true; for an existing one it
|
|
422
|
+
// is true only after the detail fetch answered. Anything else omits the
|
|
423
|
+
// key, and the API leaves the stored notes exactly where they were.
|
|
424
|
+
if (notesState === 'ready' || notesState === 'loaded') {
|
|
425
|
+
body.notes = values.notes || '';
|
|
426
|
+
}
|
|
427
|
+
|
|
278
428
|
if (clearPassword) body.password = '';
|
|
279
429
|
else if (password !== '') body.password = password;
|
|
280
430
|
|
|
@@ -694,13 +844,94 @@ const VaultEntryForm = ({
|
|
|
694
844
|
{field(
|
|
695
845
|
'notes',
|
|
696
846
|
'Notes',
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
847
|
+
<>
|
|
848
|
+
{notesToggle && (
|
|
849
|
+
<div
|
|
850
|
+
className={styles.viewToggle}
|
|
851
|
+
role="group"
|
|
852
|
+
aria-label="Notes view"
|
|
853
|
+
>
|
|
854
|
+
<button
|
|
855
|
+
type="button"
|
|
856
|
+
className={`${styles.viewChip} ${
|
|
857
|
+
notesFormatted ? styles.viewChipOn : ''
|
|
858
|
+
}`}
|
|
859
|
+
aria-pressed={notesFormatted}
|
|
860
|
+
disabled={!notesDocument}
|
|
861
|
+
title={
|
|
862
|
+
notesDocument
|
|
863
|
+
? undefined
|
|
864
|
+
: 'These notes are no longer valid JSON, so there is nothing to lay out — fix the syntax here and this comes back.'
|
|
865
|
+
}
|
|
866
|
+
onClick={() => setNotesView('formatted')}
|
|
867
|
+
>
|
|
868
|
+
Formatted
|
|
869
|
+
</button>
|
|
870
|
+
<button
|
|
871
|
+
type="button"
|
|
872
|
+
className={`${styles.viewChip} ${
|
|
873
|
+
notesFormatted ? '' : styles.viewChipOn
|
|
874
|
+
}`}
|
|
875
|
+
aria-pressed={!notesFormatted}
|
|
876
|
+
onClick={() => setNotesView('raw')}
|
|
877
|
+
>
|
|
878
|
+
Raw
|
|
879
|
+
</button>
|
|
880
|
+
</div>
|
|
881
|
+
)}
|
|
882
|
+
|
|
883
|
+
{notesFormatted ? (
|
|
884
|
+
// Read-only, and capped so a 32KB backup scrolls
|
|
885
|
+
// inside the field instead of pushing Save off the
|
|
886
|
+
// bottom of the dialog.
|
|
887
|
+
<div
|
|
888
|
+
// The label above says "Notes"; keeping the id
|
|
889
|
+
// here means it still points at what it names.
|
|
890
|
+
// Focusable because a scroll region a mouse can
|
|
891
|
+
// reach and a keyboard cannot is only half a
|
|
892
|
+
// control.
|
|
893
|
+
id="vault-notes"
|
|
894
|
+
className={styles.notesJson}
|
|
895
|
+
role="region"
|
|
896
|
+
aria-label="Notes, laid out as JSON"
|
|
897
|
+
tabIndex={0}
|
|
898
|
+
>
|
|
899
|
+
<JsonView value={notesDocument} />
|
|
900
|
+
</div>
|
|
901
|
+
) : (
|
|
902
|
+
<textarea
|
|
903
|
+
id="vault-notes"
|
|
904
|
+
className={styles.textarea}
|
|
905
|
+
rows={3}
|
|
906
|
+
value={values.notes || ''}
|
|
907
|
+
disabled={notesLocked}
|
|
908
|
+
placeholder={
|
|
909
|
+
notesState === 'loading' ? 'Loading notes…' : ''
|
|
910
|
+
}
|
|
911
|
+
onChange={(e) => set('notes', e.target.value)}
|
|
912
|
+
/>
|
|
913
|
+
)}
|
|
914
|
+
|
|
915
|
+
{notesFormatted && (
|
|
916
|
+
<p className={styles.help}>
|
|
917
|
+
These notes are JSON. Switch to Raw to edit them.
|
|
918
|
+
</p>
|
|
919
|
+
)}
|
|
920
|
+
|
|
921
|
+
{notesState === 'failed' && (
|
|
922
|
+
<p className={styles.fieldError} role="alert">
|
|
923
|
+
The notes could not be loaded, so they cannot be
|
|
924
|
+
edited here — saving will leave them as they are.{' '}
|
|
925
|
+
<button
|
|
926
|
+
type="button"
|
|
927
|
+
className={styles.linkButton}
|
|
928
|
+
onClick={loadNotes}
|
|
929
|
+
>
|
|
930
|
+
Try again
|
|
931
|
+
</button>
|
|
932
|
+
</p>
|
|
933
|
+
)}
|
|
934
|
+
</>
|
|
704
935
|
)}
|
|
705
936
|
|
|
706
937
|
{field(
|
|
@@ -168,6 +168,18 @@ const VaultManagerInner = ({
|
|
|
168
168
|
*/
|
|
169
169
|
clientId = null,
|
|
170
170
|
clientLabel = null,
|
|
171
|
+
/**
|
|
172
|
+
* The order the list opens in, before anyone clicks a column header.
|
|
173
|
+
*
|
|
174
|
+
* The library's default stays most-recently-touched first — on a vault
|
|
175
|
+
* that is worked in, the entry someone just rotated is the one they are
|
|
176
|
+
* about to verify. A host whose vault is browsed rather than worked
|
|
177
|
+
* (looked through by name, like a phone book) passes 'title' / 'asc'.
|
|
178
|
+
* Clicking a header still overrides either way; this is only the opening
|
|
179
|
+
* state.
|
|
180
|
+
*/
|
|
181
|
+
defaultSort = 'updated_at',
|
|
182
|
+
defaultDirection = 'desc',
|
|
171
183
|
}) => {
|
|
172
184
|
const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
|
|
173
185
|
|
|
@@ -190,8 +202,8 @@ const VaultManagerInner = ({
|
|
|
190
202
|
// production is one page in eight.
|
|
191
203
|
const [clientOptions, setClientOptions] = useState([]);
|
|
192
204
|
const [includeDeleted, setIncludeDeleted] = useState(false);
|
|
193
|
-
const [sort, setSort] = useState(
|
|
194
|
-
const [direction, setDirection] = useState(
|
|
205
|
+
const [sort, setSort] = useState(defaultSort);
|
|
206
|
+
const [direction, setDirection] = useState(defaultDirection);
|
|
195
207
|
const [page, setPage] = useState(1);
|
|
196
208
|
const [perPage, setPerPage] = useState(
|
|
197
209
|
Number(perPageProp) > 0 ? Number(perPageProp) : PER_PAGE
|
package/src/index.js
CHANGED
|
@@ -47,6 +47,11 @@ import ImageModal from './components/ImageModal';
|
|
|
47
47
|
import DatePickerPortal from './components/utils/DatePickerPortal';
|
|
48
48
|
import NativeDateFilterEditor from './components/columns/NativeDateFilterEditor';
|
|
49
49
|
import ImportWizardModal from './components/ImportWizardModal';
|
|
50
|
+
// A parsed JSON value as a collapsible tree, plus the rule for deciding whether
|
|
51
|
+
// some text is one worth drawing. Written for the vault's notes — a machine
|
|
52
|
+
// written backup blob in a textarea is unreadable — but there is nothing
|
|
53
|
+
// vault-specific in either half, so both live out here.
|
|
54
|
+
import JsonView, { parseJsonDocument, STRING_PREVIEW_LIMIT } from './components/JsonView';
|
|
50
55
|
export * from './components/columns/ColumnRenderers.jsx';
|
|
51
56
|
|
|
52
57
|
/** Auth Specific Components */
|
|
@@ -479,6 +484,9 @@ export {
|
|
|
479
484
|
ImportWizardModal,
|
|
480
485
|
isCompleteOtp,
|
|
481
486
|
isSuccessBody,
|
|
487
|
+
JsonView,
|
|
488
|
+
parseJsonDocument,
|
|
489
|
+
STRING_PREVIEW_LIMIT,
|
|
482
490
|
Loader,
|
|
483
491
|
Login,
|
|
484
492
|
LogoutScreen,
|