Compare commits

..

8 Commits

Author SHA1 Message Date
Corentin Thomasset
c238ae2d0c feat(authentication): added 2FA support 2025-12-31 13:44:57 +01:00
Corentin Thomasset
b8c14d0f44 feat(i18n): add timeout error message in all locales (#712) 2025-12-29 11:22:28 +00:00
Corentin Thomasset
4878a3f8dd refactor(config): replace hardcoded time and size values with constants (#708) 2025-12-28 14:36:20 +01:00
Corentin Thomasset
a213f0683b feat(timeout): implement route-specific timeout configuration for uploads (#707) 2025-12-28 02:35:32 +01:00
Corentin Thomasset
6a5bcef5ad fix(subscription): proper file size limit enforcing 2025-12-27 03:48:34 +01:00
Jibran Iqbal
607ba9496c fix(mobile): upload failing in Android (#687)
* Fixed mobile upload

* fix lint

* fix the document exit button and ios not launching issue

* added reactotron for debugging network and fixed file name issue

* added reactotron for debugging network and fixed file name issue

* back to old signature

* fix the type issues

* fix the type issues

* fix lint

* exclude the type defs

* refactor(mobile): moved declaration file to src root

---------

Co-authored-by: jibraniqbal666 <jibran.iqbal@protonmail.com>
Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-12-24 15:32:52 +01:00
Corentin Thomasset
ec34cf1788 feat(admin): organization listing and search (#702) 2025-12-22 00:21:55 +01:00
Corentin Thomasset
e52287d04f fix(tests): add createdAt field to user test data for consistency (#701) 2025-12-21 20:38:15 +00:00
74 changed files with 5507 additions and 202 deletions

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Added a dedicated increased timeout for the document upload route

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Added a feedback message upon request timeout

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Organizations listing and details in the admin dashboard

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Changed config key `config.server.routeTimeoutMs` to `config.server.defaultRouteTimeoutMs` (env variable remains the same)

View File

@@ -9,7 +9,8 @@
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "app.papra.ios"
},
"android": {
"adaptiveIcon": {

View File

@@ -0,0 +1,5 @@
import Reactotron from 'reactotron-react-native';
Reactotron.configure({ name: 'Papra' }) // controls connection & communication settings
.useReactNative() // add all built-in react native plugins
.connect(); // let's connect!

View File

@@ -3,6 +3,11 @@ import { Redirect } from 'expo-router';
import { createAuthClient } from '@/modules/auth/auth.client';
import { configLocalStorage } from '@/modules/config/config.local-storage';
if (__DEV__) {
// eslint-disable-next-line ts/no-require-imports
require('./ReactotronConfig');
}
export default function Index() {
const query = useQuery({
queryKey: ['api-server-url'],

View File

@@ -29,12 +29,13 @@
"better-auth": "catalog:",
"expo": "~54.0.22",
"expo-constants": "~18.0.10",
"expo-document-picker": "^14.0.7",
"expo-document-picker": "^14.0.8",
"expo-file-system": "^19.0.19",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.8",
"expo-network": "^8.0.8",
"expo-router": "~6.0.14",
"expo-secure-store": "^15.0.7",
"expo-sharing": "^14.0.7",
@@ -62,6 +63,7 @@
"eas-cli": "^16.27.0",
"eslint": "catalog:",
"eslint-config-expo": "~10.0.0",
"reactotron-react-native": "^5.1.18",
"typescript": "catalog:",
"vitest": "catalog:"
}

View File

@@ -41,3 +41,9 @@ export function coerceDates<T extends Record<string, unknown>>(obj: T): CoerceDa
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: coerceDateOrUndefined(obj.scheduledPurgeAt) } : {}),
} as CoerceDates<T>;
}
export type LocalDocument = {
uri: string;
name: string;
type: string | undefined;
};

View File

@@ -198,7 +198,7 @@ function createStyles({ themeColors }: { themeColors: ThemeColors }) {
borderRadius: 20,
backgroundColor: themeColors.secondaryBackground,
justifyContent: 'center',
alignItems: 'flex-start',
alignItems: 'center',
},
headerTitle: {
flex: 1,

View File

@@ -1,6 +1,6 @@
import type { LocalDocument } from '@/modules/api/api.models';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import * as DocumentPicker from 'expo-document-picker';
import { File } from 'expo-file-system';
import {
Modal,
StyleSheet,
@@ -58,12 +58,16 @@ export function ImportDrawer({ visible, onClose }: ImportDrawerProps) {
return;
}
const [pickerFile] = result.assets;
const pickerFile = result.assets[0];
if (!pickerFile) {
return;
}
const file = new File(pickerFile.uri);
const file: LocalDocument = {
uri: pickerFile.uri,
name: pickerFile.name,
type: pickerFile.mimeType,
};
await uploadDocument({ file, apiClient, organizationId: currentOrganizationId });
await queryClient.invalidateQueries({ queryKey: ['organizations', currentOrganizationId, 'documents'] });

View File

@@ -1,31 +1,37 @@
import type { ApiClient } from '../api/api.client';
import type { CoerceDates } from '../api/api.models';
import type { CoerceDates, LocalDocument } from '../api/api.models';
import type { AuthClient } from '../auth/auth.client';
import type { Document } from './documents.types';
import * as FileSystem from 'expo-file-system/legacy';
import { coerceDates } from '../api/api.models';
export function getFormData(pojo: Record<string, string | Blob>): FormData {
export function getFormData(pojo: Record<string, string | FormDataValue | Blob>): FormData {
const formData = new FormData();
Object.entries(pojo).forEach(([key, value]) => formData.append(key, value));
return formData;
}
export async function uploadDocument({
file,
organizationId,
apiClient,
}: {
file: Blob;
file: LocalDocument;
organizationId: string;
apiClient: ApiClient;
}) {
const { document } = await apiClient<{ document: Document }>({
method: 'POST',
path: `/api/organizations/${organizationId}/documents`,
body: getFormData({ file }),
body: getFormData({
file: {
uri: file.uri,
// to avoid %20 in file name it is issue in react native that upload file name replaces spaces with %20
name: file.name.replace(/ /g, '_'),
type: file.type ?? 'application/json',
},
}),
});
return {

17
apps/mobile/src/types/formdata.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
/* eslint-disable ts/consistent-type-definitions */
/* eslint-disable ts/method-signature-style */
// Source - https://stackoverflow.com/a
// Posted by Patrick Roberts, modified by community. See post 'Timeline' for change history
// Retrieved 2025-12-19, License - CC BY-SA 4.0
interface FormDataValue {
uri: string;
name: string;
type: string;
}
interface FormData {
append(name: string, value: string | FormDataValue | Blob, fileName?: string): void;
set(name: string, value: string | FormDataValue | Blob, fileName?: string): void;
}

View File

@@ -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": {

View File

@@ -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.',

View File

@@ -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

View File

@@ -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.',

View File

@@ -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à.',

View File

@@ -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à.',

View File

@@ -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.',

View File

@@ -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.',

View File

@@ -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.',

View File

@@ -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.',

View File

@@ -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.',

View File

@@ -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': '具有此地址的接收邮箱已存在。',

View File

@@ -25,11 +25,11 @@ export const adminRoutes: RouteDefinition = {
},
{
path: '/organizations',
component: () => <div class="p-6 text-muted-foreground">Not implemented yet.</div>,
component: lazy(() => import('./organizations/pages/list-organizations.page')),
},
{
path: '/settings',
component: () => <div class="p-6 text-muted-foreground">Not implemented yet.</div>,
path: '/organizations/:organizationId',
component: lazy(() => import('./organizations/pages/organization-detail.page')),
},
{
path: '/*404',

View File

@@ -23,11 +23,6 @@ const AdminLayout: ParentComponent = (props) => {
href: '/admin/organizations',
icon: 'i-tabler-building-community',
},
{
label: 'Settings',
href: '/admin/settings',
icon: 'i-tabler-settings',
},
];
const sidenav = () => (

View File

@@ -0,0 +1,95 @@
import type { IntakeEmail } from '@/modules/intake-emails/intake-emails.types';
import type { Organization } from '@/modules/organizations/organizations.types';
import type { User } from '@/modules/users/users.types';
import type { Webhook } from '@/modules/webhooks/webhooks.types';
import { apiClient } from '@/modules/shared/http/api-client';
export type OrganizationWithMemberCount = Organization & { memberCount: number };
export type OrganizationMember = {
id: string;
userId: string;
organizationId: string;
role: string;
createdAt: string;
user: User;
};
export type OrganizationStats = {
documentsCount: number;
documentsSize: number;
deletedDocumentsCount: number;
deletedDocumentsSize: number;
totalDocumentsCount: number;
totalDocumentsSize: number;
};
export async function listOrganizations({ search, pageIndex = 0, pageSize = 25 }: { search?: string; pageIndex?: number; pageSize?: number }) {
const { totalCount, organizations } = await apiClient<{
organizations: OrganizationWithMemberCount[];
totalCount: number;
pageIndex: number;
pageSize: number;
}>({
method: 'GET',
path: '/api/admin/organizations',
query: { search, pageIndex, pageSize },
});
return { pageIndex, pageSize, totalCount, organizations };
}
export async function getOrganizationBasicInfo({ organizationId }: { organizationId: string }) {
const { organization } = await apiClient<{
organization: Organization;
}>({
method: 'GET',
path: `/api/admin/organizations/${organizationId}`,
});
return { organization };
}
export async function getOrganizationMembers({ organizationId }: { organizationId: string }) {
const { members } = await apiClient<{
members: OrganizationMember[];
}>({
method: 'GET',
path: `/api/admin/organizations/${organizationId}/members`,
});
return { members };
}
export async function getOrganizationIntakeEmails({ organizationId }: { organizationId: string }) {
const { intakeEmails } = await apiClient<{
intakeEmails: IntakeEmail[];
}>({
method: 'GET',
path: `/api/admin/organizations/${organizationId}/intake-emails`,
});
return { intakeEmails };
}
export async function getOrganizationWebhooks({ organizationId }: { organizationId: string }) {
const { webhooks } = await apiClient<{
webhooks: Webhook[];
}>({
method: 'GET',
path: `/api/admin/organizations/${organizationId}/webhooks`,
});
return { webhooks };
}
export async function getOrganizationStats({ organizationId }: { organizationId: string }) {
const { stats } = await apiClient<{
stats: OrganizationStats;
}>({
method: 'GET',
path: `/api/admin/organizations/${organizationId}/stats`,
});
return { stats };
}

View File

@@ -0,0 +1,230 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { createSignal, For, Show } from 'solid-js';
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
import { Button } from '@/modules/ui/components/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
import { listOrganizations } from '../organizations.services';
export const AdminListOrganizationsPage: Component = () => {
const [search, setSearch] = createSignal('');
const [pagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 25 });
const query = useQuery(() => ({
queryKey: ['admin', 'organizations', search(), pagination()],
queryFn: () => listOrganizations({
search: search() || undefined,
pageIndex: pagination().pageIndex,
pageSize: pagination().pageSize,
}),
}));
const table = createSolidTable({
get data() {
return query.data?.organizations ?? [];
},
columns: [
{
header: 'ID',
accessorKey: 'id',
cell: data => (
<A
href={`/admin/organizations/${data.getValue<string>()}`}
class="font-mono hover:underline text-primary"
>
{data.getValue<string>()}
</A>
),
},
{
header: 'Name',
accessorKey: 'name',
cell: data => (
<div class="font-medium">
{data.getValue<string>()}
</div>
),
},
{
header: 'Members',
accessorKey: 'memberCount',
cell: data => (
<div class="text-center">
{data.getValue<number>()}
</div>
),
},
{
header: 'Created',
accessorKey: 'createdAt',
cell: data => <RelativeTime class="text-muted-foreground text-sm" date={new Date(data.getValue<string>())} />,
},
{
header: 'Updated',
accessorKey: 'updatedAt',
cell: data => <RelativeTime class="text-muted-foreground text-sm" date={new Date(data.getValue<string>())} />,
},
],
get rowCount() {
return query.data?.totalCount ?? 0;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
state: {
get pagination() {
return pagination();
},
},
manualPagination: true,
});
const handleSearch = (e: Event) => {
const target = e.target as HTMLInputElement;
setSearch(target.value);
setPagination({ pageIndex: 0, pageSize: pagination().pageSize });
};
return (
<div class="p-6">
<div class="border-b mb-6 pb-4">
<h1 class="text-xl font-bold mb-1">
Organization Management
</h1>
<p class="text-sm text-muted-foreground">
Manage and view all organizations in the system
</p>
</div>
<div class="mb-4">
<TextFieldRoot class="max-w-sm">
<TextField
type="text"
placeholder="Search by name or ID..."
value={search()}
onInput={handleSearch}
/>
</TextFieldRoot>
</div>
<Show
when={!query.isLoading}
fallback={<div class="text-center py-8 text-muted-foreground">Loading organizations...</div>}
>
<Show
when={(query.data?.organizations.length ?? 0) > 0}
fallback={(
<div class="text-center py-8 text-muted-foreground">
{search() ? 'No organizations found matching your search.' : 'No organizations found.'}
</div>
)}
>
<div class="border-y">
<Table>
<TableHeader>
<For each={table.getHeaderGroups()}>
{headerGroup => (
<TableRow>
<For each={headerGroup.headers}>
{header => (
<TableHead>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)}
</For>
</TableRow>
)}
</For>
</TableHeader>
<TableBody>
<For each={table.getRowModel().rows}>
{row => (
<TableRow>
<For each={row.getVisibleCells()}>
{cell => (
<TableCell>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
)}
</For>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-muted-foreground">
Showing
{' '}
{table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
{' '}
to
{' '}
{Math.min((table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, query.data?.totalCount ?? 0)}
{' '}
of
{' '}
{query.data?.totalCount ?? 0}
{' '}
organizations
</div>
<div class="flex items-center space-x-2">
<Button
variant="outline"
size="icon"
class="size-8"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<div class="size-4 i-tabler-chevrons-left" />
</Button>
<Button
variant="outline"
size="icon"
class="size-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<div class="size-4 i-tabler-chevron-left" />
</Button>
<div class="text-sm whitespace-nowrap">
Page
{' '}
{table.getState().pagination.pageIndex + 1}
{' '}
of
{' '}
{table.getPageCount()}
</div>
<Button
variant="outline"
size="icon"
class="size-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<div class="size-4 i-tabler-chevron-right" />
</Button>
<Button
variant="outline"
size="icon"
class="size-8"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<div class="size-4 i-tabler-chevrons-right" />
</Button>
</div>
</div>
</Show>
</Show>
</div>
);
};
export default AdminListOrganizationsPage;

View File

@@ -0,0 +1,319 @@
import type { Component } from 'solid-js';
import { formatBytes } from '@corentinth/chisels';
import { A, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { For, Show, Suspense } from 'solid-js';
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { UserListDetail } from '../../users/components/user-list-detail.component';
import {
getOrganizationBasicInfo,
getOrganizationIntakeEmails,
getOrganizationMembers,
getOrganizationStats,
getOrganizationWebhooks,
} from '../organizations.services';
const OrganizationBasicInfo: Component<{ organizationId: string }> = (props) => {
const query = useQuery(() => ({
queryKey: ['admin', 'organizations', props.organizationId, 'basic'],
queryFn: () => getOrganizationBasicInfo({ organizationId: props.organizationId }),
}));
return (
<Show when={query.data}>
{data => (
<Card>
<CardHeader>
<CardTitle>Organization Information</CardTitle>
<CardDescription>Basic organization details</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">ID</span>
<span class="font-mono text-xs">{data().organization.id}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Name</span>
<span class="text-sm font-medium">{data().organization.name}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Created</span>
<RelativeTime class="text-sm" date={new Date(data().organization.createdAt)} />
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Updated</span>
<RelativeTime class="text-sm" date={new Date(data().organization.updatedAt)} />
</div>
</CardContent>
</Card>
)}
</Show>
);
};
const OrganizationMembers: Component<{ organizationId: string }> = (props) => {
const query = useQuery(() => ({
queryKey: ['admin', 'organizations', props.organizationId, 'members'],
queryFn: () => getOrganizationMembers({ organizationId: props.organizationId }),
}));
return (
<Card>
<CardHeader>
<CardTitle>
Members (
{query.data?.members.length ?? 0}
)
</CardTitle>
<CardDescription>Users who belong to this organization</CardDescription>
</CardHeader>
<CardContent>
<Show when={query.data}>
{data => (
<Show
when={data().members.length > 0}
fallback={<p class="text-sm text-muted-foreground">No members found</p>}
>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Id</TableHead>
<TableHead>Role</TableHead>
<TableHead>Joined</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={data().members}>
{member => (
<TableRow>
<TableCell>
<UserListDetail {...member.user} />
</TableCell>
<TableCell>
<A
href={`/admin/users/${member.userId}`}
class="font-mono hover:underline"
>
<div class="font-mono text-sm">{member.userId}</div>
</A>
</TableCell>
<TableCell>
<Badge variant="secondary" class="capitalize">
{member.role}
</Badge>
</TableCell>
<TableCell>
<RelativeTime class="text-muted-foreground text-sm" date={new Date(member.createdAt)} />
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</div>
</Show>
)}
</Show>
</CardContent>
</Card>
);
};
const OrganizationIntakeEmails: Component<{ organizationId: string }> = (props) => {
const query = useQuery(() => ({
queryKey: ['admin', 'organizations', props.organizationId, 'intake-emails'],
queryFn: () => getOrganizationIntakeEmails({ organizationId: props.organizationId }),
}));
return (
<Card>
<CardHeader>
<CardTitle>
Intake Emails (
{query.data?.intakeEmails.length ?? 0}
)
</CardTitle>
<CardDescription>Email addresses for document ingestion</CardDescription>
</CardHeader>
<CardContent>
<Show when={query.data}>
{data => (
<Show
when={data().intakeEmails.length > 0}
fallback={<p class="text-sm text-muted-foreground">No intake emails configured</p>}
>
<div class="space-y-2">
<For each={data().intakeEmails}>
{email => (
<div class="flex items-center justify-between p-3 border rounded-md">
<div>
<div class="font-mono text-sm">{email.emailAddress}</div>
<div class="text-xs text-muted-foreground mt-1">
{email.isEnabled ? 'Enabled' : 'Disabled'}
</div>
</div>
<Badge variant={email.isEnabled ? 'default' : 'outline'}>
{email.isEnabled ? 'Active' : 'Inactive'}
</Badge>
</div>
)}
</For>
</div>
</Show>
)}
</Show>
</CardContent>
</Card>
);
};
const OrganizationWebhooks: Component<{ organizationId: string }> = (props) => {
const query = useQuery(() => ({
queryKey: ['admin', 'organizations', props.organizationId, 'webhooks'],
queryFn: () => getOrganizationWebhooks({ organizationId: props.organizationId }),
}));
return (
<Card>
<CardHeader>
<CardTitle>
Webhooks (
{query.data?.webhooks.length ?? 0}
)
</CardTitle>
<CardDescription>Configured webhook endpoints</CardDescription>
</CardHeader>
<CardContent>
<Show when={query.data}>
{data => (
<Show
when={data().webhooks.length > 0}
fallback={<p class="text-sm text-muted-foreground">No webhooks configured</p>}
>
<div class="space-y-2">
<For each={data().webhooks}>
{webhook => (
<div class="flex items-center justify-between p-3 border rounded-md">
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{webhook.name}</div>
<div class="font-mono text-xs text-muted-foreground truncate mt-1">{webhook.url}</div>
</div>
<Badge variant={webhook.enabled ? 'default' : 'outline'} class="ml-2 flex-shrink-0">
{webhook.enabled ? 'Active' : 'Inactive'}
</Badge>
</div>
)}
</For>
</div>
</Show>
)}
</Show>
</CardContent>
</Card>
);
};
const OrganizationStats: Component<{ organizationId: string }> = (props) => {
const query = useQuery(() => ({
queryKey: ['admin', 'organizations', props.organizationId, 'stats'],
queryFn: () => getOrganizationStats({ organizationId: props.organizationId }),
}));
return (
<Card>
<CardHeader>
<CardTitle>Usage Statistics</CardTitle>
<CardDescription>Document and storage statistics</CardDescription>
</CardHeader>
<CardContent>
<Show when={query.data}>
{data => (
<div class="space-y-3">
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Active Documents</span>
<span class="text-sm font-medium">{data().stats.documentsCount}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Active Storage</span>
<span class="text-sm font-medium">{formatBytes({ bytes: data().stats.documentsSize, base: 1000 })}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Deleted Documents</span>
<span class="text-sm font-medium">{data().stats.deletedDocumentsCount}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm text-muted-foreground">Deleted Storage</span>
<span class="text-sm font-medium">{formatBytes({ bytes: data().stats.deletedDocumentsSize, base: 1000 })}</span>
</div>
<div class="flex justify-between items-start pt-2 border-t">
<span class="text-sm font-medium">Total Documents</span>
<span class="text-sm font-bold">{data().stats.totalDocumentsCount}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm font-medium">Total Storage</span>
<span class="text-sm font-bold">{formatBytes({ bytes: data().stats.totalDocumentsSize, base: 1000 })}</span>
</div>
</div>
)}
</Show>
</CardContent>
</Card>
);
};
export const AdminOrganizationDetailPage: Component = () => {
const params = useParams<{ organizationId: string }>();
return (
<div class="p-6 mt-4">
<div class="mb-6">
<Button as={A} href="/admin/organizations" variant="ghost" size="sm" class="mb-4">
<div class="i-tabler-arrow-left size-4 mr-2" />
Back to Organizations
</Button>
<h1 class="text-2xl font-bold mb-1">
Organization Details
</h1>
<p class="text-muted-foreground">
{params.organizationId}
</p>
</div>
<div class="space-y-6">
<div class="grid gap-6 md:grid-cols-2">
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading organization info...</div>}>
<OrganizationBasicInfo organizationId={params.organizationId} />
</Suspense>
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading stats...</div>}>
<OrganizationStats organizationId={params.organizationId} />
</Suspense>
</div>
<div class="grid gap-6 md:grid-cols-2">
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading intake emails...</div>}>
<OrganizationIntakeEmails organizationId={params.organizationId} />
</Suspense>
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading webhooks...</div>}>
<OrganizationWebhooks organizationId={params.organizationId} />
</Suspense>
</div>
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading members...</div>}>
<OrganizationMembers organizationId={params.organizationId} />
</Suspense>
</div>
</div>
);
};
export default AdminOrganizationDetailPage;

View File

@@ -0,0 +1,23 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
export const UserListDetail: Component<{ id: string; name?: string | null; email: string; href?: string }> = (props) => {
return (
<A href={props.href ?? `/admin/users/${props.id}`} class="flex items-center gap-2 group">
<div class="size-9 flex items-center justify-center rounded bg-muted">
<div class="i-tabler-user size-5 group-hover:text-primary" />
</div>
<div>
<div class="font-medium group-hover:text-primary transition">
{props.name || '-'}
</div>
<div class="text-muted-foreground text-xs">
{props.email}
</div>
</div>
</A>
);
};

View File

@@ -8,6 +8,7 @@ import { Badge } from '@/modules/ui/components/badge';
import { Button } from '@/modules/ui/components/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
import { UserListDetail } from '../components/user-list-detail.component';
import { listUsers } from '../users.services';
export const AdminListUsersPage: Component = () => {
@@ -27,36 +28,24 @@ export const AdminListUsersPage: Component = () => {
return query.data?.users ?? [];
},
columns: [
{
header: 'User',
accessorKey: 'email',
cell: data => <UserListDetail {...data.row.original} />,
},
{
header: 'ID',
accessorKey: 'id',
cell: data => (
<A
href={`/admin/users/${data.getValue<string>()}`}
class="font-mono hover:underline text-primary"
class="font-mono hover:underline text-muted-foreground"
>
{data.getValue<string>()}
</A>
),
},
{
header: 'Email',
accessorKey: 'email',
cell: data => (
<div class="font-medium">
{data.getValue<string>()}
</div>
),
},
{
header: 'Name',
accessorKey: 'name',
cell: data => (
<div class="text-muted-foreground">
{data.getValue<string | null>() || '-'}
</div>
),
},
{
header: 'Status',
accessorKey: 'emailVerified',
@@ -102,7 +91,7 @@ export const AdminListUsersPage: Component = () => {
};
return (
<div class="p-6 mt-4">
<div class="p-6">
<div class="border-b mb-6 pb-4">
<h1 class="text-xl font-bold mb-1">
User Management

View File

@@ -129,8 +129,22 @@ export const AdminUserDetailPage: Component = () => {
<For each={data().organizations}>
{org => (
<TableRow>
<TableCell>{org.id}</TableCell>
<TableCell class="font-medium">{org.name}</TableCell>
<TableCell>
<A
href={`/admin/organizations/${org.id}`}
class="font-mono text-xs hover:underline text-primary"
>
{org.id}
</A>
</TableCell>
<TableCell>
<A
href={`/admin/organizations/${org.id}`}
class="font-medium hover:underline"
>
{org.name}
</A>
</TableCell>
<TableCell>
<RelativeTime class="text-muted-foreground text-sm" date={new Date(org.createdAt)} />
</TableCell>

View File

@@ -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, {

View File

@@ -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();

View File

@@ -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>
);
};

View File

@@ -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');
}
@@ -106,7 +274,7 @@ export const EmailLoginForm: Component = () => {
</Show>
</div>
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
<Button type="submit" class="w-full" isLoading={form.submitting}>{t('auth.login.form.submit')}</Button>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
@@ -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>
);
};

View File

@@ -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);
}

View 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 };

View 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} />
);
};

View 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('');
});
});
});

View File

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

View File

@@ -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}
/>
</>
);
};

View File

@@ -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>
</>

View File

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

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}
}

