From 028fbebcd69f3abebaeba1d968382844a10ec288 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 10:11:11 -0400 Subject: [PATCH] feat(next): box user card on /machine --- .../src/components/machine/box-user-card.tsx | 187 ++++++++++++++++++ .../src/components/machine/machine-shell.tsx | 3 + .../tests/component/box-user-card.test.tsx | 86 ++++++++ 3 files changed, 276 insertions(+) create mode 100644 apps/next/src/components/machine/box-user-card.tsx create mode 100644 apps/next/tests/component/box-user-card.test.tsx diff --git a/apps/next/src/components/machine/box-user-card.tsx b/apps/next/src/components/machine/box-user-card.tsx new file mode 100644 index 0000000..3fa6bcd --- /dev/null +++ b/apps/next/src/components/machine/box-user-card.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from 'convex/react'; +import { KeyRound, Loader2, UserRound } from 'lucide-react'; +import { toast } from 'sonner'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, +} from '@spoon/ui'; + +// Mirrors the worker's linuxUsername (runtime/box-user.ts) so the identity +// line shows the actual passwd entry. +const linuxUsername = (spoonUsername: string): string => { + const cleaned = spoonUsername + .toLowerCase() + .replaceAll(/[^a-z0-9_-]/g, '-') + .slice(0, 32); + if (!cleaned) return 'spoon'; + return /^[a-z_]/.test(cleaned) ? cleaned : `u${cleaned}`.slice(0, 32); +}; + +/** + * The box's Linux account: identity (user@host) and the user-settable + * password. Password is write-only — the UI only ever knows whether one is + * set. Saving stores it (encrypted) and applies it to a running box live; + * clearing reverts sudo to passwordless. + */ +export const BoxUserCard = ({ username }: { username: string }) => { + const passwordState = useQuery(api.boxSettings.hasMine, {}); + const hasPassword = passwordState?.hasPassword ?? false; + const user = linuxUsername(username || 'spoon'); + + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [saving, setSaving] = useState(false); + const [pendingRestart, setPendingRestart] = useState(false); + + const tooShort = password.length > 0 && password.length < 8; + const mismatch = confirm.length > 0 && confirm !== password; + const canSave = + !saving && password.length >= 8 && password.length <= 128 && !mismatch; + + const submit = (next: string | null) => { + setSaving(true); + void (async () => { + try { + const response = await fetch('/api/box/set-password', { + method: 'POST', + body: JSON.stringify({ password: next }), + }); + if (!response.ok) throw new Error(await response.text()); + const data = (await response.json()) as { + hasPassword: boolean; + applied: boolean; + }; + setPassword(''); + setConfirm(''); + setPendingRestart(!data.applied); + toast.success( + next === null ? 'Password removed.' : 'Password updated.', + ); + } catch (error) { + console.error(error); + toast.error('Could not update the box password.'); + } finally { + setSaving(false); + } + })(); + }; + + return ( + + + + + Box user + + + +
+
+

+ Identity +

+

+ {user}@{user}-box +

+
+
+

+ Password +

+

{hasPassword ? 'Set' : 'Not set'}

+
+
+ +

+ Without a password, sudo works + without prompting. Once you set one, sudo asks for it — like a normal + machine. +

+ +
{ + event.preventDefault(); + if (canSave) submit(password); + }} + > +
+ + setPassword(event.target.value)} + className='w-56' + /> +
+
+ + setConfirm(event.target.value)} + className='w-56' + /> +
+ + {hasPassword ? ( + + ) : null} +
+ + {tooShort ? ( +

+ Password must be at least 8 characters. +

+ ) : null} + {mismatch ? ( +

Passwords do not match.

+ ) : null} + {pendingRestart ? ( +

+ Saved — your machine is not running, so it takes effect the next + time it starts. +

+ ) : null} +
+
+ ); +}; diff --git a/apps/next/src/components/machine/machine-shell.tsx b/apps/next/src/components/machine/machine-shell.tsx index ec65bab..195a312 100644 --- a/apps/next/src/components/machine/machine-shell.tsx +++ b/apps/next/src/components/machine/machine-shell.tsx @@ -18,6 +18,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui'; import type { BoxStatus, LifecycleAction } from './box-status-card'; import { BoxStatusCard } from './box-status-card'; +import { BoxUserCard } from './box-user-card'; const STATUS_POLL_MS = 10_000; @@ -261,6 +262,8 @@ export const MachineShell = () => { onAction={runLifecycle} /> + + setActiveTab(value as 'files' | 'terminal')} diff --git a/apps/next/tests/component/box-user-card.test.tsx b/apps/next/tests/component/box-user-card.test.tsx new file mode 100644 index 0000000..e1b325c --- /dev/null +++ b/apps/next/tests/component/box-user-card.test.tsx @@ -0,0 +1,86 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BoxUserCard } from '../../src/components/machine/box-user-card'; + +const { mockUseQuery, fetchSpy } = vi.hoisted(() => ({ + mockUseQuery: vi.fn(), + fetchSpy: vi.fn(), +})); + +vi.mock('convex/react', () => ({ + useQuery: mockUseQuery, +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +describe('BoxUserCard', () => { + beforeEach(() => { + vi.stubGlobal('fetch', fetchSpy); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ hasPassword: true, applied: true })), + ); + }); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('shows the identity line and not-set state', () => { + mockUseQuery.mockReturnValue({ hasPassword: false }); + render(); + expect(screen.getByText('gabriel@gabriel-box')).toBeInTheDocument(); + expect(screen.getByText('Not set')).toBeInTheDocument(); + expect(screen.getByText('Set password')).toBeInTheDocument(); + expect(screen.queryByText('Remove password')).not.toBeInTheDocument(); + }); + + it('offers change/remove when a password is set', () => { + mockUseQuery.mockReturnValue({ hasPassword: true }); + render(); + expect(screen.getByText('Set')).toBeInTheDocument(); + expect(screen.getByText('Change password')).toBeInTheDocument(); + expect(screen.getByText('Remove password')).toBeInTheDocument(); + }); + + it('blocks submit on mismatched confirmation', () => { + mockUseQuery.mockReturnValue({ hasPassword: false }); + render(); + fireEvent.change(screen.getByLabelText('New password'), { + target: { value: 'longenough' }, + }); + fireEvent.change(screen.getByLabelText('Confirm'), { + target: { value: 'different1' }, + }); + expect(screen.getByText('Passwords do not match.')).toBeInTheDocument(); + expect(screen.getByText('Set password').closest('button')!.disabled).toBe( + true, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('posts the password and shows the restart note when not applied live', async () => { + mockUseQuery.mockReturnValue({ hasPassword: false }); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ hasPassword: true, applied: false })), + ); + render(); + fireEvent.change(screen.getByLabelText('New password'), { + target: { value: 'longenough' }, + }); + fireEvent.change(screen.getByLabelText('Confirm'), { + target: { value: 'longenough' }, + }); + fireEvent.click(screen.getByText('Set password')); + expect( + await screen.findByText(/takes effect the next time it starts/), + ).toBeInTheDocument(); + expect(fetchSpy).toHaveBeenCalledWith( + '/api/box/set-password', + expect.objectContaining({ method: 'POST' }), + ); + }); +});