diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d70171c1..c326bfda 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,6 +16,10 @@ jobs: name: Build runs-on: ubuntu-latest + env: + VITE_ENABLE_TASKS: true + VITE_ENABLE_SSH_KEYS: true + steps: - name: Checkout uses: actions/checkout@v4 @@ -69,6 +73,8 @@ jobs: env: PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/pw-browsers + VITE_ENABLE_TASKS: true + VITE_ENABLE_SSH_KEYS: true steps: - name: Checkout diff --git a/docs/Development.md b/docs/Development.md index a327fcbe..ceb194cb 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -66,6 +66,25 @@ file_share_mounts: Instead of using the `file_share_mounts` setting, you can configure file share paths in the database. This is useful for production deployments where you want centralized management of file share paths. To use the paths in the database, set `file_share_mounts: []`. See [fileglancer-janelia](https://github.com/JaneliaSciComp/fileglancer-janelia) for an example of populating the file share paths in the database, using a private wiki source. +### Feature Flags + +Optional features can be enabled via Vite environment variables. Create or edit `frontend/.env`: + +```bash +# Enable background tasks/jobs feature +VITE_ENABLE_TASKS=true + +# Enable SSH key management feature +VITE_ENABLE_SSH_KEYS=true +``` + +After changing `.env`, rebuild the frontend and restart the dev server: + +```bash +pixi run node-build +pixi run dev-launch +``` + ### Running with SSL/HTTPS (Secure Mode) By default, `pixi run dev-launch` runs the server in insecure HTTP mode on port 7878. This is suitable for most local development scenarios. diff --git a/fileglancer/app.py b/fileglancer/app.py index 1de9941a..c655feab 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -35,6 +35,7 @@ from fileglancer.user_context import UserContext, EffectiveUserContext, CurrentUserContext, UserContextConfigurationError from fileglancer.filestore import Filestore from fileglancer.log import AccessLogMiddleware +from fileglancer import sshkeys from x2s3.utils import get_read_access_acl, get_nosuchbucket_response, get_error_response from x2s3.client_file import FileProxyClient @@ -862,6 +863,115 @@ async def get_profile(username: str = Depends(get_current_user)): "groups": user_groups, } + # SSH Key Management endpoints + @app.get("/api/ssh-keys", response_model=sshkeys.SSHKeyListResponse, + description="List all SSH keys in the user's ~/.ssh directory") + async def list_ssh_keys(username: str = Depends(get_current_user)): + """List SSH keys for the authenticated user""" + with _get_user_context(username): + try: + ssh_dir = sshkeys.get_ssh_directory() + keys = sshkeys.list_ssh_keys(ssh_dir) + return sshkeys.SSHKeyListResponse(keys=keys) + except Exception as e: + logger.error(f"Error listing SSH keys for {username}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.post("/api/ssh-keys", response_model=sshkeys.GenerateKeyResponse, + description="Generate the default ed25519 SSH key (id_ed25519)") + async def generate_ssh_key( + request: sshkeys.GenerateKeyRequest = Body(default=sshkeys.GenerateKeyRequest()), + username: str = Depends(get_current_user) + ): + """Generate the default SSH key (id_ed25519) and add it to authorized_keys""" + with _get_user_context(username): + try: + ssh_dir = sshkeys.get_ssh_directory() + key_info = sshkeys.generate_ssh_key(ssh_dir, request.passphrase) + + # Read public key content to add to authorized_keys + pubkey_path = os.path.join(ssh_dir, f"{key_info.filename}.pub") + with open(pubkey_path, 'r') as f: + public_key = f.read().strip() + + # Add to authorized_keys + sshkeys.add_to_authorized_keys(ssh_dir, public_key) + + # Update the is_authorized flag + key_info = sshkeys.SSHKeyInfo( + filename=key_info.filename, + key_type=key_info.key_type, + fingerprint=key_info.fingerprint, + comment=key_info.comment, + has_private_key=key_info.has_private_key, + is_authorized=True + ) + + return sshkeys.GenerateKeyResponse( + key=key_info, + message="SSH key generated and added to authorized_keys" + ) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + logger.error(f"Error generating SSH key for {username}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.post("/api/ssh-keys/authorize", + description="Add the default SSH key to authorized_keys") + async def authorize_ssh_key(username: str = Depends(get_current_user)): + """Add the id_ed25519 key to authorized_keys""" + with _get_user_context(username): + try: + ssh_dir = sshkeys.get_ssh_directory() + pubkey_path = os.path.join(ssh_dir, "id_ed25519.pub") + + if not os.path.exists(pubkey_path): + raise HTTPException(status_code=404, detail="SSH key not found") + + # Read the public key + with open(pubkey_path, 'r') as f: + public_key = f.read().strip() + + # Add to authorized_keys + sshkeys.add_to_authorized_keys(ssh_dir, public_key) + + return {"message": "Key added to authorized_keys"} + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + logger.error(f"Error authorizing SSH key for {username}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/api/ssh-keys/content", response_model=sshkeys.SSHKeyContent, + description="Get the content of the default SSH key (id_ed25519)") + async def get_ssh_key_content( + key_type: str = Query(..., description="Type of key to fetch: 'public' or 'private'"), + username: str = Depends(get_current_user) + ): + """Get the public or private key content for copying""" + if key_type not in ("public", "private"): + raise HTTPException(status_code=400, detail="key_type must be 'public' or 'private'") + + with _get_user_context(username): + try: + ssh_dir = sshkeys.get_ssh_directory() + return sshkeys.get_key_content(ssh_dir, "id_ed25519", key_type) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Error getting SSH key content for {username}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + # File content endpoint @app.head("/api/content/{path_name:path}") async def head_file_content(path_name: str, diff --git a/fileglancer/sshkeys.py b/fileglancer/sshkeys.py new file mode 100644 index 00000000..3dd49562 --- /dev/null +++ b/fileglancer/sshkeys.py @@ -0,0 +1,444 @@ +"""SSH Key management utilities for Fileglancer. + +This module provides functions for listing, generating, and managing SSH keys +in a user's ~/.ssh directory. +""" + +import os +import shutil +import subprocess +import tempfile +from typing import List, Optional + +from loguru import logger +from pydantic import BaseModel, Field + +# Constants +AUTHORIZED_KEYS_FILENAME = "authorized_keys" +SSH_KEY_PREFIX = "ssh-" + + +def validate_path_in_directory(base_dir: str, path: str) -> str: + """Validate that a path is within the expected base directory. + + This prevents path traversal attacks by ensuring the resolved path + stays within the intended directory. + + Args: + base_dir: The base directory that the path must be within + path: The path to validate + + Returns: + The normalized absolute path if valid + + Raises: + ValueError: If the path escapes the base directory + """ + # Normalize both paths to resolve symlinks and collapse .. + real_base = os.path.realpath(base_dir) + real_path = os.path.realpath(path) + + # Ensure the path is within the base directory + if not real_path.startswith(real_base + os.sep) and real_path != real_base: + raise ValueError(f"Path '{path}' is outside the allowed directory") + + return real_path + + +def safe_join_path(base_dir: str, *parts: str) -> str: + """Safely join path components and validate the result is within base_dir. + + Args: + base_dir: The base directory + *parts: Path components to join + + Returns: + The validated absolute path + + Raises: + ValueError: If the resulting path escapes the base directory + """ + # First normalize the path to collapse any .. components + joined = os.path.normpath(os.path.join(base_dir, *parts)) + # Then validate it's within the base directory + return validate_path_in_directory(base_dir, joined) + + +class SSHKeyInfo(BaseModel): + """Information about an SSH key (without sensitive content)""" + filename: str = Field(description="The key filename without extension (e.g., 'id_ed25519')") + key_type: str = Field(description="The SSH key type (e.g., 'ssh-ed25519', 'ssh-rsa')") + fingerprint: str = Field(description="SHA256 fingerprint of the key") + comment: str = Field(description="Comment associated with the key") + has_private_key: bool = Field(description="Whether the corresponding private key exists") + is_authorized: bool = Field(description="Whether this key is in authorized_keys") + + +class SSHKeyListResponse(BaseModel): + """Response containing a list of SSH keys""" + keys: List[SSHKeyInfo] = Field(description="List of SSH keys") + + +class GenerateKeyResponse(BaseModel): + """Response after generating an SSH key""" + key: SSHKeyInfo = Field(description="The generated key info") + message: str = Field(description="Status message") + + +class SSHKeyContent(BaseModel): + """SSH key content - only fetched on demand""" + key: str = Field(description="The requested key content") + + +class GenerateKeyRequest(BaseModel): + """Request body for generating an SSH key""" + passphrase: Optional[str] = Field(default=None, description="Optional passphrase to protect the private key") + + +def get_ssh_directory() -> str: + """Get the path to the current user's .ssh directory. + + Returns: + The absolute path to ~/.ssh + """ + return os.path.expanduser("~/.ssh") + + +def ensure_ssh_directory_exists(ssh_dir: str) -> None: + """Ensure the .ssh directory exists with correct permissions. + + Args: + ssh_dir: Path to the .ssh directory + """ + if not os.path.exists(ssh_dir): + os.makedirs(ssh_dir, mode=0o700) + logger.info(f"Created SSH directory: {ssh_dir}") + else: + # Ensure permissions are correct + current_mode = os.stat(ssh_dir).st_mode & 0o777 + if current_mode != 0o700: + os.chmod(ssh_dir, 0o700) + logger.info(f"Fixed SSH directory permissions: {ssh_dir}") + + +def get_key_fingerprint(pubkey_path: str) -> str: + """Get the SHA256 fingerprint of a public key. + + Args: + pubkey_path: Path to the public key file + + Returns: + The SHA256 fingerprint string + + Raises: + ValueError: If the fingerprint cannot be determined + """ + try: + result = subprocess.run( + ['ssh-keygen', '-lf', pubkey_path], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode != 0: + raise ValueError(f"Failed to get fingerprint: {result.stderr}") + + # Output format: "256 SHA256:xxxxx comment (ED25519)" + parts = result.stdout.strip().split() + if len(parts) >= 2: + return parts[1] # SHA256:xxxxx + raise ValueError("Unexpected ssh-keygen output format") + except subprocess.TimeoutExpired: + raise ValueError("Timeout getting key fingerprint") + except FileNotFoundError: + raise ValueError("ssh-keygen not found") + + +def parse_public_key(pubkey_path: str, ssh_dir: str) -> SSHKeyInfo: + """Parse a public key file and return its information (without key content). + + Args: + pubkey_path: Path to the public key file + ssh_dir: Path to the .ssh directory (for checking authorized_keys) + + Returns: + SSHKeyInfo object with the key details (no sensitive content) + """ + with open(pubkey_path, 'r') as f: + public_key = f.read().strip() + + # Parse the public key content: "type base64key comment" + parts = public_key.split(None, 2) + if len(parts) < 2: + raise ValueError(f"Invalid public key format in {pubkey_path}") + + key_type = parts[0] # e.g., "ssh-ed25519" + comment = parts[2] if len(parts) > 2 else "" + + # Get fingerprint + fingerprint = get_key_fingerprint(pubkey_path) + + # Determine filename (without .pub extension) + filename = os.path.basename(pubkey_path) + if filename.endswith('.pub'): + filename = filename[:-4] + + # Check if private key exists (but don't read it) + private_key_path = pubkey_path[:-4] if pubkey_path.endswith('.pub') else pubkey_path + has_private_key = os.path.exists(private_key_path) and private_key_path != pubkey_path + + # Check if key is in authorized_keys + is_authorized = is_key_in_authorized_keys(ssh_dir, fingerprint) + + return SSHKeyInfo( + filename=filename, + key_type=key_type, + fingerprint=fingerprint, + comment=comment, + has_private_key=has_private_key, + is_authorized=is_authorized + ) + + +def get_key_content(ssh_dir: str, filename: str, key_type: str = "public") -> SSHKeyContent: + """Get the content of an SSH key (public or private). + + Args: + ssh_dir: Path to the .ssh directory + filename: Key filename without extension (e.g., 'id_ed25519') + key_type: Type of key to fetch: 'public' or 'private' + + Returns: + SSHKeyContent with the requested key content + + Raises: + ValueError: If the key doesn't exist or is invalid + """ + if key_type == "public": + pubkey_path = safe_join_path(ssh_dir, f"{filename}.pub") + if not os.path.exists(pubkey_path): + raise ValueError(f"Public key '{filename}' not found") + with open(pubkey_path, 'r') as f: + return SSHKeyContent(key=f.read().strip()) + + elif key_type == "private": + private_key_path = safe_join_path(ssh_dir, filename) + if not os.path.exists(private_key_path): + raise ValueError(f"Private key '{filename}' not found") + with open(private_key_path, 'r') as f: + return SSHKeyContent(key=f.read()) + + raise ValueError(f"Invalid key_type: {key_type}") + + +def is_key_in_authorized_keys(ssh_dir: str, fingerprint: str) -> bool: + """Check if a key with the given fingerprint is in authorized_keys. + + Args: + ssh_dir: Path to the .ssh directory + fingerprint: The SHA256 fingerprint to look for + + Returns: + True if the key is in authorized_keys, False otherwise + """ + authorized_keys_path = os.path.join(ssh_dir, AUTHORIZED_KEYS_FILENAME) + + if not os.path.exists(authorized_keys_path): + return False + + try: + # Get fingerprints of all keys in authorized_keys + result = subprocess.run( + ['ssh-keygen', '-lf', authorized_keys_path], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + logger.warning(f"Could not check authorized_keys: {result.stderr}") + return False + + # Check each line for the fingerprint + for line in result.stdout.strip().split('\n'): + if fingerprint in line: + return True + + return False + except Exception as e: + logger.warning(f"Error checking authorized_keys: {e}") + return False + + +def list_ssh_keys(ssh_dir: str) -> List[SSHKeyInfo]: + """List all SSH keys in the given directory. + + Args: + ssh_dir: Path to the .ssh directory + + Returns: + List of SSHKeyInfo objects + """ + keys = [] + + if not os.path.exists(ssh_dir): + return keys + + # Find all .pub files + for filename in os.listdir(ssh_dir): + if filename.endswith('.pub'): + try: + pubkey_path = safe_join_path(ssh_dir, filename) + key_info = parse_public_key(pubkey_path, ssh_dir) + keys.append(key_info) + except ValueError as e: + logger.warning(f"Skipping suspicious filename {filename}: {e}") + continue + except Exception as e: + logger.warning(f"Could not parse key {filename}: {e}") + continue + + # Sort by filename + keys.sort(key=lambda k: k.filename) + + logger.info(f"Listed {len(keys)} SSH keys in {ssh_dir}") + + return keys + + +def generate_ssh_key(ssh_dir: str, passphrase: Optional[str] = None) -> SSHKeyInfo: + """Generate the default ed25519 SSH key (id_ed25519). + + Args: + ssh_dir: Path to the .ssh directory + passphrase: Optional passphrase to protect the private key + + Returns: + SSHKeyInfo for the generated key + + Raises: + ValueError: If the key already exists + RuntimeError: If key generation fails + """ + key_name = "id_ed25519" + + # Ensure .ssh directory exists + ensure_ssh_directory_exists(ssh_dir) + + # Build key paths + key_path = os.path.join(ssh_dir, key_name) + pubkey_path = os.path.join(ssh_dir, f"{key_name}.pub") + + # Check if key already exists + if os.path.exists(key_path) or os.path.exists(pubkey_path): + raise ValueError(f"SSH key '{key_name}' already exists") + + # Build ssh-keygen command + cmd = [ + 'ssh-keygen', + '-t', 'ed25519', + '-N', passphrase or '', # Empty string if no passphrase + '-f', key_path, + ] + + logger.info(f"Generating SSH key: {key_name}") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + raise RuntimeError(f"ssh-keygen failed: {result.stderr}") + + # Set correct permissions + os.chmod(key_path, 0o600) + os.chmod(pubkey_path, 0o644) + + logger.info(f"Successfully generated SSH key: {key_name}") + + # Parse and return the generated key info + return parse_public_key(pubkey_path, ssh_dir) + + except subprocess.TimeoutExpired: + raise RuntimeError("Key generation timed out") + except FileNotFoundError: + raise RuntimeError("ssh-keygen not found on system") + + +def add_to_authorized_keys(ssh_dir: str, public_key: str) -> bool: + """Add a public key to the authorized_keys file. + + Args: + ssh_dir: Path to the .ssh directory + public_key: The public key content to add + + Returns: + True if the key was added successfully + + Raises: + ValueError: If the public key is invalid + RuntimeError: If adding the key fails + """ + # Validate public key format (basic check) + if not public_key or not public_key.startswith(SSH_KEY_PREFIX): + raise ValueError("Invalid public key format") + + # Ensure .ssh directory exists + ensure_ssh_directory_exists(ssh_dir) + + authorized_keys_path = os.path.join(ssh_dir, AUTHORIZED_KEYS_FILENAME) + + # Get fingerprint of the key we're adding to check if already present + # Write key to temp file to get its fingerprint + try: + with tempfile.NamedTemporaryFile(mode='w', suffix='.pub', delete=False) as tmp: + tmp.write(public_key) + tmp_path = tmp.name + + try: + fingerprint = get_key_fingerprint(tmp_path) + finally: + os.unlink(tmp_path) + + # Use fingerprint-based check (same as UI uses) + if is_key_in_authorized_keys(ssh_dir, fingerprint): + logger.info("Key already in authorized_keys (fingerprint match)") + return True + + except Exception as e: + logger.warning(f"Could not check fingerprint, proceeding with add: {e}") + + # Backup and append the key + try: + # Backup existing file before modifying + if os.path.exists(authorized_keys_path): + backup_path = authorized_keys_path + '.bak' + shutil.copy2(authorized_keys_path, backup_path) + logger.info(f"Backed up authorized_keys to {backup_path}") + + # Ensure file ends with newline before appending + with open(authorized_keys_path, 'rb') as f: + f.seek(0, 2) # Seek to end + if f.tell() > 0: # File is not empty + f.seek(-1, 2) # Seek to last byte + if f.read(1) != b'\n': + # File doesn't end with newline, add one + with open(authorized_keys_path, 'a') as af: + af.write('\n') + + # Append the key + with open(authorized_keys_path, 'a') as f: + f.write(public_key + '\n') + + # Ensure correct permissions + os.chmod(authorized_keys_path, 0o600) + + logger.info(f"Added key to {authorized_keys_path}") + return True + + except Exception as e: + raise RuntimeError(f"Failed to add key to authorized_keys: {e}") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e62dafa7..e502bbc4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import Jobs from '@/components/Jobs'; import Preferences from '@/components/Preferences'; import Links from '@/components/Links'; import Notifications from '@/components/Notifications'; +import SSHKeys from '@/components/SSHKeys'; import ErrorFallback from '@/components/ErrorFallback'; function RequireAuth({ children }: { readonly children: ReactNode }) { @@ -82,6 +83,7 @@ function RootRedirect() { const AppComponent = () => { const tasksEnabled = import.meta.env.VITE_ENABLE_TASKS === 'true'; + const sshKeysEnabled = import.meta.env.VITE_ENABLE_SSH_KEYS === 'true'; return ( @@ -125,6 +127,16 @@ const AppComponent = () => { } path="notifications" /> + {sshKeysEnabled ? ( + + + + } + path="ssh-keys" + /> + ) : null} key.filename === 'id_ed25519'); + + return ( + <> +
+ + SSH Key + +
+ + +
+ + + SSH keys allow you to securely connect to cluster nodes without + entering a password. Specifically, you need an ed25519 SSH key to + use Seqera Platform to run pipelines on the cluster. This page lets + you view your existing ed25519 SSH key or generate a new one. + +
+
+ + {isLoading ? ( +
+ +
+ ) : null} + + {error ? ( + + + Failed to load SSH key: {error.message} + + + + ) : null} + + {!isLoading && !error && !defaultKey ? ( + + + + No ed25519 SSH key found + + + Generate an ed25519 SSH key to enable passwordless access to cluster + nodes and integration with Seqera Platform. + + + + ) : null} + + {!isLoading && !error && defaultKey ? ( + + ) : null} + + + + ); +} diff --git a/frontend/src/components/ui/Navbar/ProfileMenu.tsx b/frontend/src/components/ui/Navbar/ProfileMenu.tsx index 2bff1555..6ddacc54 100644 --- a/frontend/src/components/ui/Navbar/ProfileMenu.tsx +++ b/frontend/src/components/ui/Navbar/ProfileMenu.tsx @@ -2,7 +2,8 @@ import { IconButton, Menu, Typography } from '@material-tailwind/react'; import { HiOutlineLogout, HiOutlineUserCircle, - HiOutlineBell + HiOutlineBell, + HiOutlineKey } from 'react-icons/hi'; import { HiOutlineAdjustmentsHorizontal } from 'react-icons/hi2'; import { Link } from 'react-router-dom'; @@ -13,6 +14,7 @@ import { useAuthContext } from '@/contexts/AuthContext'; export default function ProfileMenu() { const { profile } = useProfileContext(); const { logout, authStatus } = useAuthContext(); + const sshKeysEnabled = import.meta.env.VITE_ENABLE_SSH_KEYS === 'true'; const handleLogout = async () => { // Use logout for all auth methods (both OKTA and simple) @@ -60,6 +62,16 @@ export default function ProfileMenu() { Notifications + {sshKeysEnabled ? ( + + + SSH Key + + ) : null} >; +}; + +export default function GenerateKeyDialog({ + showDialog, + setShowDialog +}: GenerateKeyDialogProps) { + const generateMutation = useGenerateSSHKeyMutation(); + const [passphrase, setPassphrase] = useState(''); + + const handleClose = () => { + setShowDialog(false); + setPassphrase(''); + }; + + const handleGenerate = async () => { + try { + const result = await generateMutation.mutateAsync( + passphrase ? { passphrase } : undefined + ); + toast.success(result.message); + handleClose(); + } catch (error) { + toast.error( + `Failed to generate key: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }; + + return ( + + + Generate SSH Key + + + + This will create a new ed25519 SSH key pair ( + id_ed25519) in your ~/.ssh + directory and add it to your authorized_keys file. + + + + Once created, you can use this key to SSH to cluster nodes and copy the + private key for use with Seqera Platform. + + +
+ + Passphrase (optional) + + setPassphrase(e.target.value)} + placeholder="Leave empty for no passphrase" + type="password" + value={passphrase} + /> + + A passphrase adds extra security but must be entered each time you use + the key. + +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx new file mode 100644 index 00000000..97a87c81 --- /dev/null +++ b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; +import { Button, Card, Chip, Typography } from '@material-tailwind/react'; +import { + HiOutlineClipboardCopy, + HiOutlineKey, + HiOutlinePlus +} from 'react-icons/hi'; +import toast from 'react-hot-toast'; + +import { Spinner } from '@/components/ui/widgets/Loaders'; +import { + useAuthorizeSSHKeyMutation, + fetchSSHKeyContent +} from '@/queries/sshKeyQueries'; +import type { SSHKeyInfo } from '@/queries/sshKeyQueries'; + +type SSHKeyCardProps = { + readonly keyInfo: SSHKeyInfo; +}; + +export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { + const authorizeMutation = useAuthorizeSSHKeyMutation(); + const [isCopyingPublic, setIsCopyingPublic] = useState(false); + const [isCopyingPrivate, setIsCopyingPrivate] = useState(false); + + // Truncate fingerprint for display + const shortFingerprint = + keyInfo.fingerprint.replace('SHA256:', '').slice(0, 16) + '...'; + + const handleAuthorize = async () => { + try { + const result = await authorizeMutation.mutateAsync(); + toast.success(result.message); + } catch (error) { + toast.error( + `Failed to authorize key: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }; + + const handleCopyPublicKey = async () => { + setIsCopyingPublic(true); + try { + const content = await fetchSSHKeyContent('public'); + await navigator.clipboard.writeText(content.key); + toast.success('Public key copied to clipboard'); + } catch (error) { + toast.error( + `Failed to copy public key: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + setIsCopyingPublic(false); + } + }; + + const handleCopyPrivateKey = async () => { + setIsCopyingPrivate(true); + try { + const content = await fetchSSHKeyContent('private'); + await navigator.clipboard.writeText(content.key); + toast.success('Private key copied to clipboard'); + } catch (error) { + toast.error( + `Failed to copy private key: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + setIsCopyingPrivate(false); + } + }; + + return ( + +
+
+ +
+ + {keyInfo.filename} + + + {keyInfo.key_type} + + + {shortFingerprint} + + {keyInfo.comment ? ( + + {keyInfo.comment} + + ) : null} +
+
+ +
+ {keyInfo.is_authorized ? ( + + Authorized + + ) : ( + + )} + + + + {keyInfo.has_private_key ? ( + + ) : ( + + Private key not available + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/ui/widgets/CopyTooltip.tsx b/frontend/src/components/ui/widgets/CopyTooltip.tsx index 7cb2b711..79afd482 100644 --- a/frontend/src/components/ui/widgets/CopyTooltip.tsx +++ b/frontend/src/components/ui/widgets/CopyTooltip.tsx @@ -7,12 +7,14 @@ export default function CopyTooltip({ children, primaryLabel, textToCopy, - tooltipTriggerClasses + tooltipTriggerClasses, + variant = 'ghost' }: { readonly children: React.ReactNode; readonly primaryLabel: string; readonly textToCopy: string; readonly tooltipTriggerClasses?: string; + readonly variant?: 'outline' | 'ghost'; }) { const { showCopiedTooltip, handleCopy } = useCopyTooltip(); @@ -25,7 +27,7 @@ export default function CopyTooltip({ }} openCondition={showCopiedTooltip ? true : undefined} triggerClasses={tooltipTriggerClasses} - variant="ghost" + variant={variant} > {children} diff --git a/frontend/src/queries/sshKeyQueries.ts b/frontend/src/queries/sshKeyQueries.ts new file mode 100644 index 00000000..cbee850c --- /dev/null +++ b/frontend/src/queries/sshKeyQueries.ts @@ -0,0 +1,195 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'; + +import { sendFetchRequest } from '@/utils'; +import { + getResponseJsonOrError, + throwResponseNotOkError +} from '@/queries/queryUtils'; + +/** + * Information about an SSH key (without sensitive content) + */ +export type SSHKeyInfo = { + filename: string; + key_type: string; + fingerprint: string; + comment: string; + has_private_key: boolean; + is_authorized: boolean; +}; + +/** + * SSH key content - fetched on demand when user clicks copy + */ +export type SSHKeyContent = { + key: string; +}; + +/** + * Response from the list SSH keys endpoint + */ +type SSHKeyListResponse = { + keys: SSHKeyInfo[]; +}; + +/** + * Response from the generate SSH key endpoint + */ +type GenerateKeyResponse = { + key: SSHKeyInfo; + message: string; +}; + +// Query key factory for SSH keys +export const sshKeyQueryKeys = { + all: ['sshKeys'] as const, + list: () => ['sshKeys', 'list'] as const +}; + +/** + * Fetches all SSH keys from the backend + */ +const fetchSSHKeys = async (signal?: AbortSignal): Promise => { + const response = await sendFetchRequest('/api/ssh-keys', 'GET', undefined, { + signal + }); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + const data = body as SSHKeyListResponse; + return data.keys ?? []; +}; + +/** + * Query hook for fetching all SSH keys + * + * @returns Query result with all SSH keys + */ +export function useSSHKeysQuery(): UseQueryResult { + return useQuery({ + queryKey: sshKeyQueryKeys.list(), + queryFn: ({ signal }) => fetchSSHKeys(signal) + }); +} + +/** + * Parameters for generating an SSH key + */ +type GenerateKeyParams = { + passphrase?: string; +}; + +/** + * Mutation hook for generating the default SSH key (id_ed25519) + * + * Creates an ed25519 key pair and adds it to authorized_keys. + * + * @example + * const mutation = useGenerateSSHKeyMutation(); + * mutation.mutate({ passphrase: 'optional-passphrase' }); + */ +export function useGenerateSSHKeyMutation(): UseMutationResult< + GenerateKeyResponse, + Error, + GenerateKeyParams | void +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params?: GenerateKeyParams) => { + const response = await sendFetchRequest( + '/api/ssh-keys', + 'POST', + params?.passphrase ? { passphrase: params.passphrase } : undefined + ); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + return body as GenerateKeyResponse; + }, + onSuccess: () => { + // Invalidate and refetch the list + queryClient.invalidateQueries({ + queryKey: sshKeyQueryKeys.all + }); + } + }); +} + +/** + * Response from the authorize SSH key endpoint + */ +type AuthorizeKeyResponse = { + message: string; +}; + +/** + * Mutation hook for adding the SSH key to authorized_keys + * + * @example + * const mutation = useAuthorizeSSHKeyMutation(); + * mutation.mutate(); + */ +export function useAuthorizeSSHKeyMutation(): UseMutationResult< + AuthorizeKeyResponse, + Error, + void +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const response = await sendFetchRequest( + '/api/ssh-keys/authorize', + 'POST' + ); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + return body as AuthorizeKeyResponse; + }, + onSuccess: () => { + // Invalidate and refetch the list to update is_authorized status + queryClient.invalidateQueries({ + queryKey: sshKeyQueryKeys.all + }); + } + }); +} + +/** + * Fetch SSH key content (public or private key) on demand. + * This is not a hook - call it imperatively when user clicks copy. + * + * @param keyType - Type of key to fetch: 'public' or 'private' + * @returns Promise with the key content + */ +export async function fetchSSHKeyContent( + keyType: 'public' | 'private' +): Promise { + const response = await sendFetchRequest( + `/api/ssh-keys/content?key_type=${keyType}`, + 'GET' + ); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + return body as SSHKeyContent; +}