mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🛡️ feat: Add Role Dropdown to Prompt/Agents Admin Settings (#4922)
* style: update AdminSettings dialog content styles for improved accessibility/theming * style: update icon colors in ExportAndShareMenu for improved theming * feat: enhance DropdownPopup component with additional props for customization * feat: add role selection dropdown to AdminSettings for enhanced user permissions management * feat: add role selection dropdown to AdminSettings for Prompt permission management * style: add gap to button in AdminSettings for improved layout * feat: add warning message for Admin role access in Permissions settings
This commit is contained in:
parent
1c05251826
commit
4640e1b124
5 changed files with 227 additions and 95 deletions
|
@ -50,12 +50,12 @@ export default function ExportAndShareMenu({
|
|||
{
|
||||
label: localize('com_endpoint_export'),
|
||||
onClick: exportHandler,
|
||||
icon: <Upload className="icon-md mr-2 dark:text-gray-300" />,
|
||||
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_share'),
|
||||
onClick: shareHandler,
|
||||
icon: <Share2 className="icon-md mr-2 dark:text-gray-300" />,
|
||||
icon: <Share2 className="icon-md mr-2 text-text-secondary" />,
|
||||
show: isSharedButtonEnabled,
|
||||
},
|
||||
];
|
||||
|
@ -72,7 +72,7 @@ export default function ExportAndShareMenu({
|
|||
aria-label="Export options"
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Upload className="icon-md dark:text-gray-300" aria-hidden="true" focusable="false" />
|
||||
<Upload className="icon-md text-text-secondary" aria-hidden="true" focusable="false" />
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={dropdownItems}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useEffect } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
|
@ -6,7 +7,7 @@ import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form
|
|||
import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui';
|
||||
import { useUpdatePromptPermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { Button, Switch } from '~/components/ui';
|
||||
import { Button, Switch, DropdownPopup } from '~/components/ui';
|
||||
import { useToastContext } from '~/Providers';
|
||||
|
||||
type FormValues = Record<Permissions, boolean>;
|
||||
|
@ -19,8 +20,6 @@ type LabelControllerProps = {
|
|||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const defaultValues = roleDefaults[SystemRoles.USER];
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
promptPerm,
|
||||
|
@ -32,7 +31,6 @@ const LabelController: React.FC<LabelControllerProps> = ({
|
|||
<button
|
||||
className="cursor-pointer select-none"
|
||||
type="button"
|
||||
// htmlFor={promptPerm}
|
||||
onClick={() =>
|
||||
setValue(promptPerm, !getValues(promptPerm), {
|
||||
shouldDirty: true,
|
||||
|
@ -70,6 +68,16 @@ const AdminSettings = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
if (roles?.[selectedRole]) {
|
||||
return roles[selectedRole][PermissionTypes.PROMPTS];
|
||||
}
|
||||
return roleDefaults[selectedRole][PermissionTypes.PROMPTS];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
|
@ -79,20 +87,16 @@ const AdminSettings = () => {
|
|||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: useMemo(() => {
|
||||
if (roles?.[SystemRoles.USER]) {
|
||||
return roles[SystemRoles.USER][PermissionTypes.PROMPTS];
|
||||
}
|
||||
|
||||
return defaultValues[PermissionTypes.PROMPTS];
|
||||
}, [roles]),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (roles?.[SystemRoles.USER]?.[PermissionTypes.PROMPTS]) {
|
||||
reset(roles[SystemRoles.USER][PermissionTypes.PROMPTS]);
|
||||
if (roles?.[selectedRole]?.[PermissionTypes.PROMPTS]) {
|
||||
reset(roles[selectedRole][PermissionTypes.PROMPTS]);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole][PermissionTypes.PROMPTS]);
|
||||
}
|
||||
}, [roles, reset]);
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
|
@ -103,20 +107,35 @@ const AdminSettings = () => {
|
|||
promptPerm: Permissions.SHARED_GLOBAL,
|
||||
label: localize('com_ui_prompts_allow_share_global'),
|
||||
},
|
||||
{
|
||||
promptPerm: Permissions.USE,
|
||||
label: localize('com_ui_prompts_allow_use'),
|
||||
},
|
||||
{
|
||||
promptPerm: Permissions.CREATE,
|
||||
label: localize('com_ui_prompts_allow_create'),
|
||||
},
|
||||
{
|
||||
promptPerm: Permissions.USE,
|
||||
label: localize('com_ui_prompts_allow_use'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: SystemRoles.USER, updates: data });
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
|
@ -129,33 +148,70 @@ const AdminSettings = () => {
|
|||
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_prompts',
|
||||
)}`}</OGDialogTitle>
|
||||
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ promptPerm, label }) => (
|
||||
<LabelController
|
||||
key={promptPerm}
|
||||
control={control}
|
||||
promptPerm={promptPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
))}
|
||||
<OGDialogContent className="w-1/4 border-border-light bg-surface-primary text-text-primary">
|
||||
<OGDialogTitle>
|
||||
{`${localize('com_ui_admin_settings')} - ${localize('com_ui_prompts')}`}
|
||||
</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
menuId="prompt-role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
className="border border-border-light bg-surface-primary"
|
||||
itemClassName="hover:bg-surface-tertiary items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ promptPerm, label }) => (
|
||||
<div key={promptPerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
promptPerm={promptPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
{selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE && (
|
||||
<>
|
||||
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
|
||||
<span>{localize('com_ui_admin_access_warning')}</span>
|
||||
{'\n'}
|
||||
<a
|
||||
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
{localize('com_ui_more_info')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import { useMemo, useEffect } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui';
|
||||
import { useUpdateAgentPermissionsMutation } from '~/data-provider';
|
||||
import { Button, Switch, DropdownPopup } from '~/components/ui';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { Button, Switch } from '~/components/ui';
|
||||
import { useToastContext } from '~/Providers';
|
||||
|
||||
type FormValues = Record<Permissions, boolean>;
|
||||
|
@ -19,8 +20,6 @@ type LabelControllerProps = {
|
|||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const defaultValues = roleDefaults[SystemRoles.USER];
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
agentPerm,
|
||||
|
@ -69,6 +68,16 @@ const AdminSettings = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
if (roles?.[selectedRole]) {
|
||||
return roles[selectedRole][PermissionTypes.AGENTS];
|
||||
}
|
||||
return roleDefaults[selectedRole][PermissionTypes.AGENTS];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
|
@ -78,20 +87,16 @@ const AdminSettings = () => {
|
|||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: useMemo(() => {
|
||||
if (roles?.[SystemRoles.USER]) {
|
||||
return roles[SystemRoles.USER][PermissionTypes.AGENTS];
|
||||
}
|
||||
|
||||
return defaultValues[PermissionTypes.AGENTS];
|
||||
}, [roles]),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (roles?.[SystemRoles.USER]?.[PermissionTypes.AGENTS]) {
|
||||
reset(roles[SystemRoles.USER][PermissionTypes.AGENTS]);
|
||||
if (roles?.[selectedRole]?.[PermissionTypes.AGENTS]) {
|
||||
reset(roles[selectedRole][PermissionTypes.AGENTS]);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole][PermissionTypes.AGENTS]);
|
||||
}
|
||||
}, [roles, reset]);
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
|
@ -102,59 +107,112 @@ const AdminSettings = () => {
|
|||
agentPerm: Permissions.SHARED_GLOBAL,
|
||||
label: localize('com_ui_agents_allow_share_global'),
|
||||
},
|
||||
{
|
||||
agentPerm: Permissions.USE,
|
||||
label: localize('com_ui_agents_allow_use'),
|
||||
},
|
||||
{
|
||||
agentPerm: Permissions.CREATE,
|
||||
label: localize('com_ui_agents_allow_create'),
|
||||
},
|
||||
{
|
||||
agentPerm: Permissions.USE,
|
||||
label: localize('com_ui_agents_allow_use'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: SystemRoles.USER, updates: data });
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative my-1 h-9 w-full rounded-lg font-medium"
|
||||
className="btn btn-neutral border-token-border-light relative my-1 h-9 w-full gap-1 rounded-lg font-medium"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-1/4 bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300">
|
||||
<OGDialogContent className="w-1/4 border-border-light bg-surface-primary text-text-primary">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_agents',
|
||||
)}`}</OGDialogTitle>
|
||||
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ agentPerm, label }) => (
|
||||
<LabelController
|
||||
key={agentPerm}
|
||||
control={control}
|
||||
agentPerm={agentPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
))}
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
menuId="role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
className="border border-border-light bg-surface-primary"
|
||||
itemClassName="hover:bg-surface-tertiary items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ agentPerm, label }) => (
|
||||
<div key={agentPerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
agentPerm={agentPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
{selectedRole === SystemRoles.ADMIN && agentPerm === Permissions.USE && (
|
||||
<>
|
||||
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
|
||||
<span>{localize('com_ui_admin_access_warning')}</span>
|
||||
{'\n'}
|
||||
<a
|
||||
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
{localize('com_ui_more_info')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
|
|
|
@ -17,7 +17,10 @@ interface DropdownProps {
|
|||
setIsOpen: (isOpen: boolean) => void;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
itemClassName?: string;
|
||||
sameWidth?: boolean;
|
||||
anchor?: { x: string; y: string };
|
||||
gutter?: number;
|
||||
modal?: boolean;
|
||||
menuId: string;
|
||||
}
|
||||
|
@ -29,7 +32,11 @@ const DropdownPopup: React.FC<DropdownProps> = ({
|
|||
setIsOpen,
|
||||
menuId,
|
||||
modal,
|
||||
gutter = 8,
|
||||
sameWidth,
|
||||
className,
|
||||
iconClassName,
|
||||
itemClassName,
|
||||
}) => {
|
||||
const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen });
|
||||
|
||||
|
@ -38,9 +45,13 @@ const DropdownPopup: React.FC<DropdownProps> = ({
|
|||
{trigger}
|
||||
<Ariakit.Menu
|
||||
id={menuId}
|
||||
className="absolute z-50 mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
gutter={8}
|
||||
className={cn(
|
||||
'absolute z-50 mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
className,
|
||||
)}
|
||||
gutter={gutter}
|
||||
modal={modal}
|
||||
sameWidth={sameWidth}
|
||||
>
|
||||
{items
|
||||
.filter((item) => item.show !== false)
|
||||
|
@ -50,7 +61,10 @@ const DropdownPopup: React.FC<DropdownProps> = ({
|
|||
) : (
|
||||
<Ariakit.MenuItem
|
||||
key={index}
|
||||
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg p-2.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover"
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-2 rounded-lg p-2.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover',
|
||||
itemClassName,
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
|
|
|
@ -206,6 +206,9 @@ export default {
|
|||
com_ui_version_var: 'Version {0}',
|
||||
com_ui_advanced: 'Advanced',
|
||||
com_ui_admin_settings: 'Admin Settings',
|
||||
com_ui_admin_access_warning:
|
||||
'Disabling Admin access to this feature may cause unexpected UI issues requiring refresh. If saved, the only way to revert is via the interface setting in librechat.yaml config which affects all roles.',
|
||||
com_ui_role_select: 'Role',
|
||||
com_ui_error_save_admin_settings: 'There was an error saving your admin settings.',
|
||||
com_ui_prompt_preview_not_shared: 'The author has not allowed collaboration for this prompt.',
|
||||
com_ui_prompt_name_required: 'Prompt Name is required',
|
||||
|
@ -381,6 +384,7 @@ export default {
|
|||
com_ui_unarchive: 'Unarchive',
|
||||
com_ui_unarchive_error: 'Failed to unarchive conversation',
|
||||
com_ui_more_options: 'More',
|
||||
com_ui_more_info: 'More info',
|
||||
com_ui_preview: 'Preview',
|
||||
com_ui_upload: 'Upload',
|
||||
com_ui_connect: 'Connect',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue