> ## Documentation Index
> Fetch the complete documentation index at: https://docs.corbado.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Passkey Management

> Web account settings for listing, adding and revoking server-registered passkeys.

export const ToolingFrame = ({imageURL, caption, imageName, containerBackground = 'rgb(249,250,252,1)'}) => {
  React.useEffect(() => {
    const style = document.createElement('style');
    style.textContent = `
            .drag-hint {
                bottom: 20px;
            }
            .caption-text {
                font-size: 16px;
            }
            @media (max-width: 768px) {
                .drag-hint {
                    bottom: 80px !important;
                }
                .caption-text {
                    font-size: 12px !important;
                }
                .caption-container {
                    padding: 6px !important;
                }
            }
        `;
    document.head.appendChild(style);
    return () => document.head.removeChild(style);
  }, []);
  const [showLightbox, setShowLightbox] = React.useState(false);
  const [viewportPadding, setViewportPadding] = React.useState(100);
  const imgRef = React.useRef(null);
  const containerRef = React.useRef(null);
  const imageTransformRef = React.useRef(null);
  const scaleDisplayRef = React.useRef(null);
  const dragHintRef = React.useRef(null);
  const zoomInBtnRef = React.useRef(null);
  const zoomOutBtnRef = React.useRef(null);
  const resetBtnRef = React.useRef(null);
  const stateRef = React.useRef({
    isDragging: false,
    isZooming: false,
    position: {
      x: 0,
      y: 0
    },
    dragStart: {
      x: 0,
      y: 0
    },
    initialDistance: 0,
    imageDimensions: {
      width: 0,
      height: 0
    },
    imageLoaded: false,
    naturalDimensions: {
      width: 0,
      height: 0
    },
    initialDimensions: {
      width: 0,
      height: 0
    },
    gestureBaseWidth: 0
  });
  const maxScale = 3;
  const zoomStep = 0.2;
  const getViewportPadding = React.useCallback(() => {
    const width = typeof window !== 'undefined' ? window.innerWidth : 1200;
    if (width < 640) return 20;
    if (width < 1024) return 40;
    return 100;
  }, []);
  React.useEffect(() => {
    const updatePadding = () => setViewportPadding(getViewportPadding());
    updatePadding();
    window.addEventListener('resize', updatePadding);
    return () => window.removeEventListener('resize', updatePadding);
  }, [getViewportPadding]);
  const updateTransform = () => {
    if (imageTransformRef.current) {
      const {position, imageDimensions} = stateRef.current;
      imageTransformRef.current.style.transform = `translate(${position.x}px, ${position.y}px)`;
      const img = imageTransformRef.current.querySelector('img');
      if (img && imageDimensions.width) {
        img.style.width = `${imageDimensions.width}px`;
        img.style.height = 'auto';
        void img.offsetHeight;
        const actualHeight = img.offsetHeight;
        if (actualHeight && actualHeight !== imageDimensions.height) {
          stateRef.current.imageDimensions.height = actualHeight;
        }
      }
    }
    if (scaleDisplayRef.current) {
      const {imageDimensions, initialDimensions} = stateRef.current;
      if (initialDimensions.width) {
        const relativeScale = imageDimensions.width / initialDimensions.width * 100;
        scaleDisplayRef.current.textContent = `${Math.round(relativeScale)}%`;
      }
    }
    updateButtonStates();
    updateDragHint();
  };
  const updateButtonStates = () => {
    const {imageDimensions, position, initialDimensions} = stateRef.current;
    if (zoomInBtnRef.current && initialDimensions.width) {
      const absoluteMaxWidth = initialDimensions.width * maxScale;
      const isDisabled = imageDimensions.width >= absoluteMaxWidth;
      zoomInBtnRef.current.style.opacity = isDisabled ? '0.5' : '1';
      zoomInBtnRef.current.style.cursor = isDisabled ? 'not-allowed' : 'pointer';
    }
    if (zoomOutBtnRef.current && initialDimensions.width) {
      const isDisabled = imageDimensions.width <= initialDimensions.width;
      zoomOutBtnRef.current.style.opacity = isDisabled ? '0.5' : '1';
      zoomOutBtnRef.current.style.cursor = isDisabled ? 'not-allowed' : 'pointer';
    }
    if (resetBtnRef.current && initialDimensions.width) {
      const isDisabled = imageDimensions.width === initialDimensions.width && position.x === 0 && position.y === 0;
      resetBtnRef.current.style.opacity = isDisabled ? '0.5' : '1';
      resetBtnRef.current.style.cursor = isDisabled ? 'not-allowed' : 'pointer';
    }
  };
  const updateDragHint = () => {
    if (dragHintRef.current && containerRef.current) {
      const {imageDimensions} = stateRef.current;
      const imgWidth = imageDimensions.width;
      const imgHeight = imageDimensions.height;
      const containerWidth = containerRef.current.offsetWidth;
      const containerHeight = containerRef.current.offsetHeight;
      const shouldShow = imgWidth > containerWidth || imgHeight > containerHeight;
      dragHintRef.current.style.display = shouldShow ? 'block' : 'none';
    }
  };
  const updateContainerCursor = () => {
    if (!containerRef.current) return;
    const {isDragging, imageLoaded, imageDimensions} = stateRef.current;
    if (!imageLoaded) {
      containerRef.current.style.cursor = 'wait';
      return;
    }
    if (isDragging) {
      containerRef.current.style.cursor = 'grabbing';
      return;
    }
    const imgWidth = imageDimensions.width;
    const imgHeight = imageDimensions.height;
    const containerWidth = containerRef.current.offsetWidth;
    const containerHeight = containerRef.current.offsetHeight;
    const isOverflowing = imgWidth > containerWidth || imgHeight > containerHeight;
    containerRef.current.style.cursor = isOverflowing ? 'grab' : 'default';
  };
  const handleDownload = async e => {
    e.preventDefault();
    e.stopPropagation();
    try {
      const response = await fetch(imageURL);
      const blob = await response.blob();
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = imageName || 'image.webp';
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (error) {
      console.error('Download failed:', error);
    }
  };
  const handleImageClick = e => {
    e.preventDefault();
    e.stopPropagation();
    setShowLightbox(true);
  };
  const closeLightbox = () => {
    setShowLightbox(false);
    stateRef.current = {
      isDragging: false,
      isZooming: false,
      position: {
        x: 0,
        y: 0
      },
      dragStart: {
        x: 0,
        y: 0
      },
      initialDistance: 0,
      imageDimensions: {
        width: 0,
        height: 0
      },
      imageLoaded: false,
      naturalDimensions: {
        width: 0,
        height: 0
      },
      initialDimensions: {
        width: 0,
        height: 0
      },
      gestureBaseWidth: 0
    };
  };
  const handleMouseDown = e => {
    const state = stateRef.current;
    if (!containerRef.current || !state.imageDimensions.width || !state.imageDimensions.height) return;
    const containerWidth = containerRef.current.offsetWidth;
    const containerHeight = containerRef.current.offsetHeight;
    const imgWidth = state.imageDimensions.width;
    const imgHeight = state.imageDimensions.height;
    const isOverflowing = imgWidth - containerWidth >= 1 || imgHeight - containerHeight >= 1;
    if (!isOverflowing) return;
    state.isDragging = true;
    state.dragStart = {
      x: e.clientX - state.position.x,
      y: e.clientY - state.position.y
    };
    updateContainerCursor();
  };
  const handleMouseMove = e => {
    const state = stateRef.current;
    if (!state.isDragging || !containerRef.current) return;
    let newX = e.clientX - state.dragStart.x;
    let newY = e.clientY - state.dragStart.y;
    if (state.imageDimensions.width && state.imageDimensions.height) {
      const containerWidth = containerRef.current.offsetWidth;
      const containerHeight = containerRef.current.offsetHeight;
      const imgWidth = state.imageDimensions.width;
      const imgHeight = state.imageDimensions.height;
      const isWider = imgWidth - containerWidth >= 1;
      const isTaller = imgHeight - containerHeight >= 1;
      if (isWider) {
        const halfOverflowX = (imgWidth - containerWidth) / 2;
        newX = Math.min(Math.max(newX, -halfOverflowX), halfOverflowX);
      } else {
        newX = 0;
      }
      if (isTaller) {
        const halfOverflowY = (imgHeight - containerHeight) / 2;
        newY = Math.min(Math.max(newY, -halfOverflowY), halfOverflowY);
      } else {
        newY = 0;
      }
    }
    state.position = {
      x: newX,
      y: newY
    };
    updateTransform();
  };
  const handleMouseUp = () => {
    stateRef.current.isDragging = false;
    updateContainerCursor();
  };
  const handleZoom = (newWidth, centerX, centerY) => {
    const state = stateRef.current;
    if (!containerRef.current || !state.imageDimensions.width || !state.imageDimensions.height) return;
    if (!state.initialDimensions.width || !state.initialDimensions.height) return;
    if (!state.naturalDimensions.width || !state.naturalDimensions.height) return;
    const absoluteMaxWidth = state.initialDimensions.width * maxScale;
    const boundedWidth = Math.max(state.initialDimensions.width, Math.min(absoluteMaxWidth, newWidth));
    const aspectRatio = state.naturalDimensions.height / state.naturalDimensions.width;
    const boundedHeight = boundedWidth * aspectRatio;
    let newX = state.position.x;
    let newY = state.position.y;
    if (centerX !== undefined && centerY !== undefined) {
      const rect = containerRef.current.getBoundingClientRect();
      const offsetX = centerX - rect.left - rect.width / 2;
      const offsetY = centerY - rect.top - rect.height / 2;
      const sizeRatio = boundedWidth / state.imageDimensions.width;
      newX = state.position.x - offsetX * (sizeRatio - 1);
      newY = state.position.y - offsetY * (sizeRatio - 1);
    }
    const containerWidth = containerRef.current.offsetWidth;
    const containerHeight = containerRef.current.offsetHeight;
    const isWiderThanContainer = boundedWidth - containerWidth >= 1;
    const isTallerThanContainer = boundedHeight - containerHeight >= 1;
    if (isWiderThanContainer) {
      const maxX = (boundedWidth - containerWidth) / 2;
      const minX = -maxX;
      newX = Math.min(Math.max(newX, minX), maxX);
    } else {
      newX = 0;
    }
    if (isTallerThanContainer) {
      const maxY = (boundedHeight - containerHeight) / 2;
      const minY = -maxY;
      newY = Math.min(Math.max(newY, minY), maxY);
    } else {
      newY = 0;
    }
    state.position = {
      x: newX,
      y: newY
    };
    state.imageDimensions = {
      width: boundedWidth,
      height: boundedHeight
    };
    updateTransform();
    updateContainerCursor();
  };
  const zoomIn = () => {
    const state = stateRef.current;
    if (!state.initialDimensions.width) return;
    const absoluteMaxWidth = state.initialDimensions.width * maxScale;
    if (state.imageDimensions.width >= absoluteMaxWidth) return;
    const relativeZoomStep = state.initialDimensions.width * zoomStep;
    handleZoom(state.imageDimensions.width + relativeZoomStep);
  };
  const zoomOut = () => {
    const state = stateRef.current;
    if (!state.initialDimensions.width) return;
    if (state.imageDimensions.width <= state.initialDimensions.width) return;
    const relativeZoomStep = state.initialDimensions.width * zoomStep;
    handleZoom(state.imageDimensions.width - relativeZoomStep);
  };
  const resetTransform = () => {
    const state = stateRef.current;
    if (!state.initialDimensions.width || !state.initialDimensions.height) return;
    if (state.imageDimensions.width === state.initialDimensions.width && state.position.x === 0 && state.position.y === 0) return;
    state.imageDimensions = {
      ...state.initialDimensions
    };
    state.position = {
      x: 0,
      y: 0
    };
    updateTransform();
    updateContainerCursor();
  };
  const handleWheel = e => {
    e.preventDefault();
    e.stopPropagation();
    const state = stateRef.current;
    if (!state.imageDimensions.width) return;
    if (e.ctrlKey) {
      const zoomFactor = 1 - e.deltaY * 0.01;
      const newWidth = state.imageDimensions.width * zoomFactor;
      handleZoom(newWidth, e.clientX, e.clientY);
      return;
    }
    const delta = -e.deltaY * 0.001;
    const sizeChange = state.imageDimensions.width * delta;
    const newWidth = state.imageDimensions.width + sizeChange;
    handleZoom(newWidth, e.clientX, e.clientY);
  };
  const getTouchDistance = touches => {
    const dx = touches[0].clientX - touches[1].clientX;
    const dy = touches[0].clientY - touches[1].clientY;
    return Math.sqrt(dx * dx + dy * dy);
  };
  const handleTouchStart = e => {
    const state = stateRef.current;
    if (e.touches.length === 2) {
      e.preventDefault();
      state.isZooming = true;
      state.initialDistance = getTouchDistance(e.touches);
    } else if (e.touches.length === 1) {
      if (!containerRef.current || !state.imageDimensions.width || !state.imageDimensions.height) return;
      const containerWidth = containerRef.current.offsetWidth;
      const containerHeight = containerRef.current.offsetHeight;
      const imgWidth = state.imageDimensions.width;
      const imgHeight = state.imageDimensions.height;
      const isOverflowing = imgWidth - containerWidth >= 1 || imgHeight - containerHeight >= 1;
      if (!isOverflowing) return;
      state.isDragging = true;
      state.dragStart = {
        x: e.touches[0].clientX - state.position.x,
        y: e.touches[0].clientY - state.position.y
      };
      updateContainerCursor();
    }
  };
  const handleTouchMove = e => {
    const state = stateRef.current;
    if (e.touches.length === 2 && state.isZooming) {
      e.preventDefault();
      const currentDistance = getTouchDistance(e.touches);
      const sizeDelta = currentDistance / state.initialDistance;
      const newWidth = state.imageDimensions.width * sizeDelta;
      const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
      const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
      handleZoom(newWidth, centerX, centerY);
      state.initialDistance = currentDistance;
    } else if (e.touches.length === 1 && state.isDragging && containerRef.current) {
      let newX = e.touches[0].clientX - state.dragStart.x;
      let newY = e.touches[0].clientY - state.dragStart.y;
      if (state.imageDimensions.width && state.imageDimensions.height) {
        const containerWidth = containerRef.current.offsetWidth;
        const containerHeight = containerRef.current.offsetHeight;
        const imgWidth = state.imageDimensions.width;
        const imgHeight = state.imageDimensions.height;
        const isWider = imgWidth - containerWidth >= 1;
        const isTaller = imgHeight - containerHeight >= 1;
        if (isWider) {
          const halfOverflowX = (imgWidth - containerWidth) / 2;
          newX = Math.min(Math.max(newX, -halfOverflowX), halfOverflowX);
        } else {
          newX = 0;
        }
        if (isTaller) {
          const halfOverflowY = (imgHeight - containerHeight) / 2;
          newY = Math.min(Math.max(newY, -halfOverflowY), halfOverflowY);
        } else {
          newY = 0;
        }
      }
      state.position = {
        x: newX,
        y: newY
      };
      updateTransform();
    }
  };
  const handleTouchEnd = e => {
    const state = stateRef.current;
    if (e.touches.length < 2) {
      state.isZooming = false;
    }
    if (e.touches.length === 0) {
      state.isDragging = false;
      updateContainerCursor();
    }
  };
  React.useEffect(() => {
    const handleEscape = e => {
      if (e.key === 'Escape' && showLightbox) {
        closeLightbox();
      }
    };
    const preventScroll = e => {
      e.preventDefault();
      e.stopPropagation();
      return false;
    };
    if (showLightbox) {
      const originalOverflow = document.body.style.overflow;
      const originalPosition = document.body.style.position;
      const scrollY = window.scrollY;
      document.body.style.overflow = 'hidden';
      document.body.style.position = 'fixed';
      document.body.style.top = `-${scrollY}px`;
      document.body.style.width = '100%';
      window.addEventListener('wheel', preventScroll, {
        passive: false
      });
      window.addEventListener('touchmove', preventScroll, {
        passive: false
      });
      window.addEventListener('scroll', preventScroll, {
        passive: false
      });
      document.addEventListener('keydown', handleEscape);
      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', handleMouseUp);
      return () => {
        document.body.style.overflow = originalOverflow;
        document.body.style.position = originalPosition;
        document.body.style.top = '';
        document.body.style.width = '';
        window.scrollTo(0, scrollY);
        window.removeEventListener('wheel', preventScroll);
        window.removeEventListener('touchmove', preventScroll);
        window.removeEventListener('scroll', preventScroll);
        document.removeEventListener('keydown', handleEscape);
        document.removeEventListener('mousemove', handleMouseMove);
        document.removeEventListener('mouseup', handleMouseUp);
      };
    }
  }, [showLightbox]);
  React.useEffect(() => {
    if (!showLightbox || !containerRef.current) return;
    const el = containerRef.current;
    const state = stateRef.current;
    const onGestureStart = e => {
      e.preventDefault();
      e.stopPropagation();
      state.isZooming = true;
      state.gestureBaseWidth = state.imageDimensions.width || state.initialDimensions.width || 0;
    };
    const onGestureChange = e => {
      e.preventDefault();
      e.stopPropagation();
      if (!state.gestureBaseWidth) return;
      const newWidth = state.gestureBaseWidth * e.scale;
      const cx = e.clientX ?? el.getBoundingClientRect().left + el.getBoundingClientRect().width / 2;
      const cy = e.clientY ?? el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2;
      handleZoom(newWidth, cx, cy);
    };
    const onGestureEnd = e => {
      e.preventDefault();
      e.stopPropagation();
      state.isZooming = false;
      state.gestureBaseWidth = 0;
    };
    const onWheelCapture = e => {
      if (e.ctrlKey) {
        e.preventDefault();
        e.stopPropagation();
        const zoomFactor = 1 - e.deltaY * 0.01;
        const newWidth = state.imageDimensions.width * zoomFactor;
        handleZoom(newWidth, e.clientX, e.clientY);
      }
    };
    el.addEventListener('gesturestart', onGestureStart, {
      passive: false
    });
    el.addEventListener('gesturechange', onGestureChange, {
      passive: false
    });
    el.addEventListener('gestureend', onGestureEnd, {
      passive: false
    });
    el.addEventListener('wheel', onWheelCapture, {
      passive: false
    });
    return () => {
      el.removeEventListener('gesturestart', onGestureStart);
      el.removeEventListener('gesturechange', onGestureChange);
      el.removeEventListener('gestureend', onGestureEnd);
      el.removeEventListener('wheel', onWheelCapture);
    };
  }, [showLightbox]);
  return <>
            <div className="flex flex-col">
                <div onClick={e => {
    e.preventDefault();
    e.stopPropagation();
  }}>
                    <Frame caption={caption}>
                        <div onClick={handleImageClick} onKeyDown={e => {
    if (e.key === 'Enter' || e.key === ' ') {
      handleImageClick(e);
    }
  }} role="button" tabIndex={0} aria-label={`Enlarge diagram: ${caption}`} className="cursor-zoom-in" style={{
    display: 'block',
    position: 'relative'
  }}>
                            <img ref={imgRef} src={imageURL} alt={caption || imageName} className="w-full h-auto" style={{
    pointerEvents: 'none'
  }} />
                        </div>
                    </Frame>
                </div>
                <div className="flex justify-end my-2">
                    <Tooltip tip="Download image">
                        <button type="button" aria-label={`Download ${caption}`} onClick={handleDownload} className="bg-white text-black text-xs px-3 py-2 rounded-md border border-gray-300 hover:bg-gray-100 transition-all cursor-pointer">
                            <Icon icon="arrow-down-to-line" iconType="regular" color="black" /> 
                        </button>
                    </Tooltip>
                </div>
            </div>

            {showLightbox && <div role="dialog" aria-modal="true" aria-label={caption} style={{
    position: 'fixed',
    top: 0,
    left: 0,
    width: '100%',
    height: '100%',
    backgroundColor: 'rgba(0, 0, 0, 0.95)',
    zIndex: 999999,
    display: 'flex',
    alignItems: 'center',
    cursor: 'default',
    justifyContent: 'center',
    padding: `${Math.round(viewportPadding / 2)}px`,
    touchAction: 'none'
  }} onWheel={e => {
    e.preventDefault();
    e.stopPropagation();
  }}>
                    <div ref={containerRef} style={{
    position: 'relative',
    width: '100%',
    height: '100%',
    minWidth: `calc(100vw - ${viewportPadding}px)`,
    minHeight: `calc(100vh - ${viewportPadding}px)`,
    maxWidth: '100%',
    maxHeight: '100%',
    overflow: 'hidden',
    cursor: 'wait',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    borderRadius: '15px',
    border: '2px solid rgba(255, 255, 255, 0.3)',
    touchAction: 'none',
    backgroundColor: containerBackground,
    userSelect: 'none',
    WebkitUserSelect: 'none',
    MozUserSelect: 'none',
    msUserSelect: 'none',
    WebkitTouchCallout: 'none',
    WebkitTapHighlightColor: 'transparent'
  }} onMouseDown={handleMouseDown} onWheel={handleWheel} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}>
                        <div className="caption-container" style={{
    position: 'absolute',
    left: '20px',
    top: '20px',
    zIndex: 1000000,
    backgroundColor: 'rgba(36, 36, 36, 0.76)',
    padding: '10px',
    borderRadius: '15px',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    pointerEvents: 'none'
  }}>
                            <p className="caption-text" style={{
    fontSize: '16px',
    fontWeight: 'bold',
    color: 'white',
    marginLeft: '10px'
  }}>
                                {caption}
                            </p>
                        </div>
                        <button type="button" aria-label="Close enlarged view" onClick={closeLightbox} onMouseDown={e => e.stopPropagation()} style={{
    position: 'absolute',
    top: '20px',
    right: '20px',
    background: 'rgba(36, 36, 36, 0.76)',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    color: 'white',
    fontSize: '24px',
    width: '40px',
    height: '40px',
    borderRadius: '50%',
    cursor: 'pointer',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    transition: 'background 0.2s',
    zIndex: 1000000
  }} onMouseEnter={e => {
    e.target.style.background = 'rgba(36, 36, 36, 0.9)';
  }} onMouseLeave={e => {
    e.target.style.background = 'rgba(36, 36, 36, 0.76)';
  }}>
                            ×
                        </button>
                        <div style={{
    position: 'absolute',
    bottom: '20px',
    left: '20px',
    display: 'flex',
    gap: '10px',
    zIndex: 1000000
  }}>
                            <button type="button" aria-label="Zoom in" ref={zoomInBtnRef} onClick={e => {
    e.stopPropagation();
    zoomIn();
  }} onMouseDown={e => e.stopPropagation()} style={{
    background: 'rgba(36, 36, 36, 0.76)',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    color: 'white',
    fontSize: '20px',
    width: '40px',
    height: '40px',
    borderRadius: '8px',
    cursor: 'pointer',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    transition: 'background 0.2s',
    opacity: 1
  }} onMouseEnter={e => {
    if (!e.target.disabled) e.target.style.background = 'rgba(36, 36, 36, 0.9)';
  }} onMouseLeave={e => {
    e.target.style.background = 'rgba(36, 36, 36, 0.76)';
  }}>
                                +
                            </button>
                            <button type="button" aria-label="Zoom out" ref={zoomOutBtnRef} onClick={e => {
    e.stopPropagation();
    zoomOut();
  }} onMouseDown={e => e.stopPropagation()} style={{
    background: 'rgba(36, 36, 36, 0.76)',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    color: 'white',
    fontSize: '20px',
    width: '40px',
    height: '40px',
    borderRadius: '8px',
    cursor: 'not-allowed',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    transition: 'background 0.2s',
    opacity: 0.5,
    pointerEvents: 'auto'
  }} onMouseEnter={e => {
    if (e.target.style.opacity !== '0.5') {
      e.target.style.background = 'rgba(36, 36, 36, 0.9)';
    }
  }} onMouseLeave={e => {
    e.target.style.background = 'rgba(36, 36, 36, 0.76)';
  }}>
                                −
                            </button>
                            <button type="button" aria-label="Reset zoom" ref={resetBtnRef} onClick={e => {
    e.stopPropagation();
    resetTransform();
  }} onMouseDown={e => e.stopPropagation()} style={{
    background: 'rgba(36, 36, 36, 0.76)',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    color: 'white',
    fontSize: '16px',
    width: '40px',
    height: '40px',
    borderRadius: '8px',
    cursor: 'not-allowed',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    transition: 'background 0.2s',
    opacity: 0.5,
    pointerEvents: 'auto'
  }} onMouseEnter={e => {
    if (e.target.style.opacity !== '0.5') {
      e.target.style.background = 'rgba(36, 36, 36, 0.9)';
    }
  }} onMouseLeave={e => {
    e.target.style.background = 'rgba(36, 36, 36, 0.76)';
  }}>
                                ⟲
                            </button>
                            <div ref={scaleDisplayRef} style={{
    background: 'rgba(36, 36, 36, 0.76)',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    color: 'white',
    fontSize: '14px',
    padding: '0 12px',
    height: '40px',
    borderRadius: '8px',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    minWidth: '60px',
    pointerEvents: 'none'
  }}>
                                100%
                            </div>
                        </div>
                        <div ref={dragHintRef} className="drag-hint" style={{
    display: 'none',
    position: 'absolute',
    right: '20px',
    bottom: '20px',
    zIndex: 1000000,
    backgroundColor: 'rgba(36, 36, 36, 0.76)',
    padding: '10px 15px',
    borderRadius: '15px',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    pointerEvents: 'none'
  }}>
                            <div style={{
    display: 'flex',
    alignItems: 'center',
    gap: '8px'
  }}>
                                <Icon icon="hand" iconType="regular" color="white" />
                                <p style={{
    fontSize: '12px',
    color: 'white',
    margin: 0
  }}>Drag to move image</p>
                            </div>
                        </div>
                        <div ref={imageTransformRef} style={{
    position: 'relative',
    transform: 'translate(0px, 0px)',
    transition: 'none',
    willChange: 'transform',
    flexShrink: 0,
    minWidth: 0,
    minHeight: 0
  }}>
                            <img src={imageURL} alt={caption || imageName} onLoad={e => {
    const state = stateRef.current;
    const naturalWidth = e.target.naturalWidth;
    const naturalHeight = e.target.naturalHeight;
    if (!containerRef.current) return;
    const containerWidth = containerRef.current.offsetWidth;
    const containerHeight = containerRef.current.offsetHeight;
    const scaleX = containerWidth / naturalWidth;
    const scaleY = containerHeight / naturalHeight;
    const initialScale = Math.min(scaleX, scaleY, 1);
    const initialWidth = naturalWidth * initialScale;
    const initialHeight = naturalHeight * initialScale;
    state.naturalDimensions = {
      width: naturalWidth,
      height: naturalHeight
    };
    state.initialDimensions = {
      width: initialWidth,
      height: initialHeight
    };
    state.imageDimensions = {
      width: initialWidth,
      height: initialHeight
    };
    setTimeout(() => {
      state.imageLoaded = true;
      updateTransform();
      updateContainerCursor();
      updateDragHint();
      if (e.target) {
        e.target.style.opacity = '1';
      }
    }, 100);
  }} style={{
    display: 'block',
    width: 'auto',
    height: 'auto',
    maxWidth: 'none',
    maxHeight: 'none',
    opacity: 0,
    transition: 'opacity 0.2s ease-in-out',
    pointerEvents: 'none',
    userSelect: 'none',
    WebkitUserSelect: 'none',
    MozUserSelect: 'none',
    msUserSelect: 'none',
    WebkitTouchCallout: 'none',
    WebkitTapHighlightColor: 'transparent',
    WebkitDrag: 'none',
    WebkitUserDrag: 'none',
    KhtmlUserSelect: 'none'
  }} draggable="false" />
                        </div>
                    </div>
                </div>}
        </>;
};

