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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user