View File

@@ -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
];

View File

@@ -1,8 +1,10 @@
import type { RouteDefinitionContext } from '../app/server.types';
import { registerAnalyticsRoutes } from './analytics/analytics.routes';
import { registerOrganizationManagementRoutes } from './organizations/organizations.routes';
import { registerUserManagementRoutes } from './users/users.routes';
export function registerAdminRoutes(context: RouteDefinitionContext) {
registerAnalyticsRoutes(context);
registerUserManagementRoutes(context);
registerOrganizationManagementRoutes(context);
}

View File

@@ -0,0 +1,597 @@
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../../../app/database/database.test-utils';
import { createServer } from '../../../app/server';
import { createTestServerDependencies } from '../../../app/server.test-utils';
import { overrideConfig } from '../../../config/config.test-utils';
describe('admin organizations routes - permission protection', () => {
describe('get /api/admin/organizations', () => {
test('when the user has the VIEW_USERS permission, the request succeeds', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Organization 1' },
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Organization 2' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = (await response.json()) as { organizations: unknown; totalCount: number };
expect(body.organizations).to.have.length(2);
expect(body.totalCount).to.eql(2);
});
test('when using search parameter, it filters by name', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
organizations: [
{ id: 'org_alpha123456789012345678', name: 'Alpha Corporation' },
{ id: 'org_beta1234567890123456789', name: 'Beta LLC' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations?search=Alpha',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json() as { organizations: { name: string }[]; totalCount: number };
expect(body.organizations).to.have.length(1);
expect(body.organizations[0]?.name).to.eql('Alpha Corporation');
});
test('when using search parameter with organization ID, it returns exact match', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Alpha Corporation' },
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Beta LLC' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations?search=org_abcdefghijklmnopqrstuvwx',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json() as { organizations: { id: string }[]; totalCount: number };
expect(body.organizations).to.have.length(1);
expect(body.organizations[0]?.id).to.eql('org_abcdefghijklmnopqrstuvwx');
});
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'usr_regular', email: 'user@example.com' }],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations',
{ method: 'GET' },
{ loggedInUserId: 'usr_regular' },
);
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
test('when the user is not authenticated, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase();
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations',
{ method: 'GET' },
);
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
describe('get /api/admin/organizations/:organizationId', () => {
test('when the user has the VIEW_USERS permission, the request succeeds and returns organization basic info', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json() as { organization: { id: string; name: string } };
expect(body.organization.id).to.eql('org_123456789012345678901234');
expect(body.organization.name).to.eql('Test Organization');
});
test('when the organization does not exist, a 404 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_999999999999999999999999',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(404);
});
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_regular', email: 'user@example.com' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234',
{ method: 'GET' },
{ loggedInUserId: 'usr_regular' },
);
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
test('when the user is not authenticated, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234',
{ method: 'GET' },
);
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
describe('get /api/admin/organizations/:organizationId/members', () => {
test('when the user has the VIEW_USERS permission, the request succeeds and returns members', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
{ id: 'usr_member', email: 'member@example.com', name: 'Member User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
organizationMembers: [
{ userId: 'usr_member', organizationId: 'org_123456789012345678901234', role: 'member' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/members',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json() as { members: { userId: string; role: string }[] };
expect(body.members).to.have.length(1);
expect(body.members[0]?.userId).to.eql('usr_member');
expect(body.members[0]?.role).to.eql('member');
});
test('when the organization does not exist, a 404 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_999999999999999999999999/members',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(404);
});
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_regular', email: 'user@example.com' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/members',
{ method: 'GET' },
{ loggedInUserId: 'usr_regular' },
);
expect(response.status).to.eql(401);
});
test('when the user is not authenticated, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/members',
{ method: 'GET' },
);
expect(response.status).to.eql(401);
});
});
describe('get /api/admin/organizations/:organizationId/intake-emails', () => {
test('when the user has the VIEW_USERS permission, the request succeeds and returns intake emails', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
intakeEmails: [
{ organizationId: 'org_123456789012345678901234', emailAddress: 'intake@example.com', isEnabled: true },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/intake-emails',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json() as { intakeEmails: { emailAddress: string; isEnabled: boolean }[] };
expect(body.intakeEmails).to.have.length(1);
expect(body.intakeEmails[0]?.emailAddress).to.eql('intake@example.com');
});
test('when the organization does not exist, a 404 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_999999999999999999999999/intake-emails',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(404);
});
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_regular', email: 'user@example.com' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/intake-emails',
{ method: 'GET' },
{ loggedInUserId: 'usr_regular' },
);
expect(response.status).to.eql(401);
});
test('when the user is not authenticated, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/intake-emails',
{ method: 'GET' },
);
expect(response.status).to.eql(401);
});
});
describe('get /api/admin/organizations/:organizationId/webhooks', () => {
test('when the user has the VIEW_USERS permission, the request succeeds and returns webhooks', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
webhooks: [
{ organizationId: 'org_123456789012345678901234', name: 'Test Webhook', url: 'https://example.com/webhook', enabled: true },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/webhooks',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json() as { webhooks: { name: string; url: string; enabled: boolean }[] };
expect(body.webhooks).to.have.length(1);
expect(body.webhooks[0]?.name).to.eql('Test Webhook');
});
test('when the organization does not exist, a 404 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_999999999999999999999999/webhooks',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(404);
});
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_regular', email: 'user@example.com' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/webhooks',
{ method: 'GET' },
{ loggedInUserId: 'usr_regular' },
);
expect(response.status).to.eql(401);
});
test('when the user is not authenticated, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/webhooks',
{ method: 'GET' },
);
expect(response.status).to.eql(401);
});
});
describe('get /api/admin/organizations/:organizationId/stats', () => {
test('when the user has the VIEW_USERS permission, the request succeeds and returns stats', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/stats',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json() as { stats: { documentsCount: number; documentsSize: number } };
expect(body.stats).to.have.property('documentsCount');
expect(body.stats).to.have.property('documentsSize');
});
test('when the organization does not exist, a 404 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_999999999999999999999999/stats',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(404);
});
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_regular', email: 'user@example.com' },
],
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/stats',
{ method: 'GET' },
{ loggedInUserId: 'usr_regular' },
);
expect(response.status).to.eql(401);
});
test('when the user is not authenticated, a 401 error is returned', async () => {
const { db } = await createInMemoryDatabase({
organizations: [
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/organizations/org_123456789012345678901234/stats',
{ method: 'GET' },
);
expect(response.status).to.eql(401);
});
});
});

