Compare commits

..

2 Commits
2fa ... main

Author SHA1 Message Date
Corentin Thomasset
8d70a7b3c3 feat(api-keys): add endpoint to check current API key (#718) 2025-12-31 15:52:37 +01:00
Arpit Garg
7448a170af fix(documents): delete orphan file when same document exists in trash (#715)
* fix: delete orphan file when duplicate hash is found

* test(documents): add test to highlight fixed issue

* chore(version): add changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-12-31 14:08:05 +01:00
31 changed files with 410 additions and 3236 deletions

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Properly cleanup orphan file when the same document exists in trash

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Added api endpoint to check current API key (GET /api/api-keys/current)

View File

@@ -66,6 +66,19 @@ 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`

View File

@@ -29,7 +29,6 @@
"dependencies": {
"@branchlet/core": "^1.0.0",
"@corentinth/chisels": "catalog:",
"@corvu/otp-field": "^0.1.4",
"@kobalte/core": "^0.13.10",
"@kobalte/utils": "^0.9.1",
"@modular-forms/solid": "^0.25.1",
@@ -51,7 +50,6 @@
"tailwind-merge": "^2.6.0",
"unocss-preset-animations": "^1.3.0",
"unstorage": "^1.16.0",
"uqr": "^0.1.2",
"valibot": "1.0.0-beta.10"
},
"devDependencies": {

View File

@@ -40,20 +40,6 @@ export const translations = {
'auth.login.form.forgot-password.label': 'Forgot password?',
'auth.login.form.submit': 'Login',
'auth.login.two-factor.title': 'Two-Factor Verification',
'auth.login.two-factor.description.totp': 'Enter the 6-digit verification code from your authenticator app.',
'auth.login.two-factor.description.backup-code': 'Enter one of your backup codes to access your account.',
'auth.login.two-factor.code.label.totp': 'Authenticator code',
'auth.login.two-factor.code.label.backup-code': 'Backup code',
'auth.login.two-factor.code.placeholder.backup-code': 'Enter backup code',
'auth.login.two-factor.code.required': 'Please enter the verification code',
'auth.login.two-factor.trust-device.label': 'Trust this device for 30 days',
'auth.login.two-factor.back': 'Back to login',
'auth.login.two-factor.submit': 'Verify',
'auth.login.two-factor.verification-failed': 'Verification failed. Please check your code and try again.',
'auth.login.two-factor.use-backup-code': 'Use backup code instead',
'auth.login.two-factor.use-totp': 'Use authenticator app instead',
'auth.register.title': 'Register to Papra',
'auth.register.description': 'Create an account to start using Papra.',
'auth.register.register-with-email': 'Register with email',
@@ -116,66 +102,6 @@ export const translations = {
'user.settings.logout.description': 'Logout from your account. You can login again later.',
'user.settings.logout.button': 'Logout',
'user.settings.two-factor.title': 'Two-Factor Authentication',
'user.settings.two-factor.description': 'Add an extra layer of security to your account.',
'user.settings.two-factor.status.enabled': 'Enabled',
'user.settings.two-factor.status.disabled': 'Disabled',
'user.settings.two-factor.enable-button': 'Enable 2FA',
'user.settings.two-factor.disable-button': 'Disable 2FA',
'user.settings.two-factor.regenerate-codes-button': 'Regenerate backup codes',
'user.settings.two-factor.enable-dialog.title': 'Enable Two-Factor Authentication',
'user.settings.two-factor.enable-dialog.description': 'Enter your password to enable 2FA.',
'user.settings.two-factor.enable-dialog.password.label': 'Password',
'user.settings.two-factor.enable-dialog.password.placeholder': 'Enter your password',
'user.settings.two-factor.enable-dialog.password.required': 'Please enter your password',
'user.settings.two-factor.enable-dialog.cancel': 'Cancel',
'user.settings.two-factor.enable-dialog.submit': 'Continue',
'user.settings.two-factor.setup-dialog.title': 'Set Up Two-Factor Authentication',
'user.settings.two-factor.setup-dialog.description': 'Scan this QR code with your authenticator app, then enter the verification code.',
'user.settings.two-factor.setup-dialog.qr-loading': 'Loading QR code...',
'user.settings.two-factor.setup-dialog.step1.title': 'Step 1: Scan the QR code',
'user.settings.two-factor.setup-dialog.step1.description': 'Scan the QR code below or manually enter the setup key into your authenticator app.',
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Copy setup key',
'user.settings.two-factor.setup-dialog.step2.title': 'Step 2: Verify the code',
'user.settings.two-factor.setup-dialog.step2.description': 'Enter the 6-digit code generated by your authenticator app to verify and enable two-factor authentication.',
'user.settings.two-factor.setup-dialog.code.label': 'Verification code',
'user.settings.two-factor.setup-dialog.code.placeholder': 'Enter 6-digit code',
'user.settings.two-factor.setup-dialog.code.required': 'Please enter the verification code',
'user.settings.two-factor.setup-dialog.cancel': 'Cancel',
'user.settings.two-factor.setup-dialog.verify': 'Verify and enable 2FA',
'user.settings.two-factor.backup-codes-dialog.title': 'Backup Codes',
'user.settings.two-factor.backup-codes-dialog.description': 'Save these backup codes in a safe place. You can use them to access your account if you lose access to your authenticator app.',
'user.settings.two-factor.backup-codes-dialog.warning': 'Each code can only be used once.',
'user.settings.two-factor.backup-codes-dialog.copy': 'Copy backup codes',
'user.settings.two-factor.backup-codes-dialog.download': 'Download backup codes',
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
'user.settings.two-factor.backup-codes-dialog.copied': 'Codes copied to clipboard',
'user.settings.two-factor.backup-codes-dialog.close': 'I\'ve saved my codes',
'user.settings.two-factor.disable-dialog.title': 'Disable Two-Factor Authentication',
'user.settings.two-factor.disable-dialog.description': 'Enter your password to disable 2FA. This will make your account less secure.',
'user.settings.two-factor.disable-dialog.password.label': 'Password',
'user.settings.two-factor.disable-dialog.password.placeholder': 'Enter your password',
'user.settings.two-factor.disable-dialog.password.required': 'Please enter your password',
'user.settings.two-factor.disable-dialog.cancel': 'Cancel',
'user.settings.two-factor.disable-dialog.submit': 'Disable 2FA',
'user.settings.two-factor.regenerate-dialog.title': 'Regenerate Backup Codes',
'user.settings.two-factor.regenerate-dialog.description': 'This will invalidate all existing backup codes and generate new ones. Enter your password to continue.',
'user.settings.two-factor.regenerate-dialog.password.label': 'Password',
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Enter your password',
'user.settings.two-factor.regenerate-dialog.password.required': 'Please enter your password',
'user.settings.two-factor.regenerate-dialog.cancel': 'Cancel',
'user.settings.two-factor.regenerate-dialog.submit': 'Regenerate codes',
'user.settings.two-factor.enabled': 'Two-factor authentication has been enabled',
'user.settings.two-factor.disabled': 'Two-factor authentication has been disabled',
'user.settings.two-factor.codes-regenerated': 'Backup codes have been regenerated',
'user.settings.two-factor.verification-failed': 'Verification failed. Please check your code and try again.',
// Organizations
'organizations.list.title': 'Your organizations',
@@ -713,7 +639,6 @@ export const translations = {
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Failed to unlink last account',
'api-errors.ACCOUNT_NOT_FOUND': 'Account not found',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'User already has password',
'api-errors.INVALID_CODE': 'The provided code is invalid or has expired',
// Not found

View File

@@ -20,15 +20,6 @@ export function createDemoAuthClient() {
requestPasswordReset: () => Promise.resolve({}),
resetPassword: () => Promise.resolve({}),
sendVerificationEmail: () => Promise.resolve({}),
twoFactor: {
enable: () => Promise.resolve({ data: null, error: null }),
disable: () => Promise.resolve({ data: null, error: null }),
getTotpUri: () => Promise.resolve({ data: null, error: null }),
verifyTotp: () => Promise.resolve({ data: null, error: null }),
generateBackupCodes: () => Promise.resolve({ data: null, error: null }),
viewBackupCodes: () => Promise.resolve({ data: null, error: null }),
verifyBackupCode: () => Promise.resolve({ data: null, error: null }),
},
};
return new Proxy(baseClient, {

View File

@@ -1,7 +1,7 @@
import type { Config } from '../config/config';
import type { SsoProviderConfig } from './auth.types';
import { genericOAuthClient, twoFactorClient } from 'better-auth/client/plugins';
import { genericOAuthClient } from 'better-auth/client/plugins';
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
import { buildTimeConfig } from '../config/config';
import { queryClient } from '../shared/query/query-client';
@@ -13,7 +13,6 @@ export function createAuthClient() {
baseURL: buildTimeConfig.baseApiUrl,
plugins: [
genericOAuthClient(),
twoFactorClient(),
],
});
@@ -25,7 +24,6 @@ export function createAuthClient() {
resetPassword: client.resetPassword,
sendVerificationEmail: client.sendVerificationEmail,
useSession: client.useSession,
twoFactor: client.twoFactor,
signOut: async () => {
trackingServices.capture({ event: 'User logged out' });
const result = await client.signOut();
@@ -46,7 +44,6 @@ export const {
requestPasswordReset,
resetPassword,
sendVerificationEmail,
twoFactor,
} = buildTimeConfig.isDemoMode
? createDemoAuthClient()
: createAuthClient();

View File

@@ -1,33 +0,0 @@
import type { Component } from 'solid-js';
import {
OTPField,
OTPFieldGroup,
OTPFieldInput,
OTPFieldSlot,
REGEXP_ONLY_DIGITS,
} from '@/modules/ui/components/otp-field';
export const TotpField: Component<{
onComplete?: (args: { totpCode: string }) => void;
value?: string;
onValueChange?: (value: string) => void;
}> = (props) => {
return (
<OTPField
maxLength={6}
onComplete={totpCode => props.onComplete?.({ totpCode })}
value={props.value}
onValueChange={props.onValueChange}
>
<OTPFieldInput pattern={REGEXP_ONLY_DIGITS} aria-label="Enter the 6-digit verification code" />
<OTPFieldGroup>
<OTPFieldSlot index={0} />
<OTPFieldSlot index={1} />
<OTPFieldSlot index={2} />
<OTPFieldSlot index={3} />
<OTPFieldSlot index={4} />
<OTPFieldSlot index={5} />
</OTPFieldGroup>
</OTPField>
);
};

View File

@@ -2,7 +2,6 @@ import type { Component } from 'solid-js';
import type { SsoProviderConfig } from '../auth.types';
import { buildUrl } from '@corentinth/chisels';
import { A, useNavigate } from '@solidjs/router';
import { useMutation } from '@tanstack/solid-query';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
@@ -12,178 +11,16 @@ import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-err
import { Button } from '@/modules/ui/components/button';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { Separator } from '@/modules/ui/components/separator';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { authPagesPaths } from '../auth.constants';
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
import { authWithProvider, signIn, twoFactor } from '../auth.services';
import { authWithProvider, signIn } from '../auth.services';
import { AuthLegalLinks } from '../components/legal-links.component';
import { NoAuthProviderWarning } from '../components/no-auth-provider';
import { SsoProviderButton } from '../components/sso-provider-button.component';
import { TotpField } from '../components/verify-otp.component';
const TotpVerificationForm: Component = () => {
const navigate = useNavigate();
const { t } = useI18n();
const [trustDevice, setTrustDevice] = createSignal(false);
const [totpCode, setTotpCode] = createSignal('');
const verifyMutation = useMutation(() => ({
mutationFn: async ({ code, trust }: { code: string; trust: boolean }) => {
const { error } = await twoFactor.verifyTotp({ code, trustDevice: trust });
if (error) {
createToast({ type: 'error', message: t('auth.login.two-factor.verification-failed') });
throw new Error(error.message);
}
},
onSuccess: () => {
navigate('/');
},
}));
const handleTotpComplete = (code: string) => {
setTotpCode(code);
if (code.length === 6) {
verifyMutation.mutate({ code, trust: trustDevice() });
}
};
return (
<div>
<p class="text-muted-foreground mt-1 mb-4">
{t('auth.login.two-factor.description.totp')}
</p>
<div class="flex flex-col gap-1 mb-4 items-center">
<label class="sr-only">{t('auth.login.two-factor.code.label.totp')}</label>
<TotpField value={totpCode()} onValueChange={handleTotpComplete} />
<Show when={verifyMutation.error}>
{getError => <div class="text-red-500 text-sm">{getError().message}</div>}
</Show>
<Checkbox class="flex items-center gap-2 mt-4" checked={trustDevice()} onChange={setTrustDevice}>
<CheckboxControl />
<CheckboxLabel class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t('auth.login.two-factor.trust-device.label')}
</CheckboxLabel>
</Checkbox>
</div>
</div>
);
};
const BackupCodeVerificationForm: Component = () => {
const navigate = useNavigate();
const { t } = useI18n();
const [trustDevice, setTrustDevice] = createSignal(false);
const { form, Form, Field } = createForm({
onSubmit: async ({ code }) => {
const { error } = await twoFactor.verifyBackupCode({
code,
trustDevice: trustDevice(),
});
if (error) {
createToast({ type: 'error', message: t('auth.login.two-factor.verification-failed') });
throw new Error(error.message);
}
navigate('/');
},
schema: v.object({
code: v.pipe(
v.string(),
v.nonEmpty(t('auth.login.two-factor.code.required')),
),
}),
initialValues: {
code: '',
},
});
return (
<Form>
<p class="text-muted-foreground mt-1 mb-4">
{t('auth.login.two-factor.description.backup-code')}
</p>
<Field name="code">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="backup-code">{t('auth.login.two-factor.code.label.backup-code')}</TextFieldLabel>
<TextField
type="text"
id="backup-code"
placeholder={t('auth.login.two-factor.code.placeholder.backup-code')}
{...inputProps}
autoFocus
value={field.value}
aria-invalid={Boolean(field.error)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Checkbox class="flex items-center gap-2 mb-4" checked={trustDevice()} onChange={setTrustDevice}>
<CheckboxControl />
<CheckboxLabel class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t('auth.login.two-factor.trust-device.label')}
</CheckboxLabel>
</Checkbox>
<Button type="submit" class="w-full" isLoading={form.submitting}>
{t('auth.login.two-factor.submit')}
</Button>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
</Form>
);
};
const TwoFactorVerificationForm: Component<{ onBack: () => void }> = (props) => {
const [useBackupCode, setUseBackupCode] = createSignal(false);
const { t } = useI18n();
return (
<div>
<Show
when={!useBackupCode()}
fallback={(
<BackupCodeVerificationForm />
)}
>
<TotpVerificationForm />
</Show>
<div class="flex flex-col gap-2 mt-4">
<Show
when={!useBackupCode()}
fallback={(
<Button variant="link" class="p-0 h-auto text-muted-foreground" onClick={() => setUseBackupCode(false)}>
{t('auth.login.two-factor.use-totp')}
</Button>
)}
>
<Button variant="link" class="p-0 h-auto text-muted-foreground" onClick={() => setUseBackupCode(true)}>
{t('auth.login.two-factor.use-backup-code')}
</Button>
</Show>
<Button variant="link" class="p-0 h-auto text-muted-foreground" onClick={props.onBack}>
{t('auth.login.two-factor.back')}
</Button>
</div>
</div>
);
};
export const EmailLoginForm: Component<{ onTwoFactorRequired: () => void }> = (props) => {
export const EmailLoginForm: Component = () => {
const navigate = useNavigate();
const { config } = useConfig();
const { t } = useI18n();
@@ -191,7 +28,7 @@ export const EmailLoginForm: Component<{ onTwoFactorRequired: () => void }> = (p
const { form, Form, Field } = createForm({
onSubmit: async ({ email, password, rememberMe }) => {
const { data: loginResult, error } = await signIn.email({
const { error } = await signIn.email({
email,
password,
rememberMe,
@@ -199,11 +36,6 @@ export const EmailLoginForm: Component<{ onTwoFactorRequired: () => void }> = (p
callbackURL: buildUrl({ baseUrl: config.baseUrl, path: authPagesPaths.emailVerification }),
});
if (loginResult && 'twoFactorRedirect' in loginResult && loginResult.twoFactorRedirect) {
props.onTwoFactorRequired();
return;
}
if (isEmailVerificationRequiredError({ error })) {
navigate('/email-validation-required');
}
@@ -274,7 +106,7 @@ export const EmailLoginForm: Component<{ onTwoFactorRequired: () => void }> = (p
</Show>
</div>
<Button type="submit" class="w-full" isLoading={form.submitting}>{t('auth.login.form.submit')}</Button>
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
@@ -287,8 +119,6 @@ export const LoginPage: Component = () => {
const { t } = useI18n();
const [getShowEmailLoginForm, setShowEmailLoginForm] = createSignal(false);
// const [showTwoFactorForm, setShowTwoFactorForm] = createSignal(false);
const [showTwoFactorForm, setShowTwoFactorForm] = createSignal(true); // For testing purposes
const loginWithProvider = async (provider: SsoProviderConfig) => {
await authWithProvider({ provider, config });
@@ -296,69 +126,59 @@ export const LoginPage: Component = () => {
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
const hasNoAuthProviders = !config.auth.providers.email.isEnabled && !getHasSsoProviders();
if (!config.auth.providers.email.isEnabled && !getHasSsoProviders()) {
return <AuthLayout><NoAuthProviderWarning /></AuthLayout>;
}
return (
<AuthLayout>
<Show when={!hasNoAuthProviders} fallback={<NoAuthProviderWarning />}>
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
<div class="max-w-sm w-full">
<Show
when={!showTwoFactorForm()}
fallback={(
<>
<h1 class="text-xl font-bold">{t('auth.login.two-factor.title')}</h1>
<TwoFactorVerificationForm onBack={() => setShowTwoFactorForm(false)} />
</>
)}
>
<h1 class="text-xl font-bold">{t('auth.login.title')}</h1>
<p class="text-muted-foreground mt-1 mb-4">{t('auth.login.description')}</p>
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
<div class="max-w-sm w-full">
<h1 class="text-xl font-bold">{t('auth.login.title')}</h1>
<p class="text-muted-foreground mt-1 mb-4">{t('auth.login.description')}</p>
<Show when={config.auth.providers.email.isEnabled}>
{getShowEmailLoginForm() || !getHasSsoProviders()
? <EmailLoginForm onTwoFactorRequired={() => setShowTwoFactorForm(true)} />
: (
<Button onClick={() => setShowEmailLoginForm(true)} class="w-full">
<div class="i-tabler-mail mr-2 size-4.5" />
{t('auth.login.login-with-provider', { provider: 'Email' })}
</Button>
)}
</Show>
<Show when={config.auth.providers.email.isEnabled}>
{getShowEmailLoginForm() || !getHasSsoProviders()
? <EmailLoginForm />
: (
<Button onClick={() => setShowEmailLoginForm(true)} class="w-full">
<div class="i-tabler-mail mr-2 size-4.5" />
{t('auth.login.login-with-provider', { provider: 'Email' })}
</Button>
)}
</Show>
<Show when={config.auth.providers.email.isEnabled && getHasSsoProviders()}>
<Separator class="my-4" />
</Show>
<Show when={config.auth.providers.email.isEnabled && getHasSsoProviders()}>
<Separator class="my-4" />
</Show>
<Show when={getHasSsoProviders()}>
<Show when={getHasSsoProviders()}>
<div class="flex flex-col gap-2">
<For each={getEnabledSsoProviderConfigs({ config })}>
{provider => (
<SsoProviderButton
name={provider.name}
icon={provider.icon}
onClick={() => loginWithProvider(provider)}
label={t('auth.login.login-with-provider', { provider: provider.name })}
/>
)}
</For>
</div>
</Show>
<div class="flex flex-col gap-2">
<For each={getEnabledSsoProviderConfigs({ config })}>
{provider => (
<SsoProviderButton
name={provider.name}
icon={provider.icon}
onClick={() => loginWithProvider(provider)}
label={t('auth.login.login-with-provider', { provider: provider.name })}
/>
)}
</For>
</div>
</Show>
<p class="text-muted-foreground mt-4">
{t('auth.login.no-account')}
{' '}
<Button variant="link" as={A} class="inline px-0" href="/register">
{t('auth.login.register')}
</Button>
</p>
<p class="text-muted-foreground mt-4">
{t('auth.login.no-account')}
{' '}
<Button variant="link" as={A} class="inline px-0" href="/register">
{t('auth.login.register')}
</Button>
</p>
<AuthLegalLinks />
</Show>
</div>
<AuthLegalLinks />
</div>
</Show>
</div>
</AuthLayout>
);
};

View File

@@ -4,10 +4,3 @@ export function downloadFile({ url, fileName = 'file' }: { url: string; fileName
link.download = fileName;
link.click();
}
export function downloadTextFile({ content, fileName = 'file.txt' }: { content: string; fileName?: string }) {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
downloadFile({ url, fileName });
URL.revokeObjectURL(url);
}

View File

@@ -1,83 +0,0 @@
import type { DynamicProps, RootProps } from '@corvu/otp-field';
import type { Component, ComponentProps, ValidComponent } from 'solid-js';
import OtpField from '@corvu/otp-field';
import { Show, splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const REGEXP_ONLY_DIGITS = '^\\d*$';
export const REGEXP_ONLY_CHARS = '^[a-zA-Z]*$';
export const REGEXP_ONLY_DIGITS_AND_CHARS = '^[a-zA-Z0-9]*$';
type OTPFieldProps<T extends ValidComponent = 'div'> = RootProps<T> & { class?: string };
function OTPField<T extends ValidComponent = 'div'>(props: DynamicProps<T, OTPFieldProps<T>>) {
const [local, others] = splitProps(props as OTPFieldProps, ['class']);
return (
<OtpField
class={cn(
'flex items-center gap-2 disabled:cursor-not-allowed has-[:disabled]:opacity-50',
local.class,
)}
{...others}
/>
);
}
const OTPFieldInput = OtpField.Input;
const OTPFieldGroup: Component<ComponentProps<'div'>> = (props) => {
const [local, others] = splitProps(props, ['class']);
return <div class={cn('flex items-center', local.class)} {...others} />;
};
const OTPFieldSlot: Component<ComponentProps<'div'> & { index: number }> = (props) => {
const [local, others] = splitProps(props, ['class', 'index']);
const context = OtpField.useContext();
const char = () => context.value()[local.index];
const showFakeCaret = () => context.value().length === local.index && context.isInserting();
return (
<div
class={cn(
'group relative flex size-10 items-center justify-center border-y border-r border-input text-sm first:rounded-l-md first:border-l last:rounded-r-md',
local.class,
)}
{...others}
>
<div
class={cn(
'absolute inset-0 z-10 transition-all group-first:rounded-l-md group-last:rounded-r-md',
context.activeSlots().includes(local.index) && 'ring-2 ring-ring ring-offset-background',
)}
/>
{char()}
<Show when={showFakeCaret()}>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
</Show>
</div>
);
};
const OTPFieldSeparator: Component<ComponentProps<'div'>> = (props) => {
return (
<div {...props}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-6"
>
<circle cx="12.1" cy="12.1" r="1" />
</svg>
</div>
);
};
export { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSeparator, OTPFieldSlot };

View File

@@ -1,12 +0,0 @@
import type { Component, ComponentProps } from 'solid-js';
import { splitProps } from 'solid-js';
import { renderSVG } from 'uqr';
export const QrCode: Component<{ value: string } & ComponentProps<'div'>> = (props) => {
const [local, rest] = splitProps(props, ['value']);
return (
// eslint-disable-next-line solid/no-innerhtml
<div innerHTML={renderSVG(local.value)} {...rest} />
);
};

View File

@@ -1,30 +0,0 @@
import { describe, expect, test } from 'vitest';
import { getSecretFromTotpUri } from './2fa.models';
describe('2fa models', () => {
describe('getSecretFromTotpUri', () => {
test('in a valid TOTP URI the secret is a query parameter', () => {
expect(
getSecretFromTotpUri({
totpUri: 'otpauth://totp/Papra:foo.bar%40gmail.com?secret=KFBVEMJQIVFW6RKMJNWTQ42OPBKG63DBK4YWSX2LG4REOQRXGZ3Q&issuer=Papra&digits=6&period=30',
}),
).to.equal('KFBVEMJQIVFW6RKMJNWTQ42OPBKG63DBK4YWSX2LG4REOQRXGZ3Q');
});
test('if the TOTP URI does not have a secret query parameter, an empty string is returned', () => {
expect(
getSecretFromTotpUri({
totpUri: 'otpauth://totp/Papra:foo.bar%40gmail.com?issuer=Papra&digits=6&period=30',
}),
).to.equal('');
});
test('if the TOTP URI is malformed, an empty string is returned', () => {
expect(
getSecretFromTotpUri({
totpUri: 'not-a-valid-uri',
}),
).to.equal('');
});
});
});

View File

@@ -1,7 +0,0 @@
export function getSecretFromTotpUri({ totpUri }: { totpUri: string }): string {
try {
return new URL(totpUri).searchParams.get('secret') ?? '';
} catch {
return '';
}
}

View File

@@ -1,470 +0,0 @@
import type { Component } from 'solid-js';
import { useMutation } from '@tanstack/solid-query';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { twoFactor } from '@/modules/auth/auth.services';
import { TotpField } from '@/modules/auth/components/verify-otp.component';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { downloadTextFile } from '@/modules/shared/files/download';
import { createForm } from '@/modules/shared/form/form';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { CopyButton } from '@/modules/shared/utils/copy';
import { Badge } from '@/modules/ui/components/badge';
import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/modules/ui/components/dialog';
import { QrCode } from '@/modules/ui/components/qr-code';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldErrorMessage, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { getSecretFromTotpUri } from '../2fa.models';
const EnableTwoFactorDialog: Component<{
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (data: { totpURI: string; backupCodes: string[] }) => void;
}> = (props) => {
const { t } = useI18n();
const passwordSchema = v.pipe(v.string(), v.minLength(1, t('user.settings.two-factor.enable-dialog.password.required')));
const { form, Form, Field } = createForm({
schema: v.object({
password: passwordSchema,
}),
initialValues: {
password: '',
},
onSubmit: async ({ password }) => {
const { data, error } = await twoFactor.enable({ password });
if (error) {
createToast({ type: 'error', message: error.message });
return;
}
const { totpURI, backupCodes } = data;
props.onSuccess({ totpURI, backupCodes });
},
});
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('user.settings.two-factor.enable-dialog.title')}</DialogTitle>
<DialogDescription>{t('user.settings.two-factor.enable-dialog.description')}</DialogDescription>
</DialogHeader>
<Form>
<Field name="password">
{(field, inputProps) => (
<TextFieldRoot>
<TextFieldLabel for="enable-password">
{t('user.settings.two-factor.enable-dialog.password.label')}
</TextFieldLabel>
<TextField
type="password"
id="enable-password"
placeholder={t('user.settings.two-factor.enable-dialog.password.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
/>
{field.error && <TextFieldErrorMessage>{field.error}</TextFieldErrorMessage>}
</TextFieldRoot>
)}
</Field>
<DialogFooter class="mt-6">
<Button variant="outline" onClick={() => props.onOpenChange(false)}>
{t('user.settings.two-factor.enable-dialog.cancel')}
</Button>
<Button type="submit" isLoading={form.submitting}>
{t('user.settings.two-factor.enable-dialog.submit')}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};
const SetupTwoFactorDialog: Component<{
open: boolean;
onOpenChange: (open: boolean) => void;
totpUri: string;
onSuccess: () => void;
}> = (props) => {
const { t } = useI18n();
const getTotpSecret = () => getSecretFromTotpUri({ totpUri: props.totpUri });
const [getTotpCode, setTotpCode] = createSignal<string>('');
const { createI18nApiError } = useI18nApiErrors();
const verifyMutation = useMutation(() => ({
mutationFn: async ({ totpCode }: { totpCode: string }) => {
const { error } = await twoFactor.verifyTotp({ code: totpCode });
if (error) {
throw createI18nApiError({ error });
}
},
onSuccess: () => {
props.onSuccess();
createToast({ type: 'success', message: t('user.settings.two-factor.enabled') });
},
}));
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('user.settings.two-factor.setup-dialog.title')}</DialogTitle>
</DialogHeader>
<div>
<h3 class="font-semibold">{t('user.settings.two-factor.setup-dialog.step1.title')}</h3>
<p class="mb-4 text-sm text-muted-foreground">
{t('user.settings.two-factor.setup-dialog.step1.description')}
</p>
<div class="flex flex-col items-center">
<QrCode value={props.totpUri} class="w-full max-w-48" />
<CopyButton text={getTotpSecret()} variant="outline" label={t('user.settings.two-factor.setup-dialog.copy-setup-key')} size="sm" class="mt-2" />
</div>
<h3 class="mt-8 font-semibold">{t('user.settings.two-factor.setup-dialog.step2.title')}</h3>
<p class="mb-4 text-sm text-muted-foreground">
{t('user.settings.two-factor.setup-dialog.step2.description')}
</p>
<div class="mt-4 flex justify-center">
<TotpField value={getTotpCode()} onValueChange={setTotpCode} />
</div>
<Show when={verifyMutation.error}>{getError => (<div class="text-red">{getError().message}</div>)}</Show>
<div class="flex md:flex-row flex-col justify-end gap-2 mt-6">
<Button variant="outline" onClick={() => props.onOpenChange(false)}>
{t('user.settings.two-factor.setup-dialog.cancel')}
</Button>
<Button type="submit" isLoading={verifyMutation.isPending} onClick={() => verifyMutation.mutate({ totpCode: getTotpCode() })}>
{t('user.settings.two-factor.setup-dialog.verify')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
const BackupCodesDialog: Component<{
open: boolean;
onOpenChange: (open: boolean) => void;
backupCodes: string[];
}> = (props) => {
const { t } = useI18n();
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('user.settings.two-factor.backup-codes-dialog.title')}</DialogTitle>
<DialogDescription>{t('user.settings.two-factor.backup-codes-dialog.description')}</DialogDescription>
</DialogHeader>
<div>
<div class="p-4 rounded-md bg-background border">
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
<For each={props.backupCodes}>
{code => (
<div class="text-center">{code}</div>
)}
</For>
</div>
</div>
<div class="flex justify-center mt-2 md:flex-row flex-col gap-2">
<CopyButton
text={props.backupCodes.join('\n')}
label={t('user.settings.two-factor.backup-codes-dialog.copy')}
variant="outline"
size="sm"
/>
<Button
variant="outline"
size="sm"
onClick={() => downloadTextFile({
content: props.backupCodes.join('\n'),
fileName: t('user.settings.two-factor.backup-codes-dialog.download-filename'),
})}
>
<div class="i-tabler-download size-4 mr-2" />
{t('user.settings.two-factor.backup-codes-dialog.download')}
</Button>
</div>
</div>
<DialogFooter class="mt-4">
<Button onClick={() => props.onOpenChange(false)}>
{t('user.settings.two-factor.backup-codes-dialog.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const DisableTwoFactorDialog: Component<{
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}> = (props) => {
const { t } = useI18n();
const passwordSchema = v.pipe(v.string(), v.minLength(1, t('user.settings.two-factor.disable-dialog.password.required')));
const { form, Form, Field } = createForm({
schema: v.object({
password: passwordSchema,
}),
initialValues: {
password: '',
},
onSubmit: async ({ password }) => {
const { error } = await twoFactor.disable({ password });
if (error) {
createToast({ type: 'error', message: error.message });
return;
}
props.onSuccess();
createToast({ type: 'success', message: t('user.settings.two-factor.disabled') });
},
});
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('user.settings.two-factor.disable-dialog.title')}</DialogTitle>
<DialogDescription>{t('user.settings.two-factor.disable-dialog.description')}</DialogDescription>
</DialogHeader>
<Form>
<Field name="password">
{(field, inputProps) => (
<TextFieldRoot>
<TextFieldLabel for="disable-password">
{t('user.settings.two-factor.disable-dialog.password.label')}
</TextFieldLabel>
<TextField
type="password"
id="disable-password"
placeholder={t('user.settings.two-factor.disable-dialog.password.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
/>
{field.error && <TextFieldErrorMessage>{field.error}</TextFieldErrorMessage>}
</TextFieldRoot>
)}
</Field>
<DialogFooter class="mt-6">
<Button variant="outline" onClick={() => props.onOpenChange(false)}>
{t('user.settings.two-factor.disable-dialog.cancel')}
</Button>
<Button type="submit" variant="destructive" isLoading={form.submitting}>
{t('user.settings.two-factor.disable-dialog.submit')}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};
const RegenerateBackupCodesDialog: Component<{
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (backupCodes: string[]) => void;
}> = (props) => {
const { t } = useI18n();
const passwordSchema = v.pipe(v.string(), v.minLength(1, t('user.settings.two-factor.regenerate-dialog.password.required')));
const { form, Form, Field } = createForm({
schema: v.object({
password: passwordSchema,
}),
initialValues: {
password: '',
},
onSubmit: async ({ password }) => {
const { data, error } = await twoFactor.generateBackupCodes({ password });
if (error) {
createToast({ type: 'error', message: error.message });
return;
}
if (data?.backupCodes) {
props.onSuccess(data.backupCodes);
createToast({ type: 'success', message: t('user.settings.two-factor.codes-regenerated') });
}
},
});
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('user.settings.two-factor.regenerate-dialog.title')}</DialogTitle>
<DialogDescription>{t('user.settings.two-factor.regenerate-dialog.description')}</DialogDescription>
</DialogHeader>
<Form>
<Field name="password">
{(field, inputProps) => (
<TextFieldRoot>
<TextFieldLabel for="regenerate-password">
{t('user.settings.two-factor.regenerate-dialog.password.label')}
</TextFieldLabel>
<TextField
type="password"
id="regenerate-password"
placeholder={t('user.settings.two-factor.regenerate-dialog.password.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
/>
{field.error && <TextFieldErrorMessage>{field.error}</TextFieldErrorMessage>}
</TextFieldRoot>
)}
</Field>
<DialogFooter class="mt-6">
<Button variant="outline" onClick={() => props.onOpenChange(false)}>
{t('user.settings.two-factor.regenerate-dialog.cancel')}
</Button>
<Button type="submit" isLoading={form.submitting}>
{t('user.settings.two-factor.regenerate-dialog.submit')}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};
type DialogState = 'none' | 'enable-password' | 'setup-qr' | 'backup-codes' | 'disable-password' | 'regenerate-codes';
export const TwoFactorCard: Component<{ twoFactorEnabled: boolean; onUpdate: () => void }> = (props) => {
const { t } = useI18n();
const [dialogState, setDialogState] = createSignal<DialogState>('none');
const [totpUri, setTotpUri] = createSignal<string>('');
const [backupCodes, setBackupCodes] = createSignal<string[]>([]);
const handleEnableSuccess = (data: { totpURI: string; backupCodes: string[] }) => {
setTotpUri(data.totpURI);
setBackupCodes(data.backupCodes);
setDialogState('setup-qr');
};
const handleSetupSuccess = () => {
setDialogState('backup-codes');
props.onUpdate();
createToast({ type: 'success', message: t('user.settings.two-factor.enabled') });
};
const handleDisableSuccess = () => {
setDialogState('none');
props.onUpdate();
};
const handleRegenerateSuccess = (codes: string[]) => {
setBackupCodes(codes);
setDialogState('backup-codes');
};
const closeDialog = () => {
setDialogState('none');
setTotpUri('');
setBackupCodes([]);
};
return (
<>
<Card>
<CardHeader class="border-b">
<div class="flex items-center justify-between">
<div>
<CardTitle>{t('user.settings.two-factor.title')}</CardTitle>
<CardDescription>{t('user.settings.two-factor.description')}</CardDescription>
</div>
<Badge variant={props.twoFactorEnabled ? 'default' : 'secondary'}>
{props.twoFactorEnabled
? t('user.settings.two-factor.status.enabled')
: t('user.settings.two-factor.status.disabled')}
</Badge>
</div>
</CardHeader>
<CardContent class="pt-6">
<div class="flex flex-row justify-end gap-3">
<Show
when={props.twoFactorEnabled}
fallback={(
<Button onClick={() => setDialogState('enable-password')}>
{t('user.settings.two-factor.enable-button')}
</Button>
)}
>
<Button variant="outline" onClick={() => setDialogState('regenerate-codes')}>
{t('user.settings.two-factor.regenerate-codes-button')}
</Button>
<Button variant="destructive" onClick={() => setDialogState('disable-password')}>
{t('user.settings.two-factor.disable-button')}
</Button>
</Show>
</div>
</CardContent>
</Card>
<EnableTwoFactorDialog
open={dialogState() === 'enable-password'}
onOpenChange={open => !open && closeDialog()}
onSuccess={handleEnableSuccess}
/>
<SetupTwoFactorDialog
open={dialogState() === 'setup-qr'}
onOpenChange={open => !open && closeDialog()}
totpUri={totpUri()}
onSuccess={handleSetupSuccess}
/>
<BackupCodesDialog
open={dialogState() === 'backup-codes'}
onOpenChange={open => !open && closeDialog()}
backupCodes={backupCodes()}
/>
<DisableTwoFactorDialog
open={dialogState() === 'disable-password'}
onOpenChange={open => !open && closeDialog()}
onSuccess={handleDisableSuccess}
/>
<RegenerateBackupCodesDialog
open={dialogState() === 'regenerate-codes'}
onOpenChange={open => !open && closeDialog()}
onSuccess={handleRegenerateSuccess}
/>
</>
);
};

View File

@@ -10,7 +10,6 @@ import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { TwoFactorCard } from '../components/two-factor-card';
import { useUpdateCurrentUser } from '../users.composables';
import { nameSchema } from '../users.schemas';
import { fetchCurrentUser } from '../users.services';
@@ -148,7 +147,6 @@ export const UserSettingsPage: Component = () => {
<div class="mt-6 flex flex-col gap-6">
<UserEmailCard email={getUser().email} />
<UpdateFullNameCard name={getUser().name} />
<TwoFactorCard twoFactorEnabled={getUser().twoFactorEnabled} onUpdate={() => query.refetch()} />
<LogoutCard />
</div>
</>

View File

@@ -6,7 +6,6 @@ export type User = {
updatedAt: Date;
emailVerified: boolean;
maxOrganizationCount: number | null;
twoFactorEnabled: boolean;
};
export type UserMe = User & {

View File

@@ -1,31 +0,0 @@
import type { Migration } from '../migrations.types';
import { sql } from 'drizzle-orm';
export const twoFactorAuthenticationMigration = {
name: 'two-factor-authentication',
up: async ({ db }) => {
await db.batch([
db.run(sql`
CREATE TABLE "auth_two_factor" (
"id" text PRIMARY KEY NOT NULL,
"created_at" integer NOT NULL,
"updated_at" integer NOT NULL,
"user_id" text,
"secret" text,
"backup_codes" text,
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade
);
`),
db.run(sql`ALTER TABLE "users" ADD "two_factor_enabled" integer DEFAULT false NOT NULL;`),
]);
},
down: async ({ db }) => {
await db.batch([
db.run(sql`DROP TABLE "auth_two_factor";`),
db.run(sql`ALTER TABLE "users" DROP COLUMN "two_factor_enabled";`),
]);
},
} satisfies Migration;

File diff suppressed because it is too large Load Diff

View File

@@ -85,13 +85,6 @@
"when": 1761645190314,
"tag": "0011_tagging-rule-condition-match-mode",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1766411483931,
"tag": "0012_two-factor-authentication",
"breakpoints": true
}
]
}
}

View File

@@ -9,11 +9,13 @@ import { organizationsInvitationsImprovementMigration } from './list/0006-organi
import { documentActivityLogMigration } from './list/0007-document-activity-log.migration';
import { documentActivityLogOnDeleteSetNullMigration } from './list/0008-document-activity-log-on-delete-set-null.migration';
import { dropLegacyMigrationsMigration } from './list/0009-drop-legacy-migrations.migration';
import { documentFileEncryptionMigration } from './list/0010-document-file-encryption.migration';
import { softDeleteOrganizationsMigration } from './list/0011-soft-delete-organizations.migration';
import { taggingRuleConditionMatchModeMigration } from './list/0012-tagging-rule-condition-match-mode.migration';
import { dropFts5TriggersMigration } from './list/0013-drop-fts-5-triggers.migration';
import { twoFactorAuthenticationMigration } from "./list/0014-two-factor-authentication.migration";
export const migrations: Migration[] = [
initialSchemaSetupMigration,
@@ -29,5 +31,4 @@ export const migrations: Migration[] = [
softDeleteOrganizationsMigration,
taggingRuleConditionMatchModeMigration,
dropFts5TriggersMigration,
twoFactorAuthenticationMigration
];
];

View File

@@ -0,0 +1,8 @@
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,
});

View File

@@ -1,17 +1,21 @@
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);
}
@@ -82,6 +86,38 @@ 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',

View File

@@ -0,0 +1,169 @@
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',
},
});
});
});
});

View File

@@ -5,13 +5,13 @@ import type { AuthEmailsServices } from './auth.emails.services';
import { expo } from '@better-auth/expo';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { genericOAuth, twoFactor } from 'better-auth/plugins';
import { genericOAuth } from 'better-auth/plugins';
import { getServerBaseUrl } from '../../config/config.models';
import { createLogger } from '../../shared/logger/logger';
import { usersTable } from '../../users/users.table';
import { createForbiddenEmailDomainError } from './auth.errors';
import { getTrustedOrigins, isEmailDomainAllowed } from './auth.models';
import { accountsTable, sessionsTable, twoFactorTable, verificationsTable } from './auth.tables';
import { accountsTable, sessionsTable, verificationsTable } from './auth.tables';
export type Auth = ReturnType<typeof getAuth>['auth'];
@@ -74,7 +74,6 @@ export function getAuth({
account: accountsTable,
session: sessionsTable,
verification: verificationsTable,
twoFactor: twoFactorTable,
},
},
),
@@ -128,7 +127,26 @@ export function getAuth({
},
plugins: [
expo(),
twoFactor(),
// Would love to have this but it messes with the error handling in better-auth client
// {
// id: 'better-auth-error-adapter',
// onResponse: async (res) => {
// // Transform better auth error to our own error
// if (res.status < 400) {
// return { response: res };
// }
// const body = await res.clone().json();
// const code = get(body, 'code', 'unknown');
// throw createError({
// message: get(body, 'message', 'Unknown error'),
// code: `auth.${code.toLowerCase()}`,
// statusCode: res.status as ContentfulStatusCode,
// isInternal: res.status >= 500,
// });
// },
// },
...(config.auth.providers.customs.length > 0
? [genericOAuth({ config: config.auth.providers.customs })]

View File

@@ -56,15 +56,3 @@ export const verificationsTable = sqliteTable(
index('auth_verifications_identifier_index').on(table.identifier),
],
);
export const twoFactorTable = sqliteTable(
'auth_two_factor',
{
...createPrimaryKeyField({ prefix: 'auth_2fa' }),
...createTimestampColumns(),
userId: text('user_id').references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
secret: text('secret'),
backupCodes: text('backup_codes'),
},
);

View File

@@ -6,6 +6,7 @@ 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';
@@ -244,6 +245,83 @@ 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({

View File

@@ -235,9 +235,10 @@ async function handleExistingDocument({
newDocumentStorageKey: string;
logger: Logger;
}) {
if (!existingDocument.isDeleted) {
await documentsStorageService.deleteFile({ storageKey: newDocumentStorageKey });
// Delete the newly uploaded file since we'll be using the existing document's file
await documentsStorageService.deleteFile({ storageKey: newDocumentStorageKey });
if (!existingDocument.isDeleted) {
throw createDocumentAlreadyExistsError();
}

View File

@@ -45,7 +45,6 @@ function setupGetCurrentUserRoute({ app, db }: RouteDefinitionContext) {
'createdAt',
'updatedAt',
'planId',
'twoFactorEnabled',
],
),
},

View File

@@ -12,7 +12,6 @@ export const usersTable = sqliteTable(
name: text('name'),
image: text('image'),
maxOrganizationCount: integer('max_organization_count', { mode: 'number' }),
twoFactorEnabled: integer('two_factor_enabled', { mode: 'boolean' }).notNull().default(false),
},
table => [
index('users_email_index').on(table.email),

60
pnpm-lock.yaml generated
View File

@@ -134,7 +134,7 @@ importers:
dependencies:
'@better-auth/expo':
specifier: 'catalog:'
version: 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-network@8.0.8(expo@54.0.23)(react@19.1.0))(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))
version: 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-network@8.0.8(expo@54.0.23)(react@19.1.0))(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))
'@corentinth/chisels':
specifier: 'catalog:'
version: 2.1.0
@@ -283,9 +283,6 @@ importers:
'@corentinth/chisels':
specifier: 'catalog:'
version: 2.1.0
'@corvu/otp-field':
specifier: ^0.1.4
version: 0.1.4(solid-js@1.9.9)
'@kobalte/core':
specifier: ^0.13.10
version: 0.13.10(solid-js@1.9.9)
@@ -349,9 +346,6 @@ importers:
unstorage:
specifier: ^1.16.0
version: 1.16.0(@azure/storage-blob@12.27.0)(idb-keyval@6.2.1)
uqr:
specifier: ^0.1.2
version: 0.1.2
valibot:
specifier: 1.0.0-beta.10
version: 1.0.0-beta.10(typescript@5.9.3)
@@ -1803,11 +1797,6 @@ packages:
'@corentinth/friendly-ids@0.0.1':
resolution: {integrity: sha512-NtOr6rEjsSp9bKUNkB0eAH04cFoXpiN9PmPLI0xEGq32g/qXlqp9vWHzr2Cc+Op8rHDjStuJxdWlD9iXTW7onA==}
'@corvu/otp-field@0.1.4':
resolution: {integrity: sha512-3eG7OoUt6CfVqGujIYfqImdrhGR/s4DpKr5ZQT10zzw3nawIlcwVpqoHTam0v4cgv+NXXvl6I8DoA3J+WgW2YA==}
peerDependencies:
solid-js: ^1.8
'@corvu/utils@0.4.2':
resolution: {integrity: sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA==}
peerDependencies:
@@ -2828,6 +2817,10 @@ packages:
resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/plugin-kit@0.4.0':
resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/plugin-kit@0.4.1':
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -11578,9 +11571,6 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
uqr@0.1.2:
resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -11876,7 +11866,6 @@ packages:
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-fetch@3.6.20:
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
@@ -13146,7 +13135,7 @@ snapshots:
dependencies:
'@babel/core': 7.28.4
'@babel/helper-module-imports': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@babel/helper-validator-identifier': 7.27.1
'@babel/traverse': 7.28.4
transitivePeerDependencies:
- supports-color
@@ -13696,7 +13685,7 @@ snapshots:
'@babel/types@7.28.4':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@babel/helper-validator-identifier': 7.27.1
'@babel/types@7.28.5':
dependencies:
@@ -13718,23 +13707,12 @@ snapshots:
nanostores: 1.0.1
zod: 4.1.12
'@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)':
dependencies:
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18
'@standard-schema/spec': 1.0.0
better-call: 1.1.5(zod@3.25.76)
jose: 6.1.0
kysely: 0.28.8
nanostores: 1.0.1
zod: 4.1.12
'@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)':
dependencies:
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18
'@standard-schema/spec': 1.0.0
better-call: 1.1.5(zod@4.1.12)
better-call: 1.1.5(zod@3.25.76)
jose: 6.1.0
kysely: 0.28.8
nanostores: 1.0.1
@@ -13753,9 +13731,9 @@ snapshots:
expo-network: 8.0.8(expo@54.0.23)(react@19.1.0)
expo-web-browser: 15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
'@better-auth/expo@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-network@8.0.8(expo@54.0.23)(react@19.1.0))(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))':
'@better-auth/expo@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-network@8.0.8(expo@54.0.23)(react@19.1.0))(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))':
dependencies:
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
'@better-fetch/fetch': 1.1.18
better-auth: 1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3))
better-call: 1.1.5(zod@4.1.12)
@@ -13987,11 +13965,6 @@ snapshots:
'@corentinth/friendly-ids@0.0.1': {}
'@corvu/otp-field@0.1.4(solid-js@1.9.9)':
dependencies:
'@corvu/utils': 0.4.2(solid-js@1.9.9)
solid-js: 1.9.9
'@corvu/utils@0.4.2(solid-js@1.9.9)':
dependencies:
'@floating-ui/dom': 1.6.13
@@ -14582,7 +14555,7 @@ snapshots:
'@eslint/markdown@7.5.0':
dependencies:
'@eslint/core': 0.16.0
'@eslint/plugin-kit': 0.4.1
'@eslint/plugin-kit': 0.4.0
github-slugger: 2.0.0
mdast-util-from-markdown: 2.0.2
mdast-util-frontmatter: 2.0.1
@@ -14595,6 +14568,11 @@ snapshots:
'@eslint/object-schema@2.1.7': {}
'@eslint/plugin-kit@0.4.0':
dependencies:
'@eslint/core': 0.16.0
levn: 0.4.1
'@eslint/plugin-kit@0.4.1':
dependencies:
'@eslint/core': 0.17.0
@@ -18654,7 +18632,7 @@ snapshots:
'@better-fetch/fetch': 1.1.18
'@noble/ciphers': 2.0.1
'@noble/hashes': 2.0.1
better-call: 1.1.5(zod@4.1.12)
better-call: 1.1.5(zod@3.25.76)
defu: 6.1.4
jose: 6.1.0
kysely: 0.28.8
@@ -20243,7 +20221,7 @@ snapshots:
dependencies:
'@babel/helper-validator-identifier': 7.28.5
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@eslint/plugin-kit': 0.4.1
'@eslint/plugin-kit': 0.4.0
change-case: 5.4.4
ci-info: 4.3.1
clean-regexp: 1.0.0
@@ -25836,8 +25814,6 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uqr@0.1.2: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1