feat(next): box user card on /machine
This commit is contained in:
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-base'>
|
||||||
|
<UserRound className='size-4' />
|
||||||
|
Box user
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='flex flex-wrap items-center gap-x-6 gap-y-2'>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
|
||||||
|
Identity
|
||||||
|
</p>
|
||||||
|
<p className='font-mono text-sm'>
|
||||||
|
{user}@{user}-box
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
|
||||||
|
Password
|
||||||
|
</p>
|
||||||
|
<p className='text-sm'>{hasPassword ? 'Set' : 'Not set'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Without a password, <code className='font-mono'>sudo</code> works
|
||||||
|
without prompting. Once you set one, sudo asks for it — like a normal
|
||||||
|
machine.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className='flex flex-wrap items-end gap-2'
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (canSave) submit(password);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<label
|
||||||
|
className='text-muted-foreground text-xs'
|
||||||
|
htmlFor='box-password'
|
||||||
|
>
|
||||||
|
New password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id='box-password'
|
||||||
|
type='password'
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
className='w-56'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<label
|
||||||
|
className='text-muted-foreground text-xs'
|
||||||
|
htmlFor='box-password-confirm'
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id='box-password-confirm'
|
||||||
|
type='password'
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={confirm}
|
||||||
|
onChange={(event) => setConfirm(event.target.value)}
|
||||||
|
className='w-56'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type='submit' size='sm' disabled={!canSave}>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className='size-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<KeyRound className='size-4' />
|
||||||
|
)}
|
||||||
|
{hasPassword ? 'Change password' : 'Set password'}
|
||||||
|
</Button>
|
||||||
|
{hasPassword ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => submit(null)}
|
||||||
|
>
|
||||||
|
Remove password
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{tooShort ? (
|
||||||
|
<p className='text-destructive text-xs'>
|
||||||
|
Password must be at least 8 characters.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{mismatch ? (
|
||||||
|
<p className='text-destructive text-xs'>Passwords do not match.</p>
|
||||||
|
) : null}
|
||||||
|
{pendingRestart ? (
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Saved — your machine is not running, so it takes effect the next
|
||||||
|
time it starts.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
|
|||||||
|
|
||||||
import type { BoxStatus, LifecycleAction } from './box-status-card';
|
import type { BoxStatus, LifecycleAction } from './box-status-card';
|
||||||
import { BoxStatusCard } from './box-status-card';
|
import { BoxStatusCard } from './box-status-card';
|
||||||
|
import { BoxUserCard } from './box-user-card';
|
||||||
|
|
||||||
const STATUS_POLL_MS = 10_000;
|
const STATUS_POLL_MS = 10_000;
|
||||||
|
|
||||||
@@ -261,6 +262,8 @@ export const MachineShell = () => {
|
|||||||
onAction={runLifecycle}
|
onAction={runLifecycle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BoxUserCard username={username} />
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')}
|
onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')}
|
||||||
|
|||||||
@@ -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(<BoxUserCard username='Gabriel' />);
|
||||||
|
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(<BoxUserCard username='gabriel' />);
|
||||||
|
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(<BoxUserCard username='gabriel' />);
|
||||||
|
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(<BoxUserCard username='gabriel' />);
|
||||||
|
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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user