mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-12 07:49:29 +00:00
d3230a4b64
Setting a password and removing a second factor are how an administrator lets a locked-out person back in, so a token holding edit_clients or edit_users can sign in as the accounts it may edit. That stays what those abilities mean; it is now said where it is chosen. The token form warns when either is ticked, and the API guide says it beside the abilities, with the three refusals on your own account under "Staff accounts". The OpenAPI document carries the new 403s, and CHANGELOG.md an Unreleased entry. GHSA-j5cp-r8pr-m5cr
182 lines
8.6 KiB
TypeScript
182 lines
8.6 KiB
TypeScript
import InputError from '@/components/input-error';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useTranslation } from '@/hooks/use-translation';
|
|
|
|
export interface AbilityGroup {
|
|
category: string;
|
|
label: string;
|
|
abilities: { key: string; label: string }[];
|
|
}
|
|
|
|
export interface TokenFormValues {
|
|
name: string;
|
|
abilities: string[];
|
|
expires_in_days: number;
|
|
never_expires: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
values: TokenFormValues;
|
|
setValue: <K extends keyof TokenFormValues>(key: K, value: TokenFormValues[K]) => void;
|
|
errors: Partial<Record<keyof TokenFormValues | 'abilities.0', string>>;
|
|
availableAbilities: AbilityGroup[];
|
|
maxDays: number;
|
|
}
|
|
|
|
/**
|
|
* The fields shared by minting a token and editing one. Kept in one place
|
|
* because the two screens must agree on what may be granted — a divergence
|
|
* would show up as an ability offered on one and not the other.
|
|
*/
|
|
export function ApiTokenForm({ values, setValue, errors, availableAbilities, maxDays }: Props) {
|
|
const { t } = useTranslation();
|
|
|
|
const allKeys = availableAbilities.flatMap((group) => group.abilities.map((ability) => ability.key));
|
|
const allSelected = allKeys.length > 0 && allKeys.every((key) => values.abilities.includes(key));
|
|
|
|
const toggleAbility = (key: string, checked: boolean) => {
|
|
setValue('abilities', checked ? [...values.abilities, key] : values.abilities.filter((ability) => ability !== key));
|
|
};
|
|
|
|
const toggleGroup = (group: AbilityGroup, checked: boolean) => {
|
|
const keys = group.abilities.map((ability) => ability.key);
|
|
|
|
setValue('abilities', checked ? [...new Set([...values.abilities, ...keys])] : values.abilities.filter((ability) => !keys.includes(ability)));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="name">{t('Token name')}</Label>
|
|
<Input
|
|
id="name"
|
|
value={values.name}
|
|
onChange={(e) => setValue('name', e.target.value)}
|
|
placeholder={t('e.g. Zapier')}
|
|
autoComplete="off"
|
|
/>
|
|
<p className="text-muted-foreground text-sm">
|
|
{t('Name it after the tool that will use it, so you know what you are revoking later.')}
|
|
</p>
|
|
<InputError message={errors.name} />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<Label>{t('Permissions')}</Label>
|
|
{allKeys.length > 0 && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-auto px-2 py-1 text-xs"
|
|
onClick={() => setValue('abilities', allSelected ? [] : allKeys)}
|
|
>
|
|
{allSelected ? t('Clear all') : t('Select all')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<p className="text-muted-foreground text-sm">
|
|
{t('A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.')}
|
|
</p>
|
|
<p className="text-muted-foreground text-sm">
|
|
{t('Only permissions the API can currently act on are listed. More appear here as new endpoints are released.')}
|
|
</p>
|
|
|
|
{availableAbilities.length === 0 && (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t('There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.')}
|
|
</p>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
{availableAbilities.map((group) => {
|
|
const groupSelected = group.abilities.every((ability) => values.abilities.includes(ability.key));
|
|
|
|
return (
|
|
<div key={group.category} className="space-y-2">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="text-sm font-medium">{t(group.label)}</p>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground h-auto px-2 py-1 text-xs"
|
|
onClick={() => toggleGroup(group, !groupSelected)}
|
|
>
|
|
{groupSelected ? t('Clear all') : t('Select all')}
|
|
</Button>
|
|
</div>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{group.abilities.map((ability) => (
|
|
<div key={ability.key} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`ability-${ability.key}`}
|
|
checked={values.abilities.includes(ability.key)}
|
|
onCheckedChange={(checked) => toggleAbility(ability.key, checked === true)}
|
|
/>
|
|
<Label htmlFor={`ability-${ability.key}`} className="text-sm font-normal">
|
|
{t(ability.label)}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Both reach other people's sign-in: setting a password and
|
|
removing a second factor are how an administrator lets a
|
|
locked-out person back in, so a token holding either can
|
|
become the accounts it may edit. */}
|
|
{(values.abilities.includes('edit_clients') || values.abilities.includes('edit_users')) && (
|
|
<Alert>
|
|
<AlertDescription>
|
|
{t(
|
|
'This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.',
|
|
)}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<InputError message={errors.abilities ?? errors['abilities.0']} />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="expires_in_days">{t('Expires in (days)')}</Label>
|
|
<Input
|
|
id="expires_in_days"
|
|
type="number"
|
|
min={1}
|
|
max={maxDays}
|
|
value={values.expires_in_days}
|
|
disabled={values.never_expires}
|
|
onChange={(e) => setValue('expires_in_days', Number(e.target.value))}
|
|
className="max-w-32"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="never_expires"
|
|
checked={values.never_expires}
|
|
onCheckedChange={(checked) => setValue('never_expires', checked === true)}
|
|
/>
|
|
<Label htmlFor="never_expires" className="text-sm font-normal">
|
|
{t('Never expires')}
|
|
</Label>
|
|
</div>
|
|
{values.never_expires && (
|
|
<p className="text-sm text-amber-600 dark:text-amber-500">
|
|
{t('A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.')}
|
|
</p>
|
|
)}
|
|
<InputError message={errors.expires_in_days} />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|