Browse Source

Add device registration toggle with auto-disable

Implemented large toggle switch on Devices page for controlling
device auto-registration with automatic disable after 60 minutes.

**Frontend:**
- Added registration banner with large toggle switch at top of Devices page
- Shows visual indicator (green/red) when registration is enabled/disabled
- Displays countdown timer for auto-disable (time_left from API)
- Real-time updates every 10 seconds
- Added settings.js API client for registration control

**Backend:**
- Enhanced auto-registration API to return time_left (seconds until disable)
- Auto-disable logic already implemented: disables 60 minutes after last device registration
- GET /api/v1/superadmin/settings/auto-registration returns status with countdown
- POST /api/v1/superadmin/settings/auto-registration enables/disables

**Translations:**
- Added English/Russian translations for registration toggle UI
- "Device Registration Enabled/Disabled"
- "Devices can auto-register! Remember to disable after setup."
- "Auto-disable in Xm Ys"

**Other:**
- Removed "Test Credentials" from login page for security

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
root 2 weeks ago
parent
commit
94329c1440

+ 21 - 3
backend/app/api/v1/superadmin/settings.py

@@ -23,6 +23,7 @@ class AutoRegistrationResponse(BaseModel):
 
     enabled: bool
     last_device_at: str | None
+    time_left: int  # Seconds until auto-disable (0 if disabled)
 
 
 class AutoRegistrationToggleRequest(BaseModel):
