@vendure/dashboard 3.6.4-master-202605290309 → 3.6.4

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.
Files changed (43) hide show
  1. package/package.json +3 -3
  2. package/src/app/common/duplicate-entity-dialog.tsx +2 -1
  3. package/src/app/routes/_authenticated/_orders/components/fulfill-order-dialog.tsx +2 -1
  4. package/src/app/routes/_authenticated/_products/components/generate-variants-panel.tsx +133 -32
  5. package/src/i18n/locales/ar.po +97 -78
  6. package/src/i18n/locales/bg.po +97 -78
  7. package/src/i18n/locales/cs.po +97 -78
  8. package/src/i18n/locales/de.po +97 -78
  9. package/src/i18n/locales/en.po +97 -78
  10. package/src/i18n/locales/es.po +97 -78
  11. package/src/i18n/locales/fa.po +97 -78
  12. package/src/i18n/locales/fr.po +97 -78
  13. package/src/i18n/locales/he.po +97 -78
  14. package/src/i18n/locales/hr.po +97 -78
  15. package/src/i18n/locales/hu.po +97 -78
  16. package/src/i18n/locales/it.po +97 -78
  17. package/src/i18n/locales/ja.po +97 -78
  18. package/src/i18n/locales/nb.po +97 -78
  19. package/src/i18n/locales/ne.po +97 -78
  20. package/src/i18n/locales/nl.po +97 -78
  21. package/src/i18n/locales/pl.po +97 -78
  22. package/src/i18n/locales/pt_BR.po +97 -78
  23. package/src/i18n/locales/pt_PT.po +97 -78
  24. package/src/i18n/locales/ro.po +97 -78
  25. package/src/i18n/locales/ru.po +97 -78
  26. package/src/i18n/locales/sv.po +97 -78
  27. package/src/i18n/locales/tr.po +97 -78
  28. package/src/i18n/locales/uk.po +97 -78
  29. package/src/i18n/locales/zh_Hans.po +97 -78
  30. package/src/i18n/locales/zh_Hant.po +97 -78
  31. package/src/lib/components/data-input/affixed-input.tsx +2 -0
  32. package/src/lib/components/data-input/default-relation-input.tsx +60 -0
  33. package/src/lib/components/data-input/select-with-options.tsx +12 -5
  34. package/src/lib/components/shared/configurable-operation-multi-selector.tsx +2 -1
  35. package/src/lib/components/shared/configurable-operation-selector.tsx +2 -1
  36. package/src/lib/components/shared/configurable-operation-utils.spec.ts +49 -0
  37. package/src/lib/components/shared/configurable-operation-utils.ts +18 -0
  38. package/src/lib/framework/form-engine/form-schema-tools.spec.ts +39 -0
  39. package/src/lib/framework/form-engine/form-schema-tools.ts +72 -2
  40. package/src/lib/framework/form-engine/use-generated-form.tsx +13 -10
  41. package/src/lib/framework/form-engine/utils.spec.ts +50 -0
  42. package/src/lib/framework/form-engine/utils.ts +14 -0
  43. package/src/lib/providers/auth.tsx +52 -13
@@ -548,6 +548,66 @@ const createEntityConfigs = (i18n: any) => ({
548
548
  );
549
549
  },
550
550
  }),
