feat(next): box user card on /machine

This commit is contained in:
Gabriel Brown
2026-07-13 10:11:11 -04:00
parent 134bab93a5
commit 028fbebcd6
3 changed files with 276 additions and 0 deletions
@@ -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 { 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}
/>
<BoxUserCard username={username} />
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')}