@open-mercato/ui 0.6.7-develop.6661.1.0043ed1d03 → 0.6.7-develop.6670.1.4efa7961c6
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/.turbo/turbo-build.log +2 -2
- package/dist/backend/DataTable.js +9 -7
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/fields/phone.js +65 -0
- package/dist/backend/fields/phone.js.map +7 -0
- package/dist/backend/fields/registry.generated.js +1 -0
- package/dist/backend/fields/registry.generated.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +8 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +273 -21
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/utils/api.js +4 -2
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/customFieldDefs.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +18 -12
- package/src/backend/__tests__/custom-field-columns.test.ts +28 -0
- package/src/backend/__tests__/custom-field-filters.test.ts +3 -0
- package/src/backend/__tests__/custom-field-forms.test.ts +4 -0
- package/src/backend/fields/__tests__/phone.test.tsx +99 -0
- package/src/backend/fields/phone.tsx +98 -0
- package/src/backend/fields/registry.generated.ts +1 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
- package/src/backend/inputs/PhoneNumberField.tsx +307 -26
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +71 -1
- package/src/backend/utils/__tests__/api.test.ts +14 -0
- package/src/backend/utils/api.ts +12 -2
- package/src/backend/utils/customFieldDefs.ts +2 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { CrudCustomFieldRenderProps } from '../CrudForm'
|
|
5
|
+
import { FieldRegistry } from './registry'
|
|
6
|
+
import { PhoneNumberField, PHONE_COUNTRIES } from '../inputs/PhoneNumberField'
|
|
7
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectItemLeading,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from '../../primitives/select'
|
|
16
|
+
|
|
17
|
+
type PhoneFieldConfig = {
|
|
18
|
+
defaultCountryIso2?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type PhoneFieldDef = PhoneFieldConfig & {
|
|
22
|
+
configJson?: PhoneFieldConfig
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type PhoneFieldInputProps = CrudCustomFieldRenderProps & { def?: PhoneFieldDef }
|
|
26
|
+
|
|
27
|
+
// Sentinel used by the definition editor to represent "no fixed default"
|
|
28
|
+
// because Radix Select cannot use an empty string as an item value.
|
|
29
|
+
const AUTO_COUNTRY_VALUE = '__om_phone_auto__'
|
|
30
|
+
|
|
31
|
+
function PhoneFieldInput({ id, value, setValue, disabled, error, def }: PhoneFieldInputProps) {
|
|
32
|
+
const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value)
|
|
33
|
+
return (
|
|
34
|
+
<PhoneNumberField
|
|
35
|
+
id={id}
|
|
36
|
+
value={stringValue}
|
|
37
|
+
onValueChange={(next) => setValue(next ?? undefined)}
|
|
38
|
+
disabled={disabled}
|
|
39
|
+
externalError={error ?? null}
|
|
40
|
+
defaultCountryIso2={def?.defaultCountryIso2}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function PhoneFieldDefEditor({
|
|
46
|
+
def,
|
|
47
|
+
onChange,
|
|
48
|
+
}: {
|
|
49
|
+
def: { configJson?: PhoneFieldConfig } | undefined
|
|
50
|
+
onChange: (patch: Partial<PhoneFieldConfig>) => void
|
|
51
|
+
}) {
|
|
52
|
+
const t = useT()
|
|
53
|
+
const selected = typeof def?.configJson?.defaultCountryIso2 === 'string' ? def.configJson.defaultCountryIso2 : ''
|
|
54
|
+
return (
|
|
55
|
+
<div className="mt-3 space-y-3 rounded border border-dashed border-muted-foreground/40 bg-muted/30 p-3">
|
|
56
|
+
<div className="space-y-1">
|
|
57
|
+
<label className="text-xs font-medium text-muted-foreground">
|
|
58
|
+
{t('ui.customFields.phone.defaultCountry', 'Default country')}
|
|
59
|
+
</label>
|
|
60
|
+
<Select
|
|
61
|
+
value={selected || AUTO_COUNTRY_VALUE}
|
|
62
|
+
onValueChange={(next) => onChange({ defaultCountryIso2: next === AUTO_COUNTRY_VALUE ? undefined : next })}
|
|
63
|
+
>
|
|
64
|
+
<SelectTrigger size="sm">
|
|
65
|
+
<SelectValue />
|
|
66
|
+
</SelectTrigger>
|
|
67
|
+
<SelectContent align="start">
|
|
68
|
+
<SelectItem value={AUTO_COUNTRY_VALUE}>
|
|
69
|
+
{t('ui.customFields.phone.defaultCountryAuto', 'Auto-detect from value')}
|
|
70
|
+
</SelectItem>
|
|
71
|
+
{PHONE_COUNTRIES.map((country) => (
|
|
72
|
+
<SelectItem key={`${country.iso2}-${country.dialCode}`} value={country.iso2}>
|
|
73
|
+
<SelectItemLeading>
|
|
74
|
+
<span className="text-base leading-none">{country.flag}</span>
|
|
75
|
+
</SelectItemLeading>
|
|
76
|
+
<span className="flex-1 truncate">{country.label}</span>
|
|
77
|
+
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{country.dialCode}</span>
|
|
78
|
+
</SelectItem>
|
|
79
|
+
))}
|
|
80
|
+
</SelectContent>
|
|
81
|
+
</Select>
|
|
82
|
+
<p className="text-xs text-muted-foreground">
|
|
83
|
+
{t(
|
|
84
|
+
'ui.customFields.phone.defaultCountryHint',
|
|
85
|
+
'Pre-selects a country in the phone editor when the field is empty.',
|
|
86
|
+
)}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
FieldRegistry.register('phone', {
|
|
94
|
+
input: (props) => <PhoneFieldInput {...props} />,
|
|
95
|
+
defEditor: (props) => <PhoneFieldDefEditor {...props} />,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
export {}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// This file can be regenerated by module generators. Keep imports side-effectful.
|
|
3
3
|
import '@open-mercato/core/modules/attachments/fields/attachment'
|
|
4
4
|
import '@open-mercato/core/modules/dictionaries/fields/dictionary'
|
|
5
|
+
import './phone'
|
|
5
6
|
|
|
6
7
|
export function loadAll() {
|
|
7
8
|
// No-op: imports above perform registration via side effects
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
Book,
|
|
22
22
|
Bookmark,
|
|
23
23
|
Box,
|
|
24
|
+
Boxes,
|
|
24
25
|
Briefcase,
|
|
25
26
|
BriefcaseBusiness,
|
|
26
27
|
Building,
|
|
@@ -84,12 +85,14 @@ import {
|
|
|
84
85
|
Mail,
|
|
85
86
|
MailOpen,
|
|
86
87
|
MapPin,
|
|
88
|
+
MapPinned,
|
|
87
89
|
MessageCircle,
|
|
88
90
|
MinusCircle,
|
|
89
91
|
Notebook,
|
|
90
92
|
Package,
|
|
91
93
|
PackageCheck,
|
|
92
94
|
PackagePlus,
|
|
95
|
+
PackageSearch,
|
|
93
96
|
PackageX,
|
|
94
97
|
PauseCircle,
|
|
95
98
|
Percent,
|
|
@@ -141,6 +144,7 @@ import {
|
|
|
141
144
|
Users,
|
|
142
145
|
UserX,
|
|
143
146
|
Wallet,
|
|
147
|
+
Warehouse,
|
|
144
148
|
Webhook,
|
|
145
149
|
Workflow,
|
|
146
150
|
Wrench,
|
|
@@ -165,6 +169,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
165
169
|
'book': Book,
|
|
166
170
|
'bookmark': Bookmark,
|
|
167
171
|
'box': Box,
|
|
172
|
+
'boxes': Boxes,
|
|
168
173
|
'briefcase': Briefcase,
|
|
169
174
|
'briefcase-business': BriefcaseBusiness,
|
|
170
175
|
'building': Building,
|
|
@@ -228,12 +233,14 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
228
233
|
'mail': Mail,
|
|
229
234
|
'mail-open': MailOpen,
|
|
230
235
|
'map-pin': MapPin,
|
|
236
|
+
'map-pinned': MapPinned,
|
|
231
237
|
'message-circle': MessageCircle,
|
|
232
238
|
'minus-circle': MinusCircle,
|
|
233
239
|
'notebook': Notebook,
|
|
234
240
|
'package': Package,
|
|
235
241
|
'package-check': PackageCheck,
|
|
236
242
|
'package-plus': PackagePlus,
|
|
243
|
+
'package-search': PackageSearch,
|
|
237
244
|
'package-x': PackageX,
|
|
238
245
|
'pause-circle': PauseCircle,
|
|
239
246
|
'percent': Percent,
|
|
@@ -285,6 +292,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
285
292
|
'user-x': UserX,
|
|
286
293
|
'users': Users,
|
|
287
294
|
'wallet': Wallet,
|
|
295
|
+
'warehouse': Warehouse,
|
|
288
296
|
'webhook': Webhook,
|
|
289
297
|
'workflow': Workflow,
|
|
290
298
|
'wrench': Wrench,
|
|
@@ -30,35 +30,316 @@ export type PhoneCountry = {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* deterministic. The default fallback is the first entry.
|
|
33
|
+
* Emoji flag for an ISO 3166-1 alpha-2 code, assembled from the two regional
|
|
34
|
+
* indicator symbols. Deriving it keeps the (large) dictionary free of hand-typed
|
|
35
|
+
* emoji and guarantees the flag always matches the code.
|
|
37
36
|
*/
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
37
|
+
function iso2ToFlagEmoji(iso2: string): string {
|
|
38
|
+
const letters = iso2.toUpperCase().replace(/[^A-Z]/g, '')
|
|
39
|
+
if (letters.length !== 2) return ''
|
|
40
|
+
const base = 0x1f1e6
|
|
41
|
+
return String.fromCodePoint(
|
|
42
|
+
base + letters.charCodeAt(0) - 65,
|
|
43
|
+
base + letters.charCodeAt(1) - 65,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Complete static dictionary of geographic countries and territories that have
|
|
49
|
+
* an ISO 3166-1 alpha-2 code and an international calling code, based on the
|
|
50
|
+
* ITU / Wikipedia list of telephone country codes.
|
|
51
|
+
*
|
|
52
|
+
* Rules baked into this data:
|
|
53
|
+
* - Labels are English; flags are derived from the ISO code (`iso2ToFlagEmoji`).
|
|
54
|
+
* - Non-geographic / international service codes (`+800`, `+808`, `+870`,
|
|
55
|
+
* `+881`, `+882`, …) are intentionally excluded.
|
|
56
|
+
* - North American Numbering Plan territories carry their full `+1<NPA>` dial
|
|
57
|
+
* code (e.g. Bahamas `+1242`) so longest-prefix matching resolves them before
|
|
58
|
+
* the generic `+1`. The US and Canada BOTH use a bare `+1` and cannot be told
|
|
59
|
+
* apart from the prefix alone — auto-detection favours the US.
|
|
60
|
+
* - Several sovereign countries share a calling code with dependent territories
|
|
61
|
+
* (e.g. `+44` UK/Guernsey/Jersey/Isle of Man); `PRIMARY_DIAL_OWNERS` records
|
|
62
|
+
* the country that wins auto-detection for those shared codes.
|
|
63
|
+
*/
|
|
64
|
+
const RAW_PHONE_COUNTRIES: Array<Omit<PhoneCountry, 'flag'>> = [
|
|
65
|
+
{ iso2: 'US', dialCode: '+1', label: 'United States' },
|
|
66
|
+
{ iso2: 'CA', dialCode: '+1', label: 'Canada' },
|
|
67
|
+
{ iso2: 'AG', dialCode: '+1268', label: 'Antigua and Barbuda' },
|
|
68
|
+
{ iso2: 'AI', dialCode: '+1264', label: 'Anguilla' },
|
|
69
|
+
{ iso2: 'AS', dialCode: '+1684', label: 'American Samoa' },
|
|
70
|
+
{ iso2: 'BB', dialCode: '+1246', label: 'Barbados' },
|
|
71
|
+
{ iso2: 'BM', dialCode: '+1441', label: 'Bermuda' },
|
|
72
|
+
{ iso2: 'BS', dialCode: '+1242', label: 'Bahamas' },
|
|
73
|
+
{ iso2: 'DM', dialCode: '+1767', label: 'Dominica' },
|
|
74
|
+
{ iso2: 'DO', dialCode: '+1809', label: 'Dominican Republic' },
|
|
75
|
+
{ iso2: 'GD', dialCode: '+1473', label: 'Grenada' },
|
|
76
|
+
{ iso2: 'GU', dialCode: '+1671', label: 'Guam' },
|
|
77
|
+
{ iso2: 'JM', dialCode: '+1876', label: 'Jamaica' },
|
|
78
|
+
{ iso2: 'KN', dialCode: '+1869', label: 'Saint Kitts and Nevis' },
|
|
79
|
+
{ iso2: 'KY', dialCode: '+1345', label: 'Cayman Islands' },
|
|
80
|
+
{ iso2: 'LC', dialCode: '+1758', label: 'Saint Lucia' },
|
|
81
|
+
{ iso2: 'MP', dialCode: '+1670', label: 'Northern Mariana Islands' },
|
|
82
|
+
{ iso2: 'MS', dialCode: '+1664', label: 'Montserrat' },
|
|
83
|
+
{ iso2: 'PR', dialCode: '+1787', label: 'Puerto Rico' },
|
|
84
|
+
{ iso2: 'SX', dialCode: '+1721', label: 'Sint Maarten' },
|
|
85
|
+
{ iso2: 'TC', dialCode: '+1649', label: 'Turks and Caicos Islands' },
|
|
86
|
+
{ iso2: 'TT', dialCode: '+1868', label: 'Trinidad and Tobago' },
|
|
87
|
+
{ iso2: 'VC', dialCode: '+1784', label: 'Saint Vincent and the Grenadines' },
|
|
88
|
+
{ iso2: 'VG', dialCode: '+1284', label: 'British Virgin Islands' },
|
|
89
|
+
{ iso2: 'VI', dialCode: '+1340', label: 'U.S. Virgin Islands' },
|
|
90
|
+
{ iso2: 'EG', dialCode: '+20', label: 'Egypt' },
|
|
91
|
+
{ iso2: 'SS', dialCode: '+211', label: 'South Sudan' },
|
|
92
|
+
{ iso2: 'MA', dialCode: '+212', label: 'Morocco' },
|
|
93
|
+
{ iso2: 'EH', dialCode: '+212', label: 'Western Sahara' },
|
|
94
|
+
{ iso2: 'DZ', dialCode: '+213', label: 'Algeria' },
|
|
95
|
+
{ iso2: 'TN', dialCode: '+216', label: 'Tunisia' },
|
|
96
|
+
{ iso2: 'LY', dialCode: '+218', label: 'Libya' },
|
|
97
|
+
{ iso2: 'GM', dialCode: '+220', label: 'Gambia' },
|
|
98
|
+
{ iso2: 'SN', dialCode: '+221', label: 'Senegal' },
|
|
99
|
+
{ iso2: 'MR', dialCode: '+222', label: 'Mauritania' },
|
|
100
|
+
{ iso2: 'ML', dialCode: '+223', label: 'Mali' },
|
|
101
|
+
{ iso2: 'GN', dialCode: '+224', label: 'Guinea' },
|
|
102
|
+
{ iso2: 'CI', dialCode: '+225', label: "Côte d'Ivoire" },
|
|
103
|
+
{ iso2: 'BF', dialCode: '+226', label: 'Burkina Faso' },
|
|
104
|
+
{ iso2: 'NE', dialCode: '+227', label: 'Niger' },
|
|
105
|
+
{ iso2: 'TG', dialCode: '+228', label: 'Togo' },
|
|
106
|
+
{ iso2: 'BJ', dialCode: '+229', label: 'Benin' },
|
|
107
|
+
{ iso2: 'MU', dialCode: '+230', label: 'Mauritius' },
|
|
108
|
+
{ iso2: 'LR', dialCode: '+231', label: 'Liberia' },
|
|
109
|
+
{ iso2: 'SL', dialCode: '+232', label: 'Sierra Leone' },
|
|
110
|
+
{ iso2: 'GH', dialCode: '+233', label: 'Ghana' },
|
|
111
|
+
{ iso2: 'NG', dialCode: '+234', label: 'Nigeria' },
|
|
112
|
+
{ iso2: 'TD', dialCode: '+235', label: 'Chad' },
|
|
113
|
+
{ iso2: 'CF', dialCode: '+236', label: 'Central African Republic' },
|
|
114
|
+
{ iso2: 'CM', dialCode: '+237', label: 'Cameroon' },
|
|
115
|
+
{ iso2: 'CV', dialCode: '+238', label: 'Cape Verde' },
|
|
116
|
+
{ iso2: 'ST', dialCode: '+239', label: 'São Tomé and Príncipe' },
|
|
117
|
+
{ iso2: 'GQ', dialCode: '+240', label: 'Equatorial Guinea' },
|
|
118
|
+
{ iso2: 'GA', dialCode: '+241', label: 'Gabon' },
|
|
119
|
+
{ iso2: 'CG', dialCode: '+242', label: 'Republic of the Congo' },
|
|
120
|
+
{ iso2: 'CD', dialCode: '+243', label: 'DR Congo' },
|
|
121
|
+
{ iso2: 'AO', dialCode: '+244', label: 'Angola' },
|
|
122
|
+
{ iso2: 'GW', dialCode: '+245', label: 'Guinea-Bissau' },
|
|
123
|
+
{ iso2: 'IO', dialCode: '+246', label: 'British Indian Ocean Territory' },
|
|
124
|
+
{ iso2: 'SC', dialCode: '+248', label: 'Seychelles' },
|
|
125
|
+
{ iso2: 'SD', dialCode: '+249', label: 'Sudan' },
|
|
126
|
+
{ iso2: 'RW', dialCode: '+250', label: 'Rwanda' },
|
|
127
|
+
{ iso2: 'ET', dialCode: '+251', label: 'Ethiopia' },
|
|
128
|
+
{ iso2: 'SO', dialCode: '+252', label: 'Somalia' },
|
|
129
|
+
{ iso2: 'DJ', dialCode: '+253', label: 'Djibouti' },
|
|
130
|
+
{ iso2: 'KE', dialCode: '+254', label: 'Kenya' },
|
|
131
|
+
{ iso2: 'TZ', dialCode: '+255', label: 'Tanzania' },
|
|
132
|
+
{ iso2: 'UG', dialCode: '+256', label: 'Uganda' },
|
|
133
|
+
{ iso2: 'BI', dialCode: '+257', label: 'Burundi' },
|
|
134
|
+
{ iso2: 'MZ', dialCode: '+258', label: 'Mozambique' },
|
|
135
|
+
{ iso2: 'ZM', dialCode: '+260', label: 'Zambia' },
|
|
136
|
+
{ iso2: 'MG', dialCode: '+261', label: 'Madagascar' },
|
|
137
|
+
{ iso2: 'RE', dialCode: '+262', label: 'Réunion' },
|
|
138
|
+
{ iso2: 'YT', dialCode: '+262', label: 'Mayotte' },
|
|
139
|
+
{ iso2: 'ZW', dialCode: '+263', label: 'Zimbabwe' },
|
|
140
|
+
{ iso2: 'NA', dialCode: '+264', label: 'Namibia' },
|
|
141
|
+
{ iso2: 'MW', dialCode: '+265', label: 'Malawi' },
|
|
142
|
+
{ iso2: 'LS', dialCode: '+266', label: 'Lesotho' },
|
|
143
|
+
{ iso2: 'BW', dialCode: '+267', label: 'Botswana' },
|
|
144
|
+
{ iso2: 'SZ', dialCode: '+268', label: 'Eswatini' },
|
|
145
|
+
{ iso2: 'KM', dialCode: '+269', label: 'Comoros' },
|
|
146
|
+
{ iso2: 'ZA', dialCode: '+27', label: 'South Africa' },
|
|
147
|
+
{ iso2: 'SH', dialCode: '+290', label: 'Saint Helena' },
|
|
148
|
+
{ iso2: 'ER', dialCode: '+291', label: 'Eritrea' },
|
|
149
|
+
{ iso2: 'AW', dialCode: '+297', label: 'Aruba' },
|
|
150
|
+
{ iso2: 'FO', dialCode: '+298', label: 'Faroe Islands' },
|
|
151
|
+
{ iso2: 'GL', dialCode: '+299', label: 'Greenland' },
|
|
152
|
+
{ iso2: 'GR', dialCode: '+30', label: 'Greece' },
|
|
153
|
+
{ iso2: 'NL', dialCode: '+31', label: 'Netherlands' },
|
|
154
|
+
{ iso2: 'BE', dialCode: '+32', label: 'Belgium' },
|
|
155
|
+
{ iso2: 'FR', dialCode: '+33', label: 'France' },
|
|
156
|
+
{ iso2: 'ES', dialCode: '+34', label: 'Spain' },
|
|
157
|
+
{ iso2: 'GI', dialCode: '+350', label: 'Gibraltar' },
|
|
158
|
+
{ iso2: 'PT', dialCode: '+351', label: 'Portugal' },
|
|
159
|
+
{ iso2: 'LU', dialCode: '+352', label: 'Luxembourg' },
|
|
160
|
+
{ iso2: 'IE', dialCode: '+353', label: 'Ireland' },
|
|
161
|
+
{ iso2: 'IS', dialCode: '+354', label: 'Iceland' },
|
|
162
|
+
{ iso2: 'AL', dialCode: '+355', label: 'Albania' },
|
|
163
|
+
{ iso2: 'MT', dialCode: '+356', label: 'Malta' },
|
|
164
|
+
{ iso2: 'CY', dialCode: '+357', label: 'Cyprus' },
|
|
165
|
+
{ iso2: 'FI', dialCode: '+358', label: 'Finland' },
|
|
166
|
+
{ iso2: 'AX', dialCode: '+358', label: 'Åland Islands' },
|
|
167
|
+
{ iso2: 'BG', dialCode: '+359', label: 'Bulgaria' },
|
|
168
|
+
{ iso2: 'HU', dialCode: '+36', label: 'Hungary' },
|
|
169
|
+
{ iso2: 'LT', dialCode: '+370', label: 'Lithuania' },
|
|
170
|
+
{ iso2: 'LV', dialCode: '+371', label: 'Latvia' },
|
|
171
|
+
{ iso2: 'EE', dialCode: '+372', label: 'Estonia' },
|
|
172
|
+
{ iso2: 'MD', dialCode: '+373', label: 'Moldova' },
|
|
173
|
+
{ iso2: 'AM', dialCode: '+374', label: 'Armenia' },
|
|
174
|
+
{ iso2: 'BY', dialCode: '+375', label: 'Belarus' },
|
|
175
|
+
{ iso2: 'AD', dialCode: '+376', label: 'Andorra' },
|
|
176
|
+
{ iso2: 'MC', dialCode: '+377', label: 'Monaco' },
|
|
177
|
+
{ iso2: 'SM', dialCode: '+378', label: 'San Marino' },
|
|
178
|
+
{ iso2: 'VA', dialCode: '+379', label: 'Vatican City' },
|
|
179
|
+
{ iso2: 'UA', dialCode: '+380', label: 'Ukraine' },
|
|
180
|
+
{ iso2: 'RS', dialCode: '+381', label: 'Serbia' },
|
|
181
|
+
{ iso2: 'ME', dialCode: '+382', label: 'Montenegro' },
|
|
182
|
+
{ iso2: 'XK', dialCode: '+383', label: 'Kosovo' },
|
|
183
|
+
{ iso2: 'HR', dialCode: '+385', label: 'Croatia' },
|
|
184
|
+
{ iso2: 'SI', dialCode: '+386', label: 'Slovenia' },
|
|
185
|
+
{ iso2: 'BA', dialCode: '+387', label: 'Bosnia and Herzegovina' },
|
|
186
|
+
{ iso2: 'MK', dialCode: '+389', label: 'North Macedonia' },
|
|
187
|
+
{ iso2: 'IT', dialCode: '+39', label: 'Italy' },
|
|
188
|
+
{ iso2: 'RO', dialCode: '+40', label: 'Romania' },
|
|
189
|
+
{ iso2: 'CH', dialCode: '+41', label: 'Switzerland' },
|
|
190
|
+
{ iso2: 'CZ', dialCode: '+420', label: 'Czechia' },
|
|
191
|
+
{ iso2: 'SK', dialCode: '+421', label: 'Slovakia' },
|
|
192
|
+
{ iso2: 'LI', dialCode: '+423', label: 'Liechtenstein' },
|
|
193
|
+
{ iso2: 'AT', dialCode: '+43', label: 'Austria' },
|
|
194
|
+
{ iso2: 'GB', dialCode: '+44', label: 'United Kingdom' },
|
|
195
|
+
{ iso2: 'GG', dialCode: '+44', label: 'Guernsey' },
|
|
196
|
+
{ iso2: 'JE', dialCode: '+44', label: 'Jersey' },
|
|
197
|
+
{ iso2: 'IM', dialCode: '+44', label: 'Isle of Man' },
|
|
198
|
+
{ iso2: 'DK', dialCode: '+45', label: 'Denmark' },
|
|
199
|
+
{ iso2: 'SE', dialCode: '+46', label: 'Sweden' },
|
|
200
|
+
{ iso2: 'NO', dialCode: '+47', label: 'Norway' },
|
|
201
|
+
{ iso2: 'SJ', dialCode: '+47', label: 'Svalbard and Jan Mayen' },
|
|
202
|
+
{ iso2: 'PL', dialCode: '+48', label: 'Poland' },
|
|
203
|
+
{ iso2: 'DE', dialCode: '+49', label: 'Germany' },
|
|
204
|
+
{ iso2: 'FK', dialCode: '+500', label: 'Falkland Islands' },
|
|
205
|
+
{ iso2: 'BZ', dialCode: '+501', label: 'Belize' },
|
|
206
|
+
{ iso2: 'GT', dialCode: '+502', label: 'Guatemala' },
|
|
207
|
+
{ iso2: 'SV', dialCode: '+503', label: 'El Salvador' },
|
|
208
|
+
{ iso2: 'HN', dialCode: '+504', label: 'Honduras' },
|
|
209
|
+
{ iso2: 'NI', dialCode: '+505', label: 'Nicaragua' },
|
|
210
|
+
{ iso2: 'CR', dialCode: '+506', label: 'Costa Rica' },
|
|
211
|
+
{ iso2: 'PA', dialCode: '+507', label: 'Panama' },
|
|
212
|
+
{ iso2: 'PM', dialCode: '+508', label: 'Saint Pierre and Miquelon' },
|
|
213
|
+
{ iso2: 'HT', dialCode: '+509', label: 'Haiti' },
|
|
214
|
+
{ iso2: 'PE', dialCode: '+51', label: 'Peru' },
|
|
215
|
+
{ iso2: 'MX', dialCode: '+52', label: 'Mexico' },
|
|
216
|
+
{ iso2: 'CU', dialCode: '+53', label: 'Cuba' },
|
|
217
|
+
{ iso2: 'AR', dialCode: '+54', label: 'Argentina' },
|
|
218
|
+
{ iso2: 'BR', dialCode: '+55', label: 'Brazil' },
|
|
219
|
+
{ iso2: 'CL', dialCode: '+56', label: 'Chile' },
|
|
220
|
+
{ iso2: 'CO', dialCode: '+57', label: 'Colombia' },
|
|
221
|
+
{ iso2: 'VE', dialCode: '+58', label: 'Venezuela' },
|
|
222
|
+
{ iso2: 'GP', dialCode: '+590', label: 'Guadeloupe' },
|
|
223
|
+
{ iso2: 'BL', dialCode: '+590', label: 'Saint Barthélemy' },
|
|
224
|
+
{ iso2: 'MF', dialCode: '+590', label: 'Saint Martin' },
|
|
225
|
+
{ iso2: 'BO', dialCode: '+591', label: 'Bolivia' },
|
|
226
|
+
{ iso2: 'GY', dialCode: '+592', label: 'Guyana' },
|
|
227
|
+
{ iso2: 'EC', dialCode: '+593', label: 'Ecuador' },
|
|
228
|
+
{ iso2: 'GF', dialCode: '+594', label: 'French Guiana' },
|
|
229
|
+
{ iso2: 'PY', dialCode: '+595', label: 'Paraguay' },
|
|
230
|
+
{ iso2: 'MQ', dialCode: '+596', label: 'Martinique' },
|
|
231
|
+
{ iso2: 'SR', dialCode: '+597', label: 'Suriname' },
|
|
232
|
+
{ iso2: 'UY', dialCode: '+598', label: 'Uruguay' },
|
|
233
|
+
{ iso2: 'CW', dialCode: '+599', label: 'Curaçao' },
|
|
234
|
+
{ iso2: 'BQ', dialCode: '+599', label: 'Caribbean Netherlands' },
|
|
235
|
+
{ iso2: 'MY', dialCode: '+60', label: 'Malaysia' },
|
|
236
|
+
{ iso2: 'AU', dialCode: '+61', label: 'Australia' },
|
|
237
|
+
{ iso2: 'CX', dialCode: '+61', label: 'Christmas Island' },
|
|
238
|
+
{ iso2: 'CC', dialCode: '+61', label: 'Cocos (Keeling) Islands' },
|
|
239
|
+
{ iso2: 'ID', dialCode: '+62', label: 'Indonesia' },
|
|
240
|
+
{ iso2: 'PH', dialCode: '+63', label: 'Philippines' },
|
|
241
|
+
{ iso2: 'NZ', dialCode: '+64', label: 'New Zealand' },
|
|
242
|
+
{ iso2: 'SG', dialCode: '+65', label: 'Singapore' },
|
|
243
|
+
{ iso2: 'TH', dialCode: '+66', label: 'Thailand' },
|
|
244
|
+
{ iso2: 'TL', dialCode: '+670', label: 'Timor-Leste' },
|
|
245
|
+
{ iso2: 'NF', dialCode: '+672', label: 'Norfolk Island' },
|
|
246
|
+
{ iso2: 'BN', dialCode: '+673', label: 'Brunei' },
|
|
247
|
+
{ iso2: 'NR', dialCode: '+674', label: 'Nauru' },
|
|
248
|
+
{ iso2: 'PG', dialCode: '+675', label: 'Papua New Guinea' },
|
|
249
|
+
{ iso2: 'TO', dialCode: '+676', label: 'Tonga' },
|
|
250
|
+
{ iso2: 'SB', dialCode: '+677', label: 'Solomon Islands' },
|
|
251
|
+
{ iso2: 'VU', dialCode: '+678', label: 'Vanuatu' },
|
|
252
|
+
{ iso2: 'FJ', dialCode: '+679', label: 'Fiji' },
|
|
253
|
+
{ iso2: 'PW', dialCode: '+680', label: 'Palau' },
|
|
254
|
+
{ iso2: 'WF', dialCode: '+681', label: 'Wallis and Futuna' },
|
|
255
|
+
{ iso2: 'CK', dialCode: '+682', label: 'Cook Islands' },
|
|
256
|
+
{ iso2: 'NU', dialCode: '+683', label: 'Niue' },
|
|
257
|
+
{ iso2: 'WS', dialCode: '+685', label: 'Samoa' },
|
|
258
|
+
{ iso2: 'KI', dialCode: '+686', label: 'Kiribati' },
|
|
259
|
+
{ iso2: 'NC', dialCode: '+687', label: 'New Caledonia' },
|
|
260
|
+
{ iso2: 'TV', dialCode: '+688', label: 'Tuvalu' },
|
|
261
|
+
{ iso2: 'PF', dialCode: '+689', label: 'French Polynesia' },
|
|
262
|
+
{ iso2: 'TK', dialCode: '+690', label: 'Tokelau' },
|
|
263
|
+
{ iso2: 'FM', dialCode: '+691', label: 'Micronesia' },
|
|
264
|
+
{ iso2: 'MH', dialCode: '+692', label: 'Marshall Islands' },
|
|
265
|
+
{ iso2: 'RU', dialCode: '+7', label: 'Russia' },
|
|
266
|
+
{ iso2: 'KZ', dialCode: '+7', label: 'Kazakhstan' },
|
|
267
|
+
{ iso2: 'JP', dialCode: '+81', label: 'Japan' },
|
|
268
|
+
{ iso2: 'KR', dialCode: '+82', label: 'South Korea' },
|
|
269
|
+
{ iso2: 'VN', dialCode: '+84', label: 'Vietnam' },
|
|
270
|
+
{ iso2: 'KP', dialCode: '+850', label: 'North Korea' },
|
|
271
|
+
{ iso2: 'HK', dialCode: '+852', label: 'Hong Kong' },
|
|
272
|
+
{ iso2: 'MO', dialCode: '+853', label: 'Macau' },
|
|
273
|
+
{ iso2: 'KH', dialCode: '+855', label: 'Cambodia' },
|
|
274
|
+
{ iso2: 'LA', dialCode: '+856', label: 'Laos' },
|
|
275
|
+
{ iso2: 'CN', dialCode: '+86', label: 'China' },
|
|
276
|
+
{ iso2: 'BD', dialCode: '+880', label: 'Bangladesh' },
|
|
277
|
+
{ iso2: 'TW', dialCode: '+886', label: 'Taiwan' },
|
|
278
|
+
{ iso2: 'TR', dialCode: '+90', label: 'Turkey' },
|
|
279
|
+
{ iso2: 'IN', dialCode: '+91', label: 'India' },
|
|
280
|
+
{ iso2: 'PK', dialCode: '+92', label: 'Pakistan' },
|
|
281
|
+
{ iso2: 'AF', dialCode: '+93', label: 'Afghanistan' },
|
|
282
|
+
{ iso2: 'LK', dialCode: '+94', label: 'Sri Lanka' },
|
|
283
|
+
{ iso2: 'MM', dialCode: '+95', label: 'Myanmar' },
|
|
284
|
+
{ iso2: 'MV', dialCode: '+960', label: 'Maldives' },
|
|
285
|
+
{ iso2: 'LB', dialCode: '+961', label: 'Lebanon' },
|
|
286
|
+
{ iso2: 'JO', dialCode: '+962', label: 'Jordan' },
|
|
287
|
+
{ iso2: 'SY', dialCode: '+963', label: 'Syria' },
|
|
288
|
+
{ iso2: 'IQ', dialCode: '+964', label: 'Iraq' },
|
|
289
|
+
{ iso2: 'KW', dialCode: '+965', label: 'Kuwait' },
|
|
290
|
+
{ iso2: 'SA', dialCode: '+966', label: 'Saudi Arabia' },
|
|
291
|
+
{ iso2: 'YE', dialCode: '+967', label: 'Yemen' },
|
|
292
|
+
{ iso2: 'OM', dialCode: '+968', label: 'Oman' },
|
|
293
|
+
{ iso2: 'PS', dialCode: '+970', label: 'Palestine' },
|
|
294
|
+
{ iso2: 'AE', dialCode: '+971', label: 'United Arab Emirates' },
|
|
295
|
+
{ iso2: 'IL', dialCode: '+972', label: 'Israel' },
|
|
296
|
+
{ iso2: 'BH', dialCode: '+973', label: 'Bahrain' },
|
|
297
|
+
{ iso2: 'QA', dialCode: '+974', label: 'Qatar' },
|
|
298
|
+
{ iso2: 'BT', dialCode: '+975', label: 'Bhutan' },
|
|
299
|
+
{ iso2: 'MN', dialCode: '+976', label: 'Mongolia' },
|
|
300
|
+
{ iso2: 'NP', dialCode: '+977', label: 'Nepal' },
|
|
301
|
+
{ iso2: 'TJ', dialCode: '+992', label: 'Tajikistan' },
|
|
302
|
+
{ iso2: 'TM', dialCode: '+993', label: 'Turkmenistan' },
|
|
303
|
+
{ iso2: 'AZ', dialCode: '+994', label: 'Azerbaijan' },
|
|
304
|
+
{ iso2: 'GE', dialCode: '+995', label: 'Georgia' },
|
|
305
|
+
{ iso2: 'KG', dialCode: '+996', label: 'Kyrgyzstan' },
|
|
306
|
+
{ iso2: 'UZ', dialCode: '+998', label: 'Uzbekistan' },
|
|
307
|
+
{ iso2: 'IR', dialCode: '+98', label: 'Iran' },
|
|
55
308
|
]
|
|
56
309
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
310
|
+
export const PHONE_COUNTRIES: PhoneCountry[] = RAW_PHONE_COUNTRIES
|
|
311
|
+
.map((country) => ({ ...country, flag: iso2ToFlagEmoji(country.iso2) }))
|
|
312
|
+
.sort((a, b) => a.label.localeCompare(b.label, 'en', { sensitivity: 'base' }))
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Sovereign/primary country that wins auto-detection for a calling code shared
|
|
316
|
+
* by several territories. US vs. Canada is the one irreducible ambiguity — both
|
|
317
|
+
* are `+1` — and it resolves to the US.
|
|
318
|
+
*/
|
|
319
|
+
const PRIMARY_DIAL_OWNERS: Record<string, string> = {
|
|
320
|
+
'+1': 'US',
|
|
321
|
+
'+7': 'RU',
|
|
322
|
+
'+44': 'GB',
|
|
323
|
+
'+47': 'NO',
|
|
324
|
+
'+61': 'AU',
|
|
325
|
+
'+212': 'MA',
|
|
326
|
+
'+262': 'RE',
|
|
327
|
+
'+358': 'FI',
|
|
328
|
+
'+590': 'GP',
|
|
329
|
+
'+599': 'CW',
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const DEFAULT_COUNTRY =
|
|
333
|
+
PHONE_COUNTRIES.find((country) => country.iso2 === 'US') ?? PHONE_COUNTRIES[0]
|
|
334
|
+
|
|
335
|
+
// Match longer prefixes first (e.g. `+1242` before `+1`); for equal-length codes
|
|
336
|
+
// shared by several territories, prefer the sovereign in `PRIMARY_DIAL_OWNERS`.
|
|
337
|
+
const COUNTRIES_BY_DIAL_LENGTH = [...PHONE_COUNTRIES].sort((a, b) => {
|
|
338
|
+
if (b.dialCode.length !== a.dialCode.length) return b.dialCode.length - a.dialCode.length
|
|
339
|
+
const aPrimary = PRIMARY_DIAL_OWNERS[a.dialCode] === a.iso2 ? 0 : 1
|
|
340
|
+
const bPrimary = PRIMARY_DIAL_OWNERS[b.dialCode] === b.iso2 ? 0 : 1
|
|
341
|
+
return aPrimary - bPrimary
|
|
342
|
+
})
|
|
62
343
|
|
|
63
344
|
function findCountryByIso(iso2: string): PhoneCountry | undefined {
|
|
64
345
|
return PHONE_COUNTRIES.find((c) => c.iso2 === iso2)
|
|
@@ -22,7 +22,7 @@ jest.mock('@open-mercato/shared/lib/phone', () => ({
|
|
|
22
22
|
|
|
23
23
|
import * as React from 'react'
|
|
24
24
|
import { fireEvent, render, screen } from '@testing-library/react'
|
|
25
|
-
import { PhoneNumberField } from '../PhoneNumberField'
|
|
25
|
+
import { PHONE_COUNTRIES, PhoneNumberField } from '../PhoneNumberField'
|
|
26
26
|
|
|
27
27
|
function PhoneFieldHarness(props: Partial<React.ComponentProps<typeof PhoneNumberField>>) {
|
|
28
28
|
const [value, setValue] = React.useState<string | undefined>(undefined)
|
|
@@ -76,4 +76,74 @@ describe('PhoneNumberField', () => {
|
|
|
76
76
|
expect(screen.queryByText('Use an international phone number.')).not.toBeInTheDocument()
|
|
77
77
|
expect(input).toHaveAttribute('aria-invalid', 'true')
|
|
78
78
|
})
|
|
79
|
+
|
|
80
|
+
it('honours a restricted country list via the countries prop', () => {
|
|
81
|
+
render(
|
|
82
|
+
<PhoneFieldHarness
|
|
83
|
+
value="+383 44 123 456"
|
|
84
|
+
countries={[{ iso2: 'PL', dialCode: '+48', label: 'Poland', flag: '🇵🇱' }]}
|
|
85
|
+
/>,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// The value still parses against the full dictionary (Kosovo), but the
|
|
89
|
+
// picker only offers the restricted list.
|
|
90
|
+
expect(screen.getByText('+383')).toBeInTheDocument()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('PhoneNumberField country dictionary', () => {
|
|
95
|
+
it('exposes a complete, well-formed geographic dictionary', () => {
|
|
96
|
+
expect(PHONE_COUNTRIES.length).toBeGreaterThan(200)
|
|
97
|
+
|
|
98
|
+
for (const country of PHONE_COUNTRIES) {
|
|
99
|
+
expect(country.iso2).toMatch(/^[A-Z]{2}$/)
|
|
100
|
+
expect(country.dialCode).toMatch(/^\+\d+$/)
|
|
101
|
+
expect(country.label.length).toBeGreaterThan(0)
|
|
102
|
+
expect(country.flag.length).toBeGreaterThan(0)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isoCodes = PHONE_COUNTRIES.map((country) => country.iso2)
|
|
106
|
+
expect(new Set(isoCodes).size).toBe(isoCodes.length)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('includes representative codes from every numbering zone', () => {
|
|
110
|
+
const dialByIso = new Map(PHONE_COUNTRIES.map((country) => [country.iso2, country.dialCode]))
|
|
111
|
+
expect(dialByIso.get('XK')).toBe('+383')
|
|
112
|
+
expect(dialByIso.get('BS')).toBe('+1242')
|
|
113
|
+
expect(dialByIso.get('BR')).toBe('+55')
|
|
114
|
+
expect(dialByIso.get('AU')).toBe('+61')
|
|
115
|
+
expect(dialByIso.get('JP')).toBe('+81')
|
|
116
|
+
expect(dialByIso.get('TR')).toBe('+90')
|
|
117
|
+
expect(dialByIso.get('PS')).toBe('+970')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('excludes non-geographic international service codes', () => {
|
|
121
|
+
for (const code of ['+800', '+808', '+870', '+881', '+882']) {
|
|
122
|
+
expect(PHONE_COUNTRIES.some((country) => country.dialCode === code)).toBe(false)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('derives each flag emoji from the ISO code', () => {
|
|
127
|
+
expect(PHONE_COUNTRIES.find((country) => country.iso2 === 'US')?.flag).toBe('🇺🇸')
|
|
128
|
+
expect(PHONE_COUNTRIES.find((country) => country.iso2 === 'XK')?.flag).toBe('🇽🇰')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('PhoneNumberField initial country detection', () => {
|
|
133
|
+
const cases: Array<[string, string]> = [
|
|
134
|
+
['+1242 555 0100', '+1242'],
|
|
135
|
+
['+1 212 555 1234', '+1'],
|
|
136
|
+
['+383 44 123 456', '+383'],
|
|
137
|
+
['+55 11 91234 5678', '+55'],
|
|
138
|
+
['+61 2 1234 5678', '+61'],
|
|
139
|
+
['+81 3 1234 5678', '+81'],
|
|
140
|
+
['+90 212 123 4567', '+90'],
|
|
141
|
+
['+970 59 123 4567', '+970'],
|
|
142
|
+
['+44 20 7946 0000', '+44'],
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
it.each(cases)('resolves %s to dial code %s (longest prefix, sovereign first)', (value, expected) => {
|
|
146
|
+
render(<PhoneFieldHarness value={value} />)
|
|
147
|
+
expect(screen.getByText(expected)).toBeInTheDocument()
|
|
148
|
+
})
|
|
79
149
|
})
|
|
@@ -95,6 +95,20 @@ describe('apiFetch', () => {
|
|
|
95
95
|
)
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
+
it('attaches requiredFeatures to ForbiddenError so callers can name the missing permission', async () => {
|
|
99
|
+
;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () =>
|
|
100
|
+
createMockResponse(403, {
|
|
101
|
+
error: 'Forbidden',
|
|
102
|
+
requiredFeatures: ['wms.manage_locations'],
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const rejection = await apiFetch('/api/wms/locations').catch((error: unknown) => error)
|
|
107
|
+
expect(rejection).toBeInstanceOf(ForbiddenError)
|
|
108
|
+
expect((rejection as ForbiddenError).requiredFeatures).toEqual(['wms.manage_locations'])
|
|
109
|
+
expect((rejection as ForbiddenError).status).toBe(403)
|
|
110
|
+
})
|
|
111
|
+
|
|
98
112
|
it('throws ForbiddenError when ACL hints are missing', async () => {
|
|
99
113
|
const response = createMockResponse(403, {
|
|
100
114
|
error: 'Forbidden',
|
package/src/backend/utils/api.ts
CHANGED
|
@@ -74,9 +74,17 @@ export function redirectToSessionRefresh() {
|
|
|
74
74
|
|
|
75
75
|
export class ForbiddenError extends Error {
|
|
76
76
|
readonly status = 403
|
|
77
|
-
|
|
77
|
+
readonly requiredFeatures: string[] | null
|
|
78
|
+
readonly requiredRoles: string[] | null
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
message = 'Forbidden',
|
|
82
|
+
options?: { requiredFeatures?: string[] | null; requiredRoles?: string[] | null },
|
|
83
|
+
) {
|
|
78
84
|
super(message)
|
|
79
85
|
this.name = 'ForbiddenError'
|
|
86
|
+
this.requiredFeatures = options?.requiredFeatures?.length ? [...options.requiredFeatures] : null
|
|
87
|
+
this.requiredRoles = options?.requiredRoles?.length ? [...options.requiredRoles] : null
|
|
80
88
|
}
|
|
81
89
|
}
|
|
82
90
|
|
|
@@ -220,7 +228,9 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|
|
220
228
|
} else {
|
|
221
229
|
msg = await res.clone().text().catch(() => 'Forbidden')
|
|
222
230
|
}
|
|
223
|
-
|
|
231
|
+
// Attach ACL hints so callers (e.g. flashMutationError) can name the
|
|
232
|
+
// missing permission instead of surfacing a bare "Forbidden" toast.
|
|
233
|
+
throw new ForbiddenError(msg, { requiredFeatures: features, requiredRoles: roles })
|
|
224
234
|
}
|
|
225
235
|
// If already on login, just return the response for the caller to handle
|
|
226
236
|
}
|