Add soft delete for revoked devices
Admins could only revoke devices, so never-enrolled and revoked inventory rows piled up with no way to remove them. Soft-delete is limited to REVOKED devices (revoke first), and ITAM GC now hard-deletes PENDING/REVOKED orphans with no API key, postures, or valid enrollment token—including user tombstones without history. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
@@ -2513,7 +2513,7 @@
|
||||
"auth": { "actions": { "signIn": "Sign in" } },
|
||||
"authError": { "enterpriseAccountRequired": { "title": "Enterprise account required", "description": "Personal Google and Microsoft accounts cannot be used to sign in. Please use your work or school account instead." }, "emailNotVerified": { "title": "Email not verified", "description": "Your email address is not verified with the identity provider. Please verify it, then try signing in again." }, "signInSessionExpired": { "title": "Sign-in session expired", "description": "This sign-in attempt is no longer valid. Please start again from the sign-in page." }, "magicLinkExpired": { "title": "Link Expired", "description": "This magic link has expired. Magic links are only valid for 15 minutes. Please request a new one." }, "magicLinkAlreadyUsed": { "title": "Link Already Used", "description": "This magic link has already been used. Please request a new one." }, "invalidLink": { "title": "Invalid link", "description": "This magic link is invalid. Please request a new one." }, "default": { "title": "Authentication failed", "description": "We could not complete your sign-in. Please try again." } },
|
||||
"accessReviewSource": { "documentation": "Documentation", "regions": { "label": "Region", "placeholder": "Select a region", "unitedStates": "United States", "europe": "Europe" } },
|
||||
"devices": { "title": "Devices", "description": "Manage computers enrolled with the Probo posture agent.", "empty": "No devices enrolled yet", "actions": { "add": "Add device", "new": "New device", "reassign": "Re-assign", "revoke": "Revoke" }, "fields": { "organization": "Organization", "hostname": "Hostname", "owner": "Owner", "state": "State", "platform": "Platform", "osVersion": "OS version", "hardwareUuid": "Hardware UUID", "serialNumber": "Serial number", "agentVersion": "Agent version", "enrolledAt": "Enrolled at", "lastSeen": "Last seen" }, "values": { "pending": "(pending)", "never": "Never", "unassigned": "Unassigned" }, "messages": { "ownerUpdated": "Device owner updated", "revoked": "Device revoked" }, "errors": { "create": "Failed to create device", "reassign": "Failed to re-assign device", "revoke": "Failed to revoke device" }, "confirmations": { "revoke": "Revoke device \"{{hostname}}\"? The agent on the device will stop reporting and must be re-enrolled." }, "postures": { "currentTitle": "Current postures", "empty": "No posture checks recorded", "values": { "on": "On", "off": "Off", "immediate": "Immediate", "seconds": "{{seconds}}s", "minPasswordLength": "Min length {{length}}", "configured": "Configured", "none": "None", "unknown": "Unknown" }, "checks": { "diskEncryption": "Disk encryption", "screenLock": "Screen lock", "firewallEnabled": "Firewall enabled", "timeSync": "Time sync", "osVersion": "OS version", "autoUpdate": "Auto update", "passwordPolicy": "Password policy", "remoteLogin": "Remote login", "malwareProtection": "Malware protection" } }, "history": { "title": "Report history", "empty": "No posture reports yet", "checkCount_one": "{{count}} check reported", "checkCount_other": "{{count}} checks reported", "columns": { "time": "Time", "checks": "Checks", "correlationId": "Correlation ID" }, "actions": { "copyCorrelationId": "Copy correlation ID", "correlationIdCopied": "Correlation ID copied" } } },
|
||||
"devices": { "title": "Devices", "description": "Manage computers enrolled with the Probo posture agent.", "empty": "No devices enrolled yet", "actions": { "add": "Add device", "new": "New device", "reassign": "Re-assign", "revoke": "Revoke", "delete": "Delete" }, "fields": { "organization": "Organization", "hostname": "Hostname", "owner": "Owner", "state": "State", "platform": "Platform", "osVersion": "OS version", "hardwareUuid": "Hardware UUID", "serialNumber": "Serial number", "agentVersion": "Agent version", "enrolledAt": "Enrolled at", "lastSeen": "Last seen" }, "values": { "pending": "(pending)", "never": "Never", "unassigned": "Unassigned" }, "messages": { "ownerUpdated": "Device owner updated", "revoked": "Device revoked", "deleted": "Device deleted" }, "errors": { "create": "Failed to create device", "reassign": "Failed to re-assign device", "revoke": "Failed to revoke device", "delete": "Failed to delete device" }, "confirmations": { "revoke": "Revoke device \"{{hostname}}\"? The agent on the device will stop reporting and must be re-enrolled.", "delete": "Delete device \"{{hostname}}\"? This removes it from the inventory." }, "postures": { "currentTitle": "Current postures", "empty": "No posture checks recorded", "values": { "on": "On", "off": "Off", "immediate": "Immediate", "seconds": "{{seconds}}s", "minPasswordLength": "Min length {{length}}", "configured": "Configured", "none": "None", "unknown": "Unknown" }, "checks": { "diskEncryption": "Disk encryption", "screenLock": "Screen lock", "firewallEnabled": "Firewall enabled", "timeSync": "Time sync", "osVersion": "OS version", "autoUpdate": "Auto update", "passwordPolicy": "Password policy", "remoteLogin": "Remote login", "malwareProtection": "Malware protection" } }, "history": { "title": "Report history", "empty": "No posture reports yet", "checkCount_one": "{{count}} check reported", "checkCount_other": "{{count}} checks reported", "columns": { "time": "Time", "checks": "Checks", "correlationId": "Correlation ID" }, "actions": { "copyCorrelationId": "Copy correlation ID", "correlationIdCopied": "Correlation ID copied" } } },
|
||||
"employeeDevices": { "title": "Your devices" },
|
||||
"deviceEnrollment": { "pageTitle": "Enroll device", "title": "Device enrollment", "setup": "Setup", "stepProgress": "Step {{current}} of {{total}}", "steps": { "privacy": { "title": "Privacy", "description": "Review collected data" }, "organization": { "title": "Organization", "description": "Choose destination workspace" }, "enroll": { "title": "Open and wait", "description": "Finish setup in the desktop agent" } }, "unavailable": { "title": "Enrollment unavailable", "description": "You do not have permission to enroll devices in any organization." }, "intro": { "title": "Before you start", "description": "Probo collects the following device metadata for inventory and posture reporting:" }, "privacy": { "identity": "Device identity: hardware UUID, hostname, and serial number (when available).", "systemDetails": "System details: platform, OS version, and Probo agent version.", "activitySignals": "Activity signals: enrollment time, heartbeats, and posture check results." }, "organization": { "title": "Choose organization", "description": "Pick which organization will own and manage this device." }, "openAgent": { "title": "Open the Probo agent", "description": "Open the desktop agent to finish setup, then keep this page open until enrollment is confirmed." }, "actions": { "backToOrganizations": "Back to organizations", "enrollNew": "Enroll new device", "openAgent": "Open Probo agent", "preparing": "Preparing…" }, "status": { "enrolledWithHostname": "{{hostname}} is enrolled.", "enrolled": "This device is enrolled.", "closeWindow": "You can close this window.", "waitingForCheckIn": "Waiting for the agent's first check-in…", "timedOut": "We haven't heard from the agent yet. Make sure the desktop agent is installed and running, then try again." }, "manual": { "cannotEnroll": "Can't enroll new device?", "tryCreating": "Try creating it manually", "title": "Manual enrollment", "creating": "Creating device…" }, "token": { "title": "Enrollment token generated", "description": "Share this enrollment token only with the device owner through a secure channel. It can be used once and expires after seven days.", "manualInstall": "Manual install (CLI / MDM)", "installUnix": "Install on macOS or Linux (run from a shell with sudo access)", "installWindows": "Install on Windows (run from an elevated PowerShell session)", "securityNotice": "The token is passed as a CLI flag (not via curl-piped-to-shell or sudo env vars). Once installed, the agent self-updates from GitHub Releases with cosign signature verification." }, "messages": { "created": "Device created. Copy the enrollment token now — it will not be shown again." }, "errors": { "copyToClipboard": "Failed to copy to clipboard" } },
|
||||
"thirdPartyRiskAssessmentRow": {
|
||||
|
||||
@@ -5958,7 +5958,8 @@
|
||||
"add": "Ajouter un appareil",
|
||||
"new": "Nouvel appareil",
|
||||
"reassign": "Réattribuer",
|
||||
"revoke": "Révoquer"
|
||||
"revoke": "Révoquer",
|
||||
"delete": "Supprimer"
|
||||
},
|
||||
"fields": {
|
||||
"organization": "Organisation",
|
||||
@@ -5980,15 +5981,18 @@
|
||||
},
|
||||
"messages": {
|
||||
"ownerUpdated": "Propriétaire de l’appareil mis à jour",
|
||||
"revoked": "Appareil révoqué"
|
||||
"revoked": "Appareil révoqué",
|
||||
"deleted": "Appareil supprimé"
|
||||
},
|
||||
"errors": {
|
||||
"create": "Échec de la création de l’appareil",
|
||||
"reassign": "Échec de la réattribution de l’appareil",
|
||||
"revoke": "Échec de la révocation de l’appareil"
|
||||
"revoke": "Échec de la révocation de l’appareil",
|
||||
"delete": "Échec de la suppression de l’appareil"
|
||||
},
|
||||
"confirmations": {
|
||||
"revoke": "Révoquer l’appareil « {{hostname}} » ? L’agent sur l’appareil arrêtera de transmettre des données et devra être réenrôlé."
|
||||
"revoke": "Révoquer l’appareil « {{hostname}} » ? L’agent sur l’appareil arrêtera de transmettre des données et devra être réenrôlé.",
|
||||
"delete": "Supprimer l’appareil « {{hostname}} » ? Il sera retiré de l’inventaire."
|
||||
},
|
||||
"postures": {
|
||||
"currentTitle": "Postures actuelles",
|
||||
|
||||
@@ -49,7 +49,6 @@ export const trackerPatternDetectedTrackersSectionFragment = graphql`
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "TrackerPatternDetectedTrackersSection_detectedTrackers", filters: ["orderBy"]) {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
// SOFTWARE.
|
||||
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { Breadcrumb, Button, PageHeader } from "@probo/ui";
|
||||
import { Breadcrumb, Button, IconEject, IconTrashCan, PageHeader } from "@probo/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { Outlet } from "react-router";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DeviceLayoutQuery } from "#/__generated__/core/DeviceLayoutQuery.graphql";
|
||||
@@ -33,7 +33,8 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { DeviceCurrentPostures } from "./_components/DeviceCurrentPostures";
|
||||
import { DeviceDetailsCard } from "./_components/DeviceDetailsCard";
|
||||
import { displayValue } from "./_lib/deviceDisplay";
|
||||
import { displayValue, isDeviceDeletable } from "./_lib/deviceDisplay";
|
||||
import { useDeleteDevice } from "./_lib/useDeleteDevice";
|
||||
import { useRevokeDevice } from "./_lib/useRevokeDevice";
|
||||
|
||||
export const deviceLayoutQuery = graphql`
|
||||
@@ -52,6 +53,7 @@ export const deviceLayoutQuery = graphql`
|
||||
__typename
|
||||
... on Organization {
|
||||
canRevokeDevice: permission(action: "itam:device:revoke")
|
||||
canDeleteDevice: permission(action: "itam:device:delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +65,7 @@ interface DeviceLayoutProps {
|
||||
|
||||
export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const organizationId = useOrganizationId();
|
||||
const pendingLabel = t("devices.values.pending");
|
||||
|
||||
@@ -82,9 +85,16 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
|
||||
const hostnameLabel = displayValue(device.hostname, pendingLabel);
|
||||
|
||||
const [confirmRevoke, isRevoking] = useRevokeDevice();
|
||||
const [confirmDelete, isDeleting] = useDeleteDevice({
|
||||
organizationId,
|
||||
onDeleted: () => {
|
||||
void navigate(`/organizations/${organizationId}/devices`, { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const isRevoked = device.state === "REVOKED";
|
||||
const deletable = isDeviceDeletable(device.state);
|
||||
const canRevokeDevice = organization.canRevokeDevice ?? false;
|
||||
const canDeleteDevice = organization.canDeleteDevice ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
@@ -98,9 +108,10 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
|
||||
]}
|
||||
/>
|
||||
<PageHeader title={hostnameLabel}>
|
||||
{!isRevoked && canRevokeDevice && (
|
||||
{!deletable && canRevokeDevice && (
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconEject}
|
||||
onClick={() =>
|
||||
confirmRevoke({ id: device.id, hostname: device.hostname })}
|
||||
disabled={isRevoking}
|
||||
@@ -108,6 +119,17 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
|
||||
{t("devices.actions.revoke")}
|
||||
</Button>
|
||||
)}
|
||||
{deletable && canDeleteDevice && (
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={() =>
|
||||
confirmDelete({ id: device.id, hostname: device.hostname })}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("devices.actions.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<DeviceDetailsCard deviceFragmentRef={device} />
|
||||
|
||||
@@ -46,6 +46,7 @@ export const devicesPageQuery = graphql`
|
||||
id
|
||||
canAssignDevice: permission(action: "itam:device:assign")
|
||||
canRevokeDevice: permission(action: "itam:device:revoke")
|
||||
canDeleteDevice: permission(action: "itam:device:delete")
|
||||
canCreateDevice: permission(action: "itam:device:create")
|
||||
...DevicesPageFragment
|
||||
}
|
||||
@@ -72,7 +73,8 @@ const devicesPageFragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "DevicesPage_devices", filters: ["orderBy"]) {
|
||||
) @connection(key: "DevicesPage_devices", filters: []) {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -107,6 +109,7 @@ export function DevicesPage({ queryRef }: DevicesPageProps) {
|
||||
>(devicesPageFragment, organization);
|
||||
|
||||
const devices = pagination.data.devices.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.devices.__id;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -148,8 +151,10 @@ export function DevicesPage({ queryRef }: DevicesPageProps) {
|
||||
<DeviceRow
|
||||
key={device.id}
|
||||
fKey={device}
|
||||
connectionId={connectionId}
|
||||
canAssignDevice={organization.canAssignDevice ?? false}
|
||||
canRevoke={organization.canRevokeDevice ?? false}
|
||||
canDelete={organization.canDeleteDevice ?? false}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ActionDropdown,
|
||||
Badge,
|
||||
DropdownItem,
|
||||
IconEject,
|
||||
IconTrashCan,
|
||||
IconUser,
|
||||
Td,
|
||||
@@ -36,7 +37,8 @@ import { graphql } from "relay-runtime";
|
||||
import type { DeviceRowFragment$key } from "#/__generated__/core/DeviceRowFragment.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { displayValue, stateVariant } from "../_lib/deviceDisplay";
|
||||
import { displayValue, isDeviceDeletable, stateVariant } from "../_lib/deviceDisplay";
|
||||
import { useDeleteDevice } from "../_lib/useDeleteDevice";
|
||||
import { useRevokeDevice } from "../_lib/useRevokeDevice";
|
||||
import { ReassignDeviceDialog } from "../dialogs/ReassignDeviceDialog";
|
||||
|
||||
@@ -58,11 +60,19 @@ const deviceRowFragment = graphql`
|
||||
|
||||
interface DeviceRowProps {
|
||||
canAssignDevice: boolean;
|
||||
canDelete: boolean;
|
||||
canRevoke: boolean;
|
||||
connectionId: string;
|
||||
fKey: DeviceRowFragment$key;
|
||||
}
|
||||
|
||||
export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps) {
|
||||
export function DeviceRow({
|
||||
canAssignDevice,
|
||||
canDelete,
|
||||
canRevoke,
|
||||
connectionId,
|
||||
fKey,
|
||||
}: DeviceRowProps) {
|
||||
const { i18n, t } = useTranslation();
|
||||
const organizationId = useOrganizationId();
|
||||
const reassignDialogRef = useDialogRef();
|
||||
@@ -71,9 +81,16 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
|
||||
const device = useFragment(deviceRowFragment, fKey);
|
||||
|
||||
const [confirmRevoke, isRevoking] = useRevokeDevice();
|
||||
const [confirmDelete, isDeleting] = useDeleteDevice({
|
||||
organizationId,
|
||||
connectionId,
|
||||
});
|
||||
|
||||
const isRevoked = device.state === "REVOKED";
|
||||
const hasActions = !isRevoked && (canRevoke || canAssignDevice);
|
||||
const deletable = isDeviceDeletable(device.state);
|
||||
const showAssign = canAssignDevice && !deletable;
|
||||
const showRevoke = canRevoke && !deletable;
|
||||
const showDelete = canDelete && deletable;
|
||||
const hasActions = showAssign || showRevoke || showDelete;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -98,7 +115,7 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
|
||||
<Td noLink width={50} className="text-end">
|
||||
{hasActions && (
|
||||
<ActionDropdown>
|
||||
{canAssignDevice && (
|
||||
{showAssign && (
|
||||
<DropdownItem
|
||||
icon={IconUser}
|
||||
onClick={() => reassignDialogRef.current?.open()}
|
||||
@@ -106,17 +123,28 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
|
||||
{t("devices.actions.reassign")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{canRevoke && (
|
||||
{showRevoke && (
|
||||
<DropdownItem
|
||||
onClick={() =>
|
||||
confirmRevoke({ id: device.id, hostname: device.hostname })}
|
||||
disabled={isRevoking}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
icon={IconEject}
|
||||
>
|
||||
{t("devices.actions.revoke")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{showDelete && (
|
||||
<DropdownItem
|
||||
onClick={() =>
|
||||
confirmDelete({ id: device.id, hostname: device.hostname })}
|
||||
disabled={isDeleting}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{t("devices.actions.delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</Td>
|
||||
|
||||
@@ -48,8 +48,9 @@ export function PostureValueBadge({
|
||||
const { t } = useTranslation();
|
||||
const posture = useFragment(postureFragment, postureFragmentRef);
|
||||
|
||||
const label = postureValueLabel(t, posture.value);
|
||||
const variant = postureValueVariant(posture.value.kind, posture.checkKey);
|
||||
const { kind, text, number } = posture.value;
|
||||
const label = postureValueLabel(t, { kind, text, number });
|
||||
const variant = postureValueVariant(kind, posture.checkKey);
|
||||
|
||||
if (!variant) {
|
||||
return label;
|
||||
|
||||
@@ -38,6 +38,11 @@ export function stateVariant(
|
||||
}
|
||||
}
|
||||
|
||||
/** Soft-delete is only offered after the device has been revoked. */
|
||||
export function isDeviceDeletable(state: string): boolean {
|
||||
return state === "REVOKED";
|
||||
}
|
||||
|
||||
type Translator = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
/** What a posture check observed, as returned by the DevicePostureValue type. */
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useConfirm } from "@probo/ui";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ConnectionHandler,
|
||||
type DataID,
|
||||
graphql,
|
||||
} from "relay-runtime";
|
||||
|
||||
import type { useDeleteDeviceMutation } from "#/__generated__/core/useDeleteDeviceMutation.graphql";
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
import { displayValue } from "./deviceDisplay";
|
||||
|
||||
export const DevicesConnectionKey = "DevicesPage_devices";
|
||||
|
||||
const deleteDeviceMutation = graphql`
|
||||
mutation useDeleteDeviceMutation(
|
||||
$input: DeleteDeviceInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteDevice(input: $input) {
|
||||
# @deleteEdge only unlinks from list connections — do not use
|
||||
# @deleteRecord; DeviceLayout reads the node with @required(action: THROW).
|
||||
deletedDeviceId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface DeleteDeviceInput {
|
||||
id: string;
|
||||
hostname: string | null | undefined;
|
||||
}
|
||||
|
||||
interface DeleteDeviceOptions {
|
||||
organizationId: string;
|
||||
connectionId?: DataID;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
export function useDeleteDevice(options: DeleteDeviceOptions) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const pendingLabel = t("devices.values.pending");
|
||||
const organizationId = options.organizationId;
|
||||
const connectionId = options.connectionId;
|
||||
const onDeleted = options.onDeleted;
|
||||
|
||||
const [deleteDevice, isDeleting] = useMutation<useDeleteDeviceMutation>(
|
||||
deleteDeviceMutation,
|
||||
{
|
||||
successMessage: t("devices.messages.deleted"),
|
||||
errorToast: t("devices.errors.delete"),
|
||||
},
|
||||
);
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
(device: DeleteDeviceInput) => {
|
||||
const connections = [
|
||||
connectionId
|
||||
?? ConnectionHandler.getConnectionID(organizationId, DevicesConnectionKey),
|
||||
];
|
||||
|
||||
confirm(
|
||||
async () => {
|
||||
await deleteDevice({
|
||||
variables: {
|
||||
input: { deviceId: device.id },
|
||||
connections,
|
||||
},
|
||||
});
|
||||
onDeleted?.();
|
||||
},
|
||||
{
|
||||
message: t("devices.confirmations.delete", {
|
||||
hostname: displayValue(device.hostname, pendingLabel),
|
||||
}),
|
||||
variant: "danger",
|
||||
label: t("devices.actions.delete"),
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
t,
|
||||
confirm,
|
||||
pendingLabel,
|
||||
deleteDevice,
|
||||
organizationId,
|
||||
connectionId,
|
||||
onDeleted,
|
||||
],
|
||||
);
|
||||
|
||||
return [confirmDelete, isDeleting] as const;
|
||||
}
|
||||
@@ -18,14 +18,13 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useConfirm, useToast } from "@probo/ui";
|
||||
import { useConfirm } from "@probo/ui";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { useRevokeDeviceMutation } from "#/__generated__/core/useRevokeDeviceMutation.graphql";
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
import { displayValue } from "./deviceDisplay";
|
||||
|
||||
@@ -49,58 +48,34 @@ interface RevokeDeviceInput {
|
||||
|
||||
export function useRevokeDevice() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const pendingLabel = t("devices.values.pending");
|
||||
|
||||
const [revokeDevice, isRevoking] = useMutation<useRevokeDeviceMutation>(
|
||||
revokeDeviceMutation,
|
||||
{
|
||||
successMessage: t("devices.messages.revoked"),
|
||||
errorToast: t("devices.errors.revoke"),
|
||||
},
|
||||
);
|
||||
|
||||
const confirmRevoke = useCallback(
|
||||
(device: RevokeDeviceInput) => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
revokeDevice({
|
||||
variables: { input: { deviceId: device.id } },
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: t("common.error"),
|
||||
description: errors[0].message,
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("common.success"),
|
||||
description: t("devices.messages.revoked"),
|
||||
variant: "success",
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: t("common.error"),
|
||||
description: formatError(
|
||||
t("devices.errors.revoke"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
revokeDevice({
|
||||
variables: { input: { deviceId: device.id } },
|
||||
}),
|
||||
{
|
||||
message: t("devices.confirmations.revoke", { hostname: displayValue(device.hostname, pendingLabel) }),
|
||||
message: t("devices.confirmations.revoke", {
|
||||
hostname: displayValue(device.hostname, pendingLabel),
|
||||
}),
|
||||
variant: "danger",
|
||||
label: t("devices.actions.revoke"),
|
||||
},
|
||||
);
|
||||
},
|
||||
[t, confirm, pendingLabel, revokeDevice, toast],
|
||||
[t, confirm, pendingLabel, revokeDevice],
|
||||
);
|
||||
|
||||
return [confirmRevoke, isRevoking] as const;
|
||||
|
||||
@@ -45,7 +45,6 @@ const deviceFragment = graphql`
|
||||
) @connection(key: "DevicePostureReportListFragment_postureReports") {
|
||||
edges {
|
||||
node {
|
||||
createdAt
|
||||
id
|
||||
...DevicePostureReportListItemFragment
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user