View File

@@ -0,0 +1,208 @@
import type { RouteDefinitionContext } from '../../app/server.types';
import { z } from 'zod';
import { createRoleMiddleware, requireAuthentication } from '../../app/auth/auth.middleware';
import { createIntakeEmailsRepository } from '../../intake-emails/intake-emails.repository';
import { organizationIdSchema } from '../../organizations/organization.schemas';
import { createOrganizationNotFoundError } from '../../organizations/organizations.errors';
import { createOrganizationsRepository } from '../../organizations/organizations.repository';
import { PERMISSIONS } from '../../roles/roles.constants';
import { validateParams, validateQuery } from '../../shared/validation/validation';
import { createWebhookRepository } from '../../webhooks/webhook.repository';
export function registerOrganizationManagementRoutes(context: RouteDefinitionContext) {
registerListOrganizationsRoute(context);
registerGetOrganizationBasicInfoRoute(context);
registerGetOrganizationMembersRoute(context);
registerGetOrganizationIntakeEmailsRoute(context);
registerGetOrganizationWebhooksRoute(context);
registerGetOrganizationStatsRoute(context);
}
function registerListOrganizationsRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/organizations',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_USERS],
}),
validateQuery(
z.object({
search: z.string().optional(),
pageIndex: z.coerce.number().min(0).int().optional().default(0),
pageSize: z.coerce.number().min(1).max(100).int().optional().default(25),
}),
),
async (context) => {
const organizationsRepository = createOrganizationsRepository({ db });
const { search, pageIndex, pageSize } = context.req.valid('query');
const { organizations, totalCount } = await organizationsRepository.listOrganizations({
search,
pageIndex,
pageSize,
});
return context.json({
organizations,
totalCount,
pageIndex,
pageSize,
});
},
);
}
function registerGetOrganizationBasicInfoRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/organizations/:organizationId',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_USERS],
}),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
async (context) => {
const organizationsRepository = createOrganizationsRepository({ db });
const { organizationId } = context.req.valid('param');
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
if (!organization) {
throw createOrganizationNotFoundError();
}
return context.json({ organization });
},
);
}
function registerGetOrganizationMembersRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/organizations/:organizationId/members',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_USERS],
}),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
async (context) => {
const organizationsRepository = createOrganizationsRepository({ db });
const { organizationId } = context.req.valid('param');
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
if (!organization) {
throw createOrganizationNotFoundError();
}
const { members } = await organizationsRepository.getOrganizationMembers({ organizationId });
return context.json({ members });
},
);
}
function registerGetOrganizationIntakeEmailsRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/organizations/:organizationId/intake-emails',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_USERS],
}),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
async (context) => {
const organizationsRepository = createOrganizationsRepository({ db });
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
const { organizationId } = context.req.valid('param');
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
if (!organization) {
throw createOrganizationNotFoundError();
}
const { intakeEmails } = await intakeEmailsRepository.getOrganizationIntakeEmails({ organizationId });
return context.json({ intakeEmails });
},
);
}
function registerGetOrganizationWebhooksRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/organizations/:organizationId/webhooks',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_USERS],
}),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
async (context) => {
const organizationsRepository = createOrganizationsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const { organizationId } = context.req.valid('param');
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
if (!organization) {
throw createOrganizationNotFoundError();
}
const { webhooks } = await webhookRepository.getOrganizationWebhooks({ organizationId });
return context.json({ webhooks });
},
);
}
function registerGetOrganizationStatsRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/organizations/:organizationId/stats',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_USERS],
}),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
async (context) => {
const { createDocumentsRepository } = await import('../../documents/documents.repository');
const organizationsRepository = createOrganizationsRepository({ db });
const { organizationId } = context.req.valid('param');
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
if (!organization) {
throw createOrganizationNotFoundError();
}
const documentsRepository = createDocumentsRepository({ db });
const stats = await documentsRepository.getOrganizationStats({ organizationId });
return context.json({ stats });
},
);
}