551
+
552
+ Administrator: createRelationSelectorConfig({
553
+ idKey: 'id',
554
+ labelKey: 'lastName',
555
+ placeholder: i18n`Search administrator...`,
556
+ // Match the search term against any of the name/email fields independently
557
+ buildSearchFilter: (term: string) => ({
558
+ _or: [
559
+ { firstName: { contains: term } },
560
+ { lastName: { contains: term } },
561
+ { emailAddress: { contains: term } },
562
+ ],
563
+ }),
564
+ listQuery: graphql(`
565
+ query GetAdministratorsForRelationSelector($options: AdministratorListOptions) {
566
+ administrators(options: $options) {
567
+ items {
568
+ id
569
+ firstName
570
+ lastName
571
+ emailAddress
572
+ }
573
+ totalItems
574
+ }
575
+ }
576
+ `),
577
+ label: (item: any) => (
578
+ <EntityLabel
579
+ title={`${item.firstName} ${item.lastName}`}
580
+ subtitle={item.emailAddress}
581
+ placeholderLetter={item.firstName?.[0]?.toUpperCase() || 'A'}
582
+ rounded
583
+ tooltipText={`${item.firstName} ${item.lastName} (${item.emailAddress})`}
584
+ />
585
+ ),
586
+ }),
587
+
588
+ Seller: createRelationSelectorConfig({
589
+ ...createBaseEntityConfig('Seller', i18n),
590
+ listQuery: graphql(`
591
+ query GetSellersForRelationSelector($options: SellerListOptions) {
592
+ sellers(options: $options) {
593
+ items {
594
+ id
595
+ name
596
+ }
597
+ totalItems
598
+ }
599
+ }
600
+ `),
601
+ label: (item: any) => (
602
+ <EntityLabel
603
+ title={item.name}
604
+ subtitle={''}
605
+ placeholderLetter={item.name?.[0]?.toUpperCase() || 'S'}
606
+ rounded
607
+ tooltipText={item.name}
608
+ />
609
+ ),
610
+ }),
551
611
  });
552
612
 
