diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 172e2821f..02800e8cb 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -23,17 +23,18 @@ jobs: - ${{ matrix.runner }} strategy: matrix: - cpu: [arm64, amd64, arm/v7] + # cpu: [arm64, amd64, arm/v7] + cpu: [amd64] include: - - cpu: arm64 - runner: ARM64 - tag: arm64 + # - cpu: arm64 + # runner: ARM64 + # tag: arm64 - cpu: amd64 runner: X64 tag: amd64 - - cpu: arm/v7 - runner: ARM - tag: armv7 + # - cpu: arm/v7 + # runner: ARM + # tag: armv7 steps: - name: Checkout uses: actions/checkout@v4 @@ -62,30 +63,30 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - docker-manifest: - runs-on: [self-hosted, Linux] - needs: [build-docker] - steps: - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: | - ${{ env.GHCR_REPO }} - flavor: ${{ inputs.flavor }} - tags: ${{ inputs.tags }} - - name: Login to GitHub container registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Create and push manifests - run: | - tags='${{ env.GHCR_REPO }}:${{ github.sha }} ${{ steps.meta.outputs.tags }}' - for tag in ${tags} - do - docker manifest rm ${tag} || true - docker manifest create ${tag} ${{ env.GHCR_REPO }}:${{ github.sha }}-amd64 ${{ env.GHCR_REPO }}:${{ github.sha }}-arm64 ${{ env.GHCR_REPO }}:${{ github.sha }}-armv7 - docker manifest push ${tag} - done + # docker-manifest: + # runs-on: [self-hosted, Linux] + # needs: [build-docker] + # steps: + # - name: Docker meta + # id: meta + # uses: docker/metadata-action@v5 + # with: + # images: | + # ${{ env.GHCR_REPO }} + # flavor: ${{ inputs.flavor }} + # tags: ${{ inputs.tags }} + # - name: Login to GitHub container registry + # uses: docker/login-action@v3 + # with: + # registry: ghcr.io + # username: ${{ github.actor }} + # password: ${{ secrets.GITHUB_TOKEN }} + # - name: Create and push manifests + # run: | + # tags='${{ env.GHCR_REPO }}:${{ github.sha }} ${{ steps.meta.outputs.tags }}' + # for tag in ${tags} + # do + # docker manifest rm ${tag} || true + # docker manifest create ${tag} ${{ env.GHCR_REPO }}:${{ github.sha }}-amd64 ${{ env.GHCR_REPO }}:${{ github.sha }}-arm64 ${{ env.GHCR_REPO }}:${{ github.sha }}-armv7 + # docker manifest push ${tag} + # done diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml index 1f6435020..b4436b005 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -2,8 +2,9 @@ name: Build current image on: push: branches: - - main - - dev + # - main + # - dev + - network-devices paths-ignore: - "*.md" - "LICENSE" @@ -17,11 +18,10 @@ jobs: uses: ./.github/workflows/build-docker.yml with: tags: | - type=raw,value=current type=ref,event=branch type=sha - trigger-e2e: - needs: build-current - uses: ./.github/workflows/e2e.yml - secrets: inherit + # trigger-e2e: + # needs: build-current + # uses: ./.github/workflows/e2e.yml + # secrets: inherit diff --git a/.sqlx/query-0e9a8c2395d43898748c176db0d89fa20784cd93ca1dfd00b2755934937d8270.json b/.sqlx/query-0e9a8c2395d43898748c176db0d89fa20784cd93ca1dfd00b2755934937d8270.json deleted file mode 100644 index 25c3f61e0..000000000 --- a/.sqlx/query-0e9a8c2395d43898748c176db0d89fa20784cd93ca1dfd00b2755934937d8270.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH stats AS ( SELECT DISTINCT ON (device_id) device_id, endpoint, latest_handshake FROM wireguard_peer_stats WHERE network = $1 ORDER BY device_id, collected_at DESC ) SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created FROM device d JOIN wireguard_network_device wnd ON wnd.device_id = d.id LEFT JOIN stats on d.id = stats.device_id WHERE wnd.wireguard_network_id = $1 AND wnd.is_authorized = true AND (wnd.authorized_at IS NULL OR (NOW() - wnd.authorized_at) > $2 * interval '1 second') AND (stats.latest_handshake IS NULL OR (NOW() - stats.latest_handshake) > $2 * interval '1 second')", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wireguard_pubkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Int8", - "Float8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "0e9a8c2395d43898748c176db0d89fa20784cd93ca1dfd00b2755934937d8270" -} diff --git a/.sqlx/query-e6537b70e7f27eb6103a7715d520ec1bb8491908a059422e751592b7da96028f.json b/.sqlx/query-1e9a919b4a300c7a579300d72212a3c7b6c535e606e18afd3e5cc3e655c71a27.json similarity index 51% rename from .sqlx/query-e6537b70e7f27eb6103a7715d520ec1bb8491908a059422e751592b7da96028f.json rename to .sqlx/query-1e9a919b4a300c7a579300d72212a3c7b6c535e606e18afd3e5cc3e655c71a27.json index c531cd830..6f98e45f9 100644 --- a/.sqlx/query-e6537b70e7f27eb6103a7715d520ec1bb8491908a059422e751592b7da96028f.json +++ b/.sqlx/query-1e9a919b4a300c7a579300d72212a3c7b6c535e606e18afd3e5cc3e655c71a27.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"wireguard_pubkey\",\"user_id\",\"created\" FROM \"device\"", + "query": "SELECT id, \"name\",\"wireguard_pubkey\",\"user_id\",\"created\",\"device_type\" \"device_type: _\",\"description\",\"configured\" FROM \"device\"", "describe": { "columns": [ { @@ -27,6 +27,31 @@ "ordinal": 4, "name": "created", "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "device_type: _", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" } ], "parameters": { @@ -37,8 +62,11 @@ false, false, false, + false, + false, + true, false ] }, - "hash": "e6537b70e7f27eb6103a7715d520ec1bb8491908a059422e751592b7da96028f" + "hash": "1e9a919b4a300c7a579300d72212a3c7b6c535e606e18afd3e5cc3e655c71a27" } diff --git a/.sqlx/query-1f84d16de620fbc5dcee228d28bc35818e9a647dafae48b87c1651f4f9ca7f09.json b/.sqlx/query-1f84d16de620fbc5dcee228d28bc35818e9a647dafae48b87c1651f4f9ca7f09.json new file mode 100644 index 000000000..dd2875408 --- /dev/null +++ b/.sqlx/query-1f84d16de620fbc5dcee228d28bc35818e9a647dafae48b87c1651f4f9ca7f09.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT device.id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", configured FROM device JOIN \"user\" ON device.user_id = \"user\".id WHERE device.id = $1 AND \"user\".username = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "1f84d16de620fbc5dcee228d28bc35818e9a647dafae48b87c1651f4f9ca7f09" +} diff --git a/.sqlx/query-24671ce124b5ffbf8a24408379c561e6bb4b3ca32c478af85da6d4ee1e96d2bb.json b/.sqlx/query-24671ce124b5ffbf8a24408379c561e6bb4b3ca32c478af85da6d4ee1e96d2bb.json new file mode 100644 index 000000000..525c75703 --- /dev/null +++ b/.sqlx/query-24671ce124b5ffbf8a24408379c561e6bb4b3ca32c478af85da6d4ee1e96d2bb.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold FROM wireguard_network WHERE id IN (SELECT wireguard_network_id FROM wireguard_network_device where device_id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Inet" + }, + { + "ordinal": 3, + "name": "port", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "pubkey", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "prvkey", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "endpoint", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "dns", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "allowed_ips", + "type_info": "InetArray" + }, + { + "ordinal": 9, + "name": "connected_at", + "type_info": "Timestamp" + }, + { + "ordinal": 10, + "name": "mfa_enabled", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "keepalive_interval", + "type_info": "Int4" + }, + { + "ordinal": 12, + "name": "peer_disconnect_threshold", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false + ] + }, + "hash": "24671ce124b5ffbf8a24408379c561e6bb4b3ca32c478af85da6d4ee1e96d2bb" +} diff --git a/.sqlx/query-3d3c0799696f9c213982abe7f4633908aec2ddb747ad6e77e347365b4e61a8b0.json b/.sqlx/query-3d3c0799696f9c213982abe7f4633908aec2ddb747ad6e77e347365b4e61a8b0.json new file mode 100644 index 000000000..83a5d73a8 --- /dev/null +++ b/.sqlx/query-3d3c0799696f9c213982abe7f4633908aec2ddb747ad6e77e347365b4e61a8b0.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", configured FROM device d JOIN wireguard_network_device wnd ON d.id = wnd.device_id WHERE wnd.wireguard_ip = $1 AND wnd.wireguard_network_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Inet", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "3d3c0799696f9c213982abe7f4633908aec2ddb747ad6e77e347365b4e61a8b0" +} diff --git a/.sqlx/query-3f615b93c20a7d1e2740f6d826b78b62cfb433385383ffc8358be1a14ce13470.json b/.sqlx/query-3f615b93c20a7d1e2740f6d826b78b62cfb433385383ffc8358be1a14ce13470.json deleted file mode 100644 index 2fa7cce27..000000000 --- a/.sqlx/query-3f615b93c20a7d1e2740f6d826b78b62cfb433385383ffc8358be1a14ce13470.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created FROM device d JOIN wireguard_network_device wnd ON d.id = wnd.device_id WHERE wnd.wireguard_ip = $1 AND wnd.wireguard_network_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wireguard_pubkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Inet", - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "3f615b93c20a7d1e2740f6d826b78b62cfb433385383ffc8358be1a14ce13470" -} diff --git a/.sqlx/query-41e599df755801f08a6e2e02aebed2d72c903ea7fbd45b4e3c4b10199d936fab.json b/.sqlx/query-41e599df755801f08a6e2e02aebed2d72c903ea7fbd45b4e3c4b10199d936fab.json new file mode 100644 index 000000000..27302c517 --- /dev/null +++ b/.sqlx/query-41e599df755801f08a6e2e02aebed2d72c903ea7fbd45b4e3c4b10199d936fab.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"device\" (\"name\",\"wireguard_pubkey\",\"user_id\",\"created\",\"device_type\",\"description\",\"configured\") VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Timestamp", + { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + }, + "Text", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "41e599df755801f08a6e2e02aebed2d72c903ea7fbd45b4e3c4b10199d936fab" +} diff --git a/.sqlx/query-451af8e67a5df3d25ee59d8d6bf40d78fc40046e7a82f49d73ff6cd27cf64a96.json b/.sqlx/query-451af8e67a5df3d25ee59d8d6bf40d78fc40046e7a82f49d73ff6cd27cf64a96.json new file mode 100644 index 000000000..ddcaa39ab --- /dev/null +++ b/.sqlx/query-451af8e67a5df3d25ee59d8d6bf40d78fc40046e7a82f49d73ff6cd27cf64a96.json @@ -0,0 +1,72 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", configured FROM device d JOIN \"user\" u ON d.user_id = u.id WHERE u.is_active = true AND d.device_type = 'user'::device_type ORDER BY d.id ASC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "451af8e67a5df3d25ee59d8d6bf40d78fc40046e7a82f49d73ff6cd27cf64a96" +} diff --git a/.sqlx/query-462ccbc9a74f566a07eb69ddc7b874ec712a34e77cd9c22de0ff7838bca2af6c.json b/.sqlx/query-462ccbc9a74f566a07eb69ddc7b874ec712a34e77cd9c22de0ff7838bca2af6c.json new file mode 100644 index 000000000..7e5fc501f --- /dev/null +++ b/.sqlx/query-462ccbc9a74f566a07eb69ddc7b874ec712a34e77cd9c22de0ff7838bca2af6c.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH s AS ( SELECT DISTINCT ON (device_id) * FROM wireguard_peer_stats ORDER BY device_id, latest_handshake DESC ) SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", configured FROM device d JOIN s ON d.id = s.device_id WHERE s.latest_handshake >= $1 AND s.network = $2 AND d.device_type = 'user'::device_type", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Timestamp", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "462ccbc9a74f566a07eb69ddc7b874ec712a34e77cd9c22de0ff7838bca2af6c" +} diff --git a/.sqlx/query-4c9d6f904044fede2b8b7435485cb22afbf6c907e3723fc8541942ba118c9c6f.json b/.sqlx/query-4c9d6f904044fede2b8b7435485cb22afbf6c907e3723fc8541942ba118c9c6f.json new file mode 100644 index 000000000..34acd3bfa --- /dev/null +++ b/.sqlx/query-4c9d6f904044fede2b8b7435485cb22afbf6c907e3723fc8541942ba118c9c6f.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH stats AS ( SELECT DISTINCT ON (device_id) device_id, endpoint, latest_handshake FROM wireguard_peer_stats WHERE network = $1 ORDER BY device_id, collected_at DESC ) SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", configured FROM device d JOIN wireguard_network_device wnd ON wnd.device_id = d.id LEFT JOIN stats on d.id = stats.device_id WHERE wnd.wireguard_network_id = $1 AND wnd.is_authorized = true AND d.configured = true AND (wnd.authorized_at IS NULL OR (NOW() - wnd.authorized_at) > $2 * interval '1 second') AND (stats.latest_handshake IS NULL OR (NOW() - stats.latest_handshake) > $2 * interval '1 second')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Float8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "4c9d6f904044fede2b8b7435485cb22afbf6c907e3723fc8541942ba118c9c6f" +} diff --git a/.sqlx/query-5b4f2b3fabb2afc84764dfb06714823cb9b6fccbf717f531c09f0c8e8d0326e2.json b/.sqlx/query-5b4f2b3fabb2afc84764dfb06714823cb9b6fccbf717f531c09f0c8e8d0326e2.json deleted file mode 100644 index 272e72966..000000000 --- a/.sqlx/query-5b4f2b3fabb2afc84764dfb06714823cb9b6fccbf717f531c09f0c8e8d0326e2.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT device.id, name, wireguard_pubkey, user_id, created FROM device JOIN \"user\" ON device.user_id = \"user\".id WHERE device.id = $1 AND \"user\".username = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wireguard_pubkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "5b4f2b3fabb2afc84764dfb06714823cb9b6fccbf717f531c09f0c8e8d0326e2" -} diff --git a/.sqlx/query-64d373fbed97c81654689a8fbb26ffe854693a0d4ae16000242ef8db9a864b84.json b/.sqlx/query-64d373fbed97c81654689a8fbb26ffe854693a0d4ae16000242ef8db9a864b84.json deleted file mode 100644 index e7f4770ad..000000000 --- a/.sqlx/query-64d373fbed97c81654689a8fbb26ffe854693a0d4ae16000242ef8db9a864b84.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created FROM device d JOIN \"user\" u ON d.user_id = u.id WHERE u.is_active = true ORDER BY d.id ASC", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wireguard_pubkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "64d373fbed97c81654689a8fbb26ffe854693a0d4ae16000242ef8db9a864b84" -} diff --git a/.sqlx/query-67539c5f0b24db151ceca1d14f09d5d8629df64531e85c34bd3cfa295c353fae.json b/.sqlx/query-67539c5f0b24db151ceca1d14f09d5d8629df64531e85c34bd3cfa295c353fae.json new file mode 100644 index 000000000..ae7725ef3 --- /dev/null +++ b/.sqlx/query-67539c5f0b24db151ceca1d14f09d5d8629df64531e85c34bd3cfa295c353fae.json @@ -0,0 +1,125 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, email_mfa_enabled, totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_sub FROM \"user\" WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "last_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "first_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "phone", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "mfa_enabled", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "totp_enabled", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "email_mfa_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "totp_secret", + "type_info": "Bytea" + }, + { + "ordinal": 11, + "name": "email_mfa_secret", + "type_info": "Bytea" + }, + { + "ordinal": 12, + "name": "mfa_method: _", + "type_info": { + "Custom": { + "name": "mfa_method", + "kind": { + "Enum": [ + "none", + "one_time_password", + "webauthn", + "web3", + "email" + ] + } + } + } + }, + { + "ordinal": 13, + "name": "recovery_codes", + "type_info": "TextArray" + }, + { + "ordinal": 14, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 15, + "name": "openid_sub", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + true, + true, + false, + false, + false, + true + ] + }, + "hash": "67539c5f0b24db151ceca1d14f09d5d8629df64531e85c34bd3cfa295c353fae" +} diff --git a/.sqlx/query-6dfb9442da967755230ab2b7c5baf8eec06d64af5886e840bea1b32d2be10ca0.json b/.sqlx/query-6dfb9442da967755230ab2b7c5baf8eec06d64af5886e840bea1b32d2be10ca0.json deleted file mode 100644 index 1a836e180..000000000 --- a/.sqlx/query-6dfb9442da967755230ab2b7c5baf8eec06d64af5886e840bea1b32d2be10ca0.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE \"device\" SET \"name\" = $2,\"wireguard_pubkey\" = $3,\"user_id\" = $4,\"created\" = $5 WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Text", - "Int8", - "Timestamp" - ] - }, - "nullable": [] - }, - "hash": "6dfb9442da967755230ab2b7c5baf8eec06d64af5886e840bea1b32d2be10ca0" -} diff --git a/.sqlx/query-1cad18ee4393faed671f07a2f0faeff413376b8c4760ef86f5de6a7e74b7c858.json b/.sqlx/query-79d3bbf7cdce7a0c81d5ec5dbda03fcf5cc767f3659206c4187a3ae5079a6bd4.json similarity index 51% rename from .sqlx/query-1cad18ee4393faed671f07a2f0faeff413376b8c4760ef86f5de6a7e74b7c858.json rename to .sqlx/query-79d3bbf7cdce7a0c81d5ec5dbda03fcf5cc767f3659206c4187a3ae5079a6bd4.json index 97dac0244..fa640378e 100644 --- a/.sqlx/query-1cad18ee4393faed671f07a2f0faeff413376b8c4760ef86f5de6a7e74b7c858.json +++ b/.sqlx/query-79d3bbf7cdce7a0c81d5ec5dbda03fcf5cc767f3659206c4187a3ae5079a6bd4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"wireguard_pubkey\",\"user_id\",\"created\" FROM \"device\" WHERE id = $1", + "query": "SELECT id, \"name\",\"wireguard_pubkey\",\"user_id\",\"created\",\"device_type\" \"device_type: _\",\"description\",\"configured\" FROM \"device\" WHERE id = $1", "describe": { "columns": [ { @@ -27,6 +27,31 @@ "ordinal": 4, "name": "created", "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "device_type: _", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" } ], "parameters": { @@ -39,8 +64,11 @@ false, false, false, + false, + false, + true, false ] }, - "hash": "1cad18ee4393faed671f07a2f0faeff413376b8c4760ef86f5de6a7e74b7c858" + "hash": "79d3bbf7cdce7a0c81d5ec5dbda03fcf5cc767f3659206c4187a3ae5079a6bd4" } diff --git a/.sqlx/query-7a0f3ee3d0be29319693b7ab8a6c247b5b4d9e5c836b90a601531edd4dbdebf3.json b/.sqlx/query-7a0f3ee3d0be29319693b7ab8a6c247b5b4d9e5c836b90a601531edd4dbdebf3.json new file mode 100644 index 000000000..09b813d3d --- /dev/null +++ b/.sqlx/query-7a0f3ee3d0be29319693b7ab8a6c247b5b4d9e5c836b90a601531edd4dbdebf3.json @@ -0,0 +1,85 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", configured FROM device WHERE id in (SELECT device_id FROM wireguard_network_device WHERE wireguard_network_id = $1) AND device_type = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "7a0f3ee3d0be29319693b7ab8a6c247b5b4d9e5c836b90a601531edd4dbdebf3" +} diff --git a/.sqlx/query-60689e3ad76c36ed3fed76d77244de90fda17abfee564c480274bd4a1446fb37.json b/.sqlx/query-848768a99535984a81b1b8f8c9446916ec323c15200b36ce25d0d4198b15d0fa.json similarity index 50% rename from .sqlx/query-60689e3ad76c36ed3fed76d77244de90fda17abfee564c480274bd4a1446fb37.json rename to .sqlx/query-848768a99535984a81b1b8f8c9446916ec323c15200b36ce25d0d4198b15d0fa.json index ae5f88eae..6366a62ff 100644 --- a/.sqlx/query-60689e3ad76c36ed3fed76d77244de90fda17abfee564c480274bd4a1446fb37.json +++ b/.sqlx/query-848768a99535984a81b1b8f8c9446916ec323c15200b36ce25d0d4198b15d0fa.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT device.id, name, wireguard_pubkey, user_id, created FROM device JOIN \"user\" ON device.user_id = \"user\".id WHERE device.id = $1 AND \"user\".id = $2", + "query": "SELECT device.id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", configured FROM device WHERE user_id = $1 and device_type = 'user'::device_type", "describe": { "columns": [ { @@ -27,11 +27,35 @@ "ordinal": 4, "name": "created", "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" } ], "parameters": { "Left": [ - "Int8", "Int8" ] }, @@ -40,8 +64,11 @@ false, false, false, + false, + true, + false, false ] }, - "hash": "60689e3ad76c36ed3fed76d77244de90fda17abfee564c480274bd4a1446fb37" + "hash": "848768a99535984a81b1b8f8c9446916ec323c15200b36ce25d0d4198b15d0fa" } diff --git a/.sqlx/query-9213729a9a1ce371ef77898f5792d914a67400cb4cce9a8bf86227ffe7d42eda.json b/.sqlx/query-9213729a9a1ce371ef77898f5792d914a67400cb4cce9a8bf86227ffe7d42eda.json deleted file mode 100644 index 285e7121c..000000000 --- a/.sqlx/query-9213729a9a1ce371ef77898f5792d914a67400cb4cce9a8bf86227ffe7d42eda.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO \"device\" (\"name\",\"wireguard_pubkey\",\"user_id\",\"created\") VALUES ($1,$2,$3,$4) RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Int8", - "Timestamp" - ] - }, - "nullable": [ - false - ] - }, - "hash": "9213729a9a1ce371ef77898f5792d914a67400cb4cce9a8bf86227ffe7d42eda" -} diff --git a/.sqlx/query-9df3fb838f3e4323482bca3e0b80ab40e9045d220622de7a50b14db19b0dad93.json b/.sqlx/query-9df3fb838f3e4323482bca3e0b80ab40e9045d220622de7a50b14db19b0dad93.json new file mode 100644 index 000000000..abd8b36f0 --- /dev/null +++ b/.sqlx/query-9df3fb838f3e4323482bca3e0b80ab40e9045d220622de7a50b14db19b0dad93.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT device.id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", configured FROM device JOIN \"user\" ON device.user_id = \"user\".id WHERE device.id = $1 AND \"user\".id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "9df3fb838f3e4323482bca3e0b80ab40e9045d220622de7a50b14db19b0dad93" +} diff --git a/.sqlx/query-9fcd0afaa9ea0acca6ac8623899b691f9b2ec9fbb05891e079a7bbfcadb5d7a7.json b/.sqlx/query-9fcd0afaa9ea0acca6ac8623899b691f9b2ec9fbb05891e079a7bbfcadb5d7a7.json new file mode 100644 index 000000000..bd778f81e --- /dev/null +++ b/.sqlx/query-9fcd0afaa9ea0acca6ac8623899b691f9b2ec9fbb05891e079a7bbfcadb5d7a7.json @@ -0,0 +1,74 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT device.id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", configured FROM device JOIN \"user\" ON device.user_id = \"user\".id WHERE \"user\".username = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "9fcd0afaa9ea0acca6ac8623899b691f9b2ec9fbb05891e079a7bbfcadb5d7a7" +} diff --git a/.sqlx/query-ab42d3c8bd9969e10eb02c4eccf176198608932ae8b0238ef10afb2fb548f25b.json b/.sqlx/query-ab42d3c8bd9969e10eb02c4eccf176198608932ae8b0238ef10afb2fb548f25b.json deleted file mode 100644 index 387bd4153..000000000 --- a/.sqlx/query-ab42d3c8bd9969e10eb02c4eccf176198608932ae8b0238ef10afb2fb548f25b.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT device.id, name, wireguard_pubkey, user_id, created FROM device JOIN \"user\" ON device.user_id = \"user\".id WHERE \"user\".username = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wireguard_pubkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "ab42d3c8bd9969e10eb02c4eccf176198608932ae8b0238ef10afb2fb548f25b" -} diff --git a/.sqlx/query-f6cb6d9cdf5db43582cdb77f3c60335ae28f92e6653abcfd001118a965cb5d36.json b/.sqlx/query-afdf517a134155524fce80f0fc2b24a4b5bf49cda3ce28d18462407977a39ce3.json similarity index 81% rename from .sqlx/query-f6cb6d9cdf5db43582cdb77f3c60335ae28f92e6653abcfd001118a965cb5d36.json rename to .sqlx/query-afdf517a134155524fce80f0fc2b24a4b5bf49cda3ce28d18462407977a39ce3.json index b115ecb03..2b1a49f4d 100644 --- a/.sqlx/query-f6cb6d9cdf5db43582cdb77f3c60335ae28f92e6653abcfd001118a965cb5d36.json +++ b/.sqlx/query-afdf517a134155524fce80f0fc2b24a4b5bf49cda3ce28d18462407977a39ce3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, user_id, admin_id, email, created_at, expires_at, used_at, token_type FROM token", + "query": "SELECT id, user_id, admin_id, email, created_at, expires_at, used_at, token_type, device_id FROM token", "describe": { "columns": [ { @@ -42,6 +42,11 @@ "ordinal": 7, "name": "token_type", "type_info": "Text" + }, + { + "ordinal": 8, + "name": "device_id", + "type_info": "Int8" } ], "parameters": { @@ -55,8 +60,9 @@ false, false, true, + true, true ] }, - "hash": "f6cb6d9cdf5db43582cdb77f3c60335ae28f92e6653abcfd001118a965cb5d36" + "hash": "afdf517a134155524fce80f0fc2b24a4b5bf49cda3ce28d18462407977a39ce3" } diff --git a/.sqlx/query-2444fb3a5c12155737d934d156a63716a6383680dee11e01682597e24bf3ce53.json b/.sqlx/query-b4f26412c85ce1814df203070d4edf8e613ae68bd8362c91475ba2d50ae17a78.json similarity index 81% rename from .sqlx/query-2444fb3a5c12155737d934d156a63716a6383680dee11e01682597e24bf3ce53.json rename to .sqlx/query-b4f26412c85ce1814df203070d4edf8e613ae68bd8362c91475ba2d50ae17a78.json index 94fdee55e..b3e27184b 100644 --- a/.sqlx/query-2444fb3a5c12155737d934d156a63716a6383680dee11e01682597e24bf3ce53.json +++ b/.sqlx/query-b4f26412c85ce1814df203070d4edf8e613ae68bd8362c91475ba2d50ae17a78.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, user_id, admin_id, email, created_at, expires_at, used_at, token_type FROM token WHERE id = $1", + "query": "SELECT id, user_id, admin_id, email, created_at, expires_at, used_at, token_type, device_id FROM token WHERE id = $1", "describe": { "columns": [ { @@ -42,6 +42,11 @@ "ordinal": 7, "name": "token_type", "type_info": "Text" + }, + { + "ordinal": 8, + "name": "device_id", + "type_info": "Int8" } ], "parameters": { @@ -57,8 +62,9 @@ false, false, true, + true, true ] }, - "hash": "2444fb3a5c12155737d934d156a63716a6383680dee11e01682597e24bf3ce53" + "hash": "b4f26412c85ce1814df203070d4edf8e613ae68bd8362c91475ba2d50ae17a78" } diff --git a/.sqlx/query-b81a45f3c325cc4e3c4b7c4877a293d148081ee8707acc63aabbb6e6ec0abc6a.json b/.sqlx/query-b81a45f3c325cc4e3c4b7c4877a293d148081ee8707acc63aabbb6e6ec0abc6a.json new file mode 100644 index 000000000..8b094f36f --- /dev/null +++ b/.sqlx/query-b81a45f3c325cc4e3c4b7c4877a293d148081ee8707acc63aabbb6e6ec0abc6a.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", configured FROM device WHERE device_type = $1 ORDER BY name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "b81a45f3c325cc4e3c4b7c4877a293d148081ee8707acc63aabbb6e6ec0abc6a" +} diff --git a/.sqlx/query-ce03b3e26496b9e59b52fcc16072bdf4301655acf15cfc5a3b5c33065e78b36f.json b/.sqlx/query-ce03b3e26496b9e59b52fcc16072bdf4301655acf15cfc5a3b5c33065e78b36f.json new file mode 100644 index 000000000..613fd6300 --- /dev/null +++ b/.sqlx/query-ce03b3e26496b9e59b52fcc16072bdf4301655acf15cfc5a3b5c33065e78b36f.json @@ -0,0 +1,74 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", configured FROM device WHERE wireguard_pubkey = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "ce03b3e26496b9e59b52fcc16072bdf4301655acf15cfc5a3b5c33065e78b36f" +} diff --git a/.sqlx/query-e02713f082b028745cf68c2bc32d16ee640f45da0874d89bf7ab6209ee451d86.json b/.sqlx/query-e02713f082b028745cf68c2bc32d16ee640f45da0874d89bf7ab6209ee451d86.json new file mode 100644 index 000000000..785b490e7 --- /dev/null +++ b/.sqlx/query-e02713f082b028745cf68c2bc32d16ee640f45da0874d89bf7ab6209ee451d86.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \"device\" SET \"name\" = $2,\"wireguard_pubkey\" = $3,\"user_id\" = $4,\"created\" = $5,\"device_type\" = $6,\"description\" = $7,\"configured\" = $8 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Int8", + "Timestamp", + { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + }, + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "e02713f082b028745cf68c2bc32d16ee640f45da0874d89bf7ab6209ee451d86" +} diff --git a/.sqlx/query-43f040a77856123f0dadfef6aad302ecae6a6379c64b7686fdd7e6a6d32a9ed2.json b/.sqlx/query-e479998ea33e70ac7961743340964cc6f1ecf19e796de6640427600202124b0f.json similarity index 61% rename from .sqlx/query-43f040a77856123f0dadfef6aad302ecae6a6379c64b7686fdd7e6a6d32a9ed2.json rename to .sqlx/query-e479998ea33e70ac7961743340964cc6f1ecf19e796de6640427600202124b0f.json index 5ca41ad85..9c0922539 100644 --- a/.sqlx/query-43f040a77856123f0dadfef6aad302ecae6a6379c64b7686fdd7e6a6d32a9ed2.json +++ b/.sqlx/query-e479998ea33e70ac7961743340964cc6f1ecf19e796de6640427600202124b0f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO token (id, user_id, admin_id, email, created_at, expires_at, used_at, token_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + "query": "INSERT INTO token (id, user_id, admin_id, email, created_at, expires_at, used_at, token_type, device_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", "describe": { "columns": [], "parameters": { @@ -12,10 +12,11 @@ "Timestamp", "Timestamp", "Timestamp", - "Text" + "Text", + "Int8" ] }, "nullable": [] }, - "hash": "43f040a77856123f0dadfef6aad302ecae6a6379c64b7686fdd7e6a6d32a9ed2" + "hash": "e479998ea33e70ac7961743340964cc6f1ecf19e796de6640427600202124b0f" } diff --git a/.sqlx/query-e707c443a1176f9309aa284a5d5f754cf30abe8c690ded2356b2c2eed7c5c21d.json b/.sqlx/query-e707c443a1176f9309aa284a5d5f754cf30abe8c690ded2356b2c2eed7c5c21d.json new file mode 100644 index 000000000..ae9f96e05 --- /dev/null +++ b/.sqlx/query-e707c443a1176f9309aa284a5d5f754cf30abe8c690ded2356b2c2eed7c5c21d.json @@ -0,0 +1,74 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (d.id) d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", configured\n FROM device d JOIN \"user\" u ON d.user_id = u.id JOIN group_user gu ON u.id = gu.user_id JOIN \"group\" g ON gu.group_id = g.id WHERE g.\"name\" IN (SELECT * FROM UNNEST($1::text[])) AND u.is_active = true AND d.device_type = 'user'::device_type ORDER BY d.id ASC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "wireguard_pubkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "device_type: DeviceType", + "type_info": { + "Custom": { + "name": "device_type", + "kind": { + "Enum": [ + "user", + "network" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "configured", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "e707c443a1176f9309aa284a5d5f754cf30abe8c690ded2356b2c2eed7c5c21d" +} diff --git a/.sqlx/query-f323dc6850824f6b51c4b980d330cc93402cd94cdd47d80ada08063d0cb708b7.json b/.sqlx/query-f323dc6850824f6b51c4b980d330cc93402cd94cdd47d80ada08063d0cb708b7.json deleted file mode 100644 index aaddaa63c..000000000 --- a/.sqlx/query-f323dc6850824f6b51c4b980d330cc93402cd94cdd47d80ada08063d0cb708b7.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH s AS ( SELECT DISTINCT ON (device_id) * FROM wireguard_peer_stats ORDER BY device_id, latest_handshake DESC ) SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created FROM device d JOIN s ON d.id = s.device_id WHERE s.latest_handshake >= $1 AND s.network = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wireguard_pubkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Timestamp", - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "f323dc6850824f6b51c4b980d330cc93402cd94cdd47d80ada08063d0cb708b7" -} diff --git a/.sqlx/query-f5d791e2cff6d6fec9ad6c18b279b2237153a82d6057bbb59dbf4d8b22f18cae.json b/.sqlx/query-f5d791e2cff6d6fec9ad6c18b279b2237153a82d6057bbb59dbf4d8b22f18cae.json deleted file mode 100644 index 595be5483..000000000 --- a/.sqlx/query-f5d791e2cff6d6fec9ad6c18b279b2237153a82d6057bbb59dbf4d8b22f18cae.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, name, wireguard_pubkey, user_id, created FROM device WHERE wireguard_pubkey = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wireguard_pubkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "f5d791e2cff6d6fec9ad6c18b279b2237153a82d6057bbb59dbf4d8b22f18cae" -} diff --git a/.sqlx/query-865ffb2d7ee9809eaaac5bb7ad64371f0d066d89e565f72beed5965785dccfd2.json b/.sqlx/query-f6131bed08c9fb384b0c599e081085a8a4a9ca555dfbe3b7eef0f043dcc557b2.json similarity index 80% rename from .sqlx/query-865ffb2d7ee9809eaaac5bb7ad64371f0d066d89e565f72beed5965785dccfd2.json rename to .sqlx/query-f6131bed08c9fb384b0c599e081085a8a4a9ca555dfbe3b7eef0f043dcc557b2.json index 969addc3c..8f86869ba 100644 --- a/.sqlx/query-865ffb2d7ee9809eaaac5bb7ad64371f0d066d89e565f72beed5965785dccfd2.json +++ b/.sqlx/query-f6131bed08c9fb384b0c599e081085a8a4a9ca555dfbe3b7eef0f043dcc557b2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT d.wireguard_pubkey pubkey, preshared_key, array[host(wnd.wireguard_ip)] \"allowed_ips!: Vec\" FROM wireguard_network_device wnd JOIN device d ON wnd.device_id = d.id JOIN \"user\" u ON d.user_id = u.id WHERE wireguard_network_id = $1 AND (is_authorized = true OR NOT $2) AND u.is_active = true ORDER BY d.id ASC", + "query": "SELECT d.wireguard_pubkey pubkey, preshared_key, array[host(wnd.wireguard_ip)] \"allowed_ips!: Vec\" FROM wireguard_network_device wnd JOIN device d ON wnd.device_id = d.id JOIN \"user\" u ON d.user_id = u.id WHERE wireguard_network_id = $1 AND (is_authorized = true OR NOT $2) AND d.configured = true AND u.is_active = true ORDER BY d.id ASC", "describe": { "columns": [ { @@ -31,5 +31,5 @@ null ] }, - "hash": "865ffb2d7ee9809eaaac5bb7ad64371f0d066d89e565f72beed5965785dccfd2" + "hash": "f6131bed08c9fb384b0c599e081085a8a4a9ca555dfbe3b7eef0f043dcc557b2" } diff --git a/.sqlx/query-f74ae18765935516c2d99efe7a5b1257994186057c0f00bc4e2c2a401e7ddaae.json b/.sqlx/query-f74ae18765935516c2d99efe7a5b1257994186057c0f00bc4e2c2a401e7ddaae.json deleted file mode 100644 index 881bb89e7..000000000 --- a/.sqlx/query-f74ae18765935516c2d99efe7a5b1257994186057c0f00bc4e2c2a401e7ddaae.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT device.id, name, wireguard_pubkey, user_id, created FROM device WHERE user_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wireguard_pubkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "f74ae18765935516c2d99efe7a5b1257994186057c0f00bc4e2c2a401e7ddaae" -} diff --git a/.sqlx/query-f93c26d4777db959e48d8e33a08f081dafeefcc853b610ac238134ec78043a68.json b/.sqlx/query-f93c26d4777db959e48d8e33a08f081dafeefcc853b610ac238134ec78043a68.json deleted file mode 100644 index 526554820..000000000 --- a/.sqlx/query-f93c26d4777db959e48d8e33a08f081dafeefcc853b610ac238134ec78043a68.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT DISTINCT ON (d.id) d.id, d.name, d.wireguard_pubkey, d.user_id, d.created FROM device d JOIN \"user\" u ON d.user_id = u.id JOIN group_user gu ON u.id = gu.user_id JOIN \"group\" g ON gu.group_id = g.id WHERE g.\"name\" IN (SELECT * FROM UNNEST($1::text[])) AND u.is_active = true ORDER BY d.id ASC", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wireguard_pubkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "TextArray" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "f93c26d4777db959e48d8e33a08f081dafeefcc853b610ac238134ec78043a68" -} diff --git a/migrations/20241216142257_add_network_devices.down.sql b/migrations/20241216142257_add_network_devices.down.sql new file mode 100644 index 000000000..3be20639c --- /dev/null +++ b/migrations/20241216142257_add_network_devices.down.sql @@ -0,0 +1,16 @@ +ALTER TABLE device +DROP COLUMN device_type; + +ALTER TABLE device +DROP COLUMN description; + +ALTER TABLE device +DROP COLUMN configured; + +ALTER TABLE token +DROP CONSTRAINT enrollment_device_id_fkey; + +ALTER TABLE token +DROP COLUMN device_id; + +DROP TYPE device_type; diff --git a/migrations/20241216142257_add_network_devices.up.sql b/migrations/20241216142257_add_network_devices.up.sql new file mode 100644 index 000000000..afd6cda95 --- /dev/null +++ b/migrations/20241216142257_add_network_devices.up.sql @@ -0,0 +1,10 @@ +CREATE TYPE device_type AS ENUM ( + 'user', + 'network' +); + +ALTER TABLE device ADD COLUMN device_type device_type DEFAULT 'user'::device_type NOT NULL; +ALTER TABLE device ADD COLUMN description TEXT; +ALTER TABLE device ADD COLUMN configured BOOLEAN DEFAULT TRUE NOT NULL; +ALTER TABLE token ADD COLUMN device_id bigint; +ALTER TABLE token ADD CONSTRAINT enrollment_device_id_fkey FOREIGN KEY (device_id) REFERENCES device (id) ON DELETE CASCADE; diff --git a/src/auth/mod.rs b/src/auth/mod.rs index b75470fcd..5716fb119 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -228,6 +228,9 @@ macro_rules! role { state: &S, ) -> Result { let session_info = SessionInfo::from_request_parts(parts, state).await?; + if !session_info.user.is_active { + return Err(WebError::Forbidden("user is disabled".into())); + } let appstate = AppState::from_ref(state); $( let groups_with_permission = Group::find_by_permission( diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 6dd9028d7..830df02d4 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -4,7 +4,7 @@ use base64::{prelude::BASE64_STANDARD, Engine}; use chrono::{NaiveDateTime, Utc}; use ipnetwork::IpNetwork; use model_derive::Model; -use sqlx::{query, query_as, Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool}; +use sqlx::{query, query_as, Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type}; use thiserror::Error; use utoipa::ToSchema; @@ -13,7 +13,7 @@ use super::{ wireguard::{WireguardNetwork, WIREGUARD_MAX_HANDSHAKE_MINUTES}, }; use crate::{ - db::{Id, NoId}, + db::{Id, NoId, User}, KEY_LENGTH, }; @@ -31,13 +31,47 @@ pub struct DeviceConfig { pub(crate) keepalive_interval: i32, } -#[derive(Clone, Deserialize, Model, Serialize, Debug, ToSchema)] +// The type of a device: +// User: A device of a user, which may be in multiple networks, e.g. a laptop +// Network: A standalone device added by a user permamently bound to one network, e.g. a printer +#[derive(Clone, Deserialize, Serialize, PartialEq, Type, Debug)] +#[sqlx(type_name = "device_type", rename_all = "snake_case")] +pub enum DeviceType { + User, + Network, +} + +impl fmt::Display for DeviceType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::User => write!(f, "user"), + Self::Network => write!(f, "network"), + } + } +} + +impl From for String { + fn from(device_type: DeviceType) -> Self { + device_type.to_string() + } +} + +#[derive(Clone, Deserialize, Model, Serialize, Debug, ToSchema, FromRow)] pub struct Device { pub id: I, pub name: String, pub wireguard_pubkey: String, pub user_id: Id, pub created: NaiveDateTime, + #[model(enum)] + pub device_type: DeviceType, + pub description: Option, + /// Whether the device should be considered as setup and ready to use + /// or does it require some additional steps to be taken. Not configured devices + /// won't be sent to the gateway. It is assumed that an unconfigured device is already + /// added to all networks it should be in, but it's not ready to be used yet due to + /// e.g. public key not properly set up yet. + pub configured: bool, } impl fmt::Display for Device { @@ -185,6 +219,7 @@ pub struct AddDevice { pub struct ModifyDevice { pub name: String, pub wireguard_pubkey: String, + pub description: Option, } impl WireguardNetworkDevice { @@ -282,17 +317,20 @@ impl WireguardNetworkDevice { Ok(res) } - pub async fn find_by_device( - pool: &PgPool, + pub async fn find_by_device<'e, E>( + executor: E, device_id: Id, - ) -> Result>, SqlxError> { + ) -> Result>, SqlxError> + where + E: PgExecutor<'e>, + { let result = query_as!( Self, "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", preshared_key, is_authorized, authorized_at \ FROM wireguard_network_device WHERE device_id = $1", device_id ) - .fetch_all(pool) + .fetch_all(executor) .await?; Ok(if result.is_empty() { @@ -334,13 +372,23 @@ pub enum DeviceError { impl Device { #[must_use] - pub fn new(name: String, wireguard_pubkey: String, user_id: Id) -> Self { + pub fn new( + name: String, + wireguard_pubkey: String, + user_id: Id, + device_type: DeviceType, + description: Option, + configured: bool, + ) -> Self { Self { id: NoId, name, wireguard_pubkey, user_id, created: Utc::now().naive_utc(), + device_type, + description, + configured, } } } @@ -349,6 +397,7 @@ impl Device { pub fn update_from(&mut self, other: ModifyDevice) { self.name = other.name; self.wireguard_pubkey = other.wireguard_pubkey; + self.description = other.description; } /// Create WireGuard config for device. @@ -408,7 +457,8 @@ impl Device { { query_as!( Self, - "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created \ + "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", \ + configured \ FROM device d \ JOIN wireguard_network_device wnd \ ON d.id = wnd.device_id \ @@ -426,7 +476,8 @@ impl Device { { query_as!( Self, - "SELECT id, name, wireguard_pubkey, user_id, created \ + "SELECT id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", \ + configured \ FROM device WHERE wireguard_pubkey = $1", pubkey ) @@ -441,7 +492,8 @@ impl Device { ) -> Result, SqlxError> { query_as!( Self, - "SELECT device.id, name, wireguard_pubkey, user_id, created \ + "SELECT device.id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", \ + configured \ FROM device JOIN \"user\" ON device.user_id = \"user\".id \ WHERE device.id = $1 AND \"user\".username = $2", id, @@ -458,7 +510,8 @@ impl Device { ) -> Result, SqlxError> { query_as!( Self, - "SELECT device.id, name, wireguard_pubkey, user_id, created \ + "SELECT device.id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", \ + configured \ FROM device JOIN \"user\" ON device.user_id = \"user\".id \ WHERE device.id = $1 AND \"user\".id = $2", id, @@ -485,7 +538,8 @@ impl Device { pub async fn all_for_username(pool: &PgPool, username: &str) -> Result, SqlxError> { query_as!( Self, - "SELECT device.id, name, wireguard_pubkey, user_id, created \ + "SELECT device.id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", \ + configured \ FROM device JOIN \"user\" ON device.user_id = \"user\".id \ WHERE \"user\".username = $1", username @@ -494,6 +548,89 @@ impl Device { .await } + pub async fn get_network_configs( + &self, + network: &WireguardNetwork, + transaction: &mut PgConnection, + ) -> Result<(DeviceNetworkInfo, DeviceConfig), DeviceError> { + let wireguard_network_device = + WireguardNetworkDevice::find(&mut *transaction, self.id, network.id) + .await? + .ok_or_else(|| { + DeviceError::Unexpected("Device not found in network".to_string()) + })?; + let device_network_info = DeviceNetworkInfo { + network_id: network.id, + device_wireguard_ip: wireguard_network_device.wireguard_ip, + preshared_key: wireguard_network_device.preshared_key.clone(), + is_authorized: wireguard_network_device.is_authorized, + }; + + let config = self.create_config(network, &wireguard_network_device); + let device_config = DeviceConfig { + network_id: network.id, + network_name: network.name.clone(), + config, + endpoint: format!("{}:{}", network.endpoint, network.port), + address: wireguard_network_device.wireguard_ip, + allowed_ips: network.allowed_ips.clone(), + pubkey: network.pubkey.clone(), + dns: network.dns.clone(), + mfa_enabled: network.mfa_enabled, + keepalive_interval: network.keepalive_interval, + }; + + Ok((device_network_info, device_config)) + } + + pub async fn add_to_network( + &self, + network: &WireguardNetwork, + ip: IpAddr, + transaction: &mut PgConnection, + ) -> Result<(DeviceNetworkInfo, DeviceConfig), DeviceError> { + let wireguard_network_device = self + .assign_network_ip(&mut *transaction, network, ip) + .await?; + let device_network_info = DeviceNetworkInfo { + network_id: network.id, + device_wireguard_ip: wireguard_network_device.wireguard_ip, + preshared_key: wireguard_network_device.preshared_key.clone(), + is_authorized: wireguard_network_device.is_authorized, + }; + + let config = self.create_config(network, &wireguard_network_device); + let device_config = DeviceConfig { + network_id: network.id, + network_name: network.name.clone(), + config, + endpoint: format!("{}:{}", network.endpoint, network.port), + address: wireguard_network_device.wireguard_ip, + allowed_ips: network.allowed_ips.clone(), + pubkey: network.pubkey.clone(), + dns: network.dns.clone(), + mfa_enabled: network.mfa_enabled, + keepalive_interval: network.keepalive_interval, + }; + + Ok((device_network_info, device_config)) + } + + pub async fn remove_from_network( + &self, + network: &WireguardNetwork, + transaction: &mut PgConnection, + ) -> Result<(), DeviceError> { + let wireguard_network_device = + WireguardNetworkDevice::find(&mut *transaction, self.id, network.id) + .await? + .ok_or_else(|| { + DeviceError::Unexpected("Device not found in network".to_string()) + })?; + wireguard_network_device.delete(&mut *transaction).await?; + Ok(()) + } + // Add device to all existing networks pub async fn add_to_all_networks( &self, @@ -556,7 +693,7 @@ impl Device { } // Assign IP to the device in a given network - pub async fn assign_network_ip( + pub async fn assign_next_network_ip( &self, transaction: &mut PgConnection, network: &WireguardNetwork, @@ -589,6 +726,51 @@ impl Device { Err(ModelError::CannotCreate) } + pub async fn assign_network_ip( + &self, + transaction: &mut PgConnection, + network: &WireguardNetwork, + ip: IpAddr, + ) -> Result { + let net_ip = network.address.ip(); + let net_network = network.address.network(); + let net_broadcast = network.address.broadcast(); + if ip == net_ip || ip == net_network || ip == net_broadcast { + return Err(ModelError::CannotCreate); + } + + if Self::find_by_ip(&mut *transaction, ip, network.id) + .await? + .is_none() + { + info!("Assigned IP: {ip} for device: {}", self.name); + let wireguard_network_device = WireguardNetworkDevice::new(network.id, self.id, ip); + wireguard_network_device.insert(&mut *transaction).await?; + Ok(wireguard_network_device) + } else { + Err(ModelError::CannotCreate) + } + } + + pub async fn find_device_networks<'e, E>( + &self, + executor: E, + ) -> Result>, SqlxError> + where + E: PgExecutor<'e>, + { + query_as!( + WireguardNetwork, + "SELECT \ + id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, \ + connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold \ + FROM wireguard_network WHERE id IN (SELECT wireguard_network_id FROM wireguard_network_device where device_id = $1)", + self.id + ) + .fetch_all(executor) + .await + } + pub fn validate_pubkey(pubkey: &str) -> Result<(), String> { if let Ok(key) = BASE64_STANDARD.decode(pubkey) { if key.len() == KEY_LENGTH { @@ -598,6 +780,35 @@ impl Device { Err(format!("{pubkey} is not a valid pubkey")) } + + pub async fn find_by_type<'e, E>( + executor: E, + device_type: DeviceType, + ) -> Result, SqlxError> + where + E: PgExecutor<'e>, + { + query_as!(Self, + "SELECT id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", \ + configured \ + FROM device WHERE device_type = $1 ORDER BY name", + device_type as DeviceType + ).fetch_all(executor).await + } + + pub async fn get_owner<'e, E>(&self, executor: E) -> Result, SqlxError> + where + E: PgExecutor<'e>, + { + query_as!( + User, + "SELECT id, username, password_hash, last_name, first_name, email, \ + phone, mfa_enabled, totp_enabled, email_mfa_enabled, \ + totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_sub \ + FROM \"user\" WHERE id = $1", + self.user_id + ).fetch_one(executor).await + } } #[cfg(test)] @@ -625,9 +836,10 @@ mod test { } // Break loop if IP is unassigned and return device if Device::find_by_ip(pool, ip, network.id).await?.is_none() { - let device = Device::new(name.clone(), pubkey, user_id) - .save(pool) - .await?; + let device = + Device::new(name.clone(), pubkey, user_id, DeviceType::User, None, true) + .save(pool) + .await?; info!("Created device: {}", device.name); debug!("For user: {}", device.user_id); let wireguard_network_device = diff --git a/src/db/models/enrollment.rs b/src/db/models/enrollment.rs index 8555f6a7a..cacdb3b77 100644 --- a/src/db/models/enrollment.rs +++ b/src/db/models/enrollment.rs @@ -88,6 +88,7 @@ pub struct Token { pub expires_at: NaiveDateTime, pub used_at: Option, pub token_type: Option, + pub device_id: Option, } impl Token { @@ -109,6 +110,7 @@ impl Token { expires_at: (now + Duration::seconds(token_timeout_seconds as i64)).naive_utc(), used_at: None, token_type, + device_id: None, } } @@ -117,8 +119,8 @@ impl Token { E: PgExecutor<'e>, { query!( - "INSERT INTO token (id, user_id, admin_id, email, created_at, expires_at, used_at, token_type) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + "INSERT INTO token (id, user_id, admin_id, email, created_at, expires_at, used_at, token_type, device_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", self.id, self.user_id, self.admin_id, @@ -127,6 +129,7 @@ impl Token { self.expires_at, self.used_at, self.token_type, + self.device_id ) .execute(executor) .await?; @@ -198,7 +201,7 @@ impl Token { pub async fn find_by_id(pool: &PgPool, id: &str) -> Result { if let Some(enrollment) = query_as!( Self, - "SELECT id, user_id, admin_id, email, created_at, expires_at, used_at, token_type \ + "SELECT id, user_id, admin_id, email, created_at, expires_at, used_at, token_type, device_id \ FROM token WHERE id = $1", id ) @@ -216,7 +219,7 @@ impl Token { pub async fn fetch_all(pool: &PgPool) -> Result, TokenError> { let tokens = query_as!( Self, - "SELECT id, user_id, admin_id, email, created_at, expires_at, used_at, token_type \ + "SELECT id, user_id, admin_id, email, created_at, expires_at, used_at, token_type, device_id \ FROM token", ) .fetch_all(pool) @@ -503,6 +506,9 @@ impl User { enrollment_service_url: Url, send_user_notification: bool, mail_tx: UnboundedSender, + // Whether to attach some device to the token. It allows for a partial initialization of + // the device before the desktop configuration has taken place. + device_id: Option, ) -> Result { info!( "User {} starting a new desktop activation for user {}", @@ -530,13 +536,16 @@ impl User { "Create a new desktop activation token for user {}.", self.username ); - let desktop_configuration = Token::new( + let mut desktop_configuration = Token::new( self.id, Some(admin.id), email.clone(), token_timeout_seconds, Some(ENROLLMENT_TOKEN_TYPE.to_string()), ); + if let Some(device_id) = device_id { + desktop_configuration.device_id = Some(device_id); + } debug!("Saving a new desktop configuration token..."); desktop_configuration.save(&mut *transaction).await?; debug!( diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 6adcb4917..b490b952f 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -13,7 +13,7 @@ use sqlx::{query, query_as, query_scalar, Error as SqlxError, FromRow, PgExecuto use totp_lite::{totp_custom, Sha1}; use super::{ - device::{Device, UserDevice}, + device::{Device, DeviceType, UserDevice}, group::Group, webauthn::WebAuthn, MFAInfo, OAuth2AuthorizedAppInfo, SecurityKey, WalletInfo, @@ -733,8 +733,9 @@ impl User { { query_as!( Device, - "SELECT device.id, name, wireguard_pubkey, user_id, created \ - FROM device WHERE user_id = $1", + "SELECT device.id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", \ + configured \ + FROM device WHERE user_id = $1 and device_type = 'user'::device_type", self.id ) .fetch_all(executor) diff --git a/src/db/models/wireguard.rs b/src/db/models/wireguard.rs index 5bc14aa9e..4dc72bb95 100644 --- a/src/db/models/wireguard.rs +++ b/src/db/models/wireguard.rs @@ -22,7 +22,7 @@ use super::{ }; use crate::{ appstate::AppState, - db::{Id, NoId}, + db::{models::device::DeviceType, Id, NoId}, grpc::{gateway::Peer, GatewayState}, wg_config::ImportedDevice, }; @@ -320,13 +320,15 @@ impl WireguardNetwork { Some(allowed_groups) => { query_as!( Device, - "SELECT DISTINCT ON (d.id) d.id, d.name, d.wireguard_pubkey, d.user_id, d.created \ + "SELECT DISTINCT ON (d.id) d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", \ + configured FROM device d \ JOIN \"user\" u ON d.user_id = u.id \ JOIN group_user gu ON u.id = gu.user_id \ JOIN \"group\" g ON gu.group_id = g.id \ WHERE g.\"name\" IN (SELECT * FROM UNNEST($1::text[])) \ AND u.is_active = true \ + AND d.device_type = 'user'::device_type \ ORDER BY d.id ASC", &allowed_groups ) @@ -337,10 +339,12 @@ impl WireguardNetwork { None => { query_as!( Device, - "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created \ + "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", \ + configured \ FROM device d \ JOIN \"user\" u ON d.user_id = u.id \ WHERE u.is_active = true \ + AND d.device_type = 'user'::device_type \ ORDER BY d.id ASC" ) .fetch_all(&mut *transaction) @@ -364,7 +368,7 @@ impl WireguardNetwork { let devices = self.get_allowed_devices(&mut *transaction).await?; for device in devices { device - .assign_network_ip(&mut *transaction, self, None) + .assign_next_network_ip(&mut *transaction, self, None) .await?; } Ok(()) @@ -382,7 +386,7 @@ impl WireguardNetwork { let allowed_device_ids: Vec = allowed_devices.iter().map(|dev| dev.id).collect(); if allowed_device_ids.contains(&device.id) { let wireguard_network_device = device - .assign_network_ip(&mut *transaction, self, reserved_ips) + .assign_next_network_ip(&mut *transaction, self, reserved_ips) .await?; Ok(wireguard_network_device) } else { @@ -391,6 +395,21 @@ impl WireguardNetwork { } } + pub async fn add_network_device_to_network( + &self, + transaction: &mut PgConnection, + device: &WireguardNetworkDevice, + ip: IpAddr, + ) -> Result { + info!( + "Adding network device {} with IP {ip} to network {self}", + device.device_id + ); + let wireguard_network_device = WireguardNetworkDevice::new(self.id, device.device_id, ip); + wireguard_network_device.insert(&mut *transaction).await?; + Ok(wireguard_network_device) + } + /// Refresh network IPs for all relevant devices /// If the list of allowed devices has changed add/remove devices accordingly /// If the network address has changed readdress existing devices @@ -401,7 +420,11 @@ impl WireguardNetwork { ) -> Result, WireguardNetworkError> { info!("Synchronizing IPs in network {self} for all allowed devices "); // list all allowed devices - let allowed_devices = self.get_allowed_devices(&mut *transaction).await?; + let mut allowed_devices = self.get_allowed_devices(&mut *transaction).await?; + // network devices are always allowed + let network_devices = Device::find_by_type(&mut *transaction, DeviceType::Network).await?; + allowed_devices.extend(network_devices); + // convert to a map for easier processing let mut allowed_devices: HashMap> = allowed_devices .into_iter() @@ -426,7 +449,7 @@ impl WireguardNetwork { // network address changed and IP needs to be updated if !self.address.contains(device_network_config.wireguard_ip) { let wireguard_network_device = device - .assign_network_ip(&mut *transaction, self, reserved_ips) + .assign_next_network_ip(&mut *transaction, self, reserved_ips) .await?; events.push(GatewayEvent::DeviceModified(DeviceInfo { device, @@ -468,7 +491,7 @@ impl WireguardNetwork { // add configs for new allowed devices for device in allowed_devices.into_values() { let wireguard_network_device = device - .assign_network_ip(&mut *transaction, self, reserved_ips) + .assign_next_network_ip(&mut *transaction, self, reserved_ips) .await?; events.push(GatewayEvent::DeviceCreated(DeviceInfo { device, @@ -574,6 +597,9 @@ impl WireguardNetwork { mapped_device.name.clone(), mapped_device.wireguard_pubkey.clone(), mapped_device.user_id, + DeviceType::User, + None, + true, ) .save(&mut *transaction) .await?; @@ -711,7 +737,7 @@ impl WireguardNetwork { } /// Retrieves stats for specified devices - async fn device_stats( + pub(crate) async fn device_stats( &self, conn: &PgPool, devices: &[Device], @@ -789,10 +815,11 @@ impl WireguardNetwork { ORDER BY device_id, latest_handshake DESC \ ) \ SELECT \ - d.id, d.name, d.wireguard_pubkey, d.user_id, d.created \ + d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", \ + configured \ FROM device d \ JOIN s ON d.id = s.device_id \ - WHERE s.latest_handshake >= $1 AND s.network = $2", + WHERE s.latest_handshake >= $1 AND s.network = $2 AND d.device_type = 'user'::device_type", oldest_handshake, self.id, ) @@ -915,6 +942,28 @@ impl WireguardNetwork { transfer_series, }) } + + pub async fn get_devices_by_type<'e, E>( + &self, + executor: E, + device_type: DeviceType, + ) -> Result>, SqlxError> + where + E: PgExecutor<'e>, + { + query_as!( + Device, + "SELECT \ + id, name, wireguard_pubkey, user_id, created, description, device_type \"device_type: DeviceType\", \ + configured \ + FROM device WHERE id in (SELECT device_id FROM wireguard_network_device WHERE wireguard_network_id = $1) \ + AND device_type = $2", + self.id, + device_type as DeviceType + ) + .fetch_all(executor) + .await + } } // [`IpNetwork`] does not implement [`Default`] @@ -1111,10 +1160,17 @@ mod test { .save(&pool) .await .unwrap(); - let device = Device::new(String::new(), String::new(), user.id) - .save(&pool) - .await - .unwrap(); + let device = Device::new( + String::new(), + String::new(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); // insert stats let samples = 60; // 1 hour of samples @@ -1167,10 +1223,17 @@ mod test { .save(&pool) .await .unwrap(); - let device = Device::new(String::new(), String::new(), user.id) - .save(&pool) - .await - .unwrap(); + let device = Device::new( + String::new(), + String::new(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); // insert stats let samples = 60; // 1 hour of samples diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs index 264c620e1..cd259aa2f 100644 --- a/src/grpc/enrollment.rs +++ b/src/grpc/enrollment.rs @@ -14,7 +14,7 @@ use super::{ use crate::{ db::{ models::{ - device::{DeviceConfig, DeviceInfo}, + device::{DeviceConfig, DeviceInfo, DeviceType}, enrollment::{Token, TokenError, ENROLLMENT_TOKEN_TYPE}, polling_token::PollingToken, }, @@ -365,10 +365,10 @@ impl EnrollmentServer { req_device_info: Option, ) -> Result { debug!("Adding new user device: {request:?}"); - let enrollment = self.validate_session(&request.token).await?; + let enrollment_token = self.validate_session(&request.token).await?; // fetch related users - let user = enrollment.fetch_user(&self.pool).await?; + let user = enrollment_token.fetch_user(&self.pool).await?; // add device debug!( @@ -447,36 +447,118 @@ impl EnrollmentServer { request.pubkey, user.username, user.id ); - let device = Device::new( - request.name.clone(), - request.pubkey.clone(), - enrollment.user_id, - ); - debug!( - "Creating new device for user {}({:?}) {device:?}.", - user.username, user.id, - ); - let mut transaction = self.pool.begin().await.map_err(|err| { error!("Failed to begin transaction: {err}"); Status::internal("unexpected error") })?; - let device = device.save(&mut *transaction).await.map_err(|err| { - error!( - "Failed to save device {}, pubkey {} for user {}({:?}): {err}", - request.name, request.pubkey, user.username, user.id, + + let (device, network_info, configs) = if let Some(device_id) = enrollment_token.device_id { + debug!( + "A device with ID {device_id} is attached to a received enrollment token, trying to finish its configuration instead of creating a new one." ); - Status::internal("unexpected error") - })?; - info!("New device created: {device:?}."); - let _ = update_counts(&self.pool).await; + let mut device = Device::find_by_id(&mut *transaction, device_id) + .await.map_err(|err| { + error!( + "Failed to find device with ID {device_id} for user {}({:?}): {err}", + user.username, user.id + ); + Status::internal("unexpected error") + })? + .ok_or_else(|| { + error!( + "Device with ID {device_id} not found for user {}({:?}). Aborting device configuration process.", + user.username, user.id + ); + Status::not_found("device not found") + })?; - debug!( - "Adding device {} to all existing user networks for user {}({:?}).", - device.wireguard_pubkey, user.username, user.id, - ); - let (network_info, configs) = - device + // Currently not supported + if device.device_type != DeviceType::Network { + error!( + "Device {} added by user {}({:?}) is not a network device. Partial device configuration using a token is not supported for non-network devices.", + device.name, user.username, user.id + ); + return Err(Status::invalid_argument("invalid device type")); + } + + device.wireguard_pubkey = request.pubkey.clone(); + device.configured = true; + + device.save(&mut *transaction).await.map_err(|err| { + error!( + "Failed to save network device {} for user {}({:?}): {err}", + device.name, user.username, user.id + ); + Status::internal("unexpected error") + })?; + + let mut networks = device + .find_device_networks(&mut *transaction) + .await + .map_err(|err| { + error!( + "Failed to find networks for device {} for user {}({:?}): {err}", + device.name, user.username, user.id + ); + Status::internal("unexpected error") + })?; + + let network = if let Some(network) = networks.pop() { + network + } else { + error!( + "Network device {} added by user {}({:?}) is not assigned to any networks. Aborting partial device configuration process.", + device.name, user.username, user.id + ); + return Err(Status::not_found("network not found")); + }; + // We popped the last network, there should be 0 left. + if !networks.is_empty() { + warn!( + "Network device {} added by user {}({:?}) is assigned to more than one network. Using the last network as a fallback.", + device.name, user.username, user.id + ); + } + + let (network_info, configs) = device + .get_network_configs(&network, &mut transaction) + .await + .map_err(|err| { + error!( + "Failed to get network configs for device {} for user {}({:?}): {err}", + device.name, user.username, user.id + ); + Status::internal("unexpected error") + })?; + + (device, vec![network_info], vec![configs]) + } else { + debug!( + "Creating new device for user {}({:?}): {}.", + user.username, user.id, request.name + ); + let device = Device::new( + request.name.clone(), + request.pubkey.clone(), + enrollment_token.user_id, + DeviceType::User, + None, + true, + ); + let device = device.save(&mut *transaction).await.map_err(|err| { + error!( + "Failed to save device {}, pubkey {} for user {}({:?}): {err}", + request.name, request.pubkey, user.username, user.id, + ); + Status::internal("unexpected error") + })?; + info!("New device created using a token: {device:?}."); + let _ = update_counts(&self.pool).await; + debug!( + "Adding device {} to all existing user networks for user {}({:?}).", + device.wireguard_pubkey, user.username, user.id, + ); + let (network_info, configs) = device .add_to_all_networks(&mut transaction) .await .map_err(|err| { @@ -486,11 +568,13 @@ impl EnrollmentServer { ); Status::internal("unexpected error") })?; + info!( + "Added device {} to all existing user networks for user {}({:?})", + device.wireguard_pubkey, user.username, user.id + ); + (device, network_info, configs) + }; - info!( - "Added device {} to all existing user networks for user {}({:?})", - device.wireguard_pubkey, user.username, user.id - ); debug!( "Sending DeviceCreated event to gateway for device {}, user {}({:?})", device.wireguard_pubkey, user.username, user.id, @@ -514,7 +598,7 @@ impl EnrollmentServer { error!( "Failed to fetch settings for device {} creation process for user {}({:?}): {err}", device.wireguard_pubkey, user.username, user.id, -); + ); Status::internal("unexpected error") })?; debug!("Settings: {settings:?}"); @@ -585,10 +669,7 @@ impl EnrollmentServer { ) .map_err(|_| Status::internal("error rendering email template"))?; - info!( - "Device {} assigned to user {}({:?}) and added to all networks.", - device.name, user.username, user.id, - ); + info!("Device {} remote configuration done.", device.name); let response = DeviceConfigResponse { device: Some(device.into()), diff --git a/src/grpc/gateway.rs b/src/grpc/gateway.rs index 64125f34a..1405a43d8 100644 --- a/src/grpc/gateway.rs +++ b/src/grpc/gateway.rs @@ -51,6 +51,7 @@ impl WireguardNetwork { JOIN device d ON wnd.device_id = d.id \ JOIN \"user\" u ON d.user_id = u.id \ WHERE wireguard_network_id = $1 AND (is_authorized = true OR NOT $2) \ + AND d.configured = true \ AND u.is_active = true \ ORDER BY d.id ASC", self.id, diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index d32874547..27c9b12fa 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -23,6 +23,7 @@ pub(crate) mod auth; pub(crate) mod forward_auth; pub(crate) mod group; pub(crate) mod mail; +pub mod network_devices; #[cfg(feature = "openid")] pub(crate) mod openid_clients; #[cfg(feature = "openid")] diff --git a/src/handlers/network_devices.rs b/src/handlers/network_devices.rs new file mode 100644 index 000000000..5ccf00ae5 --- /dev/null +++ b/src/handlers/network_devices.rs @@ -0,0 +1,819 @@ +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + str::FromStr, +}; + +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, +}; +use chrono::NaiveDateTime; +use ipnetwork::IpNetwork; +use serde_json::json; +use sqlx::PgConnection; + +use super::{ApiResponse, ApiResult, WebError}; +use crate::{ + appstate::AppState, + auth::{AdminRole, SessionInfo}, + db::{ + models::device::{DeviceConfig, DeviceInfo, DeviceType, WireguardNetworkDevice}, + Device, GatewayEvent, Id, User, WireguardNetwork, + }, + enterprise::limits::update_counts, + handlers::mail::send_new_device_added_email, + server_config, + templates::TemplateLocation, +}; + +#[derive(Serialize)] +struct NetworkDeviceLocation { + id: Id, + name: String, +} + +#[derive(Serialize)] +struct NetworkDeviceInfo { + id: Id, + name: String, + assigned_ip: IpAddr, + description: Option, + added_by: String, + added_date: NaiveDateTime, + location: NetworkDeviceLocation, + wireguard_pubkey: String, + configured: bool, +} + +impl NetworkDeviceInfo { + async fn from_device( + device: Device, + transaction: &mut PgConnection, + ) -> Result { + let network = device + .find_device_networks(&mut *transaction) + .await? + .pop() + .ok_or(WebError::ObjectNotFound(format!( + "Failed to find the network with which the network device {} is associated", + device.name + )))?; + let wireguard_device = + WireguardNetworkDevice::find(&mut *transaction, device.id, network.id) + .await? + .ok_or(WebError::ObjectNotFound(format!( + "Failed to find network device {} network information in network {}", + device.name, network.name + )))?; + let added_by = device.get_owner(&mut *transaction).await?; + Ok(NetworkDeviceInfo { + id: device.id, + name: device.name, + assigned_ip: wireguard_device.wireguard_ip, + description: device.description, + added_by: added_by.username, + added_date: device.created, + wireguard_pubkey: device.wireguard_pubkey, + location: NetworkDeviceLocation { + id: wireguard_device.wireguard_network_id, + name: network.name, + }, + configured: device.configured, + }) + } +} + +pub async fn download_network_device_config( + _admin_role: AdminRole, + State(appstate): State, + Path(device_id): Path, +) -> Result { + debug!("Creating a WireGuard config for network device {device_id}."); + let device = + Device::find_by_id(&appstate.pool, device_id) + .await? + .ok_or(WebError::ObjectNotFound(format!( + "Network device with ID {device_id} not found" + )))?; + let network = device + .find_device_networks(&appstate.pool) + .await? + .pop() + .ok_or(WebError::ObjectNotFound(format!( + "No network found for network device: {}({})", + device.name, device.id + )))?; + let network_device = WireguardNetworkDevice::find(&appstate.pool, device_id, network.id) + .await? + .ok_or(WebError::ObjectNotFound(format!( + "No IP address found for device: {}({})", + device.name, device.id + )))?; + debug!( + "Created a WireGuard config for network device {device_id} in network {}.", + network.name + ); + Ok(device.create_config(&network, &network_device)) +} + +pub async fn get_network_device( + _admin_role: AdminRole, + session: SessionInfo, + Path(device_id): Path, + State(appstate): State, +) -> ApiResult { + debug!( + "User {} is retrieving network device with id: {device_id}", + session.user.username + ); + + let device = Device::find_by_id(&appstate.pool, device_id).await?; + if let Some(device) = device { + if device.device_type == DeviceType::Network { + let mut transaction = appstate.pool.begin().await?; + let network_device_info = + NetworkDeviceInfo::from_device(device, &mut transaction).await?; + transaction.commit().await?; + return Ok(ApiResponse { + json: json!(network_device_info), + status: StatusCode::OK, + }); + } + } + error!("Failed to retrieve network device with id: {device_id}, such network device doesn't exist."); + Err(WebError::ObjectNotFound(format!( + "Network device with ID {device_id} not found" + ))) +} + +pub(crate) async fn list_network_devices( + _admin_role: AdminRole, + State(appstate): State, +) -> ApiResult { + debug!("Listing all network devices"); + let mut devices_response: Vec = vec![]; + let mut transaction = appstate.pool.begin().await?; + let devices = Device::find_by_type(&mut *transaction, DeviceType::Network).await?; + for device in devices { + match NetworkDeviceInfo::from_device(device, &mut transaction).await { + Ok(device_info) => { + devices_response.push(device_info); + } + Err(e) => { + error!( + "Failed to get network information for network device. This device will not be displayed. Error details: {e}" + ) + } + } + } + transaction.commit().await?; + + info!("Listed {} network devices", devices_response.len()); + Ok(ApiResponse { + json: json!(devices_response), + status: StatusCode::OK, + }) +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AddNetworkDevice { + pub name: String, + pub description: Option, + pub location_id: i64, + pub assigned_ip: String, + pub wireguard_pubkey: String, +} + +#[derive(Serialize)] +pub struct AddNetworkDeviceResult { + config: DeviceConfig, + device: NetworkDeviceInfo, +} + +/// Checks if the IP falls into the range of the network +/// and if it is not already assigned to another device +async fn check_ip( + ip: IpAddr, + network: &WireguardNetwork, + transaction: &mut PgConnection, +) -> Result<(), WebError> { + let network_address = network.address; + if !network_address.contains(ip) { + return Err(WebError::BadRequest(format!( + "Provided IP address {ip} is not in the network ({}) range {network_address}", + network.name, + ))); + } + if ip == network_address.network() || ip == network_address.broadcast() { + return Err(WebError::BadRequest(format!( + "Provided IP address {ip} is network or broadcast address of network {}", + network.name + ))); + } + if ip == network_address.ip() { + return Err(WebError::BadRequest(format!( + "Provided IP address {ip} may overlap with the network's gateway IP in network {}", + network.name + ))); + } + + let device = Device::find_by_ip(transaction, ip, network.id).await?; + if let Some(device) = device { + return Err(WebError::BadRequest(format!( + "Provided IP address {ip} is already assigned to device {} in network {}", + device.name, network.name + ))); + } + + Ok(()) +} + +#[derive(Deserialize)] +pub struct IpAvailabilityCheck { + ip: String, +} + +pub(crate) async fn check_ip_availability( + _admin_role: AdminRole, + Path(network_id): Path, + State(appstate): State, + Json(ip): Json, +) -> ApiResult { + let mut transaction = appstate.pool.begin().await?; + let network = WireguardNetwork::find_by_id(&appstate.pool, network_id) + .await? + .ok_or_else(|| { + error!( + "Failed to check IP availability for network with ID {network_id}, network not found", + + ); + WebError::BadRequest("Failed to check IP availability, network not found".to_string()) + })?; + + let Ok(ip) = IpAddr::from_str(&ip.ip) else { + warn!( + "Failed to check IP availability for network with ID {network_id}, invalid IP address", + ); + return Ok(ApiResponse { + json: json!({ + "available": false, + "valid": false, + }), + status: StatusCode::OK, + }); + }; + if !network.address.contains(ip) { + warn!( + "Provided device IP is not in the network ({}) range {}", + network.name, network.address + ); + return Ok(ApiResponse { + json: json!({ + "available": false, + "valid": false, + }), + status: StatusCode::OK, + }); + } + if ip == network.address.network() || ip == network.address.broadcast() { + warn!( + "Provided device IP is network or broadcast address of network {}", + network.name + ); + return Ok(ApiResponse { + json: json!({ + "available": false, + "valid": true, + }), + status: StatusCode::OK, + }); + } + if ip == network.address.ip() { + warn!( + "Provided device IP may overlap with the network's gateway IP in network {}", + network.name + ); + return Ok(ApiResponse { + json: json!({ + "available": false, + "valid": true, + }), + status: StatusCode::OK, + }); + } + if let Some(device) = Device::find_by_ip(&mut *transaction, ip, network.id).await? { + warn!( + "Provided device IP is already assigned to device {} in network {}", + device.name, network.name + ); + return Ok(ApiResponse { + json: json!({ + "available": false, + "valid": true, + }), + status: StatusCode::OK, + }); + } + Ok(ApiResponse { + json: json!({ + "available": true, + "valid": true, + }), + status: StatusCode::OK, + }) +} + +pub(crate) async fn find_available_ip( + _admin_role: AdminRole, + Path(network_id): Path, + State(appstate): State, +) -> ApiResult { + let network = WireguardNetwork::find_by_id(&appstate.pool, network_id) + .await? + .ok_or_else(|| { + error!( + "Failed to find available IP for network with ID {}", + network_id + ); + WebError::BadRequest("Failed to find available IP, network not found".to_string()) + })?; + + let mut transaction = appstate.pool.begin().await?; + let net_ip = network.address.ip(); + let net_network = network.address.network(); + let net_broadcast = network.address.broadcast(); + for ip in &network.address { + if ip == net_ip || ip == net_network || ip == net_broadcast { + continue; + } + + // Break loop if IP is unassigned and return network device + if Device::find_by_ip(&mut *transaction, ip, network.id) + .await? + .is_none() + { + let (network_part, modifiable_part, network_prefix) = split_ip(&ip, &network.address); + transaction.commit().await?; + return Ok(ApiResponse { + json: json!({ + "ip": ip.to_string(), + "network_part": network_part, + "modifiable_part": modifiable_part, + "network_prefix": network_prefix, + }), + status: StatusCode::OK, + }); + } + } + + Ok(ApiResponse { + json: json!({}), + status: StatusCode::NOT_FOUND, + }) +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct StartNetworkDeviceSetup { + name: String, + description: Option, + location_id: i64, + assigned_ip: String, +} + +// Setup a network device to be later configured by a CLI client +pub(crate) async fn start_network_device_setup( + _admin_role: AdminRole, + session: SessionInfo, + State(appstate): State, + Json(setup_start): Json, +) -> ApiResult { + let device_name = setup_start.name.clone(); + debug!( + "User {} starting network device {device_name} setup in location with ID {}.", + session.user.username, setup_start.location_id + ); + + let user = session.user; + let network = WireguardNetwork::find_by_id(&appstate.pool, setup_start.location_id) + .await? + .ok_or_else(|| { + error!( + "Failed to add device {device_name}, network with ID {} not found", + setup_start.location_id + ); + WebError::BadRequest("Failed to add device, network not found".to_string()) + })?; + + debug!( + "Identified network location with ID {} as {}", + setup_start.location_id, network.name + ); + + let mut transaction = appstate.pool.begin().await?; + let device = Device::new( + setup_start.name, + "NOT_CONFIGURED".to_string(), + user.id, + DeviceType::Network, + setup_start.description, + false, + ) + .save(&mut *transaction) + .await?; + + debug!( + "Created a new unconfigured network device {device_name} with ID {}", + device.id + ); + + let ip: IpAddr = setup_start.assigned_ip.parse().map_err(|e| { + error!("Failed to add network device {device_name}, invalid IP address: {e}"); + WebError::BadRequest("Invalid IP address".to_string()) + })?; + check_ip(ip, &network, &mut transaction).await?; + + let (_, config) = device + .add_to_network(&network, ip, &mut transaction) + .await?; + + info!( + "User {} added a new unconfigured network device {device_name} with IP {ip} to network {}", + user.username, network.name + ); + + let result = AddNetworkDeviceResult { + config, + device: NetworkDeviceInfo::from_device(device, &mut transaction).await?, + }; + let config = server_config(); + let configuration_token = user + .start_remote_desktop_configuration( + &mut transaction, + &user, + None, + config.enrollment_token_timeout.as_secs(), + config.enrollment_url.clone(), + false, + appstate.mail_tx.clone(), + Some(result.device.id), + ) + .await?; + + debug!( + "Generated a new device CLI configuration token for a network device {device_name} with ID {}: {configuration_token}", + result.device.id + ); + + transaction.commit().await?; + update_counts(&appstate.pool).await?; + + Ok(ApiResponse { + json: json!({"enrollment_token": configuration_token, "enrollment_url": config.enrollment_url.to_string()}), + status: StatusCode::CREATED, + }) +} + +// Make a new CLI configuration token for an already added network device +pub(crate) async fn start_network_device_setup_for_device( + _admin_role: AdminRole, + session: SessionInfo, + Path(device_id): Path, + State(appstate): State, +) -> ApiResult { + debug!( + "User {} starting network device setup for already added device with ID {}.", + session.user.username, device_id + ); + let device = Device::find_by_id(&appstate.pool, device_id) + .await? + .ok_or_else(|| { + WebError::BadRequest(format!( + "Failed to start network device setup for device with ID {}, device not found", + device_id + )) + })?; + + if device.device_type != DeviceType::Network { + return Err(WebError::BadRequest( + format!("Failed to start network device setup for a choosen device {}, device is not a network device, device type: {:?}", device.name, device.device_type) + )); + } + + let mut transaction = appstate.pool.begin().await?; + let user = User::find_by_id(&mut *transaction, device.user_id) + .await? + .ok_or_else(|| { + WebError::BadRequest(format!( + "Failed to start network device setup for device with ID {}, user which added the device not found", + device_id + )) + })?; + let config = server_config(); + let configuration_token = user + .start_remote_desktop_configuration( + &mut transaction, + &user, + None, + config.enrollment_token_timeout.as_secs(), + config.enrollment_url.clone(), + false, + appstate.mail_tx.clone(), + Some(device.id), + ) + .await?; + transaction.commit().await?; + + debug!( + "Generated a new device CLI configuration token for already existing network device {} with ID {}: {configuration_token}", + device.name, device.id + ); + Ok(ApiResponse { + json: json!({"enrollment_token": configuration_token, "enrollment_url": config.enrollment_url.to_string()}), + status: StatusCode::CREATED, + }) +} + +pub(crate) async fn add_network_device( + _admin_role: AdminRole, + session: SessionInfo, + State(appstate): State, + Json(add_network_device): Json, +) -> ApiResult { + let device_name = add_network_device.name.clone(); + debug!( + "User {} adding network device {device_name} in location {}.", + session.user.username, add_network_device.location_id + ); + + let user = session.user; + let network = WireguardNetwork::find_by_id(&appstate.pool, add_network_device.location_id) + .await? + .ok_or_else(|| { + error!( + "Failed to add device {device_name}, network with ID {} not found", + add_network_device.location_id + ); + WebError::BadRequest("Failed to add device, network not found".to_string()) + })?; + + Device::validate_pubkey(&add_network_device.wireguard_pubkey) + .map_err(WebError::PubkeyValidation)?; + + // Make sure there is no device with the same pubkey, such state may lead to unexpected issues + if Device::find_by_pubkey(&appstate.pool, &add_network_device.wireguard_pubkey) + .await? + .is_some() + { + return Err(WebError::PubkeyExists(format!( + "Failed to add device {device_name}, identical pubkey ({}) already exists", + add_network_device.wireguard_pubkey + ))); + } + + let mut transaction = appstate.pool.begin().await?; + let device = Device::new( + add_network_device.name, + add_network_device.wireguard_pubkey, + user.id, + DeviceType::Network, + add_network_device.description, + true, + ) + .save(&mut *transaction) + .await?; + + let ip: IpAddr = add_network_device.assigned_ip.parse().map_err(|e| { + error!("Failed to add network device {device_name}, invalid IP address: {e}"); + WebError::BadRequest("Invalid IP address".to_string()) + })?; + check_ip(ip, &network, &mut transaction).await?; + + let (network_info, config) = device + .add_to_network(&network, ip, &mut transaction) + .await?; + + appstate.send_wireguard_event(GatewayEvent::DeviceCreated(DeviceInfo { + device: device.clone(), + network_info: vec![network_info.clone()], + })); + + let template_locations = vec![TemplateLocation { + name: config.network_name.clone(), + assigned_ip: config.address.to_string(), + }]; + + send_new_device_added_email( + &device.name, + &device.wireguard_pubkey, + &template_locations, + &user.email, + &appstate.mail_tx, + Some(session.session.ip_address.as_str()), + session.session.device_info.clone().as_deref(), + )?; + + info!( + "User {} added a new network device {device_name}.", + user.username + ); + + let result = AddNetworkDeviceResult { + config, + device: NetworkDeviceInfo::from_device(device, &mut transaction).await?, + }; + + transaction.commit().await?; + update_counts(&appstate.pool).await?; + + Ok(ApiResponse { + json: json!(result), + status: StatusCode::CREATED, + }) +} + +#[derive(Debug, Deserialize)] +pub struct ModifyNetworkDevice { + name: String, + description: Option, + assigned_ip: String, +} + +pub async fn modify_network_device( + _admin_role: AdminRole, + session: SessionInfo, + Path(device_id): Path, + State(appstate): State, + Json(data): Json, +) -> ApiResult { + debug!("User {} updating device {device_id}", session.user.username); + let mut transaction = appstate.pool.begin().await?; + let mut device = Device::find_by_id(&mut *transaction, device_id) + .await? + .ok_or_else(|| { + error!("Failed to update device {device_id}, device not found"); + WebError::ObjectNotFound(format!("Device {device_id} not found")) + })?; + let device_network = device + .find_device_networks(&mut *transaction) + .await? + .pop() + .ok_or_else(|| { + error!("Failed to update device {device_id}, device not found in any network"); + WebError::ObjectNotFound(format!("Device {device_id} not found in any network")) + })?; + let mut wireguard_network_device = + WireguardNetworkDevice::find(&mut *transaction, device.id, device_network.id) + .await? + .ok_or_else(|| { + error!("Failed to update device {device_id}, device not found in any network"); + WebError::ObjectNotFound(format!("Device {device_id} not found in any network")) + })?; + let new_ip = IpAddr::from_str(&data.assigned_ip).map_err(|e| { + WebError::BadRequest(format!( + "Failed to update device {device_id}, invalid IP address: {e}" + )) + })?; + + device.name = data.name; + device.description = data.description; + device.save(&mut *transaction).await?; + + // IP address has changed, so remove device from network and add it again with new IP address. + if new_ip != wireguard_network_device.wireguard_ip { + check_ip(new_ip, &device_network, &mut transaction).await?; + wireguard_network_device.wireguard_ip = new_ip; + wireguard_network_device.update(&mut *transaction).await?; + let device_info = DeviceInfo::from_device(&mut *transaction, device.clone()).await?; + appstate.send_wireguard_event(GatewayEvent::DeviceModified(device_info)); + info!( + "User {} changed IP address of network device {} from {} to {new_ip} in network {}", + session.user.username, + device.name, + wireguard_network_device.wireguard_ip, + device_network.name + ); + } + + let network_device_info = NetworkDeviceInfo::from_device(device, &mut transaction).await?; + transaction.commit().await?; + + Ok(ApiResponse { + json: json!(network_device_info), + status: StatusCode::OK, + }) +} + +/// Splits the IP address (IPv4 or IPv6) into three parts: network part, modifiable part and prefix +/// The network part is the part that can't be changed by the user. +/// This is to display an IP address in the UI like this: 192.168.(1.1)/16, where the part in the parenthesis can be changed by the user. +// The algorithm works as follows: +// 1. Get the network address, last address and IP address segments, e.g. 192.1.1.1 would be [192, 1, 1, 1] +// 2. Iterate over the segments and compare the last address and network segments, as long as the current segments are equal, append the segment to the network part. +// If they are not equal, we found the first modifiable segment (one of the segments of an address that may change between hosts in the same network), +// append the rest of the segments to the modifiable part. +// 3. Join the segments with the delimiter and return the network part, modifiable part and the network prefix +fn split_ip(ip: &IpAddr, network: &IpNetwork) -> (String, String, String) { + let network_addr = network.network(); + let network_prefix = network.prefix(); + + let ip_segments = match ip { + IpAddr::V4(ip) => ip.octets().iter().map(|x| *x as u16).collect(), + IpAddr::V6(ip) => ip.segments().to_vec(), + }; + + let last_addr_segments = match network { + IpNetwork::V4(net) => { + let last_ip = u32::from(net.ip()) | (!u32::from(net.mask())); + let last_ip: Ipv4Addr = last_ip.into(); + last_ip.octets().iter().map(|x| *x as u16).collect() + } + IpNetwork::V6(net) => { + let last_ip = u128::from(net.ip()) | (!u128::from(net.mask())); + let last_ip: Ipv6Addr = last_ip.into(); + last_ip.segments().to_vec() + } + }; + + let network_segments = match network_addr { + IpAddr::V4(ip) => ip.octets().iter().map(|x| *x as u16).collect(), + IpAddr::V6(ip) => ip.segments().to_vec(), + }; + + let mut network_part = String::new(); + let mut modifiable_part = String::new(); + let delimiter = if ip.is_ipv4() { "." } else { ":" }; + let formatter = |x: &u16| { + if ip.is_ipv4() { + x.to_string() + } else { + format!("{:04x}", x) + } + }; + + for (i, ((last_addr_segment, network_segment), ip_segment)) in last_addr_segments + .iter() + .zip(network_segments.iter()) + .zip(ip_segments.iter()) + .enumerate() + { + if last_addr_segment != network_segment { + let parts = ip_segments.split_at(i).1; + let joined = parts + .iter() + .map(formatter) + .collect::>() + .join(delimiter); + modifiable_part.push_str(&joined); + break; + } else { + let formatted = formatter(ip_segment); + network_part.push_str(&format!("{formatted}{delimiter}")); + } + } + + (network_part, modifiable_part, network_prefix.to_string()) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_ip_splitter() { + let net = split_ip( + &IpAddr::from_str("192.168.3.1").unwrap(), + &IpNetwork::from_str("192.168.3.1/30").unwrap(), + ); + + assert_eq!(net.0, "192.168.3."); + assert_eq!(net.1, "1"); + assert_eq!(net.2, "30"); + + let net = split_ip( + &IpAddr::from_str("192.168.5.7").unwrap(), + &IpNetwork::from_str("192.168.3.1/24").unwrap(), + ); + + assert_eq!(net.0, "192.168.5."); + assert_eq!(net.1, "7"); + assert_eq!(net.2, "24"); + + let net = split_ip( + &IpAddr::from_str("2001:0db8:85a3::8a2e:0370:7334").unwrap(), + &IpNetwork::from_str("2001:0db8:85a3::8a2e:0370:7334/64").unwrap(), + ); + + assert_eq!(net.0, "2001:0db8:85a3:0000:"); + assert_eq!(net.1, "0000:8a2e:0370:7334"); + assert_eq!(net.2, "64"); + + let net = split_ip( + &IpAddr::from_str("2001:0db8::0010:8a2e:0370:aaaa").unwrap(), + &IpNetwork::from_str("2001:db8::10:8a2e:370:aaa8/125").unwrap(), + ); + + assert_eq!(net.0, "2001:0db8:0000:0000:0010:8a2e:0370:"); + assert_eq!(net.1, "aaaa"); + assert_eq!(net.2, "125"); + } +} diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 947abaeb5..7841b3050 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -515,6 +515,7 @@ pub async fn start_remote_desktop_configuration( config.enrollment_url.clone(), data.send_enrollment_notification, appstate.mail_tx.clone(), + None, ) .await?; diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs index 544438cae..3618cae86 100644 --- a/src/handlers/wireguard.rs +++ b/src/handlers/wireguard.rs @@ -23,9 +23,13 @@ use crate::{ db::{ models::{ device::{ - DeviceConfig, DeviceInfo, DeviceNetworkInfo, ModifyDevice, WireguardNetworkDevice, + DeviceConfig, DeviceInfo, DeviceNetworkInfo, DeviceType, ModifyDevice, + WireguardNetworkDevice, + }, + wireguard::{ + DateTimeAggregation, MappedDevice, WireguardDeviceStatsRow, WireguardNetworkInfo, + WireguardUserStatsRow, }, - wireguard::{DateTimeAggregation, MappedDevice, WireguardNetworkInfo}, }, AddDevice, Device, GatewayEvent, Id, WireguardNetwork, }, @@ -213,7 +217,15 @@ pub async fn delete_network( ); let network = find_network(network_id, &appstate.pool).await?; let network_name = network.name.clone(); - network.delete(&appstate.pool).await?; + let mut transaction = appstate.pool.begin().await?; + let network_devices = network + .get_devices_by_type(&mut *transaction, DeviceType::Network) + .await?; + for device in network_devices { + device.delete(&mut *transaction).await?; + } + network.delete(&mut *transaction).await?; + transaction.commit().await?; appstate.send_wireguard_event(GatewayEvent::NetworkDeleted(network_id, network_name)); info!( "User {} deleted WireGuard network {network_id}", @@ -545,9 +557,16 @@ pub async fn add_device( // save device let mut transaction = appstate.pool.begin().await?; - let device = Device::new(add_device.name, add_device.wireguard_pubkey, user.id) - .save(&mut *transaction) - .await?; + let device = Device::new( + add_device.name, + add_device.wireguard_pubkey, + user.id, + DeviceType::User, + None, + true, + ) + .save(&mut *transaction) + .await?; let (network_info, configs) = device.add_to_all_networks(&mut transaction).await?; @@ -935,7 +954,13 @@ impl QueryFrom { } } -pub async fn user_stats( +#[derive(Serialize)] +pub struct DevicesStatsResponse { + pub user_devices: Vec, + pub network_devices: Vec, +} + +pub async fn devices_stats( _role: AdminRole, State(appstate): State, Path(network_id): Path, @@ -949,13 +974,25 @@ pub async fn user_stats( }; let from = query_from.parse_timestamp()?.naive_utc(); let aggregation = get_aggregation(from)?; - let stats = network + let user_devices_stats = network .user_stats(&appstate.pool, &from, &aggregation) .await?; + let network_devices = Device::find_by_type(&appstate.pool, DeviceType::Network).await?; + let network_devices_stats = network + .device_stats(&appstate.pool, &network_devices, &from, &aggregation) + .await? + .into_iter() + .filter(|device_stats| !device_stats.stats.is_empty()) + .collect(); + let response = DevicesStatsResponse { + user_devices: user_devices_stats, + network_devices: network_devices_stats, + }; + debug!("Displayed WireGuard user stats for network {network_id}"); Ok(ApiResponse { - json: json!(stats), + json: json!(response), status: StatusCode::OK, }) } diff --git a/src/lib.rs b/src/lib.rs index f985233cc..8b5c26003 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ use axum::{ routing::{delete, get, patch, post, put}, serve, Extension, Json, Router, }; +use db::models::device::DeviceType; use enterprise::handlers::{ check_enterprise_info, check_enterprise_status, enterprise_settings::{get_enterprise_settings, patch_enterprise_settings}, @@ -22,6 +23,11 @@ use enterprise::handlers::{ }; use handlers::{ group::{bulk_assign_to_groups, list_groups_info}, + network_devices::{ + add_network_device, check_ip_availability, download_network_device_config, + find_available_ip, get_network_device, list_network_devices, modify_network_device, + start_network_device_setup, start_network_device_setup_for_device, + }, ssh_authorized_keys::{ add_authentication_key, delete_authentication_key, fetch_authentication_keys, rename_authentication_key, @@ -51,9 +57,9 @@ use utoipa_swagger_ui::SwaggerUi; #[cfg(feature = "wireguard")] use self::handlers::wireguard::{ add_device, add_user_devices, create_network, create_network_token, delete_device, - delete_network, download_config, gateway_status, get_device, import_network, list_devices, - list_networks, list_user_devices, modify_device, modify_network, network_details, - network_stats, remove_gateway, user_stats, + delete_network, devices_stats, download_config, gateway_status, get_device, import_network, + list_devices, list_networks, list_user_devices, modify_device, modify_network, network_details, + network_stats, remove_gateway, }; #[cfg(feature = "worker")] use self::handlers::worker::{ @@ -462,6 +468,29 @@ pub fn build_webapp( .route("/device/:device_id", delete(delete_device)) .route("/device", get(list_devices)) .route("/device/user/:username", get(list_user_devices)) + // Network devices, as opposed to user devices + .route("/device/network", post(add_network_device)) + .route("/device/network", get(list_network_devices)) + .route("/device/network/ip/:network_id", get(find_available_ip)) + .route( + "/device/network/ip/:network_id", + post(check_ip_availability), + ) + .route("/device/network/:device_id", put(modify_network_device)) + .route("/device/network/:device_id", get(get_network_device)) + .route("/device/network/:device_id", delete(delete_device)) + .route( + "/device/network/:device_id/config", + get(download_network_device_config), + ) + .route( + "/device/network/start_cli", + post(start_network_device_setup), + ) + .route( + "/device/network/start_cli/:device_id", + post(start_network_device_setup_for_device), + ) .route("/network", post(create_network)) .route("/network/:network_id", put(modify_network)) .route("/network/:network_id", delete(delete_network)) @@ -479,7 +508,7 @@ pub fn build_webapp( get(download_config), ) .route("/network/:network_id/token", get(create_network_token)) - .route("/network/:network_id/stats/users", get(user_stats)) + .route("/network/:network_id/stats/users", get(devices_stats)) .route("/network/:network_id/stats", get(network_stats)) .layer(Extension(gateway_state)), ); @@ -626,12 +655,15 @@ pub async fn init_dev_env(config: &DefGuardConfig) { "TestDevice".to_string(), "gQYL5eMeFDj0R+lpC7oZyIl0/sNVmQDC6ckP7husZjc=".to_string(), 1, + DeviceType::User, + None, + true, ) .save(&mut *transaction) .await .expect("Could not save device"); device - .assign_network_ip(&mut transaction, &network, None) + .assign_next_network_ip(&mut transaction, &network, None) .await .expect("Could not assign IP to device"); } diff --git a/src/wireguard_peer_disconnect.rs b/src/wireguard_peer_disconnect.rs index e8d0fc0d4..01659e455 100644 --- a/src/wireguard_peer_disconnect.rs +++ b/src/wireguard_peer_disconnect.rs @@ -12,7 +12,7 @@ use tokio::{sync::broadcast::Sender, time::sleep}; use crate::db::{ models::{ - device::{DeviceInfo, DeviceNetworkInfo, WireguardNetworkDevice}, + device::{DeviceInfo, DeviceNetworkInfo, DeviceType, WireguardNetworkDevice}, error::ModelError, wireguard::WireguardNetworkError, }, @@ -67,11 +67,12 @@ pub async fn run_periodic_peer_disconnect( WHERE network = $1 \ ORDER BY device_id, collected_at DESC \ ) \ - SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created \ + SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", \ + configured \ FROM device d \ JOIN wireguard_network_device wnd ON wnd.device_id = d.id \ LEFT JOIN stats on d.id = stats.device_id \ - WHERE wnd.wireguard_network_id = $1 AND wnd.is_authorized = true AND \ + WHERE wnd.wireguard_network_id = $1 AND wnd.is_authorized = true AND d.configured = true AND \ (wnd.authorized_at IS NULL OR (NOW() - wnd.authorized_at) > $2 * interval '1 second') AND \ (stats.latest_handshake IS NULL OR (NOW() - stats.latest_handshake) > $2 * interval '1 second')", location.id, diff --git a/tests/wireguard_network_allowed_groups.rs b/tests/wireguard_network_allowed_groups.rs index c5e93e471..b622f50e2 100644 --- a/tests/wireguard_network_allowed_groups.rs +++ b/tests/wireguard_network_allowed_groups.rs @@ -2,7 +2,7 @@ mod common; use claims::assert_err; use defguard::{ - db::{Device, GatewayEvent, Group, Id, User, WireguardNetwork}, + db::{models::device::DeviceType, Device, GatewayEvent, Group, Id, User, WireguardNetwork}, handlers::{wireguard::ImportedNetworkData, Auth}, }; use matches::assert_matches; @@ -30,6 +30,9 @@ async fn setup_test_users(pool: &PgPool) -> (Vec>, Vec>) { "admin device".into(), "nst4lmZz9kPTq6OdeQq2G2th3n+QneHKmG1wJJ3Jrq0=".into(), admin_user.id, + DeviceType::User, + None, + true, ) .save(pool) .await @@ -47,6 +50,9 @@ async fn setup_test_users(pool: &PgPool) -> (Vec>, Vec>) { "test device".into(), "wYOt6ImBaQ3BEMQ3Xf5P5fTnbqwOvjcqYkkSBt+1xOg=".into(), test_user.id, + DeviceType::User, + None, + true, ) .save(pool) .await @@ -74,6 +80,9 @@ async fn setup_test_users(pool: &PgPool) -> (Vec>, Vec>) { "other device".into(), "v2U14sjNN4tOYD3P15z0WkjriKY9Hl85I3vIEPomrYs=".into(), other_user.id, + DeviceType::User, + None, + true, ) .save(pool) .await @@ -97,6 +106,9 @@ async fn setup_test_users(pool: &PgPool) -> (Vec>, Vec>) { "non group device".into(), "6xmL/jRuxmzQ3J2/kVZnKnh+6dwODcEEczmmkIKU4sM=".into(), non_group_user.id, + DeviceType::User, + None, + true, ) .save(pool) .await diff --git a/tests/wireguard_network_devices.rs b/tests/wireguard_network_devices.rs new file mode 100644 index 000000000..f4abaa77d --- /dev/null +++ b/tests/wireguard_network_devices.rs @@ -0,0 +1,275 @@ +mod common; + +use std::{net::IpAddr, str::FromStr}; + +use defguard::{ + db::{Device, GatewayEvent, Id, WireguardNetwork}, + handlers::{network_devices::AddNetworkDevice, Auth}, +}; +use ipnetwork::IpNetwork; +use matches::assert_matches; +use reqwest::StatusCode; +use serde::Deserialize; +use serde_json::{json, Value}; + +use self::common::make_test_client; + +fn make_network() -> Value { + json!({ + "name": "network", + "address": "10.1.1.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + "allowed_groups": [], + "mfa_enabled": false, + "keepalive_interval": 25, + "peer_disconnect_threshold": 180 + }) +} + +fn make_second_network() -> Value { + json!({ + "name": "network-2", + "address": "10.6.1.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.6.1.0/24", + "dns": "1.1.1.1", + "allowed_groups": [], + "mfa_enabled": false, + "keepalive_interval": 25, + "peer_disconnect_threshold": 180 + }) +} + +#[tokio::test] +async fn test_network_devices() { + let (client, client_state) = make_test_client().await; + + let mut wg_rx = client_state.wireguard_rx; + + let auth = Auth::new("admin", "pass123"); + let response = &client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // create networks + let response = client + .post("/api/v1/network") + .json(&make_network()) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let network_1: WireguardNetwork = response.json().await; + assert_eq!(network_1.name, "network"); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::NetworkCreated(..)); + let response = client + .post("/api/v1/network") + .json(&make_second_network()) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let network_2: WireguardNetwork = response.json().await; + assert_eq!(network_2.name, "network-2"); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::NetworkCreated(..)); + + // ip suggestions + let response = client.get("/api/v1/device/network/ip/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + let res = response.json::().await; + let ip = res["ip"].as_str().unwrap(); + let ip = ip.parse::().unwrap(); + let net_ip = IpAddr::from_str("10.1.1.1").unwrap(); + let network_range = IpNetwork::new(net_ip, 24).unwrap(); + assert!(network_range.contains(ip)); + + // checking whether ip is valid/availble + #[derive(Deserialize)] + struct IpCheckRes { + available: bool, + valid: bool, + } + let ip_check = json!( + { + "ip": "10.1.1.2".to_string(), + } + ); + let response = client + .post("/api/v1/device/network/ip/1") + .json(&ip_check) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let res = response.json::().await; + assert!(res.available); + assert!(res.valid); + + let ip_check = json!( + { + "ip": "10.1.1.0".to_string(), + } + ); + let response = client + .post("/api/v1/device/network/ip/1") + .json(&ip_check) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let res = response.json::().await; + assert!(!res.available); + assert!(res.valid); + + let ip_check = json!( + { + "ip": "10.1.1.1".to_string(), + } + ); + let response = client + .post("/api/v1/device/network/ip/1") + .json(&ip_check) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let res = response.json::().await; + assert!(!res.available); + assert!(res.valid); + + let ip_check = json!( + { + "ip": "10.1.1.abc".to_string(), + } + ); + let response = client + .post("/api/v1/device/network/ip/1") + .json(&ip_check) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let res = response.json::().await; + assert!(!res.available); + assert!(!res.valid); + + // make network device (manual, WireGuard flow) + let network_device = AddNetworkDevice { + name: "device-1".into(), + wireguard_pubkey: "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=".into(), + assigned_ip: ip.to_string(), + location_id: 1, + description: None, + }; + let response = client + .post("/api/v1/device/network") + .json(&network_device) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let json = response.json::().await; + let device_id = json["device"]["id"].as_i64().unwrap(); + let configured = json["device"]["configured"].as_bool().unwrap(); + let config_text = json["config"]["config"].as_str().unwrap(); + assert!(configured); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::DeviceCreated(..)); + + // download WG config + let response = client.get("/api/v1/device/network/1/config").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_config = response.text().await; + assert_eq!(response_config, config_text); + + // edit the device + let modify_device = json!({ + "name": "device-1", + "description": "new description", + "assigned_ip": "10.1.1.3" + }); + let response = client + .put(format!("/api/v1/device/network/{}", device_id)) + .json(&modify_device) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let json = response.json::().await; + let description = json["description"].as_str().unwrap(); + let assigned_ip = json["assigned_ip"].as_str().unwrap(); + assert_eq!(description, "new description"); + assert_eq!( + assigned_ip, + IpAddr::from_str("10.1.1.3").unwrap().to_string() + ); + let device = Device::find_by_id(&client_state.pool, device_id as i64) + .await + .unwrap() + .unwrap(); + assert_eq!(device.name, "device-1"); + assert_eq!(device.description, Some("new description".to_string())); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::DeviceModified(..)); + + // Make sure the device is only in the selected network + let device_networks = device + .find_device_networks(&client_state.pool) + .await + .unwrap(); + assert_eq!(device_networks.len(), 1); + assert_eq!(network_1.id, device_networks[0].id); + + // Try making cli "enrollment" token for that device + let response = client + .post("/api/v1/device/network/start_cli/1") + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let json = response.json::().await; + let token = json["enrollment_token"].as_str().unwrap(); + assert_eq!(token.len(), 32); + let enrollment_url = json["enrollment_url"].as_str().unwrap(); + assert_eq!(enrollment_url, "http://localhost:8080/"); + + // Enrollment flow for 2nd device + let setup_start = json!( + { + "name": "device-2", + "description": "new description", + "assigned_ip": "10.1.1.10", + "location_id": 1, + } + ); + let response = client + .post("/api/v1/device/network/start_cli") + .json(&setup_start) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let json = response.json::().await; + let token = json["enrollment_token"].as_str().unwrap(); + assert_eq!(token.len(), 32); + let enrollment_url = json["enrollment_url"].as_str().unwrap(); + assert_eq!(enrollment_url, "http://localhost:8080/"); + let device = Device::find_by_id(&client_state.pool, 2) + .await + .unwrap() + .unwrap(); + assert!(!device.configured); + assert_eq!(device.name, "device-2"); + let device_network = device + .find_device_networks(&client_state.pool) + .await + .unwrap(); + assert_eq!(device_network.len(), 1); + assert_eq!(device_network[0].id, network_1.id); + + // Deleting the device + let response = client + .delete(format!("/api/v1/device/network/{}", device_id)) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let device = Device::find_by_id(&client_state.pool, device_id as i64) + .await + .unwrap(); + assert!(device.is_none()); +} diff --git a/tests/wireguard_network_import.rs b/tests/wireguard_network_import.rs index d5e43656f..4c45d91d9 100644 --- a/tests/wireguard_network_import.rs +++ b/tests/wireguard_network_import.rs @@ -3,7 +3,7 @@ mod common; use defguard::{ db::{ models::{ - device::UserDevice, + device::{DeviceType, UserDevice}, wireguard::{DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL}, }, Device, GatewayEvent, WireguardNetwork, @@ -66,6 +66,9 @@ async fn test_config_import() { "test device".into(), "l07+qPWs4jzW3Gp1DKbHgBMRRm4Jg3q2BJxw0ZYl6c4=".into(), 1, + DeviceType::User, + None, + true, ) .save(&mut *transaction) .await @@ -79,6 +82,9 @@ async fn test_config_import() { "another test device".into(), "v2U14sjNN4tOYD3P15z0WkjriKY9Hl85I3vIEPomrYs=".into(), 1, + DeviceType::User, + None, + true, ) .save(&mut *transaction) .await diff --git a/tests/wireguard_network_stats.rs b/tests/wireguard_network_stats.rs index 11f5f7654..6e813595c 100644 --- a/tests/wireguard_network_stats.rs +++ b/tests/wireguard_network_stats.rs @@ -4,13 +4,15 @@ use chrono::{Datelike, Duration, NaiveDate, SubsecRound, Timelike, Utc}; use defguard::{ db::{ models::wireguard::{ - WireguardDeviceTransferRow, WireguardNetworkStats, WireguardUserStatsRow, + WireguardDeviceStatsRow, WireguardDeviceTransferRow, WireguardNetworkStats, + WireguardUserStatsRow, }, Device, Id, NoId, WireguardPeerStats, }, handlers::Auth, }; use reqwest::StatusCode; +use serde::Deserialize; use serde_json::{json, Value}; use self::common::make_test_client; @@ -40,7 +42,12 @@ async fn test_stats() { let auth = Auth::new("admin", "pass123"); let response = &client.post("/api/v1/auth").json(&auth).send().await; assert_eq!(response.status(), StatusCode::OK); - + #[derive(Deserialize)] + struct StatsResponse { + user_devices: Vec, + #[serde(rename = "network_devices")] + _network_devices: Vec, + } // create network let response = client .post("/api/v1/network") @@ -93,7 +100,8 @@ async fn test_stats() { .send() .await; assert_eq!(response.status(), StatusCode::OK); - let stats: Vec = response.json().await; + let stats = response.json::().await; + let stats = stats.user_devices; assert!(stats.is_empty()); // insert stats @@ -126,7 +134,8 @@ async fn test_stats() { .send() .await; assert_eq!(response.status(), StatusCode::OK); - let stats: Vec = response.json().await; + let stats = response.json::().await; + let stats = stats.user_devices; assert_eq!(stats.len(), 1); assert_eq!(stats[0].devices.len(), 2); assert_eq!( @@ -230,7 +239,8 @@ async fn test_stats() { .send() .await; assert_eq!(response.status(), StatusCode::OK); - let stats: Vec = response.json().await; + let stats = response.json::().await; + let stats = stats.user_devices; assert_eq!(stats.len(), 1); assert_eq!(stats[0].devices.len(), 2); assert_eq!( diff --git a/web/.env b/web/.env deleted file mode 100644 index 3afe802fb..000000000 --- a/web/.env +++ /dev/null @@ -1 +0,0 @@ -PROXY_TARGET=https://defguard-dev.teonite.net diff --git a/web/package.json b/web/package.json index dbffb4a94..86e1ec923 100644 --- a/web/package.json +++ b/web/package.json @@ -58,6 +58,7 @@ "get-text-width": "^1.0.3", "hex-rgb": "^5.0.0", "html-react-parser": "^5.2.0", + "humanize-duration": "^3.32.1", "ipaddr.js": "^2.2.0", "itertools": "^2.3.2", "lodash-es": "^4.17.21", @@ -94,6 +95,7 @@ "@tanstack/react-query-devtools": "^4.36.1", "@types/byte-size": "^8.1.2", "@types/file-saver": "^2.0.7", + "@types/humanize-duration": "^3.27.4", "@types/lodash-es": "^4.17.12", "@types/node": "^22.10.2", "@types/react": "^18.2.48", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1f794eb15..3af2fceb8 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: html-react-parser: specifier: ^5.2.0 version: 5.2.0(@types/react@18.2.48)(react@18.2.0) + humanize-duration: + specifier: ^3.32.1 + version: 3.32.1 ipaddr.js: specifier: ^2.2.0 version: 2.2.0 @@ -183,6 +186,9 @@ importers: '@types/file-saver': specifier: ^2.0.7 version: 2.0.7 + '@types/humanize-duration': + specifier: ^3.27.4 + version: 3.27.4 '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -1213,6 +1219,9 @@ packages: '@types/history@4.7.11': resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} + '@types/humanize-duration@3.27.4': + resolution: {integrity: sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2727,6 +2736,9 @@ packages: resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} engines: {node: '>=14.18.0'} + humanize-duration@3.32.1: + resolution: {integrity: sha512-inh5wue5XdfObhu/IGEMiA1nUXigSGcaKNemcbLRKa7jXYGDZXr3LoT9pTIzq2hPEbld7w/qv9h+ikWGz8fL1g==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -5773,6 +5785,8 @@ snapshots: '@types/history@4.7.11': {} + '@types/humanize-duration@3.27.4': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -7679,6 +7693,8 @@ snapshots: human-signals@4.3.1: {} + humanize-duration@3.32.1: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 diff --git a/web/src/components/App/App.tsx b/web/src/components/App/App.tsx index 3d6a1059a..03d49ac8a 100644 --- a/web/src/components/App/App.tsx +++ b/web/src/components/App/App.tsx @@ -11,7 +11,6 @@ import { EnrollmentPage } from '../../pages/enrollment/EnrollmentPage'; import { GroupsPage } from '../../pages/groups/GroupsPage'; import { NetworkPage } from '../../pages/network/NetworkPage'; import { OpenidClientsListPage } from '../../pages/openid/OpenidClientsListPage/OpenidClientsListPage'; -import { AddStandaloneDeviceModal } from '../../pages/overview/modals/AddStandaloneDeviceModal/AddStandaloneDeviceModal'; import { OverviewPage } from '../../pages/overview/OverviewPage'; import { ProvisionersPage } from '../../pages/provisioners/ProvisionersPage'; import { SettingsPage } from '../../pages/settings/SettingsPage'; @@ -192,7 +191,6 @@ const App = () => { - diff --git a/web/src/components/Navigation/Navigation.tsx b/web/src/components/Navigation/Navigation.tsx index 69b2e48e8..bf3935dad 100644 --- a/web/src/components/Navigation/Navigation.tsx +++ b/web/src/components/Navigation/Navigation.tsx @@ -24,6 +24,7 @@ import { useUserProfileStore } from '../../shared/hooks/store/useUserProfileStor import useApi from '../../shared/hooks/useApi'; import { QueryKeys } from '../../shared/queries'; import { User } from '../../shared/types'; +import { DevicePageNavigationIcon } from './components/DevicesPageNavigationIcon'; import { NavigationDesktop } from './components/NavigationDesktop/NavigationDesktop'; import { NavigationMobile } from './components/NavigationMobile/NavigationMobile'; import { navigationExcludedRoutes } from './config'; @@ -106,6 +107,13 @@ export const Navigation = () => { allowedToView: ['admin'], enabled: true, }, + { + title: LL.navigation.bar.devices(), + linkPath: '/admin/devices', + icon: , + allowedToView: ['admin'], + enabled: true, + }, { title: LL.navigation.bar.openId(), linkPath: '/admin/openid', diff --git a/web/src/components/Navigation/components/DevicesPageNavigationIcon.tsx b/web/src/components/Navigation/components/DevicesPageNavigationIcon.tsx new file mode 100644 index 000000000..5f16eb874 --- /dev/null +++ b/web/src/components/Navigation/components/DevicesPageNavigationIcon.tsx @@ -0,0 +1,52 @@ +import { useId } from 'react'; + +export const DevicePageNavigationIcon = () => { + const id = useId(); + return ( + + + + + + + + + + + + ); +}; diff --git a/web/src/components/Navigation/components/NavigationBar/style.scss b/web/src/components/Navigation/components/NavigationBar/style.scss index 693fad706..e161db978 100644 --- a/web/src/components/Navigation/components/NavigationBar/style.scss +++ b/web/src/components/Navigation/components/NavigationBar/style.scss @@ -101,7 +101,8 @@ & > svg { g, - path { + path, + rect { fill: var(--primary); } } diff --git a/web/src/components/Navigation/components/NavigationLink/style.scss b/web/src/components/Navigation/components/NavigationLink/style.scss index 05e773f11..cd1d84e41 100644 --- a/web/src/components/Navigation/components/NavigationLink/style.scss +++ b/web/src/components/Navigation/components/NavigationLink/style.scss @@ -1,5 +1,9 @@ .navigation-link { svg { + rect { + fill: var(--surface-icon-primary); + } + path { fill: var(--surface-icon-primary); } @@ -12,6 +16,10 @@ &:hover, &.active { svg { + rect { + fill: var(--surface-main-primary); + } + path { fill: var(--surface-main-primary); } diff --git a/web/src/components/Navigation/components/NavigationMobile/NavigationMobile.tsx b/web/src/components/Navigation/components/NavigationMobile/NavigationMobile.tsx index 91b2488f9..350a37b88 100644 --- a/web/src/components/Navigation/components/NavigationMobile/NavigationMobile.tsx +++ b/web/src/components/Navigation/components/NavigationMobile/NavigationMobile.tsx @@ -66,6 +66,14 @@ export const NavigationMobile = ({ navItems, onLogout }: Props) => { path: '/admin/openid', title: LL.navigation.mobileTitles.openId(), }, + { + path: '/admin/groups', + title: LL.navigation.mobileTitles.groups(), + }, + { + path: '/admin/devices', + title: LL.navigation.mobileTitles.devices(), + }, ], [LL.navigation.mobileTitles], ); diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index e4cefa6b0..90f5f80a4 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -29,6 +29,9 @@ const en: BaseTranslation = { }, key: 'Key', name: 'Name', + noData: 'No data', + unavailable: 'Unavailable', + notSet: 'Not set', }, messages: { error: 'Error has occurred.', @@ -42,15 +45,59 @@ const en: BaseTranslation = { }, }, modals: { + standaloneDeviceEnrollmentModal: { + title: 'Network device token', + toasters: { + error: 'Token generation failed.', + }, + }, + standaloneDeviceConfigModal: { + title: 'Network device config', + cardTitle: 'Config', + toasters: { + getConfig: { + error: 'Failed to get device config.', + }, + }, + }, + editStandaloneModal: { + title: 'Edit network device', + toasts: { + success: 'Device modified', + failure: 'Modifying the device failed', + }, + }, deleteStandaloneDevice: { title: 'Delete network device', content: 'Device {name: string} will be deleted.', + messages: { + success: 'Device deleted', + error: 'Failed to remove device.', + }, }, addStandaloneDevice: { + toasts: { + deviceCreated: 'Device added', + creationFailed: 'Device could not be added.', + }, infoBox: { setup: 'Here you can add definitions or generate configurations for devices that can connect to your VPN. Only locations without Multi-Factor Authentication are available here, as MFA is only supported in Defguard Desktop Client for now.', }, + form: { + submit: 'Add Device', + labels: { + deviceName: 'Device Name', + location: 'Location', + assignedAddress: 'Assigned IP', + description: 'Description', + generation: { + auto: 'Generate key pair', + manual: 'Use my own public key', + }, + publicKey: 'Provide Your Public Key', + }, + }, steps: { method: { title: 'Choose a proffered method', @@ -70,22 +117,6 @@ const en: BaseTranslation = { }, manual: { title: 'Add new VPN device using WireGuard Client', - setup: { - form: { - submit: 'Add Device', - labels: { - deviceName: 'Device Name', - location: 'Location', - assignedAddress: 'Assigned IP', - description: 'Description', - generation: { - auto: 'Generate key pair', - manual: 'Use my own public key', - }, - publicKey: 'Provide Your Public Key', - }, - }, - }, finish: { messageTop: 'Download the provided configuration file to your device and import it into your VPN client to complete the setup.', @@ -112,12 +143,6 @@ const en: BaseTranslation = { stepMessage: 'Here you can add definitions or generate configurations for devices that can connect to your VPN. Only locations without Multi-Factor Authentication are available here, as MFA is only supported in Defguard Desktop Client for now.', form: { - labels: { - deviceName: 'Device Name', - location: 'Location', - assignedAddress: 'Assigned IP', - description: 'Description', - }, submit: 'Add Device', }, }, @@ -867,6 +892,10 @@ const en: BaseTranslation = { }, usersOverview: { pageTitle: 'Users', + grid: { + usersTitle: 'Connected Users', + devicesTitle: 'Connected Network Devices', + }, search: { placeholder: 'Find users', }, @@ -910,6 +939,7 @@ const en: BaseTranslation = { enrollment: 'Enrollment', support: 'Support', groups: 'Groups', + devices: 'Network Devices', }, mobileTitles: { groups: 'Groups', @@ -924,6 +954,7 @@ const en: BaseTranslation = { networkSettings: 'Edit Location', enrollment: 'Enrollment', support: 'Support', + devices: 'Network Devices', }, copyright: 'Copyright ©2023-2024', version: { @@ -944,6 +975,9 @@ const en: BaseTranslation = { username: 'Username', }, error: { + reservedName: 'Name is already taken.', + invalidIp: 'IP is invalid.', + reservedIp: 'IP is already in use.', forbiddenCharacter: 'Field contains forbidden characters.', usernameTaken: 'Username is already in use.', invalidKey: 'Key is invalid.', @@ -978,6 +1012,14 @@ const en: BaseTranslation = { }, }, components: { + standaloneDeviceTokenModalContent: { + headerMessage: + 'First download defguard command line client binaries and install them on your server.', + downloadButton: 'Download defguard CLI Client', + expandableCard: { + title: 'Copy and paste this command in your terminal on the device', + }, + }, deviceConfigsCard: { cardTitle: 'WireGuard Config for location:', messages: { @@ -1636,6 +1678,10 @@ const en: BaseTranslation = { out: 'Out:', gatewayDisconnected: 'Gateway disconnected', }, + cardsLabels: { + users: 'Connected Users', + devices: 'Connected Network Devices', + }, }, connectedUsersOverview: { pageTitle: 'Connected users', @@ -1988,7 +2034,7 @@ Any other requests you can reach us at: support@defguard.net }, }, devicesPage: { - title: 'Devices', + title: 'Network Devices', search: { placeholder: 'Find', }, @@ -2010,10 +2056,11 @@ Any other requests you can reach us at: support@defguard.net addedAt: 'Add date', edit: 'Edit', }, - edit: { - actionLabels: { - edit: 'Edit', - }, + }, + edit: { + actionLabels: { + config: 'View config', + generateToken: 'Generate auth token', }, }, }, diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 06e7cd42b..e23211680 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -108,6 +108,18 @@ type RootTranslation = { * N​a​m​e */ name: string + /** + * N​o​ ​d​a​t​a + */ + noData: string + /** + * U​n​a​v​a​i​l​a​b​l​e + */ + unavailable: string + /** + * N​o​t​ ​s​e​t + */ + notSet: string } messages: { /** @@ -142,6 +154,52 @@ type RootTranslation = { } } modals: { + standaloneDeviceEnrollmentModal: { + /** + * N​e​t​w​o​r​k​ ​d​e​v​i​c​e​ ​t​o​k​e​n + */ + title: string + toasters: { + /** + * T​o​k​e​n​ ​g​e​n​e​r​a​t​i​o​n​ ​f​a​i​l​e​d​. + */ + error: string + } + } + standaloneDeviceConfigModal: { + /** + * N​e​t​w​o​r​k​ ​d​e​v​i​c​e​ ​c​o​n​f​i​g + */ + title: string + /** + * C​o​n​f​i​g + */ + cardTitle: string + toasters: { + getConfig: { + /** + * F​a​i​l​e​d​ ​t​o​ ​g​e​t​ ​d​e​v​i​c​e​ ​c​o​n​f​i​g​. + */ + error: string + } + } + } + editStandaloneModal: { + /** + * E​d​i​t​ ​n​e​t​w​o​r​k​ ​d​e​v​i​c​e + */ + title: string + toasts: { + /** + * D​e​v​i​c​e​ ​m​o​d​i​f​i​e​d + */ + success: string + /** + * M​o​d​i​f​y​i​n​g​ ​t​h​e​ ​d​e​v​i​c​e​ ​f​a​i​l​e​d + */ + failure: string + } + } deleteStandaloneDevice: { /** * D​e​l​e​t​e​ ​n​e​t​w​o​r​k​ ​d​e​v​i​c​e @@ -152,14 +210,72 @@ type RootTranslation = { * @param {string} name */ content: RequiredParams<'name'> + messages: { + /** + * D​e​v​i​c​e​ ​d​e​l​e​t​e​d + */ + success: string + /** + * F​a​i​l​e​d​ ​t​o​ ​r​e​m​o​v​e​ ​d​e​v​i​c​e​. + */ + error: string + } } addStandaloneDevice: { + toasts: { + /** + * D​e​v​i​c​e​ ​a​d​d​e​d + */ + deviceCreated: string + /** + * D​e​v​i​c​e​ ​c​o​u​l​d​ ​n​o​t​ ​b​e​ ​a​d​d​e​d​. + */ + creationFailed: string + } infoBox: { /** * H​e​r​e​ ​y​o​u​ ​c​a​n​ ​a​d​d​ ​d​e​f​i​n​i​t​i​o​n​s​ ​o​r​ ​g​e​n​e​r​a​t​e​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​s​ ​f​o​r​ ​d​e​v​i​c​e​s​ ​t​h​a​t​ ​c​a​n​ ​c​o​n​n​e​c​t​ ​t​o​ ​y​o​u​r​ ​V​P​N​.​ ​O​n​l​y​ ​l​o​c​a​t​i​o​n​s​ ​w​i​t​h​o​u​t​ ​M​u​l​t​i​-​F​a​c​t​o​r​ ​A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​a​r​e​ ​a​v​a​i​l​a​b​l​e​ ​h​e​r​e​,​ ​a​s​ ​M​F​A​ ​i​s​ ​o​n​l​y​ ​s​u​p​p​o​r​t​e​d​ ​i​n​ ​D​e​f​g​u​a​r​d​ ​D​e​s​k​t​o​p​ ​C​l​i​e​n​t​ ​f​o​r​ ​n​o​w​. */ setup: string } + form: { + /** + * A​d​d​ ​D​e​v​i​c​e + */ + submit: string + labels: { + /** + * D​e​v​i​c​e​ ​N​a​m​e + */ + deviceName: string + /** + * L​o​c​a​t​i​o​n + */ + location: string + /** + * A​s​s​i​g​n​e​d​ ​I​P + */ + assignedAddress: string + /** + * D​e​s​c​r​i​p​t​i​o​n + */ + description: string + generation: { + /** + * G​e​n​e​r​a​t​e​ ​k​e​y​ ​p​a​i​r + */ + auto: string + /** + * U​s​e​ ​m​y​ ​o​w​n​ ​p​u​b​l​i​c​ ​k​e​y + */ + manual: string + } + /** + * P​r​o​v​i​d​e​ ​Y​o​u​r​ ​P​u​b​l​i​c​ ​K​e​y + */ + publicKey: string + } + } steps: { method: { /** @@ -198,46 +314,6 @@ type RootTranslation = { * A​d​d​ ​n​e​w​ ​V​P​N​ ​d​e​v​i​c​e​ ​u​s​i​n​g​ ​W​i​r​e​G​u​a​r​d​ ​C​l​i​e​n​t */ title: string - setup: { - form: { - /** - * A​d​d​ ​D​e​v​i​c​e - */ - submit: string - labels: { - /** - * D​e​v​i​c​e​ ​N​a​m​e - */ - deviceName: string - /** - * L​o​c​a​t​i​o​n - */ - location: string - /** - * A​s​s​i​g​n​e​d​ ​I​P - */ - assignedAddress: string - /** - * D​e​s​c​r​i​p​t​i​o​n - */ - description: string - generation: { - /** - * G​e​n​e​r​a​t​e​ ​k​e​y​ ​p​a​i​r - */ - auto: string - /** - * U​s​e​ ​m​y​ ​o​w​n​ ​p​u​b​l​i​c​ ​k​e​y - */ - manual: string - } - /** - * P​r​o​v​i​d​e​ ​Y​o​u​r​ ​P​u​b​l​i​c​ ​K​e​y - */ - publicKey: string - } - } - } finish: { /** * D​o​w​n​l​o​a​d​ ​t​h​e​ ​p​r​o​v​i​d​e​d​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​i​l​e​ ​t​o​ ​y​o​u​r​ ​d​e​v​i​c​e​ ​a​n​d​ ​i​m​p​o​r​t​ ​i​t​ ​i​n​t​o​ ​y​o​u​r​ ​V​P​N​ ​c​l​i​e​n​t​ ​t​o​ ​c​o​m​p​l​e​t​e​ ​t​h​e​ ​s​e​t​u​p​. @@ -286,24 +362,6 @@ type RootTranslation = { */ stepMessage: string form: { - labels: { - /** - * D​e​v​i​c​e​ ​N​a​m​e - */ - deviceName: string - /** - * L​o​c​a​t​i​o​n - */ - location: string - /** - * A​s​s​i​g​n​e​d​ ​I​P - */ - assignedAddress: string - /** - * D​e​s​c​r​i​p​t​i​o​n - */ - description: string - } /** * A​d​d​ ​D​e​v​i​c​e */ @@ -2027,6 +2085,16 @@ type RootTranslation = { * U​s​e​r​s */ pageTitle: string + grid: { + /** + * C​o​n​n​e​c​t​e​d​ ​U​s​e​r​s + */ + usersTitle: string + /** + * C​o​n​n​e​c​t​e​d​ ​N​e​t​w​o​r​k​ ​D​e​v​i​c​e​s + */ + devicesTitle: string + } search: { /** * F​i​n​d​ ​u​s​e​r​s @@ -2160,6 +2228,10 @@ type RootTranslation = { * G​r​o​u​p​s */ groups: string + /** + * N​e​t​w​o​r​k​ ​D​e​v​i​c​e​s + */ + devices: string } mobileTitles: { /** @@ -2210,6 +2282,10 @@ type RootTranslation = { * S​u​p​p​o​r​t */ support: string + /** + * N​e​t​w​o​r​k​ ​D​e​v​i​c​e​s + */ + devices: string } /** * C​o​p​y​r​i​g​h​t​ ​©​2​0​2​3​-​2​0​2​4 @@ -2268,6 +2344,18 @@ type RootTranslation = { username: string } error: { + /** + * N​a​m​e​ ​i​s​ ​a​l​r​e​a​d​y​ ​t​a​k​e​n​. + */ + reservedName: string + /** + * I​P​ ​i​s​ ​i​n​v​a​l​i​d​. + */ + invalidIp: string + /** + * I​P​ ​i​s​ ​a​l​r​e​a​d​y​ ​i​n​ ​u​s​e​. + */ + reservedIp: string /** * F​i​e​l​d​ ​c​o​n​t​a​i​n​s​ ​f​o​r​b​i​d​d​e​n​ ​c​h​a​r​a​c​t​e​r​s​. */ @@ -2393,6 +2481,22 @@ type RootTranslation = { } } components: { + standaloneDeviceTokenModalContent: { + /** + * F​i​r​s​t​ ​d​o​w​n​l​o​a​d​ ​d​e​f​g​u​a​r​d​ ​c​o​m​m​a​n​d​ ​l​i​n​e​ ​c​l​i​e​n​t​ ​b​i​n​a​r​i​e​s​ ​a​n​d​ ​i​n​s​t​a​l​l​ ​t​h​e​m​ ​o​n​ ​y​o​u​r​ ​s​e​r​v​e​r​. + */ + headerMessage: string + /** + * D​o​w​n​l​o​a​d​ ​d​e​f​g​u​a​r​d​ ​C​L​I​ ​C​l​i​e​n​t + */ + downloadButton: string + expandableCard: { + /** + * C​o​p​y​ ​a​n​d​ ​p​a​s​t​e​ ​t​h​i​s​ ​c​o​m​m​a​n​d​ ​i​n​ ​y​o​u​r​ ​t​e​r​m​i​n​a​l​ ​o​n​ ​t​h​e​ ​d​e​v​i​c​e + */ + title: string + } + } deviceConfigsCard: { /** * W​i​r​e​G​u​a​r​d​ ​C​o​n​f​i​g​ ​f​o​r​ ​l​o​c​a​t​i​o​n​: @@ -3890,6 +3994,16 @@ type RootTranslation = { */ gatewayDisconnected: string } + cardsLabels: { + /** + * C​o​n​n​e​c​t​e​d​ ​U​s​e​r​s + */ + users: string + /** + * C​o​n​n​e​c​t​e​d​ ​N​e​t​w​o​r​k​ ​D​e​v​i​c​e​s + */ + devices: string + } } connectedUsersOverview: { /** @@ -4668,7 +4782,7 @@ type RootTranslation = { } devicesPage: { /** - * D​e​v​i​c​e​s + * N​e​t​w​o​r​k​ ​D​e​v​i​c​e​s */ title: string search: { @@ -4723,13 +4837,17 @@ type RootTranslation = { */ edit: string } - edit: { - actionLabels: { - /** - * E​d​i​t - */ - edit: string - } + } + edit: { + actionLabels: { + /** + * V​i​e​w​ ​c​o​n​f​i​g + */ + config: string + /** + * G​e​n​e​r​a​t​e​ ​a​u​t​h​ ​t​o​k​e​n + */ + generateToken: string } } } @@ -4830,6 +4948,18 @@ export type TranslationFunctions = { * Name */ name: () => LocalizedString + /** + * No data + */ + noData: () => LocalizedString + /** + * Unavailable + */ + unavailable: () => LocalizedString + /** + * Not set + */ + notSet: () => LocalizedString } messages: { /** @@ -4864,6 +4994,52 @@ export type TranslationFunctions = { } } modals: { + standaloneDeviceEnrollmentModal: { + /** + * Network device token + */ + title: () => LocalizedString + toasters: { + /** + * Token generation failed. + */ + error: () => LocalizedString + } + } + standaloneDeviceConfigModal: { + /** + * Network device config + */ + title: () => LocalizedString + /** + * Config + */ + cardTitle: () => LocalizedString + toasters: { + getConfig: { + /** + * Failed to get device config. + */ + error: () => LocalizedString + } + } + } + editStandaloneModal: { + /** + * Edit network device + */ + title: () => LocalizedString + toasts: { + /** + * Device modified + */ + success: () => LocalizedString + /** + * Modifying the device failed + */ + failure: () => LocalizedString + } + } deleteStandaloneDevice: { /** * Delete network device @@ -4873,14 +5049,72 @@ export type TranslationFunctions = { * Device {name} will be deleted. */ content: (arg: { name: string }) => LocalizedString + messages: { + /** + * Device deleted + */ + success: () => LocalizedString + /** + * Failed to remove device. + */ + error: () => LocalizedString + } } addStandaloneDevice: { + toasts: { + /** + * Device added + */ + deviceCreated: () => LocalizedString + /** + * Device could not be added. + */ + creationFailed: () => LocalizedString + } infoBox: { /** * Here you can add definitions or generate configurations for devices that can connect to your VPN. Only locations without Multi-Factor Authentication are available here, as MFA is only supported in Defguard Desktop Client for now. */ setup: () => LocalizedString } + form: { + /** + * Add Device + */ + submit: () => LocalizedString + labels: { + /** + * Device Name + */ + deviceName: () => LocalizedString + /** + * Location + */ + location: () => LocalizedString + /** + * Assigned IP + */ + assignedAddress: () => LocalizedString + /** + * Description + */ + description: () => LocalizedString + generation: { + /** + * Generate key pair + */ + auto: () => LocalizedString + /** + * Use my own public key + */ + manual: () => LocalizedString + } + /** + * Provide Your Public Key + */ + publicKey: () => LocalizedString + } + } steps: { method: { /** @@ -4919,46 +5153,6 @@ export type TranslationFunctions = { * Add new VPN device using WireGuard Client */ title: () => LocalizedString - setup: { - form: { - /** - * Add Device - */ - submit: () => LocalizedString - labels: { - /** - * Device Name - */ - deviceName: () => LocalizedString - /** - * Location - */ - location: () => LocalizedString - /** - * Assigned IP - */ - assignedAddress: () => LocalizedString - /** - * Description - */ - description: () => LocalizedString - generation: { - /** - * Generate key pair - */ - auto: () => LocalizedString - /** - * Use my own public key - */ - manual: () => LocalizedString - } - /** - * Provide Your Public Key - */ - publicKey: () => LocalizedString - } - } - } finish: { /** * Download the provided configuration file to your device and import it into your VPN client to complete the setup. @@ -5007,24 +5201,6 @@ export type TranslationFunctions = { */ stepMessage: () => LocalizedString form: { - labels: { - /** - * Device Name - */ - deviceName: () => LocalizedString - /** - * Location - */ - location: () => LocalizedString - /** - * Assigned IP - */ - assignedAddress: () => LocalizedString - /** - * Description - */ - description: () => LocalizedString - } /** * Add Device */ @@ -6730,6 +6906,16 @@ export type TranslationFunctions = { * Users */ pageTitle: () => LocalizedString + grid: { + /** + * Connected Users + */ + usersTitle: () => LocalizedString + /** + * Connected Network Devices + */ + devicesTitle: () => LocalizedString + } search: { /** * Find users @@ -6863,6 +7049,10 @@ export type TranslationFunctions = { * Groups */ groups: () => LocalizedString + /** + * Network Devices + */ + devices: () => LocalizedString } mobileTitles: { /** @@ -6913,6 +7103,10 @@ export type TranslationFunctions = { * Support */ support: () => LocalizedString + /** + * Network Devices + */ + devices: () => LocalizedString } /** * Copyright ©2023-2024 @@ -6969,6 +7163,18 @@ export type TranslationFunctions = { username: () => LocalizedString } error: { + /** + * Name is already taken. + */ + reservedName: () => LocalizedString + /** + * IP is invalid. + */ + invalidIp: () => LocalizedString + /** + * IP is already in use. + */ + reservedIp: () => LocalizedString /** * Field contains forbidden characters. */ @@ -7090,6 +7296,22 @@ export type TranslationFunctions = { } } components: { + standaloneDeviceTokenModalContent: { + /** + * First download defguard command line client binaries and install them on your server. + */ + headerMessage: () => LocalizedString + /** + * Download defguard CLI Client + */ + downloadButton: () => LocalizedString + expandableCard: { + /** + * Copy and paste this command in your terminal on the device + */ + title: () => LocalizedString + } + } deviceConfigsCard: { /** * WireGuard Config for location: @@ -8577,6 +8799,16 @@ export type TranslationFunctions = { */ gatewayDisconnected: () => LocalizedString } + cardsLabels: { + /** + * Connected Users + */ + users: () => LocalizedString + /** + * Connected Network Devices + */ + devices: () => LocalizedString + } } connectedUsersOverview: { /** @@ -9351,7 +9583,7 @@ export type TranslationFunctions = { } devicesPage: { /** - * Devices + * Network Devices */ title: () => LocalizedString search: { @@ -9406,13 +9638,17 @@ export type TranslationFunctions = { */ edit: () => LocalizedString } - edit: { - actionLabels: { - /** - * Edit - */ - edit: () => LocalizedString - } + } + edit: { + actionLabels: { + /** + * View config + */ + config: () => LocalizedString + /** + * Generate auth token + */ + generateToken: () => LocalizedString } } } diff --git a/web/src/pages/devices/DevicesPage.tsx b/web/src/pages/devices/DevicesPage.tsx index a497a1f41..1b2ce96f8 100644 --- a/web/src/pages/devices/DevicesPage.tsx +++ b/web/src/pages/devices/DevicesPage.tsx @@ -1,16 +1,24 @@ +import './style.scss'; + +import { useQuery } from '@tanstack/react-query'; import { PropsWithChildren, useEffect } from 'react'; import { useI18nContext } from '../../i18n/i18n-react'; import { ManagementPageLayout } from '../../shared/components/Layout/ManagementPageLayout/ManagementPageLayout'; import { Button } from '../../shared/defguard-ui/components/Layout/Button/Button'; import { ButtonStyleVariant } from '../../shared/defguard-ui/components/Layout/Button/types'; -import { AddStandaloneDeviceModal } from '../overview/modals/AddStandaloneDeviceModal/AddStandaloneDeviceModal'; -import { useAddStandaloneDeviceModal } from '../overview/modals/AddStandaloneDeviceModal/store'; +import { useAuthStore } from '../../shared/hooks/store/useAuthStore'; +import useApi from '../../shared/hooks/useApi'; +import { QueryKeys } from '../../shared/queries'; import { AddDeviceIcon } from './components/AddDeviceIcon'; import { DevicesList } from './components/DevicesList/DevicesList'; import { ConfirmDeviceDeleteModal } from './components/DevicesList/modals/ConfirmDeviceDeleteModal'; import { DevicesPageProvider, useDevicesPage } from './hooks/useDevicesPage'; -import { mockDevices } from './mock'; +import { AddStandaloneDeviceModal } from './modals/AddStandaloneDeviceModal/AddStandaloneDeviceModal'; +import { useAddStandaloneDeviceModal } from './modals/AddStandaloneDeviceModal/store'; +import { EditStandaloneModal } from './modals/EditStandaloneDeviceModal/EditStandaloneModal'; +import { StandaloneDeviceConfigModal } from './modals/StandaloneDeviceConfigModal/StandaloneDeviceConfigModal'; +import { StandaloneDeviceEnrollmentModal } from './modals/StandaloneDeviceEnrollmentModal/StandaloneDeviceEnrollmentModal'; export const DevicesPage = () => { return ( @@ -19,6 +27,9 @@ export const DevicesPage = () => { {/* Add modals here */} + + + ); }; @@ -47,21 +58,57 @@ const Page = () => { const { LL } = useI18nContext(); const localLL = LL.devicesPage; const [{ devices }, setPageState] = useDevicesPage(); + const currentUser = useAuthStore((s) => s.user); + + const { + standaloneDevice: { getDevicesList }, + user: { getUser }, + } = useApi(); + + const { data: userProfile } = useQuery({ + queryFn: () => getUser(currentUser?.username as string), + queryKey: [QueryKeys.FETCH_USER_PROFILE, currentUser?.id], + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + enabled: currentUser !== undefined, + }); + + const { data: devicesData } = useQuery({ + queryKey: [QueryKeys.FETCH_STANDALONE_DEVICE_LIST], + queryFn: getDevicesList, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + }); useEffect(() => { - setPageState((s) => ({ - ...s, - devices: mockDevices, - })); + if (userProfile && devicesData) { + setPageState((s) => ({ + ...s, + reservedDeviceNames: [ + ...userProfile.devices.map((d) => d.name.trim()), + ...devicesData + .filter((d) => d.added_by === (currentUser?.username as string)) + .map((d) => d.name.trim()), + ], + devices: devicesData, + })); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [userProfile, devicesData]); + return ( { - console.log(v); + setPageState((s) => ({ + ...s, + search: v, + })); }, }} actions={} diff --git a/web/src/pages/devices/components/DevicesList/DevicesList.tsx b/web/src/pages/devices/components/DevicesList/DevicesList.tsx index 7262512e9..45320a971 100644 --- a/web/src/pages/devices/components/DevicesList/DevicesList.tsx +++ b/web/src/pages/devices/components/DevicesList/DevicesList.tsx @@ -1,7 +1,9 @@ import './style.scss'; +import { useMutation } from '@tanstack/react-query'; import dayjs from 'dayjs'; import { useCallback, useMemo } from 'react'; +import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../../../i18n/i18n-react'; import { DeviceAvatar } from '../../../../shared/defguard-ui/components/Layout/DeviceAvatar/DeviceAvatar'; @@ -14,7 +16,14 @@ import { ListSortDirection, } from '../../../../shared/defguard-ui/components/Layout/VirtualizedList/types'; import { VirtualizedList } from '../../../../shared/defguard-ui/components/Layout/VirtualizedList/VirtualizedList'; -import { MockDevice, useDevicesPage } from '../../hooks/useDevicesPage'; +import useApi from '../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../shared/hooks/useToaster'; +import { StandaloneDevice } from '../../../../shared/types'; +import { useDeleteStandaloneDeviceModal } from '../../hooks/useDeleteStandaloneDeviceModal'; +import { useDevicesPage } from '../../hooks/useDevicesPage'; +import { useEditStandaloneDeviceModal } from '../../hooks/useEditStandaloneDeviceModal'; +import { useStandaloneDeviceConfigModal } from '../../modals/StandaloneDeviceConfigModal/store'; +import { useStandaloneDeviceEnrollmentModal } from '../../modals/StandaloneDeviceEnrollmentModal/store'; export const DevicesList = () => { const { LL } = useI18nContext(); @@ -23,7 +32,7 @@ export const DevicesList = () => { const [{ devices, search }] = useDevicesPage(); const renderRow = useCallback( - (device: MockDevice) => , + (device: StandaloneDevice) => , [], ); @@ -71,19 +80,12 @@ export const DevicesList = () => { ); }; -const DeviceRow = ({ - addedBy, - addedDate, - assignedIp, - description, - id, - location, - name, -}: MockDevice) => { +const DeviceRow = (props: StandaloneDevice) => { + const { description, id, location, name, added_by, added_date, assigned_ip } = props; const formatDate = useMemo(() => { - const day = dayjs(addedDate); + const day = dayjs(added_date); return day.format('DD.MM.YYYY | HH:mm'); - }, [addedDate]); + }, [added_date]); return (
@@ -91,38 +93,92 @@ const DeviceRow = ({
- +
- {assignedIp} + {assigned_ip}
- +
- +
{formatDate}
- +
); }; -const DeviceRowEditButton = () => { +const DeviceRowEditButton = (props: { data: StandaloneDevice }) => { const { LL } = useI18nContext(); + const { + standaloneDevice: { getDeviceConfig, generateAuthToken }, + } = useApi(); + const toaster = useToaster(); + const { mutateAsync, isLoading: deviceConfigLoading } = useMutation({ + mutationFn: getDeviceConfig, + onError: (e) => { + toaster.error(LL.modals.standaloneDeviceConfigModal.toasters.getConfig.error()); + console.error(e); + }, + }); + + const { mutateAsync: mutateTokenGen, isLoading: deviceTokenLoading } = useMutation({ + mutationFn: generateAuthToken, + onError: (e) => { + toaster.error(LL.modals.standaloneDeviceEnrollmentModal.toasters.error()); + console.error(e); + }, + }); + + const openDelete = useDeleteStandaloneDeviceModal((s) => s.open, shallow); + const openEdit = useEditStandaloneDeviceModal((s) => s.open, shallow); + const openConfig = useStandaloneDeviceConfigModal((s) => s.open); + const openEnrollment = useStandaloneDeviceEnrollmentModal((s) => s.open); + + const handleOpenConfig = useCallback(() => { + mutateAsync(props.data.id).then((config) => { + openConfig({ + device: props.data, + config, + }); + }); + }, [mutateAsync, openConfig, props.data]); + + const handleTokenGen = useCallback(() => { + mutateTokenGen(props.data.id).then((res) => { + openEnrollment({ + device: props.data, + enrollment: res, + }); + }); + }, [mutateTokenGen, openEnrollment, props.data]); + return ( - + openEdit(props.data)} + /> + handleOpenConfig()} + disabled={!props.data.configured || deviceConfigLoading} + /> + handleTokenGen()} + disabled={deviceTokenLoading} + /> openDelete(props.data)} /> ); diff --git a/web/src/pages/devices/components/DevicesList/modals/ConfirmDeviceDeleteModal.tsx b/web/src/pages/devices/components/DevicesList/modals/ConfirmDeviceDeleteModal.tsx index 541fb4c14..d089b8ba8 100644 --- a/web/src/pages/devices/components/DevicesList/modals/ConfirmDeviceDeleteModal.tsx +++ b/web/src/pages/devices/components/DevicesList/modals/ConfirmDeviceDeleteModal.tsx @@ -1,7 +1,11 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../../../../i18n/i18n-react'; import { ConfirmModal } from '../../../../../shared/defguard-ui/components/Layout/modals/ConfirmModal/ConfirmModal'; +import useApi from '../../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { QueryKeys } from '../../../../../shared/queries'; import { useDeleteStandaloneDeviceModal } from '../../../hooks/useDeleteStandaloneDeviceModal'; export const ConfirmDeviceDeleteModal = () => { @@ -11,11 +15,34 @@ export const ConfirmDeviceDeleteModal = () => { (s) => [s.visible, s.device], shallow, ); + const queryClient = useQueryClient(); const [close, reset] = useDeleteStandaloneDeviceModal( (s) => [s.close, s.reset], shallow, ); + const { + standaloneDevice: { deleteDevice }, + } = useApi(); + + const toaster = useToaster(); + + const { mutate, isLoading } = useMutation({ + mutationFn: deleteDevice, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QueryKeys.FETCH_STANDALONE_DEVICE_LIST], + }); + close(); + toaster.success(localLL.messages.success()); + }, + onError: (e) => { + toaster.error(localLL.messages.error()); + close(); + console.error(e); + }, + }); + const isOpen = visible && device !== undefined; return ( @@ -28,10 +55,13 @@ export const ConfirmDeviceDeleteModal = () => { submitText={LL.common.controls.delete()} cancelText={LL.common.controls.cancel()} onSubmit={() => { - console.warn('Delete device not implemented!'); + if (device) { + mutate(device.id); + } }} onClose={close} afterClose={reset} + loading={isLoading} /> ); }; diff --git a/web/src/pages/devices/components/DevicesList/style.scss b/web/src/pages/devices/components/DevicesList/style.scss index 751bcc6e3..fafb86011 100644 --- a/web/src/pages/devices/components/DevicesList/style.scss +++ b/web/src/pages/devices/components/DevicesList/style.scss @@ -59,6 +59,11 @@ } #devices-page-devices-list .device-row { + .avatar-icon { + min-width: 40px; + max-width: 40px; + } + p, span { font-family: 'Roboto'; diff --git a/web/src/pages/devices/hooks/useDeleteStandaloneDeviceModal.tsx b/web/src/pages/devices/hooks/useDeleteStandaloneDeviceModal.tsx index 7ef658ebc..c793d4b09 100644 --- a/web/src/pages/devices/hooks/useDeleteStandaloneDeviceModal.tsx +++ b/web/src/pages/devices/hooks/useDeleteStandaloneDeviceModal.tsx @@ -1,6 +1,6 @@ import { createWithEqualityFn } from 'zustand/traditional'; -import { MockDevice } from './useDevicesPage'; +import { StandaloneDevice } from '../../../shared/types'; const defaultValues: StoreValues = { visible: false, @@ -21,10 +21,10 @@ type Store = StoreValues & StoreMethods; type StoreValues = { visible: boolean; - device?: MockDevice; + device?: StandaloneDevice; }; type StoreMethods = { - open: (device: MockDevice) => void; + open: (device: StandaloneDevice) => void; close: () => void; reset: () => void; }; diff --git a/web/src/pages/devices/hooks/useDevicesPage.tsx b/web/src/pages/devices/hooks/useDevicesPage.tsx index b292eaa90..c94472948 100644 --- a/web/src/pages/devices/hooks/useDevicesPage.tsx +++ b/web/src/pages/devices/hooks/useDevicesPage.tsx @@ -1,29 +1,17 @@ import { useState } from 'react'; import { createContainer } from 'react-tracked'; -type MockedLocation = { - id: number; - name: string; -}; - -export type MockDevice = { - id: number; - name: string; - assignedIp: string; - description: string; - addedBy: string; - // ISO date - addedDate: string; - location: MockedLocation[]; -}; +import { StandaloneDevice } from '../../../shared/types'; export type DevicesPageContext = { - devices: MockDevice[]; + devices: StandaloneDevice[]; + reservedDeviceNames: string[]; search: string; }; const initialState: DevicesPageContext = { devices: [], + reservedDeviceNames: [], search: '', }; diff --git a/web/src/pages/devices/hooks/useEditStandaloneDeviceModal.tsx b/web/src/pages/devices/hooks/useEditStandaloneDeviceModal.tsx new file mode 100644 index 000000000..ac746c057 --- /dev/null +++ b/web/src/pages/devices/hooks/useEditStandaloneDeviceModal.tsx @@ -0,0 +1,29 @@ +import { createWithEqualityFn } from 'zustand/traditional'; + +import { StandaloneDevice } from '../../../shared/types'; + +const defaults: StoreValues = { + visible: false, + device: undefined, +}; + +export const useEditStandaloneDeviceModal = createWithEqualityFn( + (set) => ({ + ...defaults, + open: (device) => set({ device: device, visible: true }), + close: () => set({ visible: false }), + reset: () => set(defaults), + }), + Object.is, +); + +type Store = StoreValues & StoreMethods; +type StoreValues = { + visible: boolean; + device?: StandaloneDevice; +}; +type StoreMethods = { + open: (device: StandaloneDevice) => void; + close: () => void; + reset: () => void; +}; diff --git a/web/src/pages/overview/modals/AddStandaloneDeviceModal/AddStandaloneDeviceModal.tsx b/web/src/pages/devices/modals/AddStandaloneDeviceModal/AddStandaloneDeviceModal.tsx similarity index 100% rename from web/src/pages/overview/modals/AddStandaloneDeviceModal/AddStandaloneDeviceModal.tsx rename to web/src/pages/devices/modals/AddStandaloneDeviceModal/AddStandaloneDeviceModal.tsx diff --git a/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/FinishCliStep/FinishCliStep.tsx b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/FinishCliStep/FinishCliStep.tsx new file mode 100644 index 000000000..b42923e4e --- /dev/null +++ b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/FinishCliStep/FinishCliStep.tsx @@ -0,0 +1,35 @@ +import './style.scss'; + +import { shallow } from 'zustand/shallow'; + +import { useI18nContext } from '../../../../../../i18n/i18n-react'; +import { Button } from '../../../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../../../../shared/defguard-ui/components/Layout/Button/types'; +import { StandaloneDeviceModalEnrollmentContent } from '../../../components/StandaloneDeviceModalEnrollmentContent/StandaloneDeviceModalEnrollmentContent'; +import { useAddStandaloneDeviceModal } from '../../store'; + +export const FinishCliStep = () => { + const { LL } = useI18nContext(); + const [closeModal] = useAddStandaloneDeviceModal((s) => [s.close], shallow); + const enroll = useAddStandaloneDeviceModal((s) => s.enrollResponse); + + if (!enroll) return null; + return ( +
+ +
+
+
+ ); +}; diff --git a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/FinishCliStep/style.scss b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/FinishCliStep/style.scss similarity index 79% rename from web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/FinishCliStep/style.scss rename to web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/FinishCliStep/style.scss index 88e8bdc39..98058a417 100644 --- a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/FinishCliStep/style.scss +++ b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/FinishCliStep/style.scss @@ -17,5 +17,11 @@ height: 47px; } } + + .expanded-content { + p { + @include typography(app-code); + } + } } } diff --git a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/FinishManualStep/FinishManualStep.tsx b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/FinishManualStep/FinishManualStep.tsx similarity index 90% rename from web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/FinishManualStep/FinishManualStep.tsx rename to web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/FinishManualStep/FinishManualStep.tsx index b03a46171..b15a488cb 100644 --- a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/FinishManualStep/FinishManualStep.tsx +++ b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/FinishManualStep/FinishManualStep.tsx @@ -20,21 +20,12 @@ import { useClipboard } from '../../../../../../shared/hooks/useClipboard'; import { downloadWGConfig } from '../../../../../../shared/utils/downloadWGConfig'; import { useAddStandaloneDeviceModal } from '../../store'; -const mockConfig = `[Interface] -PrivateKey = YOUR_PRIVATE_KEY -Address = 10.2.0.16/24 -DNS = 10.2.0.1 - -[Peer] -PublicKey = 2LYRr2HgSScFe0UucpGCdXKDDAluNc9VAE= -AllowedIPs = 10.6.0.0/24, 10.2.0.0/24, 10.3.0.0/24, 10.4.0.0/24, 256.23.12.12/23 -Endpoint = 123.223.23.123:8596 -PersistentKeepalive = 23`; - export const FinishManualStep = () => { const { LL } = useI18nContext(); const localLL = LL.modals.addStandaloneDevice.steps.manual.finish; const [closeModal] = useAddStandaloneDeviceModal((s) => [s.close], shallow); + const manual = useAddStandaloneDeviceModal((s) => s.manualResponse); + const generatedKeys = useAddStandaloneDeviceModal((s) => s.genKeys); return (
@@ -47,7 +38,7 @@ export const FinishManualStep = () => {

{LL.modals.addStandaloneDevice.steps.manual.setup.form.labels.deviceName()}:

-

Mocked name

+

{manual?.device.name}

{localLL.ctaInstruction()}

@@ -55,12 +46,14 @@ export const FinishManualStep = () => { - + {manual && ( + + )}
+
+ ); +}; diff --git a/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupManualStep/SetupManualStep.tsx b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupManualStep/SetupManualStep.tsx new file mode 100644 index 000000000..537b1707b --- /dev/null +++ b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupManualStep/SetupManualStep.tsx @@ -0,0 +1,137 @@ +import './style.scss'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo, useState } from 'react'; +import { shallow } from 'zustand/shallow'; + +import { useI18nContext } from '../../../../../../i18n/i18n-react'; +import { Button } from '../../../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../../../../shared/defguard-ui/components/Layout/Button/types'; +import { useAuthStore } from '../../../../../../shared/hooks/store/useAuthStore'; +import useApi from '../../../../../../shared/hooks/useApi'; +import { QueryKeys } from '../../../../../../shared/queries'; +import { generateWGKeys } from '../../../../../../shared/utils/generateWGKeys'; +import { useDevicesPage } from '../../../../hooks/useDevicesPage'; +import { StandaloneDeviceModalForm } from '../../../components/StandaloneDeviceModalForm/StandaloneDeviceModalForm'; +import { StandaloneDeviceModalFormMode } from '../../../components/types'; +import { useAddStandaloneDeviceModal } from '../../store'; +import { + AddStandaloneDeviceFormFields, + AddStandaloneDeviceModalStep, + WGConfigGenChoice, +} from '../../types'; + +export const SetupManualStep = () => { + const { LL } = useI18nContext(); + const localLL = LL.modals.addStandaloneDevice.steps.manual.setup; + const [formLoading, setFormLoading] = useState(false); + const [setState, next, submitSubject, close] = useAddStandaloneDeviceModal( + (s) => [s.setStore, s.changeStep, s.submitSubject, s.close], + shallow, + ); + const [initialIp, locationOptions] = useAddStandaloneDeviceModal( + (s) => [s.initAvailableIp, s.networkOptions], + shallow, + ); + + const queryClient = useQueryClient(); + + const currentUserId = useAuthStore((s) => s.user?.id); + + const [{ reservedDeviceNames }] = useDevicesPage(); + + const { + standaloneDevice: { createManualDevice: createDevice }, + } = useApi(); + + const { mutateAsync } = useMutation({ + mutationFn: createDevice, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QueryKeys.FETCH_STANDALONE_DEVICE_LIST], + }); + queryClient.invalidateQueries({ + queryKey: [QueryKeys.FETCH_USER_PROFILE, currentUserId], + }); + }, + }); + + const handleSubmit = useCallback( + async (values: AddStandaloneDeviceFormFields) => { + let pub = values.wireguard_pubkey; + if (values.generationChoice === WGConfigGenChoice.AUTO) { + const keys = generateWGKeys(); + pub = keys.publicKey; + setState({ + genKeys: keys, + }); + } + const response = await mutateAsync({ + assigned_ip: values.assigned_ip, + location_id: values.location_id, + name: values.name, + description: values.description, + wireguard_pubkey: pub, + }); + setState({ + genChoice: values.generationChoice, + manualResponse: response, + }); + next(AddStandaloneDeviceModalStep.FINISH_MANUAL); + }, + [mutateAsync, next, setState], + ); + + const defaultFormValues = useMemo(() => { + if (locationOptions && initialIp) { + const res: AddStandaloneDeviceFormFields = { + assigned_ip: initialIp, + generationChoice: WGConfigGenChoice.AUTO, + location_id: locationOptions[0].value, + name: '', + wireguard_pubkey: '', + description: '', + }; + return res; + } + return undefined; + }, [initialIp, locationOptions]); + + if (initialIp === undefined || defaultFormValues === undefined) return null; + + return ( +
+ +
+
+
+ ); +}; diff --git a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/SetupManualStep/style.scss b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupManualStep/style.scss similarity index 100% rename from web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/SetupManualStep/style.scss rename to web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupManualStep/style.scss diff --git a/web/src/pages/overview/modals/AddStandaloneDeviceModal/store.tsx b/web/src/pages/devices/modals/AddStandaloneDeviceModal/store.tsx similarity index 72% rename from web/src/pages/overview/modals/AddStandaloneDeviceModal/store.tsx rename to web/src/pages/devices/modals/AddStandaloneDeviceModal/store.tsx index 6f18d1ef2..4b7533625 100644 --- a/web/src/pages/overview/modals/AddStandaloneDeviceModal/store.tsx +++ b/web/src/pages/devices/modals/AddStandaloneDeviceModal/store.tsx @@ -1,8 +1,13 @@ import { isObject } from 'lodash-es'; +import { Subject } from 'rxjs'; import { createWithEqualityFn } from 'zustand/traditional'; import { SelectOption } from '../../../../shared/defguard-ui/components/Layout/Select/types'; -import { Network } from '../../../../shared/types'; +import { + CreateStandaloneDeviceResponse, + Network, + StartEnrollmentResponse, +} from '../../../../shared/types'; import { AddStandaloneDeviceModalChoice, AddStandaloneDeviceModalStep, @@ -16,6 +21,11 @@ const defaultValues: StoreValues = { networks: undefined, networkOptions: [], genChoice: WGConfigGenChoice.AUTO, + submitSubject: new Subject(), + initAvailableIp: undefined, + genKeys: undefined, + manualResponse: undefined, + enrollResponse: undefined, }; export const useAddStandaloneDeviceModal = createWithEqualityFn( @@ -37,8 +47,16 @@ type StoreValues = { currentStep: AddStandaloneDeviceModalStep; choice: AddStandaloneDeviceModalChoice; networkOptions: SelectOption[]; - networks?: Network[]; genChoice: WGConfigGenChoice; + submitSubject: Subject; + initAvailableIp?: string; + networks?: Network[]; + genKeys?: { + publicKey: string; + privateKey: string; + }; + manualResponse?: CreateStandaloneDeviceResponse; + enrollResponse?: StartEnrollmentResponse; }; type StoreMethods = { diff --git a/web/src/pages/overview/modals/AddStandaloneDeviceModal/style.scss b/web/src/pages/devices/modals/AddStandaloneDeviceModal/style.scss similarity index 100% rename from web/src/pages/overview/modals/AddStandaloneDeviceModal/style.scss rename to web/src/pages/devices/modals/AddStandaloneDeviceModal/style.scss diff --git a/web/src/pages/devices/modals/AddStandaloneDeviceModal/types.ts b/web/src/pages/devices/modals/AddStandaloneDeviceModal/types.ts new file mode 100644 index 000000000..773315f9a --- /dev/null +++ b/web/src/pages/devices/modals/AddStandaloneDeviceModal/types.ts @@ -0,0 +1,31 @@ +export enum AddStandaloneDeviceModalStep { + METHOD_CHOICE, + SETUP_CLI, + FINISH_CLI, + SETUP_MANUAL, + FINISH_MANUAL, +} + +export enum AddStandaloneDeviceModalChoice { + CLI, + MANUAL, +} + +export enum WGConfigGenChoice { + MANUAL, + AUTO, +} + +export type AddStandaloneDeviceFormFields = { + name: string; + location_id: number; + assigned_ip: string; + wireguard_pubkey: string; + generationChoice: WGConfigGenChoice; + description?: string; +}; + +export type AddStandaloneDeviceCLIFormFields = Omit< + AddStandaloneDeviceFormFields, + 'generationChoice' | 'wireguard_pubkey' +>; diff --git a/web/src/pages/devices/modals/EditStandaloneDeviceModal/EditStandaloneModal.tsx b/web/src/pages/devices/modals/EditStandaloneDeviceModal/EditStandaloneModal.tsx new file mode 100644 index 000000000..48ea9e384 --- /dev/null +++ b/web/src/pages/devices/modals/EditStandaloneDeviceModal/EditStandaloneModal.tsx @@ -0,0 +1,189 @@ +import './style.scss'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { Subject } from 'rxjs'; +import { shallow } from 'zustand/shallow'; + +import { useI18nContext } from '../../../../i18n/i18n-react'; +import { Button } from '../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../../shared/defguard-ui/components/Layout/Button/types'; +import { LoaderSpinner } from '../../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; +import { ModalWithTitle } from '../../../../shared/defguard-ui/components/Layout/modals/ModalWithTitle/ModalWithTitle'; +import { useAuthStore } from '../../../../shared/hooks/store/useAuthStore'; +import useApi from '../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../shared/hooks/useToaster'; +import { QueryKeys } from '../../../../shared/queries'; +import { Network } from '../../../../shared/types'; +import { selectifyNetworks } from '../../../../shared/utils/form/selectifyNetwork'; +import { useDevicesPage } from '../../hooks/useDevicesPage'; +import { useEditStandaloneDeviceModal } from '../../hooks/useEditStandaloneDeviceModal'; +import { + AddStandaloneDeviceFormFields, + WGConfigGenChoice, +} from '../AddStandaloneDeviceModal/types'; +import { StandaloneDeviceModalForm } from '../components/StandaloneDeviceModalForm/StandaloneDeviceModalForm'; +import { StandaloneDeviceModalFormMode } from '../components/types'; + +export const EditStandaloneModal = () => { + const { LL } = useI18nContext(); + const localLL = LL.modals.editStandaloneModal; + const [close, reset] = useEditStandaloneDeviceModal((s) => [s.close, s.reset], shallow); + const isOpen = useEditStandaloneDeviceModal((s) => s.visible); + + useEffect(() => { + return () => { + reset(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + ); +}; + +const ModalContent = () => { + // this is needs bcs opening modal again and again would prevent availableIp to refetch + const modalSessionID = useId(); + const { LL } = useI18nContext(); + const localLL = LL.modals.editStandaloneModal; + const device = useEditStandaloneDeviceModal((s) => s.device); + const [submitSubject] = useState(new Subject()); + const [formLoading, setFormLoading] = useState(false); + const [closeModal] = useEditStandaloneDeviceModal((s) => [s.close], shallow); + const toaster = useToaster(); + const queryClient = useQueryClient(); + const currentUserId = useAuthStore((s) => s.user?.id); + const [{ reservedDeviceNames }] = useDevicesPage(); + + const { + network: { getNetworks }, + standaloneDevice: { getAvailableIp, editDevice }, + } = useApi(); + + const { mutateAsync } = useMutation({ + mutationFn: editDevice, + onSuccess: () => { + toaster.success(localLL.toasts.success()); + queryClient.invalidateQueries({ + queryKey: [QueryKeys.FETCH_STANDALONE_DEVICE_LIST], + }); + queryClient.invalidateQueries({ + queryKey: [QueryKeys.FETCH_USER_PROFILE, currentUserId], + }); + closeModal(); + }, + onError: (e) => { + toaster.error(localLL.toasts.failure()); + console.error(e); + }, + }); + + const { data: networks } = useQuery({ + queryKey: [QueryKeys.FETCH_NETWORKS], + queryFn: getNetworks, + refetchOnWindowFocus: false, + refetchOnMount: true, + }); + + const { data: availableIp } = useQuery({ + queryKey: [ + 'ADD_STANDALONE_DEVICE_MODAL_FETCH_INITIAL_AVAILABLE_IP', + networks, + modalSessionID, + ], + queryFn: () => + getAvailableIp({ + locationId: (networks as Network[])[0].id, + }), + enabled: networks !== undefined && Array.isArray(networks) && networks.length > 0, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: false, + }); + + const locationOptions = useMemo(() => { + if (networks) { + return selectifyNetworks(networks); + } + return []; + }, [networks]); + + const defaultValues = useMemo(() => { + if (locationOptions && availableIp && device) { + const res: AddStandaloneDeviceFormFields = { + name: device?.name, + assigned_ip: device.assigned_ip, + location_id: device.location.id, + description: device.description, + generationChoice: WGConfigGenChoice.AUTO, + wireguard_pubkey: '', + }; + return res; + } + return undefined; + }, [availableIp, device, locationOptions]); + + const handleSubmit = useCallback( + async (values: AddStandaloneDeviceFormFields) => { + if (device) { + await mutateAsync({ + assigned_ip: values.assigned_ip, + id: device.id, + name: values.name, + description: values.description, + }); + } + }, + [device, mutateAsync], + ); + + return ( + <> + {defaultValues && ( + + )} + {!defaultValues && ( +
+ +
+ )} +
+
+ + ); +}; diff --git a/web/src/pages/devices/modals/EditStandaloneDeviceModal/style.scss b/web/src/pages/devices/modals/EditStandaloneDeviceModal/style.scss new file mode 100644 index 000000000..fd45ad7f6 --- /dev/null +++ b/web/src/pages/devices/modals/EditStandaloneDeviceModal/style.scss @@ -0,0 +1,47 @@ +#edit-standalone-device-modal { + width: 100%; + max-width: 920px; + + .loader { + width: 100%; + display: flex; + flex-flow: row; + align-items: center; + justify-content: center; + min-height: 300px; + } + + .controls { + display: flex; + width: 100%; + gap: 20px; + padding-top: 40px; + box-sizing: border-box; + padding-left: 30px; + padding-right: 30px; + flex-flow: column; + align-items: center; + justify-content: flex-start; + + @include media-breakpoint-up(lg) { + flex-flow: row; + align-items: center; + justify-content: flex-start; + } + + .btn { + width: 100%; + height: 47px; + } + + &.solo { + width: 100%; + align-items: center; + justify-content: center; + + .btn { + width: 50%; + } + } + } +} diff --git a/web/src/pages/devices/modals/StandaloneDeviceConfigModal/StandaloneDeviceConfigModal.tsx b/web/src/pages/devices/modals/StandaloneDeviceConfigModal/StandaloneDeviceConfigModal.tsx new file mode 100644 index 000000000..4d5230435 --- /dev/null +++ b/web/src/pages/devices/modals/StandaloneDeviceConfigModal/StandaloneDeviceConfigModal.tsx @@ -0,0 +1,64 @@ +import './style.scss'; + +import { useEffect } from 'react'; +import { shallow } from 'zustand/shallow'; + +import { useI18nContext } from '../../../../i18n/i18n-react'; +import { WireguardConfigExpandable } from '../../../../shared/components/Layout/WireguardConfigExpandable/WireguardConfigExpandable'; +import { Button } from '../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { ModalWithTitle } from '../../../../shared/defguard-ui/components/Layout/modals/ModalWithTitle/ModalWithTitle'; +import { useStandaloneDeviceConfigModal } from './store'; + +export const StandaloneDeviceConfigModal = () => { + const { LL } = useI18nContext(); + const isOpen = useStandaloneDeviceConfigModal((s) => s.visible); + const [close, reset] = useStandaloneDeviceConfigModal( + (s) => [s.close, s.reset], + shallow, + ); + + useEffect(() => { + return () => { + reset(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + ); +}; + +const ModalContent = () => { + const { LL } = useI18nContext(); + const data = useStandaloneDeviceConfigModal((s) => s.data); + const close = useStandaloneDeviceConfigModal((s) => s.close, shallow); + + if (!data) return null; + return ( + <> + +
+
+ + ); +}; diff --git a/web/src/pages/devices/modals/StandaloneDeviceConfigModal/store.tsx b/web/src/pages/devices/modals/StandaloneDeviceConfigModal/store.tsx new file mode 100644 index 000000000..20306fd82 --- /dev/null +++ b/web/src/pages/devices/modals/StandaloneDeviceConfigModal/store.tsx @@ -0,0 +1,32 @@ +import { createWithEqualityFn } from 'zustand/traditional'; + +import { StandaloneDevice } from '../../../../shared/types'; + +const defaults: StoreValues = { + visible: false, +}; + +export const useStandaloneDeviceConfigModal = createWithEqualityFn( + (set) => ({ + ...defaults, + open: (data) => set({ data, visible: true }), + close: () => set({ visible: false }), + reset: () => set(defaults), + }), + Object.is, +); + +type Store = StoreValues & StoreMethods; +type StoreValues = { + visible: boolean; + data?: { + device: StandaloneDevice; + config: string; + }; +}; + +type StoreMethods = { + open: (values: StoreValues['data']) => void; + close: () => void; + reset: () => void; +}; diff --git a/web/src/pages/devices/modals/StandaloneDeviceConfigModal/style.scss b/web/src/pages/devices/modals/StandaloneDeviceConfigModal/style.scss new file mode 100644 index 000000000..e69de29bb diff --git a/web/src/pages/devices/modals/StandaloneDeviceEnrollmentModal/StandaloneDeviceEnrollmentModal.tsx b/web/src/pages/devices/modals/StandaloneDeviceEnrollmentModal/StandaloneDeviceEnrollmentModal.tsx new file mode 100644 index 000000000..967dfd872 --- /dev/null +++ b/web/src/pages/devices/modals/StandaloneDeviceEnrollmentModal/StandaloneDeviceEnrollmentModal.tsx @@ -0,0 +1,48 @@ +import { shallow } from 'zustand/shallow'; + +import { useI18nContext } from '../../../../i18n/i18n-react'; +import { Button } from '../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { ModalWithTitle } from '../../../../shared/defguard-ui/components/Layout/modals/ModalWithTitle/ModalWithTitle'; +import { StandaloneDeviceModalEnrollmentContent } from '../components/StandaloneDeviceModalEnrollmentContent/StandaloneDeviceModalEnrollmentContent'; +import { useStandaloneDeviceEnrollmentModal } from './store'; + +export const StandaloneDeviceEnrollmentModal = () => { + const { LL } = useI18nContext(); + const localLL = LL.modals.standaloneDeviceEnrollmentModal; + const [close, reset] = useStandaloneDeviceEnrollmentModal( + (s) => [s.close, s.reset], + shallow, + ); + const isOpen = useStandaloneDeviceEnrollmentModal((s) => s.visible); + return ( + + + + ); +}; + +const ModalContent = () => { + const { LL } = useI18nContext(); + const modalData = useStandaloneDeviceEnrollmentModal((s) => s.data); + const closeModal = useStandaloneDeviceEnrollmentModal((s) => s.close, shallow); + if (!modalData) return null; + return ( + <> + +
+
+ + ); +}; diff --git a/web/src/pages/devices/modals/StandaloneDeviceEnrollmentModal/store.tsx b/web/src/pages/devices/modals/StandaloneDeviceEnrollmentModal/store.tsx new file mode 100644 index 000000000..9ce847a7e --- /dev/null +++ b/web/src/pages/devices/modals/StandaloneDeviceEnrollmentModal/store.tsx @@ -0,0 +1,32 @@ +import { createWithEqualityFn } from 'zustand/traditional'; + +import { StandaloneDevice, StartEnrollmentResponse } from '../../../../shared/types'; + +const defaults: StoreValues = { + visible: false, + data: undefined, +}; + +export const useStandaloneDeviceEnrollmentModal = createWithEqualityFn( + (set) => ({ + ...defaults, + open: (data) => set({ data: data, visible: true }), + close: () => set({ visible: false }), + reset: () => set(defaults), + }), + Object.is, +); + +type Store = StoreValues & StoreMethods; +type StoreValues = { + visible: boolean; + data?: { + device: StandaloneDevice; + enrollment: StartEnrollmentResponse; + }; +}; +type StoreMethods = { + open: (data: StoreValues['data']) => void; + close: () => void; + reset: () => void; +}; diff --git a/web/src/pages/devices/modals/components/StandaloneDeviceModalEnrollmentContent/StandaloneDeviceModalEnrollmentContent.tsx b/web/src/pages/devices/modals/components/StandaloneDeviceModalEnrollmentContent/StandaloneDeviceModalEnrollmentContent.tsx new file mode 100644 index 000000000..b665ed5ae --- /dev/null +++ b/web/src/pages/devices/modals/components/StandaloneDeviceModalEnrollmentContent/StandaloneDeviceModalEnrollmentContent.tsx @@ -0,0 +1,72 @@ +import './style.scss'; + +import { useMemo } from 'react'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { ActionButton } from '../../../../../shared/defguard-ui/components/Layout/ActionButton/ActionButton'; +import { ActionButtonVariant } from '../../../../../shared/defguard-ui/components/Layout/ActionButton/types'; +import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../../../shared/defguard-ui/components/Layout/Button/types'; +import { ExpandableCard } from '../../../../../shared/defguard-ui/components/Layout/ExpandableCard/ExpandableCard'; +import { MessageBox } from '../../../../../shared/defguard-ui/components/Layout/MessageBox/MessageBox'; +import { MessageBoxType } from '../../../../../shared/defguard-ui/components/Layout/MessageBox/types'; +import { useClipboard } from '../../../../../shared/hooks/useClipboard'; +import { externalLink } from '../../../../../shared/links'; +import { StartEnrollmentResponse } from '../../../../../shared/types'; + +type Props = { + enrollmentData: StartEnrollmentResponse; +}; +export const StandaloneDeviceModalEnrollmentContent = ({ + enrollmentData: { enrollment_token, enrollment_url }, +}: Props) => { + const { LL } = useI18nContext(); + const localLL = LL.components.standaloneDeviceTokenModalContent; + const { writeToClipboard } = useClipboard(); + const commandToCopy = useMemo(() => { + return `defguard -u ${enrollment_url} -t ${enrollment_token}`; + }, [enrollment_token, enrollment_url]); + + return ( +
+ + + { + writeToClipboard(commandToCopy); + }} + key={0} + />, + ]} + expanded={true} + disableExpand={true} + > +