View File

@@ -5,13 +5,13 @@ import type { AuthEmailsServices } from './auth.emails.services';
import { expo } from '@better-auth/expo';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { genericOAuth } 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 })]

View File

@@ -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'),
},
);

View File

@@ -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);
});
});
});

View File

@@ -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 {

View File

@@ -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([

View File

@@ -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 });

View File

@@ -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,
});
});
});
});

View File

@@ -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',
},
},

View File

@@ -1,6 +1,10 @@
import type { OrganizationInvitation } from './organizations.types';
import { isAfter } from 'date-fns';
import { ORGANIZATION_INVITATION_STATUS } from './organizations.constants';
import { eq, like } from 'drizzle-orm';
import { escapeLikeWildcards } from '../shared/db/sql.helpers';
import { isNilOrEmptyString } from '../shared/utils';
import { ORGANIZATION_ID_REGEX, ORGANIZATION_INVITATION_STATUS } from './organizations.constants';
import { organizationsTable } from './organizations.table';
export function ensureInvitationStatus({ invitation, now = new Date() }: { invitation?: OrganizationInvitation | null | undefined; now?: Date }) {
if (!invitation) {
@@ -17,3 +21,20 @@ export function ensureInvitationStatus({ invitation, now = new Date() }: { invit
return { ...invitation, status: ORGANIZATION_INVITATION_STATUS.EXPIRED };
}
export function createSearchOrganizationWhereClause({ search }: { search?: string }) {
const trimmedSearch = search?.trim();
if (isNilOrEmptyString(trimmedSearch)) {
return undefined;
}
if (ORGANIZATION_ID_REGEX.test(trimmedSearch)) {
return eq(organizationsTable.id, trimmedSearch);
}
const escapedSearch = escapeLikeWildcards(trimmedSearch);
const likeSearch = `%${escapedSearch}%`;
return like(organizationsTable.name, likeSearch);
}

View File

@@ -250,4 +250,167 @@ describe('organizations repository', () => {
expect(organizationCount).to.equal(2);
});
});
describe('listOrganizations', () => {
test('when no organizations exist, an empty list is returned', async () => {
const { db } = await createInMemoryDatabase();
const { listOrganizations } = createOrganizationsRepository({ db });
const result = await listOrganizations({});
expect(result).to.deep.equal({
organizations: [],
totalCount: 0,
pageIndex: 0,
pageSize: 25,
});
});
test('when multiple organizations exist, all organizations are returned with member counts', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user_1', email: 'user1@example.com', name: 'User 1' },
{ id: 'user_2', email: 'user2@example.com', name: 'User 2' },
],
organizations: [
{ id: 'org_1', name: 'Alpha Corp', createdAt: new Date('2025-01-02') },
{ id: 'org_2', name: 'Beta LLC', createdAt: new Date('2025-01-01') },
],
organizationMembers: [
{ userId: 'user_1', organizationId: 'org_1', role: 'owner' },
{ userId: 'user_2', organizationId: 'org_1', role: 'member' },
],
});
const { listOrganizations } = createOrganizationsRepository({ db });
const result = await listOrganizations({});
expect(result.organizations).to.have.length(2);
expect(result.totalCount).to.equal(2);
expect(result.pageIndex).to.equal(0);
expect(result.pageSize).to.equal(25);
expect(
result.organizations.map(org => ({
id: org.id,
name: org.name,
memberCount: org.memberCount,
})),
).to.deep.equal([
{ id: 'org_1', name: 'Alpha Corp', memberCount: 2 },
{ id: 'org_2', name: 'Beta LLC', memberCount: 0 },
]);
});
test('when searching by organization ID, only the exact matching organization is returned', async () => {
const { db } = await createInMemoryDatabase({
organizations: [
{ id: 'org_123456789012345678901234', name: 'Alpha Corp' },
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Beta LLC' },
],
});
const { listOrganizations } = createOrganizationsRepository({ db });
const result = await listOrganizations({ search: 'org_abcdefghijklmnopqrstuvwx' });
expect(result.organizations).to.have.length(1);
expect(result.organizations[0]?.id).to.equal('org_abcdefghijklmnopqrstuvwx');
expect(result.totalCount).to.equal(1);
});
test('when searching by partial name, matching organizations are returned', async () => {
const { db } = await createInMemoryDatabase({
organizations: [
{ id: 'org_1', name: 'Alpha Corporation', createdAt: new Date('2025-01-02') },
{ id: 'org_2', name: 'Beta LLC', createdAt: new Date('2025-01-03') },
{ id: 'org_3', name: 'Alpha Industries', createdAt: new Date('2025-01-01') },
],
});
const { listOrganizations } = createOrganizationsRepository({ db });
const result = await listOrganizations({ search: 'Alpha' });
expect(result.organizations).to.have.length(2);
expect(result.totalCount).to.equal(2);
expect(result.organizations.map(org => org.name)).to.deep.equal([
'Alpha Corporation',
'Alpha Industries',
]);
});
test('when searching with an empty string, all organizations are returned', async () => {
const { db } = await createInMemoryDatabase({
organizations: [
{ id: 'org_1', name: 'Alpha Corp' },
{ id: 'org_2', name: 'Beta LLC' },
],
});
const { listOrganizations } = createOrganizationsRepository({ db });
const result = await listOrganizations({ search: ' ' });
expect(result.organizations).to.have.length(2);
expect(result.totalCount).to.equal(2);
});
test('when using pagination, only the requested page is returned', async () => {
const { db } = await createInMemoryDatabase({
organizations: [
{ id: 'org_1', name: 'Org 1' },
{ id: 'org_2', name: 'Org 2' },
{ id: 'org_3', name: 'Org 3' },
{ id: 'org_4', name: 'Org 4' },
{ id: 'org_5', name: 'Org 5' },
],
});
const { listOrganizations } = createOrganizationsRepository({ db });
const firstPage = await listOrganizations({ pageIndex: 0, pageSize: 2 });
const secondPage = await listOrganizations({ pageIndex: 1, pageSize: 2 });
expect(firstPage.organizations).to.have.length(2);
expect(firstPage.totalCount).to.equal(5);
expect(secondPage.organizations).to.have.length(2);
expect(secondPage.totalCount).to.equal(5);
expect(firstPage.organizations[0]?.id).to.not.equal(secondPage.organizations[0]?.id);
});
test('when searching with pagination, the total count reflects the search results', async () => {
const { db } = await createInMemoryDatabase({
organizations: [
{ id: 'org_1', name: 'Tech Corp 1' },
{ id: 'org_2', name: 'Tech Corp 2' },
{ id: 'org_3', name: 'Tech Corp 3' },
{ id: 'org_4', name: 'Media LLC' },
],
});
const { listOrganizations } = createOrganizationsRepository({ db });
const result = await listOrganizations({ search: 'Tech', pageIndex: 0, pageSize: 2 });
expect(result.organizations).to.have.length(2);
expect(result.totalCount).to.equal(3);
});
test('when soft-deleted organizations exist, they are excluded from the results', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'user_1', email: 'user1@test.com' }],
organizations: [
{ id: 'org_1', name: 'Active Org', createdAt: new Date('2025-01-02') },
{ id: 'org_2', name: 'Deleted Org', createdAt: new Date('2025-01-03'), deletedAt: new Date('2025-05-15'), deletedBy: 'user_1', scheduledPurgeAt: new Date('2025-06-15') },
{ id: 'org_3', name: 'Another Active Org', createdAt: new Date('2025-01-01') },
],
});
const { listOrganizations } = createOrganizationsRepository({ db });
const result = await listOrganizations({});
expect(result.organizations).to.have.length(2);
expect(result.totalCount).to.equal(2);
expect(result.organizations.map(org => org.name)).to.deep.equal([
'Active Org',
'Another Active Org',
]);
});
});
});