## Create, manage or delete Passkeys via Passkey List Component

Passkey management lets users view and revoke passkeys registered to their account. The server-side list shows which credentials the relying party accepts. Provider-side copies may remain until the credential provider acts on best-effort reconciliation.

### General Passkey Management

<ToolingFrame imageURL="/images/authentication-flow/web-app/desktop/general-passkey-management-macos.webp" caption="Representative web passkey management flow shown on macOS" imageName="general-passkey-management-macos.webp" />

<Steps>
  <Step title="General Passkey Settings in Profile Section">
    * Users can see all the passkeys created for the current account here.
    * Each entry shows the authenticator or passkey-provider name resolved from the AAGUID stored during registration. If no trusted mapping exists, the entry uses a neutral name instead of guessing.
    * Users can initiate passkey registration in the current browser through its active credential provider.
    * Users can revoke an existing credential and, where supported, edit an RP-side friendly label. The cryptographic credential itself is not updated.
  </Step>

  <Step title="When no Passkey is created for the User Account">
    * When the user has no passkey created, the passkey list is shown empty.
  </Step>

  <Step title="Passkey Delete Modal">
    * Provide a clearly named revoke or remove action associated with the selected passkey.
    * This action is not reversible, so the user is again asked for
      confirmation on deletion of passkey.
    * Revoke the credential in server state so every later assertion from it is rejected. Then send best-effort [Signal API](/passkey-ui-flows/web/signal-api) reconciliation so the credential manager can hide or remove its stale client-side copy.
    * User has the possibility to create a new one.
  </Step>