{commandToCopy}

+
+
+ ); +}; diff --git a/web/src/pages/devices/modals/components/StandaloneDeviceModalEnrollmentContent/style.scss b/web/src/pages/devices/modals/components/StandaloneDeviceModalEnrollmentContent/style.scss new file mode 100644 index 000000000..35cbfc50b --- /dev/null +++ b/web/src/pages/devices/modals/components/StandaloneDeviceModalEnrollmentContent/style.scss @@ -0,0 +1,19 @@ +.standalone-device-enrollment-content { + .download { + width: 100%; + display: flex; + flex-flow: row; + align-items: center; + justify-content: center; + padding-bottom: 30px; + + a { + text-decoration: none; + cursor: pointer; + } + + .btn { + height: 47px; + } + } +} diff --git a/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx new file mode 100644 index 000000000..17e682f0d --- /dev/null +++ b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx @@ -0,0 +1,257 @@ +import './style.scss'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { Subject } from 'rxjs'; +import { z } from 'zod'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; +import { FormSelect } from '../../../../../shared/defguard-ui/components/Form/FormSelect/FormSelect'; +import { FormToggle } from '../../../../../shared/defguard-ui/components/Form/FormToggle/FormToggle'; +import { + SelectOption, + SelectSelectedValue, +} from '../../../../../shared/defguard-ui/components/Layout/Select/types'; +import { ToggleOption } from '../../../../../shared/defguard-ui/components/Layout/Toggle/types'; +import useApi from '../../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { validateWireguardPublicKey } from '../../../../../shared/validators'; +import { + AddStandaloneDeviceFormFields, + WGConfigGenChoice, +} from '../../AddStandaloneDeviceModal/types'; +import { StandaloneDeviceModalFormMode } from '../types'; + +type Props = { + onSubmit: (formValues: AddStandaloneDeviceFormFields) => Promise; + mode: StandaloneDeviceModalFormMode; + onLoadingChange: (value: boolean) => void; + locationOptions: SelectOption[]; + submitSubject: Subject; + defaults: AddStandaloneDeviceFormFields; + reservedNames: string[]; +}; + +export const StandaloneDeviceModalForm = ({ + onSubmit, + mode, + onLoadingChange, + locationOptions, + submitSubject, + defaults, + reservedNames, +}: Props) => { + const { LL } = useI18nContext(); + const { + standaloneDevice: { validateLocationIp, getAvailableIp }, + } = useApi(); + // auto assign upon location change is happening + const [ipIsLoading, setIpIsLoading] = useState(false); + const localLL = LL.modals.addStandaloneDevice.form; + const errors = LL.form.error; + const labels = localLL.labels; + const submitRef = useRef(null); + const toaster = useToaster(); + const renderSelectedOption = useCallback( + (val?: number): SelectSelectedValue => { + const empty: SelectSelectedValue = { + displayValue: '', + key: 'empty', + }; + if (val !== undefined) { + const option = locationOptions.find((n) => n.value === val); + if (option) { + return { + displayValue: option.label, + key: option.key, + }; + } + } + return empty; + }, + [locationOptions], + ); + + const toggleOptions = useMemo( + (): ToggleOption[] => [ + { + text: labels.generation.auto(), + value: WGConfigGenChoice.AUTO, + disabled: false, + }, + { + text: labels.generation.manual(), + value: WGConfigGenChoice.MANUAL, + disabled: false, + }, + ], + [labels.generation], + ); + + const schema = useMemo( + () => + z + .object({ + name: z + .string() + .min(1, LL.form.error.required()) + .refine((value) => { + if (mode === StandaloneDeviceModalFormMode.EDIT) { + const filtered = reservedNames.filter((n) => n !== defaults.name.trim()); + return !filtered.includes(value.trim()); + } + return !reservedNames.includes(value.trim()); + }, LL.form.error.reservedName()), + location_id: z.number(), + description: z.string(), + assigned_ip: z.string().min(1, LL.form.error.required()), + generationChoice: z.nativeEnum(WGConfigGenChoice), + wireguard_pubkey: z.string().optional(), + }) + .superRefine((vals, ctx) => { + if (mode === StandaloneDeviceModalFormMode.CREATE_MANUAL) { + if (vals.generationChoice === WGConfigGenChoice.MANUAL) { + const result = validateWireguardPublicKey({ + requiredError: errors.required(), + maxError: errors.maximumLengthOf({ length: 44 }), + minError: errors.minimumLengthOf({ length: 44 }), + validKeyError: errors.invalid(), + }).safeParse(vals.wireguard_pubkey); + if (!result.success) { + result.error.errors.forEach((e) => { + ctx.addIssue({ + path: ['wireguard_pubkey'], + message: e.message, + code: 'custom', + }); + }); + } + } + } + }), + [LL.form.error, defaults.name, errors, mode, reservedNames], + ); + + const { + handleSubmit, + control, + watch, + formState: { isSubmitting }, + setError, + setValue, + } = useForm({ + defaultValues: defaults, + resolver: zodResolver(schema), + mode: 'all', + }); + + const generationChoiceValue = watch('generationChoice'); + + const submitHandler: SubmitHandler = async (values) => { + if ( + mode === StandaloneDeviceModalFormMode.EDIT && + values.assigned_ip === defaults.assigned_ip + ) { + await onSubmit(values); + return; + } + try { + const response = await validateLocationIp({ + ip: values.assigned_ip, + location: values.location_id, + }); + const { available, valid } = response; + if (available && valid) { + await onSubmit(values); + } else { + if (!available) { + setError('assigned_ip', { + message: LL.form.error.reservedIp(), + }); + } + if (!valid) { + setError('assigned_ip', { + message: LL.form.error.invalidIp(), + }); + } + } + } catch (e) { + toaster.error(LL.messages.error()); + } + }; + + const autoAssignRecommendedIp = useCallback( + (locationId: number | undefined) => { + if (locationId !== undefined) { + setIpIsLoading(true); + getAvailableIp({ + locationId, + }) + .then((resp) => setValue('assigned_ip', resp.ip)) + .finally(() => { + setIpIsLoading(false); + }); + } + }, + [getAvailableIp, setValue], + ); + + // inform parent that form is processing stuff + useEffect(() => { + const res = isSubmitting; + onLoadingChange(res); + }, [isSubmitting, onLoadingChange]); + + // handle form sub from outside + useEffect(() => { + const sub = submitSubject.subscribe(() => { + if (submitRef.current) { + submitRef.current.click(); + } + }); + return () => sub.unsubscribe(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [submitSubject]); + + return ( +
+ +
+ []>} + renderSelected={renderSelectedOption} + label={labels.location()} + onChangeSingle={autoAssignRecommendedIp} + disabled={mode === StandaloneDeviceModalFormMode.EDIT} + disableOpen={mode === StandaloneDeviceModalFormMode.EDIT} + /> + +
+ + {mode === StandaloneDeviceModalFormMode.CREATE_MANUAL && ( + <> + + + + )} + + + ); +}; diff --git a/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/style.scss b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/style.scss new file mode 100644 index 000000000..ef5f509ce --- /dev/null +++ b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/style.scss @@ -0,0 +1,14 @@ +.standalone-device-modal-form { + .row { + width: 100%; + display: flex; + flex-flow: column; + row-gap: 0; + + @include media-breakpoint-up(lg) { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 20px; + } + } +} diff --git a/web/src/pages/devices/modals/components/types.ts b/web/src/pages/devices/modals/components/types.ts new file mode 100644 index 000000000..408ff8b14 --- /dev/null +++ b/web/src/pages/devices/modals/components/types.ts @@ -0,0 +1,5 @@ +export enum StandaloneDeviceModalFormMode { + CREATE_CLI, + CREATE_MANUAL, + EDIT, +} diff --git a/web/src/pages/devices/style.scss b/web/src/pages/devices/style.scss index e69de29bb..eb7878e3b 100644 --- a/web/src/pages/devices/style.scss +++ b/web/src/pages/devices/style.scss @@ -0,0 +1,7 @@ +#standalone-devices-page { + .list-container { + position: relative; + max-width: 100%; + overflow: auto; + } +} diff --git a/web/src/pages/overview/OverviewConnectedUsers/OverviewConnectedUsers.tsx b/web/src/pages/overview/OverviewConnectedUsers/OverviewConnectedUsers.tsx index c61ffbcde..f538a93b9 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/OverviewConnectedUsers.tsx +++ b/web/src/pages/overview/OverviewConnectedUsers/OverviewConnectedUsers.tsx @@ -1,90 +1,21 @@ import './style.scss'; -import { useMemo } from 'react'; - -import { useI18nContext } from '../../../i18n/i18n-react'; -import { NetworkUserStats, OverviewLayoutType } from '../../../shared/types'; -import { useOverviewStore } from '../hooks/store/useOverviewStore'; +import { NetworkUserStats } from '../../../shared/types'; import { UserConnectionCard } from './UserConnectionCard/UserConnectionCard'; -import { UserConnectionListItem } from './UserConnectionListItem/UserConnectionListItem'; interface Props { stats?: NetworkUserStats[]; } export const OverviewConnectedUsers = ({ stats }: Props) => { - const viewMode = useOverviewStore((state) => state.viewMode); - const getContentClassName = useMemo(() => { - const rest = ['connected-users']; - switch (viewMode) { - case OverviewLayoutType.GRID: - rest.push('grid'); - break; - case OverviewLayoutType.LIST: - rest.push('list'); - break; - } - return rest.join(' '); - }, [viewMode]); - const { LL } = useI18nContext(); - - const renderedStats = useMemo(() => { - if (!stats || !stats.length) { - return null; - } - - if (viewMode === OverviewLayoutType.GRID) { - return stats.map((userStats) => ( - - )); - } - - return ; - }, [stats, viewMode]); - - return ( -
-
-

