diff --git a/common/mac.go b/common/mac.go new file mode 100644 index 000000000..1e1055e02 --- /dev/null +++ b/common/mac.go @@ -0,0 +1,16 @@ +package common + +import ( + "fmt" + "net" + "strings" +) + +// NormalizeMacAddress validates an EUI-48 address and returns its canonical form. +func NormalizeMacAddress(value string) (string, error) { + hardwareAddress, err := net.ParseMAC(strings.TrimSpace(value)) + if err != nil || len(hardwareAddress) != 6 { + return "", fmt.Errorf("invalid MAC address: %q", value) + } + return strings.ToLower(hardwareAddress.String()), nil +} diff --git a/common/mac_test.go b/common/mac_test.go new file mode 100644 index 000000000..86e5d7feb --- /dev/null +++ b/common/mac_test.go @@ -0,0 +1,36 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeMacAddress(t *testing.T) { + tests := []struct { + name string + input string + expected string + valid bool + }{ + {name: "colon separated", input: "94:b6:09:f6:4f:41", expected: "94:b6:09:f6:4f:41", valid: true}, + {name: "hyphen separated uppercase", input: "94-B6-09-F6-4F-41", expected: "94:b6:09:f6:4f:41", valid: true}, + {name: "surrounding whitespace", input: " 94:b6:09:f6:4f:41 ", expected: "94:b6:09:f6:4f:41", valid: true}, + {name: "missing octet", input: "94:b6:09:f6:4f", valid: false}, + {name: "eui64 is not a client MAC", input: "94:b6:09:f6:4f:41:00:01", valid: false}, + {name: "empty", input: "", valid: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := NormalizeMacAddress(test.input) + if !test.valid { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expected, actual) + }) + } +} diff --git a/controller/token.go b/controller/token.go index 836e9b295..e65ae0ea0 100644 --- a/controller/token.go +++ b/controller/token.go @@ -175,6 +175,10 @@ func AddToken(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) return } + if err := token.NormalizeMacLimits(); err != nil { + common.ApiError(c, err) + return + } // 非无限额度时,检查额度值是否超出有效范围 if !token.UnlimitedQuota { if token.RemainQuota < 0 { @@ -219,6 +223,8 @@ func AddToken(c *gin.Context) { ModelLimitsEnabled: token.ModelLimitsEnabled, ModelLimits: token.ModelLimits, AllowIps: token.AllowIps, + MacCheckEnabled: token.MacCheckEnabled, + AllowMacs: token.AllowMacs, Group: token.Group, CrossGroupRetry: token.CrossGroupRetry, } @@ -260,6 +266,10 @@ func UpdateToken(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) return } + if err := token.NormalizeMacLimits(); err != nil { + common.ApiError(c, err) + return + } if !token.UnlimitedQuota { if token.RemainQuota < 0 { common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative) @@ -297,6 +307,8 @@ func UpdateToken(c *gin.Context) { cleanToken.ModelLimitsEnabled = token.ModelLimitsEnabled cleanToken.ModelLimits = token.ModelLimits cleanToken.AllowIps = token.AllowIps + cleanToken.MacCheckEnabled = token.MacCheckEnabled + cleanToken.AllowMacs = token.AllowMacs cleanToken.Group = token.Group cleanToken.CrossGroupRetry = token.CrossGroupRetry } diff --git a/controller/token_test.go b/controller/token_test.go index b9b661524..1d566eaa0 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -311,6 +311,11 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin } migrateTokenControllerTestDB(t, db) + for _, column := range []string{"mac_check_enabled", "allow_macs"} { + if !db.Migrator().HasColumn(&model.Token{}, column) { + t.Fatalf("expected migrated token schema to contain %s", column) + } + } if got := getTokenKeyColumnType(t, db, dialect); got != "varchar(128)" { t.Fatalf("expected migrated key column type varchar(128), got %q", got) @@ -504,6 +509,33 @@ func TestGetTokenMasksKeyInResponse(t *testing.T) { } } +func TestAddTokenPersistsMacValidation(t *testing.T) { + db := setupTokenControllerTestDB(t) + body := map[string]any{ + "name": "mac-restricted-token", + "expired_time": -1, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + "mac_check_enabled": true, + "allow_macs": "94-B6-09-F6-4F-41", + "group": "default", + "cross_group_retry": false, + } + + ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/", body, 1) + AddToken(ctx) + + response := decodeAPIResponse(t, recorder) + require.True(t, response.Success, "expected success response, got message: %s", response.Message) + + var created model.Token + require.NoError(t, db.First(&created, "user_id = ? AND name = ?", 1, "mac-restricted-token").Error) + require.True(t, created.MacCheckEnabled) + require.NotNil(t, created.AllowMacs) + require.Equal(t, "94:b6:09:f6:4f:41", *created.AllowMacs) +} + func TestUpdateTokenMasksKeyInResponse(t *testing.T) { db := setupTokenControllerTestDB(t) token := seedToken(t, db, 1, "editable-token", "yzab1234cdef5678") @@ -516,6 +548,8 @@ func TestUpdateTokenMasksKeyInResponse(t *testing.T) { "unlimited_quota": true, "model_limits_enabled": false, "model_limits": "", + "mac_check_enabled": true, + "allow_macs": "94-B6-09-F6-4F-41\n00:11:22:33:44:55", "group": "default", "cross_group_retry": false, } @@ -538,6 +572,12 @@ func TestUpdateTokenMasksKeyInResponse(t *testing.T) { if strings.Contains(recorder.Body.String(), token.Key) { t.Fatalf("update response leaked raw token key: %s", recorder.Body.String()) } + + var updated model.Token + require.NoError(t, db.First(&updated, token.Id).Error) + require.True(t, updated.MacCheckEnabled) + require.NotNil(t, updated.AllowMacs) + require.Equal(t, "94:b6:09:f6:4f:41\n00:11:22:33:44:55", *updated.AllowMacs) } func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) { diff --git a/middleware/auth.go b/middleware/auth.go index 86abddc79..4981c6c05 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -402,6 +402,21 @@ func TokenAuth() func(c *gin.Context) { logger.LogDebug(c, "Client IP %s passed the token IP restrictions check", clientIp) } + if token.MacCheckEnabled { + clientMac := c.Request.Header.Get("X-Client-Mac") + allowed, err := tokenAllowsMac(token, clientMac) + if err != nil { + common.SysLog(fmt.Sprintf("TokenAuth invalid MAC whitelist configuration for token %d: %v", token.Id, err)) + abortWithOpenAiMessage(c, http.StatusInternalServerError, "MAC address whitelist configuration is invalid") + return + } + if !allowed { + abortWithOpenAiMessage(c, http.StatusForbidden, "Your MAC address is not allowed by this token", types.ErrorCodeAccessDenied) + return + } + logger.LogDebug(c, "Client MAC %s passed the token MAC restrictions check", clientMac) + } + userCache, err := model.GetUserCache(token.UserId) if err != nil { common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err)) @@ -444,6 +459,30 @@ func TokenAuth() func(c *gin.Context) { } } +func tokenAllowsMac(token *model.Token, clientMac string) (bool, error) { + if token == nil { + return false, fmt.Errorf("token is nil") + } + if !token.MacCheckEnabled { + return true, nil + } + + normalizedClientMac, err := common.NormalizeMacAddress(clientMac) + if err != nil { + return false, nil + } + for _, allowedMac := range token.GetMacLimits() { + normalizedAllowedMac, err := common.NormalizeMacAddress(allowedMac) + if err != nil { + return false, err + } + if normalizedClientMac == normalizedAllowedMac { + return true, nil + } + } + return false, nil +} + func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error { if token == nil { return fmt.Errorf("token is nil") diff --git a/middleware/auth_mac_test.go b/middleware/auth_mac_test.go new file mode 100644 index 000000000..6f883204e --- /dev/null +++ b/middleware/auth_mac_test.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "testing" + + "github.com/QuantumNous/new-api/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTokenAllowsMac(t *testing.T) { + allowedMacs := "94:b6:09:f6:4f:41\n00:11:22:33:44:55" + invalidMacs := "not-a-mac" + + tests := []struct { + name string + token *model.Token + clientMac string + allowed bool + wantError bool + }{ + {name: "disabled token skips validation", token: &model.Token{}, allowed: true}, + {name: "enabled token accepts listed MAC", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}, clientMac: "94-B6-09-F6-4F-41", allowed: true}, + {name: "enabled token rejects missing header", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}}, + {name: "enabled token rejects malformed header", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}, clientMac: "invalid"}, + {name: "enabled token rejects unlisted MAC", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}, clientMac: "aa:bb:cc:dd:ee:ff"}, + {name: "invalid configured MAC fails closed", token: &model.Token{MacCheckEnabled: true, AllowMacs: &invalidMacs}, clientMac: "94:b6:09:f6:4f:41", wantError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + allowed, err := tokenAllowsMac(test.token, test.clientMac) + if test.wantError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.allowed, allowed) + }) + } +} diff --git a/model/token.go b/model/token.go index 92f0b61e7..1c2930d59 100644 --- a/model/token.go +++ b/model/token.go @@ -25,6 +25,8 @@ type Token struct { ModelLimitsEnabled bool `json:"model_limits_enabled"` ModelLimits string `json:"model_limits" gorm:"type:text"` AllowIps *string `json:"allow_ips" gorm:"default:''"` + MacCheckEnabled bool `json:"mac_check_enabled"` + AllowMacs *string `json:"allow_macs" gorm:"type:text"` UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota Group string `json:"group" gorm:"default:''"` CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效 @@ -78,6 +80,54 @@ func (token *Token) GetIpLimits() []string { return ipLimits } +func (token *Token) GetMacLimits() []string { + macLimits := make([]string, 0) + if token.AllowMacs == nil { + return macLimits + } + cleanMacs := strings.ReplaceAll(*token.AllowMacs, " ", "") + if cleanMacs == "" { + return macLimits + } + for _, mac := range strings.Split(cleanMacs, "\n") { + mac = strings.TrimSpace(strings.ReplaceAll(mac, ",", "")) + if mac != "" { + macLimits = append(macLimits, mac) + } + } + return macLimits +} + +func (token *Token) NormalizeMacLimits() error { + macLimits := token.GetMacLimits() + if len(macLimits) == 0 { + empty := "" + token.AllowMacs = &empty + if token.MacCheckEnabled { + return errors.New("MAC address whitelist is required when MAC validation is enabled") + } + return nil + } + + normalizedMacs := make([]string, 0, len(macLimits)) + seen := make(map[string]struct{}, len(macLimits)) + for _, mac := range macLimits { + normalizedMac, err := common.NormalizeMacAddress(mac) + if err != nil { + return err + } + if _, exists := seen[normalizedMac]; exists { + continue + } + seen[normalizedMac] = struct{}{} + normalizedMacs = append(normalizedMacs, normalizedMac) + } + + normalized := strings.Join(normalizedMacs, "\n") + token.AllowMacs = &normalized + return nil +} + func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) { var tokens []*Token var err error @@ -321,7 +371,7 @@ func (token *Token) Update() (err error) { } }() err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota", - "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry").Updates(token).Error + "model_limits_enabled", "model_limits", "allow_ips", "mac_check_enabled", "allow_macs", "group", "cross_group_retry").Updates(token).Error return err } diff --git a/model/token_mac_test.go b/model/token_mac_test.go new file mode 100644 index 000000000..c656f0664 --- /dev/null +++ b/model/token_mac_test.go @@ -0,0 +1,40 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeMacLimits(t *testing.T) { + t.Run("enabled token requires whitelist", func(t *testing.T) { + token := Token{MacCheckEnabled: true} + + require.Error(t, token.NormalizeMacLimits()) + require.NotNil(t, token.AllowMacs) + assert.Empty(t, *token.AllowMacs) + }) + + t.Run("normalizes and deduplicates addresses", func(t *testing.T) { + allowMacs := "94-B6-09-F6-4F-41\n94:b6:09:f6:4f:41\n00:11:22:33:44:55" + token := Token{MacCheckEnabled: true, AllowMacs: &allowMacs} + + require.NoError(t, token.NormalizeMacLimits()) + require.NotNil(t, token.AllowMacs) + assert.Equal(t, "94:b6:09:f6:4f:41\n00:11:22:33:44:55", *token.AllowMacs) + }) + + t.Run("rejects invalid address", func(t *testing.T) { + allowMacs := "not-a-mac" + token := Token{AllowMacs: &allowMacs} + + require.Error(t, token.NormalizeMacLimits()) + }) + + t.Run("disabled token accepts empty whitelist", func(t *testing.T) { + token := Token{} + + require.NoError(t, token.NormalizeMacLimits()) + }) +} diff --git a/web/classic/src/components/table/tokens/modals/EditTokenModal.jsx b/web/classic/src/components/table/tokens/modals/EditTokenModal.jsx index 9112658bd..400c8973b 100644 --- a/web/classic/src/components/table/tokens/modals/EditTokenModal.jsx +++ b/web/classic/src/components/table/tokens/modals/EditTokenModal.jsx @@ -79,6 +79,8 @@ const EditTokenModal = (props) => { model_limits_enabled: false, model_limits: [], allow_ips: '', + mac_check_enabled: false, + allow_macs: '', group: '', cross_group_retry: false, tokenCount: 1, @@ -552,7 +554,10 @@ const EditTokenModal = (props) => { ? `▾ ${t('收起原生额度输入')}` : `▸ ${t('使用原生额度输入')}`} -
+
{ style={{ width: '100%' }} /> + + + + {values.mac_check_enabled && ( + + + + )}
diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index 363926b25..8b2df21a4 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -3830,6 +3830,10 @@ "默认折叠侧边栏": "Default collapse sidebar", "默认测试模型": "Default Test Model", "默认用户消息": "Default User Message", - "默认补全倍率": "Default completion ratio" + "默认补全倍率": "Default completion ratio", + "MAC Address Validation": "MAC Address Validation", + "MAC Address Whitelist": "MAC Address Whitelist", + "One MAC address per line": "One MAC address per line", + "Require X-Client-Mac to match this API key's whitelist": "Require X-Client-Mac to match this API key's whitelist" } } diff --git a/web/classic/src/i18n/locales/fr.json b/web/classic/src/i18n/locales/fr.json index 420206ff7..1b913ff7b 100644 --- a/web/classic/src/i18n/locales/fr.json +++ b/web/classic/src/i18n/locales/fr.json @@ -3684,6 +3684,10 @@ "默认折叠侧边栏": "Réduire la barre latérale par défaut", "默认测试模型": "Modèle de test par défaut", "默认用户消息": "Bonjour", - "默认补全倍率": "Taux de complétion par défaut" + "默认补全倍率": "Taux de complétion par défaut", + "MAC Address Validation": "Validation de l'adresse MAC", + "MAC Address Whitelist": "Liste blanche d'adresses MAC", + "One MAC address per line": "Une adresse MAC par ligne", + "Require X-Client-Mac to match this API key's whitelist": "Exiger que X-Client-Mac corresponde à la liste blanche de cette clé API" } } diff --git a/web/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json index d071b81de..d3fdc84ff 100644 --- a/web/classic/src/i18n/locales/ja.json +++ b/web/classic/src/i18n/locales/ja.json @@ -3653,6 +3653,10 @@ "默认折叠侧边栏": "サイドバーをデフォルトで折りたたむ", "默认测试模型": "デフォルトテストモデル", "默认用户消息": "こんにちは", - "默认补全倍率": "デフォルト補完倍率" + "默认补全倍率": "デフォルト補完倍率", + "MAC Address Validation": "MACアドレス検証", + "MAC Address Whitelist": "MACアドレス許可リスト", + "One MAC address per line": "1行に1つのMACアドレス", + "Require X-Client-Mac to match this API key's whitelist": "X-Client-Mac がこの API キーの許可リストと一致することを要求します" } } diff --git a/web/classic/src/i18n/locales/ru.json b/web/classic/src/i18n/locales/ru.json index 63660aa14..d48b93411 100644 --- a/web/classic/src/i18n/locales/ru.json +++ b/web/classic/src/i18n/locales/ru.json @@ -3704,6 +3704,10 @@ "默认折叠侧边栏": "Сворачивать боковую панель по умолчанию", "默认测试模型": "Модель для тестирования по умолчанию", "默认用户消息": "Здравствуйте", - "默认补全倍率": "Коэффициент завершения по умолчанию" + "默认补全倍率": "Коэффициент завершения по умолчанию", + "MAC Address Validation": "Проверка MAC-адреса", + "MAC Address Whitelist": "Белый список MAC-адресов", + "One MAC address per line": "Один MAC-адрес на строку", + "Require X-Client-Mac to match this API key's whitelist": "Требовать соответствия X-Client-Mac белому списку этого API-ключа" } } diff --git a/web/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json index 6a8f09ea6..202c6332c 100644 --- a/web/classic/src/i18n/locales/vi.json +++ b/web/classic/src/i18n/locales/vi.json @@ -4218,6 +4218,10 @@ "默认折叠侧边栏": "Mặc định thu gọn thanh bên", "默认测试模型": "Mô hình kiểm tra mặc định", "默认用户消息": "Xin chào", - "默认补全倍率": "Tỷ lệ hoàn thành mặc định" + "默认补全倍率": "Tỷ lệ hoàn thành mặc định", + "MAC Address Validation": "Xác thực địa chỉ MAC", + "MAC Address Whitelist": "Danh sách cho phép địa chỉ MAC", + "One MAC address per line": "Mỗi dòng một địa chỉ MAC", + "Require X-Client-Mac to match this API key's whitelist": "Yêu cầu X-Client-Mac khớp với danh sách cho phép của khóa API này" } } diff --git a/web/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json index 906b8503f..2ca69567e 100644 --- a/web/classic/src/i18n/locales/zh-CN.json +++ b/web/classic/src/i18n/locales/zh-CN.json @@ -3813,6 +3813,10 @@ "默认折叠侧边栏": "默认折叠侧边栏", "默认测试模型": "默认测试模型", "默认用户消息": "你好", - "默认补全倍率": "默认补全倍率" + "默认补全倍率": "默认补全倍率", + "MAC Address Validation": "MAC 地址校验", + "MAC Address Whitelist": "MAC 地址白名单", + "One MAC address per line": "每行填写一个 MAC 地址", + "Require X-Client-Mac to match this API key's whitelist": "要求 X-Client-Mac 与此 API 密钥的白名单匹配" } } diff --git a/web/classic/src/i18n/locales/zh-TW.json b/web/classic/src/i18n/locales/zh-TW.json index d6f20609c..cf4e2961a 100644 --- a/web/classic/src/i18n/locales/zh-TW.json +++ b/web/classic/src/i18n/locales/zh-TW.json @@ -3677,6 +3677,10 @@ "默认折叠侧边栏": "預設摺疊側邊欄", "默认测试模型": "預設測試模型", "默认用户消息": "你好", - "默认补全倍率": "預設補全倍率" + "默认补全倍率": "預設補全倍率", + "MAC Address Validation": "MAC 位址驗證", + "MAC Address Whitelist": "MAC 位址白名單", + "One MAC address per line": "每行填寫一個 MAC 位址", + "Require X-Client-Mac to match this API key's whitelist": "要求 X-Client-Mac 與此 API 金鑰的白名單相符" } } diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json index e8d0c219d..9ee692afd 100644 --- a/web/classic/src/i18n/locales/zh.json +++ b/web/classic/src/i18n/locales/zh.json @@ -2630,6 +2630,10 @@ "并确认自行承担部署": "并确认自行承担部署", "运营和收费行为产生的法律责任": "运营和收费行为产生的法律责任", ",": ",", - "、": "、" + "、": "、", + "MAC Address Validation": "MAC 地址校验", + "MAC Address Whitelist": "MAC 地址白名单", + "One MAC address per line": "每行填写一个 MAC 地址", + "Require X-Client-Mac to match this API key's whitelist": "要求 X-Client-Mac 与此 API 密钥的白名单匹配" } } diff --git a/web/default/src/features/keys/components/api-keys-cells.tsx b/web/default/src/features/keys/components/api-keys-cells.tsx index d72aca893..7c5919610 100644 --- a/web/default/src/features/keys/components/api-keys-cells.tsx +++ b/web/default/src/features/keys/components/api-keys-cells.tsx @@ -220,3 +220,45 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) { ) } + +export function MacRestrictionsCell({ apiKey }: { apiKey: ApiKey }) { + const { t } = useTranslation() + const allowMacs = apiKey.allow_macs?.trim() + + if (!apiKey.mac_check_enabled || !allowMacs) { + return ( + + ) + } + + const macs = allowMacs + .split('\n') + .map((mac) => mac.trim()) + .filter(Boolean) + + return ( + + }> + + + +
+ {macs.map((mac) => ( +
+ {mac} +
+ ))} +
+
+
+ ) +} diff --git a/web/default/src/features/keys/components/api-keys-columns.tsx b/web/default/src/features/keys/components/api-keys-columns.tsx index a3584a12d..ad7a8212c 100644 --- a/web/default/src/features/keys/components/api-keys-columns.tsx +++ b/web/default/src/features/keys/components/api-keys-columns.tsx @@ -43,6 +43,7 @@ import { ApiKeyCell, ModelLimitsCell, IpRestrictionsCell, + MacRestrictionsCell, } from './api-keys-cells' import { DataTableRowActions } from './data-table-row-actions' @@ -259,6 +260,15 @@ export function useApiKeysColumns(now: number): ColumnDef[] { size: 160, meta: { mobileHidden: true }, }, + { + id: 'allow_macs', + accessorKey: 'allow_macs', + header: t('MAC Restriction'), + cell: ({ row }) => , + enableSorting: false, + size: 160, + meta: { mobileHidden: true }, + }, { accessorKey: 'created_time', header: t('Created'), diff --git a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx index 9fa64e6f3..b6bb5e871 100644 --- a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx +++ b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx @@ -249,6 +249,7 @@ export function ApiKeysMutateDrawer({ : t('Enter quota in {{currency}}', { currency: currencyLabel }) const selectedGroup = form.watch('group') const unlimitedQuota = form.watch('unlimited_quota') + const macCheckEnabled = form.watch('mac_check_enabled') return ( )} /> + + ( + +
+ + {t('MAC Address Validation')} + + + {t( + "Require X-Client-Mac to match this API key's whitelist" + )} + +
+ + + +
+ )} + /> + + {macCheckEnabled && ( + ( + + {t('MAC Address Whitelist')} + +