188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
'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>
|
|
);
|
|
};
|