</Steps>

<Warning>
  **Server revocation completes the security action, but not the credential-manager cleanup.** The stale passkey may keep appearing in [Conditional UI](/passkey-ui-flows/web/passkey-login/conditional-ui) and the account chooser until the credential manager acts on a signal. The user can select the stale passkey and complete device authorization, but the server rejects the assertion and login fails. This can make the settings screen appear broken. Call `signalAllAcceptedCredentials` from the revoke action, but treat signalling as best-effort because providers may hide, remove or ignore the credential. See the [WebAuthn Signal API](/passkey-ui-flows/web/signal-api).
</Warning>

### Passkey List Error States

<ToolingFrame imageURL="/images/authentication-flow/web-app/desktop/passkey-list-errors-macos.webp" caption="Representative web passkey list error states shown on macOS" imageName="passkey-list-errors-macos.webp" />

<Steps>
  <Step title="Passkey List Loading Error">
    * The list of passkeys fails to load (e.g., due to network or system
      error).
  </Step>

  <Step title="User aborts Passkey Creation">
    * User cancels creation during system prompts or internal dialogs. No
      new passkey is added in the list.
  </Step>

  <Step title="Passkey Creation failed due to API Errors">
    * User experiences errors from backend/API during passkey creation. The
      error is communicated clearly.
  </Step>

  <Step title="Passkey Deletion fails">
    * Deletion of the passkey returns an error due to system or API failure.
  </Step>

  <Step title="Passkey already exists">
    * Attempting to register a passkey that is already present prompts an
      informative error.
  </Step>

  <Step title="No Passkey Support">
    * When the client cannot create a passkey, omit or disable only the add action with an accurate explanation. Keep the server-side list and revoke actions available.
  </Step>