@@ -59,17 +60,23 @@ async def get_auto_registration_status(
     enabled = setting.value.get("enabled", False)
     last_device_at_str = setting.value.get("last_device_at")
 
+    time_left = 0
     if enabled and last_device_at_str:
         last_device_at = datetime.fromisoformat(last_device_at_str)
-        if datetime.now(timezone.utc) - last_device_at > timedelta(hours=1):
+        time_since = datetime.now(timezone.utc) - last_device_at
+        time_left = max(0, int(3600 - time_since.total_seconds()))  # 1 hour = 3600 seconds
+
+        if time_since > timedelta(hours=1):
             # Auto-disable
             setting.value["enabled"] = False
             await db.commit()
             enabled = False
+            time_left = 0
 
     return AutoRegistrationResponse(
         enabled=enabled,
         last_device_at=last_device_at_str,
+        time_left=time_left,
     )
 
 
@@ -106,9 +113,20 @@ async def toggle_auto_registration(
 
     await db.commit()
 
+    # Calculate time_left
+    time_left = 0
+    enabled = setting.value.get("enabled", False)
+    last_device_at_str = setting.value.get("last_device_at")
+
+    if enabled and last_device_at_str:
+        last_device_at = datetime.fromisoformat(last_device_at_str)
+        time_since = datetime.now(timezone.utc) - last_device_at
+        time_left = max(0, int(3600 - time_since.total_seconds()))
+
     return AutoRegistrationResponse(
-        enabled=setting.value["enabled"],
-        last_device_at=setting.value.get("last_device_at"),
+        enabled=enabled,
+        last_device_at=last_device_at_str,
+        time_left=time_left,
     )
 
 

+ 37 - 0
frontend/src/api/settings.js

@@ -0,0 +1,37 @@
+import client from './client'
+
+export default {
+  /**
+   * Get auto-registration status
+   */
+  async getAutoRegistrationStatus() {
+    const { data } = await client.get('/superadmin/settings/auto-registration')
+    return data
+  },
+
+  /**
+   * Toggle auto-registration on/off
+   */
+  async toggleAutoRegistration(enabled) {
+    const { data } = await client.post('/superadmin/settings/auto-registration', {
+      enabled
+    })
+    return data
+  },
+
+  /**
+   * Get all settings
+   */
+  async getAllSettings() {
+    const { data } = await client.get('/superadmin/settings')
+    return data
+  },
+
+  /**
+   * Update setting
+   */
+  async updateSetting(key, value) {
+    const { data } = await client.put(`/superadmin/settings/${key}`, { value })
+    return data
+  }
+}

+ 12 - 2
frontend/src/i18n/index.js

@@ -91,7 +91,12 @@ const messages = {
         forceCloud: 'Force Cloud Mode',
         debug: 'Debug Mode',
         dashboardEnabled: 'Dashboard Enabled'
-      }
+      },
+      registrationEnabled: 'Device Registration Enabled',
+      registrationDisabled: 'Device Registration Disabled',
+      registrationWarning: 'Devices can auto-register! Remember to disable after setup.',
+      registrationHint: 'Enable to allow new devices to join the network',
+      autoDisableIn: 'Auto-disable in'
     },
     users: {
       title: 'Users',
@@ -262,7 +267,12 @@ const messages = {
         forceCloud: 'Принудительный облачный режим',
         debug: 'Режим отладки',
         dashboardEnabled: 'Dashboard включен'
-      }
+      },
+      registrationEnabled: 'Регистрация устройств включена',
+      registrationDisabled: 'Регистрация устройств отключена',
+      registrationWarning: 'Устройства могут регистрироваться автоматически! Не забудьте выключить после настройки.',
+      registrationHint: 'Включите, чтобы разрешить новым устройствам подключаться к сети',
+      autoDisableIn: 'Авто-выключение через'
     },
     users: {
       title: 'Пользователи',

+ 0 - 8
frontend/src/views/auth/LoginView.vue

@@ -38,14 +38,6 @@
           {{ loading ? 'Signing in...' : 'Sign in' }}
         </button>
       </form>
-
-      <div class="login-footer">
-        <p class="test-credentials">
-          <strong>Test Credentials:</strong><br>
-          Superadmin: superadmin@mybeacon.com / Admin123!<br>
-          Owner: admin@mybeacon.com / Admin123!
-        </p>
-      </div>
     </div>
   </div>
 </template>

+ 190 - 0
frontend/src/views/superadmin/DevicesView.vue

@@ -7,6 +7,37 @@
       </div>
     </div>
 
+    <!-- Device Registration Toggle -->
+    <div class="registration-banner" :class="{ enabled: autoRegistrationEnabled }">
+      <div class="registration-content">
+        <div class="registration-info">
+          <h2>
+            <span v-if="autoRegistrationEnabled">🟢 {{ $t('devices.registrationEnabled') }}</span>
+            <span v-else>🔴 {{ $t('devices.registrationDisabled') }}</span>
+          </h2>
+          <p v-if="autoRegistrationEnabled" class="warning">
+            ⚠️ {{ $t('devices.registrationWarning') }}
+            <span v-if="registrationTimeLeft > 0">
+              - {{ $t('devices.autoDisableIn') }} {{ formatTimeLeft(registrationTimeLeft) }}
+            </span>
+          </p>
+          <p v-else>{{ $t('devices.registrationHint') }}</p>
+        </div>
+        <div class="registration-toggle">
+          <label class="toggle-switch-large">
+            <input
+              type="checkbox"
+              :checked="autoRegistrationEnabled"
+              @change="toggleAutoRegistration"
+              :disabled="togglingRegistration"
+            />
+            <span class="toggle-slider"></span>
+            <span v-if="togglingRegistration" class="spinner">⏳</span>
+          </label>
+        </div>
+      </div>
+    </div>
+
     <div class="content">
       <!-- Search and Filters -->
       <div class="filters-row">
@@ -437,6 +468,13 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
 import devicesApi from '@/api/devices'
 import organizationsApi from '@/api/organizations'
 import tunnelsApi from '@/api/tunnels'
+import settingsApi from '@/api/settings'
+
+// Auto-registration state
+const autoRegistrationEnabled = ref(false)
+const togglingRegistration = ref(false)
+const registrationTimeLeft = ref(0)
+let registrationTimer = null
 
 const devices = ref([])
 const organizations = ref([])
@@ -980,9 +1018,65 @@ async function saveDefaultConfig() {
   }
 }
 