{LL.connectedUsersOverview.pageTitle()}

-
- {!stats || !stats.length ? ( -

{LL.connectedUsersOverview.noUsersMessage()}

- ) : null} -
{renderedStats}
-
- ); -}; - -interface RenderUserListProps { - data: NetworkUserStats[]; -} - -const RenderUserList = ({ data }: RenderUserListProps) => { - const { LL } = useI18nContext(); + if (!stats || stats.length === 0) return null; return ( - <> -
-
- {LL.connectedUsersOverview.userList.username()} -
-
- {LL.connectedUsersOverview.userList.device()} -
-
- {LL.connectedUsersOverview.userList.connected()} -
-
- {LL.connectedUsersOverview.userList.deviceLocation()} -
-
- {LL.connectedUsersOverview.userList.networkUsage()} -
-
-
- {data.map((userStats) => ( - +
+
+ {stats.map((userStats) => ( + ))}
- +
); }; diff --git a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/UserConnectionCard.tsx b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/UserConnectionCard.tsx index e806c1e28..8b90bfca5 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/UserConnectionCard.tsx +++ b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/UserConnectionCard.tsx @@ -2,9 +2,8 @@ import './style.scss'; import classNames from 'classnames'; import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; import { motion } from 'framer-motion'; -import { floor } from 'lodash-es'; +import { sumBy } from 'lodash-es'; import { useCallback, useEffect, useMemo, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { timer } from 'rxjs'; @@ -21,12 +20,31 @@ import { NetworkSpeed } from '../../../../shared/defguard-ui/components/Layout/N import { NetworkDirection } from '../../../../shared/defguard-ui/components/Layout/NetworkSpeed/types'; import { UserInitials } from '../../../../shared/defguard-ui/components/Layout/UserInitials/UserInitials'; import { getUserFullName } from '../../../../shared/helpers/getUserFullName'; -import { NetworkDeviceStats, NetworkUserStats } from '../../../../shared/types'; +import { + NetworkDeviceStats, + NetworkUserStats, + StandaloneDeviceStats, +} from '../../../../shared/types'; import { titleCase } from '../../../../shared/utils/titleCase'; -import { summarizeDeviceStats, summarizeUsersNetworkStats } from '../../helpers/stats'; +import { + summarizeDevicesStats, + summarizeDeviceStats, + summarizeUsersNetworkStats, +} from '../../helpers/stats'; import { NetworkUsageChart } from '../shared/components/NetworkUsageChart/NetworkUsageChart'; +import { formatConnectionTime } from './formatConnectionTime'; -dayjs.extend(utc); +type DeviceConnectionCardProps = { + data: StandaloneDeviceStats; +}; + +export const StandaloneDeviceConnectionCard = ({ data }: DeviceConnectionCardProps) => { + return ( +
+ +
+ ); +}; interface Props { data: NetworkUserStats; @@ -59,6 +77,58 @@ export const UserConnectionCard = ({ data }: Props) => { ); }; +const DeviceCardContent = (props: { data: StandaloneDeviceStats }) => { + const { data } = props; + + const getSummarizedStats = useMemo(() => summarizeDeviceStats(data.stats), [data]); + + const totalUpload = useMemo( + () => sumBy(getSummarizedStats, (s) => s.upload), + [getSummarizedStats], + ); + const totalDownload = useMemo( + () => sumBy(getSummarizedStats, (s) => s.download), + [getSummarizedStats], + ); + + return ( +
+
+ + +
+
+ +
+
+ + +
+
+ + {({ height, width }) => ( + + )} + +
+
+
+
+ ); +}; + interface MainCardContentProps { data: NetworkUserStats; } @@ -74,7 +144,7 @@ const MainCardContent = ({ data }: MainCardContentProps) => { }, [data]); const getSummarizedStats = useMemo( - () => summarizeDeviceStats(data.devices), + () => summarizeDevicesStats(data.devices), [data.devices], ); @@ -131,24 +201,30 @@ const MainCardContent = ({ data }: MainCardContentProps) => { interface NameBoxProps { name: string; - publicIp: string; - wireguardIp: string; + publicIp?: string; + wireguardIp?: string; } const NameBox = ({ name, publicIp, wireguardIp }: NameBoxProps) => { return (
{name} -
- - -
+ {(publicIp || wireguardIp) && ( +
+ {publicIp !== undefined && publicIp.length > 0 && ( + + )} + {wireguardIp !== undefined && wireguardIp.length > 0 && ( + + )} +
+ )}
); }; interface ConnectionTimeProps { - connectedAt: string; + connectedAt?: string; } const ConnectionTime = ({ connectedAt }: ConnectionTimeProps) => { @@ -157,18 +233,11 @@ const ConnectionTime = ({ connectedAt }: ConnectionTimeProps) => { const [displayedTime, setDisplayedTime] = useState(); const updateConnectionTime = useCallback(() => { - const minutes = dayjs().diff(dayjs.utc(connectedAt), 'm'); - if (minutes > 60) { - const hours = floor(minutes / 60); - const res = [`${hours}h`]; - if (minutes % 60 > 0) { - res.push(`${minutes % 60}m`); - } - setDisplayedTime(res.join(' ')); - } else { - setDisplayedTime(`${minutes}m`); + if (connectedAt) { + setDisplayedTime(formatConnectionTime(connectedAt)); } - }, [connectedAt]); + return LL.common.noData(); + }, [connectedAt, LL.common]); useEffect(() => { const interval = 60 * 1000; @@ -246,7 +315,7 @@ interface ExpandedDeviceCardProps { } const ExpandedDeviceCard = ({ data }: ExpandedDeviceCardProps) => { - const getSummarizedStats = useMemo(() => summarizeDeviceStats([data]), [data]); + const getSummarizedStats = useMemo(() => summarizeDevicesStats([data]), [data]); const downloadSummary = getSummarizedStats.reduce((sum, e) => { return sum + e.download; }, 0); diff --git a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/formatConnectionTime.ts b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/formatConnectionTime.ts new file mode 100644 index 000000000..ad098f9fe --- /dev/null +++ b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/formatConnectionTime.ts @@ -0,0 +1,33 @@ +import dayjs from 'dayjs'; +import humanizeDuration from 'humanize-duration'; + +const short = humanizeDuration.humanizer({ + language: 'short', + languages: { + short: { + y: () => 'y', + mo: () => 'mo', + w: () => 'w', + d: () => 'd', + h: () => 'h', + m: () => 'm', + s: () => 's', + ms: () => 'ms', + }, + }, +}); + +export const formatConnectionTime = (connectedAt: string): string => { + const day = dayjs.utc(connectedAt); + const diff = dayjs().utc().diff(day, 'ms'); + + const res = short(diff, { + largest: 2, + round: true, + language: 'short', + units: ['y', 'mo', 'w', 'd', 'h', 'm', 's'], + }) + .replaceAll(' ', '') + .replaceAll(',', ' '); + return res; +}; diff --git a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/style.scss b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/style.scss index ce3a9423f..a65daf9b9 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/style.scss +++ b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/style.scss @@ -14,6 +14,10 @@ grid-template-rows: min-content 0fr; height: min-content; + &:hover { + border-color: var(--gray-border); + } + &.expanded { grid-template-rows: min-content min-content; border-color: var(--gray-border); @@ -112,6 +116,16 @@ grid-row: 1; grid-column: 2 / 3; } + + .avatar-icon { + min-width: 40px; + min-height: 40px; + + svg { + width: 30px; + height: 30px; + } + } } & > .lower { @@ -121,20 +135,37 @@ grid-template-rows: 1fr 1fr; grid-template-columns: 1fr 1fr; row-gap: 10px; - grid-template-areas: - 'time devices' - 'usage usage'; column-gap: 10px; align-items: center; justify-items: start; justify-content: space-evenly; - @media (min-width: 400px) { - grid-template-rows: 1fr; - grid-template-columns: 100px 100px 1fr; - grid-template-areas: 'time devices usage'; - row-gap: 0; - column-gap: 10px; + &:not(.device) { + grid-template-areas: + 'time devices' + 'usage usage'; + + @media (min-width: 400px) { + grid-template-rows: 1fr; + grid-template-columns: 100px 100px 1fr; + grid-template-areas: 'time devices usage'; + row-gap: 0; + column-gap: 10px; + } + } + + &.device { + grid-template-areas: + 'time' + 'usage'; + + @media (min-width: 400px) { + grid-template-rows: 1fr; + grid-template-columns: 100px 1fr; + grid-template-areas: 'time usage'; + row-gap: 0; + column-gap: 10px; + } } .connection-time { @@ -242,6 +273,7 @@ align-items: center; justify-content: center; height: 100%; + & > span { @include typography-legacy(15px, 18px, medium); @include text-overflow-dots; diff --git a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx index fe19b1512..203c7a2d0 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx +++ b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx @@ -21,7 +21,7 @@ import { NetworkDirection } from '../../../../shared/defguard-ui/components/Layo import { UserInitials } from '../../../../shared/defguard-ui/components/Layout/UserInitials/UserInitials'; import { getUserFullName } from '../../../../shared/helpers/getUserFullName'; import { NetworkDeviceStats, NetworkUserStats } from '../../../../shared/types'; -import { summarizeDeviceStats } from '../../helpers/stats'; +import { summarizeDevicesStats } from '../../helpers/stats'; import { NetworkUsageChart } from '../shared/components/NetworkUsageChart/NetworkUsageChart'; dayjs.extend(utc); @@ -66,7 +66,7 @@ const UserRow = ({ data }: UserRowProps) => { }, [data]); const getSummarizedDevicesStat = useMemo( - () => summarizeDeviceStats(data.devices), + () => summarizeDevicesStats(data.devices), [data.devices], ); const downloadSummary = getSummarizedDevicesStat.reduce((sum, e) => { diff --git a/web/src/pages/overview/OverviewConnectedUsers/shared/components/NetworkUsageChart/NetworkUsageChart.tsx b/web/src/pages/overview/OverviewConnectedUsers/shared/components/NetworkUsageChart/NetworkUsageChart.tsx index 3dc2a52e7..daf469b41 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/shared/components/NetworkUsageChart/NetworkUsageChart.tsx +++ b/web/src/pages/overview/OverviewConnectedUsers/shared/components/NetworkUsageChart/NetworkUsageChart.tsx @@ -32,7 +32,8 @@ export const NetworkUsageChart = ({ width={width} data={getFormattedData} margin={{ bottom: 0, left: 0, right: 0, top: 0 }} - barGap={2} + barGap={0} + barCategoryGap={0} barSize={barSize} > :nth-child(#{$i}) { - width: nth($column-widths, $i); - } - } -} - -.overview-connected-users { - & > header { - display: flex; - flex-direction: row; - align-items: center; - align-content: center; - justify-content: flex-start; - width: 100%; - height: 40px; - box-sizing: border-box; - } - - & > .connected-users { + .connected-users { min-height: calc(100% - 94px); max-height: calc(100% - 94px); - &.list { - display: flex; - flex-direction: column; - align-items: flex-start; - align-content: flex-start; - justify-content: flex-start; - box-sizing: border-box; - padding-left: 0 !important; - - @include media-breakpoint-down(xl) { - overflow: auto; - } - - @include media-breakpoint-up(xl) { - overflow: hidden; - padding-right: 1rem; - } - - & > .headers { - display: flex; - flex-direction: row; - align-items: center; - align-content: center; - justify-content: flex-start; - box-sizing: border-box; - height: 22px; - - @include media-breakpoint-down(xl) { - padding-left: calc(52px + 1.5rem); - padding-right: 1.5rem; - } - - @include media-breakpoint-up(xl) { - padding-left: calc(52px + 6rem); - padding-right: 34px; - width: 100%; - } - - & > .header { - display: flex; - flex-direction: row; - align-items: center; - align-content: center; - justify-content: flex-start; - column-gap: 0.6rem; - - & > span { - @include typography-legacy(12px, 14px, medium, v.$gray-light); - } - - &.active { - & > span { - color: v.$text-main; - } - } - } - - @include set-list-column-widths; - } - - & > .users-list { - box-sizing: border-box; - overflow: auto; - padding-right: 1.5rem; - padding-top: 1.5rem; - padding-bottom: 1.5rem; - - @include media-breakpoint-down(xl) { - max-height: calc(50vh); - padding-left: 1.5rem; - } - - @include media-breakpoint-up(xl) { - min-height: calc(100% - 28px); - max-height: calc(100% - 28px); - padding-left: 6rem; - width: 100%; - } - - & > .user-connection-list-item { - & > .user-row, - .device-row { - @include set-list-column-widths; - } - - &:not(:last-of-type) { - margin-bottom: 1rem; - } - } - } - } - &.grid { display: grid; width: 100%; - gap: 1.5rem; + gap: 15px; overflow: auto; box-sizing: border-box; position: relative; - padding-top: 1rem; - padding-bottom: 4rem; - height: auto; grid-template-rows: min-content; grid-template-columns: 1fr; - @include media-breakpoint-down(md) { - justify-content: center; - } - - @include media-breakpoint-up(xl) { - padding-top: 1rem; - padding-bottom: 4rem; - grid-template-columns: repeat(auto-fit, 370px); + @include media-breakpoint-up(lg) { + grid-template-columns: repeat(auto-fill, 370px); } } } } - -.no-data-text { - margin-left: 6rem; - margin-top: 1rem; - - @include media-breakpoint-down(md) { - margin-left: 0; - text-align: center; - } -} diff --git a/web/src/pages/overview/OverviewExpandable/OverviewExpandable.tsx b/web/src/pages/overview/OverviewExpandable/OverviewExpandable.tsx new file mode 100644 index 000000000..1658791c7 --- /dev/null +++ b/web/src/pages/overview/OverviewExpandable/OverviewExpandable.tsx @@ -0,0 +1,48 @@ +import './style.scss'; + +import clsx from 'clsx'; +import { ReactNode, useState } from 'react'; + +type Props = { + children?: ReactNode; + className?: string; + id?: string; + title: string; +}; + +export const OverviewExpandable = ({ children, title, className, id }: Props) => { + const [expanded, setExpanded] = useState(true); + return ( +
+
setExpanded((s) => !s)}> +

{title}

+ + + + +
+
+
{children}
+
+
+ ); +}; diff --git a/web/src/pages/overview/OverviewExpandable/style.scss b/web/src/pages/overview/OverviewExpandable/style.scss new file mode 100644 index 000000000..652b3fcda --- /dev/null +++ b/web/src/pages/overview/OverviewExpandable/style.scss @@ -0,0 +1,56 @@ +.overview-expandable { + width: 100%; + + & > .header { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + column-gap: 10px; + width: min-content; + user-select: none; + cursor: pointer; + + p { + @include typography(app-body-1); + text-wrap: nowrap; + + & { + font-weight: 600; + color: var(--text-body-primary); + } + } + + svg { + transform: rotateZ(-90deg); + + @include transition(standard); + + & { + &.expanded { + transform: rotateZ(0deg); + } + } + } + } + + .expandable { + display: grid; + grid-template-rows: 0fr; + width: 100%; + transition-property: grid-template-rows; + transition-duration: 100ms; + transition-timing-function: ease-in-out; + + & > div { + box-sizing: border-box; + padding-top: 20px; + overflow: hidden; + width: 100%; + } + + &.expanded { + grid-template-rows: 1fr; + } + } +} diff --git a/web/src/pages/overview/OverviewPage.tsx b/web/src/pages/overview/OverviewPage.tsx index cf9aad956..9c33b9662 100644 --- a/web/src/pages/overview/OverviewPage.tsx +++ b/web/src/pages/overview/OverviewPage.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router'; import { useBreakpoint } from 'use-breakpoint'; +import { useI18nContext } from '../../i18n/i18n-react'; import { PageContainer } from '../../shared/components/Layout/PageContainer/PageContainer'; import { GatewaysStatus } from '../../shared/components/network/GatewaysStatus/GatewaysStatus'; import { deviceBreakpoints } from '../../shared/constants'; @@ -13,12 +14,14 @@ import { LoaderSpinner } from '../../shared/defguard-ui/components/Layout/Loader import { NoData } from '../../shared/defguard-ui/components/Layout/NoData/NoData'; import useApi from '../../shared/hooks/useApi'; import { QueryKeys } from '../../shared/queries'; -import { NetworkUserStats, OverviewLayoutType } from '../../shared/types'; +import { OverviewLayoutType } from '../../shared/types'; import { sortByDate } from '../../shared/utils/sortByDate'; import { useWizardStore } from '../wizard/hooks/useWizardStore'; import { getNetworkStatsFilterValue } from './helpers/stats'; import { useOverviewStore } from './hooks/store/useOverviewStore'; import { OverviewConnectedUsers } from './OverviewConnectedUsers/OverviewConnectedUsers'; +import { StandaloneDeviceConnectionCard } from './OverviewConnectedUsers/UserConnectionCard/UserConnectionCard'; +import { OverviewExpandable } from './OverviewExpandable/OverviewExpandable'; import { OverviewHeader } from './OverviewHeader/OverviewHeader'; import { OverviewStats } from './OverviewStats/OverviewStats'; @@ -32,9 +35,10 @@ export const OverviewPage = () => { const selectedNetworkId = useOverviewStore((state) => state.selectedNetworkId); const resetWizard = useWizardStore((state) => state.resetState); const viewMode = useOverviewStore((state) => state.viewMode); + const { LL } = useI18nContext(); const { - network: { getNetworks, getUsersStats, getNetworkStats }, + network: { getNetworks, getOverviewStats, getNetworkStats }, } = useApi(); const { isLoading: networksLoading } = useQuery( @@ -74,10 +78,10 @@ export const OverviewPage = () => { }, ); - const { data: networkUsersStats, isLoading: userStatsLoading } = useQuery( + const { data: overviewStats, isLoading: userStatsLoading } = useQuery( [QueryKeys.FETCH_NETWORK_USERS_STATS, statsFilter, selectedNetworkId], () => - getUsersStats({ + getOverviewStats({ from: getNetworkStatsFilterValue(statsFilter), id: selectedNetworkId as number, }), @@ -89,21 +93,24 @@ export const OverviewPage = () => { ); const getNetworkUsers = useMemo(() => { - let res: NetworkUserStats[] = []; - if (!isUndefined(networkUsersStats)) { - res = sortByDate( - networkUsersStats, - (i) => { - const devices = sortByDate(i.devices, (d) => d.connected_at, false); - return devices[0].connected_at; - }, - false, + if (overviewStats !== undefined) { + const user = sortByDate(overviewStats.user_devices, (s) => { + const fistDevice = sortByDate(s.devices, (d) => d.connected_at, false)[0]; + return fistDevice.connected_at; + }); + const devices = sortByDate( + overviewStats.network_devices.filter((d) => d.connected_at !== undefined), + (d) => d.connected_at as string, ); + return { + network_devices: devices, + user_devices: user, + }; } - return res; - }, [networkUsersStats]); + return undefined; + }, [overviewStats]); - // FIXME: lockdown viewMode on grid for now + // FIXME: lock viewMode on grid for now useEffect(() => { if (viewMode !== OverviewLayoutType.GRID) { setOverViewStore({ viewMode: OverviewLayoutType.GRID }); @@ -117,19 +124,43 @@ export const OverviewPage = () => { {breakpoint === 'desktop' && !isUndefined(selectedNetworkId) && ( )} - {networkStats && networkUsersStats && ( - + {networkStats && overviewStats && ( + )}
- {userStatsLoading ? ( + {userStatsLoading && (
- ) : getNetworkUsers.length > 0 ? ( - - ) : ( - )} + {!getNetworkUsers && !userStatsLoading && } + {!userStatsLoading && + getNetworkUsers && + getNetworkUsers.network_devices.length === 0 && + getNetworkUsers.user_devices.length === 0 && } + {!userStatsLoading && + getNetworkUsers && + getNetworkUsers.user_devices.length > 0 && ( + + + + )} + {!userStatsLoading && + getNetworkUsers && + getNetworkUsers.network_devices.length > 0 && ( + +
+
+ {getNetworkUsers.network_devices.map((device) => ( + + ))} +
+
+
+ )}
{/* Modals */} diff --git a/web/src/pages/overview/helpers/stats.ts b/web/src/pages/overview/helpers/stats.ts index 8944e2d12..0226ea7f0 100644 --- a/web/src/pages/overview/helpers/stats.ts +++ b/web/src/pages/overview/helpers/stats.ts @@ -72,21 +72,19 @@ export const getMaxDeviceStats = (data: NetworkUserStats[]): number => { return maxUpload > maxDownload ? maxUpload : maxDownload; }; -export const summarizeDeviceStats = (data: NetworkDeviceStats[]): NetworkSpeedStats[] => { +export const summarizeDeviceStats = (stats: NetworkSpeedStats[]) => { const merge: MergeStruct = {}; - data.forEach((device) => { - device.stats.forEach((stat) => { - const inRank = merge[stat.collected_at]; - if (isUndefined(inRank)) { - merge[stat.collected_at] = { - download: stat.download, - upload: stat.upload, - }; - } else { - inRank.download = inRank.download + stat.download; - inRank.upload = inRank.upload + stat.upload; - } - }); + stats.forEach((stat) => { + const inRank = merge[stat.collected_at]; + if (isUndefined(inRank)) { + merge[stat.collected_at] = { + download: stat.download, + upload: stat.upload, + }; + } else { + inRank.download = inRank.download + stat.download; + inRank.upload = inRank.upload + stat.upload; + } }); return Object.keys(merge).map((collectedAt) => ({ collected_at: collectedAt, @@ -95,6 +93,16 @@ export const summarizeDeviceStats = (data: NetworkDeviceStats[]): NetworkSpeedSt })); }; +export const summarizeDevicesStats = ( + data: NetworkDeviceStats[], +): NetworkSpeedStats[] => { + let res: NetworkSpeedStats[] = []; + data.forEach((device) => { + res = [...res, ...summarizeDeviceStats(device.stats)]; + }); + return res; +}; + export interface StatsChartData extends Pick { collected_at: number; } diff --git a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/FinishCliStep/FinishCliStep.tsx b/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/FinishCliStep/FinishCliStep.tsx deleted file mode 100644 index d7d1db669..000000000 --- a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/FinishCliStep/FinishCliStep.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import './style.scss'; - -import { shallow } from 'zustand/shallow'; - -import { useI18nContext } from '../../../../../../i18n/i18n-react'; -import { ActionButton } from '../../../../../../shared/defguard-ui/components/Layout/ActionButton/ActionButton'; -import { ActionButtonVariant } from '../../../../../../shared/defguard-ui/components/Layout/ActionButton/types'; -import { Button } from '../../../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../../../shared/defguard-ui/components/Layout/Button/types'; -import { ExpandableCard } from '../../../../../../shared/defguard-ui/components/Layout/ExpandableCard/ExpandableCard'; -import { MessageBox } from '../../../../../../shared/defguard-ui/components/Layout/MessageBox/MessageBox'; -import { MessageBoxType } from '../../../../../../shared/defguard-ui/components/Layout/MessageBox/types'; -import { useAddStandaloneDeviceModal } from '../../store'; - -export const FinishCliStep = () => { - const { LL } = useI18nContext(); - const localLL = LL.modals.addStandaloneDevice.steps.cli.finish; - const [closeModal] = useAddStandaloneDeviceModal((s) => [s.close], shallow); - return ( -
- - - {}} key={0} />, - ]} - expanded={true} - disableExpand={true} - > -

{'defguard -u https://enrollment.defguard.net -t sdf$&9234&8dfsk345LSD3'}

-
-
-
-
- ); -}; diff --git a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/SetupCliStep/SetupCliStep.tsx b/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/SetupCliStep/SetupCliStep.tsx deleted file mode 100644 index dea2daef4..000000000 --- a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/SetupCliStep/SetupCliStep.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { useCallback, useEffect, useMemo } from 'react'; -import { SubmitHandler, useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { shallow } from 'zustand/shallow'; - -import { useI18nContext } from '../../../../../../i18n/i18n-react'; -import { FormInput } from '../../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; -import { FormSelect } from '../../../../../../shared/defguard-ui/components/Form/FormSelect/FormSelect'; -import { Button } from '../../../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../../../shared/defguard-ui/components/Layout/Button/types'; -import { MessageBox } from '../../../../../../shared/defguard-ui/components/Layout/MessageBox/MessageBox'; -import { MessageBoxType } from '../../../../../../shared/defguard-ui/components/Layout/MessageBox/types'; -import { - SelectOption, - SelectSelectedValue, -} from '../../../../../../shared/defguard-ui/components/Layout/Select/types'; -import { useAddStandaloneDeviceModal } from '../../store'; -import { AddStandaloneDeviceModalStep } from '../../types'; - -type FormFields = { - name: string; - location: number; - description: string; - assignedAddress: string; -}; - -export const SetupCliStep = () => { - const { LL } = useI18nContext(); - const localLL = LL.modals.addStandaloneDevice.steps.cli.setup; - const labels = localLL.form.labels; - const locationOptions = useAddStandaloneDeviceModal((s) => s.networkOptions); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [setState, close, next] = useAddStandaloneDeviceModal( - (s) => [s.setStore, s.close, s.changeStep], - shallow, - ); - - const schema = useMemo( - () => - z.object({ - name: z - .string() - .min(1, LL.form.error.required()) - .min(2, LL.form.error.minimumLength()), - location: z.number({ - required_error: LL.form.error.required(), - invalid_type_error: LL.form.error.invalid(), - }), - assignedAddress: z.string().min(1, LL.form.error.required()), - publicKey: z.string().optional(), - }), - [LL.form.error], - ); - - const renderLocationOption = useCallback( - (value: number): SelectSelectedValue => { - if (locationOptions) { - const option = locationOptions.find((o) => o.value === value); - if (option) { - return { - displayValue: option.label, - key: option.key, - }; - } - return { - displayValue: '', - key: 'unknown', - }; - } - return { - displayValue: '', - key: 'unknown', - }; - }, - [locationOptions], - ); - - const { control, handleSubmit, setValue, getValues } = useForm({ - resolver: zodResolver(schema), - mode: 'all', - defaultValues: { - assignedAddress: '', - description: '', - location: 0, - name: '', - }, - }); - - const validSubmit: SubmitHandler = (values) => { - console.table(values); - next(AddStandaloneDeviceModalStep.FINISH_CLI); - }; - - useEffect(() => { - if (locationOptions && locationOptions.length) { - const firstId = locationOptions[0].value; - if (getValues().location !== firstId) { - setValue('location', firstId); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [locationOptions]); - - return ( -
- -
- -
- []>} - renderSelected={renderLocationOption} - label={labels.location()} - /> - -
- -
-
- -
- ); -}; diff --git a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/SetupManualStep/SetupManualStep.tsx b/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/SetupManualStep/SetupManualStep.tsx deleted file mode 100644 index daa1b9db7..000000000 --- a/web/src/pages/overview/modals/AddStandaloneDeviceModal/steps/SetupManualStep/SetupManualStep.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import './style.scss'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { useCallback, useMemo } from 'react'; -import { SubmitHandler, useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { shallow } from 'zustand/shallow'; - -import { useI18nContext } from '../../../../../../i18n/i18n-react'; -import { FormInput } from '../../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; -import { FormSelect } from '../../../../../../shared/defguard-ui/components/Form/FormSelect/FormSelect'; -import { FormToggle } from '../../../../../../shared/defguard-ui/components/Form/FormToggle/FormToggle'; -import { Button } from '../../../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../../../shared/defguard-ui/components/Layout/Button/types'; -import { - SelectOption, - SelectSelectedValue, -} from '../../../../../../shared/defguard-ui/components/Layout/Select/types'; -import { ToggleOption } from '../../../../../../shared/defguard-ui/components/Layout/Toggle/types'; -import { validateWireguardPublicKey } from '../../../../../../shared/validators'; -import { useAddStandaloneDeviceModal } from '../../store'; -import { AddStandaloneDeviceModalStep, WGConfigGenChoice } from '../../types'; - -type FormFields = { - name: string; - location: number; - description: string; - assignedAddress: string; - generationChoice: WGConfigGenChoice; - publicKey?: string; -}; - -export const SetupManualStep = () => { - const { LL } = useI18nContext(); - const localLL = LL.modals.addStandaloneDevice.steps.manual.setup; - const errors = LL.form.error; - const labels = localLL.form.labels; - const locationOptions = useAddStandaloneDeviceModal((s) => s.networkOptions); - const [setState, next] = useAddStandaloneDeviceModal( - (s) => [s.setStore, s.changeStep], - shallow, - ); - const renderSelectedOption = useCallback( - (val?: number): SelectSelectedValue => { - const empty: SelectSelectedValue = { - displayValue: '', - key: 'empty', - }; - if (val !== undefined) { - const option = locationOptions.find((n) => n.value === val); - if (option) { - return { - displayValue: option.label, - key: option.key, - }; - } - } - return empty; - }, - [locationOptions], - ); - - const toggleOptions = useMemo( - (): ToggleOption[] => [ - { - text: labels.generation.auto(), - value: WGConfigGenChoice.AUTO, - disabled: false, - }, - { - text: labels.generation.manual(), - value: WGConfigGenChoice.MANUAL, - disabled: false, - }, - ], - [labels.generation], - ); - - const schema = useMemo( - () => - z - .object({ - name: z.string(), - location: z.number(), - description: z.string(), - assignedAddress: z.string(), - generationChoice: z.nativeEnum(WGConfigGenChoice), - publicKey: z.string().optional(), - }) - .superRefine((vals, ctx) => { - if (vals.generationChoice === WGConfigGenChoice.MANUAL) { - const result = validateWireguardPublicKey({ - requiredError: errors.required(), - maxError: errors.maximumLengthOf({ length: 44 }), - minError: errors.minimumLengthOf({ length: 44 }), - validKeyError: errors.invalid(), - }).safeParse(vals.publicKey); - if (!result.success) { - ctx.addIssue({ - path: ['publicKey'], - message: result.error.message, - code: 'custom', - }); - } - } - }), - [errors], - ); - - const { handleSubmit, control, watch } = useForm({ - resolver: zodResolver(schema), - mode: 'all', - defaultValues: { - assignedAddress: '', - description: '', - generationChoice: WGConfigGenChoice.AUTO, - name: '', - publicKey: '', - location: locationOptions[0].value, - }, - }); - - const generationChoiceValue = watch('generationChoice'); - - const validSubmit: SubmitHandler = (values) => { - console.table(values); - setState({ genChoice: values.generationChoice }); - next(AddStandaloneDeviceModalStep.FINISH_MANUAL); - }; - - return ( -
-
- -
- []>} - renderSelected={renderSelectedOption} - label={labels.location()} - /> - -
- - - -
-
- -
- ); -}; diff --git a/web/src/pages/overview/modals/AddStandaloneDeviceModal/types.ts b/web/src/pages/overview/modals/AddStandaloneDeviceModal/types.ts deleted file mode 100644 index 93714fe54..000000000 --- a/web/src/pages/overview/modals/AddStandaloneDeviceModal/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -export enum AddStandaloneDeviceModalStep { - METHOD_CHOICE, - SETUP_CLI, - FINISH_CLI, - SETUP_MANUAL, - FINISH_MANUAL, -} - -export enum AddStandaloneDeviceModalChoice { - CLI, - MANUAL, -} - -export enum WGConfigGenChoice { - MANUAL, - AUTO, -} diff --git a/web/src/pages/overview/style.scss b/web/src/pages/overview/style.scss index 5758ca224..c1f8380a5 100644 --- a/web/src/pages/overview/style.scss +++ b/web/src/pages/overview/style.scss @@ -33,12 +33,14 @@ & > .btn { margin-left: auto; + .content { span { white-space: nowrap; } } } + & > .network-gateways-connection { width: auto; } @@ -72,10 +74,12 @@ & > header { box-sizing: border-box; margin-bottom: 10px; + @include media-breakpoint-up(md) { padding-left: 1.5rem; padding-right: 1.5rem; } + @include media-breakpoint-up(lg) { padding: 2rem 2rem 0; display: grid; @@ -112,6 +116,7 @@ grid-row: 2; grid-column: 1; } + @include media-breakpoint-up(xxl) { grid-row: 1; grid-column: 2; @@ -119,6 +124,7 @@ & > .select { min-width: 200px; + .select-container { height: 40px; } @@ -131,6 +137,7 @@ & > .select-skeleton { width: 200px; height: 40px; + & > span { height: 100%; } @@ -142,58 +149,24 @@ @include media-breakpoint-down(xl) { margin-bottom: 1.5rem; } + @include media-breakpoint-up(xl) { margin-bottom: 5rem; } } .bottom-row { - @include media-breakpoint-down(xl) { - display: block; - } - @include media-breakpoint-up(xl) { - display: grid; - flex-grow: 1; - grid-template-columns: 1fr; - column-gap: 4rem; - max-width: 100%; - padding-right: 6rem; - } + display: flex; + flex-flow: column; + row-gap: 20px; + box-sizing: border-box; + padding: 0 50px 40px 60px; & > .no-data { width: 100%; padding-top: 20px; text-align: center; } - - & > .overview-connected-users { - @include media-breakpoint-down(xl) { - & > header, - .connected-users { - padding-left: 2rem; - padding-right: 2rem; - margin: auto; - } - } - @include media-breakpoint-up(xl) { - grid-column: 1; - - & > header, - .connected-users { - padding-left: 6rem; - } - } - } - - // & > .activity-stream { - // @include media-breakpoint-down(xl) { - // padding-left: 2rem; - // padding-right: 2rem; - // } - // @include media-breakpoint-up(xl) { - // grid-column: 2; - // } - // } } h1 { @@ -215,6 +188,7 @@ align-content: center; justify-content: center; align-items: center; + @include media-breakpoint-up(lg) { grid-row: 1; } diff --git a/web/src/shared/components/Layout/ManagementPageLayout/ManagementPageLayout.tsx b/web/src/shared/components/Layout/ManagementPageLayout/ManagementPageLayout.tsx index fe5cfeff1..04b99a931 100644 --- a/web/src/shared/components/Layout/ManagementPageLayout/ManagementPageLayout.tsx +++ b/web/src/shared/components/Layout/ManagementPageLayout/ManagementPageLayout.tsx @@ -12,6 +12,7 @@ export const ManagementPageLayout = ({ actions, itemsCount, search, + id, }: ManagementPageProps) => { const navOpen = useNavigationStore((s) => s.isOpen); return ( @@ -19,6 +20,7 @@ export const ManagementPageLayout = ({ className={clsx('management-page', { 'nav-open': navOpen, })} + id={id} >
diff --git a/web/src/shared/components/Layout/ManagementPageLayout/types.ts b/web/src/shared/components/Layout/ManagementPageLayout/types.ts index e7e8c6036..a867bd10b 100644 --- a/web/src/shared/components/Layout/ManagementPageLayout/types.ts +++ b/web/src/shared/components/Layout/ManagementPageLayout/types.ts @@ -6,6 +6,7 @@ export type ManagementPageProps = { search?: ManagementPageSearch; actions?: ReactNode; itemsCount?: ManagementPageItemsCount; + id?: string; }; export type ManagementPageItemsCount = { diff --git a/web/src/shared/components/Layout/WireguardConfigExpandable/WireguardConfigExpandable.tsx b/web/src/shared/components/Layout/WireguardConfigExpandable/WireguardConfigExpandable.tsx new file mode 100644 index 000000000..7ff781800 --- /dev/null +++ b/web/src/shared/components/Layout/WireguardConfigExpandable/WireguardConfigExpandable.tsx @@ -0,0 +1,113 @@ +import './style.scss'; + +import { Fragment, ReactNode, useCallback, useMemo, useState } from 'react'; +import QRCode from 'react-qr-code'; + +import { useI18nContext } from '../../../../i18n/i18n-react'; +import { ActionButton } from '../../../defguard-ui/components/Layout/ActionButton/ActionButton'; +import { ActionButtonVariant } from '../../../defguard-ui/components/Layout/ActionButton/types'; +import { ExpandableCard } from '../../../defguard-ui/components/Layout/ExpandableCard/ExpandableCard'; +import { useClipboard } from '../../../hooks/useClipboard'; +import { downloadWGConfig } from '../../../utils/downloadWGConfig'; + +type Props = { + config: string; + publicKey: string; + deviceName: string; + privateKey?: string; +}; + +enum ConfigCardView { + FILE, + QR, +} + +export const WireguardConfigExpandable = ({ + config, + deviceName, + publicKey, + privateKey, +}: Props) => { + const { LL } = useI18nContext(); + const { writeToClipboard } = useClipboard(); + const localLL = LL.modals.addStandaloneDevice.steps.manual.finish; + const [view, setView] = useState(ConfigCardView.FILE); + + const configForExport = useMemo(() => { + if (privateKey) { + return config.replace('YOUR_PRIVATE_KEY', privateKey); + } + return config; + }, [config, privateKey]); + + const getQRConfig = useMemo((): string => { + if (privateKey) { + return config.replace('YOUR_PRIVATE_KEY', privateKey); + } + return config.replace('YOUR_PRIVATE_KEY', publicKey); + }, [config, privateKey, publicKey]); + + const renderTextConfig = () => { + const content = configForExport.split('\n'); + return ( +

+ {content.map((text, index) => ( + + {text} + {index !== content.length - 1 &&
} +
+ ))} +

+ ); + }; + + const handleConfigCopy = useCallback(() => { + writeToClipboard( + configForExport, + LL.components.deviceConfigsCard.messages.copyConfig(), + ); + }, [LL.components.deviceConfigsCard.messages, configForExport, writeToClipboard]); + + const handleConfigDownload = useCallback(() => { + downloadWGConfig(configForExport, deviceName.toLowerCase().replace(' ', '-')); + }, [configForExport, deviceName]); + + const actions = useMemo( + (): ReactNode[] => [ + setView(ConfigCardView.FILE)} + />, + setView(ConfigCardView.QR)} + />, + , + , + ], + [handleConfigCopy, handleConfigDownload, view], + ); + return ( + + {view === ConfigCardView.FILE && renderTextConfig()} + {view === ConfigCardView.QR && } + + ); +}; diff --git a/web/src/shared/components/Layout/WireguardConfigExpandable/style.scss b/web/src/shared/components/Layout/WireguardConfigExpandable/style.scss new file mode 100644 index 000000000..62957360e --- /dev/null +++ b/web/src/shared/components/Layout/WireguardConfigExpandable/style.scss @@ -0,0 +1,18 @@ +.wireguard-config-card { + .expanded-content { + display: flex; + flex-flow: column; + align-items: center; + + .config { + width: 100%; + @include typography(app-code); + color: var(--text-button-primary); + + span { + @include typography(app-code); + color: var(--text-button-primary); + } + } + } +} diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index 6f06169d5..42df77027 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit 6f06169d535f05336d808d6a2a3faa8eb1c803bd +Subproject commit 42df770270e98b7b834b9cc0c6e7ba5be3768f90 diff --git a/web/src/shared/hooks/useApi.tsx b/web/src/shared/hooks/useApi.tsx index 97f7bb7cc..accf4b63c 100644 --- a/web/src/shared/hooks/useApi.tsx +++ b/web/src/shared/hooks/useApi.tsx @@ -21,7 +21,6 @@ import { MFALoginResponse, Network, NetworkToken, - NetworkUserStats, OpenidClient, OpenIdProvider, RemoveUserClientRequest, @@ -269,9 +268,11 @@ const useApi = (props?: HookProps): ApiHook => { }) .then((res) => res.data); - const getUsersStats = (data: GetNetworkStatsRequest) => + const getOverviewStats: ApiHook['network']['getOverviewStats'] = ( + data: GetNetworkStatsRequest, + ) => client - .get(`/network/${data.id}/stats/users`, { + .get(`/network/${data.id}/stats/users`, { params: { ...data, }, @@ -470,6 +471,44 @@ const useApi = (props?: HookProps): ApiHook => { const testDirsync: ApiHook['settings']['testDirsync'] = () => client.get('/test_directory_sync').then(unpackRequest); + const createStandaloneDevice: ApiHook['standaloneDevice']['createManualDevice'] = ( + data, + ) => client.post('/device/network', data).then(unpackRequest); + + const deleteStandaloneDevice: ApiHook['standaloneDevice']['deleteDevice'] = ( + deviceId, + ) => client.delete(`/device/network/${deviceId}`); + const editStandaloneDevice: ApiHook['standaloneDevice']['editDevice'] = ({ + id, + ...data + }) => client.put(`/device/network/${id}`, data).then(unpackRequest); + + const getStandaloneDevice: ApiHook['standaloneDevice']['getDevice'] = (deviceId) => + client.get(`/device/network/${deviceId}`).then(unpackRequest); + + const getAvailableLocationIp: ApiHook['standaloneDevice']['getAvailableIp'] = (data) => + client.get(`/device/network/ip/${data.locationId}`).then(unpackRequest); + + const validateLocationIp: ApiHook['standaloneDevice']['validateLocationIp'] = ({ + location, + ...rest + }) => client.post(`/device/network/ip/${location}`, rest).then(unpackRequest); + + const getStandaloneDevicesList: ApiHook['standaloneDevice']['getDevicesList'] = () => + client.get('/device/network').then(unpackRequest); + + const createStandaloneCliDevice: ApiHook['standaloneDevice']['createCliDevice'] = ( + data, + ) => client.post('/device/network/start_cli', data).then(unpackRequest); + + const getStandaloneDeviceConfig: ApiHook['standaloneDevice']['getDeviceConfig'] = ( + id, + ) => client.get(`/device/network/${id}/config`).then(unpackRequest); + + // eslint-disable-next-line max-len + const generateStandaloneDeviceAuthToken: ApiHook['standaloneDevice']['generateAuthToken'] = + (id) => client.post(`/device/network/start_cli/${id}`).then(unpackRequest); + useEffect(() => { client.interceptors.response.use( (res) => { @@ -513,6 +552,18 @@ const useApi = (props?: HookProps): ApiHook => { editGroup, addUsersToGroups, }, + standaloneDevice: { + createManualDevice: createStandaloneDevice, + deleteDevice: deleteStandaloneDevice, + editDevice: editStandaloneDevice, + getDevice: getStandaloneDevice, + getAvailableIp: getAvailableLocationIp, + validateLocationIp: validateLocationIp, + getDevicesList: getStandaloneDevicesList, + createCliDevice: createStandaloneCliDevice, + getDeviceConfig: getStandaloneDeviceConfig, + generateAuthToken: generateStandaloneDeviceAuthToken, + }, user: { getMe, addUser, @@ -554,11 +605,11 @@ const useApi = (props?: HookProps): ApiHook => { getNetworks: fetchNetworks, editNetwork: modifyNetwork, deleteNetwork, - getUsersStats, getNetworkToken, getNetworkStats, getGatewaysStatus, deleteGateway, + getOverviewStats: getOverviewStats, }, auth: { login, diff --git a/web/src/shared/links.ts b/web/src/shared/links.ts index 7832a056c..acea1fd29 100644 --- a/web/src/shared/links.ts +++ b/web/src/shared/links.ts @@ -18,4 +18,6 @@ export const externalLink = { wireguard: { download: 'https://www.wireguard.com/install/', }, + //TODO: change me + defguardCliDownload: 'https://github.com/DefGuard/client/releases', }; diff --git a/web/src/shared/mutations.ts b/web/src/shared/mutations.ts index 58795971d..da2f35fee 100644 --- a/web/src/shared/mutations.ts +++ b/web/src/shared/mutations.ts @@ -46,4 +46,7 @@ export const MutationKeys = { CHANGE_NETWORK: 'CHANGE_NETWORK', IMPORT_NETWORK: 'IMPORT_NETWORK', CREATE_USER_DEVICES: 'CREATE_USER_DEVICES', + CREATE_STANDALONE_DEVICE: 'CREATE_STANDALONE_DEVICE', + EDIT_STANDALONE_DEVICE: 'EDIT_STANDALONE_DEVICE', + DELETE_STANDALONE_DEVICE: 'DELETE_STANDALONE_DEVICE', }; diff --git a/web/src/shared/queries.ts b/web/src/shared/queries.ts index f4d3c6459..46ead835e 100644 --- a/web/src/shared/queries.ts +++ b/web/src/shared/queries.ts @@ -32,4 +32,6 @@ export const QueryKeys = { FETCH_ENTERPRISE_SETTINGS: 'FETCH_ENTERPRISE_SETTINGS', FETCH_ENTERPRISE_INFO: 'FETCH_ENTERPRISE_INFO', FETCH_NEW_VERSION: 'FETCH_NEW_VERSION', + FETCH_STANDALONE_DEVICE: 'FETCH_STANDALONE_DEVICE', + FETCH_STANDALONE_DEVICE_LIST: 'FETCH_STANDALONE_DEVICE_LIST', }; diff --git a/web/src/shared/scss/global/_animations.scss b/web/src/shared/scss/global/_animations.scss new file mode 100644 index 000000000..49132821d --- /dev/null +++ b/web/src/shared/scss/global/_animations.scss @@ -0,0 +1,6 @@ +@mixin transition($value) { + @if $value == 'standard' { + transition-timing-function: cubic-bezier(0.25, 1, 0.5, 1); + transition-duration: 250ms; + } +} diff --git a/web/src/shared/scss/global/_index.scss b/web/src/shared/scss/global/_index.scss index 6a3fa9cf3..4f0dc896e 100644 --- a/web/src/shared/scss/global/_index.scss +++ b/web/src/shared/scss/global/_index.scss @@ -1,4 +1,5 @@ // this is prepended to all .scss files so all mixins and helpers can be used anywhere @forward './bootstrap/'; @forward './mixins'; +@forward './animations'; @forward '../../defguard-ui/scss/typography'; diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 61414114e..6973e3dbf 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -489,6 +489,26 @@ export interface ApiHook { }) => EmptyApiResponse; deleteYubiKey: (data: { id: number; username: string }) => EmptyApiResponse; }; + standaloneDevice: { + createManualDevice: ( + data: CreateStandaloneDeviceRequest, + ) => Promise; + createCliDevice: ( + data: CreateStandaloneDeviceRequest, + ) => Promise; + getDevice: (deviceId: number | string) => Promise; + deleteDevice: (deviceId: number | string) => Promise; + editDevice: (data: StandaloneDeviceEditRequest) => Promise; + getAvailableIp: ( + data: GetAvailableLocationIpRequest, + ) => Promise; + validateLocationIp: ( + data: ValidateLocationIpRequest, + ) => Promise; + getDevicesList: () => Promise; + getDeviceConfig: (deviceId: number | string) => Promise; + generateAuthToken: (deviceId: number | string) => Promise; + }; device: { addDevice: (device: AddDeviceRequest) => Promise; getDevice: (deviceId: string) => Promise; @@ -506,7 +526,7 @@ export interface ApiHook { getNetworks: () => Promise; editNetwork: (network: ModifyNetworkRequest) => Promise; deleteNetwork: (networkId: number) => EmptyApiResponse; - getUsersStats: (data: GetNetworkStatsRequest) => Promise; + getOverviewStats: (data: GetNetworkStatsRequest) => Promise; getNetworkToken: (networkId: Network['id']) => Promise; getNetworkStats: (data: GetNetworkStatsRequest) => Promise; getGatewaysStatus: (networkId: number) => Promise; @@ -999,6 +1019,22 @@ export interface NetworkDeviceStats { wireguard_ip: string; stats: NetworkSpeedStats[]; } + +export type OverviewStatsResponse = { + user_devices: NetworkUserStats[]; + network_devices: StandaloneDeviceStats[]; +}; + +export type StandaloneDeviceStats = { + id: number; + stats: NetworkSpeedStats[]; + user_id: number; + name: string; + wireguard_ip?: string; + public_ip?: string; + connected_at?: string; +}; + export interface NetworkUserStats { user: User; devices: NetworkDeviceStats[]; @@ -1060,3 +1096,69 @@ export type DirsyncTestResponse = { message: string; success: boolean; }; + +export type CreateStandaloneDeviceRequest = { + name: string; + location_id: number; + assigned_ip: string; + wireguard_pubkey?: string; + description?: string; +}; + +export type ValidateLocationIpRequest = { + ip: string; + location: number | string; +}; + +export type ValidateLocationIpResponse = { + available: boolean; + valid: boolean; +}; + +export type GetAvailableLocationIpRequest = { + locationId: number | string; +}; + +export type GetAvailableLocationIpResponse = { + ip: string; +}; + +export type StandaloneDevice = { + id: number; + name: string; + assigned_ip: string; + description?: string; + added_by: string; + added_date: string; + configured: boolean; + // when configured is false this will be empty + wireguard_pubkey?: string; + location: { + id: number; + name: string; + }; +}; + +export type DeviceConfigurationResponse = { + address: string; + allowed_ips: string[]; + config: string; + endpoint: string; + keepalive_interval: number; + mfa_enabled: boolean; + network_id: number; + network_name: string; + pubkey: string; +}; + +export type CreateStandaloneDeviceResponse = { + config: DeviceConfigurationResponse; + device: StandaloneDevice; +}; + +export type StandaloneDeviceEditRequest = { + id: number; + assigned_ip: string; + description?: string; + name: string; +}; diff --git a/web/src/shared/utils/form/selectifyNetwork.ts b/web/src/shared/utils/form/selectifyNetwork.ts new file mode 100644 index 000000000..8ed7f4535 --- /dev/null +++ b/web/src/shared/utils/form/selectifyNetwork.ts @@ -0,0 +1,9 @@ +import { SelectOption } from '../../defguard-ui/components/Layout/Select/types'; +import { Network } from '../../types'; + +export const selectifyNetworks = (data: Network[]): SelectOption[] => + data.map((network) => ({ + key: network.id, + label: network.name, + value: network.id, + })); diff --git a/web/src/shared/utils/isPresent.ts b/web/src/shared/utils/isPresent.ts new file mode 100644 index 000000000..510ce8d49 --- /dev/null +++ b/web/src/shared/utils/isPresent.ts @@ -0,0 +1,3 @@ +export const isPresent = (value: T): value is NonNullable => { + return value !== null && value !== undefined; +};