mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-31 09:00:00 -06:00
Compare commits
5 Commits
loading-st
...
2fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c238ae2d0c | ||
|
|
b8c14d0f44 | ||
|
|
4878a3f8dd | ||
|
|
a213f0683b | ||
|
|
6a5bcef5ad |
5
.changeset/khaki-glasses-draw.md
Normal file
5
.changeset/khaki-glasses-draw.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added a dedicated increased timeout for the document upload route
|
||||
5
.changeset/metal-buttons-mate.md
Normal file
5
.changeset/metal-buttons-mate.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added a feedback message upon request timeout
|
||||
5
.changeset/ten-friends-shine.md
Normal file
5
.changeset/ten-friends-shine.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Changed config key `config.server.routeTimeoutMs` to `config.server.defaultRouteTimeoutMs` (env variable remains the same)
|
||||
@@ -29,6 +29,7 @@
|
||||
"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",
|
||||
@@ -50,6 +51,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -600,6 +600,7 @@ 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.',
|
||||
|
||||
@@ -40,6 +40,20 @@ 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',
|
||||
@@ -102,6 +116,66 @@ 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',
|
||||
@@ -598,6 +672,7 @@ 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.',
|
||||
@@ -638,6 +713,7 @@ 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
|
||||
|
||||
|
||||
@@ -600,6 +600,7 @@ 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,6 +600,7 @@ 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,6 +600,7 @@ 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,6 +600,7 @@ 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,6 +600,7 @@ 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,6 +600,7 @@ 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,6 +600,7 @@ 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,6 +600,7 @@ 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,6 +600,7 @@ 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': '具有此地址的接收邮箱已存在。',
|
||||
|
||||
@@ -20,6 +20,15 @@ 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, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Config } from '../config/config';
|
||||
|
||||
import type { SsoProviderConfig } from './auth.types';
|
||||
import { genericOAuthClient } from 'better-auth/client/plugins';
|
||||
import { genericOAuthClient, twoFactorClient } 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,6 +13,7 @@ export function createAuthClient() {
|
||||
baseURL: buildTimeConfig.baseApiUrl,
|
||||
plugins: [
|
||||
genericOAuthClient(),
|
||||
twoFactorClient(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -24,6 +25,7 @@ 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();
|
||||
@@ -44,6 +46,7 @@ export const {
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
sendVerificationEmail,
|
||||
twoFactor,
|
||||
} = buildTimeConfig.isDemoMode
|
||||
? createDemoAuthClient()
|
||||
: createAuthClient();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -11,16 +12,178 @@ 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 } from '../auth.services';
|
||||
import { authWithProvider, signIn, twoFactor } 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';
|
||||
|
||||
export const EmailLoginForm: 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) => {
|
||||
const navigate = useNavigate();
|
||||
const { config } = useConfig();
|
||||
const { t } = useI18n();
|
||||
@@ -28,7 +191,7 @@ export const EmailLoginForm: Component = () => {
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: async ({ email, password, rememberMe }) => {
|
||||
const { error } = await signIn.email({
|
||||
const { data: loginResult, error } = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
rememberMe,
|
||||
@@ -36,6 +199,11 @@ export const EmailLoginForm: Component = () => {
|
||||
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');
|
||||
}
|
||||
@@ -119,6 +287,8 @@ 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 });
|
||||
@@ -126,59 +296,69 @@ export const LoginPage: Component = () => {
|
||||
|
||||
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
|
||||
|
||||
if (!config.auth.providers.email.isEnabled && !getHasSsoProviders()) {
|
||||
return <AuthLayout><NoAuthProviderWarning /></AuthLayout>;
|
||||
}
|
||||
const hasNoAuthProviders = !config.auth.providers.email.isEnabled && !getHasSsoProviders();
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<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={!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>
|
||||
|
||||
<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}>
|
||||
{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 && 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 />
|
||||
<AuthLegalLinks />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,7 +100,7 @@ export const EmailRegisterForm: Component = () => {
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full" isLoading={form.submitting}>{t('auth.register.form.submit')}</Button>
|
||||
<Button type="submit" class="w-full">{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" isLoading={form.submitting}>
|
||||
<Button type="submit" class="w-full">
|
||||
{t('auth.reset-password.form.submit')}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@ 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, getIsDeletingDocument } = useDeleteDocument();
|
||||
const { deleteDocument } = useDeleteDocument();
|
||||
const { openRenameDialog } = useRenameDocumentDialog();
|
||||
|
||||
const deleteDoc = () => deleteDocument({
|
||||
@@ -53,14 +52,8 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
||||
<DropdownMenuItem
|
||||
class="cursor-pointer text-red"
|
||||
onClick={() => deleteDoc()}
|
||||
disabled={getIsDeletingDocument()}
|
||||
>
|
||||
<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>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
<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" isLoading={renameDocumentMutation.isPending}>{t('documents.rename.form.submit')}</Button>
|
||||
<Button type="submit">{t('documents.rename.form.submit')}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -24,10 +24,8 @@ 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',
|
||||
@@ -45,8 +43,6 @@ export function useDeleteDocument() {
|
||||
return { hasDeleted: false };
|
||||
}
|
||||
|
||||
setIsDeletingDocument(true);
|
||||
|
||||
await deleteDocument({
|
||||
documentId,
|
||||
organizationId,
|
||||
@@ -55,8 +51,6 @@ export function useDeleteDocument() {
|
||||
await invalidateOrganizationDocumentsQuery({ organizationId });
|
||||
createToast({ type: 'success', message: 'Document deleted' });
|
||||
|
||||
setIsDeletingDocument(false);
|
||||
|
||||
return { hasDeleted: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,7 +30,6 @@ 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 () => {
|
||||
@@ -46,10 +45,8 @@ 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({
|
||||
@@ -112,7 +109,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" isLoading={form.submitting}>
|
||||
<Button type="submit">
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('intake-emails.allowed-origins.add.button')}
|
||||
</Button>
|
||||
@@ -143,7 +140,6 @@ const AllowedOriginsDialog: Component<{
|
||||
size="icon"
|
||||
class="text-red"
|
||||
onClick={() => deleteAllowedOrigin({ origin })}
|
||||
isLoading={deletingOrigin() === origin}
|
||||
>
|
||||
<div class="i-tabler-trash size-4" />
|
||||
</Button>
|
||||
@@ -161,9 +157,6 @@ 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 (
|
||||
@@ -202,8 +195,6 @@ export const IntakeEmailsPage: Component = () => {
|
||||
}));
|
||||
|
||||
const createEmail = async () => {
|
||||
setIsCreatingEmail(true);
|
||||
|
||||
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
|
||||
|
||||
if (error) {
|
||||
@@ -212,7 +203,6 @@ export const IntakeEmailsPage: Component = () => {
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
setIsCreatingEmail(false);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -222,8 +212,6 @@ export const IntakeEmailsPage: Component = () => {
|
||||
message: t('intake-emails.create.success'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
setIsCreatingEmail(false);
|
||||
};
|
||||
|
||||
const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => {
|
||||
@@ -243,8 +231,6 @@ export const IntakeEmailsPage: Component = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingEmailId(intakeEmailId);
|
||||
|
||||
await deleteIntakeEmail({ organizationId: params.organizationId, intakeEmailId });
|
||||
await query.refetch();
|
||||
|
||||
@@ -252,13 +238,9 @@ 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();
|
||||
|
||||
@@ -266,8 +248,6 @@ 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) => {
|
||||
@@ -304,7 +284,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
class="pt-0"
|
||||
icon="i-tabler-mail"
|
||||
cta={(
|
||||
<Button variant="secondary" onClick={createEmail} isLoading={isCreatingEmail()}>
|
||||
<Button variant="secondary" onClick={createEmail}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('intake-emails.empty.generate')}
|
||||
</Button>
|
||||
@@ -321,7 +301,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button onClick={createEmail} isLoading={isCreatingEmail()}>
|
||||
<Button onClick={createEmail}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('intake-emails.new')}
|
||||
</Button>
|
||||
@@ -379,14 +359,8 @@ export const IntakeEmailsPage: Component = () => {
|
||||
setOpenDropdownId(null);
|
||||
updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled });
|
||||
}}
|
||||
disabled={updatingEmailId() === intakeEmail.id}
|
||||
>
|
||||
<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>
|
||||
<div class="i-tabler-power size-4 mr-2" />
|
||||
{intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -403,14 +377,8 @@ export const IntakeEmailsPage: Component = () => {
|
||||
deleteEmail({ intakeEmailId: intakeEmail.id });
|
||||
}}
|
||||
class="text-red"
|
||||
disabled={deletingEmailId() === intakeEmail.id}
|
||||
>
|
||||
<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>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
{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 { createSignal, For, Show } from 'solid-js';
|
||||
import { 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,9 +30,6 @@ 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: () => {
|
||||
@@ -78,23 +75,11 @@ const MemberList: Component = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingMemberId(memberId);
|
||||
try {
|
||||
await removeMemberMutation.mutateAsync({ memberId });
|
||||
}
|
||||
finally {
|
||||
setDeletingMemberId(null);
|
||||
}
|
||||
removeMemberMutation.mutate({ memberId });
|
||||
};
|
||||
|
||||
const handleUpdateMemberRole = async ({ memberId, role }: { memberId: string; role: OrganizationMemberRole }) => {
|
||||
setUpdatingMemberId(memberId);
|
||||
try {
|
||||
await updateMemberRoleMutation.mutateAsync({ memberId, role });
|
||||
}
|
||||
finally {
|
||||
setUpdatingMemberId(null);
|
||||
}
|
||||
await updateMemberRoleMutation.mutateAsync({ memberId, role });
|
||||
};
|
||||
|
||||
const table = createSolidTable({
|
||||
@@ -114,14 +99,9 @@ const MemberList: Component = () => {
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete({ memberId: data.row.original.id })}
|
||||
disabled={data.row.original.role === ORGANIZATION_ROLES.OWNER || !getIsAtLeastAdmin() || deletingMemberId() === data.row.original.id}
|
||||
disabled={data.row.original.role === ORGANIZATION_ROLES.OWNER || !getIsAtLeastAdmin()}
|
||||
>
|
||||
<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>
|
||||
<div class="i-tabler-user-x size-4 mr-2" />
|
||||
{t('organizations.members.remove-from-organization')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -131,19 +111,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 }) || updatingMemberId() === data.row.original.id}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.OWNER })}
|
||||
>
|
||||
{t(`organizations.members.roles.owner`)}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value={ORGANIZATION_ROLES.ADMIN}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.ADMIN }) || updatingMemberId() === data.row.original.id}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.ADMIN })}
|
||||
>
|
||||
{t(`organizations.members.roles.admin`)}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value={ORGANIZATION_ROLES.MEMBER}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.MEMBER }) || updatingMemberId() === data.row.original.id}
|
||||
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.MEMBER })}
|
||||
>
|
||||
{t(`organizations.members.roles.member`)}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
@@ -4,3 +4,10 @@ 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);
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ export const TaggingRuleForm: Component<{
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
<Button type="submit" isLoading={form.submitting}>{props.submitButtonText ?? t('tagging-rules.form.submit')}</Button>
|
||||
<Button type="submit">{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" isLoading={form.submitting}>
|
||||
<Button type="submit">
|
||||
{props.submitLabel ?? t('tags.create')}
|
||||
</Button>
|
||||
|
||||
@@ -229,7 +229,6 @@ 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'],
|
||||
@@ -254,8 +253,6 @@ export const TagsPage: Component = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingTagId(tag.id);
|
||||
|
||||
const [, error] = await safely(deleteTag({
|
||||
organizationId: params.organizationId,
|
||||
tagId: tag.id,
|
||||
@@ -267,7 +264,6 @@ export const TagsPage: Component = () => {
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
setDeletingTagId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -280,8 +276,6 @@ export const TagsPage: Component = () => {
|
||||
message: t('tags.delete.success'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
setDeletingTagId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -374,7 +368,7 @@ export const TagsPage: Component = () => {
|
||||
)}
|
||||
</UpdateTagModal>
|
||||
|
||||
<Button size="icon" variant="outline" class="size-7 text-red" onClick={() => del({ tag })} isLoading={deletingTagId() === tag.id}>
|
||||
<Button size="icon" variant="outline" class="size-7 text-red" onClick={() => del({ tag })}>
|
||||
<div class="i-tabler-trash size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
83
apps/papra-client/src/modules/ui/components/otp-field.tsx
Normal file
83
apps/papra-client/src/modules/ui/components/otp-field.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
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 };
|
||||
12
apps/papra-client/src/modules/ui/components/qr-code.tsx
Normal file
12
apps/papra-client/src/modules/ui/components/qr-code.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
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} />
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export { TableSkeleton } from './table-skeleton';
|
||||
export { CardSkeleton } from './card-skeleton';
|
||||
export { GridSkeleton } from './grid-skeleton';
|
||||
@@ -1,31 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
30
apps/papra-client/src/modules/users/2fa.models.test.ts
Normal file
30
apps/papra-client/src/modules/users/2fa.models.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
7
apps/papra-client/src/modules/users/2fa.models.ts
Normal file
7
apps/papra-client/src/modules/users/2fa.models.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function getSecretFromTotpUri({ totpUri }: { totpUri: string }): string {
|
||||
try {
|
||||
return new URL(totpUri).searchParams.get('secret') ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -147,6 +148,7 @@ 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>
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ export type User = {
|
||||
updatedAt: Date;
|
||||
emailVerified: boolean;
|
||||
maxOrganizationCount: number | null;
|
||||
twoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
export type UserMe = User & {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
2170
apps/papra-server/src/migrations/meta/0012_snapshot.json
Normal file
2170
apps/papra-server/src/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
||||
"when": 1761645190314,
|
||||
"tag": "0011_tagging-rule-condition-match-mode",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1766411483931,
|
||||
"tag": "0012_two-factor-authentication",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,11 @@ 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,
|
||||
@@ -31,4 +29,5 @@ export const migrations: Migration[] = [
|
||||
softDeleteOrganizationsMigration,
|
||||
taggingRuleConditionMatchModeMigration,
|
||||
dropFts5TriggersMigration,
|
||||
];
|
||||
twoFactorAuthenticationMigration
|
||||
];
|
||||
@@ -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 } from 'better-auth/plugins';
|
||||
import { genericOAuth, twoFactor } 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, verificationsTable } from './auth.tables';
|
||||
import { accountsTable, sessionsTable, twoFactorTable, verificationsTable } from './auth.tables';
|
||||
|
||||
export type Auth = ReturnType<typeof getAuth>['auth'];
|
||||
|
||||
@@ -74,6 +74,7 @@ export function getAuth({
|
||||
account: accountsTable,
|
||||
session: sessionsTable,
|
||||
verification: verificationsTable,
|
||||
twoFactor: twoFactorTable,
|
||||
},
|
||||
},
|
||||
),
|
||||
@@ -127,26 +128,7 @@ export function getAuth({
|
||||
},
|
||||
plugins: [
|
||||
expo(),
|
||||
// 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,
|
||||
// });
|
||||
// },
|
||||
// },
|
||||
twoFactor(),
|
||||
|
||||
...(config.auth.providers.customs.length > 0
|
||||
? [genericOAuth({ config: config.auth.providers.customs })]
|
||||
|
||||
@@ -56,3 +56,15 @@ 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'),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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: { routeTimeoutMs: 50 } });
|
||||
const config = overrideConfig({ server: { defaultRouteTimeoutMs: 50 } });
|
||||
|
||||
const app = new Hono<ServerInstanceGenerics>();
|
||||
registerErrorMiddleware({ app });
|
||||
@@ -45,5 +45,107 @@ 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,11 +1,42 @@
|
||||
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 { server: { routeTimeoutMs } } = config;
|
||||
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 });
|
||||
|
||||
let timerId: NodeJS.Timeout | undefined;
|
||||
|
||||
@@ -16,7 +47,7 @@ export function createTimeoutMiddleware({ config }: { config: Config }) {
|
||||
message: 'The request timed out',
|
||||
statusCode: 504,
|
||||
}),
|
||||
), routeTimeoutMs);
|
||||
), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -84,12 +85,29 @@ export const configDefinition = {
|
||||
default: '0.0.0.0',
|
||||
env: 'SERVER_HOSTNAME',
|
||||
},
|
||||
routeTimeoutMs: {
|
||||
defaultRouteTimeoutMs: {
|
||||
doc: 'The maximum time in milliseconds for a route to complete before timing out',
|
||||
schema: z.coerce.number().int().positive(),
|
||||
default: 20_000,
|
||||
default: 20 * IN_MS.SECOND,
|
||||
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,8 +6,11 @@ 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';
|
||||
@@ -45,12 +48,17 @@ function setupCreateDocumentRoute({ app, ...deps }: RouteDefinitionContext) {
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const { maxUploadSize } = config.documentsStorage;
|
||||
// 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 { fileStream, fileName, mimeType } = await getFileStreamFromMultipartForm({
|
||||
body: context.req.raw.body,
|
||||
headers: context.req.header(),
|
||||
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : undefined,
|
||||
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize: maxFileSize }) ? maxFileSize : undefined,
|
||||
});
|
||||
|
||||
const createDocument = createDocumentCreationUsecase({ ...deps });
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
@@ -247,5 +248,123 @@ 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,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -27,7 +28,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_000,
|
||||
default: 2 * IN_MS.SECOND,
|
||||
env: 'INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
@@ -30,7 +31,7 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
||||
id: FREE_PLAN_ID,
|
||||
name: 'Free',
|
||||
limits: {
|
||||
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1024 * 1024 * 500, // 500 MiB
|
||||
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 500 * IN_BYTES.MEGABYTE,
|
||||
maxIntakeEmailsCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1,
|
||||
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 3,
|
||||
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : Number.POSITIVE_INFINITY,
|
||||
@@ -42,10 +43,10 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
||||
monthlyPriceId: config.organizationPlans.plusPlanMonthlyPriceId,
|
||||
annualPriceId: config.organizationPlans.plusPlanAnnualPriceId,
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 5, // 5 GiB
|
||||
maxDocumentStorageBytes: 5 * IN_BYTES.GIGABYTE, // 5 GiB
|
||||
maxIntakeEmailsCount: 10,
|
||||
maxOrganizationsMembersCount: 10,
|
||||
maxFileSize: 1024 * 1024 * 100, // 100 MiB
|
||||
maxFileSize: 100 * IN_BYTES.MEGABYTE, // 100 MiB
|
||||
},
|
||||
},
|
||||
[PRO_PLAN_ID]: {
|
||||
@@ -54,10 +55,10 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
||||
monthlyPriceId: config.organizationPlans.proPlanMonthlyPriceId,
|
||||
annualPriceId: config.organizationPlans.proPlanAnnualPriceId,
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 50, // 50 GiB
|
||||
maxDocumentStorageBytes: 50 * IN_BYTES.GIGABYTE, // 50 GiB
|
||||
maxIntakeEmailsCount: 100,
|
||||
maxOrganizationsMembersCount: 50,
|
||||
maxFileSize: 1024 * 1024 * 500, // 500 MiB
|
||||
maxFileSize: 500 * IN_BYTES.MEGABYTE, // 500 MiB
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
16
apps/papra-server/src/modules/shared/units.ts
Normal file
16
apps/papra-server/src/modules/shared/units.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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,6 +2,7 @@ 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 = {
|
||||
@@ -35,7 +36,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_000,
|
||||
default: 1 * IN_MS.SECOND,
|
||||
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_POLL_INTERVAL_MS',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ function setupGetCurrentUserRoute({ app, db }: RouteDefinitionContext) {
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'planId',
|
||||
'twoFactorEnabled',
|
||||
],
|
||||
),
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
60
pnpm-lock.yaml
generated
@@ -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@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)))
|
||||
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)))
|
||||
'@corentinth/chisels':
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
@@ -283,6 +283,9 @@ 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)
|
||||
@@ -346,6 +349,9 @@ 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)
|
||||
@@ -1797,6 +1803,11 @@ 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:
|
||||
@@ -2817,10 +2828,6 @@ 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}
|
||||
@@ -11571,6 +11578,9 @@ 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==}
|
||||
|
||||
@@ -11866,6 +11876,7 @@ 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==}
|
||||
@@ -13135,7 +13146,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/core': 7.28.4
|
||||
'@babel/helper-module-imports': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
'@babel/traverse': 7.28.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13685,7 +13696,7 @@ snapshots:
|
||||
'@babel/types@7.28.4':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@babel/types@7.28.5':
|
||||
dependencies:
|
||||
@@ -13707,7 +13718,7 @@ 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@4.1.12))(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@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
|
||||
@@ -13718,6 +13729,17 @@ 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@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)
|
||||
jose: 6.1.0
|
||||
kysely: 0.28.8
|
||||
nanostores: 1.0.1
|
||||
zod: 4.1.12
|
||||
|
||||
'@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.67))(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.67))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
|
||||
@@ -13731,9 +13753,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@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)))':
|
||||
'@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)))':
|
||||
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@4.1.12))(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@3.25.76))(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)
|
||||
@@ -13965,6 +13987,11 @@ 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
|
||||
@@ -14555,7 +14582,7 @@ snapshots:
|
||||
'@eslint/markdown@7.5.0':
|
||||
dependencies:
|
||||
'@eslint/core': 0.16.0
|
||||
'@eslint/plugin-kit': 0.4.0
|
||||
'@eslint/plugin-kit': 0.4.1
|
||||
github-slugger: 2.0.0
|
||||
mdast-util-from-markdown: 2.0.2
|
||||
mdast-util-frontmatter: 2.0.1
|
||||
@@ -14568,11 +14595,6 @@ 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
|
||||
@@ -18632,7 +18654,7 @@ snapshots:
|
||||
'@better-fetch/fetch': 1.1.18
|
||||
'@noble/ciphers': 2.0.1
|
||||
'@noble/hashes': 2.0.1
|
||||
better-call: 1.1.5(zod@3.25.76)
|
||||
better-call: 1.1.5(zod@4.1.12)
|
||||
defu: 6.1.4
|
||||
jose: 6.1.0
|
||||
kysely: 0.28.8
|
||||
@@ -20221,7 +20243,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.0
|
||||
'@eslint/plugin-kit': 0.4.1
|
||||
change-case: 5.4.4
|
||||
ci-info: 4.3.1
|
||||
clean-regexp: 1.0.0
|
||||
@@ -25814,6 +25836,8 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uqr@0.1.2: {}
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
Reference in New Issue
Block a user