+// Auto-registration functions
+async function loadAutoRegistrationStatus() {
+  try {
+    const status = await settingsApi.getAutoRegistrationStatus()
+    autoRegistrationEnabled.value = status.enabled
+    registrationTimeLeft.value = status.time_left || 0
+  } catch (err) {
+    console.error('Failed to load registration status:', err)
+  }
+}
+
+async function toggleAutoRegistration() {
+  togglingRegistration.value = true
+  try {
+    const newState = !autoRegistrationEnabled.value
+    await settingsApi.toggleAutoRegistration(newState)
+    autoRegistrationEnabled.value = newState
+    if (newState) {
+      startRegistrationTimer()
+    } else {
+      stopRegistrationTimer()
+    }
+  } catch (err) {
+    console.error('Failed to toggle registration:', err)
+    // Reload status to sync
+    await loadAutoRegistrationStatus()
+  } finally {
+    togglingRegistration.value = false
+  }
+}
+
+function startRegistrationTimer() {
+  stopRegistrationTimer()
+  registrationTimer = setInterval(async () => {
+    await loadAutoRegistrationStatus()
+    if (!autoRegistrationEnabled.value) {
+      stopRegistrationTimer()
+    }
+  }, 10000) // Update every 10 seconds
+}
+
+function stopRegistrationTimer() {
+  if (registrationTimer) {
+    clearInterval(registrationTimer)
+    registrationTimer = null
+  }
+}
+
+function formatTimeLeft(seconds) {
+  if (seconds < 60) return `${seconds}s`
+  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
+  return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
+}
+
 onMounted(() => {
   loadDevices()
   loadOrganizations()
+  loadAutoRegistrationStatus()
+  startRegistrationTimer()
 
   // Real-time polling every 10 seconds
   pollingInterval = setInterval(() => {
@@ -999,6 +1093,7 @@ onBeforeUnmount(() => {
   if (searchDebounceTimer) {
     clearTimeout(searchDebounceTimer)
   }
+  stopRegistrationTimer()
 })
 </script>
 
@@ -1007,6 +1102,101 @@ onBeforeUnmount(() => {
 .page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; }
 .page-header h1 { font-size: 32px; font-weight: 700; color: #1a202c; margin-bottom: 8px; }
 .page-header p { color: #718096; font-size: 16px; }
+
+/* Registration Banner */
+.registration-banner {
+  background: #fff5f5;
+  border: 2px solid #fc8181;
+  border-radius: 12px;
+  padding: 20px 24px;
+  margin-bottom: 24px;
+  transition: all 0.3s;
+}
+.registration-banner.enabled {
+  background: #f0fff4;
+  border-color: #68d391;
+}
+.registration-content {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 24px;
+}
+.registration-info h2 {
+  font-size: 20px;
+  font-weight: 700;
+  margin-bottom: 8px;
+  color: #1a202c;
+}
+.registration-info p {
+  font-size: 14px;
+  color: #718096;
+  margin: 0;
+}
+.registration-info .warning {
+  color: #c53030;
+  font-weight: 500;
+}
+.registration-banner.enabled .warning {
+  color: #276749;
+}
+
+/* Large Toggle Switch */
+.toggle-switch-large {
+  position: relative;
+  display: inline-block;
+  width: 80px;
+  height: 44px;
+}
+.toggle-switch-large input {
+  opacity: 0;
+  width: 0;
+  height: 0;
+}
+.toggle-slider {
+  position: absolute;
+  cursor: pointer;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: #cbd5e0;
+  transition: 0.4s;
+  border-radius: 44px;
+  border: 2px solid #a0aec0;
+}
+.toggle-slider:before {
+  position: absolute;
+  content: '';
+  height: 36px;
+  width: 36px;
+  left: 2px;
+  bottom: 2px;
+  background-color: white;
+  transition: 0.4s;
+  border-radius: 50%;
+  box-shadow: 0 2px 4px rgba(0,0,0,0.2);
+}
+.toggle-switch-large input:checked + .toggle-slider {
+  background-color: #48bb78;
+  border-color: #38a169;
+}
+.toggle-switch-large input:checked + .toggle-slider:before {
+  transform: translateX(36px);
+}
+.toggle-switch-large input:disabled + .toggle-slider {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+.toggle-switch-large .spinner {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  font-size: 20px;
+  animation: spin 1s linear infinite;
+}
+
 .content { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
 
 /* Filters Row */