mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-23 03:17:14 +00:00
feat(clients): allow removing a single HWID device (#6265)
* feat(clients): allow removing a single HWID device Only "list" and "clear all" existed for registered HWID devices, so freeing one slot under a client's HWID limit meant clearing every device and waiting for the ones you kept to re-register. Adds a per-device delete: DELETE /panel/api/clients/hwids/:email/:id, scoped to the client's own sub_id (device ids are a global auto-increment, not per-subID, so this also prevents deleting another client's device), plus a delete button next to each device in the existing HWID modal. Addresses MHSanaei/3x-ui#6245. * feat(clients): surface HWID limit + device log in the client info card Mirrors the existing IP-limit row/eye-icon-modal pattern that's already in this card. The HWID devices modal reuses the same list/clear-all/per-device-delete UI already shipped for the edit form's own HWID modal, so a device can be removed without opening the edit form at all. * i18n: add HWID single-delete strings to all 13 locales deleteHwid/deleteHwidConfirm/hwidDeleted were only added to en-US and ru-RU in the previous commit; backfilling the other 11 locales the project's own translation set covers. * fix(clients): address automated review of HWID single-delete PR - ClientInfoModal: use the existing dateLabel() helper (Jalali-aware) for HWID first/last-seen instead of a raw dayjs format, matching every other timestamp in the same modal. - Add okText/cancelText to the delete-device Popconfirm in both ClientInfoModal and ClientFormModal so all 13 locales get a translated confirm dialog instead of Antd's English default. - deleteHwid controller: stop reusing the success toast key on both error paths, which rendered a red "Update successful" toast on a real (not just theoretical) failure such as a stale HWID modal. - Trim DeleteClientHwid's doc comment to the repo's 2-line cap and correct it: deletion is scoped by sub_id, which can span more than one ClientRecord, not strictly "this client only". - Add TestDeleteClientHwid covering cross-sub_id id rejection, unknown id rejection, and a real successful delete. * chore: retrigger CI (previous run stuck installing Playwright Chromium) * fix(clients): address the arbiter review on the HWID single-delete PR - Extract the HWID device list into a shared frontend/src/lib/clients/ hwid-log.ts type/normalizer, a shared useClientHwids hook, and a shared ClientHwidListModal component, mirroring the existing IP-log pattern. ClientInfoModal and ClientFormModal both render the same component now, so the two copies can no longer drift the way they already had (different date formatting, different tag styles). - Add a Popconfirm to the HWID "Clear all" button (previously unconfirmed, unlike the per-device delete right next to it) — closes the confirm/no-confirm asymmetry the review flagged as the main risk. - Sync docs/public/openapi.json with the two hwids paths and regenerate clients.mdx. Scoped to just those two paths rather than a full copy from frontend/public/openapi.json: the docs copy is far enough behind on unrelated paths (a host-group API rename) that a full sync breaks the Next.js build on locale pages referencing the old shape — out of scope for this PR. * fix(clients): trim HWID list comment blocks to 2 lines Repo convention caps comment blocks at 2 lines; both were 1 line over. * chore: retrigger CI build (arm64) and build (armv6) failed on a transient Go module proxy network error (INTERNAL_ERROR stream reset), unrelated to this PR's changes.
This commit is contained in:
@@ -200,6 +200,27 @@ func (s *ClientService) ClearClientHwids(email string) error {
|
||||
return database.GetDB().Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
|
||||
}
|
||||
|
||||
// DeleteClientHwid removes one device, scoped to the client's sub_id: ids
|
||||
// are a global auto-increment, so an id outside this subscription won't match.
|
||||
func (s *ClientService) DeleteClientHwid(email string, id int) error {
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subID := strings.TrimSpace(rec.SubID)
|
||||
if subID == "" {
|
||||
return errors.New("client has no subscription id")
|
||||
}
|
||||
res := database.GetDB().Where("sub_id = ? AND id = ?", subID, id).Delete(&model.ClientHwid{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("device not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ClientService) setClientLimitHwidByEmail(tx *gorm.DB, email string, limit int) error {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
|
||||
@@ -151,6 +151,53 @@ func TestClientHwidGateRegistersAndBlocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteClientHwid(t *testing.T) {
|
||||
initClientHwidTestDB(t)
|
||||
svc := &ClientService{}
|
||||
db := database.GetDB()
|
||||
|
||||
rec := seedHwidClient(t, 5)
|
||||
if _, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-own"}); err != nil {
|
||||
t.Fatalf("register own device: %v", err)
|
||||
}
|
||||
list, err := svc.ListClientHwids(rec.Email)
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list own devices: err=%v list=%+v", err, list)
|
||||
}
|
||||
ownID := list[0].Id
|
||||
|
||||
other := &model.ClientRecord{Email: "other@example.com", SubID: "sub-other", UUID: "33333333-2222-4333-8444-555555555555", Enable: true, LimitHwid: 5}
|
||||
if err := db.Create(other).Error; err != nil {
|
||||
t.Fatalf("seed other client: %v", err)
|
||||
}
|
||||
if _, err := svc.EnforceHwidForSubID(other.SubID, HwidRequest{Hwid: "device-foreign"}); err != nil {
|
||||
t.Fatalf("register foreign device: %v", err)
|
||||
}
|
||||
otherList, err := svc.ListClientHwids(other.Email)
|
||||
if err != nil || len(otherList) != 1 {
|
||||
t.Fatalf("list foreign devices: err=%v list=%+v", err, otherList)
|
||||
}
|
||||
foreignID := otherList[0].Id
|
||||
|
||||
if err := svc.DeleteClientHwid(rec.Email, foreignID); err == nil {
|
||||
t.Fatalf("deleting a foreign sub_id's device id should fail")
|
||||
}
|
||||
if list, err := svc.ListClientHwids(other.Email); err != nil || len(list) != 1 {
|
||||
t.Fatalf("foreign device should survive a cross-sub_id delete attempt: err=%v list=%+v", err, list)
|
||||
}
|
||||
|
||||
if err := svc.DeleteClientHwid(rec.Email, 999999); err == nil {
|
||||
t.Fatalf("deleting an unknown id should fail")
|
||||
}
|
||||
|
||||
if err := svc.DeleteClientHwid(rec.Email, ownID); err != nil {
|
||||
t.Fatalf("delete own device: %v", err)
|
||||
}
|
||||
if list, err := svc.ListClientHwids(rec.Email); err != nil || len(list) != 0 {
|
||||
t.Fatalf("own device should be gone: err=%v list=%+v", err, list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) {
|
||||
initClientHwidTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
Reference in New Issue
Block a user