View File

@@ -2,13 +2,14 @@ import type { Database } from '../app/database/database.types';
import type { DbInsertableOrganization, OrganizationInvitationStatus, OrganizationRole } from './organizations.types';
import { injectArguments } from '@corentinth/chisels';
import { addDays, startOfDay } from 'date-fns';
import { and, count, eq, getTableColumns, gte, isNotNull, isNull, lte } from 'drizzle-orm';
import { and, count, desc, eq, getTableColumns, gte, isNotNull, isNull, lte } from 'drizzle-orm';
import { omit } from 'lodash-es';
import { withPagination } from '../shared/db/pagination';
import { omitUndefined } from '../shared/utils';
import { usersTable } from '../users/users.table';
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
import { createOrganizationNotFoundError } from './organizations.errors';
import { ensureInvitationStatus } from './organizations.repository.models';
import { createSearchOrganizationWhereClause, ensureInvitationStatus } from './organizations.repository.models';
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
export type OrganizationsRepository = ReturnType<typeof createOrganizationsRepository>;
@@ -50,6 +51,7 @@ export function createOrganizationsRepository({ db }: { db: Database }) {
getUserDeletedOrganizations,
getExpiredSoftDeletedOrganizations,
getOrganizationCount,
listOrganizations,
},
{ db },
);
@@ -553,3 +555,52 @@ async function getOrganizationCount({ db }: { db: Database }) {
organizationCount,
};
}
async function listOrganizations({
db,
search,
pageIndex = 0,
pageSize = 25,
}: {
db: Database;
search?: string;
pageIndex?: number;
pageSize?: number;
}) {
const searchWhereClause = createSearchOrganizationWhereClause({ search });
const whereClause = searchWhereClause
? and(searchWhereClause, isNull(organizationsTable.deletedAt))
: isNull(organizationsTable.deletedAt);
const query = db
.select({
...getTableColumns(organizationsTable),
memberCount: count(organizationMembersTable.id),
})
.from(organizationsTable)
.leftJoin(
organizationMembersTable,
eq(organizationsTable.id, organizationMembersTable.organizationId),
)
.where(whereClause)
.groupBy(organizationsTable.id)
.$dynamic();
const organizations = await withPagination(query, {
orderByColumn: desc(organizationsTable.createdAt),
pageIndex,
pageSize,
});
const [{ totalCount = 0 } = {}] = await db
.select({ totalCount: count() })
.from(organizationsTable)
.where(whereClause);
return {
organizations,
totalCount,
pageIndex,
pageSize,
};
}