553
613
  type DefaultRelationInputProps = DashboardFormComponentProps & {
@@ -8,6 +8,7 @@ import {
8
8
  } from '@/vdb/framework/form-engine/form-engine-types.js';
9
9
  import {
10
10
  extractFieldOptions,
11
+ isFieldNullable,
11
12
  isReadonlyField,
12
13
  isStringFieldWithOptions,
13
14
  isStringStructFieldWithOptions,
@@ -83,24 +84,30 @@ export function SelectWithOptions({
83
84
  }
84
85
 
85
86
  // For single fields, use regular Select
86
- const currentValue = value ?? '';
87
+ const isNullable = isFieldNullable(fieldDef);
88
+ const selectValue = isNullable ? (value == null || value === '' ? null : value) : (value ?? '');
87
89
 
88
- const handleValueChange = (value: string) => {
89
- if (value) {
90
- onChange(value);
90
+ const handleValueChange = (newValue: string | null) => {
91
+ if (isNullable) {
92
+ onChange(newValue ?? null);
93
+ } else if (newValue) {
94
+ onChange(newValue);
91
95
  }
92
96
  };
93
97
 
94
98
  const selectItems = Object.fromEntries(
95
99
  options.map(option => [option.value, option.label ? getTranslation(option.label) : option.value]),
96
100
  );
101
+ // Add a null item for nullable selects
102
+ if (isNullable) selectItems.null = '';
97
103
 
98
104
  return (
99
- <Select value={currentValue} onValueChange={handleValueChange} disabled={readOnly} items={selectItems}>
105
+ <Select value={selectValue} onValueChange={handleValueChange} disabled={readOnly} items={selectItems}>
100
106
  <SelectTrigger className="mb-0">
101
107
  <SelectValue placeholder={placeholder || <Trans>Select an option</Trans>} />
102
108
  </SelectTrigger>
103
109
  <SelectContent>
110
+ {isNullable && <SelectItem value={null}>{'\u00a0'}</SelectItem>}
104
111
  {options.map(option => (
105
112
  <SelectItem key={option.value} value={option.value}>
106
113
  {option.label ? getTranslation(option.label) : option.value}
@@ -14,6 +14,7 @@ import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@v
14
14
  import { Plus } from 'lucide-react';
15
15
  import { useCallback, useEffect, useRef } from 'react';
16
16
  import { ConfigurableOperationInput } from './configurable-operation-input.js';
17
+ import { getInitialConfigArgValue } from './configurable-operation-utils.js';
17
18
 
18
19
  /**
19
20
  * Props interface for ConfigurableOperationMultiSelector component
@@ -178,7 +179,7 @@ export function ConfigurableOperationMultiSelector({
178
179
  code: operation.code,
179
180
  arguments: operationDef.args.map(arg => ({
180
181
  name: arg.name,
181
- value: arg.defaultValue != null ? arg.defaultValue.toString() : arg.list ? '[]' : '',
182
+ value: getInitialConfigArgValue(arg),
182
183
  })),
183
184
  },
184
185
  ]);
@@ -12,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
12
12
  import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
13
13
  import { Plus } from 'lucide-react';
14
14
  import { ConfigurableOperationInput } from './configurable-operation-input.js';
15
+ import { getInitialConfigArgValue } from './configurable-operation-utils.js';
15
16
 
16
17
  /**
17
18
  * Props interface for ConfigurableOperationSelector component
@@ -103,7 +104,7 @@ export function ConfigurableOperationSelector({
103
104
  code: operation.code,
104
105
  arguments: operationDef.args.map(arg => ({
105
106
  name: arg.name,
106
- value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
107
+ value: getInitialConfigArgValue(arg),
107
108
  })),
108
109
  });
109
110
  };
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
4
+ import { getInitialConfigArgValue } from './configurable-operation-utils.js';
5
+
6
+ type ConfigArgDef = ConfigurableOperationDefFragment['args'][number];
7
+
8
+ const argDef = (overrides: Partial<ConfigArgDef>): ConfigArgDef =>
9
+ ({
10
+ name: 'test',
11
+ type: 'string',
12
+ required: true,
13
+ defaultValue: null,
14
+ list: false,
15
+ ui: null,
16
+ label: 'Test',
17
+ description: null,
18
+ ...overrides,
19
+ }) as ConfigArgDef;
20
+
21
+ describe('getInitialConfigArgValue', () => {
22
+ it('should initialize list args as JSON arrays', () => {
23
+ expect(getInitialConfigArgValue(argDef({ list: true }))).toBe('[]');
24
+ expect(getInitialConfigArgValue(argDef({ list: true, defaultValue: true }))).toBe('[true]');
25
+ });
26
+
27
+ it('should initialize boolean scalar args without defaults as false', () => {
28
+ expect(getInitialConfigArgValue(argDef({ type: 'boolean' }))).toBe('false');
29
+ });
30
+
31
+ it('should preserve scalar defaults and empty scalar fallback', () => {
32
+ expect(getInitialConfigArgValue(argDef({ defaultValue: 1 }))).toBe('1');
33
+ expect(getInitialConfigArgValue(argDef({ type: 'string' }))).toBe('');
34
+ });
35
+
36
+ // The `!= null` guard must treat falsy-but-defined defaults (0, false, '') as
37
+ // real values rather than absent. A naive truthy check (`if (arg.defaultValue)`)
38
+ // would drop these and fall through to the type-based fallbacks.
39
+ it('should preserve falsy-but-defined scalar defaults', () => {
40
+ expect(getInitialConfigArgValue(argDef({ type: 'int', defaultValue: 0 }))).toBe('0');
41
+ expect(getInitialConfigArgValue(argDef({ type: 'boolean', defaultValue: false }))).toBe('false');
42
+ expect(getInitialConfigArgValue(argDef({ type: 'string', defaultValue: '' }))).toBe('');
43
+ });
44
+
45
+ it('should wrap falsy-but-defined list defaults in a JSON array', () => {
46
+ expect(getInitialConfigArgValue(argDef({ list: true, defaultValue: 0 }))).toBe('[0]');
47
+ expect(getInitialConfigArgValue(argDef({ list: true, defaultValue: false }))).toBe('[false]');
48
+ });
49
+ });
@@ -0,0 +1,18 @@
1
+ import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
2
+
3
+ type ConfigurableOperationArgDef = ConfigurableOperationDefFragment['args'][number];
4
+
5
+ export function getInitialConfigArgValue(arg: ConfigurableOperationArgDef): string {
6
+ if (arg.list) {
7
+ return arg.defaultValue != null ? JSON.stringify([arg.defaultValue]) : '[]';
8
+ }
9
+ if (arg.defaultValue != null) {
10
+ return arg.defaultValue.toString();
11
+ }
12
+ // Required boolean args render as an off switch by default, so persist the
13
+ // same explicit false value that the UI communicates to validation.
14
+ if (arg.type === 'boolean') {
15
+ return 'false';
16
+ }
17
+ return '';
18
+ }
@@ -2,6 +2,7 @@ import { FieldInfo } from '@/vdb/framework/document-introspection/get-document-s
2
2
  import { describe, expect, it } from 'vitest';
3
3
 
4
4
  import {
5
+ applyNullableSelectCustomFieldDefaults,
5
6
  createFormSchemaFromFields,
6
7
  getDefaultValuesFromFields,
7
8
  getZodTypeFromField,
@@ -643,6 +644,44 @@ describe('form-schema-tools', () => {
643
644
  const defaults = getDefaultValuesFromFields(fields, 'en');
644
645
  expect(defaults.value).toBe(expected);
645
646
  });
647
+
648
+ it('nullable string custom field with options should default to null', () => {
649
+ const fields: FieldInfo[] = [
650
+ createMockField('customFields', 'Object', false, false, [
651
+ createMockField('featureType', 'String', true),
652
+ ]),
653
+ ];
654
+ const customFieldConfigs = [
655
+ {
656
+ name: 'featureType',
657
+ type: 'string',
658
+ nullable: true,
659
+ readonly: false,
660
+ list: false,
661
+ options: [{ value: 'standard' }, { value: 'premium' }],
662
+ },
663
+ ] as any[];
664
+
665
+ const defaults = getDefaultValuesFromFields(fields, 'en', customFieldConfigs);
666
+ expect(defaults.customFields.featureType).toBeNull();
667
+ });
668
+
669
+ it('applyNullableSelectCustomFieldDefaults should normalize empty string to null', () => {
670
+ const values = { customFields: { featureType: '' } };
671
+ const customFieldConfigs = [
672
+ {
673
+ name: 'featureType',
674
+ type: 'string',
675
+ nullable: true,
676
+ readonly: false,
677
+ list: false,
678
+ options: [{ value: 'a' }],
679
+ },
680
+ ] as any[];
681
+
682
+ const result = applyNullableSelectCustomFieldDefaults(values, customFieldConfigs);
683
+ expect(result.customFields.featureType).toBeNull();
684
+ });
646
685
  });
647
686
 
648
687
  describe('createFormSchemaFromFields - edge cases', () => {
@@ -4,6 +4,7 @@ import {
4
4
  isScalarType,
5
5
  } from '@/vdb/framework/document-introspection/get-document-structure.js';
6
6
  import {
7
+ ConfigurableFieldDef,
7
8
  CustomFieldConfig,
8
9
  DateTimeCustomFieldConfig,
9
10
  FloatCustomFieldConfig,
@@ -12,6 +13,7 @@ import {
12
13
  StructCustomFieldConfig,
13
14
  StructField,
14
15
  } from '@/vdb/framework/form-engine/form-engine-types.js';
16
+ import { isStringFieldWithOptions, isStructCustomFieldConfig } from '@/vdb/framework/form-engine/utils.js';
15
17
  import { z, ZodRawShape, ZodType, ZodTypeAny } from '@/vdb/lib/zod.js';
16
18
 
17
19
  function mapGraphQLCustomFieldToConfig(field: StructField) {
@@ -335,7 +337,11 @@ export function createFormSchemaFromFields(
335
337
  return z.object(schemaConfig);
336
338
  }
337
339
 
338
- export function getDefaultValuesFromFields(fields: FieldInfo[], defaultLanguageCode?: string) {
340
+ export function getDefaultValuesFromFields(
341
+ fields: FieldInfo[],
342
+ defaultLanguageCode?: string,
343
+ customFieldConfigs?: CustomFieldConfig[],
344
+ ) {
339
345
  const defaultValues: Record<string, any> = {};
340
346
  for (const field of fields) {
341
347
  if (field.typeInfo) {
@@ -349,7 +355,71 @@ export function getDefaultValuesFromFields(fields: FieldInfo[], defaultLanguageC
349
355
  defaultValues[field.name] = getDefaultValueFromField(field, defaultLanguageCode);
350
356
  }
351
357
  }
352
- return defaultValues;
358
+ return applyNullableSelectCustomFieldDefaults(defaultValues, customFieldConfigs);
359
+ }
360
+
361
+ /**
362
+ * Nullable string fields with options should default to `null`, not `''`.
363
+ */
364
+ export function applyNullableSelectCustomFieldDefaults<T extends Record<string, any>>(
365
+ values: T,
366
+ customFieldConfigs?: CustomFieldConfig[],
367
+ ): T {
368
+ if (!customFieldConfigs?.length || values.customFields == null) return values;
369
+
370
+ return {
371
+ ...values,
372
+ customFields: applyNullableSelectDefaultsToCustomFieldsObject(
373
+ values.customFields,
374
+ customFieldConfigs,
375
+ ),
376
+ };
377
+ }
378
+
379
+ function applyNullableSelectDefaultsToCustomFieldsObject(
380
+ customFieldsDefaults: Record<string, any>,
381
+ configs: CustomFieldConfig[],
382
+ ): Record<string, any> {
383
+ const result = { ...customFieldsDefaults };
384
+ for (const config of configs) {
385
+ const fieldDef = config as ConfigurableFieldDef;
386
+ if (isStructCustomFieldConfig(fieldDef)) {
387
+ const structValue = result[fieldDef.name];
388
+ if (structValue && typeof structValue === 'object' && !Array.isArray(structValue)) {
389
+ result[fieldDef.name] = applyNullableSelectDefaultsToStructFields(
390
+ structValue,
391
+ fieldDef.fields,
392
+ );
393
+ }
394
+ } else if (
395
+ config.type === 'string' &&
396
+ isStringFieldWithOptions(config as ConfigurableFieldDef) &&
397
+ config.nullable !== false &&
398
+ (result[config.name] === '' || result[config.name] === undefined)
399
+ ) {
400
+ result[config.name] = null;
401
+ }
402
+ }
403
+ return result;
404
+ }
405
+
406
+ function applyNullableSelectDefaultsToStructFields(
407
+ structDefaults: Record<string, any>,
408
+ fields: StructField[],
409
+ ): Record<string, any> {
410
+ const result = { ...structDefaults };
411
+ for (const field of fields) {
412
+ const subFieldConfig = mapGraphQLCustomFieldToConfig(field);
413
+ if (
414
+ subFieldConfig.type === 'string' &&
415
+ isStringFieldWithOptions(subFieldConfig as ConfigurableFieldDef) &&
416
+ subFieldConfig.nullable !== false &&
417
+ (result[field.name] === '' || result[field.name] === undefined)
418
+ ) {
419
+ result[field.name] = null;
420
+ }
421
+ }
422
+ return result;
353
423
  }
354
424
 
355
425
  export function getDefaultValueFromField(field: FieldInfo, defaultLanguageCode?: string) {
@@ -6,7 +6,11 @@ import { useForm } from 'react-hook-form';
6
6
  import { useChannel } from '../../hooks/use-channel.js';
7
7
  import { useServerConfig } from '../../hooks/use-server-config.js';
8
8
  import { getOperationVariablesFields } from '../document-introspection/get-document-structure.js';
9
- import { createFormSchemaFromFields, getDefaultValuesFromFields } from './form-schema-tools.js';
9
+ import {
10
+ applyNullableSelectCustomFieldDefaults,
11
+ createFormSchemaFromFields,
12
+ getDefaultValuesFromFields,
13
+ } from './form-schema-tools.js';
10
14
  import {
11
15
  convertEmptyStringsToNull,
12
16
  removeEmptyIdFields,
@@ -133,8 +137,8 @@ export function useGeneratedForm<
133
137
  [updateFields, customFieldConfig],
134
138
  );
135
139
  const defaultValues = useMemo(
136
- () => getDefaultValuesFromFields(updateFields, activeChannel?.defaultLanguageCode),
137
- [updateFields, activeChannel?.defaultLanguageCode],
140
+ () => getDefaultValuesFromFields(updateFields, activeChannel?.defaultLanguageCode, customFieldConfig),
141
+ [updateFields, activeChannel?.defaultLanguageCode, customFieldConfig],
138
142
  );
139
143
  const processedEntity = useMemo(
140
144
  () => ensureTranslationsForAllLanguages(entity, availableLanguages, defaultValues),
@@ -147,13 +151,12 @@ export function useGeneratedForm<
147
151
  [defaultValues, availableLanguages],
148
152
  );
149
153
 
150
- const values = useMemo(
151
- () =>
152
- processedEntity
153
- ? transformRelationFields(updateFields, setValuesRef.current(processedEntity))
154
- : processedDefaultValues,
155
- [processedEntity, processedDefaultValues, updateFields],
156
- );
154
+ const values = useMemo(() => {
155
+ const raw = processedEntity
156
+ ? transformRelationFields(updateFields, setValuesRef.current(processedEntity))
157
+ : processedDefaultValues;
158
+ return applyNullableSelectCustomFieldDefaults(raw, customFieldConfig);
159
+ }, [processedEntity, processedDefaultValues, updateFields, customFieldConfig]);
157
160
 
158
161
  const form = useForm({
159
162
  resolver: async (values, context, options) => {
@@ -5,10 +5,12 @@ import { FieldInfo, getOperationVariablesFields } from '../document-introspectio
5
5
 
6
6
  import {
7
7
  convertEmptyStringsToNull,
8
+ isFieldNullable,
8
9
  removeEmptyIdFields,
9
10
  stripNullNullableFields,
10
11
  transformRelationFields,
11
12
  } from './utils.js';
13
+ import { ConfigurableFieldDef } from "./form-engine-types.js";
12
14
 
13
15
  const createProductDocument = graphql(`
14
16
  mutation CreateProduct($input: CreateProductInput!) {
@@ -674,3 +676,51 @@ describe('stripNullNullableFields', () => {
674
676
  expect(result).toEqual({});
675
677
  });
676
678
  });
679
+
680
+ describe('isFieldNullable', () => {
681
+ it('should return true for nullable custom fields', () => {
682
+ expect(
683
+ isFieldNullable({
684
+ name: 'featureType',
685
+ type: 'string',
686
+ nullable: true,
687
+ readonly: false,
688
+ list: false,
689
+ } as ConfigurableFieldDef),
690
+ ).toBe(true);
691
+ });
692
+
693
+ it('should return false for non-nullable custom fields', () => {
694
+ expect(
695
+ isFieldNullable({
696
+ name: 'priority',
697
+ type: 'string',
698
+ nullable: false,
699
+ readonly: false,
700
+ list: false,
701
+ } as ConfigurableFieldDef),
702
+ ).toBe(false);
703
+ });
704
+
705
+ it('should return true for nullable struct sub-fields', () => {
706
+ expect(
707
+ isFieldNullable({
708
+ name: 'kind',
709
+ type: 'string',
710
+ nullable: true,
711
+ options: [{ value: 'a' }],
712
+ } as ConfigurableFieldDef),
713
+ ).toBe(true);
714
+ });
715
+
716
+ it('should return false for configurable operation args', () => {
717
+ expect(
718
+ isFieldNullable({
719
+ name: 'arg',
720
+ type: 'string',
721
+ list: false,
722
+ ui: { options: [{ value: 'a' }] },
723
+ } as ConfigurableFieldDef),
724
+ ).toBe(false);
725
+ });
726
+ });
@@ -444,6 +444,20 @@ export function isNullableField(input: ConfigurableFieldDef): boolean {
444
444
  return isCustomFieldConfig(input) && Boolean(input.nullable);
445
445
  }
446
446
 
447
+ /**
448
+ * Determines if a custom field or struct sub-field allows null values.
449
+ * Configurable operation args are never treated as nullable.
450
+ */
451
+ export function isFieldNullable(input: ConfigurableFieldDef | StructField): boolean {
452
+ if (isCustomFieldConfig(input as ConfigurableFieldDef)) {
453
+ return (input as ConfigurableFieldDef & { nullable?: boolean }).nullable !== false;
454
+ }
455
+ if ('nullable' in input && input.nullable) {
456
+ return true;
457
+ }
458
+ return false;
459
+ }
460
+
447
461
  /**
448
462
  * Handles nested form submission to prevent event bubbling in nested forms.
449
463
  * This is useful when you have a form inside a dialog that's within another form.
@@ -135,6 +135,9 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
135
135
  }
136
136
  }, [settings.activeChannelId, currentUserData?.me?.channels]);
137
137
 
138
+ // Determine isAuthenticated from currentUserData
139
+ const isAuthenticated = !!currentUserData?.me?.id;
140
+
138
141
  // Auth actions
139
142
  const login = React.useCallback(
140
143
  (username: string, password: string, onLoginSuccess?: () => void) => {
@@ -187,26 +190,62 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
187
190
 
188
191
  const logout = React.useCallback(
189
192
  async (onLogoutSuccess?: () => void) => {
193
+ // Clear any stale error from a previous logout attempt. Matches the
194
+ // login() pattern (line 149 below clears it on the success path)
195
+ // and ensures UI doesn't keep rendering a previous failure's
196
+ // message during a retry.
197
+ setAuthenticationError(undefined);
190
198
  setIsLoginLogoutInProgress(true);
191
199
  setStatus('verifying');
192
- api.mutate(LogOutMutation)({}).then(async data => {
193
- if (data?.logout.success) {
194
- // Clear all cached queries to prevent stale data
195
- queryClient.clear();
196
- // Clear selected channel from localStorage
197
- localStorage.removeItem(LS_KEY_SELECTED_CHANNEL_TOKEN);
200
+ // Try block scoped to the mutation call only. Exceptions thrown
201
+ // by the success-branch side-effects below (queryClient.clear,
202
+ // localStorage.removeItem, onLogoutSuccess) propagate naturally
203
+ // rather than being misclassified as transport failures.
204
+ let data;
205
+ try {
206
+ data = await api.mutate(LogOutMutation)({});
207
+ } catch (error) {
208
+ // Network/server failure. Transport failure doesn't tell us
209
+ // whether the server applied the logout, so refetch the
210
+ // current user to determine actual state rather than trusting
211
+ // the cached isAuthenticated snapshot (staleTime: Infinity
212
+ // means react-query won't auto-refetch this query).
213
+ setAuthenticationError(error instanceof Error ? error.message : String(error));
214
+ const { data: refreshedData, error: refreshedError } = await refetchCurrentUser();
215
+ if (refreshedError || !refreshedData?.me?.id) {
198
216
  setStatus('unauthenticated');
199
- setIsLoginLogoutInProgress(false);
200
- onLogoutSuccess?.();
217
+ } else {
218
+ setStatus('authenticated');
201
219
  }
202
- });
220
+ setIsLoginLogoutInProgress(false);
221
+ return;
222
+ }
223
+
224
+ if (data?.logout.success) {
225
+ // Clear all cached queries to prevent stale data
226
+ queryClient.clear();
227
+ try {
228
+ // localStorage can throw (quota exceeded, Safari private
229
+ // mode, security errors, storage disabled). The server-side
230
+ // logout already succeeded — don't let storage cleanup
231
+ // failure block the UI state transition below.
232
+ localStorage.removeItem(LS_KEY_SELECTED_CHANNEL_TOKEN);
233
+ } catch {
234
+ // intentionally swallowed — see comment above
235
+ }
236
+ setStatus('unauthenticated');
237
+ setIsLoginLogoutInProgress(false);
238
+ onLogoutSuccess?.();
239
+ } else {
240
+ // Server responded but reported success=false. Restore the
241
+ // pre-logout authenticated status so the UI is interactive again.
242
+ setStatus(isAuthenticated ? 'authenticated' : 'unauthenticated');
243
+ setIsLoginLogoutInProgress(false);
244
+ }
203
245
  },
204
- [queryClient],
246
+ [queryClient, isAuthenticated, refetchCurrentUser],
205
247
  );
206
248
 
207
- // Determine isAuthenticated from currentUserData
208
- const isAuthenticated = !!currentUserData?.me?.id;
209
-
210
249
  // Handle status transitions based on query state
211
250
  React.useEffect(() => {
212
251
  // Don't change status if we're in the middle of login/logout