mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-12 15:58:58 +00:00
db65731c3a
Your own email address, password and second factor are changed from your profile, which asks for your current password. The staff screen and the API changed the first two with no password at all, and the API removed the third on your own account without the confirmation the web asks for. StaffAccounts::ownCredentialChanges is the one rule both now ask: the staff screen refuses your own email or password with a validation error and points to the profile, and the API answers 403, as it does for removing your own second factor. Changing somebody else's password is unchanged: that is what edit_users and edit_clients mean, on the screen and over the API. It now also revokes that account's API tokens. Browser sessions already ended with the password hash; tokens did not. GHSA-j5cp-r8pr-m5cr
220 lines
8.2 KiB
TypeScript
220 lines
8.2 KiB
TypeScript
import { Link } from '@inertiajs/react';
|
|
import { X } from 'lucide-react';
|
|
|
|
import InputError from '@/components/input-error';
|
|
import { PasswordRequirements } from '@/components/password-requirements';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { useTranslation } from '@/hooks/use-translation';
|
|
|
|
export interface AssignableRole {
|
|
id: number;
|
|
name: string;
|
|
is_system: boolean;
|
|
client_scoped: boolean;
|
|
}
|
|
|
|
export interface ClientOption {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
interface UserFormProps {
|
|
name: string;
|
|
email: string;
|
|
roleId: string;
|
|
password: string;
|
|
passwordConfirmation: string;
|
|
onChange: (field: 'name' | 'email' | 'role_id' | 'password' | 'password_confirmation', value: string) => void;
|
|
roles: AssignableRole[];
|
|
clients: ClientOption[];
|
|
assignedClients: number[];
|
|
onAssignedClientsChange: (ids: number[]) => void;
|
|
passwordOptional: boolean;
|
|
/**
|
|
* Editing your own account: the email address and password are changed
|
|
* from your profile, which asks for your current password first, so
|
|
* this form shows the address and leaves both alone.
|
|
*/
|
|
ownAccount?: boolean;
|
|
errors: Partial<Record<string, string>>;
|
|
}
|
|
|
|
export function UserForm({
|
|
name,
|
|
email,
|
|
roleId,
|
|
password,
|
|
passwordConfirmation,
|
|
onChange,
|
|
roles,
|
|
clients,
|
|
assignedClients,
|
|
onAssignedClientsChange,
|
|
passwordOptional,
|
|
ownAccount = false,
|
|
errors,
|
|
}: UserFormProps) {
|
|
const { t } = useTranslation();
|
|
|
|
const selectedRole = roles.find((role) => String(role.id) === roleId);
|
|
|
|
return (
|
|
<div className="grid max-w-md gap-6">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="name">{t('Name')}</Label>
|
|
<Input id="name" value={name} onChange={(e) => onChange('name', e.target.value)} required autoComplete="off" />
|
|
<InputError message={errors.name} />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="email">{t('Email address')}</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => onChange('email', e.target.value)}
|
|
required
|
|
readOnly={ownAccount}
|
|
autoComplete="off"
|
|
/>
|
|
{ownAccount && (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t('Change your own email address and password from your profile.')}{' '}
|
|
<Link href={route('profile.edit')} className="underline underline-offset-4">
|
|
{t('Go to your profile')}
|
|
</Link>
|
|
</p>
|
|
)}
|
|
<InputError message={errors.email} />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="role_id">{t('Role')}</Label>
|
|
<Select value={roleId} onValueChange={(value) => onChange('role_id', value)}>
|
|
<SelectTrigger id="role_id" className="w-full">
|
|
<SelectValue placeholder={t('Select a role')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{roles.map((role) => (
|
|
<SelectItem key={role.id} value={String(role.id)}>
|
|
{role.is_system ? t(role.name) : role.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError message={errors.role_id} />
|
|
</div>
|
|
|
|
{selectedRole?.client_scoped && (
|
|
<AssignedClientsField
|
|
clients={clients}
|
|
selected={assignedClients}
|
|
onChange={onAssignedClientsChange}
|
|
error={errors.assigned_clients}
|
|
/>
|
|
)}
|
|
|
|
{!ownAccount && (
|
|
<>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="password">{passwordOptional ? t('New password (leave blank to keep current)') : t('Password')}</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => onChange('password', e.target.value)}
|
|
required={!passwordOptional}
|
|
autoComplete="new-password"
|
|
/>
|
|
<PasswordRequirements />
|
|
<InputError message={errors.password} />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="password_confirmation">{t('Confirm password')}</Label>
|
|
<Input
|
|
id="password_confirmation"
|
|
type="password"
|
|
value={passwordConfirmation}
|
|
onChange={(e) => onChange('password_confirmation', e.target.value)}
|
|
required={!passwordOptional || password !== ''}
|
|
autoComplete="new-password"
|
|
/>
|
|
<InputError message={errors.password_confirmation} />
|
|
</div>
|
|
</>
|
|
)}
|
|
{ownAccount && <InputError message={errors.password} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The clients a client-scoped staff member manages: pick from a dropdown,
|
|
* remove via chips. Mirrors the folder-sharing picker.
|
|
*/
|
|
function AssignedClientsField({
|
|
clients,
|
|
selected,
|
|
onChange,
|
|
error,
|
|
}: {
|
|
clients: ClientOption[];
|
|
selected: number[];
|
|
onChange: (ids: number[]) => void;
|
|
error?: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
const available = clients.filter((client) => !selected.includes(client.id));
|
|
const nameFor = (id: number) => clients.find((client) => client.id === id)?.name ?? String(id);
|
|
|
|
return (
|
|
<div className="grid gap-2">
|
|
<Label>{t('Assigned clients')}</Label>
|
|
<p className="text-muted-foreground text-sm">
|
|
{t('This role only sees files and folders belonging to these clients, plus its own uploads.')}
|
|
</p>
|
|
|
|
<Select value="" onValueChange={(value) => onChange([...selected, Number(value)])}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder={t('Add a client')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{available.length === 0 ? (
|
|
<div className="text-muted-foreground px-2 py-1.5 text-sm">{t('No more clients to add')}</div>
|
|
) : (
|
|
available.map((client) => (
|
|
<SelectItem key={client.id} value={String(client.id)}>
|
|
{client.name}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{selected.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{selected.map((id) => (
|
|
<span key={id} className="bg-muted inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm">
|
|
{nameFor(id)}
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
onClick={() => onChange(selected.filter((x) => x !== id))}
|
|
>
|
|
<X className="size-3.5" />
|
|
<span className="sr-only">{t('Remove')}</span>
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<InputError message={error} />
|
|
</div>
|
|
);
|
|
}
|