</Steps>

## Acceptance criteria

| Benchmark as | User reach |
| ------------ | ---------- |
| Baseline     | Low        |

Low user reach does not mean low importance. Passkey management is a baseline lifecycle and security capability: it gives users control over registered credentials and protects account continuity when a credential is lost or no longer trusted.

| ID          | Level   | Acceptance criterion                                                                                                                                                                                                                                                                    | How to verify                                                                                                                                                                                                       |
| ----------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `W3.1-AC01` | Core    | The passkey list contains every credential the server accepts for the current account.                                                                                                                                                                                                  | Compare the rendered list with server records for accounts with zero, one and several credentials, including a credential created on another client.                                                                |
| `W3.1-AC02` | Core    | Revoking a selected credential changes only that server record and causes every later assertion from it to be rejected.                                                                                                                                                                 | Give one account several credentials, revoke one, then authenticate with each. The revoked credential fails and every other active credential still works.                                                          |
| `W3.1-AC03` | Core    | Confirmed revocation invalidates first-party One-Tap, last-used or availability hints that depend on the revoked credential.                                                                                                                                                            | Delete the hinted credential, then start the next login; no personalization or availability decision relies on that credential.                                                                                     |
| `W3.1-AC04` | Core    | Deletion copy describes server-side revocation without promising that a credential provider will remove the private key or its local entry.                                                                                                                                             | Review the confirmation and success copy; it may describe best-effort provider cleanup but does not claim that local removal occurred.                                                                              |
| `W3.1-AC05` | Core    | After a deletion attempt, the displayed list matches server state before presenting a final result or retry.                                                                                                                                                                            | Test confirmed deletion, confirmed non-deletion and response loss after a server update. The entry is removed, retained or resolved by reading the current server state.                                            |
| `W3.1-AC06` | Core    | Revoking one credential does not clear valid first-party login hints for unrelated credentials or accounts.                                                                                                                                                                             | Revoke the hinted credential while another credential and another account retain valid hints; only state that depends on the revoked credential is removed.                                                         |
| `W3.1-AC07` | Quality | Each entry shows a recognizable authenticator or passkey-provider name resolved from the credential's stored AAGUID when a trusted mapping is available. An unknown or generic AAGUID uses a neutral label instead of guessing the provider, physical device or exact storage location. | Register credentials with known AAGUIDs from different authenticators or passkey providers and one unknown AAGUID. Confirm that the known entries show the mapped names and the unknown entry uses a neutral label. |
| `W3.1-AC08` | Quality | After confirmed server deletion, a client that supports credential-list reconciliation receives the current complete accepted-credential list.                                                                                                                                          | Delete a credential on a supporting client and inspect the reconciliation call; its list matches the server state after deletion.                                                                                   |

