@tradejs/app 1.0.5 → 1.0.6

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.
@@ -0,0 +1,809 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import {
5
+ Box,
6
+ Button,
7
+ CloseButton,
8
+ Drawer,
9
+ Field,
10
+ Flex,
11
+ HStack,
12
+ IconButton,
13
+ Input,
14
+ Portal,
15
+ Spinner,
16
+ Stack,
17
+ Text,
18
+ } from '@chakra-ui/react';
19
+ import { FiEdit2, FiSettings } from 'react-icons/fi';
20
+ import { toaster } from '@UI';
21
+
22
+ type SettingsResponse = {
23
+ userName: string;
24
+ settings: {
25
+ bybit: {
26
+ apiKey: string;
27
+ apiSecret: string;
28
+ };
29
+ token: string;
30
+ coinalyze: {
31
+ apiKey: string;
32
+ };
33
+ openai: {
34
+ apiKey: string;
35
+ apiEndpoint: string;
36
+ };
37
+ telegram: {
38
+ botToken: string;
39
+ chatId: string;
40
+ };
41
+ };
42
+ };
43
+
44
+ type SettingsErrorResponse = {
45
+ error?: string;
46
+ };
47
+
48
+ type SettingsViewState = {
49
+ userName: string;
50
+ bybitApiKey: string;
51
+ bybitApiSecret: string;
52
+ token: string;
53
+ coinalyzeApiKey: string;
54
+ openAIApiKey: string;
55
+ openAIApiEndpoint: string;
56
+ tgBotToken: string;
57
+ tgChatId: string;
58
+ };
59
+
60
+ type SettingsDraftState = Omit<SettingsViewState, 'userName'>;
61
+
62
+ type PasswordState = {
63
+ password: string;
64
+ confirmPassword: string;
65
+ };
66
+
67
+ type SectionName =
68
+ | 'bybit'
69
+ | 'password'
70
+ | 'token'
71
+ | 'coinalyze'
72
+ | 'openai'
73
+ | 'telegram';
74
+ type EditableField =
75
+ | 'bybitApiKey'
76
+ | 'bybitApiSecret'
77
+ | 'token'
78
+ | 'coinalyzeApiKey'
79
+ | 'openAIApiKey'
80
+ | 'openAIApiEndpoint'
81
+ | 'tgBotToken'
82
+ | 'tgChatId';
83
+
84
+ const EMPTY_SETTINGS: SettingsViewState = {
85
+ userName: '',
86
+ bybitApiKey: '',
87
+ bybitApiSecret: '',
88
+ token: '',
89
+ coinalyzeApiKey: '',
90
+ openAIApiKey: '',
91
+ openAIApiEndpoint: '',
92
+ tgBotToken: '',
93
+ tgChatId: '',
94
+ };
95
+
96
+ const EMPTY_DRAFTS: SettingsDraftState = {
97
+ bybitApiKey: '',
98
+ bybitApiSecret: '',
99
+ token: '',
100
+ coinalyzeApiKey: '',
101
+ openAIApiKey: '',
102
+ openAIApiEndpoint: '',
103
+ tgBotToken: '',
104
+ tgChatId: '',
105
+ };
106
+
107
+ const EMPTY_PASSWORDS: PasswordState = {
108
+ password: '',
109
+ confirmPassword: '',
110
+ };
111
+
112
+ const EMPTY_EDITING: Record<EditableField, boolean> = {
113
+ bybitApiKey: false,
114
+ bybitApiSecret: false,
115
+ token: false,
116
+ coinalyzeApiKey: false,
117
+ openAIApiKey: false,
118
+ openAIApiEndpoint: false,
119
+ tgBotToken: false,
120
+ tgChatId: false,
121
+ };
122
+
123
+ const SECTION_FIELDS: Record<
124
+ Exclude<SectionName, 'password'>,
125
+ EditableField[]
126
+ > = {
127
+ bybit: ['bybitApiKey', 'bybitApiSecret'],
128
+ token: ['token'],
129
+ coinalyze: ['coinalyzeApiKey'],
130
+ openai: ['openAIApiKey', 'openAIApiEndpoint'],
131
+ telegram: ['tgBotToken', 'tgChatId'],
132
+ };
133
+
134
+ const MASKED_FIELDS = new Set<EditableField>([
135
+ 'bybitApiKey',
136
+ 'bybitApiSecret',
137
+ 'token',
138
+ 'coinalyzeApiKey',
139
+ 'openAIApiKey',
140
+ 'tgBotToken',
141
+ ]);
142
+
143
+ const toViewState = (payload: SettingsResponse): SettingsViewState => ({
144
+ userName: payload.userName,
145
+ bybitApiKey: payload.settings.bybit.apiKey || '',
146
+ bybitApiSecret: payload.settings.bybit.apiSecret || '',
147
+ token: payload.settings.token || '',
148
+ coinalyzeApiKey: payload.settings.coinalyze.apiKey || '',
149
+ openAIApiKey: payload.settings.openai.apiKey || '',
150
+ openAIApiEndpoint: payload.settings.openai.apiEndpoint || '',
151
+ tgBotToken: payload.settings.telegram.botToken || '',
152
+ tgChatId: payload.settings.telegram.chatId || '',
153
+ });
154
+
155
+ const toDraftState = (view: SettingsViewState): SettingsDraftState => ({
156
+ ...EMPTY_DRAFTS,
157
+ openAIApiEndpoint: view.openAIApiEndpoint,
158
+ tgChatId: view.tgChatId,
159
+ });
160
+
161
+ const isSettingsResponse = (
162
+ payload: SettingsResponse | SettingsErrorResponse,
163
+ ): payload is SettingsResponse =>
164
+ Boolean(payload && typeof payload === 'object' && 'settings' in payload);
165
+
166
+ const getErrorMessage = (
167
+ payload: SettingsResponse | SettingsErrorResponse,
168
+ fallback: string,
169
+ ) =>
170
+ 'error' in payload && typeof payload.error === 'string' && payload.error
171
+ ? payload.error
172
+ : fallback;
173
+
174
+ export const AccountSettingsDrawer = () => {
175
+ const [open, setOpen] = useState(false);
176
+ const [loading, setLoading] = useState(false);
177
+ const [settings, setSettings] = useState<SettingsViewState>(EMPTY_SETTINGS);
178
+ const [drafts, setDrafts] = useState<SettingsDraftState>(EMPTY_DRAFTS);
179
+ const [passwords, setPasswords] = useState<PasswordState>(EMPTY_PASSWORDS);
180
+ const [editing, setEditing] =
181
+ useState<Record<EditableField, boolean>>(EMPTY_EDITING);
182
+ const [savingSection, setSavingSection] = useState<SectionName | null>(null);
183
+
184
+ const syncState = useCallback((payload: SettingsResponse) => {
185
+ const next = toViewState(payload);
186
+ setSettings(next);
187
+ setDrafts(toDraftState(next));
188
+ setEditing(EMPTY_EDITING);
189
+ }, []);
190
+
191
+ const loadSettings = useCallback(async () => {
192
+ setLoading(true);
193
+
194
+ try {
195
+ const response = await fetch('/api/user/settings', {
196
+ cache: 'no-store',
197
+ });
198
+ const payload = (await response.json()) as
199
+ | SettingsResponse
200
+ | SettingsErrorResponse;
201
+
202
+ if (!response.ok || !isSettingsResponse(payload)) {
203
+ throw new Error(
204
+ getErrorMessage(payload, 'Failed to load account settings'),
205
+ );
206
+ }
207
+
208
+ syncState(payload);
209
+ } catch (error) {
210
+ toaster.error({
211
+ title: 'Failed to load settings',
212
+ description: (error as Error).message,
213
+ });
214
+ } finally {
215
+ setLoading(false);
216
+ }
217
+ }, [syncState]);
218
+
219
+ useEffect(() => {
220
+ if (!open) {
221
+ return;
222
+ }
223
+
224
+ void loadSettings();
225
+ }, [open, loadSettings]);
226
+
227
+ const passwordError = useMemo(() => {
228
+ if (!passwords.password && !passwords.confirmPassword) {
229
+ return '';
230
+ }
231
+
232
+ if (!passwords.password) {
233
+ return 'Password is required';
234
+ }
235
+
236
+ if (passwords.password !== passwords.confirmPassword) {
237
+ return 'Passwords do not match';
238
+ }
239
+
240
+ return '';
241
+ }, [passwords.confirmPassword, passwords.password]);
242
+
243
+ const isSectionDirty = useCallback(
244
+ (section: SectionName) => {
245
+ if (section === 'password') {
246
+ return Boolean(passwords.password || passwords.confirmPassword);
247
+ }
248
+
249
+ if (section === 'openai') {
250
+ return (
251
+ Boolean(drafts.openAIApiKey.trim()) ||
252
+ drafts.openAIApiEndpoint !== settings.openAIApiEndpoint
253
+ );
254
+ }
255
+
256
+ if (section === 'coinalyze') {
257
+ return Boolean(drafts.coinalyzeApiKey.trim());
258
+ }
259
+
260
+ if (section === 'telegram') {
261
+ return (
262
+ Boolean(drafts.tgBotToken.trim()) ||
263
+ drafts.tgChatId !== settings.tgChatId
264
+ );
265
+ }
266
+
267
+ return SECTION_FIELDS[section].some((field) =>
268
+ Boolean(drafts[field].trim()),
269
+ );
270
+ },
271
+ [drafts, passwords.confirmPassword, passwords.password, settings],
272
+ );
273
+
274
+ const updateDraft = (field: EditableField, value: string) => {
275
+ setDrafts((current) => ({
276
+ ...current,
277
+ [field]: value,
278
+ }));
279
+ };
280
+
281
+ const resetFieldDraft = useCallback(
282
+ (field: EditableField) => {
283
+ setDrafts((current) => ({
284
+ ...current,
285
+ [field]: MASKED_FIELDS.has(field) ? '' : settings[field],
286
+ }));
287
+ },
288
+ [settings],
289
+ );
290
+
291
+ const cancelEditing = useCallback(
292
+ (field: EditableField) => {
293
+ setEditing((current) => ({
294
+ ...current,
295
+ [field]: false,
296
+ }));
297
+ resetFieldDraft(field);
298
+ },
299
+ [resetFieldDraft],
300
+ );
301
+
302
+ const enableEditing = useCallback(
303
+ (field: EditableField) => {
304
+ setEditing((current) => ({
305
+ ...current,
306
+ [field]: true,
307
+ }));
308
+ setDrafts((current) => ({
309
+ ...current,
310
+ [field]: MASKED_FIELDS.has(field) ? '' : settings[field],
311
+ }));
312
+ },
313
+ [settings],
314
+ );
315
+
316
+ const getSecretUpdateValue = (field: EditableField) => {
317
+ const trimmed = drafts[field].trim();
318
+ return trimmed ? trimmed : undefined;
319
+ };
320
+
321
+ const handleFieldBlur = useCallback(
322
+ (field: EditableField, value: string) => {
323
+ if (MASKED_FIELDS.has(field)) {
324
+ if (!value.trim()) {
325
+ cancelEditing(field);
326
+ }
327
+
328
+ return;
329
+ }
330
+
331
+ if (value === settings[field]) {
332
+ cancelEditing(field);
333
+ }
334
+ },
335
+ [cancelEditing, settings],
336
+ );
337
+
338
+ const saveSection = async (section: SectionName) => {
339
+ if (section !== 'password' && !isSectionDirty(section)) {
340
+ return;
341
+ }
342
+
343
+ if (section === 'password' && passwordError) {
344
+ toaster.error({
345
+ title: 'Password update failed',
346
+ description: passwordError,
347
+ });
348
+ return;
349
+ }
350
+
351
+ setSavingSection(section);
352
+
353
+ try {
354
+ const body =
355
+ section === 'bybit'
356
+ ? {
357
+ section,
358
+ data: {
359
+ apiKey: getSecretUpdateValue('bybitApiKey'),
360
+ apiSecret: getSecretUpdateValue('bybitApiSecret'),
361
+ },
362
+ }
363
+ : section === 'token'
364
+ ? {
365
+ section,
366
+ data: {
367
+ token: getSecretUpdateValue('token'),
368
+ },
369
+ }
370
+ : section === 'coinalyze'
371
+ ? {
372
+ section,
373
+ data: {
374
+ apiKey: getSecretUpdateValue('coinalyzeApiKey'),
375
+ },
376
+ }
377
+ : section === 'openai'
378
+ ? {
379
+ section,
380
+ data: {
381
+ apiKey: getSecretUpdateValue('openAIApiKey'),
382
+ apiEndpoint: getSecretUpdateValue('openAIApiEndpoint'),
383
+ },
384
+ }
385
+ : section === 'telegram'
386
+ ? {
387
+ section,
388
+ data: {
389
+ botToken: getSecretUpdateValue('tgBotToken'),
390
+ chatId:
391
+ drafts.tgChatId !== settings.tgChatId
392
+ ? drafts.tgChatId.trim()
393
+ : undefined,
394
+ },
395
+ }
396
+ : {
397
+ section,
398
+ data: {
399
+ password: passwords.password,
400
+ confirmPassword: passwords.confirmPassword,
401
+ },
402
+ };
403
+
404
+ const response = await fetch('/api/user/settings', {
405
+ method: 'PATCH',
406
+ headers: {
407
+ 'Content-Type': 'application/json',
408
+ },
409
+ body: JSON.stringify(body),
410
+ });
411
+ const payload = (await response.json()) as
412
+ | SettingsResponse
413
+ | SettingsErrorResponse;
414
+
415
+ if (!response.ok || !isSettingsResponse(payload)) {
416
+ throw new Error(
417
+ getErrorMessage(payload, 'Failed to save account settings'),
418
+ );
419
+ }
420
+
421
+ syncState(payload);
422
+
423
+ if (section === 'password') {
424
+ setPasswords(EMPTY_PASSWORDS);
425
+ }
426
+
427
+ toaster.success({
428
+ title: 'Settings saved',
429
+ description:
430
+ section === 'password'
431
+ ? 'Password updated successfully.'
432
+ : 'Account settings updated successfully.',
433
+ });
434
+ } catch (error) {
435
+ toaster.error({
436
+ title: 'Save failed',
437
+ description: (error as Error).message,
438
+ });
439
+ } finally {
440
+ setSavingSection(null);
441
+ }
442
+ };
443
+
444
+ const renderEditableField = ({
445
+ label,
446
+ field,
447
+ placeholder,
448
+ }: {
449
+ label: string;
450
+ field: EditableField;
451
+ placeholder?: string;
452
+ }) => {
453
+ const isEditing = editing[field];
454
+ const savedValue = settings[field];
455
+ const isMasked = MASKED_FIELDS.has(field);
456
+
457
+ return (
458
+ <Field.Root key={field} width="full">
459
+ <Field.Label>{label}</Field.Label>
460
+ <HStack align="stretch" width="full">
461
+ {isEditing ? (
462
+ <Input
463
+ flex="1"
464
+ minW="0"
465
+ value={drafts[field]}
466
+ placeholder={placeholder}
467
+ onChange={(event) => updateDraft(field, event.target.value)}
468
+ onBlur={(event) => handleFieldBlur(field, event.target.value)}
469
+ onKeyDown={(event) => {
470
+ if (event.key === 'Escape') {
471
+ event.preventDefault();
472
+ cancelEditing(field);
473
+ }
474
+ }}
475
+ autoFocus
476
+ fontFamily={isMasked ? 'mono' : undefined}
477
+ fontVariantNumeric="tabular-nums"
478
+ />
479
+ ) : (
480
+ <Flex
481
+ flex="1"
482
+ minW="0"
483
+ h="10"
484
+ px="3"
485
+ borderWidth="1px"
486
+ borderColor="whiteAlpha.200"
487
+ borderRadius="md"
488
+ bg="blackAlpha.300"
489
+ align="center"
490
+ color={savedValue ? 'gray.200' : 'gray.500'}
491
+ cursor="default"
492
+ userSelect="none"
493
+ >
494
+ <Text
495
+ width="full"
496
+ overflow="hidden"
497
+ whiteSpace="nowrap"
498
+ textOverflow="ellipsis"
499
+ color={savedValue ? 'gray.200' : 'gray.500'}
500
+ fontFamily={savedValue ? 'mono' : undefined}
501
+ fontVariantNumeric="tabular-nums"
502
+ >
503
+ {savedValue || 'Not set'}
504
+ </Text>
505
+ </Flex>
506
+ )}
507
+ <IconButton
508
+ aria-label={`Edit ${label}`}
509
+ size="md"
510
+ colorPalette="teal"
511
+ variant={isEditing ? 'solid' : 'outline'}
512
+ flexShrink={0}
513
+ onClick={() => enableEditing(field)}
514
+ >
515
+ <FiEdit2 />
516
+ </IconButton>
517
+ </HStack>
518
+ </Field.Root>
519
+ );
520
+ };
521
+
522
+ return (
523
+ <Drawer.Root
524
+ open={open}
525
+ onOpenChange={(event) => setOpen(event.open)}
526
+ size="xl"
527
+ >
528
+ <Drawer.Trigger asChild>
529
+ <IconButton
530
+ aria-label="Account settings"
531
+ size="md"
532
+ colorPalette="teal"
533
+ variant="outline"
534
+ >
535
+ <FiSettings />
536
+ </IconButton>
537
+ </Drawer.Trigger>
538
+ <Portal>
539
+ <Drawer.Backdrop />
540
+ <Drawer.Positioner>
541
+ <Drawer.Content display="flex" flexDirection="column">
542
+ <Drawer.Header>
543
+ <Drawer.Title>Account settings</Drawer.Title>
544
+ <Drawer.CloseTrigger asChild>
545
+ <CloseButton position="absolute" right="3" top="3" />
546
+ </Drawer.CloseTrigger>
547
+ </Drawer.Header>
548
+
549
+ <Drawer.Body overflowY="auto">
550
+ {loading ? (
551
+ <Flex minH="320px" align="center" justify="center">
552
+ <Spinner color="teal.300" size="lg" />
553
+ </Flex>
554
+ ) : (
555
+ <Stack gap={5}>
556
+ <Box>
557
+ <Text fontSize="sm" color="gray.400">
558
+ Signed in as
559
+ </Text>
560
+ <Text fontSize="lg" fontWeight="600">
561
+ {settings.userName || 'Unknown user'}
562
+ </Text>
563
+ </Box>
564
+
565
+ <Box
566
+ borderWidth="1px"
567
+ borderColor="gray.700"
568
+ borderRadius="lg"
569
+ p={4}
570
+ bg="gray.900"
571
+ >
572
+ <Stack gap={4}>
573
+ <Box>
574
+ <Text fontWeight="600">Bybit connection</Text>
575
+ <Text fontSize="sm" color="gray.400">
576
+ API credentials used for exchange access.
577
+ </Text>
578
+ </Box>
579
+ {renderEditableField({
580
+ label: 'BYBIT_API_KEY',
581
+ field: 'bybitApiKey',
582
+ placeholder: 'Enter a new Bybit API key',
583
+ })}
584
+ {renderEditableField({
585
+ label: 'BYBIT_API_SECRET',
586
+ field: 'bybitApiSecret',
587
+ placeholder: 'Enter a new Bybit API secret',
588
+ })}
589
+ <Flex justify="flex-end">
590
+ <Button
591
+ colorPalette="teal"
592
+ loading={savingSection === 'bybit'}
593
+ disabled={!isSectionDirty('bybit')}
594
+ onClick={() => saveSection('bybit')}
595
+ >
596
+ Save
597
+ </Button>
598
+ </Flex>
599
+ </Stack>
600
+ </Box>
601
+
602
+ <Box
603
+ borderWidth="1px"
604
+ borderColor="gray.700"
605
+ borderRadius="lg"
606
+ p={4}
607
+ bg="gray.900"
608
+ >
609
+ <Stack gap={4}>
610
+ <Box>
611
+ <Text fontWeight="600">OpenAI / LLM</Text>
612
+ <Text fontSize="sm" color="gray.400">
613
+ Stored in the user profile and used for AI analysis.
614
+ </Text>
615
+ </Box>
616
+ {renderEditableField({
617
+ label: 'OPENAI_API_ENDPOINT',
618
+ field: 'openAIApiEndpoint',
619
+ placeholder: 'Enter a new OpenAI API endpoint',
620
+ })}
621
+ {renderEditableField({
622
+ label: 'OPENAI_API_KEY',
623
+ field: 'openAIApiKey',
624
+ placeholder: 'Enter a new OpenAI API key',
625
+ })}
626
+ <Flex justify="flex-end">
627
+ <Button
628
+ colorPalette="teal"
629
+ loading={savingSection === 'openai'}
630
+ disabled={!isSectionDirty('openai')}
631
+ onClick={() => saveSection('openai')}
632
+ >
633
+ Save
634
+ </Button>
635
+ </Flex>
636
+ </Stack>
637
+ </Box>
638
+
639
+ <Box
640
+ borderWidth="1px"
641
+ borderColor="gray.700"
642
+ borderRadius="lg"
643
+ p={4}
644
+ bg="gray.900"
645
+ >
646
+ <Stack gap={4}>
647
+ <Box>
648
+ <Text fontWeight="600">Coinalyze</Text>
649
+ <Text fontSize="sm" color="gray.400">
650
+ API key stored in the user profile for derivatives
651
+ data ingestion.
652
+ </Text>
653
+ </Box>
654
+ {renderEditableField({
655
+ label: 'COINALYZE_API_KEY',
656
+ field: 'coinalyzeApiKey',
657
+ placeholder: 'Enter a new Coinalyze API key',
658
+ })}
659
+ <Flex justify="flex-end">
660
+ <Button
661
+ colorPalette="teal"
662
+ loading={savingSection === 'coinalyze'}
663
+ disabled={!isSectionDirty('coinalyze')}
664
+ onClick={() => saveSection('coinalyze')}
665
+ >
666
+ Save
667
+ </Button>
668
+ </Flex>
669
+ </Stack>
670
+ </Box>
671
+
672
+ <Box
673
+ borderWidth="1px"
674
+ borderColor="gray.700"
675
+ borderRadius="lg"
676
+ p={4}
677
+ bg="gray.900"
678
+ >
679
+ <Stack gap={4}>
680
+ <Box>
681
+ <Text fontWeight="600">Telegram</Text>
682
+ <Text fontSize="sm" color="gray.400">
683
+ Bot credentials used for signal delivery.
684
+ </Text>
685
+ </Box>
686
+ {renderEditableField({
687
+ label: 'TG_BOT_TOKEN',
688
+ field: 'tgBotToken',
689
+ placeholder: 'Enter a new Telegram bot token',
690
+ })}
691
+ {renderEditableField({
692
+ label: 'TG_CHAT_ID',
693
+ field: 'tgChatId',
694
+ placeholder: 'Enter Telegram chat ID',
695
+ })}
696
+ <Flex justify="flex-end">
697
+ <Button
698
+ colorPalette="teal"
699
+ loading={savingSection === 'telegram'}
700
+ disabled={!isSectionDirty('telegram')}
701
+ onClick={() => saveSection('telegram')}
702
+ >
703
+ Save
704
+ </Button>
705
+ </Flex>
706
+ </Stack>
707
+ </Box>
708
+
709
+ <Box
710
+ borderWidth="1px"
711
+ borderColor="gray.700"
712
+ borderRadius="lg"
713
+ p={4}
714
+ bg="gray.900"
715
+ >
716
+ <Stack gap={4}>
717
+ <Box>
718
+ <Text fontWeight="600">Password</Text>
719
+ <Text fontSize="sm" color="gray.400">
720
+ Update the password used for sign in.
721
+ </Text>
722
+ </Box>
723
+ <Field.Root>
724
+ <Field.Label>Password</Field.Label>
725
+ <Input
726
+ type="password"
727
+ value={passwords.password}
728
+ onChange={(event) =>
729
+ setPasswords((current) => ({
730
+ ...current,
731
+ password: event.target.value,
732
+ }))
733
+ }
734
+ />
735
+ </Field.Root>
736
+ <Field.Root>
737
+ <Field.Label>Confirm password</Field.Label>
738
+ <Input
739
+ type="password"
740
+ value={passwords.confirmPassword}
741
+ onChange={(event) =>
742
+ setPasswords((current) => ({
743
+ ...current,
744
+ confirmPassword: event.target.value,
745
+ }))
746
+ }
747
+ />
748
+ </Field.Root>
749
+ {passwordError ? (
750
+ <Text fontSize="sm" color="red.300">
751
+ {passwordError}
752
+ </Text>
753
+ ) : null}
754
+ <Flex justify="flex-end">
755
+ <Button
756
+ colorPalette="teal"
757
+ loading={savingSection === 'password'}
758
+ disabled={
759
+ !isSectionDirty('password') ||
760
+ Boolean(passwordError)
761
+ }
762
+ onClick={() => saveSection('password')}
763
+ >
764
+ Save
765
+ </Button>
766
+ </Flex>
767
+ </Stack>
768
+ </Box>
769
+
770
+ <Box
771
+ borderWidth="1px"
772
+ borderColor="gray.700"
773
+ borderRadius="lg"
774
+ p={4}
775
+ bg="gray.900"
776
+ >
777
+ <Stack gap={4}>
778
+ <Box>
779
+ <Text fontWeight="600">Passwordless auth token</Text>
780
+ <Text fontSize="sm" color="gray.400">
781
+ Token accepted in dashboard links for instant auth.
782
+ </Text>
783
+ </Box>
784
+ {renderEditableField({
785
+ label: 'TOKEN',
786
+ field: 'token',
787
+ placeholder: 'Enter a new passwordless token',
788
+ })}
789
+ <Flex justify="flex-end">
790
+ <Button
791
+ colorPalette="teal"
792
+ loading={savingSection === 'token'}
793
+ disabled={!isSectionDirty('token')}
794
+ onClick={() => saveSection('token')}
795
+ >
796
+ Save
797
+ </Button>
798
+ </Flex>
799
+ </Stack>
800
+ </Box>
801
+ </Stack>
802
+ )}
803
+ </Drawer.Body>
804
+ </Drawer.Content>
805
+ </Drawer.Positioner>
806
+ </Portal>
807
+ </Drawer.Root>
808
+ );
809
+ };