View File

@@ -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
},
},
};

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from 'vitest';
import { escapeLikeWildcards } from './sql.helpers';
describe('sql helpers', () => {
describe('escapeLikeWildcards', () => {
test('when input contains percent sign, it is escaped', () => {
const result = escapeLikeWildcards('hello%world');
expect(result).to.equal('hello\\%world');
});
test('when input contains underscore, it is escaped', () => {
const result = escapeLikeWildcards('hello_world');
expect(result).to.equal('hello\\_world');
});
test('when input contains both percent and underscore, both are escaped', () => {
const result = escapeLikeWildcards('test%value_name');
expect(result).to.equal('test\\%value\\_name');
});
test('when input contains multiple wildcards, all are escaped', () => {
const result = escapeLikeWildcards('%%__%%');
expect(result).to.equal('\\%\\%\\_\\_\\%\\%');
});
test('when input contains backslashes, they are escaped', () => {
const result = escapeLikeWildcards('hello\\world');
expect(result).to.equal('hello\\\\world');
});
test('when input contains backslashes and wildcards, all are escaped', () => {
const result = escapeLikeWildcards('test\\%value');
expect(result).to.equal('test\\\\\\%value');
});
test('when input contains no wildcards, it is returned unchanged', () => {
const result = escapeLikeWildcards('hello world');
expect(result).to.equal('hello world');
});
test('when input is empty string, empty string is returned', () => {
const result = escapeLikeWildcards('');
expect(result).to.equal('');
});
});
});

