@dloizides/ui-access-admin 1.0.1

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.js ADDED
@@ -0,0 +1,576 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var reactNative = require('react-native');
5
+ var uiButtons = require('@dloizides/ui-buttons');
6
+ var uiFeedback = require('@dloizides/ui-feedback');
7
+ var uiLayout = require('@dloizides/ui-layout');
8
+ var jsxRuntime = require('react/jsx-runtime');
9
+ var uiForms = require('@dloizides/ui-forms');
10
+
11
+ // src/AccessLinkPanel/AccessLinkPanel.tsx
12
+
13
+ // src/AccessResultTone.ts
14
+ var AccessResultTone = /* @__PURE__ */ ((AccessResultTone2) => {
15
+ AccessResultTone2["Success"] = "success";
16
+ AccessResultTone2["Error"] = "error";
17
+ return AccessResultTone2;
18
+ })(AccessResultTone || {});
19
+
20
+ // src/constants.ts
21
+ var ACCESS_ADMIN_I18N = {
22
+ // ── AccessLinkPanel: framing ────────────────────────────────────────────────
23
+ /** Panel heading. `{{p1}}` = subject display name. */
24
+ panelHeading: "accessAdmin.link.panelHeading",
25
+ /** Supporting line explaining what the link grants. */
26
+ panelNote: "accessAdmin.link.panelNote",
27
+ /** The security warning. Shown always, not only after a mint — see AccessLinkPanel. */
28
+ capabilityWarning: "accessAdmin.link.capabilityWarning",
29
+ /** "A link created now stops working on {{p1}}." `{{p1}}` = yyyy-mm-dd. */
30
+ expiryPreview: "accessAdmin.link.expiryPreview",
31
+ // ── AccessLinkPanel: minting ────────────────────────────────────────────────
32
+ mint: "accessAdmin.link.mint",
33
+ mintHint: "accessAdmin.link.mintHint",
34
+ /** Heading over the revealed URL. `{{p1}}` = subject display name. */
35
+ mintedTitle: "accessAdmin.link.mintedTitle",
36
+ /** "Shown once — copy it now." The honesty that makes the one-shot reveal safe. */
37
+ mintedHint: "accessAdmin.link.mintedHint",
38
+ /** Reported when the mint landed AND the clipboard write succeeded. `{{p1}}` = name. */
39
+ copySuccess: "accessAdmin.link.copySuccess",
40
+ /** Reported when the mint landed but the clipboard REFUSED. `{{p1}}` = name. */
41
+ mintNotCopied: "accessAdmin.link.mintNotCopied",
42
+ /** Reported when a re-copy of an already-revealed URL failed. `{{p1}}` = name. */
43
+ copyFailed: "accessAdmin.link.copyFailed",
44
+ // ── AccessLinkPanel: acting on a revealed URL ───────────────────────────────
45
+ recopy: "accessAdmin.link.recopy",
46
+ recopyHint: "accessAdmin.link.recopyHint",
47
+ copied: "accessAdmin.link.copied",
48
+ dismiss: "accessAdmin.link.dismiss",
49
+ dismissHint: "accessAdmin.link.dismissHint",
50
+ email: "accessAdmin.link.email",
51
+ emailHint: "accessAdmin.link.emailHint",
52
+ /** "This opens YOUR mail app — we do not send it for you." */
53
+ emailNote: "accessAdmin.link.emailNote",
54
+ /** Mail subject. `{{p1}}` = subject display name. */
55
+ emailSubject: "accessAdmin.link.emailSubject",
56
+ /** Mail body. `{{p1}}` = name, `{{p2}}` = url, `{{p3}}` = expiry date. */
57
+ emailBody: "accessAdmin.link.emailBody",
58
+ // ── AccessLinkPanel: the live list ──────────────────────────────────────────
59
+ /** "Live links ({{p1}})". `{{p1}}` = count. */
60
+ liveHeading: "accessAdmin.link.liveHeading",
61
+ /** "Created {{p1}} · stops working {{p2}}". */
62
+ liveMeta: "accessAdmin.link.liveMeta",
63
+ /** Shown in `liveMeta`'s `{{p2}}` slot when a link has no expiry at all. */
64
+ expiresNever: "accessAdmin.link.expiresNever",
65
+ noLive: "accessAdmin.link.noLive",
66
+ /** Shown when the link list FAILED to load — never conflated with "there are none". */
67
+ liveLoadError: "accessAdmin.link.liveLoadError",
68
+ close: "accessAdmin.link.close",
69
+ closeHint: "accessAdmin.link.closeHint",
70
+ // ── AccountLinkPanel ────────────────────────────────────────────────────────
71
+ /** Panel heading. `{{p1}}` = subject display name. */
72
+ accountPanelHeading: "accessAdmin.account.panelHeading",
73
+ accountPanelNote: "accessAdmin.account.panelNote",
74
+ accountPickerLabel: "accessAdmin.account.pickerLabel",
75
+ accountPickerHint: "accessAdmin.account.pickerHint",
76
+ accountPickerPlaceholder: "accessAdmin.account.pickerPlaceholder",
77
+ accountOptionHint: "accessAdmin.account.optionHint",
78
+ /** Label for a directory entry carrying neither a display name nor an email. */
79
+ accountUnnamed: "accessAdmin.account.unnamed",
80
+ /** Shown when the directory is empty (or every entry is taken by another subject). */
81
+ accountNone: "accessAdmin.account.none",
82
+ accountLink: "accessAdmin.account.link",
83
+ accountLinkHint: "accessAdmin.account.linkHint",
84
+ /** `{{p1}}` = subject display name. */
85
+ accountLinkSuccess: "accessAdmin.account.linkSuccess",
86
+ accountUnlink: "accessAdmin.account.unlink",
87
+ accountUnlinkHint: "accessAdmin.account.unlinkHint",
88
+ /** `{{p1}}` = subject display name. */
89
+ accountUnlinkSuccess: "accessAdmin.account.unlinkSuccess",
90
+ accountClose: "accessAdmin.account.close",
91
+ accountCloseHint: "accessAdmin.account.closeHint"
92
+ };
93
+ var ACCESS_ADMIN_TEST_IDS = {
94
+ linkPanel: "access-link-panel",
95
+ accountPanel: "account-link-panel"
96
+ };
97
+
98
+ // src/expiry/expiryPolicy.ts
99
+ var MS_PER_DAY = 864e5;
100
+ var ISO_DATE_LENGTH = 10;
101
+ function daysUntilAnchor(anchorDate, now) {
102
+ if (typeof anchorDate !== "string" || anchorDate.trim() === "") return null;
103
+ const anchor = Date.parse(anchorDate);
104
+ if (Number.isNaN(anchor)) return null;
105
+ return Math.floor(anchor / MS_PER_DAY) - Math.floor(now.getTime() / MS_PER_DAY);
106
+ }
107
+ function capabilityExpiryDays(policy, now) {
108
+ const untilAnchor = daysUntilAnchor(policy.anchorDate, now);
109
+ const raw = untilAnchor === null ? policy.tailDays : untilAnchor + policy.tailDays;
110
+ if (raw < policy.minDays) return policy.minDays;
111
+ if (raw > policy.maxDays) return policy.maxDays;
112
+ return raw;
113
+ }
114
+ function capabilityExpiryDate(policy, now) {
115
+ const days = capabilityExpiryDays(policy, now);
116
+ return new Date(now.getTime() + days * MS_PER_DAY).toISOString().slice(0, ISO_DATE_LENGTH);
117
+ }
118
+
119
+ // src/links/capabilityLinkHelpers.ts
120
+ var ISO_DATE_LENGTH2 = 10;
121
+ function linksForSubject(links, subjectId) {
122
+ return links.filter((link) => link.subjectId === subjectId).sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
123
+ }
124
+ function activeLinksForSubject(links, subjectId) {
125
+ return linksForSubject(links, subjectId).filter((link) => link.isActive);
126
+ }
127
+ function formatLinkDate(value, fallback) {
128
+ if (typeof value !== "string" || value.trim() === "") return fallback;
129
+ return value.slice(0, ISO_DATE_LENGTH2);
130
+ }
131
+ function buildCapabilityLinkMailto(subject, body, url) {
132
+ if (url.trim() === "") return null;
133
+ return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
134
+ }
135
+ function useCapabilityLinkMint(options) {
136
+ const { subject, policy, mint, describeError, onResult, t, now, clipboardWriter } = options;
137
+ const [minted, setMinted] = react.useState(null);
138
+ const [copied, setCopied] = react.useState(false);
139
+ const [isPending, setIsPending] = react.useState(false);
140
+ const mountedRef = react.useRef(true);
141
+ react.useEffect(() => {
142
+ mountedRef.current = true;
143
+ return () => {
144
+ mountedRef.current = false;
145
+ };
146
+ }, []);
147
+ const copyUrl = react.useCallback(
148
+ async (url) => {
149
+ const ok = await uiLayout.copyText(url, clipboardWriter);
150
+ if (mountedRef.current) setCopied(ok);
151
+ return ok;
152
+ },
153
+ [clipboardWriter]
154
+ );
155
+ const runMint = react.useCallback(() => {
156
+ if (isPending) return;
157
+ setIsPending(true);
158
+ void (async () => {
159
+ try {
160
+ const expiresInDays = capabilityExpiryDays(policy, now === void 0 ? /* @__PURE__ */ new Date() : now());
161
+ const link = await mint({ subject, expiresInDays });
162
+ if (!mountedRef.current) return;
163
+ setMinted(link);
164
+ const ok = await copyUrl(link.url ?? "");
165
+ if (!mountedRef.current) return;
166
+ onResult(
167
+ t(
168
+ ok ? ACCESS_ADMIN_I18N.copySuccess : ACCESS_ADMIN_I18N.mintNotCopied,
169
+ subject.displayName
170
+ ),
171
+ ok ? "success" /* Success */ : "error" /* Error */
172
+ );
173
+ } catch (error) {
174
+ if (!mountedRef.current) return;
175
+ onResult(describeError(error), "error" /* Error */);
176
+ } finally {
177
+ if (mountedRef.current) setIsPending(false);
178
+ }
179
+ })();
180
+ }, [isPending, policy, now, mint, subject, copyUrl, onResult, t, describeError]);
181
+ const recopy = react.useCallback(() => {
182
+ const url = minted?.url ?? "";
183
+ if (url === "") return;
184
+ void (async () => {
185
+ const ok = await copyUrl(url);
186
+ if (!mountedRef.current) return;
187
+ onResult(
188
+ t(ok ? ACCESS_ADMIN_I18N.copySuccess : ACCESS_ADMIN_I18N.copyFailed, subject.displayName),
189
+ ok ? "success" /* Success */ : "error" /* Error */
190
+ );
191
+ })();
192
+ }, [minted, copyUrl, onResult, t, subject.displayName]);
193
+ const dismiss = react.useCallback(() => {
194
+ setMinted(null);
195
+ setCopied(false);
196
+ }, []);
197
+ return { minted, isPending, copied, mint: runMint, recopy, dismiss };
198
+ }
199
+ function useAccessAdminPalette() {
200
+ const { theme } = uiFeedback.useUi();
201
+ const { colors, palette, semantic } = theme;
202
+ return react.useMemo(() => {
203
+ const success = semantic.success ?? semantic.error;
204
+ return {
205
+ panelSurface: colors.surface,
206
+ border: colors.border,
207
+ textPrimary: colors.text,
208
+ textSecondary: colors.textSecondary,
209
+ danger: semantic.error["500"],
210
+ // A tinted step when the host's scale has one, else the elevated surface —
211
+ // never a hardcoded pastel, which would be wrong in dark mode.
212
+ revealSurface: success["50"] ?? success["100"] ?? colors.surfaceElevated,
213
+ revealBorder: success["500"],
214
+ revealHeading: palette.primary["700"] ?? palette.primary["500"]
215
+ };
216
+ }, [colors, palette, semantic]);
217
+ }
218
+ var SPACE_XS = 4;
219
+ var SPACE_SM = 8;
220
+ var SPACE_MD = 12;
221
+ var SPACE_LG = 16;
222
+ var FONT_SM = 12;
223
+ var FONT_MD = 13;
224
+ var AccessLinkPanel = ({
225
+ subject,
226
+ policy,
227
+ links,
228
+ linksFailed = false,
229
+ mint,
230
+ describeError,
231
+ onClose,
232
+ onResult,
233
+ renderLinkActions,
234
+ now,
235
+ clipboardWriter,
236
+ testIDPrefix = ACCESS_ADMIN_TEST_IDS.linkPanel
237
+ }) => {
238
+ const { t } = uiFeedback.useUi();
239
+ const palette = useAccessAdminPalette();
240
+ const styles = react.useMemo(() => buildStyles(palette), [palette]);
241
+ const state = useCapabilityLinkMint({
242
+ subject,
243
+ policy,
244
+ mint,
245
+ describeError,
246
+ onResult,
247
+ t,
248
+ now,
249
+ clipboardWriter
250
+ });
251
+ const expiryDate = react.useMemo(
252
+ () => capabilityExpiryDate(policy, now === void 0 ? /* @__PURE__ */ new Date() : now()),
253
+ [policy, now]
254
+ );
255
+ const liveLinks = react.useMemo(() => activeLinksForSubject(links, subject.id), [links, subject.id]);
256
+ const mintedUrl = state.minted?.url ?? "";
257
+ const handleEmail = () => {
258
+ const mailto = buildCapabilityLinkMailto(
259
+ t(ACCESS_ADMIN_I18N.emailSubject, subject.displayName),
260
+ t(ACCESS_ADMIN_I18N.emailBody, subject.displayName, mintedUrl, expiryDate),
261
+ mintedUrl
262
+ );
263
+ if (mailto === null) return;
264
+ void reactNative.Linking.openURL(mailto).catch(() => {
265
+ onResult(t(ACCESS_ADMIN_I18N.copyFailed, subject.displayName), "error" /* Error */);
266
+ });
267
+ };
268
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.panel, testID: testIDPrefix, children: [
269
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.heading, children: t(ACCESS_ADMIN_I18N.panelHeading, subject.displayName) }),
270
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.note, children: t(ACCESS_ADMIN_I18N.panelNote) }),
271
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.warning, testID: `${testIDPrefix}-capability-warning`, children: t(ACCESS_ADMIN_I18N.capabilityWarning) }),
272
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.note, testID: `${testIDPrefix}-expiry-preview`, children: t(ACCESS_ADMIN_I18N.expiryPreview, expiryDate) }),
273
+ state.minted !== null ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.revealBox, testID: `${testIDPrefix}-minted`, children: [
274
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.revealTitle, children: t(ACCESS_ADMIN_I18N.mintedTitle, subject.displayName) }),
275
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.note, children: t(ACCESS_ADMIN_I18N.mintedHint) }),
276
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { selectable: true, style: styles.url, testID: `${testIDPrefix}-minted-url`, children: mintedUrl }),
277
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.actions, children: [
278
+ /* @__PURE__ */ jsxRuntime.jsx(
279
+ uiButtons.Button,
280
+ {
281
+ accessibilityHint: t(ACCESS_ADMIN_I18N.recopyHint),
282
+ accessibilityLabel: state.copied ? t(ACCESS_ADMIN_I18N.copied) : t(ACCESS_ADMIN_I18N.recopy),
283
+ label: state.copied ? t(ACCESS_ADMIN_I18N.copied) : t(ACCESS_ADMIN_I18N.recopy),
284
+ onPress: state.recopy,
285
+ testID: `${testIDPrefix}-recopy`
286
+ }
287
+ ),
288
+ /* @__PURE__ */ jsxRuntime.jsx(
289
+ uiButtons.Button,
290
+ {
291
+ accessibilityHint: t(ACCESS_ADMIN_I18N.emailHint),
292
+ accessibilityLabel: t(ACCESS_ADMIN_I18N.email),
293
+ label: t(ACCESS_ADMIN_I18N.email),
294
+ onPress: handleEmail,
295
+ testID: `${testIDPrefix}-email`,
296
+ variant: "secondary"
297
+ }
298
+ ),
299
+ /* @__PURE__ */ jsxRuntime.jsx(
300
+ uiButtons.Button,
301
+ {
302
+ accessibilityHint: t(ACCESS_ADMIN_I18N.dismissHint),
303
+ accessibilityLabel: t(ACCESS_ADMIN_I18N.dismiss),
304
+ label: t(ACCESS_ADMIN_I18N.dismiss),
305
+ onPress: state.dismiss,
306
+ testID: `${testIDPrefix}-dismiss`,
307
+ variant: "secondary"
308
+ }
309
+ )
310
+ ] }),
311
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.note, children: t(ACCESS_ADMIN_I18N.emailNote) })
312
+ ] }) : null,
313
+ linksFailed ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.warning, testID: `${testIDPrefix}-load-error`, children: t(ACCESS_ADMIN_I18N.liveLoadError) }) : null,
314
+ !linksFailed && liveLinks.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.liveBox, children: [
315
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.subheading, children: t(ACCESS_ADMIN_I18N.liveHeading, String(liveLinks.length)) }),
316
+ liveLinks.map((link) => /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.liveRow, children: [
317
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.liveMeta, children: t(
318
+ ACCESS_ADMIN_I18N.liveMeta,
319
+ formatLinkDate(link.createdAt, ""),
320
+ formatLinkDate(link.expiresAt, t(ACCESS_ADMIN_I18N.expiresNever))
321
+ ) }),
322
+ renderLinkActions === void 0 ? null : renderLinkActions(link)
323
+ ] }, link.id))
324
+ ] }) : null,
325
+ !linksFailed && liveLinks.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.note, testID: `${testIDPrefix}-no-live`, children: t(ACCESS_ADMIN_I18N.noLive) }) : null,
326
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.actions, children: [
327
+ /* @__PURE__ */ jsxRuntime.jsx(
328
+ uiButtons.Button,
329
+ {
330
+ accessibilityHint: t(ACCESS_ADMIN_I18N.mintHint),
331
+ accessibilityLabel: t(ACCESS_ADMIN_I18N.mint),
332
+ disabled: state.isPending,
333
+ label: t(ACCESS_ADMIN_I18N.mint),
334
+ loading: state.isPending,
335
+ onPress: state.mint,
336
+ testID: `${testIDPrefix}-mint`
337
+ }
338
+ ),
339
+ /* @__PURE__ */ jsxRuntime.jsx(
340
+ uiButtons.Button,
341
+ {
342
+ accessibilityHint: t(ACCESS_ADMIN_I18N.closeHint),
343
+ accessibilityLabel: t(ACCESS_ADMIN_I18N.close),
344
+ disabled: state.isPending,
345
+ label: t(ACCESS_ADMIN_I18N.close),
346
+ onPress: onClose,
347
+ testID: `${testIDPrefix}-close`,
348
+ variant: "secondary"
349
+ }
350
+ )
351
+ ] })
352
+ ] });
353
+ };
354
+ var buildStyles = (c) => reactNative.StyleSheet.create({
355
+ actions: { flexDirection: "row", flexWrap: "wrap", gap: SPACE_SM },
356
+ heading: { color: c.textPrimary, fontSize: FONT_MD, fontWeight: "700" },
357
+ liveBox: { gap: SPACE_SM, marginTop: SPACE_XS },
358
+ liveMeta: { color: c.textSecondary, fontSize: FONT_SM },
359
+ liveRow: {
360
+ borderTopColor: c.border,
361
+ borderTopWidth: 1,
362
+ gap: SPACE_XS,
363
+ paddingTop: SPACE_SM
364
+ },
365
+ note: { color: c.textSecondary, fontSize: FONT_SM },
366
+ panel: {
367
+ backgroundColor: c.panelSurface,
368
+ borderColor: c.border,
369
+ borderRadius: SPACE_SM,
370
+ borderWidth: 1,
371
+ gap: SPACE_XS,
372
+ marginBottom: SPACE_LG,
373
+ padding: SPACE_MD
374
+ },
375
+ revealBox: {
376
+ backgroundColor: c.revealSurface,
377
+ borderColor: c.revealBorder,
378
+ borderRadius: SPACE_SM,
379
+ borderWidth: 1,
380
+ gap: SPACE_XS,
381
+ marginVertical: SPACE_SM,
382
+ padding: SPACE_MD
383
+ },
384
+ revealTitle: { color: c.revealHeading, fontSize: FONT_MD, fontWeight: "800" },
385
+ subheading: { color: c.textPrimary, fontSize: FONT_SM, fontWeight: "700" },
386
+ url: { color: c.textPrimary, fontSize: FONT_MD, fontWeight: "600" },
387
+ warning: { color: c.danger, fontSize: FONT_SM, fontWeight: "600" }
388
+ });
389
+
390
+ // src/accounts/accountOptions.ts
391
+ function isFilled(value) {
392
+ return typeof value === "string" && value.trim() !== "";
393
+ }
394
+ function accountDisplayLabel(account, unnamedLabel) {
395
+ if (isFilled(account.displayName)) return account.displayName.trim();
396
+ if (isFilled(account.email)) return account.email.trim();
397
+ return unnamedLabel;
398
+ }
399
+ function buildAccountOptions(accounts, bindings, subject, unnamedLabel) {
400
+ const takenByOthers = /* @__PURE__ */ new Set();
401
+ bindings.forEach((accountId, subjectId) => {
402
+ if (subjectId !== subject.id && isFilled(accountId)) takenByOthers.add(accountId);
403
+ });
404
+ return accounts.filter((account) => !takenByOthers.has(account.userId)).map((account) => ({
405
+ value: account.userId,
406
+ label: accountDisplayLabel(account, unnamedLabel)
407
+ }));
408
+ }
409
+ function useSubjectAccountLink(options) {
410
+ const { subject, boundAccountId, linkAccount, describeError, onResult, t } = options;
411
+ const [selectedAccountId, setSelectedAccountId] = react.useState(boundAccountId);
412
+ const [isPending, setIsPending] = react.useState(false);
413
+ const mountedRef = react.useRef(true);
414
+ react.useEffect(() => {
415
+ mountedRef.current = true;
416
+ return () => {
417
+ mountedRef.current = false;
418
+ };
419
+ }, []);
420
+ react.useEffect(() => {
421
+ setSelectedAccountId(boundAccountId);
422
+ }, [subject.id, boundAccountId]);
423
+ const canLink = !isPending && selectedAccountId !== null && selectedAccountId !== boundAccountId;
424
+ const canUnlink = !isPending && boundAccountId !== null;
425
+ const submit = react.useCallback(
426
+ (accountId, successKey) => {
427
+ setIsPending(true);
428
+ void (async () => {
429
+ try {
430
+ await linkAccount(subject, accountId);
431
+ if (!mountedRef.current) return;
432
+ onResult(t(successKey, subject.displayName), "success" /* Success */);
433
+ } catch (error) {
434
+ if (!mountedRef.current) return;
435
+ onResult(describeError(error), "error" /* Error */);
436
+ } finally {
437
+ if (mountedRef.current) setIsPending(false);
438
+ }
439
+ })();
440
+ },
441
+ [linkAccount, subject, onResult, t, describeError]
442
+ );
443
+ const link = react.useCallback(() => {
444
+ if (!canLink || selectedAccountId === null) return;
445
+ submit(selectedAccountId, ACCESS_ADMIN_I18N.accountLinkSuccess);
446
+ }, [canLink, selectedAccountId, submit]);
447
+ const unlink = react.useCallback(() => {
448
+ if (!canUnlink) return;
449
+ submit(null, ACCESS_ADMIN_I18N.accountUnlinkSuccess);
450
+ }, [canUnlink, submit]);
451
+ return { selectedAccountId, select: setSelectedAccountId, isPending, canLink, canUnlink, link, unlink };
452
+ }
453
+ var SPACE_XS2 = 4;
454
+ var SPACE_SM2 = 8;
455
+ var SPACE_MD2 = 12;
456
+ var SPACE_LG2 = 16;
457
+ var FONT_SM2 = 12;
458
+ var FONT_MD2 = 13;
459
+ var AccountLinkPanel = ({
460
+ subject,
461
+ boundAccountId,
462
+ accounts,
463
+ bindings,
464
+ linkAccount,
465
+ describeError,
466
+ onClose,
467
+ onResult,
468
+ testIDPrefix = ACCESS_ADMIN_TEST_IDS.accountPanel
469
+ }) => {
470
+ const { t } = uiFeedback.useUi();
471
+ const palette = useAccessAdminPalette();
472
+ const styles = react.useMemo(() => buildStyles2(palette), [palette]);
473
+ const state = useSubjectAccountLink({
474
+ subject,
475
+ boundAccountId,
476
+ linkAccount,
477
+ describeError,
478
+ onResult,
479
+ t
480
+ });
481
+ const unnamedLabel = t(ACCESS_ADMIN_I18N.accountUnnamed);
482
+ const options = react.useMemo(
483
+ () => buildAccountOptions(accounts, bindings, subject, unnamedLabel),
484
+ [accounts, bindings, subject, unnamedLabel]
485
+ );
486
+ const selectedLabel = options.find((option) => option.value === state.selectedAccountId)?.label ?? "";
487
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.panel, testID: testIDPrefix, children: [
488
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.heading, children: t(ACCESS_ADMIN_I18N.accountPanelHeading, subject.displayName) }),
489
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.note, children: t(ACCESS_ADMIN_I18N.accountPanelNote) }),
490
+ options.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(
491
+ uiForms.SelectControl,
492
+ {
493
+ accessibilityHint: t(ACCESS_ADMIN_I18N.accountPickerHint),
494
+ accessibilityLabel: `${t(ACCESS_ADMIN_I18N.accountPickerLabel)}: ${selectedLabel === "" ? t(ACCESS_ADMIN_I18N.accountPickerPlaceholder) : selectedLabel}`,
495
+ onChange: state.select,
496
+ optionHint: t(ACCESS_ADMIN_I18N.accountOptionHint),
497
+ options,
498
+ placeholder: t(ACCESS_ADMIN_I18N.accountPickerPlaceholder),
499
+ testID: `${testIDPrefix}-picker`,
500
+ value: state.selectedAccountId ?? ""
501
+ }
502
+ ) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.note, testID: `${testIDPrefix}-no-accounts`, children: t(ACCESS_ADMIN_I18N.accountNone) }),
503
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles.actions, children: [
504
+ /* @__PURE__ */ jsxRuntime.jsx(
505
+ uiButtons.Button,
506
+ {
507
+ accessibilityHint: t(ACCESS_ADMIN_I18N.accountLinkHint),
508
+ accessibilityLabel: t(ACCESS_ADMIN_I18N.accountLink),
509
+ disabled: !state.canLink,
510
+ label: t(ACCESS_ADMIN_I18N.accountLink),
511
+ loading: state.isPending,
512
+ onPress: state.link,
513
+ testID: `${testIDPrefix}-link`
514
+ }
515
+ ),
516
+ state.canUnlink ? /* @__PURE__ */ jsxRuntime.jsx(
517
+ uiButtons.Button,
518
+ {
519
+ accessibilityHint: t(ACCESS_ADMIN_I18N.accountUnlinkHint),
520
+ accessibilityLabel: t(ACCESS_ADMIN_I18N.accountUnlink),
521
+ disabled: state.isPending,
522
+ label: t(ACCESS_ADMIN_I18N.accountUnlink),
523
+ onPress: state.unlink,
524
+ testID: `${testIDPrefix}-unlink`,
525
+ variant: "danger"
526
+ }
527
+ ) : null,
528
+ /* @__PURE__ */ jsxRuntime.jsx(
529
+ uiButtons.Button,
530
+ {
531
+ accessibilityHint: t(ACCESS_ADMIN_I18N.accountCloseHint),
532
+ accessibilityLabel: t(ACCESS_ADMIN_I18N.accountClose),
533
+ disabled: state.isPending,
534
+ label: t(ACCESS_ADMIN_I18N.accountClose),
535
+ onPress: onClose,
536
+ testID: `${testIDPrefix}-close`,
537
+ variant: "secondary"
538
+ }
539
+ )
540
+ ] })
541
+ ] });
542
+ };
543
+ var buildStyles2 = (c) => reactNative.StyleSheet.create({
544
+ actions: { flexDirection: "row", flexWrap: "wrap", gap: SPACE_SM2 },
545
+ heading: { color: c.textPrimary, fontSize: FONT_MD2, fontWeight: "700" },
546
+ note: { color: c.textSecondary, fontSize: FONT_SM2 },
547
+ panel: {
548
+ backgroundColor: c.panelSurface,
549
+ borderColor: c.border,
550
+ borderRadius: SPACE_SM2,
551
+ borderWidth: 1,
552
+ gap: SPACE_XS2,
553
+ marginBottom: SPACE_LG2,
554
+ padding: SPACE_MD2
555
+ }
556
+ });
557
+
558
+ exports.ACCESS_ADMIN_I18N = ACCESS_ADMIN_I18N;
559
+ exports.ACCESS_ADMIN_TEST_IDS = ACCESS_ADMIN_TEST_IDS;
560
+ exports.AccessLinkPanel = AccessLinkPanel;
561
+ exports.AccessResultTone = AccessResultTone;
562
+ exports.AccountLinkPanel = AccountLinkPanel;
563
+ exports.accountDisplayLabel = accountDisplayLabel;
564
+ exports.activeLinksForSubject = activeLinksForSubject;
565
+ exports.buildAccountOptions = buildAccountOptions;
566
+ exports.buildCapabilityLinkMailto = buildCapabilityLinkMailto;
567
+ exports.capabilityExpiryDate = capabilityExpiryDate;
568
+ exports.capabilityExpiryDays = capabilityExpiryDays;
569
+ exports.daysUntilAnchor = daysUntilAnchor;
570
+ exports.formatLinkDate = formatLinkDate;
571
+ exports.linksForSubject = linksForSubject;
572
+ exports.useAccessAdminPalette = useAccessAdminPalette;
573
+ exports.useCapabilityLinkMint = useCapabilityLinkMint;
574
+ exports.useSubjectAccountLink = useSubjectAccountLink;
575
+ //# sourceMappingURL=index.js.map
576
+ //# sourceMappingURL=index.js.map