### References

* **Relevant criteria:** **W3.1-AC04 and W3.1-AC07**: [Google passkey user journeys](https://developers.google.com/identity/passkeys/ux/user-journeys) supports distinguishable entries and accurate deletion language.
* **Relevant criterion:** **W3.1-AC07**: [FIDO Metadata Statement](https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html) defines AAGUIDs and human-readable authenticator or passkey-provider names intended for display to users.
* **Relevant criteria:** **W3.1-AC01, W3.1-AC04 and W3.1-AC07**: [FIDO Alliance: Build vs. Buy: A Guide to Deploying Passkey-Based Authentication](https://fidoalliance.org/wp-content/uploads/2023/07/Build-vs.-Buy-A-Guide-To-Deploying-Passkey-Based-Authentication.pdf) supports complete credential lists, useful metadata and server-side deletion.
* **Relevant criteria:** **W3.1-AC01–AC02**: [NIST SP 800-63B: Authenticator Event Management](https://pages.nist.gov/800-63-4/sp800-63b/events/) supports account-scoped authenticator records and targeted revocation.
* **Relevant criteria:** **W3.1-AC01–AC02**: [Web Authentication Level 3: Decommissioning](https://www.w3.org/TR/webauthn-3/#sctn-sample-decommissioning) supports server-side revocation and rejection of later assertions.
* **Relevant criteria:** **W3.1-AC04 and W3.1-AC08**: [Corbado: WebAuthn Signal API](https://www.corbado.com/blog/webauthn-signal-api) explains the separation between server-side revocation and optional credential-provider cleanup.
* **Relevant criteria:** **W3.1-AC03 and W3.1-AC05–AC06**: These criteria keep first-party passkey hints consistent with server revocation and resolve response loss without changing an unrelated credential or account.