View File

@@ -0,0 +1,3 @@
export function escapeLikeWildcards(input: string): string {
return input.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&');
}

View 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
};

View File

@@ -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',
},
},

View File

@@ -1,18 +0,0 @@
import { describe, expect, test } from 'vitest';
import { escapeLikeWildcards } from './users.repository.models';
describe('users repository models', () => {
describe('escapeLikeWildcards', () => {
test('escape % and _ characters by prefixing them with a backslash', () => {
expect(escapeLikeWildcards('100%_sure')).to.eql('100\\%\\_sure');
expect(escapeLikeWildcards('hello')).to.eql('hello');
expect(escapeLikeWildcards(' ')).to.eql(' ');
});
test('backslashes are also escaped', () => {
expect(escapeLikeWildcards('C:\\path\\to\\file_%')).to.eql('C:\\\\path\\\\to\\\\file\\_\\%');
expect(escapeLikeWildcards('\\%_')).to.eql('\\\\\\%\\_');
});
});
});

View File

@@ -1,12 +1,9 @@
import { eq, like, or } from 'drizzle-orm';
import { escapeLikeWildcards } from '../shared/db/sql.helpers';
import { isNilOrEmptyString } from '../shared/utils';
import { USER_ID_REGEX } from './users.constants';
import { usersTable } from './users.table';
export function escapeLikeWildcards(input: string) {
return input.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&');
}
export function createSearchUserWhereClause({ search }: { search?: string }) {
const trimmedSearch = search?.trim();

View File

@@ -97,8 +97,8 @@ describe('users repository', () => {
test('when searching by user ID, only the exact matching user is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_123456789012345678901234', email: 'alice@example.com', name: 'Alice' },
{ id: 'usr_abcdefghijklmnopqrstuvwx', email: 'bob@example.com', name: 'Bob' },
{ id: 'usr_123456789012345678901234', email: 'alice@example.com', name: 'Alice', createdAt: new Date('2025-01-01') },
{ id: 'usr_abcdefghijklmnopqrstuvwx', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-02') },
],
});
const { listUsers } = createUsersRepository({ db });
@@ -113,9 +113,9 @@ describe('users repository', () => {
test('when searching by partial email, matching users are returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice' },
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob' },
{ id: 'usr_3', email: 'alice.smith@test.com', name: 'Alice Smith' },
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice', createdAt: new Date('2025-01-01') },
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-02') },
{ id: 'usr_3', email: 'alice.smith@test.com', name: 'Alice Smith', createdAt: new Date('2025-01-03') },
],
});
const { listUsers } = createUsersRepository({ db });
@@ -125,17 +125,17 @@ describe('users repository', () => {
expect(result.users).to.have.length(2);
expect(result.totalCount).to.equal(2);
expect(result.users.map(u => u.email)).to.deep.equal([
'alice@example.com',
'alice.smith@test.com',
'alice@example.com',
]);
});
test('when searching by partial name, matching users are returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice Johnson' },
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob Smith' },
{ id: 'usr_3', email: 'charlie@example.com', name: 'Charlie Johnson' },
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice Johnson', createdAt: new Date('2025-01-01') },
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob Smith', createdAt: new Date('2025-01-02') },
{ id: 'usr_3', email: 'charlie@example.com', name: 'Charlie Johnson', createdAt: new Date('2025-01-03') },
],
});
const { listUsers } = createUsersRepository({ db });
@@ -145,16 +145,16 @@ describe('users repository', () => {
expect(result.users).to.have.length(2);
expect(result.totalCount).to.equal(2);
expect(result.users.map(u => u.name)).to.deep.equal([
'Alice Johnson',
'Charlie Johnson',
'Alice Johnson',
]);
});
test('when searching with an empty string, all users are returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice' },
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob' },
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice', createdAt: new Date('2025-01-01') },
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-02') },
],
});
const { listUsers } = createUsersRepository({ db });
@@ -168,11 +168,11 @@ describe('users repository', () => {
test('when using pagination, only the requested page is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_1', email: 'user1@example.com', name: 'User 1' },
{ id: 'usr_2', email: 'user2@example.com', name: 'User 2' },
{ id: 'usr_3', email: 'user3@example.com', name: 'User 3' },
{ id: 'usr_4', email: 'user4@example.com', name: 'User 4' },
{ id: 'usr_5', email: 'user5@example.com', name: 'User 5' },
{ id: 'usr_1', email: 'user1@example.com', name: 'User 1', createdAt: new Date('2025-01-01') },
{ id: 'usr_2', email: 'user2@example.com', name: 'User 2', createdAt: new Date('2025-01-02') },
{ id: 'usr_3', email: 'user3@example.com', name: 'User 3', createdAt: new Date('2025-01-03') },
{ id: 'usr_4', email: 'user4@example.com', name: 'User 4', createdAt: new Date('2025-01-04') },
{ id: 'usr_5', email: 'user5@example.com', name: 'User 5', createdAt: new Date('2025-01-05') },
],
});
const { listUsers } = createUsersRepository({ db });
@@ -190,10 +190,10 @@ describe('users repository', () => {
test('when searching with pagination, the total count reflects the search results', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_1', email: 'alice1@example.com', name: 'Alice 1' },
{ id: 'usr_2', email: 'alice2@example.com', name: 'Alice 2' },
{ id: 'usr_3', email: 'alice3@example.com', name: 'Alice 3' },
{ id: 'usr_4', email: 'bob@example.com', name: 'Bob' },
{ id: 'usr_1', email: 'alice1@example.com', name: 'Alice 1', createdAt: new Date('2025-01-01') },
{ id: 'usr_2', email: 'alice2@example.com', name: 'Alice 2', createdAt: new Date('2025-01-02') },
{ id: 'usr_3', email: 'alice3@example.com', name: 'Alice 3', createdAt: new Date('2025-01-03') },
{ id: 'usr_4', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-04') },
],
});
const { listUsers } = createUsersRepository({ db });

View File

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

View File

@@ -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),

