mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-31 09:00:00 -06:00
Compare commits
1 Commits
main
...
loading-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05a8cadbf5 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added a dedicated increased timeout for the document upload route
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added a feedback message upon request timeout
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Properly cleanup orphan file when the same document exists in trash
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Changed config key `config.server.routeTimeoutMs` to `config.server.defaultRouteTimeoutMs` (env variable remains the same)
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added api endpoint to check current API key (GET /api/api-keys/current)
|
||||
@@ -66,19 +66,6 @@ When creating an API key, you can select from the following permissions:
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Check current API key
|
||||
|
||||
**GET** `/api/api-keys/current`
|
||||
|
||||
Get information about the currently used API key.
|
||||
|
||||
- Required API key permissions: none
|
||||
- Response (JSON)
|
||||
- `apiKey`: The current API key information.
|
||||
- `id`: The API key ID.
|
||||
- `name`: The API key name.
|
||||
- `permissions`: The list of permissions associated with the API key.
|
||||
|
||||
### List organizations
|
||||
|
||||
**GET** `/api/organizations`
|
||||
|
||||
@@ -600,7 +600,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': 'Die Anfrage hat zu lange gedauert und ist abgelaufen. Bitte versuchen Sie es erneut.',
|
||||
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
|
||||
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
|
||||
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',
|
||||
|
||||
@@ -598,7 +598,6 @@ export const translations = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': 'The request took too long and timed out. Please try again.',
|
||||
'api-errors.document.already_exists': 'The document already exists',
|
||||
'api-errors.document.size_too_large': 'The file size is too large',
|
||||
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',
|
||||
|
||||
@@ -600,7 +600,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': 'La solicitud tardó demasiado y se agotó el tiempo. Por favor, inténtalo de nuevo.',
|
||||
'api-errors.document.already_exists': 'El documento ya existe',
|
||||
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
|
||||
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',
|
||||
|
||||
@@ -600,7 +600,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': 'La requête a pris trop de temps et a expiré. Veuillez réessayer.',
|
||||
'api-errors.document.already_exists': 'Le document existe déjà',
|
||||
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
|
||||
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',
|
||||
|
||||
@@ -600,7 +600,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': 'La richiesta ha impiegato troppo tempo ed è scaduta. Riprova.',
|
||||
'api-errors.document.already_exists': 'Il documento esiste già',
|
||||
'api-errors.document.size_too_large': 'Il file è troppo grande',
|
||||
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',
|
||||
|
||||
@@ -600,7 +600,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': 'Het verzoek duurde te lang en is verlopen. Probeer het opnieuw.',
|
||||
'api-errors.document.already_exists': 'Het document bestaat al',
|
||||
'api-errors.document.size_too_large': 'Het bestand is te groot',
|
||||
'api-errors.intake-emails.already_exists': 'Er bestaat al een intake-e-mail met dit adres.',
|
||||
|
||||
@@ -600,7 +600,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': 'Żądanie trwało zbyt długo i przekroczyło limit czasu. Spróbuj ponownie.',
|
||||
'api-errors.document.already_exists': 'Dokument już istnieje',
|
||||
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
|
||||
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',
|
||||
|
||||
@@ -600,7 +600,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': 'A solicitação demorou muito e expirou. Por favor, tente novamente.',
|
||||
'api-errors.document.already_exists': 'O documento já existe',
|
||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||
|
||||
@@ -600,7 +600,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': 'O pedido demorou muito tempo e expirou. Por favor, tente novamente.',
|
||||
'api-errors.document.already_exists': 'O documento já existe',
|
||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||
|
||||
@@ -600,7 +600,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': 'Cererea a durat prea mult și a expirat. Vă rugăm să încercați din nou.',
|
||||
'api-errors.document.already_exists': 'Documentul există deja',
|
||||
'api-errors.document.size_too_large': 'Fișierul este prea mare',
|
||||
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',
|
||||
|
||||
@@ -600,7 +600,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API errors
|
||||
|
||||
'api-errors.api.timeout': '请求耗时过长已超时。请重试。',
|
||||
'api-errors.document.already_exists': '文档已存在',
|
||||
'api-errors.document.size_too_large': '文件大小过大',
|
||||
'api-errors.intake-emails.already_exists': '具有此地址的接收邮箱已存在。',
|
||||
|
||||
@@ -106,7 +106,7 @@ export const EmailLoginForm: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
|
||||
<Button type="submit" class="w-full" isLoading={form.submitting}>{t('auth.login.form.submit')}</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export const EmailRegisterForm: Component = () => {
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full">{t('auth.register.form.submit')}</Button>
|
||||
<Button type="submit" class="w-full" isLoading={form.submitting}>{t('auth.register.form.submit')}</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ResetPasswordForm: Component<{ onSubmit: (args: { newPassword: stri
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full">
|
||||
<Button type="submit" class="w-full" isLoading={form.submitting}>
|
||||
{t('auth.reset-password.form.submit')}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import type { DropdownMenuSubTriggerProps } from '@kobalte/core/dropdown-menu';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { A } from '@solidjs/router';
|
||||
import { Show } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
|
||||
import { useDeleteDocument } from '../documents.composables';
|
||||
import { useRenameDocumentDialog } from './rename-document-button.component';
|
||||
|
||||
export const DocumentManagementDropdown: Component<{ document: Document }> = (props) => {
|
||||
const { deleteDocument } = useDeleteDocument();
|
||||
const { deleteDocument, getIsDeletingDocument } = useDeleteDocument();
|
||||
const { openRenameDialog } = useRenameDocumentDialog();
|
||||
|
||||
const deleteDoc = () => deleteDocument({
|
||||
@@ -52,8 +53,14 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
||||
<DropdownMenuItem
|
||||
class="cursor-pointer text-red"
|
||||
onClick={() => deleteDoc()}
|
||||
disabled={getIsDeletingDocument()}
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
<Show when={getIsDeletingDocument()}>
|
||||
<div class="i-tabler-loader-2 animate-spin size-4 mr-2" />
|
||||
</Show>
|
||||
<Show when={!getIsDeletingDocument()}>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
</Show>
|
||||
<span>Delete document</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const RenameDocumentDialog: Component<{
|
||||
<Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)}>
|
||||
{t('documents.rename.cancel')}
|
||||
</Button>
|
||||
<Button type="submit">{t('documents.rename.form.submit')}</Button>
|
||||
<Button type="submit" isLoading={renameDocumentMutation.isPending}>{t('documents.rename.form.submit')}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -24,8 +24,10 @@ function getConfirmMessage(documentName: string) {
|
||||
|
||||
export function useDeleteDocument() {
|
||||
const { confirm } = useConfirmModal();
|
||||
const [getIsDeletingDocument, setIsDeletingDocument] = createSignal(false);
|
||||
|
||||
return {
|
||||
getIsDeletingDocument,
|
||||
async deleteDocument({ documentId, organizationId, documentName }: { documentId: string; organizationId: string; documentName: string }): Promise<{ hasDeleted: boolean }> {
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Delete document',
|
||||
@@ -43,6 +45,8 @@ export function useDeleteDocument() {
|
||||
return { hasDeleted: false };
|
||||
}
|
||||
|
||||
setIsDeletingDocument(true);
|
||||
|
||||
await deleteDocument({
|
||||
documentId,
|
||||
organizationId,
|
||||
@@ -51,6 +55,8 @@ export function useDeleteDocument() {
|
||||
await invalidateOrganizationDocumentsQuery({ organizationId });
|
||||
createToast({ type: 'success', message: 'Document deleted' });
|
||||
|
||||
setIsDeletingDocument(false);
|
||||
|
||||
return { hasDeleted: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ const AllowedOriginsDialog: Component<{
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}> = (props) => {
|
||||
const [getAllowedOrigins, setAllowedOrigins] = createSignal(props.intakeEmails?.allowedOrigins || []);
|
||||
const [deletingOrigin, setDeletingOrigin] = createSignal<string | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const update = async () => {
|
||||
@@ -45,8 +46,10 @@ const AllowedOriginsDialog: Component<{
|
||||
};
|
||||
|
||||
const deleteAllowedOrigin = async ({ origin }: { origin: string }) => {
|
||||
setDeletingOrigin(origin);
|
||||
setAllowedOrigins(origins => origins.filter(o => o !== origin));
|
||||
await update();
|
||||
setDeletingOrigin(null);
|
||||
};
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
@@ -109,7 +112,7 @@ const AllowedOriginsDialog: Component<{
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<Button type="submit">
|
||||
<Button type="submit" isLoading={form.submitting}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('intake-emails.allowed-origins.add.button')}
|
||||
</Button>
|
||||
@@ -140,6 +143,7 @@ const AllowedOriginsDialog: Component<{
|
||||
size="icon"
|
||||
class="text-red"
|
||||
onClick={() => deleteAllowedOrigin({ origin })}
|
||||
isLoading={deletingOrigin() === origin}
|
||||
>
|
||||
<div class="i-tabler-trash size-4" />
|
||||
</Button>
|
||||
@@ -157,6 +161,9 @@ export const IntakeEmailsPage: Component = () => {
|
||||
const { t, te } = useI18n();
|
||||
const [selectedIntakeEmail, setSelectedIntakeEmail] = createSignal<IntakeEmail | null>(null);
|
||||
const [openDropdownId, setOpenDropdownId] = createSignal<string | null>(null);
|
||||
const [isCreatingEmail, setIsCreatingEmail] = createSignal(false);
|
||||
const [updatingEmailId, setUpdatingEmailId] = createSignal<string | null>(null);
|
||||
const [deletingEmailId, setDeletingEmailId] = createSignal<string | null>(null);
|
||||
|
||||
if (!config.intakeEmails.isEnabled) {
|
||||
return (
|
||||
@@ -195,6 +202,8 @@ export const IntakeEmailsPage: Component = () => {
|
||||
}));
|
||||
|
||||
const createEmail = async () => {
|
||||
setIsCreatingEmail(true);
|
||||
|
||||
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
|
||||
|
||||
if (error) {
|
||||
@@ -203,6 +212,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
setIsCreatingEmail(false);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -212,6 +222,8 @@ export const IntakeEmailsPage: Component = () => {
|
||||
message: t('intake-emails.create.success'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
setIsCreatingEmail(false);
|
||||
};
|
||||
|
||||
const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => {
|
||||
@@ -231,6 +243,8 @@ export const IntakeEmailsPage: Component = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingEmailId(intakeEmailId);
|
||||
|
||||
await deleteIntakeEmail({ organizationId: params.organizationId, intakeEmailId });
|
||||
await query.refetch();
|
||||
|
||||
@@ -238,9 +252,13 @@ export const IntakeEmailsPage: Component = () => {
|
||||
message: t('intake-emails.delete.success'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
setDeletingEmailId(null);
|
||||
};
|
||||
|
||||
const updateEmail = async ({ intakeEmailId, isEnabled }: { intakeEmailId: string; isEnabled: boolean }) => {
|
||||
setUpdatingEmailId(intakeEmailId);
|
||||
|
||||
await updateIntakeEmail({ organizationId: params.organizationId, intakeEmailId, isEnabled });
|
||||
await query.refetch();
|
||||
|
||||
@@ -248,6 +266,8 @@ export const IntakeEmailsPage: Component = () => {
|
||||
message: isEnabled ? t('intake-emails.update.success.enabled') : t('intake-emails.update.success.disabled'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
setUpdatingEmailId(null);
|
||||
};
|
||||
|
||||
const openAllowedOriginsDialog = (intakeEmail: IntakeEmail) => {
|
||||
@@ -284,7 +304,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
class="pt-0"
|
||||
icon="i-tabler-mail"
|
||||
cta={(
|
||||
<Button variant="secondary" onClick={createEmail}>
|
||||
<Button variant="secondary" onClick={createEmail} isLoading={isCreatingEmail()}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('intake-emails.empty.generate')}
|
||||
</Button>
|
||||
@@ -301,7 +321,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button onClick={createEmail}>
|
||||
<Button onClick={createEmail} isLoading={isCreatingEmail()}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('intake-emails.new')}
|
||||
</Button>
|
||||
@@ -359,8 +379,14 @@ export const IntakeEmailsPage: Component = () => {
|
||||
setOpenDropdownId(null);
|
||||
updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled });
|
||||
}}
|
||||
disabled={updatingEmailId() === intakeEmail.id}
|
||||
>
|
||||
<div class="i-tabler-power size-4 mr-2" />
|
||||
<Show when={updatingEmailId() === intakeEmail.id}>
|
||||
<div class="i-tabler-loader-2 animate-spin size-4 mr-2" />
|
||||
</Show>
|
||||
<Show when={updatingEmailId() !== intakeEmail.id}>
|
||||
<div class="i-tabler-power size-4 mr-2" />
|
||||
</Show>
|
||||
{intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -377,8 +403,14 @@ export const IntakeEmailsPage: Component = () => {
|
||||
deleteEmail({ intakeEmailId: intakeEmail.id });
|
||||
}}
|
||||
class="text-red"
|
||||
disabled={deletingEmailId() === intakeEmail.id}
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
<Show when={deletingEmailId() === intakeEmail.id}>
|
||||
<div class="i-tabler-loader-2 animate-spin size-4 mr-2" />
|
||||
</Show>
|
||||
<Show when={deletingEmailId() !== intakeEmail.id}>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
</Show>
|
||||
{t('intake-emails.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { OrganizationMemberRole } from '../organizations.types';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
@@ -30,6 +30,9 @@ const MemberList: Component = () => {
|
||||
|
||||
const { getIsAtLeastAdmin, getRole } = useCurrentUserRole({ organizationId: params.organizationId });
|
||||
|
||||
const [deletingMemberId, setDeletingMemberId] = createSignal<string | null>(null);
|
||||
const [updatingMemberId, setUpdatingMemberId] = createSignal<string | null>(null);
|
||||
|
||||
const removeMemberMutation = useMutation(() => ({
|
||||
mutationFn: ({ memberId }: { memberId: string }) => removeOrganizationMember({ organizationId: params.organizationId, memberId }),
|
||||
onSuccess: () => {
|
||||
@@ -75,11 +78,23 @@ const MemberList: Component = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
removeMemberMutation.mutate({ memberId });
|
||||
setDeletingMemberId(memberId);
|
||||
try {
|
||||
await removeMemberMutation.mutateAsync({ memberId });
|
||||
}
|
||||
finally {
|
||||
setDeletingMemberId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMemberRole = async ({ memberId, role }: { memberId: string; role: OrganizationMemberRole }) => {
|
||||
await updateMemberRoleMutation.mutateAsync({ memberId, role });
|
||||
setUpdatingMemberId(memberId);
|
||||
try {
|
||||
await updateMemberRoleMutation.mutateAsync({ memberId, role });
|
||||
}
|
||||
finally {
|
||||
setUpdatingMemberId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const table = createSolidTable({
|
||||
@@ -99,9 +114,14 @@ const MemberList: Component = () => {
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete({ memberId: data.row.original.id })}
|
||||
disabled={data.row.original.role === ORGANIZATION_ROLES.OWNER || !getIsAtLeastAdmin()}
|
||||
disabled={data.row.original.role === ORGANIZATION_ROLES.OWNER || !getIsAtLeastAdmin() || deletingMemberId() === data.row.original.id}
|
||||
>
|
||||
<div class="i-tabler-user-x size-4 mr-2" />
|
||||
<Show when={deletingMemberId() === data.row.original.id}>
|
||||
<div class="i-tabler-loader-2 animate-spin size-4 mr-2" />
|
||||
</Show>
|
||||
<Show when={deletingMemberId() !== data.row.original.id}>
|
||||
<div class="i-tabler-user-x size-4 mr-2" />
|
||||
</Show>
|
||||
{t('organizations.members.remove-from-organization')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -111,19 +131,19 @@ const MemberList: Component = () => {
|
||||
<DropdownMenuRadioGroup value={data.row.original.role} onChange={role => handleUpdateMemberRole({ memberId: data.row.original.id, role: role as OrganizationMemberRole })}>
|
||||
<DropdownMenuRadioItem
|
||||
value={ORGANIZATION_ROLES.OWNER}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.OWNER })}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.OWNER }) || updatingMemberId() === data.row.original.id}
|
||||
>
|
||||
{t(`organizations.members.roles.owner`)}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value={ORGANIZATION_ROLES.ADMIN}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.ADMIN })}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.ADMIN }) || updatingMemberId() === data.row.original.id}
|
||||
>
|
||||
{t(`organizations.members.roles.admin`)}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value={ORGANIZATION_ROLES.MEMBER}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.MEMBER })}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.MEMBER }) || updatingMemberId() === data.row.original.id}
|
||||
>
|
||||
{t(`organizations.members.roles.member`)}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
@@ -284,7 +284,7 @@ export const TaggingRuleForm: Component<{
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
<Button type="submit">{props.submitButtonText ?? t('tagging-rules.form.submit')}</Button>
|
||||
<Button type="submit" isLoading={form.submitting}>{props.submitButtonText ?? t('tagging-rules.form.submit')}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -115,7 +115,7 @@ const TagForm: Component<{
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-row-reverse justify-between items-center mt-6">
|
||||
<Button type="submit">
|
||||
<Button type="submit" isLoading={form.submitting}>
|
||||
{props.submitLabel ?? t('tags.create')}
|
||||
</Button>
|
||||
|
||||
@@ -229,6 +229,7 @@ export const TagsPage: Component = () => {
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
const { getErrorMessage } = useI18nApiErrors({ t });
|
||||
const [deletingTagId, setDeletingTagId] = createSignal<string | null>(null);
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||
@@ -253,6 +254,8 @@ export const TagsPage: Component = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingTagId(tag.id);
|
||||
|
||||
const [, error] = await safely(deleteTag({
|
||||
organizationId: params.organizationId,
|
||||
tagId: tag.id,
|
||||
@@ -264,6 +267,7 @@ export const TagsPage: Component = () => {
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
setDeletingTagId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,6 +280,8 @@ export const TagsPage: Component = () => {
|
||||
message: t('tags.delete.success'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
setDeletingTagId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -368,7 +374,7 @@ export const TagsPage: Component = () => {
|
||||
)}
|
||||
</UpdateTagModal>
|
||||
|
||||
<Button size="icon" variant="outline" class="size-7 text-red" onClick={() => del({ tag })}>
|
||||
<Button size="icon" variant="outline" class="size-7 text-red" onClick={() => del({ tag })} isLoading={deletingTagId() === tag.id}>
|
||||
<div class="i-tabler-trash size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For } from 'solid-js';
|
||||
import { Skeleton } from '../skeleton';
|
||||
|
||||
type cardSkeletonProps = {
|
||||
lines?: number;
|
||||
};
|
||||
|
||||
export const CardSkeleton: Component<cardSkeletonProps> = (props) => {
|
||||
const lines = () => props.lines ?? 3;
|
||||
|
||||
return (
|
||||
<div class="border border-border rounded-lg p-4">
|
||||
<Skeleton class="h-6 w-1/3 mb-3" />
|
||||
<div class="space-y-2">
|
||||
<For each={Array.from({ length: lines() })}>
|
||||
{(_, index) => (
|
||||
<Skeleton class={`h-4 ${index() === lines() - 1 ? 'w-2/3' : 'w-full'}`} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For } from 'solid-js';
|
||||
import { CardSkeleton } from './card-skeleton';
|
||||
|
||||
type gridSkeletonProps = {
|
||||
items?: number;
|
||||
columns?: number;
|
||||
};
|
||||
|
||||
export const GridSkeleton: Component<gridSkeletonProps> = (props) => {
|
||||
const items = () => props.items ?? 6;
|
||||
const columns = () => props.columns ?? 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="grid gap-4"
|
||||
style={{
|
||||
'grid-template-columns': `repeat(${columns()}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
<For each={Array.from({ length: items() })}>
|
||||
{() => <CardSkeleton />}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { TableSkeleton } from './table-skeleton';
|
||||
export { CardSkeleton } from './card-skeleton';
|
||||
export { GridSkeleton } from './grid-skeleton';
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For } from 'solid-js';
|
||||
import { Skeleton } from '../skeleton';
|
||||
|
||||
type tableSkeletonProps = {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
};
|
||||
|
||||
export const TableSkeleton: Component<tableSkeletonProps> = (props) => {
|
||||
const rows = () => props.rows ?? 5;
|
||||
const columns = () => props.columns ?? 4;
|
||||
|
||||
return (
|
||||
<div class="w-full">
|
||||
<For each={Array.from({ length: rows() })}>
|
||||
{() => (
|
||||
<div class="flex gap-4 py-3 border-b border-border/80">
|
||||
<For each={Array.from({ length: columns() })}>
|
||||
{(_, index) => (
|
||||
<div class={index() === 0 ? 'flex-1' : 'w-24'}>
|
||||
<Skeleton class="h-5 w-full" />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import { createErrorFactory } from '../shared/errors/errors';
|
||||
|
||||
// Error when the authentication is not using an API key but the route is api-key only
|
||||
export const createNotApiKeyAuthError = createErrorFactory({
|
||||
code: 'api_keys.authentication_not_api_key',
|
||||
message: 'Authentication must be done using an API key to access this resource',
|
||||
statusCode: 401,
|
||||
});
|
||||
@@ -1,21 +1,17 @@
|
||||
import type { RouteDefinitionContext } from '../app/server.types';
|
||||
import type { ApiKeyPermissions } from './api-keys.types';
|
||||
import { z } from 'zod';
|
||||
import { createUnauthorizedError } from '../app/auth/auth.errors';
|
||||
import { requireAuthentication } from '../app/auth/auth.middleware';
|
||||
import { getUser } from '../app/auth/auth.models';
|
||||
import { createError } from '../shared/errors/errors';
|
||||
import { isNil } from '../shared/utils';
|
||||
import { validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||
import { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants';
|
||||
import { createNotApiKeyAuthError } from './api-keys.errors';
|
||||
import { createApiKeysRepository } from './api-keys.repository';
|
||||
import { apiKeyIdSchema } from './api-keys.schemas';
|
||||
import { createApiKey } from './api-keys.usecases';
|
||||
|
||||
export function registerApiKeysRoutes(context: RouteDefinitionContext) {
|
||||
setupCreateApiKeyRoute(context);
|
||||
setupGetCurrentApiKeyRoute(context); // Should be before the get api keys route otherwise it conflicts ("current" as apiKeyId)
|
||||
setupGetApiKeysRoute(context);
|
||||
setupDeleteApiKeyRoute(context);
|
||||
}
|
||||
@@ -86,38 +82,6 @@ function setupGetApiKeysRoute({ app, db }: RouteDefinitionContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// Mainly use for authentication verification in client SDKs
|
||||
function setupGetCurrentApiKeyRoute({ app }: RouteDefinitionContext) {
|
||||
app.get(
|
||||
'/api/api-keys/current',
|
||||
async (context) => {
|
||||
const authType = context.get('authType');
|
||||
const apiKey = context.get('apiKey');
|
||||
|
||||
if (isNil(authType)) {
|
||||
throw createUnauthorizedError();
|
||||
}
|
||||
|
||||
if (authType !== 'api-key') {
|
||||
throw createNotApiKeyAuthError();
|
||||
}
|
||||
|
||||
if (isNil(apiKey)) {
|
||||
// Should not happen as authType is 'api-key', but for type safety
|
||||
throw createUnauthorizedError();
|
||||
}
|
||||
|
||||
return context.json({
|
||||
apiKey: {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
permissions: apiKey.permissions,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setupDeleteApiKeyRoute({ app, db }: RouteDefinitionContext) {
|
||||
app.delete(
|
||||
'/api/api-keys/:apiKeyId',
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createInMemoryDatabase } from '../../app/database/database.test-utils';
|
||||
import { createServer } from '../../app/server';
|
||||
import { createTestServerDependencies } from '../../app/server.test-utils';
|
||||
import { overrideConfig } from '../../config/config.test-utils';
|
||||
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
|
||||
import { API_KEY_ID_PREFIX, API_KEY_TOKEN_LENGTH } from '../api-keys.constants';
|
||||
|
||||
describe('api-key e2e', () => {
|
||||
describe('get /api/api-keys/current', () => {
|
||||
test('when using an api key, one can request the /api/api-keys/current route to check that the api key is valid', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||
organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }],
|
||||
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
env: 'test',
|
||||
documentsStorage: {
|
||||
driver: 'in-memory',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const createApiKeyResponse = await app.request(
|
||||
'/api/api-keys',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Test API Key',
|
||||
permissions: ['documents:create'],
|
||||
}),
|
||||
},
|
||||
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||
);
|
||||
|
||||
expect(createApiKeyResponse.status).toBe(200);
|
||||
const { token, apiKey } = await createApiKeyResponse.json() as { token: string; apiKey: { id: string } };
|
||||
|
||||
const getCurrentApiKeyResponse = await app.request(
|
||||
'/api/api-keys/current',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const response = await getCurrentApiKeyResponse.json();
|
||||
|
||||
expect(response).to.deep.equal({
|
||||
apiKey: {
|
||||
id: apiKey.id,
|
||||
name: 'Test API Key',
|
||||
permissions: ['documents:create'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getCurrentApiKeyResponse.status).toBe(200);
|
||||
});
|
||||
|
||||
test('when not using an api key, requesting the /api/api-keys/current route returns an error', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||
organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }],
|
||||
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
env: 'test',
|
||||
documentsStorage: {
|
||||
driver: 'in-memory',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const getCurrentApiKeyResponse = await app.request(
|
||||
'/api/api-keys/current',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||
);
|
||||
|
||||
expect(getCurrentApiKeyResponse.status).toBe(401);
|
||||
const response = await getCurrentApiKeyResponse.json();
|
||||
|
||||
expect(response).to.deep.equal({
|
||||
error: {
|
||||
code: 'api_keys.authentication_not_api_key',
|
||||
message: 'Authentication must be done using an API key to access this resource',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('when not authenticated at all, requesting the /api/api-keys/current route returns an error', async () => {
|
||||
const { db } = await createInMemoryDatabase();
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
env: 'test',
|
||||
documentsStorage: {
|
||||
driver: 'in-memory',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const getCurrentApiKeyResponse = await app.request(
|
||||
'/api/api-keys/current',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
);
|
||||
|
||||
expect(getCurrentApiKeyResponse.status).toBe(401);
|
||||
const response = await getCurrentApiKeyResponse.json();
|
||||
|
||||
expect(response).to.deep.equal({
|
||||
error: {
|
||||
code: 'auth.unauthorized',
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('if the api key used is invalid, requesting the /api/api-keys/current route returns an error', async () => {
|
||||
const { db } = await createInMemoryDatabase();
|
||||
const invalidButLegitApiKeyToken = `${API_KEY_ID_PREFIX}_${'x'.repeat(API_KEY_TOKEN_LENGTH)}`;
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
env: 'test',
|
||||
documentsStorage: {
|
||||
driver: 'in-memory',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const getCurrentApiKeyResponse = await app.request(
|
||||
'/api/api-keys/current',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${invalidButLegitApiKeyToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getCurrentApiKeyResponse.status).toBe(401);
|
||||
const response = await getCurrentApiKeyResponse.json();
|
||||
|
||||
expect(response).to.deep.equal({
|
||||
error: {
|
||||
code: 'auth.unauthorized',
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { createTimeoutMiddleware } from './timeout.middleware';
|
||||
describe('middlewares', () => {
|
||||
describe('timeoutMiddleware', () => {
|
||||
test('when a request last longer than the config timeout, a 504 error is raised', async () => {
|
||||
const config = overrideConfig({ server: { defaultRouteTimeoutMs: 50 } });
|
||||
const config = overrideConfig({ server: { routeTimeoutMs: 50 } });
|
||||
|
||||
const app = new Hono<ServerInstanceGenerics>();
|
||||
registerErrorMiddleware({ app });
|
||||
@@ -45,107 +45,5 @@ describe('middlewares', () => {
|
||||
expect(response2.status).to.eql(200);
|
||||
expect(await response2.json()).to.eql({ status: 'ok' });
|
||||
});
|
||||
|
||||
test('route-specific timeout overrides default timeout for matching routes', async () => {
|
||||
const config = overrideConfig({
|
||||
server: {
|
||||
defaultRouteTimeoutMs: 50,
|
||||
routeTimeouts: [
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/api/upload/:id',
|
||||
timeoutMs: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const app = new Hono<ServerInstanceGenerics>();
|
||||
registerErrorMiddleware({ app });
|
||||
|
||||
// POST to matching route with longer timeout - should not timeout
|
||||
app.post(
|
||||
'/api/upload/:id',
|
||||
createTimeoutMiddleware({ config }),
|
||||
async (context) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return context.json({ status: 'ok' });
|
||||
},
|
||||
);
|
||||
|
||||
// GET to same route - should timeout with default
|
||||
app.get(
|
||||
'/api/upload/:id',
|
||||
createTimeoutMiddleware({ config }),
|
||||
async (context) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return context.json({ status: 'ok' });
|
||||
},
|
||||
);
|
||||
|
||||
// Different route - should timeout with default
|
||||
app.post(
|
||||
'/api/other',
|
||||
createTimeoutMiddleware({ config }),
|
||||
async (context) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return context.json({ status: 'ok' });
|
||||
},
|
||||
);
|
||||
|
||||
// POST to matching pattern should succeed
|
||||
const response1 = await app.request('/api/upload/123', { method: 'POST' });
|
||||
expect(response1.status).to.eql(200);
|
||||
|
||||
// GET to same path should timeout (method mismatch)
|
||||
const response2 = await app.request('/api/upload/123', { method: 'GET' });
|
||||
expect(response2.status).to.eql(504);
|
||||
|
||||
// POST to different path should timeout (path mismatch)
|
||||
const response3 = await app.request('/api/other', { method: 'POST' });
|
||||
expect(response3.status).to.eql(504);
|
||||
});
|
||||
|
||||
test('when registered globally with .use(), route-specific timeouts should work', async () => {
|
||||
const config = overrideConfig({
|
||||
server: {
|
||||
defaultRouteTimeoutMs: 50,
|
||||
routeTimeouts: [
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/api/organizations/:orgId/documents',
|
||||
timeoutMs: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const app = new Hono<ServerInstanceGenerics>();
|
||||
registerErrorMiddleware({ app });
|
||||
|
||||
// Register middleware globally (like in server.ts)
|
||||
app.use(createTimeoutMiddleware({ config }));
|
||||
|
||||
// Route that should have extended timeout
|
||||
app.post('/api/organizations/:orgId/documents', async (context) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return context.json({ status: 'upload ok' });
|
||||
});
|
||||
|
||||
// Route that should use default timeout
|
||||
app.get('/api/other', async (context) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return context.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// POST to upload route should succeed (extended timeout)
|
||||
const response1 = await app.request('/api/organizations/org-123/documents', { method: 'POST' });
|
||||
expect(response1.status).to.eql(200);
|
||||
expect(await response1.json()).to.eql({ status: 'upload ok' });
|
||||
|
||||
// GET to other route should timeout (default timeout)
|
||||
const response2 = await app.request('/api/other', { method: 'GET' });
|
||||
expect(response2.status).to.eql(504);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,42 +1,11 @@
|
||||
import type { Config } from '../../config/config.types';
|
||||
import type { Context } from '../server.types';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import { routePath } from 'hono/route';
|
||||
import { createError } from '../../shared/errors/errors';
|
||||
|
||||
function getTimeoutForRoute({
|
||||
defaultRouteTimeoutMs,
|
||||
routeTimeouts,
|
||||
method,
|
||||
path,
|
||||
}: {
|
||||
defaultRouteTimeoutMs: number;
|
||||
routeTimeouts: { method: string; route: string; timeoutMs: number }[];
|
||||
method: string;
|
||||
path: string;
|
||||
}): number {
|
||||
const matchingRoute = routeTimeouts.find((routeConfig) => {
|
||||
if (routeConfig.method !== method) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (routeConfig.route !== path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return matchingRoute?.timeoutMs ?? defaultRouteTimeoutMs;
|
||||
}
|
||||
|
||||
export function createTimeoutMiddleware({ config }: { config: Config }) {
|
||||
return createMiddleware(async (context: Context, next) => {
|
||||
const method = context.req.method;
|
||||
const path = routePath(context, -1); // Get the last matched route path, without the -1 we get /* for all routes
|
||||
const { defaultRouteTimeoutMs, routeTimeouts } = config.server;
|
||||
|
||||
const timeoutMs = getTimeoutForRoute({ defaultRouteTimeoutMs, routeTimeouts, method, path });
|
||||
const { server: { routeTimeoutMs } } = config;
|
||||
|
||||
let timerId: NodeJS.Timeout | undefined;
|
||||
|
||||
@@ -47,7 +16,7 @@ export function createTimeoutMiddleware({ config }: { config: Config }) {
|
||||
message: 'The request timed out',
|
||||
statusCode: 504,
|
||||
}),
|
||||
), timeoutMs);
|
||||
), routeTimeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { intakeEmailsConfig } from '../intake-emails/intake-emails.config';
|
||||
import { organizationsConfig } from '../organizations/organizations.config';
|
||||
import { organizationPlansConfig } from '../plans/plans.config';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { IN_MS } from '../shared/units';
|
||||
import { isString } from '../shared/utils';
|
||||
import { subscriptionsConfig } from '../subscriptions/subscriptions.config';
|
||||
import { tasksConfig } from '../tasks/tasks.config';
|
||||
@@ -85,29 +84,12 @@ export const configDefinition = {
|
||||
default: '0.0.0.0',
|
||||
env: 'SERVER_HOSTNAME',
|
||||
},
|
||||
defaultRouteTimeoutMs: {
|
||||
routeTimeoutMs: {
|
||||
doc: 'The maximum time in milliseconds for a route to complete before timing out',
|
||||
schema: z.coerce.number().int().positive(),
|
||||
default: 20 * IN_MS.SECOND,
|
||||
default: 20_000,
|
||||
env: 'SERVER_API_ROUTES_TIMEOUT_MS',
|
||||
},
|
||||
routeTimeouts: {
|
||||
doc: 'Route-specific timeout overrides. Allows setting different timeouts for specific HTTP method and route paths.',
|
||||
schema: z.array(
|
||||
z.object({
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']),
|
||||
route: z.string(),
|
||||
timeoutMs: z.number().int().positive(),
|
||||
}),
|
||||
),
|
||||
default: [
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/api/organizations/:organizationId/documents',
|
||||
timeoutMs: 5 * IN_MS.MINUTE,
|
||||
},
|
||||
],
|
||||
},
|
||||
corsOrigins: {
|
||||
doc: 'The CORS origin for the api server',
|
||||
schema: z.union([
|
||||
|
||||
@@ -6,11 +6,8 @@ import { getUser } from '../app/auth/auth.models';
|
||||
import { organizationIdSchema } from '../organizations/organization.schemas';
|
||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||
import { createPlansRepository } from '../plans/plans.repository';
|
||||
import { getOrganizationPlan } from '../plans/plans.usecases';
|
||||
import { getFileStreamFromMultipartForm } from '../shared/streams/file-upload';
|
||||
import { validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createDocumentIsNotDeletedError } from './documents.errors';
|
||||
import { formatDocumentForApi, formatDocumentsForApi, isDocumentSizeLimitEnabled } from './documents.models';
|
||||
import { createDocumentsRepository } from './documents.repository';
|
||||
@@ -48,17 +45,12 @@ function setupCreateDocumentRoute({ app, ...deps }: RouteDefinitionContext) {
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
// Get organization's plan-specific upload limit
|
||||
const plansRepository = createPlansRepository({ config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
const { organizationPlan } = await getOrganizationPlan({ organizationId, plansRepository, subscriptionsRepository });
|
||||
const { maxFileSize } = organizationPlan.limits;
|
||||
const { maxUploadSize } = config.documentsStorage;
|
||||
|
||||
const { fileStream, fileName, mimeType } = await getFileStreamFromMultipartForm({
|
||||
body: context.req.raw.body,
|
||||
headers: context.req.header(),
|
||||
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize: maxFileSize }) ? maxFileSize : undefined,
|
||||
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : undefined,
|
||||
});
|
||||
|
||||
const createDocument = createDocumentCreationUsecase({ ...deps });
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createTestEventServices } from '../app/events/events.test-utils';
|
||||
import { overrideConfig } from '../config/config.test-utils';
|
||||
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
||||
import { createOrganizationDocumentStorageLimitReachedError } from '../organizations/organizations.errors';
|
||||
import { createDeterministicIdGenerator } from '../shared/random/ids';
|
||||
import { collectReadableStreamToString, createReadableStream } from '../shared/streams/readable-stream';
|
||||
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
|
||||
import { createTagsRepository } from '../tags/tags.repository';
|
||||
@@ -245,83 +244,6 @@ describe('documents usecases', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
test('when restoring a deleted document via duplicate upload, the optimistically saved new file should be cleaned up to prevent orphan files', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const config = overrideConfig({
|
||||
organizationPlans: { isFreePlanUnlimited: true },
|
||||
documentsStorage: { driver: 'in-memory' },
|
||||
});
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const inMemoryDocumentsStorageService = inMemoryStorageDriverFactory();
|
||||
|
||||
const createDocument = createDocumentCreationUsecase({
|
||||
db,
|
||||
config,
|
||||
generateDocumentId: createDeterministicIdGenerator({ prefix: 'doc' }),
|
||||
documentsStorageService: inMemoryDocumentsStorageService,
|
||||
taskServices,
|
||||
eventServices: createTestEventServices(),
|
||||
});
|
||||
|
||||
const userId = 'user-1';
|
||||
const organizationId = 'organization-1';
|
||||
|
||||
// Step 1: Upload a file
|
||||
const { document: document1 } = await createDocument({
|
||||
fileStream: createReadableStream({ content: 'Hello, world!' }),
|
||||
fileName: 'file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
userId,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
expect(document1.id).to.eql('doc_000000000000000000000001');
|
||||
expect(
|
||||
Array.from(inMemoryDocumentsStorageService._getStorage().keys()),
|
||||
).to.eql([
|
||||
'organization-1/originals/doc_000000000000000000000001.pdf',
|
||||
]);
|
||||
|
||||
// Step 2: Delete the document (soft delete)
|
||||
await trashDocument({
|
||||
documentId: document1.id,
|
||||
organizationId,
|
||||
userId,
|
||||
documentsRepository,
|
||||
eventServices: createTestEventServices(),
|
||||
});
|
||||
|
||||
const { document: trashedDoc } = await documentsRepository.getDocumentById({ documentId: document1.id, organizationId });
|
||||
expect(trashedDoc?.isDeleted).to.eql(true);
|
||||
|
||||
// Step 3: Upload the same file again - this should restore the original document
|
||||
const { document: restoredDocument } = await createDocument({
|
||||
fileStream: createReadableStream({ content: 'Hello, world!' }),
|
||||
fileName: 'file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
userId,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
// The document should be restored (same ID)
|
||||
expect(restoredDocument.id).to.eql('doc_000000000000000000000001');
|
||||
expect(restoredDocument.isDeleted).to.eql(false);
|
||||
|
||||
// Step 5: Verify no orphan files remain in storage
|
||||
// The optimistically saved file (doc_2.pdf) should have been cleaned up during restoration
|
||||
expect(
|
||||
Array.from(inMemoryDocumentsStorageService._getStorage().keys()),
|
||||
).to.eql([
|
||||
'organization-1/originals/doc_000000000000000000000001.pdf',
|
||||
]);
|
||||
});
|
||||
|
||||
test('when there is an issue when inserting the document in the db, the file should not be saved in the storage', async () => {
|
||||
const taskServices = createInMemoryTaskServices();
|
||||
const { db } = await createInMemoryDatabase({
|
||||
|
||||
@@ -235,10 +235,9 @@ async function handleExistingDocument({
|
||||
newDocumentStorageKey: string;
|
||||
logger: Logger;
|
||||
}) {
|
||||
// Delete the newly uploaded file since we'll be using the existing document's file
|
||||
await documentsStorageService.deleteFile({ storageKey: newDocumentStorageKey });
|
||||
|
||||
if (!existingDocument.isDeleted) {
|
||||
await documentsStorageService.deleteFile({ storageKey: newDocumentStorageKey });
|
||||
|
||||
throw createDocumentAlreadyExistsError();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createServer } from '../../app/server';
|
||||
import { createTestServerDependencies } from '../../app/server.test-utils';
|
||||
import { overrideConfig } from '../../config/config.test-utils';
|
||||
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
|
||||
import { PLUS_PLAN_ID, PRO_PLAN_ID } from '../../plans/plans.constants';
|
||||
import { documentsTable } from '../documents.table';
|
||||
import { inMemoryStorageDriverFactory } from '../storage/drivers/memory/memory.storage-driver';
|
||||
|
||||
@@ -248,123 +247,5 @@ describe('documents e2e', () => {
|
||||
expect(retrievedDocument).to.eql({ ...document, tags: [] });
|
||||
}
|
||||
});
|
||||
|
||||
test('organizations on Plus plan should be able to upload files up to 100 MiB (not limited by global config)', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||
organizations: [{ id: 'org_222222222222222222222222', name: 'Plus Org', customerId: 'cus_plus123' }],
|
||||
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||
organizationSubscriptions: [{
|
||||
id: 'sub_plus123',
|
||||
customerId: 'cus_plus123',
|
||||
organizationId: 'org_222222222222222222222222',
|
||||
planId: PLUS_PLAN_ID,
|
||||
status: 'active',
|
||||
seatsCount: 5,
|
||||
currentPeriodStart: new Date('2024-01-01'),
|
||||
currentPeriodEnd: new Date('2024-02-01'),
|
||||
cancelAtPeriodEnd: false,
|
||||
}],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
env: 'test',
|
||||
documentsStorage: {
|
||||
driver: 'in-memory',
|
||||
// Global config set to 10 MiB (simulating free tier limit)
|
||||
maxUploadSize: 1024 * 1024 * 10, // 10 MiB
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// File size: 50 MiB - exceeds global config (10 MiB) but within Plus plan limit (100 MiB)
|
||||
const fileSizeBytes = 1024 * 1024 * 50; // 50 MiB
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File(['a'.repeat(fileSizeBytes)], 'large-document.txt', { type: 'text/plain' }));
|
||||
const body = new Response(formData);
|
||||
|
||||
const createDocumentResponse = await app.request(
|
||||
'/api/organizations/org_222222222222222222222222/documents',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...Object.fromEntries(body.headers.entries()),
|
||||
},
|
||||
body: await body.arrayBuffer(),
|
||||
},
|
||||
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||
);
|
||||
|
||||
// Should succeed because Plus plan allows 100 MiB
|
||||
expect(createDocumentResponse.status).to.eql(200);
|
||||
const { document } = (await createDocumentResponse.json()) as { document: Document };
|
||||
|
||||
expect(document).to.include({
|
||||
name: 'large-document.txt',
|
||||
mimeType: 'text/plain',
|
||||
originalSize: fileSizeBytes,
|
||||
});
|
||||
});
|
||||
|
||||
test('organizations on Pro plan should be able to upload files up to 500 MiB (not limited by global config)', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||
organizations: [{ id: 'org_333333333333333333333333', name: 'Pro Org', customerId: 'cus_pro123' }],
|
||||
organizationMembers: [{ organizationId: 'org_333333333333333333333333', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||
organizationSubscriptions: [{
|
||||
id: 'sub_pro123',
|
||||
customerId: 'cus_pro123',
|
||||
organizationId: 'org_333333333333333333333333',
|
||||
planId: PRO_PLAN_ID,
|
||||
status: 'active',
|
||||
seatsCount: 20,
|
||||
currentPeriodStart: new Date('2024-01-01'),
|
||||
currentPeriodEnd: new Date('2024-02-01'),
|
||||
cancelAtPeriodEnd: false,
|
||||
}],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
env: 'test',
|
||||
documentsStorage: {
|
||||
driver: 'in-memory',
|
||||
// Global config set to 10 MiB (simulating free tier limit)
|
||||
maxUploadSize: 1024 * 1024 * 10, // 10 MiB
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// File size: 200 MiB - exceeds global config (10 MiB) but within Pro plan limit (500 MiB)
|
||||
const fileSizeBytes = 1024 * 1024 * 200; // 200 MiB
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File(['a'.repeat(fileSizeBytes)], 'very-large-document.txt', { type: 'text/plain' }));
|
||||
const body = new Response(formData);
|
||||
|
||||
const createDocumentResponse = await app.request(
|
||||
'/api/organizations/org_333333333333333333333333/documents',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...Object.fromEntries(body.headers.entries()),
|
||||
},
|
||||
body: await body.arrayBuffer(),
|
||||
},
|
||||
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||
);
|
||||
|
||||
// Should succeed because Pro plan allows 500 MiB
|
||||
expect(createDocumentResponse.status).to.eql(200);
|
||||
const { document } = (await createDocumentResponse.json()) as { document: Document };
|
||||
|
||||
expect(document).to.include({
|
||||
name: 'very-large-document.txt',
|
||||
mimeType: 'text/plain',
|
||||
originalSize: fileSizeBytes,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'zod';
|
||||
import { booleanishSchema } from '../config/config.schemas';
|
||||
import { IN_MS } from '../shared/units';
|
||||
import { isString } from '../shared/utils';
|
||||
import { defaultIgnoredPatterns } from './ingestion-folders.constants';
|
||||
|
||||
@@ -28,7 +27,7 @@ export const ingestionFolderConfig = {
|
||||
pollingInterval: {
|
||||
doc: 'When polling is used, this is the interval at which the watcher checks for changes in the ingestion folder (in milliseconds)',
|
||||
schema: z.coerce.number().int().positive(),
|
||||
default: 2 * IN_MS.SECOND,
|
||||
default: 2_000,
|
||||
env: 'INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Config } from '../config/config.types';
|
||||
import type { OrganizationPlanRecord } from './plans.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { isDocumentSizeLimitEnabled } from '../documents/documents.models';
|
||||
import { IN_BYTES } from '../shared/units';
|
||||
import { FREE_PLAN_ID, PLUS_PLAN_ID, PRO_PLAN_ID } from './plans.constants';
|
||||
import { createPlanNotFoundError } from './plans.errors';
|
||||
|
||||
@@ -31,7 +30,7 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
||||
id: FREE_PLAN_ID,
|
||||
name: 'Free',
|
||||
limits: {
|
||||
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 500 * IN_BYTES.MEGABYTE,
|
||||
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1024 * 1024 * 500, // 500 MiB
|
||||
maxIntakeEmailsCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1,
|
||||
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 3,
|
||||
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : Number.POSITIVE_INFINITY,
|
||||
@@ -43,10 +42,10 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
||||
monthlyPriceId: config.organizationPlans.plusPlanMonthlyPriceId,
|
||||
annualPriceId: config.organizationPlans.plusPlanAnnualPriceId,
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 5 * IN_BYTES.GIGABYTE, // 5 GiB
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 5, // 5 GiB
|
||||
maxIntakeEmailsCount: 10,
|
||||
maxOrganizationsMembersCount: 10,
|
||||
maxFileSize: 100 * IN_BYTES.MEGABYTE, // 100 MiB
|
||||
maxFileSize: 1024 * 1024 * 100, // 100 MiB
|
||||
},
|
||||
},
|
||||
[PRO_PLAN_ID]: {
|
||||
@@ -55,10 +54,10 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
||||
monthlyPriceId: config.organizationPlans.proPlanMonthlyPriceId,
|
||||
annualPriceId: config.organizationPlans.proPlanAnnualPriceId,
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 50 * IN_BYTES.GIGABYTE, // 50 GiB
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 50, // 50 GiB
|
||||
maxIntakeEmailsCount: 100,
|
||||
maxOrganizationsMembersCount: 50,
|
||||
maxFileSize: 500 * IN_BYTES.MEGABYTE, // 500 MiB
|
||||
maxFileSize: 1024 * 1024 * 500, // 500 MiB
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export const IN_MS = {
|
||||
SECOND: 1_000,
|
||||
MINUTE: 60_000, // 60 * 1_000
|
||||
HOUR: 3_600_000, // 60 * 60 * 1_000
|
||||
DAY: 86_400_000, // 24 * 60 * 60 * 1_000
|
||||
WEEK: 604_800_000, // 7 * 24 * 60 * 60 * 1_000
|
||||
MONTH: 2_630_016_000, // 30.44 * 24 * 60 * 60 * 1_000 -- approximation using average month length
|
||||
YEAR: 31_556_736_000, // 365.24 * 24 * 60 * 60 * 1_000 -- approximation using average year length
|
||||
};
|
||||
|
||||
export const IN_BYTES = {
|
||||
KILOBYTE: 1_024,
|
||||
MEGABYTE: 1_048_576, // 1_024 * 1_024
|
||||
GIGABYTE: 1_073_741_824, // 1_024 * 1_024 * 1_024
|
||||
TERABYTE: 1_099_511_627_776, // 1_024 * 1_024 * 1_024 * 1_024
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import type { ConfigDefinition } from 'figue';
|
||||
import type { TasksDriverName } from './drivers/tasks-driver.constants';
|
||||
import { z } from 'zod';
|
||||
import { booleanishSchema } from '../config/config.schemas';
|
||||
import { IN_MS } from '../shared/units';
|
||||
import { tasksDriverNames } from './drivers/tasks-driver.constants';
|
||||
|
||||
export const tasksConfig = {
|
||||
@@ -36,7 +35,7 @@ export const tasksConfig = {
|
||||
pollIntervalMs: {
|
||||
doc: 'The interval at which the task persistence driver polls for new tasks',
|
||||
schema: z.coerce.number().int().positive(),
|
||||
default: 1 * IN_MS.SECOND,
|
||||
default: 1_000,
|
||||
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_POLL_INTERVAL_MS',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user