121
pnpm-lock.yaml generated
View File

@@ -134,7 +134,7 @@ importers:
dependencies:
'@better-auth/expo':
specifier: 'catalog:'
version: 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@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-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
@@ -169,8 +169,8 @@ importers:
specifier: ~18.0.10
version: 18.0.10(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))
expo-document-picker:
specifier: ^14.0.7
version: 14.0.7(expo@54.0.23)
specifier: ^14.0.8
version: 14.0.8(expo@54.0.23)
expo-file-system:
specifier: ^19.0.19
version: 19.0.19(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))
@@ -186,6 +186,9 @@ importers:
expo-linking:
specifier: ~8.0.8
version: 8.0.8(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))(react@19.1.0)
expo-network:
specifier: ^8.0.8
version: 8.0.8(expo@54.0.23)(react@19.1.0)
expo-router:
specifier: ~6.0.14
version: 6.0.14(49a2902d0574a0052a669c80d9729e84)
@@ -262,6 +265,9 @@ importers:
eslint-config-expo:
specifier: ~10.0.0
version: 10.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
reactotron-react-native:
specifier: ^5.1.18
version: 5.1.18(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))
typescript:
specifier: 'catalog:'
version: 5.9.3
@@ -277,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)
@@ -340,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)
@@ -397,7 +409,7 @@ importers:
version: 12.27.0
'@better-auth/expo':
specifier: 'catalog:'
version: 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.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-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.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)))
'@cadence-mq/core':
specifier: ^0.2.1
version: 0.2.1
@@ -1791,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:
@@ -2811,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}
@@ -7339,8 +7352,8 @@ packages:
expo: '*'
react-native: '*'
expo-document-picker@14.0.7:
resolution: {integrity: sha512-81Jh8RDD0GYBUoSTmIBq30hXXjmkDV1ZY2BNIp1+3HR5PDSh2WmdhD/Ezz5YFsv46hIXHsQc+Kh1q8vn6OLT9Q==}
expo-document-picker@14.0.8:
resolution: {integrity: sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==}
peerDependencies:
expo: '*'
@@ -7395,6 +7408,12 @@ packages:
react: '*'
react-native: '*'
expo-network@8.0.8:
resolution: {integrity: sha512-dgrL8UHAmWofqeY4UEjWskCl/RoQAM0DG6PZR8xz2WZt+6aQEboQgFRXowCfhbKZ71d16sNuKXtwBEsp2DtdNw==}
peerDependencies:
expo: '*'
react: '*'
expo-router@6.0.14:
resolution: {integrity: sha512-vizLO4SgnMEL+PPs2dXr+etEOuksjue7yUQBCtfCEdqoDkQlB0r35zI7rS34Wt53sxKWSlM2p+038qQEpxtiFw==}
peerDependencies:
@@ -9303,6 +9322,9 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
@@ -10171,6 +10193,17 @@ packages:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'}
reactotron-core-client@2.9.9:
resolution: {integrity: sha512-caI6ZWpfV/gNeCBhm6alxuCpOxYEFxg9FsgVaOXJWZeKjK2egjxkXcUEFWeS3b9mC6Kwi7Wuu79zfeGtkbdAYw==}
reactotron-core-contract@0.3.2:
resolution: {integrity: sha512-39ZNLfxxV7WEOszekIRg56F6VFZrQk7+Lei+8MccNESX3VbQzs1uex/GNesxk6CMTS6ytwqYIBAgLyc9WX/PaA==}
reactotron-react-native@5.1.18:
resolution: {integrity: sha512-Nyb/w7tRQICtfvjAJSN6Q/3aRO8L6eTfEcWpDR/atUyoINkyzducMx6yXA+sz1mazxivY16N3VPkmQ1XohQAcg==}
peerDependencies:
react-native: '>=0.40.0'
read-yaml-file@1.1.0:
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
engines: {node: '>=6'}
@@ -11545,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==}
@@ -11840,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==}
@@ -13109,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
@@ -13659,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:
@@ -13681,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
@@ -13692,7 +13729,18 @@ snapshots:
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-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/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)
'@better-fetch/fetch': 1.1.18
@@ -13702,11 +13750,12 @@ snapshots:
optionalDependencies:
expo-constants: 18.0.10(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))
expo-linking: 8.0.8(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))(react@19.1.0)
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-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)
@@ -13714,6 +13763,7 @@ snapshots:
optionalDependencies:
expo-constants: 18.0.10(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))
expo-linking: 8.0.8(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))(react@19.1.0)
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/telemetry@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))':
@@ -13937,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
@@ -14527,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
@@ -14540,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
@@ -18604,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
@@ -20193,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
@@ -20405,7 +20455,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
expo-document-picker@14.0.7(expo@54.0.23):
expo-document-picker@14.0.8(expo@54.0.23):
dependencies:
expo: 54.0.23(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.14)(graphql@16.8.1)(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))(react@19.1.0)
@@ -20462,6 +20512,11 @@ snapshots:
react: 19.1.0
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)
expo-network@8.0.8(expo@54.0.23)(react@19.1.0):
dependencies:
expo: 54.0.23(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.14)(graphql@16.8.1)(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))(react@19.1.0)
react: 19.1.0
expo-router@6.0.14(49a2902d0574a0052a669c80d9729e84):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.23)(react-dom@19.1.0(react@19.1.0))(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))(react@19.1.0)
@@ -23121,6 +23176,8 @@ snapshots:
dependencies:
minipass: 7.1.2
mitt@3.0.1: {}
mkdirp-classic@0.5.3: {}
mkdirp@0.5.6:
@@ -24053,6 +24110,18 @@ snapshots:
react@19.1.0: {}
reactotron-core-client@2.9.9:
dependencies:
reactotron-core-contract: 0.3.2
reactotron-core-contract@0.3.2: {}
reactotron-react-native@5.1.18(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:
mitt: 3.0.1
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)
reactotron-core-client: 2.9.9
read-yaml-file@1.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -25767,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